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.
Files changed (57) hide show
  1. package/dist/ai-keys.d.ts +1 -1
  2. package/dist/ai-keys.js +2 -0
  3. package/dist/lyrics-publish.d.ts +56 -0
  4. package/dist/lyrics-publish.js +194 -0
  5. package/dist/lyrics.d.ts +67 -0
  6. package/dist/lyrics.js +145 -0
  7. package/dist/server.d.ts +7 -1
  8. package/dist/server.js +64 -3
  9. package/dist/station-cli.js +42 -0
  10. package/dist/station-page.d.ts +1 -1
  11. package/dist/station-page.js +39 -3
  12. package/dist/station-routes.d.ts +9 -0
  13. package/dist/station-routes.js +39 -1
  14. package/dist/station-usage.d.ts +1 -1
  15. package/dist/station-usage.js +5 -0
  16. package/dist/stations.d.ts +2 -0
  17. package/dist/stations.js +6 -0
  18. package/dist/support.d.ts +65 -0
  19. package/dist/support.js +187 -5
  20. package/dist/track-facts.d.ts +4 -0
  21. package/dist/track-facts.js +8 -4
  22. package/package.json +1 -1
  23. package/src/ai-keys.ts +2 -0
  24. package/src/lyrics-publish.ts +203 -0
  25. package/src/lyrics.ts +161 -0
  26. package/src/server.ts +68 -3
  27. package/src/station-cli.ts +40 -0
  28. package/src/station-page.ts +39 -3
  29. package/src/station-routes.ts +46 -1
  30. package/src/station-usage.ts +5 -0
  31. package/src/stations.ts +4 -0
  32. package/src/support.ts +201 -6
  33. package/src/track-facts.ts +11 -2
  34. package/web/dist/assets/{ar-DJ2gQPOo.js → ar-DJAQCP6v.js} +1 -1
  35. package/web/dist/assets/{cs-HEbqVNx5.js → cs-BYeHBx_F.js} +1 -1
  36. package/web/dist/assets/{da-C87QYmEx.js → da-CR89gWR7.js} +1 -1
  37. package/web/dist/assets/{de-BqR-HScZ.js → de-DPFsA0HC.js} +1 -1
  38. package/web/dist/assets/{es-DjzlzxAQ.js → es-Bg5Vx2bB.js} +1 -1
  39. package/web/dist/assets/{fi-2Is-qqeQ.js → fi-BVpY7Uww.js} +1 -1
  40. package/web/dist/assets/{fr-CTEDRgPI.js → fr-DsSofhGt.js} +1 -1
  41. package/web/dist/assets/{hi-CU5X7c5G.js → hi-DkSvHtix.js} +1 -1
  42. package/web/dist/assets/{hu-CQAs9Ye7.js → hu-D4Z2GpU0.js} +1 -1
  43. package/web/dist/assets/{id-5dd2TXs7.js → id-BzOPbgSu.js} +1 -1
  44. package/web/dist/assets/{it-B69_ME4E.js → it-BAT4YCiY.js} +1 -1
  45. package/web/dist/assets/{main-DRvUTBup.js → main-Dpj3oiHi.js} +2 -2
  46. package/web/dist/assets/{nl-Drb6oxnO.js → nl-CiTdg-si.js} +1 -1
  47. package/web/dist/assets/{ru-C4u8G5A0.js → ru-CJBuL5JA.js} +1 -1
  48. package/web/dist/assets/{station--cxhU1wr.css → station-BwDP2cuf.css} +1 -1
  49. package/web/dist/assets/station-DXRmjrqv.js +3 -0
  50. package/web/dist/assets/{sv-Bi-av_7u.js → sv-Dptz7Qud.js} +1 -1
  51. package/web/dist/assets/{uk-DZVF8mrT.js → uk-yL3tD6Kt.js} +1 -1
  52. package/web/dist/assets/{vi-B2UJxM5O.js → vi-CuVvOIut.js} +1 -1
  53. package/web/dist/assets/{zh-XRXZxCqU.js → zh-kHhOVHwJ.js} +1 -1
  54. package/web/dist/index.html +1 -1
  55. package/web/dist/station.html +9 -2
  56. package/web/dist/sw.js +21 -21
  57. package/web/dist/assets/station-CdQ9LR6K.js +0 -1
@@ -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;
@@ -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\">&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";
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\">&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 <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. */
@@ -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>
@@ -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>;
@@ -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" });
@@ -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.";
@@ -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]
@@ -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
+ }