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, 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, 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 createVideoMiniatureFromUrl(options.videoObject.icon.url, options.video, ThumbnailType.MINIATURE)
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) if (thumbnailModel) await options.video.addAndSaveThumbnail(thumbnailModel, t)
294 // FIXME: use icon URL instead
295 const previewUrl = buildRemoteBaseUrl(options.video, join(STATIC_PATHS.PREVIEWS, options.video.getPreview().filename))
296 const previewModel = createPlaceholderThumbnail(previewUrl, options.video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
297 await options.video.addAndSaveThumbnail(previewModel, t)
300 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
301 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
303 // Remove video files that do not exist anymore
304 const destroyTasks = options.video.VideoFiles
305 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
306 .map(f => f.destroy(sequelizeOptions))
307 await Promise.all(destroyTasks)
309 // Update or add other one
310 const upsertTasks = videoFileAttributes.map(a => {
311 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
312 .then(([ file ]) => file)
315 options.video.VideoFiles = await Promise.all(upsertTasks)
319 const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(
322 options.video.VideoFiles
324 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
326 // Remove video files that do not exist anymore
327 const destroyTasks = options.video.VideoStreamingPlaylists
328 .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
329 .map(f => f.destroy(sequelizeOptions))
330 await Promise.all(destroyTasks)
332 // Update or add other one
333 const upsertTasks = streamingPlaylistAttributes.map(a => {
334 return VideoStreamingPlaylistModel.upsert<VideoStreamingPlaylistModel>(a, { returning: true, transaction: t })
335 .then(([ streamingPlaylist ]) => streamingPlaylist)
338 options.video.VideoStreamingPlaylists = await Promise.all(upsertTasks)
343 const tags = options.videoObject.tag.map(tag => tag.name)
344 const tagInstances = await TagModel.findOrCreateTags(tags, t)
345 await options.video.$set('Tags', tagInstances, sequelizeOptions)
350 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
352 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
353 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
355 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
360 if (wasPrivateVideo || wasUnlistedVideo) {
361 Notifier.Instance.notifyOnNewVideo(options.video)
364 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
366 if (options.video !== undefined && videoFieldsSave !== undefined) {
367 resetSequelizeInstance(options.video, videoFieldsSave)
370 // This is just a debug because we will retry the insert
371 logger.debug('Cannot update the remote video.', { err })
376 async function refreshVideoIfNeeded (options: {
378 fetchedType: VideoFetchByUrlType,
380 }): Promise<VideoModel> {
381 if (!options.video.isOutdated()) return options.video
383 // We need more attributes if the argument video was fetched with not enough joints
384 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
387 const { response, videoObject } = await fetchRemoteVideo(video.url)
388 if (response.statusCode === 404) {
389 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
391 // Video does not exist anymore
392 await video.destroy()
396 if (videoObject === undefined) {
397 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
399 await video.setAsRefreshed()
403 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
404 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
406 const updateOptions = {
410 channel: channelActor.VideoChannel
412 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
413 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
417 logger.warn('Cannot refresh video %s.', options.video.url, { err })
419 // Don't refresh in loop
420 await video.setAsRefreshed()
427 refreshVideoIfNeeded,
428 federateVideoIfNeeded,
430 getOrCreateVideoAndAccountAndChannel,
431 fetchRemoteVideoStaticFile,
432 fetchRemoteVideoDescription,
433 getOrCreateVideoChannelFromVideoObject
436 // ---------------------------------------------------------------------------
438 function isAPVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
439 const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
441 const urlMediaType = url.mediaType || url.mimeType
442 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
445 function isAPStreamingPlaylistUrlObject (url: ActivityUrlObject): url is ActivityPlaylistUrlObject {
446 const urlMediaType = url.mediaType || url.mimeType
448 return urlMediaType === 'application/x-mpegURL'
451 function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
452 const urlMediaType = tag.mediaType || tag.mimeType
454 return tag.name === 'sha256' && tag.type === 'Link' && urlMediaType === 'application/json'
457 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
458 logger.debug('Adding remote video %s.', videoObject.id)
460 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
461 const video = VideoModel.build(videoData)
463 const promiseThumbnail = createVideoMiniatureFromUrl(videoObject.icon.url, video, ThumbnailType.MINIATURE)
465 let thumbnailModel: ThumbnailModel
466 if (waitThumbnail === true) {
467 thumbnailModel = await promiseThumbnail
470 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
471 const sequelizeOptions = { transaction: t }
473 const videoCreated = await video.save(sequelizeOptions)
474 videoCreated.VideoChannel = channelActor.VideoChannel
476 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
478 // FIXME: use icon URL instead
479 const previewUrl = buildRemoteBaseUrl(videoCreated, join(STATIC_PATHS.PREVIEWS, video.generatePreviewName()))
480 const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
481 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
484 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
485 if (videoFileAttributes.length === 0) {
486 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
489 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
490 const videoFiles = await Promise.all(videoFilePromises)
492 const videoStreamingPlaylists = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject, videoFiles)
493 const playlistPromises = videoStreamingPlaylists.map(p => VideoStreamingPlaylistModel.create(p, { transaction: t }))
494 await Promise.all(playlistPromises)
497 const tags = videoObject.tag
498 .filter(t => t.type === 'Hashtag')
500 const tagInstances = await TagModel.findOrCreateTags(tags, t)
501 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
504 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
505 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
507 await Promise.all(videoCaptionsPromises)
509 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
514 if (waitThumbnail === false) {
515 promiseThumbnail.then(thumbnailModel => {
516 thumbnailModel = videoCreated.id
518 return thumbnailModel.save()
525 async function videoActivityObjectToDBAttributes (
526 videoChannel: VideoChannelModel,
527 videoObject: VideoTorrentObject,
530 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
531 const duration = videoObject.duration.replace(/[^\d]+/, '')
533 let language: string | undefined
534 if (videoObject.language) {
535 language = videoObject.language.identifier
538 let category: number | undefined
539 if (videoObject.category) {
540 category = parseInt(videoObject.category.identifier, 10)
543 let licence: number | undefined
544 if (videoObject.licence) {
545 licence = parseInt(videoObject.licence.identifier, 10)
548 const description = videoObject.content || null
549 const support = videoObject.support || null
552 name: videoObject.name,
553 uuid: videoObject.uuid,
560 nsfw: videoObject.sensitive,
561 commentsEnabled: videoObject.commentsEnabled,
562 downloadEnabled: videoObject.downloadEnabled,
563 waitTranscoding: videoObject.waitTranscoding,
564 state: videoObject.state,
565 channelId: videoChannel.id,
566 duration: parseInt(duration, 10),
567 createdAt: new Date(videoObject.published),
568 publishedAt: new Date(videoObject.published),
569 originallyPublishedAt: videoObject.originallyPublishedAt ? new Date(videoObject.originallyPublishedAt) : null,
570 // FIXME: updatedAt does not seems to be considered by Sequelize
571 updatedAt: new Date(videoObject.updated),
572 views: videoObject.views,
580 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
581 const fileUrls = videoObject.url.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
583 if (fileUrls.length === 0) {
584 throw new Error('Cannot find video files for ' + video.url)
587 const attributes: FilteredModelAttributes<VideoFileModel>[] = []
588 for (const fileUrl of fileUrls) {
589 // Fetch associated magnet uri
590 const magnet = videoObject.url.find(u => {
591 const mediaType = u.mediaType || u.mimeType
592 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
595 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
597 const parsed = magnetUtil.decode(magnet.href)
598 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
599 throw new Error('Cannot parse magnet URI ' + magnet.href)
602 const mediaType = fileUrl.mediaType || fileUrl.mimeType
604 extname: MIMETYPES.VIDEO.MIMETYPE_EXT[ mediaType ],
605 infoHash: parsed.infoHash,
606 resolution: fileUrl.height,
609 fps: fileUrl.fps || -1
612 attributes.push(attribute)
618 function streamingPlaylistActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject, videoFiles: VideoFileModel[]) {
619 const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
620 if (playlistUrls.length === 0) return []
622 const attributes: FilteredModelAttributes<VideoStreamingPlaylistModel>[] = []
623 for (const playlistUrlObject of playlistUrls) {
624 const segmentsSha256UrlObject = playlistUrlObject.tag
626 return isAPPlaylistSegmentHashesUrlObject(t)
627 }) as ActivityPlaylistSegmentHashesObject
628 if (!segmentsSha256UrlObject) {
629 logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
634 type: VideoStreamingPlaylistType.HLS,
635 playlistUrl: playlistUrlObject.href,
636 segmentsSha256Url: segmentsSha256UrlObject.href,
637 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, videoFiles),
638 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
642 attributes.push(attribute)