Files
mediabunny/src/segment.ts
T
2026-03-06 15:49:24 +01:00

136 lines
3.8 KiB
TypeScript

import { AES_128_BLOCK_SIZE, createAesDecryptStream } from './aes';
import { ENCRYPTION_KEY_CACHE_GROUP, Input } from './input';
import { SegmentedInput } from './segmented-input';
import { arrayArgmin, assert } from './misc';
import { readBytes, Reader } from './reader';
import { ReadableStreamSource, Source } from './source';
export type SegmentEncryptionInfo = {
method: 'AES-128';
keyUri: string;
iv: Uint8Array | null;
keyFormat: string;
};
export type SegmentLocation = {
path: string;
offset: number;
length: number | null;
};
export class Segment {
input: SegmentedInput;
location: SegmentLocation;
timestamp: number;
relativeToUnixEpoch: boolean;
duration: number;
title: string | null;
encryption: SegmentEncryptionInfo | null;
firstSegment: Segment | null;
initSegment: Segment | null;
constructor(
input: SegmentedInput,
location: SegmentLocation,
timestamp: number,
relativeToUnixEpoch: boolean,
duration: number,
title: string | null,
encryption: SegmentEncryptionInfo | null,
firstSegment: Segment | null,
initSegment: Segment | null,
) {
this.input = input;
this.location = location;
this.timestamp = timestamp;
this.relativeToUnixEpoch = relativeToUnixEpoch;
this.duration = duration;
this.title = title;
this.encryption = encryption;
this.firstSegment = firstSegment;
this.initSegment = initSegment;
}
toInput(): Input {
const cacheEntry = this.input.inputCache.find(x => x.segment === this);
if (cacheEntry) {
cacheEntry.age = this.input.nextInputCacheAge++;
return cacheEntry.input;
}
let initInput: Input | null = null;
if (this.initSegment || this.firstSegment) {
initInput = (this.initSegment ?? this.firstSegment)!.toInput();
}
const input = new Input({
entryPath: this.location.path,
source: async (request) => {
if (request.path !== this.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.input._getSourceUncached(request);
}
let source: Source;
const needsSlice = this.location.offset > 0 || this.location.length !== null;
if (!this.encryption) {
source = await this.input.input._getSourceCached(request);
if (needsSlice) {
source = source.slice(this.location.offset, this.location.length ?? undefined);
}
} else {
assert(this.encryption.iv);
let ciphertextSource = await this.input.input._getSourceCached(request);
if (needsSlice) {
// Slice before decrypting
ciphertextSource = ciphertextSource.slice(
this.location.offset,
this.location.length ?? undefined,
);
}
const ciphertextReader = new Reader(ciphertextSource);
const stream = createAesDecryptStream(ciphertextReader, async () => {
const keySource = await this.input.input._getSourceCached(
{ path: this.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);
return { key, iv: this.encryption!.iv! };
});
source = new ReadableStreamSource(stream);
}
return source;
},
formats: this.input.input._formats,
initInput: initInput ?? undefined,
});
this.input.inputCache.push({
segment: this,
input,
age: this.input.nextInputCacheAge++,
});
const MAX_INPUT_CACHE_SIZE = 4;
if (this.input.inputCache.length > MAX_INPUT_CACHE_SIZE) {
const minAgeIndex = arrayArgmin(this.input.inputCache, x => x.age);
this.input.inputCache.splice(minAgeIndex, 1);
}
return input;
}
}