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, getAPUrl } 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 allowRefresh?: boolean // true by default
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 allowRefresh = options.allowRefresh !== false
170 const videoUrl = getAPUrl(options.videoObject)
172 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
173 if (videoFromDatabase) {
175 if (allowRefresh === true) {
176 const refreshOptions = {
177 video: videoFromDatabase,
178 fetchedType: fetchType,
182 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
183 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', videoUrl: videoFromDatabase.url } })
186 return { video: videoFromDatabase }
189 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
190 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
192 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
193 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
195 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
200 async function updateVideoFromAP (options: {
202 videoObject: VideoTorrentObject,
203 account: AccountModel,
204 channel: VideoChannelModel,
205 overrideTo?: string[]
207 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
208 let videoFieldsSave: any
211 await sequelizeTypescript.transaction(async t => {
212 const sequelizeOptions = {
216 videoFieldsSave = options.video.toJSON()
218 // Check actor has the right to update the video
219 const videoChannel = options.video.VideoChannel
220 if (videoChannel.Account.id !== options.account.id) {
221 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
224 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
225 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
226 options.video.set('name', videoData.name)
227 options.video.set('uuid', videoData.uuid)
228 options.video.set('url', videoData.url)
229 options.video.set('category', videoData.category)
230 options.video.set('licence', videoData.licence)
231 options.video.set('language', videoData.language)
232 options.video.set('description', videoData.description)
233 options.video.set('support', videoData.support)
234 options.video.set('nsfw', videoData.nsfw)
235 options.video.set('commentsEnabled', videoData.commentsEnabled)
236 options.video.set('waitTranscoding', videoData.waitTranscoding)
237 options.video.set('state', videoData.state)
238 options.video.set('duration', videoData.duration)
239 options.video.set('createdAt', videoData.createdAt)
240 options.video.set('publishedAt', videoData.publishedAt)
241 options.video.set('privacy', videoData.privacy)
242 options.video.set('channelId', videoData.channelId)
243 options.video.set('views', videoData.views)
245 await options.video.save(sequelizeOptions)
248 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
249 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
251 // Remove video files that do not exist anymore
252 const destroyTasks = options.video.VideoFiles
253 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
254 .map(f => f.destroy(sequelizeOptions))
255 await Promise.all(destroyTasks)
257 // Update or add other one
258 const upsertTasks = videoFileAttributes.map(a => {
259 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
260 .then(([ file ]) => file)
263 options.video.VideoFiles = await Promise.all(upsertTasks)
268 const tags = options.videoObject.tag.map(tag => tag.name)
269 const tagInstances = await TagModel.findOrCreateTags(tags, t)
270 await options.video.$set('Tags', tagInstances, sequelizeOptions)
275 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
277 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
278 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
280 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
284 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
286 if (options.video !== undefined && videoFieldsSave !== undefined) {
287 resetSequelizeInstance(options.video, videoFieldsSave)
290 // This is just a debug because we will retry the insert
291 logger.debug('Cannot update the remote video.', { err })
296 await generateThumbnailFromUrl(options.video, options.videoObject.icon)
298 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
302 async function refreshVideoIfNeeded (options: {
304 fetchedType: VideoFetchByUrlType,
306 }): Promise<VideoModel> {
307 if (!options.video.isOutdated()) return options.video
309 // We need more attributes if the argument video was fetched with not enough joints
310 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
313 const { response, videoObject } = await fetchRemoteVideo(video.url)
314 if (response.statusCode === 404) {
315 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
317 // Video does not exist anymore
318 await video.destroy()
322 if (videoObject === undefined) {
323 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
325 await video.setAsRefreshed()
329 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
330 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
332 const updateOptions = {
336 channel: channelActor.VideoChannel
338 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
339 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
343 logger.warn('Cannot refresh video %s.', options.video.url, { err })
345 // Don't refresh in loop
346 await video.setAsRefreshed()
353 refreshVideoIfNeeded,
354 federateVideoIfNeeded,
356 getOrCreateVideoAndAccountAndChannel,
357 fetchRemoteVideoStaticFile,
358 fetchRemoteVideoDescription,
359 generateThumbnailFromUrl,
360 getOrCreateVideoChannelFromVideoObject
363 // ---------------------------------------------------------------------------
365 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
366 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
368 const urlMediaType = url.mediaType || url.mimeType
369 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
372 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
373 logger.debug('Adding remote video %s.', videoObject.id)
375 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
376 const sequelizeOptions = { transaction: t }
378 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
379 const video = VideoModel.build(videoData)
381 const videoCreated = await video.save(sequelizeOptions)
384 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
385 if (videoFileAttributes.length === 0) {
386 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
389 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
390 await Promise.all(videoFilePromises)
393 const tags = videoObject.tag.map(t => t.name)
394 const tagInstances = await TagModel.findOrCreateTags(tags, t)
395 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
398 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
399 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
401 await Promise.all(videoCaptionsPromises)
403 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
405 videoCreated.VideoChannel = channelActor.VideoChannel
409 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
410 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
412 if (waitThumbnail === true) await p
417 async function videoActivityObjectToDBAttributes (
418 videoChannel: VideoChannelModel,
419 videoObject: VideoTorrentObject,
422 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
423 const duration = videoObject.duration.replace(/[^\d]+/, '')
425 let language: string | undefined
426 if (videoObject.language) {
427 language = videoObject.language.identifier
430 let category: number | undefined
431 if (videoObject.category) {
432 category = parseInt(videoObject.category.identifier, 10)
435 let licence: number | undefined
436 if (videoObject.licence) {
437 licence = parseInt(videoObject.licence.identifier, 10)
440 const description = videoObject.content || null
441 const support = videoObject.support || null
444 name: videoObject.name,
445 uuid: videoObject.uuid,
452 nsfw: videoObject.sensitive,
453 commentsEnabled: videoObject.commentsEnabled,
454 waitTranscoding: videoObject.waitTranscoding,
455 state: videoObject.state,
456 channelId: videoChannel.id,
457 duration: parseInt(duration, 10),
458 createdAt: new Date(videoObject.published),
459 publishedAt: new Date(videoObject.published),
460 // FIXME: updatedAt does not seems to be considered by Sequelize
461 updatedAt: new Date(videoObject.updated),
462 views: videoObject.views,
470 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
471 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
473 if (fileUrls.length === 0) {
474 throw new Error('Cannot find video files for ' + video.url)
477 const attributes: VideoFileModel[] = []
478 for (const fileUrl of fileUrls) {
479 // Fetch associated magnet uri
480 const magnet = videoObject.url.find(u => {
481 const mediaType = u.mediaType || u.mimeType
482 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
485 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
487 const parsed = magnetUtil.decode(magnet.href)
488 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
489 throw new Error('Cannot parse magnet URI ' + magnet.href)
492 const mediaType = fileUrl.mediaType || fileUrl.mimeType
494 extname: VIDEO_MIMETYPE_EXT[ mediaType ],
495 infoHash: parsed.infoHash,
496 resolution: fileUrl.height,
499 fps: fileUrl.fps || -1
501 attributes.push(attribute)