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/server.d.ts CHANGED
@@ -11,6 +11,8 @@ import { SignIn } from "./oauth.ts";
11
11
  import { Owner } from "./owner.ts";
12
12
  import { Directory } from "./directory.ts";
13
13
  import { PartyLine } from "./partyline.ts";
14
+ import { HlsPackagers } from "./hls.ts";
15
+ import { Enricher } from "./enrich.ts";
14
16
  import { Follows } from "./follows.ts";
15
17
  import { Favorites } from "./favorites.ts";
16
18
  import { Catalogs } from "./catalogs.ts";
@@ -422,6 +424,10 @@ export interface HandlerOptions {
422
424
  ffprobe?: string[];
423
425
  /** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
424
426
  ytdlp?: string[] | null;
427
+ /** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */
428
+ hls?: HlsPackagers;
429
+ /** What a name is -- a film, a channel, a fixture -- asked of nichedb.dev and remembered. */
430
+ enricher?: Enricher;
425
431
  /** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
426
432
  cookies?: string;
427
433
  /** Who is listening, for the admin view. */
package/dist/server.js CHANGED
@@ -34,6 +34,8 @@ import { stateDir } from "./daemon.js";
34
34
  import { readSession } from "./session.js";
35
35
  import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
36
36
  import { PartyLine, telnyxSms } from "./partyline.js";
37
+ import { HlsPackagers, withKey } from "./hls.js";
38
+ import { DEFAULT_SITE as NICHEDB, Enricher } from "./enrich.js";
37
39
  import { contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat, } from "./links.js";
38
40
  import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
39
41
  import pg from "pg";
@@ -2316,6 +2318,35 @@ export function createHandler(engine, options) {
2316
2318
  // own, the way a catalog entry is played: started for whoever asked,
2317
2319
  // stopped a minute after the last viewer leaves. Open to anyone holding
2318
2320
  // the link, like picking something from a catalog.
2321
+ // --- what is this? ------------------------------------------------------
2322
+ //
2323
+ // A file name, a playlist entry, a channel: the poster, the logo, the
2324
+ // year, the rating, from nichedb.dev, remembered here so a library is
2325
+ // asked about once. Open to whoever holds the link, like the playlist.
2326
+ if (path === "/api/enrich" && request.method === "GET") {
2327
+ const name = (url.searchParams.get("name") ?? "").trim().slice(0, 300);
2328
+ if (name === "") {
2329
+ json(response, 400, { error: "name is required" });
2330
+ return;
2331
+ }
2332
+ if (!options.enricher) {
2333
+ json(response, 200, { match: null });
2334
+ return;
2335
+ }
2336
+ const kinds = ["auto", "title", "channel", "fixture"];
2337
+ const asked = url.searchParams.get("kind") ?? "auto";
2338
+ const kind = kinds.includes(asked) ? asked : "auto";
2339
+ const year = Number(url.searchParams.get("year")) || null;
2340
+ const match = await options.enricher.lookup(name, kind, year);
2341
+ response.writeHead(200, {
2342
+ ...CORS,
2343
+ "content-type": "application/json; charset=utf-8",
2344
+ // A miss is worth asking again in a few hours; a hit lasts the day.
2345
+ "cache-control": match ? "public, max-age=3600" : "public, max-age=600",
2346
+ });
2347
+ response.end(JSON.stringify({ match }));
2348
+ return;
2349
+ }
2319
2350
  if (path === "/api/links/play" && request.method === "POST") {
2320
2351
  let body = {};
2321
2352
  try {
@@ -2441,8 +2472,52 @@ export function createHandler(engine, options) {
2441
2472
  if (path.startsWith("/api/channels/") && options.channels) {
2442
2473
  const channels = options.channels;
2443
2474
  const rest = path.slice("/api/channels/".length);
2444
- const [rawId, action] = rest.split("/");
2475
+ const [rawId, action, file] = rest.split("/");
2445
2476
  const id = cleanId(rawId);
2477
+ // The same channel as HLS: a playlist of short files, which is what
2478
+ // Safari on an iPhone plays live -- it will not take the endless MP4
2479
+ // below, and spun on it a few times before giving up. Packaged on
2480
+ // demand, copying the fragments the browser would have got.
2481
+ if (action === "hls" && request.method === "GET") {
2482
+ if (!options.hls) {
2483
+ json(response, 503, { error: "this server cannot package HLS" });
2484
+ return;
2485
+ }
2486
+ if (!channels.has(id)) {
2487
+ json(response, 404, { error: "nothing is playing on that channel" });
2488
+ return;
2489
+ }
2490
+ if (file === "index.m3u8") {
2491
+ const playlist = await options.hls.playlist(id);
2492
+ if (playlist === null) {
2493
+ json(response, 503, { error: "that channel could not be packaged as HLS yet; try again in a moment" });
2494
+ return;
2495
+ }
2496
+ response.writeHead(200, {
2497
+ ...CORS,
2498
+ "content-type": "application/vnd.apple.mpegurl",
2499
+ "cache-control": "no-store",
2500
+ });
2501
+ // The key rides on every segment line: a browser drops the query
2502
+ // when it resolves a segment against the playlist.
2503
+ response.end(withKey(playlist, url.searchParams.get("k") ?? ""));
2504
+ return;
2505
+ }
2506
+ const segment = options.hls.segment(id, file ?? "");
2507
+ if (segment === "") {
2508
+ json(response, 404, { error: "no such segment" });
2509
+ return;
2510
+ }
2511
+ watch(request, response, "stream", id);
2512
+ response.writeHead(200, {
2513
+ ...CORS,
2514
+ "content-type": "video/mp2t",
2515
+ "cache-control": "no-store",
2516
+ "content-length": statSync(segment).size,
2517
+ });
2518
+ createReadStream(segment).pipe(response);
2519
+ return;
2520
+ }
2446
2521
  if (action === undefined && request.method === "GET") {
2447
2522
  // Listening. The response is the fan-out target: whatever ffmpeg
2448
2523
  // produces for this channel is written to it until one end goes away.
@@ -3474,6 +3549,20 @@ export async function serve(argv, version = "0.1.0") {
3474
3549
  onStart: (info) => console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
3475
3550
  onEnd: (info) => console.log(` "${info.id}" stopped.`),
3476
3551
  });
3552
+ // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
3553
+ // channel's fragments into short files while somebody is asking for them.
3554
+ // What things are, from nichedb.dev, remembered beside the keys so a
3555
+ // library is asked about once across restarts.
3556
+ const enricher = new Enricher({
3557
+ site: process.env["NIXAMP_NICHEDB"] || NICHEDB,
3558
+ cacheFile: join(stateDir(), "enrich.json"),
3559
+ onEvent: (message) => console.log(message),
3560
+ });
3561
+ const hls = new HlsPackagers({
3562
+ ffmpeg: tools.ffmpeg,
3563
+ listen: (id, listener) => channels.listen(id, listener),
3564
+ onEvent: (message) => console.log(message),
3565
+ });
3477
3566
  // The channels this server was carrying when it was last stopped, put back
3478
3567
  // on. A server is restarted to pick up a new version, which is often, and
3479
3568
  // every restart used to take CNN off the air until somebody noticed.
@@ -3825,6 +3914,8 @@ export async function serve(argv, version = "0.1.0") {
3825
3914
  // for the sites that will not talk to a datacenter without one.
3826
3915
  ytdlp: tools.ytdlp ?? null,
3827
3916
  cookies: cookiesFile(),
3917
+ hls,
3918
+ enricher,
3828
3919
  ...(tls ? { tls } : {}),
3829
3920
  // Untagged, so a directory of five thousand files answers at once; the
3830
3921
  // tags follow through `tag` below.
@@ -4244,6 +4335,8 @@ export async function serve(argv, version = "0.1.0") {
4244
4335
  });
4245
4336
  const shutdown = () => {
4246
4337
  rtmp?.stop();
4338
+ enricher.save();
4339
+ hls.stopAll();
4247
4340
  channels.stopAll();
4248
4341
  ingest?.stopRtmp();
4249
4342
  ingest?.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/audio.ts CHANGED
@@ -468,7 +468,11 @@ export function videoArgs(codecs: Codecs, capKbps = 0): string[] {
468
468
  const keepAudio = !transportStream && (codecs.audio === "aac" || codecs.audio === "mp3");
469
469
  return [
470
470
  "-c:v", keepVideo ? "copy" : "libx264",
471
- ...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
471
+ // A keyframe every two seconds when encoding. A fragment starts on a
472
+ // keyframe, so this is how soon a joiner sees a picture -- and an HLS
473
+ // segment, which is cut on keyframes too, was ten seconds long on
474
+ // x264's default and made a phone wait thirty before it played.
475
+ ...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", "-g", "48", "-keyint_min", "48", "-sc_threshold", "0"]),
472
476
  "-c:a", keepAudio ? "copy" : "aac",
473
477
  ...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
474
478
  "-f", "mp4",
package/src/enrich.ts ADDED
@@ -0,0 +1,294 @@
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
+
20
+ /** Where the answers come from, unless a deployment says otherwise. */
21
+ export const DEFAULT_SITE = "https://nichedb.dev";
22
+ /** How long a hit is believed. Titles and channels change on the order of months. */
23
+ export const HIT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
24
+ /** How long a miss is believed: nichedb's catalogue is still filling in. */
25
+ export const MISS_TTL_MS = 6 * 60 * 60 * 1000;
26
+ /** A fixture's score is stale in a minute. */
27
+ export const FIXTURE_TTL_MS = 60 * 1000;
28
+ /**
29
+ * Below this the best match is a guess, and a wrong poster is worse than none.
30
+ * Measured: "Severance" against a channel called "Sever" scored 0.45, and
31
+ * "Lakers at Celtics" against "Rangers at Celtic" 0.44. Half is above both.
32
+ */
33
+ export const MIN_SCORE = 0.5;
34
+ /** A weaker score is still taken when one name plainly begins with the other. */
35
+ export const PREFIX_SCORE = 0.42;
36
+ /** How many answers the cache keeps before the oldest go. */
37
+ export const MAX_ENTRIES = 5000;
38
+
39
+ export type EnrichKind = "auto" | "title" | "channel" | "fixture";
40
+
41
+ export interface Enriched {
42
+ /** What nichedb says it is. */
43
+ kind: "title" | "channel" | "fixture";
44
+ title: string;
45
+ /** For a title, its year; for a fixture, when it starts. */
46
+ year: number | null;
47
+ /** A poster, a logo, or nothing. */
48
+ image: string | null;
49
+ summary: string | null;
50
+ /** nichedb's page for it, for a link. */
51
+ page: string;
52
+ score: number;
53
+ /** The rest, as the collection shapes it: rating, genres, country, scores… */
54
+ data: Record<string, unknown>;
55
+ tags: string[];
56
+ }
57
+
58
+ interface Cached {
59
+ at: number;
60
+ hit: Enriched | null;
61
+ }
62
+
63
+ /** nichedb's answer to /api/v1/match, as much of it as is read here. */
64
+ interface MatchAnswer {
65
+ parsed?: { name?: string; year?: number | null; kind?: string; season?: number | null; episode?: number | null };
66
+ items?: {
67
+ kind?: string;
68
+ title?: string;
69
+ summary?: string | null;
70
+ image_url?: string | null;
71
+ published_at?: string | null;
72
+ page?: string;
73
+ score?: number;
74
+ data?: Record<string, unknown>;
75
+ tags?: string[];
76
+ }[];
77
+ }
78
+
79
+ /** The collection and kind a name is asked about, from what the caller knows. */
80
+ export function whereToAsk(kind: EnrichKind, parsedKind?: string): { collection: string; kind: string } | null {
81
+ const k = kind === "auto" ? parsedKind ?? "" : kind;
82
+ switch (k) {
83
+ case "channel":
84
+ return { collection: "channels", kind: "channel" };
85
+ case "fixture":
86
+ return { collection: "sports", kind: "fixture" };
87
+ case "title":
88
+ case "movie":
89
+ case "series":
90
+ return { collection: "screen", kind: "title" };
91
+ default:
92
+ // Music and the rest: nichedb has no answer worth a poster yet.
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /** The key one name is remembered under: case and spacing do not make it a different name. */
98
+ export function cacheKey(name: string, kind: EnrichKind, year: number | null): string {
99
+ return `${kind}|${year ?? ""}|${name.trim().toLowerCase().replace(/\s+/g, " ")}`;
100
+ }
101
+
102
+ /** Whether a stored answer is still worth believing. */
103
+ export function fresh(entry: Cached, now: number): boolean {
104
+ const ttl = entry.hit === null ? MISS_TTL_MS : entry.hit.kind === "fixture" ? FIXTURE_TTL_MS : HIT_TTL_MS;
105
+ return now - entry.at < ttl;
106
+ }
107
+
108
+ /** The best of nichedb's answers, or nothing when the best is a guess. */
109
+ export function pickBest(answer: MatchAnswer, asked: string): Enriched | null {
110
+ const items = answer.items ?? [];
111
+ const wanted = asked.trim().toLowerCase();
112
+ type Item = NonNullable<MatchAnswer["items"]>[number];
113
+ let best: Item | undefined;
114
+ const isExact = (item: Item | undefined): boolean =>
115
+ item !== undefined && String(item.title ?? "").toLowerCase() === wanted;
116
+ const plain = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
117
+ const askedPlain = plain(wanted);
118
+ for (const item of items) {
119
+ const exact = isExact(item);
120
+ const score = Number(item.score ?? 0);
121
+ const titlePlain = plain(String(item.title ?? ""));
122
+ // Whole words: "Top Gun Maverick Extended" begins with "Top Gun Maverick",
123
+ // but "Severance" does not begin with a channel called "Sever".
124
+ const prefix =
125
+ titlePlain.length >= 4 &&
126
+ askedPlain.length >= 4 &&
127
+ (askedPlain.startsWith(`${titlePlain} `) || titlePlain.startsWith(`${askedPlain} `));
128
+ if (!exact && score < (prefix ? PREFIX_SCORE : MIN_SCORE)) continue;
129
+ // An exact title beats any score; among the rest, the score decides.
130
+ if (!best) best = item;
131
+ else if (exact && !isExact(best)) best = item;
132
+ else if (!isExact(best) && score > Number(best.score ?? 0)) best = item;
133
+ }
134
+ if (!best) return null;
135
+ const kind = best.kind === "channel" || best.kind === "fixture" ? best.kind : "title";
136
+ const year = typeof best.data?.["year"] === "number"
137
+ ? (best.data["year"] as number)
138
+ : best.published_at
139
+ ? new Date(best.published_at).getUTCFullYear() || null
140
+ : null;
141
+ return {
142
+ kind,
143
+ title: String(best.title ?? ""),
144
+ year: Number.isFinite(year) ? year : null,
145
+ image: best.image_url ?? null,
146
+ summary: best.summary ?? null,
147
+ page: String(best.page ?? ""),
148
+ score: Number(best.score ?? 0),
149
+ data: best.data ?? {},
150
+ tags: best.tags ?? [],
151
+ };
152
+ }
153
+
154
+ export interface EnricherOptions {
155
+ site?: string;
156
+ fetch?: typeof globalThis.fetch;
157
+ /** Where answers are kept between runs; none means memory only. */
158
+ cacheFile?: string;
159
+ now?: () => number;
160
+ onEvent?: (message: string) => void;
161
+ }
162
+
163
+ export class Enricher {
164
+ private readonly site: string;
165
+ private readonly fetcher: typeof globalThis.fetch;
166
+ private readonly now: () => number;
167
+ private readonly cache = new Map<string, Cached>();
168
+ private readonly inflight = new Map<string, Promise<Enriched | null>>();
169
+ private saveTimer: ReturnType<typeof setTimeout> | null = null;
170
+ private dirty = false;
171
+
172
+ constructor(private readonly options: EnricherOptions = {}) {
173
+ this.site = (options.site ?? DEFAULT_SITE).replace(/\/+$/, "");
174
+ this.fetcher = options.fetch ?? globalThis.fetch;
175
+ this.now = options.now ?? Date.now;
176
+ this.load();
177
+ }
178
+
179
+ /** How many names are remembered. */
180
+ get size(): number {
181
+ return this.cache.size;
182
+ }
183
+
184
+ /**
185
+ * What a name is, from the cache or from nichedb.
186
+ *
187
+ * `kind` narrows the question when the caller knows: a live channel is a
188
+ * channel however its name reads. `auto` lets nichedb's parser decide from
189
+ * the name itself, which is right for a file.
190
+ */
191
+ async lookup(name: string, kind: EnrichKind = "auto", year: number | null = null): Promise<Enriched | null> {
192
+ const asked = String(name ?? "").trim();
193
+ if (asked === "") return null;
194
+ const key = cacheKey(asked, kind, year);
195
+ const had = this.cache.get(key);
196
+ if (had && fresh(had, this.now())) return had.hit;
197
+ const running = this.inflight.get(key);
198
+ if (running) return running;
199
+ const work = this.ask(asked, kind, year)
200
+ .then((hit) => {
201
+ this.remember(key, hit);
202
+ return hit;
203
+ })
204
+ .catch((error: unknown) => {
205
+ this.options.onEvent?.(` nichedb did not answer for "${asked}": ${(error as Error).message}`);
206
+ // Not remembered: a network fault is not a miss.
207
+ return had?.hit ?? null;
208
+ })
209
+ .finally(() => this.inflight.delete(key));
210
+ this.inflight.set(key, work);
211
+ return work;
212
+ }
213
+
214
+ private async ask(name: string, kind: EnrichKind, year: number | null): Promise<Enriched | null> {
215
+ // Two round trips at most: nichedb parses the name; when the caller did
216
+ // not say what it is, the first answer's reading says where to look.
217
+ const first = new URLSearchParams({ q: name, limit: "3" });
218
+ if (year !== null) first.set("year", String(year));
219
+ const where = whereToAsk(kind);
220
+ if (where) {
221
+ first.set("collection", where.collection);
222
+ first.set("kind", where.kind);
223
+ }
224
+ const answer = await this.get(`/api/v1/match?${first}`);
225
+ if (where) return pickBest(answer, answer.parsed?.name ?? name);
226
+ const guessed = whereToAsk("auto", answer.parsed?.kind);
227
+ if (!guessed) return null;
228
+ const second = new URLSearchParams(first);
229
+ second.set("collection", guessed.collection);
230
+ second.set("kind", guessed.kind);
231
+ // The year the name carried narrows the second question.
232
+ if (year === null && answer.parsed?.year) second.set("year", String(answer.parsed.year));
233
+ return pickBest(await this.get(`/api/v1/match?${second}`), answer.parsed?.name ?? name);
234
+ }
235
+
236
+ private async get(path: string): Promise<MatchAnswer> {
237
+ const response = await this.fetcher(`${this.site}${path}`, {
238
+ headers: { accept: "application/json", "user-agent": "nixamp (+https://nixamp.com)" },
239
+ signal: AbortSignal.timeout(15_000),
240
+ });
241
+ if (!response.ok) throw new Error(`nichedb answered ${response.status}`);
242
+ return (await response.json()) as MatchAnswer;
243
+ }
244
+
245
+ private remember(key: string, hit: Enriched | null): void {
246
+ this.cache.set(key, { at: this.now(), hit });
247
+ if (this.cache.size > MAX_ENTRIES) {
248
+ // Oldest first: a Map remembers insertion order.
249
+ const drop = this.cache.size - MAX_ENTRIES;
250
+ let n = 0;
251
+ for (const k of this.cache.keys()) {
252
+ if (n++ >= drop) break;
253
+ this.cache.delete(k);
254
+ }
255
+ }
256
+ this.dirty = true;
257
+ this.scheduleSave();
258
+ }
259
+
260
+ private load(): void {
261
+ if (!this.options.cacheFile) return;
262
+ try {
263
+ const parsed = JSON.parse(readFileSync(this.options.cacheFile, "utf8")) as Record<string, Cached>;
264
+ for (const [k, v] of Object.entries(parsed)) {
265
+ if (v && typeof v.at === "number") this.cache.set(k, v);
266
+ }
267
+ } catch {
268
+ // No cache yet, or one that is not JSON: start empty.
269
+ }
270
+ }
271
+
272
+ private scheduleSave(): void {
273
+ if (!this.options.cacheFile || this.saveTimer) return;
274
+ this.saveTimer = setTimeout(() => {
275
+ this.saveTimer = null;
276
+ this.save();
277
+ }, 2000);
278
+ this.saveTimer.unref?.();
279
+ }
280
+
281
+ /** Write the cache now. Called on a timer, and by whoever is shutting down. */
282
+ save(): void {
283
+ if (!this.options.cacheFile || !this.dirty) return;
284
+ try {
285
+ mkdirSync(dirname(this.options.cacheFile), { recursive: true });
286
+ const tmp = `${this.options.cacheFile}.tmp`;
287
+ writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.cache)));
288
+ renameSync(tmp, this.options.cacheFile);
289
+ this.dirty = false;
290
+ } catch (error) {
291
+ this.options.onEvent?.(` could not save the enrichment cache: ${(error as Error).message}`);
292
+ }
293
+ }
294
+ }