1 import * as Bluebird from 'bluebird'
2 import * as sequelize from 'sequelize'
3 import * as magnetUtil from 'magnet-uri'
4 import { join } from 'path'
5 import * as request from 'request'
6 import { ActivityIconObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState } from '../../../shared/index'
7 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
8 import { VideoPrivacy } from '../../../shared/models/videos'
9 import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
10 import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
11 import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
12 import { logger } from '../../helpers/logger'
13 import { doRequest, downloadImage } from '../../helpers/requests'
14 import { ACTIVITY_PUB, CONFIG, REMOTE_SCHEME, sequelizeTypescript, THUMBNAILS_SIZE, VIDEO_MIMETYPE_EXT } from '../../initializers'
15 import { ActorModel } from '../../models/activitypub/actor'
16 import { TagModel } from '../../models/video/tag'
17 import { VideoModel } from '../../models/video/video'
18 import { VideoChannelModel } from '../../models/video/video-channel'
19 import { VideoFileModel } from '../../models/video/video-file'
20 import { getOrCreateActorAndServerAndModel } from './actor'
21 import { addVideoComments } from './video-comments'
22 import { crawlCollectionPage } from './crawl'
23 import { sendCreateVideo, sendUpdateVideo } from './send'
24 import { isArray } from '../../helpers/custom-validators/misc'
25 import { VideoCaptionModel } from '../../models/video/video-caption'
26 import { JobQueue } from '../job-queue'
27 import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub-http-fetcher'
28 import { createRates } from './video-rates'
29 import { addVideoShares, shareVideoByServerAndChannel } from './share'
30 import { AccountModel } from '../../models/account/account'
31 import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
32 import { checkUrlsSameHost } from '../../helpers/activitypub'
34 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
35 // If the video is not private and published, we federate it
36 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
37 // Fetch more attributes that we will need to serialize in AP object
38 if (isArray(video.VideoCaptions) === false) {
39 video.VideoCaptions = await video.$get('VideoCaptions', {
40 attributes: [ 'language' ],
42 }) as VideoCaptionModel[]
46 // Now we'll add the video's meta data to our followers
47 await sendCreateVideo(video, transaction)
48 await shareVideoByServerAndChannel(video, transaction)
50 await sendUpdateVideo(video, transaction)
55 async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
63 logger.info('Fetching remote video %s.', videoUrl)
65 const { response, body } = await doRequest(options)
67 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
68 logger.debug('Remote video JSON is not valid.', { body })
69 return { response, videoObject: undefined }
72 return { response, videoObject: body }
75 async function fetchRemoteVideoDescription (video: VideoModel) {
76 const host = video.VideoChannel.Account.Actor.Server.host
77 const path = video.getDescriptionAPIPath()
79 uri: REMOTE_SCHEME.HTTP + '://' + host + path,
83 const { body } = await doRequest(options)
84 return body.description ? body.description : ''
87 function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
88 const host = video.VideoChannel.Account.Actor.Server.host
90 // We need to provide a callback, if no we could have an uncaught exception
91 return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
96 function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
97 const thumbnailName = video.getThumbnailName()
98 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
100 return downloadImage(icon.url, thumbnailPath, THUMBNAILS_SIZE)
103 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
104 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
105 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
107 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
108 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
111 return getOrCreateActorAndServerAndModel(channel.id, 'all')
120 refreshVideo: boolean
122 async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
123 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
125 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
127 if (syncParam.likes === true) {
128 await crawlCollectionPage<string>(fetchedVideo.likes, items => createRates(items, video, 'like'))
129 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
131 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
134 if (syncParam.dislikes === true) {
135 await crawlCollectionPage<string>(fetchedVideo.dislikes, items => createRates(items, video, 'dislike'))
136 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
138 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
141 if (syncParam.shares === true) {
142 await crawlCollectionPage<string>(fetchedVideo.shares, items => addVideoShares(items, video))
143 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
145 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
148 if (syncParam.comments === true) {
149 await crawlCollectionPage<string>(fetchedVideo.comments, items => addVideoComments(items, video))
150 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
152 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
155 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
158 async function getOrCreateVideoAndAccountAndChannel (options: {
159 videoObject: VideoTorrentObject | string,
160 syncParam?: SyncParam,
161 fetchType?: VideoFetchByUrlType,
162 refreshViews?: boolean
165 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
166 const fetchType = options.fetchType || 'all'
167 const refreshViews = options.refreshViews || false
170 const videoUrl = typeof options.videoObject === 'string' ? options.videoObject : options.videoObject.id
172 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
173 if (videoFromDatabase) {
174 const refreshOptions = {
175 video: videoFromDatabase,
176 fetchedType: fetchType,
180 const p = refreshVideoIfNeeded(refreshOptions)
181 if (syncParam.refreshVideo === true) videoFromDatabase = await p
183 return { video: videoFromDatabase }
186 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
187 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
189 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
190 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
192 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
197 async function updateVideoFromAP (options: {
199 videoObject: VideoTorrentObject,
200 account: AccountModel,
201 channel: VideoChannelModel,
202 updateViews: boolean,
203 overrideTo?: string[]
205 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
206 let videoFieldsSave: any
209 await sequelizeTypescript.transaction(async t => {
210 const sequelizeOptions = {
214 videoFieldsSave = options.video.toJSON()
216 // Check actor has the right to update the video
217 const videoChannel = options.video.VideoChannel
218 if (videoChannel.Account.id !== options.account.id) {
219 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
222 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
223 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
224 options.video.set('name', videoData.name)
225 options.video.set('uuid', videoData.uuid)
226 options.video.set('url', videoData.url)
227 options.video.set('category', videoData.category)
228 options.video.set('licence', videoData.licence)
229 options.video.set('language', videoData.language)
230 options.video.set('description', videoData.description)
231 options.video.set('support', videoData.support)
232 options.video.set('nsfw', videoData.nsfw)
233 options.video.set('commentsEnabled', videoData.commentsEnabled)
234 options.video.set('waitTranscoding', videoData.waitTranscoding)
235 options.video.set('state', videoData.state)
236 options.video.set('duration', videoData.duration)
237 options.video.set('createdAt', videoData.createdAt)
238 options.video.set('publishedAt', videoData.publishedAt)
239 options.video.set('privacy', videoData.privacy)
240 options.video.set('channelId', videoData.channelId)
242 if (options.updateViews === true) options.video.set('views', videoData.views)
243 await options.video.save(sequelizeOptions)
245 // Don't block on request
246 generateThumbnailFromUrl(options.video, options.videoObject.icon)
247 .catch(err => logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err }))
250 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
251 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
253 // Remove video files that do not exist anymore
254 const destroyTasks = options.video.VideoFiles
255 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
256 .map(f => f.destroy(sequelizeOptions))
257 await Promise.all(destroyTasks)
259 // Update or add other one
260 const upsertTasks = videoFileAttributes.map(a => {
261 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
262 .then(([ file ]) => file)
265 options.video.VideoFiles = await Promise.all(upsertTasks)
270 const tags = options.videoObject.tag.map(tag => tag.name)
271 const tagInstances = await TagModel.findOrCreateTags(tags, t)
272 await options.video.$set('Tags', tagInstances, sequelizeOptions)
277 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
279 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
280 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
282 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
286 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
288 if (options.video !== undefined && videoFieldsSave !== undefined) {
289 resetSequelizeInstance(options.video, videoFieldsSave)
292 // This is just a debug because we will retry the insert
293 logger.debug('Cannot update the remote video.', { err })
300 federateVideoIfNeeded,
302 getOrCreateVideoAndAccountAndChannel,
303 fetchRemoteVideoStaticFile,
304 fetchRemoteVideoDescription,
305 generateThumbnailFromUrl,
306 getOrCreateVideoChannelFromVideoObject
309 // ---------------------------------------------------------------------------
311 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
312 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
314 const urlMediaType = url.mediaType || url.mimeType
315 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
318 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
319 logger.debug('Adding remote video %s.', videoObject.id)
321 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
322 const sequelizeOptions = { transaction: t }
324 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
325 const video = VideoModel.build(videoData)
327 const videoCreated = await video.save(sequelizeOptions)
330 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
331 if (videoFileAttributes.length === 0) {
332 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
335 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
336 await Promise.all(videoFilePromises)
339 const tags = videoObject.tag.map(t => t.name)
340 const tagInstances = await TagModel.findOrCreateTags(tags, t)
341 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
344 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
345 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
347 await Promise.all(videoCaptionsPromises)
349 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
351 videoCreated.VideoChannel = channelActor.VideoChannel
355 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
356 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
358 if (waitThumbnail === true) await p
363 async function refreshVideoIfNeeded (options: {
365 fetchedType: VideoFetchByUrlType,
366 syncParam: SyncParam,
367 refreshViews: boolean
368 }): Promise<VideoModel> {
369 if (!options.video.isOutdated()) return options.video
371 // We need more attributes if the argument video was fetched with not enough joints
372 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
375 const { response, videoObject } = await fetchRemoteVideo(video.url)
376 if (response.statusCode === 404) {
377 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
379 // Video does not exist anymore
380 await video.destroy()
384 if (videoObject === undefined) {
385 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
389 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
390 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
392 const updateOptions = {
396 channel: channelActor.VideoChannel,
397 updateViews: options.refreshViews
399 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
400 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
404 logger.warn('Cannot refresh video %s.', options.video.url, { err })
409 async function videoActivityObjectToDBAttributes (
410 videoChannel: VideoChannelModel,
411 videoObject: VideoTorrentObject,
414 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
415 const duration = videoObject.duration.replace(/[^\d]+/, '')
417 let language: string | undefined
418 if (videoObject.language) {
419 language = videoObject.language.identifier
422 let category: number | undefined
423 if (videoObject.category) {
424 category = parseInt(videoObject.category.identifier, 10)
427 let licence: number | undefined
428 if (videoObject.licence) {
429 licence = parseInt(videoObject.licence.identifier, 10)
432 const description = videoObject.content || null
433 const support = videoObject.support || null
436 name: videoObject.name,
437 uuid: videoObject.uuid,
444 nsfw: videoObject.sensitive,
445 commentsEnabled: videoObject.commentsEnabled,
446 waitTranscoding: videoObject.waitTranscoding,
447 state: videoObject.state,
448 channelId: videoChannel.id,
449 duration: parseInt(duration, 10),
450 createdAt: new Date(videoObject.published),
451 publishedAt: new Date(videoObject.published),
452 // FIXME: updatedAt does not seems to be considered by Sequelize
453 updatedAt: new Date(videoObject.updated),
454 views: videoObject.views,
462 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
463 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
465 if (fileUrls.length === 0) {
466 throw new Error('Cannot find video files for ' + video.url)
469 const attributes: VideoFileModel[] = []
470 for (const fileUrl of fileUrls) {
471 // Fetch associated magnet uri
472 const magnet = videoObject.url.find(u => {
473 const mediaType = u.mediaType || u.mimeType
474 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
477 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
479 const parsed = magnetUtil.decode(magnet.href)
480 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
481 throw new Error('Cannot parse magnet URI ' + magnet.href)
484 const mediaType = fileUrl.mediaType || fileUrl.mimeType
486 extname: VIDEO_MIMETYPE_EXT[ mediaType ],
487 infoHash: parsed.infoHash,
488 resolution: fileUrl.height,
491 fps: fileUrl.fps || -1
493 attributes.push(attribute)