5794988a516e826f11cf68383c75d9b9434ec351
[oweals/peertube.git] / server / lib / activitypub / videos.ts
1 import * as Bluebird from 'bluebird'
2 import * as sequelize from 'sequelize'
3 import * as magnetUtil from 'magnet-uri'
4 import * as request from 'request'
5 import { ActivityIconObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState } from '../../../shared/index'
6 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
7 import { VideoPrivacy } from '../../../shared/models/videos'
8 import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
9 import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
10 import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
11 import { logger } from '../../helpers/logger'
12 import { doRequest, downloadImage } from '../../helpers/requests'
13 import { ACTIVITY_PUB, CONFIG, MIMETYPES, REMOTE_SCHEME, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers'
14 import { ActorModel } from '../../models/activitypub/actor'
15 import { TagModel } from '../../models/video/tag'
16 import { VideoModel } from '../../models/video/video'
17 import { VideoChannelModel } from '../../models/video/video-channel'
18 import { VideoFileModel } from '../../models/video/video-file'
19 import { getOrCreateActorAndServerAndModel } from './actor'
20 import { addVideoComments } from './video-comments'
21 import { crawlCollectionPage } from './crawl'
22 import { sendCreateVideo, sendUpdateVideo } from './send'
23 import { isArray } from '../../helpers/custom-validators/misc'
24 import { VideoCaptionModel } from '../../models/video/video-caption'
25 import { JobQueue } from '../job-queue'
26 import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub-http-fetcher'
27 import { createRates } from './video-rates'
28 import { addVideoShares, shareVideoByServerAndChannel } from './share'
29 import { AccountModel } from '../../models/account/account'
30 import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
31 import { checkUrlsSameHost, getAPUrl } from '../../helpers/activitypub'
32 import { Notifier } from '../notifier'
33
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' ],
41         transaction
42       }) as VideoCaptionModel[]
43     }
44
45     if (isNewVideo) {
46       // Now we'll add the video's meta data to our followers
47       await sendCreateVideo(video, transaction)
48       await shareVideoByServerAndChannel(video, transaction)
49     } else {
50       await sendUpdateVideo(video, transaction)
51     }
52   }
53 }
54
55 async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
56   const options = {
57     uri: videoUrl,
58     method: 'GET',
59     json: true,
60     activityPub: true
61   }
62
63   logger.info('Fetching remote video %s.', videoUrl)
64
65   const { response, body } = await doRequest(options)
66
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 }
70   }
71
72   return { response, videoObject: body }
73 }
74
75 async function fetchRemoteVideoDescription (video: VideoModel) {
76   const host = video.VideoChannel.Account.Actor.Server.host
77   const path = video.getDescriptionAPIPath()
78   const options = {
79     uri: REMOTE_SCHEME.HTTP + '://' + host + path,
80     json: true
81   }
82
83   const { body } = await doRequest(options)
84   return body.description ? body.description : ''
85 }
86
87 function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
88   const host = video.VideoChannel.Account.Actor.Server.host
89
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 => {
92     if (err) reject(err)
93   })
94 }
95
96 function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
97   const thumbnailName = video.getThumbnailName()
98
99   return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
100 }
101
102 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
103   const channel = videoObject.attributedTo.find(a => a.type === 'Group')
104   if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
105
106   if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
107     throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
108   }
109
110   return getOrCreateActorAndServerAndModel(channel.id, 'all')
111 }
112
113 type SyncParam = {
114   likes: boolean
115   dislikes: boolean
116   shares: boolean
117   comments: boolean
118   thumbnail: boolean
119   refreshVideo?: boolean
120 }
121 async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
122   logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
123
124   const jobPayloads: ActivitypubHttpFetcherPayload[] = []
125
126   if (syncParam.likes === true) {
127     await crawlCollectionPage<string>(fetchedVideo.likes, items => createRates(items, video, 'like'))
128       .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
129   } else {
130     jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
131   }
132
133   if (syncParam.dislikes === true) {
134     await crawlCollectionPage<string>(fetchedVideo.dislikes, items => createRates(items, video, 'dislike'))
135       .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
136   } else {
137     jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
138   }
139
140   if (syncParam.shares === true) {
141     await crawlCollectionPage<string>(fetchedVideo.shares, items => addVideoShares(items, video))
142       .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
143   } else {
144     jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
145   }
146
147   if (syncParam.comments === true) {
148     await crawlCollectionPage<string>(fetchedVideo.comments, items => addVideoComments(items, video))
149       .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
150   } else {
151     jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
152   }
153
154   await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
155 }
156
157 async function getOrCreateVideoAndAccountAndChannel (options: {
158   videoObject: VideoTorrentObject | string,
159   syncParam?: SyncParam,
160   fetchType?: VideoFetchByUrlType,
161   allowRefresh?: boolean // true by default
162 }) {
163   // Default params
164   const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
165   const fetchType = options.fetchType || 'all'
166   const allowRefresh = options.allowRefresh !== false
167
168   // Get video url
169   const videoUrl = getAPUrl(options.videoObject)
170
171   let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
172   if (videoFromDatabase) {
173
174     if (allowRefresh === true) {
175       const refreshOptions = {
176         video: videoFromDatabase,
177         fetchedType: fetchType,
178         syncParam
179       }
180
181       if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
182       else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', videoUrl: videoFromDatabase.url } })
183     }
184
185     return { video: videoFromDatabase, created: false }
186   }
187
188   const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
189   if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
190
191   const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
192   const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
193
194   await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
195
196   return { video, created: true }
197 }
198
199 async function updateVideoFromAP (options: {
200   video: VideoModel,
201   videoObject: VideoTorrentObject,
202   account: AccountModel,
203   channel: VideoChannelModel,
204   overrideTo?: string[]
205 }) {
206   logger.debug('Updating remote video "%s".', options.videoObject.uuid)
207   let videoFieldsSave: any
208
209   try {
210     await sequelizeTypescript.transaction(async t => {
211       const sequelizeOptions = {
212         transaction: t
213       }
214
215       videoFieldsSave = options.video.toJSON()
216
217       const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
218       const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
219
220       // Check actor has the right to update the video
221       const videoChannel = options.video.VideoChannel
222       if (videoChannel.Account.id !== options.account.id) {
223         throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
224       }
225
226       const to = options.overrideTo ? options.overrideTo : options.videoObject.to
227       const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
228       options.video.set('name', videoData.name)
229       options.video.set('uuid', videoData.uuid)
230       options.video.set('url', videoData.url)
231       options.video.set('category', videoData.category)
232       options.video.set('licence', videoData.licence)
233       options.video.set('language', videoData.language)
234       options.video.set('description', videoData.description)
235       options.video.set('support', videoData.support)
236       options.video.set('nsfw', videoData.nsfw)
237       options.video.set('commentsEnabled', videoData.commentsEnabled)
238       options.video.set('waitTranscoding', videoData.waitTranscoding)
239       options.video.set('state', videoData.state)
240       options.video.set('duration', videoData.duration)
241       options.video.set('createdAt', videoData.createdAt)
242       options.video.set('publishedAt', videoData.publishedAt)
243       options.video.set('privacy', videoData.privacy)
244       options.video.set('channelId', videoData.channelId)
245       options.video.set('views', videoData.views)
246
247       await options.video.save(sequelizeOptions)
248
249       {
250         const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
251         const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
252
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)
258
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)
263         })
264
265         options.video.VideoFiles = await Promise.all(upsertTasks)
266       }
267
268       {
269         // Update Tags
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)
273       }
274
275       {
276         // Update captions
277         await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
278
279         const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
280           return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
281         })
282         options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
283       }
284
285       {
286         // Notify our users?
287         if (wasPrivateVideo || wasUnlistedVideo) {
288           Notifier.Instance.notifyOnNewVideo(options.video)
289         }
290       }
291     })
292
293     logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
294   } catch (err) {
295     if (options.video !== undefined && videoFieldsSave !== undefined) {
296       resetSequelizeInstance(options.video, videoFieldsSave)
297     }
298
299     // This is just a debug because we will retry the insert
300     logger.debug('Cannot update the remote video.', { err })
301     throw err
302   }
303
304   try {
305     await generateThumbnailFromUrl(options.video, options.videoObject.icon)
306   } catch (err) {
307     logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
308   }
309 }
310
311 async function refreshVideoIfNeeded (options: {
312   video: VideoModel,
313   fetchedType: VideoFetchByUrlType,
314   syncParam: SyncParam
315 }): Promise<VideoModel> {
316   if (!options.video.isOutdated()) return options.video
317
318   // We need more attributes if the argument video was fetched with not enough joints
319   const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
320
321   try {
322     const { response, videoObject } = await fetchRemoteVideo(video.url)
323     if (response.statusCode === 404) {
324       logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
325
326       // Video does not exist anymore
327       await video.destroy()
328       return undefined
329     }
330
331     if (videoObject === undefined) {
332       logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
333
334       await video.setAsRefreshed()
335       return video
336     }
337
338     const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
339     const account = await AccountModel.load(channelActor.VideoChannel.accountId)
340
341     const updateOptions = {
342       video,
343       videoObject,
344       account,
345       channel: channelActor.VideoChannel
346     }
347     await retryTransactionWrapper(updateVideoFromAP, updateOptions)
348     await syncVideoExternalAttributes(video, videoObject, options.syncParam)
349
350     return video
351   } catch (err) {
352     logger.warn('Cannot refresh video %s.', options.video.url, { err })
353
354     // Don't refresh in loop
355     await video.setAsRefreshed()
356     return video
357   }
358 }
359
360 export {
361   updateVideoFromAP,
362   refreshVideoIfNeeded,
363   federateVideoIfNeeded,
364   fetchRemoteVideo,
365   getOrCreateVideoAndAccountAndChannel,
366   fetchRemoteVideoStaticFile,
367   fetchRemoteVideoDescription,
368   generateThumbnailFromUrl,
369   getOrCreateVideoChannelFromVideoObject
370 }
371
372 // ---------------------------------------------------------------------------
373
374 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
375   const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
376
377   const urlMediaType = url.mediaType || url.mimeType
378   return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
379 }
380
381 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
382   logger.debug('Adding remote video %s.', videoObject.id)
383
384   const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
385     const sequelizeOptions = { transaction: t }
386
387     const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
388     const video = VideoModel.build(videoData)
389
390     const videoCreated = await video.save(sequelizeOptions)
391
392     // Process files
393     const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
394     if (videoFileAttributes.length === 0) {
395       throw new Error('Cannot find valid files for video %s ' + videoObject.url)
396     }
397
398     const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
399     await Promise.all(videoFilePromises)
400
401     // Process tags
402     const tags = videoObject.tag.map(t => t.name)
403     const tagInstances = await TagModel.findOrCreateTags(tags, t)
404     await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
405
406     // Process captions
407     const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
408       return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
409     })
410     await Promise.all(videoCaptionsPromises)
411
412     logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
413
414     videoCreated.VideoChannel = channelActor.VideoChannel
415     return videoCreated
416   })
417
418   const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
419     .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
420
421   if (waitThumbnail === true) await p
422
423   return videoCreated
424 }
425
426 async function videoActivityObjectToDBAttributes (
427   videoChannel: VideoChannelModel,
428   videoObject: VideoTorrentObject,
429   to: string[] = []
430 ) {
431   const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
432   const duration = videoObject.duration.replace(/[^\d]+/, '')
433
434   let language: string | undefined
435   if (videoObject.language) {
436     language = videoObject.language.identifier
437   }
438
439   let category: number | undefined
440   if (videoObject.category) {
441     category = parseInt(videoObject.category.identifier, 10)
442   }
443
444   let licence: number | undefined
445   if (videoObject.licence) {
446     licence = parseInt(videoObject.licence.identifier, 10)
447   }
448
449   const description = videoObject.content || null
450   const support = videoObject.support || null
451
452   return {
453     name: videoObject.name,
454     uuid: videoObject.uuid,
455     url: videoObject.id,
456     category,
457     licence,
458     language,
459     description,
460     support,
461     nsfw: videoObject.sensitive,
462     commentsEnabled: videoObject.commentsEnabled,
463     waitTranscoding: videoObject.waitTranscoding,
464     state: videoObject.state,
465     channelId: videoChannel.id,
466     duration: parseInt(duration, 10),
467     createdAt: new Date(videoObject.published),
468     publishedAt: new Date(videoObject.published),
469     // FIXME: updatedAt does not seems to be considered by Sequelize
470     updatedAt: new Date(videoObject.updated),
471     views: videoObject.views,
472     likes: 0,
473     dislikes: 0,
474     remote: true,
475     privacy
476   }
477 }
478
479 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
480   const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
481
482   if (fileUrls.length === 0) {
483     throw new Error('Cannot find video files for ' + video.url)
484   }
485
486   const attributes: VideoFileModel[] = []
487   for (const fileUrl of fileUrls) {
488     // Fetch associated magnet uri
489     const magnet = videoObject.url.find(u => {
490       const mediaType = u.mediaType || u.mimeType
491       return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
492     })
493
494     if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
495
496     const parsed = magnetUtil.decode(magnet.href)
497     if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
498       throw new Error('Cannot parse magnet URI ' + magnet.href)
499     }
500
501     const mediaType = fileUrl.mediaType || fileUrl.mimeType
502     const attribute = {
503       extname: MIMETYPES.VIDEO.MIMETYPE_EXT[ mediaType ],
504       infoHash: parsed.infoHash,
505       resolution: fileUrl.height,
506       size: fileUrl.size,
507       videoId: video.id,
508       fps: fileUrl.fps || -1
509     } as VideoFileModel
510     attributes.push(attribute)
511   }
512
513   return attributes
514 }