nixamp 0.36.4 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/ai-keys.d.ts +9 -0
  2. package/dist/ai-keys.js +46 -0
  3. package/dist/ai-radio.d.ts +148 -0
  4. package/dist/ai-radio.js +477 -0
  5. package/dist/ai-routes.d.ts +31 -0
  6. package/dist/ai-routes.js +191 -0
  7. package/dist/ai-text.d.ts +23 -0
  8. package/dist/ai-text.js +102 -0
  9. package/dist/library.d.ts +2 -0
  10. package/dist/library.js +1 -0
  11. package/dist/listener-shares.d.ts +136 -0
  12. package/dist/listener-shares.js +486 -0
  13. package/dist/playlist-input.d.ts +1 -1
  14. package/dist/playlist-input.js +2 -1
  15. package/dist/server.d.ts +8 -0
  16. package/dist/server.js +62 -1
  17. package/dist/station-cli.js +179 -0
  18. package/dist/station-guide.d.ts +4 -1
  19. package/dist/station-guide.js +118 -21
  20. package/dist/station-mcp.d.ts +291 -20
  21. package/dist/station-mcp.js +66 -0
  22. package/dist/station-routes.d.ts +4 -0
  23. package/dist/station-routes.js +8 -1
  24. package/dist/station-runner.js +4 -2
  25. package/dist/station-usage.d.ts +1 -1
  26. package/dist/station-usage.js +21 -1
  27. package/package.json +1 -1
  28. package/src/ai-keys.ts +49 -0
  29. package/src/ai-radio.ts +536 -0
  30. package/src/ai-routes.ts +220 -0
  31. package/src/ai-text.ts +117 -0
  32. package/src/library.ts +3 -0
  33. package/src/listener-shares.ts +528 -0
  34. package/src/playlist-input.ts +3 -2
  35. package/src/server.ts +69 -1
  36. package/src/station-cli.ts +165 -0
  37. package/src/station-guide.ts +122 -21
  38. package/src/station-mcp.ts +62 -0
  39. package/src/station-routes.ts +11 -2
  40. package/src/station-runner.ts +4 -2
  41. package/src/station-usage.ts +21 -1
  42. package/web/dist/assets/{ar-B9jc6q-w.js → ar-CO5BKvO0.js} +1 -1
  43. package/web/dist/assets/{cs-lU25OfSM.js → cs-BUpUmRvP.js} +1 -1
  44. package/web/dist/assets/{da-CAgdgQoL.js → da-zX4iMHja.js} +1 -1
  45. package/web/dist/assets/{de-CiusGizF.js → de-Tn7jOSGa.js} +1 -1
  46. package/web/dist/assets/{es-DVxl-KYi.js → es-7u7Y8_4k.js} +1 -1
  47. package/web/dist/assets/{fi-C1-78Aib.js → fi-V6JD2KmM.js} +1 -1
  48. package/web/dist/assets/{fr-BcAXoFCf.js → fr-Cywrbe0W.js} +1 -1
  49. package/web/dist/assets/{hi-DXdG5DSQ.js → hi-Bi1gVC9W.js} +1 -1
  50. package/web/dist/assets/{hls-3VKVEQE3-C3dxJvEy.js → hls-3VKVEQE3-DiRfULQE.js} +1 -1
  51. package/web/dist/assets/{hu-qrAMY3zu.js → hu-R_kphMiO.js} +1 -1
  52. package/web/dist/assets/{id-Cgtr-WML.js → id-DsjVfoDE.js} +1 -1
  53. package/web/dist/assets/{it-kuAXXts8.js → it-D_Jw0vo4.js} +1 -1
  54. package/web/dist/assets/{main-C5IsPzDk.js → main-26sEJB31.js} +2 -2
  55. package/web/dist/assets/{mpegts-BWzEpcbk.js → mpegts-DCmyLJq-.js} +1 -1
  56. package/web/dist/assets/{mpegts-LO6RVLD6-CYjdm0vd.js → mpegts-LO6RVLD6-BCbjcAdN.js} +1 -1
  57. package/web/dist/assets/{nl-CEXpOYtY.js → nl-D_AiIeeo.js} +1 -1
  58. package/web/dist/assets/{ru-v7b_XixQ.js → ru-BalU1IsS.js} +1 -1
  59. package/web/dist/assets/{sv-CJK5wtih.js → sv-CRhToKKj.js} +1 -1
  60. package/web/dist/assets/{uk-qDSjg5zB.js → uk-BzTMYhrk.js} +1 -1
  61. package/web/dist/assets/{vi-kckrzXyJ.js → vi-DkoVH56o.js} +1 -1
  62. package/web/dist/assets/{zh-Cbq-3ikH.js → zh-DHjjJx8Z.js} +1 -1
  63. package/web/dist/index.html +1 -1
  64. package/web/dist/sw.js +22 -22
