nixamp 0.30.3 → 0.33.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 (90) hide show
  1. package/dist/channels.d.ts +11 -2
  2. package/dist/channels.js +26 -4
  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.js +5 -2
  17. package/dist/server.d.ts +9 -0
  18. package/dist/server.js +87 -13
  19. package/dist/station-cli.js +4 -3
  20. package/dist/station-mcp.d.ts +17 -0
  21. package/dist/station-mcp.js +25 -19
  22. package/dist/station-page.d.ts +1 -1
  23. package/dist/station-page.js +25 -4
  24. package/dist/station-routes.js +54 -1
  25. package/dist/station-usage.d.ts +1 -1
  26. package/dist/station-usage.js +1 -1
  27. package/package.json +1 -1
  28. package/src/channels.ts +23 -4
  29. package/src/library-cli.ts +175 -0
  30. package/src/library-index.ts +709 -0
  31. package/src/library-mcp.ts +132 -0
  32. package/src/library-routes.ts +282 -0
  33. package/src/library-usage.ts +21 -0
  34. package/src/library-user.ts +218 -0
  35. package/src/main.ts +7 -0
  36. package/src/mcp.ts +5 -2
  37. package/src/server.ts +86 -14
  38. package/src/station-cli.ts +4 -3
  39. package/src/station-mcp.ts +22 -15
  40. package/src/station-page.ts +25 -4
  41. package/src/station-routes.ts +53 -1
  42. package/src/station-usage.ts +1 -1
  43. package/web/dist/assets/ar-4kD-mTIA.js +1 -0
  44. package/web/dist/assets/cs-CziXiWNY.js +1 -0
  45. package/web/dist/assets/da-CyjJ8UOr.js +1 -0
  46. package/web/dist/assets/de-DxwYa667.js +1 -0
  47. package/web/dist/assets/es-QgMTJeSc.js +1 -0
  48. package/web/dist/assets/fi-Clj-jHhR.js +1 -0
  49. package/web/dist/assets/fr-nH_gzSFv.js +1 -0
  50. package/web/dist/assets/hi-D7djQ6_C.js +1 -0
  51. package/web/dist/assets/hls-3VKVEQE3-BaWVouMg.js +1 -0
  52. package/web/dist/assets/hu-H5Q3L8bD.js +1 -0
  53. package/web/dist/assets/id-40z_EQdf.js +1 -0
  54. package/web/dist/assets/it-C9Fcgywl.js +1 -0
  55. package/web/dist/assets/{index-CDBIr8Tz.js → main-BJUyBy0h.js} +2 -2
  56. package/web/dist/assets/{mpegts-63ylMmge.js → mpegts-BlmyNlBC.js} +1 -1
  57. package/web/dist/assets/mpegts-LO6RVLD6-CAuth8C5.js +1 -0
  58. package/web/dist/assets/nl-CrL4Psk9.js +1 -0
  59. package/web/dist/assets/radio-B58WgC1j.js +1 -0
  60. package/web/dist/assets/radio-CC8ihiO_.css +1 -0
  61. package/web/dist/assets/ru-C84JS0ch.js +1 -0
  62. package/web/dist/assets/styles-D70tSrIe.js +1 -0
  63. package/web/dist/assets/styles-qC62Gona.css +1 -0
  64. package/web/dist/assets/sv-DtMcl0sf.js +1 -0
  65. package/web/dist/assets/uk-dOLf99vZ.js +1 -0
  66. package/web/dist/assets/vi-BWeQVRZ8.js +1 -0
  67. package/web/dist/assets/zh-Cnv3Qpux.js +1 -0
  68. package/web/dist/index.html +4 -2
  69. package/web/dist/radio.html +134 -0
  70. package/web/dist/sw.js +26 -23
  71. package/web/dist/assets/ar-ClCIWtVb.js +0 -1
  72. package/web/dist/assets/cs-CVG4U823.js +0 -1
  73. package/web/dist/assets/da-igdiQUk8.js +0 -1
  74. package/web/dist/assets/de-CHPsc6lc.js +0 -1
  75. package/web/dist/assets/es-AvbMspQX.js +0 -1
  76. package/web/dist/assets/fi-5JGUOm3W.js +0 -1
  77. package/web/dist/assets/fr-YfMwk1FR.js +0 -1
  78. package/web/dist/assets/hi-BUZuQTkp.js +0 -1
  79. package/web/dist/assets/hls-3VKVEQE3-BCXawNaW.js +0 -1
  80. package/web/dist/assets/hu-C7on-yUl.js +0 -1
  81. package/web/dist/assets/id-pIrzVeOi.js +0 -1
  82. package/web/dist/assets/index-Bo3utcDw.css +0 -1
  83. package/web/dist/assets/it-Bl9xk5B4.js +0 -1
  84. package/web/dist/assets/mpegts-LO6RVLD6-DBakOXU5.js +0 -1
  85. package/web/dist/assets/nl-b5BUih3i.js +0 -1
  86. package/web/dist/assets/ru-BlAkbEPp.js +0 -1
  87. package/web/dist/assets/sv-BEKvqELG.js +0 -1
  88. package/web/dist/assets/uk-JY8Dh56p.js +0 -1
  89. package/web/dist/assets/vi-DbtxuQPL.js +0 -1
  90. package/web/dist/assets/zh-4x0DI5jO.js +0 -1
