From 2d0aac3208522897daaccedaebfa85d403aa4ce1 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:53:36 +0200 Subject: [PATCH] Fix HLS demuxer parsing bug, extract HLS mime type out, add TargetRequest.mimeType --- src/hls/hls-demuxer.ts | 16 ++++++++++---- src/hls/hls-misc.ts | 2 ++ src/hls/hls-muxer.ts | 22 +++++++++++++++---- src/input-format.ts | 3 ++- src/output-format.ts | 3 ++- src/output.ts | 6 ++++- src/target.ts | 4 +++- test/node/hls-input.test.ts | 44 ++++++++++++++++++++++++++++++++++++- 8 files changed, 87 insertions(+), 13 deletions(-) diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts index 240479e..b947338 100644 --- a/src/hls/hls-demuxer.ts +++ b/src/hls/hls-demuxer.ts @@ -20,7 +20,7 @@ import { TrackType } from '../output'; import { assert, joinPaths, MaybePromise, Rotation, UNDETERMINED_LANGUAGE } from '../misc'; import { EncodedPacket } from '../packet'; import { readAllLines } from '../reader'; -import { AttributeList, canIgnoreLine } from './hls-misc'; +import { AttributeList, canIgnoreLine, HLS_MIME_TYPE } from './hls-misc'; import { HlsSegmentedInput } from './hls-segmented-input'; import { PathedSource } from '../source'; import { SegmentedInputTrackDeclaration } from '../segmented-input'; @@ -105,7 +105,7 @@ export class HlsDemuxer extends Demuxer { if (bandwidth === null) { throw new Error( 'Invalid M3U8 file; #EXT-X-STREAM-INF tag requires a BANDWIDTH attribute with a valid' - + ' number value.', + + ' numerical value.', ); } @@ -116,7 +116,7 @@ export class HlsDemuxer extends Demuxer { hasOnlyKeyPackets: false, }); } else if (line.startsWith('#EXT-X-I-FRAME-STREAM-INF:')) { - const attributes = new AttributeList(line.slice(18)); + const attributes = new AttributeList(line.slice(26)); const playlistPath = attributes.get('uri'); if (playlistPath === null) { @@ -125,6 +125,14 @@ export class HlsDemuxer extends Demuxer { ); } + const bandwidth = attributes.getAsNumber('bandwidth'); + if (bandwidth === null) { + throw new Error( + 'Invalid M3U8 file; #EXT-X-I-FRAME-STREAM-INF tag requires a BANDWIDTH attribute with a' + + ' valid numerical value.', + ); + } + const fullPath = joinPaths(source.rootPath, playlistPath); variantStreams.push({ @@ -610,7 +618,7 @@ export class HlsDemuxer extends Demuxer { } async getMimeType(): Promise { - return 'application/vnd.apple.mpegurl'; + return HLS_MIME_TYPE; } override dispose(): void { diff --git a/src/hls/hls-misc.ts b/src/hls/hls-misc.ts index db583c3..3a68ebe 100644 --- a/src/hls/hls-misc.ts +++ b/src/hls/hls-misc.ts @@ -6,6 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +export const HLS_MIME_TYPE = 'application/vnd.apple.mpegurl'; + export const canIgnoreLine = (line: string) => line.length === 0 || (line.startsWith('#') && !line.startsWith('#EXT')); export class AttributeList { diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index 3b2917f..5c0751b 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -29,6 +29,7 @@ import { Writer } from '../writer'; import { EncodedPacket } from '../packet'; import { SubtitleCue, SubtitleMetadata } from '../subtitles'; import { NullTarget, PathedTarget, Target, TargetRequest } from '../target'; +import { HLS_MIME_TYPE } from './hls-misc'; type HlsTrackData = { track: OutputTrack; @@ -499,7 +500,7 @@ export class HlsMuxer extends Muxer { } async getMimeType(): Promise { - return 'application/vnd.apple.mpegurl'; + return HLS_MIME_TYPE; } private allTracksAreKnown(playlist: Playlist) { @@ -829,7 +830,11 @@ export class HlsMuxer extends Muxer { relativeSegmentPath, ); - const target = await this.output._getTarget({ path: fullSegmentPath, isRoot: false }); + const target = await this.output._getTarget({ + path: fullSegmentPath, + isRoot: false, + mimeType: playlist.segmentFormat.mimeType, + }); target._start(); playlist.singleFile = { @@ -926,6 +931,7 @@ export class HlsMuxer extends Muxer { const target = await this.output._getTarget({ path: initPath, isRoot: false, + mimeType: playlist.segmentFormat.mimeType, }); target.on('write', ({ end }) => { playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end); @@ -1179,7 +1185,11 @@ export class HlsMuxer extends Muxer { this.format._options.onPlaylist?.(playlistText, toPlaylistInfo(playlist)); - const target = await this.output._getTarget({ path: playlistPath, isRoot: false }); + const target = await this.output._getTarget({ + path: playlistPath, + isRoot: false, + mimeType: HLS_MIME_TYPE, + }); const writer = new Writer(target); writer.start(); writer.write(textEncoder.encode(playlistText)); @@ -1382,7 +1392,11 @@ export class HlsMuxer extends Muxer { } else { // For subsequent master playlist writes, we *must* obtain a different target in order to overwrite // the file. - const target = await this.output._getTarget({ path: pathedTarget.rootPath, isRoot: true }); + const target = await this.output._getTarget({ + path: pathedTarget.rootPath, + isRoot: true, + mimeType: HLS_MIME_TYPE, + }); writer = new Writer(target); writer.start(); } diff --git a/src/input-format.ts b/src/input-format.ts index de09cc6..6b8cc41 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -33,6 +33,7 @@ import { FlacDemuxer } from './flac/flac-demuxer'; import { MpegTsDemuxer } from './mpeg-ts/mpeg-ts-demuxer'; import { TS_PACKET_SIZE } from './mpeg-ts/mpeg-ts-misc'; import { HlsDemuxer } from './hls/hls-demuxer'; +import { HLS_MIME_TYPE } from './hls/hls-misc'; import { PathedSource } from './source'; /** @@ -610,7 +611,7 @@ export class HlsInputFormat extends InputFormat { } get mimeType() { - return 'application/vnd.apple.mpegurl'; + return HLS_MIME_TYPE; } } diff --git a/src/output-format.ts b/src/output-format.ts index 5c5031d..2133592 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -29,6 +29,7 @@ import { Output, OutputTrack, TrackType } from './output'; import { MpegTsMuxer } from './mpeg-ts/mpeg-ts-muxer'; import { WaveMuxer } from './wave/wave-muxer'; import { HlsMuxer } from './hls/hls-muxer'; +import { HLS_MIME_TYPE } from './hls/hls-misc'; import { MaybePromise, FilePath, toArray } from './misc'; import { Target } from './target'; @@ -1368,7 +1369,7 @@ export class HlsOutputFormat extends OutputFormat { } get mimeType() { - return 'application/vnd.apple.mpegurl'; + return HLS_MIME_TYPE; } getSupportedCodecs(): MediaCodec[] { diff --git a/src/output.ts b/src/output.ts index 26ff181..9219a8b 100644 --- a/src/output.ts +++ b/src/output.ts @@ -534,7 +534,11 @@ export class Output< return this._target; } - const request: TargetRequest = { path: this._target.rootPath, isRoot: true }; + const request: TargetRequest = { + path: this._target.rootPath, + isRoot: true, + mimeType: this.format.mimeType, + }; const result = this._getTargetValidated(request); const handleResult = (target: T) => { diff --git a/src/target.ts b/src/target.ts index 891442f..f250c54 100644 --- a/src/target.ts +++ b/src/target.ts @@ -727,6 +727,8 @@ export class PathedTarget { export type TargetRequest = { /** The requested file path. */ path: FilePath; - /** Whether the requested file is the root file. */ + /** Whether the to-be-written file will be the root file. */ isRoot: boolean; + /** The MIME type of the to-be-written file. */ + mimeType: string; }; diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts index c9fbfb7..2bb9965 100644 --- a/test/node/hls-input.test.ts +++ b/test/node/hls-input.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @stylistic/max-len */ -import { ALL_FORMATS, createInputFrom, EncodedPacketSink, InputAudioTrack, InputVideoTrack } from 'mediabunny'; +import { ALL_FORMATS, BufferSource, createInputFrom, EncodedPacketSink, Input, InputAudioTrack, InputVideoTrack, PathedSource } from 'mediabunny'; import { expect, test } from 'vitest'; import { HLS, HlsInputFormat } from '../../src/input-format.js'; import { assert, rejectAfter } from '../../src/misc.js'; @@ -721,3 +721,45 @@ test.concurrent('Live HLS', { timeout: 30_000 }, async () => { ]); expect(metadataDuration).toBeGreaterThan((Date.now() / 1000) - 3600); }); + +test.concurrent('#EXT-X-I-FRAME-STREAM-INF tags are parsed properly', async () => { + const text = `#EXTM3U +#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=256000,CODECS="avc1.4D401E",RESOLUTION=480x270,URI="7f2459cb12854fdbbc7ec1e7279da179/f74fb10563564130a4702743d64112a3/index_11.m3u8" +`; + + const input = new Input({ + formats: ALL_FORMATS, + source: new PathedSource('master.m3u8', () => new BufferSource(new TextEncoder().encode(text))), + }); + + const tracks = await input.getTracks(); + expect(tracks).toHaveLength(1); + expect(await tracks[0]!.hasOnlyKeyPackets()).toBe(true); +}); + +test.concurrent('#EXT-X-I-FRAME-STREAM-INF tags without BANDWIDTH attribute are rejected', async () => { + const text = `#EXTM3U +#EXT-X-I-FRAME-STREAM-INF:CODECS="avc1.4D401E",RESOLUTION=480x270,URI="7f2459cb12854fdbbc7ec1e7279da179/f74fb10563564130a4702743d64112a3/index_11.m3u8" +`; + + const input = new Input({ + formats: ALL_FORMATS, + source: new PathedSource('master.m3u8', () => new BufferSource(new TextEncoder().encode(text))), + }); + + await expect(input.getTracks()).rejects.toThrow('BANDWIDTH'); +}); + +test.concurrent('#EXT-X-STREAM-INF tags without BANDWIDTH attribute are rejected', async () => { + const text = `#EXTM3U +#EXT-X-STREAM-INF:CODECS="avc1.4D401E",RESOLUTION=480x270 +7f2459cb12854fdbbc7ec1e7279da179/f74fb10563564130a4702743d64112a3/index_11.m3u8 +`; + + const input = new Input({ + formats: ALL_FORMATS, + source: new PathedSource('master.m3u8', () => new BufferSource(new TextEncoder().encode(text))), + }); + + await expect(input.getTracks()).rejects.toThrow('BANDWIDTH'); +});