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