UNPKG

rx-player

Version:
368 lines (341 loc) 12.7 kB
/** * Copyright 2015 CANAL+ Group * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import log from "../../../log"; import type { IManifest } from "../../../manifest"; import { SUPPORTED_ADAPTATIONS_TYPE } from "../../../manifest"; import { StaticRepresentationIndex } from "../../../manifest/classes"; import type { ITrackType } from "../../../public_types"; import idGenerator from "../../../utils/id_generator"; import isNullOrUndefined from "../../../utils/is_null_or_undefined"; import getMonotonicTimeStamp from "../../../utils/monotonic_timestamp"; import { getFilenameIndexInUrl } from "../../../utils/url-utils"; import type { IParsedAdaptation, IParsedAdaptations, IParsedManifest, IParsedPeriod, IParsedRepresentation, } from "../types"; import MetaRepresentationIndex from "./representation_index"; export type IParserResponse<T> = | { type: "needs-manifest-loader"; value: { ressources: Array<{ url: string; transportType: string }>; continue: (loadedRessources: IManifest[]) => IParserResponse<T>; }; } | { type: "done"; value: T }; export interface IMetaPlaylistTextTrack { url: string; language: string; closedCaption: boolean; mimeType: string; codecs?: string; } export interface IMetaPlaylist { type: "MPL"; // Obligatory token version: string; // MAJOR.MINOR dynamic?: boolean; // The MetaPlaylist could need to be updated pollInterval?: number; // Refresh interval in seconds contents: Array<{ // Sub-Manifests url: string; // URL of the Manifest startTime: number; // start timestamp in seconds endTime: number; // end timestamp in seconds transport: string; // "dash" | "smooth" | "metaplaylist" textTracks?: IMetaPlaylistTextTrack[]; }>; } /** * Parse playlist string to JSON. * Returns an array of contents. * @param {string} data * @param {Object} parserOptions * @returns {Object} */ export default function parseMetaPlaylist( data: unknown, parserOptions: { url?: string | undefined; serverSyncInfos?: | { serverTimestamp: number; clientTime: number; } | undefined; }, ): IParserResponse<IParsedManifest> { let parsedData; if (typeof data === "object" && data !== null) { parsedData = data; } else if (typeof data === "string") { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment parsedData = JSON.parse(data); } catch (_error) { throw new Error("MPL Parser: Bad MetaPlaylist file. Expected JSON."); } } else { throw new Error( "MPL Parser: Parser input must be either a string " + "or the MetaPlaylist data directly.", ); } const { contents, version, type } = parsedData as IMetaPlaylist; if (type !== "MPL") { throw new Error( "MPL Parser: Bad MetaPlaylist. " + "The `type` property is not set to `MPL`", ); } if (version !== "0.1") { throw new Error("MPL Parser: Bad MetaPlaylist version"); } // quick checks if (isNullOrUndefined(contents) || contents.length === 0) { throw new Error("MPL Parser: No content found."); } const ressources: Array<{ url: string; transportType: string }> = []; for (const content of contents) { if ( isNullOrUndefined(content.url) || isNullOrUndefined(content.startTime) || isNullOrUndefined(content.endTime) || isNullOrUndefined(content.transport) ) { throw new Error("MPL Parser: Malformed content."); } ressources.push({ url: content.url, transportType: content.transport }); } const metaPlaylist: IMetaPlaylist = parsedData as IMetaPlaylist; return { type: "needs-manifest-loader", value: { ressources, continue: function parseWholeMPL(loadedRessources: IManifest[]) { const parsedManifest = createManifest( metaPlaylist, loadedRessources, parserOptions, ); return { type: "done", value: parsedManifest }; }, }, }; } /** * From several parsed manifests, generate a single bigger manifest. * Each content presents a start and end time, so that periods * boudaries could be adapted. * @param {Object} mplData * @param {Array<Object>} manifests * @param {Object} parserOptions * @returns {Object} */ function createManifest( mplData: IMetaPlaylist, manifests: IManifest[], parserOptions: { url?: string | undefined; serverSyncInfos?: { serverTimestamp: number; clientTime: number } | undefined; }, ): IParsedManifest { const { url, serverSyncInfos } = parserOptions; const clockOffset = serverSyncInfos !== undefined ? serverSyncInfos.serverTimestamp - serverSyncInfos.clientTime : undefined; const generateAdaptationID = idGenerator(); const generateRepresentationID = idGenerator(); const { contents } = mplData; const minimumTime = contents.length > 0 ? contents[0].startTime : 0; const maximumTime = contents.length > 0 ? contents[contents.length - 1].endTime : 0; const isDynamic = mplData.dynamic === true; let firstStart: number | null = null; let lastEnd: number | null = null; const periods: IParsedPeriod[] = []; for (let iMan = 0; iMan < contents.length; iMan++) { const content = contents[iMan]; firstStart = firstStart !== null ? Math.min(firstStart, content.startTime) : content.startTime; lastEnd = lastEnd !== null ? Math.max(lastEnd, content.endTime) : content.endTime; const currentManifest = manifests[iMan]; if (currentManifest.periods.length <= 0) { continue; } const contentOffset = content.startTime - currentManifest.periods[0].start; const contentEnd = content.endTime; const manifestPeriods = []; for (let iPer = 0; iPer < currentManifest.periods.length; iPer++) { const currentPeriod = currentManifest.periods[iPer]; const adaptations = SUPPORTED_ADAPTATIONS_TYPE.reduce<IParsedAdaptations>( (acc, type: ITrackType) => { const currentAdaptations = currentPeriod.adaptations[type]; if (isNullOrUndefined(currentAdaptations)) { return acc; } const adaptationsForCurrentType: IParsedAdaptation[] = []; for (let iAda = 0; iAda < currentAdaptations.length; iAda++) { const currentAdaptation = currentAdaptations[iAda]; const representations: IParsedRepresentation[] = []; for (let iRep = 0; iRep < currentAdaptation.representations.length; iRep++) { const currentRepresentation = currentAdaptation.representations[iRep]; const baseContentMetadata = { isLive: currentManifest.isLive, manifestPublishTime: currentManifest.publishTime, periodStart: currentPeriod.start, periodEnd: currentPeriod.end, }; const newIndex = new MetaRepresentationIndex( currentRepresentation.index, [contentOffset, contentEnd], content.transport, baseContentMetadata, ); let supplementalCodecs: string | undefined; if (currentRepresentation.codecs.length > 1) { if (currentRepresentation.codecs.length > 2) { log.warn( "metaplaylist", "MetaPlaylist relying on more than 2 groups of " + "codecs with retro-compatibility", ); } supplementalCodecs = currentRepresentation.codecs[0]; } const codecs = currentRepresentation.codecs[currentRepresentation.codecs.length - 1]; representations.push({ bitrate: currentRepresentation.bitrate, index: newIndex, cdnMetadata: currentRepresentation.cdnMetadata, id: currentRepresentation.id, height: currentRepresentation.height, width: currentRepresentation.width, mimeType: currentRepresentation.mimeType, frameRate: currentRepresentation.frameRate, codecs, supplementalCodecs, isSpatialAudio: currentRepresentation.isSpatialAudio, contentProtections: currentRepresentation.contentProtections, }); } adaptationsForCurrentType.push({ id: currentAdaptation.id, representations, type: currentAdaptation.type, audioDescription: currentAdaptation.isAudioDescription, closedCaption: currentAdaptation.isClosedCaption, isDub: currentAdaptation.isDub, language: currentAdaptation.language, isSignInterpreted: currentAdaptation.isSignInterpreted, }); acc[type] = adaptationsForCurrentType; } return acc; }, {}, ); // TODO only first period? const textTracks: IMetaPlaylistTextTrack[] = content.textTracks === undefined ? [] : content.textTracks; const newTextAdaptations: IParsedAdaptation[] = textTracks.map((track) => { const adaptationID = "gen-text-ada-" + generateAdaptationID(); const representationID = "gen-text-rep-" + generateRepresentationID(); const indexOfFilename = getFilenameIndexInUrl(track.url); const cdnUrl = track.url.substring(0, indexOfFilename); const filename = track.url.substring(indexOfFilename); return { id: adaptationID, type: "text", language: track.language, closedCaption: track.closedCaption, manuallyAdded: true, representations: [ { bitrate: 0, cdnMetadata: [{ baseUrl: cdnUrl }], id: representationID, mimeType: track.mimeType, codecs: track.codecs, index: new StaticRepresentationIndex({ media: filename }), }, ], }; }, []); if (newTextAdaptations.length > 0) { if (isNullOrUndefined(adaptations.text)) { adaptations.text = newTextAdaptations; } else { adaptations.text.push(...newTextAdaptations); } } const newPeriod: IParsedPeriod = { id: formatId(currentManifest.id) + "_" + formatId(currentPeriod.id), adaptations, duration: currentPeriod.duration, start: contentOffset + currentPeriod.start, thumbnailTracks: currentPeriod.thumbnailTracks, }; manifestPeriods.push(newPeriod); } for (let i = manifestPeriods.length - 1; i >= 0; i--) { const period = manifestPeriods[i]; if (period.start >= content.endTime) { manifestPeriods.splice(i, 1); } else if (!isNullOrUndefined(period.duration)) { if (period.start + period.duration > content.endTime) { period.duration = content.endTime - period.start; } } else if (i === manifestPeriods.length - 1) { period.duration = content.endTime - period.start; } } periods.push(...manifestPeriods); } const time = getMonotonicTimeStamp(); const isLastPeriodKnown = !isDynamic || (mplData.pollInterval === undefined && (manifests.length <= 0 || manifests[manifests.length - 1].isLastPeriodKnown)); const manifest = { availabilityStartTime: 0, clockOffset, suggestedPresentationDelay: 10, periods, transportType: "metaplaylist", isLive: isDynamic, isDynamic, isLastPeriodKnown, uris: isNullOrUndefined(url) ? [] : [url], // TODO more precize time bounds? timeBounds: { minimumSafePosition: minimumTime, timeshiftDepth: null, maximumTimeData: { isLinear: false, maximumSafePosition: maximumTime, livePosition: undefined, time, }, }, lifetime: mplData.pollInterval, }; return manifest; } function formatId(str: string) { return str.replace(/_/g, "_"); }