nixamp 0.34.0 → 0.36.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/auto-stations.d.ts +66 -0
- package/dist/auto-stations.js +108 -0
- package/dist/channels.d.ts +1 -1
- package/dist/library-index.d.ts +2 -0
- package/dist/library-index.js +8 -0
- package/dist/library.d.ts +2 -0
- package/dist/library.js +1 -0
- package/dist/playlist-input.d.ts +9 -0
- package/dist/playlist-input.js +30 -8
- package/dist/server.d.ts +7 -0
- package/dist/server.js +102 -1
- package/dist/station-breaks.d.ts +19 -0
- package/dist/station-breaks.js +92 -0
- package/dist/station-cli.js +22 -0
- package/dist/station-guide.d.ts +4 -0
- package/dist/station-guide.js +86 -0
- package/dist/station-mcp.d.ts +99 -0
- package/dist/station-mcp.js +16 -0
- package/dist/station-metering.d.ts +59 -0
- package/dist/station-metering.js +189 -0
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +20 -5
- package/dist/station-routes.d.ts +13 -0
- package/dist/station-routes.js +90 -3
- package/dist/station-runner.d.ts +9 -0
- package/dist/station-runner.js +15 -0
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +2 -0
- package/dist/stations.d.ts +14 -0
- package/dist/stations.js +26 -0
- package/package.json +1 -1
- package/src/auto-stations.ts +171 -0
- package/src/channels.ts +1 -1
- package/src/library-index.ts +5 -0
- package/src/library.ts +3 -0
- package/src/playlist-input.ts +31 -8
- package/src/server.ts +106 -2
- package/src/station-breaks.ts +93 -0
- package/src/station-cli.ts +21 -0
- package/src/station-guide.ts +92 -0
- package/src/station-mcp.ts +16 -0
- package/src/station-metering.ts +218 -0
- package/src/station-page.ts +20 -5
- package/src/station-routes.ts +98 -3
- package/src/station-runner.ts +20 -0
- package/src/station-usage.ts +2 -0
- package/src/stations.ts +37 -0
- package/web/dist/assets/{ar-DoW0wbjC.js → ar-Ck-FmDpQ.js} +1 -1
- package/web/dist/assets/{cs-CjKqCjy5.js → cs-CPJY3K69.js} +1 -1
- package/web/dist/assets/{da-BQERb7Qz.js → da-BnWtLL09.js} +1 -1
- package/web/dist/assets/{de-CKKbRy6c.js → de-CHevIt93.js} +1 -1
- package/web/dist/assets/{es-CL3v5Og9.js → es-B6wpaUMv.js} +1 -1
- package/web/dist/assets/{fi-DgV81qNU.js → fi-8fKiz2AA.js} +1 -1
- package/web/dist/assets/{fr-BGumytzF.js → fr-0GgY52Sm.js} +1 -1
- package/web/dist/assets/{hi-CAQ9elqo.js → hi-BlsrUAw-.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-Ceri0flt.js → hls-3VKVEQE3-BZd3zjlr.js} +1 -1
- package/web/dist/assets/{hu-UJNgJVWX.js → hu-D3i_6i5w.js} +1 -1
- package/web/dist/assets/{id-BqQOlrF-.js → id-CLLAXbOP.js} +1 -1
- package/web/dist/assets/{it-Cs8G6mvW.js → it-BU13BsSn.js} +1 -1
- package/web/dist/assets/{main-Y1Rvn1GX.js → main-C1ggd2H7.js} +2 -2
- package/web/dist/assets/{mpegts-jS_Diizz.js → mpegts-BaoQnMCf.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CPI8xaZ-.js → mpegts-LO6RVLD6-YBT-W6xc.js} +1 -1
- package/web/dist/assets/{nl-BJXQ9nqo.js → nl-DEshyyt7.js} +1 -1
- package/web/dist/assets/radio-BpH1qMMQ.js +1 -0
- package/web/dist/assets/{ru-XmVkvNY8.js → ru-D7sPOc6m.js} +1 -1
- package/web/dist/assets/{sv-pVm9p66e.js → sv-sdrsKAiG.js} +1 -1
- package/web/dist/assets/{uk-4hcyaD3e.js → uk-_lz6hkvo.js} +1 -1
- package/web/dist/assets/{vi-B7MipBlv.js → vi-D81wl7uk.js} +1 -1
- package/web/dist/assets/{zh-B5YVExOW.js → zh-BqMqEom6.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +1 -1
- package/web/dist/sw.js +23 -23
- package/web/dist/assets/radio-B58WgC1j.js +0 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto stations: a station for every genre the library has, kept up to date.
|
|
3
|
+
*
|
|
4
|
+
* Radio from the music's own genre tags, folded into genres a listener would
|
|
5
|
+
* recognise ("Rap/Hip-Hop", "Hip Hop" and "Gangsta" are all Hip-Hop; "Pop,
|
|
6
|
+
* Rock, Metal" is in three). TV from the genres nichedb knows for each film.
|
|
7
|
+
* Lectures, interviews and talk are not music and stay out of radio.
|
|
8
|
+
*
|
|
9
|
+
* Each genre's station is found again by its key on the next sync and its
|
|
10
|
+
* files replaced, so new music lands in the right station by itself and a
|
|
11
|
+
* station's link never changes. Nothing is published: an auto station is
|
|
12
|
+
* unlisted, a link for whoever the owner hands it to. Making one public is
|
|
13
|
+
* the owner's decision, with the rights basis that asks for.
|
|
14
|
+
*
|
|
15
|
+
* TV costs a core per running station (it is re-encoded), so only the
|
|
16
|
+
* biggest few run; the rest are made and wait, stopped.
|
|
17
|
+
*/
|
|
18
|
+
import type { LibraryIndex } from "./library-index.ts";
|
|
19
|
+
import type { StationRunner } from "./station-runner.ts";
|
|
20
|
+
import type { StationStore } from "./stations.ts";
|
|
21
|
+
/** The genres a tag belongs to, recognisably named; [] when it is not music or not known. */
|
|
22
|
+
export declare function genreBuckets(raw: string): string[];
|
|
23
|
+
export interface AutoOptions {
|
|
24
|
+
/** Radio from tags; TV from nichedb's genres. */
|
|
25
|
+
video: boolean;
|
|
26
|
+
/** A genre needs at least this many files (radio) to get a station. */
|
|
27
|
+
minItems: number;
|
|
28
|
+
/** Films for a TV station. */
|
|
29
|
+
minFilms: number;
|
|
30
|
+
start: boolean;
|
|
31
|
+
/** TV stations running at once. */
|
|
32
|
+
maxVideoRunning: number;
|
|
33
|
+
/** Seconds between breaks on the stations this makes. */
|
|
34
|
+
breaksEvery: number;
|
|
35
|
+
}
|
|
36
|
+
export declare const DEFAULT_AUTO: AutoOptions;
|
|
37
|
+
export interface AutoReport {
|
|
38
|
+
radio: Array<{
|
|
39
|
+
genre: string;
|
|
40
|
+
id: string;
|
|
41
|
+
files: number;
|
|
42
|
+
made: boolean;
|
|
43
|
+
running: boolean;
|
|
44
|
+
}>;
|
|
45
|
+
tv: Array<{
|
|
46
|
+
genre: string;
|
|
47
|
+
id: string;
|
|
48
|
+
films: number;
|
|
49
|
+
made: boolean;
|
|
50
|
+
running: boolean;
|
|
51
|
+
}>;
|
|
52
|
+
skipped: string[];
|
|
53
|
+
}
|
|
54
|
+
export interface AutoDeps {
|
|
55
|
+
index: LibraryIndex;
|
|
56
|
+
store: StationStore;
|
|
57
|
+
runner: StationRunner;
|
|
58
|
+
owner: string;
|
|
59
|
+
/** nichedb's genres for a film, from the enrichment cache or a lookup; [] when unknown. */
|
|
60
|
+
filmGenres?: (title: string) => Promise<string[]>;
|
|
61
|
+
/** Whether another station may be started now (the server's running limit). */
|
|
62
|
+
mayStart: () => boolean;
|
|
63
|
+
log?: (line: string) => void;
|
|
64
|
+
}
|
|
65
|
+
/** Make or update a station per genre; start what the limits allow. */
|
|
66
|
+
export declare function syncAutoStations(deps: AutoDeps, options?: AutoOptions): Promise<AutoReport>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { shown } from "./library-index.js";
|
|
2
|
+
const RULES = [
|
|
3
|
+
[/metal|thrash|trash|doom|death|grind|neue deutsche|h(a|ä)rte|djent|metalcore/i, "Metal"],
|
|
4
|
+
[/punk|hardcore|emo|screamo/i, "Punk"],
|
|
5
|
+
[/hip.?hop|\brap\b|gangsta|trap|grime|drill/i, "Hip-Hop"],
|
|
6
|
+
[/industrial/i, "Industrial"],
|
|
7
|
+
[/electro|techno|house|edm|dance|trance|ambient|drum.?n.?bass|dubstep|synth/i, "Electronic"],
|
|
8
|
+
[/reggae|\bska\b|\bdub\b|dancehall/i, "Reggae"],
|
|
9
|
+
[/jazz|swing|bebop/i, "Jazz"],
|
|
10
|
+
[/blues/i, "Blues"],
|
|
11
|
+
[/classical|symphon|orchestr|baroque|opera/i, "Classical"],
|
|
12
|
+
[/country|folk|bluegrass|americana/i, "Country & Folk"],
|
|
13
|
+
[/soul|r&b|rnb|funk|motown|gospel/i, "Soul & R&B"],
|
|
14
|
+
[/latin|reggaeton|salsa|bachata|cumbia/i, "Latin"],
|
|
15
|
+
[/alternat|indie|ind(e|é)|grunge|shoegaze/i, "Alternative"],
|
|
16
|
+
[/rock/i, "Rock"],
|
|
17
|
+
[/pop/i, "Pop"],
|
|
18
|
+
];
|
|
19
|
+
const NOT_MUSIC = /lecture|interview|audiobook|podcast|speech|spoken|talk|course|sermon|lesson|comedy/i;
|
|
20
|
+
/** The genres a tag belongs to, recognisably named; [] when it is not music or not known. */
|
|
21
|
+
export function genreBuckets(raw) {
|
|
22
|
+
if (!raw || NOT_MUSIC.test(raw))
|
|
23
|
+
return [];
|
|
24
|
+
const out = new Set();
|
|
25
|
+
for (const part of raw.split(/[,;/|]+|\s+&\s+/)) {
|
|
26
|
+
const hit = RULES.find(([pattern]) => pattern.test(part));
|
|
27
|
+
if (hit)
|
|
28
|
+
out.add(hit[1]);
|
|
29
|
+
}
|
|
30
|
+
return [...out];
|
|
31
|
+
}
|
|
32
|
+
export const DEFAULT_AUTO = { video: true, minItems: 10, minFilms: 3, start: true, maxVideoRunning: 2, breaksEvery: 600 };
|
|
33
|
+
function order(items) {
|
|
34
|
+
return [...items].sort((a, b) => {
|
|
35
|
+
const x = shown(a), y = shown(b);
|
|
36
|
+
return (x.albumArtist || x.artist).localeCompare(y.albumArtist || y.artist) || x.album.localeCompare(y.album) || x.disc - y.disc || x.track - y.track || a.rel.localeCompare(b.rel);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async function upsert(deps, key, name, paths, video, options) {
|
|
40
|
+
const existing = deps.store.byAuto(key);
|
|
41
|
+
if (existing) {
|
|
42
|
+
const same = existing.items.length === paths.length && existing.items.every((one, at) => one === paths[at]);
|
|
43
|
+
return { station: same ? existing : deps.store.update(existing.id, {}, paths), made: false };
|
|
44
|
+
}
|
|
45
|
+
const station = deps.store.create({ name, shuffle: true, repeat: true, video, breaksEvery: options.breaksEvery }, paths, deps.owner);
|
|
46
|
+
deps.store.markAuto(station.id, key);
|
|
47
|
+
// A link, not a listing: unlisted needs no rights basis and publishes nothing.
|
|
48
|
+
deps.store.setAccess(station.id, { visibility: "unlisted" }, deps.owner || "auto");
|
|
49
|
+
return { station, made: true };
|
|
50
|
+
}
|
|
51
|
+
/** Make or update a station per genre; start what the limits allow. */
|
|
52
|
+
export async function syncAutoStations(deps, options = DEFAULT_AUTO) {
|
|
53
|
+
const report = { radio: [], tv: [], skipped: [] };
|
|
54
|
+
const ready = deps.index.search({ state: "ready", limit: 500, offset: 0 });
|
|
55
|
+
// search() pages at 500; walk them all.
|
|
56
|
+
const all = [...ready.items];
|
|
57
|
+
for (let offset = 500; offset < ready.total; offset += 500)
|
|
58
|
+
all.push(...deps.index.search({ state: "ready", limit: 500, offset }).items);
|
|
59
|
+
const radio = new Map();
|
|
60
|
+
for (const item of all) {
|
|
61
|
+
if (item.video)
|
|
62
|
+
continue;
|
|
63
|
+
for (const genre of genreBuckets(shown(item).genre)) {
|
|
64
|
+
const list = radio.get(genre) ?? [];
|
|
65
|
+
list.push(item);
|
|
66
|
+
radio.set(genre, list);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const [genre, items] of [...radio].sort((a, b) => b[1].length - a[1].length)) {
|
|
70
|
+
if (items.length < options.minItems) {
|
|
71
|
+
report.skipped.push(`${genre} radio (${items.length} files)`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const { station, made } = await upsert(deps, `genre:audio:${genre.toLowerCase()}`, `${genre} Radio`, order(items).map((one) => one.path), false, options);
|
|
75
|
+
let running = station.desired === "running";
|
|
76
|
+
if (!running && options.start && deps.mayStart()) {
|
|
77
|
+
await deps.runner.start(station.id).then(() => { running = true; }, (error) => deps.log?.(` auto stations: ${genre} Radio did not start: ${error.message}`));
|
|
78
|
+
}
|
|
79
|
+
report.radio.push({ genre, id: station.id, files: items.length, made, running });
|
|
80
|
+
}
|
|
81
|
+
if (options.video && deps.filmGenres) {
|
|
82
|
+
const tv = new Map();
|
|
83
|
+
for (const item of all) {
|
|
84
|
+
if (!item.video || item.duration < 40 * 60)
|
|
85
|
+
continue;
|
|
86
|
+
const genres = await deps.filmGenres(shown(item).title).catch(() => []);
|
|
87
|
+
for (const genre of genres.slice(0, 2)) {
|
|
88
|
+
const list = tv.get(genre) ?? [];
|
|
89
|
+
list.push(item);
|
|
90
|
+
tv.set(genre, list);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
let runningTv = deps.store.list().filter((one) => one.video && one.auto && one.desired === "running").length;
|
|
94
|
+
for (const [genre, films] of [...tv].sort((a, b) => b[1].length - a[1].length)) {
|
|
95
|
+
if (films.length < options.minFilms) {
|
|
96
|
+
report.skipped.push(`${genre} TV (${films.length} films)`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const { station, made } = await upsert(deps, `genre:video:${genre.toLowerCase()}`, `${genre} TV`, films.map((one) => one.path), true, options);
|
|
100
|
+
let running = station.desired === "running";
|
|
101
|
+
if (!running && options.start && runningTv < options.maxVideoRunning && deps.mayStart()) {
|
|
102
|
+
await deps.runner.start(station.id).then(() => { running = true; runningTv += 1; }, (error) => deps.log?.(` auto stations: ${genre} TV did not start: ${error.message}`));
|
|
103
|
+
}
|
|
104
|
+
report.tv.push({ genre, id: station.id, films: films.length, made, running });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return report;
|
|
108
|
+
}
|
package/dist/channels.d.ts
CHANGED
|
@@ -112,7 +112,7 @@ export interface PullResume {
|
|
|
112
112
|
* lists of files on this machine; not remembered, because whoever owns the
|
|
113
113
|
* rotation (the station) puts it back.
|
|
114
114
|
*/
|
|
115
|
-
rotation?: Pick<PlaylistInput, "repeat" | "order" | "current" | "excluded" | "onFailure" | "passOver">;
|
|
115
|
+
rotation?: Pick<PlaylistInput, "repeat" | "order" | "current" | "excluded" | "onFailure" | "passOver" | "breaks">;
|
|
116
116
|
}
|
|
117
117
|
/**
|
|
118
118
|
* A source read by us rather than by ffmpeg: what to tell ffmpeg it is,
|
package/dist/library-index.d.ts
CHANGED
|
@@ -127,6 +127,8 @@ export declare class LibraryIndex {
|
|
|
127
127
|
private timer;
|
|
128
128
|
private dirty;
|
|
129
129
|
private jobs;
|
|
130
|
+
/** Told when any scan finishes, for whatever keeps up with the library (auto stations). */
|
|
131
|
+
onScanned: ((report: ScanReport) => void) | null;
|
|
130
132
|
constructor(file: string, io?: {
|
|
131
133
|
read: (path: string) => string | null;
|
|
132
134
|
write: (path: string, body: string) => void;
|
package/dist/library-index.js
CHANGED
|
@@ -243,6 +243,8 @@ export class LibraryIndex {
|
|
|
243
243
|
timer = null;
|
|
244
244
|
dirty = false;
|
|
245
245
|
jobs = new Map();
|
|
246
|
+
/** Told when any scan finishes, for whatever keeps up with the library (auto stations). */
|
|
247
|
+
onScanned = null;
|
|
246
248
|
constructor(file, io = {
|
|
247
249
|
read: (path) => { try {
|
|
248
250
|
return readFileSync(path, "utf8");
|
|
@@ -531,6 +533,12 @@ export class LibraryIndex {
|
|
|
531
533
|
report.status = signal.aborted ? "cancelled" : "done";
|
|
532
534
|
report.finishedAt = this.now();
|
|
533
535
|
this.touch();
|
|
536
|
+
if (report.status === "done") {
|
|
537
|
+
try {
|
|
538
|
+
this.onScanned?.(report);
|
|
539
|
+
}
|
|
540
|
+
catch { /* a listener's trouble is not the scan's */ }
|
|
541
|
+
}
|
|
534
542
|
return report;
|
|
535
543
|
}
|
|
536
544
|
/** The owner's corrections; an empty value clears that field's correction. */
|
package/dist/library.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export interface Config {
|
|
|
3
3
|
library?: string;
|
|
4
4
|
/** Whether this machine is listed in the public directory; unset until chosen. */
|
|
5
5
|
publish?: boolean;
|
|
6
|
+
/** Auto stations: a station per genre, re-synced after every library scan. */
|
|
7
|
+
autoStations?: Record<string, unknown>;
|
|
6
8
|
}
|
|
7
9
|
export declare function configPath(): string;
|
|
8
10
|
export declare function readConfig(): Config;
|
package/dist/library.js
CHANGED
|
@@ -31,6 +31,7 @@ export function readConfig() {
|
|
|
31
31
|
return {
|
|
32
32
|
...(typeof record["library"] === "string" ? { library: record["library"] } : {}),
|
|
33
33
|
...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
|
|
34
|
+
...(typeof record["autoStations"] === "object" && record["autoStations"] !== null ? { autoStations: record["autoStations"] } : {}),
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
37
|
catch {
|
package/dist/playlist-input.d.ts
CHANGED
|
@@ -29,6 +29,15 @@ export interface PlaylistInput {
|
|
|
29
29
|
* still arriving, and the channel redials and waits for it.
|
|
30
30
|
*/
|
|
31
31
|
passOver?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* A break every so often, between entries and never in the middle of one:
|
|
34
|
+
* `next` answers a file to play (an advert, an ident) or null for no break
|
|
35
|
+
* this time. The break is outside the rotation and the station's history.
|
|
36
|
+
*/
|
|
37
|
+
breaks?: {
|
|
38
|
+
everySeconds: number;
|
|
39
|
+
next: () => Promise<string | null>;
|
|
40
|
+
};
|
|
32
41
|
/** Told when an entry is passed over because it would not play. */
|
|
33
42
|
onFailure?: (event: {
|
|
34
43
|
at: number;
|
package/dist/playlist-input.js
CHANGED
|
@@ -60,9 +60,10 @@ export class PlaylistInputStream {
|
|
|
60
60
|
if (files.length === 0)
|
|
61
61
|
throw new Error("The playlist is empty");
|
|
62
62
|
let order = orderFor(files);
|
|
63
|
-
let step = Math.min(o.at, order.length);
|
|
63
|
+
let step = Math.min(Math.max(0, o.at), order.length);
|
|
64
64
|
let played = 0;
|
|
65
65
|
let last = "";
|
|
66
|
+
let lastBreak = 0;
|
|
66
67
|
for (;;) {
|
|
67
68
|
if (signal.aborted)
|
|
68
69
|
break;
|
|
@@ -93,11 +94,23 @@ export class PlaylistInputStream {
|
|
|
93
94
|
continue;
|
|
94
95
|
}
|
|
95
96
|
{
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
// A break, when one is due: in place of the next entry, which stays
|
|
98
|
+
// next. Not at the very start, and never inside an entry.
|
|
99
|
+
let breakFile = null;
|
|
100
|
+
if (o.breaks && !first && offset - lastBreak >= o.breaks.everySeconds) {
|
|
101
|
+
lastBreak = offset;
|
|
102
|
+
breakFile = await o.breaks.next().catch(() => null);
|
|
103
|
+
if (signal.aborted)
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const isBreak = breakFile !== null;
|
|
107
|
+
const at = isBreak ? -1 : order[step];
|
|
108
|
+
if (!isBreak)
|
|
109
|
+
step += 1;
|
|
110
|
+
const file = isBreak ? breakFile : files[at];
|
|
111
|
+
if (!isBreak)
|
|
112
|
+
last = file;
|
|
113
|
+
if (!isBreak && o.excluded?.(file))
|
|
101
114
|
continue;
|
|
102
115
|
const position = first && step - 1 === o.at ? o.position : 0;
|
|
103
116
|
first = false;
|
|
@@ -174,14 +187,23 @@ export class PlaylistInputStream {
|
|
|
174
187
|
abort();
|
|
175
188
|
await closed;
|
|
176
189
|
}
|
|
177
|
-
|
|
178
|
-
|
|
190
|
+
if (!isBreak) {
|
|
191
|
+
failuresInARow = 0;
|
|
192
|
+
played += 1;
|
|
193
|
+
}
|
|
179
194
|
// Match the frame grid used above so rounding cannot accumulate a
|
|
180
195
|
// backwards timestamp over hundreds of short files.
|
|
181
196
|
const length = skipped ? Math.min(duration, reached) : duration;
|
|
182
197
|
offset += o.video ? Math.ceil(length * 30) / 30 : length;
|
|
198
|
+
// The next break is counted from the end of this one, so a spot
|
|
199
|
+
// longer than the gap cannot follow itself.
|
|
200
|
+
if (isBreak)
|
|
201
|
+
lastBreak = offset;
|
|
183
202
|
}
|
|
184
203
|
catch (error) {
|
|
204
|
+
// A break that would not play is simply no break.
|
|
205
|
+
if (isBreak && !signal.aborted)
|
|
206
|
+
continue;
|
|
185
207
|
if (signal.aborted || !o.passOver)
|
|
186
208
|
throw error;
|
|
187
209
|
// Passed over, not dialled again: the next entry is what a
|
package/dist/server.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { ListenerStore } from "./library-user.ts";
|
|
|
26
26
|
import { Importer } from "./handoff.ts";
|
|
27
27
|
import { StationRunner } from "./station-runner.ts";
|
|
28
28
|
import { StationStore } from "./stations.ts";
|
|
29
|
+
import { StationUsage, type Limits } from "./station-metering.ts";
|
|
29
30
|
import { CompressionService } from "./compression/service.ts";
|
|
30
31
|
import { Enricher } from "./enrich.ts";
|
|
31
32
|
import { Follows } from "./follows.ts";
|
|
@@ -582,6 +583,12 @@ export interface HandlerOptions {
|
|
|
582
583
|
stations?: {
|
|
583
584
|
store: StationStore;
|
|
584
585
|
runner: StationRunner;
|
|
586
|
+
usage?: StationUsage;
|
|
587
|
+
limits?: Limits;
|
|
588
|
+
auto?: {
|
|
589
|
+
sync: (input: Record<string, unknown>) => Promise<unknown>;
|
|
590
|
+
setting: () => unknown;
|
|
591
|
+
};
|
|
585
592
|
};
|
|
586
593
|
web: string | null;
|
|
587
594
|
/** Branded clients on this same backend, keyed by an explicitly configured host. */
|
package/dist/server.js
CHANGED
|
@@ -61,8 +61,12 @@ import { ListenerStore } from "./library-user.js";
|
|
|
61
61
|
import { Importer } from "./handoff.js";
|
|
62
62
|
import { handleHandoffs } from "./handoff-routes.js";
|
|
63
63
|
import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
|
|
64
|
+
import { GUIDE_CSP, GUIDE_HTML, guideHosts } from "./station-guide.js";
|
|
64
65
|
import { StationRunner } from "./station-runner.js";
|
|
65
66
|
import { StationError, StationStore, stationOfChannel } from "./stations.js";
|
|
67
|
+
import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv } from "./station-metering.js";
|
|
68
|
+
import { StationBreaks } from "./station-breaks.js";
|
|
69
|
+
import { DEFAULT_AUTO, syncAutoStations } from "./auto-stations.js";
|
|
66
70
|
import { CompressionService } from "./compression/service.js";
|
|
67
71
|
import { handleChannelCompression, handleCompressionApi, handleStaticRelay } from "./compression/routes.js";
|
|
68
72
|
import { DEFAULT_SITE as NICHEDB, Enricher, FIXTURE_TTL_MS } from "./enrich.js";
|
|
@@ -2130,6 +2134,11 @@ export function createHandler(engine, options) {
|
|
|
2130
2134
|
// port is open from another device before wondering whether the link is
|
|
2131
2135
|
// wrong, and it says nothing about the library.
|
|
2132
2136
|
const eventChannelRequest = Boolean(channelId && await eventForChannel());
|
|
2137
|
+
// The public station guide: at /guide, and at the front of a host named
|
|
2138
|
+
// for it (a Moshpit name such as live.streaming, NIXAMP_GUIDE_HOSTS).
|
|
2139
|
+
const requestHost = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "").split(",")[0].trim().replace(/:\d+$/, "").toLowerCase();
|
|
2140
|
+
const isGuidePage = Boolean(options.stations) && (request.method === "GET" || request.method === "HEAD") &&
|
|
2141
|
+
(path === "/guide" || (path === "/" && guideHosts().has(requestHost)));
|
|
2133
2142
|
// Whether this request holds only the listen key. A listener may browse
|
|
2134
2143
|
// and hear the library, but where it lives on this machine is the
|
|
2135
2144
|
// owner's business: the absolute path names the user account and the disk.
|
|
@@ -2141,6 +2150,7 @@ export function createHandler(engine, options) {
|
|
|
2141
2150
|
// A station's link is its own key, checked by the station.
|
|
2142
2151
|
!isStationListenPath(path) &&
|
|
2143
2152
|
!STATION_PAGE.test(path) &&
|
|
2153
|
+
!isGuidePage &&
|
|
2144
2154
|
!eventChannelRequest) {
|
|
2145
2155
|
let scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
2146
2156
|
// Kept for the routes below that answer a listener with less.
|
|
@@ -2193,6 +2203,17 @@ export function createHandler(engine, options) {
|
|
|
2193
2203
|
}
|
|
2194
2204
|
return { allowed: false, account: "" };
|
|
2195
2205
|
};
|
|
2206
|
+
if (isGuidePage) {
|
|
2207
|
+
response.writeHead(200, {
|
|
2208
|
+
"content-type": "text/html; charset=utf-8",
|
|
2209
|
+
"cache-control": "no-cache",
|
|
2210
|
+
"content-security-policy": GUIDE_CSP,
|
|
2211
|
+
"referrer-policy": "no-referrer",
|
|
2212
|
+
"x-content-type-options": "nosniff",
|
|
2213
|
+
});
|
|
2214
|
+
response.end(request.method === "HEAD" ? undefined : GUIDE_HTML);
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2196
2217
|
// The page a station's link opens. The same bytes for every station: it
|
|
2197
2218
|
// reads its id and token from its own address.
|
|
2198
2219
|
if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
|
|
@@ -2274,6 +2295,13 @@ export function createHandler(engine, options) {
|
|
|
2274
2295
|
json,
|
|
2275
2296
|
readBody,
|
|
2276
2297
|
headers: CORS,
|
|
2298
|
+
...(options.stations.usage ? { usage: options.stations.usage } : {}),
|
|
2299
|
+
...(options.stations.limits ? { limits: options.stations.limits } : {}),
|
|
2300
|
+
...(options.stations.auto ? { auto: options.stations.auto } : {}),
|
|
2301
|
+
throttle: () => {
|
|
2302
|
+
const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
2303
|
+
return verdict.ok ? null : { retryAfter: verdict.retryAfter };
|
|
2304
|
+
},
|
|
2277
2305
|
});
|
|
2278
2306
|
return;
|
|
2279
2307
|
}
|
|
@@ -6168,7 +6196,21 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6168
6196
|
const listeners = new ListenerStore(ListenerStore.fileFor(stateDir(), options.port));
|
|
6169
6197
|
const library = { index: libraryIndex, probe: probeFile, retag: retagPlayer, listeners };
|
|
6170
6198
|
const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
|
|
6199
|
+
// What stations deliver, per month, and the operator's limits on it.
|
|
6200
|
+
const stationUsage = new StationUsage(StationUsage.fileFor(stateDir(), options.port));
|
|
6201
|
+
const stationLimits = limitsFromEnv();
|
|
6202
|
+
process.once("exit", () => stationUsage.flush());
|
|
6203
|
+
// Breaks between songs and films: the network's spot, or a house one.
|
|
6204
|
+
const stationBreaks = new StationBreaks({
|
|
6205
|
+
dir: join(stateDir(), "ads"),
|
|
6206
|
+
site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
|
|
6207
|
+
hasSlot: Boolean(process.env["AD_SLOT"]),
|
|
6208
|
+
guard: (url) => refusedDestination(url),
|
|
6209
|
+
});
|
|
6171
6210
|
stationRunner = new StationRunner({
|
|
6211
|
+
breaks: (video) => stationBreaks.next(video ? "video" : "audio"),
|
|
6212
|
+
onRestart: (id) => stationUsage.restarted(id),
|
|
6213
|
+
onFailure: (id) => stationUsage.failed(id),
|
|
6172
6214
|
store: stationStore,
|
|
6173
6215
|
channels,
|
|
6174
6216
|
pull: async (channelId, name, files, known) => Boolean(await pullChannel(channels, tools.ffprobe, channelId, name, files[0], [], "", {
|
|
@@ -6181,7 +6223,66 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6181
6223
|
roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
|
|
6182
6224
|
log: (line) => console.log(line),
|
|
6183
6225
|
});
|
|
6184
|
-
|
|
6226
|
+
// Auto stations: a station per genre, kept in step with the library.
|
|
6227
|
+
const autoSetting = () => {
|
|
6228
|
+
const saved = readConfig().autoStations;
|
|
6229
|
+
if (!saved)
|
|
6230
|
+
return null;
|
|
6231
|
+
const pick = (name) => (saved[name] === undefined ? DEFAULT_AUTO[name] : saved[name]);
|
|
6232
|
+
return {
|
|
6233
|
+
video: pick("video") !== false,
|
|
6234
|
+
minItems: Number(pick("minItems")) || DEFAULT_AUTO.minItems,
|
|
6235
|
+
minFilms: Number(pick("minFilms")) || DEFAULT_AUTO.minFilms,
|
|
6236
|
+
start: pick("start") !== false,
|
|
6237
|
+
maxVideoRunning: Number(pick("maxVideoRunning")) >= 0 ? Number(pick("maxVideoRunning")) : DEFAULT_AUTO.maxVideoRunning,
|
|
6238
|
+
breaksEvery: Number(pick("breaksEvery")) >= 0 ? Number(pick("breaksEvery")) : DEFAULT_AUTO.breaksEvery,
|
|
6239
|
+
};
|
|
6240
|
+
};
|
|
6241
|
+
let autoRunning = null;
|
|
6242
|
+
const runAuto = (settings) => {
|
|
6243
|
+
if (autoRunning)
|
|
6244
|
+
return autoRunning;
|
|
6245
|
+
autoRunning = syncAutoStations({
|
|
6246
|
+
index: libraryIndex,
|
|
6247
|
+
store: stationStore,
|
|
6248
|
+
runner: stationRunner,
|
|
6249
|
+
owner: options.owner || "",
|
|
6250
|
+
mayStart: () => stationLimits.runningStations === 0 || stationStore.list().filter((one) => one.desired === "running").length < stationLimits.runningStations,
|
|
6251
|
+
filmGenres: async (title) => {
|
|
6252
|
+
const hit = await enricher.lookup(title, "title");
|
|
6253
|
+
const genres = hit?.data?.["genres"];
|
|
6254
|
+
return Array.isArray(genres) ? genres.filter((one) => typeof one === "string") : [];
|
|
6255
|
+
},
|
|
6256
|
+
log: (line) => console.log(line),
|
|
6257
|
+
}, settings).then((report) => {
|
|
6258
|
+
const made = report;
|
|
6259
|
+
console.log(` auto stations: ${made.radio.length} radio, ${made.tv.length} TV.`);
|
|
6260
|
+
return report;
|
|
6261
|
+
}).finally(() => { autoRunning = null; });
|
|
6262
|
+
return autoRunning;
|
|
6263
|
+
};
|
|
6264
|
+
libraryIndex.onScanned = () => {
|
|
6265
|
+
const settings = autoSetting();
|
|
6266
|
+
if (settings)
|
|
6267
|
+
void runAuto(settings);
|
|
6268
|
+
};
|
|
6269
|
+
const stationAuto = {
|
|
6270
|
+
setting: () => autoSetting(),
|
|
6271
|
+
sync: async (input) => {
|
|
6272
|
+
if (input["off"] === true) {
|
|
6273
|
+
writeConfig({ autoStations: undefined });
|
|
6274
|
+
return { off: true };
|
|
6275
|
+
}
|
|
6276
|
+
const settings = { ...DEFAULT_AUTO, ...(autoSetting() ?? {}) };
|
|
6277
|
+
for (const name of Object.keys(DEFAULT_AUTO)) {
|
|
6278
|
+
if (input[name] !== undefined)
|
|
6279
|
+
settings[name] = input[name];
|
|
6280
|
+
}
|
|
6281
|
+
writeConfig({ autoStations: settings });
|
|
6282
|
+
return runAuto(settings);
|
|
6283
|
+
},
|
|
6284
|
+
};
|
|
6285
|
+
const stations = { store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits, auto: stationAuto };
|
|
6185
6286
|
// Music handed over from BitTorrented, copied into a folder of our own.
|
|
6186
6287
|
const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
|
|
6187
6288
|
index: libraryIndex,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Advert } from "./ads.ts";
|
|
2
|
+
export interface BreakOptions {
|
|
3
|
+
dir: string;
|
|
4
|
+
/** nixamp.com, when this server has no slot of its own. */
|
|
5
|
+
site: string;
|
|
6
|
+
hasSlot: boolean;
|
|
7
|
+
guard: (url: string) => Promise<string>;
|
|
8
|
+
fetcher?: typeof fetch;
|
|
9
|
+
/** Injected by tests in place of the network. */
|
|
10
|
+
ask?: (kind: "audio" | "video") => Promise<Advert>;
|
|
11
|
+
}
|
|
12
|
+
export declare class StationBreaks {
|
|
13
|
+
private readonly options;
|
|
14
|
+
constructor(options: BreakOptions);
|
|
15
|
+
private ask;
|
|
16
|
+
/** A file to play for this break, or null for none. */
|
|
17
|
+
next(kind: "audio" | "video"): Promise<string | null>;
|
|
18
|
+
private cache;
|
|
19
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The break: a short spot between songs or films, instead of commercials.
|
|
3
|
+
*
|
|
4
|
+
* Asked for once per break, never per listener, and never in the middle of
|
|
5
|
+
* anything (playlist-input.ts places it between entries). Where it comes
|
|
6
|
+
* from, in order:
|
|
7
|
+
*
|
|
8
|
+
* 1. the ad network, through this server's own AD_SLOT (ads.ts), or
|
|
9
|
+
* through nixamp.com's /api/ads/next when this server has none, so a
|
|
10
|
+
* self-hosted station carries the same spot the site does and the
|
|
11
|
+
* network meters the impression;
|
|
12
|
+
* 2. a house spot the operator dropped in $XDG_STATE_HOME/nixamp/ads/
|
|
13
|
+
* (spot.mp4 for TV, spot.mp3 for radio);
|
|
14
|
+
* 3. nothing: no break this time, and the music goes on.
|
|
15
|
+
*
|
|
16
|
+
* A creative is fetched once per URL into ads/cache and played from disk:
|
|
17
|
+
* https only, public addresses only, at most 50 MB.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
21
|
+
import { extname, join } from "node:path";
|
|
22
|
+
import { Readable } from "node:stream";
|
|
23
|
+
import { pipeline } from "node:stream/promises";
|
|
24
|
+
import { nextAdvert } from "./ads.js";
|
|
25
|
+
const MAX_BYTES = 50 * 1024 * 1024;
|
|
26
|
+
export class StationBreaks {
|
|
27
|
+
options;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.options = options;
|
|
30
|
+
}
|
|
31
|
+
async ask(kind) {
|
|
32
|
+
if (this.options.ask)
|
|
33
|
+
return this.options.ask(kind);
|
|
34
|
+
if (this.options.hasSlot)
|
|
35
|
+
return nextAdvert(kind);
|
|
36
|
+
const send = this.options.fetcher ?? fetch;
|
|
37
|
+
try {
|
|
38
|
+
const answer = await send(`${this.options.site.replace(/\/+$/, "")}/api/ads/next?kind=${kind}`, { signal: AbortSignal.timeout(2_000) });
|
|
39
|
+
if (!answer.ok)
|
|
40
|
+
return { url: null };
|
|
41
|
+
const body = (await answer.json());
|
|
42
|
+
return typeof body.url === "string" ? { url: body.url, kind: body.kind === "video" ? "video" : "audio" } : { url: null };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { url: null };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** A file to play for this break, or null for none. */
|
|
49
|
+
async next(kind) {
|
|
50
|
+
const advert = await this.ask(kind);
|
|
51
|
+
if (advert.url) {
|
|
52
|
+
const cached = await this.cache(advert.url).catch(() => null);
|
|
53
|
+
if (cached)
|
|
54
|
+
return cached;
|
|
55
|
+
}
|
|
56
|
+
const house = join(this.options.dir, kind === "video" ? "spot.mp4" : "spot.mp3");
|
|
57
|
+
return existsSync(house) ? house : null;
|
|
58
|
+
}
|
|
59
|
+
async cache(url) {
|
|
60
|
+
const parsed = new URL(url);
|
|
61
|
+
if (parsed.protocol !== "https:")
|
|
62
|
+
return null;
|
|
63
|
+
const folder = join(this.options.dir, "cache");
|
|
64
|
+
const file = join(folder, `${createHash("sha1").update(url).digest("hex")}${extname(parsed.pathname).slice(0, 6) || ".mp4"}`);
|
|
65
|
+
if (existsSync(file))
|
|
66
|
+
return file;
|
|
67
|
+
if (await this.options.guard(url))
|
|
68
|
+
return null;
|
|
69
|
+
const answer = await (this.options.fetcher ?? fetch)(url, { signal: AbortSignal.timeout(20_000) });
|
|
70
|
+
if (!answer.ok || !answer.body)
|
|
71
|
+
return null;
|
|
72
|
+
if (Number(answer.headers.get("content-length") ?? 0) > MAX_BYTES)
|
|
73
|
+
return null;
|
|
74
|
+
mkdirSync(folder, { recursive: true });
|
|
75
|
+
const part = `${file}.part`;
|
|
76
|
+
let bytes = 0;
|
|
77
|
+
const counted = Readable.fromWeb(answer.body).on("data", (chunk) => {
|
|
78
|
+
bytes += chunk.length;
|
|
79
|
+
if (bytes > MAX_BYTES)
|
|
80
|
+
counted.destroy(new Error("too big"));
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
await pipeline(counted, createWriteStream(part));
|
|
84
|
+
renameSync(part, file);
|
|
85
|
+
return file;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
rmSync(part, { force: true });
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/station-cli.js
CHANGED
|
@@ -181,6 +181,28 @@ export async function station(argv) {
|
|
|
181
181
|
return [`page ${links["page"]}`, `stream ${links["stream"]}`, `m3u ${links["m3u"]}`].join("\n");
|
|
182
182
|
});
|
|
183
183
|
}
|
|
184
|
+
case "auto": {
|
|
185
|
+
const body = argv.includes("--off") ? { off: true } : {
|
|
186
|
+
...(argv.includes("--no-video") ? { video: false } : {}),
|
|
187
|
+
...(argv.includes("--no-start") ? { start: false } : {}),
|
|
188
|
+
...(flag(argv, "--min") ? { minItems: Number(flag(argv, "--min")) } : {}),
|
|
189
|
+
...(flag(argv, "--films") ? { minFilms: Number(flag(argv, "--films")) } : {}),
|
|
190
|
+
...(flag(argv, "--tv") ? { maxVideoRunning: Number(flag(argv, "--tv")) } : {}),
|
|
191
|
+
...(flag(argv, "--breaks") ? { breaksEvery: Number(flag(argv, "--breaks")) } : {}),
|
|
192
|
+
};
|
|
193
|
+
return show(await call("POST", "/api/v1/stations/auto", body), (answer) => {
|
|
194
|
+
const report = answer["report"];
|
|
195
|
+
if (report.off)
|
|
196
|
+
return "Auto stations are off. The stations already made stay as they are.";
|
|
197
|
+
const line = (one, unit) => ` ${String(one["genre"]).padEnd(16)} ${String(one["files"] ?? one["films"]).padStart(5)} ${unit} ${one["running"] ? "on air" : "stopped"}${one["made"] ? " (new)" : ""} [${String(one["id"])}]`;
|
|
198
|
+
return [
|
|
199
|
+
"Radio:", ...(report.radio ?? []).map((one) => line(one, "files")),
|
|
200
|
+
"TV:", ...((report.tv ?? []).length ? (report.tv ?? []).map((one) => line(one, "films")) : [" none (no film genres known yet)"]),
|
|
201
|
+
...((report.skipped ?? []).length ? [`Too small for a station: ${(report.skipped ?? []).join(", ")}`] : []),
|
|
202
|
+
"Every library scan keeps these up to date. They are unlisted: `nixamp station link ID` for a link.",
|
|
203
|
+
].join("\n");
|
|
204
|
+
});
|
|
205
|
+
}
|
|
184
206
|
case "delete": {
|
|
185
207
|
const station = needId();
|
|
186
208
|
if (!station)
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const GUIDE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Live \u00B7 stations</title>\n<meta name=\"description\" content=\"Radio and TV stations, live now.\">\n<link rel=\"icon\" href=\"data:,\">\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}\n*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif}\nmain{max-width:880px;margin:0 auto;padding:40px 16px}\nh1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}\nh2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}\na.station{display:block;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;color:inherit;text-decoration:none}\na.station:hover,a.station:focus-visible{border-color:var(--accent);outline:none}\n.name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}\n.live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}\nfooter{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}\n</style>\n</head>\n<body>\n<main>\n <h1>Live</h1>\n <p class=\"lead\">Radio and TV, on the air now. Pick one and press play.</p>\n <h2 id=\"radio-title\" hidden>Radio</h2>\n <div id=\"radio\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"tv-title\" hidden>TV</h2>\n <div id=\"tv\" class=\"grid\" aria-live=\"polite\"></div>\n <p id=\"empty\" class=\"empty\">Nothing is on the air here yet.</p>\n <footer>Played by <a href=\"https://nixamp.com\">nixamp</a>, on the Moshpit.</footer>\n</main>\n<script>\n(async () => {\n const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };\n const draw = (stations) => {\n for (const [kind, box] of [[\"audio\", \"#radio\"], [\"video\", \"#tv\"]]) {\n const grid = document.querySelector(box);\n grid.replaceChildren();\n const list = stations.filter((s) => s.kind === kind);\n document.querySelector(box + \"-title\").hidden = list.length === 0;\n for (const s of list) {\n const a = el(\"a\", \"station\");\n a.href = s.page;\n a.append(el(\"div\", \"live\", s.state === \"live\" ? \"\u25CF LIVE\" : \"off the air\"), el(\"div\", \"name\", s.name),\n el(\"div\", \"now\", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(\" \u2014 \") : \"\"));\n grid.append(a);\n }\n }\n document.querySelector(\"#empty\").hidden = stations.length > 0;\n };\n const load = async () => {\n try {\n const r = await fetch(\"/api/v1/stations/guide\", { cache: \"no-store\" });\n if (r.ok) draw((await r.json()).stations);\n } catch {}\n };\n await load();\n setInterval(load, 15000);\n})();\n</script>\n</body>\n</html>\n";
|
|
2
|
+
export declare const GUIDE_CSP: string;
|
|
3
|
+
/** The hosts whose front page is the guide (NIXAMP_GUIDE_HOSTS, comma-separated). */
|
|
4
|
+
export declare function guideHosts(env?: Record<string, string | undefined>): Set<string>;
|