Limit associations fetch when loading token
[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 { 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, doRequestAndSaveToFile } from '../../helpers/requests'
14 import { ACTIVITY_PUB, CONFIG, REMOTE_SCHEME, sequelizeTypescript, 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
33 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
34   // If the video is not private and published, we federate it
35   if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
36     // Fetch more attributes that we will need to serialize in AP object
37     if (isArray(video.VideoCaptions) === false) {
38       video.VideoCaptions = await video.$get('VideoCaptions', {
39         attributes: [ 'language' ],
40         transaction
41       }) as VideoCaptionModel[]
42     }
43
44     if (isNewVideo) {
45       // Now we'll add the video's meta data to our followers
46       await sendCreateVideo(video, transaction)
47       await shareVideoByServerAndChannel(video, transaction)
48     } else {
49       await sendUpdateVideo(video, transaction)
50     }
51   }
52 }
53
54 async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
55   const options = {
56     uri: videoUrl,
57     method: 'GET',
58     json: true,
59     activityPub: true
60   }
61
62   logger.info('Fetching remote video %s.', videoUrl)
63
64   const { response, body } = await doRequest(options)
65
66   if (sanitizeAndCheckVideoTorrentObject(body) === false) {
67     logger.debug('Remote video JSON is not valid.', { body })
68     return { response, videoObject: undefined }
69   }
70
71   return { response, videoObject: body }
72 }
73
74 async function fetchRemoteVideoDescription (video: VideoModel) {
75   const host = video.VideoChannel.Account.Actor.Server.host
76   const path = video.getDescriptionAPIPath()
77   const options = {
78     uri: REMOTE_SCHEME.HTTP + '://' + host + path,
79     json: true
80   }
81
82   const { body } = await doRequest(options)
83   return body.description ? body.description : ''
84 }
85
86 function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
87   const host = video.VideoChannel.Account.Actor.Server.host
88
89   // We need to provide a callback, if no we could have an uncaught exception
90   return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
91     if (err) reject(err)
92   })
93 }
94
95 function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
96   const thumbnailName = video.getThumbnailName()
97   const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
98
99   const options = {
100     method: 'GET',
101     uri: icon.url
102   }
103   return doRequestAndSaveToFile(options, thumbnailPath)
104 }
105
106 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
107   const channel = videoObject.attributedTo.find(a => a.type === 'Group')
108   if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
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   refreshViews?: boolean
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 refreshViews = options.refreshViews || false
167
168   // Get video url
169   const videoUrl = typeof options.videoObject === 'string' ? options.videoObject : options.videoObject.id
170
171   let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
172   if (videoFromDatabase) {
173     const refreshOptions = {
174       video: videoFromDatabase,
175       fetchedType: fetchType,
176       syncParam,
177       refreshViews
178     }
179     const p = retryTransactionWrapper(refreshVideoIfNeeded, refreshOptions)
180     if (syncParam.refreshVideo === true) videoFromDatabase = await p
181
182     return { video: videoFromDatabase }
183   }
184
185   const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
186   if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
187
188   const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
189   const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
190
191   await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
192
193   return { video }
194 }
195
196 async function updateVideoFromAP (options: {
197   video: VideoModel,
198   videoObject: VideoTorrentObject,
199   account: AccountModel,
200   channel: VideoChannelModel,
201   updateViews: boolean,
202   overrideTo?: string[]
203 }) {
204   logger.debug('Updating remote video "%s".', options.videoObject.uuid)
205   let videoFieldsSave: any
206
207   try {
208     const updatedVideo: VideoModel = await sequelizeTypescript.transaction(async t => {
209       const sequelizeOptions = {
210         transaction: t
211       }
212
213       videoFieldsSave = options.video.toJSON()
214
215       // Check actor has the right to update the video
216       const videoChannel = options.video.VideoChannel
217       if (videoChannel.Account.id !== options.account.id) {
218         throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
219       }
220
221       const to = options.overrideTo ? options.overrideTo : options.videoObject.to
222       const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
223       options.video.set('name', videoData.name)
224       options.video.set('uuid', videoData.uuid)
225       options.video.set('url', videoData.url)
226       options.video.set('category', videoData.category)
227       options.video.set('licence', videoData.licence)
228       options.video.set('language', videoData.language)
229       options.video.set('description', videoData.description)
230       options.video.set('support', videoData.support)
231       options.video.set('nsfw', videoData.nsfw)
232       options.video.set('commentsEnabled', videoData.commentsEnabled)
233       options.video.set('waitTranscoding', videoData.waitTranscoding)
234       options.video.set('state', videoData.state)
235       options.video.set('duration', videoData.duration)
236       options.video.set('createdAt', videoData.createdAt)
237       options.video.set('publishedAt', videoData.publishedAt)
238       options.video.set('privacy', videoData.privacy)
239       options.video.set('channelId', videoData.channelId)
240
241       if (options.updateViews === true) options.video.set('views', videoData.views)
242       await options.video.save(sequelizeOptions)
243
244       // Don't block on request
245       generateThumbnailFromUrl(options.video, options.videoObject.icon)
246         .catch(err => logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err }))
247
248       // Remove old video files
249       const videoFileDestroyTasks: Bluebird<void>[] = []
250       for (const videoFile of options.video.VideoFiles) {
251         videoFileDestroyTasks.push(videoFile.destroy(sequelizeOptions))
252       }
253       await Promise.all(videoFileDestroyTasks)
254
255       const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
256       const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, sequelizeOptions))
257       await Promise.all(tasks)
258
259       // Update Tags
260       const tags = options.videoObject.tag.map(tag => tag.name)
261       const tagInstances = await TagModel.findOrCreateTags(tags, t)
262       await options.video.$set('Tags', tagInstances, sequelizeOptions)
263
264       // Update captions
265       await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
266
267       const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
268         return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
269       })
270       await Promise.all(videoCaptionsPromises)
271     })
272
273     logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
274
275     return updatedVideo
276   } catch (err) {
277     if (options.video !== undefined && videoFieldsSave !== undefined) {
278       resetSequelizeInstance(options.video, videoFieldsSave)
279     }
280
281     // This is just a debug because we will retry the insert
282     logger.debug('Cannot update the remote video.', { err })
283     throw err
284   }
285 }
286
287 export {
288   updateVideoFromAP,
289   federateVideoIfNeeded,
290   fetchRemoteVideo,
291   getOrCreateVideoAndAccountAndChannel,
292   fetchRemoteVideoStaticFile,
293   fetchRemoteVideoDescription,
294   generateThumbnailFromUrl,
295   getOrCreateVideoChannelFromVideoObject
296 }
297
298 // ---------------------------------------------------------------------------
299
300 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
301   const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
302
303   return mimeTypes.indexOf(url.mimeType) !== -1 && url.mimeType.startsWith('video/')
304 }
305
306 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
307   logger.debug('Adding remote video %s.', videoObject.id)
308
309   const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
310     const sequelizeOptions = { transaction: t }
311
312     const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
313     const video = VideoModel.build(videoData)
314
315     const videoCreated = await video.save(sequelizeOptions)
316
317     // Process files
318     const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
319     if (videoFileAttributes.length === 0) {
320       throw new Error('Cannot find valid files for video %s ' + videoObject.url)
321     }
322
323     const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
324     await Promise.all(videoFilePromises)
325
326     // Process tags
327     const tags = videoObject.tag.map(t => t.name)
328     const tagInstances = await TagModel.findOrCreateTags(tags, t)
329     await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
330
331     // Process captions
332     const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
333       return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
334     })
335     await Promise.all(videoCaptionsPromises)
336
337     logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
338
339     videoCreated.VideoChannel = channelActor.VideoChannel
340     return videoCreated
341   })
342
343   const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
344     .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
345
346   if (waitThumbnail === true) await p
347
348   return videoCreated
349 }
350
351 async function refreshVideoIfNeeded (options: {
352   video: VideoModel,
353   fetchedType: VideoFetchByUrlType,
354   syncParam: SyncParam,
355   refreshViews: boolean
356 }): Promise<VideoModel> {
357   if (!options.video.isOutdated()) return options.video
358
359   // We need more attributes if the argument video was fetched with not enough joints
360   const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
361
362   try {
363     const { response, videoObject } = await fetchRemoteVideo(video.url)
364     if (response.statusCode === 404) {
365       // Video does not exist anymore
366       await video.destroy()
367       return undefined
368     }
369
370     if (videoObject === undefined) {
371       logger.warn('Cannot refresh remote video: invalid body.')
372       return video
373     }
374
375     const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
376     const account = await AccountModel.load(channelActor.VideoChannel.accountId)
377
378     const updateOptions = {
379       video,
380       videoObject,
381       account,
382       channel: channelActor.VideoChannel,
383       updateViews: options.refreshViews
384     }
385     await updateVideoFromAP(updateOptions)
386     await syncVideoExternalAttributes(video, videoObject, options.syncParam)
387   } catch (err) {
388     logger.warn('Cannot refresh video.', { err })
389     return video
390   }
391 }
392
393 async function videoActivityObjectToDBAttributes (
394   videoChannel: VideoChannelModel,
395   videoObject: VideoTorrentObject,
396   to: string[] = []
397 ) {
398   const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
399   const duration = videoObject.duration.replace(/[^\d]+/, '')
400
401   let language: string | undefined
402   if (videoObject.language) {
403     language = videoObject.language.identifier
404   }
405
406   let category: number | undefined
407   if (videoObject.category) {
408     category = parseInt(videoObject.category.identifier, 10)
409   }
410
411   let licence: number | undefined
412   if (videoObject.licence) {
413     licence = parseInt(videoObject.licence.identifier, 10)
414   }
415
416   const description = videoObject.content || null
417   const support = videoObject.support || null
418
419   return {
420     name: videoObject.name,
421     uuid: videoObject.uuid,
422     url: videoObject.id,
423     category,
424     licence,
425     language,
426     description,
427     support,
428     nsfw: videoObject.sensitive,
429     commentsEnabled: videoObject.commentsEnabled,
430     waitTranscoding: videoObject.waitTranscoding,
431     state: videoObject.state,
432     channelId: videoChannel.id,
433     duration: parseInt(duration, 10),
434     createdAt: new Date(videoObject.published),
435     publishedAt: new Date(videoObject.published),
436     // FIXME: updatedAt does not seems to be considered by Sequelize
437     updatedAt: new Date(videoObject.updated),
438     views: videoObject.views,
439     likes: 0,
440     dislikes: 0,
441     remote: true,
442     privacy
443   }
444 }
445
446 function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObject: VideoTorrentObject) {
447   const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
448
449   if (fileUrls.length === 0) {
450     throw new Error('Cannot find video files for ' + videoCreated.url)
451   }
452
453   const attributes: VideoFileModel[] = []
454   for (const fileUrl of fileUrls) {
455     // Fetch associated magnet uri
456     const magnet = videoObject.url.find(u => {
457       return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.height === fileUrl.height
458     })
459
460     if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
461
462     const parsed = magnetUtil.decode(magnet.href)
463     if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
464       throw new Error('Cannot parse magnet URI ' + magnet.href)
465     }
466
467     const attribute = {
468       extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ],
469       infoHash: parsed.infoHash,
470       resolution: fileUrl.height,
471       size: fileUrl.size,
472       videoId: videoCreated.id,
473       fps: fileUrl.fps
474     } as VideoFileModel
475     attributes.push(attribute)
476   }
477
478   return attributes
479 }