nixamp 0.29.1 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/channels.js +4 -1
  2. package/dist/directory.d.ts +3 -1
  3. package/dist/directory.js +8 -2
  4. package/dist/main.d.ts +0 -8
  5. package/dist/main.js +9 -0
  6. package/dist/server.d.ts +27 -1
  7. package/dist/server.js +162 -6
  8. package/dist/station-cli.d.ts +3 -0
  9. package/dist/station-cli.js +201 -0
  10. package/dist/station-page.d.ts +5 -0
  11. package/dist/station-page.js +137 -0
  12. package/dist/station-routes.d.ts +60 -0
  13. package/dist/station-routes.js +249 -0
  14. package/dist/station-runner.d.ts +67 -0
  15. package/dist/station-runner.js +219 -0
  16. package/dist/station-usage.d.ts +2 -0
  17. package/dist/station-usage.js +20 -0
  18. package/dist/stations.d.ts +148 -0
  19. package/dist/stations.js +331 -0
  20. package/package.json +1 -1
  21. package/src/channels.ts +4 -1
  22. package/src/directory.ts +10 -2
  23. package/src/main.ts +8 -0
  24. package/src/server.ts +169 -7
  25. package/src/station-cli.ts +195 -0
  26. package/src/station-page.ts +144 -0
  27. package/src/station-routes.ts +301 -0
  28. package/src/station-runner.ts +244 -0
  29. package/src/station-usage.ts +20 -0
  30. package/src/stations.ts +407 -0
  31. package/web/dist/assets/{ar-D6_ZFDan.js → ar-CTUDxy1J.js} +1 -1
  32. package/web/dist/assets/{cs-DEPGOGEV.js → cs-m4JTaO3o.js} +1 -1
  33. package/web/dist/assets/{da-CQw7SPC9.js → da-DHnJElYB.js} +1 -1
  34. package/web/dist/assets/{de-OWnobbah.js → de-DkxPohnf.js} +1 -1
  35. package/web/dist/assets/{es-qMpyH9bM.js → es-oY_G83d7.js} +1 -1
  36. package/web/dist/assets/{fi-DpqqwJTD.js → fi-fCO8XRdg.js} +1 -1
  37. package/web/dist/assets/{fr-BiO_ceen.js → fr-CSupf-IN.js} +1 -1
  38. package/web/dist/assets/{hi-D-q5wZuY.js → hi-Dve7PJsC.js} +1 -1
  39. package/web/dist/assets/{hls-3VKVEQE3-D2W4K8VR.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
  40. package/web/dist/assets/{hu-FIjHyhVC.js → hu-D1KYamzV.js} +1 -1
  41. package/web/dist/assets/{id-gmB6lu56.js → id-Cofp_4aQ.js} +1 -1
  42. package/web/dist/assets/{index-UmGvzuun.js → index-7mCQZGNF.js} +2 -2
  43. package/web/dist/assets/{it-Dxfo713A.js → it-62iXQWQD.js} +1 -1
  44. package/web/dist/assets/{mpegts-BT4XGr_E.js → mpegts-BT5ESacM.js} +1 -1
  45. package/web/dist/assets/{mpegts-LO6RVLD6-CpZnlq0W.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
  46. package/web/dist/assets/{nl-mk5eN3Io.js → nl-CzRRG-CN.js} +1 -1
  47. package/web/dist/assets/{ru-B2psU0kN.js → ru-OykC1LJQ.js} +1 -1
  48. package/web/dist/assets/{sv-Cg6waoNZ.js → sv-B8FXBDHm.js} +1 -1
  49. package/web/dist/assets/{uk-BcFkukLd.js → uk-DhAJBjwH.js} +1 -1
  50. package/web/dist/assets/{vi-CnqIC1Sz.js → vi-CdBSRReC.js} +1 -1
  51. package/web/dist/assets/{zh-CvnXIFvN.js → zh-wGWNyBEZ.js} +1 -1
  52. package/web/dist/index.html +1 -1
  53. package/web/dist/sw.js +22 -22
@@ -0,0 +1,5 @@
1
+ /** A station page's path: /station/<id>. */
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<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\">&nbsp;</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 const audio = new Audio();\n audio.preload = \"none\";\n let wanted = false, retry = 0, timer = 0, last = null;\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 document.title = s.name + \" \u00B7 nixamp\";\n $(\"#name\").textContent = s.name;\n $(\"#about\").textContent = s.description || \"\";\n $(\"#about\").hidden = !s.description;\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 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 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 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 $(\"#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 setInterval(poll, 5000);\n})();\n</script>\n</body>\n</html>\n";
4
+ /** Only its own script and style, only its own server, never framed. */
5
+ export declare const STATION_PAGE_CSP: string;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The page a station's link opens: its name, what is on, and Play.
3
+ *
4
+ * Served by the station's own server, without the server's key, because the
5
+ * link is the key: the page reads `t` from its own address and asks the
6
+ * station's state and stream with it. Nothing about the station is written
7
+ * into the HTML, so the page is the same bytes for every station and can be
8
+ * locked down with a content security policy naming its script and style by
9
+ * hash. A private station, or a wrong link, shows "not available" -- the
10
+ * same answer, so the page cannot be used to find out which stations exist.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ const STYLE = `
14
+ :root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}
15
+ @media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}
16
+ *{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}
17
+ main{max-width:560px;margin:0 auto;padding:48px 16px}
18
+ .card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
19
+ h1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}
20
+ .about{color:var(--muted);margin:0 0 20px}
21
+ .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)}
22
+ .chip::before{content:"";width:8px;height:8px;border-radius:50%;background:currentColor}
23
+ .live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}
24
+ .now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}
25
+ .controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
26
+ button{font:inherit;font-weight:600;border:0;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;cursor:pointer;min-width:120px}
27
+ button:disabled{opacity:.5;cursor:not-allowed}
28
+ button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
29
+ label{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}
30
+ input[type=range]{width:120px;accent-color:var(--accent)}
31
+ .small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}
32
+ .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
33
+ @media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}
34
+ @keyframes pulse{50%{opacity:.35}}
35
+ `;
36
+ const SCRIPT = `
37
+ (() => {
38
+ const id = location.pathname.split("/").filter(Boolean)[1] || "";
39
+ const token = new URLSearchParams(location.search).get("t") || "";
40
+ const q = "?t=" + encodeURIComponent(token);
41
+ const api = "/api/v1/stations/" + encodeURIComponent(id);
42
+ const $ = (s) => document.querySelector(s);
43
+ const audio = new Audio();
44
+ audio.preload = "none";
45
+ let wanted = false, retry = 0, timer = 0, last = null;
46
+ const chip = (state, text) => { const c = $("#state"); c.className = "chip " + state; c.textContent = text; };
47
+ const say = (text) => { $("#announce").textContent = text; };
48
+ const draw = (s) => {
49
+ last = s;
50
+ document.title = s.name + " · nixamp";
51
+ $("#name").textContent = s.name;
52
+ $("#about").textContent = s.description || "";
53
+ $("#about").hidden = !s.description;
54
+ const playing = s.nowPlaying;
55
+ $("#title").textContent = playing ? (playing.title || "Untitled") : (s.state === "live" ? "On the air" : "");
56
+ $("#by").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(" · ") : "";
57
+ if (wanted && retry > 0) chip("reconnecting", "Reconnecting");
58
+ else if (s.state === "live") chip("live", "Live");
59
+ else if (s.state === "starting" || s.state === "recovering") chip("reconnecting", "Coming back");
60
+ else if (s.state === "stopped") chip("stopped", "Off the air");
61
+ else chip("unavailable", "Unavailable");
62
+ $("#play").disabled = !(s.state === "live" || s.state === "recovering" || s.state === "starting");
63
+ $("#m3u").href = api + "/listen.m3u" + q;
64
+ };
65
+ const poll = async () => {
66
+ try {
67
+ const r = await fetch(api + "/state" + q, { cache: "no-store" });
68
+ if (r.status === 404) { $("#name").textContent = "This station is not available"; chip("unavailable", "Unavailable"); $("#play").disabled = true; stop(); return; }
69
+ if (r.ok) draw(await r.json());
70
+ } catch {}
71
+ };
72
+ // Always the live edge: a fresh request, never a cached or paused one.
73
+ const join = () => { audio.src = api + "/stream" + q + "&n=" + Date.now(); audio.play().catch(() => { wanted = false; button(); }); };
74
+ const stop = () => { wanted = false; clearTimeout(timer); audio.pause(); audio.removeAttribute("src"); audio.load(); button(); };
75
+ const button = () => { const b = $("#play"); b.textContent = wanted ? "Stop" : "Play"; b.setAttribute("aria-pressed", String(wanted)); };
76
+ const again = () => {
77
+ if (!wanted) return;
78
+ retry += 1;
79
+ if (last) draw(last);
80
+ clearTimeout(timer);
81
+ timer = setTimeout(join, Math.min(15000, 1000 * 2 ** Math.min(retry, 4)));
82
+ };
83
+ audio.addEventListener("playing", () => { if (retry) say("Back on"); retry = 0; if (last) draw(last); });
84
+ audio.addEventListener("error", again);
85
+ audio.addEventListener("ended", again);
86
+ audio.addEventListener("stalled", () => { if (wanted && audio.readyState < 3) again(); });
87
+ $("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
88
+ $("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
89
+ poll();
90
+ setInterval(poll, 5000);
91
+ })();
92
+ `;
93
+ const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
94
+ const PAGE = `<!doctype html>
95
+ <html lang="en">
96
+ <head>
97
+ <meta charset="utf-8">
98
+ <meta name="viewport" content="width=device-width, initial-scale=1">
99
+ <meta name="robots" content="noindex">
100
+ <title>Station · nixamp</title>
101
+ <style>${STYLE}</style>
102
+ </head>
103
+ <body>
104
+ <main>
105
+ <div class="card">
106
+ <span id="state" class="chip unavailable" role="status">Loading</span>
107
+ <h1 id="name">&nbsp;</h1>
108
+ <p id="about" class="about" hidden></p>
109
+ <div class="now" aria-live="polite"><div id="title" class="title"></div><div id="by" class="by"></div></div>
110
+ <div class="controls">
111
+ <button id="play" type="button" aria-pressed="false" disabled>Play</button>
112
+ <label>Volume <input id="volume" type="range" min="0" max="1" step="0.05" value="1"></label>
113
+ </div>
114
+ <p class="small"><a id="m3u" href="#">Open in another player (.m3u)</a> · Pausing here does not pause the station.</p>
115
+ </div>
116
+ <p class="small">Played by <a href="https://nixamp.com">nixamp</a>.</p>
117
+ <div id="announce" class="sr" aria-live="assertive"></div>
118
+ </main>
119
+ <script>${SCRIPT}</script>
120
+ </body>
121
+ </html>
122
+ `;
123
+ /** A station page's path: /station/<id>. */
124
+ export const STATION_PAGE = /^\/station\/([a-z0-9]{10})\/?$/;
125
+ export const STATION_PAGE_HTML = PAGE;
126
+ /** Only its own script and style, only its own server, never framed. */
127
+ export const STATION_PAGE_CSP = [
128
+ "default-src 'none'",
129
+ `script-src ${hash(SCRIPT)}`,
130
+ `style-src ${hash(STYLE)}`,
131
+ "connect-src 'self'",
132
+ "media-src 'self'",
133
+ "img-src 'self'",
134
+ "base-uri 'none'",
135
+ "form-action 'none'",
136
+ "frame-ancestors 'none'",
137
+ ].join("; ");
@@ -0,0 +1,60 @@
1
+ /**
2
+ * /api/v1/stations: making, running and sharing stations.
3
+ *
4
+ * Two kinds of caller, kept apart. The owner -- the control key, or the
5
+ * nixamp.com account that owns this server -- manages: create, edit, start,
6
+ * stop, skip, share. A listener holds one station's link and may do three
7
+ * things with it: hear the stream, ask what is on, and fetch an .m3u for
8
+ * another player. A listener's link never opens the library, another station,
9
+ * or anything that changes what is playing.
10
+ *
11
+ * Errors carry a stable `code` beside the sentence, so the page, the CLI and
12
+ * an agent over MCP can each tell "rights_required" from "not_found" without
13
+ * reading English.
14
+ */
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+ import type { Channels } from "./channels.ts";
17
+ import type { StationRunner } from "./station-runner.ts";
18
+ import { type OwnerStation, type PublicStation, type Station, type StationStore } from "./stations.ts";
19
+ /** Whether this path is one a station's own link may open, without the server's key. */
20
+ export declare function isStationListenPath(path: string): boolean;
21
+ export interface StationContext {
22
+ store: StationStore;
23
+ runner: StationRunner;
24
+ channels: Channels;
25
+ /** Holds the control key, or is signed in as this server's owner. */
26
+ canControl: boolean;
27
+ /** The nixamp.com account asking, "" when it is the control key. */
28
+ account: string;
29
+ /** Files chosen by track index or by folder; null when neither was given. */
30
+ select: (body: {
31
+ items?: unknown;
32
+ folder?: unknown;
33
+ }) => string[] | null;
34
+ /** What the library knows about a file, for now playing. */
35
+ trackFor: (file: string) => {
36
+ title: string;
37
+ artist: string;
38
+ album: string;
39
+ } | null;
40
+ /** This server's address as the asker reached it, for links. */
41
+ base: string;
42
+ json: (response: ServerResponse, status: number, body: unknown) => void;
43
+ readBody: (request: IncomingMessage) => Promise<string>;
44
+ /** Headers every answer carries (CORS). */
45
+ headers: Record<string, string>;
46
+ }
47
+ declare function links(ctx: StationContext, station: Station): {
48
+ page: string;
49
+ stream: string;
50
+ m3u: string;
51
+ state: string;
52
+ };
53
+ export declare function publicView(ctx: StationContext, station: Station): PublicStation;
54
+ export declare function ownerView(ctx: StationContext, station: Station): OwnerStation & {
55
+ links: ReturnType<typeof links>;
56
+ nowPlaying: string;
57
+ };
58
+ /** Answers anything under /api/v1/stations; false when the path is not one. */
59
+ export declare function handleStations(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: StationContext): Promise<boolean>;
60
+ export {};
@@ -0,0 +1,249 @@
1
+ import { StationError, channelOf, tokensMatch, } from "./stations.js";
2
+ /** The three things a station's link opens. */
3
+ const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u)$/;
4
+ /** Whether this path is one a station's own link may open, without the server's key. */
5
+ export function isStationListenPath(path) {
6
+ return LISTEN.test(path);
7
+ }
8
+ function fail(ctx, response, error) {
9
+ if (error instanceof StationError) {
10
+ ctx.json(response, error.status, { error: error.message, code: error.code });
11
+ return;
12
+ }
13
+ ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
14
+ }
15
+ function links(ctx, station) {
16
+ const query = `t=${encodeURIComponent(station.listenToken)}`;
17
+ const at = `${ctx.base}/api/v1/stations/${station.id}`;
18
+ return {
19
+ page: `${ctx.base}/station/${station.id}?${query}`,
20
+ stream: `${at}/stream?${query}`,
21
+ m3u: `${at}/listen.m3u?${query}`,
22
+ state: `${at}/state?${query}`,
23
+ };
24
+ }
25
+ /** The file on the air now, as the channel's own encoder last reported it. */
26
+ function nowPlayingFile(ctx, station) {
27
+ const info = ctx.channels.info(channelOf(station.id));
28
+ if (!info?.playlist?.length)
29
+ return "";
30
+ return info.playlist[info.playlistAt ?? 0] ?? "";
31
+ }
32
+ export function publicView(ctx, station) {
33
+ const state = ctx.runner.observed(station.id);
34
+ const file = state === "live" || state === "recovering" ? nowPlayingFile(ctx, station) : "";
35
+ const track = file ? ctx.trackFor(file) : null;
36
+ return {
37
+ id: station.id,
38
+ name: station.name,
39
+ description: station.description,
40
+ visibility: station.visibility,
41
+ state,
42
+ // Tags, or nothing: a bare file name is the owner's, not the public's.
43
+ nowPlaying: track && (track.title || track.artist) ? track : null,
44
+ format: state === "stopped" || state === "offline" ? null : { codec: "mp3", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 },
45
+ listeners: ctx.runner.listeners(station.id),
46
+ };
47
+ }
48
+ export function ownerView(ctx, station) {
49
+ const { failures, idempotencyKey: _key, ...rest } = station;
50
+ return {
51
+ ...rest,
52
+ state: ctx.runner.observed(station.id),
53
+ excluded: Object.entries(failures).filter(([, count]) => count >= 3).map(([file]) => file),
54
+ listeners: ctx.runner.listeners(station.id),
55
+ error: ctx.runner.error(station.id),
56
+ links: links(ctx, station),
57
+ nowPlaying: nowPlayingFile(ctx, station),
58
+ };
59
+ }
60
+ /** Answers anything under /api/v1/stations; false when the path is not one. */
61
+ export async function handleStations(request, response, path, url, ctx) {
62
+ if (path !== "/api/v1/stations" && !path.startsWith("/api/v1/stations/"))
63
+ return false;
64
+ const method = request.method ?? "GET";
65
+ const listen = LISTEN.exec(path);
66
+ if (listen) {
67
+ const station = ctx.store.get(listen[1]);
68
+ const token = url.searchParams.get("t") ?? "";
69
+ // The same answer for "no such station" and "not with that link", so a
70
+ // link cannot be used to find out which stations exist.
71
+ const allowed = station !== undefined &&
72
+ (ctx.canControl || (station.visibility !== "private" && token !== "" && tokensMatch(token, station.listenToken)));
73
+ if (!station || !allowed) {
74
+ ctx.json(response, 404, { error: "no such station, or not with that link", code: "not_found" });
75
+ return true;
76
+ }
77
+ if (method !== "GET" && method !== "HEAD") {
78
+ ctx.json(response, 405, { error: "a station's link can listen, not drive", code: "method_not_allowed" });
79
+ return true;
80
+ }
81
+ const what = listen[2];
82
+ if (what === "state") {
83
+ ctx.json(response, 200, publicView(ctx, station));
84
+ return true;
85
+ }
86
+ if (what === "listen.m3u") {
87
+ const stream = links(ctx, station).stream;
88
+ response.writeHead(200, {
89
+ ...ctx.headers,
90
+ "content-type": "audio/x-mpegurl; charset=utf-8",
91
+ "content-disposition": `inline; filename="${station.id}.m3u"`,
92
+ "cache-control": "no-store",
93
+ });
94
+ response.end(`#EXTM3U\n#EXTINF:-1,${station.name.replace(/[\r\n]/g, " ")}\n${stream}\n`);
95
+ return true;
96
+ }
97
+ const channelId = channelOf(station.id);
98
+ if (!ctx.channels.has(channelId)) {
99
+ ctx.json(response, 503, { error: "the station is not on the air", code: "not_live", state: ctx.runner.observed(station.id) });
100
+ return true;
101
+ }
102
+ response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
103
+ if (method === "HEAD") {
104
+ response.end();
105
+ return true;
106
+ }
107
+ const detach = ctx.channels.listen(channelId, response);
108
+ if (detach === null) {
109
+ response.end();
110
+ return true;
111
+ }
112
+ const untrack = ctx.runner.track(station.id, { end: () => response.destroy() });
113
+ const leave = () => {
114
+ detach();
115
+ untrack();
116
+ };
117
+ request.on("close", leave);
118
+ response.on("close", leave);
119
+ return true;
120
+ }
121
+ // Everything else is the owner's.
122
+ if (!ctx.canControl) {
123
+ ctx.json(response, 403, { error: "only this server's owner manages its stations", code: "forbidden" });
124
+ return true;
125
+ }
126
+ const body = async () => {
127
+ if (method === "GET" || method === "DELETE")
128
+ return {};
129
+ const raw = await ctx.readBody(request);
130
+ if (raw.trim() === "")
131
+ return {};
132
+ let parsed;
133
+ try {
134
+ parsed = JSON.parse(raw);
135
+ }
136
+ catch {
137
+ throw new StationError("bad_json", "the body is not JSON");
138
+ }
139
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
140
+ throw new StationError("bad_json", "the body is not a JSON object");
141
+ return parsed;
142
+ };
143
+ try {
144
+ if (path === "/api/v1/stations") {
145
+ if (method === "GET") {
146
+ const all = ctx.store.list();
147
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit")) || 50));
148
+ const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
149
+ ctx.json(response, 200, { stations: all.slice(offset, offset + limit).map((one) => ownerView(ctx, one)), total: all.length, limit, offset });
150
+ return true;
151
+ }
152
+ if (method === "POST") {
153
+ const input = await body();
154
+ const key = String(request.headers["idempotency-key"] ?? input["idempotencyKey"] ?? "").trim();
155
+ const again = ctx.store.byIdempotencyKey(key);
156
+ if (again) {
157
+ ctx.json(response, 200, { station: ownerView(ctx, again), created: false });
158
+ return true;
159
+ }
160
+ const items = ctx.select(input);
161
+ if (items === null)
162
+ throw new StationError("items_required", "choose files by track index (items) or a folder");
163
+ const station = ctx.store.create(input, items, ctx.account, Date.now(), key);
164
+ ctx.json(response, 201, { station: ownerView(ctx, station), created: true });
165
+ return true;
166
+ }
167
+ ctx.json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
168
+ return true;
169
+ }
170
+ const match = /^\/api\/v1\/stations\/([a-z0-9]{10})(?:\/([a-z-]+))?$/.exec(path);
171
+ if (!match) {
172
+ ctx.json(response, 404, { error: "no such station", code: "not_found" });
173
+ return true;
174
+ }
175
+ const id = match[1];
176
+ const action = match[2];
177
+ const station = ctx.store.get(id);
178
+ if (!station) {
179
+ ctx.json(response, 404, { error: "no such station", code: "not_found" });
180
+ return true;
181
+ }
182
+ if (action === undefined) {
183
+ if (method === "GET") {
184
+ ctx.json(response, 200, { station: ownerView(ctx, station) });
185
+ return true;
186
+ }
187
+ if (method === "PATCH") {
188
+ const input = await body();
189
+ const items = ctx.select(input);
190
+ const updated = ctx.store.update(id, input, items);
191
+ ctx.json(response, 200, { station: ownerView(ctx, updated) });
192
+ return true;
193
+ }
194
+ if (method === "DELETE") {
195
+ ctx.runner.remove(id);
196
+ ctx.json(response, 200, { deleted: id });
197
+ return true;
198
+ }
199
+ ctx.json(response, 405, { error: "GET, PATCH or DELETE", code: "method_not_allowed" });
200
+ return true;
201
+ }
202
+ if (method !== "POST") {
203
+ ctx.json(response, 405, { error: "POST", code: "method_not_allowed" });
204
+ return true;
205
+ }
206
+ if (action === "start") {
207
+ const started = await ctx.runner.start(id);
208
+ ctx.json(response, 200, { station: ownerView(ctx, started) });
209
+ return true;
210
+ }
211
+ if (action === "stop") {
212
+ ctx.json(response, 200, { station: ownerView(ctx, ctx.runner.stop(id)) });
213
+ return true;
214
+ }
215
+ if (action === "skip") {
216
+ ctx.runner.skip(id);
217
+ ctx.json(response, 200, { station: ownerView(ctx, station) });
218
+ return true;
219
+ }
220
+ if (action === "retry") {
221
+ ctx.json(response, 200, { station: ownerView(ctx, ctx.store.clearFailures(id)) });
222
+ return true;
223
+ }
224
+ if (action === "rotate-link") {
225
+ const rotated = ctx.store.rotateToken(id);
226
+ const dropped = ctx.runner.revoke(id);
227
+ ctx.json(response, 200, { station: ownerView(ctx, rotated), dropped });
228
+ return true;
229
+ }
230
+ if (action === "access") {
231
+ const input = await body();
232
+ const rights = input["rights"];
233
+ const { station: changed, revoked } = ctx.store.setAccess(id, {
234
+ visibility: input["visibility"],
235
+ listed: input["listed"],
236
+ rights: typeof rights === "object" && rights !== null ? rights : null,
237
+ }, ctx.account || "control-key");
238
+ const dropped = revoked ? ctx.runner.revoke(id) : 0;
239
+ ctx.json(response, 200, { station: ownerView(ctx, changed), dropped });
240
+ return true;
241
+ }
242
+ ctx.json(response, 404, { error: "no such action", code: "not_found" });
243
+ return true;
244
+ }
245
+ catch (error) {
246
+ fail(ctx, response, error);
247
+ return true;
248
+ }
249
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Keeping stations on the air: start, stop, skip, and coming back after
3
+ * something went wrong.
4
+ *
5
+ * The runner owns the gap between what the owner asked for and what is
6
+ * happening. A station that should be running and is not is restarted with a
7
+ * growing delay (RECOVERY_DELAYS_MS), then tried every OFFLINE_RETRY_MS for as
8
+ * long as it is still wanted, so a disk that comes back or a file put back in
9
+ * place brings it back under the same id and link. A station its owner
10
+ * stopped is never restarted by anything here.
11
+ */
12
+ import type { Channels, ChannelInfo, PullResume } from "./channels.ts";
13
+ import { type Observed, type Station, type StationStore } from "./stations.ts";
14
+ export interface RunnerDeps {
15
+ store: StationStore;
16
+ channels: Channels;
17
+ /** Put a list of files on the air as this channel; false when it could not. */
18
+ pull: (channelId: string, name: string, files: string[], known: {
19
+ rotation: NonNullable<PullResume["rotation"]>;
20
+ audioBitrate: number;
21
+ }) => Promise<boolean>;
22
+ /** The folders a station's files have to be inside. */
23
+ roots: () => string[];
24
+ log?: (line: string) => void;
25
+ now?: () => number;
26
+ }
27
+ /** Something a listener is connected through, that can be ended. */
28
+ export interface Ending {
29
+ end(): void;
30
+ }
31
+ export declare class StationRunner {
32
+ private readonly deps;
33
+ private attempts;
34
+ private timers;
35
+ private startedAt;
36
+ private stopping;
37
+ private starting;
38
+ private errors;
39
+ private listening;
40
+ constructor(deps: RunnerDeps);
41
+ private now;
42
+ private log;
43
+ /** The station's files that are still where they were, inside the library. */
44
+ playable(station: Station): string[];
45
+ /** Wanted on, and put on. */
46
+ start(id: string): Promise<Station>;
47
+ /** Wanted off, and taken off. Stays off across restarts. */
48
+ stop(id: string): Station;
49
+ /** The next track, now. */
50
+ skip(id: string): void;
51
+ /** Stopped and forgotten. */
52
+ remove(id: string): void;
53
+ /** Put back every station that was running when this server last stopped. */
54
+ restore(): void;
55
+ private launch;
56
+ /** Told by the server when any channel ends. */
57
+ onChannelEnd(info: ChannelInfo): void;
58
+ private scheduleRecovery;
59
+ private cancelRecovery;
60
+ observed(id: string): Observed;
61
+ error(id: string): string;
62
+ /** A listener on a station's link, ended if the link is revoked. */
63
+ track(id: string, listener: Ending): () => void;
64
+ listeners(id: string): number;
65
+ /** Everybody listening through a link that no longer works is let go. */
66
+ revoke(id: string): number;
67
+ }