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