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,86 @@
|
|
|
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
|
+
const STYLE = `
|
|
11
|
+
:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}
|
|
12
|
+
@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}
|
|
13
|
+
*{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}
|
|
14
|
+
main{max-width:880px;margin:0 auto;padding:40px 16px}
|
|
15
|
+
h1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}
|
|
16
|
+
h2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}
|
|
17
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
|
|
18
|
+
a.station{display:block;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;color:inherit;text-decoration:none}
|
|
19
|
+
a.station:hover,a.station:focus-visible{border-color:var(--accent);outline:none}
|
|
20
|
+
.name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}
|
|
21
|
+
.live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}
|
|
22
|
+
footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
|
|
23
|
+
`;
|
|
24
|
+
const SCRIPT = `
|
|
25
|
+
(async () => {
|
|
26
|
+
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
|
|
27
|
+
const draw = (stations) => {
|
|
28
|
+
for (const [kind, box] of [["audio", "#radio"], ["video", "#tv"]]) {
|
|
29
|
+
const grid = document.querySelector(box);
|
|
30
|
+
grid.replaceChildren();
|
|
31
|
+
const list = stations.filter((s) => s.kind === kind);
|
|
32
|
+
document.querySelector(box + "-title").hidden = list.length === 0;
|
|
33
|
+
for (const s of list) {
|
|
34
|
+
const a = el("a", "station");
|
|
35
|
+
a.href = s.page;
|
|
36
|
+
a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
|
|
37
|
+
el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
|
|
38
|
+
grid.append(a);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
document.querySelector("#empty").hidden = stations.length > 0;
|
|
42
|
+
};
|
|
43
|
+
const load = async () => {
|
|
44
|
+
try {
|
|
45
|
+
const r = await fetch("/api/v1/stations/guide", { cache: "no-store" });
|
|
46
|
+
if (r.ok) draw((await r.json()).stations);
|
|
47
|
+
} catch {}
|
|
48
|
+
};
|
|
49
|
+
await load();
|
|
50
|
+
setInterval(load, 15000);
|
|
51
|
+
})();
|
|
52
|
+
`;
|
|
53
|
+
const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
54
|
+
export const GUIDE_HTML = `<!doctype html>
|
|
55
|
+
<html lang="en">
|
|
56
|
+
<head>
|
|
57
|
+
<meta charset="utf-8">
|
|
58
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
59
|
+
<title>Live · stations</title>
|
|
60
|
+
<meta name="description" content="Radio and TV stations, live now.">
|
|
61
|
+
<link rel="icon" href="data:,">
|
|
62
|
+
<style>${STYLE}</style>
|
|
63
|
+
</head>
|
|
64
|
+
<body>
|
|
65
|
+
<main>
|
|
66
|
+
<h1>Live</h1>
|
|
67
|
+
<p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
|
|
68
|
+
<h2 id="radio-title" hidden>Radio</h2>
|
|
69
|
+
<div id="radio" class="grid" aria-live="polite"></div>
|
|
70
|
+
<h2 id="tv-title" hidden>TV</h2>
|
|
71
|
+
<div id="tv" class="grid" aria-live="polite"></div>
|
|
72
|
+
<p id="empty" class="empty">Nothing is on the air here yet.</p>
|
|
73
|
+
<footer>Played by <a href="https://nixamp.com">nixamp</a>, on the Moshpit.</footer>
|
|
74
|
+
</main>
|
|
75
|
+
<script>${SCRIPT}</script>
|
|
76
|
+
</body>
|
|
77
|
+
</html>
|
|
78
|
+
`;
|
|
79
|
+
export const GUIDE_CSP = [
|
|
80
|
+
"default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
|
|
81
|
+
"img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
|
|
82
|
+
].join("; ");
|
|
83
|
+
/** The hosts whose front page is the guide (NIXAMP_GUIDE_HOSTS, comma-separated). */
|
|
84
|
+
export function guideHosts(env = process.env) {
|
|
85
|
+
return new Set((env["NIXAMP_GUIDE_HOSTS"] ?? "").split(",").map((one) => one.trim().toLowerCase()).filter(Boolean));
|
|
86
|
+
}
|
package/dist/station-mcp.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ export declare const STATION_TOOLS: ({
|
|
|
22
22
|
listed?: undefined;
|
|
23
23
|
basis?: undefined;
|
|
24
24
|
note?: undefined;
|
|
25
|
+
video?: undefined;
|
|
26
|
+
minItems?: undefined;
|
|
27
|
+
minFilms?: undefined;
|
|
28
|
+
maxVideoRunning?: undefined;
|
|
29
|
+
breaksEvery?: undefined;
|
|
30
|
+
start?: undefined;
|
|
31
|
+
off?: undefined;
|
|
25
32
|
id?: undefined;
|
|
26
33
|
private?: undefined;
|
|
27
34
|
};
|
|
@@ -55,6 +62,13 @@ export declare const STATION_TOOLS: ({
|
|
|
55
62
|
listed?: undefined;
|
|
56
63
|
basis?: undefined;
|
|
57
64
|
note?: undefined;
|
|
65
|
+
video?: undefined;
|
|
66
|
+
minItems?: undefined;
|
|
67
|
+
minFilms?: undefined;
|
|
68
|
+
maxVideoRunning?: undefined;
|
|
69
|
+
breaksEvery?: undefined;
|
|
70
|
+
start?: undefined;
|
|
71
|
+
off?: undefined;
|
|
58
72
|
private?: undefined;
|
|
59
73
|
};
|
|
60
74
|
required: string[];
|
|
@@ -110,6 +124,13 @@ export declare const STATION_TOOLS: ({
|
|
|
110
124
|
listed?: undefined;
|
|
111
125
|
basis?: undefined;
|
|
112
126
|
note?: undefined;
|
|
127
|
+
video?: undefined;
|
|
128
|
+
minItems?: undefined;
|
|
129
|
+
minFilms?: undefined;
|
|
130
|
+
maxVideoRunning?: undefined;
|
|
131
|
+
breaksEvery?: undefined;
|
|
132
|
+
start?: undefined;
|
|
133
|
+
off?: undefined;
|
|
113
134
|
id?: undefined;
|
|
114
135
|
private?: undefined;
|
|
115
136
|
};
|
|
@@ -163,6 +184,13 @@ export declare const STATION_TOOLS: ({
|
|
|
163
184
|
listed?: undefined;
|
|
164
185
|
basis?: undefined;
|
|
165
186
|
note?: undefined;
|
|
187
|
+
video?: undefined;
|
|
188
|
+
minItems?: undefined;
|
|
189
|
+
minFilms?: undefined;
|
|
190
|
+
maxVideoRunning?: undefined;
|
|
191
|
+
breaksEvery?: undefined;
|
|
192
|
+
start?: undefined;
|
|
193
|
+
off?: undefined;
|
|
166
194
|
private?: undefined;
|
|
167
195
|
};
|
|
168
196
|
required: string[];
|
|
@@ -198,6 +226,13 @@ export declare const STATION_TOOLS: ({
|
|
|
198
226
|
listed?: undefined;
|
|
199
227
|
basis?: undefined;
|
|
200
228
|
note?: undefined;
|
|
229
|
+
video?: undefined;
|
|
230
|
+
minItems?: undefined;
|
|
231
|
+
minFilms?: undefined;
|
|
232
|
+
maxVideoRunning?: undefined;
|
|
233
|
+
breaksEvery?: undefined;
|
|
234
|
+
start?: undefined;
|
|
235
|
+
off?: undefined;
|
|
201
236
|
private?: undefined;
|
|
202
237
|
};
|
|
203
238
|
required: string[];
|
|
@@ -241,6 +276,13 @@ export declare const STATION_TOOLS: ({
|
|
|
241
276
|
note: {
|
|
242
277
|
readonly type: "string";
|
|
243
278
|
};
|
|
279
|
+
video?: undefined;
|
|
280
|
+
minItems?: undefined;
|
|
281
|
+
minFilms?: undefined;
|
|
282
|
+
maxVideoRunning?: undefined;
|
|
283
|
+
breaksEvery?: undefined;
|
|
284
|
+
start?: undefined;
|
|
285
|
+
off?: undefined;
|
|
244
286
|
private?: undefined;
|
|
245
287
|
};
|
|
246
288
|
required: string[];
|
|
@@ -269,6 +311,63 @@ export declare const STATION_TOOLS: ({
|
|
|
269
311
|
type: "string";
|
|
270
312
|
description: string;
|
|
271
313
|
};
|
|
314
|
+
video: {
|
|
315
|
+
type: string;
|
|
316
|
+
};
|
|
317
|
+
minItems: {
|
|
318
|
+
type: string;
|
|
319
|
+
};
|
|
320
|
+
minFilms: {
|
|
321
|
+
type: string;
|
|
322
|
+
};
|
|
323
|
+
maxVideoRunning: {
|
|
324
|
+
type: string;
|
|
325
|
+
};
|
|
326
|
+
breaksEvery: {
|
|
327
|
+
type: string;
|
|
328
|
+
};
|
|
329
|
+
start: {
|
|
330
|
+
type: string;
|
|
331
|
+
};
|
|
332
|
+
off: {
|
|
333
|
+
type: string;
|
|
334
|
+
};
|
|
335
|
+
id?: undefined;
|
|
336
|
+
private?: undefined;
|
|
337
|
+
};
|
|
338
|
+
required?: undefined;
|
|
339
|
+
};
|
|
340
|
+
} | {
|
|
341
|
+
name: string;
|
|
342
|
+
description: string;
|
|
343
|
+
inputSchema: {
|
|
344
|
+
type: string;
|
|
345
|
+
properties: {
|
|
346
|
+
playlist?: undefined;
|
|
347
|
+
idempotencyKey?: undefined;
|
|
348
|
+
name?: undefined;
|
|
349
|
+
description?: undefined;
|
|
350
|
+
folder?: undefined;
|
|
351
|
+
items?: undefined;
|
|
352
|
+
shuffle?: undefined;
|
|
353
|
+
repeat?: undefined;
|
|
354
|
+
bitrate?: undefined;
|
|
355
|
+
action?: undefined;
|
|
356
|
+
visibility?: undefined;
|
|
357
|
+
listed?: undefined;
|
|
358
|
+
basis?: undefined;
|
|
359
|
+
note?: undefined;
|
|
360
|
+
video?: undefined;
|
|
361
|
+
minItems?: undefined;
|
|
362
|
+
minFilms?: undefined;
|
|
363
|
+
maxVideoRunning?: undefined;
|
|
364
|
+
breaksEvery?: undefined;
|
|
365
|
+
start?: undefined;
|
|
366
|
+
off?: undefined;
|
|
367
|
+
server: {
|
|
368
|
+
type: "string";
|
|
369
|
+
description: string;
|
|
370
|
+
};
|
|
272
371
|
id: {
|
|
273
372
|
type: "string";
|
|
274
373
|
description: string;
|
package/dist/station-mcp.js
CHANGED
|
@@ -78,6 +78,18 @@ export const STATION_TOOLS = [
|
|
|
78
78
|
required: ["id", "visibility"],
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
|
+
{
|
|
82
|
+
name: "station_auto",
|
|
83
|
+
description: "A station for every genre in the library (radio from music tags, TV from film genres), kept up to date after every library scan, with a short break between songs and films. Unlisted; publishing stays a separate step. off: true stops the automation.",
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
server: SERVER,
|
|
88
|
+
video: { type: "boolean" }, minItems: { type: "integer" }, minFilms: { type: "integer" },
|
|
89
|
+
maxVideoRunning: { type: "integer" }, breaksEvery: { type: "integer" }, start: { type: "boolean" }, off: { type: "boolean" },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
81
93
|
{
|
|
82
94
|
name: "station_unpublish",
|
|
83
95
|
description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
|
|
@@ -134,6 +146,10 @@ export async function callStationTool(name, args, options = {}) {
|
|
|
134
146
|
const all = answer.body["stations"] ?? [];
|
|
135
147
|
return say(all.length === 0 ? "No stations on this server yet." : all.map(line).join("\n"));
|
|
136
148
|
}
|
|
149
|
+
if (name === "station_auto") {
|
|
150
|
+
const answer = await call("POST", "/api/v1/stations/auto", pick(["video", "minItems", "minFilms", "maxVideoRunning", "breaksEvery", "start", "off"]));
|
|
151
|
+
return answer.ok ? say(JSON.stringify(answer.body["report"], null, 2)) : refused(answer.body);
|
|
152
|
+
}
|
|
137
153
|
if (!/^[a-z0-9]{10}$/.test(id) && name !== "station_create")
|
|
138
154
|
return say("Which station? Pass its id from station_list.", true);
|
|
139
155
|
if (name === "station_get") {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface Limits {
|
|
2
|
+
stationListeners: number;
|
|
3
|
+
totalListeners: number;
|
|
4
|
+
runningStations: number;
|
|
5
|
+
/** Bytes per station per month; 0 is no allowance. */
|
|
6
|
+
monthlyBytes: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function limitsFromEnv(env?: Record<string, string | undefined>): Limits;
|
|
9
|
+
/** Station control: start, stop, edit, publish. Thirty a minute is a person. */
|
|
10
|
+
export declare const STATION_CONTROL_LIMIT: {
|
|
11
|
+
allowed: number;
|
|
12
|
+
windowMs: number;
|
|
13
|
+
};
|
|
14
|
+
export interface MonthUsage {
|
|
15
|
+
bytes: number;
|
|
16
|
+
listenerSeconds: number;
|
|
17
|
+
peakListeners: number;
|
|
18
|
+
restarts: number;
|
|
19
|
+
failures: number;
|
|
20
|
+
}
|
|
21
|
+
export declare function monthOf(at: number): string;
|
|
22
|
+
export declare class StationUsage {
|
|
23
|
+
private readonly file;
|
|
24
|
+
private readonly now;
|
|
25
|
+
/** station -> month -> usage */
|
|
26
|
+
private usage;
|
|
27
|
+
private live;
|
|
28
|
+
private timer;
|
|
29
|
+
constructor(file: string, now?: () => number);
|
|
30
|
+
static fileFor(stateDir: string, port: number): string;
|
|
31
|
+
private month;
|
|
32
|
+
private touch;
|
|
33
|
+
flush(): void;
|
|
34
|
+
/** Somebody started listening; returns what to call when they stop. */
|
|
35
|
+
joined(station: string): () => void;
|
|
36
|
+
delivered(station: string, bytes: number): void;
|
|
37
|
+
restarted(station: string): void;
|
|
38
|
+
failed(station: string): void;
|
|
39
|
+
listeners(station: string): number;
|
|
40
|
+
totalListeners(): number;
|
|
41
|
+
thisMonth(station: string): MonthUsage;
|
|
42
|
+
history(station: string): Record<string, MonthUsage>;
|
|
43
|
+
forget(station: string): void;
|
|
44
|
+
/**
|
|
45
|
+
* Whether one more listener may join this station now: null to go
|
|
46
|
+
* ahead, or why not, with a code and how long to wait.
|
|
47
|
+
*/
|
|
48
|
+
admit(station: string, limits: Limits): {
|
|
49
|
+
code: string;
|
|
50
|
+
error: string;
|
|
51
|
+
status: number;
|
|
52
|
+
retryAfter: number;
|
|
53
|
+
} | null;
|
|
54
|
+
}
|
|
55
|
+
/** Things an owner should know about a station, in words. */
|
|
56
|
+
export declare function alertsFor(station: {
|
|
57
|
+
desired: string;
|
|
58
|
+
failures: Record<string, number>;
|
|
59
|
+
}, state: string, offlineSince: number, usage: MonthUsage, limits: Limits, now?: number): string[];
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What stations cost to run, and the limits that keep one from costing
|
|
3
|
+
* everything.
|
|
4
|
+
*
|
|
5
|
+
* Counted where it is spent: every byte a station's link delivers and every
|
|
6
|
+
* second somebody is connected, per station, per calendar month, kept in
|
|
7
|
+
* `usage-<port>.json`. Restarts and files that would not play are counted
|
|
8
|
+
* too, because those are what an operator is asked about.
|
|
9
|
+
*
|
|
10
|
+
* Limits are the operator's (environment, below), and each is a refusal a
|
|
11
|
+
* person can act on, never a silent cut:
|
|
12
|
+
*
|
|
13
|
+
* NIXAMP_STATION_MAX_LISTENERS per station (default 200)
|
|
14
|
+
* NIXAMP_MAX_LISTENERS across every station (default 500)
|
|
15
|
+
* NIXAMP_MAX_RUNNING_STATIONS stations on the air at once (default 10)
|
|
16
|
+
* NIXAMP_STATION_MONTHLY_GB delivered per station per month (default 0: no allowance)
|
|
17
|
+
*
|
|
18
|
+
* When an allowance runs out the policy is "pause": nobody new is admitted
|
|
19
|
+
* until the month turns or the allowance is raised; nobody already
|
|
20
|
+
* listening is cut off mid-song. Control requests are limited separately
|
|
21
|
+
* (STATION_CONTROL_LIMIT) so that a flood of listeners never locks the owner
|
|
22
|
+
* out, and a flood of control requests never starves listeners.
|
|
23
|
+
*/
|
|
24
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
export function limitsFromEnv(env = process.env) {
|
|
27
|
+
const whole = (name, fallback) => {
|
|
28
|
+
const value = Number(env[name]);
|
|
29
|
+
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
stationListeners: whole("NIXAMP_STATION_MAX_LISTENERS", 200),
|
|
33
|
+
totalListeners: whole("NIXAMP_MAX_LISTENERS", 500),
|
|
34
|
+
runningStations: whole("NIXAMP_MAX_RUNNING_STATIONS", 10),
|
|
35
|
+
monthlyBytes: Math.round(Number(env["NIXAMP_STATION_MONTHLY_GB"] ?? 0) * 1024 ** 3) || 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Station control: start, stop, edit, publish. Thirty a minute is a person. */
|
|
39
|
+
export const STATION_CONTROL_LIMIT = { allowed: 30, windowMs: 60_000 };
|
|
40
|
+
const BLANK = { bytes: 0, listenerSeconds: 0, peakListeners: 0, restarts: 0, failures: 0 };
|
|
41
|
+
export function monthOf(at) {
|
|
42
|
+
return new Date(at).toISOString().slice(0, 7);
|
|
43
|
+
}
|
|
44
|
+
export class StationUsage {
|
|
45
|
+
file;
|
|
46
|
+
now;
|
|
47
|
+
/** station -> month -> usage */
|
|
48
|
+
usage = {};
|
|
49
|
+
live = new Map();
|
|
50
|
+
timer = null;
|
|
51
|
+
constructor(file, now = Date.now) {
|
|
52
|
+
this.file = file;
|
|
53
|
+
this.now = now;
|
|
54
|
+
try {
|
|
55
|
+
this.usage = JSON.parse(readFileSync(file, "utf8"));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
this.usage = {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
static fileFor(stateDir, port) {
|
|
62
|
+
return join(stateDir, `usage-${port}.json`);
|
|
63
|
+
}
|
|
64
|
+
month(station, at = this.now()) {
|
|
65
|
+
const byMonth = (this.usage[station] ??= {});
|
|
66
|
+
return (byMonth[monthOf(at)] ??= { ...BLANK });
|
|
67
|
+
}
|
|
68
|
+
touch() {
|
|
69
|
+
if (this.timer)
|
|
70
|
+
return;
|
|
71
|
+
this.timer = setTimeout(() => this.flush(), 5_000);
|
|
72
|
+
this.timer.unref?.();
|
|
73
|
+
}
|
|
74
|
+
flush() {
|
|
75
|
+
if (this.timer)
|
|
76
|
+
clearTimeout(this.timer);
|
|
77
|
+
this.timer = null;
|
|
78
|
+
// Listening still going on is counted up to now, then counted on from now.
|
|
79
|
+
const at = this.now();
|
|
80
|
+
for (const [station, listeners] of this.live) {
|
|
81
|
+
for (const [who, since] of listeners) {
|
|
82
|
+
this.month(station, at).listenerSeconds += Math.max(0, (at - since) / 1000);
|
|
83
|
+
listeners.set(who, at);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
88
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
89
|
+
writeFileSync(temporary, JSON.stringify(this.usage), { mode: 0o600 });
|
|
90
|
+
renameSync(temporary, this.file);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Counting is not a reason to stop serving.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Somebody started listening; returns what to call when they stop. */
|
|
97
|
+
joined(station) {
|
|
98
|
+
const who = {};
|
|
99
|
+
let listeners = this.live.get(station);
|
|
100
|
+
if (!listeners) {
|
|
101
|
+
listeners = new Map();
|
|
102
|
+
this.live.set(station, listeners);
|
|
103
|
+
}
|
|
104
|
+
listeners.set(who, this.now());
|
|
105
|
+
const month = this.month(station);
|
|
106
|
+
month.peakListeners = Math.max(month.peakListeners, listeners.size);
|
|
107
|
+
this.touch();
|
|
108
|
+
return () => {
|
|
109
|
+
const since = listeners.get(who);
|
|
110
|
+
if (since === undefined)
|
|
111
|
+
return;
|
|
112
|
+
listeners.delete(who);
|
|
113
|
+
this.month(station).listenerSeconds += Math.max(0, (this.now() - since) / 1000);
|
|
114
|
+
this.touch();
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
delivered(station, bytes) {
|
|
118
|
+
this.month(station).bytes += bytes;
|
|
119
|
+
this.touch();
|
|
120
|
+
}
|
|
121
|
+
restarted(station) {
|
|
122
|
+
this.month(station).restarts += 1;
|
|
123
|
+
this.touch();
|
|
124
|
+
}
|
|
125
|
+
failed(station) {
|
|
126
|
+
this.month(station).failures += 1;
|
|
127
|
+
this.touch();
|
|
128
|
+
}
|
|
129
|
+
listeners(station) {
|
|
130
|
+
return this.live.get(station)?.size ?? 0;
|
|
131
|
+
}
|
|
132
|
+
totalListeners() {
|
|
133
|
+
let total = 0;
|
|
134
|
+
for (const listeners of this.live.values())
|
|
135
|
+
total += listeners.size;
|
|
136
|
+
return total;
|
|
137
|
+
}
|
|
138
|
+
thisMonth(station) {
|
|
139
|
+
return { ...this.month(station) };
|
|
140
|
+
}
|
|
141
|
+
history(station) {
|
|
142
|
+
return { ...(this.usage[station] ?? {}) };
|
|
143
|
+
}
|
|
144
|
+
forget(station) {
|
|
145
|
+
delete this.usage[station];
|
|
146
|
+
this.live.delete(station);
|
|
147
|
+
this.touch();
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Whether one more listener may join this station now: null to go
|
|
151
|
+
* ahead, or why not, with a code and how long to wait.
|
|
152
|
+
*/
|
|
153
|
+
admit(station, limits) {
|
|
154
|
+
if (limits.monthlyBytes > 0 && this.month(station).bytes >= limits.monthlyBytes) {
|
|
155
|
+
const now = new Date(this.now());
|
|
156
|
+
const next = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
|
|
157
|
+
return { code: "allowance_exhausted", error: "this station has used its delivery allowance for the month", status: 429, retryAfter: Math.ceil((next - this.now()) / 1000) };
|
|
158
|
+
}
|
|
159
|
+
if (limits.stationListeners > 0 && this.listeners(station) >= limits.stationListeners) {
|
|
160
|
+
return { code: "station_full", error: "this station is full; try again in a little while", status: 503, retryAfter: 60 };
|
|
161
|
+
}
|
|
162
|
+
if (limits.totalListeners > 0 && this.totalListeners() >= limits.totalListeners) {
|
|
163
|
+
return { code: "server_full", error: "this server is carrying as many listeners as it can; try again in a little while", status: 503, retryAfter: 60 };
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Things an owner should know about a station, in words. */
|
|
169
|
+
export function alertsFor(station, state, offlineSince, usage, limits, now = Date.now()) {
|
|
170
|
+
const alerts = [];
|
|
171
|
+
if (station.desired === "running" && (state === "offline" || state === "recovering") && offlineSince > 0) {
|
|
172
|
+
const minutes = Math.floor((now - offlineSince) / 60_000);
|
|
173
|
+
if (minutes >= 2)
|
|
174
|
+
alerts.push(`Off the air for ${minutes} minutes while it should be running.`);
|
|
175
|
+
}
|
|
176
|
+
const left = Object.values(station.failures).filter((count) => count >= 3).length;
|
|
177
|
+
if (left > 0)
|
|
178
|
+
alerts.push(`${left} file${left === 1 ? " is" : "s are"} left out because ${left === 1 ? "it" : "they"} would not play.`);
|
|
179
|
+
if (usage.restarts >= 5)
|
|
180
|
+
alerts.push(`Restarted ${usage.restarts} times this month: something keeps stopping it.`);
|
|
181
|
+
if (limits.monthlyBytes > 0) {
|
|
182
|
+
const share = usage.bytes / limits.monthlyBytes;
|
|
183
|
+
if (share >= 1)
|
|
184
|
+
alerts.push("The delivery allowance for this month is used up: nobody new can join until it resets.");
|
|
185
|
+
else if (share >= 0.9)
|
|
186
|
+
alerts.push(`${Math.round(share * 100)}% of this month's delivery allowance is used.`);
|
|
187
|
+
}
|
|
188
|
+
return alerts;
|
|
189
|
+
}
|
package/dist/station-page.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** A station page's path: /station/<id>. */
|
|
2
2
|
export declare const STATION_PAGE: RegExp;
|
|
3
|
-
export declare const STATION_PAGE_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<meta name=\"robots\" content=\"noindex\">\n<link rel=\"icon\" href=\"data:,\">\n<title>Station \u00B7 nixamp</title>\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}\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:560px;margin:0 auto;padding:48px 16px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}\nh1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}\n.about{color:var(--muted);margin:0 0 20px}\n.chip{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600;padding:4px 10px;border-radius:999px;border:1px solid var(--line)}\n.chip::before{content:\"\";width:8px;height:8px;border-radius:50%;background:currentColor}\n.live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}\n.now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}\n.controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}\nbutton{font:inherit;font-weight:600;border:0;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;cursor:pointer;min-width:120px}\nbutton:disabled{opacity:.5;cursor:not-allowed}\nbutton:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}\nlabel{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}\ninput[type=range]{width:120px;accent-color:var(--accent)}\n.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}\n.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\n@media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}\n@keyframes pulse{50%{opacity:.35}}\n</style>\n</head>\n<body>\n<main>\n <div class=\"card\">\n <span id=\"state\" class=\"chip unavailable\" role=\"status\">Loading</span>\n <h1 id=\"name\"> </h1>\n <p id=\"about\" class=\"about\" hidden></p>\n <div class=\"now\" aria-live=\"polite\"><div id=\"title\" class=\"title\"></div><div id=\"by\" class=\"by\"></div></div>\n <div class=\"controls\">\n <button id=\"play\" type=\"button\" aria-pressed=\"false\" disabled>Play</button>\n <label>Volume <input id=\"volume\" type=\"range\" min=\"0\" max=\"1\" step=\"0.05\" value=\"1\"></label>\n </div>\n <p class=\"small\"><a id=\"m3u\" href=\"#\">Open in another player (.m3u)</a> \u00B7 Pausing here does not pause the station.</p>\n </div>\n <p class=\"small\">Played by <a href=\"https://nixamp.com\">nixamp</a>.</p>\n <div id=\"announce\" class=\"sr\" aria-live=\"assertive\"></div>\n</main>\n<script>\n(() => {\n const id = location.pathname.split(\"/\").filter(Boolean)[1] || \"\";\n const token = new URLSearchParams(location.search).get(\"t\") || \"\";\n const q = \"?t=\" + encodeURIComponent(token);\n const api = \"/api/v1/stations/\" + encodeURIComponent(id);\n const $ = (s) => document.querySelector(s);\n
|
|
3
|
+
export declare const STATION_PAGE_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<meta name=\"robots\" content=\"noindex\">\n<link rel=\"icon\" href=\"data:,\">\n<title>Station \u00B7 nixamp</title>\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}\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:560px;margin:0 auto;padding:48px 16px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}\nh1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}\n.about{color:var(--muted);margin:0 0 20px}\n.chip{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600;padding:4px 10px;border-radius:999px;border:1px solid var(--line)}\n.chip::before{content:\"\";width:8px;height:8px;border-radius:50%;background:currentColor}\n.live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}\n.now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}\n.controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}\nbutton{font:inherit;font-weight:600;border:0;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;cursor:pointer;min-width:120px}\nbutton:disabled{opacity:.5;cursor:not-allowed}\nbutton:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}\nlabel{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}\ninput[type=range]{width:120px;accent-color:var(--accent)}\n.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}\n.screen{display:block;width:100%;aspect-ratio:16/9;background:#000;border-radius:10px;margin:12px 0}\n.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\n@media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}\n@keyframes pulse{50%{opacity:.35}}\n</style>\n</head>\n<body>\n<main>\n <div class=\"card\">\n <span id=\"state\" class=\"chip unavailable\" role=\"status\">Loading</span>\n <video id=\"screen\" class=\"screen\" playsinline preload=\"none\" hidden></video>\n <h1 id=\"name\"> </h1>\n <p id=\"about\" class=\"about\" hidden></p>\n <div class=\"now\" aria-live=\"polite\"><div id=\"title\" class=\"title\"></div><div id=\"by\" class=\"by\"></div></div>\n <div class=\"controls\">\n <button id=\"play\" type=\"button\" aria-pressed=\"false\" disabled>Play</button>\n <label>Volume <input id=\"volume\" type=\"range\" min=\"0\" max=\"1\" step=\"0.05\" value=\"1\"></label>\n </div>\n <p class=\"small\"><a id=\"m3u\" href=\"#\">Open in another player (.m3u)</a> \u00B7 Pausing here does not pause the station.</p>\n </div>\n <p class=\"small\">Played by <a href=\"https://nixamp.com\">nixamp</a>.</p>\n <div id=\"announce\" class=\"sr\" aria-live=\"assertive\"></div>\n</main>\n<script>\n(() => {\n const id = location.pathname.split(\"/\").filter(Boolean)[1] || \"\";\n const token = new URLSearchParams(location.search).get(\"t\") || \"\";\n const q = \"?t=\" + encodeURIComponent(token);\n const api = \"/api/v1/stations/\" + encodeURIComponent(id);\n const $ = (s) => document.querySelector(s);\n // Sound, or a TV station's picture: the element is chosen by the first state.\n let audio = new Audio();\n audio.preload = \"none\";\n let wired = false;\n const wire = () => {\n if (wired) return;\n wired = true;\n audio.addEventListener(\"playing\", () => { if (retry) say(\"Back on\"); retry = 0; if (last) draw(last); });\n audio.addEventListener(\"error\", again);\n audio.addEventListener(\"ended\", again);\n audio.addEventListener(\"stalled\", () => { if (wanted && audio.readyState < 3) again(); });\n };\n let wanted = false, retry = 0, timer = 0, last = null, live = false;\n const chip = (state, text) => { const c = $(\"#state\"); c.className = \"chip \" + state; c.textContent = text; };\n const say = (text) => { $(\"#announce\").textContent = text; };\n const draw = (s) => {\n last = s;\n if (s.kind === \"video\" && audio.tagName !== \"VIDEO\") {\n audio = $(\"#screen\");\n audio.hidden = false;\n wired = false;\n wire();\n }\n document.title = s.name + \" \u00B7 nixamp\";\n $(\"#name\").textContent = s.name;\n $(\"#about\").textContent = s.description || \"\";\n $(\"#about\").hidden = !s.description;\n if (!live) title(s);\n if (wanted && retry > 0) chip(\"reconnecting\", \"Reconnecting\");\n else if (s.state === \"live\") chip(\"live\", \"Live\");\n else if (s.state === \"starting\" || s.state === \"recovering\") chip(\"reconnecting\", \"Coming back\");\n else if (s.state === \"stopped\") chip(\"stopped\", \"Off the air\");\n else chip(\"unavailable\", \"Unavailable\");\n $(\"#play\").disabled = !(s.state === \"live\" || s.state === \"recovering\" || s.state === \"starting\");\n $(\"#m3u\").href = api + \"/listen.m3u\" + q;\n };\n const title = (s) => {\n const playing = s.nowPlaying;\n $(\"#title\").textContent = playing ? (playing.title || \"Untitled\") : (s.state === \"live\" ? \"On the air\" : \"\");\n $(\"#by\").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(\" \u00B7 \") : \"\";\n };\n // What is ahead of the speaker: the sound the player holds and has not\n // played yet. A new title waits that long, so it changes with the song.\n const ahead = () => {\n if (!wanted || audio.paused || audio.buffered.length === 0) return 0;\n return Math.max(0, Math.min(30, audio.buffered.end(audio.buffered.length - 1) - audio.currentTime));\n };\n const listen = () => {\n if (!window.EventSource) return;\n const events = new EventSource(api + \"/events\" + q);\n events.addEventListener(\"hello\", (e) => { live = true; title(JSON.parse(e.data)); });\n events.addEventListener(\"now\", (e) => {\n const s = JSON.parse(e.data);\n setTimeout(() => { title(s); say(s.nowPlaying ? \"Now playing \" + (s.nowPlaying.title || \"\") : \"\"); }, ahead() * 1000);\n });\n events.addEventListener(\"state\", () => poll());\n events.onerror = () => { live = false; };\n };\n const poll = async () => {\n try {\n const r = await fetch(api + \"/state\" + q, { cache: \"no-store\" });\n if (r.status === 404) { $(\"#name\").textContent = \"This station is not available\"; chip(\"unavailable\", \"Unavailable\"); $(\"#play\").disabled = true; stop(); return; }\n if (r.ok) draw(await r.json());\n } catch {}\n };\n // Always the live edge: a fresh request, never a cached or paused one.\n const join = () => { audio.src = api + \"/stream\" + q + \"&n=\" + Date.now(); audio.play().catch(() => { wanted = false; button(); }); };\n const stop = () => { wanted = false; clearTimeout(timer); audio.pause(); audio.removeAttribute(\"src\"); audio.load(); button(); };\n const button = () => { const b = $(\"#play\"); b.textContent = wanted ? \"Stop\" : \"Play\"; b.setAttribute(\"aria-pressed\", String(wanted)); };\n const again = () => {\n if (!wanted) return;\n retry += 1;\n if (last) draw(last);\n clearTimeout(timer);\n timer = setTimeout(join, Math.min(15000, 1000 * 2 ** Math.min(retry, 4)));\n };\n wire();\n $(\"#play\").addEventListener(\"click\", () => { if (wanted) { stop(); say(\"Stopped\"); } else { wanted = true; retry = 0; button(); join(); say(\"Playing\"); } });\n $(\"#volume\").addEventListener(\"input\", (e) => { audio.volume = Number(e.target.value); });\n poll();\n listen();\n setInterval(poll, 5000);\n})();\n</script>\n</body>\n</html>\n";
|
|
4
4
|
/** Only its own script and style, only its own server, never framed. */
|
|
5
5
|
export declare const STATION_PAGE_CSP: string;
|
package/dist/station-page.js
CHANGED
|
@@ -29,6 +29,7 @@ button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(-
|
|
|
29
29
|
label{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}
|
|
30
30
|
input[type=range]{width:120px;accent-color:var(--accent)}
|
|
31
31
|
.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}
|
|
32
|
+
.screen{display:block;width:100%;aspect-ratio:16/9;background:#000;border-radius:10px;margin:12px 0}
|
|
32
33
|
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
33
34
|
@media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}
|
|
34
35
|
@keyframes pulse{50%{opacity:.35}}
|
|
@@ -40,13 +41,29 @@ const SCRIPT = `
|
|
|
40
41
|
const q = "?t=" + encodeURIComponent(token);
|
|
41
42
|
const api = "/api/v1/stations/" + encodeURIComponent(id);
|
|
42
43
|
const $ = (s) => document.querySelector(s);
|
|
43
|
-
|
|
44
|
+
// Sound, or a TV station's picture: the element is chosen by the first state.
|
|
45
|
+
let audio = new Audio();
|
|
44
46
|
audio.preload = "none";
|
|
47
|
+
let wired = false;
|
|
48
|
+
const wire = () => {
|
|
49
|
+
if (wired) return;
|
|
50
|
+
wired = true;
|
|
51
|
+
audio.addEventListener("playing", () => { if (retry) say("Back on"); retry = 0; if (last) draw(last); });
|
|
52
|
+
audio.addEventListener("error", again);
|
|
53
|
+
audio.addEventListener("ended", again);
|
|
54
|
+
audio.addEventListener("stalled", () => { if (wanted && audio.readyState < 3) again(); });
|
|
55
|
+
};
|
|
45
56
|
let wanted = false, retry = 0, timer = 0, last = null, live = false;
|
|
46
57
|
const chip = (state, text) => { const c = $("#state"); c.className = "chip " + state; c.textContent = text; };
|
|
47
58
|
const say = (text) => { $("#announce").textContent = text; };
|
|
48
59
|
const draw = (s) => {
|
|
49
60
|
last = s;
|
|
61
|
+
if (s.kind === "video" && audio.tagName !== "VIDEO") {
|
|
62
|
+
audio = $("#screen");
|
|
63
|
+
audio.hidden = false;
|
|
64
|
+
wired = false;
|
|
65
|
+
wire();
|
|
66
|
+
}
|
|
50
67
|
document.title = s.name + " · nixamp";
|
|
51
68
|
$("#name").textContent = s.name;
|
|
52
69
|
$("#about").textContent = s.description || "";
|
|
@@ -100,10 +117,7 @@ const SCRIPT = `
|
|
|
100
117
|
clearTimeout(timer);
|
|
101
118
|
timer = setTimeout(join, Math.min(15000, 1000 * 2 ** Math.min(retry, 4)));
|
|
102
119
|
};
|
|
103
|
-
|
|
104
|
-
audio.addEventListener("error", again);
|
|
105
|
-
audio.addEventListener("ended", again);
|
|
106
|
-
audio.addEventListener("stalled", () => { if (wanted && audio.readyState < 3) again(); });
|
|
120
|
+
wire();
|
|
107
121
|
$("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
|
|
108
122
|
$("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
|
|
109
123
|
poll();
|
|
@@ -126,6 +140,7 @@ const PAGE = `<!doctype html>
|
|
|
126
140
|
<main>
|
|
127
141
|
<div class="card">
|
|
128
142
|
<span id="state" class="chip unavailable" role="status">Loading</span>
|
|
143
|
+
<video id="screen" class="screen" playsinline preload="none" hidden></video>
|
|
129
144
|
<h1 id="name"> </h1>
|
|
130
145
|
<p id="about" class="about" hidden></p>
|
|
131
146
|
<div class="now" aria-live="polite"><div id="title" class="title"></div><div id="by" class="by"></div></div>
|
package/dist/station-routes.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
16
|
import type { Channels } from "./channels.ts";
|
|
17
17
|
import type { StationRunner } from "./station-runner.ts";
|
|
18
|
+
import { type Limits, type StationUsage } from "./station-metering.ts";
|
|
18
19
|
import { type OwnerStation, type PublicStation, type Station, type StationStore } from "./stations.ts";
|
|
19
20
|
/** Whether this path is one a station's own link may open, without the server's key. */
|
|
20
21
|
export declare function isStationListenPath(path: string): boolean;
|
|
@@ -43,6 +44,18 @@ export interface StationContext {
|
|
|
43
44
|
readBody: (request: IncomingMessage) => Promise<string>;
|
|
44
45
|
/** Headers every answer carries (CORS). */
|
|
45
46
|
headers: Record<string, string>;
|
|
47
|
+
/** Metering and limits; absent in tests that do not care. */
|
|
48
|
+
usage?: StationUsage;
|
|
49
|
+
limits?: Limits;
|
|
50
|
+
/** Null to go ahead with a change, or how long to wait (control rate limit). */
|
|
51
|
+
throttle?: () => {
|
|
52
|
+
retryAfter: number;
|
|
53
|
+
} | null;
|
|
54
|
+
/** Auto stations: run a sync with these settings (and keep them), or read them. */
|
|
55
|
+
auto?: {
|
|
56
|
+
sync: (input: Record<string, unknown>) => Promise<unknown>;
|
|
57
|
+
setting: () => unknown;
|
|
58
|
+
};
|
|
46
59
|
}
|
|
47
60
|
declare function links(ctx: StationContext, station: Station): {
|
|
48
61
|
page: string;
|