nixamp 0.28.45 → 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.
- package/dist/audio.js +3 -0
- package/dist/channels.d.ts +14 -0
- package/dist/channels.js +20 -4
- package/dist/decoder-policy.d.ts +20 -0
- package/dist/decoder-policy.js +34 -0
- package/dist/destinations.d.ts +5 -0
- package/dist/destinations.js +82 -0
- package/dist/directory.d.ts +3 -1
- package/dist/directory.js +8 -2
- package/dist/library.d.ts +2 -0
- package/dist/library.js +4 -1
- package/dist/links.d.ts +4 -0
- package/dist/links.js +20 -2
- package/dist/main.d.ts +0 -8
- package/dist/main.js +9 -0
- package/dist/playlist-input.d.ts +46 -0
- package/dist/playlist-input.js +158 -54
- package/dist/publish-choice.d.ts +21 -0
- package/dist/publish-choice.js +17 -0
- package/dist/server.d.ts +27 -1
- package/dist/server.js +228 -17
- package/dist/share.d.ts +1 -1
- package/dist/share.js +5 -4
- package/dist/station-cli.d.ts +3 -0
- package/dist/station-cli.js +201 -0
- package/dist/station-page.d.ts +5 -0
- package/dist/station-page.js +137 -0
- package/dist/station-routes.d.ts +60 -0
- package/dist/station-routes.js +249 -0
- package/dist/station-runner.d.ts +67 -0
- package/dist/station-runner.js +219 -0
- package/dist/station-usage.d.ts +2 -0
- package/dist/station-usage.js +20 -0
- package/dist/stations.d.ts +148 -0
- package/dist/stations.js +331 -0
- package/package.json +1 -1
- package/src/audio.ts +3 -0
- package/src/channels.ts +30 -5
- package/src/decoder-policy.ts +33 -0
- package/src/destinations.ts +78 -0
- package/src/directory.ts +10 -2
- package/src/library.ts +6 -1
- package/src/links.ts +23 -4
- package/src/main.ts +8 -0
- package/src/playlist-input.ts +172 -50
- package/src/publish-choice.ts +35 -0
- package/src/server.ts +227 -18
- package/src/share.ts +5 -4
- package/src/station-cli.ts +195 -0
- package/src/station-page.ts +144 -0
- package/src/station-routes.ts +301 -0
- package/src/station-runner.ts +244 -0
- package/src/station-usage.ts +20 -0
- package/src/stations.ts +407 -0
- package/web/dist/assets/{ar-8fDonUoB.js → ar-CTUDxy1J.js} +1 -1
- package/web/dist/assets/{cs-DCClolTy.js → cs-m4JTaO3o.js} +1 -1
- package/web/dist/assets/{da-C3RPhXUd.js → da-DHnJElYB.js} +1 -1
- package/web/dist/assets/{de-CYBoEomk.js → de-DkxPohnf.js} +1 -1
- package/web/dist/assets/{es-DS3ls4xT.js → es-oY_G83d7.js} +1 -1
- package/web/dist/assets/{fi-DbCZpO89.js → fi-fCO8XRdg.js} +1 -1
- package/web/dist/assets/{fr-D0z3UpdL.js → fr-CSupf-IN.js} +1 -1
- package/web/dist/assets/{hi-BTU3sRlQ.js → hi-Dve7PJsC.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-CQXTVHn_.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
- package/web/dist/assets/{hu-B37Q8ldT.js → hu-D1KYamzV.js} +1 -1
- package/web/dist/assets/{id-4pdXNa4G.js → id-Cofp_4aQ.js} +1 -1
- package/web/dist/assets/index-7mCQZGNF.js +22 -0
- package/web/dist/assets/{it-CTe69-UY.js → it-62iXQWQD.js} +1 -1
- package/web/dist/assets/{mpegts-BBmkDppm.js → mpegts-BT5ESacM.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CUVdjYFi.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
- package/web/dist/assets/{nl-CxrmHrMn.js → nl-CzRRG-CN.js} +1 -1
- package/web/dist/assets/{ru-zjVCfIKq.js → ru-OykC1LJQ.js} +1 -1
- package/web/dist/assets/{sv-CEoiT2Lr.js → sv-B8FXBDHm.js} +1 -1
- package/web/dist/assets/{uk-ZC6JzQvD.js → uk-DhAJBjwH.js} +1 -1
- package/web/dist/assets/{vi-D9anF-cl.js → vi-CdBSRReC.js} +1 -1
- package/web/dist/assets/{zh-BeLYdC0I.js → zh-wGWNyBEZ.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
- package/web/dist/assets/index-BDBbN5Qy.js +0 -22
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp station`: stations from a terminal, a script or a cron job.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the daemon on this machine with its control key, or to another
|
|
5
|
+
* server with --url and --key, exactly as `nixamp admin` does. Every command
|
|
6
|
+
* takes --json for a script; without it, a line a person can read. Exit codes
|
|
7
|
+
* a script can branch on: 0 done, 1 the server said no (the reason and a
|
|
8
|
+
* stable code are printed), 2 the command was wrong, 3 the server could not
|
|
9
|
+
* be reached.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here publishes as a side effect. `create` and `start` leave a
|
|
12
|
+
* station private; `publish` is its own command and says on what basis.
|
|
13
|
+
*/
|
|
14
|
+
import { resolveTarget } from "./admin.js";
|
|
15
|
+
import { readState } from "./daemon.js";
|
|
16
|
+
import { KEY_HEADER } from "./share.js";
|
|
17
|
+
import { USAGE } from "./station-usage.js";
|
|
18
|
+
export { USAGE };
|
|
19
|
+
function flag(argv, name) {
|
|
20
|
+
const at = argv.indexOf(name);
|
|
21
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
22
|
+
}
|
|
23
|
+
function has(argv, name) {
|
|
24
|
+
return argv.includes(name);
|
|
25
|
+
}
|
|
26
|
+
class Unreachable extends Error {
|
|
27
|
+
}
|
|
28
|
+
export async function station(argv) {
|
|
29
|
+
const [command, id] = argv;
|
|
30
|
+
const json = has(argv, "--json");
|
|
31
|
+
if (!command || command === "help" || has(argv, "--help")) {
|
|
32
|
+
console.log(USAGE);
|
|
33
|
+
return command ? 0 : 2;
|
|
34
|
+
}
|
|
35
|
+
let target;
|
|
36
|
+
try {
|
|
37
|
+
target = resolveTarget(argv);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error(error.message);
|
|
41
|
+
return 3;
|
|
42
|
+
}
|
|
43
|
+
const headers = { "content-type": "application/json", ...(target.key ? { [KEY_HEADER]: target.key } : {}) };
|
|
44
|
+
const call = async (method, path, body) => {
|
|
45
|
+
let response;
|
|
46
|
+
try {
|
|
47
|
+
response = await fetch(`${target.url}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Unreachable(`could not reach ${target.url}: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
const text = await response.text();
|
|
53
|
+
let parsed = {};
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
parsed = { error: text };
|
|
59
|
+
}
|
|
60
|
+
return { status: response.status, body: parsed };
|
|
61
|
+
};
|
|
62
|
+
const needId = () => {
|
|
63
|
+
if (!id || id.startsWith("--")) {
|
|
64
|
+
console.error(`nixamp station ${command} needs a station id. \`nixamp station list\` shows them.`);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return id;
|
|
68
|
+
};
|
|
69
|
+
const show = (answer, line) => {
|
|
70
|
+
if (json) {
|
|
71
|
+
console.log(JSON.stringify(answer.body, null, 2));
|
|
72
|
+
}
|
|
73
|
+
else if (answer.status < 400) {
|
|
74
|
+
console.log(line(answer.body));
|
|
75
|
+
}
|
|
76
|
+
if (answer.status >= 400) {
|
|
77
|
+
if (!json)
|
|
78
|
+
console.error(`nixamp: ${String(answer.body["error"] ?? "refused")} (${String(answer.body["code"] ?? answer.status)})`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
};
|
|
83
|
+
const describe = (station) => {
|
|
84
|
+
const links = (station["links"] ?? {});
|
|
85
|
+
return [
|
|
86
|
+
`${String(station["name"])} [${String(station["id"])}]`,
|
|
87
|
+
` state ${String(station["state"])} (wanted ${String(station["desired"])})`,
|
|
88
|
+
` visibility ${String(station["visibility"])}${station["listed"] ? ", listed in the directory" : ""}`,
|
|
89
|
+
` rotation ${station["shuffle"] ? "shuffle" : "in order"}${station["repeat"] ? ", repeating" : ", once"}; ${station["items"]?.length ?? 0} files at ${String(station["bitrate"])} kbps`,
|
|
90
|
+
...(station["error"] ? [` problem ${String(station["error"])}`] : []),
|
|
91
|
+
...(station["excluded"]?.length ? [` left out ${station["excluded"].length} files (nixamp station retry)`] : []),
|
|
92
|
+
...(station["visibility"] !== "private" && links["page"] ? [` listen ${links["page"]}`] : []),
|
|
93
|
+
].join("\n");
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
switch (command) {
|
|
97
|
+
case "list": {
|
|
98
|
+
const answer = await call("GET", "/api/v1/stations?limit=200");
|
|
99
|
+
return show(answer, (body) => {
|
|
100
|
+
const all = body["stations"] ?? [];
|
|
101
|
+
return all.length === 0 ? "No stations yet. nixamp station create --name NAME --folder PATH" : all.map(describe).join("\n\n");
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
case "create": {
|
|
105
|
+
const name = flag(argv, "--name");
|
|
106
|
+
if (!name) {
|
|
107
|
+
console.error("nixamp station create needs --name");
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
const items = flag(argv, "--items");
|
|
111
|
+
let folder = flag(argv, "--folder");
|
|
112
|
+
if (folder !== undefined) {
|
|
113
|
+
// A path under the library is taken relative to it.
|
|
114
|
+
const root = readState()?.source ?? "";
|
|
115
|
+
if (root && folder.startsWith(root))
|
|
116
|
+
folder = folder.slice(root.length);
|
|
117
|
+
}
|
|
118
|
+
if (items === undefined && folder === undefined) {
|
|
119
|
+
console.error("nixamp station create needs --folder PATH or --items 0,1,2");
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
const bitrate = flag(argv, "--bitrate");
|
|
123
|
+
const answer = await call("POST", "/api/v1/stations", {
|
|
124
|
+
name,
|
|
125
|
+
...(items !== undefined ? { items: items.split(",").map((one) => Number(one.trim())) } : { folder }),
|
|
126
|
+
shuffle: has(argv, "--shuffle"),
|
|
127
|
+
repeat: !has(argv, "--no-repeat"),
|
|
128
|
+
...(bitrate ? { bitrate: Number(bitrate) } : {}),
|
|
129
|
+
});
|
|
130
|
+
return show(answer, (body) => `Made (private, stopped):\n${describe(body["station"])}`);
|
|
131
|
+
}
|
|
132
|
+
case "show":
|
|
133
|
+
case "status": {
|
|
134
|
+
const station = needId();
|
|
135
|
+
if (!station)
|
|
136
|
+
return 2;
|
|
137
|
+
return show(await call("GET", `/api/v1/stations/${station}`), (body) => describe(body["station"]));
|
|
138
|
+
}
|
|
139
|
+
case "start":
|
|
140
|
+
case "stop":
|
|
141
|
+
case "skip":
|
|
142
|
+
case "retry": {
|
|
143
|
+
const station = needId();
|
|
144
|
+
if (!station)
|
|
145
|
+
return 2;
|
|
146
|
+
return show(await call("POST", `/api/v1/stations/${station}/${command}`, {}), (body) => describe(body["station"]));
|
|
147
|
+
}
|
|
148
|
+
case "publish": {
|
|
149
|
+
const station = needId();
|
|
150
|
+
if (!station)
|
|
151
|
+
return 2;
|
|
152
|
+
const basis = flag(argv, "--basis");
|
|
153
|
+
const unlisted = has(argv, "--unlisted");
|
|
154
|
+
const access = {
|
|
155
|
+
visibility: unlisted ? "unlisted" : "public",
|
|
156
|
+
listed: !unlisted && !has(argv, "--no-list"),
|
|
157
|
+
...(basis ? { rights: { basis, note: flag(argv, "--note") ?? "" } } : {}),
|
|
158
|
+
};
|
|
159
|
+
return show(await call("POST", `/api/v1/stations/${station}/access`, access), (body) => describe(body["station"]));
|
|
160
|
+
}
|
|
161
|
+
case "unpublish": {
|
|
162
|
+
const station = needId();
|
|
163
|
+
if (!station)
|
|
164
|
+
return 2;
|
|
165
|
+
const access = has(argv, "--private") ? { visibility: "private" } : { listed: false };
|
|
166
|
+
return show(await call("POST", `/api/v1/stations/${station}/access`, access), (body) => {
|
|
167
|
+
const dropped = Number(body["dropped"] ?? 0);
|
|
168
|
+
return `${describe(body["station"])}${dropped ? `\n ${dropped} listener(s) disconnected; old links no longer work.` : ""}`;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
case "link": {
|
|
172
|
+
const station = needId();
|
|
173
|
+
if (!station)
|
|
174
|
+
return 2;
|
|
175
|
+
const answer = has(argv, "--rotate")
|
|
176
|
+
? await call("POST", `/api/v1/stations/${station}/rotate-link`, {})
|
|
177
|
+
: await call("GET", `/api/v1/stations/${station}`);
|
|
178
|
+
return show(answer, (body) => {
|
|
179
|
+
const links = (body["station"]["links"] ?? {});
|
|
180
|
+
return [`page ${links["page"]}`, `stream ${links["stream"]}`, `m3u ${links["m3u"]}`].join("\n");
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
case "delete": {
|
|
184
|
+
const station = needId();
|
|
185
|
+
if (!station)
|
|
186
|
+
return 2;
|
|
187
|
+
return show(await call("DELETE", `/api/v1/stations/${station}`), () => `Deleted ${station}. Its files were not touched.`);
|
|
188
|
+
}
|
|
189
|
+
default:
|
|
190
|
+
console.error(`nixamp station: no command "${command}".\n\n${USAGE}`);
|
|
191
|
+
return 2;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error instanceof Unreachable) {
|
|
196
|
+
console.error(`nixamp: ${error.message}`);
|
|
197
|
+
return 3;
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -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\"> </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"> </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
|
+
}
|