Add support for live HLS streams, add playlist refresh logic, add live input tracks

This commit is contained in:
Vanilagy
2026-03-12 15:54:19 +01:00
parent ff523c01c8
commit e8df451bf5
23 changed files with 22399 additions and 417 deletions
+8
View File
@@ -686,6 +686,14 @@ abstract class HlsInputTrackBacking implements InputTrackBacking {
return this.internalTrack.averageBitrate;
}
async getLiveRefreshInterval(): Promise<number | null> {
if (!this.internalTrack.backingTrack) {
throw new TrackNotHydratedError();
}
return this.internalTrack.backingTrack._backing.getLiveRefreshInterval();
}
getHasOnlyKeyPackets() {
return this.internalTrack.hasOnlyKeyPackets || null;
}
+480 -206
View File
@@ -1,26 +1,45 @@
import { AES_128_BLOCK_SIZE } from '../aes';
import { Segment, SegmentEncryptionInfo, SegmentLocation } from '../segment';
import { SegmentedInput } from '../segmented-input';
import { toDataView, joinPaths, last, assert } from '../misc';
import { readAllLines, Reader } from '../reader';
import { AES_128_BLOCK_SIZE, createAesDecryptStream } from '../aes';
import { ENCRYPTION_KEY_CACHE_GROUP, Input } from '../input';
import { Segment, SegmentedInput, SegmentRetrievalOptions } from '../segmented-input';
import { toDataView, joinPaths, last, assert, binarySearchLessOrEqual, arrayArgmin, wait } from '../misc';
import { readAllLines, readBytes, Reader } from '../reader';
import { ReadableStreamSource, Source } from '../source';
import { HlsDemuxer } from './hls-demuxer';
import { AttributeList, canIgnoreLine } from './hls-misc';
const IV_STRING_REGEX = /^0[xX][0-9a-fA-F]+$/;
export type HlsSegment = Segment & {
sequenceNumber: number | null;
location: HlsSegmentLocation;
duration: number;
encryption: HlsEncryptionInfo | null;
firstSegment: HlsSegment | null;
initSegment: HlsSegment | null;
lastProgramDateTimeSeconds: number | null;
};
export type HlsEncryptionInfo = {
method: 'AES-128';
keyUri: string;
iv: Uint8Array | null;
keyFormat: string;
};
export type HlsSegmentLocation = {
path: string;
offset: number;
length: number | null;
};
export class HlsSegmentedInput extends SegmentedInput {
demuxer: HlsDemuxer;
nextSegmentDuration: number | null = null;
accumulatedTime = 0;
headerRead = false;
segmentsPromise: Promise<Segment[]>;
currentKey: SegmentEncryptionInfo | null = null;
nextSequenceNumber = 0;
currentFirstSegment: Segment | null = null;
currentInitSegment: Segment | null = null;
lastByteRangeEnd: number | null = null;
nextByteRange: { offset: number; length: number } | null = null;
lastProgramDateTimeSeconds: number | null = null;
segments: HlsSegment[] = [];
nextLines: string[] | null = null;
currentUpdateSegmentsPromise: Promise<void> | null = null;
streamHasEnded = false;
lastSegmentUpdateTime = -Infinity;
refreshInterval = 5; // Reasonable default in case the playlist doesn't specify it
constructor(
demuxer: HlsDemuxer,
@@ -30,37 +49,136 @@ export class HlsSegmentedInput extends SegmentedInput {
super(demuxer.input, path);
this.demuxer = demuxer;
this.nextLines = lines;
}
this.segmentsPromise ??= (async () => {
if (!lines) {
const source = await demuxer.input._getSourceUncached({ path: this.path });
const reader = new Reader(source);
const slice = await reader.requestEntireFile();
assert(slice);
lines = readAllLines(slice, slice.length, { ignore: canIgnoreLine });
runUpdateSegments() {
return this.currentUpdateSegmentsPromise ??= (async () => {
const remainingWaitTimeMs = this.getRemainingWaitTimeMs();
if (remainingWaitTimeMs > 0) {
await wait(remainingWaitTimeMs);
}
const segments: Segment[] = [];
this.lastSegmentUpdateTime = performance.now();
await this.updateSegments();
this.currentUpdateSegmentsPromise = null;
})();
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
getRemainingWaitTimeMs() {
const elapsed = performance.now() - this.lastSegmentUpdateTime;
const result = Math.max(0, 1000 * this.refreshInterval - elapsed);
if (!this.headerRead) {
if (line !== '#EXTM3U') {
throw new Error('Invalid M3U8 file; expected first line to be #EXTM3U.');
}
if (result <= 50) {
// If only a little bit of time is left, don't wait at all; this removes the chance for timing race
// conditions when running a task every `refreshInterval` seconds
return 0;
}
this.headerRead = true;
continue;
return result;
}
/**
* Reads and parses the segment info from the playlist file. When called more than one, it updates the existing
* segments by appending the new ones. Existing segments are never removed.
*/
async updateSegments() {
let lines = this.nextLines;
this.nextLines = null;
if (!lines) {
const source = await this.demuxer.input._getSourceUncached({ path: this.path });
const reader = new Reader(source);
const slice = await reader.requestEntireFile();
assert(slice);
lines = readAllLines(slice, slice.length, { ignore: canIgnoreLine });
}
let headerRead = false;
let accumulatedTime = 0;
let nextSegmentDuration: number | null = null;
let currentKey: HlsEncryptionInfo | null = null;
let nextSequenceNumber = 0;
let currentFirstSegment: HlsSegment | null = null;
let currentInitSegment: HlsSegment | null = null;
let lastByteRangeEnd: number | null = null;
let nextByteRange: { offset: number; length: number } | null = null;
let lastProgramDateTimeSeconds: number | null = null;
let prevLastSegment = last(this.segments) ?? null;
if (Math.PI === 3) {
// Stupid hack needed to prevent TypeScript from incorrectly narrowing the local variables; not sure if
// there is a better workaround
nextByteRange = { offset: 6, length: 7 };
lastByteRangeEnd = 1337;
}
const parseAndUpdateByteRange = (content: string) => {
const atIndex = content.indexOf('@');
const length = Number(atIndex === -1 ? content : content.slice(0, atIndex));
if (!Number.isInteger(length) || length < 0) {
throw new Error(`Invalid #EXT-X-BYTERANGE length '${content}'.`);
}
let offset: number;
if (atIndex !== -1) {
offset = Number(content.slice(atIndex + 1));
if (!Number.isInteger(offset) || offset < 0) {
throw new Error(`Invalid #EXT-X-BYTERANGE offset '${content}'.`);
}
} else {
if (lastByteRangeEnd === null) {
throw new Error(
'Invalid M3U8 file; #EXT-X-BYTERANGE without offset requires a previous byte range.',
);
}
offset = lastByteRangeEnd;
}
nextByteRange = { offset, length };
lastByteRangeEnd = offset + length;
};
const setNextSequenceNumber = (number: number) => {
nextSequenceNumber = number;
if (prevLastSegment) {
assert(prevLastSegment.sequenceNumber !== null);
if (prevLastSegment.sequenceNumber < number) {
// The sequence number has finally exceeded the last sequence number we knew, meaning we can now
// continue the segment list from there. Set some data to continue where we left off.
accumulatedTime = prevLastSegment.timestamp + prevLastSegment.duration;
currentFirstSegment = prevLastSegment.firstSegment;
currentInitSegment = prevLastSegment.initSegment;
lastProgramDateTimeSeconds = prevLastSegment.lastProgramDateTimeSeconds;
prevLastSegment = null;
}
}
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (!headerRead) {
if (line !== '#EXTM3U') {
throw new Error('Invalid M3U8 file; expected first line to be #EXTM3U.');
}
if (!line.startsWith('#')) {
if (this.nextSegmentDuration === null) {
headerRead = true;
continue;
}
if (!line.startsWith('#')) {
if (!prevLastSegment) {
if (nextSegmentDuration === null) {
throw new Error('Invalid M3U8 file; a segment must be preceeded by a #EXTINF tag.');
}
let key = this.currentKey;
let key = currentKey;
if (key && !key.iv) {
// "the Media Sequence Number is to be used as the IV when decrypting a Media Segment, by
// putting its big-endian binary representation into a 16-octet (128-bit) buffer and padding
@@ -68,226 +186,382 @@ export class HlsSegmentedInput extends SegmentedInput {
const iv = new Uint8Array(AES_128_BLOCK_SIZE);
const view = toDataView(iv);
view.setUint32(8, Math.floor(this.nextSequenceNumber / (2 ** 32)));
view.setUint32(12, this.nextSequenceNumber);
view.setUint32(8, Math.floor(nextSequenceNumber / (2 ** 32)));
view.setUint32(12, nextSequenceNumber);
key = { ...key, iv };
}
const fullPath = joinPaths(this.path, line);
const location: SegmentLocation = {
const location: HlsSegmentLocation = {
path: fullPath,
offset: this.nextByteRange?.offset ?? 0,
length: this.nextByteRange?.length ?? null,
offset: nextByteRange?.offset ?? 0,
length: nextByteRange?.length ?? null,
};
const segment = new Segment(
this,
const segment: HlsSegment = {
timestamp: accumulatedTime,
relativeToUnixEpoch: lastProgramDateTimeSeconds !== null,
firstSegment: currentFirstSegment,
sequenceNumber: nextSequenceNumber,
location,
this.accumulatedTime,
this.lastProgramDateTimeSeconds !== null,
this.nextSegmentDuration,
key,
this.currentFirstSegment,
this.currentInitSegment,
);
segments.push(segment);
this.accumulatedTime += this.nextSegmentDuration;
this.nextSequenceNumber++;
this.currentFirstSegment ??= segment;
duration: nextSegmentDuration,
encryption: key,
initSegment: currentInitSegment,
lastProgramDateTimeSeconds,
};
this.nextSegmentDuration = null;
currentFirstSegment ??= segment;
accumulatedTime += nextSegmentDuration;
if (this.nextByteRange === null) {
this.lastByteRangeEnd = null;
} else {
this.nextByteRange = null;
}
this.segments.push(segment);
} else {
// We're still seeing segments we already know about
}
if (line.startsWith('#EXTINF:')) {
const extinfContent = line.slice(8);
const commaIndex = extinfContent.indexOf(',');
const durationStr = commaIndex === -1 ? extinfContent : extinfContent.slice(0, commaIndex);
const duration = Number(durationStr);
if (!Number.isFinite(duration) || duration < 0) {
throw new Error(`Invalid #EXTINF tag duration '${durationStr}'.`);
}
nextSegmentDuration = null;
this.nextSegmentDuration = duration;
} else if (line.startsWith('#EXT-X-MAP:')) {
const attributes = new AttributeList(line.slice(11));
const uri = attributes.get('uri');
if (!uri) {
throw new Error('Invalid #EXT-X-MAP tag; missing URI attribute.');
}
if (nextByteRange === null) {
lastByteRangeEnd = null;
} else {
nextByteRange = null;
}
const byteRange = attributes.get('byterange');
if (byteRange !== null) {
this.parseAndUpdateByteRange(byteRange);
}
setNextSequenceNumber(nextSequenceNumber + 1);
}
if (line.startsWith('#EXTINF:')) {
if (prevLastSegment) {
continue;
}
const extinfContent = line.slice(8);
const commaIndex = extinfContent.indexOf(',');
const durationStr = commaIndex === -1 ? extinfContent : extinfContent.slice(0, commaIndex);
const duration = Number(durationStr);
if (!Number.isFinite(duration) || duration < 0) {
throw new Error(`Invalid #EXTINF tag duration '${durationStr}'.`);
}
nextSegmentDuration = duration;
} else if (line.startsWith('#EXT-X-MAP:')) {
const attributes = new AttributeList(line.slice(11));
const uri = attributes.get('uri');
if (!uri) {
throw new Error('Invalid #EXT-X-MAP tag; missing URI attribute.');
}
const byteRange = attributes.get('byterange');
if (byteRange !== null) {
parseAndUpdateByteRange(byteRange);
}
if (!prevLastSegment) {
const fullPath = joinPaths(this.path, uri);
const location: SegmentLocation = {
const location: HlsSegmentLocation = {
path: fullPath,
offset: this.nextByteRange?.offset ?? 0,
length: this.nextByteRange?.length ?? null,
offset: nextByteRange?.offset ?? 0,
length: nextByteRange?.length ?? null,
};
if (this.currentKey?.method === 'AES-128' && !this.currentKey.iv) {
if (currentKey?.method === 'AES-128' && !currentKey.iv) {
// Required by the spec
throw new Error('IV attribute must be set on #EXT-X-KEY tag preceding the #EXT-X-MAP tag.');
}
const segment = new Segment(
this,
const segment: HlsSegment = {
timestamp: accumulatedTime,
relativeToUnixEpoch: lastProgramDateTimeSeconds !== null,
firstSegment: null,
sequenceNumber: null,
location,
this.accumulatedTime,
this.lastProgramDateTimeSeconds !== null,
0,
this.currentKey,
null,
null,
);
duration: 0,
encryption: currentKey,
initSegment: null,
lastProgramDateTimeSeconds,
};
// Accumulated time and sequence number are not updated in this case
this.currentInitSegment = segment;
currentInitSegment = segment;
} else {
// We're still seeing segments we already know about
}
this.nextSegmentDuration = null;
nextSegmentDuration = null;
if (this.nextByteRange === null) {
this.lastByteRangeEnd = null;
} else {
this.nextByteRange = null;
if (nextByteRange === null) {
lastByteRangeEnd = null;
} else {
nextByteRange = null;
}
} else if (line.startsWith('#EXT-X-KEY:')) {
const attributes = new AttributeList(line.slice(11));
const method = attributes.get('method');
if (method === 'NONE') {
currentKey = null;
} else if (method === 'AES-128') {
const uri = attributes.get('uri');
if (!uri) {
throw new Error('Invalid #EXT-X-KEY: AES-128 requires a URI attribute.');
}
} else if (line.startsWith('#EXT-X-KEY:')) {
const attributes = new AttributeList(line.slice(11));
const method = attributes.get('method');
if (method === 'NONE') {
this.currentKey = null;
} else if (method === 'AES-128') {
const uri = attributes.get('uri');
if (!uri) {
throw new Error('Invalid #EXT-X-KEY: AES-128 requires a URI attribute.');
let iv: Uint8Array | null = null;
const ivString = attributes.get('iv');
if (ivString) {
if (!IV_STRING_REGEX.test(ivString)) {
throw new Error(`Unsupported IV format '${ivString}'.`);
}
let iv: Uint8Array | null = null;
const ivString = attributes.get('iv');
if (ivString) {
if (!IV_STRING_REGEX.test(ivString)) {
throw new Error(`Unsupported IV format '${ivString}'.`);
}
let hex = ivString.slice(2);
hex = hex.padStart(AES_128_BLOCK_SIZE * 2, '0');
let hex = ivString.slice(2);
hex = hex.padStart(AES_128_BLOCK_SIZE * 2, '0');
iv = new Uint8Array(AES_128_BLOCK_SIZE);
for (let i = 0; i < AES_128_BLOCK_SIZE; i++) {
const startIndex = -AES_128_BLOCK_SIZE * 2 + i;
iv[i] = parseInt(hex.slice(startIndex, startIndex + 2), 16);
}
iv = new Uint8Array(AES_128_BLOCK_SIZE);
for (let i = 0; i < AES_128_BLOCK_SIZE; i++) {
const startIndex = -AES_128_BLOCK_SIZE * 2 + i;
iv[i] = parseInt(hex.slice(startIndex, startIndex + 2), 16);
}
}
this.currentKey = {
method: 'AES-128',
keyUri: joinPaths(this.path, uri),
iv,
keyFormat: attributes.get('keyformat') ?? 'identity',
};
} else {
throw new Error(
`Unsupported encryption method '${method}'. If you think this method should be supported,`
+ ` please raise an issue.`,
currentKey = {
method: 'AES-128',
keyUri: joinPaths(this.path, uri),
iv,
keyFormat: attributes.get('keyformat') ?? 'identity',
};
} else {
throw new Error(
`Unsupported encryption method '${method}'. If you think this method should be supported,`
+ ` please raise an issue.`,
);
}
} else if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
const value = line.slice(22);
const number = Number(value);
if (!Number.isInteger(number) || number < 0) {
throw new Error(`Invalid EXT-X-MEDIA-SEQUENCE value '${value}'.`);
}
setNextSequenceNumber(number);
} else if (line.startsWith('#EXT-X-BYTERANGE:')) {
parseAndUpdateByteRange(line.slice(17));
} else if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) {
if (prevLastSegment) {
continue; // No need to spend effort parsing dates if we're gonna discard it anyway
}
const dateTime = line.slice(25);
const dateTimeMs = Date.parse(dateTime);
if (!Number.isFinite(dateTimeMs)) {
continue;
}
const dateTimeSeconds = dateTimeMs / 1000;
if (lastProgramDateTimeSeconds === dateTimeSeconds) {
continue;
}
if (lastProgramDateTimeSeconds === null && this.segments.length > 0) {
// "If the first EXT-X-PROGRAM-DATE-TIME tag in a Playlist appears after
// one or more Media Segment URIs, the client SHOULD extrapolate
// backward from that tag (using EXTINF durations and/or media
// timestamps) to associate dates with those segments."
const lastSegment = last(this.segments)!;
const lastSegmentEnd = lastSegment.timestamp + lastSegment.duration;
const offset = dateTimeSeconds - lastSegmentEnd;
for (const segment of this.segments) {
segment.timestamp += offset;
segment.relativeToUnixEpoch = true;
}
accumulatedTime += offset;
}
lastProgramDateTimeSeconds = dateTimeSeconds;
accumulatedTime = dateTimeSeconds; // Snap the accumulated time to the datetime
} else if (line === '#EXT-X-DISCONTINUITY') {
currentFirstSegment = null;
currentInitSegment = null;
} else if (line.startsWith('#EXT-X-TARGETDURATION:')) {
const value = line.slice(22);
const duration = Number(value);
if (!Number.isFinite(duration) || duration < 0) {
throw new Error(`Invalid EXT-X-TARGETDURATION value '${value}'.`);
}
this.refreshInterval = duration;
} else if (line === '#EXT-X-ENDLIST') {
this.streamHasEnded = true;
break; // No need to keep reading after this
}
}
}
async getFirstSegment() {
if (this.segments.length === 0) {
await this.runUpdateSegments();
}
return this.segments[0] ?? null;
}
async getSegmentAt(timestamp: number, options: SegmentRetrievalOptions) {
if (this.segments.length === 0) {
await this.runUpdateSegments();
}
// If we're skipping the live wait BUT there's no wait time, we're actually not lazy for the first iteration
let isLazy = !!options.skipLiveWait && this.getRemainingWaitTimeMs() > 0;
while (true) {
const index = binarySearchLessOrEqual(this.segments, timestamp, x => x.timestamp);
if (index === -1) {
return null;
}
if (index < this.segments.length - 1 || this.streamHasEnded || isLazy) {
return this.segments[index]!;
}
const segment = this.segments[index]!;
if (timestamp < segment.timestamp + segment.duration) {
return segment;
}
await this.runUpdateSegments();
if (options.skipLiveWait) {
isLazy = true; // Definitely lazy in the next iteration
}
}
}
async getNextSegment(segment: Segment, options: SegmentRetrievalOptions) {
const index = this.segments.indexOf(segment as HlsSegment);
assert(index !== -1);
const nextIndex = index + 1;
// If we're skipping the live wait BUT there's no wait time, we're actually not lazy for the first iteration
let isLazy = !!options.skipLiveWait && this.getRemainingWaitTimeMs() > 0;
while (true) {
if (nextIndex < this.segments.length) {
return this.segments[nextIndex]!;
}
if (this.streamHasEnded || isLazy) {
return null;
}
await this.runUpdateSegments();
if (options.skipLiveWait) {
isLazy = true; // Definitely lazy in the next iteration
}
}
}
async getPreviousSegment(segment: Segment) {
const index = this.segments.indexOf(segment as HlsSegment);
assert(index !== -1);
return this.segments[index - 1] ?? null;
}
getInputForSegment(segment: Segment): Input {
const hlsSegment = segment as HlsSegment;
const cacheEntry = this.inputCache.find(x => x.segment === hlsSegment);
if (cacheEntry) {
cacheEntry.age = this.nextInputCacheAge++;
return cacheEntry.input;
}
let initInput: Input | null = null;
if (hlsSegment.initSegment || hlsSegment.firstSegment) {
initInput = this.getInputForSegment((hlsSegment.initSegment ?? hlsSegment.firstSegment)!);
}
const input = new Input({
entryPath: hlsSegment.location.path,
source: async (request) => {
if (request.path !== hlsSegment.location.path) {
// This code technically allows for recursive .m3u8 files for example. Uncached because the added
// input adds its own layer of caching, so here we just do a passthrough.
return this.input._getSourceUncached(request);
}
let source: Source;
const needsSlice = hlsSegment.location.offset > 0 || hlsSegment.location.length !== null;
if (!hlsSegment.encryption) {
source = await this.input._getSourceCached(request);
if (needsSlice) {
source = source.slice(hlsSegment.location.offset, hlsSegment.location.length ?? undefined);
}
} else {
assert(hlsSegment.encryption.iv);
let ciphertextSource = await this.input._getSourceCached(request);
if (needsSlice) {
// Slice before decrypting
ciphertextSource = ciphertextSource.slice(
hlsSegment.location.offset,
hlsSegment.location.length ?? undefined,
);
}
} else if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
const value = line.slice(22);
const number = Number(value);
if (!Number.isInteger(number) || number < 0) {
throw new Error(`Invalid EXT-X-MEDIA-SEQUENCE value '${value}'.`);
}
const ciphertextReader = new Reader(ciphertextSource);
this.nextSequenceNumber = number;
} else if (line.startsWith('#EXT-X-BYTERANGE:')) {
this.parseAndUpdateByteRange(line.slice(17));
} else if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) {
const dateTime = line.slice(25);
const dateTimeMs = Date.parse(dateTime);
if (!Number.isFinite(dateTimeMs)) {
continue;
}
const dateTimeSeconds = dateTimeMs / 1000;
if (this.lastProgramDateTimeSeconds === dateTimeSeconds) {
continue;
}
if (this.lastProgramDateTimeSeconds === null && segments.length > 0) {
// "If the first EXT-X-PROGRAM-DATE-TIME tag in a Playlist appears after
// one or more Media Segment URIs, the client SHOULD extrapolate
// backward from that tag (using EXTINF durations and/or media
// timestamps) to associate dates with those segments."
const lastSegment = last(segments)!;
const lastSegmentEnd = lastSegment.timestamp + lastSegment.duration;
const offset = dateTimeSeconds - lastSegmentEnd;
for (const segment of segments) {
segment.timestamp += offset;
segment.relativeToUnixEpoch = true;
const stream = createAesDecryptStream(ciphertextReader, async () => {
const keySource = await this.input._getSourceCached(
{ path: hlsSegment.encryption!.keyUri },
ENCRYPTION_KEY_CACHE_GROUP,
);
const keyReader = new Reader(keySource);
const keySlice = await keyReader.requestSlice(0, AES_128_BLOCK_SIZE);
if (!keySlice) {
throw new Error('Invalid AES-128 key; expected at least 16 bytes of data.');
}
const key = readBytes(keySlice, AES_128_BLOCK_SIZE);
this.accumulatedTime += offset;
}
return { key, iv: hlsSegment.encryption!.iv! };
});
this.lastProgramDateTimeSeconds = dateTimeSeconds;
if (Math.abs(this.accumulatedTime - dateTimeSeconds) >= 1) {
// Only snap to the datetime if the current time is sufficiently far away from it. If we always
// snapped, we'd lose the sub-second accuracy that's often provided by precise
// segment durations.
this.accumulatedTime = dateTimeSeconds;
}
} else if (line.startsWith('#EXT-X-DISCONTINUITY')) {
this.currentFirstSegment = null;
this.currentInitSegment = null;
source = new ReadableStreamSource(stream);
}
}
return segments;
})();
}
return source;
},
formats: this.input._formats,
initInput: initInput ?? undefined,
});
parseAndUpdateByteRange(content: string) {
const atIndex = content.indexOf('@');
this.inputCache.push({
segment: hlsSegment,
input,
age: this.nextInputCacheAge++,
});
const length = Number(atIndex === -1 ? content : content.slice(0, atIndex));
if (!Number.isInteger(length) || length < 0) {
throw new Error(`Invalid #EXT-X-BYTERANGE length '${content}'.`);
const MAX_INPUT_CACHE_SIZE = 4;
if (this.inputCache.length > MAX_INPUT_CACHE_SIZE) {
const minAgeIndex = arrayArgmin(this.inputCache, x => x.age);
this.inputCache.splice(minAgeIndex, 1);
}
let offset: number;
if (atIndex !== -1) {
offset = Number(content.slice(atIndex + 1));
if (!Number.isInteger(offset) || offset < 0) {
throw new Error(`Invalid #EXT-X-BYTERANGE offset '${content}'.`);
}
} else {
if (this.lastByteRangeEnd === null) {
throw new Error(
'Invalid M3U8 file; #EXT-X-BYTERANGE without offset requires a previous byte range.',
);
}
offset = this.lastByteRangeEnd;
}
this.nextByteRange = { offset, length };
this.lastByteRangeEnd = offset + length;
return input;
}
async getSegments() {
return this.segmentsPromise;
async getLiveRefreshInterval() {
if (this.getRemainingWaitTimeMs() === 0) {
await this.runUpdateSegments();
}
return this.streamHasEnded ? null : this.refreshInterval;
}
}