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