nixamp 0.30.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/channels.d.ts +12 -0
  2. package/dist/channels.js +8 -0
  3. package/dist/library-cli.d.ts +3 -0
  4. package/dist/library-cli.js +198 -0
  5. package/dist/library-index.d.ts +190 -0
  6. package/dist/library-index.js +643 -0
  7. package/dist/library-mcp.d.ts +230 -0
  8. package/dist/library-mcp.js +139 -0
  9. package/dist/library-routes.d.ts +34 -0
  10. package/dist/library-routes.js +258 -0
  11. package/dist/library-usage.d.ts +3 -0
  12. package/dist/library-usage.js +20 -0
  13. package/dist/library-user.d.ts +67 -0
  14. package/dist/library-user.js +204 -0
  15. package/dist/main.js +8 -0
  16. package/dist/mcp.d.ts +3 -0
  17. package/dist/mcp.js +10 -1
  18. package/dist/playlist-input.d.ts +10 -0
  19. package/dist/playlist-input.js +9 -0
  20. package/dist/server.d.ts +9 -0
  21. package/dist/server.js +83 -12
  22. package/dist/station-cli.js +4 -3
  23. package/dist/station-mcp.d.ts +311 -0
  24. package/dist/station-mcp.js +179 -0
  25. package/dist/station-page.d.ts +1 -1
  26. package/dist/station-page.js +2 -1
  27. package/dist/station-routes.js +2 -1
  28. package/dist/station-usage.d.ts +1 -1
  29. package/dist/station-usage.js +1 -1
  30. package/package.json +1 -1
  31. package/src/channels.ts +10 -0
  32. package/src/library-cli.ts +175 -0
  33. package/src/library-index.ts +709 -0
  34. package/src/library-mcp.ts +132 -0
  35. package/src/library-routes.ts +282 -0
  36. package/src/library-usage.ts +21 -0
  37. package/src/library-user.ts +218 -0
  38. package/src/main.ts +7 -0
  39. package/src/mcp.ts +12 -1
  40. package/src/playlist-input.ts +9 -0
  41. package/src/server.ts +83 -13
  42. package/src/station-cli.ts +4 -3
  43. package/src/station-mcp.ts +190 -0
  44. package/src/station-page.ts +2 -1
  45. package/src/station-routes.ts +2 -1
  46. package/src/station-usage.ts +1 -1
  47. package/web/dist/assets/{ar-CTUDxy1J.js → ar-C_4gIadE.js} +1 -1
  48. package/web/dist/assets/{cs-m4JTaO3o.js → cs-B18R5adY.js} +1 -1
  49. package/web/dist/assets/{da-DHnJElYB.js → da-B6w5URQ2.js} +1 -1
  50. package/web/dist/assets/{de-DkxPohnf.js → de-Dlhr9ikG.js} +1 -1
  51. package/web/dist/assets/{es-oY_G83d7.js → es-BIpArFOC.js} +1 -1
  52. package/web/dist/assets/{fi-fCO8XRdg.js → fi-o9W4XS3X.js} +1 -1
  53. package/web/dist/assets/{fr-CSupf-IN.js → fr-BgVwhPsP.js} +1 -1
  54. package/web/dist/assets/{hi-Dve7PJsC.js → hi-zIXqrXNC.js} +1 -1
  55. package/web/dist/assets/{hls-3VKVEQE3-D5-AkKSR.js → hls-3VKVEQE3-5pAjDDLr.js} +1 -1
  56. package/web/dist/assets/{hu-D1KYamzV.js → hu-Dpn_DIRi.js} +1 -1
  57. package/web/dist/assets/{id-Cofp_4aQ.js → id-Cu1V6gAm.js} +1 -1
  58. package/web/dist/assets/{index-7mCQZGNF.js → index-D-PZ4nGC.js} +2 -2
  59. package/web/dist/assets/{it-62iXQWQD.js → it-BNcoJEYQ.js} +1 -1
  60. package/web/dist/assets/{mpegts-BT5ESacM.js → mpegts-CabjhxTI.js} +1 -1
  61. package/web/dist/assets/{mpegts-LO6RVLD6-BgHd8PFT.js → mpegts-LO6RVLD6-CRSxiR1J.js} +1 -1
  62. package/web/dist/assets/{nl-CzRRG-CN.js → nl-gvivlDEE.js} +1 -1
  63. package/web/dist/assets/{ru-OykC1LJQ.js → ru-BxQnVrHu.js} +1 -1
  64. package/web/dist/assets/{sv-B8FXBDHm.js → sv-BP1JhAR8.js} +1 -1
  65. package/web/dist/assets/{uk-DhAJBjwH.js → uk-ChYhgzlf.js} +1 -1
  66. package/web/dist/assets/{vi-CdBSRReC.js → vi-CD7vzLwP.js} +1 -1
  67. package/web/dist/assets/{zh-wGWNyBEZ.js → zh-BmGS-RcE.js} +1 -1
  68. package/web/dist/index.html +1 -1
  69. package/web/dist/sw.js +22 -22
