]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/lib/activitypub/videos.ts
Upsert cache file on create activity
[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
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
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 }
e5565833 179 const p = 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 {
d382f4e9 208 await sequelizeTypescript.transaction(async t => {
1297eb5d
C
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 247
e5565833
C
248 {
249 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
250 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
0032ebe9 251
e5565833
C
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)
2ccaeeb3 257
e5565833 258 // Update or add other one
d382f4e9
C
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)
e5565833 265 }
2ccaeeb3 266
e5565833
C
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 }
2ccaeeb3 273
e5565833
C
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 })
d382f4e9 281 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
e5565833 282 }
1297eb5d
C
283 })
284
d4defe07 285 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
1297eb5d 286 } catch (err) {
d4defe07
C
287 if (options.video !== undefined && videoFieldsSave !== undefined) {
288 resetSequelizeInstance(options.video, videoFieldsSave)
1297eb5d
C
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 }
892211e8 295}
2186386c
C
296
297export {
1297eb5d 298 updateVideoFromAP,
2186386c
C
299 federateVideoIfNeeded,
300 fetchRemoteVideo,
1297eb5d 301 getOrCreateVideoAndAccountAndChannel,
40e87e9e 302 fetchRemoteVideoStaticFile,
2186386c
C
303 fetchRemoteVideoDescription,
304 generateThumbnailFromUrl,
4157cdb1 305 getOrCreateVideoChannelFromVideoObject
2186386c 306}
c48e82b5
C
307
308// ---------------------------------------------------------------------------
309
310function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
311 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
312
313 return mimeTypes.indexOf(url.mimeType) !== -1 && url.mimeType.startsWith('video/')
314}
4157cdb1
C
315
316async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
317 logger.debug('Adding remote video %s.', videoObject.id)
318
319 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
320 const sequelizeOptions = { transaction: t }
321
322 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
323 const video = VideoModel.build(videoData)
324
325 const videoCreated = await video.save(sequelizeOptions)
326
327 // Process files
328 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
329 if (videoFileAttributes.length === 0) {
330 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
331 }
332
333 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
334 await Promise.all(videoFilePromises)
335
336 // Process tags
337 const tags = videoObject.tag.map(t => t.name)
338 const tagInstances = await TagModel.findOrCreateTags(tags, t)
339 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
340
341 // Process captions
342 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
343 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
344 })
345 await Promise.all(videoCaptionsPromises)
346
347 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
348
349 videoCreated.VideoChannel = channelActor.VideoChannel
350 return videoCreated
351 })
352
353 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
354 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
355
356 if (waitThumbnail === true) await p
357
358 return videoCreated
359}
360
d4defe07
C
361async function refreshVideoIfNeeded (options: {
362 video: VideoModel,
363 fetchedType: VideoFetchByUrlType,
364 syncParam: SyncParam,
365 refreshViews: boolean
366}): Promise<VideoModel> {
91411dba
C
367 if (!options.video.isOutdated()) return options.video
368
4157cdb1 369 // We need more attributes if the argument video was fetched with not enough joints
d4defe07 370 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
4157cdb1 371
4157cdb1
C
372 try {
373 const { response, videoObject } = await fetchRemoteVideo(video.url)
374 if (response.statusCode === 404) {
26649b42
C
375 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
376
4157cdb1
C
377 // Video does not exist anymore
378 await video.destroy()
379 return undefined
380 }
381
382 if (videoObject === undefined) {
26649b42 383 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
4157cdb1
C
384 return video
385 }
386
387 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
388 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
389
d4defe07
C
390 const updateOptions = {
391 video,
392 videoObject,
393 account,
394 channel: channelActor.VideoChannel,
395 updateViews: options.refreshViews
396 }
d382f4e9
C
397 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
398 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
792e5b8e
C
399
400 return video
4157cdb1 401 } catch (err) {
d382f4e9 402 logger.warn('Cannot refresh video %s.', options.video.url, { err })
4157cdb1
C
403 return video
404 }
405}
406
407async function videoActivityObjectToDBAttributes (
408 videoChannel: VideoChannelModel,
409 videoObject: VideoTorrentObject,
410 to: string[] = []
411) {
412 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
413 const duration = videoObject.duration.replace(/[^\d]+/, '')
414
415 let language: string | undefined
416 if (videoObject.language) {
417 language = videoObject.language.identifier
418 }
419
420 let category: number | undefined
421 if (videoObject.category) {
422 category = parseInt(videoObject.category.identifier, 10)
423 }
424
425 let licence: number | undefined
426 if (videoObject.licence) {
427 licence = parseInt(videoObject.licence.identifier, 10)
428 }
429
430 const description = videoObject.content || null
431 const support = videoObject.support || null
432
433 return {
434 name: videoObject.name,
435 uuid: videoObject.uuid,
436 url: videoObject.id,
437 category,
438 licence,
439 language,
440 description,
441 support,
442 nsfw: videoObject.sensitive,
443 commentsEnabled: videoObject.commentsEnabled,
444 waitTranscoding: videoObject.waitTranscoding,
445 state: videoObject.state,
446 channelId: videoChannel.id,
447 duration: parseInt(duration, 10),
448 createdAt: new Date(videoObject.published),
449 publishedAt: new Date(videoObject.published),
450 // FIXME: updatedAt does not seems to be considered by Sequelize
451 updatedAt: new Date(videoObject.updated),
452 views: videoObject.views,
453 likes: 0,
454 dislikes: 0,
455 remote: true,
456 privacy
457 }
458}
459
a3737cbf 460function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
4157cdb1
C
461 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
462
463 if (fileUrls.length === 0) {
a3737cbf 464 throw new Error('Cannot find video files for ' + video.url)
4157cdb1
C
465 }
466
467 const attributes: VideoFileModel[] = []
468 for (const fileUrl of fileUrls) {
469 // Fetch associated magnet uri
470 const magnet = videoObject.url.find(u => {
471 return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.height === fileUrl.height
472 })
473
474 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
475
476 const parsed = magnetUtil.decode(magnet.href)
477 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
478 throw new Error('Cannot parse magnet URI ' + magnet.href)
479 }
480
481 const attribute = {
482 extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ],
483 infoHash: parsed.infoHash,
484 resolution: fileUrl.height,
485 size: fileUrl.size,
a3737cbf 486 videoId: video.id,
2e7cf5ae 487 fps: fileUrl.fps || -1
4157cdb1
C
488 } as VideoFileModel
489 attributes.push(attribute)
490 }
491
492 return attributes
493}