nixamp 0.37.3 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backtoschool/dist/assets/{ar-BQPKp0M2.js → ar-D6D9frpI.js} +1 -1
- package/backtoschool/dist/assets/{cs-DjJnhGNB.js → cs-D2qHYTeK.js} +1 -1
- package/backtoschool/dist/assets/{da-CbTvkIH7.js → da-BCSCPyEz.js} +1 -1
- package/backtoschool/dist/assets/{de-SsCkny26.js → de-Ckx6mPwx.js} +1 -1
- package/backtoschool/dist/assets/{es-BBlBPcq1.js → es-7_qm1pwd.js} +1 -1
- package/backtoschool/dist/assets/{fi-ffo35_4W.js → fi-VplJDUF7.js} +1 -1
- package/backtoschool/dist/assets/{fr-BVvavrk7.js → fr-BXH-jOgH.js} +1 -1
- package/backtoschool/dist/assets/{hi-DDy_zxu6.js → hi-CpsESvoe.js} +1 -1
- package/backtoschool/dist/assets/{hls-3VKVEQE3-QZWXCWjR.js → hls-3VKVEQE3-CEDGoYXh.js} +1 -1
- package/backtoschool/dist/assets/{hu-BoJj7pZa.js → hu-dvbS5NyU.js} +1 -1
- package/backtoschool/dist/assets/{id-CXN9gchF.js → id-BI4-G6PZ.js} +1 -1
- package/backtoschool/dist/assets/{index-Dovv39gH.js → index-DKXMW74z.js} +2 -2
- package/backtoschool/dist/assets/{it-C9g0xD6S.js → it-BPQWosW2.js} +1 -1
- package/backtoschool/dist/assets/{mpegts-LO6RVLD6-BrVQzFhM.js → mpegts-LO6RVLD6-ClmSzXR6.js} +1 -1
- package/backtoschool/dist/assets/{mpegts-GAwbg-pX.js → mpegts-hXHY39bG.js} +1 -1
- package/backtoschool/dist/assets/{nl-Ciq1Yrk4.js → nl-Dv0WvhFv.js} +1 -1
- package/backtoschool/dist/assets/{ru-B_ZMUvh6.js → ru-CeZpU_ik.js} +1 -1
- package/backtoschool/dist/assets/{sv-DH0ZO3Eh.js → sv-Dqbvvto9.js} +1 -1
- package/backtoschool/dist/assets/{uk-Cof-TUZd.js → uk-B90iq6kI.js} +1 -1
- package/backtoschool/dist/assets/{vi-D0QPpzbE.js → vi-BEzJO8uT.js} +1 -1
- package/backtoschool/dist/assets/{zh-CLTzTBOU.js → zh-CCt49uVl.js} +1 -1
- package/backtoschool/dist/index.html +1 -1
- package/dist/classroom.d.ts +1 -1
- package/dist/classroom.js +11 -0
- package/dist/library.d.ts +2 -0
- package/dist/library.js +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +21 -7
- package/dist/station-cli.js +18 -0
- package/dist/station-guide.d.ts +28 -1
- package/dist/station-guide.js +113 -1
- package/dist/station-page.d.ts +2 -0
- package/dist/station-page.js +12 -0
- package/dist/station-routes.d.ts +2 -0
- package/dist/station-routes.js +6 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +2 -0
- package/package.json +1 -1
- package/src/classroom.ts +12 -1
- package/src/library.ts +3 -0
- package/src/server.ts +23 -7
- package/src/station-cli.ts +15 -0
- package/src/station-guide.ts +103 -1
- package/src/station-page.ts +13 -0
- package/src/station-routes.ts +8 -1
- package/src/station-usage.ts +2 -0
- package/web/dist/assets/{ar-M-rJ-i8u.js → ar-B1It2qWd.js} +1 -1
- package/web/dist/assets/chunk-T4EFNLAS-DY12ZYvL.js +1 -0
- package/web/dist/assets/{cs-DIDjmSmj.js → cs-jbwjT9RF.js} +1 -1
- package/web/dist/assets/{da-mz0EqGwk.js → da-C75Rnl7w.js} +1 -1
- package/web/dist/assets/{de-Bn3AucNg.js → de-BSB4ZNLW.js} +1 -1
- package/web/dist/assets/{es-ByK9v1xB.js → es-Uf1tv6i9.js} +1 -1
- package/web/dist/assets/{fi-CLniYyab.js → fi-B14D46OI.js} +1 -1
- package/web/dist/assets/{fr-D1wL3tO9.js → fr-DN_LZiXa.js} +1 -1
- package/web/dist/assets/{hi-C7nhAaqc.js → hi-DiV2s397.js} +1 -1
- package/web/dist/assets/hls-3VKVEQE3-CNNE0JKA.js +1 -0
- package/web/dist/assets/{hu-BYQN--o4.js → hu-CVyH-Nb6.js} +1 -1
- package/web/dist/assets/{id-C9nWiAaF.js → id-DK_roo-9.js} +1 -1
- package/web/dist/assets/{it-C1B7Zioo.js → it-BW0fVJXm.js} +1 -1
- package/web/dist/assets/main-D_k0QHkM.js +22 -0
- package/web/dist/assets/{mpegts-BsYkrVdS.js → mpegts-BRxCagMa.js} +1 -1
- package/web/dist/assets/mpegts-LO6RVLD6-B1c-xVqn.js +1 -0
- package/web/dist/assets/{nl-CyciFDJY.js → nl-xta7UD_A.js} +1 -1
- package/web/dist/assets/{radio-CC8ihiO_.css → radio-BbO0Ekne.css} +1 -1
- package/web/dist/assets/radio-DM3Rft14.js +1 -0
- package/web/dist/assets/remote-DJqvFdE6.js +1 -0
- package/web/dist/assets/{ru-z2rPXuyZ.js → ru-WDiEfNpz.js} +1 -1
- package/web/dist/assets/station-Bq-LiY6G.css +1 -0
- package/web/dist/assets/station-DDvu2lUI.js +1 -0
- package/web/dist/assets/{sv-DrDn-9vi.js → sv-BG6gdeMJ.js} +1 -1
- package/web/dist/assets/{uk-Cuf4DVDl.js → uk-Dt8EYEnC.js} +1 -1
- package/web/dist/assets/{vi-tIwkw_Y8.js → vi-Dewq0eaV.js} +1 -1
- package/web/dist/assets/{zh-B2HJfTvl.js → zh-YQ6Y8Ipw.js} +1 -1
- package/web/dist/index.html +3 -1
- package/web/dist/radio.html +8 -5
- package/web/dist/station.html +30 -0
- package/web/dist/sw.js +28 -24
- package/web/dist/assets/hls-3VKVEQE3-jTDhndWc.js +0 -1
- package/web/dist/assets/main-D0g9H121.js +0 -22
- package/web/dist/assets/mpegts-LO6RVLD6-Bb_fz5oU.js +0 -1
- package/web/dist/assets/radio-BpH1qMMQ.js +0 -1
package/dist/station-guide.js
CHANGED
|
@@ -58,7 +58,7 @@ const SCRIPT = `
|
|
|
58
58
|
a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
|
|
59
59
|
el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
|
|
60
60
|
card.append(a);
|
|
61
|
-
if (group === "shared") {
|
|
61
|
+
if (group === "shared" && !s.peer) {
|
|
62
62
|
const b = el("button", "report", "Report this track");
|
|
63
63
|
b.type = "button";
|
|
64
64
|
b.addEventListener("click", () => report(s.id, b));
|
|
@@ -214,3 +214,115 @@ export function guideHosts(env = process.env) {
|
|
|
214
214
|
} })();
|
|
215
215
|
return new Set([...(env["NIXAMP_GUIDE_HOSTS"] ?? "").split(","), ...saved].map((one) => one.trim().toLowerCase()).filter(Boolean));
|
|
216
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* The other servers whose public, listed stations this guide shows beside
|
|
219
|
+
* its own: `guidePeers` in config.json (`nixamp station guide-peer URL`), plus
|
|
220
|
+
* NIXAMP_GUIDE_PEERS, comma-separated. One Moshpit name (live.streaming) points
|
|
221
|
+
* at one server; this is how a station on any of the owner's servers gets on it.
|
|
222
|
+
*/
|
|
223
|
+
export function guidePeers(env = process.env) {
|
|
224
|
+
const saved = (() => { try {
|
|
225
|
+
return readConfig().guidePeers ?? [];
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return [];
|
|
229
|
+
} })();
|
|
230
|
+
const peers = new Set();
|
|
231
|
+
for (const one of [...(env["NIXAMP_GUIDE_PEERS"] ?? "").split(","), ...saved]) {
|
|
232
|
+
const base = peerBase(one);
|
|
233
|
+
if (base)
|
|
234
|
+
peers.add(base);
|
|
235
|
+
}
|
|
236
|
+
return [...peers];
|
|
237
|
+
}
|
|
238
|
+
/** An https server address as a bare origin, or "" when it is not one. */
|
|
239
|
+
export function peerBase(value) {
|
|
240
|
+
try {
|
|
241
|
+
const url = new URL(value.trim());
|
|
242
|
+
return url.protocol === "https:" && !url.username && !url.password ? url.origin : "";
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return "";
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const PEER_FRESH_MS = 15_000;
|
|
249
|
+
const PEER_TIMEOUT_MS = 4_000;
|
|
250
|
+
function peerEntry(value, origin) {
|
|
251
|
+
if (typeof value !== "object" || value === null)
|
|
252
|
+
return null;
|
|
253
|
+
const one = value;
|
|
254
|
+
const page = typeof one["page"] === "string" ? one["page"] : "";
|
|
255
|
+
// A peer lists its own stations, on its own address, and nothing else.
|
|
256
|
+
try {
|
|
257
|
+
if (new URL(page).origin !== origin)
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
if (typeof one["name"] !== "string")
|
|
264
|
+
return null;
|
|
265
|
+
const now = one["nowPlaying"];
|
|
266
|
+
const group = one["group"];
|
|
267
|
+
return {
|
|
268
|
+
...(typeof one["id"] === "string" ? { id: one["id"] } : {}),
|
|
269
|
+
name: one["name"].slice(0, 120),
|
|
270
|
+
kind: one["kind"] === "video" ? "video" : "audio",
|
|
271
|
+
...(group === "ai" || group === "shared" || group === "radio" || group === "tv" ? { group } : {}),
|
|
272
|
+
state: typeof one["state"] === "string" ? one["state"] : "offline",
|
|
273
|
+
page,
|
|
274
|
+
nowPlaying: now && typeof now === "object"
|
|
275
|
+
? { title: String(now["title"] ?? "").slice(0, 200), artist: String(now["artist"] ?? "").slice(0, 200) }
|
|
276
|
+
: null,
|
|
277
|
+
peer: true,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* What the peers' guides list, asked at most every 15 seconds each and never
|
|
282
|
+
* waited on for more than 4: a peer that is down drops out of the guide, it
|
|
283
|
+
* does not hold the guide up. Each peer is asked for its own stations only
|
|
284
|
+
* (?local=1), so two servers that list each other do not ask round in a ring.
|
|
285
|
+
*/
|
|
286
|
+
export class PeerGuides {
|
|
287
|
+
peers;
|
|
288
|
+
fetcher;
|
|
289
|
+
now;
|
|
290
|
+
held = new Map();
|
|
291
|
+
asking = new Map();
|
|
292
|
+
constructor(peers = () => guidePeers(), fetcher = fetch, now = Date.now) {
|
|
293
|
+
this.peers = peers;
|
|
294
|
+
this.fetcher = fetcher;
|
|
295
|
+
this.now = now;
|
|
296
|
+
}
|
|
297
|
+
async entries() {
|
|
298
|
+
const peers = this.peers();
|
|
299
|
+
await Promise.all(peers.map((peer) => {
|
|
300
|
+
const kept = this.held.get(peer);
|
|
301
|
+
return kept && this.now() - kept.at < PEER_FRESH_MS ? undefined : this.ask(peer);
|
|
302
|
+
}));
|
|
303
|
+
return peers.flatMap((peer) => this.held.get(peer)?.entries ?? []);
|
|
304
|
+
}
|
|
305
|
+
ask(peer) {
|
|
306
|
+
const pending = this.asking.get(peer);
|
|
307
|
+
if (pending)
|
|
308
|
+
return pending;
|
|
309
|
+
const work = (async () => {
|
|
310
|
+
let entries = [];
|
|
311
|
+
try {
|
|
312
|
+
const answer = await this.fetcher(`${peer}/api/v1/stations/guide?local=1`, { signal: AbortSignal.timeout(PEER_TIMEOUT_MS) });
|
|
313
|
+
if (answer.ok) {
|
|
314
|
+
const body = (await answer.json());
|
|
315
|
+
entries = Array.isArray(body.stations)
|
|
316
|
+
? body.stations.map((one) => peerEntry(one, peer)).filter((one) => one !== null).slice(0, 500)
|
|
317
|
+
: [];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
entries = [];
|
|
322
|
+
}
|
|
323
|
+
this.held.set(peer, { at: this.now(), entries });
|
|
324
|
+
})().finally(() => this.asking.delete(peer));
|
|
325
|
+
this.asking.set(peer, work);
|
|
326
|
+
return work;
|
|
327
|
+
}
|
|
328
|
+
}
|
package/dist/station-page.d.ts
CHANGED
|
@@ -3,3 +3,5 @@ export declare const STATION_PAGE: RegExp;
|
|
|
3
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";
|
|
4
4
|
/** Only its own script and style, only its own server, never framed. */
|
|
5
5
|
export declare const STATION_PAGE_CSP: string;
|
|
6
|
+
/** The built page: its own bundle, styles and player engines, only this server, never framed. */
|
|
7
|
+
export declare const STATION_APP_CSP: string;
|
package/dist/station-page.js
CHANGED
|
@@ -172,3 +172,15 @@ export const STATION_PAGE_CSP = [
|
|
|
172
172
|
"form-action 'none'",
|
|
173
173
|
"frame-ancestors 'none'",
|
|
174
174
|
].join("; ");
|
|
175
|
+
/** The built page: its own bundle, styles and player engines, only this server, never framed. */
|
|
176
|
+
export const STATION_APP_CSP = [
|
|
177
|
+
"default-src 'self'",
|
|
178
|
+
"style-src 'self' 'unsafe-inline'",
|
|
179
|
+
"media-src 'self' blob:",
|
|
180
|
+
"worker-src 'self' blob:",
|
|
181
|
+
"img-src 'self' data: blob:",
|
|
182
|
+
"connect-src 'self'",
|
|
183
|
+
"base-uri 'none'",
|
|
184
|
+
"form-action 'none'",
|
|
185
|
+
"frame-ancestors 'none'",
|
|
186
|
+
].join("; ");
|
package/dist/station-routes.d.ts
CHANGED
|
@@ -64,6 +64,8 @@ export interface StationContext {
|
|
|
64
64
|
sync: (input: Record<string, unknown>) => Promise<unknown>;
|
|
65
65
|
setting: () => unknown;
|
|
66
66
|
};
|
|
67
|
+
/** The public stations other servers list (guide peers); absent when there are none. */
|
|
68
|
+
peers?: () => Promise<unknown[]>;
|
|
67
69
|
}
|
|
68
70
|
declare function links(ctx: StationContext, station: Station, base?: string): {
|
|
69
71
|
page: string;
|
package/dist/station-routes.js
CHANGED
|
@@ -90,8 +90,13 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
90
90
|
const method = request.method ?? "GET";
|
|
91
91
|
// The public guide: only stations their owner made public and listed, with
|
|
92
92
|
// their links, because public is what that means. Nothing else, ever.
|
|
93
|
+
// A peer asking for this one's (?local=1) gets this server's stations only.
|
|
93
94
|
if (path === "/api/v1/stations/guide" && (method === "GET" || method === "HEAD")) {
|
|
94
|
-
|
|
95
|
+
const own = guideEntries(ctx);
|
|
96
|
+
const shown = new Set(own.map((one) => one.page));
|
|
97
|
+
const others = url.searchParams.has("local") || !ctx.peers ? [] : (await ctx.peers())
|
|
98
|
+
.filter((one) => !shown.has(one.page));
|
|
99
|
+
ctx.json(response, 200, { stations: [...own, ...others] });
|
|
95
100
|
return true;
|
|
96
101
|
}
|
|
97
102
|
const listen = LISTEN.exec(path);
|
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 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 --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 --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
|
@@ -16,6 +16,8 @@ export const USAGE = `nixamp station — make a collection into a station
|
|
|
16
16
|
nixamp station delete ID
|
|
17
17
|
nixamp station guide-host NAME [--remove]
|
|
18
18
|
NAME's front page is the public station guide (e.g. a Moshpit name)
|
|
19
|
+
nixamp station guide-peer URL [--remove]
|
|
20
|
+
the guide also lists the public, listed stations of the server at URL
|
|
19
21
|
nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]
|
|
20
22
|
a station per genre, kept in step with the library (--off stops it)
|
|
21
23
|
|
package/package.json
CHANGED
package/src/classroom.ts
CHANGED
|
@@ -16,7 +16,7 @@ export function publicWebUrl(value: unknown): string | null {
|
|
|
16
16
|
} catch { return null; }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function classroomBroadcast(value: unknown): { provider: "pairux" | "nixamp" | "media"; url: string; embed: string; join: string } | null {
|
|
19
|
+
export function classroomBroadcast(value: unknown): { provider: "pairux" | "nixamp" | "station" | "media"; url: string; embed: string; join: string } | null {
|
|
20
20
|
const safe = publicWebUrl(value);
|
|
21
21
|
if (!safe) return null;
|
|
22
22
|
const url = new URL(safe);
|
|
@@ -41,6 +41,17 @@ export function classroomBroadcast(value: unknown): { provider: "pairux" | "nixa
|
|
|
41
41
|
clean.searchParams.set("embed", "1");
|
|
42
42
|
return { provider: "nixamp", url: original, embed: clean.href, join: original };
|
|
43
43
|
}
|
|
44
|
+
// A station's listening link: its page, opened with the station's own
|
|
45
|
+
// token. The token opens that one stream and nothing else on the server, so
|
|
46
|
+
// it may be published; any other query (a server key) may not.
|
|
47
|
+
if (url.hostname.endsWith(".nixamp.com")) {
|
|
48
|
+
const id = /^\/station\/([a-z0-9]{10})\/?$/.exec(url.pathname)?.[1];
|
|
49
|
+
const token = url.searchParams.get("t") ?? "";
|
|
50
|
+
if (id && /^[A-Za-z0-9_-]{16,64}$/.test(token) && [...url.searchParams.keys()].every((name) => name === "t")) {
|
|
51
|
+
const page = `${url.origin}/station/${id}?t=${token}`;
|
|
52
|
+
return { provider: "station", url: page, embed: `${url.origin}/api/v1/stations/${id}/stream?t=${token}`, join: page };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
44
55
|
// A self-hosted Nixamp viewer link can be played through the shared client.
|
|
45
56
|
if (url.hostname.endsWith(".nixamp.com") && /^\/view\/[A-Za-z0-9_-]+\/?$/.test(url.pathname) && !url.search) {
|
|
46
57
|
const player = new URL("https://nixamp.com/");
|
package/src/library.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface Config {
|
|
|
29
29
|
autoStations?: Record<string, unknown>;
|
|
30
30
|
/** Hosts (e.g. Moshpit names) whose front page is the public station guide. */
|
|
31
31
|
guideHosts?: string[];
|
|
32
|
+
/** Other servers (https base addresses) whose public, listed stations this guide shows too. */
|
|
33
|
+
guidePeers?: string[];
|
|
32
34
|
/** Station limits kept across restarts (station-metering.ts). */
|
|
33
35
|
limits?: Record<string, unknown>;
|
|
34
36
|
/** Proxies whose X-Forwarded-For is believed for listener shares (`nixamp station shares trust ADDRESS`). */
|
|
@@ -51,6 +53,7 @@ export function readConfig(): Config {
|
|
|
51
53
|
...(typeof record["limits"] === "object" && record["limits"] !== null ? { limits: record["limits"] as Record<string, unknown> } : {}),
|
|
52
54
|
...(Array.isArray(record["trustedProxies"]) ? { trustedProxies: (record["trustedProxies"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
|
|
53
55
|
...(Array.isArray(record["guideHosts"]) ? { guideHosts: (record["guideHosts"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
|
|
56
|
+
...(Array.isArray(record["guidePeers"]) ? { guidePeers: (record["guidePeers"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
|
|
54
57
|
};
|
|
55
58
|
} catch {
|
|
56
59
|
return {};
|
package/src/server.ts
CHANGED
|
@@ -80,8 +80,8 @@ import { handleHandoffs } from "./handoff-routes.ts";
|
|
|
80
80
|
import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
|
|
81
81
|
import { AiRadio } from "./ai-radio.ts";
|
|
82
82
|
import { ListenerShares } from "./listener-shares.ts";
|
|
83
|
-
import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
|
|
84
|
-
import { GUIDE_CSP, guideHosts, renderGuide } from "./station-guide.ts";
|
|
83
|
+
import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
|
|
84
|
+
import { GUIDE_CSP, guideHosts, PeerGuides, renderGuide, type GuideEntry } from "./station-guide.ts";
|
|
85
85
|
import { StationRunner } from "./station-runner.ts";
|
|
86
86
|
import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
|
|
87
87
|
import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv, type Limits } from "./station-metering.ts";
|
|
@@ -1459,7 +1459,7 @@ const CORS: Record<string, string> = {
|
|
|
1459
1459
|
// control API has to be reachable cross-origin. It exposes no filesystem
|
|
1460
1460
|
// paths and takes six commands; binding to 127.0.0.1 is what keeps it shut.
|
|
1461
1461
|
"access-control-allow-origin": "*",
|
|
1462
|
-
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
1462
|
+
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
1463
1463
|
"access-control-allow-headers": "content-type, authorization",
|
|
1464
1464
|
"access-control-max-age": "86400",
|
|
1465
1465
|
};
|
|
@@ -1737,6 +1737,8 @@ export interface HandlerOptions {
|
|
|
1737
1737
|
shares?: ListenerShares;
|
|
1738
1738
|
/** Proxies (by address) whose X-Forwarded-For is believed, for per-sharer limits. */
|
|
1739
1739
|
trustedProxies?: () => Set<string>;
|
|
1740
|
+
/** Other servers' public stations, for this guide to list beside its own. */
|
|
1741
|
+
peers?: PeerGuides;
|
|
1740
1742
|
};
|
|
1741
1743
|
web: string | null;
|
|
1742
1744
|
/** Branded clients on this same backend, keyed by an explicitly configured host. */
|
|
@@ -2665,6 +2667,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2665
2667
|
const requestHost = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "").split(",")[0]!.trim().replace(/:\d+$/, "").toLowerCase();
|
|
2666
2668
|
const isGuidePage = Boolean(options.stations) && (request.method === "GET" || request.method === "HEAD") &&
|
|
2667
2669
|
(path === "/guide" || (path === "/" && guideHosts().has(requestHost)));
|
|
2670
|
+
// The built web bundle is the same public code nixamp.com hands anyone;
|
|
2671
|
+
// a station's page loads it, and a station link carries no server key.
|
|
2672
|
+
const isPublicAsset = (request.method === "GET" || request.method === "HEAD") &&
|
|
2673
|
+
(/^\/assets\/[A-Za-z0-9._-]+$/.test(path) || path === "/favicon.png");
|
|
2668
2674
|
// Whether this request holds only the listen key. A listener may browse
|
|
2669
2675
|
// and hear the library, but where it lives on this machine is the
|
|
2670
2676
|
// owner's business: the absolute path names the user account and the disk.
|
|
@@ -2679,6 +2685,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2679
2685
|
// Sharing a track and reporting one are for anybody the guide reaches.
|
|
2680
2686
|
!isPublicSharePath(path) &&
|
|
2681
2687
|
!STATION_PAGE.test(path) &&
|
|
2688
|
+
!isPublicAsset &&
|
|
2682
2689
|
!isGuidePage &&
|
|
2683
2690
|
!eventChannelRequest
|
|
2684
2691
|
) {
|
|
@@ -2692,7 +2699,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2692
2699
|
// server was refused, and the address of a machine you administer was
|
|
2693
2700
|
// useless without a link you had to go and find.
|
|
2694
2701
|
if (scope === null && options.owner) {
|
|
2695
|
-
|
|
2702
|
+
// In the query too, as controlOf takes it: an <audio> on another
|
|
2703
|
+
// origin (nixamp.com/radio) cannot send a header.
|
|
2704
|
+
const check = await options.owner.check(false, tokenFrom(request.headers) || url.searchParams.get("session") || "");
|
|
2696
2705
|
if (check.allowed) scope = "control";
|
|
2697
2706
|
}
|
|
2698
2707
|
if (scope === null) {
|
|
@@ -2748,7 +2757,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2748
2757
|
readBody,
|
|
2749
2758
|
headers: CORS,
|
|
2750
2759
|
});
|
|
2751
|
-
const
|
|
2760
|
+
const own = new Set(entries.map((one) => one.page));
|
|
2761
|
+
const others = options.stations!.peers ? (await options.stations!.peers.entries()).filter((one) => !own.has(one.page)) : [];
|
|
2762
|
+
const page = renderGuide([...entries, ...others] as GuideEntry[]);
|
|
2752
2763
|
response.writeHead(200, {
|
|
2753
2764
|
"content-type": "text/html; charset=utf-8",
|
|
2754
2765
|
"cache-control": "no-cache",
|
|
@@ -2763,14 +2774,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2763
2774
|
// The page a station's link opens. The same bytes for every station: it
|
|
2764
2775
|
// reads its id and token from its own address.
|
|
2765
2776
|
if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
|
|
2777
|
+
// The built page plays in @profullstack/player; the inline one is for a
|
|
2778
|
+
// server started without the web build.
|
|
2779
|
+
const built = web !== null && isFile(join(web, "station.html")) ? readFileSync(join(web, "station.html")) : null;
|
|
2766
2780
|
response.writeHead(200, {
|
|
2767
2781
|
"content-type": "text/html; charset=utf-8",
|
|
2768
2782
|
"cache-control": "no-cache",
|
|
2769
|
-
"content-security-policy": STATION_PAGE_CSP,
|
|
2783
|
+
"content-security-policy": built ? STATION_APP_CSP : STATION_PAGE_CSP,
|
|
2770
2784
|
"referrer-policy": "no-referrer",
|
|
2771
2785
|
"x-content-type-options": "nosniff",
|
|
2772
2786
|
});
|
|
2773
|
-
response.end(request.method === "HEAD" ? undefined : STATION_PAGE_HTML);
|
|
2787
|
+
response.end(request.method === "HEAD" ? undefined : built ?? STATION_PAGE_HTML);
|
|
2774
2788
|
return;
|
|
2775
2789
|
}
|
|
2776
2790
|
|
|
@@ -2870,6 +2884,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2870
2884
|
...(options.stations.usage ? { usage: options.stations.usage } : {}),
|
|
2871
2885
|
...(options.stations.limits ? { limits: options.stations.limits } : {}),
|
|
2872
2886
|
...(options.stations.auto ? { auto: options.stations.auto } : {}),
|
|
2887
|
+
...(options.stations.peers ? { peers: () => options.stations!.peers!.entries() } : {}),
|
|
2873
2888
|
throttle: () => {
|
|
2874
2889
|
const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
2875
2890
|
return verdict.ok ? null : { retryAfter: verdict.retryAfter };
|
|
@@ -6836,6 +6851,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6836
6851
|
trustedProxies: () => new Set([...(process.env["NIXAMP_TRUSTED_PROXIES"] ?? "").split(","), ...(readConfig().trustedProxies ?? [])].map((one) => one.trim()).filter(Boolean)),
|
|
6837
6852
|
// Known once the server has worked out its addresses, after it is listening.
|
|
6838
6853
|
publicBase: () => (publishable_?.url ?? "").replace(/\/+$/, ""),
|
|
6854
|
+
peers: new PeerGuides(),
|
|
6839
6855
|
};
|
|
6840
6856
|
// Music handed over from BitTorrented, copied into a folder of our own.
|
|
6841
6857
|
const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
|
package/src/station-cli.ts
CHANGED
|
@@ -199,6 +199,21 @@ export async function station(argv: string[]): Promise<number> {
|
|
|
199
199
|
console.log(hosts.size ? `The guide is the front page for: ${[...hosts].join(", ")}` : "No host shows the guide as its front page (it is still at /guide).");
|
|
200
200
|
return 0;
|
|
201
201
|
}
|
|
202
|
+
case "guide-peer": {
|
|
203
|
+
// Another server whose public, listed stations this guide shows too.
|
|
204
|
+
const { readConfig, writeConfig } = await import("./library.ts");
|
|
205
|
+
const { peerBase } = await import("./station-guide.ts");
|
|
206
|
+
const peer = peerBase(id ?? "");
|
|
207
|
+
if (id && !peer) {
|
|
208
|
+
console.error("A guide peer is an https address, e.g. https://server2.example.com:4321");
|
|
209
|
+
return 2;
|
|
210
|
+
}
|
|
211
|
+
const peers = new Set(readConfig().guidePeers ?? []);
|
|
212
|
+
if (argv.includes("--remove")) peers.delete(peer); else if (peer) peers.add(peer);
|
|
213
|
+
writeConfig({ guidePeers: [...peers] });
|
|
214
|
+
console.log(peers.size ? `The guide also lists the public stations of: ${[...peers].join(", ")}` : "The guide lists only this server's stations.");
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
202
217
|
case "auto": {
|
|
203
218
|
const body: Record<string, unknown> = argv.includes("--off") ? { off: true } : {
|
|
204
219
|
...(argv.includes("--no-video") ? { video: false } : {}),
|
package/src/station-guide.ts
CHANGED
|
@@ -60,7 +60,7 @@ const SCRIPT = `
|
|
|
60
60
|
a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
|
|
61
61
|
el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
|
|
62
62
|
card.append(a);
|
|
63
|
-
if (group === "shared") {
|
|
63
|
+
if (group === "shared" && !s.peer) {
|
|
64
64
|
const b = el("button", "report", "Report this track");
|
|
65
65
|
b.type = "button";
|
|
66
66
|
b.addEventListener("click", () => report(s.id, b));
|
|
@@ -138,6 +138,8 @@ export interface GuideEntry {
|
|
|
138
138
|
state: string;
|
|
139
139
|
page: string;
|
|
140
140
|
nowPlaying: { title: string; artist: string } | null;
|
|
141
|
+
/** Listed by another server (a guide peer); its own page, its own reports. */
|
|
142
|
+
peer?: boolean;
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
const GROUPS = ["ai", "shared", "radio", "tv"] as const;
|
|
@@ -229,3 +231,103 @@ export function guideHosts(env: Record<string, string | undefined> = process.env
|
|
|
229
231
|
const saved = (() => { try { return readConfig().guideHosts ?? []; } catch { return []; } })();
|
|
230
232
|
return new Set([...(env["NIXAMP_GUIDE_HOSTS"] ?? "").split(","), ...saved].map((one) => one.trim().toLowerCase()).filter(Boolean));
|
|
231
233
|
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The other servers whose public, listed stations this guide shows beside
|
|
237
|
+
* its own: `guidePeers` in config.json (`nixamp station guide-peer URL`), plus
|
|
238
|
+
* NIXAMP_GUIDE_PEERS, comma-separated. One Moshpit name (live.streaming) points
|
|
239
|
+
* at one server; this is how a station on any of the owner's servers gets on it.
|
|
240
|
+
*/
|
|
241
|
+
export function guidePeers(env: Record<string, string | undefined> = process.env): string[] {
|
|
242
|
+
const saved = (() => { try { return readConfig().guidePeers ?? []; } catch { return []; } })();
|
|
243
|
+
const peers = new Set<string>();
|
|
244
|
+
for (const one of [...(env["NIXAMP_GUIDE_PEERS"] ?? "").split(","), ...saved]) {
|
|
245
|
+
const base = peerBase(one);
|
|
246
|
+
if (base) peers.add(base);
|
|
247
|
+
}
|
|
248
|
+
return [...peers];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** An https server address as a bare origin, or "" when it is not one. */
|
|
252
|
+
export function peerBase(value: string): string {
|
|
253
|
+
try {
|
|
254
|
+
const url = new URL(value.trim());
|
|
255
|
+
return url.protocol === "https:" && !url.username && !url.password ? url.origin : "";
|
|
256
|
+
} catch {
|
|
257
|
+
return "";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const PEER_FRESH_MS = 15_000;
|
|
262
|
+
const PEER_TIMEOUT_MS = 4_000;
|
|
263
|
+
|
|
264
|
+
function peerEntry(value: unknown, origin: string): GuideEntry | null {
|
|
265
|
+
if (typeof value !== "object" || value === null) return null;
|
|
266
|
+
const one = value as Record<string, unknown>;
|
|
267
|
+
const page = typeof one["page"] === "string" ? one["page"] : "";
|
|
268
|
+
// A peer lists its own stations, on its own address, and nothing else.
|
|
269
|
+
try { if (new URL(page).origin !== origin) return null; } catch { return null; }
|
|
270
|
+
if (typeof one["name"] !== "string") return null;
|
|
271
|
+
const now = one["nowPlaying"] as Record<string, unknown> | null | undefined;
|
|
272
|
+
const group = one["group"];
|
|
273
|
+
return {
|
|
274
|
+
...(typeof one["id"] === "string" ? { id: one["id"] } : {}),
|
|
275
|
+
name: one["name"].slice(0, 120),
|
|
276
|
+
kind: one["kind"] === "video" ? "video" : "audio",
|
|
277
|
+
...(group === "ai" || group === "shared" || group === "radio" || group === "tv" ? { group } : {}),
|
|
278
|
+
state: typeof one["state"] === "string" ? one["state"] : "offline",
|
|
279
|
+
page,
|
|
280
|
+
nowPlaying: now && typeof now === "object"
|
|
281
|
+
? { title: String(now["title"] ?? "").slice(0, 200), artist: String(now["artist"] ?? "").slice(0, 200) }
|
|
282
|
+
: null,
|
|
283
|
+
peer: true,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* What the peers' guides list, asked at most every 15 seconds each and never
|
|
289
|
+
* waited on for more than 4: a peer that is down drops out of the guide, it
|
|
290
|
+
* does not hold the guide up. Each peer is asked for its own stations only
|
|
291
|
+
* (?local=1), so two servers that list each other do not ask round in a ring.
|
|
292
|
+
*/
|
|
293
|
+
export class PeerGuides {
|
|
294
|
+
private readonly held = new Map<string, { at: number; entries: GuideEntry[] }>();
|
|
295
|
+
private readonly asking = new Map<string, Promise<void>>();
|
|
296
|
+
|
|
297
|
+
constructor(
|
|
298
|
+
private readonly peers: () => string[] = () => guidePeers(),
|
|
299
|
+
private readonly fetcher: typeof fetch = fetch,
|
|
300
|
+
private readonly now: () => number = Date.now,
|
|
301
|
+
) {}
|
|
302
|
+
|
|
303
|
+
async entries(): Promise<GuideEntry[]> {
|
|
304
|
+
const peers = this.peers();
|
|
305
|
+
await Promise.all(peers.map((peer) => {
|
|
306
|
+
const kept = this.held.get(peer);
|
|
307
|
+
return kept && this.now() - kept.at < PEER_FRESH_MS ? undefined : this.ask(peer);
|
|
308
|
+
}));
|
|
309
|
+
return peers.flatMap((peer) => this.held.get(peer)?.entries ?? []);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private ask(peer: string): Promise<void> {
|
|
313
|
+
const pending = this.asking.get(peer);
|
|
314
|
+
if (pending) return pending;
|
|
315
|
+
const work = (async () => {
|
|
316
|
+
let entries: GuideEntry[] = [];
|
|
317
|
+
try {
|
|
318
|
+
const answer = await this.fetcher(`${peer}/api/v1/stations/guide?local=1`, { signal: AbortSignal.timeout(PEER_TIMEOUT_MS) });
|
|
319
|
+
if (answer.ok) {
|
|
320
|
+
const body = (await answer.json()) as { stations?: unknown };
|
|
321
|
+
entries = Array.isArray(body.stations)
|
|
322
|
+
? body.stations.map((one) => peerEntry(one, peer)).filter((one): one is GuideEntry => one !== null).slice(0, 500)
|
|
323
|
+
: [];
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
entries = [];
|
|
327
|
+
}
|
|
328
|
+
this.held.set(peer, { at: this.now(), entries });
|
|
329
|
+
})().finally(() => this.asking.delete(peer));
|
|
330
|
+
this.asking.set(peer, work);
|
|
331
|
+
return work;
|
|
332
|
+
}
|
|
333
|
+
}
|
package/src/station-page.ts
CHANGED
|
@@ -179,3 +179,16 @@ export const STATION_PAGE_CSP = [
|
|
|
179
179
|
"form-action 'none'",
|
|
180
180
|
"frame-ancestors 'none'",
|
|
181
181
|
].join("; ");
|
|
182
|
+
|
|
183
|
+
/** The built page: its own bundle, styles and player engines, only this server, never framed. */
|
|
184
|
+
export const STATION_APP_CSP = [
|
|
185
|
+
"default-src 'self'",
|
|
186
|
+
"style-src 'self' 'unsafe-inline'",
|
|
187
|
+
"media-src 'self' blob:",
|
|
188
|
+
"worker-src 'self' blob:",
|
|
189
|
+
"img-src 'self' data: blob:",
|
|
190
|
+
"connect-src 'self'",
|
|
191
|
+
"base-uri 'none'",
|
|
192
|
+
"form-action 'none'",
|
|
193
|
+
"frame-ancestors 'none'",
|
|
194
|
+
].join("; ");
|
package/src/station-routes.ts
CHANGED
|
@@ -61,6 +61,8 @@ export interface StationContext {
|
|
|
61
61
|
throttle?: () => { retryAfter: number } | null;
|
|
62
62
|
/** Auto stations: run a sync with these settings (and keep them), or read them. */
|
|
63
63
|
auto?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
|
|
64
|
+
/** The public stations other servers list (guide peers); absent when there are none. */
|
|
65
|
+
peers?: () => Promise<unknown[]>;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
function fail(ctx: StationContext, response: ServerResponse, error: unknown): void {
|
|
@@ -160,8 +162,13 @@ export async function handleStations(
|
|
|
160
162
|
|
|
161
163
|
// The public guide: only stations their owner made public and listed, with
|
|
162
164
|
// their links, because public is what that means. Nothing else, ever.
|
|
165
|
+
// A peer asking for this one's (?local=1) gets this server's stations only.
|
|
163
166
|
if (path === "/api/v1/stations/guide" && (method === "GET" || method === "HEAD")) {
|
|
164
|
-
|
|
167
|
+
const own = guideEntries(ctx);
|
|
168
|
+
const shown = new Set(own.map((one) => one.page));
|
|
169
|
+
const others = url.searchParams.has("local") || !ctx.peers ? [] : (await ctx.peers())
|
|
170
|
+
.filter((one) => !shown.has((one as { page: string }).page));
|
|
171
|
+
ctx.json(response, 200, { stations: [...own, ...others] });
|
|
165
172
|
return true;
|
|
166
173
|
}
|
|
167
174
|
|
package/src/station-usage.ts
CHANGED
|
@@ -16,6 +16,8 @@ export const USAGE = `nixamp station — make a collection into a station
|
|
|
16
16
|
nixamp station delete ID
|
|
17
17
|
nixamp station guide-host NAME [--remove]
|
|
18
18
|
NAME's front page is the public station guide (e.g. a Moshpit name)
|
|
19
|
+
nixamp station guide-peer URL [--remove]
|
|
20
|
+
the guide also lists the public, listed stations of the server at URL
|
|
19
21
|
nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]
|
|
20
22
|
a station per genre, kept in step with the library (--off stops it)
|
|
21
23
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{t as e}from"./main-D_k0QHkM.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};
|