nixamp 0.41.2 → 0.43.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.
- package/dist/ai-keys.d.ts +1 -1
- package/dist/ai-keys.js +2 -0
- package/dist/lyrics-publish.d.ts +56 -0
- package/dist/lyrics-publish.js +194 -0
- package/dist/lyrics.d.ts +67 -0
- package/dist/lyrics.js +145 -0
- package/dist/server.d.ts +7 -1
- package/dist/server.js +64 -3
- package/dist/station-cli.js +42 -0
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +39 -3
- package/dist/station-routes.d.ts +9 -0
- package/dist/station-routes.js +39 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +5 -0
- package/dist/stations.d.ts +2 -0
- package/dist/stations.js +6 -0
- package/dist/support.d.ts +65 -0
- package/dist/support.js +187 -5
- package/dist/track-facts.d.ts +4 -0
- package/dist/track-facts.js +8 -4
- package/package.json +1 -1
- package/src/ai-keys.ts +2 -0
- package/src/lyrics-publish.ts +203 -0
- package/src/lyrics.ts +161 -0
- package/src/server.ts +68 -3
- package/src/station-cli.ts +40 -0
- package/src/station-page.ts +39 -3
- package/src/station-routes.ts +46 -1
- package/src/station-usage.ts +5 -0
- package/src/stations.ts +4 -0
- package/src/support.ts +201 -6
- package/src/track-facts.ts +11 -2
- package/web/dist/assets/{ar-DJ2gQPOo.js → ar-DJAQCP6v.js} +1 -1
- package/web/dist/assets/{cs-HEbqVNx5.js → cs-BYeHBx_F.js} +1 -1
- package/web/dist/assets/{da-C87QYmEx.js → da-CR89gWR7.js} +1 -1
- package/web/dist/assets/{de-BqR-HScZ.js → de-DPFsA0HC.js} +1 -1
- package/web/dist/assets/{es-DjzlzxAQ.js → es-Bg5Vx2bB.js} +1 -1
- package/web/dist/assets/{fi-2Is-qqeQ.js → fi-BVpY7Uww.js} +1 -1
- package/web/dist/assets/{fr-CTEDRgPI.js → fr-DsSofhGt.js} +1 -1
- package/web/dist/assets/{hi-CU5X7c5G.js → hi-DkSvHtix.js} +1 -1
- package/web/dist/assets/{hu-CQAs9Ye7.js → hu-D4Z2GpU0.js} +1 -1
- package/web/dist/assets/{id-5dd2TXs7.js → id-BzOPbgSu.js} +1 -1
- package/web/dist/assets/{it-B69_ME4E.js → it-BAT4YCiY.js} +1 -1
- package/web/dist/assets/{main-DRvUTBup.js → main-Dpj3oiHi.js} +2 -2
- package/web/dist/assets/{nl-Drb6oxnO.js → nl-CiTdg-si.js} +1 -1
- package/web/dist/assets/{ru-C4u8G5A0.js → ru-CJBuL5JA.js} +1 -1
- package/web/dist/assets/{station--cxhU1wr.css → station-BwDP2cuf.css} +1 -1
- package/web/dist/assets/station-DXRmjrqv.js +3 -0
- package/web/dist/assets/{sv-Bi-av_7u.js → sv-Dptz7Qud.js} +1 -1
- package/web/dist/assets/{uk-DZVF8mrT.js → uk-yL3tD6Kt.js} +1 -1
- package/web/dist/assets/{vi-B2UJxM5O.js → vi-CuVvOIut.js} +1 -1
- package/web/dist/assets/{zh-XRXZxCqU.js → zh-kHhOVHwJ.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +9 -2
- package/web/dist/sw.js +21 -21
- package/web/dist/assets/station-CdQ9LR6K.js +0 -1
package/dist/station-cli.js
CHANGED
|
@@ -265,6 +265,8 @@ export async function station(argv) {
|
|
|
265
265
|
return await aiCommand(argv.slice(1), call, show, json);
|
|
266
266
|
case "shares":
|
|
267
267
|
return await sharesCommand(argv.slice(1), call, show);
|
|
268
|
+
case "lyrics":
|
|
269
|
+
return await lyricsCommand(argv.slice(1), call, show, json);
|
|
268
270
|
case "holder":
|
|
269
271
|
case "holders": {
|
|
270
272
|
const sub = argv[1] ?? "list";
|
|
@@ -417,6 +419,46 @@ async function aiCommand(argv, call, show, json) {
|
|
|
417
419
|
return 2;
|
|
418
420
|
}
|
|
419
421
|
}
|
|
422
|
+
/** `nixamp station lyrics ...`: our own songs' words, published back to nichedb.dev. */
|
|
423
|
+
async function lyricsCommand(argv, call, show, json) {
|
|
424
|
+
const [sub, id] = argv;
|
|
425
|
+
switch (sub) {
|
|
426
|
+
case undefined:
|
|
427
|
+
case "status":
|
|
428
|
+
return show(await call("GET", "/api/v1/stations/lyrics"), (body) => {
|
|
429
|
+
const stations = body["stations"] ?? [];
|
|
430
|
+
const last = body["last"];
|
|
431
|
+
return [
|
|
432
|
+
`key ${body["key"] ? "set" : "- (nixamp station lyrics key, the value on stdin)"}`,
|
|
433
|
+
`target ${String(body["target"])}`,
|
|
434
|
+
`published ${String(body["published"])} files${body["running"] ? "; publishing now" : ""}`,
|
|
435
|
+
...(last ? [`last run ${new Date(last.at).toISOString()}: ${last.reports.map((one) => `${String(one["station"])} +${String(one["published"])}`).join(", ") || "nothing eligible"}`] : []),
|
|
436
|
+
...(body["lastError"] ? [`problem ${String(body["lastError"])}`] : []),
|
|
437
|
+
"",
|
|
438
|
+
...stations.map((one) => ` ${String(one["id"])} ${one["eligible"] ? "publishes" : "no "} ${String(one["published"]).padStart(4)}/${String(one["files"]).padEnd(5)} ${String(one["name"])}${one["eligible"] ? "" : ` (${String(one["why"])})`}`),
|
|
439
|
+
].join("\n");
|
|
440
|
+
});
|
|
441
|
+
case "publish": {
|
|
442
|
+
if (!id || id.startsWith("--")) {
|
|
443
|
+
console.error("nixamp station lyrics publish needs a station id");
|
|
444
|
+
return 2;
|
|
445
|
+
}
|
|
446
|
+
if (!json)
|
|
447
|
+
console.log("Publishing; a second between songs…");
|
|
448
|
+
return show(await call("POST", `/api/v1/stations/lyrics/${id}/publish`, {}), (body) => {
|
|
449
|
+
const report = body["report"];
|
|
450
|
+
return `${String(report["published"])} published, ${String(report["unchanged"])} already there, ${String(report["without"])} without words, ${String(report["failed"])} failed${report["stopped"] ? `\nstopped: ${String(report["stopped"])}` : ""}`;
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
case "key": {
|
|
454
|
+
const value = argv.includes("--clear") ? "" : await readStdin();
|
|
455
|
+
return show(await call("POST", "/api/v1/stations/ai/keys", { name: "NIXAMP_LYRICS_KEY", value }), (answer) => `nichedb lyrics key ${(answer["keys"] ?? {})["NIXAMP_LYRICS_KEY"] ? "set" : "cleared"}.`);
|
|
456
|
+
}
|
|
457
|
+
default:
|
|
458
|
+
console.error(`nixamp station lyrics: no command "${sub}".\n\n${USAGE}`);
|
|
459
|
+
return 2;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
420
462
|
/** `nixamp station shares ...` */
|
|
421
463
|
async function sharesCommand(argv, call, show) {
|
|
422
464
|
const [sub, id] = argv;
|
package/dist/station-page.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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.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\"> </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";
|
|
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}[hidden]{display:none!important}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.lyrics{margin:20px 0 0;border-top:1px solid var(--line);padding-top:12px}\n.lyrics summary{cursor:pointer;font-weight:600;min-height:24px}\n.lyrics .for{font-weight:400;color:var(--muted);font-size:.9rem}\n.words{margin-top:10px;line-height:1.6}\n.words p{margin:0 0 1em;white-space:pre-wrap;overflow-wrap:anywhere}\n.words p.sec{margin:1.2em 0 .3em;font-size:.75rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}\n.words p.sec:first-child{margin-top:0}\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\"> </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 <section id=\"lyrics\" class=\"lyrics\" aria-label=\"Lyrics\" hidden>\n <details id=\"lyrics-box\" open><summary>Lyrics<span id=\"lyrics-for\" class=\"for\"></span></summary><div id=\"words\" class=\"words\"></div></details>\n </section>\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 // The words of what is on, line by line; \"[Verse 1]\" as a small header. Text only, never HTML.\n let words = null;\n const lyrics = async () => {\n try {\n const r = await fetch(api + \"/lyrics\" + q, { cache: \"no-store\" });\n const got = r.ok ? (await r.json()).lyrics : null;\n const key = got ? got.title + \"\\n\" + got.text : \"\";\n if (key === words) return;\n words = key;\n $(\"#lyrics\").hidden = !got;\n if (!got) return;\n const out = [];\n let stanza = [];\n const flush = () => { if (stanza.length) { const p = document.createElement(\"p\"); p.textContent = stanza.join(\"\\n\"); out.push(p); stanza = []; } };\n for (const line of got.text.split(\"\\n\")) {\n if (/^\\s*\\[[^\\]]{1,160}\\]\\s*$/.test(line)) { flush(); const h = document.createElement(\"p\"); h.className = \"sec\"; h.textContent = line.trim().slice(1, -1); out.push(h); }\n else if (line.trim() === \"\") flush();\n else stanza.push(line);\n }\n flush();\n $(\"#words\").replaceChildren(...out);\n $(\"#lyrics-for\").textContent = got.title ? \" \u00B7 \" + got.title : \"\";\n } catch {}\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)); lyrics(); });\n events.addEventListener(\"now\", (e) => {\n const s = JSON.parse(e.data);\n setTimeout(() => { title(s); lyrics(); 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 if (window.matchMedia && matchMedia(\"(max-width: 640px)\").matches) $(\"#lyrics-box\").open = false;\n poll();\n lyrics();\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;
|
|
6
6
|
/** The built page: its own bundle, styles and player engines, only this server, never framed. */
|
package/dist/station-page.js
CHANGED
|
@@ -13,7 +13,7 @@ import { createHash } from "node:crypto";
|
|
|
13
13
|
const STYLE = `
|
|
14
14
|
:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}
|
|
15
15
|
@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}
|
|
16
|
-
*{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}
|
|
16
|
+
*{box-sizing:border-box}[hidden]{display:none!important}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
17
17
|
main{max-width:560px;margin:0 auto;padding:48px 16px}
|
|
18
18
|
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
|
|
19
19
|
h1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}
|
|
@@ -29,6 +29,13 @@ 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
|
+
.lyrics{margin:20px 0 0;border-top:1px solid var(--line);padding-top:12px}
|
|
33
|
+
.lyrics summary{cursor:pointer;font-weight:600;min-height:24px}
|
|
34
|
+
.lyrics .for{font-weight:400;color:var(--muted);font-size:.9rem}
|
|
35
|
+
.words{margin-top:10px;line-height:1.6}
|
|
36
|
+
.words p{margin:0 0 1em;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
37
|
+
.words p.sec{margin:1.2em 0 .3em;font-size:.75rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
|
|
38
|
+
.words p.sec:first-child{margin-top:0}
|
|
32
39
|
.screen{display:block;width:100%;aspect-ratio:16/9;background:#000;border-radius:10px;margin:12px 0}
|
|
33
40
|
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
34
41
|
@media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}
|
|
@@ -88,13 +95,37 @@ const SCRIPT = `
|
|
|
88
95
|
if (!wanted || audio.paused || audio.buffered.length === 0) return 0;
|
|
89
96
|
return Math.max(0, Math.min(30, audio.buffered.end(audio.buffered.length - 1) - audio.currentTime));
|
|
90
97
|
};
|
|
98
|
+
// The words of what is on, line by line; "[Verse 1]" as a small header. Text only, never HTML.
|
|
99
|
+
let words = null;
|
|
100
|
+
const lyrics = async () => {
|
|
101
|
+
try {
|
|
102
|
+
const r = await fetch(api + "/lyrics" + q, { cache: "no-store" });
|
|
103
|
+
const got = r.ok ? (await r.json()).lyrics : null;
|
|
104
|
+
const key = got ? got.title + "\\n" + got.text : "";
|
|
105
|
+
if (key === words) return;
|
|
106
|
+
words = key;
|
|
107
|
+
$("#lyrics").hidden = !got;
|
|
108
|
+
if (!got) return;
|
|
109
|
+
const out = [];
|
|
110
|
+
let stanza = [];
|
|
111
|
+
const flush = () => { if (stanza.length) { const p = document.createElement("p"); p.textContent = stanza.join("\\n"); out.push(p); stanza = []; } };
|
|
112
|
+
for (const line of got.text.split("\\n")) {
|
|
113
|
+
if (/^\\s*\\[[^\\]]{1,160}\\]\\s*$/.test(line)) { flush(); const h = document.createElement("p"); h.className = "sec"; h.textContent = line.trim().slice(1, -1); out.push(h); }
|
|
114
|
+
else if (line.trim() === "") flush();
|
|
115
|
+
else stanza.push(line);
|
|
116
|
+
}
|
|
117
|
+
flush();
|
|
118
|
+
$("#words").replaceChildren(...out);
|
|
119
|
+
$("#lyrics-for").textContent = got.title ? " · " + got.title : "";
|
|
120
|
+
} catch {}
|
|
121
|
+
};
|
|
91
122
|
const listen = () => {
|
|
92
123
|
if (!window.EventSource) return;
|
|
93
124
|
const events = new EventSource(api + "/events" + q);
|
|
94
|
-
events.addEventListener("hello", (e) => { live = true; title(JSON.parse(e.data)); });
|
|
125
|
+
events.addEventListener("hello", (e) => { live = true; title(JSON.parse(e.data)); lyrics(); });
|
|
95
126
|
events.addEventListener("now", (e) => {
|
|
96
127
|
const s = JSON.parse(e.data);
|
|
97
|
-
setTimeout(() => { title(s); say(s.nowPlaying ? "Now playing " + (s.nowPlaying.title || "") : ""); }, ahead() * 1000);
|
|
128
|
+
setTimeout(() => { title(s); lyrics(); say(s.nowPlaying ? "Now playing " + (s.nowPlaying.title || "") : ""); }, ahead() * 1000);
|
|
98
129
|
});
|
|
99
130
|
events.addEventListener("state", () => poll());
|
|
100
131
|
events.onerror = () => { live = false; };
|
|
@@ -120,7 +151,9 @@ const SCRIPT = `
|
|
|
120
151
|
wire();
|
|
121
152
|
$("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
|
|
122
153
|
$("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
|
|
154
|
+
if (window.matchMedia && matchMedia("(max-width: 640px)").matches) $("#lyrics-box").open = false;
|
|
123
155
|
poll();
|
|
156
|
+
lyrics();
|
|
124
157
|
listen();
|
|
125
158
|
setInterval(poll, 5000);
|
|
126
159
|
})();
|
|
@@ -148,6 +181,9 @@ const PAGE = `<!doctype html>
|
|
|
148
181
|
<button id="play" type="button" aria-pressed="false" disabled>Play</button>
|
|
149
182
|
<label>Volume <input id="volume" type="range" min="0" max="1" step="0.05" value="1"></label>
|
|
150
183
|
</div>
|
|
184
|
+
<section id="lyrics" class="lyrics" aria-label="Lyrics" hidden>
|
|
185
|
+
<details id="lyrics-box" open><summary>Lyrics<span id="lyrics-for" class="for"></span></summary><div id="words" class="words"></div></details>
|
|
186
|
+
</section>
|
|
151
187
|
<p class="small"><a id="m3u" href="#">Open in another player (.m3u)</a> · Pausing here does not pause the station.</p>
|
|
152
188
|
</div>
|
|
153
189
|
<p class="small">Played by <a href="https://nixamp.com">nixamp</a>.</p>
|
package/dist/station-routes.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ import type { Channels } from "./channels.ts";
|
|
|
17
17
|
import type { StationRunner } from "./station-runner.ts";
|
|
18
18
|
import { type Limits, type StationUsage } from "./station-metering.ts";
|
|
19
19
|
import { type TrackFacts } from "./track-facts.ts";
|
|
20
|
+
import type { Lyrics } from "./lyrics.ts";
|
|
21
|
+
import type { PublishReport } from "./lyrics-publish.ts";
|
|
20
22
|
import { type OwnerStation, type PublicStation, type Station, type StationStore } from "./stations.ts";
|
|
21
23
|
/** Whether this path is one a station's own link may open, without the server's key. */
|
|
22
24
|
export declare function isStationListenPath(path: string): boolean;
|
|
@@ -67,6 +69,13 @@ export interface StationContext {
|
|
|
67
69
|
bytes: Buffer;
|
|
68
70
|
type: string;
|
|
69
71
|
} | null>;
|
|
72
|
+
/** The words of a file, from its tags or nichedb; null when none are known. */
|
|
73
|
+
lyrics?: (station: Station, file: string) => Promise<Lyrics | null>;
|
|
74
|
+
/** Our own songs' words, published back to nichedb (lyrics-publish.ts). */
|
|
75
|
+
lyricsPublisher?: {
|
|
76
|
+
status: () => unknown;
|
|
77
|
+
publish: (station: Station) => Promise<PublishReport>;
|
|
78
|
+
};
|
|
70
79
|
/** Auto stations: run a sync with these settings (and keep them), or read them. */
|
|
71
80
|
auto?: {
|
|
72
81
|
sync: (input: Record<string, unknown>) => Promise<unknown>;
|
package/dist/station-routes.js
CHANGED
|
@@ -2,7 +2,7 @@ import { alertsFor } from "./station-metering.js";
|
|
|
2
2
|
import { aiFromTags } from "./track-facts.js";
|
|
3
3
|
import { StationError, channelOf, tokensMatch, } from "./stations.js";
|
|
4
4
|
/** The three things a station's link opens. */
|
|
5
|
-
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now|art)$/;
|
|
5
|
+
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now|art|lyrics)$/;
|
|
6
6
|
/** Whether this path is one a station's own link may open, without the server's key. */
|
|
7
7
|
export function isStationListenPath(path) {
|
|
8
8
|
return LISTEN.test(path) || path === "/api/v1/stations/guide";
|
|
@@ -199,6 +199,16 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
199
199
|
response.end(method === "HEAD" ? undefined : picture.bytes);
|
|
200
200
|
return true;
|
|
201
201
|
}
|
|
202
|
+
if (what === "lyrics") {
|
|
203
|
+
const file = ctx.runner.observed(station.id) === "live" || ctx.runner.observed(station.id) === "recovering" ? nowPlayingFile(ctx, station) : "";
|
|
204
|
+
const lyrics = file && ctx.lyrics ? await ctx.lyrics(station, file).catch(() => null) : null;
|
|
205
|
+
if (!lyrics) {
|
|
206
|
+
ctx.json(response, 404, { error: "no lyrics for what is playing", code: "not_found" });
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
ctx.json(response, 200, { lyrics: { title: lyrics.title, artist: lyrics.artist, text: lyrics.text, source: lyrics.source, language: lyrics.language } });
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
202
212
|
if (what === "now") {
|
|
203
213
|
const file = ctx.runner.observed(station.id) === "live" || ctx.runner.observed(station.id) === "recovering" ? nowPlayingFile(ctx, station) : "";
|
|
204
214
|
ctx.json(response, 200, { station: station.id, facts: file && ctx.facts ? await ctx.facts(station, file).catch(() => null) : null });
|
|
@@ -347,6 +357,34 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
347
357
|
ctx.json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
|
|
348
358
|
return true;
|
|
349
359
|
}
|
|
360
|
+
if (path === "/api/v1/stations/lyrics") {
|
|
361
|
+
if (!ctx.lyricsPublisher) {
|
|
362
|
+
ctx.json(response, 404, { error: "lyrics publishing is not available here", code: "not_found" });
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
if (method !== "GET") {
|
|
366
|
+
ctx.json(response, 405, { error: "GET", code: "method_not_allowed" });
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
ctx.json(response, 200, ctx.lyricsPublisher.status());
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
const publishLyrics = /^\/api\/v1\/stations\/lyrics\/([a-z0-9]{10})\/publish$/.exec(path);
|
|
373
|
+
if (publishLyrics) {
|
|
374
|
+
const target = ctx.store.get(publishLyrics[1]);
|
|
375
|
+
if (!ctx.lyricsPublisher || !target) {
|
|
376
|
+
ctx.json(response, 404, { error: "no such station", code: "not_found" });
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
if (method !== "POST") {
|
|
380
|
+
ctx.json(response, 405, { error: "POST", code: "method_not_allowed" });
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
const report = await ctx.lyricsPublisher.publish(target);
|
|
384
|
+
const refused = report.stopped.startsWith("not published") ? "not_eligible" : report.stopped.startsWith("no key") ? "no_key" : "";
|
|
385
|
+
ctx.json(response, refused ? 409 : 200, refused ? { error: report.stopped, code: refused, report } : { report });
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
350
388
|
const match = /^\/api\/v1\/stations\/([a-z0-9]{10})(?:\/([a-z-]+))?$/.exec(path);
|
|
351
389
|
if (!match) {
|
|
352
390
|
ctx.json(response, 404, { error: "no such station", code: "not_found" });
|
package/dist/station-usage.d.ts
CHANGED
|
@@ -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] [--breaks 600]\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 breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\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 guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station guide-peer URL [--remove]\n the guide also lists the public, listed stations of the server at URL\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 AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,\n NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in), from stdin\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):\n nixamp station holder list\n nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]\n nixamp station holder remove 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] [--breaks 600]\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 breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\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 guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station guide-peer URL [--remove]\n the guide also lists the public, listed stations of the server at URL\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 AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,\n NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in), from stdin\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):\n nixamp station lyrics the key, what was published, which stations may publish\n nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)\n nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)\n\n Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):\n nixamp station holder list\n nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]\n nixamp station holder remove 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.";
|
package/dist/station-usage.js
CHANGED
|
@@ -41,6 +41,11 @@ export const USAGE = `nixamp station — make a collection into a station
|
|
|
41
41
|
nixamp station shares approve|reject|delete SHARE
|
|
42
42
|
nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For
|
|
43
43
|
|
|
44
|
+
Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):
|
|
45
|
+
nixamp station lyrics the key, what was published, which stations may publish
|
|
46
|
+
nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)
|
|
47
|
+
nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)
|
|
48
|
+
|
|
44
49
|
Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):
|
|
45
50
|
nixamp station holder list
|
|
46
51
|
nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]
|
package/dist/stations.d.ts
CHANGED
|
@@ -78,6 +78,8 @@ export declare class StationStore {
|
|
|
78
78
|
write: (path: string, body: string) => void;
|
|
79
79
|
});
|
|
80
80
|
static fileFor(stateDir: string, port: number): string;
|
|
81
|
+
/** Told after every change is written (lyrics publishing listens). */
|
|
82
|
+
onSaved: (() => void) | null;
|
|
81
83
|
private save;
|
|
82
84
|
list(): Station[];
|
|
83
85
|
get(id: string): Station | undefined;
|
package/dist/stations.js
CHANGED
|
@@ -136,8 +136,14 @@ export class StationStore {
|
|
|
136
136
|
static fileFor(stateDir, port) {
|
|
137
137
|
return join(stateDir, `stations-${port}.json`);
|
|
138
138
|
}
|
|
139
|
+
/** Told after every change is written (lyrics publishing listens). */
|
|
140
|
+
onSaved = null;
|
|
139
141
|
save() {
|
|
140
142
|
this.io.write(this.file, `${JSON.stringify({ version: 1, stations: [...this.stations.values()] }, null, 2)}\n`);
|
|
143
|
+
try {
|
|
144
|
+
this.onSaved?.();
|
|
145
|
+
}
|
|
146
|
+
catch { /* a listener's trouble is not the store's */ }
|
|
141
147
|
}
|
|
142
148
|
list() {
|
|
143
149
|
return [...this.stations.values()].sort((a, b) => a.createdAt - b.createdAt);
|
package/dist/support.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export declare class Support {
|
|
|
20
20
|
fetcher?: typeof fetch;
|
|
21
21
|
now?: () => number;
|
|
22
22
|
});
|
|
23
|
+
/** For a subscription's renewals, which ask CoinPay about their payments. */
|
|
24
|
+
get key(): string;
|
|
23
25
|
holder(id: string): SupportHolder | undefined;
|
|
24
26
|
get enabled(): boolean;
|
|
25
27
|
private provider;
|
|
@@ -35,9 +37,72 @@ export declare class Support {
|
|
|
35
37
|
coin?: unknown;
|
|
36
38
|
station?: unknown;
|
|
37
39
|
track?: unknown;
|
|
40
|
+
metadata?: Record<string, unknown>;
|
|
38
41
|
}): Promise<{
|
|
39
42
|
url: string;
|
|
43
|
+
paymentId: string;
|
|
40
44
|
}>;
|
|
41
45
|
}
|
|
42
46
|
export declare const SUPPORT_PAGE: string;
|
|
43
47
|
export declare const SUPPORT_CSP: string;
|
|
48
|
+
/** Postgres, as nixamp.com's pool offers it. */
|
|
49
|
+
export interface Db {
|
|
50
|
+
query: (text: string, params?: unknown[]) => Promise<{
|
|
51
|
+
rows: Array<Record<string, unknown>>;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
/** One month on from a time, on the same day where the month has it. */
|
|
55
|
+
export declare function nextMonth(at: number): number;
|
|
56
|
+
/**
|
|
57
|
+
* Monthly support, run here rather than by CoinPay: CoinPay's only merchant
|
|
58
|
+
* subscriptions are Stripe's, and its recurring invoices refuse a scoped key.
|
|
59
|
+
* A subscription is an email, an amount, a coin and a holder. Month one is
|
|
60
|
+
* paid at once on CoinPay's checkout; each month after, a new payment is made
|
|
61
|
+
* and its link emailed, with a one-click cancel. A month left unpaid ends it
|
|
62
|
+
* (lapsed) rather than emailing forever.
|
|
63
|
+
*/
|
|
64
|
+
export declare class SupportSubscriptions {
|
|
65
|
+
private readonly options;
|
|
66
|
+
private schema;
|
|
67
|
+
private timer;
|
|
68
|
+
constructor(options: {
|
|
69
|
+
db: Db;
|
|
70
|
+
support: Support;
|
|
71
|
+
/** Resend; null leaves renewals unsent (and says so in the log). */
|
|
72
|
+
mail: {
|
|
73
|
+
apiKey: string;
|
|
74
|
+
from: string;
|
|
75
|
+
} | null;
|
|
76
|
+
site: string;
|
|
77
|
+
fetcher?: typeof fetch;
|
|
78
|
+
now?: () => number;
|
|
79
|
+
log?: (line: string) => void;
|
|
80
|
+
});
|
|
81
|
+
private now;
|
|
82
|
+
private ready;
|
|
83
|
+
/** Month one: a subscription and its first checkout. */
|
|
84
|
+
start(input: {
|
|
85
|
+
holder?: unknown;
|
|
86
|
+
amountUsd?: unknown;
|
|
87
|
+
coin?: unknown;
|
|
88
|
+
email?: unknown;
|
|
89
|
+
station?: unknown;
|
|
90
|
+
track?: unknown;
|
|
91
|
+
}): Promise<{
|
|
92
|
+
url: string;
|
|
93
|
+
id: string;
|
|
94
|
+
}>;
|
|
95
|
+
cancelLink(id: string, token: string): string;
|
|
96
|
+
cancel(id: string, token: string): Promise<{
|
|
97
|
+
canceled: boolean;
|
|
98
|
+
holder: string;
|
|
99
|
+
}>;
|
|
100
|
+
private paid;
|
|
101
|
+
/** Every subscription now due: a new month's payment and its link, or lapsed when last month went unpaid. */
|
|
102
|
+
renew(): Promise<{
|
|
103
|
+
renewed: number;
|
|
104
|
+
lapsed: number;
|
|
105
|
+
}>;
|
|
106
|
+
startSchedule(everyMs?: number): void;
|
|
107
|
+
private mail;
|
|
108
|
+
}
|