nixamp 0.29.1 → 0.30.3
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/channels.d.ts +12 -0
- package/dist/channels.js +12 -1
- package/dist/directory.d.ts +3 -1
- package/dist/directory.js +8 -2
- package/dist/main.d.ts +0 -8
- package/dist/main.js +9 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +7 -1
- package/dist/playlist-input.d.ts +10 -0
- package/dist/playlist-input.js +9 -0
- package/dist/server.d.ts +27 -1
- package/dist/server.js +162 -6
- package/dist/station-cli.d.ts +3 -0
- package/dist/station-cli.js +201 -0
- package/dist/station-mcp.d.ts +294 -0
- package/dist/station-mcp.js +173 -0
- package/dist/station-page.d.ts +5 -0
- package/dist/station-page.js +138 -0
- package/dist/station-routes.d.ts +60 -0
- package/dist/station-routes.js +250 -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/channels.ts +14 -1
- package/src/directory.ts +10 -2
- package/src/main.ts +8 -0
- package/src/mcp.ts +9 -1
- package/src/playlist-input.ts +9 -0
- package/src/server.ts +169 -7
- package/src/station-cli.ts +195 -0
- package/src/station-mcp.ts +183 -0
- package/src/station-page.ts +145 -0
- package/src/station-routes.ts +302 -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-D6_ZFDan.js → ar-ClCIWtVb.js} +1 -1
- package/web/dist/assets/{cs-DEPGOGEV.js → cs-CVG4U823.js} +1 -1
- package/web/dist/assets/{da-CQw7SPC9.js → da-igdiQUk8.js} +1 -1
- package/web/dist/assets/{de-OWnobbah.js → de-CHPsc6lc.js} +1 -1
- package/web/dist/assets/{es-qMpyH9bM.js → es-AvbMspQX.js} +1 -1
- package/web/dist/assets/{fi-DpqqwJTD.js → fi-5JGUOm3W.js} +1 -1
- package/web/dist/assets/{fr-BiO_ceen.js → fr-YfMwk1FR.js} +1 -1
- package/web/dist/assets/{hi-D-q5wZuY.js → hi-BUZuQTkp.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-D2W4K8VR.js → hls-3VKVEQE3-BCXawNaW.js} +1 -1
- package/web/dist/assets/{hu-FIjHyhVC.js → hu-C7on-yUl.js} +1 -1
- package/web/dist/assets/{id-gmB6lu56.js → id-pIrzVeOi.js} +1 -1
- package/web/dist/assets/{index-UmGvzuun.js → index-CDBIr8Tz.js} +2 -2
- package/web/dist/assets/{it-Dxfo713A.js → it-Bl9xk5B4.js} +1 -1
- package/web/dist/assets/{mpegts-BT4XGr_E.js → mpegts-63ylMmge.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CpZnlq0W.js → mpegts-LO6RVLD6-DBakOXU5.js} +1 -1
- package/web/dist/assets/{nl-mk5eN3Io.js → nl-b5BUih3i.js} +1 -1
- package/web/dist/assets/{ru-B2psU0kN.js → ru-BlAkbEPp.js} +1 -1
- package/web/dist/assets/{sv-Cg6waoNZ.js → sv-BEKvqELG.js} +1 -1
- package/web/dist/assets/{uk-BcFkukLd.js → uk-JY8Dh56p.js} +1 -1
- package/web/dist/assets/{vi-CnqIC1Sz.js → vi-DbtxuQPL.js} +1 -1
- package/web/dist/assets/{zh-CvnXIFvN.js → zh-4x0DI5jO.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stations as MCP tools: what `nixamp station` does, for an agent.
|
|
3
|
+
*
|
|
4
|
+
* The same API and the same rules as the CLI and the page. On this machine
|
|
5
|
+
* the tools use the daemon's control key, which is what being on the machine
|
|
6
|
+
* means; for another server they send this machine's nixamp.com session,
|
|
7
|
+
* which the server accepts only if that account owns it. Publishing is its
|
|
8
|
+
* own tool and asks for the basis, exactly as for a person.
|
|
9
|
+
*/
|
|
10
|
+
import { daemonUrl, readState } from "./daemon.ts";
|
|
11
|
+
import { readSession } from "./session.ts";
|
|
12
|
+
import { KEY_HEADER } from "./share.ts";
|
|
13
|
+
|
|
14
|
+
const STRING = { type: "string" } as const;
|
|
15
|
+
const ID = { ...STRING, description: "The station's id (10 characters), from station_list." };
|
|
16
|
+
const SERVER = { ...STRING, description: "A nixamp server's address; this machine's daemon when omitted." };
|
|
17
|
+
|
|
18
|
+
export const STATION_TOOLS = [
|
|
19
|
+
{
|
|
20
|
+
name: "station_list",
|
|
21
|
+
description: "List this server's stations: id, name, state (live, stopped, offline...), visibility and listening link.",
|
|
22
|
+
inputSchema: { type: "object", properties: { server: SERVER } },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "station_get",
|
|
26
|
+
description: "One station in full: files, rotation, state, any problem, and its links.",
|
|
27
|
+
inputSchema: { type: "object", properties: { server: SERVER, id: ID }, required: ["id"] },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "station_create",
|
|
31
|
+
description: "Make a station from a folder in the library (path relative to it, \"\" for all of it) or from track indexes. It starts private and stopped; nothing is published.",
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
server: SERVER,
|
|
36
|
+
name: STRING,
|
|
37
|
+
folder: { ...STRING, description: "A folder under the library, e.g. \"Albums/Late Night\"." },
|
|
38
|
+
items: { type: "array", items: { type: "integer" }, description: "Track indexes, as /api/state lists them." },
|
|
39
|
+
description: STRING,
|
|
40
|
+
shuffle: { type: "boolean" },
|
|
41
|
+
repeat: { type: "boolean", description: "Round and round (default) or once." },
|
|
42
|
+
bitrate: { type: "integer", description: "kbps: 64, 96, 128 (default), 160, 192, 256 or 320." },
|
|
43
|
+
idempotencyKey: { ...STRING, description: "Asking again with the same key returns the same station." },
|
|
44
|
+
},
|
|
45
|
+
required: ["name"],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "station_update",
|
|
50
|
+
description: "Rename, describe, change files or rotation. Edits to the files land on the next track.",
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: { server: SERVER, id: ID, name: STRING, description: STRING, folder: STRING, items: { type: "array", items: { type: "integer" } }, shuffle: { type: "boolean" }, repeat: { type: "boolean" }, bitrate: { type: "integer" } },
|
|
54
|
+
required: ["id"],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "station_control",
|
|
59
|
+
description: "start, stop or skip a station, or retry the files it left out. Starting does not publish.",
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: { server: SERVER, id: ID, action: { type: "string", enum: ["start", "stop", "skip", "retry"] } },
|
|
63
|
+
required: ["id", "action"],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "station_publish",
|
|
68
|
+
description: "Make a station unlisted (a link) or public (and listed in the nixamp.com directory). Public needs the basis on which it may be broadcast: owned, permission, licensed or public-domain. A declaration, not legal clearance.",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
server: SERVER,
|
|
73
|
+
id: ID,
|
|
74
|
+
visibility: { type: "string", enum: ["unlisted", "public"] },
|
|
75
|
+
listed: { type: "boolean", description: "In the directory (public only). Default true for public." },
|
|
76
|
+
basis: { type: "string", enum: ["owned", "permission", "licensed", "public-domain"] },
|
|
77
|
+
note: STRING,
|
|
78
|
+
},
|
|
79
|
+
required: ["id", "visibility"],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "station_unpublish",
|
|
84
|
+
description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
|
|
85
|
+
inputSchema: { type: "object", properties: { server: SERVER, id: ID, private: { type: "boolean" } }, required: ["id"] },
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
interface Result {
|
|
90
|
+
content: { type: "text"; text: string }[];
|
|
91
|
+
isError?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const say = (value: string, isError = false): Result => ({ content: [{ type: "text", text: value }], ...(isError ? { isError: true } : {}) });
|
|
95
|
+
|
|
96
|
+
export interface StationToolOptions {
|
|
97
|
+
fetcher?: typeof fetch;
|
|
98
|
+
/** Where the local daemon is and its key; the tests hand these in. */
|
|
99
|
+
local?: { url: string; key: string | null } | null;
|
|
100
|
+
session?: { site: string; token: string } | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function isStationTool(name: string): boolean {
|
|
104
|
+
return STATION_TOOLS.some((tool) => tool.name === name);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function line(station: Record<string, unknown>): string {
|
|
108
|
+
const links = (station["links"] ?? {}) as Record<string, string>;
|
|
109
|
+
const problem = station["error"] ? `; problem: ${String(station["error"])}` : "";
|
|
110
|
+
const link = station["visibility"] !== "private" && links["page"] ? `\n listen: ${links["page"]}` : "";
|
|
111
|
+
return `${String(station["name"])} [${String(station["id"])}] ${String(station["state"])}, ${String(station["visibility"])}${station["listed"] ? " and listed" : ""}${problem}${link}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function callStationTool(name: string, args: Record<string, unknown>, options: StationToolOptions = {}): Promise<Result> {
|
|
115
|
+
const send = options.fetcher ?? fetch;
|
|
116
|
+
const server = typeof args["server"] === "string" ? args["server"].replace(/\/+$/, "") : "";
|
|
117
|
+
let base = server;
|
|
118
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
119
|
+
if (server === "") {
|
|
120
|
+
const local = options.local === undefined ? (() => { const state = readState(); return state ? { url: daemonUrl(state), key: state.key } : null; })() : options.local;
|
|
121
|
+
if (!local) return say("No nixamp daemon runs on this machine. Start one (nixamp daemon start ~/Music) or pass `server`.", true);
|
|
122
|
+
base = local.url;
|
|
123
|
+
if (local.key) headers[KEY_HEADER] = local.key;
|
|
124
|
+
} else {
|
|
125
|
+
const session = options.session === undefined ? readSession() : options.session;
|
|
126
|
+
if (!session) return say("Another server's stations need this machine signed in as its owner: nixamp login.", true);
|
|
127
|
+
headers["authorization"] = `Bearer ${session.token}`;
|
|
128
|
+
}
|
|
129
|
+
const id = typeof args["id"] === "string" ? args["id"] : "";
|
|
130
|
+
const call = async (method: string, path: string, body?: unknown): Promise<{ ok: boolean; body: Record<string, unknown> }> => {
|
|
131
|
+
const response = await send(`${base}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
132
|
+
const parsed = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
|
133
|
+
return { ok: response.ok, body: parsed };
|
|
134
|
+
};
|
|
135
|
+
const refused = (body: Record<string, unknown>): Result => say(`${String(body["error"] ?? "refused")} (${String(body["code"] ?? "error")})`, true);
|
|
136
|
+
const pick = (keys: string[]): Record<string, unknown> => Object.fromEntries(keys.filter((key) => args[key] !== undefined).map((key) => [key, args[key]]));
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
if (name === "station_list") {
|
|
140
|
+
const answer = await call("GET", "/api/v1/stations?limit=200");
|
|
141
|
+
if (!answer.ok) return refused(answer.body);
|
|
142
|
+
const all = (answer.body["stations"] as Record<string, unknown>[] | undefined) ?? [];
|
|
143
|
+
return say(all.length === 0 ? "No stations on this server yet." : all.map(line).join("\n"));
|
|
144
|
+
}
|
|
145
|
+
if (!/^[a-z0-9]{10}$/.test(id) && name !== "station_create") return say("Which station? Pass its id from station_list.", true);
|
|
146
|
+
if (name === "station_get") {
|
|
147
|
+
const answer = await call("GET", `/api/v1/stations/${id}`);
|
|
148
|
+
return answer.ok ? say(JSON.stringify(answer.body["station"], null, 2)) : refused(answer.body);
|
|
149
|
+
}
|
|
150
|
+
if (name === "station_create") {
|
|
151
|
+
const answer = await call("POST", "/api/v1/stations", pick(["name", "folder", "items", "description", "shuffle", "repeat", "bitrate", "idempotencyKey"]));
|
|
152
|
+
return answer.ok ? say(`Made, private and stopped: ${line(answer.body["station"] as Record<string, unknown>)}`) : refused(answer.body);
|
|
153
|
+
}
|
|
154
|
+
if (name === "station_update") {
|
|
155
|
+
const answer = await call("PATCH", `/api/v1/stations/${id}`, pick(["name", "description", "folder", "items", "shuffle", "repeat", "bitrate"]));
|
|
156
|
+
return answer.ok ? say(line(answer.body["station"] as Record<string, unknown>)) : refused(answer.body);
|
|
157
|
+
}
|
|
158
|
+
if (name === "station_control") {
|
|
159
|
+
const action = String(args["action"] ?? "");
|
|
160
|
+
if (!["start", "stop", "skip", "retry"].includes(action)) return say("action is start, stop, skip or retry", true);
|
|
161
|
+
const answer = await call("POST", `/api/v1/stations/${id}/${action}`, {});
|
|
162
|
+
return answer.ok ? say(line(answer.body["station"] as Record<string, unknown>)) : refused(answer.body);
|
|
163
|
+
}
|
|
164
|
+
if (name === "station_publish") {
|
|
165
|
+
const visibility = args["visibility"] === "public" ? "public" : "unlisted";
|
|
166
|
+
const answer = await call("POST", `/api/v1/stations/${id}/access`, {
|
|
167
|
+
visibility,
|
|
168
|
+
listed: visibility === "public" && args["listed"] !== false,
|
|
169
|
+
...(typeof args["basis"] === "string" ? { rights: { basis: args["basis"], note: typeof args["note"] === "string" ? args["note"] : "" } } : {}),
|
|
170
|
+
});
|
|
171
|
+
return answer.ok ? say(line(answer.body["station"] as Record<string, unknown>)) : refused(answer.body);
|
|
172
|
+
}
|
|
173
|
+
if (name === "station_unpublish") {
|
|
174
|
+
const answer = await call("POST", `/api/v1/stations/${id}/access`, args["private"] === true ? { visibility: "private" } : { listed: false });
|
|
175
|
+
if (!answer.ok) return refused(answer.body);
|
|
176
|
+
const dropped = Number(answer.body["dropped"] ?? 0);
|
|
177
|
+
return say(`${line(answer.body["station"] as Record<string, unknown>)}${dropped ? `\n${dropped} listener(s) disconnected.` : ""}`);
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return say(`Could not reach ${base}: ${(error as Error).message}`, true);
|
|
181
|
+
}
|
|
182
|
+
return say(`No such tool: ${name}`, true);
|
|
183
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
|
|
14
|
+
const STYLE = `
|
|
15
|
+
:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}
|
|
16
|
+
@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}
|
|
17
|
+
*{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}
|
|
18
|
+
main{max-width:560px;margin:0 auto;padding:48px 16px}
|
|
19
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
|
|
20
|
+
h1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}
|
|
21
|
+
.about{color:var(--muted);margin:0 0 20px}
|
|
22
|
+
.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)}
|
|
23
|
+
.chip::before{content:"";width:8px;height:8px;border-radius:50%;background:currentColor}
|
|
24
|
+
.live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}
|
|
25
|
+
.now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}
|
|
26
|
+
.controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
|
|
27
|
+
button{font:inherit;font-weight:600;border:0;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;cursor:pointer;min-width:120px}
|
|
28
|
+
button:disabled{opacity:.5;cursor:not-allowed}
|
|
29
|
+
button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
|
|
30
|
+
label{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}
|
|
31
|
+
input[type=range]{width:120px;accent-color:var(--accent)}
|
|
32
|
+
.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}
|
|
33
|
+
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
34
|
+
@media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}
|
|
35
|
+
@keyframes pulse{50%{opacity:.35}}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const SCRIPT = `
|
|
39
|
+
(() => {
|
|
40
|
+
const id = location.pathname.split("/").filter(Boolean)[1] || "";
|
|
41
|
+
const token = new URLSearchParams(location.search).get("t") || "";
|
|
42
|
+
const q = "?t=" + encodeURIComponent(token);
|
|
43
|
+
const api = "/api/v1/stations/" + encodeURIComponent(id);
|
|
44
|
+
const $ = (s) => document.querySelector(s);
|
|
45
|
+
const audio = new Audio();
|
|
46
|
+
audio.preload = "none";
|
|
47
|
+
let wanted = false, retry = 0, timer = 0, last = null;
|
|
48
|
+
const chip = (state, text) => { const c = $("#state"); c.className = "chip " + state; c.textContent = text; };
|
|
49
|
+
const say = (text) => { $("#announce").textContent = text; };
|
|
50
|
+
const draw = (s) => {
|
|
51
|
+
last = s;
|
|
52
|
+
document.title = s.name + " · nixamp";
|
|
53
|
+
$("#name").textContent = s.name;
|
|
54
|
+
$("#about").textContent = s.description || "";
|
|
55
|
+
$("#about").hidden = !s.description;
|
|
56
|
+
const playing = s.nowPlaying;
|
|
57
|
+
$("#title").textContent = playing ? (playing.title || "Untitled") : (s.state === "live" ? "On the air" : "");
|
|
58
|
+
$("#by").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(" · ") : "";
|
|
59
|
+
if (wanted && retry > 0) chip("reconnecting", "Reconnecting");
|
|
60
|
+
else if (s.state === "live") chip("live", "Live");
|
|
61
|
+
else if (s.state === "starting" || s.state === "recovering") chip("reconnecting", "Coming back");
|
|
62
|
+
else if (s.state === "stopped") chip("stopped", "Off the air");
|
|
63
|
+
else chip("unavailable", "Unavailable");
|
|
64
|
+
$("#play").disabled = !(s.state === "live" || s.state === "recovering" || s.state === "starting");
|
|
65
|
+
$("#m3u").href = api + "/listen.m3u" + q;
|
|
66
|
+
};
|
|
67
|
+
const poll = async () => {
|
|
68
|
+
try {
|
|
69
|
+
const r = await fetch(api + "/state" + q, { cache: "no-store" });
|
|
70
|
+
if (r.status === 404) { $("#name").textContent = "This station is not available"; chip("unavailable", "Unavailable"); $("#play").disabled = true; stop(); return; }
|
|
71
|
+
if (r.ok) draw(await r.json());
|
|
72
|
+
} catch {}
|
|
73
|
+
};
|
|
74
|
+
// Always the live edge: a fresh request, never a cached or paused one.
|
|
75
|
+
const join = () => { audio.src = api + "/stream" + q + "&n=" + Date.now(); audio.play().catch(() => { wanted = false; button(); }); };
|
|
76
|
+
const stop = () => { wanted = false; clearTimeout(timer); audio.pause(); audio.removeAttribute("src"); audio.load(); button(); };
|
|
77
|
+
const button = () => { const b = $("#play"); b.textContent = wanted ? "Stop" : "Play"; b.setAttribute("aria-pressed", String(wanted)); };
|
|
78
|
+
const again = () => {
|
|
79
|
+
if (!wanted) return;
|
|
80
|
+
retry += 1;
|
|
81
|
+
if (last) draw(last);
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
timer = setTimeout(join, Math.min(15000, 1000 * 2 ** Math.min(retry, 4)));
|
|
84
|
+
};
|
|
85
|
+
audio.addEventListener("playing", () => { if (retry) say("Back on"); retry = 0; if (last) draw(last); });
|
|
86
|
+
audio.addEventListener("error", again);
|
|
87
|
+
audio.addEventListener("ended", again);
|
|
88
|
+
audio.addEventListener("stalled", () => { if (wanted && audio.readyState < 3) again(); });
|
|
89
|
+
$("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
|
|
90
|
+
$("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
|
|
91
|
+
poll();
|
|
92
|
+
setInterval(poll, 5000);
|
|
93
|
+
})();
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
97
|
+
|
|
98
|
+
const PAGE = `<!doctype html>
|
|
99
|
+
<html lang="en">
|
|
100
|
+
<head>
|
|
101
|
+
<meta charset="utf-8">
|
|
102
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
103
|
+
<meta name="robots" content="noindex">
|
|
104
|
+
<link rel="icon" href="data:,">
|
|
105
|
+
<title>Station · nixamp</title>
|
|
106
|
+
<style>${STYLE}</style>
|
|
107
|
+
</head>
|
|
108
|
+
<body>
|
|
109
|
+
<main>
|
|
110
|
+
<div class="card">
|
|
111
|
+
<span id="state" class="chip unavailable" role="status">Loading</span>
|
|
112
|
+
<h1 id="name"> </h1>
|
|
113
|
+
<p id="about" class="about" hidden></p>
|
|
114
|
+
<div class="now" aria-live="polite"><div id="title" class="title"></div><div id="by" class="by"></div></div>
|
|
115
|
+
<div class="controls">
|
|
116
|
+
<button id="play" type="button" aria-pressed="false" disabled>Play</button>
|
|
117
|
+
<label>Volume <input id="volume" type="range" min="0" max="1" step="0.05" value="1"></label>
|
|
118
|
+
</div>
|
|
119
|
+
<p class="small"><a id="m3u" href="#">Open in another player (.m3u)</a> · Pausing here does not pause the station.</p>
|
|
120
|
+
</div>
|
|
121
|
+
<p class="small">Played by <a href="https://nixamp.com">nixamp</a>.</p>
|
|
122
|
+
<div id="announce" class="sr" aria-live="assertive"></div>
|
|
123
|
+
</main>
|
|
124
|
+
<script>${SCRIPT}</script>
|
|
125
|
+
</body>
|
|
126
|
+
</html>
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
/** A station page's path: /station/<id>. */
|
|
130
|
+
export const STATION_PAGE = /^\/station\/([a-z0-9]{10})\/?$/;
|
|
131
|
+
|
|
132
|
+
export const STATION_PAGE_HTML = PAGE;
|
|
133
|
+
|
|
134
|
+
/** Only its own script and style, only its own server, never framed. */
|
|
135
|
+
export const STATION_PAGE_CSP = [
|
|
136
|
+
"default-src 'none'",
|
|
137
|
+
`script-src ${hash(SCRIPT)}`,
|
|
138
|
+
`style-src ${hash(STYLE)}`,
|
|
139
|
+
"connect-src 'self'",
|
|
140
|
+
"media-src 'self'",
|
|
141
|
+
"img-src 'self' data:",
|
|
142
|
+
"base-uri 'none'",
|
|
143
|
+
"form-action 'none'",
|
|
144
|
+
"frame-ancestors 'none'",
|
|
145
|
+
].join("; ");
|
|
@@ -0,0 +1,302 @@
|
|
|
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 {
|
|
19
|
+
StationError, channelOf, tokensMatch, type OwnerStation, type PublicStation, type Station, type StationStore,
|
|
20
|
+
} from "./stations.ts";
|
|
21
|
+
|
|
22
|
+
/** The three things a station's link opens. */
|
|
23
|
+
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u)$/;
|
|
24
|
+
|
|
25
|
+
/** Whether this path is one a station's own link may open, without the server's key. */
|
|
26
|
+
export function isStationListenPath(path: string): boolean {
|
|
27
|
+
return LISTEN.test(path);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StationContext {
|
|
31
|
+
store: StationStore;
|
|
32
|
+
runner: StationRunner;
|
|
33
|
+
channels: Channels;
|
|
34
|
+
/** Holds the control key, or is signed in as this server's owner. */
|
|
35
|
+
canControl: boolean;
|
|
36
|
+
/** The nixamp.com account asking, "" when it is the control key. */
|
|
37
|
+
account: string;
|
|
38
|
+
/** Files chosen by track index or by folder; null when neither was given. */
|
|
39
|
+
select: (body: { items?: unknown; folder?: unknown }) => string[] | null;
|
|
40
|
+
/** What the library knows about a file, for now playing. */
|
|
41
|
+
trackFor: (file: string) => { title: string; artist: string; album: string } | null;
|
|
42
|
+
/** This server's address as the asker reached it, for links. */
|
|
43
|
+
base: string;
|
|
44
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
45
|
+
readBody: (request: IncomingMessage) => Promise<string>;
|
|
46
|
+
/** Headers every answer carries (CORS). */
|
|
47
|
+
headers: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fail(ctx: StationContext, response: ServerResponse, error: unknown): void {
|
|
51
|
+
if (error instanceof StationError) {
|
|
52
|
+
ctx.json(response, error.status, { error: error.message, code: error.code });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function links(ctx: StationContext, station: Station): { page: string; stream: string; m3u: string; state: string } {
|
|
59
|
+
const query = `t=${encodeURIComponent(station.listenToken)}`;
|
|
60
|
+
const at = `${ctx.base}/api/v1/stations/${station.id}`;
|
|
61
|
+
return {
|
|
62
|
+
page: `${ctx.base}/station/${station.id}?${query}`,
|
|
63
|
+
stream: `${at}/stream?${query}`,
|
|
64
|
+
m3u: `${at}/listen.m3u?${query}`,
|
|
65
|
+
state: `${at}/state?${query}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The file on the air now, as the channel's own encoder last reported it. */
|
|
70
|
+
function nowPlayingFile(ctx: StationContext, station: Station): string {
|
|
71
|
+
const info = ctx.channels.info(channelOf(station.id));
|
|
72
|
+
if (!info?.playlist?.length) return "";
|
|
73
|
+
return info.playlist[info.playlistAt ?? 0] ?? "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function publicView(ctx: StationContext, station: Station): PublicStation {
|
|
77
|
+
const state = ctx.runner.observed(station.id);
|
|
78
|
+
const file = state === "live" || state === "recovering" ? nowPlayingFile(ctx, station) : "";
|
|
79
|
+
// The tags the player read when it put this file on, else the library's.
|
|
80
|
+
const track = file ? (ctx.channels.tagsOf(channelOf(station.id), file) ?? ctx.trackFor(file)) : null;
|
|
81
|
+
return {
|
|
82
|
+
id: station.id,
|
|
83
|
+
name: station.name,
|
|
84
|
+
description: station.description,
|
|
85
|
+
visibility: station.visibility,
|
|
86
|
+
state,
|
|
87
|
+
// Tags, or nothing: a bare file name is the owner's, not the public's.
|
|
88
|
+
nowPlaying: track && (track.title || track.artist) ? track : null,
|
|
89
|
+
format: state === "stopped" || state === "offline" ? null : { codec: "mp3", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 },
|
|
90
|
+
listeners: ctx.runner.listeners(station.id),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function ownerView(ctx: StationContext, station: Station): OwnerStation & { links: ReturnType<typeof links>; nowPlaying: string } {
|
|
95
|
+
const { failures, idempotencyKey: _key, ...rest } = station;
|
|
96
|
+
return {
|
|
97
|
+
...rest,
|
|
98
|
+
state: ctx.runner.observed(station.id),
|
|
99
|
+
excluded: Object.entries(failures).filter(([, count]) => count >= 3).map(([file]) => file),
|
|
100
|
+
listeners: ctx.runner.listeners(station.id),
|
|
101
|
+
error: ctx.runner.error(station.id),
|
|
102
|
+
links: links(ctx, station),
|
|
103
|
+
nowPlaying: nowPlayingFile(ctx, station),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Answers anything under /api/v1/stations; false when the path is not one. */
|
|
108
|
+
export async function handleStations(
|
|
109
|
+
request: IncomingMessage,
|
|
110
|
+
response: ServerResponse,
|
|
111
|
+
path: string,
|
|
112
|
+
url: URL,
|
|
113
|
+
ctx: StationContext,
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
if (path !== "/api/v1/stations" && !path.startsWith("/api/v1/stations/")) return false;
|
|
116
|
+
const method = request.method ?? "GET";
|
|
117
|
+
|
|
118
|
+
const listen = LISTEN.exec(path);
|
|
119
|
+
if (listen) {
|
|
120
|
+
const station = ctx.store.get(listen[1]!);
|
|
121
|
+
const token = url.searchParams.get("t") ?? "";
|
|
122
|
+
// The same answer for "no such station" and "not with that link", so a
|
|
123
|
+
// link cannot be used to find out which stations exist.
|
|
124
|
+
const allowed = station !== undefined &&
|
|
125
|
+
(ctx.canControl || (station.visibility !== "private" && token !== "" && tokensMatch(token, station.listenToken)));
|
|
126
|
+
if (!station || !allowed) {
|
|
127
|
+
ctx.json(response, 404, { error: "no such station, or not with that link", code: "not_found" });
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
131
|
+
ctx.json(response, 405, { error: "a station's link can listen, not drive", code: "method_not_allowed" });
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
const what = listen[2];
|
|
135
|
+
if (what === "state") {
|
|
136
|
+
ctx.json(response, 200, publicView(ctx, station));
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
if (what === "listen.m3u") {
|
|
140
|
+
const stream = links(ctx, station).stream;
|
|
141
|
+
response.writeHead(200, {
|
|
142
|
+
...ctx.headers,
|
|
143
|
+
"content-type": "audio/x-mpegurl; charset=utf-8",
|
|
144
|
+
"content-disposition": `inline; filename="${station.id}.m3u"`,
|
|
145
|
+
"cache-control": "no-store",
|
|
146
|
+
});
|
|
147
|
+
response.end(`#EXTM3U\n#EXTINF:-1,${station.name.replace(/[\r\n]/g, " ")}\n${stream}\n`);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
const channelId = channelOf(station.id);
|
|
151
|
+
if (!ctx.channels.has(channelId)) {
|
|
152
|
+
ctx.json(response, 503, { error: "the station is not on the air", code: "not_live", state: ctx.runner.observed(station.id) });
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
|
|
156
|
+
if (method === "HEAD") {
|
|
157
|
+
response.end();
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
const detach = ctx.channels.listen(channelId, response);
|
|
161
|
+
if (detach === null) {
|
|
162
|
+
response.end();
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
const untrack = ctx.runner.track(station.id, { end: () => response.destroy() });
|
|
166
|
+
const leave = (): void => {
|
|
167
|
+
detach();
|
|
168
|
+
untrack();
|
|
169
|
+
};
|
|
170
|
+
request.on("close", leave);
|
|
171
|
+
response.on("close", leave);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Everything else is the owner's.
|
|
176
|
+
if (!ctx.canControl) {
|
|
177
|
+
ctx.json(response, 403, { error: "only this server's owner manages its stations", code: "forbidden" });
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const body = async (): Promise<Record<string, unknown>> => {
|
|
182
|
+
if (method === "GET" || method === "DELETE") return {};
|
|
183
|
+
const raw = await ctx.readBody(request);
|
|
184
|
+
if (raw.trim() === "") return {};
|
|
185
|
+
let parsed: unknown;
|
|
186
|
+
try {
|
|
187
|
+
parsed = JSON.parse(raw);
|
|
188
|
+
} catch {
|
|
189
|
+
throw new StationError("bad_json", "the body is not JSON");
|
|
190
|
+
}
|
|
191
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new StationError("bad_json", "the body is not a JSON object");
|
|
192
|
+
return parsed as Record<string, unknown>;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
if (path === "/api/v1/stations") {
|
|
197
|
+
if (method === "GET") {
|
|
198
|
+
const all = ctx.store.list();
|
|
199
|
+
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit")) || 50));
|
|
200
|
+
const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
|
|
201
|
+
ctx.json(response, 200, { stations: all.slice(offset, offset + limit).map((one) => ownerView(ctx, one)), total: all.length, limit, offset });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
if (method === "POST") {
|
|
205
|
+
const input = await body();
|
|
206
|
+
const key = String(request.headers["idempotency-key"] ?? input["idempotencyKey"] ?? "").trim();
|
|
207
|
+
const again = ctx.store.byIdempotencyKey(key);
|
|
208
|
+
if (again) {
|
|
209
|
+
ctx.json(response, 200, { station: ownerView(ctx, again), created: false });
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
const items = ctx.select(input);
|
|
213
|
+
if (items === null) throw new StationError("items_required", "choose files by track index (items) or a folder");
|
|
214
|
+
const station = ctx.store.create(input, items, ctx.account, Date.now(), key);
|
|
215
|
+
ctx.json(response, 201, { station: ownerView(ctx, station), created: true });
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
ctx.json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const match = /^\/api\/v1\/stations\/([a-z0-9]{10})(?:\/([a-z-]+))?$/.exec(path);
|
|
223
|
+
if (!match) {
|
|
224
|
+
ctx.json(response, 404, { error: "no such station", code: "not_found" });
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
const id = match[1]!;
|
|
228
|
+
const action = match[2];
|
|
229
|
+
const station = ctx.store.get(id);
|
|
230
|
+
if (!station) {
|
|
231
|
+
ctx.json(response, 404, { error: "no such station", code: "not_found" });
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (action === undefined) {
|
|
236
|
+
if (method === "GET") {
|
|
237
|
+
ctx.json(response, 200, { station: ownerView(ctx, station) });
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
if (method === "PATCH") {
|
|
241
|
+
const input = await body();
|
|
242
|
+
const items = ctx.select(input);
|
|
243
|
+
const updated = ctx.store.update(id, input, items);
|
|
244
|
+
ctx.json(response, 200, { station: ownerView(ctx, updated) });
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
if (method === "DELETE") {
|
|
248
|
+
ctx.runner.remove(id);
|
|
249
|
+
ctx.json(response, 200, { deleted: id });
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
ctx.json(response, 405, { error: "GET, PATCH or DELETE", code: "method_not_allowed" });
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (method !== "POST") {
|
|
257
|
+
ctx.json(response, 405, { error: "POST", code: "method_not_allowed" });
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
if (action === "start") {
|
|
261
|
+
const started = await ctx.runner.start(id);
|
|
262
|
+
ctx.json(response, 200, { station: ownerView(ctx, started) });
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
if (action === "stop") {
|
|
266
|
+
ctx.json(response, 200, { station: ownerView(ctx, ctx.runner.stop(id)) });
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
if (action === "skip") {
|
|
270
|
+
ctx.runner.skip(id);
|
|
271
|
+
ctx.json(response, 200, { station: ownerView(ctx, station) });
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
if (action === "retry") {
|
|
275
|
+
ctx.json(response, 200, { station: ownerView(ctx, ctx.store.clearFailures(id)) });
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
if (action === "rotate-link") {
|
|
279
|
+
const rotated = ctx.store.rotateToken(id);
|
|
280
|
+
const dropped = ctx.runner.revoke(id);
|
|
281
|
+
ctx.json(response, 200, { station: ownerView(ctx, rotated), dropped });
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
if (action === "access") {
|
|
285
|
+
const input = await body();
|
|
286
|
+
const rights = input["rights"];
|
|
287
|
+
const { station: changed, revoked } = ctx.store.setAccess(id, {
|
|
288
|
+
visibility: input["visibility"],
|
|
289
|
+
listed: input["listed"],
|
|
290
|
+
rights: typeof rights === "object" && rights !== null ? rights as { basis?: unknown; note?: unknown } : null,
|
|
291
|
+
}, ctx.account || "control-key");
|
|
292
|
+
const dropped = revoked ? ctx.runner.revoke(id) : 0;
|
|
293
|
+
ctx.json(response, 200, { station: ownerView(ctx, changed), dropped });
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
ctx.json(response, 404, { error: "no such action", code: "not_found" });
|
|
297
|
+
return true;
|
|
298
|
+
} catch (error) {
|
|
299
|
+
fail(ctx, response, error);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
}
|