]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/lib/activitypub/videos.ts
Add originallyPublishedAt unit tests
[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 4import * as request from 'request'
09209296
C
5import {
6 ActivityIconObject,
7 ActivityPlaylistSegmentHashesObject,
8 ActivityPlaylistUrlObject,
9 ActivityUrlObject,
10 ActivityVideoUrlObject,
11 VideoState
12} from '../../../shared/index'
2ccaeeb3 13import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
1297eb5d 14import { VideoPrivacy } from '../../../shared/models/videos'
1d6e5dfc 15import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
2ccaeeb3 16import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
c48e82b5 17import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
2ccaeeb3 18import { logger } from '../../helpers/logger'
58d515e3 19import { doRequest, downloadImage } from '../../helpers/requests'
14e2014a 20import { ACTIVITY_PUB, CONFIG, MIMETYPES, REMOTE_SCHEME, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers'
2ccaeeb3
C
21import { ActorModel } from '../../models/activitypub/actor'
22import { TagModel } from '../../models/video/tag'
3fd3ab2d 23import { VideoModel } from '../../models/video/video'
2ccaeeb3
C
24import { VideoChannelModel } from '../../models/video/video-channel'
25import { VideoFileModel } from '../../models/video/video-file'
c48e82b5 26import { getOrCreateActorAndServerAndModel } from './actor'
7acee6f1 27import { addVideoComments } from './video-comments'
8fffe21a 28import { crawlCollectionPage } from './crawl'
2186386c 29import { sendCreateVideo, sendUpdateVideo } from './send'
40e87e9e
C
30import { isArray } from '../../helpers/custom-validators/misc'
31import { VideoCaptionModel } from '../../models/video/video-caption'
f6eebcb3
C
32import { JobQueue } from '../job-queue'
33import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub-http-fetcher'
1297eb5d
C
34import { createRates } from './video-rates'
35import { addVideoShares, shareVideoByServerAndChannel } from './share'
36import { AccountModel } from '../../models/account/account'
4157cdb1 37import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
848f499d 38import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
cef534ed 39import { Notifier } from '../notifier'
09209296
C
40import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
41import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
42import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
2186386c
C
43
44async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
45 // If the video is not private and published, we federate it
46 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
40e87e9e
C
47 // Fetch more attributes that we will need to serialize in AP object
48 if (isArray(video.VideoCaptions) === false) {
49 video.VideoCaptions = await video.$get('VideoCaptions', {
50 attributes: [ 'language' ],
51 transaction
52 }) as VideoCaptionModel[]
53 }
54
2cebd797 55 if (isNewVideo) {
2186386c
C
56 // Now we'll add the video's meta data to our followers
57 await sendCreateVideo(video, transaction)
58 await shareVideoByServerAndChannel(video, transaction)
59 } else {
60 await sendUpdateVideo(video, transaction)
61 }
62 }
63}
892211e8 64
4157cdb1
C
65async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
66 const options = {
67 uri: videoUrl,
68 method: 'GET',
69 json: true,
70 activityPub: true
71 }
892211e8 72
4157cdb1
C
73 logger.info('Fetching remote video %s.', videoUrl)
74
75 const { response, body } = await doRequest(options)
76
5c6d985f 77 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
4157cdb1
C
78 logger.debug('Remote video JSON is not valid.', { body })
79 return { response, videoObject: undefined }
80 }
81
82 return { response, videoObject: body }
892211e8
C
83}
84
3fd3ab2d 85async function fetchRemoteVideoDescription (video: VideoModel) {
50d6de9c 86 const host = video.VideoChannel.Account.Actor.Server.host
96f29c0f 87 const path = video.getDescriptionAPIPath()
892211e8
C
88 const options = {
89 uri: REMOTE_SCHEME.HTTP + '://' + host + path,
90 json: true
91 }
92
93 const { body } = await doRequest(options)
94 return body.description ? body.description : ''
95}
96
4157cdb1
C
97function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
98 const host = video.VideoChannel.Account.Actor.Server.host
99
100 // We need to provide a callback, if no we could have an uncaught exception
101 return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
102 if (err) reject(err)
103 })
104}
105
3fd3ab2d 106function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
892211e8 107 const thumbnailName = video.getThumbnailName()
892211e8 108
6040f87d 109 return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
892211e8
C
110}
111
f37dc0dd 112function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
0f320037
C
113 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
114 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
115
5c6d985f
C
116 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
117 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
118 }
119
e587e0ec 120 return getOrCreateActorAndServerAndModel(channel.id, 'all')
0f320037
C
121}
122
f6eebcb3 123type SyncParam = {
1297eb5d
C
124 likes: boolean
125 dislikes: boolean
126 shares: boolean
127 comments: boolean
f6eebcb3 128 thumbnail: boolean
04b8c3fb 129 refreshVideo?: boolean
f6eebcb3 130}
4157cdb1 131async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
f6eebcb3 132 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
2ccaeeb3 133
f6eebcb3 134 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
2ccaeeb3 135
f6eebcb3
C
136 if (syncParam.likes === true) {
137 await crawlCollectionPage<string>(fetchedVideo.likes, items => createRates(items, video, 'like'))
138 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
139 } else {
140 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
141 }
7acee6f1 142
f6eebcb3
C
143 if (syncParam.dislikes === true) {
144 await crawlCollectionPage<string>(fetchedVideo.dislikes, items => createRates(items, video, 'dislike'))
145 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
146 } else {
147 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
148 }
149
150 if (syncParam.shares === true) {
151 await crawlCollectionPage<string>(fetchedVideo.shares, items => addVideoShares(items, video))
152 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
153 } else {
154 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
155 }
7acee6f1 156
f6eebcb3
C
157 if (syncParam.comments === true) {
158 await crawlCollectionPage<string>(fetchedVideo.comments, items => addVideoComments(items, video))
159 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
160 } else {
161 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
162 }
7acee6f1 163
f6eebcb3 164 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
2ccaeeb3
C
165}
166
4157cdb1 167async function getOrCreateVideoAndAccountAndChannel (options: {
848f499d 168 videoObject: { id: string } | string,
4157cdb1 169 syncParam?: SyncParam,
d4defe07 170 fetchType?: VideoFetchByUrlType,
74577825 171 allowRefresh?: boolean // true by default
4157cdb1
C
172}) {
173 // Default params
174 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
175 const fetchType = options.fetchType || 'all'
74577825 176 const allowRefresh = options.allowRefresh !== false
1297eb5d 177
4157cdb1 178 // Get video url
848f499d 179 const videoUrl = getAPId(options.videoObject)
1297eb5d 180
4157cdb1
C
181 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
182 if (videoFromDatabase) {
04b8c3fb 183
74577825
C
184 if (allowRefresh === true) {
185 const refreshOptions = {
186 video: videoFromDatabase,
187 fetchedType: fetchType,
188 syncParam
189 }
190
191 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
744d0eca 192 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: videoFromDatabase.url } })
d4defe07 193 }
1297eb5d 194
cef534ed 195 return { video: videoFromDatabase, created: false }
1297eb5d
C
196 }
197
4157cdb1
C
198 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
199 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
7acee6f1 200
4157cdb1
C
201 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
202 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
7acee6f1 203
4157cdb1 204 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
7acee6f1 205
cef534ed 206 return { video, created: true }
7acee6f1
C
207}
208
d4defe07 209async function updateVideoFromAP (options: {
1297eb5d
C
210 video: VideoModel,
211 videoObject: VideoTorrentObject,
c48e82b5
C
212 account: AccountModel,
213 channel: VideoChannelModel,
1297eb5d 214 overrideTo?: string[]
d4defe07
C
215}) {
216 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
e8d246d5 217
1297eb5d 218 let videoFieldsSave: any
e8d246d5
C
219 const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
220 const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
1297eb5d
C
221
222 try {
d382f4e9 223 await sequelizeTypescript.transaction(async t => {
e8d246d5 224 const sequelizeOptions = { transaction: t }
2ccaeeb3 225
d4defe07 226 videoFieldsSave = options.video.toJSON()
2ccaeeb3 227
1297eb5d 228 // Check actor has the right to update the video
d4defe07
C
229 const videoChannel = options.video.VideoChannel
230 if (videoChannel.Account.id !== options.account.id) {
231 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
f6eebcb3
C
232 }
233
d4defe07
C
234 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
235 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
236 options.video.set('name', videoData.name)
237 options.video.set('uuid', videoData.uuid)
238 options.video.set('url', videoData.url)
239 options.video.set('category', videoData.category)
240 options.video.set('licence', videoData.licence)
241 options.video.set('language', videoData.language)
242 options.video.set('description', videoData.description)
243 options.video.set('support', videoData.support)
244 options.video.set('nsfw', videoData.nsfw)
245 options.video.set('commentsEnabled', videoData.commentsEnabled)
7f2cfe3a 246 options.video.set('downloadEnabled', videoData.downloadEnabled)
d4defe07
C
247 options.video.set('waitTranscoding', videoData.waitTranscoding)
248 options.video.set('state', videoData.state)
249 options.video.set('duration', videoData.duration)
250 options.video.set('createdAt', videoData.createdAt)
251 options.video.set('publishedAt', videoData.publishedAt)
7519127b 252 options.video.set('originallyPublishedAt', videoData.originallyPublishedAt)
d4defe07
C
253 options.video.set('privacy', videoData.privacy)
254 options.video.set('channelId', videoData.channelId)
04b8c3fb 255 options.video.set('views', videoData.views)
d4defe07 256
d4defe07 257 await options.video.save(sequelizeOptions)
1297eb5d 258
e5565833
C
259 {
260 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
261 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
0032ebe9 262
e5565833
C
263 // Remove video files that do not exist anymore
264 const destroyTasks = options.video.VideoFiles
265 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
266 .map(f => f.destroy(sequelizeOptions))
267 await Promise.all(destroyTasks)
2ccaeeb3 268
e5565833 269 // Update or add other one
d382f4e9
C
270 const upsertTasks = videoFileAttributes.map(a => {
271 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
272 .then(([ file ]) => file)
273 })
274
275 options.video.VideoFiles = await Promise.all(upsertTasks)
e5565833 276 }
2ccaeeb3 277
09209296
C
278 {
279 const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(options.video, options.videoObject)
280 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
281
282 // Remove video files that do not exist anymore
283 const destroyTasks = options.video.VideoStreamingPlaylists
284 .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
285 .map(f => f.destroy(sequelizeOptions))
286 await Promise.all(destroyTasks)
287
288 // Update or add other one
289 const upsertTasks = streamingPlaylistAttributes.map(a => {
290 return VideoStreamingPlaylistModel.upsert<VideoStreamingPlaylistModel>(a, { returning: true, transaction: t })
291 .then(([ streamingPlaylist ]) => streamingPlaylist)
292 })
293
294 options.video.VideoStreamingPlaylists = await Promise.all(upsertTasks)
295 }
296
e5565833
C
297 {
298 // Update Tags
299 const tags = options.videoObject.tag.map(tag => tag.name)
300 const tagInstances = await TagModel.findOrCreateTags(tags, t)
301 await options.video.$set('Tags', tagInstances, sequelizeOptions)
302 }
2ccaeeb3 303
e5565833
C
304 {
305 // Update captions
306 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
307
308 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
309 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
310 })
d382f4e9 311 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
e5565833 312 }
1297eb5d
C
313 })
314
e8d246d5
C
315 // Notify our users?
316 if (wasPrivateVideo || wasUnlistedVideo) {
317 Notifier.Instance.notifyOnNewVideo(options.video)
318 }
319
d4defe07 320 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
1297eb5d 321 } catch (err) {
d4defe07
C
322 if (options.video !== undefined && videoFieldsSave !== undefined) {
323 resetSequelizeInstance(options.video, videoFieldsSave)
1297eb5d
C
324 }
325
326 // This is just a debug because we will retry the insert
327 logger.debug('Cannot update the remote video.', { err })
328 throw err
329 }
a8a63227
C
330
331 try {
332 await generateThumbnailFromUrl(options.video, options.videoObject.icon)
333 } catch (err) {
334 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
335 }
892211e8 336}
2186386c 337
04b8c3fb
C
338async function refreshVideoIfNeeded (options: {
339 video: VideoModel,
340 fetchedType: VideoFetchByUrlType,
341 syncParam: SyncParam
342}): Promise<VideoModel> {
343 if (!options.video.isOutdated()) return options.video
344
345 // We need more attributes if the argument video was fetched with not enough joints
346 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
347
348 try {
349 const { response, videoObject } = await fetchRemoteVideo(video.url)
350 if (response.statusCode === 404) {
351 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
352
353 // Video does not exist anymore
354 await video.destroy()
355 return undefined
356 }
357
358 if (videoObject === undefined) {
359 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
360
361 await video.setAsRefreshed()
362 return video
363 }
364
365 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
366 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
367
368 const updateOptions = {
369 video,
370 videoObject,
371 account,
372 channel: channelActor.VideoChannel
373 }
374 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
375 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
376
377 return video
378 } catch (err) {
379 logger.warn('Cannot refresh video %s.', options.video.url, { err })
380
381 // Don't refresh in loop
382 await video.setAsRefreshed()
383 return video
384 }
892211e8 385}
2186386c
C
386
387export {
1297eb5d 388 updateVideoFromAP,
04b8c3fb 389 refreshVideoIfNeeded,
2186386c
C
390 federateVideoIfNeeded,
391 fetchRemoteVideo,
1297eb5d 392 getOrCreateVideoAndAccountAndChannel,
40e87e9e 393 fetchRemoteVideoStaticFile,
2186386c
C
394 fetchRemoteVideoDescription,
395 generateThumbnailFromUrl,
4157cdb1 396 getOrCreateVideoChannelFromVideoObject
2186386c 397}
c48e82b5
C
398
399// ---------------------------------------------------------------------------
400
09209296 401function isAPVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
14e2014a 402 const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
c48e82b5 403
e27ff5da
C
404 const urlMediaType = url.mediaType || url.mimeType
405 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
c48e82b5 406}
4157cdb1 407
09209296
C
408function isAPStreamingPlaylistUrlObject (url: ActivityUrlObject): url is ActivityPlaylistUrlObject {
409 const urlMediaType = url.mediaType || url.mimeType
410
411 return urlMediaType === 'application/x-mpegURL'
412}
413
414function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
415 const urlMediaType = tag.mediaType || tag.mimeType
416
417 return tag.name === 'sha256' && tag.type === 'Link' && urlMediaType === 'application/json'
c48e82b5 418}
4157cdb1
C
419
420async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
421 logger.debug('Adding remote video %s.', videoObject.id)
422
423 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
424 const sequelizeOptions = { transaction: t }
425
426 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
427 const video = VideoModel.build(videoData)
428
429 const videoCreated = await video.save(sequelizeOptions)
430
431 // Process files
432 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
433 if (videoFileAttributes.length === 0) {
434 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
435 }
436
437 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
438 await Promise.all(videoFilePromises)
439
09209296
C
440 const videoStreamingPlaylists = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject)
441 const playlistPromises = videoStreamingPlaylists.map(p => VideoStreamingPlaylistModel.create(p, { transaction: t }))
442 await Promise.all(playlistPromises)
443
4157cdb1 444 // Process tags
09209296
C
445 const tags = videoObject.tag
446 .filter(t => t.type === 'Hashtag')
447 .map(t => t.name)
4157cdb1
C
448 const tagInstances = await TagModel.findOrCreateTags(tags, t)
449 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
450
451 // Process captions
452 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
453 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
454 })
455 await Promise.all(videoCaptionsPromises)
456
457 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
458
459 videoCreated.VideoChannel = channelActor.VideoChannel
460 return videoCreated
461 })
462
463 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
464 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
465
466 if (waitThumbnail === true) await p
467
468 return videoCreated
469}
470
4157cdb1
C
471async function videoActivityObjectToDBAttributes (
472 videoChannel: VideoChannelModel,
473 videoObject: VideoTorrentObject,
474 to: string[] = []
475) {
476 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
477 const duration = videoObject.duration.replace(/[^\d]+/, '')
478
479 let language: string | undefined
480 if (videoObject.language) {
481 language = videoObject.language.identifier
482 }
483
484 let category: number | undefined
485 if (videoObject.category) {
486 category = parseInt(videoObject.category.identifier, 10)
487 }
488
489 let licence: number | undefined
490 if (videoObject.licence) {
491 licence = parseInt(videoObject.licence.identifier, 10)
492 }
493
494 const description = videoObject.content || null
495 const support = videoObject.support || null
496
497 return {
498 name: videoObject.name,
499 uuid: videoObject.uuid,
500 url: videoObject.id,
501 category,
502 licence,
503 language,
504 description,
505 support,
506 nsfw: videoObject.sensitive,
507 commentsEnabled: videoObject.commentsEnabled,
7f2cfe3a 508 downloadEnabled: videoObject.downloadEnabled,
4157cdb1
C
509 waitTranscoding: videoObject.waitTranscoding,
510 state: videoObject.state,
511 channelId: videoChannel.id,
512 duration: parseInt(duration, 10),
513 createdAt: new Date(videoObject.published),
514 publishedAt: new Date(videoObject.published),
7519127b 515 originallyPublishedAt: videoObject.originallyPublishedAt ? new Date(videoObject.originallyPublishedAt) : null,
4157cdb1
C
516 // FIXME: updatedAt does not seems to be considered by Sequelize
517 updatedAt: new Date(videoObject.updated),
518 views: videoObject.views,
519 likes: 0,
520 dislikes: 0,
521 remote: true,
522 privacy
523 }
524}
525
a3737cbf 526function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
09209296 527 const fileUrls = videoObject.url.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
4157cdb1
C
528
529 if (fileUrls.length === 0) {
a3737cbf 530 throw new Error('Cannot find video files for ' + video.url)
4157cdb1
C
531 }
532
09209296 533 const attributes: FilteredModelAttributes<VideoFileModel>[] = []
4157cdb1
C
534 for (const fileUrl of fileUrls) {
535 // Fetch associated magnet uri
536 const magnet = videoObject.url.find(u => {
e27ff5da
C
537 const mediaType = u.mediaType || u.mimeType
538 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
4157cdb1
C
539 })
540
541 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
542
543 const parsed = magnetUtil.decode(magnet.href)
544 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
545 throw new Error('Cannot parse magnet URI ' + magnet.href)
546 }
547
e27ff5da 548 const mediaType = fileUrl.mediaType || fileUrl.mimeType
4157cdb1 549 const attribute = {
14e2014a 550 extname: MIMETYPES.VIDEO.MIMETYPE_EXT[ mediaType ],
4157cdb1
C
551 infoHash: parsed.infoHash,
552 resolution: fileUrl.height,
553 size: fileUrl.size,
a3737cbf 554 videoId: video.id,
2e7cf5ae 555 fps: fileUrl.fps || -1
09209296
C
556 }
557
558 attributes.push(attribute)
559 }
560
561 return attributes
562}
563
564function streamingPlaylistActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
565 const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
566 if (playlistUrls.length === 0) return []
567
568 const attributes: FilteredModelAttributes<VideoStreamingPlaylistModel>[] = []
569 for (const playlistUrlObject of playlistUrls) {
570 const p2pMediaLoaderInfohashes = playlistUrlObject.tag
571 .filter(t => t.type === 'Infohash')
572 .map(t => t.name)
573 if (p2pMediaLoaderInfohashes.length === 0) {
574 logger.warn('No infohashes found in AP playlist object.', { playlistUrl: playlistUrlObject })
575 continue
576 }
577
578 const segmentsSha256UrlObject = playlistUrlObject.tag
579 .find(t => {
580 return isAPPlaylistSegmentHashesUrlObject(t)
581 }) as ActivityPlaylistSegmentHashesObject
582 if (!segmentsSha256UrlObject) {
583 logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
584 continue
585 }
586
587 const attribute = {
588 type: VideoStreamingPlaylistType.HLS,
589 playlistUrl: playlistUrlObject.href,
590 segmentsSha256Url: segmentsSha256UrlObject.href,
591 p2pMediaLoaderInfohashes,
592 videoId: video.id
593 }
594
4157cdb1
C
595 attributes.push(attribute)
596 }
597
598 return attributes
599}