]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/lib/activitypub/videos.ts
Fix checkbox margins
[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
C
97 const thumbnailName = video.getThumbnailName()
98 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
99
58d515e3 100 return downloadImage(icon.url, thumbnailPath, THUMBNAILS_SIZE)
892211e8
C
101}
102
f37dc0dd 103function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
0f320037
C
104 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
105 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
106
5c6d985f
C
107 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
108 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
109 }
110
e587e0ec 111 return getOrCreateActorAndServerAndModel(channel.id, 'all')
0f320037
C
112}
113
f6eebcb3 114type SyncParam = {
1297eb5d
C
115 likes: boolean
116 dislikes: boolean
117 shares: boolean
118 comments: boolean
f6eebcb3 119 thumbnail: boolean
1297eb5d 120 refreshVideo: boolean
f6eebcb3 121}
4157cdb1 122async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
f6eebcb3 123 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
2ccaeeb3 124
f6eebcb3 125 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
2ccaeeb3 126
f6eebcb3
C
127 if (syncParam.likes === true) {
128 await crawlCollectionPage<string>(fetchedVideo.likes, items => createRates(items, video, 'like'))
129 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
130 } else {
131 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
132 }
7acee6f1 133
f6eebcb3
C
134 if (syncParam.dislikes === true) {
135 await crawlCollectionPage<string>(fetchedVideo.dislikes, items => createRates(items, video, 'dislike'))
136 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
137 } else {
138 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
139 }
140
141 if (syncParam.shares === true) {
142 await crawlCollectionPage<string>(fetchedVideo.shares, items => addVideoShares(items, video))
143 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
144 } else {
145 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
146 }
7acee6f1 147
f6eebcb3
C
148 if (syncParam.comments === true) {
149 await crawlCollectionPage<string>(fetchedVideo.comments, items => addVideoComments(items, video))
150 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
151 } else {
152 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
153 }
7acee6f1 154
f6eebcb3 155 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
2ccaeeb3
C
156}
157
4157cdb1
C
158async function getOrCreateVideoAndAccountAndChannel (options: {
159 videoObject: VideoTorrentObject | string,
160 syncParam?: SyncParam,
d4defe07
C
161 fetchType?: VideoFetchByUrlType,
162 refreshViews?: boolean
4157cdb1
C
163}) {
164 // Default params
165 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
166 const fetchType = options.fetchType || 'all'
d4defe07 167 const refreshViews = options.refreshViews || false
1297eb5d 168
4157cdb1 169 // Get video url
361805c4 170 const videoUrl = getAPUrl(options.videoObject)
1297eb5d 171
4157cdb1
C
172 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
173 if (videoFromDatabase) {
d4defe07
C
174 const refreshOptions = {
175 video: videoFromDatabase,
176 fetchedType: fetchType,
177 syncParam,
178 refreshViews
179 }
e5565833 180 const p = refreshVideoIfNeeded(refreshOptions)
4157cdb1 181 if (syncParam.refreshVideo === true) videoFromDatabase = await p
1297eb5d 182
4157cdb1 183 return { video: videoFromDatabase }
1297eb5d
C
184 }
185
4157cdb1
C
186 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
187 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
7acee6f1 188
4157cdb1
C
189 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
190 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
7acee6f1 191
4157cdb1 192 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
7acee6f1 193
4157cdb1 194 return { video }
7acee6f1
C
195}
196
d4defe07 197async function updateVideoFromAP (options: {
1297eb5d
C
198 video: VideoModel,
199 videoObject: VideoTorrentObject,
c48e82b5
C
200 account: AccountModel,
201 channel: VideoChannelModel,
d4defe07 202 updateViews: boolean,
1297eb5d 203 overrideTo?: string[]
d4defe07
C
204}) {
205 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
1297eb5d
C
206 let videoFieldsSave: any
207
208 try {
d382f4e9 209 await sequelizeTypescript.transaction(async t => {
1297eb5d
C
210 const sequelizeOptions = {
211 transaction: t
212 }
2ccaeeb3 213
d4defe07 214 videoFieldsSave = options.video.toJSON()
2ccaeeb3 215
1297eb5d 216 // Check actor has the right to update the video
d4defe07
C
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)
f6eebcb3
C
220 }
221
d4defe07
C
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
242 if (options.updateViews === true) options.video.set('views', videoData.views)
243 await options.video.save(sequelizeOptions)
1297eb5d 244
e5565833
C
245 {
246 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
247 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
0032ebe9 248
e5565833
C
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)
2ccaeeb3 254
e5565833 255 // Update or add other one
d382f4e9
C
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)
e5565833 262 }
2ccaeeb3 263
e5565833
C
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 }
2ccaeeb3 270
e5565833
C
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 })
d382f4e9 278 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
e5565833 279 }
1297eb5d
C
280 })
281
d4defe07 282 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
1297eb5d 283 } catch (err) {
d4defe07
C
284 if (options.video !== undefined && videoFieldsSave !== undefined) {
285 resetSequelizeInstance(options.video, videoFieldsSave)
1297eb5d
C
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 }
a8a63227
C
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 }
892211e8 298}
2186386c
C
299
300export {
1297eb5d 301 updateVideoFromAP,
2186386c
C
302 federateVideoIfNeeded,
303 fetchRemoteVideo,
1297eb5d 304 getOrCreateVideoAndAccountAndChannel,
40e87e9e 305 fetchRemoteVideoStaticFile,
2186386c
C
306 fetchRemoteVideoDescription,
307 generateThumbnailFromUrl,
4157cdb1 308 getOrCreateVideoChannelFromVideoObject
2186386c 309}
c48e82b5
C
310
311// ---------------------------------------------------------------------------
312
313function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
314 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
315
e27ff5da
C
316 const urlMediaType = url.mediaType || url.mimeType
317 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
c48e82b5 318}
4157cdb1
C
319
320async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
321 logger.debug('Adding remote video %s.', videoObject.id)
322
323 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
324 const sequelizeOptions = { transaction: t }
325
326 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
327 const video = VideoModel.build(videoData)
328
329 const videoCreated = await video.save(sequelizeOptions)
330
331 // Process files
332 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
333 if (videoFileAttributes.length === 0) {
334 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
335 }
336
337 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
338 await Promise.all(videoFilePromises)
339
340 // Process tags
341 const tags = videoObject.tag.map(t => t.name)
342 const tagInstances = await TagModel.findOrCreateTags(tags, t)
343 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
344
345 // Process captions
346 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
347 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
348 })
349 await Promise.all(videoCaptionsPromises)
350
351 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
352
353 videoCreated.VideoChannel = channelActor.VideoChannel
354 return videoCreated
355 })
356
357 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
358 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
359
360 if (waitThumbnail === true) await p
361
362 return videoCreated
363}
364
d4defe07
C
365async function refreshVideoIfNeeded (options: {
366 video: VideoModel,
367 fetchedType: VideoFetchByUrlType,
368 syncParam: SyncParam,
369 refreshViews: boolean
370}): Promise<VideoModel> {
91411dba
C
371 if (!options.video.isOutdated()) return options.video
372
4157cdb1 373 // We need more attributes if the argument video was fetched with not enough joints
d4defe07 374 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
4157cdb1 375
4157cdb1
C
376 try {
377 const { response, videoObject } = await fetchRemoteVideo(video.url)
378 if (response.statusCode === 404) {
26649b42
C
379 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
380
4157cdb1
C
381 // Video does not exist anymore
382 await video.destroy()
383 return undefined
384 }
385
386 if (videoObject === undefined) {
26649b42 387 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
4157cdb1
C
388 return video
389 }
390
391 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
392 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
393
d4defe07
C
394 const updateOptions = {
395 video,
396 videoObject,
397 account,
398 channel: channelActor.VideoChannel,
399 updateViews: options.refreshViews
400 }
d382f4e9
C
401 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
402 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
792e5b8e
C
403
404 return video
4157cdb1 405 } catch (err) {
d382f4e9 406 logger.warn('Cannot refresh video %s.', options.video.url, { err })
4157cdb1
C
407 return video
408 }
409}
410
411async function videoActivityObjectToDBAttributes (
412 videoChannel: VideoChannelModel,
413 videoObject: VideoTorrentObject,
414 to: string[] = []
415) {
416 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
417 const duration = videoObject.duration.replace(/[^\d]+/, '')
418
419 let language: string | undefined
420 if (videoObject.language) {
421 language = videoObject.language.identifier
422 }
423
424 let category: number | undefined
425 if (videoObject.category) {
426 category = parseInt(videoObject.category.identifier, 10)
427 }
428
429 let licence: number | undefined
430 if (videoObject.licence) {
431 licence = parseInt(videoObject.licence.identifier, 10)
432 }
433
434 const description = videoObject.content || null
435 const support = videoObject.support || null
436
437 return {
438 name: videoObject.name,
439 uuid: videoObject.uuid,
440 url: videoObject.id,
441 category,
442 licence,
443 language,
444 description,
445 support,
446 nsfw: videoObject.sensitive,
447 commentsEnabled: videoObject.commentsEnabled,
448 waitTranscoding: videoObject.waitTranscoding,
449 state: videoObject.state,
450 channelId: videoChannel.id,
451 duration: parseInt(duration, 10),
452 createdAt: new Date(videoObject.published),
453 publishedAt: new Date(videoObject.published),
454 // FIXME: updatedAt does not seems to be considered by Sequelize
455 updatedAt: new Date(videoObject.updated),
456 views: videoObject.views,
457 likes: 0,
458 dislikes: 0,
459 remote: true,
460 privacy
461 }
462}
463
a3737cbf 464function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
4157cdb1
C
465 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
466
467 if (fileUrls.length === 0) {
a3737cbf 468 throw new Error('Cannot find video files for ' + video.url)
4157cdb1
C
469 }
470
471 const attributes: VideoFileModel[] = []
472 for (const fileUrl of fileUrls) {
473 // Fetch associated magnet uri
474 const magnet = videoObject.url.find(u => {
e27ff5da
C
475 const mediaType = u.mediaType || u.mimeType
476 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
4157cdb1
C
477 })
478
479 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
480
481 const parsed = magnetUtil.decode(magnet.href)
482 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
483 throw new Error('Cannot parse magnet URI ' + magnet.href)
484 }
485
e27ff5da 486 const mediaType = fileUrl.mediaType || fileUrl.mimeType
4157cdb1 487 const attribute = {
e27ff5da 488 extname: VIDEO_MIMETYPE_EXT[ mediaType ],
4157cdb1
C
489 infoHash: parsed.infoHash,
490 resolution: fileUrl.height,
491 size: fileUrl.size,
a3737cbf 492 videoId: video.id,
2e7cf5ae 493 fps: fileUrl.fps || -1
4157cdb1
C
494 } as VideoFileModel
495 attributes.push(attribute)
496 }
497
498 return attributes
499}