AP mimeType -> mediaType
[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   const urlMediaType = url.mediaType || url.mimeType
314   return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
315 }
316
317 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
318   logger.debug('Adding remote video %s.', videoObject.id)
319
320   const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
321     const sequelizeOptions = { transaction: t }
322
323     const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
324     const video = VideoModel.build(videoData)
325
326     const videoCreated = await video.save(sequelizeOptions)
327
328     // Process files
329     const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
330     if (videoFileAttributes.length === 0) {
331       throw new Error('Cannot find valid files for video %s ' + videoObject.url)
332     }
333
334     const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
335     await Promise.all(videoFilePromises)
336
337     // Process tags
338     const tags = videoObject.tag.map(t => t.name)
339     const tagInstances = await TagModel.findOrCreateTags(tags, t)
340     await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
341
342     // Process captions
343     const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
344       return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
345     })
346     await Promise.all(videoCaptionsPromises)
347
348     logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
349
350     videoCreated.VideoChannel = channelActor.VideoChannel
351     return videoCreated
352   })
353
354   const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
355     .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
356
357   if (waitThumbnail === true) await p
358
359   return videoCreated
360 }
361
362 async function refreshVideoIfNeeded (options: {
363   video: VideoModel,
364   fetchedType: VideoFetchByUrlType,
365   syncParam: SyncParam,
366   refreshViews: boolean
367 }): Promise<VideoModel> {
368   if (!options.video.isOutdated()) return options.video
369
370   // We need more attributes if the argument video was fetched with not enough joints
371   const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
372
373   try {
374     const { response, videoObject } = await fetchRemoteVideo(video.url)
375     if (response.statusCode === 404) {
376       logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
377
378       // Video does not exist anymore
379       await video.destroy()
380       return undefined
381     }
382
383     if (videoObject === undefined) {
384       logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
385       return video
386     }
387
388     const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
389     const account = await AccountModel.load(channelActor.VideoChannel.accountId)
390
391     const updateOptions = {
392       video,
393       videoObject,
394       account,
395       channel: channelActor.VideoChannel,
396       updateViews: options.refreshViews
397     }
398     await retryTransactionWrapper(updateVideoFromAP, updateOptions)
399     await syncVideoExternalAttributes(video, videoObject, options.syncParam)
400
401     return video
402   } catch (err) {
403     logger.warn('Cannot refresh video %s.', options.video.url, { err })
404     return video
405   }
406 }
407
408 async function videoActivityObjectToDBAttributes (
409   videoChannel: VideoChannelModel,
410   videoObject: VideoTorrentObject,
411   to: string[] = []
412 ) {
413   const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
414   const duration = videoObject.duration.replace(/[^\d]+/, '')
415
416   let language: string | undefined
417   if (videoObject.language) {
418     language = videoObject.language.identifier
419   }
420
421   let category: number | undefined
422   if (videoObject.category) {
423     category = parseInt(videoObject.category.identifier, 10)
424   }
425
426   let licence: number | undefined
427   if (videoObject.licence) {
428     licence = parseInt(videoObject.licence.identifier, 10)
429   }
430
431   const description = videoObject.content || null
432   const support = videoObject.support || null
433
434   return {
435     name: videoObject.name,
436     uuid: videoObject.uuid,
437     url: videoObject.id,
438     category,
439     licence,
440     language,
441     description,
442     support,
443     nsfw: videoObject.sensitive,
444     commentsEnabled: videoObject.commentsEnabled,
445     waitTranscoding: videoObject.waitTranscoding,
446     state: videoObject.state,
447     channelId: videoChannel.id,
448     duration: parseInt(duration, 10),
449     createdAt: new Date(videoObject.published),
450     publishedAt: new Date(videoObject.published),
451     // FIXME: updatedAt does not seems to be considered by Sequelize
452     updatedAt: new Date(videoObject.updated),
453     views: videoObject.views,
454     likes: 0,
455     dislikes: 0,
456     remote: true,
457     privacy
458   }
459 }
460
461 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
462   const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
463
464   if (fileUrls.length === 0) {
465     throw new Error('Cannot find video files for ' + video.url)
466   }
467
468   const attributes: VideoFileModel[] = []
469   for (const fileUrl of fileUrls) {
470     // Fetch associated magnet uri
471     const magnet = videoObject.url.find(u => {
472       const mediaType = u.mediaType || u.mimeType
473       return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
474     })
475
476     if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
477
478     const parsed = magnetUtil.decode(magnet.href)
479     if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
480       throw new Error('Cannot parse magnet URI ' + magnet.href)
481     }
482
483     const mediaType = fileUrl.mediaType || fileUrl.mimeType
484     const attribute = {
485       extname: VIDEO_MIMETYPE_EXT[ mediaType ],
486       infoHash: parsed.infoHash,
487       resolution: fileUrl.height,
488       size: fileUrl.size,
489       videoId: video.id,
490       fps: fileUrl.fps || -1
491     } as VideoFileModel
492     attributes.push(attribute)
493   }
494
495   return attributes
496 }