@@ -87,6 +87,7 @@ export async function station(argv) {
87
87
  ` state ${String(station["state"])} (wanted ${String(station["desired"])})`,
88
88
  ` visibility ${String(station["visibility"])}${station["listed"] ? ", listed in the directory" : ""}`,
89
89
  ` rotation ${station["shuffle"] ? "shuffle" : "in order"}${station["repeat"] ? ", repeating" : ", once"}; ${station["items"]?.length ?? 0} files at ${String(station["bitrate"])} kbps`,
90
+ ` breaks ${Number(station["breaksEvery"] ?? 0) > 0 ? `a spot every ${Math.round(Number(station["breaksEvery"]) / 60)} min, between entries` : "none"}`,
90
91
  ...(station["error"] ? [` problem ${String(station["error"])}`] : []),
91
92
  ...(station["excluded"]?.length ? [` left out ${station["excluded"].length} files (nixamp station retry)`] : []),
92
93
  ...(station["visibility"] !== "private" && links["page"] ? [` listen ${links["page"]}`] : []),
@@ -127,6 +128,7 @@ export async function station(argv) {
127
128
  shuffle: has(argv, "--shuffle"),
128
129
  repeat: !has(argv, "--no-repeat"),
129
130
  ...(bitrate ? { bitrate: Number(bitrate) } : {}),
131
+ ...(flag(argv, "--breaks") ? { breaksEvery: Number(flag(argv, "--breaks")) } : {}),
130
132
  });
131
133
  return show(answer, (body) => `Made (private, stopped):\n${describe(body["station"])}`);
132
134
  }
@@ -146,6 +148,17 @@ export async function station(argv) {
146
148
  return 2;
147
149
  return show(await call("POST", `/api/v1/stations/${station}/${command}`, {}), (body) => describe(body["station"]));
148
150
  }
151
+ case "breaks": {
152
+ const station = needId();
153
+ if (!station)
154
+ return 2;
155
+ const seconds = argv.includes("--off") ? 0 : Number(argv[argv.indexOf(station) + 1]);
156
+ if (!Number.isFinite(seconds)) {
157
+ console.error("nixamp station breaks ID SECONDS (or --off)");
158
+ return 2;
159
+ }
160
+ return show(await call("PATCH", `/api/v1/stations/${station}`, { breaksEvery: seconds }), (body) => describe(body["station"]));
161
+ }
149
162
  case "publish": {
150
163
  const station = needId();
151
164
  if (!station)
@@ -230,6 +243,10 @@ export async function station(argv) {
230
243
  ].join("\n");
231
244
  });
232
245
  }
246
+ case "ai":
247
+ return await aiCommand(argv.slice(1), call, show, json);
248
+ case "shares":
249
+ return await sharesCommand(argv.slice(1), call, show);
233
250
  case "delete": {
234
251
  const station = needId();
235
252
  if (!station)
@@ -249,3 +266,165 @@ export async function station(argv) {
249
266
  throw error;
250
267
  }
251
268
  }
