]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/lib/activitypub/videos.ts
Fix thumbnail 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'
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'
361805c4 32import { checkUrlsSameHost, getAPUrl } 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
04b8c3fb 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,
74577825
C
161 fetchType?: VideoFetchByUrlType,
162 allowRefresh?: boolean // true by default
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'
74577825 167 const allowRefresh = options.allowRefresh !== false
1297eb5d 168
4157cdb1 169 // Get video url
361805c4 170 const videoUrl = getAPUrl(options.videoObject)
1297eb5d 171
4157cdb1
C
172 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
173 if (videoFromDatabase) {
04b8c3fb 174
74577825
C
175 if (allowRefresh === true) {
176 const refreshOptions = {
177 video: videoFromDatabase,
178 fetchedType: fetchType,
179 syncParam
180 }
181
182 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
183 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', videoUrl: videoFromDatabase.url } })
184 }
1297eb5d 185
4157cdb1 186 return { video: videoFromDatabase }
1297eb5d
C
187 }
188
4157cdb1
C
189 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
190 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
7acee6f1 191
4157cdb1
C
192 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
193 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
7acee6f1 194
4157cdb1 195 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
7acee6f1 196
4157cdb1 197 return { video }
7acee6f1
C
198}
199
d4defe07 200async function updateVideoFromAP (options: {
1297eb5d
C
201 video: VideoModel,
202 videoObject: VideoTorrentObject,
c48e82b5
C
203 account: AccountModel,
204 channel: VideoChannelModel,
1297eb5d 205 overrideTo?: string[]
d4defe07
C
206}) {
207 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
1297eb5d
C
208 let videoFieldsSave: any
209
210 try {
d382f4e9 211 await sequelizeTypescript.transaction(async t => {
1297eb5d
C
212 const sequelizeOptions = {
213 transaction: t
214 }
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
d4defe07 284 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
1297eb5d 285 } catch (err) {
d4defe07
C
286 if (options.video !== undefined && videoFieldsSave !== undefined) {
287 resetSequelizeInstance(options.video, videoFieldsSave)
1297eb5d
C
288 }
289
290 // This is just a debug because we will retry the insert
291 logger.debug('Cannot update the remote video.', { err })
292 throw err
293 }
a8a63227
C
294
295 try {
296 await generateThumbnailFromUrl(options.video, options.videoObject.icon)
297 } catch (err) {
298 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
299 }
892211e8 300}
2186386c 301
04b8c3fb
C
302async function refreshVideoIfNeeded (options: {
303 video: VideoModel,
304 fetchedType: VideoFetchByUrlType,
305 syncParam: SyncParam
306}): Promise<VideoModel> {
307 if (!options.video.isOutdated()) return options.video
308
309 // We need more attributes if the argument video was fetched with not enough joints
310 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
311
312 try {
313 const { response, videoObject } = await fetchRemoteVideo(video.url)
314 if (response.statusCode === 404) {
315 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
316
317 // Video does not exist anymore
318 await video.destroy()
319 return undefined
320 }
321
322 if (videoObject === undefined) {
323 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
324
325 await video.setAsRefreshed()
326 return video
327 }
328
329 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
330 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
331
332 const updateOptions = {
333 video,
334 videoObject,
335 account,
336 channel: channelActor.VideoChannel
337 }
338 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
339 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
340
341 return video
342 } catch (err) {
343 logger.warn('Cannot refresh video %s.', options.video.url, { err })
344
345 // Don't refresh in loop
346 await video.setAsRefreshed()
347 return video
348 }
349}
350
2186386c 351export {
1297eb5d 352 updateVideoFromAP,
04b8c3fb 353 refreshVideoIfNeeded,
2186386c
C
354 federateVideoIfNeeded,
355 fetchRemoteVideo,
1297eb5d 356 getOrCreateVideoAndAccountAndChannel,
40e87e9e 357 fetchRemoteVideoStaticFile,
2186386c
C
358 fetchRemoteVideoDescription,
359 generateThumbnailFromUrl,
4157cdb1 360 getOrCreateVideoChannelFromVideoObject
2186386c 361}
c48e82b5
C
362
363// ---------------------------------------------------------------------------
364
365function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
366 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
367
e27ff5da
C
368 const urlMediaType = url.mediaType || url.mimeType
369 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
c48e82b5 370}
4157cdb1
C
371
372async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
373 logger.debug('Adding remote video %s.', videoObject.id)
374
375 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
376 const sequelizeOptions = { transaction: t }
377
378 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
379 const video = VideoModel.build(videoData)
380
381 const videoCreated = await video.save(sequelizeOptions)
382
383 // Process files
384 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
385 if (videoFileAttributes.length === 0) {
386 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
387 }
388
389 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
390 await Promise.all(videoFilePromises)
391
392 // Process tags
393 const tags = videoObject.tag.map(t => t.name)
394 const tagInstances = await TagModel.findOrCreateTags(tags, t)
395 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
396
397 // Process captions
398 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
399 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
400 })
401 await Promise.all(videoCaptionsPromises)
402
403 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
404
405 videoCreated.VideoChannel = channelActor.VideoChannel
406 return videoCreated
407 })
408
409 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
410 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
411
412 if (waitThumbnail === true) await p
413
414 return videoCreated
415}
416
4157cdb1
C
417async function videoActivityObjectToDBAttributes (
418 videoChannel: VideoChannelModel,
419 videoObject: VideoTorrentObject,
420 to: string[] = []
421) {
422 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
423 const duration = videoObject.duration.replace(/[^\d]+/, '')
424
425 let language: string | undefined
426 if (videoObject.language) {
427 language = videoObject.language.identifier
428 }
429
430 let category: number | undefined
431 if (videoObject.category) {
432 category = parseInt(videoObject.category.identifier, 10)
433 }
434
435 let licence: number | undefined
436 if (videoObject.licence) {
437 licence = parseInt(videoObject.licence.identifier, 10)
438 }
439
440 const description = videoObject.content || null
441 const support = videoObject.support || null
442
443 return {
444 name: videoObject.name,
445 uuid: videoObject.uuid,
446 url: videoObject.id,
447 category,
448 licence,
449 language,
450 description,
451 support,
452 nsfw: videoObject.sensitive,
453 commentsEnabled: videoObject.commentsEnabled,
454 waitTranscoding: videoObject.waitTranscoding,
455 state: videoObject.state,
456 channelId: videoChannel.id,
457 duration: parseInt(duration, 10),
458 createdAt: new Date(videoObject.published),
459 publishedAt: new Date(videoObject.published),
460 // FIXME: updatedAt does not seems to be considered by Sequelize
461 updatedAt: new Date(videoObject.updated),
462 views: videoObject.views,
463 likes: 0,
464 dislikes: 0,
465 remote: true,
466 privacy
467 }
468}
469
a3737cbf 470function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
4157cdb1
C
471 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
472
473 if (fileUrls.length === 0) {
a3737cbf 474 throw new Error('Cannot find video files for ' + video.url)
4157cdb1
C
475 }
476
477 const attributes: VideoFileModel[] = []
478 for (const fileUrl of fileUrls) {
479 // Fetch associated magnet uri
480 const magnet = videoObject.url.find(u => {
e27ff5da
C
481 const mediaType = u.mediaType || u.mimeType
482 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
4157cdb1
C
483 })
484
485 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
486
487 const parsed = magnetUtil.decode(magnet.href)
488 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
489 throw new Error('Cannot parse magnet URI ' + magnet.href)
490 }
491
e27ff5da 492 const mediaType = fileUrl.mediaType || fileUrl.mimeType
4157cdb1 493 const attribute = {
e27ff5da 494 extname: VIDEO_MIMETYPE_EXT[ mediaType ],
4157cdb1
C
495 infoHash: parsed.infoHash,
496 resolution: fileUrl.height,
497 size: fileUrl.size,
a3737cbf 498 videoId: video.id,
2e7cf5ae 499 fps: fileUrl.fps || -1
4157cdb1
C
500 } as VideoFileModel
501 attributes.push(attribute)
502 }
503
504 return attributes
505}