nixamp 0.35.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 +4 -0
- package/dist/server.js +89 -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-page.d.ts +1 -1
- package/dist/station-page.js +20 -5
- package/dist/station-routes.d.ts +5 -0
- package/dist/station-routes.js +30 -2
- package/dist/station-runner.d.ts +2 -0
- package/dist/station-runner.js +3 -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 +93 -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-page.ts +20 -5
- package/src/station-routes.ts +34 -2
- package/src/station-runner.ts +5 -0
- package/src/station-usage.ts +2 -0
- package/src/stations.ts +37 -0
- package/web/dist/assets/{ar-Doh06ZMu.js → ar-Ck-FmDpQ.js} +1 -1
- package/web/dist/assets/{cs-C_lZl4jI.js → cs-CPJY3K69.js} +1 -1
- package/web/dist/assets/{da-CSBBA_ZI.js → da-BnWtLL09.js} +1 -1
- package/web/dist/assets/{de-zorW0tO_.js → de-CHevIt93.js} +1 -1
- package/web/dist/assets/{es-DBUl3DGo.js → es-B6wpaUMv.js} +1 -1
- package/web/dist/assets/{fi-DpUx7XY-.js → fi-8fKiz2AA.js} +1 -1
- package/web/dist/assets/{fr-Cg_xppCf.js → fr-0GgY52Sm.js} +1 -1
- package/web/dist/assets/{hi-PD1kdcf2.js → hi-BlsrUAw-.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-BEEk729h.js → hls-3VKVEQE3-BZd3zjlr.js} +1 -1
- package/web/dist/assets/{hu-C1fGbjZO.js → hu-D3i_6i5w.js} +1 -1
- package/web/dist/assets/{id-DRBhZ8KM.js → id-CLLAXbOP.js} +1 -1
- package/web/dist/assets/{it-Ci3OZV2-.js → it-BU13BsSn.js} +1 -1
- package/web/dist/assets/{main-CEJhJH5y.js → main-C1ggd2H7.js} +2 -2
- package/web/dist/assets/{mpegts-y-HAxt1b.js → mpegts-BaoQnMCf.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DPrv1ran.js → mpegts-LO6RVLD6-YBT-W6xc.js} +1 -1
- package/web/dist/assets/{nl-CwWOHKbv.js → nl-DEshyyt7.js} +1 -1
- package/web/dist/assets/{ru-BWlIGsoL.js → ru-D7sPOc6m.js} +1 -1
- package/web/dist/assets/{sv-Gb1cLNsc.js → sv-sdrsKAiG.js} +1 -1
- package/web/dist/assets/{uk-MZUnyyuR.js → uk-_lz6hkvo.js} +1 -1
- package/web/dist/assets/{vi-DNWS5fxc.js → vi-D81wl7uk.js} +1 -1
- package/web/dist/assets/{zh-BcOiladF.js → zh-BqMqEom6.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
|
@@ -0,0 +1,171 @@
|
|
|
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, LibraryItem } from "./library-index.ts";
|
|
19
|
+
import { shown } from "./library-index.ts";
|
|
20
|
+
import type { StationRunner } from "./station-runner.ts";
|
|
21
|
+
import type { Station, StationStore } from "./stations.ts";
|
|
22
|
+
|
|
23
|
+
const RULES: Array<[RegExp, string]> = [
|
|
24
|
+
[/metal|thrash|trash|doom|death|grind|neue deutsche|h(a|ä)rte|djent|metalcore/i, "Metal"],
|
|
25
|
+
[/punk|hardcore|emo|screamo/i, "Punk"],
|
|
26
|
+
[/hip.?hop|\brap\b|gangsta|trap|grime|drill/i, "Hip-Hop"],
|
|
27
|
+
[/industrial/i, "Industrial"],
|
|
28
|
+
[/electro|techno|house|edm|dance|trance|ambient|drum.?n.?bass|dubstep|synth/i, "Electronic"],
|
|
29
|
+
[/reggae|\bska\b|\bdub\b|dancehall/i, "Reggae"],
|
|
30
|
+
[/jazz|swing|bebop/i, "Jazz"],
|
|
31
|
+
[/blues/i, "Blues"],
|
|
32
|
+
[/classical|symphon|orchestr|baroque|opera/i, "Classical"],
|
|
33
|
+
[/country|folk|bluegrass|americana/i, "Country & Folk"],
|
|
34
|
+
[/soul|r&b|rnb|funk|motown|gospel/i, "Soul & R&B"],
|
|
35
|
+
[/latin|reggaeton|salsa|bachata|cumbia/i, "Latin"],
|
|
36
|
+
[/alternat|indie|ind(e|é)|grunge|shoegaze/i, "Alternative"],
|
|
37
|
+
[/rock/i, "Rock"],
|
|
38
|
+
[/pop/i, "Pop"],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const NOT_MUSIC = /lecture|interview|audiobook|podcast|speech|spoken|talk|course|sermon|lesson|comedy/i;
|
|
42
|
+
|
|
43
|
+
/** The genres a tag belongs to, recognisably named; [] when it is not music or not known. */
|
|
44
|
+
export function genreBuckets(raw: string): string[] {
|
|
45
|
+
if (!raw || NOT_MUSIC.test(raw)) return [];
|
|
46
|
+
const out = new Set<string>();
|
|
47
|
+
for (const part of raw.split(/[,;/|]+|\s+&\s+/)) {
|
|
48
|
+
const hit = RULES.find(([pattern]) => pattern.test(part));
|
|
49
|
+
if (hit) out.add(hit[1]);
|
|
50
|
+
}
|
|
51
|
+
return [...out];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AutoOptions {
|
|
55
|
+
/** Radio from tags; TV from nichedb's genres. */
|
|
56
|
+
video: boolean;
|
|
57
|
+
/** A genre needs at least this many files (radio) to get a station. */
|
|
58
|
+
minItems: number;
|
|
59
|
+
/** Films for a TV station. */
|
|
60
|
+
minFilms: number;
|
|
61
|
+
start: boolean;
|
|
62
|
+
/** TV stations running at once. */
|
|
63
|
+
maxVideoRunning: number;
|
|
64
|
+
/** Seconds between breaks on the stations this makes. */
|
|
65
|
+
breaksEvery: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const DEFAULT_AUTO: AutoOptions = { video: true, minItems: 10, minFilms: 3, start: true, maxVideoRunning: 2, breaksEvery: 600 };
|
|
69
|
+
|
|
70
|
+
export interface AutoReport {
|
|
71
|
+
radio: Array<{ genre: string; id: string; files: number; made: boolean; running: boolean }>;
|
|
72
|
+
tv: Array<{ genre: string; id: string; films: number; made: boolean; running: boolean }>;
|
|
73
|
+
skipped: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AutoDeps {
|
|
77
|
+
index: LibraryIndex;
|
|
78
|
+
store: StationStore;
|
|
79
|
+
runner: StationRunner;
|
|
80
|
+
owner: string;
|
|
81
|
+
/** nichedb's genres for a film, from the enrichment cache or a lookup; [] when unknown. */
|
|
82
|
+
filmGenres?: (title: string) => Promise<string[]>;
|
|
83
|
+
/** Whether another station may be started now (the server's running limit). */
|
|
84
|
+
mayStart: () => boolean;
|
|
85
|
+
log?: (line: string) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function order(items: LibraryItem[]): LibraryItem[] {
|
|
89
|
+
return [...items].sort((a, b) => {
|
|
90
|
+
const x = shown(a), y = shown(b);
|
|
91
|
+
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);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function upsert(
|
|
96
|
+
deps: AutoDeps,
|
|
97
|
+
key: string,
|
|
98
|
+
name: string,
|
|
99
|
+
paths: string[],
|
|
100
|
+
video: boolean,
|
|
101
|
+
options: AutoOptions,
|
|
102
|
+
): Promise<{ station: Station; made: boolean }> {
|
|
103
|
+
const existing = deps.store.byAuto(key);
|
|
104
|
+
if (existing) {
|
|
105
|
+
const same = existing.items.length === paths.length && existing.items.every((one, at) => one === paths[at]);
|
|
106
|
+
return { station: same ? existing : deps.store.update(existing.id, {}, paths), made: false };
|
|
107
|
+
}
|
|
108
|
+
const station = deps.store.create({ name, shuffle: true, repeat: true, video, breaksEvery: options.breaksEvery }, paths, deps.owner);
|
|
109
|
+
deps.store.markAuto(station.id, key);
|
|
110
|
+
// A link, not a listing: unlisted needs no rights basis and publishes nothing.
|
|
111
|
+
deps.store.setAccess(station.id, { visibility: "unlisted" }, deps.owner || "auto");
|
|
112
|
+
return { station, made: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Make or update a station per genre; start what the limits allow. */
|
|
116
|
+
export async function syncAutoStations(deps: AutoDeps, options: AutoOptions = DEFAULT_AUTO): Promise<AutoReport> {
|
|
117
|
+
const report: AutoReport = { radio: [], tv: [], skipped: [] };
|
|
118
|
+
const ready = deps.index.search({ state: "ready", limit: 500, offset: 0 });
|
|
119
|
+
// search() pages at 500; walk them all.
|
|
120
|
+
const all: LibraryItem[] = [...ready.items];
|
|
121
|
+
for (let offset = 500; offset < ready.total; offset += 500) all.push(...deps.index.search({ state: "ready", limit: 500, offset }).items);
|
|
122
|
+
|
|
123
|
+
const radio = new Map<string, LibraryItem[]>();
|
|
124
|
+
for (const item of all) {
|
|
125
|
+
if (item.video) continue;
|
|
126
|
+
for (const genre of genreBuckets(shown(item).genre)) {
|
|
127
|
+
const list = radio.get(genre) ?? [];
|
|
128
|
+
list.push(item);
|
|
129
|
+
radio.set(genre, list);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const [genre, items] of [...radio].sort((a, b) => b[1].length - a[1].length)) {
|
|
133
|
+
if (items.length < options.minItems) {
|
|
134
|
+
report.skipped.push(`${genre} radio (${items.length} files)`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const { station, made } = await upsert(deps, `genre:audio:${genre.toLowerCase()}`, `${genre} Radio`, order(items).map((one) => one.path), false, options);
|
|
138
|
+
let running = station.desired === "running";
|
|
139
|
+
if (!running && options.start && deps.mayStart()) {
|
|
140
|
+
await deps.runner.start(station.id).then(() => { running = true; }, (error: Error) => deps.log?.(` auto stations: ${genre} Radio did not start: ${error.message}`));
|
|
141
|
+
}
|
|
142
|
+
report.radio.push({ genre, id: station.id, files: items.length, made, running });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (options.video && deps.filmGenres) {
|
|
146
|
+
const tv = new Map<string, LibraryItem[]>();
|
|
147
|
+
for (const item of all) {
|
|
148
|
+
if (!item.video || item.duration < 40 * 60) continue;
|
|
149
|
+
const genres = await deps.filmGenres(shown(item).title).catch(() => []);
|
|
150
|
+
for (const genre of genres.slice(0, 2)) {
|
|
151
|
+
const list = tv.get(genre) ?? [];
|
|
152
|
+
list.push(item);
|
|
153
|
+
tv.set(genre, list);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
let runningTv = deps.store.list().filter((one) => one.video && one.auto && one.desired === "running").length;
|
|
157
|
+
for (const [genre, films] of [...tv].sort((a, b) => b[1].length - a[1].length)) {
|
|
158
|
+
if (films.length < options.minFilms) {
|
|
159
|
+
report.skipped.push(`${genre} TV (${films.length} films)`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const { station, made } = await upsert(deps, `genre:video:${genre.toLowerCase()}`, `${genre} TV`, films.map((one) => one.path), true, options);
|
|
163
|
+
let running = station.desired === "running";
|
|
164
|
+
if (!running && options.start && runningTv < options.maxVideoRunning && deps.mayStart()) {
|
|
165
|
+
await deps.runner.start(station.id).then(() => { running = true; runningTv += 1; }, (error: Error) => deps.log?.(` auto stations: ${genre} TV did not start: ${error.message}`));
|
|
166
|
+
}
|
|
167
|
+
report.tv.push({ genre, id: station.id, films: films.length, made, running });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return report;
|
|
171
|
+
}
|
package/src/channels.ts
CHANGED
|
@@ -129,7 +129,7 @@ export interface PullResume {
|
|
|
129
129
|
* lists of files on this machine; not remembered, because whoever owns the
|
|
130
130
|
* rotation (the station) puts it back.
|
|
131
131
|
*/
|
|
132
|
-
rotation?: Pick<PlaylistInput, "repeat" | "order" | "current" | "excluded" | "onFailure" | "passOver">;
|
|
132
|
+
rotation?: Pick<PlaylistInput, "repeat" | "order" | "current" | "excluded" | "onFailure" | "passOver" | "breaks">;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/**
|
package/src/library-index.ts
CHANGED
|
@@ -335,6 +335,8 @@ export class LibraryIndex {
|
|
|
335
335
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
336
336
|
private dirty = false;
|
|
337
337
|
private jobs = new Map<string, { report: ScanReport; abort: AbortController }>();
|
|
338
|
+
/** Told when any scan finishes, for whatever keeps up with the library (auto stations). */
|
|
339
|
+
onScanned: ((report: ScanReport) => void) | null = null;
|
|
338
340
|
|
|
339
341
|
constructor(
|
|
340
342
|
private readonly file: string,
|
|
@@ -622,6 +624,9 @@ export class LibraryIndex {
|
|
|
622
624
|
report.status = signal.aborted ? "cancelled" : "done";
|
|
623
625
|
report.finishedAt = this.now();
|
|
624
626
|
this.touch();
|
|
627
|
+
if (report.status === "done") {
|
|
628
|
+
try { this.onScanned?.(report); } catch { /* a listener's trouble is not the scan's */ }
|
|
629
|
+
}
|
|
625
630
|
return report;
|
|
626
631
|
}
|
|
627
632
|
|
package/src/library.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface Config {
|
|
|
25
25
|
library?: string;
|
|
26
26
|
/** Whether this machine is listed in the public directory; unset until chosen. */
|
|
27
27
|
publish?: boolean;
|
|
28
|
+
/** Auto stations: a station per genre, re-synced after every library scan. */
|
|
29
|
+
autoStations?: Record<string, unknown>;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export function configPath(): string {
|
|
@@ -39,6 +41,7 @@ export function readConfig(): Config {
|
|
|
39
41
|
return {
|
|
40
42
|
...(typeof record["library"] === "string" ? { library: record["library"] } : {}),
|
|
41
43
|
...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
|
|
44
|
+
...(typeof record["autoStations"] === "object" && record["autoStations"] !== null ? { autoStations: record["autoStations"] as Record<string, unknown> } : {}),
|
|
42
45
|
};
|
|
43
46
|
} catch {
|
|
44
47
|
return {};
|
package/src/playlist-input.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface PlaylistInput {
|
|
|
34
34
|
* still arriving, and the channel redials and waits for it.
|
|
35
35
|
*/
|
|
36
36
|
passOver?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* A break every so often, between entries and never in the middle of one:
|
|
39
|
+
* `next` answers a file to play (an advert, an ident) or null for no break
|
|
40
|
+
* this time. The break is outside the rotation and the station's history.
|
|
41
|
+
*/
|
|
42
|
+
breaks?: { everySeconds: number; next: () => Promise<string | null> };
|
|
37
43
|
/** Told when an entry is passed over because it would not play. */
|
|
38
44
|
onFailure?: (event: { at: number; file: string; reason: string }) => void;
|
|
39
45
|
}
|
|
@@ -99,9 +105,10 @@ export class PlaylistInputStream {
|
|
|
99
105
|
let files = o.current?.() ?? o.files;
|
|
100
106
|
if (files.length === 0) throw new Error("The playlist is empty");
|
|
101
107
|
let order = orderFor(files);
|
|
102
|
-
let step = Math.min(o.at, order.length);
|
|
108
|
+
let step = Math.min(Math.max(0, o.at), order.length);
|
|
103
109
|
let played = 0;
|
|
104
110
|
let last = "";
|
|
111
|
+
let lastBreak = 0;
|
|
105
112
|
for (;;) {
|
|
106
113
|
if (signal.aborted) break;
|
|
107
114
|
// Asked at every boundary, so an edit lands on the next track. The
|
|
@@ -128,11 +135,20 @@ export class PlaylistInputStream {
|
|
|
128
135
|
continue;
|
|
129
136
|
}
|
|
130
137
|
{
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
138
|
+
// A break, when one is due: in place of the next entry, which stays
|
|
139
|
+
// next. Not at the very start, and never inside an entry.
|
|
140
|
+
let breakFile: string | null = null;
|
|
141
|
+
if (o.breaks && !first && offset - lastBreak >= o.breaks.everySeconds) {
|
|
142
|
+
lastBreak = offset;
|
|
143
|
+
breakFile = await o.breaks.next().catch(() => null);
|
|
144
|
+
if (signal.aborted) return;
|
|
145
|
+
}
|
|
146
|
+
const isBreak = breakFile !== null;
|
|
147
|
+
const at = isBreak ? -1 : order[step]!;
|
|
148
|
+
if (!isBreak) step += 1;
|
|
149
|
+
const file = isBreak ? breakFile! : files[at]!;
|
|
150
|
+
if (!isBreak) last = file;
|
|
151
|
+
if (!isBreak && o.excluded?.(file)) continue;
|
|
136
152
|
const position = first && step - 1 === o.at ? o.position : 0;
|
|
137
153
|
first = false;
|
|
138
154
|
let reached = 0;
|
|
@@ -200,13 +216,20 @@ export class PlaylistInputStream {
|
|
|
200
216
|
abort();
|
|
201
217
|
await closed;
|
|
202
218
|
}
|
|
203
|
-
|
|
204
|
-
|
|
219
|
+
if (!isBreak) {
|
|
220
|
+
failuresInARow = 0;
|
|
221
|
+
played += 1;
|
|
222
|
+
}
|
|
205
223
|
// Match the frame grid used above so rounding cannot accumulate a
|
|
206
224
|
// backwards timestamp over hundreds of short files.
|
|
207
225
|
const length = skipped ? Math.min(duration, reached) : duration;
|
|
208
226
|
offset += o.video ? Math.ceil(length * 30) / 30 : length;
|
|
227
|
+
// The next break is counted from the end of this one, so a spot
|
|
228
|
+
// longer than the gap cannot follow itself.
|
|
229
|
+
if (isBreak) lastBreak = offset;
|
|
209
230
|
} catch (error) {
|
|
231
|
+
// A break that would not play is simply no break.
|
|
232
|
+
if (isBreak && !signal.aborted) continue;
|
|
210
233
|
if (signal.aborted || !o.passOver) throw error;
|
|
211
234
|
// Passed over, not dialled again: the next entry is what a
|
|
212
235
|
// listener should hear, and a file that will not decode now will
|
package/src/server.ts
CHANGED
|
@@ -78,9 +78,12 @@ import { ListenerStore } from "./library-user.ts";
|
|
|
78
78
|
import { Importer } from "./handoff.ts";
|
|
79
79
|
import { handleHandoffs } from "./handoff-routes.ts";
|
|
80
80
|
import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
|
|
81
|
+
import { GUIDE_CSP, GUIDE_HTML, guideHosts } from "./station-guide.ts";
|
|
81
82
|
import { StationRunner } from "./station-runner.ts";
|
|
82
83
|
import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
|
|
83
84
|
import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv, type Limits } from "./station-metering.ts";
|
|
85
|
+
import { StationBreaks } from "./station-breaks.ts";
|
|
86
|
+
import { DEFAULT_AUTO, syncAutoStations, type AutoOptions } from "./auto-stations.ts";
|
|
84
87
|
import { CompressionService } from "./compression/service.ts";
|
|
85
88
|
import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
|
|
86
89
|
import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
|
|
@@ -1717,7 +1720,13 @@ export interface HandlerOptions {
|
|
|
1717
1720
|
/** Music handed over from BitTorrented. */
|
|
1718
1721
|
importer?: Importer;
|
|
1719
1722
|
/** This server's stations and what keeps them on the air. */
|
|
1720
|
-
stations?: {
|
|
1723
|
+
stations?: {
|
|
1724
|
+
store: StationStore;
|
|
1725
|
+
runner: StationRunner;
|
|
1726
|
+
usage?: StationUsage;
|
|
1727
|
+
limits?: Limits;
|
|
1728
|
+
auto?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
|
|
1729
|
+
};
|
|
1721
1730
|
web: string | null;
|
|
1722
1731
|
/** Branded clients on this same backend, keyed by an explicitly configured host. */
|
|
1723
1732
|
webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
|
|
@@ -2640,6 +2649,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2640
2649
|
// port is open from another device before wondering whether the link is
|
|
2641
2650
|
// wrong, and it says nothing about the library.
|
|
2642
2651
|
const eventChannelRequest = Boolean(channelId && await eventForChannel());
|
|
2652
|
+
// The public station guide: at /guide, and at the front of a host named
|
|
2653
|
+
// for it (a Moshpit name such as live.streaming, NIXAMP_GUIDE_HOSTS).
|
|
2654
|
+
const requestHost = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "").split(",")[0]!.trim().replace(/:\d+$/, "").toLowerCase();
|
|
2655
|
+
const isGuidePage = Boolean(options.stations) && (request.method === "GET" || request.method === "HEAD") &&
|
|
2656
|
+
(path === "/guide" || (path === "/" && guideHosts().has(requestHost)));
|
|
2643
2657
|
// Whether this request holds only the listen key. A listener may browse
|
|
2644
2658
|
// and hear the library, but where it lives on this machine is the
|
|
2645
2659
|
// owner's business: the absolute path names the user account and the disk.
|
|
@@ -2652,6 +2666,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2652
2666
|
// A station's link is its own key, checked by the station.
|
|
2653
2667
|
!isStationListenPath(path) &&
|
|
2654
2668
|
!STATION_PAGE.test(path) &&
|
|
2669
|
+
!isGuidePage &&
|
|
2655
2670
|
!eventChannelRequest
|
|
2656
2671
|
) {
|
|
2657
2672
|
let scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
@@ -2704,6 +2719,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2704
2719
|
return { allowed: false, account: "" };
|
|
2705
2720
|
};
|
|
2706
2721
|
|
|
2722
|
+
if (isGuidePage) {
|
|
2723
|
+
response.writeHead(200, {
|
|
2724
|
+
"content-type": "text/html; charset=utf-8",
|
|
2725
|
+
"cache-control": "no-cache",
|
|
2726
|
+
"content-security-policy": GUIDE_CSP,
|
|
2727
|
+
"referrer-policy": "no-referrer",
|
|
2728
|
+
"x-content-type-options": "nosniff",
|
|
2729
|
+
});
|
|
2730
|
+
response.end(request.method === "HEAD" ? undefined : GUIDE_HTML);
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2707
2734
|
// The page a station's link opens. The same bytes for every station: it
|
|
2708
2735
|
// reads its id and token from its own address.
|
|
2709
2736
|
if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
|
|
@@ -2788,6 +2815,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2788
2815
|
headers: CORS,
|
|
2789
2816
|
...(options.stations.usage ? { usage: options.stations.usage } : {}),
|
|
2790
2817
|
...(options.stations.limits ? { limits: options.stations.limits } : {}),
|
|
2818
|
+
...(options.stations.auto ? { auto: options.stations.auto } : {}),
|
|
2791
2819
|
throttle: () => {
|
|
2792
2820
|
const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
2793
2821
|
return verdict.ok ? null : { retryAfter: verdict.retryAfter };
|
|
@@ -6663,7 +6691,15 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6663
6691
|
const stationUsage = new StationUsage(StationUsage.fileFor(stateDir(), options.port));
|
|
6664
6692
|
const stationLimits = limitsFromEnv();
|
|
6665
6693
|
process.once("exit", () => stationUsage.flush());
|
|
6694
|
+
// Breaks between songs and films: the network's spot, or a house one.
|
|
6695
|
+
const stationBreaks = new StationBreaks({
|
|
6696
|
+
dir: join(stateDir(), "ads"),
|
|
6697
|
+
site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
|
|
6698
|
+
hasSlot: Boolean(process.env["AD_SLOT"]),
|
|
6699
|
+
guard: (url) => refusedDestination(url),
|
|
6700
|
+
});
|
|
6666
6701
|
stationRunner = new StationRunner({
|
|
6702
|
+
breaks: (video) => stationBreaks.next(video ? "video" : "audio"),
|
|
6667
6703
|
onRestart: (id) => stationUsage.restarted(id),
|
|
6668
6704
|
onFailure: (id) => stationUsage.failed(id),
|
|
6669
6705
|
store: stationStore,
|
|
@@ -6679,7 +6715,62 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6679
6715
|
roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
|
|
6680
6716
|
log: (line) => console.log(line),
|
|
6681
6717
|
});
|
|
6682
|
-
|
|
6718
|
+
// Auto stations: a station per genre, kept in step with the library.
|
|
6719
|
+
const autoSetting = (): AutoOptions | null => {
|
|
6720
|
+
const saved = readConfig().autoStations;
|
|
6721
|
+
if (!saved) return null;
|
|
6722
|
+
const pick = (name: keyof AutoOptions) => (saved[name] === undefined ? DEFAULT_AUTO[name] : saved[name]);
|
|
6723
|
+
return {
|
|
6724
|
+
video: pick("video") !== false,
|
|
6725
|
+
minItems: Number(pick("minItems")) || DEFAULT_AUTO.minItems,
|
|
6726
|
+
minFilms: Number(pick("minFilms")) || DEFAULT_AUTO.minFilms,
|
|
6727
|
+
start: pick("start") !== false,
|
|
6728
|
+
maxVideoRunning: Number(pick("maxVideoRunning")) >= 0 ? Number(pick("maxVideoRunning")) : DEFAULT_AUTO.maxVideoRunning,
|
|
6729
|
+
breaksEvery: Number(pick("breaksEvery")) >= 0 ? Number(pick("breaksEvery")) : DEFAULT_AUTO.breaksEvery,
|
|
6730
|
+
};
|
|
6731
|
+
};
|
|
6732
|
+
let autoRunning: Promise<unknown> | null = null;
|
|
6733
|
+
const runAuto = (settings: AutoOptions): Promise<unknown> => {
|
|
6734
|
+
if (autoRunning) return autoRunning;
|
|
6735
|
+
autoRunning = syncAutoStations({
|
|
6736
|
+
index: libraryIndex,
|
|
6737
|
+
store: stationStore,
|
|
6738
|
+
runner: stationRunner!,
|
|
6739
|
+
owner: options.owner || "",
|
|
6740
|
+
mayStart: () => stationLimits.runningStations === 0 || stationStore.list().filter((one) => one.desired === "running").length < stationLimits.runningStations,
|
|
6741
|
+
filmGenres: async (title) => {
|
|
6742
|
+
const hit = await enricher.lookup(title, "title");
|
|
6743
|
+
const genres = hit?.data?.["genres"];
|
|
6744
|
+
return Array.isArray(genres) ? genres.filter((one): one is string => typeof one === "string") : [];
|
|
6745
|
+
},
|
|
6746
|
+
log: (line) => console.log(line),
|
|
6747
|
+
}, settings).then((report) => {
|
|
6748
|
+
const made = report as { radio: unknown[]; tv: unknown[] };
|
|
6749
|
+
console.log(` auto stations: ${made.radio.length} radio, ${made.tv.length} TV.`);
|
|
6750
|
+
return report;
|
|
6751
|
+
}).finally(() => { autoRunning = null; });
|
|
6752
|
+
return autoRunning;
|
|
6753
|
+
};
|
|
6754
|
+
libraryIndex.onScanned = () => {
|
|
6755
|
+
const settings = autoSetting();
|
|
6756
|
+
if (settings) void runAuto(settings);
|
|
6757
|
+
};
|
|
6758
|
+
const stationAuto = {
|
|
6759
|
+
setting: () => autoSetting(),
|
|
6760
|
+
sync: async (input: Record<string, unknown>) => {
|
|
6761
|
+
if (input["off"] === true) {
|
|
6762
|
+
writeConfig({ autoStations: undefined });
|
|
6763
|
+
return { off: true };
|
|
6764
|
+
}
|
|
6765
|
+
const settings: AutoOptions = { ...DEFAULT_AUTO, ...(autoSetting() ?? {}) };
|
|
6766
|
+
for (const name of Object.keys(DEFAULT_AUTO) as (keyof AutoOptions)[]) {
|
|
6767
|
+
if (input[name] !== undefined) (settings as unknown as Record<string, unknown>)[name] = input[name];
|
|
6768
|
+
}
|
|
6769
|
+
writeConfig({ autoStations: settings as unknown as Record<string, unknown> });
|
|
6770
|
+
return runAuto(settings);
|
|
6771
|
+
},
|
|
6772
|
+
};
|
|
6773
|
+
const stations = { store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits, auto: stationAuto };
|
|
6683
6774
|
// Music handed over from BitTorrented, copied into a folder of our own.
|
|
6684
6775
|
const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
|
|
6685
6776
|
index: libraryIndex,
|
|
@@ -0,0 +1,93 @@
|
|
|
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, type Advert } from "./ads.ts";
|
|
25
|
+
|
|
26
|
+
const MAX_BYTES = 50 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
export interface BreakOptions {
|
|
29
|
+
dir: string;
|
|
30
|
+
/** nixamp.com, when this server has no slot of its own. */
|
|
31
|
+
site: string;
|
|
32
|
+
hasSlot: boolean;
|
|
33
|
+
guard: (url: string) => Promise<string>;
|
|
34
|
+
fetcher?: typeof fetch;
|
|
35
|
+
/** Injected by tests in place of the network. */
|
|
36
|
+
ask?: (kind: "audio" | "video") => Promise<Advert>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class StationBreaks {
|
|
40
|
+
constructor(private readonly options: BreakOptions) {}
|
|
41
|
+
|
|
42
|
+
private async ask(kind: "audio" | "video"): Promise<Advert> {
|
|
43
|
+
if (this.options.ask) return this.options.ask(kind);
|
|
44
|
+
if (this.options.hasSlot) return nextAdvert(kind);
|
|
45
|
+
const send = this.options.fetcher ?? fetch;
|
|
46
|
+
try {
|
|
47
|
+
const answer = await send(`${this.options.site.replace(/\/+$/, "")}/api/ads/next?kind=${kind}`, { signal: AbortSignal.timeout(2_000) });
|
|
48
|
+
if (!answer.ok) return { url: null };
|
|
49
|
+
const body = (await answer.json()) as { url?: unknown; kind?: unknown };
|
|
50
|
+
return typeof body.url === "string" ? { url: body.url, kind: body.kind === "video" ? "video" : "audio" } : { url: null };
|
|
51
|
+
} catch {
|
|
52
|
+
return { url: null };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A file to play for this break, or null for none. */
|
|
57
|
+
async next(kind: "audio" | "video"): Promise<string | null> {
|
|
58
|
+
const advert = await this.ask(kind);
|
|
59
|
+
if (advert.url) {
|
|
60
|
+
const cached = await this.cache(advert.url).catch(() => null);
|
|
61
|
+
if (cached) return cached;
|
|
62
|
+
}
|
|
63
|
+
const house = join(this.options.dir, kind === "video" ? "spot.mp4" : "spot.mp3");
|
|
64
|
+
return existsSync(house) ? house : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async cache(url: string): Promise<string | null> {
|
|
68
|
+
const parsed = new URL(url);
|
|
69
|
+
if (parsed.protocol !== "https:") return null;
|
|
70
|
+
const folder = join(this.options.dir, "cache");
|
|
71
|
+
const file = join(folder, `${createHash("sha1").update(url).digest("hex")}${extname(parsed.pathname).slice(0, 6) || ".mp4"}`);
|
|
72
|
+
if (existsSync(file)) return file;
|
|
73
|
+
if (await this.options.guard(url)) return null;
|
|
74
|
+
const answer = await (this.options.fetcher ?? fetch)(url, { signal: AbortSignal.timeout(20_000) });
|
|
75
|
+
if (!answer.ok || !answer.body) return null;
|
|
76
|
+
if (Number(answer.headers.get("content-length") ?? 0) > MAX_BYTES) return null;
|
|
77
|
+
mkdirSync(folder, { recursive: true });
|
|
78
|
+
const part = `${file}.part`;
|
|
79
|
+
let bytes = 0;
|
|
80
|
+
const counted = Readable.fromWeb(answer.body as import("node:stream/web").ReadableStream).on("data", (chunk: Buffer) => {
|
|
81
|
+
bytes += chunk.length;
|
|
82
|
+
if (bytes > MAX_BYTES) counted.destroy(new Error("too big"));
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
await pipeline(counted, createWriteStream(part));
|
|
86
|
+
renameSync(part, file);
|
|
87
|
+
return file;
|
|
88
|
+
} catch {
|
|
89
|
+
rmSync(part, { force: true });
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/station-cli.ts
CHANGED
|
@@ -177,6 +177,27 @@ export async function station(argv: string[]): Promise<number> {
|
|
|
177
177
|
return [`page ${links["page"]}`, `stream ${links["stream"]}`, `m3u ${links["m3u"]}`].join("\n");
|
|
178
178
|
});
|
|
179
179
|
}
|
|
180
|
+
case "auto": {
|
|
181
|
+
const body: Record<string, unknown> = argv.includes("--off") ? { off: true } : {
|
|
182
|
+
...(argv.includes("--no-video") ? { video: false } : {}),
|
|
183
|
+
...(argv.includes("--no-start") ? { start: false } : {}),
|
|
184
|
+
...(flag(argv, "--min") ? { minItems: Number(flag(argv, "--min")) } : {}),
|
|
185
|
+
...(flag(argv, "--films") ? { minFilms: Number(flag(argv, "--films")) } : {}),
|
|
186
|
+
...(flag(argv, "--tv") ? { maxVideoRunning: Number(flag(argv, "--tv")) } : {}),
|
|
187
|
+
...(flag(argv, "--breaks") ? { breaksEvery: Number(flag(argv, "--breaks")) } : {}),
|
|
188
|
+
};
|
|
189
|
+
return show(await call("POST", "/api/v1/stations/auto", body), (answer) => {
|
|
190
|
+
const report = answer["report"] as { off?: boolean; radio?: Array<Record<string, unknown>>; tv?: Array<Record<string, unknown>>; skipped?: string[] };
|
|
191
|
+
if (report.off) return "Auto stations are off. The stations already made stay as they are.";
|
|
192
|
+
const line = (one: Record<string, unknown>, unit: string) => ` ${String(one["genre"]).padEnd(16)} ${String(one["files"] ?? one["films"]).padStart(5)} ${unit} ${one["running"] ? "on air" : "stopped"}${one["made"] ? " (new)" : ""} [${String(one["id"])}]`;
|
|
193
|
+
return [
|
|
194
|
+
"Radio:", ...(report.radio ?? []).map((one) => line(one, "files")),
|
|
195
|
+
"TV:", ...((report.tv ?? []).length ? (report.tv ?? []).map((one) => line(one, "films")) : [" none (no film genres known yet)"]),
|
|
196
|
+
...((report.skipped ?? []).length ? [`Too small for a station: ${(report.skipped ?? []).join(", ")}`] : []),
|
|
197
|
+
"Every library scan keeps these up to date. They are unlisted: `nixamp station link ID` for a link.",
|
|
198
|
+
].join("\n");
|
|
199
|
+
});
|
|
200
|
+
}
|
|
180
201
|
case "delete": {
|
|
181
202
|
const station = needId();
|
|
182
203
|
if (!station) return 2;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The station guide: every station on this server its owner made public
|
|
3
|
+
* and listed, what is on each, and a way in. What a Moshpit name like
|
|
4
|
+
* live.streaming opens.
|
|
5
|
+
*
|
|
6
|
+
* Like the station page, the same bytes for every server: it asks
|
|
7
|
+
* /api/v1/stations/guide for the list and writes it in as text.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
const STYLE = `
|
|
12
|
+
:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}
|
|
13
|
+
@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}
|
|
14
|
+
*{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}
|
|
15
|
+
main{max-width:880px;margin:0 auto;padding:40px 16px}
|
|
16
|
+
h1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}
|
|
17
|
+
h2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}
|
|
18
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
|
|
19
|
+
a.station{display:block;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;color:inherit;text-decoration:none}
|
|
20
|
+
a.station:hover,a.station:focus-visible{border-color:var(--accent);outline:none}
|
|
21
|
+
.name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}
|
|
22
|
+
.live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}
|
|
23
|
+
footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const SCRIPT = `
|
|
27
|
+
(async () => {
|
|
28
|
+
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
|
|
29
|
+
const draw = (stations) => {
|
|
30
|
+
for (const [kind, box] of [["audio", "#radio"], ["video", "#tv"]]) {
|
|
31
|
+
const grid = document.querySelector(box);
|
|
32
|
+
grid.replaceChildren();
|
|
33
|
+
const list = stations.filter((s) => s.kind === kind);
|
|
34
|
+
document.querySelector(box + "-title").hidden = list.length === 0;
|
|
35
|
+
for (const s of list) {
|
|
36
|
+
const a = el("a", "station");
|
|
37
|
+
a.href = s.page;
|
|
38
|
+
a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
|
|
39
|
+
el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
|
|
40
|
+
grid.append(a);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
document.querySelector("#empty").hidden = stations.length > 0;
|
|
44
|
+
};
|
|
45
|
+
const load = async () => {
|
|
46
|
+
try {
|
|
47
|
+
const r = await fetch("/api/v1/stations/guide", { cache: "no-store" });
|
|
48
|
+
if (r.ok) draw((await r.json()).stations);
|
|
49
|
+
} catch {}
|
|
50
|
+
};
|
|
51
|
+
await load();
|
|
52
|
+
setInterval(load, 15000);
|
|
53
|
+
})();
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
57
|
+
|
|
58
|
+
export const GUIDE_HTML = `<!doctype html>
|
|
59
|
+
<html lang="en">
|
|
60
|
+
<head>
|
|
61
|
+
<meta charset="utf-8">
|
|
62
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
63
|
+
<title>Live · stations</title>
|
|
64
|
+
<meta name="description" content="Radio and TV stations, live now.">
|
|
65
|
+
<link rel="icon" href="data:,">
|
|
66
|
+
<style>${STYLE}</style>
|
|
67
|
+
</head>
|
|
68
|
+
<body>
|
|
69
|
+
<main>
|
|
70
|
+
<h1>Live</h1>
|
|
71
|
+
<p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
|
|
72
|
+
<h2 id="radio-title" hidden>Radio</h2>
|
|
73
|
+
<div id="radio" class="grid" aria-live="polite"></div>
|
|
74
|
+
<h2 id="tv-title" hidden>TV</h2>
|
|
75
|
+
<div id="tv" class="grid" aria-live="polite"></div>
|
|
76
|
+
<p id="empty" class="empty">Nothing is on the air here yet.</p>
|
|
77
|
+
<footer>Played by <a href="https://nixamp.com">nixamp</a>, on the Moshpit.</footer>
|
|
78
|
+
</main>
|
|
79
|
+
<script>${SCRIPT}</script>
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
export const GUIDE_CSP = [
|
|
85
|
+
"default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
|
|
86
|
+
"img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
|
|
87
|
+
].join("; ");
|
|
88
|
+
|
|
89
|
+
/** The hosts whose front page is the guide (NIXAMP_GUIDE_HOSTS, comma-separated). */
|
|
90
|
+
export function guideHosts(env: Record<string, string | undefined> = process.env): Set<string> {
|
|
91
|
+
return new Set((env["NIXAMP_GUIDE_HOSTS"] ?? "").split(",").map((one) => one.trim().toLowerCase()).filter(Boolean));
|
|
92
|
+
}
|