nixamp 0.35.0 → 0.36.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 (67) hide show
  1. package/dist/auto-stations.d.ts +66 -0
  2. package/dist/auto-stations.js +108 -0
  3. package/dist/channels.d.ts +1 -1
  4. package/dist/library-index.d.ts +2 -0
  5. package/dist/library-index.js +8 -0
  6. package/dist/library.d.ts +2 -0
  7. package/dist/library.js +1 -0
  8. package/dist/playlist-input.d.ts +9 -0
  9. package/dist/playlist-input.js +30 -8
  10. package/dist/server.d.ts +4 -0
  11. package/dist/server.js +89 -1
  12. package/dist/station-breaks.d.ts +19 -0
  13. package/dist/station-breaks.js +92 -0
  14. package/dist/station-cli.js +22 -0
  15. package/dist/station-guide.d.ts +4 -0
  16. package/dist/station-guide.js +86 -0
  17. package/dist/station-mcp.d.ts +99 -0
  18. package/dist/station-mcp.js +16 -0
  19. package/dist/station-page.d.ts +1 -1
  20. package/dist/station-page.js +20 -5
  21. package/dist/station-routes.d.ts +5 -0
  22. package/dist/station-routes.js +30 -2
  23. package/dist/station-runner.d.ts +2 -0
  24. package/dist/station-runner.js +3 -0
  25. package/dist/station-usage.d.ts +1 -1
  26. package/dist/station-usage.js +2 -0
  27. package/dist/stations.d.ts +14 -0
  28. package/dist/stations.js +26 -0
  29. package/package.json +1 -1
  30. package/src/auto-stations.ts +171 -0
  31. package/src/channels.ts +1 -1
  32. package/src/library-index.ts +5 -0
  33. package/src/library.ts +3 -0
  34. package/src/playlist-input.ts +31 -8
  35. package/src/server.ts +93 -2
  36. package/src/station-breaks.ts +93 -0
  37. package/src/station-cli.ts +21 -0
  38. package/src/station-guide.ts +92 -0
  39. package/src/station-mcp.ts +16 -0
  40. package/src/station-page.ts +20 -5
  41. package/src/station-routes.ts +34 -2
  42. package/src/station-runner.ts +5 -0
  43. package/src/station-usage.ts +2 -0
  44. package/src/stations.ts +37 -0
  45. package/web/dist/assets/{ar-Doh06ZMu.js → ar-Ck-FmDpQ.js} +1 -1
  46. package/web/dist/assets/{cs-C_lZl4jI.js → cs-CPJY3K69.js} +1 -1
  47. package/web/dist/assets/{da-CSBBA_ZI.js → da-BnWtLL09.js} +1 -1
  48. package/web/dist/assets/{de-zorW0tO_.js → de-CHevIt93.js} +1 -1
  49. package/web/dist/assets/{es-DBUl3DGo.js → es-B6wpaUMv.js} +1 -1
  50. package/web/dist/assets/{fi-DpUx7XY-.js → fi-8fKiz2AA.js} +1 -1
  51. package/web/dist/assets/{fr-Cg_xppCf.js → fr-0GgY52Sm.js} +1 -1
  52. package/web/dist/assets/{hi-PD1kdcf2.js → hi-BlsrUAw-.js} +1 -1
  53. package/web/dist/assets/{hls-3VKVEQE3-BEEk729h.js → hls-3VKVEQE3-BZd3zjlr.js} +1 -1
  54. package/web/dist/assets/{hu-C1fGbjZO.js → hu-D3i_6i5w.js} +1 -1
  55. package/web/dist/assets/{id-DRBhZ8KM.js → id-CLLAXbOP.js} +1 -1
  56. package/web/dist/assets/{it-Ci3OZV2-.js → it-BU13BsSn.js} +1 -1
  57. package/web/dist/assets/{main-CEJhJH5y.js → main-C1ggd2H7.js} +2 -2
  58. package/web/dist/assets/{mpegts-y-HAxt1b.js → mpegts-BaoQnMCf.js} +1 -1
  59. package/web/dist/assets/{mpegts-LO6RVLD6-DPrv1ran.js → mpegts-LO6RVLD6-YBT-W6xc.js} +1 -1
  60. package/web/dist/assets/{nl-CwWOHKbv.js → nl-DEshyyt7.js} +1 -1
  61. package/web/dist/assets/{ru-BWlIGsoL.js → ru-D7sPOc6m.js} +1 -1
  62. package/web/dist/assets/{sv-Gb1cLNsc.js → sv-sdrsKAiG.js} +1 -1
  63. package/web/dist/assets/{uk-MZUnyyuR.js → uk-_lz6hkvo.js} +1 -1
  64. package/web/dist/assets/{vi-DNWS5fxc.js → vi-D81wl7uk.js} +1 -1
  65. package/web/dist/assets/{zh-BcOiladF.js → zh-BqMqEom6.js} +1 -1
  66. package/web/dist/index.html +1 -1
  67. package/web/dist/sw.js +22 -22
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The station guide: every station on this server its owner made public
3
+ * and listed, what is on each, and a way in. What a Moshpit name like
4
+ * live.streaming opens.
5
+ *
6
+ * Like the station page, the same bytes for every server: it asks
7
+ * /api/v1/stations/guide for the list and writes it in as text.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ const STYLE = `
11
+ :root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}
12
+ @media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}
13
+ *{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}
14
+ main{max-width:880px;margin:0 auto;padding:40px 16px}
15
+ h1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}
16
+ h2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}
17
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
18
+ a.station{display:block;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;color:inherit;text-decoration:none}
19
+ a.station:hover,a.station:focus-visible{border-color:var(--accent);outline:none}
20
+ .name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}
21
+ .live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}
22
+ footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
23
+ `;
24
+ const SCRIPT = `
25
+ (async () => {
26
+ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
27
+ const draw = (stations) => {
28
+ for (const [kind, box] of [["audio", "#radio"], ["video", "#tv"]]) {
29
+ const grid = document.querySelector(box);
30
+ grid.replaceChildren();
31
+ const list = stations.filter((s) => s.kind === kind);
32
+ document.querySelector(box + "-title").hidden = list.length === 0;
33
+ for (const s of list) {
34
+ const a = el("a", "station");
35
+ a.href = s.page;
36
+ a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
37
+ el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
38
+ grid.append(a);
39
+ }
40
+ }
41
+ document.querySelector("#empty").hidden = stations.length > 0;
42
+ };
43
+ const load = async () => {
44
+ try {
45
+ const r = await fetch("/api/v1/stations/guide", { cache: "no-store" });
46
+ if (r.ok) draw((await r.json()).stations);
47
+ } catch {}
48
+ };
49
+ await load();
50
+ setInterval(load, 15000);
51
+ })();
52
+ `;
53
+ const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
54
+ export const GUIDE_HTML = `<!doctype html>
55
+ <html lang="en">
56
+ <head>
57
+ <meta charset="utf-8">
58
+ <meta name="viewport" content="width=device-width, initial-scale=1">
59
+ <title>Live · stations</title>
60
+ <meta name="description" content="Radio and TV stations, live now.">
61
+ <link rel="icon" href="data:,">
62
+ <style>${STYLE}</style>
63
+ </head>
64
+ <body>
65
+ <main>
66
+ <h1>Live</h1>
67
+ <p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
68
+ <h2 id="radio-title" hidden>Radio</h2>
69
+ <div id="radio" class="grid" aria-live="polite"></div>
70
+ <h2 id="tv-title" hidden>TV</h2>
71
+ <div id="tv" class="grid" aria-live="polite"></div>
72
+ <p id="empty" class="empty">Nothing is on the air here yet.</p>
73
+ <footer>Played by <a href="https://nixamp.com">nixamp</a>, on the Moshpit.</footer>
74
+ </main>
75
+ <script>${SCRIPT}</script>
76
+ </body>
77
+ </html>
78
+ `;
79
+ export const GUIDE_CSP = [
80
+ "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
81
+ "img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
82
+ ].join("; ");
83
+ /** The hosts whose front page is the guide (NIXAMP_GUIDE_HOSTS, comma-separated). */
84
+ export function guideHosts(env = process.env) {
85
+ return new Set((env["NIXAMP_GUIDE_HOSTS"] ?? "").split(",").map((one) => one.trim().toLowerCase()).filter(Boolean));
86
+ }
@@ -22,6 +22,13 @@ export declare const STATION_TOOLS: ({
22
22
  listed?: undefined;
23
23
  basis?: undefined;
24
24
  note?: undefined;
25
+ video?: undefined;
26
+ minItems?: undefined;
27
+ minFilms?: undefined;
28
+ maxVideoRunning?: undefined;
29
+ breaksEvery?: undefined;
30
+ start?: undefined;
31
+ off?: undefined;
25
32
  id?: undefined;
26
33
  private?: undefined;
27
34
  };
@@ -55,6 +62,13 @@ export declare const STATION_TOOLS: ({
55
62
  listed?: undefined;
56
63
  basis?: undefined;
57
64
  note?: undefined;
65
+ video?: undefined;
66
+ minItems?: undefined;
67
+ minFilms?: undefined;
68
+ maxVideoRunning?: undefined;
69
+ breaksEvery?: undefined;
70
+ start?: undefined;
71
+ off?: undefined;
58
72
  private?: undefined;
59
73
  };
60
74
  required: string[];
@@ -110,6 +124,13 @@ export declare const STATION_TOOLS: ({
110
124
  listed?: undefined;
111
125
  basis?: undefined;
112
126
  note?: undefined;
127
+ video?: undefined;
128
+ minItems?: undefined;
129
+ minFilms?: undefined;
130
+ maxVideoRunning?: undefined;
131
+ breaksEvery?: undefined;
132
+ start?: undefined;
133
+ off?: undefined;
113
134
  id?: undefined;
114
135
  private?: undefined;
115
136
  };
@@ -163,6 +184,13 @@ export declare const STATION_TOOLS: ({
163
184
  listed?: undefined;
164
185
  basis?: undefined;
165
186
  note?: undefined;
187
+ video?: undefined;
188
+ minItems?: undefined;
189
+ minFilms?: undefined;
190
+ maxVideoRunning?: undefined;
191
+ breaksEvery?: undefined;
192
+ start?: undefined;
193
+ off?: undefined;
166
194
  private?: undefined;
167
195
  };
168
196
  required: string[];
@@ -198,6 +226,13 @@ export declare const STATION_TOOLS: ({
198
226
  listed?: undefined;
199
227
  basis?: undefined;
200
228
  note?: undefined;
229
+ video?: undefined;
230
+ minItems?: undefined;
231
+ minFilms?: undefined;
232
+ maxVideoRunning?: undefined;
233
+ breaksEvery?: undefined;
234
+ start?: undefined;
235
+ off?: undefined;
201
236
  private?: undefined;
202
237
  };
203
238
  required: string[];
@@ -241,6 +276,13 @@ export declare const STATION_TOOLS: ({
241
276
  note: {
242
277
  readonly type: "string";
243
278
  };
279
+ video?: undefined;
280
+ minItems?: undefined;
281
+ minFilms?: undefined;
282
+ maxVideoRunning?: undefined;
283
+ breaksEvery?: undefined;
284
+ start?: undefined;
285
+ off?: undefined;
244
286
  private?: undefined;
245
287
  };
246
288
  required: string[];
@@ -269,6 +311,63 @@ export declare const STATION_TOOLS: ({
269
311
  type: "string";
270
312
  description: string;
271
313
  };
314
+ video: {
315
+ type: string;
316
+ };
317
+ minItems: {
318
+ type: string;
319
+ };
320
+ minFilms: {
321
+ type: string;
322
+ };
323
+ maxVideoRunning: {
324
+ type: string;
325
+ };
326
+ breaksEvery: {
327
+ type: string;
328
+ };
329
+ start: {
330
+ type: string;
331
+ };
332
+ off: {
333
+ type: string;
334
+ };
335
+ id?: undefined;
336
+ private?: undefined;
337
+ };
338
+ required?: undefined;
339
+ };
340
+ } | {
341
+ name: string;
342
+ description: string;
343
+ inputSchema: {
344
+ type: string;
345
+ properties: {
346
+ playlist?: undefined;
347
+ idempotencyKey?: undefined;
348
+ name?: undefined;
349
+ description?: undefined;
350
+ folder?: undefined;
351
+ items?: undefined;
352
+ shuffle?: undefined;
353
+ repeat?: undefined;
354
+ bitrate?: undefined;
355
+ action?: undefined;
356
+ visibility?: undefined;
357
+ listed?: undefined;
358
+ basis?: undefined;
359
+ note?: undefined;
360
+ video?: undefined;
361
+ minItems?: undefined;
362
+ minFilms?: undefined;
363
+ maxVideoRunning?: undefined;
364
+ breaksEvery?: undefined;
365
+ start?: undefined;
366
+ off?: undefined;
367
+ server: {
368
+ type: "string";
369
+ description: string;
370
+ };
272
371
  id: {
273
372
  type: "string";
274
373
  description: string;
@@ -78,6 +78,18 @@ export const STATION_TOOLS = [
78
78
  required: ["id", "visibility"],
79
79
  },
80
80
  },
81
+ {
82
+ name: "station_auto",
83
+ description: "A station for every genre in the library (radio from music tags, TV from film genres), kept up to date after every library scan, with a short break between songs and films. Unlisted; publishing stays a separate step. off: true stops the automation.",
84
+ inputSchema: {
85
+ type: "object",
86
+ properties: {
87
+ server: SERVER,
88
+ video: { type: "boolean" }, minItems: { type: "integer" }, minFilms: { type: "integer" },
89
+ maxVideoRunning: { type: "integer" }, breaksEvery: { type: "integer" }, start: { type: "boolean" }, off: { type: "boolean" },
90
+ },
91
+ },
92
+ },
81
93
  {
82
94
  name: "station_unpublish",
83
95
  description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
@@ -134,6 +146,10 @@ export async function callStationTool(name, args, options = {}) {
134
146
  const all = answer.body["stations"] ?? [];
135
147
  return say(all.length === 0 ? "No stations on this server yet." : all.map(line).join("\n"));
136
148
  }
149
+ if (name === "station_auto") {
150
+ const answer = await call("POST", "/api/v1/stations/auto", pick(["video", "minItems", "minFilms", "maxVideoRunning", "breaksEvery", "start", "off"]));
151
+ return answer.ok ? say(JSON.stringify(answer.body["report"], null, 2)) : refused(answer.body);
152
+ }
137
153
  if (!/^[a-z0-9]{10}$/.test(id) && name !== "station_create")
138
154
  return say("Which station? Pass its id from station_list.", true);
139
155
  if (name === "station_get") {
@@ -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, 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";
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.screen{display:block;width:100%;aspect-ratio:16/9;background:#000;border-radius:10px;margin:12px 0}\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 <video id=\"screen\" class=\"screen\" playsinline preload=\"none\" hidden></video>\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 // Sound, or a TV station's picture: the element is chosen by the first state.\n let audio = new Audio();\n audio.preload = \"none\";\n let wired = false;\n const wire = () => {\n if (wired) return;\n wired = true;\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 };\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 if (s.kind === \"video\" && audio.tagName !== \"VIDEO\") {\n audio = $(\"#screen\");\n audio.hidden = false;\n wired = false;\n wire();\n }\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 wire();\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;
@@ -29,6 +29,7 @@ button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(-
29
29
  label{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}
30
30
  input[type=range]{width:120px;accent-color:var(--accent)}
31
31
  .small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}
32
+ .screen{display:block;width:100%;aspect-ratio:16/9;background:#000;border-radius:10px;margin:12px 0}
32
33
  .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
33
34
  @media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}
34
35
  @keyframes pulse{50%{opacity:.35}}
@@ -40,13 +41,29 @@ const SCRIPT = `
40
41
  const q = "?t=" + encodeURIComponent(token);
41
42
  const api = "/api/v1/stations/" + encodeURIComponent(id);
42
43
  const $ = (s) => document.querySelector(s);
43
- const audio = new Audio();
44
+ // Sound, or a TV station's picture: the element is chosen by the first state.
45
+ let audio = new Audio();
44
46
  audio.preload = "none";
47
+ let wired = false;
48
+ const wire = () => {
49
+ if (wired) return;
50
+ wired = true;
51
+ audio.addEventListener("playing", () => { if (retry) say("Back on"); retry = 0; if (last) draw(last); });
52
+ audio.addEventListener("error", again);
53
+ audio.addEventListener("ended", again);
54
+ audio.addEventListener("stalled", () => { if (wanted && audio.readyState < 3) again(); });
55
+ };
45
56
  let wanted = false, retry = 0, timer = 0, last = null, live = false;
46
57
  const chip = (state, text) => { const c = $("#state"); c.className = "chip " + state; c.textContent = text; };
47
58
  const say = (text) => { $("#announce").textContent = text; };
48
59
  const draw = (s) => {
49
60
  last = s;
61
+ if (s.kind === "video" && audio.tagName !== "VIDEO") {
62
+ audio = $("#screen");
63
+ audio.hidden = false;
64
+ wired = false;
65
+ wire();
66
+ }
50
67
  document.title = s.name + " · nixamp";
51
68
  $("#name").textContent = s.name;
52
69
  $("#about").textContent = s.description || "";
@@ -100,10 +117,7 @@ const SCRIPT = `
100
117
  clearTimeout(timer);
101
118
  timer = setTimeout(join, Math.min(15000, 1000 * 2 ** Math.min(retry, 4)));
102
119
  };
103
- audio.addEventListener("playing", () => { if (retry) say("Back on"); retry = 0; if (last) draw(last); });
104
- audio.addEventListener("error", again);
105
- audio.addEventListener("ended", again);
106
- audio.addEventListener("stalled", () => { if (wanted && audio.readyState < 3) again(); });
120
+ wire();
107
121
  $("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
108
122
  $("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
109
123
  poll();
@@ -126,6 +140,7 @@ const PAGE = `<!doctype html>
126
140
  <main>
127
141
  <div class="card">
128
142
  <span id="state" class="chip unavailable" role="status">Loading</span>
143
+ <video id="screen" class="screen" playsinline preload="none" hidden></video>
129
144
  <h1 id="name">&nbsp;</h1>
130
145
  <p id="about" class="about" hidden></p>
131
146
  <div class="now" aria-live="polite"><div id="title" class="title"></div><div id="by" class="by"></div></div>
@@ -51,6 +51,11 @@ export interface StationContext {
51
51
  throttle?: () => {
52
52
  retryAfter: number;
53
53
  } | null;
54
+ /** Auto stations: run a sync with these settings (and keep them), or read them. */
55
+ auto?: {
56
+ sync: (input: Record<string, unknown>) => Promise<unknown>;
57
+ setting: () => unknown;
58
+ };
54
59
  }
55
60
  declare function links(ctx: StationContext, station: Station): {
56
61
  page: string;
@@ -4,7 +4,7 @@ import { StationError, channelOf, tokensMatch, } from "./stations.js";
4
4
  const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events)$/;
5
5
  /** Whether this path is one a station's own link may open, without the server's key. */
6
6
  export function isStationListenPath(path) {
7
- return LISTEN.test(path);
7
+ return LISTEN.test(path) || path === "/api/v1/stations/guide";
8
8
  }
9
9
  function fail(ctx, response, error) {
10
10
  if (error instanceof StationError) {
@@ -43,7 +43,10 @@ export function publicView(ctx, station) {
43
43
  state,
44
44
  // Tags, or nothing: a bare file name is the owner's, not the public's.
45
45
  nowPlaying: track && (track.title || track.artist) ? track : null,
46
- format: state === "stopped" || state === "offline" ? null : { codec: "mp3", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 },
46
+ kind: station.video ? "video" : "audio",
47
+ format: state === "stopped" || state === "offline" ? null : station.video
48
+ ? { codec: "h264+aac", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 }
49
+ : { codec: "mp3", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 },
47
50
  listeners: ctx.runner.listeners(station.id),
48
51
  };
49
52
  }
@@ -68,6 +71,15 @@ export async function handleStations(request, response, path, url, ctx) {
68
71
  if (path !== "/api/v1/stations" && !path.startsWith("/api/v1/stations/"))
69
72
  return false;
70
73
  const method = request.method ?? "GET";
74
+ // The public guide: only stations their owner made public and listed, with
75
+ // their links, because public is what that means. Nothing else, ever.
76
+ if (path === "/api/v1/stations/guide" && (method === "GET" || method === "HEAD")) {
77
+ const listed = ctx.store.list().filter((one) => one.visibility === "public" && one.listed);
78
+ ctx.json(response, 200, {
79
+ stations: listed.map((one) => ({ ...publicView(ctx, one), page: links(ctx, one).page, m3u: links(ctx, one).m3u })),
80
+ });
81
+ return true;
82
+ }
71
83
  const listen = LISTEN.exec(path);
72
84
  if (listen) {
73
85
  const station = ctx.store.get(listen[1]);
@@ -261,6 +273,22 @@ export async function handleStations(request, response, path, url, ctx) {
261
273
  ctx.json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
262
274
  return true;
263
275
  }
276
+ if (path === "/api/v1/stations/auto") {
277
+ if (!ctx.auto) {
278
+ ctx.json(response, 404, { error: "auto stations are not available here", code: "not_found" });
279
+ return true;
280
+ }
281
+ if (method === "GET") {
282
+ ctx.json(response, 200, { setting: ctx.auto.setting() });
283
+ return true;
284
+ }
285
+ if (method === "POST") {
286
+ ctx.json(response, 200, { report: await ctx.auto.sync(await body()) });
287
+ return true;
288
+ }
289
+ ctx.json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
290
+ return true;
291
+ }
264
292
  const match = /^\/api\/v1\/stations\/([a-z0-9]{10})(?:\/([a-z-]+))?$/.exec(path);
265
293
  if (!match) {
266
294
  ctx.json(response, 404, { error: "no such station", code: "not_found" });
@@ -26,6 +26,8 @@ export interface RunnerDeps {
26
26
  /** Told when a station is restarted after an unexpected end, and when a file will not play. */
27
27
  onRestart?: (id: string) => void;
28
28
  onFailure?: (id: string) => void;
29
+ /** A file for a break on a radio (false) or TV (true) station, or null for none. */
30
+ breaks?: (video: boolean) => Promise<string | null>;
29
31
  }
30
32
  /** Something a listener is connected through, that can be ended. */
31
33
  export interface Ending {
@@ -107,6 +107,9 @@ export class StationRunner {
107
107
  return now ? this.playable(now) : [];
108
108
  },
109
109
  excluded: (file) => store.isExcluded(id, file),
110
+ ...(station.breaksEvery > 0 && this.deps.breaks
111
+ ? { breaks: { everySeconds: station.breaksEvery, next: () => this.deps.breaks(station.video) } }
112
+ : {}),
110
113
  onFailure: ({ file, reason }) => {
111
114
  this.errors.set(id, reason);
112
115
  this.deps.onFailure?.(id);
@@ -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 | --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
+ 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 nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\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.";
@@ -13,6 +13,8 @@ export const USAGE = `nixamp station — make a collection into a station
13
13
  nixamp station unpublish ID [--private]
14
14
  nixamp station link ID [--rotate]
15
15
  nixamp station delete ID
16
+ nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]
17
+ a station per genre, kept in step with the library (--off stops it)
16
18
 
17
19
  --json machine-readable output
18
20
  --url, --key another server, as with \`nixamp admin\`
@@ -26,6 +26,12 @@ export interface Station {
26
26
  listenToken: string;
27
27
  /** Audio bitrate of the station's rendition, in kbps. */
28
28
  bitrate: number;
29
+ /** A TV station: pictures as well as sound. */
30
+ video: boolean;
31
+ /** A break (advert or ident) at most this often, in seconds; 0 for none. */
32
+ breaksEvery: number;
33
+ /** Made and kept up to date by auto stations (e.g. "genre:audio:rock"); "" for one a person made. */
34
+ auto: string;
29
35
  rights: Rights | null;
30
36
  /** nixamp.com account that made it, "" when made with the control key. */
31
37
  owner: string;
@@ -59,6 +65,8 @@ export interface StationInput {
59
65
  shuffle?: unknown;
60
66
  repeat?: unknown;
61
67
  bitrate?: unknown;
68
+ video?: unknown;
69
+ breaksEvery?: unknown;
62
70
  }
63
71
  /** The stations of one server, kept in one file beside its keys. */
64
72
  export declare class StationStore {
@@ -77,6 +85,10 @@ export declare class StationStore {
77
85
  /** The station a create with this key already made. */
78
86
  byIdempotencyKey(key: string): Station | undefined;
79
87
  create(input: StationInput, items: string[], owner: string, now?: number, idempotencyKey?: string): Station;
88
+ private breaksFrom;
89
+ /** Mark a station as made by auto stations, so a later sync updates it rather than making another. */
90
+ markAuto(id: string, key: string): Station;
91
+ byAuto(key: string): Station | undefined;
80
92
  private bitrateFrom;
81
93
  /** Name, words, files and rotation. Who may listen is changed by publish/unpublish. */
82
94
  update(id: string, input: StationInput, items: string[] | null, now?: number): Station;
@@ -126,6 +138,8 @@ export interface PublicStation {
126
138
  description: string;
127
139
  visibility: Visibility;
128
140
  state: Observed;
141
+ /** Radio or TV. */
142
+ kind: "audio" | "video";
129
143
  nowPlaying: {
130
144
  title: string;
131
145
  artist: string;
package/dist/stations.js CHANGED
@@ -121,6 +121,9 @@ export class StationStore {
121
121
  owner: one.owner ?? "",
122
122
  description: one.description ?? "",
123
123
  idempotencyKey: one.idempotencyKey ?? "",
124
+ video: one.video ?? false,
125
+ breaksEvery: one.breaksEvery ?? 0,
126
+ auto: one.auto ?? "",
124
127
  });
125
128
  }
126
129
  }
@@ -169,6 +172,9 @@ export class StationStore {
169
172
  desired: "stopped",
170
173
  listenToken: newToken(),
171
174
  bitrate: this.bitrateFrom(input.bitrate),
175
+ video: input.video === true,
176
+ breaksEvery: this.breaksFrom(input.breaksEvery),
177
+ auto: "",
172
178
  rights: null,
173
179
  owner,
174
180
  failures: {},
@@ -180,6 +186,24 @@ export class StationStore {
180
186
  this.save();
181
187
  return station;
182
188
  }
189
+ breaksFrom(value) {
190
+ if (value === undefined || value === null || value === 0)
191
+ return 0;
192
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 120 || value > 86_400) {
193
+ throw new StationError("invalid_breaks", "breaksEvery is seconds between breaks, from 120 to 86400, or 0 for none");
194
+ }
195
+ return value;
196
+ }
197
+ /** Mark a station as made by auto stations, so a later sync updates it rather than making another. */
198
+ markAuto(id, key) {
199
+ const station = this.require(id);
200
+ station.auto = key;
201
+ this.save();
202
+ return station;
203
+ }
204
+ byAuto(key) {
205
+ return [...this.stations.values()].find((one) => one.auto === key);
206
+ }
183
207
  bitrateFrom(value) {
184
208
  if (value === undefined)
185
209
  return DEFAULT_BITRATE;
@@ -205,6 +229,8 @@ export class StationStore {
205
229
  station.repeat = input.repeat !== false;
206
230
  if (input.bitrate !== undefined)
207
231
  station.bitrate = this.bitrateFrom(input.bitrate);
232
+ if (input.breaksEvery !== undefined)
233
+ station.breaksEvery = this.breaksFrom(input.breaksEvery);
208
234
  if (items !== null) {
209
235
  if (items.length === 0)
210
236
  throw new StationError("empty_station", "a station needs at least one file that plays");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.35.0",
3
+ "version": "0.36.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",