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