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