]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/lib/activitypub/videos.ts
998f903303137aa17040d6bb6c5cfc860a5a819f
[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, downloadImage } from '../../helpers/requests'
14 import { ACTIVITY_PUB, CONFIG, REMOTE_SCHEME, sequelizeTypescript, THUMBNAILS_SIZE, 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, getAPUrl } 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 return downloadImage(icon.url, thumbnailPath, THUMBNAILS_SIZE)
101 }
102
103 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
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
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
111 return getOrCreateActorAndServerAndModel(channel.id, 'all')
112 }
113
114 type SyncParam = {
115 likes: boolean
116 dislikes: boolean
117 shares: boolean
118 comments: boolean
119 thumbnail: boolean
120 refreshVideo?: boolean
121 }
122 async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
123 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
124
125 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
126
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 }
133
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 }
147
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 }
154
155 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
156 }
157
158 async function getOrCreateVideoAndAccountAndChannel (options: {
159 videoObject: VideoTorrentObject | string,
160 syncParam?: SyncParam,
161 fetchType?: VideoFetchByUrlType
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'
166
167 // Get video url
168 const videoUrl = getAPUrl(options.videoObject)
169
170 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
171 if (videoFromDatabase) {
172 const refreshOptions = {
173 video: videoFromDatabase,
174 fetchedType: fetchType,
175 syncParam
176 }
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 } })
180
181 return { video: videoFromDatabase }
182 }
183
184 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
185 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
186
187 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
188 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
189
190 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
191
192 return { video }
193 }
194
195 async function updateVideoFromAP (options: {
196 video: VideoModel,
197 videoObject: VideoTorrentObject,
198 account: AccountModel,
199 channel: VideoChannelModel,
200 overrideTo?: string[]
201 }) {
202 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
203 let videoFieldsSave: any
204
205 try {
206 await sequelizeTypescript.transaction(async t => {
207 const sequelizeOptions = {
208 transaction: t
209 }
210
211 videoFieldsSave = options.video.toJSON()
212
213 // Check actor has the right to update the video
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)
217 }
218
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)
238 options.video.set('views', videoData.views)
239
240 await options.video.save(sequelizeOptions)
241
242 {
243 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
244 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
245
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)
251
252 // Update or add other one
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)
259 }
260
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 }
267
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 })
275 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
276 }
277 })
278
279 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
280 } catch (err) {
281 if (options.video !== undefined && videoFieldsSave !== undefined) {
282 resetSequelizeInstance(options.video, videoFieldsSave)
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 }
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 }
295 }
296
297 async 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
346 export {
347 updateVideoFromAP,
348 refreshVideoIfNeeded,
349 federateVideoIfNeeded,
350 fetchRemoteVideo,
351 getOrCreateVideoAndAccountAndChannel,
352 fetchRemoteVideoStaticFile,
353 fetchRemoteVideoDescription,
354 generateThumbnailFromUrl,
355 getOrCreateVideoChannelFromVideoObject
356 }
357
358 // ---------------------------------------------------------------------------
359
360 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
361 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
362
363 const urlMediaType = url.mediaType || url.mimeType
364 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
365 }
366
367 async 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
412 async 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
465 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
466 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
467
468 if (fileUrls.length === 0) {
469 throw new Error('Cannot find video files for ' + video.url)
470 }
471
472 const attributes: VideoFileModel[] = []
473 for (const fileUrl of fileUrls) {
474 // Fetch associated magnet uri
475 const magnet = videoObject.url.find(u => {
476 const mediaType = u.mediaType || u.mimeType
477 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
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
487 const mediaType = fileUrl.mediaType || fileUrl.mimeType
488 const attribute = {
489 extname: VIDEO_MIMETYPE_EXT[ mediaType ],
490 infoHash: parsed.infoHash,
491 resolution: fileUrl.height,
492 size: fileUrl.size,
493 videoId: video.id,
494 fps: fileUrl.fps || -1
495 } as VideoFileModel
496 attributes.push(attribute)
497 }
498
499 return attributes
500 }