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'
6 ActivityPlaylistSegmentHashesObject,
7 ActivityPlaylistUrlObject,
9 ActivityVideoUrlObject, VideoCreate,
11 } from '../../../shared/index'
12 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
13 import { VideoPrivacy } from '../../../shared/models/videos'
14 import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
15 import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
16 import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
17 import { logger } from '../../helpers/logger'
18 import { doRequest } from '../../helpers/requests'
22 P2P_MEDIA_LOADER_PEER_VERSION,
26 } from '../../initializers/constants'
27 import { ActorModel } from '../../models/activitypub/actor'
28 import { TagModel } from '../../models/video/tag'
29 import { VideoModel } from '../../models/video/video'
30 import { VideoChannelModel } from '../../models/video/video-channel'
31 import { VideoFileModel } from '../../models/video/video-file'
32 import { getOrCreateActorAndServerAndModel } from './actor'
33 import { addVideoComments } from './video-comments'
34 import { crawlCollectionPage } from './crawl'
35 import { sendCreateVideo, sendUpdateVideo } from './send'
36 import { isArray } from '../../helpers/custom-validators/misc'
37 import { VideoCaptionModel } from '../../models/video/video-caption'
38 import { JobQueue } from '../job-queue'
39 import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub-http-fetcher'
40 import { createRates } from './video-rates'
41 import { addVideoShares, shareVideoByServerAndChannel } from './share'
42 import { AccountModel } from '../../models/account/account'
43 import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
44 import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
45 import { Notifier } from '../notifier'
46 import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
47 import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
48 import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
49 import { AccountVideoRateModel } from '../../models/account/account-video-rate'
50 import { VideoShareModel } from '../../models/video/video-share'
51 import { VideoCommentModel } from '../../models/video/video-comment'
52 import { sequelizeTypescript } from '../../initializers/database'
53 import { createPlaceholderThumbnail, createVideoThumbnailFromUrl } from '../thumbnail'
54 import { ThumbnailModel } from '../../models/video/thumbnail'
55 import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
56 import { join } from 'path'
58 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
59 // If the video is not private and is published, we federate it
60 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
61 // Fetch more attributes that we will need to serialize in AP object
62 if (isArray(video.VideoCaptions) === false) {
63 video.VideoCaptions = await video.$get('VideoCaptions', {
64 attributes: [ 'language' ],
66 }) as VideoCaptionModel[]
70 // Now we'll add the video's meta data to our followers
71 await sendCreateVideo(video, transaction)
72 await shareVideoByServerAndChannel(video, transaction)
74 await sendUpdateVideo(video, transaction)
79 async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
87 logger.info('Fetching remote video %s.', videoUrl)
89 const { response, body } = await doRequest(options)
91 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
92 logger.debug('Remote video JSON is not valid.', { body })
93 return { response, videoObject: undefined }
96 return { response, videoObject: body }
99 async function fetchRemoteVideoDescription (video: VideoModel) {
100 const host = video.VideoChannel.Account.Actor.Server.host
101 const path = video.getDescriptionAPIPath()
103 uri: REMOTE_SCHEME.HTTP + '://' + host + path,
107 const { body } = await doRequest(options)
108 return body.description ? body.description : ''
111 function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
112 const url = buildRemoteBaseUrl(video, path)
114 // We need to provide a callback, if no we could have an uncaught exception
115 return request.get(url, err => {
120 function buildRemoteBaseUrl (video: VideoModel, path: string) {
121 const host = video.VideoChannel.Account.Actor.Server.host
123 return REMOTE_SCHEME.HTTP + '://' + host + path
126 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
127 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
128 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
130 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
131 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
134 return getOrCreateActorAndServerAndModel(channel.id, 'all')
143 refreshVideo?: boolean
145 async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
146 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
148 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
150 if (syncParam.likes === true) {
151 const handler = items => createRates(items, video, 'like')
152 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'like' as 'like', crawlStartDate)
154 await crawlCollectionPage<string>(fetchedVideo.likes, handler, cleaner)
155 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
157 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
160 if (syncParam.dislikes === true) {
161 const handler = items => createRates(items, video, 'dislike')
162 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'dislike' as 'dislike', crawlStartDate)
164 await crawlCollectionPage<string>(fetchedVideo.dislikes, handler, cleaner)
165 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
167 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
170 if (syncParam.shares === true) {
171 const handler = items => addVideoShares(items, video)
172 const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate)
174 await crawlCollectionPage<string>(fetchedVideo.shares, handler, cleaner)
175 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
177 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
180 if (syncParam.comments === true) {
181 const handler = items => addVideoComments(items, video)
182 const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
184 await crawlCollectionPage<string>(fetchedVideo.comments, handler, cleaner)
185 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
187 jobPayloads.push({ uri: fetchedVideo.comments, videoId: video.id, type: 'video-comments' as 'video-comments' })
190 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
193 async function getOrCreateVideoAndAccountAndChannel (options: {
194 videoObject: { id: string } | string,
195 syncParam?: SyncParam,
196 fetchType?: VideoFetchByUrlType,
197 allowRefresh?: boolean // true by default
200 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
201 const fetchType = options.fetchType || 'all'
202 const allowRefresh = options.allowRefresh !== false
205 const videoUrl = getAPId(options.videoObject)
207 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
208 if (videoFromDatabase) {
209 if (videoFromDatabase.isOutdated() && allowRefresh === true) {
210 const refreshOptions = {
211 video: videoFromDatabase,
212 fetchedType: fetchType,
216 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
217 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: videoFromDatabase.url } })
220 return { video: videoFromDatabase, created: false }
223 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
224 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
226 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
227 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
229 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
231 return { video, created: true }
234 async function updateVideoFromAP (options: {
236 videoObject: VideoTorrentObject,
237 account: AccountModel,
238 channel: VideoChannelModel,
239 overrideTo?: string[]
241 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
243 let videoFieldsSave: any
244 const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
245 const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
248 let thumbnailModel: ThumbnailModel
251 thumbnailModel = await createVideoThumbnailFromUrl(options.videoObject.icon.url, options.video, ThumbnailType.THUMBNAIL)
253 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
256 await sequelizeTypescript.transaction(async t => {
257 const sequelizeOptions = { transaction: t }
259 videoFieldsSave = options.video.toJSON()
261 // Check actor has the right to update the video
262 const videoChannel = options.video.VideoChannel
263 if (videoChannel.Account.id !== options.account.id) {
264 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
267 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
268 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
269 options.video.set('name', videoData.name)
270 options.video.set('uuid', videoData.uuid)
271 options.video.set('url', videoData.url)
272 options.video.set('category', videoData.category)
273 options.video.set('licence', videoData.licence)
274 options.video.set('language', videoData.language)
275 options.video.set('description', videoData.description)
276 options.video.set('support', videoData.support)
277 options.video.set('nsfw', videoData.nsfw)
278 options.video.set('commentsEnabled', videoData.commentsEnabled)
279 options.video.set('downloadEnabled', videoData.downloadEnabled)
280 options.video.set('waitTranscoding', videoData.waitTranscoding)
281 options.video.set('state', videoData.state)
282 options.video.set('duration', videoData.duration)
283 options.video.set('createdAt', videoData.createdAt)
284 options.video.set('publishedAt', videoData.publishedAt)
285 options.video.set('originallyPublishedAt', videoData.originallyPublishedAt)
286 options.video.set('privacy', videoData.privacy)
287 options.video.set('channelId', videoData.channelId)
288 options.video.set('views', videoData.views)
290 await options.video.save(sequelizeOptions)
292 if (thumbnailModel) {
293 thumbnailModel.videoId = options.video.id
294 options.video.addThumbnail(await thumbnailModel.save({ transaction: t }))
297 // FIXME: use icon URL instead
298 const previewUrl = buildRemoteBaseUrl(options.video, join(STATIC_PATHS.PREVIEWS, options.video.getPreview().filename))
299 const previewModel = createPlaceholderThumbnail(previewUrl, options.video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
301 options.video.addThumbnail(await previewModel.save({ transaction: t }))
304 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
305 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
307 // Remove video files that do not exist anymore
308 const destroyTasks = options.video.VideoFiles
309 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
310 .map(f => f.destroy(sequelizeOptions))
311 await Promise.all(destroyTasks)
313 // Update or add other one
314 const upsertTasks = videoFileAttributes.map(a => {
315 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
316 .then(([ file ]) => file)
319 options.video.VideoFiles = await Promise.all(upsertTasks)
323 const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(
326 options.video.VideoFiles
328 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
330 // Remove video files that do not exist anymore
331 const destroyTasks = options.video.VideoStreamingPlaylists
332 .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
333 .map(f => f.destroy(sequelizeOptions))
334 await Promise.all(destroyTasks)
336 // Update or add other one
337 const upsertTasks = streamingPlaylistAttributes.map(a => {
338 return VideoStreamingPlaylistModel.upsert<VideoStreamingPlaylistModel>(a, { returning: true, transaction: t })
339 .then(([ streamingPlaylist ]) => streamingPlaylist)
342 options.video.VideoStreamingPlaylists = await Promise.all(upsertTasks)
347 const tags = options.videoObject.tag.map(tag => tag.name)
348 const tagInstances = await TagModel.findOrCreateTags(tags, t)
349 await options.video.$set('Tags', tagInstances, sequelizeOptions)
354 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
356 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
357 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
359 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
364 if (wasPrivateVideo || wasUnlistedVideo) {
365 Notifier.Instance.notifyOnNewVideo(options.video)
368 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
370 if (options.video !== undefined && videoFieldsSave !== undefined) {
371 resetSequelizeInstance(options.video, videoFieldsSave)
374 // This is just a debug because we will retry the insert
375 logger.debug('Cannot update the remote video.', { err })
380 async function refreshVideoIfNeeded (options: {
382 fetchedType: VideoFetchByUrlType,
384 }): Promise<VideoModel> {
385 if (!options.video.isOutdated()) return options.video
387 // We need more attributes if the argument video was fetched with not enough joints
388 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
391 const { response, videoObject } = await fetchRemoteVideo(video.url)
392 if (response.statusCode === 404) {
393 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
395 // Video does not exist anymore
396 await video.destroy()
400 if (videoObject === undefined) {
401 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
403 await video.setAsRefreshed()
407 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
408 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
410 const updateOptions = {
414 channel: channelActor.VideoChannel
416 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
417 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
421 logger.warn('Cannot refresh video %s.', options.video.url, { err })
423 // Don't refresh in loop
424 await video.setAsRefreshed()
431 refreshVideoIfNeeded,
432 federateVideoIfNeeded,
434 getOrCreateVideoAndAccountAndChannel,
435 fetchRemoteVideoStaticFile,
436 fetchRemoteVideoDescription,
437 getOrCreateVideoChannelFromVideoObject
440 // ---------------------------------------------------------------------------
442 function isAPVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
443 const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
445 const urlMediaType = url.mediaType || url.mimeType
446 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
449 function isAPStreamingPlaylistUrlObject (url: ActivityUrlObject): url is ActivityPlaylistUrlObject {
450 const urlMediaType = url.mediaType || url.mimeType
452 return urlMediaType === 'application/x-mpegURL'
455 function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
456 const urlMediaType = tag.mediaType || tag.mimeType
458 return tag.name === 'sha256' && tag.type === 'Link' && urlMediaType === 'application/json'
461 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
462 logger.debug('Adding remote video %s.', videoObject.id)
464 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
465 const video = VideoModel.build(videoData)
467 const promiseThumbnail = createVideoThumbnailFromUrl(videoObject.icon.url, video, ThumbnailType.THUMBNAIL)
469 let thumbnailModel: ThumbnailModel
470 if (waitThumbnail === true) {
471 thumbnailModel = await promiseThumbnail
474 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
475 const sequelizeOptions = { transaction: t }
477 const videoCreated = await video.save(sequelizeOptions)
478 videoCreated.VideoChannel = channelActor.VideoChannel
480 if (thumbnailModel) {
481 thumbnailModel.videoId = videoCreated.id
483 videoCreated.addThumbnail(await thumbnailModel.save({ transaction: t }))
486 // FIXME: use icon URL instead
487 const previewUrl = buildRemoteBaseUrl(videoCreated, join(STATIC_PATHS.PREVIEWS, video.generatePreviewName()))
488 const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
489 previewModel.videoId = videoCreated.id
491 videoCreated.addThumbnail(await previewModel.save({ transaction: t }))
494 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
495 if (videoFileAttributes.length === 0) {
496 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
499 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
500 const videoFiles = await Promise.all(videoFilePromises)
502 const videoStreamingPlaylists = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject, videoFiles)
503 const playlistPromises = videoStreamingPlaylists.map(p => VideoStreamingPlaylistModel.create(p, { transaction: t }))
504 await Promise.all(playlistPromises)
507 const tags = videoObject.tag
508 .filter(t => t.type === 'Hashtag')
510 const tagInstances = await TagModel.findOrCreateTags(tags, t)
511 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
514 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
515 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
517 await Promise.all(videoCaptionsPromises)
519 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
524 if (waitThumbnail === false) {
525 promiseThumbnail.then(thumbnailModel => {
526 thumbnailModel = videoCreated.id
528 return thumbnailModel.save()
535 async function videoActivityObjectToDBAttributes (
536 videoChannel: VideoChannelModel,
537 videoObject: VideoTorrentObject,
540 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
541 const duration = videoObject.duration.replace(/[^\d]+/, '')
543 let language: string | undefined
544 if (videoObject.language) {
545 language = videoObject.language.identifier
548 let category: number | undefined
549 if (videoObject.category) {
550 category = parseInt(videoObject.category.identifier, 10)
553 let licence: number | undefined
554 if (videoObject.licence) {
555 licence = parseInt(videoObject.licence.identifier, 10)
558 const description = videoObject.content || null
559 const support = videoObject.support || null
562 name: videoObject.name,
563 uuid: videoObject.uuid,
570 nsfw: videoObject.sensitive,
571 commentsEnabled: videoObject.commentsEnabled,
572 downloadEnabled: videoObject.downloadEnabled,
573 waitTranscoding: videoObject.waitTranscoding,
574 state: videoObject.state,
575 channelId: videoChannel.id,
576 duration: parseInt(duration, 10),
577 createdAt: new Date(videoObject.published),
578 publishedAt: new Date(videoObject.published),
579 originallyPublishedAt: videoObject.originallyPublishedAt ? new Date(videoObject.originallyPublishedAt) : null,
580 // FIXME: updatedAt does not seems to be considered by Sequelize
581 updatedAt: new Date(videoObject.updated),
582 views: videoObject.views,
590 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
591 const fileUrls = videoObject.url.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
593 if (fileUrls.length === 0) {
594 throw new Error('Cannot find video files for ' + video.url)
597 const attributes: FilteredModelAttributes<VideoFileModel>[] = []
598 for (const fileUrl of fileUrls) {
599 // Fetch associated magnet uri
600 const magnet = videoObject.url.find(u => {
601 const mediaType = u.mediaType || u.mimeType
602 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
605 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
607 const parsed = magnetUtil.decode(magnet.href)
608 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
609 throw new Error('Cannot parse magnet URI ' + magnet.href)
612 const mediaType = fileUrl.mediaType || fileUrl.mimeType
614 extname: MIMETYPES.VIDEO.MIMETYPE_EXT[ mediaType ],
615 infoHash: parsed.infoHash,
616 resolution: fileUrl.height,
619 fps: fileUrl.fps || -1
622 attributes.push(attribute)
628 function streamingPlaylistActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject, videoFiles: VideoFileModel[]) {
629 const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
630 if (playlistUrls.length === 0) return []
632 const attributes: FilteredModelAttributes<VideoStreamingPlaylistModel>[] = []
633 for (const playlistUrlObject of playlistUrls) {
634 const segmentsSha256UrlObject = playlistUrlObject.tag
636 return isAPPlaylistSegmentHashesUrlObject(t)
637 }) as ActivityPlaylistSegmentHashesObject
638 if (!segmentsSha256UrlObject) {
639 logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
644 type: VideoStreamingPlaylistType.HLS,
645 playlistUrl: playlistUrlObject.href,
646 segmentsSha256Url: segmentsSha256UrlObject.href,
647 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, videoFiles),
648 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
652 attributes.push(attribute)