nixamp 0.42.0 → 0.43.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +4 -0
- package/dist/server.js +28 -0
- 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/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 +32 -0
- 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/track-facts.ts +11 -2
- package/web/dist/assets/{ar-S4rqddex.js → ar-CnqhCJJo.js} +1 -1
- package/web/dist/assets/{cs-BShF2rip.js → cs-Bsyx7wxR.js} +1 -1
- package/web/dist/assets/{da-BO87iZOE.js → da-B4kIRtMO.js} +1 -1
- package/web/dist/assets/{de-CUANqdKo.js → de-CUIKrC06.js} +1 -1
- package/web/dist/assets/{es-BdUyDUC0.js → es-xgQHSTbS.js} +1 -1
- package/web/dist/assets/{fi-BBBq-Bd8.js → fi-BarRr2r9.js} +1 -1
- package/web/dist/assets/{fr-DqZ3HEY8.js → fr-CkdhPlV8.js} +1 -1
- package/web/dist/assets/{hi-BBMF8sCD.js → hi-Bmz_R4Qe.js} +1 -1
- package/web/dist/assets/{hu-D1LEEcho.js → hu-Buques8B.js} +1 -1
- package/web/dist/assets/{id-CBC-Hup3.js → id-DI6eGXlp.js} +1 -1
- package/web/dist/assets/{it-sk9K0Y5L.js → it-DoPVwxbT.js} +1 -1
- package/web/dist/assets/{main-ByEHJWGn.js → main-VepIOCc0.js} +2 -2
- package/web/dist/assets/{nl-DioO6nqY.js → nl-Bi2G8eau.js} +1 -1
- package/web/dist/assets/{ru-o2SYLX7L.js → ru-gdX1kz2l.js} +1 -1
- package/web/dist/assets/station-B9ORrH8G.js +3 -0
- package/web/dist/assets/{station--cxhU1wr.css → station-BwDP2cuf.css} +1 -1
- package/web/dist/assets/{sv-CCey7ex0.js → sv-DETdNoY-.js} +1 -1
- package/web/dist/assets/{uk-C2gHK98V.js → uk-BiRmx495.js} +1 -1
- package/web/dist/assets/{vi-B6AG6Gxz.js → vi-C018OY69.js} +1 -1
- package/web/dist/assets/{zh-Cnjg8Tp_.js → zh-HZ6x11wW.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-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/track-facts.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface TrackFacts {
|
|
|
24
24
|
} | null;
|
|
25
25
|
/** The track's own cover, on this station's link (it changes with the track); "" when it has none. */
|
|
26
26
|
art: string;
|
|
27
|
+
/** Where the track's words are, on this station's link; "" when none are known. */
|
|
28
|
+
lyrics: string;
|
|
27
29
|
}
|
|
28
30
|
export interface Holder {
|
|
29
31
|
id: string;
|
|
@@ -76,6 +78,8 @@ export interface FactSources {
|
|
|
76
78
|
} | null;
|
|
77
79
|
/** Where its cover is served, when it has one. */
|
|
78
80
|
art?: string;
|
|
81
|
+
/** Where its words are served, when it has them. */
|
|
82
|
+
lyrics?: string;
|
|
79
83
|
}
|
|
80
84
|
export declare function factsFor(input: FactSources): TrackFacts;
|
|
81
85
|
/** The copyright holders this server's owner registered. */
|
package/dist/track-facts.js
CHANGED
|
@@ -57,9 +57,13 @@ export class TagReader {
|
|
|
57
57
|
try {
|
|
58
58
|
const parsed = JSON.parse(out);
|
|
59
59
|
for (const source of [...(parsed.streams ?? []).map((one) => one.tags ?? {}), parsed.format?.tags ?? {}]) {
|
|
60
|
-
for (const [name, value] of Object.entries(source))
|
|
61
|
-
if (typeof value
|
|
62
|
-
|
|
60
|
+
for (const [name, value] of Object.entries(source)) {
|
|
61
|
+
if (typeof value !== "string")
|
|
62
|
+
continue;
|
|
63
|
+
const lower = name.toLowerCase();
|
|
64
|
+
// A song's words run longer than any other tag (lyrics.ts).
|
|
65
|
+
tags[lower] = value.slice(0, /lyrics/.test(lower) ? 20_000 : 2000);
|
|
66
|
+
}
|
|
63
67
|
}
|
|
64
68
|
}
|
|
65
69
|
catch {
|
|
@@ -144,7 +148,7 @@ export function factsFor(input) {
|
|
|
144
148
|
rights = { holder: "", basis: "", note: "", license: "", source: "" };
|
|
145
149
|
}
|
|
146
150
|
const support = holder && holder.support ? { holder: holder.name, url: holder.support, label: holder.label || "Support" } : null;
|
|
147
|
-
return { title, artist, album, year, genre, ai, rights, support, art: input.art ?? "" };
|
|
151
|
+
return { title, artist, album, year, genre, ai, rights, support, art: input.art ?? "", lyrics: input.lyrics ?? "" };
|
|
148
152
|
}
|
|
149
153
|
function slug(text) {
|
|
150
154
|
return text.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "holder";
|
package/package.json
CHANGED
package/src/ai-keys.ts
CHANGED
|
@@ -14,6 +14,8 @@ export const AI_KEY_NAMES = [
|
|
|
14
14
|
"ELEVENLABS_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
|
15
15
|
// Any OpenAI-compatible endpoint in OpenAI's place (Cloudflare Workers AI, Ollama, ...) and its model.
|
|
16
16
|
"NIXAMP_OPENAI_URL", "NIXAMP_OPENAI_MODEL",
|
|
17
|
+
// nichedb.dev's key for publishing our own songs' words (lyrics-publish.ts).
|
|
18
|
+
"NIXAMP_LYRICS_KEY",
|
|
17
19
|
] as const;
|
|
18
20
|
export type AiKeyName = (typeof AI_KEY_NAMES)[number];
|
|
19
21
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Our own songs' words, published back to nichedb.dev.
|
|
3
|
+
*
|
|
4
|
+
* A station whose owner declared the music theirs (basis "owned") and put it
|
|
5
|
+
* on the public guide (public and listed) is music we may publish the words
|
|
6
|
+
* of. For each of its files whose own tags carry lyrics (lyrics.ts), the
|
|
7
|
+
* words go to nichedb's POST /api/v1/lyrics with the key the owner set
|
|
8
|
+
* (NIXAMP_LYRICS_KEY, or `nixamp station lyrics key` into ai.json), once per
|
|
9
|
+
* text: what was sent is remembered by a hash of the words, so a file is sent
|
|
10
|
+
* again only when its words change. One at a time, a second apart, and a
|
|
11
|
+
* refusal of the key (401/403) or a 429 stops the run.
|
|
12
|
+
*
|
|
13
|
+
* Nothing is published without a key, and nothing for a station that is not
|
|
14
|
+
* owned, whatever else is true of it.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { lyricsFromTags, NICHEDB_LYRICS } from "./lyrics.ts";
|
|
20
|
+
import type { Station } from "./stations.ts";
|
|
21
|
+
|
|
22
|
+
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
|
23
|
+
|
|
24
|
+
/** Whether a station's lyrics may be published: owned, public and listed. */
|
|
25
|
+
export function mayPublishLyrics(station: Pick<Station, "visibility" | "listed" | "rights">): boolean {
|
|
26
|
+
return station.rights?.basis === "owned" && station.visibility === "public" && station.listed === true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Why not, in words, or "" when it may. */
|
|
30
|
+
export function whyNotPublish(station: Pick<Station, "visibility" | "listed" | "rights">): string {
|
|
31
|
+
if (station.rights?.basis !== "owned") return "its music is not declared owned (nixamp station publish ID --basis owned)";
|
|
32
|
+
if (station.visibility !== "public") return "it is not public";
|
|
33
|
+
if (!station.listed) return "it is not listed on the guide";
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PublishReport {
|
|
38
|
+
station: string;
|
|
39
|
+
published: number;
|
|
40
|
+
unchanged: number;
|
|
41
|
+
/** Files with no words in their tags, or no artist or title to file them under. */
|
|
42
|
+
without: number;
|
|
43
|
+
failed: number;
|
|
44
|
+
stopped: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LyricsPublisherOptions {
|
|
48
|
+
file: string;
|
|
49
|
+
/** The key nichedb takes, read each run (it may be set while the server runs); "" when unset. */
|
|
50
|
+
key: () => string;
|
|
51
|
+
stations: () => Station[];
|
|
52
|
+
tags: (file: string) => Promise<Record<string, string>>;
|
|
53
|
+
known?: (file: string) => { title: string; artist: string; album: string } | null;
|
|
54
|
+
base?: string;
|
|
55
|
+
fetcher?: Fetcher;
|
|
56
|
+
/** Between two posts. */
|
|
57
|
+
gapMs?: number;
|
|
58
|
+
/** After a change, before a run (changes come in bursts). */
|
|
59
|
+
debounceMs?: number;
|
|
60
|
+
log?: (line: string) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Sent { hash: string; at: number; artist: string; title: string }
|
|
64
|
+
|
|
65
|
+
export class LyricsPublisher {
|
|
66
|
+
private sent: Record<string, Sent> = {};
|
|
67
|
+
private running: Promise<PublishReport[]> | null = null;
|
|
68
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
69
|
+
private last: { at: number; reports: PublishReport[] } | null = null;
|
|
70
|
+
private lastError = "";
|
|
71
|
+
private readonly base: string;
|
|
72
|
+
private readonly fetcher: Fetcher;
|
|
73
|
+
|
|
74
|
+
constructor(private readonly options: LyricsPublisherOptions) {
|
|
75
|
+
this.base = (options.base ?? process.env["NIXAMP_LYRICS_URL"] ?? NICHEDB_LYRICS).replace(/\/+$/, "");
|
|
76
|
+
this.fetcher = options.fetcher ?? ((url, init) => fetch(url, init));
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(readFileSync(options.file, "utf8")) as { sent?: Record<string, Sent> };
|
|
79
|
+
this.sent = parsed.sent ?? {};
|
|
80
|
+
} catch {
|
|
81
|
+
this.sent = {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static fileFor(stateDir: string, port: number): string {
|
|
86
|
+
return join(stateDir, `lyrics-published-${port}.json`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private save(): void {
|
|
90
|
+
mkdirSync(dirname(this.options.file), { recursive: true });
|
|
91
|
+
const temporary = `${this.options.file}.${process.pid}.tmp`;
|
|
92
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, sent: this.sent }, null, 2)}\n`, { mode: 0o600 });
|
|
93
|
+
renameSync(temporary, this.options.file);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Something changed (a station, the library): publish what is new, shortly. Nothing without a key. */
|
|
97
|
+
soon(): void {
|
|
98
|
+
// A run is already due: changes in a burst ride on it (a busy store saves often).
|
|
99
|
+
if (!this.options.key() || this.timer) return;
|
|
100
|
+
this.timer = setTimeout(() => {
|
|
101
|
+
this.timer = null;
|
|
102
|
+
void this.publishAll().catch(() => undefined);
|
|
103
|
+
}, this.options.debounceMs ?? 30_000);
|
|
104
|
+
this.timer.unref?.();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
status(): Record<string, unknown> {
|
|
108
|
+
const eligible = this.options.stations().filter(mayPublishLyrics);
|
|
109
|
+
return {
|
|
110
|
+
key: this.options.key() !== "",
|
|
111
|
+
target: this.base,
|
|
112
|
+
published: Object.keys(this.sent).length,
|
|
113
|
+
running: this.running !== null,
|
|
114
|
+
last: this.last,
|
|
115
|
+
lastError: this.lastError,
|
|
116
|
+
stations: this.options.stations().map((one) => ({
|
|
117
|
+
id: one.id,
|
|
118
|
+
name: one.name,
|
|
119
|
+
eligible: mayPublishLyrics(one),
|
|
120
|
+
why: whyNotPublish(one),
|
|
121
|
+
files: one.items.length,
|
|
122
|
+
published: one.items.filter((file) => this.sent[file]).length,
|
|
123
|
+
})),
|
|
124
|
+
eligible: eligible.length,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Every eligible station, one after another; a run already going is joined, not doubled. */
|
|
129
|
+
publishAll(): Promise<PublishReport[]> {
|
|
130
|
+
if (this.running) return this.running;
|
|
131
|
+
this.running = (async () => {
|
|
132
|
+
const reports: PublishReport[] = [];
|
|
133
|
+
for (const station of this.options.stations().filter(mayPublishLyrics)) {
|
|
134
|
+
const report = await this.publishOne(station);
|
|
135
|
+
reports.push(report);
|
|
136
|
+
if (report.stopped) break;
|
|
137
|
+
}
|
|
138
|
+
this.last = { at: Date.now(), reports };
|
|
139
|
+
const sent = reports.reduce((sum, one) => sum + one.published, 0);
|
|
140
|
+
if (sent > 0) this.options.log?.(` lyrics: published ${sent} to ${this.base}.`);
|
|
141
|
+
return reports;
|
|
142
|
+
})().finally(() => { this.running = null; });
|
|
143
|
+
return this.running;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One station, now. Refused (stopped says why) when it may not be published or there is no key. */
|
|
147
|
+
async publish(station: Station): Promise<PublishReport> {
|
|
148
|
+
if (this.running) await this.running.catch(() => undefined);
|
|
149
|
+
return this.publishOne(station);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async publishOne(station: Station): Promise<PublishReport> {
|
|
153
|
+
const report: PublishReport = { station: station.id, published: 0, unchanged: 0, without: 0, failed: 0, stopped: "" };
|
|
154
|
+
const why = whyNotPublish(station);
|
|
155
|
+
if (why) { report.stopped = `not published: ${why}`; return report; }
|
|
156
|
+
const key = this.options.key();
|
|
157
|
+
if (!key) { report.stopped = "no key: set NIXAMP_LYRICS_KEY or `nixamp station lyrics key`"; return report; }
|
|
158
|
+
if (!this.base) { report.stopped = "no target: NIXAMP_LYRICS_URL is empty"; return report; }
|
|
159
|
+
let first = true;
|
|
160
|
+
for (const file of station.items) {
|
|
161
|
+
let tags: Record<string, string> = {};
|
|
162
|
+
try { tags = await this.options.tags(file); } catch { tags = {}; }
|
|
163
|
+
const words = lyricsFromTags(tags);
|
|
164
|
+
const known = this.options.known?.(file) ?? null;
|
|
165
|
+
const title = tags["title"] || known?.title || "";
|
|
166
|
+
const artist = tags["artist"] || tags["album_artist"] || known?.artist || "";
|
|
167
|
+
const album = tags["album"] || known?.album || "";
|
|
168
|
+
if (!words || !title || !artist) { report.without += 1; continue; }
|
|
169
|
+
const hash = createHash("sha256").update(`${artist}\n${title}\n${words.text}`).digest("hex");
|
|
170
|
+
if (this.sent[file]?.hash === hash) { report.unchanged += 1; continue; }
|
|
171
|
+
if (!first) await new Promise((done) => setTimeout(done, this.options.gapMs ?? 1_000));
|
|
172
|
+
first = false;
|
|
173
|
+
let response: Response;
|
|
174
|
+
try {
|
|
175
|
+
response = await this.fetcher(`${this.base}/api/v1/lyrics`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${key}` },
|
|
178
|
+
body: JSON.stringify({ artist, title, album, language: words.language, text: words.text, source: "embedded" }),
|
|
179
|
+
signal: AbortSignal.timeout(15_000),
|
|
180
|
+
});
|
|
181
|
+
} catch (error) {
|
|
182
|
+
report.failed += 1;
|
|
183
|
+
this.lastError = `could not reach ${this.base}: ${(error as Error).message}`;
|
|
184
|
+
report.stopped = this.lastError;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (response.ok) {
|
|
188
|
+
this.sent[file] = { hash, at: Date.now(), artist, title };
|
|
189
|
+
this.save();
|
|
190
|
+
report.published += 1;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
report.failed += 1;
|
|
194
|
+
const detail = (await response.text().catch(() => "")).slice(0, 200);
|
|
195
|
+
this.lastError = `nichedb answered ${response.status} for "${title}"${detail ? `: ${detail}` : ""}`;
|
|
196
|
+
if (response.status === 401 || response.status === 403 || response.status === 429 || response.status >= 500) {
|
|
197
|
+
report.stopped = this.lastError;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return report;
|
|
202
|
+
}
|
|
203
|
+
}
|