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