Skip to content

Commit 01fba1b

Browse files
authored
feat: Parse EXT-X-KEY (#179)
* Parse EXT-X-KEY * Fixed tests
1 parent 98531f5 commit 01fba1b

File tree

6 files changed

+120
-3
lines changed

6 files changed

+120
-3
lines changed

apps/stitcher/src/parser/helpers.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,32 @@ function splitByCommaWithPreservingQuotes(str: string) {
5757

5858
return list;
5959
}
60+
61+
export function hexToByteSequence(str: string): Uint8Array {
62+
if (str.startsWith("0x") || str.startsWith("0X")) {
63+
str = str.slice(2);
64+
}
65+
const numArray = new Uint8Array(str.length / 2);
66+
for (let i = 0; i < str.length; i += 2) {
67+
numArray[i / 2] = Number.parseInt(str.slice(i, i + 2), 16);
68+
}
69+
return numArray;
70+
}
71+
72+
export function byteSequenceToHex(
73+
sequence: Uint8Array,
74+
start = 0,
75+
end = sequence.byteLength,
76+
) {
77+
if (end <= start) {
78+
throw new Error("End must be larger than start");
79+
}
80+
const list: string[] = [];
81+
for (let i = start; i < end; i++) {
82+
const chunk = sequence[i];
83+
if (chunk !== undefined) {
84+
list.push(`0${(chunk & 0xff).toString(16).toUpperCase()}`.slice(-2));
85+
}
86+
}
87+
return `0x${list.join("")}`;
88+
}

apps/stitcher/src/parser/lexical-parse.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { DateTime } from "luxon";
22
import { assert } from "shared/assert";
3-
import { mapAttributes, partOf } from "./helpers";
3+
import { hexToByteSequence, mapAttributes, partOf } from "./helpers";
44
import type {
55
DateRange,
6+
Key,
67
MediaInitializationSection,
78
PlaylistType,
89
Resolution,
@@ -42,7 +43,8 @@ export type Tag =
4243
| ["EXT-X-MEDIA", Media]
4344
| ["EXT-X-MAP", MediaInitializationSection]
4445
| ["EXT-X-DATERANGE", DateRange]
45-
| ["EXT-X-CUE-OUT", CueOut];
46+
| ["EXT-X-CUE-OUT", CueOut]
47+
| ["EXT-X-KEY", Key];
4648

4749
export interface ExtInf {
4850
duration: number;
@@ -319,11 +321,53 @@ function parseLine(line: string): Tag | null {
319321
];
320322
}
321323

324+
case "EXT-X-KEY": {
325+
assert(param, "EXT-X-KEY: no param");
326+
327+
const attrs: Partial<Key> = {};
328+
329+
mapAttributes(param, (key, value) => {
330+
switch (key) {
331+
case "METHOD":
332+
case "URI":
333+
case "KEYFORMAT":
334+
case "KEYFORMATVERSION":
335+
attrs.method = value;
336+
break;
337+
338+
case "IV":
339+
attrs.iv = parseIV(value);
340+
break;
341+
}
342+
});
343+
344+
assert(attrs.method, "EXT-X-KEY: no method");
345+
346+
return [
347+
name,
348+
{
349+
method: attrs.method,
350+
uri: attrs.uri,
351+
iv: attrs.iv,
352+
format: attrs.format,
353+
formatVersion: attrs.formatVersion,
354+
},
355+
];
356+
}
357+
322358
default:
323359
return null;
324360
}
325361
}
326362

