]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/lib/activitypub/videos.ts
Add tmp and redundancy directories
[github/Chocobozzz/PeerTube.git] / server / lib / activitypub / videos.ts
CommitLineData
7acee6f1 1import * as Bluebird from 'bluebird'
2186386c 2import * as sequelize from 'sequelize'
2ccaeeb3 3import * as magnetUtil from 'magnet-uri'
892211e8
C
4import { join } from 'path'
5import * as request from 'request'
4157cdb1 6import { ActivityIconObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState } from '../../../shared/index'
2ccaeeb3 7import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
1297eb5d 8import { VideoPrivacy } from '../../../shared/models/videos'
1d6e5dfc 9import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
2ccaeeb3 10import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
c48e82b5 11import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
2ccaeeb3 12import { logger } from '../../helpers/logger'
58d515e3
C
13import { doRequest, downloadImage } from '../../helpers/requests'
14import { ACTIVITY_PUB, CONFIG, REMOTE_SCHEME, sequelizeTypescript, THUMBNAILS_SIZE, VIDEO_MIMETYPE_EXT } from '../../initializers'
2ccaeeb3
C
15import { ActorModel } from '../../models/activitypub/actor'
16import { TagModel } from '../../models/video/tag'
3fd3ab2d 17import { VideoModel } from '../../models/video/video'
2ccaeeb3
C
18import { VideoChannelModel } from '../../models/video/video-channel'
19import { VideoFileModel } from '../../models/video/video-file'
c48e82b5 20import { getOrCreateActorAndServerAndModel } from './actor'
7acee6f1 21import { addVideoComments } from './video-comments'
8fffe21a 22import { crawlCollectionPage } from './crawl'
2186386c 23import { sendCreateVideo, sendUpdateVideo } from './send'
40e87e9e
C
24import { isArray } from '../../helpers/custom-validators/misc'
25import { VideoCaptionModel } from '../../models/video/video-caption'
f6eebcb3
C
26import { JobQueue } from '../job-queue'
27import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub-http-fetcher'
1297eb5d
C
28import { createRates } from './video-rates'
29import { addVideoShares, shareVideoByServerAndChannel } from './share'
30import { AccountModel } from '../../models/account/account'
4157cdb1 31import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
361805c4 32import { checkUrlsSameHost, getAPUrl } from '../../helpers/activitypub'
2186386c
C
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) {
40e87e9e
C
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
2cebd797 45 if (isNewVideo) {
2186386c
C
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}
892211e8 54
4157cdb1
C
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 }
892211e8 62
4157cdb1
C
63 logger.info('Fetching remote video %s.', videoUrl)
64
65 const { response, body } = await doRequest(options)
66
5c6d985f 67 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
4157cdb1
C
68 logger.debug('Remote video JSON is not valid.', { body })
69 return { response, videoObject: undefined }
70 }
71
72 return { response, videoObject: body }
892211e8
C
73}
74
3fd3ab2d 75async function fetchRemoteVideoDescription (video: VideoModel) {
50d6de9c 76 const host = video.VideoChannel.Account.Actor.Server.host
96f29c0f 77 const path = video.getDescriptionAPIPath()
892211e8
C
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
4157cdb1
C
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
3fd3ab2d 96function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
892211e8 97 const thumbnailName = video.getThumbnailName()
892211e8 98
6040f87d 99 return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
892211e8
C
100}
101
f37dc0dd 102function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
0f320037
C
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
5c6d985f
C
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
e587e0ec 110 return getOrCreateActorAndServerAndModel(channel.id, 'all')
0f320037
C
111}
112
f6eebcb3 113type SyncParam = {
1297eb5d
C
114 likes: boolean
115 dislikes: boolean
116 shares: boolean
117 comments: boolean
f6eebcb3 118 thumbnail: boolean
04b8c3fb 119 refreshVideo?: boolean
f6eebcb3 120}
4157cdb1 121async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
f6eebcb3 122 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
2ccaeeb3 123
f6eebcb3 124 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
2ccaeeb3 125
f6eebcb3
C
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 }
7acee6f1 132
f6eebcb3
C
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 }
7acee6f1 146
f6eebcb3
C
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 }
7acee6f1 153
f6eebcb3 154 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
2ccaeeb3
C
155}
156
4157cdb1
C
157async function getOrCreateVideoAndAccountAndChannel (options: {
158 videoObject: VideoTorrentObject | string,
159 syncParam?: SyncParam,
74577825
C
160 fetchType?: VideoFetchByUrlType,
161 allowRefresh?: boolean // true by default
4157cdb1
C
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'
74577825 166 const allowRefresh = options.allowRefresh !== false
1297eb5d 167
4157cdb1 168 // Get video url
361805c4 169 const videoUrl = getAPUrl(options.videoObject)
1297eb5d 170
4157cdb1
C
171 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
172 if (videoFromDatabase) {
04b8c3fb 173
74577825
C
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', videoUrl: videoFromDatabase.url } })
183 }
1297eb5d 184
4157cdb1 185 return { video: videoFromDatabase }
1297eb5d
C
186 }
187
4157cdb1
C
188 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
189 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
7acee6f1 190
4157cdb1
C
191 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
192 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
7acee6f1 193
4157cdb1 194 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
7acee6f1 195
4157cdb1 196 return { video }
7acee6f1
C
197}
198
d4defe07 199async function updateVideoFromAP (options: {
1297eb5d
C
200 video: VideoModel,
201 videoObject: VideoTorrentObject,
c48e82b5
C
202 account: AccountModel,
203 channel: VideoChannelModel,
1297eb5d 204 overrideTo?: string[]
d4defe07
C
205}) {
206 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
1297eb5d
C
207 let videoFieldsSave: any
208
209 try {
d382f4e9 210 await sequelizeTypescript.transaction(async t => {
1297eb5d
C
211 const sequelizeOptions = {
212 transaction: t
213 }
2ccaeeb3 214
d4defe07 215 videoFieldsSave = options.video.toJSON()
2ccaeeb3 216
1297eb5d 217 // Check actor has the right to update the video
d4defe07
C
218 const videoChannel = options.video.VideoChannel
219 if (videoChannel.Account.id !== options.account.id) {
220 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
f6eebcb3
C
221 }
222
d4defe07
C
223 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
224 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
225 options.video.set('name', videoData.name)
226 options.video.set('uuid', videoData.uuid)
227 options.video.set('url', videoData.url)
228 options.video.set('category', videoData.category)
229 options.video.set('licence', videoData.licence)
230 options.video.set('language', videoData.language)
231 options.video.set('description', videoData.description)
232 options.video.set('support', videoData.support)
233 options.video.set('nsfw', videoData.nsfw)
234 options.video.set('commentsEnabled', videoData.commentsEnabled)
235 options.video.set('waitTranscoding', videoData.waitTranscoding)
236 options.video.set('state', videoData.state)
237 options.video.set('duration', videoData.duration)
238 options.video.set('createdAt', videoData.createdAt)
239 options.video.set('publishedAt', videoData.publishedAt)
240 options.video.set('privacy', videoData.privacy)
241 options.video.set('channelId', videoData.channelId)
04b8c3fb 242 options.video.set('views', videoData.views)
d4defe07 243
d4defe07 244 await options.video.save(sequelizeOptions)
1297eb5d 245
e5565833
C
246 {
247 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
248 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
0032ebe9 249
e5565833
C
250 // Remove video files that do not exist anymore
251 const destroyTasks = options.video.VideoFiles
252 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
253 .map(f => f.destroy(sequelizeOptions))
254 await Promise.all(destroyTasks)
2ccaeeb3 255
e5565833 256 // Update or add other one
d382f4e9
C
257 const upsertTasks = videoFileAttributes.map(a => {
258 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
259 .then(([ file ]) => file)
260 })
261
262 options.video.VideoFiles = await Promise.all(upsertTasks)
e5565833 263 }
2ccaeeb3 264
e5565833
C
265 {
266 // Update Tags
267 const tags = options.videoObject.tag.map(tag => tag.name)
268 const tagInstances = await TagModel.findOrCreateTags(tags, t)
269 await options.video.$set('Tags', tagInstances, sequelizeOptions)
270 }
2ccaeeb3 271
e5565833
C
272 {
273 // Update captions
274 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
275
276 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
277 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
278 })
d382f4e9 279 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
e5565833 280 }
1297eb5d
C
281 })
282
d4defe07 283 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
1297eb5d 284 } catch (err) {
d4defe07
C
285 if (options.video !== undefined && videoFieldsSave !== undefined) {
286 resetSequelizeInstance(options.video, videoFieldsSave)
1297eb5d
C
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 }
a8a63227
C
293
294 try {
295 await generateThumbnailFromUrl(options.video, options.videoObject.icon)
296 } catch (err) {
297 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
298 }
892211e8 299}
2186386c 300
04b8c3fb
C
301async function refreshVideoIfNeeded (options: {
302 video: VideoModel,
303 fetchedType: VideoFetchByUrlType,
304 syncParam: SyncParam
305}): Promise<VideoModel> {
306 if (!options.video.isOutdated()) return options.video
307
308 // We need more attributes if the argument video was fetched with not enough joints
309 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
310
311 try {
312 const { response, videoObject } = await fetchRemoteVideo(video.url)
313 if (response.statusCode === 404) {
314 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
315
316 // Video does not exist anymore
317 await video.destroy()
318 return undefined
319 }
320
321 if (videoObject === undefined) {
322 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
323
324 await video.setAsRefreshed()
325 return video
326 }
327
328 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
329 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
330
331 const updateOptions = {
332 video,
333 videoObject,
334 account,
335 channel: channelActor.VideoChannel
336 }
337 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
338 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
339
340 return video
341 } catch (err) {
342 logger.warn('Cannot refresh video %s.', options.video.url, { err })
343
344 // Don't refresh in loop
345 await video.setAsRefreshed()
346 return video
347 }
348}
349
2186386c 350export {
1297eb5d 351 updateVideoFromAP,
04b8c3fb 352 refreshVideoIfNeeded,
2186386c
C
353 federateVideoIfNeeded,
354 fetchRemoteVideo,
1297eb5d 355 getOrCreateVideoAndAccountAndChannel,
40e87e9e 356 fetchRemoteVideoStaticFile,
2186386c
C
357 fetchRemoteVideoDescription,
358 generateThumbnailFromUrl,
4157cdb1 359 getOrCreateVideoChannelFromVideoObject
2186386c 360}
c48e82b5
C
361
362// ---------------------------------------------------------------------------
363
364function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
365 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
366
e27ff5da
C
367 const urlMediaType = url.mediaType || url.mimeType
368 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
c48e82b5 369}
4157cdb1
C
370
371async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
372 logger.debug('Adding remote video %s.', videoObject.id)
373
374 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
375 const sequelizeOptions = { transaction: t }
376
377 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
378 const video = VideoModel.build(videoData)
379
380 const videoCreated = await video.save(sequelizeOptions)
381
382 // Process files
383 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
384 if (videoFileAttributes.length === 0) {
385 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
386 }
387
388 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
389 await Promise.all(videoFilePromises)
390
391 // Process tags
392 const tags = videoObject.tag.map(t => t.name)
393 const tagInstances = await TagModel.findOrCreateTags(tags, t)
394 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
395
396 // Process captions
397 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
398 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
399 })
400 await Promise.all(videoCaptionsPromises)
401
402 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
403
404 videoCreated.VideoChannel = channelActor.VideoChannel
405 return videoCreated
406 })
407
408 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
409 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
410
411 if (waitThumbnail === true) await p
412
413 return videoCreated
414}
415
4157cdb1
C
416async function videoActivityObjectToDBAttributes (
417 videoChannel: VideoChannelModel,
418 videoObject: VideoTorrentObject,
419 to: string[] = []
420) {
421 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
422 const duration = videoObject.duration.replace(/[^\d]+/, '')
423
424 let language: string | undefined
425 if (videoObject.language) {
426 language = videoObject.language.identifier
427 }
428
429 let category: number | undefined
430 if (videoObject.category) {
431 category = parseInt(videoObject.category.identifier, 10)
432 }
433
434 let licence: number | undefined
435 if (videoObject.licence) {
436 licence = parseInt(videoObject.licence.identifier, 10)
437 }
438
439 const description = videoObject.content || null
440 const support = videoObject.support || null
441
442 return {
443 name: videoObject.name,
444 uuid: videoObject.uuid,
445 url: videoObject.id,
446 category,
447 licence,
448 language,
449 description,
450 support,
451 nsfw: videoObject.sensitive,
452 commentsEnabled: videoObject.commentsEnabled,
453 waitTranscoding: videoObject.waitTranscoding,
454 state: videoObject.state,
455 channelId: videoChannel.id,
456 duration: parseInt(duration, 10),
457 createdAt: new Date(videoObject.published),
458 publishedAt: new Date(videoObject.published),
459 // FIXME: updatedAt does not seems to be considered by Sequelize
460 updatedAt: new Date(videoObject.updated),
461 views: videoObject.views,
462 likes: 0,
463 dislikes: 0,
464 remote: true,
465 privacy
466 }
467}
468
a3737cbf 469function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
4157cdb1
C
470 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
471
472 if (fileUrls.length === 0) {
a3737cbf 473 throw new Error('Cannot find video files for ' + video.url)
4157cdb1
C
474 }
475
476 const attributes: VideoFileModel[] = []
477 for (const fileUrl of fileUrls) {
478 // Fetch associated magnet uri
479 const magnet = videoObject.url.find(u => {
e27ff5da
C
480 const mediaType = u.mediaType || u.mimeType
481 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
4157cdb1
C
482 })
483
484 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
485
486 const parsed = magnetUtil.decode(magnet.href)
487 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
488 throw new Error('Cannot parse magnet URI ' + magnet.href)
489 }
490
e27ff5da 491 const mediaType = fileUrl.mediaType || fileUrl.mimeType
4157cdb1 492 const attribute = {
e27ff5da 493 extname: VIDEO_MIMETYPE_EXT[ mediaType ],
4157cdb1
C
494 infoHash: parsed.infoHash,
495 resolution: fileUrl.height,
496 size: fileUrl.size,
a3737cbf 497 videoId: video.id,
2e7cf5ae 498 fps: fileUrl.fps || -1
4157cdb1
C
499 } as VideoFileModel
500 attributes.push(attribute)
501 }
502
503 return attributes
504}