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,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") {
|
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
|
@@ -51,6 +51,11 @@ export interface StationContext {
|
|
|
51
51
|
throttle?: () => {
|
|
52
52
|
retryAfter: number;
|
|
53
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
|
+
};
|
|
54
59
|
}
|
|
55
60
|
declare function links(ctx: StationContext, station: Station): {
|
|
56
61
|
page: string;
|
package/dist/station-routes.js
CHANGED
|
@@ -4,7 +4,7 @@ import { StationError, channelOf, tokensMatch, } from "./stations.js";
|
|
|
4
4
|
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events)$/;
|
|
5
5
|
/** Whether this path is one a station's own link may open, without the server's key. */
|
|
6
6
|
export function isStationListenPath(path) {
|
|
7
|
-
return LISTEN.test(path);
|
|
7
|
+
return LISTEN.test(path) || path === "/api/v1/stations/guide";
|
|
8
8
|
}
|
|
9
9
|
function fail(ctx, response, error) {
|
|
10
10
|
if (error instanceof StationError) {
|
|
@@ -43,7 +43,10 @@ export function publicView(ctx, station) {
|
|
|
43
43
|
state,
|
|
44
44
|
// Tags, or nothing: a bare file name is the owner's, not the public's.
|
|
45
45
|
nowPlaying: track && (track.title || track.artist) ? track : null,
|
|
46
|
-
|
|
46
|
+
kind: station.video ? "video" : "audio",
|
|
47
|
+
format: state === "stopped" || state === "offline" ? null : station.video
|
|
48
|
+
? { codec: "h264+aac", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 }
|
|
49
|
+
: { codec: "mp3", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 },
|
|
47
50
|
listeners: ctx.runner.listeners(station.id),
|
|
48
51
|
};
|
|
49
52
|
}
|
|
@@ -68,6 +71,15 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
68
71
|
if (path !== "/api/v1/stations" && !path.startsWith("/api/v1/stations/"))
|
|
69
72
|
return false;
|
|
70
73
|
const method = request.method ?? "GET";
|
|
74
|
+
// The public guide: only stations their owner made public and listed, with
|
|
75
|
+
// their links, because public is what that means. Nothing else, ever.
|
|
76
|
+
if (path === "/api/v1/stations/guide" && (method === "GET" || method === "HEAD")) {
|
|
77
|
+
const listed = ctx.store.list().filter((one) => one.visibility === "public" && one.listed);
|
|
78
|
+
ctx.json(response, 200, {
|
|
79
|
+
stations: listed.map((one) => ({ ...publicView(ctx, one), page: links(ctx, one).page, m3u: links(ctx, one).m3u })),
|
|
80
|
+
});
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
71
83
|
const listen = LISTEN.exec(path);
|
|
72
84
|
if (listen) {
|
|
73
85
|
const station = ctx.store.get(listen[1]);
|
|
@@ -261,6 +273,22 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
261
273
|
ctx.json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
|
|
262
274
|
return true;
|
|
263
275
|
}
|
|
276
|
+
if (path === "/api/v1/stations/auto") {
|
|
277
|
+
if (!ctx.auto) {
|
|
278
|
+
ctx.json(response, 404, { error: "auto stations are not available here", code: "not_found" });
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
if (method === "GET") {
|
|
282
|
+
ctx.json(response, 200, { setting: ctx.auto.setting() });
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
if (method === "POST") {
|
|
286
|
+
ctx.json(response, 200, { report: await ctx.auto.sync(await body()) });
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
ctx.json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
264
292
|
const match = /^\/api\/v1\/stations\/([a-z0-9]{10})(?:\/([a-z-]+))?$/.exec(path);
|
|
265
293
|
if (!match) {
|
|
266
294
|
ctx.json(response, 404, { error: "no such station", code: "not_found" });
|
package/dist/station-runner.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface RunnerDeps {
|
|
|
26
26
|
/** Told when a station is restarted after an unexpected end, and when a file will not play. */
|
|
27
27
|
onRestart?: (id: string) => void;
|
|
28
28
|
onFailure?: (id: string) => void;
|
|
29
|
+
/** A file for a break on a radio (false) or TV (true) station, or null for none. */
|
|
30
|
+
breaks?: (video: boolean) => Promise<string | null>;
|
|
29
31
|
}
|
|
30
32
|
/** Something a listener is connected through, that can be ended. */
|
|
31
33
|
export interface Ending {
|
package/dist/station-runner.js
CHANGED
|
@@ -107,6 +107,9 @@ export class StationRunner {
|
|
|
107
107
|
return now ? this.playable(now) : [];
|
|
108
108
|
},
|
|
109
109
|
excluded: (file) => store.isExcluded(id, file),
|
|
110
|
+
...(station.breaksEvery > 0 && this.deps.breaks
|
|
111
|
+
? { breaks: { everySeconds: station.breaksEvery, next: () => this.deps.breaks(station.video) } }
|
|
112
|
+
: {}),
|
|
110
113
|
onFailure: ({ file, reason }) => {
|
|
111
114
|
this.errors.set(id, reason);
|
|
112
115
|
this.deps.onFailure?.(id);
|
package/dist/station-usage.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** `nixamp station` usage, apart so `nixamp help station` does not load the client. */
|
|
2
|
-
export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
|
|
2
|
+
export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
|
package/dist/station-usage.js
CHANGED
|
@@ -13,6 +13,8 @@ export const USAGE = `nixamp station — make a collection into a station
|
|
|
13
13
|
nixamp station unpublish ID [--private]
|
|
14
14
|
nixamp station link ID [--rotate]
|
|
15
15
|
nixamp station delete ID
|
|
16
|
+
nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]
|
|
17
|
+
a station per genre, kept in step with the library (--off stops it)
|
|
16
18
|
|
|
17
19
|
--json machine-readable output
|
|
18
20
|
--url, --key another server, as with \`nixamp admin\`
|
package/dist/stations.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface Station {
|
|
|
26
26
|
listenToken: string;
|
|
27
27
|
/** Audio bitrate of the station's rendition, in kbps. */
|
|
28
28
|
bitrate: number;
|
|
29
|
+
/** A TV station: pictures as well as sound. */
|
|
30
|
+
video: boolean;
|
|
31
|
+
/** A break (advert or ident) at most this often, in seconds; 0 for none. */
|
|
32
|
+
breaksEvery: number;
|
|
33
|
+
/** Made and kept up to date by auto stations (e.g. "genre:audio:rock"); "" for one a person made. */
|
|
34
|
+
auto: string;
|
|
29
35
|
rights: Rights | null;
|
|
30
36
|
/** nixamp.com account that made it, "" when made with the control key. */
|
|
31
37
|
owner: string;
|
|
@@ -59,6 +65,8 @@ export interface StationInput {
|
|
|
59
65
|
shuffle?: unknown;
|
|
60
66
|
repeat?: unknown;
|
|
61
67
|
bitrate?: unknown;
|
|
68
|
+
video?: unknown;
|
|
69
|
+
breaksEvery?: unknown;
|
|
62
70
|
}
|
|
63
71
|
/** The stations of one server, kept in one file beside its keys. */
|
|
64
72
|
export declare class StationStore {
|
|
@@ -77,6 +85,10 @@ export declare class StationStore {
|
|
|
77
85
|
/** The station a create with this key already made. */
|
|
78
86
|
byIdempotencyKey(key: string): Station | undefined;
|
|
79
87
|
create(input: StationInput, items: string[], owner: string, now?: number, idempotencyKey?: string): Station;
|
|
88
|
+
private breaksFrom;
|
|
89
|
+
/** Mark a station as made by auto stations, so a later sync updates it rather than making another. */
|
|
90
|
+
markAuto(id: string, key: string): Station;
|
|
91
|
+
byAuto(key: string): Station | undefined;
|
|
80
92
|
private bitrateFrom;
|
|
81
93
|
/** Name, words, files and rotation. Who may listen is changed by publish/unpublish. */
|
|
82
94
|
update(id: string, input: StationInput, items: string[] | null, now?: number): Station;
|
|
@@ -126,6 +138,8 @@ export interface PublicStation {
|
|
|
126
138
|
description: string;
|
|
127
139
|
visibility: Visibility;
|
|
128
140
|
state: Observed;
|
|
141
|
+
/** Radio or TV. */
|
|
142
|
+
kind: "audio" | "video";
|
|
129
143
|
nowPlaying: {
|
|
130
144
|
title: string;
|
|
131
145
|
artist: string;
|
package/dist/stations.js
CHANGED
|
@@ -121,6 +121,9 @@ export class StationStore {
|
|
|
121
121
|
owner: one.owner ?? "",
|
|
122
122
|
description: one.description ?? "",
|
|
123
123
|
idempotencyKey: one.idempotencyKey ?? "",
|
|
124
|
+
video: one.video ?? false,
|
|
125
|
+
breaksEvery: one.breaksEvery ?? 0,
|
|
126
|
+
auto: one.auto ?? "",
|
|
124
127
|
});
|
|
125
128
|
}
|
|
126
129
|
}
|
|
@@ -169,6 +172,9 @@ export class StationStore {
|
|
|
169
172
|
desired: "stopped",
|
|
170
173
|
listenToken: newToken(),
|
|
171
174
|
bitrate: this.bitrateFrom(input.bitrate),
|
|
175
|
+
video: input.video === true,
|
|
176
|
+
breaksEvery: this.breaksFrom(input.breaksEvery),
|
|
177
|
+
auto: "",
|
|
172
178
|
rights: null,
|
|
173
179
|
owner,
|
|
174
180
|
failures: {},
|
|
@@ -180,6 +186,24 @@ export class StationStore {
|
|
|
180
186
|
this.save();
|
|
181
187
|
return station;
|
|
182
188
|
}
|
|
189
|
+
breaksFrom(value) {
|
|
190
|
+
if (value === undefined || value === null || value === 0)
|
|
191
|
+
return 0;
|
|
192
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 120 || value > 86_400) {
|
|
193
|
+
throw new StationError("invalid_breaks", "breaksEvery is seconds between breaks, from 120 to 86400, or 0 for none");
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
/** Mark a station as made by auto stations, so a later sync updates it rather than making another. */
|
|
198
|
+
markAuto(id, key) {
|
|
199
|
+
const station = this.require(id);
|
|
200
|
+
station.auto = key;
|
|
201
|
+
this.save();
|
|
202
|
+
return station;
|
|
203
|
+
}
|
|
204
|
+
byAuto(key) {
|
|
205
|
+
return [...this.stations.values()].find((one) => one.auto === key);
|
|
206
|
+
}
|
|
183
207
|
bitrateFrom(value) {
|
|
184
208
|
if (value === undefined)
|
|
185
209
|
return DEFAULT_BITRATE;
|
|
@@ -205,6 +229,8 @@ export class StationStore {
|
|
|
205
229
|
station.repeat = input.repeat !== false;
|
|
206
230
|
if (input.bitrate !== undefined)
|
|
207
231
|
station.bitrate = this.bitrateFrom(input.bitrate);
|
|
232
|
+
if (input.breaksEvery !== undefined)
|
|
233
|
+
station.breaksEvery = this.breaksFrom(input.breaksEvery);
|
|
208
234
|
if (items !== null) {
|
|
209
235
|
if (items.length === 0)
|
|
210
236
|
throw new StationError("empty_station", "a station needs at least one file that plays");
|