]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/lib/activitypub/videos.ts
a5d649391f6cd684e942bb735894bfc1989051f6
[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 allowRefresh?: boolean // true by default
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'
167 const allowRefresh = options.allowRefresh !== false
168
169 // Get video url
170 const videoUrl = getAPUrl(options.videoObject)
171
172 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
173 if (videoFromDatabase) {
174
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 }
185
186 return { video: videoFromDatabase }
187 }
188
189 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
190 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
191
192 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
193 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
194
195 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
196
197 return { video }
198 }
199
200 async function updateVideoFromAP (options: {
201 video: VideoModel,
202 videoObject: VideoTorrentObject,
203 account: AccountModel,
204 channel: VideoChannelModel,
205 overrideTo?: string[]
206 }) {
207 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
208 let videoFieldsSave: any
209
210 try {
211 await sequelizeTypescript.transaction(async t => {
212 const sequelizeOptions = {
213 transaction: t
214 }
215
216 videoFieldsSave = options.video.toJSON()
217
218 // Check actor has the right to update the video
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)
222 }
223
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)
243 options.video.set('views', videoData.views)
244
245 await options.video.save(sequelizeOptions)
246
247 {
248 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
249 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
250
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)
256
257 // Update or add other one
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)
264 }
265
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 }
272
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 })
280 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
281 }
282 })
283
284 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
285 } catch (err) {
286 if (options.video !== undefined && videoFieldsSave !== undefined) {
287 resetSequelizeInstance(options.video, videoFieldsSave)
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 }
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 }
300 }
301
302 async 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
351 export {
352 updateVideoFromAP,
353 refreshVideoIfNeeded,
354 federateVideoIfNeeded,
355 fetchRemoteVideo,
356 getOrCreateVideoAndAccountAndChannel,
357 fetchRemoteVideoStaticFile,
358 fetchRemoteVideoDescription,
359 generateThumbnailFromUrl,
360 getOrCreateVideoChannelFromVideoObject
361 }
362
363 // ---------------------------------------------------------------------------
364
365 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
366 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
367
368 const urlMediaType = url.mediaType || url.mimeType
369 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
370 }
371
372 async 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
417 async 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
470 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
471 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
472
473 if (fileUrls.length === 0) {
474 throw new Error('Cannot find video files for ' + video.url)
475 }
476
477 const attributes: VideoFileModel[] = []
478 for (const fileUrl of fileUrls) {
479 // Fetch associated magnet uri
480 const magnet = videoObject.url.find(u => {
481 const mediaType = u.mediaType || u.mimeType
482 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
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
492 const mediaType = fileUrl.mediaType || fileUrl.mimeType
493 const attribute = {
494 extname: VIDEO_MIMETYPE_EXT[ mediaType ],
495 infoHash: parsed.infoHash,
496 resolution: fileUrl.height,
497 size: fileUrl.size,
498 videoId: video.id,
499 fps: fileUrl.fps || -1
500 } as VideoFileModel
501 attributes.push(attribute)
502 }
503
504 return attributes
505 }