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