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