Skip to content

Commit

Permalink
feat: HLS parser - renditions & variants (#146)
Browse files Browse the repository at this point in the history
* Make uri optional

* Fix EXT-X-MAP

* Fixed tests
  • Loading branch information
matvp91 authored Dec 13, 2024
1 parent 2b59303 commit 99ec221
Show file tree
Hide file tree
Showing 11 changed files with 253 additions and 329 deletions.
12 changes: 8 additions & 4 deletions packages/stitcher/src/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,14 @@ export function filterMasterPlaylist(master: MasterPlaylist, filter: Filter) {
}
if (filter.audioLanguage !== undefined) {
const list = parseFilterToList(filter.audioLanguage);
master.variants.filter((variant) => {
variant.audio = variant.audio.filter(
(audio) => !audio.language || list.includes(audio.language),
);
master.renditions = master.renditions.filter((rendition) => {
if (rendition.type === "AUDIO") {
if (rendition.language && list.includes(rendition.language)) {
return true;
}
return false;
}
return true;
});
}
}
15 changes: 0 additions & 15 deletions packages/stitcher/src/parser/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,3 @@
import type { Rendition, Variant } from "./types";

export function getRenditions(variants: Variant[]) {
const group = new Set<Rendition>();
variants.forEach((variant) => {
variant.audio.forEach((rendition) => {
group.add(rendition);
});
variant.subtitles.forEach((rendition) => {
group.add(rendition);
});
});
return Array.from(group.values());
}

export function mapAttributes(
param: string,
callback: (key: string, value: string) => void,
Expand Down
1 change: 0 additions & 1 deletion packages/stitcher/src/parser/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export { parseMasterPlaylist, parseMediaPlaylist } from "./parse";
export { stringifyMasterPlaylist, stringifyMediaPlaylist } from "./stringify";
export { getRenditions } from "./helpers";

export * from "./types";
7 changes: 2 additions & 5 deletions packages/stitcher/src/parser/lexical-parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,12 @@ export interface StreamInf {
subtitles?: string;
}

export type MediaType = "AUDIO" | "SUBTITLES";

export interface Media {
type: MediaType;
type: "AUDIO" | "SUBTITLES";
groupId: string;
name: string;
language?: string;
uri: string;
uri?: string;
channels?: string;
}

Expand Down Expand Up @@ -187,7 +185,6 @@ function parseLine(line: string): Tag | null {
assert(attrs.type, "EXT-X-MEDIA: no type");
assert(attrs.groupId, "EXT-X-MEDIA: no groupId");
assert(attrs.name, "EXT-X-MEDIA: no name");
assert(attrs.uri, "EXT-X-MEDIA: no uri");

return [
name,
Expand Down
191 changes: 71 additions & 120 deletions packages/stitcher/src/parser/parse.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { assert } from "shared/assert";
import { lexicalParse } from "./lexical-parse";
import type { Media, StreamInf, Tag } from "./lexical-parse";
import type { Tag } from "./lexical-parse";
import type {
DateRange,
MasterPlaylist,
Expand All @@ -13,14 +13,52 @@ import type {
} from "./types";
import type { DateTime } from "luxon";

function formatMasterPlaylist(tags: Tag[]): MasterPlaylist {
let independentSegments = false;
const variants: Variant[] = [];
const renditions: Rendition[] = [];

tags.forEach(([name, value], index) => {
if (name === "EXT-X-INDEPENDENT-SEGMENTS") {
independentSegments = true;
}
if (name === "EXT-X-MEDIA") {
renditions.push({
type: value.type,
groupId: value.groupId,
name: value.name,
uri: value.uri,
channels: value.channels,
language: value.language,
});
}
if (name === "EXT-X-STREAM-INF") {
const uri = nextLiteral(tags, index);
variants.push({
uri,
bandwidth: value.bandwidth,
resolution: value.resolution,
codecs: value.codecs,
audio: value.audio,
subtitles: value.subtitles,
});
}
});

return {
independentSegments,
variants,
renditions,
};
}

function formatMediaPlaylist(tags: Tag[]): MediaPlaylist {
let targetDuration: number | undefined;
let endlist = false;
let playlistType: PlaylistType | undefined;
let independentSegments = false;
let mediaSequenceBase: number | undefined;
let discontinuitySequenceBase: number | undefined;
let map: MediaInitializationSection | undefined;
const dateRanges: DateRange[] = [];

tags.forEach(([name, value]) => {
Expand All @@ -33,9 +71,6 @@ function formatMediaPlaylist(tags: Tag[]): MediaPlaylist {
if (name === "EXT-X-PLAYLIST-TYPE") {
playlistType = value;
}
if (name === "EXT-X-MAP") {
map = value;
}
if (name === "EXT-X-INDEPENDENT-SEGMENTS") {
independentSegments = true;
}
Expand All @@ -53,7 +88,12 @@ function formatMediaPlaylist(tags: Tag[]): MediaPlaylist {
const segments: Segment[] = [];
let segmentStart = -1;

tags.forEach(([name], index) => {
let map: MediaInitializationSection | undefined;
tags.forEach(([name, value], index) => {
if (name === "EXT-X-MAP") {
map = value;
}

if (isSegmentTag(name)) {
segmentStart = index - 1;
}
Expand Down Expand Up @@ -86,6 +126,31 @@ function formatMediaPlaylist(tags: Tag[]): MediaPlaylist {
};
}

function nextLiteral(tags: Tag[], index: number) {
if (!tags[index + 1]) {
throw new Error("Expecting next tag to be found");
}
const tag = tags[index + 1];
if (!tag) {
throw new Error(`Expected valid tag on ${index + 1}`);
}
const [name, value] = tag;
if (name !== "LITERAL") {
throw new Error("Expecting next tag to be a literal");
}
return value;
}

function isSegmentTag(name: Tag[0]) {
switch (name) {
case "EXTINF":
case "EXT-X-DISCONTINUITY":
case "EXT-X-PROGRAM-DATE-TIME":
return true;
}
return false;
}

function parseSegment(
tags: Tag[],
uri: string,
Expand Down Expand Up @@ -118,120 +183,6 @@ function parseSegment(
};
}

function createRendition(media: Media, renditions: Map<string, Rendition>) {
let rendition = renditions.get(media.uri);
if (rendition) {
return rendition;
}

rendition = {
type: media.type,
groupId: media.groupId,
name: media.name,
language: media.language,
uri: media.uri,
channels: media.channels,
};

renditions.set(media.uri, rendition);

return rendition;
}

function addRendition(
variant: Variant,
media: Media,
renditions: Map<string, Rendition>,
) {
const rendition = createRendition(media, renditions);

if (media.type === "AUDIO") {
variant.audio.push(rendition);
}

if (media.type === "SUBTITLES") {
variant.subtitles.push(rendition);
}
}

function parseVariant(
tags: Tag[],
streamInf: StreamInf,
uri: string,
renditions: Map<string, Rendition>,
) {
const variant: Variant = {
uri,
bandwidth: streamInf.bandwidth,
resolution: streamInf.resolution,
codecs: streamInf.codecs,
audio: [],
subtitles: [],
};

for (const [name, value] of tags) {
if (name === "EXT-X-MEDIA") {
if (
streamInf.audio === value.groupId ||
streamInf.subtitles === value.groupId
) {
addRendition(variant, value, renditions);
}
}
}

return variant;
}

function formatMasterPlaylist(tags: Tag[]): MasterPlaylist {
const variants: Variant[] = [];
let independentSegments = false;

const renditions = new Map<string, Rendition>();

tags.forEach(([name, value], index) => {
if (name === "EXT-X-STREAM-INF") {
const uri = nextLiteral(tags, index);
const variant = parseVariant(tags, value, uri, renditions);
variants.push(variant);
}
if (name === "EXT-X-INDEPENDENT-SEGMENTS") {
independentSegments = true;
}
});

return {
independentSegments,
variants,
};
}

function nextLiteral(tags: Tag[], index: number) {
if (!tags[index + 1]) {
throw new Error("Expecting next tag to be found");
}
const tag = tags[index + 1];
if (!tag) {
throw new Error(`Expected valid tag on ${index + 1}`);
}
const [name, value] = tag;
if (name !== "LITERAL") {
throw new Error("Expecting next tag to be a literal");
}
return value;
}

function isSegmentTag(name: Tag[0]) {
switch (name) {
case "EXTINF":
case "EXT-X-DISCONTINUITY":
case "EXT-X-MAP":
case "EXT-X-PROGRAM-DATE-TIME":
return true;
}
return false;
}

export function parseMasterPlaylist(text: string) {
const tags = lexicalParse(text);
return formatMasterPlaylist(tags);
Expand Down
Loading

0 comments on commit 99ec221

Please sign in to comment.