]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/lib/activitypub/videos.ts
Optimize video view AP processing
[github/Chocobozzz/PeerTube.git] / server / lib / activitypub / videos.ts
CommitLineData
7acee6f1 1import * as Bluebird from 'bluebird'
2186386c 2import * as sequelize from 'sequelize'
2ccaeeb3 3import * as magnetUtil from 'magnet-uri'
892211e8
C
4import { join } from 'path'
5import * as request from 'request'
4157cdb1 6import { ActivityIconObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState } from '../../../shared/index'
2ccaeeb3 7import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
1297eb5d 8import { VideoPrivacy } from '../../../shared/models/videos'
1d6e5dfc 9import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
2ccaeeb3 10import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
c48e82b5 11import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
2ccaeeb3 12import { logger } from '../../helpers/logger'
da854ddd 13import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests'
1297eb5d 14import { ACTIVITY_PUB, CONFIG, REMOTE_SCHEME, sequelizeTypescript, VIDEO_MIMETYPE_EXT } from '../../initializers'
2ccaeeb3
C
15import { ActorModel } from '../../models/activitypub/actor'
16import { TagModel } from '../../models/video/tag'
3fd3ab2d 17import { VideoModel } from '../../models/video/video'
2ccaeeb3
C
18import { VideoChannelModel } from '../../models/video/video-channel'
19import { VideoFileModel } from '../../models/video/video-file'
c48e82b5 20import { getOrCreateActorAndServerAndModel } from './actor'
7acee6f1 21import { addVideoComments } from './video-comments'
8fffe21a 22import { crawlCollectionPage } from './crawl'
2186386c 23import { sendCreateVideo, sendUpdateVideo } from './send'
40e87e9e
C
24import { isArray } from '../../helpers/custom-validators/misc'
25import { VideoCaptionModel } from '../../models/video/video-caption'
f6eebcb3
C
26import { JobQueue } from '../job-queue'
27import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub-http-fetcher'
1297eb5d
C
28import { createRates } from './video-rates'
29import { addVideoShares, shareVideoByServerAndChannel } from './share'
30import { AccountModel } from '../../models/account/account'
4157cdb1 31import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
2186386c
C
32
33async 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) {
40e87e9e
C
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
2cebd797 44 if (isNewVideo) {
2186386c
C
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}
892211e8 53
4157cdb1
C
54async 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 }
892211e8 61
4157cdb1
C
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 }
892211e8
C
72}
73
3fd3ab2d 74async function fetchRemoteVideoDescription (video: VideoModel) {
50d6de9c 75 const host = video.VideoChannel.Account.Actor.Server.host
96f29c0f 76 const path = video.getDescriptionAPIPath()
892211e8
C
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
4157cdb1
C
86function 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
3fd3ab2d 95function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
892211e8
C
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
f37dc0dd 106function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
0f320037
C
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)
111}
112
f6eebcb3 113type SyncParam = {
1297eb5d
C
114 likes: boolean
115 dislikes: boolean
116 shares: boolean
117 comments: boolean
f6eebcb3 118 thumbnail: boolean
1297eb5d 119 refreshVideo: boolean
f6eebcb3 120}
4157cdb1 121async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
f6eebcb3 122 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
2ccaeeb3 123
f6eebcb3 124 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
2ccaeeb3 125
f6eebcb3
C
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 }
7acee6f1 132
f6eebcb3
C
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 }
7acee6f1 146
f6eebcb3
C
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 }
7acee6f1 153
f6eebcb3 154 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
2ccaeeb3
C
155}
156
4157cdb1
C
157async function getOrCreateVideoAndAccountAndChannel (options: {
158 videoObject: VideoTorrentObject | string,
159 syncParam?: SyncParam,
d4defe07
C
160 fetchType?: VideoFetchByUrlType,
161 refreshViews?: boolean
4157cdb1
C
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'
d4defe07 166 const refreshViews = options.refreshViews || false
1297eb5d 167
4157cdb1
C
168 // Get video url
169 const videoUrl = typeof options.videoObject === 'string' ? options.videoObject : options.videoObject.id
1297eb5d 170
4157cdb1
C
171 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
172 if (videoFromDatabase) {
d4defe07
C
173 const refreshOptions = {
174 video: videoFromDatabase,
175 fetchedType: fetchType,
176 syncParam,
177 refreshViews
178 }
179 const p = retryTransactionWrapper(refreshVideoIfNeeded, refreshOptions)
4157cdb1 180 if (syncParam.refreshVideo === true) videoFromDatabase = await p
1297eb5d 181
4157cdb1 182 return { video: videoFromDatabase }
1297eb5d
C
183 }
184
4157cdb1
C
185 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
186 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
7acee6f1 187
4157cdb1
C
188 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
189 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
7acee6f1 190
4157cdb1 191 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
7acee6f1 192
4157cdb1 193 return { video }
7acee6f1
C
194}
195
d4defe07 196async function updateVideoFromAP (options: {
1297eb5d
C
197 video: VideoModel,
198 videoObject: VideoTorrentObject,
c48e82b5
C
199 account: AccountModel,
200 channel: VideoChannelModel,
d4defe07 201 updateViews: boolean,
1297eb5d 202 overrideTo?: string[]
d4defe07
C
203}) {
204 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
1297eb5d
C
205 let videoFieldsSave: any
206
207 try {
208 const updatedVideo: VideoModel = await sequelizeTypescript.transaction(async t => {
209 const sequelizeOptions = {
210 transaction: t
211 }
2ccaeeb3 212
d4defe07 213 videoFieldsSave = options.video.toJSON()
2ccaeeb3 214
1297eb5d 215 // Check actor has the right to update the video
d4defe07
C
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)
f6eebcb3
C
219 }
220
d4defe07
C
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)
1297eb5d
C
243
244 // Don't block on request
d4defe07
C
245 generateThumbnailFromUrl(options.video, options.videoObject.icon)
246 .catch(err => logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err }))
1297eb5d
C
247
248 // Remove old video files
249 const videoFileDestroyTasks: Bluebird<void>[] = []
d4defe07 250 for (const videoFile of options.video.VideoFiles) {
1297eb5d
C
251 videoFileDestroyTasks.push(videoFile.destroy(sequelizeOptions))
252 }
253 await Promise.all(videoFileDestroyTasks)
0032ebe9 254
d4defe07 255 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
1297eb5d
C
256 const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, sequelizeOptions))
257 await Promise.all(tasks)
2ccaeeb3 258
1297eb5d 259 // Update Tags
d4defe07 260 const tags = options.videoObject.tag.map(tag => tag.name)
1297eb5d 261 const tagInstances = await TagModel.findOrCreateTags(tags, t)
d4defe07 262 await options.video.$set('Tags', tagInstances, sequelizeOptions)
2ccaeeb3 263
1297eb5d 264 // Update captions
d4defe07 265 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
2ccaeeb3 266
d4defe07
C
267 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
268 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
1297eb5d
C
269 })
270 await Promise.all(videoCaptionsPromises)
271 })
272
d4defe07 273 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
2ccaeeb3 274
1297eb5d
C
275 return updatedVideo
276 } catch (err) {
d4defe07
C
277 if (options.video !== undefined && videoFieldsSave !== undefined) {
278 resetSequelizeInstance(options.video, videoFieldsSave)
1297eb5d
C
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 }
892211e8 285}
2186386c
C
286
287export {
1297eb5d 288 updateVideoFromAP,
2186386c
C
289 federateVideoIfNeeded,
290 fetchRemoteVideo,
1297eb5d 291 getOrCreateVideoAndAccountAndChannel,
40e87e9e 292 fetchRemoteVideoStaticFile,
2186386c
C
293 fetchRemoteVideoDescription,
294 generateThumbnailFromUrl,
4157cdb1 295 getOrCreateVideoChannelFromVideoObject
2186386c 296}
c48e82b5
C
297
298// ---------------------------------------------------------------------------
299
300function 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}
4157cdb1
C
305
306async 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
d4defe07
C
351async function refreshVideoIfNeeded (options: {
352 video: VideoModel,
353 fetchedType: VideoFetchByUrlType,
354 syncParam: SyncParam,
355 refreshViews: boolean
356}): Promise<VideoModel> {
4157cdb1 357 // We need more attributes if the argument video was fetched with not enough joints
d4defe07 358 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
4157cdb1
C
359
360 if (!video.isOutdated()) return video
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
d4defe07
C
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)
4157cdb1
C
387 } catch (err) {
388 logger.warn('Cannot refresh video.', { err })
389 return video
390 }
391}
392
393async 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
446function 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}