@@ -0,0 +1,311 @@
1
+ export declare const STATION_TOOLS: ({
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: string;
6
+ properties: {
7
+ server: {
8
+ type: "string";
9
+ description: string;
10
+ };
11
+ playlist?: undefined;
12
+ idempotencyKey?: undefined;
13
+ name?: undefined;
14
+ description?: undefined;
15
+ folder?: undefined;
16
+ items?: undefined;
17
+ shuffle?: undefined;
18
+ repeat?: undefined;
19
+ bitrate?: undefined;
20
+ action?: undefined;
21
+ visibility?: undefined;
22
+ listed?: undefined;
23
+ basis?: undefined;
24
+ note?: undefined;
25
+ id?: undefined;
26
+ private?: undefined;
27
+ };
28
+ required?: undefined;
29
+ };
30
+ } | {
31
+ name: string;
32
+ description: string;
33
+ inputSchema: {
34
+ type: string;
35
+ properties: {
36
+ server: {
37
+ type: "string";
38
+ description: string;
39
+ };
40
+ id: {
41
+ type: "string";
42
+ description: string;
43
+ };
44
+ playlist?: undefined;
45
+ idempotencyKey?: undefined;
46
+ name?: undefined;
47
+ description?: undefined;
48
+ folder?: undefined;
49
+ items?: undefined;
50
+ shuffle?: undefined;
51
+ repeat?: undefined;
52
+ bitrate?: undefined;
53
+ action?: undefined;
54
+ visibility?: undefined;
55
+ listed?: undefined;
56
+ basis?: undefined;
57
+ note?: undefined;
58
+ private?: undefined;
59
+ };
60
+ required: string[];
61
+ };
62
+ } | {
63
+ name: string;
64
+ description: string;
65
+ inputSchema: {
66
+ type: string;
67
+ properties: {
68
+ server: {
69
+ type: "string";
70
+ description: string;
71
+ };
72
+ name: {
73
+ readonly type: "string";
74
+ };
75
+ folder: {
76
+ type: "string";
77
+ description: string;
78
+ };
79
+ items: {
80
+ type: string;
81
+ items: {
82
+ type: string;
83
+ };
84
+ description: string;
85
+ };
86
+ playlist: {
87
+ type: "string";
88
+ description: string;
89
+ };
90
+ description: {
91
+ readonly type: "string";
92
+ };
93
+ shuffle: {
94
+ type: string;
95
+ };
96
+ repeat: {
97
+ type: string;
98
+ description: string;
99
+ };
100
+ bitrate: {
101
+ type: string;
102
+ description: string;
103
+ };
104
+ idempotencyKey: {
105
+ type: "string";
106
+ description: string;
107
+ };
108
+ action?: undefined;
109
+ visibility?: undefined;
110
+ listed?: undefined;
111
+ basis?: undefined;
112
+ note?: undefined;
113
+ id?: undefined;
114
+ private?: undefined;
115
+ };
116
+ required: string[];
117
+ };
118
+ } | {
119
+ name: string;
120
+ description: string;
121
+ inputSchema: {
122
+ type: string;
123
+ properties: {
124
+ playlist?: undefined;
125
+ idempotencyKey?: undefined;
126
+ server: {
127
+ type: "string";
128
+ description: string;
129
+ };
130
+ id: {
131
+ type: "string";
132
+ description: string;
133
+ };
134
+ name: {
135
+ readonly type: "string";
136
+ };
137
+ description: {
138
+ readonly type: "string";
139
+ };
140
+ folder: {
141
+ readonly type: "string";
142
+ };
143
+ items: {
144
+ description?: undefined;
145
+ type: string;
146
+ items: {
147
+ type: string;
148
+ };
149
+ };
150
+ shuffle: {
151
+ type: string;
152
+ };
153
+ repeat: {
154
+ description?: undefined;
155
+ type: string;
156
+ };
157
+ bitrate: {
158
+ description?: undefined;
159
+ type: string;
160
+ };
161
+ action?: undefined;
162
+ visibility?: undefined;
163
+ listed?: undefined;
164
+ basis?: undefined;
165
+ note?: undefined;
166
+ private?: undefined;
167
+ };
168
+ required: string[];
169
+ };
170
+ } | {
171
+ name: string;
172
+ description: string;
173
+ inputSchema: {
174
+ type: string;
175
+ properties: {
176
+ playlist?: undefined;
177
+ idempotencyKey?: undefined;
178
+ name?: undefined;
179
+ description?: undefined;
180
+ folder?: undefined;
181
+ items?: undefined;
182
+ shuffle?: undefined;
183
+ repeat?: undefined;
184
+ bitrate?: undefined;
185
+ server: {
186
+ type: "string";
187
+ description: string;
188
+ };
189
+ id: {
190
+ type: "string";
191
+ description: string;
192
+ };
193
+ action: {
194
+ type: string;
195
+ enum: string[];
196
+ };
197
+ visibility?: undefined;
198
+ listed?: undefined;
199
+ basis?: undefined;
200
+ note?: undefined;
201
+ private?: undefined;
202
+ };
203
+ required: string[];
204
+ };
205
+ } | {
206
+ name: string;
207
+ description: string;
208
+ inputSchema: {
209
+ type: string;
210
+ properties: {
211
+ playlist?: undefined;
212
+ idempotencyKey?: undefined;
213
+ name?: undefined;
214
+ description?: undefined;
215
+ folder?: undefined;
216
+ items?: undefined;
217
+ shuffle?: undefined;
218
+ repeat?: undefined;
219
+ bitrate?: undefined;
220
+ action?: undefined;
221
+ server: {
222
+ type: "string";
223
+ description: string;
224
+ };
225
+ id: {
226
+ type: "string";
227
+ description: string;
228
+ };
229
+ visibility: {
230
+ type: string;
231
+ enum: string[];
232
+ };
233
+ listed: {
234
+ type: string;
235
+ description: string;
236
+ };
237
+ basis: {
238
+ type: string;
239
+ enum: string[];
240
+ };
241
+ note: {
242
+ readonly type: "string";
243
+ };
244
+ private?: undefined;
245
+ };
246
+ required: string[];
247
+ };
248
+ } | {
249
+ name: string;
250
+ description: string;
251
+ inputSchema: {
252
+ type: string;
253
+ properties: {
254
+ playlist?: undefined;
255
+ idempotencyKey?: undefined;
256
+ name?: undefined;
257
+ description?: undefined;
258
+ folder?: undefined;
259
+ items?: undefined;
260
+ shuffle?: undefined;
261
+ repeat?: undefined;
262
+ bitrate?: undefined;
263
+ action?: undefined;
264
+ visibility?: undefined;
265
+ listed?: undefined;
266
+ basis?: undefined;
267
+ note?: undefined;
268
+ server: {
269
+ type: "string";
270
+ description: string;
271
+ };
272
+ id: {
273
+ type: "string";
274
+ description: string;
275
+ };
276
+ private: {
277
+ type: string;
278
+ };
279
+ };
280
+ required: string[];
281
+ };
282
+ })[];
283
+ interface Result {
284
+ content: {
285
+ type: "text";
286
+ text: string;
287
+ }[];
288
+ isError?: boolean;
289
+ }
290
+ export interface StationToolOptions {
291
+ fetcher?: typeof fetch;
292
+ /** Where the local daemon is and its key; the tests hand these in. */
293
+ local?: {
294
+ url: string;
295
+ key: string | null;
296
+ } | null;
297
+ session?: {
298
+ site: string;
299
+ token: string;
300
+ } | null;
301
+ }
302
+ /** Where to send a tool's request, and with which credential. */
303
+ export declare function connect(args: Record<string, unknown>, options: StationToolOptions): {
304
+ base: string;
305
+ headers: Record<string, string>;
306
+ } | {
307
+ error: string;
308
+ };
309
+ export declare function isStationTool(name: string): boolean;
310
+ export declare function callStationTool(name: string, args: Record<string, unknown>, options?: StationToolOptions): Promise<Result>;
311
+ export {};
@@ -0,0 +1,179 @@
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.js";
11
+ import { readSession } from "./session.js";
12
+ import { KEY_HEADER } from "./share.js";
13
+ const STRING = { type: "string" };
14
+ const ID = { ...STRING, description: "The station's id (10 characters), from station_list." };
15
+ const SERVER = { ...STRING, description: "A nixamp server's address; this machine's daemon when omitted." };
16
+ export const STATION_TOOLS = [
17
+ {
18
+ name: "station_list",
19
+ description: "List this server's stations: id, name, state (live, stopped, offline...), visibility and listening link.",
20
+ inputSchema: { type: "object", properties: { server: SERVER } },
21
+ },
22
+ {
23
+ name: "station_get",
24
+ description: "One station in full: files, rotation, state, any problem, and its links.",
25
+ inputSchema: { type: "object", properties: { server: SERVER, id: ID }, required: ["id"] },
26
+ },
27
+ {
28
+ name: "station_create",
29
+ description: "Make a station from a folder in the library (path relative to it, \"\" for all of it), a playlist, or track indexes. It starts private and stopped; nothing is published.",
30
+ inputSchema: {
31
+ type: "object",
32
+ properties: {
33
+ server: SERVER,
34
+ name: STRING,
35
+ folder: { ...STRING, description: "A folder under the library, e.g. \"Albums/Late Night\"." },
36
+ items: { type: "array", items: { type: "integer" }, description: "Track indexes, as /api/state lists them." },
37
+ playlist: { ...STRING, description: "A playlist id (playlist_list): the station plays its ready items in order." },
38
+ description: STRING,
39
+ shuffle: { type: "boolean" },
40
+ repeat: { type: "boolean", description: "Round and round (default) or once." },
41
+ bitrate: { type: "integer", description: "kbps: 64, 96, 128 (default), 160, 192, 256 or 320." },
42
+ idempotencyKey: { ...STRING, description: "Asking again with the same key returns the same station." },
43
+ },
44
+ required: ["name"],
45
+ },
46
+ },
47
+ {
48
+ name: "station_update",
49
+ description: "Rename, describe, change files or rotation. Edits to the files land on the next track.",
50
+ inputSchema: {
51
+ type: "object",
52
+ 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" } },
53
+ required: ["id"],
54
+ },
55
+ },
56
+ {
57
+ name: "station_control",
58
+ description: "start, stop or skip a station, or retry the files it left out. Starting does not publish.",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: { server: SERVER, id: ID, action: { type: "string", enum: ["start", "stop", "skip", "retry"] } },
62
+ required: ["id", "action"],
63
+ },
64
+ },
65
+ {
66
+ name: "station_publish",
67
+ 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.",
68
+ inputSchema: {
69
+ type: "object",
70
+ properties: {
71
+ server: SERVER,
72
+ id: ID,
73
+ visibility: { type: "string", enum: ["unlisted", "public"] },
74
+ listed: { type: "boolean", description: "In the directory (public only). Default true for public." },
75
+ basis: { type: "string", enum: ["owned", "permission", "licensed", "public-domain"] },
76
+ note: STRING,
77
+ },
78
+ required: ["id", "visibility"],
79
+ },
80
+ },
81
+ {
82
+ name: "station_unpublish",
83
+ description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
84
+ inputSchema: { type: "object", properties: { server: SERVER, id: ID, private: { type: "boolean" } }, required: ["id"] },
85
+ },
86
+ ];
87
+ const say = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...(isError ? { isError: true } : {}) });
88
+ /** Where to send a tool's request, and with which credential. */
89
+ export function connect(args, options) {
90
+ const server = typeof args["server"] === "string" ? args["server"].replace(/\/+$/, "") : "";
91
+ const headers = { "content-type": "application/json" };
92
+ if (server === "") {
93
+ const local = options.local === undefined ? (() => { const state = readState(); return state ? { url: daemonUrl(state), key: state.key } : null; })() : options.local;
94
+ if (!local)
95
+ return { error: "No nixamp daemon runs on this machine. Start one (nixamp daemon start ~/Music) or pass `server`." };
96
+ if (local.key)
97
+ headers[KEY_HEADER] = local.key;
98
+ return { base: local.url, headers };
99
+ }
100
+ const session = options.session === undefined ? readSession() : options.session;
101
+ if (!session)
102
+ return { error: "Another server needs this machine signed in as its owner: nixamp login." };
103
+ headers["authorization"] = `Bearer ${session.token}`;
104
+ return { base: server, headers };
105
+ }
106
+ export function isStationTool(name) {
107
+ return STATION_TOOLS.some((tool) => tool.name === name);
108
+ }
109
+ function line(station) {
110
+ const links = (station["links"] ?? {});
111
+ const problem = station["error"] ? `; problem: ${String(station["error"])}` : "";
112
+ const link = station["visibility"] !== "private" && links["page"] ? `\n listen: ${links["page"]}` : "";
113
+ return `${String(station["name"])} [${String(station["id"])}] ${String(station["state"])}, ${String(station["visibility"])}${station["listed"] ? " and listed" : ""}${problem}${link}`;
114
+ }
115
+ export async function callStationTool(name, args, options = {}) {
116
+ const send = options.fetcher ?? fetch;
117
+ const target = connect(args, options);
118
+ if ("error" in target)
119
+ return say(target.error, true);
120
+ const { base, headers } = target;
121
+ const id = typeof args["id"] === "string" ? args["id"] : "";
122
+ const call = async (method, path, body) => {
123
+ const response = await send(`${base}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
124
+ const parsed = (await response.json().catch(() => ({})));
125
+ return { ok: response.ok, body: parsed };
126
+ };
127
+ const refused = (body) => say(`${String(body["error"] ?? "refused")} (${String(body["code"] ?? "error")})`, true);
128
+ const pick = (keys) => Object.fromEntries(keys.filter((key) => args[key] !== undefined).map((key) => [key, args[key]]));
129
+ try {
130
+ if (name === "station_list") {
131
+ const answer = await call("GET", "/api/v1/stations?limit=200");
132
+ if (!answer.ok)
133
+ return refused(answer.body);
134
+ const all = answer.body["stations"] ?? [];
135
+ return say(all.length === 0 ? "No stations on this server yet." : all.map(line).join("\n"));
136
+ }
137
+ if (!/^[a-z0-9]{10}$/.test(id) && name !== "station_create")
138
+ return say("Which station? Pass its id from station_list.", true);
139
+ if (name === "station_get") {
140
+ const answer = await call("GET", `/api/v1/stations/${id}`);
141
+ return answer.ok ? say(JSON.stringify(answer.body["station"], null, 2)) : refused(answer.body);
142
+ }
143
+ if (name === "station_create") {
144
+ const answer = await call("POST", "/api/v1/stations", pick(["name", "folder", "items", "playlist", "description", "shuffle", "repeat", "bitrate", "idempotencyKey"]));
145
+ return answer.ok ? say(`Made, private and stopped: ${line(answer.body["station"])}`) : refused(answer.body);
146
+ }
147
+ if (name === "station_update") {
148
+ const answer = await call("PATCH", `/api/v1/stations/${id}`, pick(["name", "description", "folder", "items", "shuffle", "repeat", "bitrate"]));
149
+ return answer.ok ? say(line(answer.body["station"])) : refused(answer.body);
150
+ }
151
+ if (name === "station_control") {
152
+ const action = String(args["action"] ?? "");
153
+ if (!["start", "stop", "skip", "retry"].includes(action))
154
+ return say("action is start, stop, skip or retry", true);
155
+ const answer = await call("POST", `/api/v1/stations/${id}/${action}`, {});
156
+ return answer.ok ? say(line(answer.body["station"])) : refused(answer.body);
157
+ }
158
+ if (name === "station_publish") {
159
+ const visibility = args["visibility"] === "public" ? "public" : "unlisted";
160
+ const answer = await call("POST", `/api/v1/stations/${id}/access`, {
161
+ visibility,
162
+ listed: visibility === "public" && args["listed"] !== false,
163
+ ...(typeof args["basis"] === "string" ? { rights: { basis: args["basis"], note: typeof args["note"] === "string" ? args["note"] : "" } } : {}),
164
+ });
165
+ return answer.ok ? say(line(answer.body["station"])) : refused(answer.body);
166
+ }
167
+ if (name === "station_unpublish") {
168
+ const answer = await call("POST", `/api/v1/stations/${id}/access`, args["private"] === true ? { visibility: "private" } : { listed: false });
169
+ if (!answer.ok)
170
+ return refused(answer.body);
171
+ const dropped = Number(answer.body["dropped"] ?? 0);
172
+ return say(`${line(answer.body["station"])}${dropped ? `\n${dropped} listener(s) disconnected.` : ""}`);
173
+ }
174
+ }
175
+ catch (error) {
176
+ return say(`Could not reach ${base}: ${error.message}`, true);
177
+ }
178
+ return say(`No such tool: ${name}`, true);
179
+ }
@@ -1,5 +1,5 @@
1
1
  /** A station page's path: /station/<id>. */
2
2
  export declare const STATION_PAGE: RegExp;
3
- export declare const STATION_PAGE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<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";
3
+ export declare const STATION_PAGE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<link rel=\"icon\" href=\"data:,\">\n<title>Station \u00B7 nixamp</title>\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}\n*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif}\nmain{max-width:560px;margin:0 auto;padding:48px 16px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}\nh1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}\n.about{color:var(--muted);margin:0 0 20px}\n.chip{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600;padding:4px 10px;border-radius:999px;border:1px solid var(--line)}\n.chip::before{content:\"\";width:8px;height:8px;border-radius:50%;background:currentColor}\n.live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}\n.now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}\n.controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}\nbutton{font:inherit;font-weight:600;border:0;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;cursor:pointer;min-width:120px}\nbutton:disabled{opacity:.5;cursor:not-allowed}\nbutton:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}\nlabel{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}\ninput[type=range]{width:120px;accent-color:var(--accent)}\n.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}\n.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\n@media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}\n@keyframes pulse{50%{opacity:.35}}\n</style>\n</head>\n<body>\n<main>\n <div class=\"card\">\n <span id=\"state\" class=\"chip unavailable\" role=\"status\">Loading</span>\n <h1 id=\"name\">&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
4
  /** Only its own script and style, only its own server, never framed. */
5
5
  export declare const STATION_PAGE_CSP: string;
@@ -97,6 +97,7 @@ const PAGE = `<!doctype html>
97
97
  <meta charset="utf-8">
98
98
  <meta name="viewport" content="width=device-width, initial-scale=1">
99
99
  <meta name="robots" content="noindex">
100
+ <link rel="icon" href="data:,">
100
101
  <title>Station · nixamp</title>
101
102
  <style>${STYLE}</style>
102
103
  </head>
@@ -130,7 +131,7 @@ export const STATION_PAGE_CSP = [
130
131
  `style-src ${hash(STYLE)}`,
131
132
  "connect-src 'self'",
132
133
  "media-src 'self'",
133
- "img-src 'self'",
134
+ "img-src 'self' data:",
134
135
  "base-uri 'none'",
135
136
  "form-action 'none'",
136
137
  "frame-ancestors 'none'",
@@ -32,7 +32,8 @@ function nowPlayingFile(ctx, station) {
32
32
  export function publicView(ctx, station) {
33
33
  const state = ctx.runner.observed(station.id);
34
34
  const file = state === "live" || state === "recovering" ? nowPlayingFile(ctx, station) : "";
35
- const track = file ? ctx.trackFor(file) : null;
35
+ // The tags the player read when it put this file on, else the library's.
36
+ const track = file ? (ctx.channels.tagsOf(channelOf(station.id), file) ?? ctx.trackFor(file)) : null;
36
37
  return {
37
38
  id: station.id,
38
39
  name: station.name,
@@ -1,2 +1,2 @@
1
1
  /** `nixamp station` usage, apart so `nixamp help station` does not load the client. */
2
- export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
2
+ export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
@@ -2,7 +2,7 @@
2
2
  export const USAGE = `nixamp station — make a collection into a station
3
3
 
4
4
  nixamp station list
5
- nixamp station create --name NAME (--folder PATH | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]
5
+ nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]
6
6
  nixamp station show ID
7
7
  nixamp station status ID
8
8
  nixamp station start ID
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.30.1",
3
+ "version": "0.32.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/channels.ts CHANGED
@@ -299,6 +299,11 @@ export class Channel {
299
299
  skip(): boolean {
300
300
  return this.playlistInput?.skip() ?? false;
301
301
  }
302
+
303
+ /** An entry's tags, as read when it was put on. */
304
+ tagsOf(file: string): { title: string; artist: string; album: string } | undefined {
305
+ return this.playlistInput?.tags.get(file);
306
+ }
302
307
  private failures = 0;
303
308
  private timer: ReturnType<typeof setTimeout> | null = null;
304
309
  /** Fires when a pulled source has said nothing for STALL. */
@@ -1341,6 +1346,11 @@ export class Channels {
1341
1346
  return this.open.get(id)?.info.kind;
1342
1347
  }
1343
1348
 
1349
+ /** An entry's tags on a channel playing a list of files. */
1350
+ tagsOf(id: string, file: string): { title: string; artist: string; album: string } | undefined {
1351
+ return this.open.get(id)?.tagsOf(file);
1352
+ }
1353
+
1344
1354
  /** The next entry, now, on a channel playing a list of files. */
1345
1355
  skip(id: string): boolean {
1346
1356
  return this.open.get(id)?.skip() ?? false;