]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/lib/activitypub/videos.ts
Fix AP collections pagination
[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'
5c6d985f 32import { checkUrlsSameHost } from '../../helpers/activitypub'
2186386c
C
33
34async 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) {
40e87e9e
C
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
2cebd797 45 if (isNewVideo) {
2186386c
C
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}
892211e8 54
4157cdb1
C
55async 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 }
892211e8 62
4157cdb1
C
63 logger.info('Fetching remote video %s.', videoUrl)
64
65 const { response, body } = await doRequest(options)
66
5c6d985f 67 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
4157cdb1
C
68 logger.debug('Remote video JSON is not valid.', { body })
69 return { response, videoObject: undefined }
70 }
71
72 return { response, videoObject: body }
892211e8
C
73}
74
3fd3ab2d 75async function fetchRemoteVideoDescription (video: VideoModel) {
50d6de9c 76 const host = video.VideoChannel.Account.Actor.Server.host
96f29c0f 77 const path = video.getDescriptionAPIPath()
892211e8
C
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
4157cdb1
C
87function 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
3fd3ab2d 96function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
892211e8
C
97 const thumbnailName = video.getThumbnailName()
98 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
99
100 const options = {
101 method: 'GET',
102 uri: icon.url
103 }
104 return doRequestAndSaveToFile(options, thumbnailPath)
105}
106
f37dc0dd 107function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
0f320037
C
108 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
109 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
110
5c6d985f
C
111 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
112 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
113 }
114
e587e0ec 115 return getOrCreateActorAndServerAndModel(channel.id, 'all')
0f320037
C
116}
117
f6eebcb3 118type SyncParam = {
1297eb5d
C
119 likes: boolean
120 dislikes: boolean
121 shares: boolean
122 comments: boolean
f6eebcb3 123 thumbnail: boolean
1297eb5d 124 refreshVideo: boolean
f6eebcb3 125}
4157cdb1 126async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
f6eebcb3 127 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
2ccaeeb3 128
f6eebcb3 129 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
2ccaeeb3 130
f6eebcb3
C
131 if (syncParam.likes === true) {
132 await crawlCollectionPage<string>(fetchedVideo.likes, items => createRates(items, video, 'like'))
133 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
134 } else {
135 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
136 }
7acee6f1 137
f6eebcb3
C
138 if (syncParam.dislikes === true) {
139 await crawlCollectionPage<string>(fetchedVideo.dislikes, items => createRates(items, video, 'dislike'))
140 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
141 } else {
142 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
143 }
144
145 if (syncParam.shares === true) {
146 await crawlCollectionPage<string>(fetchedVideo.shares, items => addVideoShares(items, video))
147 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
148 } else {
149 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
150 }
7acee6f1 151
f6eebcb3
C
152 if (syncParam.comments === true) {
153 await crawlCollectionPage<string>(fetchedVideo.comments, items => addVideoComments(items, video))
154 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
155 } else {
156 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
157 }
7acee6f1 158
f6eebcb3 159 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
2ccaeeb3
C
160}
161
4157cdb1
C
162async function getOrCreateVideoAndAccountAndChannel (options: {
163 videoObject: VideoTorrentObject | string,
164 syncParam?: SyncParam,
d4defe07
C
165 fetchType?: VideoFetchByUrlType,
166 refreshViews?: boolean
4157cdb1
C
167}) {
168 // Default params
169 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
170 const fetchType = options.fetchType || 'all'
d4defe07 171 const refreshViews = options.refreshViews || false
1297eb5d 172
4157cdb1
C
173 // Get video url
174 const videoUrl = typeof options.videoObject === 'string' ? options.videoObject : options.videoObject.id
1297eb5d 175
4157cdb1
C
176 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
177 if (videoFromDatabase) {
d4defe07
C
178 const refreshOptions = {
179 video: videoFromDatabase,
180 fetchedType: fetchType,
181 syncParam,
182 refreshViews
183 }
e5565833 184 const p = refreshVideoIfNeeded(refreshOptions)
4157cdb1 185 if (syncParam.refreshVideo === true) videoFromDatabase = await p
1297eb5d 186
4157cdb1 187 return { video: videoFromDatabase }
1297eb5d
C
188 }
189
4157cdb1
C
190 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
191 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
7acee6f1 192
4157cdb1
C
193 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
194 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
7acee6f1 195
4157cdb1 196 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
7acee6f1 197
4157cdb1 198 return { video }
7acee6f1
C
199}
200
d4defe07 201async function updateVideoFromAP (options: {
1297eb5d
C
202 video: VideoModel,
203 videoObject: VideoTorrentObject,
c48e82b5
C
204 account: AccountModel,
205 channel: VideoChannelModel,
d4defe07 206 updateViews: boolean,
1297eb5d 207 overrideTo?: string[]
d4defe07
C
208}) {
209 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
1297eb5d
C
210 let videoFieldsSave: any
211
212 try {
d382f4e9 213 await sequelizeTypescript.transaction(async t => {
1297eb5d
C
214 const sequelizeOptions = {
215 transaction: t
216 }
2ccaeeb3 217
d4defe07 218 videoFieldsSave = options.video.toJSON()
2ccaeeb3 219
1297eb5d 220 // Check actor has the right to update the video
d4defe07
C
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)
f6eebcb3
C
224 }
225
d4defe07
C
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
246 if (options.updateViews === true) options.video.set('views', videoData.views)
247 await options.video.save(sequelizeOptions)
1297eb5d
C
248
249 // Don't block on request
d4defe07
C
250 generateThumbnailFromUrl(options.video, options.videoObject.icon)
251 .catch(err => logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err }))
1297eb5d 252
e5565833
C
253 {
254 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
255 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
0032ebe9 256
e5565833
C
257 // Remove video files that do not exist anymore
258 const destroyTasks = options.video.VideoFiles
259 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
260 .map(f => f.destroy(sequelizeOptions))
261 await Promise.all(destroyTasks)
2ccaeeb3 262
e5565833 263 // Update or add other one
d382f4e9
C
264 const upsertTasks = videoFileAttributes.map(a => {
265 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
266 .then(([ file ]) => file)
267 })
268
269 options.video.VideoFiles = await Promise.all(upsertTasks)
e5565833 270 }
2ccaeeb3 271
e5565833
C
272 {
273 // Update Tags
274 const tags = options.videoObject.tag.map(tag => tag.name)
275 const tagInstances = await TagModel.findOrCreateTags(tags, t)
276 await options.video.$set('Tags', tagInstances, sequelizeOptions)
277 }
2ccaeeb3 278
e5565833
C
279 {
280 // Update captions
281 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
282
283 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
284 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
285 })
d382f4e9 286 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
e5565833 287 }
1297eb5d
C
288 })
289
d4defe07 290 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
1297eb5d 291 } catch (err) {
d4defe07
C
292 if (options.video !== undefined && videoFieldsSave !== undefined) {
293 resetSequelizeInstance(options.video, videoFieldsSave)
1297eb5d
C
294 }
295
296 // This is just a debug because we will retry the insert
297 logger.debug('Cannot update the remote video.', { err })
298 throw err
299 }
892211e8 300}
2186386c
C
301
302export {
1297eb5d 303 updateVideoFromAP,
2186386c
C
304 federateVideoIfNeeded,
305 fetchRemoteVideo,
1297eb5d 306 getOrCreateVideoAndAccountAndChannel,
40e87e9e 307 fetchRemoteVideoStaticFile,
2186386c
C
308 fetchRemoteVideoDescription,
309 generateThumbnailFromUrl,
4157cdb1 310 getOrCreateVideoChannelFromVideoObject
2186386c 311}
c48e82b5
C
312
313// ---------------------------------------------------------------------------
314
315function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
316 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
317
e27ff5da
C
318 const urlMediaType = url.mediaType || url.mimeType
319 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
c48e82b5 320}
4157cdb1
C
321
322async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
323 logger.debug('Adding remote video %s.', videoObject.id)
324
325 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
326 const sequelizeOptions = { transaction: t }
327
328 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
329 const video = VideoModel.build(videoData)
330
331 const videoCreated = await video.save(sequelizeOptions)
332
333 // Process files
334 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
335 if (videoFileAttributes.length === 0) {
336 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
337 }
338
339 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
340 await Promise.all(videoFilePromises)
341
342 // Process tags
343 const tags = videoObject.tag.map(t => t.name)
344 const tagInstances = await TagModel.findOrCreateTags(tags, t)
345 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
346
347 // Process captions
348 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
349 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
350 })
351 await Promise.all(videoCaptionsPromises)
352
353 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
354
355 videoCreated.VideoChannel = channelActor.VideoChannel
356 return videoCreated
357 })
358
359 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
360 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
361
362 if (waitThumbnail === true) await p
363
364 return videoCreated
365}
366
d4defe07
C
367async function refreshVideoIfNeeded (options: {
368 video: VideoModel,
369 fetchedType: VideoFetchByUrlType,
370 syncParam: SyncParam,
371 refreshViews: boolean
372}): Promise<VideoModel> {
91411dba
C
373 if (!options.video.isOutdated()) return options.video
374
4157cdb1 375 // We need more attributes if the argument video was fetched with not enough joints
d4defe07 376 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
4157cdb1 377
4157cdb1
C
378 try {
379 const { response, videoObject } = await fetchRemoteVideo(video.url)
380 if (response.statusCode === 404) {
26649b42
C
381 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
382
4157cdb1
C
383 // Video does not exist anymore
384 await video.destroy()
385 return undefined
386 }
387
388 if (videoObject === undefined) {
26649b42 389 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
4157cdb1
C
390 return video
391 }
392
393 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
394 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
395
d4defe07
C
396 const updateOptions = {
397 video,
398 videoObject,
399 account,
400 channel: channelActor.VideoChannel,
401 updateViews: options.refreshViews
402 }
d382f4e9
C
403 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
404 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
792e5b8e
C
405
406 return video
4157cdb1 407 } catch (err) {
d382f4e9 408 logger.warn('Cannot refresh video %s.', options.video.url, { err })
4157cdb1
C
409 return video
410 }
411}
412
413async function videoActivityObjectToDBAttributes (
414 videoChannel: VideoChannelModel,
415 videoObject: VideoTorrentObject,
416 to: string[] = []
417) {
418 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
419 const duration = videoObject.duration.replace(/[^\d]+/, '')
420
421 let language: string | undefined
422 if (videoObject.language) {
423 language = videoObject.language.identifier
424 }
425
426 let category: number | undefined
427 if (videoObject.category) {
428 category = parseInt(videoObject.category.identifier, 10)
429 }
430
431 let licence: number | undefined
432 if (videoObject.licence) {
433 licence = parseInt(videoObject.licence.identifier, 10)
434 }
435
436 const description = videoObject.content || null
437 const support = videoObject.support || null
438
439 return {
440 name: videoObject.name,
441 uuid: videoObject.uuid,
442 url: videoObject.id,
443 category,
444 licence,
445 language,
446 description,
447 support,
448 nsfw: videoObject.sensitive,
449 commentsEnabled: videoObject.commentsEnabled,
450 waitTranscoding: videoObject.waitTranscoding,
451 state: videoObject.state,
452 channelId: videoChannel.id,
453 duration: parseInt(duration, 10),
454 createdAt: new Date(videoObject.published),
455 publishedAt: new Date(videoObject.published),
456 // FIXME: updatedAt does not seems to be considered by Sequelize
457 updatedAt: new Date(videoObject.updated),
458 views: videoObject.views,
459 likes: 0,
460 dislikes: 0,
461 remote: true,
462 privacy
463 }
464}
465
a3737cbf 466function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
4157cdb1
C
467 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
468
469 if (fileUrls.length === 0) {
a3737cbf 470 throw new Error('Cannot find video files for ' + video.url)
4157cdb1
C
471 }
472
473 const attributes: VideoFileModel[] = []
474 for (const fileUrl of fileUrls) {
475 // Fetch associated magnet uri
476 const magnet = videoObject.url.find(u => {
e27ff5da
C
477 const mediaType = u.mediaType || u.mimeType
478 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
4157cdb1
C
479 })
480
481 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
482
483 const parsed = magnetUtil.decode(magnet.href)
484 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
485 throw new Error('Cannot parse magnet URI ' + magnet.href)
486 }
487
e27ff5da 488 const mediaType = fileUrl.mediaType || fileUrl.mimeType
4157cdb1 489 const attribute = {
e27ff5da 490 extname: VIDEO_MIMETYPE_EXT[ mediaType ],
4157cdb1
C
491 infoHash: parsed.infoHash,
492 resolution: fileUrl.height,
493 size: fileUrl.size,
a3737cbf 494 videoId: video.id,
2e7cf5ae 495 fps: fileUrl.fps || -1
4157cdb1
C
496 } as VideoFileModel
497 attributes.push(attribute)
498 }
499
500 return attributes
501}