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