nixamp 0.30.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.
Files changed (41) hide show
  1. package/dist/channels.d.ts +12 -0
  2. package/dist/channels.js +8 -0
  3. package/dist/mcp.d.ts +3 -0
  4. package/dist/mcp.js +7 -1
  5. package/dist/playlist-input.d.ts +10 -0
  6. package/dist/playlist-input.js +9 -0
  7. package/dist/station-mcp.d.ts +294 -0
  8. package/dist/station-mcp.js +173 -0
  9. package/dist/station-page.d.ts +1 -1
  10. package/dist/station-page.js +2 -1
  11. package/dist/station-routes.js +2 -1
  12. package/package.json +1 -1
  13. package/src/channels.ts +10 -0
  14. package/src/mcp.ts +9 -1
  15. package/src/playlist-input.ts +9 -0
  16. package/src/station-mcp.ts +183 -0
  17. package/src/station-page.ts +2 -1
  18. package/src/station-routes.ts +2 -1
  19. package/web/dist/assets/{ar-CTUDxy1J.js → ar-ClCIWtVb.js} +1 -1
  20. package/web/dist/assets/{cs-m4JTaO3o.js → cs-CVG4U823.js} +1 -1
  21. package/web/dist/assets/{da-DHnJElYB.js → da-igdiQUk8.js} +1 -1
  22. package/web/dist/assets/{de-DkxPohnf.js → de-CHPsc6lc.js} +1 -1
  23. package/web/dist/assets/{es-oY_G83d7.js → es-AvbMspQX.js} +1 -1
  24. package/web/dist/assets/{fi-fCO8XRdg.js → fi-5JGUOm3W.js} +1 -1
  25. package/web/dist/assets/{fr-CSupf-IN.js → fr-YfMwk1FR.js} +1 -1
  26. package/web/dist/assets/{hi-Dve7PJsC.js → hi-BUZuQTkp.js} +1 -1
  27. package/web/dist/assets/{hls-3VKVEQE3-D5-AkKSR.js → hls-3VKVEQE3-BCXawNaW.js} +1 -1
  28. package/web/dist/assets/{hu-D1KYamzV.js → hu-C7on-yUl.js} +1 -1
  29. package/web/dist/assets/{id-Cofp_4aQ.js → id-pIrzVeOi.js} +1 -1
  30. package/web/dist/assets/{index-7mCQZGNF.js → index-CDBIr8Tz.js} +2 -2
  31. package/web/dist/assets/{it-62iXQWQD.js → it-Bl9xk5B4.js} +1 -1
  32. package/web/dist/assets/{mpegts-BT5ESacM.js → mpegts-63ylMmge.js} +1 -1
  33. package/web/dist/assets/{mpegts-LO6RVLD6-BgHd8PFT.js → mpegts-LO6RVLD6-DBakOXU5.js} +1 -1
  34. package/web/dist/assets/{nl-CzRRG-CN.js → nl-b5BUih3i.js} +1 -1
  35. package/web/dist/assets/{ru-OykC1LJQ.js → ru-BlAkbEPp.js} +1 -1
  36. package/web/dist/assets/{sv-B8FXBDHm.js → sv-BEKvqELG.js} +1 -1
  37. package/web/dist/assets/{uk-DhAJBjwH.js → uk-JY8Dh56p.js} +1 -1
  38. package/web/dist/assets/{vi-CdBSRReC.js → vi-DbtxuQPL.js} +1 -1
  39. package/web/dist/assets/{zh-wGWNyBEZ.js → zh-4x0DI5jO.js} +1 -1
  40. package/web/dist/index.html +1 -1
  41. package/web/dist/sw.js +22 -22
