import type shaka from "shaka-player/dist/shaka-player.compiled.d.ts"; import { SegmentManager } from "./segment-manager.js"; import { HookedStream, Shaka, HookedNetworkingEngine, P2PMLShakaData, } from "./types.js"; import { StreamType, debug, generateStreamShortId, } from "p2p-media-loader-core"; const AUDIO_CODECS = [ "mp4a", "ac-3", "ec-3", "ec+3", "opus", "vorb", "flac", ]; export class ManifestParserDecorator implements shaka.extern.ManifestParser { private readonly debug = debug("p2pml-shaka:manifest-parser"); private readonly isHls: boolean; private segmentManager?: SegmentManager; private player?: shaka.Player; constructor( private readonly shaka: Readonly, private readonly originalManifestParser: shaka.extern.ManifestParser, ) { this.isHls = this.originalManifestParser instanceof shaka.hls.HlsParser; } configure(config: shaka.extern.ManifestConfiguration) { return this.originalManifestParser.configure(config) as unknown; } banLocation(uri: string): unknown { return this.originalManifestParser.banLocation(uri); } onInitialVariantChosen(variant: shaka.extern.Variant): unknown { return this.originalManifestParser.onInitialVariantChosen(variant); } private setP2PMediaLoaderData(p2pml?: P2PMLShakaData) { if (!p2pml) return; this.segmentManager = p2pml.segmentManager; this.player = p2pml.player; p2pml.streamInfo.protocol = this.isHls ? "hls" : "dash"; } async start( uri: string, playerInterface: shaka.extern.ManifestParser.PlayerInterface, ): Promise { const { p2pml } = playerInterface.networkingEngine as HookedNetworkingEngine; this.setP2PMediaLoaderData(p2pml); const manifest = await this.originalManifestParser.start( uri, playerInterface, ); if (!p2pml) return manifest; if (this.isHls) { this.hookHlsStreamMediaSequenceTimeMaps(manifest.variants); } this.processStreams(manifest.variants); return manifest; } stop() { return this.originalManifestParser.stop(); } update() { return this.originalManifestParser.update() as unknown; } setMediaElement(mediaElement: HTMLMediaElement | null) { return this.originalManifestParser.setMediaElement(mediaElement) as unknown; } onExpirationUpdated(sessionId: string, expiration: number) { return this.originalManifestParser.onExpirationUpdated( sessionId, expiration, ) as unknown; } private processStreams(variants: shaka.extern.Variant[]) { const { segmentManager } = this; if (!segmentManager) return; const processedStreams = new Set(); const processStream = ( stream: shaka.extern.Stream, type: StreamType, index: string, ) => { this.hookSegmentIndex(stream); segmentManager.setStream(stream, type, index); processedStreams.add(stream.id); return true; }; for (const variant of variants) { const { video, audio } = variant; if (video && !processedStreams.has(video.id)) { const isMissingMetadata = variant.bandwidth === 0; // In muxed streams, Shaka natively includes audio codecs in the video codec array. // We strip standard audio prefixes here to strictly match HLS.js's cleanly separated // videoCodec parsing, ensuring peers on identical video tracks share P2P segments // regardless of differently selected audio track descriptors. const videoCodecs = video.codecs ? video.codecs .split(",") .map((c) => c.trim().toLowerCase()) .filter((c) => !AUDIO_CODECS.some((p) => c.startsWith(p))) .join(",") : undefined; const { frameRate, hdr: videoRange } = video; const index = generateStreamShortId({ bitrate: variant.bandwidth, codecs: isMissingMetadata ? undefined : videoCodecs, width: isMissingMetadata ? undefined : video.width, height: isMissingMetadata ? undefined : video.height, frameRate: isMissingMetadata ? undefined : frameRate, videoRange: isMissingMetadata ? undefined : videoRange, }); processStream(video, "main", index); } if (audio && !processedStreams.has(audio.id)) { const isMain = !video; // audio-only master playlist variants const name = audio.label ?? audio.originalId ?? undefined; const index = generateStreamShortId({ bitrate: isMain ? variant.bandwidth : 0, codecs: isMain ? undefined : audio.codecs, language: isMain ? undefined : audio.language, channels: isMain ? undefined : audio.channelsCount, name: isMain ? undefined : name, }); processStream(audio, isMain ? "main" : "secondary", index); } } } private hookSegmentIndex(stream: HookedStream): void { const { segmentManager } = this; if (!segmentManager) return; const substituteSegmentIndexGet = ( segmentIndex: shaka.media.SegmentIndex, callFromCreateSegmentIndexMethod = false, ) => { let prevReference: shaka.media.SegmentReference | null = null; let prevFirstItemReference: shaka.media.SegmentReference; let prevLastItemReference: shaka.media.SegmentReference; // eslint-disable-next-line @typescript-eslint/unbound-method const originalGet = segmentIndex.get as ( position: number, ) => shaka.media.SegmentReference; const customGet = (position: number) => { const reference = originalGet.call(segmentIndex, position); if ( reference === prevReference || (!this.player?.isLive() && stream.isSegmentIndexAlreadyRead) ) { return reference; } prevReference = reference; segmentIndex.get = originalGet; try { const references = getReferencesArray( segmentIndex as unknown as Record, this.shaka, ); if (!references) { throw new Error("Segment references not found"); } const firstItemReference = references[0]; const lastItemReference = references[references.length - 1]; if ( firstItemReference === prevFirstItemReference && lastItemReference === prevLastItemReference ) { return reference; } prevFirstItemReference = firstItemReference; prevLastItemReference = lastItemReference; // Segment index have been updated segmentManager.updateStreamSegments(stream, references); stream.isSegmentIndexAlreadyRead = true; this.debug(`Stream ${stream.id} is updated`); } catch { // Ignore an error when segmentIndex inner array is empty } finally { // Do not set custom get again if the segment index is already read and the stream is VOD if ( !stream.isSegmentIndexAlreadyRead || !!this.player?.isLive() || !callFromCreateSegmentIndexMethod ) { segmentIndex.get = customGet; } } return reference; }; segmentIndex.get = customGet; }; if (stream.segmentIndex) { substituteSegmentIndexGet(stream.segmentIndex); return; } const createSegmentIndexOriginal = stream.createSegmentIndex; stream.createSegmentIndex = async () => { const result: unknown = await createSegmentIndexOriginal.call(stream); if (stream.segmentIndex) { substituteSegmentIndexGet(stream.segmentIndex, true); } return result; }; } private hookHlsStreamMediaSequenceTimeMaps(variants: shaka.extern.Variant[]) { const maps = getMapPropertiesFromObject( this.originalManifestParser as unknown as Record, ); // For version 4.3 and above let videoMap: Map | undefined; let audioMap: Map | undefined; const keysToCheck = ["video", "audio", "text", "image"]; for (const map of maps) { if (!keysToCheck.every((key) => map.has(key))) continue; videoMap = map.get("video") as Map | undefined; audioMap = map.get("audio") as Map | undefined; } if (videoMap && audioMap) { for (const variant of variants) { const { video: videoStream, audio: audioStream } = variant; if (videoStream) { (videoStream as HookedStream).mediaSequenceTimeMap = videoMap; } if (audioStream) { (audioStream as HookedStream).mediaSequenceTimeMap = videoMap; } } return; } // For version 4.2; Retrieving mediaSequence map for each HLS playlist const manifestVariantsMap = maps.find((map) => { const item = map.values().next().value; return ( // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access typeof item === "object" && (item as any)?.streams?.createSegmentIndex ); }); if (!manifestVariantsMap) return; const manifestVariantMapValues = [...manifestVariantsMap.values()]; for (const variant of manifestVariantMapValues) { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access if ((variant as any)?.stream?.mediaSequenceTimeMap) continue; const mediaSequenceTimeMap = getMapPropertiesFromObject( variant as Record, ).find((map) => { const [key, value] = map.entries().next().value ?? []; return typeof key === "number" && typeof value === "number"; }); if (!mediaSequenceTimeMap) continue; // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access (variant as any).stream.mediaSequenceTimeMap = mediaSequenceTimeMap as Map; } } } export class HlsManifestParser extends ManifestParserDecorator { public constructor(shaka: Shaka) { super(shaka, new shaka.hls.HlsParser()); } } export class DashManifestParser extends ManifestParserDecorator { public constructor(shaka: Shaka) { super(shaka, new shaka.dash.DashParser()); } } function getReferencesArray( obj: Record, shaka: Shaka, ): shaka.media.SegmentReference[] | null { for (const key in obj) { if ( Array.isArray(obj[key]) && obj[key].length > 0 && obj[key][0] instanceof shaka.media.SegmentReference ) { return obj[key] as shaka.media.SegmentReference[]; } else if (typeof obj[key] === "object") { const references = getReferencesArray( obj[key] as Record, shaka, ); if (references) return references; } } return null; } function getMapPropertiesFromObject(object: Record) { return Object.values(object).filter( (property): property is Map => property instanceof Map, ); }