363+
function parseIV(value: string) {
364+
const iv = hexToByteSequence(value);
365+
if (iv.length !== 16) {
366+
throw new Error("IV must be a 128-bit unsigned integer");
367+
}
368+
return iv;
369+
}
370+
327371
function splitLine(line: string): [string, string | null] {
328372
const index = line.indexOf(":");
329373
if (index === -1) {

apps/stitcher/src/parser/parse.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { lexicalParse } from "./lexical-parse";
33
import type { Tag } from "./lexical-parse";
44
import type {
55
DateRange,
6+
Key,
67
MasterPlaylist,
78
MediaInitializationSection,
89
MediaPlaylist,
@@ -92,12 +93,18 @@ function formatMediaPlaylist(tags: Tag[]): MediaPlaylist {
9293
let segmentStart = -1;
9394

9495
let map: MediaInitializationSection | undefined;
96+
let key: Key | undefined;
9597
tags.forEach(([name, value], index) => {
9698
if (name === "EXT-X-MAP") {
9799
// TODO: We might be better off passing on segments to |parseSegment| and look up
98100
// the last valid map.
99101
map = value;
100102
}
103+
if (name === "EXT-X-KEY") {
104+
// TODO: We might be better off passing on segments to |parseSegment| and look up
105+
// the last valid key.
106+
key = value;
107+
}
101108

102109
// TODO: When we have EXT-X-KEY support, we're better off passing a full list of segments
103110
// to |parseSegment|, maybe?
@@ -116,7 +123,7 @@ function formatMediaPlaylist(tags: Tag[]): MediaPlaylist {
116123
const segmentTags = tags.slice(segmentStart, index + 1);
117124
const uri = nextLiteral(segmentTags, segmentTags.length - 2);
118125

119-
const segment = parseSegment(segmentTags, uri, map);
126+
const segment = parseSegment(segmentTags, uri, map, key);
120127
segments.push(segment);
121128

122129
segmentStart = -1;
@@ -166,6 +173,7 @@ function isSegmentTag(name: Tag[0]) {
166173
case "EXT-X-MAP":
167174
case "EXT-X-CUE-OUT":
168175
case "EXT-X-CUE-IN":
176+
case "EXT-X-KEY":
169177
return true;
170178
}
171179
return false;
@@ -175,6 +183,7 @@ function parseSegment(
175183
tags: Tag[],
176184
uri: string,
177185
map?: MediaInitializationSection,
186+
key?: Key,
178187
): Segment {
179188
let duration: number | undefined;
180189
let discontinuity: boolean | undefined;
@@ -206,6 +215,7 @@ function parseSegment(
206215
duration,
207216
discontinuity,
208217
map,
218+
key,
209219
programDateTime,
210220
spliceInfo,
211221
};

apps/stitcher/src/parser/stringify.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { byteSequenceToHex } from "./helpers";
12
import type {
3+
Key,
24
MasterPlaylist,
35
MediaInitializationSection,
46
MediaPlaylist,
@@ -105,6 +107,7 @@ export function stringifyMediaPlaylist(playlist: MediaPlaylist) {
105107
}
106108

107109
let lastMap: MediaInitializationSection | undefined;
110+
let lastKey: Key | undefined;
108111

109112
playlist.segments.forEach((segment) => {
110113
// See https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-16#section-4.4.4.5
@@ -117,6 +120,25 @@ export function stringifyMediaPlaylist(playlist: MediaPlaylist) {
117120
}
118121
lastMap = segment.map;
119122
}
123+
if (segment.key !== lastKey) {
124+
if (segment.key) {
125+
const attrs = [`METHOD=${segment.key.method}`];
126+
if (segment.key.uri) {
127+
attrs.push(`URI="${segment.key.uri}"`);
128+
}
129+
if (segment.key.iv) {
130+
attrs.push(`IV=${byteSequenceToHex(segment.key.iv)}`);
131+
}
132+
if (segment.key.format) {
133+
attrs.push(`KEYFORMAT="${segment.key.format}"`);
134+
}
135+
if (segment.key.formatVersion) {
136+
attrs.push(`KEYFORMATVERSIONS="${segment.key.formatVersion}"`);
137+
}
138+
lines.push(`#EXT-X-KEY:${attrs.join(",")}`);
139+
}
140+
lastKey = segment.key;
141+
}
120142

121143
if (segment.discontinuity) {
122144
lines.push(`#EXT-X-DISCONTINUITY`);

apps/stitcher/src/parser/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface Segment {
4040
duration: number;
4141
discontinuity?: boolean;
4242
map?: MediaInitializationSection;
43+
key?: Key;
4344
programDateTime?: DateTime;
4445
spliceInfo?: SpliceInfo;
4546
}
@@ -51,6 +52,14 @@ export interface SpliceInfo {
5152

5253
export type PlaylistType = "EVENT" | "VOD";
5354

55+
export interface Key {
56+
method: string;
57+
uri?: string;
58+
iv?: Uint8Array;
59+
format?: string;
60+
formatVersion?: string;
61+
}
62+
5463
export interface MediaPlaylist {
5564
independentSegments?: boolean;
5665
targetDuration: number;

apps/stitcher/test/parser/__snapshots__/parse-media.test.ts.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ exports[`parse media should parse basic 1`] = `
1212
{
1313
"discontinuity": undefined,
1414
"duration": 10,
15+
"key": undefined,
1516
"map": undefined,
1617
"programDateTime": undefined,
1718
"spliceInfo": undefined,
@@ -20,6 +21,7 @@ exports[`parse media should parse basic 1`] = `
2021
{
2122
"discontinuity": undefined,
2223
"duration": 10,
24+
"key": undefined,
2325
"map": undefined,
2426
"programDateTime": undefined,
2527
"spliceInfo": undefined,
@@ -28,6 +30,7 @@ exports[`parse media should parse basic 1`] = `
2830
{
2931
"discontinuity": undefined,
3032
"duration": 4.5,
33+
"key": undefined,
3134
"map": undefined,
3235
"programDateTime": undefined,
3336
"spliceInfo": undefined,

0 commit comments

Comments
 (0)