@@ -26,7 +26,7 @@ export const STATION_TOOLS = [
26
26
  },
27
27
  {
28
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.",
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
30
  inputSchema: {
31
31
  type: "object",
32
32
  properties: {
@@ -34,6 +34,7 @@ export const STATION_TOOLS = [
34
34
  name: STRING,
35
35
  folder: { ...STRING, description: "A folder under the library, e.g. \"Albums/Late Night\"." },
36
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." },
37
38
  description: STRING,
38
39
  shuffle: { type: "boolean" },
39
40
  repeat: { type: "boolean", description: "Round and round (default) or once." },
@@ -84,6 +85,24 @@ export const STATION_TOOLS = [
84
85
  },
85
86
  ];
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
+ }
87
106
  export function isStationTool(name) {
88
107
  return STATION_TOOLS.some((tool) => tool.name === name);
89
108
  }
@@ -95,23 +114,10 @@ function line(station) {
95
114
  }
96
115
  export async function callStationTool(name, args, options = {}) {
97
116
  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
- }
117
+ const target = connect(args, options);
118
+ if ("error" in target)
119
+ return say(target.error, true);
120
+ const { base, headers } = target;
115
121
  const id = typeof args["id"] === "string" ? args["id"] : "";
116
122
  const call = async (method, path, body) => {
117
123
  const response = await send(`${base}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
@@ -135,7 +141,7 @@ export async function callStationTool(name, args, options = {}) {
135
141
  return answer.ok ? say(JSON.stringify(answer.body["station"], null, 2)) : refused(answer.body);
136
142
  }
137
143
  if (name === "station_create") {
138
- const answer = await call("POST", "/api/v1/stations", pick(["name", "folder", "items", "description", "shuffle", "repeat", "bitrate", "idempotencyKey"]));
144
+ const answer = await call("POST", "/api/v1/stations", pick(["name", "folder", "items", "playlist", "description", "shuffle", "repeat", "bitrate", "idempotencyKey"]));
139
145
  return answer.ok ? say(`Made, private and stopped: ${line(answer.body["station"])}`) : refused(answer.body);
140
146
  }
141
147
  if (name === "station_update") {
@@ -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<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";
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, live = false;\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 if (!live) title(s);\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 title = (s) => {\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 };\n // What is ahead of the speaker: the sound the player holds and has not\n // played yet. A new title waits that long, so it changes with the song.\n const ahead = () => {\n if (!wanted || audio.paused || audio.buffered.length === 0) return 0;\n return Math.max(0, Math.min(30, audio.buffered.end(audio.buffered.length - 1) - audio.currentTime));\n };\n const listen = () => {\n if (!window.EventSource) return;\n const events = new EventSource(api + \"/events\" + q);\n events.addEventListener(\"hello\", (e) => { live = true; title(JSON.parse(e.data)); });\n events.addEventListener(\"now\", (e) => {\n const s = JSON.parse(e.data);\n setTimeout(() => { title(s); say(s.nowPlaying ? \"Now playing \" + (s.nowPlaying.title || \"\") : \"\"); }, ahead() * 1000);\n });\n events.addEventListener(\"state\", () => poll());\n events.onerror = () => { live = false; };\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 listen();\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;
@@ -42,7 +42,7 @@ const SCRIPT = `
42
42
  const $ = (s) => document.querySelector(s);
43
43
  const audio = new Audio();
44
44
  audio.preload = "none";
45
- let wanted = false, retry = 0, timer = 0, last = null;
45
+ let wanted = false, retry = 0, timer = 0, last = null, live = false;
46
46
  const chip = (state, text) => { const c = $("#state"); c.className = "chip " + state; c.textContent = text; };
47
47
  const say = (text) => { $("#announce").textContent = text; };
48
48
  const draw = (s) => {
@@ -51,9 +51,7 @@ const SCRIPT = `
51
51
  $("#name").textContent = s.name;
52
52
  $("#about").textContent = s.description || "";
53
53
  $("#about").hidden = !s.description;
54
- const playing = s.nowPlaying;
55
- $("#title").textContent = playing ? (playing.title || "Untitled") : (s.state === "live" ? "On the air" : "");
56
- $("#by").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(" · ") : "";
54
+ if (!live) title(s);
57
55
  if (wanted && retry > 0) chip("reconnecting", "Reconnecting");
58
56
  else if (s.state === "live") chip("live", "Live");
59
57
  else if (s.state === "starting" || s.state === "recovering") chip("reconnecting", "Coming back");
@@ -62,6 +60,28 @@ const SCRIPT = `
62
60
  $("#play").disabled = !(s.state === "live" || s.state === "recovering" || s.state === "starting");
63
61
  $("#m3u").href = api + "/listen.m3u" + q;
64
62
  };
63
+ const title = (s) => {
64
+ const playing = s.nowPlaying;
65
+ $("#title").textContent = playing ? (playing.title || "Untitled") : (s.state === "live" ? "On the air" : "");
66
+ $("#by").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(" · ") : "";
67
+ };
68
+ // What is ahead of the speaker: the sound the player holds and has not
69
+ // played yet. A new title waits that long, so it changes with the song.
70
+ const ahead = () => {
71
+ if (!wanted || audio.paused || audio.buffered.length === 0) return 0;
72
+ return Math.max(0, Math.min(30, audio.buffered.end(audio.buffered.length - 1) - audio.currentTime));
73
+ };
74
+ const listen = () => {
75
+ if (!window.EventSource) return;
76
+ const events = new EventSource(api + "/events" + q);
77
+ events.addEventListener("hello", (e) => { live = true; title(JSON.parse(e.data)); });
78
+ events.addEventListener("now", (e) => {
79
+ const s = JSON.parse(e.data);
80
+ setTimeout(() => { title(s); say(s.nowPlaying ? "Now playing " + (s.nowPlaying.title || "") : ""); }, ahead() * 1000);
81
+ });
82
+ events.addEventListener("state", () => poll());
83
+ events.onerror = () => { live = false; };
84
+ };
65
85
  const poll = async () => {
66
86
  try {
67
87
  const r = await fetch(api + "/state" + q, { cache: "no-store" });
@@ -87,6 +107,7 @@ const SCRIPT = `
87
107
  $("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
88
108
  $("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
89
109
  poll();
110
+ listen();
90
111
  setInterval(poll, 5000);
91
112
  })();
92
113
  `;
@@ -1,6 +1,6 @@
1
1
  import { StationError, channelOf, tokensMatch, } from "./stations.js";
2
2
  /** The three things a station's link opens. */
3
- const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u)$/;
3
+ const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events)$/;
4
4
  /** Whether this path is one a station's own link may open, without the server's key. */
5
5
  export function isStationListenPath(path) {
6
6
  return LISTEN.test(path);
@@ -80,6 +80,59 @@ export async function handleStations(request, response, path, url, ctx) {
80
80
  return true;
81
81
  }
82
82
  const what = listen[2];
83
+ if (what === "events") {
84
+ // Now playing as it changes, for a player to show when its own sound
85
+ // gets there: each "now" is sent when the station's encoder moves on to
86
+ // the next file, and a listener is behind that by its own buffer.
87
+ response.writeHead(200, {
88
+ ...ctx.headers,
89
+ "content-type": "text/event-stream; charset=utf-8",
90
+ "cache-control": "no-store",
91
+ connection: "keep-alive",
92
+ "x-accel-buffering": "no",
93
+ });
94
+ let instance = 0;
95
+ let lastFile = nowPlayingFile(ctx, station);
96
+ let lastState = ctx.runner.observed(station.id);
97
+ const send = (event, data) => {
98
+ response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
99
+ };
100
+ const now = () => {
101
+ const view = publicView(ctx, station);
102
+ return { station: station.id, playback: `${station.id}:${ctx.channels.info(channelOf(station.id))?.startedAt ?? 0}`, track: `${station.id}:${instance}`, at: Date.now(), state: view.state, nowPlaying: view.nowPlaying };
103
+ };
104
+ send("hello", now());
105
+ const tick = setInterval(() => {
106
+ const current = ctx.store.get(station.id);
107
+ if (!current) {
108
+ response.end();
109
+ return;
110
+ }
111
+ const file = nowPlayingFile(ctx, current);
112
+ const state = ctx.runner.observed(current.id);
113
+ if (file !== lastFile) {
114
+ lastFile = file;
115
+ instance += 1;
116
+ send("now", now());
117
+ }
118
+ else if (state !== lastState) {
119
+ send("state", now());
120
+ }
121
+ lastState = state;
122
+ }, 500);
123
+ tick.unref?.();
124
+ const beat = setInterval(() => response.write(": beat\n\n"), 20_000);
125
+ beat.unref?.();
126
+ const untrack = ctx.runner.track(station.id, { end: () => response.end() });
127
+ const leave = () => {
128
+ clearInterval(tick);
129
+ clearInterval(beat);
130
+ untrack();
131
+ };
132
+ request.on("close", leave);
133
+ response.on("close", leave);
134
+ return true;
135
+ }
83
136
  if (what === "state") {
84
137
  ctx.json(response, 200, publicView(ctx, station));
85
138
  return true;
@@ -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.3",
3
+ "version": "0.33.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
@@ -295,9 +295,28 @@ export class Channel {
295
295
  /** The list being played entry by entry, when it is one; what skip() moves on. */
296
296
  private playlistInput: PlaylistInputStream | null = null;
297
297
 
298
- /** On to the next entry of a list of files. False when this is not one. */
298
+ /**
299
+ * On to the entry after the one listeners hear now. False when this is
300
+ * not a list of files.
301
+ *
302
+ * The list's decoder runs ahead of the speaker by whatever the pipes hold,
303
+ * so ending the entry it is decoding would skip the wrong song -- usually
304
+ * the next one, while this one plays on. Instead the channel is dialled
305
+ * again from the next entry, which also drops what was read ahead. Not a
306
+ * failure, so it does not count as a redial.
307
+ */
299
308
  skip(): boolean {
300
- return this.playlistInput?.skip() ?? false;
309
+ const list = this.info.playlist;
310
+ if (!this.playlistInput || !list?.length) return false;
311
+ const redials = this.info.redials;
312
+ this.info.playlistAt = ((this.info.playlistAt ?? 0) + 1) % list.length;
313
+ this.info.position = 0;
314
+ // MP3 frames carry on into the same connections, as a list advancing
315
+ // does; a picture needs a fresh start, and the player rejoins on its own.
316
+ const done = this.restart(this.info.kind === "audio");
317
+ if (redials === undefined) delete this.info.redials;
318
+ else this.info.redials = redials;
319
+ return done;
301
320
  }
302
321
 
303
322
  /** An entry's tags, as read when it was put on. */
@@ -635,7 +654,7 @@ export class Channel {
635
654
  * decided the thing is worth another go, and should not inherit the four
636
655
  * strikes a dead CDN ran up an hour ago.
637
656
  */
638
- restart(): boolean {
657
+ restart(preserveListeners = false): boolean {
639
658
  if (this.closing) return false;
640
659
  // Started over from the outro: the show itself, from its beginning.
641
660
  if (this.outroOn && this.dialed) {
@@ -666,7 +685,7 @@ export class Channel {
666
685
  const old = this.child;
667
686
  this.child = null;
668
687
  old?.kill("SIGKILL");
669
- this.startOver();
688
+ this.startOver(preserveListeners);
670
689
  dial();
671
690
  return true;
672
691
  }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `nixamp library <command>`: the index, from a terminal or a script.
3
+ *
4
+ * `nixamp library ~/Music` still sets the folder a daemon serves (library.ts);
5
+ * these are the questions you ask once it is running. Same conventions as
6
+ * `nixamp station`: the daemon on this machine unless --url/--key, --json for
7
+ * scripts, exit 0 done, 1 refused, 2 usage, 3 unreachable.
8
+ */
9
+ import { resolveTarget } from "./admin.ts";
10
+ import { KEY_HEADER } from "./share.ts";
11
+
12
+ import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.ts";
13
+
14
+ export { LIBRARY_COMMANDS, LIBRARY_USAGE };
15
+
16
+ function flag(argv: string[], name: string): string | undefined {
17
+ const at = argv.indexOf(name);
18
+ return at === -1 ? undefined : argv[at + 1];
19
+ }
20
+
21
+ /** Words that are not flags or flag values. */
22
+ function words(argv: string[]): string[] {
23
+ const out: string[] = [];
24
+ for (let at = 0; at < argv.length; at++) {
25
+ const one = argv[at]!;
26
+ if (one.startsWith("--")) {
27
+ if (!["--json", "--force", "--wait"].includes(one)) at += 1;
28
+ continue;
29
+ }
30
+ out.push(one);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ export async function libraryTool(argv: string[]): Promise<number> {
36
+ const [command] = argv;
37
+ const json = argv.includes("--json");
38
+ let target;
39
+ try {
40
+ target = resolveTarget(argv);
41
+ } catch (error) {
42
+ console.error((error as Error).message);
43
+ return 3;
44
+ }
45
+ const headers: Record<string, string> = { "content-type": "application/json", ...(target.key ? { [KEY_HEADER]: target.key } : {}) };
46
+ const call = async (method: string, path: string, body?: unknown): Promise<{ status: number; body: Record<string, unknown> }> => {
47
+ let response: Response;
48
+ try {
49
+ response = await fetch(`${target.url}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
50
+ } catch (error) {
51
+ throw Object.assign(new Error(`could not reach ${target.url}: ${(error as Error).message}`), { unreachable: true });
52
+ }
53
+ const parsed = (await response.json().catch(() => ({}))) as Record<string, unknown>;
54
+ return { status: response.status, body: parsed };
55
+ };
56
+ const out = (answer: { status: number; body: Record<string, unknown> }, line: (body: Record<string, unknown>) => string): number => {
57
+ if (json) console.log(JSON.stringify(answer.body, null, 2));
58
+ else if (answer.status < 400) console.log(line(answer.body));
59
+ if (answer.status >= 400) {
60
+ if (!json) console.error(`nixamp: ${String(answer.body["error"] ?? "refused")} (${String(answer.body["code"] ?? answer.status)})`);
61
+ return 1;
62
+ }
63
+ return 0;
64
+ };
65
+ const scanLine = (scan: Record<string, unknown> | null | undefined): string =>
66
+ scan ? `${String(scan["status"])}: ${String(scan["found"])} found, ${String(scan["unchanged"])} unchanged, ${String(scan["probed"])} read, ${String(scan["failed"])} failed, ${String(scan["missing"])} gone` : "never scanned";
67
+ const itemLine = (item: Record<string, unknown>): string =>
68
+ `${String(item["id"])} ${String(item["artist"] || "?")} — ${String(item["title"])}${item["album"] ? ` (${String(item["album"])})` : ""}${item["state"] !== "ready" ? ` [${String(item["state"])}]` : ""}`;
69
+ const rest = words(argv.slice(1));
70
+
71
+ try {
72
+ switch (command) {
73
+ case "status":
74
+ return out(await call("GET", "/api/v1/library"), (body) => {
75
+ const counts = body["counts"] as Record<string, number>;
76
+ const sources = (body["sources"] as Record<string, unknown>[]) ?? [];
77
+ return [
78
+ `${String(body["items"])} items: ${Object.entries(counts).filter(([, n]) => n > 0).map(([state, n]) => `${n} ${state}`).join(", ") || "none"}`,
79
+ ...sources.map((one) => ` ${String(one["id"])} ${String(one["root"] ?? "")}${one["enabled"] ? "" : " (off)"}\n last scan ${scanLine(one["lastScan"] as Record<string, unknown> | null)}`),
80
+ ].join("\n");
81
+ });
82
+ case "sources":
83
+ return out(await call("GET", "/api/v1/library/sources"), (body) =>
84
+ ((body["sources"] as Record<string, unknown>[]) ?? []).map((one) => `${String(one["id"])} ${String(one["root"] ?? "")}${one["enabled"] ? "" : " (off)"}`).join("\n") || "No sources.");
85
+ case "add": {
86
+ const [path] = rest;
87
+ if (!path) { console.error("nixamp library add needs a folder"); return 2; }
88
+ const { resolve } = await import("node:path");
89
+ const { homedir } = await import("node:os");
90
+ return out(await call("POST", "/api/v1/library/sources", { root: resolve(path.replace(/^~(?=$|\/)/, homedir())) }), (body) =>
91
+ `Indexing ${String((body["source"] as Record<string, unknown>)["root"])} as ${String((body["source"] as Record<string, unknown>)["id"])}.`);
92
+ }
93
+ case "scan": {
94
+ let [source] = rest;
95
+ if (!source) {
96
+ const listed = await call("GET", "/api/v1/library/sources");
97
+ const all = (listed.body["sources"] as Record<string, unknown>[]) ?? [];
98
+ if (all.length !== 1) { console.error("Which source? `nixamp library sources` lists them."); return 2; }
99
+ source = String(all[0]!["id"]);
100
+ }
101
+ const started = await call("POST", `/api/v1/library/sources/${source}/scan`, { force: argv.includes("--force") });
102
+ if (started.status >= 400 || !argv.includes("--wait")) return out(started, (body) => `Scanning: job ${String((body["job"] as Record<string, unknown>)["jobId"])}.`);
103
+ const jobId = String((started.body["job"] as Record<string, unknown>)["jobId"]);
104
+ for (;;) {
105
+ const polled = await call("GET", `/api/v1/library/jobs/${jobId}`);
106
+ const job = polled.body["job"] as Record<string, unknown> | undefined;
107
+ if (polled.status >= 400 || !job || job["status"] !== "running") return out(polled, () => scanLine(job));
108
+ await new Promise((next) => setTimeout(next, 1000));
109
+ }
110
+ }
111
+ case "search": {
112
+ const query = new URLSearchParams({ q: rest.join(" "), limit: flag(argv, "--limit") ?? "20", sort: "artist" });
113
+ for (const name of ["artist", "album", "genre", "state", "folder"]) {
114
+ const value = flag(argv, `--${name}`);
115
+ if (value) query.set(name, value);
116
+ }
117
+ return out(await call("GET", `/api/v1/library/items?${query}`), (body) => {
118
+ const items = (body["items"] as Record<string, unknown>[]) ?? [];
119
+ return items.length ? `${items.map(itemLine).join("\n")}\n${String(body["total"])} in all` : "Nothing matches.";
120
+ });
121
+ }
122
+ case "show": {
123
+ const [item] = rest;
124
+ if (!item) { console.error("nixamp library show needs an item id"); return 2; }
125
+ return out(await call("GET", `/api/v1/library/items/${item}`), (body) => JSON.stringify(body["item"], null, 2));
126
+ }
127
+ case "fix": {
128
+ const [item] = rest;
129
+ if (!item) { console.error("nixamp library fix needs an item id"); return 2; }
130
+ const patch: Record<string, unknown> = {};
131
+ for (const name of ["title", "artist", "album", "albumArtist", "genre"]) {
132
+ const value = flag(argv, `--${name}`);
133
+ if (value !== undefined) patch[name] = value;
134
+ }
135
+ for (const name of ["track", "disc", "year"]) {
136
+ const value = flag(argv, `--${name}`);
137
+ if (value !== undefined) patch[name] = Number(value);
138
+ }
139
+ if (Object.keys(patch).length === 0) { console.error("nixamp library fix needs at least one of --title --artist --album --genre --track --disc --year"); return 2; }
140
+ return out(await call("PATCH", `/api/v1/library/items/${item}`, patch), (body) => itemLine(body["item"] as Record<string, unknown>));
141
+ }
142
+ case "remove": {
143
+ const [source] = rest;
144
+ if (!source) { console.error("nixamp library remove needs a source id"); return 2; }
145
+ return out(await call("DELETE", `/api/v1/library/sources/${source}`), (body) => `Forgot ${String(body["forgotten"])} items. No file was touched.`);
146
+ }
147
+ case "playlists":
148
+ return out(await call("GET", "/api/v1/library/playlists"), (body) =>
149
+ ((body["playlists"] as Record<string, unknown>[]) ?? []).map((one) => `${String(one["id"])} ${String(one["name"])} (${String(one["count"])})`).join("\n") || "No playlists yet.");
150
+ case "playlist": {
151
+ const [verb, first, ...others] = rest;
152
+ const showList = (body: Record<string, unknown>): string => {
153
+ const list = body["playlist"] as Record<string, unknown>;
154
+ const items = (list["items"] as Record<string, unknown>[] | undefined) ?? [];
155
+ return [`${String(list["id"])} ${String(list["name"])} (revision ${String(list["revision"])})`, ...items.map((one, at) => ` ${at + 1}. ${itemLine(one)}`)].join("\n");
156
+ };
157
+ if (verb === "new" && first) return out(await call("POST", "/api/v1/library/playlists", { name: first, items: others }), showList);
158
+ if (verb === "show" && first) return out(await call("GET", `/api/v1/library/playlists/${first}`), showList);
159
+ if (verb === "add" && first && others.length) return out(await call("POST", `/api/v1/library/playlists/${first}/items`, { items: others }), showList);
160
+ if (verb === "delete" && first) return out(await call("DELETE", `/api/v1/library/playlists/${first}`), () => `Deleted playlist ${first}.`);
161
+ console.error("nixamp library playlist new NAME [ITEM...] | show ID | add ID ITEM... | delete ID");
162
+ return 2;
163
+ }
164
+ default:
165
+ console.log(LIBRARY_USAGE);
166
+ return 2;
167
+ }
168
+ } catch (error) {
169
+ if ((error as { unreachable?: boolean }).unreachable) {
170
+ console.error(`nixamp: ${(error as Error).message}`);
171
+ return 3;
172
+ }
173
+ throw error;
174
+ }
175
+ }