src/loader/fragment.ts
import { buildAbsoluteURL } from 'url-toolkit';
import { logger } from '../utils/logger';
import { LevelKey } from './level-key';
import { LoadStats } from './load-stats';
import { AttrList } from '../utils/attr-list';
import type {
FragmentLoaderContext,
Loader,
PlaylistLevelType,
} from '../types/loader';
export enum ElementaryStreamTypes {
AUDIO = 'audio',
VIDEO = 'video',
AUDIOVIDEO = 'audiovideo',
}
export interface ElementaryStreamInfo {
startPTS: number;
endPTS: number;
startDTS: number;
endDTS: number;
partial?: boolean;
}
export type ElementaryStreams = Record<
ElementaryStreamTypes,
ElementaryStreamInfo | null
>;
export class BaseSegment {
private _byteRange: number[] | null = null;
private _url: string | null = null;
// baseurl is the URL to the playlist
public readonly baseurl: string;
// relurl is the portion of the URL that comes from inside the playlist.
public relurl?: string;
// Holds the types of data this fragment supports
public elementaryStreams: ElementaryStreams = {
[ElementaryStreamTypes.AUDIO]: null,
[ElementaryStreamTypes.VIDEO]: null,
[ElementaryStreamTypes.AUDIOVIDEO]: null,
};
constructor(baseurl: string) {
this.baseurl = baseurl;
}
// setByteRange converts a EXT-X-BYTERANGE attribute into a two element array
setByteRange(value: string, previous?: BaseSegment) {
const params = value.split('@', 2);
const byteRange: number[] = [];
if (params.length === 1) {
byteRange[0] = previous ? previous.byteRangeEndOffset : 0;
} else {
byteRange[0] = parseInt(params[1]);
}
byteRange[1] = parseInt(params[0]) + byteRange[0];
this._byteRange = byteRange;
}
get byteRange(): number[] {
if (!this._byteRange) {
return [];
}
return this._byteRange;
}
get byteRangeStartOffset(): number {
return this.byteRange[0];
}
get byteRangeEndOffset(): number {
return this.byteRange[1];
}
get url(): string {
if (!this._url && this.baseurl && this.relurl) {
this._url = buildAbsoluteURL(this.baseurl, this.relurl, {
alwaysNormalize: true,
});
}
return this._url || '';
}
set url(value: string) {
this._url = value;
}
}
export class Fragment extends BaseSegment {
private _decryptdata: LevelKey | null = null;
public rawProgramDateTime: string | null = null;
public programDateTime: number | null = null;
public tagList: Array<string[]> = [];
// EXTINF has to be present for a m38 to be considered valid
public duration: number = 0;
// sn notates the sequence number for a segment, and if set to a string can be 'initSegment'
public sn: number | 'initSegment' = 0;
// levelkey is the EXT-X-KEY that applies to this segment for decryption
// core difference from the private field _decryptdata is the lack of the initialized IV
// _decryptdata will set the IV for this segment based on the segment number in the fragment
public levelkey?: LevelKey;
// A string representing the fragment type
public readonly type: PlaylistLevelType;
// A reference to the loader. Set while the fragment is loading, and removed afterwards. Used to abort fragment loading
public loader: Loader<FragmentLoaderContext> | null = null;
// The level/track index to which the fragment belongs
public level: number = -1;
// The continuity counter of the fragment
public cc: number = 0;
// The starting Presentation Time Stamp (PTS) of the fragment. Set after transmux complete.
public startPTS?: number;
// The ending Presentation Time Stamp (PTS) of the fragment. Set after transmux complete.
public endPTS?: number;
// The latest Presentation Time Stamp (PTS) appended to the buffer.
public appendedPTS?: number;
// The starting Decode Time Stamp (DTS) of the fragment. Set after transmux complete.
public startDTS!: number;
// The ending Decode Time Stamp (DTS) of the fragment. Set after transmux complete.
public endDTS!: number;
// The start time of the fragment, as listed in the manifest. Updated after transmux complete.
public start: number = 0;
// Set by `updateFragPTSDTS` in level-helper
public deltaPTS?: number;
// The maximum starting Presentation Time Stamp (audio/video PTS) of the fragment. Set after transmux complete.
public maxStartPTS?: number;
// The minimum ending Presentation Time Stamp (audio/video PTS) of the fragment. Set after transmux complete.
public minEndPTS?: number;
// Load/parse timing information
public stats: LoadStats = new LoadStats();
public urlId: number = 0;
public data?: Uint8Array;
// A flag indicating whether the segment was downloaded in order to test bitrate, and was not buffered
public bitrateTest: boolean = false;
// #EXTINF segment title
public title: string | null = null;
constructor(type: PlaylistLevelType, baseurl: string) {
super(baseurl);
this.type = type;
}
get decryptdata(): LevelKey | null {
if (!this.levelkey && !this._decryptdata) {
return null;
}
if (!this._decryptdata && this.levelkey) {
let sn = this.sn;
if (typeof sn !== 'number') {
// We are fetching decryption data for a initialization segment
// If the segment was encrypted with AES-128
// It must have an IV defined. We cannot substitute the Segment Number in.
if (
this.levelkey &&
this.levelkey.method === 'AES-128' &&
!this.levelkey.iv
) {
logger.warn(
`missing IV for initialization segment with method="${this.levelkey.method}" - compliance issue`
);
}
/*
Be converted to a Number.
'initSegment' will become NaN.
NaN, which when converted through ToInt32() -> +0.
---
Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation.
*/
sn = 0;
}
this._decryptdata = this.setDecryptDataFromLevelKey(this.levelkey, sn);
}
return this._decryptdata;
}
get end(): number {
return this.start + this.duration;
}
get endProgramDateTime() {
if (this.programDateTime === null) {
return null;
}
if (!Number.isFinite(this.programDateTime)) {
return null;
}
const duration = !Number.isFinite(this.duration) ? 0 : this.duration;
return this.programDateTime + duration * 1000;
}
get encrypted() {
// At the m3u8-parser level we need to add support for manifest signalled keyformats
// when we want the fragment to start reporting that it is encrypted.
// Currently, keyFormat will only be set for identity keys
if (this.decryptdata?.keyFormat && this.decryptdata.uri) {
return true;
}
return false;
}
/**
* Utility method for parseLevelPlaylist to create an initialization vector for a given segment
* @param {number} segmentNumber - segment number to generate IV with
* @returns {Uint8Array}
*/
createInitializationVector(segmentNumber: number): Uint8Array {
const uint8View = new Uint8Array(16);
for (let i = 12; i < 16; i++) {
uint8View[i] = (segmentNumber >> (8 * (15 - i))) & 0xff;
}
return uint8View;
}
/**
* Utility method for parseLevelPlaylist to get a fragment's decryption data from the currently parsed encryption key data
* @param levelkey - a playlist's encryption info
* @param segmentNumber - the fragment's segment number
* @returns {LevelKey} - an object to be applied as a fragment's decryptdata
*/
setDecryptDataFromLevelKey(
levelkey: LevelKey,
segmentNumber: number
): LevelKey {
let decryptdata = levelkey;
if (levelkey?.method === 'AES-128' && levelkey.uri && !levelkey.iv) {
decryptdata = LevelKey.fromURI(levelkey.uri);
decryptdata.method = levelkey.method;
decryptdata.iv = this.createInitializationVector(segmentNumber);
decryptdata.keyFormat = 'identity';
}
return decryptdata;
}
setElementaryStreamInfo(
type: ElementaryStreamTypes,
startPTS: number,
endPTS: number,
startDTS: number,
endDTS: number,
partial: boolean = false
) {
const { elementaryStreams } = this;
const info = elementaryStreams[type];
if (!info) {
elementaryStreams[type] = {
startPTS,
endPTS,
startDTS,
endDTS,
partial,
};
return;
}
info.startPTS = Math.min(info.startPTS, startPTS);
info.endPTS = Math.max(info.endPTS, endPTS);
info.startDTS = Math.min(info.startDTS, startDTS);
info.endDTS = Math.max(info.endDTS, endDTS);
}
clearElementaryStreamInfo() {
const { elementaryStreams } = this;
elementaryStreams[ElementaryStreamTypes.AUDIO] = null;
elementaryStreams[ElementaryStreamTypes.VIDEO] = null;
elementaryStreams[ElementaryStreamTypes.AUDIOVIDEO] = null;
}
}
export class Part extends BaseSegment {
public readonly fragOffset: number = 0;
public readonly duration: number = 0;
public readonly gap: boolean = false;
public readonly independent: boolean = false;
public readonly relurl: string;
public readonly fragment: Fragment;
public readonly index: number;
public stats: LoadStats = new LoadStats();
constructor(
partAttrs: AttrList,
frag: Fragment,
baseurl: string,
index: number,
previous?: Part
) {
super(baseurl);
this.duration = partAttrs.decimalFloatingPoint('DURATION');
this.gap = partAttrs.bool('GAP');
this.independent = partAttrs.INDEPENDENT
? partAttrs.bool('INDEPENDENT')
: true;
this.relurl = partAttrs.enumeratedString('URI') as string;
this.fragment = frag;
this.index = index;
const byteRange = partAttrs.enumeratedString('BYTERANGE');
if (byteRange) {
this.setByteRange(byteRange, previous);
}
if (previous) {
this.fragOffset = previous.fragOffset + previous.duration;
}
}
get start(): number {
return this.fragment.start + this.fragOffset;
}
get end(): number {
return this.start + this.duration;
}
get loaded(): boolean {
const { elementaryStreams } = this;
return !!(
elementaryStreams.audio ||
elementaryStreams.video ||
elementaryStreams.audiovideo
);
}
}