nixamp 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio.js +5 -1
- package/dist/enrich.d.ts +108 -0
- package/dist/enrich.js +249 -0
- package/dist/hls.d.ts +55 -0
- package/dist/hls.js +254 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +94 -1
- package/package.json +1 -1
- package/src/audio.ts +5 -1
- package/src/enrich.ts +294 -0
- package/src/hls.ts +265 -0
- package/src/server.ts +100 -1
- package/web/dist/assets/{hls-3VKVEQE3-5T3M0hCY.js → hls-3VKVEQE3-CHT5Rca0.js} +1 -1
- package/web/dist/assets/index-2_Frv88t.js +1 -0
- package/web/dist/assets/index-DPtsuCcK.css +1 -0
- package/web/dist/assets/{mpegts-LO6RVLD6-DUwEL0l2.js → mpegts-LO6RVLD6-5rFzjEr5.js} +1 -1
- package/web/dist/assets/{mpegts-DeEbMNtT.js → mpegts-sgBnfefF.js} +1 -1
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-Bqujphpd.js +0 -1
- package/web/dist/assets/index-D9uHBJcW.css +0 -1
package/dist/audio.js
CHANGED
|
@@ -408,7 +408,11 @@ export function videoArgs(codecs, capKbps = 0) {
|
|
|
408
408
|
const keepAudio = !transportStream && (codecs.audio === "aac" || codecs.audio === "mp3");
|
|
409
409
|
return [
|
|
410
410
|
"-c:v", keepVideo ? "copy" : "libx264",
|
|
411
|
-
|
|
411
|
+
// A keyframe every two seconds when encoding. A fragment starts on a
|
|
412
|
+
// keyframe, so this is how soon a joiner sees a picture -- and an HLS
|
|
413
|
+
// segment, which is cut on keyframes too, was ten seconds long on
|
|
414
|
+
// x264's default and made a phone wait thirty before it played.
|
|
415
|
+
...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", "-g", "48", "-keyint_min", "48", "-sc_threshold", "0"]),
|
|
412
416
|
"-c:a", keepAudio ? "copy" : "aac",
|
|
413
417
|
...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
|
|
414
418
|
"-f", "mp4",
|
package/dist/enrich.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Where the answers come from, unless a deployment says otherwise. */
|
|
2
|
+
export declare const DEFAULT_SITE = "https://nichedb.dev";
|
|
3
|
+
/** How long a hit is believed. Titles and channels change on the order of months. */
|
|
4
|
+
export declare const HIT_TTL_MS: number;
|
|
5
|
+
/** How long a miss is believed: nichedb's catalogue is still filling in. */
|
|
6
|
+
export declare const MISS_TTL_MS: number;
|
|
7
|
+
/** A fixture's score is stale in a minute. */
|
|
8
|
+
export declare const FIXTURE_TTL_MS: number;
|
|
9
|
+
/**
|
|
10
|
+
* Below this the best match is a guess, and a wrong poster is worse than none.
|
|
11
|
+
* Measured: "Severance" against a channel called "Sever" scored 0.45, and
|
|
12
|
+
* "Lakers at Celtics" against "Rangers at Celtic" 0.44. Half is above both.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MIN_SCORE = 0.5;
|
|
15
|
+
/** A weaker score is still taken when one name plainly begins with the other. */
|
|
16
|
+
export declare const PREFIX_SCORE = 0.42;
|
|
17
|
+
/** How many answers the cache keeps before the oldest go. */
|
|
18
|
+
export declare const MAX_ENTRIES = 5000;
|
|
19
|
+
export type EnrichKind = "auto" | "title" | "channel" | "fixture";
|
|
20
|
+
export interface Enriched {
|
|
21
|
+
/** What nichedb says it is. */
|
|
22
|
+
kind: "title" | "channel" | "fixture";
|
|
23
|
+
title: string;
|
|
24
|
+
/** For a title, its year; for a fixture, when it starts. */
|
|
25
|
+
year: number | null;
|
|
26
|
+
/** A poster, a logo, or nothing. */
|
|
27
|
+
image: string | null;
|
|
28
|
+
summary: string | null;
|
|
29
|
+
/** nichedb's page for it, for a link. */
|
|
30
|
+
page: string;
|
|
31
|
+
score: number;
|
|
32
|
+
/** The rest, as the collection shapes it: rating, genres, country, scores… */
|
|
33
|
+
data: Record<string, unknown>;
|
|
34
|
+
tags: string[];
|
|
35
|
+
}
|
|
36
|
+
interface Cached {
|
|
37
|
+
at: number;
|
|
38
|
+
hit: Enriched | null;
|
|
39
|
+
}
|
|
40
|
+
/** nichedb's answer to /api/v1/match, as much of it as is read here. */
|
|
41
|
+
interface MatchAnswer {
|
|
42
|
+
parsed?: {
|
|
43
|
+
name?: string;
|
|
44
|
+
year?: number | null;
|
|
45
|
+
kind?: string;
|
|
46
|
+
season?: number | null;
|
|
47
|
+
episode?: number | null;
|
|
48
|
+
};
|
|
49
|
+
items?: {
|
|
50
|
+
kind?: string;
|
|
51
|
+
title?: string;
|
|
52
|
+
summary?: string | null;
|
|
53
|
+
image_url?: string | null;
|
|
54
|
+
published_at?: string | null;
|
|
55
|
+
page?: string;
|
|
56
|
+
score?: number;
|
|
57
|
+
data?: Record<string, unknown>;
|
|
58
|
+
tags?: string[];
|
|
59
|
+
}[];
|
|
60
|
+
}
|
|
61
|
+
/** The collection and kind a name is asked about, from what the caller knows. */
|
|
62
|
+
export declare function whereToAsk(kind: EnrichKind, parsedKind?: string): {
|
|
63
|
+
collection: string;
|
|
64
|
+
kind: string;
|
|
65
|
+
} | null;
|
|
66
|
+
/** The key one name is remembered under: case and spacing do not make it a different name. */
|
|
67
|
+
export declare function cacheKey(name: string, kind: EnrichKind, year: number | null): string;
|
|
68
|
+
/** Whether a stored answer is still worth believing. */
|
|
69
|
+
export declare function fresh(entry: Cached, now: number): boolean;
|
|
70
|
+
/** The best of nichedb's answers, or nothing when the best is a guess. */
|
|
71
|
+
export declare function pickBest(answer: MatchAnswer, asked: string): Enriched | null;
|
|
72
|
+
export interface EnricherOptions {
|
|
73
|
+
site?: string;
|
|
74
|
+
fetch?: typeof globalThis.fetch;
|
|
75
|
+
/** Where answers are kept between runs; none means memory only. */
|
|
76
|
+
cacheFile?: string;
|
|
77
|
+
now?: () => number;
|
|
78
|
+
onEvent?: (message: string) => void;
|
|
79
|
+
}
|
|
80
|
+
export declare class Enricher {
|
|
81
|
+
private readonly options;
|
|
82
|
+
private readonly site;
|
|
83
|
+
private readonly fetcher;
|
|
84
|
+
private readonly now;
|
|
85
|
+
private readonly cache;
|
|
86
|
+
private readonly inflight;
|
|
87
|
+
private saveTimer;
|
|
88
|
+
private dirty;
|
|
89
|
+
constructor(options?: EnricherOptions);
|
|
90
|
+
/** How many names are remembered. */
|
|
91
|
+
get size(): number;
|
|
92
|
+
/**
|
|
93
|
+
* What a name is, from the cache or from nichedb.
|
|
94
|
+
*
|
|
95
|
+
* `kind` narrows the question when the caller knows: a live channel is a
|
|
96
|
+
* channel however its name reads. `auto` lets nichedb's parser decide from
|
|
97
|
+
* the name itself, which is right for a file.
|
|
98
|
+
*/
|
|
99
|
+
lookup(name: string, kind?: EnrichKind, year?: number | null): Promise<Enriched | null>;
|
|
100
|
+
private ask;
|
|
101
|
+
private get;
|
|
102
|
+
private remember;
|
|
103
|
+
private load;
|
|
104
|
+
private scheduleSave;
|
|
105
|
+
/** Write the cache now. Called on a timer, and by whoever is shutting down. */
|
|
106
|
+
save(): void;
|
|
107
|
+
}
|
|
108
|
+
export {};
|
package/dist/enrich.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is this, really?
|
|
3
|
+
*
|
|
4
|
+
* A file called "Top.Gun.Maverick.2022.1080p.WEB-DL.mkv" is a film with a
|
|
5
|
+
* poster, a year and a rating; a playlist entry called "US: ESPN2 HD" is a
|
|
6
|
+
* channel with a logo, a country and a category; "Lakers at Celtics" is a
|
|
7
|
+
* fixture with a score. nixamp knows none of that on its own -- ffprobe reads
|
|
8
|
+
* tags, and a torrent's tags are its file name -- so it asks nichedb.dev,
|
|
9
|
+
* which keeps the titles, channels and fixtures every profullstack site is
|
|
10
|
+
* built on, and answers a name with the best match and a score.
|
|
11
|
+
*
|
|
12
|
+
* Asked once per name and remembered: a library of five thousand files must
|
|
13
|
+
* not become five thousand requests a day, and a channel that was ESPN2
|
|
14
|
+
* yesterday is ESPN2 today. Misses are remembered too, for less long, so a
|
|
15
|
+
* file nichedb has never heard of is not asked about every time it plays.
|
|
16
|
+
*/
|
|
17
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname } from "node:path";
|
|
19
|
+
/** Where the answers come from, unless a deployment says otherwise. */
|
|
20
|
+
export const DEFAULT_SITE = "https://nichedb.dev";
|
|
21
|
+
/** How long a hit is believed. Titles and channels change on the order of months. */
|
|
22
|
+
export const HIT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
|
+
/** How long a miss is believed: nichedb's catalogue is still filling in. */
|
|
24
|
+
export const MISS_TTL_MS = 6 * 60 * 60 * 1000;
|
|
25
|
+
/** A fixture's score is stale in a minute. */
|
|
26
|
+
export const FIXTURE_TTL_MS = 60 * 1000;
|
|
27
|
+
/**
|
|
28
|
+
* Below this the best match is a guess, and a wrong poster is worse than none.
|
|
29
|
+
* Measured: "Severance" against a channel called "Sever" scored 0.45, and
|
|
30
|
+
* "Lakers at Celtics" against "Rangers at Celtic" 0.44. Half is above both.
|
|
31
|
+
*/
|
|
32
|
+
export const MIN_SCORE = 0.5;
|
|
33
|
+
/** A weaker score is still taken when one name plainly begins with the other. */
|
|
34
|
+
export const PREFIX_SCORE = 0.42;
|
|
35
|
+
/** How many answers the cache keeps before the oldest go. */
|
|
36
|
+
export const MAX_ENTRIES = 5000;
|
|
37
|
+
/** The collection and kind a name is asked about, from what the caller knows. */
|
|
38
|
+
export function whereToAsk(kind, parsedKind) {
|
|
39
|
+
const k = kind === "auto" ? parsedKind ?? "" : kind;
|
|
40
|
+
switch (k) {
|
|
41
|
+
case "channel":
|
|
42
|
+
return { collection: "channels", kind: "channel" };
|
|
43
|
+
case "fixture":
|
|
44
|
+
return { collection: "sports", kind: "fixture" };
|
|
45
|
+
case "title":
|
|
46
|
+
case "movie":
|
|
47
|
+
case "series":
|
|
48
|
+
return { collection: "screen", kind: "title" };
|
|
49
|
+
default:
|
|
50
|
+
// Music and the rest: nichedb has no answer worth a poster yet.
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The key one name is remembered under: case and spacing do not make it a different name. */
|
|
55
|
+
export function cacheKey(name, kind, year) {
|
|
56
|
+
return `${kind}|${year ?? ""}|${name.trim().toLowerCase().replace(/\s+/g, " ")}`;
|
|
57
|
+
}
|
|
58
|
+
/** Whether a stored answer is still worth believing. */
|
|
59
|
+
export function fresh(entry, now) {
|
|
60
|
+
const ttl = entry.hit === null ? MISS_TTL_MS : entry.hit.kind === "fixture" ? FIXTURE_TTL_MS : HIT_TTL_MS;
|
|
61
|
+
return now - entry.at < ttl;
|
|
62
|
+
}
|
|
63
|
+
/** The best of nichedb's answers, or nothing when the best is a guess. */
|
|
64
|
+
export function pickBest(answer, asked) {
|
|
65
|
+
const items = answer.items ?? [];
|
|
66
|
+
const wanted = asked.trim().toLowerCase();
|
|
67
|
+
let best;
|
|
68
|
+
const isExact = (item) => item !== undefined && String(item.title ?? "").toLowerCase() === wanted;
|
|
69
|
+
const plain = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
70
|
+
const askedPlain = plain(wanted);
|
|
71
|
+
for (const item of items) {
|
|
72
|
+
const exact = isExact(item);
|
|
73
|
+
const score = Number(item.score ?? 0);
|
|
74
|
+
const titlePlain = plain(String(item.title ?? ""));
|
|
75
|
+
// Whole words: "Top Gun Maverick Extended" begins with "Top Gun Maverick",
|
|
76
|
+
// but "Severance" does not begin with a channel called "Sever".
|
|
77
|
+
const prefix = titlePlain.length >= 4 &&
|
|
78
|
+
askedPlain.length >= 4 &&
|
|
79
|
+
(askedPlain.startsWith(`${titlePlain} `) || titlePlain.startsWith(`${askedPlain} `));
|
|
80
|
+
if (!exact && score < (prefix ? PREFIX_SCORE : MIN_SCORE))
|
|
81
|
+
continue;
|
|
82
|
+
// An exact title beats any score; among the rest, the score decides.
|
|
83
|
+
if (!best)
|
|
84
|
+
best = item;
|
|
85
|
+
else if (exact && !isExact(best))
|
|
86
|
+
best = item;
|
|
87
|
+
else if (!isExact(best) && score > Number(best.score ?? 0))
|
|
88
|
+
best = item;
|
|
89
|
+
}
|
|
90
|
+
if (!best)
|
|
91
|
+
return null;
|
|
92
|
+
const kind = best.kind === "channel" || best.kind === "fixture" ? best.kind : "title";
|
|
93
|
+
const year = typeof best.data?.["year"] === "number"
|
|
94
|
+
? best.data["year"]
|
|
95
|
+
: best.published_at
|
|
96
|
+
? new Date(best.published_at).getUTCFullYear() || null
|
|
97
|
+
: null;
|
|
98
|
+
return {
|
|
99
|
+
kind,
|
|
100
|
+
title: String(best.title ?? ""),
|
|
101
|
+
year: Number.isFinite(year) ? year : null,
|
|
102
|
+
image: best.image_url ?? null,
|
|
103
|
+
summary: best.summary ?? null,
|
|
104
|
+
page: String(best.page ?? ""),
|
|
105
|
+
score: Number(best.score ?? 0),
|
|
106
|
+
data: best.data ?? {},
|
|
107
|
+
tags: best.tags ?? [],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export class Enricher {
|
|
111
|
+
options;
|
|
112
|
+
site;
|
|
113
|
+
fetcher;
|
|
114
|
+
now;
|
|
115
|
+
cache = new Map();
|
|
116
|
+
inflight = new Map();
|
|
117
|
+
saveTimer = null;
|
|
118
|
+
dirty = false;
|
|
119
|
+
constructor(options = {}) {
|
|
120
|
+
this.options = options;
|
|
121
|
+
this.site = (options.site ?? DEFAULT_SITE).replace(/\/+$/, "");
|
|
122
|
+
this.fetcher = options.fetch ?? globalThis.fetch;
|
|
123
|
+
this.now = options.now ?? Date.now;
|
|
124
|
+
this.load();
|
|
125
|
+
}
|
|
126
|
+
/** How many names are remembered. */
|
|
127
|
+
get size() {
|
|
128
|
+
return this.cache.size;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* What a name is, from the cache or from nichedb.
|
|
132
|
+
*
|
|
133
|
+
* `kind` narrows the question when the caller knows: a live channel is a
|
|
134
|
+
* channel however its name reads. `auto` lets nichedb's parser decide from
|
|
135
|
+
* the name itself, which is right for a file.
|
|
136
|
+
*/
|
|
137
|
+
async lookup(name, kind = "auto", year = null) {
|
|
138
|
+
const asked = String(name ?? "").trim();
|
|
139
|
+
if (asked === "")
|
|
140
|
+
return null;
|
|
141
|
+
const key = cacheKey(asked, kind, year);
|
|
142
|
+
const had = this.cache.get(key);
|
|
143
|
+
if (had && fresh(had, this.now()))
|
|
144
|
+
return had.hit;
|
|
145
|
+
const running = this.inflight.get(key);
|
|
146
|
+
if (running)
|
|
147
|
+
return running;
|
|
148
|
+
const work = this.ask(asked, kind, year)
|
|
149
|
+
.then((hit) => {
|
|
150
|
+
this.remember(key, hit);
|
|
151
|
+
return hit;
|
|
152
|
+
})
|
|
153
|
+
.catch((error) => {
|
|
154
|
+
this.options.onEvent?.(` nichedb did not answer for "${asked}": ${error.message}`);
|
|
155
|
+
// Not remembered: a network fault is not a miss.
|
|
156
|
+
return had?.hit ?? null;
|
|
157
|
+
})
|
|
158
|
+
.finally(() => this.inflight.delete(key));
|
|
159
|
+
this.inflight.set(key, work);
|
|
160
|
+
return work;
|
|
161
|
+
}
|
|
162
|
+
async ask(name, kind, year) {
|
|
163
|
+
// Two round trips at most: nichedb parses the name; when the caller did
|
|
164
|
+
// not say what it is, the first answer's reading says where to look.
|
|
165
|
+
const first = new URLSearchParams({ q: name, limit: "3" });
|
|
166
|
+
if (year !== null)
|
|
167
|
+
first.set("year", String(year));
|
|
168
|
+
const where = whereToAsk(kind);
|
|
169
|
+
if (where) {
|
|
170
|
+
first.set("collection", where.collection);
|
|
171
|
+
first.set("kind", where.kind);
|
|
172
|
+
}
|
|
173
|
+
const answer = await this.get(`/api/v1/match?${first}`);
|
|
174
|
+
if (where)
|
|
175
|
+
return pickBest(answer, answer.parsed?.name ?? name);
|
|
176
|
+
const guessed = whereToAsk("auto", answer.parsed?.kind);
|
|
177
|
+
if (!guessed)
|
|
178
|
+
return null;
|
|
179
|
+
const second = new URLSearchParams(first);
|
|
180
|
+
second.set("collection", guessed.collection);
|
|
181
|
+
second.set("kind", guessed.kind);
|
|
182
|
+
// The year the name carried narrows the second question.
|
|
183
|
+
if (year === null && answer.parsed?.year)
|
|
184
|
+
second.set("year", String(answer.parsed.year));
|
|
185
|
+
return pickBest(await this.get(`/api/v1/match?${second}`), answer.parsed?.name ?? name);
|
|
186
|
+
}
|
|
187
|
+
async get(path) {
|
|
188
|
+
const response = await this.fetcher(`${this.site}${path}`, {
|
|
189
|
+
headers: { accept: "application/json", "user-agent": "nixamp (+https://nixamp.com)" },
|
|
190
|
+
signal: AbortSignal.timeout(15_000),
|
|
191
|
+
});
|
|
192
|
+
if (!response.ok)
|
|
193
|
+
throw new Error(`nichedb answered ${response.status}`);
|
|
194
|
+
return (await response.json());
|
|
195
|
+
}
|
|
196
|
+
remember(key, hit) {
|
|
197
|
+
this.cache.set(key, { at: this.now(), hit });
|
|
198
|
+
if (this.cache.size > MAX_ENTRIES) {
|
|
199
|
+
// Oldest first: a Map remembers insertion order.
|
|
200
|
+
const drop = this.cache.size - MAX_ENTRIES;
|
|
201
|
+
let n = 0;
|
|
202
|
+
for (const k of this.cache.keys()) {
|
|
203
|
+
if (n++ >= drop)
|
|
204
|
+
break;
|
|
205
|
+
this.cache.delete(k);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
this.dirty = true;
|
|
209
|
+
this.scheduleSave();
|
|
210
|
+
}
|
|
211
|
+
load() {
|
|
212
|
+
if (!this.options.cacheFile)
|
|
213
|
+
return;
|
|
214
|
+
try {
|
|
215
|
+
const parsed = JSON.parse(readFileSync(this.options.cacheFile, "utf8"));
|
|
216
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
217
|
+
if (v && typeof v.at === "number")
|
|
218
|
+
this.cache.set(k, v);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// No cache yet, or one that is not JSON: start empty.
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
scheduleSave() {
|
|
226
|
+
if (!this.options.cacheFile || this.saveTimer)
|
|
227
|
+
return;
|
|
228
|
+
this.saveTimer = setTimeout(() => {
|
|
229
|
+
this.saveTimer = null;
|
|
230
|
+
this.save();
|
|
231
|
+
}, 2000);
|
|
232
|
+
this.saveTimer.unref?.();
|
|
233
|
+
}
|
|
234
|
+
/** Write the cache now. Called on a timer, and by whoever is shutting down. */
|
|
235
|
+
save() {
|
|
236
|
+
if (!this.options.cacheFile || !this.dirty)
|
|
237
|
+
return;
|
|
238
|
+
try {
|
|
239
|
+
mkdirSync(dirname(this.options.cacheFile), { recursive: true });
|
|
240
|
+
const tmp = `${this.options.cacheFile}.tmp`;
|
|
241
|
+
writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.cache)));
|
|
242
|
+
renameSync(tmp, this.options.cacheFile);
|
|
243
|
+
this.dirty = false;
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
this.options.onEvent?.(` could not save the enrichment cache: ${error.message}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
package/dist/hls.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Seconds of video per segment. Short, so joining is quick; long enough that a playlist is not churn. */
|
|
2
|
+
export declare const SEGMENT_SECONDS = 2;
|
|
3
|
+
/** How many segments the playlist offers. About twelve seconds of catch-up. */
|
|
4
|
+
export declare const PLAYLIST_SEGMENTS = 6;
|
|
5
|
+
/** How long a packager runs with nobody asking for its playlist. */
|
|
6
|
+
export declare const IDLE_MS = 60000;
|
|
7
|
+
/** How long the first playlist may take to appear before it is a failure. */
|
|
8
|
+
export declare const FIRST_PLAYLIST_MS = 20000;
|
|
9
|
+
/** A segment file name, or "" for anything that is not one. Never a path. */
|
|
10
|
+
export declare function segmentName(requested: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* The playlist with the key on every segment.
|
|
13
|
+
*
|
|
14
|
+
* A browser resolves segment names against the playlist's URL and drops its
|
|
15
|
+
* query, so the key that opened the playlist never reaches the segments and
|
|
16
|
+
* each one answers 401. The key travels on every line instead.
|
|
17
|
+
*/
|
|
18
|
+
export declare function withKey(playlist: string, key: string): string;
|
|
19
|
+
/** The ffmpeg arguments: copy what arrives on stdin into a rolling playlist. */
|
|
20
|
+
export declare function packagerArgs(dir: string): string[];
|
|
21
|
+
export interface Packaged {
|
|
22
|
+
/** Feed it the channel's bytes: the header first, then every fragment. */
|
|
23
|
+
write(chunk: Buffer): boolean;
|
|
24
|
+
/** The channel ended; finish and stop. */
|
|
25
|
+
end(): void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The packagers, one per channel that somebody is watching this way.
|
|
29
|
+
*
|
|
30
|
+
* `listen` is how a packager becomes a listener on its channel: it is handed
|
|
31
|
+
* the header and then every fragment, exactly as a browser would be.
|
|
32
|
+
*/
|
|
33
|
+
export declare class HlsPackagers {
|
|
34
|
+
private readonly options;
|
|
35
|
+
private readonly running;
|
|
36
|
+
constructor(options: {
|
|
37
|
+
ffmpeg: string[];
|
|
38
|
+
listen: (id: string, listener: Packaged) => (() => void) | null;
|
|
39
|
+
onEvent?: (message: string) => void;
|
|
40
|
+
/** Injected for tests: how long to wait for the first playlist. */
|
|
41
|
+
firstPlaylistMs?: number;
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* The playlist for a channel, starting the packager if it is not running,
|
|
45
|
+
* and waiting for the first segments to exist. Null when the channel is not
|
|
46
|
+
* there or nothing could be packaged.
|
|
47
|
+
*/
|
|
48
|
+
playlist(id: string): Promise<string | null>;
|
|
49
|
+
/** A segment's path, or "" when there is no such segment. */
|
|
50
|
+
segment(id: string, name: string): string;
|
|
51
|
+
/** The channel went: stop packaging it. */
|
|
52
|
+
stop(id: string): void;
|
|
53
|
+
stopAll(): void;
|
|
54
|
+
get count(): number;
|
|
55
|
+
}
|
package/dist/hls.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A channel as HLS, for the browsers that will not take it any other way.
|
|
3
|
+
*
|
|
4
|
+
* A channel is sent as one endless fragmented MP4 down a chunked response,
|
|
5
|
+
* which Chrome and Firefox play as they would a file. Safari on an iPhone
|
|
6
|
+
* will not: it asks for a byte range, gets a stream with no length, and gives
|
|
7
|
+
* up before the first picture -- a spinner that flashes and a player that
|
|
8
|
+
* never starts. What Safari plays live is HLS, a playlist of short files.
|
|
9
|
+
*
|
|
10
|
+
* So this packages the same bytes into HLS on demand: one ffmpeg per channel,
|
|
11
|
+
* copying (never re-encoding) the fragments it is fed into two-second
|
|
12
|
+
* MPEG-TS segments in a temporary directory, started when the first playlist
|
|
13
|
+
* is asked for and stopped a minute after the last. The channel itself is
|
|
14
|
+
* untouched; this is one more listener on it.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
/** Seconds of video per segment. Short, so joining is quick; long enough that a playlist is not churn. */
|
|
21
|
+
export const SEGMENT_SECONDS = 2;
|
|
22
|
+
/** How many segments the playlist offers. About twelve seconds of catch-up. */
|
|
23
|
+
export const PLAYLIST_SEGMENTS = 6;
|
|
24
|
+
/** How long a packager runs with nobody asking for its playlist. */
|
|
25
|
+
export const IDLE_MS = 60_000;
|
|
26
|
+
/** How long the first playlist may take to appear before it is a failure. */
|
|
27
|
+
export const FIRST_PLAYLIST_MS = 20_000;
|
|
28
|
+
const SEGMENT = /^seg\d{5}\.ts$/;
|
|
29
|
+
/** A segment file name, or "" for anything that is not one. Never a path. */
|
|
30
|
+
export function segmentName(requested) {
|
|
31
|
+
return SEGMENT.test(requested) ? requested : "";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The playlist with the key on every segment.
|
|
35
|
+
*
|
|
36
|
+
* A browser resolves segment names against the playlist's URL and drops its
|
|
37
|
+
* query, so the key that opened the playlist never reaches the segments and
|
|
38
|
+
* each one answers 401. The key travels on every line instead.
|
|
39
|
+
*/
|
|
40
|
+
export function withKey(playlist, key) {
|
|
41
|
+
if (key === "")
|
|
42
|
+
return playlist;
|
|
43
|
+
return playlist
|
|
44
|
+
.split("\n")
|
|
45
|
+
.map((line) => (line !== "" && !line.startsWith("#") ? `${line}?k=${encodeURIComponent(key)}` : line))
|
|
46
|
+
.join("\n");
|
|
47
|
+
}
|
|
48
|
+
/** The ffmpeg arguments: copy what arrives on stdin into a rolling playlist. */
|
|
49
|
+
export function packagerArgs(dir) {
|
|
50
|
+
return [
|
|
51
|
+
"-hide_banner",
|
|
52
|
+
"-loglevel", "error",
|
|
53
|
+
"-i", "pipe:0",
|
|
54
|
+
"-c", "copy",
|
|
55
|
+
"-f", "hls",
|
|
56
|
+
"-hls_time", String(SEGMENT_SECONDS),
|
|
57
|
+
"-hls_list_size", String(PLAYLIST_SEGMENTS),
|
|
58
|
+
// Old segments go; the playlist never ends; each segment starts on a
|
|
59
|
+
// keyframe so a joiner can begin anywhere; written whole then renamed so
|
|
60
|
+
// a request never reads half a file.
|
|
61
|
+
"-hls_flags", "delete_segments+omit_endlist+independent_segments+temp_file",
|
|
62
|
+
"-hls_segment_type", "mpegts",
|
|
63
|
+
"-hls_segment_filename", join(dir, "seg%05d.ts"),
|
|
64
|
+
join(dir, "index.m3u8"),
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
/** One channel's packager. */
|
|
68
|
+
class Packager {
|
|
69
|
+
id;
|
|
70
|
+
ffmpeg;
|
|
71
|
+
onStop;
|
|
72
|
+
onEvent;
|
|
73
|
+
dir;
|
|
74
|
+
child = null;
|
|
75
|
+
idle = null;
|
|
76
|
+
stopped = false;
|
|
77
|
+
detach = null;
|
|
78
|
+
constructor(id, ffmpeg, onStop, onEvent) {
|
|
79
|
+
this.id = id;
|
|
80
|
+
this.ffmpeg = ffmpeg;
|
|
81
|
+
this.onStop = onStop;
|
|
82
|
+
this.onEvent = onEvent;
|
|
83
|
+
this.dir = mkdtempSync(join(tmpdir(), `nixamp-hls-${id}-`));
|
|
84
|
+
}
|
|
85
|
+
start(listen) {
|
|
86
|
+
const [command, ...prefix] = this.ffmpeg;
|
|
87
|
+
try {
|
|
88
|
+
this.child = spawn(command, [...prefix, ...packagerArgs(this.dir)], { stdio: ["pipe", "ignore", "pipe"] });
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
this.onEvent(` HLS for "${this.id}" could not start: ${error.message}`);
|
|
92
|
+
this.stop();
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
let complaint = "";
|
|
96
|
+
this.child.stderr?.on("data", (chunk) => {
|
|
97
|
+
if (complaint.length < 4000)
|
|
98
|
+
complaint += chunk.toString("utf8");
|
|
99
|
+
});
|
|
100
|
+
this.child.stdin?.on("error", () => undefined);
|
|
101
|
+
this.child.on("error", (error) => {
|
|
102
|
+
this.onEvent(` HLS for "${this.id}" failed: ${error.message}`);
|
|
103
|
+
this.stop();
|
|
104
|
+
});
|
|
105
|
+
this.child.on("close", (code) => {
|
|
106
|
+
if (!this.stopped && code !== 0) {
|
|
107
|
+
this.onEvent(` HLS for "${this.id}" stopped: ${complaint.trim().split("\n").pop() ?? code}`);
|
|
108
|
+
}
|
|
109
|
+
this.stop();
|
|
110
|
+
});
|
|
111
|
+
this.detach = listen(this);
|
|
112
|
+
if (this.detach === null) {
|
|
113
|
+
this.stop();
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
this.touch();
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
write(chunk) {
|
|
120
|
+
const stdin = this.child?.stdin;
|
|
121
|
+
if (!stdin || stdin.destroyed)
|
|
122
|
+
return false;
|
|
123
|
+
try {
|
|
124
|
+
stdin.write(chunk);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
end() {
|
|
132
|
+
try {
|
|
133
|
+
this.child?.stdin?.end();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Already gone.
|
|
137
|
+
}
|
|
138
|
+
this.stop();
|
|
139
|
+
}
|
|
140
|
+
/** Somebody asked for the playlist: it is wanted for another minute. */
|
|
141
|
+
touch() {
|
|
142
|
+
if (this.idle)
|
|
143
|
+
clearTimeout(this.idle);
|
|
144
|
+
this.idle = setTimeout(() => this.stop(), IDLE_MS);
|
|
145
|
+
this.idle.unref?.();
|
|
146
|
+
}
|
|
147
|
+
/** The playlist as it stands, or "" before the first segment is written. */
|
|
148
|
+
playlist() {
|
|
149
|
+
try {
|
|
150
|
+
const text = readFileSync(join(this.dir, "index.m3u8"), "utf8");
|
|
151
|
+
return text.includes("#EXTINF") ? text : "";
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** A segment's path, or "" when it is not there (any more). */
|
|
158
|
+
segment(name) {
|
|
159
|
+
const safe = segmentName(name);
|
|
160
|
+
if (safe === "")
|
|
161
|
+
return "";
|
|
162
|
+
const path = join(this.dir, safe);
|
|
163
|
+
try {
|
|
164
|
+
return statSync(path).isFile() ? path : "";
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
stop() {
|
|
171
|
+
if (this.stopped)
|
|
172
|
+
return;
|
|
173
|
+
this.stopped = true;
|
|
174
|
+
if (this.idle)
|
|
175
|
+
clearTimeout(this.idle);
|
|
176
|
+
this.idle = null;
|
|
177
|
+
this.detach?.();
|
|
178
|
+
this.detach = null;
|
|
179
|
+
const child = this.child;
|
|
180
|
+
this.child = null;
|
|
181
|
+
if (child && child.exitCode === null) {
|
|
182
|
+
try {
|
|
183
|
+
child.stdin?.end();
|
|
184
|
+
child.kill("SIGKILL");
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Already gone.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
rmSync(this.dir, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// A directory that is already gone is fine.
|
|
195
|
+
}
|
|
196
|
+
this.onStop(this.id);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The packagers, one per channel that somebody is watching this way.
|
|
201
|
+
*
|
|
202
|
+
* `listen` is how a packager becomes a listener on its channel: it is handed
|
|
203
|
+
* the header and then every fragment, exactly as a browser would be.
|
|
204
|
+
*/
|
|
205
|
+
export class HlsPackagers {
|
|
206
|
+
options;
|
|
207
|
+
running = new Map();
|
|
208
|
+
constructor(options) {
|
|
209
|
+
this.options = options;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The playlist for a channel, starting the packager if it is not running,
|
|
213
|
+
* and waiting for the first segments to exist. Null when the channel is not
|
|
214
|
+
* there or nothing could be packaged.
|
|
215
|
+
*/
|
|
216
|
+
async playlist(id) {
|
|
217
|
+
let packager = this.running.get(id);
|
|
218
|
+
if (!packager) {
|
|
219
|
+
packager = new Packager(id, this.options.ffmpeg, (gone) => this.running.delete(gone), this.options.onEvent ?? (() => undefined));
|
|
220
|
+
this.running.set(id, packager);
|
|
221
|
+
if (!packager.start((listener) => this.options.listen(id, listener)))
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
packager.touch();
|
|
225
|
+
const deadline = Date.now() + (this.options.firstPlaylistMs ?? FIRST_PLAYLIST_MS);
|
|
226
|
+
for (;;) {
|
|
227
|
+
const text = packager.playlist();
|
|
228
|
+
if (text !== "")
|
|
229
|
+
return text;
|
|
230
|
+
if (Date.now() > deadline || !this.running.has(id))
|
|
231
|
+
return null;
|
|
232
|
+
await new Promise((done) => setTimeout(done, 250));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** A segment's path, or "" when there is no such segment. */
|
|
236
|
+
segment(id, name) {
|
|
237
|
+
const packager = this.running.get(id);
|
|
238
|
+
if (!packager)
|
|
239
|
+
return "";
|
|
240
|
+
packager.touch();
|
|
241
|
+
return packager.segment(name);
|
|
242
|
+
}
|
|
243
|
+
/** The channel went: stop packaging it. */
|
|
244
|
+
stop(id) {
|
|
245
|
+
this.running.get(id)?.stop();
|
|
246
|
+
}
|
|
247
|
+
stopAll() {
|
|
248
|
+
for (const packager of [...this.running.values()])
|
|
249
|
+
packager.stop();
|
|
250
|
+
}
|
|
251
|
+
get count() {
|
|
252
|
+
return this.running.size;
|
|
253
|
+
}
|
|
254
|
+
}
|