Fix video fps validator
[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 = 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     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       {
249         const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
250         const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
251
252         // Remove video files that do not exist anymore
253         const destroyTasks = options.video.VideoFiles
254                                     .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
255                                     .map(f => f.destroy(sequelizeOptions))
256         await Promise.all(destroyTasks)
257
258         // Update or add other one
259         const upsertTasks = videoFileAttributes.map(a => {
260           return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
261             .then(([ file ]) => file)
262         })
263
264         options.video.VideoFiles = await Promise.all(upsertTasks)
265       }
266
267       {
268         // Update Tags
269         const tags = options.videoObject.tag.map(tag => tag.name)
270         const tagInstances = await TagModel.findOrCreateTags(tags, t)
271         await options.video.$set('Tags', tagInstances, sequelizeOptions)
272       }
273
274       {
275         // Update captions
276         await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
277
278         const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
279           return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
280         })
281         options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
282       }
283     })
284
285     logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
286   } catch (err) {
287     if (options.video !== undefined && videoFieldsSave !== undefined) {
288       resetSequelizeInstance(options.video, videoFieldsSave)
289     }
290
291     // This is just a debug because we will retry the insert
292     logger.debug('Cannot update the remote video.', { err })
293     throw err
294   }
295 }
296
297 export {
298   updateVideoFromAP,
299   federateVideoIfNeeded,
300   fetchRemoteVideo,
301   getOrCreateVideoAndAccountAndChannel,
302   fetchRemoteVideoStaticFile,
303   fetchRemoteVideoDescription,
304   generateThumbnailFromUrl,
305   getOrCreateVideoChannelFromVideoObject
306 }
307
308 // ---------------------------------------------------------------------------
309
310 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
311   const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
312
313   return mimeTypes.indexOf(url.mimeType) !== -1 && url.mimeType.startsWith('video/')
314 }
315
316 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
317   logger.debug('Adding remote video %s.', videoObject.id)
318
319   const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
320     const sequelizeOptions = { transaction: t }
321
322     const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
323     const video = VideoModel.build(videoData)
324
325     const videoCreated = await video.save(sequelizeOptions)
326
327     // Process files
328     const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
329     if (videoFileAttributes.length === 0) {
330       throw new Error('Cannot find valid files for video %s ' + videoObject.url)
331     }
332
333     const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
334     await Promise.all(videoFilePromises)
335
336     // Process tags
337     const tags = videoObject.tag.map(t => t.name)
338     const tagInstances = await TagModel.findOrCreateTags(tags, t)
339     await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
340
341     // Process captions
342     const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
343       return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
344     })
345     await Promise.all(videoCaptionsPromises)
346
347     logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
348
349     videoCreated.VideoChannel = channelActor.VideoChannel
350     return videoCreated
351   })
352
353   const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
354     .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
355
356   if (waitThumbnail === true) await p
357
358   return videoCreated
359 }
360
361 async function refreshVideoIfNeeded (options: {
362   video: VideoModel,
363   fetchedType: VideoFetchByUrlType,
364   syncParam: SyncParam,
365   refreshViews: boolean
366 }): Promise<VideoModel> {
367   if (!options.video.isOutdated()) return options.video
368
369   // We need more attributes if the argument video was fetched with not enough joints
370   const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
371
372   try {
373     const { response, videoObject } = await fetchRemoteVideo(video.url)
374     if (response.statusCode === 404) {
375       logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
376
377       // Video does not exist anymore
378       await video.destroy()
379       return undefined
380     }
381
382     if (videoObject === undefined) {
383       logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
384       return video
385     }
386
387     const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
388     const account = await AccountModel.load(channelActor.VideoChannel.accountId)
389
390     const updateOptions = {
391       video,
392       videoObject,
393       account,
394       channel: channelActor.VideoChannel,
395       updateViews: options.refreshViews
396     }
397     await retryTransactionWrapper(updateVideoFromAP, updateOptions)
398     await syncVideoExternalAttributes(video, videoObject, options.syncParam)
399   } catch (err) {
400     logger.warn('Cannot refresh video %s.', options.video.url, { err })
401     return video
402   }
403 }
404
405 async function videoActivityObjectToDBAttributes (
406   videoChannel: VideoChannelModel,
407   videoObject: VideoTorrentObject,
408   to: string[] = []
409 ) {
410   const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
411   const duration = videoObject.duration.replace(/[^\d]+/, '')
412
413   let language: string | undefined
414   if (videoObject.language) {
415     language = videoObject.language.identifier
416   }
417
418   let category: number | undefined
419   if (videoObject.category) {
420     category = parseInt(videoObject.category.identifier, 10)
421   }
422
423   let licence: number | undefined
424   if (videoObject.licence) {
425     licence = parseInt(videoObject.licence.identifier, 10)
426   }
427
428   const description = videoObject.content || null
429   const support = videoObject.support || null
430
431   return {
432     name: videoObject.name,
433     uuid: videoObject.uuid,
434     url: videoObject.id,
435     category,
436     licence,
437     language,
438     description,
439     support,
440     nsfw: videoObject.sensitive,
441     commentsEnabled: videoObject.commentsEnabled,
442     waitTranscoding: videoObject.waitTranscoding,
443     state: videoObject.state,
444     channelId: videoChannel.id,
445     duration: parseInt(duration, 10),
446     createdAt: new Date(videoObject.published),
447     publishedAt: new Date(videoObject.published),
448     // FIXME: updatedAt does not seems to be considered by Sequelize
449     updatedAt: new Date(videoObject.updated),
450     views: videoObject.views,
451     likes: 0,
452     dislikes: 0,
453     remote: true,
454     privacy
455   }
456 }
457
458 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
459   const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
460
461   if (fileUrls.length === 0) {
462     throw new Error('Cannot find video files for ' + video.url)
463   }
464
465   const attributes: VideoFileModel[] = []
466   for (const fileUrl of fileUrls) {
467     // Fetch associated magnet uri
468     const magnet = videoObject.url.find(u => {
469       return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.height === fileUrl.height
470     })
471
472     if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
473
474     const parsed = magnetUtil.decode(magnet.href)
475     if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
476       throw new Error('Cannot parse magnet URI ' + magnet.href)
477     }
478
479     const attribute = {
480       extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ],
481       infoHash: parsed.infoHash,
482       resolution: fileUrl.height,
483       size: fileUrl.size,
484       videoId: video.id,
485       fps: fileUrl.fps || -1
486     } as VideoFileModel
487     attributes.push(attribute)
488   }
489
490   return attributes
491 }