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