nixamp 0.10.3 → 0.11.1
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/enrich.d.ts +108 -0
- package/dist/enrich.js +253 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +39 -0
- package/package.json +1 -1
- package/src/enrich.ts +297 -0
- package/src/server.ts +42 -0
- package/web/dist/assets/{hls-3VKVEQE3-DR8q_kGX.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-B0VjYPAC.js → mpegts-LO6RVLD6-5rFzjEr5.js} +1 -1
- package/web/dist/assets/{mpegts-D-7xRRW6.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-D9uHBJcW.css +0 -1
- package/web/dist/assets/index-g9xRD0Ln.js +0 -1
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,253 @@
|
|
|
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 exact titles the one with a
|
|
83
|
+
// picture wins, since nichedb keeps both the IMDb row and the TMDB row of
|
|
84
|
+
// a film and only one has the poster; among the rest, the score decides.
|
|
85
|
+
if (!best)
|
|
86
|
+
best = item;
|
|
87
|
+
else if (exact && !isExact(best))
|
|
88
|
+
best = item;
|
|
89
|
+
else if (exact && isExact(best) && !best.image_url && item.image_url)
|
|
90
|
+
best = item;
|
|
91
|
+
else if (!isExact(best) && score > Number(best.score ?? 0))
|
|
92
|
+
best = item;
|
|
93
|
+
}
|
|
94
|
+
if (!best)
|
|
95
|
+
return null;
|
|
96
|
+
const kind = best.kind === "channel" || best.kind === "fixture" ? best.kind : "title";
|
|
97
|
+
const year = typeof best.data?.["year"] === "number"
|
|
98
|
+
? best.data["year"]
|
|
99
|
+
: best.published_at
|
|
100
|
+
? new Date(best.published_at).getUTCFullYear() || null
|
|
101
|
+
: null;
|
|
102
|
+
return {
|
|
103
|
+
kind,
|
|
104
|
+
title: String(best.title ?? ""),
|
|
105
|
+
year: Number.isFinite(year) ? year : null,
|
|
106
|
+
image: best.image_url ?? null,
|
|
107
|
+
summary: best.summary ?? null,
|
|
108
|
+
page: String(best.page ?? ""),
|
|
109
|
+
score: Number(best.score ?? 0),
|
|
110
|
+
data: best.data ?? {},
|
|
111
|
+
tags: best.tags ?? [],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export class Enricher {
|
|
115
|
+
options;
|
|
116
|
+
site;
|
|
117
|
+
fetcher;
|
|
118
|
+
now;
|
|
119
|
+
cache = new Map();
|
|
120
|
+
inflight = new Map();
|
|
121
|
+
saveTimer = null;
|
|
122
|
+
dirty = false;
|
|
123
|
+
constructor(options = {}) {
|
|
124
|
+
this.options = options;
|
|
125
|
+
this.site = (options.site ?? DEFAULT_SITE).replace(/\/+$/, "");
|
|
126
|
+
this.fetcher = options.fetch ?? globalThis.fetch;
|
|
127
|
+
this.now = options.now ?? Date.now;
|
|
128
|
+
this.load();
|
|
129
|
+
}
|
|
130
|
+
/** How many names are remembered. */
|
|
131
|
+
get size() {
|
|
132
|
+
return this.cache.size;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* What a name is, from the cache or from nichedb.
|
|
136
|
+
*
|
|
137
|
+
* `kind` narrows the question when the caller knows: a live channel is a
|
|
138
|
+
* channel however its name reads. `auto` lets nichedb's parser decide from
|
|
139
|
+
* the name itself, which is right for a file.
|
|
140
|
+
*/
|
|
141
|
+
async lookup(name, kind = "auto", year = null) {
|
|
142
|
+
const asked = String(name ?? "").trim();
|
|
143
|
+
if (asked === "")
|
|
144
|
+
return null;
|
|
145
|
+
const key = cacheKey(asked, kind, year);
|
|
146
|
+
const had = this.cache.get(key);
|
|
147
|
+
if (had && fresh(had, this.now()))
|
|
148
|
+
return had.hit;
|
|
149
|
+
const running = this.inflight.get(key);
|
|
150
|
+
if (running)
|
|
151
|
+
return running;
|
|
152
|
+
const work = this.ask(asked, kind, year)
|
|
153
|
+
.then((hit) => {
|
|
154
|
+
this.remember(key, hit);
|
|
155
|
+
return hit;
|
|
156
|
+
})
|
|
157
|
+
.catch((error) => {
|
|
158
|
+
this.options.onEvent?.(` nichedb did not answer for "${asked}": ${error.message}`);
|
|
159
|
+
// Not remembered: a network fault is not a miss.
|
|
160
|
+
return had?.hit ?? null;
|
|
161
|
+
})
|
|
162
|
+
.finally(() => this.inflight.delete(key));
|
|
163
|
+
this.inflight.set(key, work);
|
|
164
|
+
return work;
|
|
165
|
+
}
|
|
166
|
+
async ask(name, kind, year) {
|
|
167
|
+
// Two round trips at most: nichedb parses the name; when the caller did
|
|
168
|
+
// not say what it is, the first answer's reading says where to look.
|
|
169
|
+
const first = new URLSearchParams({ q: name, limit: "3" });
|
|
170
|
+
if (year !== null)
|
|
171
|
+
first.set("year", String(year));
|
|
172
|
+
const where = whereToAsk(kind);
|
|
173
|
+
if (where) {
|
|
174
|
+
first.set("collection", where.collection);
|
|
175
|
+
first.set("kind", where.kind);
|
|
176
|
+
}
|
|
177
|
+
const answer = await this.get(`/api/v1/match?${first}`);
|
|
178
|
+
if (where)
|
|
179
|
+
return pickBest(answer, answer.parsed?.name ?? name);
|
|
180
|
+
const guessed = whereToAsk("auto", answer.parsed?.kind);
|
|
181
|
+
if (!guessed)
|
|
182
|
+
return null;
|
|
183
|
+
const second = new URLSearchParams(first);
|
|
184
|
+
second.set("collection", guessed.collection);
|
|
185
|
+
second.set("kind", guessed.kind);
|
|
186
|
+
// The year the name carried narrows the second question.
|
|
187
|
+
if (year === null && answer.parsed?.year)
|
|
188
|
+
second.set("year", String(answer.parsed.year));
|
|
189
|
+
return pickBest(await this.get(`/api/v1/match?${second}`), answer.parsed?.name ?? name);
|
|
190
|
+
}
|
|
191
|
+
async get(path) {
|
|
192
|
+
const response = await this.fetcher(`${this.site}${path}`, {
|
|
193
|
+
headers: { accept: "application/json", "user-agent": "nixamp (+https://nixamp.com)" },
|
|
194
|
+
signal: AbortSignal.timeout(15_000),
|
|
195
|
+
});
|
|
196
|
+
if (!response.ok)
|
|
197
|
+
throw new Error(`nichedb answered ${response.status}`);
|
|
198
|
+
return (await response.json());
|
|
199
|
+
}
|
|
200
|
+
remember(key, hit) {
|
|
201
|
+
this.cache.set(key, { at: this.now(), hit });
|
|
202
|
+
if (this.cache.size > MAX_ENTRIES) {
|
|
203
|
+
// Oldest first: a Map remembers insertion order.
|
|
204
|
+
const drop = this.cache.size - MAX_ENTRIES;
|
|
205
|
+
let n = 0;
|
|
206
|
+
for (const k of this.cache.keys()) {
|
|
207
|
+
if (n++ >= drop)
|
|
208
|
+
break;
|
|
209
|
+
this.cache.delete(k);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
this.dirty = true;
|
|
213
|
+
this.scheduleSave();
|
|
214
|
+
}
|
|
215
|
+
load() {
|
|
216
|
+
if (!this.options.cacheFile)
|
|
217
|
+
return;
|
|
218
|
+
try {
|
|
219
|
+
const parsed = JSON.parse(readFileSync(this.options.cacheFile, "utf8"));
|
|
220
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
221
|
+
if (v && typeof v.at === "number")
|
|
222
|
+
this.cache.set(k, v);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// No cache yet, or one that is not JSON: start empty.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
scheduleSave() {
|
|
230
|
+
if (!this.options.cacheFile || this.saveTimer)
|
|
231
|
+
return;
|
|
232
|
+
this.saveTimer = setTimeout(() => {
|
|
233
|
+
this.saveTimer = null;
|
|
234
|
+
this.save();
|
|
235
|
+
}, 2000);
|
|
236
|
+
this.saveTimer.unref?.();
|
|
237
|
+
}
|
|
238
|
+
/** Write the cache now. Called on a timer, and by whoever is shutting down. */
|
|
239
|
+
save() {
|
|
240
|
+
if (!this.options.cacheFile || !this.dirty)
|
|
241
|
+
return;
|
|
242
|
+
try {
|
|
243
|
+
mkdirSync(dirname(this.options.cacheFile), { recursive: true });
|
|
244
|
+
const tmp = `${this.options.cacheFile}.tmp`;
|
|
245
|
+
writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.cache)));
|
|
246
|
+
renameSync(tmp, this.options.cacheFile);
|
|
247
|
+
this.dirty = false;
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
this.options.onEvent?.(` could not save the enrichment cache: ${error.message}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { Owner } from "./owner.ts";
|
|
|
12
12
|
import { Directory } from "./directory.ts";
|
|
13
13
|
import { PartyLine } from "./partyline.ts";
|
|
14
14
|
import { HlsPackagers } from "./hls.ts";
|
|
15
|
+
import { Enricher } from "./enrich.ts";
|
|
15
16
|
import { Follows } from "./follows.ts";
|
|
16
17
|
import { Favorites } from "./favorites.ts";
|
|
17
18
|
import { Catalogs } from "./catalogs.ts";
|
|
@@ -425,6 +426,8 @@ export interface HandlerOptions {
|
|
|
425
426
|
ytdlp?: string[] | null;
|
|
426
427
|
/** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */
|
|
427
428
|
hls?: HlsPackagers;
|
|
429
|
+
/** What a name is -- a film, a channel, a fixture -- asked of nichedb.dev and remembered. */
|
|
430
|
+
enricher?: Enricher;
|
|
428
431
|
/** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
|
|
429
432
|
cookies?: string;
|
|
430
433
|
/** Who is listening, for the admin view. */
|
package/dist/server.js
CHANGED
|
@@ -35,6 +35,7 @@ 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
37
|
import { HlsPackagers, withKey } from "./hls.js";
|
|
38
|
+
import { DEFAULT_SITE as NICHEDB, Enricher } from "./enrich.js";
|
|
38
39
|
import { contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat, } from "./links.js";
|
|
39
40
|
import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
|
|
40
41
|
import pg from "pg";
|
|
@@ -2317,6 +2318,35 @@ export function createHandler(engine, options) {
|
|
|
2317
2318
|
// own, the way a catalog entry is played: started for whoever asked,
|
|
2318
2319
|
// stopped a minute after the last viewer leaves. Open to anyone holding
|
|
2319
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
|
+
}
|
|
2320
2350
|
if (path === "/api/links/play" && request.method === "POST") {
|
|
2321
2351
|
let body = {};
|
|
2322
2352
|
try {
|
|
@@ -3521,6 +3551,13 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3521
3551
|
});
|
|
3522
3552
|
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
3523
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
|
+
});
|
|
3524
3561
|
const hls = new HlsPackagers({
|
|
3525
3562
|
ffmpeg: tools.ffmpeg,
|
|
3526
3563
|
listen: (id, listener) => channels.listen(id, listener),
|
|
@@ -3878,6 +3915,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3878
3915
|
ytdlp: tools.ytdlp ?? null,
|
|
3879
3916
|
cookies: cookiesFile(),
|
|
3880
3917
|
hls,
|
|
3918
|
+
enricher,
|
|
3881
3919
|
...(tls ? { tls } : {}),
|
|
3882
3920
|
// Untagged, so a directory of five thousand files answers at once; the
|
|
3883
3921
|
// tags follow through `tag` below.
|
|
@@ -4297,6 +4335,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4297
4335
|
});
|
|
4298
4336
|
const shutdown = () => {
|
|
4299
4337
|
rtmp?.stop();
|
|
4338
|
+
enricher.save();
|
|
4300
4339
|
hls.stopAll();
|
|
4301
4340
|
channels.stopAll();
|
|
4302
4341
|
ingest?.stopRtmp();
|