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, doRequestAndSaveToFile } 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, createVideoMiniatureFromUrl } from '../thumbnail'
53 import { ThumbnailModel } from '../../models/video/thumbnail'
54 import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
55 import { join } from 'path'
56 import { FilteredModelAttributes } from '../../typings/sequelize'
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, destPath: string) {
112 const url = buildRemoteBaseUrl(video, path)
114 // We need to provide a callback, if no we could have an uncaught exception
115 return doRequestAndSaveToFile({ uri: url }, destPath)
118 function buildRemoteBaseUrl (video: VideoModel, path: string) {
119 const host = video.VideoChannel.Account.Actor.Server.host
121 return REMOTE_SCHEME.HTTP + '://' + host + path
124 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
125 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
126 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
128 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
129 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
132 return getOrCreateActorAndServerAndModel(channel.id, 'all')
141 refreshVideo?: boolean
143 async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
144 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
146 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
148 if (syncParam.likes === true) {
149 const handler = items => createRates(items, video, 'like')
150 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'like' as 'like', crawlStartDate)
152 await crawlCollectionPage<string>(fetchedVideo.likes, handler, cleaner)
153 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
155 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
158 if (syncParam.dislikes === true) {
159 const handler = items => createRates(items, video, 'dislike')
160 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'dislike' as 'dislike', crawlStartDate)
162 await crawlCollectionPage<string>(fetchedVideo.dislikes, handler, cleaner)
163 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
165 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
168 if (syncParam.shares === true) {
169 const handler = items => addVideoShares(items, video)
170 const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate)
172 await crawlCollectionPage<string>(fetchedVideo.shares, handler, cleaner)
173 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
175 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
178 if (syncParam.comments === true) {
179 const handler = items => addVideoComments(items, video)
180 const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
182 await crawlCollectionPage<string>(fetchedVideo.comments, handler, cleaner)
183 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
185 jobPayloads.push({ uri: fetchedVideo.comments, videoId: video.id, type: 'video-comments' as 'video-comments' })
188 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
191 async function getOrCreateVideoAndAccountAndChannel (options: {
192 videoObject: { id: string } | string,
193 syncParam?: SyncParam,
194 fetchType?: VideoFetchByUrlType,
195 allowRefresh?: boolean // true by default
198 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
199 const fetchType = options.fetchType || 'all'
200 const allowRefresh = options.allowRefresh !== false
203 const videoUrl = getAPId(options.videoObject)
205 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
206 if (videoFromDatabase) {
207 if (videoFromDatabase.isOutdated() && allowRefresh === true) {
208 const refreshOptions = {
209 video: videoFromDatabase,
210 fetchedType: fetchType,
214 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
215 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: videoFromDatabase.url } })
218 return { video: videoFromDatabase, created: false }
221 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
222 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
224 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
225 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
227 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
229 return { video, created: true }
232 async function updateVideoFromAP (options: {
234 videoObject: VideoTorrentObject,
235 account: AccountModel,
236 channel: VideoChannelModel,
237 overrideTo?: string[]
239 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
241 let videoFieldsSave: any
242 const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
243 const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
246 let thumbnailModel: ThumbnailModel
249 thumbnailModel = await createVideoMiniatureFromUrl(options.videoObject.icon.url, options.video, ThumbnailType.MINIATURE)
251 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
254 await sequelizeTypescript.transaction(async t => {
255 const sequelizeOptions = { transaction: t }
257 videoFieldsSave = options.video.toJSON()
259 // Check actor has the right to update the video
260 const videoChannel = options.video.VideoChannel
261 if (videoChannel.Account.id !== options.account.id) {
262 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
265 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
266 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
267 options.video.set('name', videoData.name)
268 options.video.set('uuid', videoData.uuid)
269 options.video.set('url', videoData.url)
270 options.video.set('category', videoData.category)
271 options.video.set('licence', videoData.licence)
272 options.video.set('language', videoData.language)
273 options.video.set('description', videoData.description)
274 options.video.set('support', videoData.support)
275 options.video.set('nsfw', videoData.nsfw)
276 options.video.set('commentsEnabled', videoData.commentsEnabled)
277 options.video.set('downloadEnabled', videoData.downloadEnabled)
278 options.video.set('waitTranscoding', videoData.waitTranscoding)
279 options.video.set('state', videoData.state)
280 options.video.set('duration', videoData.duration)
281 options.video.set('createdAt', videoData.createdAt)
282 options.video.set('publishedAt', videoData.publishedAt)
283 options.video.set('originallyPublishedAt', videoData.originallyPublishedAt)
284 options.video.set('privacy', videoData.privacy)
285 options.video.set('channelId', videoData.channelId)
286 options.video.set('views', videoData.views)
288 await options.video.save(sequelizeOptions)
290 if (thumbnailModel) if (thumbnailModel) await options.video.addAndSaveThumbnail(thumbnailModel, t)
292 // FIXME: use icon URL instead
293 const previewUrl = buildRemoteBaseUrl(options.video, join(STATIC_PATHS.PREVIEWS, options.video.getPreview().filename))
294 const previewModel = createPlaceholderThumbnail(previewUrl, options.video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
295 await options.video.addAndSaveThumbnail(previewModel, t)
298 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
299 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
301 // Remove video files that do not exist anymore
302 const destroyTasks = options.video.VideoFiles
303 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
304 .map(f => f.destroy(sequelizeOptions))
305 await Promise.all(destroyTasks)
307 // Update or add other one
308 const upsertTasks = videoFileAttributes.map(a => {
309 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
310 .then(([ file ]) => file)
313 options.video.VideoFiles = await Promise.all(upsertTasks)
317 const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(
320 options.video.VideoFiles
322 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
324 // Remove video files that do not exist anymore
325 const destroyTasks = options.video.VideoStreamingPlaylists
326 .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
327 .map(f => f.destroy(sequelizeOptions))
328 await Promise.all(destroyTasks)
330 // Update or add other one
331 const upsertTasks = streamingPlaylistAttributes.map(a => {
332 return VideoStreamingPlaylistModel.upsert<VideoStreamingPlaylistModel>(a, { returning: true, transaction: t })
333 .then(([ streamingPlaylist ]) => streamingPlaylist)
336 options.video.VideoStreamingPlaylists = await Promise.all(upsertTasks)
341 const tags = options.videoObject.tag.map(tag => tag.name)
342 const tagInstances = await TagModel.findOrCreateTags(tags, t)
343 await options.video.$set('Tags', tagInstances, sequelizeOptions)
348 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
350 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
351 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
353 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
358 if (wasPrivateVideo || wasUnlistedVideo) {
359 Notifier.Instance.notifyOnNewVideo(options.video)
362 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
364 if (options.video !== undefined && videoFieldsSave !== undefined) {
365 resetSequelizeInstance(options.video, videoFieldsSave)
368 // This is just a debug because we will retry the insert
369 logger.debug('Cannot update the remote video.', { err })
374 async function refreshVideoIfNeeded (options: {
376 fetchedType: VideoFetchByUrlType,
378 }): Promise<VideoModel> {
379 if (!options.video.isOutdated()) return options.video
381 // We need more attributes if the argument video was fetched with not enough joints
382 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
385 const { response, videoObject } = await fetchRemoteVideo(video.url)
386 if (response.statusCode === 404) {
387 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
389 // Video does not exist anymore
390 await video.destroy()
394 if (videoObject === undefined) {
395 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
397 await video.setAsRefreshed()
401 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
402 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
404 const updateOptions = {
408 channel: channelActor.VideoChannel
410 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
411 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
415 logger.warn('Cannot refresh video %s.', options.video.url, { err })
417 // Don't refresh in loop
418 await video.setAsRefreshed()
425 refreshVideoIfNeeded,
426 federateVideoIfNeeded,
428 getOrCreateVideoAndAccountAndChannel,
429 fetchRemoteVideoStaticFile,
430 fetchRemoteVideoDescription,
431 getOrCreateVideoChannelFromVideoObject
434 // ---------------------------------------------------------------------------
436 function isAPVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
437 const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
439 const urlMediaType = url.mediaType || url.mimeType
440 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
443 function isAPStreamingPlaylistUrlObject (url: ActivityUrlObject): url is ActivityPlaylistUrlObject {
444 const urlMediaType = url.mediaType || url.mimeType
446 return urlMediaType === 'application/x-mpegURL'
449 function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
450 const urlMediaType = tag.mediaType || tag.mimeType
452 return tag.name === 'sha256' && tag.type === 'Link' && urlMediaType === 'application/json'
455 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
456 logger.debug('Adding remote video %s.', videoObject.id)
458 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
459 const video = VideoModel.build(videoData)
461 const promiseThumbnail = createVideoMiniatureFromUrl(videoObject.icon.url, video, ThumbnailType.MINIATURE)
463 let thumbnailModel: ThumbnailModel
464 if (waitThumbnail === true) {
465 thumbnailModel = await promiseThumbnail
468 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
469 const sequelizeOptions = { transaction: t }
471 const videoCreated = await video.save(sequelizeOptions)
472 videoCreated.VideoChannel = channelActor.VideoChannel
474 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
476 // FIXME: use icon URL instead
477 const previewUrl = buildRemoteBaseUrl(videoCreated, join(STATIC_PATHS.PREVIEWS, video.generatePreviewName()))
478 const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
479 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
482 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
483 if (videoFileAttributes.length === 0) {
484 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
487 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
488 const videoFiles = await Promise.all(videoFilePromises)
490 const videoStreamingPlaylists = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject, videoFiles)
491 const playlistPromises = videoStreamingPlaylists.map(p => VideoStreamingPlaylistModel.create(p, { transaction: t }))
492 await Promise.all(playlistPromises)
495 const tags = videoObject.tag
496 .filter(t => t.type === 'Hashtag')
498 const tagInstances = await TagModel.findOrCreateTags(tags, t)
499 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
502 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
503 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
505 await Promise.all(videoCaptionsPromises)
507 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
512 if (waitThumbnail === false) {
513 promiseThumbnail.then(thumbnailModel => {
514 thumbnailModel = videoCreated.id
516 return thumbnailModel.save()
523 async function videoActivityObjectToDBAttributes (
524 videoChannel: VideoChannelModel,
525 videoObject: VideoTorrentObject,
528 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
529 const duration = videoObject.duration.replace(/[^\d]+/, '')
531 let language: string | undefined
532 if (videoObject.language) {
533 language = videoObject.language.identifier
536 let category: number | undefined
537 if (videoObject.category) {
538 category = parseInt(videoObject.category.identifier, 10)
541 let licence: number | undefined
542 if (videoObject.licence) {
543 licence = parseInt(videoObject.licence.identifier, 10)
546 const description = videoObject.content || null
547 const support = videoObject.support || null
550 name: videoObject.name,
551 uuid: videoObject.uuid,
558 nsfw: videoObject.sensitive,
559 commentsEnabled: videoObject.commentsEnabled,
560 downloadEnabled: videoObject.downloadEnabled,
561 waitTranscoding: videoObject.waitTranscoding,
562 state: videoObject.state,
563 channelId: videoChannel.id,
564 duration: parseInt(duration, 10),
565 createdAt: new Date(videoObject.published),
566 publishedAt: new Date(videoObject.published),
567 originallyPublishedAt: videoObject.originallyPublishedAt ? new Date(videoObject.originallyPublishedAt) : null,
568 // FIXME: updatedAt does not seems to be considered by Sequelize
569 updatedAt: new Date(videoObject.updated),
570 views: videoObject.views,
578 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
579 const fileUrls = videoObject.url.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
581 if (fileUrls.length === 0) {
582 throw new Error('Cannot find video files for ' + video.url)
585 const attributes: FilteredModelAttributes<VideoFileModel>[] = []
586 for (const fileUrl of fileUrls) {
587 // Fetch associated magnet uri
588 const magnet = videoObject.url.find(u => {
589 const mediaType = u.mediaType || u.mimeType
590 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
593 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
595 const parsed = magnetUtil.decode(magnet.href)
596 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
597 throw new Error('Cannot parse magnet URI ' + magnet.href)
600 const mediaType = fileUrl.mediaType || fileUrl.mimeType
602 extname: MIMETYPES.VIDEO.MIMETYPE_EXT[ mediaType ],
603 infoHash: parsed.infoHash,
604 resolution: fileUrl.height,
607 fps: fileUrl.fps || -1
610 attributes.push(attribute)
616 function streamingPlaylistActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject, videoFiles: VideoFileModel[]) {
617 const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
618 if (playlistUrls.length === 0) return []
620 const attributes: FilteredModelAttributes<VideoStreamingPlaylistModel>[] = []
621 for (const playlistUrlObject of playlistUrls) {
622 const segmentsSha256UrlObject = playlistUrlObject.tag
624 return isAPPlaylistSegmentHashesUrlObject(t)
625 }) as ActivityPlaylistSegmentHashesObject
626 if (!segmentsSha256UrlObject) {
627 logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
632 type: VideoStreamingPlaylistType.HLS,
633 playlistUrl: playlistUrlObject.href,
634 segmentsSha256Url: segmentsSha256UrlObject.href,
635 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, videoFiles),
636 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
640 attributes.push(attribute)