]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/lib/activitypub/videos.ts
Basic video redundancy implementation
[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'
c48e82b5 6import { ActivityIconObject, ActivityVideoUrlObject, VideoState, ActivityUrlObject } 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'
2186386c
C
31
32async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
33 // If the video is not private and published, we federate it
34 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
40e87e9e
C
35 // Fetch more attributes that we will need to serialize in AP object
36 if (isArray(video.VideoCaptions) === false) {
37 video.VideoCaptions = await video.$get('VideoCaptions', {
38 attributes: [ 'language' ],
39 transaction
40 }) as VideoCaptionModel[]
41 }
42
2cebd797 43 if (isNewVideo) {
2186386c
C
44 // Now we'll add the video's meta data to our followers
45 await sendCreateVideo(video, transaction)
46 await shareVideoByServerAndChannel(video, transaction)
47 } else {
48 await sendUpdateVideo(video, transaction)
49 }
50 }
51}
892211e8 52
40e87e9e 53function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
50d6de9c 54 const host = video.VideoChannel.Account.Actor.Server.host
892211e8 55
f05a1c30 56 // We need to provide a callback, if no we could have an uncaught exception
f40bbe31
C
57 return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
58 if (err) reject(err)
59 })
892211e8
C
60}
61
3fd3ab2d 62async function fetchRemoteVideoDescription (video: VideoModel) {
50d6de9c 63 const host = video.VideoChannel.Account.Actor.Server.host
892211e8
C
64 const path = video.getDescriptionPath()
65 const options = {
66 uri: REMOTE_SCHEME.HTTP + '://' + host + path,
67 json: true
68 }
69
70 const { body } = await doRequest(options)
71 return body.description ? body.description : ''
72}
73
3fd3ab2d 74function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
892211e8
C
75 const thumbnailName = video.getThumbnailName()
76 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
77
78 const options = {
79 method: 'GET',
80 uri: icon.url
81 }
82 return doRequestAndSaveToFile(options, thumbnailPath)
83}
84
2186386c
C
85async function videoActivityObjectToDBAttributes (
86 videoChannel: VideoChannelModel,
87 videoObject: VideoTorrentObject,
88 to: string[] = []
89) {
276d03ed 90 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
2ccaeeb3 91 const duration = videoObject.duration.replace(/[^\d]+/, '')
9d3ef9fe 92
c1e791ba 93 let language: string | undefined
2ccaeeb3 94 if (videoObject.language) {
9d3ef9fe 95 language = videoObject.language.identifier
2ccaeeb3
C
96 }
97
c1e791ba 98 let category: number | undefined
2ccaeeb3
C
99 if (videoObject.category) {
100 category = parseInt(videoObject.category.identifier, 10)
101 }
102
c1e791ba 103 let licence: number | undefined
2ccaeeb3
C
104 if (videoObject.licence) {
105 licence = parseInt(videoObject.licence.identifier, 10)
106 }
107
276d03ed
C
108 const description = videoObject.content || null
109 const support = videoObject.support || null
2422c46b 110
2ccaeeb3
C
111 return {
112 name: videoObject.name,
113 uuid: videoObject.uuid,
114 url: videoObject.id,
115 category,
116 licence,
117 language,
118 description,
2422c46b 119 support,
0a67e28b 120 nsfw: videoObject.sensitive,
2ccaeeb3 121 commentsEnabled: videoObject.commentsEnabled,
2186386c
C
122 waitTranscoding: videoObject.waitTranscoding,
123 state: videoObject.state,
2ccaeeb3
C
124 channelId: videoChannel.id,
125 duration: parseInt(duration, 10),
126 createdAt: new Date(videoObject.published),
53a61317 127 publishedAt: new Date(videoObject.published),
2ccaeeb3
C
128 // FIXME: updatedAt does not seems to be considered by Sequelize
129 updatedAt: new Date(videoObject.updated),
130 views: videoObject.views,
131 likes: 0,
132 dislikes: 0,
133 remote: true,
134 privacy
135 }
136}
137
138function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObject: VideoTorrentObject) {
c48e82b5 139 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
2ccaeeb3
C
140
141 if (fileUrls.length === 0) {
142 throw new Error('Cannot find video files for ' + videoCreated.url)
143 }
144
c1e791ba 145 const attributes: VideoFileModel[] = []
2ccaeeb3
C
146 for (const fileUrl of fileUrls) {
147 // Fetch associated magnet uri
148 const magnet = videoObject.url.find(u => {
965c4b22 149 return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.height === fileUrl.height
2ccaeeb3
C
150 })
151
9fb3abfd 152 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
2ccaeeb3 153
9fb3abfd 154 const parsed = magnetUtil.decode(magnet.href)
2cebd797 155 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
c1e791ba
RK
156 throw new Error('Cannot parse magnet URI ' + magnet.href)
157 }
2ccaeeb3
C
158
159 const attribute = {
160 extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ],
161 infoHash: parsed.infoHash,
965c4b22 162 resolution: fileUrl.height,
2ccaeeb3 163 size: fileUrl.size,
b2977eec
C
164 videoId: videoCreated.id,
165 fps: fileUrl.fps
c1e791ba 166 } as VideoFileModel
2ccaeeb3
C
167 attributes.push(attribute)
168 }
169
170 return attributes
171}
172
f37dc0dd 173function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
0f320037
C
174 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
175 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
176
177 return getOrCreateActorAndServerAndModel(channel.id)
178}
179
1297eb5d 180async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
2ccaeeb3
C
181 logger.debug('Adding remote video %s.', videoObject.id)
182
f6eebcb3 183 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
1297eb5d 184 const sequelizeOptions = { transaction: t }
2ccaeeb3 185
276d03ed 186 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
2ccaeeb3
C
187 const video = VideoModel.build(videoData)
188
2ccaeeb3
C
189 const videoCreated = await video.save(sequelizeOptions)
190
40e87e9e 191 // Process files
2ccaeeb3
C
192 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
193 if (videoFileAttributes.length === 0) {
194 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
195 }
196
40e87e9e
C
197 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
198 await Promise.all(videoFilePromises)
2ccaeeb3 199
40e87e9e 200 // Process tags
2ccaeeb3
C
201 const tags = videoObject.tag.map(t => t.name)
202 const tagInstances = await TagModel.findOrCreateTags(tags, t)
203 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
204
40e87e9e
C
205 // Process captions
206 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
207 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
208 })
209 await Promise.all(videoCaptionsPromises)
210
2ccaeeb3
C
211 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
212
213 videoCreated.VideoChannel = channelActor.VideoChannel
214 return videoCreated
215 })
f6eebcb3
C
216
217 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
218 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
219
220 if (waitThumbnail === true) await p
221
222 return videoCreated
0032ebe9
C
223}
224
f6eebcb3 225type SyncParam = {
1297eb5d
C
226 likes: boolean
227 dislikes: boolean
228 shares: boolean
229 comments: boolean
f6eebcb3 230 thumbnail: boolean
1297eb5d 231 refreshVideo: boolean
f6eebcb3 232}
1297eb5d 233async function getOrCreateVideoAndAccountAndChannel (
f6eebcb3 234 videoObject: VideoTorrentObject | string,
1297eb5d 235 syncParam: SyncParam = { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
f6eebcb3 236) {
2186386c
C
237 const videoUrl = typeof videoObject === 'string' ? videoObject : videoObject.id
238
1297eb5d
C
239 let videoFromDatabase = await VideoModel.loadByUrlAndPopulateAccount(videoUrl)
240 if (videoFromDatabase) {
241 const p = retryTransactionWrapper(refreshVideoIfNeeded, videoFromDatabase)
242 if (syncParam.refreshVideo === true) videoFromDatabase = await p
243
244 return { video: videoFromDatabase }
245 }
2ccaeeb3 246
1297eb5d 247 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
f6eebcb3 248 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
2186386c 249
f37dc0dd 250 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
1297eb5d 251 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
2ccaeeb3 252
f6eebcb3 253 // Process outside the transaction because we could fetch remote data
2ccaeeb3 254
f6eebcb3 255 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
2ccaeeb3 256
f6eebcb3 257 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
2ccaeeb3 258
f6eebcb3
C
259 if (syncParam.likes === true) {
260 await crawlCollectionPage<string>(fetchedVideo.likes, items => createRates(items, video, 'like'))
261 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
262 } else {
263 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
264 }
7acee6f1 265
f6eebcb3
C
266 if (syncParam.dislikes === true) {
267 await crawlCollectionPage<string>(fetchedVideo.dislikes, items => createRates(items, video, 'dislike'))
268 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
269 } else {
270 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
271 }
272
273 if (syncParam.shares === true) {
274 await crawlCollectionPage<string>(fetchedVideo.shares, items => addVideoShares(items, video))
275 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
276 } else {
277 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
278 }
7acee6f1 279
f6eebcb3
C
280 if (syncParam.comments === true) {
281 await crawlCollectionPage<string>(fetchedVideo.comments, items => addVideoComments(items, video))
282 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
283 } else {
284 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
285 }
7acee6f1 286
f6eebcb3 287 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
7acee6f1 288
f6eebcb3 289 return { video }
2ccaeeb3
C
290}
291
1297eb5d
C
292async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
293 const options = {
294 uri: videoUrl,
295 method: 'GET',
296 json: true,
297 activityPub: true
298 }
299
300 logger.info('Fetching remote video %s.', videoUrl)
301
302 const { response, body } = await doRequest(options)
303
304 if (sanitizeAndCheckVideoTorrentObject(body) === false) {
305 logger.debug('Remote video JSON is not valid.', { body })
306 return { response, videoObject: undefined }
307 }
308
309 return { response, videoObject: body }
310}
311
312async function refreshVideoIfNeeded (video: VideoModel): Promise<VideoModel> {
313 if (!video.isOutdated()) return video
314
315 try {
316 const { response, videoObject } = await fetchRemoteVideo(video.url)
317 if (response.statusCode === 404) {
318 // Video does not exist anymore
319 await video.destroy()
320 return undefined
f6eebcb3 321 }
7acee6f1 322
1297eb5d
C
323 if (videoObject === undefined) {
324 logger.warn('Cannot refresh remote video: invalid body.')
325 return video
326 }
7acee6f1 327
f37dc0dd 328 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
1297eb5d 329 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
7acee6f1 330
c48e82b5 331 return updateVideoFromAP(video, videoObject, account, channelActor.VideoChannel)
1297eb5d
C
332 } catch (err) {
333 logger.warn('Cannot refresh video.', { err })
334 return video
335 }
7acee6f1
C
336}
337
1297eb5d
C
338async function updateVideoFromAP (
339 video: VideoModel,
340 videoObject: VideoTorrentObject,
c48e82b5
C
341 account: AccountModel,
342 channel: VideoChannelModel,
1297eb5d
C
343 overrideTo?: string[]
344) {
345 logger.debug('Updating remote video "%s".', videoObject.uuid)
346 let videoFieldsSave: any
347
348 try {
349 const updatedVideo: VideoModel = await sequelizeTypescript.transaction(async t => {
350 const sequelizeOptions = {
351 transaction: t
352 }
2ccaeeb3 353
1297eb5d 354 videoFieldsSave = video.toJSON()
2ccaeeb3 355
1297eb5d
C
356 // Check actor has the right to update the video
357 const videoChannel = video.VideoChannel
c48e82b5
C
358 if (videoChannel.Account.id !== account.id) {
359 throw new Error('Account ' + account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
f6eebcb3
C
360 }
361
1297eb5d 362 const to = overrideTo ? overrideTo : videoObject.to
c48e82b5 363 const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, to)
1297eb5d
C
364 video.set('name', videoData.name)
365 video.set('uuid', videoData.uuid)
366 video.set('url', videoData.url)
367 video.set('category', videoData.category)
368 video.set('licence', videoData.licence)
369 video.set('language', videoData.language)
370 video.set('description', videoData.description)
371 video.set('support', videoData.support)
372 video.set('nsfw', videoData.nsfw)
373 video.set('commentsEnabled', videoData.commentsEnabled)
374 video.set('waitTranscoding', videoData.waitTranscoding)
375 video.set('state', videoData.state)
376 video.set('duration', videoData.duration)
377 video.set('createdAt', videoData.createdAt)
378 video.set('publishedAt', videoData.publishedAt)
379 video.set('views', videoData.views)
380 video.set('privacy', videoData.privacy)
381 video.set('channelId', videoData.channelId)
382
383 await video.save(sequelizeOptions)
384
385 // Don't block on request
386 generateThumbnailFromUrl(video, videoObject.icon)
387 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
388
389 // Remove old video files
390 const videoFileDestroyTasks: Bluebird<void>[] = []
391 for (const videoFile of video.VideoFiles) {
392 videoFileDestroyTasks.push(videoFile.destroy(sequelizeOptions))
393 }
394 await Promise.all(videoFileDestroyTasks)
0032ebe9 395
1297eb5d
C
396 const videoFileAttributes = videoFileActivityUrlToDBAttributes(video, videoObject)
397 const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, sequelizeOptions))
398 await Promise.all(tasks)
2ccaeeb3 399
1297eb5d
C
400 // Update Tags
401 const tags = videoObject.tag.map(tag => tag.name)
402 const tagInstances = await TagModel.findOrCreateTags(tags, t)
403 await video.$set('Tags', tagInstances, sequelizeOptions)
2ccaeeb3 404
1297eb5d
C
405 // Update captions
406 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(video.id, t)
2ccaeeb3 407
1297eb5d
C
408 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
409 return VideoCaptionModel.insertOrReplaceLanguage(video.id, c.identifier, t)
410 })
411 await Promise.all(videoCaptionsPromises)
412 })
413
414 logger.info('Remote video with uuid %s updated', videoObject.uuid)
2ccaeeb3 415
1297eb5d
C
416 return updatedVideo
417 } catch (err) {
418 if (video !== undefined && videoFieldsSave !== undefined) {
419 resetSequelizeInstance(video, videoFieldsSave)
420 }
421
422 // This is just a debug because we will retry the insert
423 logger.debug('Cannot update the remote video.', { err })
424 throw err
425 }
892211e8 426}
2186386c
C
427
428export {
1297eb5d 429 updateVideoFromAP,
2186386c
C
430 federateVideoIfNeeded,
431 fetchRemoteVideo,
1297eb5d 432 getOrCreateVideoAndAccountAndChannel,
40e87e9e 433 fetchRemoteVideoStaticFile,
2186386c
C
434 fetchRemoteVideoDescription,
435 generateThumbnailFromUrl,
436 videoActivityObjectToDBAttributes,
437 videoFileActivityUrlToDBAttributes,
1297eb5d 438 createVideo,
f37dc0dd 439 getOrCreateVideoChannelFromVideoObject,
f6eebcb3
C
440 addVideoShares,
441 createRates
2186386c 442}
c48e82b5
C
443
444// ---------------------------------------------------------------------------
445
446function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
447 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
448
449 return mimeTypes.indexOf(url.mimeType) !== -1 && url.mimeType.startsWith('video/')
450}