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,
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 { AccountVideoRateModel } from '../../models/account/account-video-rate'
49 import { VideoShareModel } from '../../models/video/video-share'
50 import { VideoCommentModel } from '../../models/video/video-comment'
51 import { sequelizeTypescript } from '../../initializers/database'
52 import { createPlaceholderThumbnail, createVideoThumbnailFromUrl } from '../thumbnail'
53 import { ThumbnailModel } from '../../models/video/thumbnail'
54 import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
55 import { join } from 'path'
57 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
58 // If the video is not private and is published, we federate it
59 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
60 // Fetch more attributes that we will need to serialize in AP object
61 if (isArray(video.VideoCaptions) === false) {
62 video.VideoCaptions = await video.$get('VideoCaptions', {
63 attributes: [ 'language' ],
65 }) as VideoCaptionModel[]
69 // Now we'll add the video's meta data to our followers
70 await sendCreateVideo(video, transaction)
71 await shareVideoByServerAndChannel(video, transaction)
73 await sendUpdateVideo(video, transaction)
78 async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
86 logger.info('Fetching remote video %s.', videoUrl)
88 const { response, body } = await doRequest(options)
90 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
91 logger.debug('Remote video JSON is not valid.', { body })
92 return { response, videoObject: undefined }
95 return { response, videoObject: body }
98 async function fetchRemoteVideoDescription (video: VideoModel) {
99 const host = video.VideoChannel.Account.Actor.Server.host
100 const path = video.getDescriptionAPIPath()
102 uri: REMOTE_SCHEME.HTTP + '://' + host + path,
106 const { body } = await doRequest(options)
107 return body.description ? body.description : ''
110 function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
111 const url = buildRemoteBaseUrl(video, path)
113 // We need to provide a callback, if no we could have an uncaught exception
114 return request.get(url, err => {
119 function buildRemoteBaseUrl (video: VideoModel, path: string) {
120 const host = video.VideoChannel.Account.Actor.Server.host
122 return REMOTE_SCHEME.HTTP + '://' + host + path
125 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
126 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
127 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
129 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
130 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
133 return getOrCreateActorAndServerAndModel(channel.id, 'all')
142 refreshVideo?: boolean
144 async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
145 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
147 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
149 if (syncParam.likes === true) {
150 const handler = items => createRates(items, video, 'like')
151 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'like' as 'like', crawlStartDate)
153 await crawlCollectionPage<string>(fetchedVideo.likes, handler, cleaner)
154 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
156 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
159 if (syncParam.dislikes === true) {
160 const handler = items => createRates(items, video, 'dislike')
161 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'dislike' as 'dislike', crawlStartDate)
163 await crawlCollectionPage<string>(fetchedVideo.dislikes, handler, cleaner)
164 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
166 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
169 if (syncParam.shares === true) {
170 const handler = items => addVideoShares(items, video)
171 const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate)
173 await crawlCollectionPage<string>(fetchedVideo.shares, handler, cleaner)
174 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
176 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
179 if (syncParam.comments === true) {
180 const handler = items => addVideoComments(items, video)
181 const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
183 await crawlCollectionPage<string>(fetchedVideo.comments, handler, cleaner)
184 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
186 jobPayloads.push({ uri: fetchedVideo.comments, videoId: video.id, type: 'video-comments' as 'video-comments' })
189 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
192 async function getOrCreateVideoAndAccountAndChannel (options: {
193 videoObject: { id: string } | string,
194 syncParam?: SyncParam,
195 fetchType?: VideoFetchByUrlType,
196 allowRefresh?: boolean // true by default
199 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
200 const fetchType = options.fetchType || 'all'
201 const allowRefresh = options.allowRefresh !== false
204 const videoUrl = getAPId(options.videoObject)
206 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
207 if (videoFromDatabase) {
208 if (videoFromDatabase.isOutdated() && allowRefresh === true) {
209 const refreshOptions = {
210 video: videoFromDatabase,
211 fetchedType: fetchType,
215 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
216 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: videoFromDatabase.url } })
219 return { video: videoFromDatabase, created: false }
222 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
223 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
225 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
226 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
228 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
230 return { video, created: true }
233 async function updateVideoFromAP (options: {
235 videoObject: VideoTorrentObject,
236 account: AccountModel,
237 channel: VideoChannelModel,
238 overrideTo?: string[]
240 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
242 let videoFieldsSave: any
243 const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
244 const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
247 let thumbnailModel: ThumbnailModel
250 thumbnailModel = await createVideoThumbnailFromUrl(options.videoObject.icon.url, options.video, ThumbnailType.THUMBNAIL)
252 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
255 await sequelizeTypescript.transaction(async t => {
256 const sequelizeOptions = { transaction: t }
258 videoFieldsSave = options.video.toJSON()
260 // Check actor has the right to update the video
261 const videoChannel = options.video.VideoChannel
262 if (videoChannel.Account.id !== options.account.id) {
263 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
266 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
267 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
268 options.video.set('name', videoData.name)
269 options.video.set('uuid', videoData.uuid)
270 options.video.set('url', videoData.url)
271 options.video.set('category', videoData.category)
272 options.video.set('licence', videoData.licence)
273 options.video.set('language', videoData.language)
274 options.video.set('description', videoData.description)
275 options.video.set('support', videoData.support)
276 options.video.set('nsfw', videoData.nsfw)
277 options.video.set('commentsEnabled', videoData.commentsEnabled)
278 options.video.set('downloadEnabled', videoData.downloadEnabled)
279 options.video.set('waitTranscoding', videoData.waitTranscoding)
280 options.video.set('state', videoData.state)
281 options.video.set('duration', videoData.duration)
282 options.video.set('createdAt', videoData.createdAt)
283 options.video.set('publishedAt', videoData.publishedAt)
284 options.video.set('originallyPublishedAt', videoData.originallyPublishedAt)
285 options.video.set('privacy', videoData.privacy)
286 options.video.set('channelId', videoData.channelId)
287 options.video.set('views', videoData.views)
289 await options.video.save(sequelizeOptions)
291 if (thumbnailModel) {
292 thumbnailModel.videoId = options.video.id
293 options.video.addThumbnail(await thumbnailModel.save({ transaction: t }))
296 // FIXME: use icon URL instead
297 const previewUrl = buildRemoteBaseUrl(options.video, join(STATIC_PATHS.PREVIEWS, options.video.getPreview().filename))
298 const previewModel = createPlaceholderThumbnail(previewUrl, options.video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
300 options.video.addThumbnail(await previewModel.save({ transaction: t }))
303 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
304 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
306 // Remove video files that do not exist anymore
307 const destroyTasks = options.video.VideoFiles
308 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
309 .map(f => f.destroy(sequelizeOptions))
310 await Promise.all(destroyTasks)
312 // Update or add other one
313 const upsertTasks = videoFileAttributes.map(a => {
314 return (VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t }) as any) // FIXME: sequelize typings
315 .then(([ file ]) => file)
318 options.video.VideoFiles = await Promise.all(upsertTasks)
322 const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(
325 options.video.VideoFiles
327 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
329 // Remove video files that do not exist anymore
330 const destroyTasks = options.video.VideoStreamingPlaylists
331 .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
332 .map(f => f.destroy(sequelizeOptions))
333 await Promise.all(destroyTasks)
335 // Update or add other one
336 const upsertTasks = streamingPlaylistAttributes.map(a => {
337 // FIXME: sequelize typings
338 return (VideoStreamingPlaylistModel.upsert<VideoStreamingPlaylistModel>(a, { returning: true, transaction: t }) as any)
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: object[] = [] // FIXME: add typings
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: object[] = [] // FIXME: add typings
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)