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