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/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
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
|
-
|
|
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
|
+
}
|