]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/lib/activitypub/videos.ts
Check video channel name is unique on our instance
[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, 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 import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
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) {
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 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
98
99 const options = {
100 method: 'GET',
101 uri: icon.url
102 }
103 return doRequestAndSaveToFile(options, thumbnailPath)
104 }
105
106 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
107 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
108 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
109
110 return getOrCreateActorAndServerAndModel(channel.id, 'all')
111 }
112
113 type SyncParam = {
114 likes: boolean
115 dislikes: boolean
116 shares: boolean
117 comments: boolean
118 thumbnail: boolean
119 refreshVideo: boolean
120 }
121 async 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
157 async function getOrCreateVideoAndAccountAndChannel (options: {
158 videoObject: VideoTorrentObject | string,
159 syncParam?: SyncParam,
160 fetchType?: VideoFetchByUrlType,
161 refreshViews?: boolean
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 refreshViews = options.refreshViews || false
167
168 // Get video url
169 const videoUrl = typeof options.videoObject === 'string' ? options.videoObject : options.videoObject.id
170
171 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
172 if (videoFromDatabase) {
173 const refreshOptions = {
174 video: videoFromDatabase,
175 fetchedType: fetchType,
176 syncParam,
177 refreshViews
178 }
179 const p = refreshVideoIfNeeded(refreshOptions)
180 if (syncParam.refreshVideo === true) videoFromDatabase = await p
181
182 return { video: videoFromDatabase }
183 }
184
185 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
186 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
187
188 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
189 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
190
191 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
192
193 return { video }
194 }
195
196 async function updateVideoFromAP (options: {
197 video: VideoModel,
198 videoObject: VideoTorrentObject,
199 account: AccountModel,
200 channel: VideoChannelModel,
201 updateViews: boolean,
202 overrideTo?: string[]
203 }) {
204 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
205 let videoFieldsSave: any
206
207 try {
208 const updatedVideo: VideoModel = await sequelizeTypescript.transaction(async t => {
209 const sequelizeOptions = {
210 transaction: t
211 }
212
213 videoFieldsSave = options.video.toJSON()
214
215 // Check actor has the right to update the video
216 const videoChannel = options.video.VideoChannel
217 if (videoChannel.Account.id !== options.account.id) {
218 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
219 }
220
221 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
222 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
223 options.video.set('name', videoData.name)
224 options.video.set('uuid', videoData.uuid)
225 options.video.set('url', videoData.url)
226 options.video.set('category', videoData.category)
227 options.video.set('licence', videoData.licence)
228 options.video.set('language', videoData.language)
229 options.video.set('description', videoData.description)
230 options.video.set('support', videoData.support)
231 options.video.set('nsfw', videoData.nsfw)
232 options.video.set('commentsEnabled', videoData.commentsEnabled)
233 options.video.set('waitTranscoding', videoData.waitTranscoding)
234 options.video.set('state', videoData.state)
235 options.video.set('duration', videoData.duration)
236 options.video.set('createdAt', videoData.createdAt)
237 options.video.set('publishedAt', videoData.publishedAt)
238 options.video.set('privacy', videoData.privacy)
239 options.video.set('channelId', videoData.channelId)
240
241 if (options.updateViews === true) options.video.set('views', videoData.views)
242 await options.video.save(sequelizeOptions)
243
244 // Don't block on request
245 generateThumbnailFromUrl(options.video, options.videoObject.icon)
246 .catch(err => logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err }))
247
248 {
249 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
250 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
251
252 // Remove video files that do not exist anymore
253 const destroyTasks = options.video.VideoFiles
254 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
255 .map(f => f.destroy(sequelizeOptions))
256 await Promise.all(destroyTasks)
257
258 // Update or add other one
259 const upsertTasks = videoFileAttributes.map(a => VideoFileModel.upsert(a, sequelizeOptions))
260 await Promise.all(upsertTasks)
261 }
262
263 {
264 // Update Tags
265 const tags = options.videoObject.tag.map(tag => tag.name)
266 const tagInstances = await TagModel.findOrCreateTags(tags, t)
267 await options.video.$set('Tags', tagInstances, sequelizeOptions)
268 }
269
270 {
271 // Update captions
272 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
273
274 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
275 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
276 })
277 await Promise.all(videoCaptionsPromises)
278 }
279 })
280
281 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
282
283 return updatedVideo
284 } catch (err) {
285 if (options.video !== undefined && videoFieldsSave !== undefined) {
286 resetSequelizeInstance(options.video, videoFieldsSave)
287 }
288
289 // This is just a debug because we will retry the insert
290 logger.debug('Cannot update the remote video.', { err })
291 throw err
292 }
293 }
294
295 export {
296 updateVideoFromAP,
297 federateVideoIfNeeded,
298 fetchRemoteVideo,
299 getOrCreateVideoAndAccountAndChannel,
300 fetchRemoteVideoStaticFile,
301 fetchRemoteVideoDescription,
302 generateThumbnailFromUrl,
303 getOrCreateVideoChannelFromVideoObject
304 }
305
306 // ---------------------------------------------------------------------------
307
308 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
309 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
310
311 return mimeTypes.indexOf(url.mimeType) !== -1 && url.mimeType.startsWith('video/')
312 }
313
314 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
315 logger.debug('Adding remote video %s.', videoObject.id)
316
317 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
318 const sequelizeOptions = { transaction: t }
319
320 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
321 const video = VideoModel.build(videoData)
322
323 const videoCreated = await video.save(sequelizeOptions)
324
325 // Process files
326 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
327 if (videoFileAttributes.length === 0) {
328 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
329 }
330
331 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
332 await Promise.all(videoFilePromises)
333
334 // Process tags
335 const tags = videoObject.tag.map(t => t.name)
336 const tagInstances = await TagModel.findOrCreateTags(tags, t)
337 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
338
339 // Process captions
340 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
341 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
342 })
343 await Promise.all(videoCaptionsPromises)
344
345 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
346
347 videoCreated.VideoChannel = channelActor.VideoChannel
348 return videoCreated
349 })
350
351 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
352 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
353
354 if (waitThumbnail === true) await p
355
356 return videoCreated
357 }
358
359 async function refreshVideoIfNeeded (options: {
360 video: VideoModel,
361 fetchedType: VideoFetchByUrlType,
362 syncParam: SyncParam,
363 refreshViews: boolean
364 }): Promise<VideoModel> {
365 if (!options.video.isOutdated()) return options.video
366
367 // We need more attributes if the argument video was fetched with not enough joints
368 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
369
370 try {
371 const { response, videoObject } = await fetchRemoteVideo(video.url)
372 if (response.statusCode === 404) {
373 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
374
375 // Video does not exist anymore
376 await video.destroy()
377 return undefined
378 }
379
380 if (videoObject === undefined) {
381 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
382 return video
383 }
384
385 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
386 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
387
388 const updateOptions = {
389 video,
390 videoObject,
391 account,
392 channel: channelActor.VideoChannel,
393 updateViews: options.refreshViews
394 }
395 const videoUpdated = await retryTransactionWrapper(updateVideoFromAP, updateOptions)
396 await syncVideoExternalAttributes(videoUpdated, videoObject, options.syncParam)
397
398 return videoUpdated
399 } catch (err) {
400 logger.warn('Cannot refresh video.', { err })
401 return video
402 }
403 }
404
405 async function videoActivityObjectToDBAttributes (
406 videoChannel: VideoChannelModel,
407 videoObject: VideoTorrentObject,
408 to: string[] = []
409 ) {
410 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
411 const duration = videoObject.duration.replace(/[^\d]+/, '')
412
413 let language: string | undefined
414 if (videoObject.language) {
415 language = videoObject.language.identifier
416 }
417
418 let category: number | undefined
419 if (videoObject.category) {
420 category = parseInt(videoObject.category.identifier, 10)
421 }
422
423 let licence: number | undefined
424 if (videoObject.licence) {
425 licence = parseInt(videoObject.licence.identifier, 10)
426 }
427
428 const description = videoObject.content || null
429 const support = videoObject.support || null
430
431 return {
432 name: videoObject.name,
433 uuid: videoObject.uuid,
434 url: videoObject.id,
435 category,
436 licence,
437 language,
438 description,
439 support,
440 nsfw: videoObject.sensitive,
441 commentsEnabled: videoObject.commentsEnabled,
442 waitTranscoding: videoObject.waitTranscoding,
443 state: videoObject.state,
444 channelId: videoChannel.id,
445 duration: parseInt(duration, 10),
446 createdAt: new Date(videoObject.published),
447 publishedAt: new Date(videoObject.published),
448 // FIXME: updatedAt does not seems to be considered by Sequelize
449 updatedAt: new Date(videoObject.updated),
450 views: videoObject.views,
451 likes: 0,
452 dislikes: 0,
453 remote: true,
454 privacy
455 }
456 }
457
458 function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObject: VideoTorrentObject) {
459 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
460
461 if (fileUrls.length === 0) {
462 throw new Error('Cannot find video files for ' + videoCreated.url)
463 }
464
465 const attributes: VideoFileModel[] = []
466 for (const fileUrl of fileUrls) {
467 // Fetch associated magnet uri
468 const magnet = videoObject.url.find(u => {
469 return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.height === fileUrl.height
470 })
471
472 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
473
474 const parsed = magnetUtil.decode(magnet.href)
475 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
476 throw new Error('Cannot parse magnet URI ' + magnet.href)
477 }
478
479 const attribute = {
480 extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ],
481 infoHash: parsed.infoHash,
482 resolution: fileUrl.height,
483 size: fileUrl.size,
484 videoId: videoCreated.id,
485 fps: fileUrl.fps || -1
486 } as VideoFileModel
487 attributes.push(attribute)
488 }
489
490 return attributes
491 }