269
+ async function readStdin() {
270
+ let text = "";
271
+ for await (const chunk of process.stdin)
272
+ text += String(chunk);
273
+ return text.trim();
274
+ }
275
+ /** `nixamp station ai ...` */
276
+ async function aiCommand(argv, call, show, json) {
277
+ const [sub, key] = argv;
278
+ const describe = (one) => [
279
+ `${String(one["name"])} [${String(one["key"])}]${one["enabled"] ? "" : " (off)"}`,
280
+ ` style ${String(one["style"])}`,
281
+ ` tracks ${String(one["tracks"] ?? one["tracks"]?.length ?? 0)} of ${String(one["trackSeconds"])} s, ${String(one["perDay"])} a day${one["vocals"] ? ", with vocals" : ""}`,
282
+ ` station ${one["stationId"] ? String(one["stationId"]) : "made with its first track"}${one["publish"] ? ", public on the guide" : ", unlisted"}`,
283
+ ...(one["lastError"] ? [` problem ${String(one["lastError"])}`] : []),
284
+ ].join("\n");
285
+ switch (sub) {
286
+ case undefined:
287
+ case "status":
288
+ case "list":
289
+ return show(await call("GET", "/api/v1/stations/ai"), (body) => {
290
+ const keys = (body["keys"] ?? {});
291
+ const stations = body["stations"] ?? [];
292
+ return [
293
+ `composer ${String(body["composer"] ?? "none")}; briefs by ${String(body["writer"])}`,
294
+ `this month ${String(body["secondsThisMonth"])} of ${Number(body["monthlySeconds"]) === 0 ? "unlimited" : String(body["monthlySeconds"])} s${body["busy"] ? `; composing for ${String(body["busy"])}` : ""}`,
295
+ ...(body["paused"] ? [`paused ${String(body["paused"])}`] : []),
296
+ `keys ${Object.entries(keys).map(([name, set]) => `${name} ${set ? "set" : "-"}`).join(", ")}`,
297
+ "",
298
+ ...(stations.length ? stations.map(describe) : [`No AI stations yet. nixamp station ai create --preset ${(body["presets"] ?? ["lofi"])[0]}`]),
299
+ ].join("\n");
300
+ });
301
+ case "create": {
302
+ const seconds = flag(argv, "--seconds");
303
+ const perDay = flag(argv, "--per-day");
304
+ const body = {
305
+ ...(flag(argv, "--preset") ? { preset: flag(argv, "--preset") } : {}),
306
+ ...(flag(argv, "--name") ? { name: flag(argv, "--name") } : {}),
307
+ ...(flag(argv, "--style") ? { style: flag(argv, "--style") } : {}),
308
+ ...(flag(argv, "--description") ? { description: flag(argv, "--description") } : {}),
309
+ ...(has(argv, "--vocals") ? { vocals: true } : has(argv, "--instrumental") ? { vocals: false } : {}),
310
+ ...(seconds ? { trackSeconds: Number(seconds) } : {}),
311
+ ...(perDay ? { perDay: Number(perDay) } : {}),
312
+ publish: !has(argv, "--unlisted"),
313
+ };
314
+ return show(await call("POST", "/api/v1/stations/ai/stations", body), (answer) => `Made. Its first tracks are being composed now; the station appears with the first.\n${describe(answer["station"])}`);
315
+ }
316
+ case "generate": {
317
+ if (!key) {
318
+ console.error("nixamp station ai generate needs the AI station's key (nixamp station ai)");
319
+ return 2;
320
+ }
321
+ if (!json)
322
+ console.log("Composing; this takes a minute or two…");
323
+ return show(await call("POST", `/api/v1/stations/ai/stations/${key}/generate`, {}), (answer) => {
324
+ const track = answer["track"];
325
+ return `"${String(track["title"])}" (${String(track["seconds"])} s, ${String(track["made"])}) is on the station.`;
326
+ });
327
+ }
328
+ case "set": {
329
+ if (!key) {
330
+ console.error("nixamp station ai set needs the AI station's key");
331
+ return 2;
332
+ }
333
+ const body = {
334
+ ...(flag(argv, "--style") ? { style: flag(argv, "--style") } : {}),
335
+ ...(flag(argv, "--name") ? { name: flag(argv, "--name") } : {}),
336
+ ...(flag(argv, "--per-day") ? { perDay: Number(flag(argv, "--per-day")) } : {}),
337
+ ...(flag(argv, "--seconds") ? { trackSeconds: Number(flag(argv, "--seconds")) } : {}),
338
+ ...(has(argv, "--off") ? { enabled: false } : has(argv, "--on") ? { enabled: true } : {}),
339
+ ...(has(argv, "--vocals") ? { vocals: true } : has(argv, "--instrumental") ? { vocals: false } : {}),
340
+ };
341
+ return show(await call("PATCH", `/api/v1/stations/ai/stations/${key}`, body), (answer) => describe({ ...answer["station"], tracks: answer["station"]["tracks"].length }));
342
+ }
343
+ case "remove": {
344
+ if (!key) {
345
+ console.error("nixamp station ai remove needs the AI station's key");
346
+ return 2;
347
+ }
348
+ const files = has(argv, "--files");
349
+ return show(await call("DELETE", `/api/v1/stations/ai/stations/${key}${files ? "?files=1" : ""}`), () => files ? "Removed, with its station and its music." : "Removed. Its station and its music stay; nothing new will be composed.");
350
+ }
351
+ case "budget":
352
+ return show(await call("POST", "/api/v1/stations/ai", { monthlySeconds: Number(key) }), (answer) => {
353
+ const settings = answer["settings"];
354
+ return `AI music: at most ${Number(settings["monthlySeconds"]) === 0 ? "unlimited" : `${String(settings["monthlySeconds"])} s`} a month.`;
355
+ });
356
+ case "resume":
357
+ return show(await call("POST", "/api/v1/stations/ai", { resume: true }), () => "Composing again.");
358
+ case "key": {
359
+ if (!key) {
360
+ console.error("nixamp station ai key needs a name, e.g. ELEVENLABS_API_KEY, and the value on stdin");
361
+ return 2;
362
+ }
363
+ const value = has(argv, "--clear") ? "" : await readStdin();
364
+ return show(await call("POST", "/api/v1/stations/ai/keys", { name: key, value }), (answer) => `Keys: ${Object.entries((answer["keys"] ?? {})).map(([name, set]) => `${name} ${set ? "set" : "-"}`).join(", ")}`);
365
+ }
366
+ default:
367
+ console.error(`nixamp station ai: no command "${sub}".\n\n${USAGE}`);
368
+ return 2;
369
+ }
370
+ }
371
+ /** `nixamp station shares ...` */
372
+ async function sharesCommand(argv, call, show) {
373
+ const [sub, id] = argv;
374
+ const line = (one) => ` ${String(one["id"])} ${String(one["state"]).padEnd(9)} ${String(one["genre"] || "?").padEnd(14)} ${String(one["title"] || one["name"])}${one["artist"] ? ` — ${String(one["artist"])}` : ""} [${String(one["rights"]?.["basis"] ?? "")}]${one["reason"] ? `\n ${String(one["reason"])}` : ""}`;
375
+ const summary = (body) => {
376
+ const setting = (body["setting"] ?? {});
377
+ const shares = body["shares"] ?? [];
378
+ return [
379
+ `shares ${setting["open"] ? "open" : "closed"}; moderation ${String(setting["moderation"])}${setting["model"] ? "" : " (no AI key: everything waits for you)"}; ${setting["publish"] ? "public on the guide" : "unlisted"}; ${Math.round(Number(setting["maxBytes"]) / 1048576)} MB, ${String(setting["perHour"])} an hour each`,
380
+ ...(shares.length ? ["", ...shares.map(line)] : []),
381
+ ].join("\n");
382
+ };
383
+ switch (sub) {
384
+ case undefined:
385
+ case "list": {
386
+ const state = flag(argv, "--state");
387
+ return show(await call("GET", `/api/v1/stations/shares${state ? `?state=${encodeURIComponent(state)}` : sub === undefined ? "?state=held" : ""}`), summary);
388
+ }
389
+ case "on":
390
+ case "off": {
391
+ const body = sub === "off" ? { open: false } : {
392
+ open: true,
393
+ moderation: has(argv, "--owner-moderation") ? "owner" : "ai",
394
+ publish: !has(argv, "--unlisted"),
395
+ ...(flag(argv, "--max-mb") ? { maxMb: Number(flag(argv, "--max-mb")) } : {}),
396
+ ...(flag(argv, "--per-hour") ? { perHour: Number(flag(argv, "--per-hour")) } : {}),
397
+ };
398
+ return show(await call("POST", "/api/v1/stations/shares", body), (answer) => summary(answer));
399
+ }
400
+ case "approve":
401
+ case "reject": {
402
+ if (!id) {
403
+ console.error(`nixamp station shares ${sub} needs a share id`);
404
+ return 2;
405
+ }
406
+ return show(await call("POST", `/api/v1/stations/shares/${id}/${sub}`, { reason: flag(argv, "--reason") ?? "" }), (answer) => line(answer["share"]));
407
+ }
408
+ case "delete": {
409
+ if (!id) {
410
+ console.error("nixamp station shares delete needs a share id");
411
+ return 2;
412
+ }
413
+ return show(await call("DELETE", `/api/v1/stations/shares/${id}`), () => `Deleted ${id} and its copy.`);
414
+ }
415
+ case "trust": {
416
+ const { readConfig, writeConfig } = await import("./library.js");
417
+ const proxies = new Set(readConfig().trustedProxies ?? []);
418
+ if (argv.includes("--remove"))
419
+ proxies.delete(id ?? "");
420
+ else if (id)
421
+ proxies.add(id);
422
+ writeConfig({ trustedProxies: [...proxies] });
423
+ console.log(proxies.size ? `X-Forwarded-For believed from: ${[...proxies].join(", ")}` : "No proxy is trusted.");
424
+ return 0;
425
+ }
426
+ default:
427
+ console.error(`nixamp station shares: no command "${sub}".\n\n${USAGE}`);
428
+ return 2;
429
+ }
430
+ }
@@ -1,6 +1,9 @@
1
1
  export interface GuideEntry {
2
+ id?: string;
2
3
  name: string;
3
4
  kind: "audio" | "video";
5
+ /** ai, shared, radio or tv; from kind when absent. */
6
+ group?: "ai" | "shared" | "radio" | "tv";
4
7
  state: string;
5
8
  page: string;
6
9
  nowPlaying: {
@@ -14,7 +17,7 @@ export interface GuideEntry {
14
17
  * /n/<name>), and for a crawler. Everything from a station is escaped.
15
18
  */
16
19
  export declare function renderGuide(entries: GuideEntry[]): string;
17
- export declare const GUIDE_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<title>Live \u00B7 stations</title>\n<meta name=\"description\" content=\"Radio and TV stations, live now.\">\n<link rel=\"icon\" href=\"data:,\">\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}\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:880px;margin:0 auto;padding:40px 16px}\nh1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}\nh2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}\na.station{display:block;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;color:inherit;text-decoration:none}\na.station:hover,a.station:focus-visible{border-color:var(--accent);outline:none}\n.name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}\n.live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}\nfooter{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}\n</style>\n</head>\n<body>\n<main>\n <h1>Live</h1>\n <p class=\"lead\">Radio and TV, on the air now. Pick one and press play.</p>\n <h2 id=\"radio-title\" hidden>Radio</h2>\n <div id=\"radio\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"tv-title\" hidden>TV</h2>\n <div id=\"tv\" class=\"grid\" aria-live=\"polite\"></div>\n <p id=\"empty\" class=\"empty\">Nothing is on the air here yet.</p>\n <footer>Played by <a href=\"https://nixamp.com\">nixamp</a>, on the Moshpit.</footer>\n</main>\n<script>\n(async () => {\n const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };\n const draw = (stations) => {\n for (const [kind, box] of [[\"audio\", \"#radio\"], [\"video\", \"#tv\"]]) {\n const grid = document.querySelector(box);\n grid.replaceChildren();\n const list = stations.filter((s) => s.kind === kind);\n document.querySelector(box + \"-title\").hidden = list.length === 0;\n for (const s of list) {\n const a = el(\"a\", \"station\");\n a.href = s.page;\n a.append(el(\"div\", \"live\", s.state === \"live\" ? \"\u25CF LIVE\" : \"off the air\"), el(\"div\", \"name\", s.name),\n el(\"div\", \"now\", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(\" \u2014 \") : \"\"));\n grid.append(a);\n }\n }\n document.querySelector(\"#empty\").hidden = stations.length > 0;\n };\n const load = async () => {\n try {\n const r = await fetch(\"/api/v1/stations/guide\", { cache: \"no-store\" });\n if (r.ok && (r.headers.get(\"content-type\") || \"\").includes(\"json\")) draw((await r.json()).stations);\n } catch {}\n };\n await load();\n setInterval(load, 15000);\n})();\n</script>\n</body>\n</html>\n";
20
+ export declare const GUIDE_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<title>Live \u00B7 stations</title>\n<meta name=\"description\" content=\"Radio and TV stations, live now: AI-composed music, tracks shared by listeners, and more.\">\n<link rel=\"icon\" href=\"data:,\">\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#a1460b}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#f0a35e}}\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:880px;margin:0 auto;padding:40px 16px}\nh1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}\nh2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;position:relative}\n.card:hover,.card:focus-within{border-color:var(--accent)}\na.station{color:inherit;text-decoration:none;display:block}a.station:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:6px}\n.name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}\n.live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}\nbutton.report{margin-top:10px;background:none;border:0;padding:0;color:var(--muted);font:inherit;font-size:.8rem;text-decoration:underline;cursor:pointer}\nbutton.report:hover{color:var(--warn)}\n#share{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin-top:12px}\n#share label{display:block;font-size:.9rem;margin:10px 0 4px;color:var(--muted)}\n#share input[type=url],#share input[type=text],#share select{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);font:inherit}\n#share .agree{display:flex;gap:8px;align-items:flex-start;color:var(--fg);margin-top:12px}\n#share button[type=submit]{margin-top:14px;background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 16px;font:inherit;font-weight:600;cursor:pointer}\n#share .or{color:var(--muted);font-size:.85rem;margin:8px 0 0}\n#status{margin-top:10px;font-size:.9rem;min-height:1.4em}\nfooter{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}\n</style>\n</head>\n<body>\n<main>\n <h1>Live</h1>\n <p class=\"lead\">Radio and TV, on the air now. Pick one and press play.</p>\n <h2 id=\"ai-title\" hidden>AI Radio</h2>\n <div id=\"ai\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"shared-title\" hidden>Shared by listeners</h2>\n <div id=\"shared\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"radio-title\" hidden>Radio</h2>\n <div id=\"radio\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"tv-title\" hidden>TV</h2>\n <div id=\"tv\" class=\"grid\" aria-live=\"polite\"></div>\n <p id=\"empty\" class=\"empty\">Nothing is on the air here yet.</p>\n <h2 id=\"share-title\" hidden>Share a track</h2>\n <form id=\"share\" hidden>\n <label for=\"url\">A link to an MP3</label>\n <input id=\"url\" name=\"url\" type=\"url\" placeholder=\"https://example.com/my-song.mp3\" inputmode=\"url\">\n <p class=\"or\">or upload one (up to <span id=\"max\">60 MB</span>)</p>\n <input id=\"file\" name=\"file\" type=\"file\" accept=\"audio/*,.mp3,.ogg,.flac,.m4a,.wav\">\n <label for=\"basis\">Why it may be broadcast</label>\n <select id=\"basis\" name=\"basis\" required>\n <option value=\"owned\">I made it</option>\n <option value=\"permission\">I have the artist's permission</option>\n <option value=\"licensed\">It is licensed for broadcast</option>\n <option value=\"creative-commons\">It is Creative Commons (allows sharing)</option>\n <option value=\"public-domain\">It is in the public domain</option>\n </select>\n <label for=\"note\">Anything we should know (licence, link to the artist)</label>\n <input id=\"note\" name=\"note\" type=\"text\" maxlength=\"300\">\n <label for=\"by\">Credit me as (optional)</label>\n <input id=\"by\" name=\"by\" type=\"text\" maxlength=\"60\">\n <label class=\"agree\"><input type=\"checkbox\" name=\"agree\" value=\"1\"> I have the right to share this for broadcast here. AI files it under a genre and checks it before it airs; anything can be reported off the air.</label>\n <button type=\"submit\">Share it</button>\n <div id=\"status\" role=\"status\" aria-live=\"polite\"></div>\n </form>\n <footer>Played by <a href=\"https://nixamp.com\">nixamp</a>, on the Moshpit.</footer>\n</main>\n<script>\n(async () => {\n const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };\n const say = (text) => { document.querySelector(\"#status\").textContent = text; };\n const report = async (id, button) => {\n if (!confirm(\"Report the track playing now? It comes off the air until the owner looks at it.\")) return;\n button.disabled = true;\n try {\n const r = await fetch(\"/api/v1/share/report\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ station: id }) });\n const body = await r.json();\n button.textContent = body.reported ? \"Reported: off the air\" : \"Nothing shared is playing\";\n } catch { button.textContent = \"Could not report; try again\"; button.disabled = false; }\n };\n const draw = (stations) => {\n for (const group of [\"ai\", \"shared\", \"radio\", \"tv\"]) {\n const grid = document.querySelector(\"#\" + group);\n grid.replaceChildren();\n const list = stations.filter((s) => (s.group || (s.kind === \"video\" ? \"tv\" : \"radio\")) === group);\n document.querySelector(\"#\" + group + \"-title\").hidden = list.length === 0;\n for (const s of list) {\n const card = el(\"div\", \"card\");\n const a = el(\"a\", \"station\");\n a.href = s.page;\n a.append(el(\"div\", \"live\", s.state === \"live\" ? \"\u25CF LIVE\" : \"off the air\"), el(\"div\", \"name\", s.name),\n el(\"div\", \"now\", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(\" \u2014 \") : \"\"));\n card.append(a);\n if (group === \"shared\") {\n const b = el(\"button\", \"report\", \"Report this track\");\n b.type = \"button\";\n b.addEventListener(\"click\", () => report(s.id, b));\n card.append(b);\n }\n grid.append(card);\n }\n }\n document.querySelector(\"#empty\").hidden = stations.length > 0;\n };\n const load = async () => {\n try {\n const r = await fetch(\"/api/v1/stations/guide\", { cache: \"no-store\" });\n if (r.ok && (r.headers.get(\"content-type\") || \"\").includes(\"json\")) draw((await r.json()).stations);\n } catch {}\n };\n const watch = async (status) => {\n for (let i = 0; i < 120; i++) {\n await new Promise((done) => setTimeout(done, 3000));\n try {\n const share = (await (await fetch(status, { cache: \"no-store\" })).json()).share;\n if (!share) return;\n const words = { fetching: \"Fetching it\u2026\", checking: \"Listening to it and checking it\u2026\", held: \"Waiting for the owner to look at it\", approved: \"On the air\", rejected: \"Not accepted\", failed: \"Could not use it\", withdrawn: \"Withdrawn\", reported: \"Reported\" };\n say((words[share.state] || share.state) + (share.genre ? \" \u00B7 \" + share.genre : \"\") + (share.reason ? \" \u2014 \" + share.reason : \"\"));\n if (![\"fetching\", \"checking\"].includes(share.state)) { load(); return; }\n } catch {}\n }\n };\n const form = document.querySelector(\"#share\");\n try {\n const info = await (await fetch(\"/api/v1/share\", { cache: \"no-store\" })).json();\n if (info.open) {\n document.querySelector(\"#share-title\").hidden = false;\n form.hidden = false;\n document.querySelector(\"#max\").textContent = info.maxMb + \" MB\";\n }\n } catch {}\n form.addEventListener(\"submit\", async (event) => {\n event.preventDefault();\n const data = new FormData(form);\n const file = data.get(\"file\");\n const link = String(data.get(\"url\") || \"\").trim();\n const fields = { basis: String(data.get(\"basis\") || \"\"), note: String(data.get(\"note\") || \"\"), by: String(data.get(\"by\") || \"\"), agree: data.get(\"agree\") ? \"1\" : \"\" };\n if (!fields.agree) { say(\"Tick the box to confirm you have the right to share it.\"); return; }\n if (!link && !(file && file.size)) { say(\"Paste a link to an MP3, or choose a file.\"); return; }\n say(file && file.size && !link ? \"Uploading\u2026\" : \"Sending\u2026\");\n try {\n const r = file && file.size && !link\n ? await fetch(\"/api/v1/share/upload?\" + new URLSearchParams({ ...fields, name: file.name }), { method: \"PUT\", headers: { \"content-type\": file.type || \"audio/mpeg\" }, body: file })\n : await fetch(\"/api/v1/share\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ ...fields, agree: true, url: link }) });\n const body = await r.json();\n if (!r.ok) { say(body.error || \"That did not work.\"); return; }\n form.reset();\n say(\"Got it. Checking it now\u2026\");\n watch(body.status);\n } catch { say(\"Could not reach the station; try again.\"); }\n });\n await load();\n setInterval(load, 15000);\n})();\n</script>\n</body>\n</html>\n";
18
21
  export declare const GUIDE_CSP: string;
19
22
  /**
20
23
  * The hosts whose front page is the guide: `guideHosts` in config.json
@@ -9,34 +9,62 @@
9
9
  import { createHash } from "node:crypto";
10
10
  import { readConfig } from "./library.js";
11
11
  const STYLE = `
12
- :root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}
13
- @media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}
12
+ :root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#a1460b}
13
+ @media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#f0a35e}}
14
14
  *{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}
15
15
  main{max-width:880px;margin:0 auto;padding:40px 16px}
16
16
  h1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}
17
17
  h2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}
18
18
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
19
- a.station{display:block;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;color:inherit;text-decoration:none}
20
- a.station:hover,a.station:focus-visible{border-color:var(--accent);outline:none}
19
+ .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;position:relative}
20
+ .card:hover,.card:focus-within{border-color:var(--accent)}
21
+ a.station{color:inherit;text-decoration:none;display:block}a.station:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:6px}
21
22
  .name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}
22
23
  .live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}
24
+ button.report{margin-top:10px;background:none;border:0;padding:0;color:var(--muted);font:inherit;font-size:.8rem;text-decoration:underline;cursor:pointer}
25
+ button.report:hover{color:var(--warn)}
26
+ #share{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin-top:12px}
27
+ #share label{display:block;font-size:.9rem;margin:10px 0 4px;color:var(--muted)}
28
+ #share input[type=url],#share input[type=text],#share select{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);font:inherit}
29
+ #share .agree{display:flex;gap:8px;align-items:flex-start;color:var(--fg);margin-top:12px}
30
+ #share button[type=submit]{margin-top:14px;background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 16px;font:inherit;font-weight:600;cursor:pointer}
31
+ #share .or{color:var(--muted);font-size:.85rem;margin:8px 0 0}
32
+ #status{margin-top:10px;font-size:.9rem;min-height:1.4em}
23
33
  footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
24
34
  `;
25
35
  const SCRIPT = `
26
36
  (async () => {
27
37
  const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
38
+ const say = (text) => { document.querySelector("#status").textContent = text; };
39
+ const report = async (id, button) => {
40
+ if (!confirm("Report the track playing now? It comes off the air until the owner looks at it.")) return;
41
+ button.disabled = true;
42
+ try {
43
+ const r = await fetch("/api/v1/share/report", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ station: id }) });
44
+ const body = await r.json();
45
+ button.textContent = body.reported ? "Reported: off the air" : "Nothing shared is playing";
46
+ } catch { button.textContent = "Could not report; try again"; button.disabled = false; }
47
+ };
28
48
  const draw = (stations) => {
29
- for (const [kind, box] of [["audio", "#radio"], ["video", "#tv"]]) {
30
- const grid = document.querySelector(box);
49
+ for (const group of ["ai", "shared", "radio", "tv"]) {
50
+ const grid = document.querySelector("#" + group);
31
51
  grid.replaceChildren();
32
- const list = stations.filter((s) => s.kind === kind);
33
- document.querySelector(box + "-title").hidden = list.length === 0;
52
+ const list = stations.filter((s) => (s.group || (s.kind === "video" ? "tv" : "radio")) === group);
53
+ document.querySelector("#" + group + "-title").hidden = list.length === 0;
34
54
  for (const s of list) {
55
+ const card = el("div", "card");
35
56
  const a = el("a", "station");
36
57
  a.href = s.page;
37
58
  a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
38
59
  el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
39
- grid.append(a);
60
+ card.append(a);
61
+ if (group === "shared") {
62
+ const b = el("button", "report", "Report this track");
63
+ b.type = "button";
64
+ b.addEventListener("click", () => report(s.id, b));
65
+ card.append(b);
66
+ }
67
+ grid.append(card);
40
68
  }
41
69
  }
42
70
  document.querySelector("#empty").hidden = stations.length > 0;
@@ -47,6 +75,47 @@ const SCRIPT = `
47
75
  if (r.ok && (r.headers.get("content-type") || "").includes("json")) draw((await r.json()).stations);
48
76
  } catch {}
49
77
  };
78
+ const watch = async (status) => {
79
+ for (let i = 0; i < 120; i++) {
80
+ await new Promise((done) => setTimeout(done, 3000));
81
+ try {
82
+ const share = (await (await fetch(status, { cache: "no-store" })).json()).share;
83
+ if (!share) return;
84
+ const words = { fetching: "Fetching it…", checking: "Listening to it and checking it…", held: "Waiting for the owner to look at it", approved: "On the air", rejected: "Not accepted", failed: "Could not use it", withdrawn: "Withdrawn", reported: "Reported" };
85
+ say((words[share.state] || share.state) + (share.genre ? " · " + share.genre : "") + (share.reason ? " — " + share.reason : ""));
86
+ if (!["fetching", "checking"].includes(share.state)) { load(); return; }
87
+ } catch {}
88
+ }
89
+ };
90
+ const form = document.querySelector("#share");
91
+ try {
92
+ const info = await (await fetch("/api/v1/share", { cache: "no-store" })).json();
93
+ if (info.open) {
94
+ document.querySelector("#share-title").hidden = false;
95
+ form.hidden = false;
96
+ document.querySelector("#max").textContent = info.maxMb + " MB";
97
+ }
98
+ } catch {}
99
+ form.addEventListener("submit", async (event) => {
100
+ event.preventDefault();
101
+ const data = new FormData(form);
102
+ const file = data.get("file");
103
+ const link = String(data.get("url") || "").trim();
104
+ const fields = { basis: String(data.get("basis") || ""), note: String(data.get("note") || ""), by: String(data.get("by") || ""), agree: data.get("agree") ? "1" : "" };
105
+ if (!fields.agree) { say("Tick the box to confirm you have the right to share it."); return; }
106
+ if (!link && !(file && file.size)) { say("Paste a link to an MP3, or choose a file."); return; }
107
+ say(file && file.size && !link ? "Uploading…" : "Sending…");
108
+ try {
109
+ const r = file && file.size && !link
110
+ ? await fetch("/api/v1/share/upload?" + new URLSearchParams({ ...fields, name: file.name }), { method: "PUT", headers: { "content-type": file.type || "audio/mpeg" }, body: file })
111
+ : await fetch("/api/v1/share", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...fields, agree: true, url: link }) });
112
+ const body = await r.json();
113
+ if (!r.ok) { say(body.error || "That did not work."); return; }
114
+ form.reset();
115
+ say("Got it. Checking it now…");
116
+ watch(body.status);
117
+ } catch { say("Could not reach the station; try again."); }
118
+ });
50
119
  await load();
51
120
  setInterval(load, 15000);
52
121
  })();
@@ -55,24 +124,26 @@ const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base
55
124
  function escape(text) {
56
125
  return text.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]);
57
126
  }
127
+ const GROUPS = ["ai", "shared", "radio", "tv"];
58
128
  /**
59
129
  * The guide with its stations already in it, so it reads the same through a
60
130
  * gateway that cannot run its script against this server (the Moshpit's
61
131
  * /n/<name>), and for a crawler. Everything from a station is escaped.
62
132
  */
63
133
  export function renderGuide(entries) {
64
- const cards = (kind) => entries
65
- .filter((one) => one.kind === kind && /^https?:\/\//.test(one.page))
66
- .map((one) => `<a class="station" href="${escape(one.page)}"><div class="live">${one.state === "live" ? "● LIVE" : "off the air"}</div><div class="name">${escape(one.name)}</div><div class="now">${one.nowPlaying ? escape([one.nowPlaying.title, one.nowPlaying.artist].filter(Boolean).join(" — ")) : ""}</div></a>`)
134
+ const groupOf = (one) => one.group ?? (one.kind === "video" ? "tv" : "radio");
135
+ const cards = (group) => entries
136
+ .filter((one) => groupOf(one) === group && /^https?:\/\//.test(one.page))
137
+ .map((one) => `<div class="card"><a class="station" href="${escape(one.page)}"><div class="live">${one.state === "live" ? "● LIVE" : "off the air"}</div><div class="name">${escape(one.name)}</div><div class="now">${one.nowPlaying ? escape([one.nowPlaying.title, one.nowPlaying.artist].filter(Boolean).join(" — ")) : ""}</div></a></div>`)
67
138
  .join("");
68
- const radio = cards("audio");
69
- const tv = cards("video");
70
- return GUIDE_HTML
71
- .replace('<h2 id="radio-title" hidden>', radio ? '<h2 id="radio-title">' : '<h2 id="radio-title" hidden>')
72
- .replace('<div id="radio" class="grid" aria-live="polite"></div>', `<div id="radio" class="grid" aria-live="polite">${radio}</div>`)
73
- .replace('<h2 id="tv-title" hidden>', tv ? '<h2 id="tv-title">' : '<h2 id="tv-title" hidden>')
74
- .replace('<div id="tv" class="grid" aria-live="polite"></div>', `<div id="tv" class="grid" aria-live="polite">${tv}</div>`)
75
- .replace('<p id="empty" class="empty">', entries.length ? '<p id="empty" class="empty" hidden>' : '<p id="empty" class="empty">');
139
+ let page = GUIDE_HTML;
140
+ for (const group of GROUPS) {
141
+ const made = cards(group);
142
+ page = page
143
+ .replace(`<h2 id="${group}-title" hidden>`, made ? `<h2 id="${group}-title">` : `<h2 id="${group}-title" hidden>`)
144
+ .replace(`<div id="${group}" class="grid" aria-live="polite"></div>`, `<div id="${group}" class="grid" aria-live="polite">${made}</div>`);
145
+ }
146
+ return page.replace('<p id="empty" class="empty">', entries.length ? '<p id="empty" class="empty" hidden>' : '<p id="empty" class="empty">');
76
147
  }
77
148
  export const GUIDE_HTML = `<!doctype html>
78
149
  <html lang="en">
@@ -80,7 +151,7 @@ export const GUIDE_HTML = `<!doctype html>
80
151
  <meta charset="utf-8">
81
152
  <meta name="viewport" content="width=device-width, initial-scale=1">
82
153
  <title>Live · stations</title>
83
- <meta name="description" content="Radio and TV stations, live now.">
154
+ <meta name="description" content="Radio and TV stations, live now: AI-composed music, tracks shared by listeners, and more.">
84
155
  <link rel="icon" href="data:,">
85
156
  <style>${STYLE}</style>
86
157
  </head>
@@ -88,11 +159,37 @@ export const GUIDE_HTML = `<!doctype html>
88
159
  <main>
89
160
  <h1>Live</h1>
90
161
  <p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
162
+ <h2 id="ai-title" hidden>AI Radio</h2>
163
+ <div id="ai" class="grid" aria-live="polite"></div>
164
+ <h2 id="shared-title" hidden>Shared by listeners</h2>
165
+ <div id="shared" class="grid" aria-live="polite"></div>
91
166
  <h2 id="radio-title" hidden>Radio</h2>
92
167
  <div id="radio" class="grid" aria-live="polite"></div>
93
168
  <h2 id="tv-title" hidden>TV</h2>
94
169
  <div id="tv" class="grid" aria-live="polite"></div>
95
170
  <p id="empty" class="empty">Nothing is on the air here yet.</p>
171
+ <h2 id="share-title" hidden>Share a track</h2>
172
+ <form id="share" hidden>
173
+ <label for="url">A link to an MP3</label>
174
+ <input id="url" name="url" type="url" placeholder="https://example.com/my-song.mp3" inputmode="url">
175
+ <p class="or">or upload one (up to <span id="max">60 MB</span>)</p>
176
+ <input id="file" name="file" type="file" accept="audio/*,.mp3,.ogg,.flac,.m4a,.wav">
177
+ <label for="basis">Why it may be broadcast</label>
178
+ <select id="basis" name="basis" required>
179
+ <option value="owned">I made it</option>
180
+ <option value="permission">I have the artist's permission</option>
181
+ <option value="licensed">It is licensed for broadcast</option>
182
+ <option value="creative-commons">It is Creative Commons (allows sharing)</option>
183
+ <option value="public-domain">It is in the public domain</option>
184
+ </select>
185
+ <label for="note">Anything we should know (licence, link to the artist)</label>
186
+ <input id="note" name="note" type="text" maxlength="300">
187
+ <label for="by">Credit me as (optional)</label>
188
+ <input id="by" name="by" type="text" maxlength="60">
189
+ <label class="agree"><input type="checkbox" name="agree" value="1"> I have the right to share this for broadcast here. AI files it under a genre and checks it before it airs; anything can be reported off the air.</label>
190
+ <button type="submit">Share it</button>
191
+ <div id="status" role="status" aria-live="polite"></div>
192
+ </form>
96
193
  <footer>Played by <a href="https://nixamp.com">nixamp</a>, on the Moshpit.</footer>
97
194
  </main>
98
195
  <script>${SCRIPT}</script>