@@ -232,6 +232,12 @@ export declare class Channel {
232
232
  private playlistInput;
233
233
  /** On to the next entry of a list of files. False when this is not one. */
234
234
  skip(): boolean;
235
+ /** An entry's tags, as read when it was put on. */
236
+ tagsOf(file: string): {
237
+ title: string;
238
+ artist: string;
239
+ album: string;
240
+ } | undefined;
235
241
  private failures;
236
242
  private timer;
237
243
  /** Fires when a pulled source has said nothing for STALL. */
@@ -497,6 +503,12 @@ export declare class Channels {
497
503
  setThrough(provider: ThroughProvider | null): void;
498
504
  /** The kind of a channel, for a relay to say what it is carrying. */
499
505
  kindOf(id: string): "audio" | "video" | undefined;
506
+ /** An entry's tags on a channel playing a list of files. */
507
+ tagsOf(id: string, file: string): {
508
+ title: string;
509
+ artist: string;
510
+ album: string;
511
+ } | undefined;
500
512
  /** The next entry, now, on a channel playing a list of files. */
501
513
  skip(id: string): boolean;
502
514
  stop(id: string): boolean;
package/dist/channels.js CHANGED
@@ -141,6 +141,10 @@ export class Channel {
141
141
  skip() {
142
142
  return this.playlistInput?.skip() ?? false;
143
143
  }
144
+ /** An entry's tags, as read when it was put on. */
145
+ tagsOf(file) {
146
+ return this.playlistInput?.tags.get(file);
147
+ }
144
148
  failures = 0;
145
149
  timer = null;
146
150
  /** Fires when a pulled source has said nothing for STALL. */
@@ -1168,6 +1172,10 @@ export class Channels {
1168
1172
  kindOf(id) {
1169
1173
  return this.open.get(id)?.info.kind;
1170
1174
  }
1175
+ /** An entry's tags on a channel playing a list of files. */
1176
+ tagsOf(id, file) {
1177
+ return this.open.get(id)?.tagsOf(file);
1178
+ }
1171
1179
  /** The next entry, now, on a channel playing a list of files. */
1172
1180
  skip(id) {
1173
1181
  return this.open.get(id)?.skip() ?? false;
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { wavOf, type Window } from "./transcribe.ts";
2
2
  import { type Described } from "./media-local.ts";
3
+ import { type StationToolOptions } from "./station-mcp.ts";
3
4
  export declare const PROTOCOL_VERSION = "2025-06-18";
4
5
  interface Request {
5
6
  jsonrpc: "2.0";
@@ -15,6 +16,8 @@ interface ToolDefinition {
15
16
  export declare const TOOLS: ToolDefinition[];
16
17
  export interface McpOptions {
17
18
  fetcher?: typeof fetch;
19
+ /** Where stations are asked; the tests hand in a local daemon. */
20
+ stations?: StationToolOptions;
18
21
  /** Injected by the tests; the session on disk otherwise. */
19
22
  session?: {
20
23
  site: string;
package/dist/mcp.js CHANGED
@@ -30,6 +30,7 @@ import { describeRecord } from "./hash.js";
30
30
  import { describeFile, keepFile } from "./media-local.js";
31
31
  import { fileFingerprint, idFrom, languageCode, mediaOfUrl, transcriptIdOf } from "./transcripts.js";
32
32
  import { personaLines, readPersona, readVoices, writePersona } from "./profile.js";
33
+ import { STATION_TOOLS, callStationTool, isStationTool } from "./station-mcp.js";
33
34
  export const PROTOCOL_VERSION = "2025-06-18";
34
35
  const STRING = { type: "string" };
35
36
  export const TOOLS = [
@@ -215,6 +216,7 @@ export const TOOLS = [
215
216
  required: ["server"],
216
217
  },
217
218
  },
219
+ ...STATION_TOOLS,
218
220
  ];
219
221
  function said(line) {
220
222
  return `${line.createdAt} ${line.handle}: ${line.body}`;
@@ -246,6 +248,10 @@ function describe(row) {
246
248
  * pipe, and so the same call is reachable from anywhere else that wants it.
247
249
  */
248
250
  export async function callTool(name, args, options = {}) {
251
+ // Stations first: on this machine they need the daemon, not a sign-in.
252
+ if (isStationTool(name)) {
253
+ return callStationTool(name, args, { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) });
254
+ }
249
255
  const session = options.session === undefined ? readSession() : options.session;
250
256
  if (!session) {
251
257
  return failed("This machine is not signed in to nixamp. Run `nixamp login`, or set NIXAMP_TOKEN.");
@@ -551,7 +557,7 @@ export async function handleMessage(message, options = {}) {
551
557
  return reply({
552
558
  protocolVersion: PROTOCOL_VERSION,
553
559
  capabilities: { tools: { listChanged: false } },
554
- serverInfo: { name: "nixamp", title: "nixamp: watch parties, rooms and transcripts", version: "2" },
560
+ serverInfo: { name: "nixamp", title: "nixamp: stations, watch parties, rooms and transcripts", version: "2" },
555
561
  instructions: "Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
556
562
  });
557
563
  }
@@ -51,6 +51,16 @@ export declare class PlaylistInputStream {
51
51
  private entries;
52
52
  error: Error | null;
53
53
  complete: boolean;
54
+ /**
55
+ * What each entry's own tags say, read when it was probed to be played --
56
+ * so a station can say what is on before the library has got round to
57
+ * reading the file, which on a big library is a while.
58
+ */
59
+ readonly tags: Map<string, {
60
+ title: string;
61
+ artist: string;
62
+ album: string;
63
+ }>;
54
64
  constructor(options: PlaylistInput);
55
65
  /** Every entry that has started, in the order it started, with where it sits in the stream. */
56
66
  get history(): ReadonlyArray<{
@@ -16,6 +16,12 @@ export class PlaylistInputStream {
16
16
  entries = [];
17
17
  error = null;
18
18
  complete = false;
19
+ /**
20
+ * What each entry's own tags say, read when it was probed to be played --
21
+ * so a station can say what is on before the library has got round to
22
+ * reading the file, which on a big library is a while.
23
+ */
24
+ tags = new Map();
19
25
  constructor(options) {
20
26
  this.options = options;
21
27
  }
@@ -102,6 +108,9 @@ export class PlaylistInputStream {
102
108
  return;
103
109
  if (!codecs.audio && !codecs.video)
104
110
  throw new Error(`Cannot decode playlist entry ${at + 1}`);
111
+ if (codecs.tags && (codecs.tags.title || codecs.tags.artist)) {
112
+ this.tags.set(file, { title: codecs.tags.title ?? "", artist: codecs.tags.artist ?? "", album: codecs.tags.album ?? "" });
113
+ }
105
114
  const duration = (codecs.duration ?? 0) - position;
106
115
  if (duration <= 0)
107
116
  throw new Error(`No playable duration for playlist entry ${at + 1}`);
@@ -0,0 +1,294 @@
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
+ idempotencyKey?: undefined;
12
+ name?: undefined;
13
+ description?: undefined;
14
+ folder?: undefined;
15
+ items?: undefined;
16
+ shuffle?: undefined;
17
+ repeat?: undefined;
18
+ bitrate?: undefined;
19
+ action?: undefined;
20
+ visibility?: undefined;
21
+ listed?: undefined;
22
+ basis?: undefined;
23
+ note?: undefined;
24
+ id?: undefined;
25
+ private?: undefined;
26
+ };
27
+ required?: undefined;
28
+ };
29
+ } | {
30
+ name: string;
31
+ description: string;
32
+ inputSchema: {
33
+ type: string;
34
+ properties: {
35
+ server: {
36
+ type: "string";
37
+ description: string;
38
+ };
39
+ id: {
40
+ type: "string";
41
+ description: string;
42
+ };
43
+ idempotencyKey?: undefined;
44
+ name?: undefined;
45
+ description?: undefined;
46
+ folder?: undefined;
47
+ items?: undefined;
48
+ shuffle?: undefined;
49
+ repeat?: undefined;
50
+ bitrate?: undefined;
51
+ action?: undefined;
52
+ visibility?: undefined;
53
+ listed?: undefined;
54
+ basis?: undefined;
55
+ note?: undefined;
56
+ private?: undefined;
57
+ };
58
+ required: string[];
59
+ };
60
+ } | {
61
+ name: string;
62
+ description: string;
63
+ inputSchema: {
64
+ type: string;
65
+ properties: {
66
+ server: {
67
+ type: "string";
68
+ description: string;
69
+ };
70
+ name: {
71
+ readonly type: "string";
72
+ };
73
+ folder: {
74
+ type: "string";
75
+ description: string;
76
+ };
77
+ items: {
78
+ type: string;
79
+ items: {
80
+ type: string;
81
+ };
82
+ description: string;
83
+ };
84
+ description: {
85
+ readonly type: "string";
86
+ };
87
+ shuffle: {
88
+ type: string;
89
+ };
90
+ repeat: {
91
+ type: string;
92
+ description: string;
93
+ };
94
+ bitrate: {
95
+ type: string;
96
+ description: string;
97
+ };
98
+ idempotencyKey: {
99
+ type: "string";
100
+ description: string;
101
+ };
102
+ action?: undefined;
103
+ visibility?: undefined;
104
+ listed?: undefined;
105
+ basis?: undefined;
106
+ note?: undefined;
107
+ id?: undefined;
108
+ private?: undefined;
109
+ };
110
+ required: string[];
111
+ };
112
+ } | {
113
+ name: string;
114
+ description: string;
115
+ inputSchema: {
116
+ type: string;
117
+ properties: {
118
+ idempotencyKey?: undefined;
119
+ server: {
120
+ type: "string";
121
+ description: string;
122
+ };
123
+ id: {
124
+ type: "string";
125
+ description: string;
126
+ };
127
+ name: {
128
+ readonly type: "string";
129
+ };
130
+ description: {
131
+ readonly type: "string";
132
+ };
133
+ folder: {
134
+ readonly type: "string";
135
+ };
136
+ items: {
137
+ description?: undefined;
138
+ type: string;
139
+ items: {
140
+ type: string;
141
+ };
142
+ };
143
+ shuffle: {
144
+ type: string;
145
+ };
146
+ repeat: {
147
+ description?: undefined;
148
+ type: string;
149
+ };
150
+ bitrate: {
151
+ description?: undefined;
152
+ type: string;
153
+ };
154
+ action?: undefined;
155
+ visibility?: undefined;
156
+ listed?: undefined;
157
+ basis?: undefined;
158
+ note?: undefined;
159
+ private?: undefined;
160
+ };
161
+ required: string[];
162
+ };
163
+ } | {
164
+ name: string;
165
+ description: string;
166
+ inputSchema: {
167
+ type: string;
168
+ properties: {
169
+ idempotencyKey?: undefined;
170
+ name?: undefined;
171
+ description?: undefined;
172
+ folder?: undefined;
173
+ items?: undefined;
174
+ shuffle?: undefined;
175
+ repeat?: undefined;
176
+ bitrate?: undefined;
177
+ server: {
178
+ type: "string";
179
+ description: string;
180
+ };
181
+ id: {
182
+ type: "string";
183
+ description: string;
184
+ };
185
+ action: {
186
+ type: string;
187
+ enum: string[];
188
+ };
189
+ visibility?: undefined;
190
+ listed?: undefined;
191
+ basis?: undefined;
192
+ note?: undefined;
193
+ private?: undefined;
194
+ };
195
+ required: string[];
196
+ };
197
+ } | {
198
+ name: string;
199
+ description: string;
200
+ inputSchema: {
201
+ type: string;
202
+ properties: {
203
+ idempotencyKey?: undefined;
204
+ name?: undefined;
205
+ description?: undefined;
206
+ folder?: undefined;
207
+ items?: undefined;
208
+ shuffle?: undefined;
209
+ repeat?: undefined;
210
+ bitrate?: undefined;
211
+ action?: undefined;
212
+ server: {
213
+ type: "string";
214
+ description: string;
215
+ };
216
+ id: {
217
+ type: "string";
218
+ description: string;
219
+ };
220
+ visibility: {
221
+ type: string;
222
+ enum: string[];
223
+ };
224
+ listed: {
225
+ type: string;
226
+ description: string;
227
+ };
228
+ basis: {
229
+ type: string;
230
+ enum: string[];
231
+ };
232
+ note: {
233
+ readonly type: "string";
234
+ };
235
+ private?: undefined;
236
+ };
237
+ required: string[];
238
+ };
239
+ } | {
240
+ name: string;
241
+ description: string;
242
+ inputSchema: {
243
+ type: string;
244
+ properties: {
245
+ idempotencyKey?: undefined;
246
+ name?: undefined;
247
+ description?: undefined;
248
+ folder?: undefined;
249
+ items?: undefined;
250
+ shuffle?: undefined;
251
+ repeat?: undefined;
252
+ bitrate?: undefined;
253
+ action?: undefined;
254
+ visibility?: undefined;
255
+ listed?: undefined;
256
+ basis?: undefined;
257
+ note?: undefined;
258
+ server: {
259
+ type: "string";
260
+ description: string;
261
+ };
262
+ id: {
263
+ type: "string";
264
+ description: string;
265
+ };
266
+ private: {
267
+ type: string;
268
+ };
269
+ };
270
+ required: string[];
271
+ };
272
+ })[];
273
+ interface Result {
274
+ content: {
275
+ type: "text";
276
+ text: string;
277
+ }[];
278
+ isError?: boolean;
279
+ }
280
+ export interface StationToolOptions {
281
+ fetcher?: typeof fetch;
282
+ /** Where the local daemon is and its key; the tests hand these in. */
283
+ local?: {
284
+ url: string;
285
+ key: string | null;
286
+ } | null;
287
+ session?: {
288
+ site: string;
289
+ token: string;
290
+ } | null;
291
+ }
292
+ export declare function isStationTool(name: string): boolean;
293
+ export declare function callStationTool(name: string, args: Record<string, unknown>, options?: StationToolOptions): Promise<Result>;
294
+ export {};
@@ -0,0 +1,173 @@
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) or from 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
+ description: STRING,
38
+ shuffle: { type: "boolean" },
39
+ repeat: { type: "boolean", description: "Round and round (default) or once." },
40
+ bitrate: { type: "integer", description: "kbps: 64, 96, 128 (default), 160, 192, 256 or 320." },
41
+ idempotencyKey: { ...STRING, description: "Asking again with the same key returns the same station." },
42
+ },
43
+ required: ["name"],
44
+ },
45
+ },
46
+ {
47
+ name: "station_update",
48
+ description: "Rename, describe, change files or rotation. Edits to the files land on the next track.",
49
+ inputSchema: {
50
+ type: "object",
51
+ 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" } },
52
+ required: ["id"],
53
+ },
54
+ },
55
+ {
56
+ name: "station_control",
57
+ description: "start, stop or skip a station, or retry the files it left out. Starting does not publish.",
58
+ inputSchema: {
59
+ type: "object",
60
+ properties: { server: SERVER, id: ID, action: { type: "string", enum: ["start", "stop", "skip", "retry"] } },
61
+ required: ["id", "action"],
62
+ },
63
+ },
64
+ {
65
+ name: "station_publish",
66
+ 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.",
67
+ inputSchema: {
68
+ type: "object",
69
+ properties: {
70
+ server: SERVER,
71
+ id: ID,
72
+ visibility: { type: "string", enum: ["unlisted", "public"] },
73
+ listed: { type: "boolean", description: "In the directory (public only). Default true for public." },
74
+ basis: { type: "string", enum: ["owned", "permission", "licensed", "public-domain"] },
75
+ note: STRING,
76
+ },
77
+ required: ["id", "visibility"],
78
+ },
79
+ },
80
+ {
81
+ name: "station_unpublish",
82
+ description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
83
+ inputSchema: { type: "object", properties: { server: SERVER, id: ID, private: { type: "boolean" } }, required: ["id"] },
84
+ },
85
+ ];
86
+ const say = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...(isError ? { isError: true } : {}) });
87
+ export function isStationTool(name) {
88
+ return STATION_TOOLS.some((tool) => tool.name === name);
89
+ }
90
+ function line(station) {
91
+ const links = (station["links"] ?? {});
92
+ const problem = station["error"] ? `; problem: ${String(station["error"])}` : "";
93
+ const link = station["visibility"] !== "private" && links["page"] ? `\n listen: ${links["page"]}` : "";
94
+ return `${String(station["name"])} [${String(station["id"])}] ${String(station["state"])}, ${String(station["visibility"])}${station["listed"] ? " and listed" : ""}${problem}${link}`;
95
+ }
96
+ export async function callStationTool(name, args, options = {}) {
97
+ const send = options.fetcher ?? fetch;
98
+ const server = typeof args["server"] === "string" ? args["server"].replace(/\/+$/, "") : "";
99
+ let base = server;
100
+ const headers = { "content-type": "application/json" };
101
+ if (server === "") {
102
+ const local = options.local === undefined ? (() => { const state = readState(); return state ? { url: daemonUrl(state), key: state.key } : null; })() : options.local;
103
+ if (!local)
104
+ return say("No nixamp daemon runs on this machine. Start one (nixamp daemon start ~/Music) or pass `server`.", true);
105
+ base = local.url;
106
+ if (local.key)
107
+ headers[KEY_HEADER] = local.key;
108
+ }
109
+ else {
110
+ const session = options.session === undefined ? readSession() : options.session;
111
+ if (!session)
112
+ return say("Another server's stations need this machine signed in as its owner: nixamp login.", true);
113
+ headers["authorization"] = `Bearer ${session.token}`;
114
+ }
115
+ const id = typeof args["id"] === "string" ? args["id"] : "";
116
+ const call = async (method, path, body) => {
117
+ const response = await send(`${base}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
118
+ const parsed = (await response.json().catch(() => ({})));
119
+ return { ok: response.ok, body: parsed };
120
+ };
121
+ const refused = (body) => say(`${String(body["error"] ?? "refused")} (${String(body["code"] ?? "error")})`, true);
122
+ const pick = (keys) => Object.fromEntries(keys.filter((key) => args[key] !== undefined).map((key) => [key, args[key]]));
123
+ try {
124
+ if (name === "station_list") {
125
+ const answer = await call("GET", "/api/v1/stations?limit=200");
126
+ if (!answer.ok)
127
+ return refused(answer.body);
128
+ const all = answer.body["stations"] ?? [];
129
+ return say(all.length === 0 ? "No stations on this server yet." : all.map(line).join("\n"));
130
+ }
131
+ if (!/^[a-z0-9]{10}$/.test(id) && name !== "station_create")
132
+ return say("Which station? Pass its id from station_list.", true);
133
+ if (name === "station_get") {
134
+ const answer = await call("GET", `/api/v1/stations/${id}`);
135
+ return answer.ok ? say(JSON.stringify(answer.body["station"], null, 2)) : refused(answer.body);
136
+ }
137
+ if (name === "station_create") {
138
+ const answer = await call("POST", "/api/v1/stations", pick(["name", "folder", "items", "description", "shuffle", "repeat", "bitrate", "idempotencyKey"]));
139
+ return answer.ok ? say(`Made, private and stopped: ${line(answer.body["station"])}`) : refused(answer.body);
140
+ }
141
+ if (name === "station_update") {
142
+ const answer = await call("PATCH", `/api/v1/stations/${id}`, pick(["name", "description", "folder", "items", "shuffle", "repeat", "bitrate"]));
143
+ return answer.ok ? say(line(answer.body["station"])) : refused(answer.body);
144
+ }
145
+ if (name === "station_control") {
146
+ const action = String(args["action"] ?? "");
147
+ if (!["start", "stop", "skip", "retry"].includes(action))
148
+ return say("action is start, stop, skip or retry", true);
149
+ const answer = await call("POST", `/api/v1/stations/${id}/${action}`, {});
150
+ return answer.ok ? say(line(answer.body["station"])) : refused(answer.body);
151
+ }
152
+ if (name === "station_publish") {
153
+ const visibility = args["visibility"] === "public" ? "public" : "unlisted";
154
+ const answer = await call("POST", `/api/v1/stations/${id}/access`, {
155
+ visibility,
156
+ listed: visibility === "public" && args["listed"] !== false,
157
+ ...(typeof args["basis"] === "string" ? { rights: { basis: args["basis"], note: typeof args["note"] === "string" ? args["note"] : "" } } : {}),
158
+ });
159
+ return answer.ok ? say(line(answer.body["station"])) : refused(answer.body);
160
+ }
161
+ if (name === "station_unpublish") {
162
+ const answer = await call("POST", `/api/v1/stations/${id}/access`, args["private"] === true ? { visibility: "private" } : { listed: false });
163
+ if (!answer.ok)
164
+ return refused(answer.body);
165
+ const dropped = Number(answer.body["dropped"] ?? 0);
166
+ return say(`${line(answer.body["station"])}${dropped ? `\n${dropped} listener(s) disconnected.` : ""}`);
167
+ }
168
+ }
169
+ catch (error) {
170
+ return say(`Could not reach ${base}: ${error.message}`, true);
171
+ }
172
+ return say(`No such tool: ${name}`, true);
173
+ }
@@ -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'",