nixamp 0.43.1 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/guide-rooms.d.ts +90 -0
  2. package/dist/guide-rooms.js +256 -0
  3. package/dist/library.d.ts +8 -0
  4. package/dist/library.js +1 -0
  5. package/dist/pit-footer.d.ts +3 -0
  6. package/dist/pit-footer.js +40 -0
  7. package/dist/server.d.ts +6 -0
  8. package/dist/server.js +75 -5
  9. package/dist/station-cli.js +19 -0
  10. package/dist/station-guide.d.ts +24 -3
  11. package/dist/station-guide.js +60 -8
  12. package/dist/station-page.d.ts +5 -0
  13. package/dist/station-page.js +15 -0
  14. package/dist/station-routes.d.ts +2 -0
  15. package/dist/station-routes.js +3 -1
  16. package/dist/station-usage.d.ts +1 -1
  17. package/dist/station-usage.js +2 -0
  18. package/package.json +1 -1
  19. package/src/guide-rooms.ts +296 -0
  20. package/src/library.ts +5 -0
  21. package/src/pit-footer.ts +40 -0
  22. package/src/server.ts +79 -5
  23. package/src/station-cli.ts +21 -0
  24. package/src/station-guide.ts +68 -10
  25. package/src/station-page.ts +16 -0
  26. package/src/station-routes.ts +5 -1
  27. package/src/station-usage.ts +2 -0
  28. package/web/dist/assets/{ar-CnqhCJJo.js → ar-CV8jyOug.js} +1 -1
  29. package/web/dist/assets/{cs-Bsyx7wxR.js → cs-_qnbzDla.js} +1 -1
  30. package/web/dist/assets/{da-B4kIRtMO.js → da-B5FYL3Hl.js} +1 -1
  31. package/web/dist/assets/{de-CUIKrC06.js → de-CBwqcL_O.js} +1 -1
  32. package/web/dist/assets/{es-xgQHSTbS.js → es-Cqpz6_wS.js} +1 -1
  33. package/web/dist/assets/{fi-BarRr2r9.js → fi-BCckNeQZ.js} +1 -1
  34. package/web/dist/assets/{fr-CkdhPlV8.js → fr-BtTpQJDS.js} +1 -1
  35. package/web/dist/assets/{hi-Bmz_R4Qe.js → hi-kHrHp-LW.js} +1 -1
  36. package/web/dist/assets/{hu-Buques8B.js → hu-uG0HRO6c.js} +1 -1
  37. package/web/dist/assets/{id-DI6eGXlp.js → id-hoEx4_vN.js} +1 -1
  38. package/web/dist/assets/{it-DoPVwxbT.js → it-Bx4j5Tgv.js} +1 -1
  39. package/web/dist/assets/main-B__9rtDL.js +22 -0
  40. package/web/dist/assets/{nl-Bi2G8eau.js → nl-BZCUFXO1.js} +1 -1
  41. package/web/dist/assets/{radio-Cvjck2GD.js → radio-BU-XH1pD.js} +1 -1
  42. package/web/dist/assets/{ru-gdX1kz2l.js → ru-CnhmLVES.js} +1 -1
  43. package/web/dist/assets/{station-B9ORrH8G.js → station-D3ICZ3fF.js} +1 -1
  44. package/web/dist/assets/styles-dKtpEefI.css +1 -0
  45. package/web/dist/assets/{sv-DETdNoY-.js → sv-DgLuYxwC.js} +1 -1
  46. package/web/dist/assets/{uk-BiRmx495.js → uk-DSdKgI41.js} +1 -1
  47. package/web/dist/assets/{vi-C018OY69.js → vi-BR1oDS1f.js} +1 -1
  48. package/web/dist/assets/watch-DHAXWXqJ.js +1 -0
  49. package/web/dist/assets/{zh-HZ6x11wW.js → zh-Dcq7yWXP.js} +1 -1
  50. package/web/dist/index.html +40 -5
  51. package/web/dist/radio.html +3 -3
  52. package/web/dist/station.html +3 -3
  53. package/web/dist/sw.js +25 -23
  54. package/web/dist/watch.html +30 -0
  55. package/web/dist/assets/main-VepIOCc0.js +0 -22
  56. package/web/dist/assets/styles-qC62Gona.css +0 -1
  57. /package/web/dist/assets/{styles-D70tSrIe.js → styles-DmL68lWt.js} +0 -0
@@ -31,6 +31,8 @@ button.report:hover{color:var(--warn)}
31
31
  #share .or{color:var(--muted);font-size:.85rem;margin:8px 0 0}
32
32
  #status{margin-top:10px;font-size:.9rem;min-height:1.4em}
33
33
  footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
34
+ .ad-unit{margin:28px auto 0;display:flex;justify-content:center}.ad-unit iframe{border:0;display:block;background:transparent}
35
+ .ad-banner_300x250 iframe{width:300px;height:250px;max-width:100%}.ad-text_link iframe{width:100%;height:40px}
34
36
  `;
35
37
  /**
36
38
  * A card's now-playing line from tags as they come: links pasted into a title
@@ -63,7 +65,7 @@ const SCRIPT = `
63
65
  } catch { button.textContent = "Could not report; try again"; button.disabled = false; }
64
66
  };
65
67
  const draw = (stations) => {
66
- for (const group of ["ai", "shared", "radio", "tv"]) {
68
+ for (const group of ["rooms", "ai", "shared", "radio", "tv"]) {
67
69
  const grid = document.querySelector("#" + group);
68
70
  grid.replaceChildren();
69
71
  const list = stations.filter((s) => (s.group || (s.kind === "video" ? "tv" : "radio")) === group);
@@ -137,17 +139,18 @@ const SCRIPT = `
137
139
  setInterval(load, 15000);
138
140
  })();
139
141
  `;
142
+ const CRAWLPROOF = "https://crawlproof.com";
140
143
  const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
141
144
  function escape(text) {
142
145
  return text.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]);
143
146
  }
144
- const GROUPS = ["ai", "shared", "radio", "tv"];
147
+ const GROUPS = ["rooms", "ai", "shared", "radio", "tv"];
145
148
  /**
146
149
  * The guide with its stations already in it, so it reads the same through a
147
150
  * gateway that cannot run its script against this server (the Moshpit's
148
151
  * /n/<name>), and for a crawler. Everything from a station is escaped.
149
152
  */
150
- export function renderGuide(entries, apiBase = "") {
153
+ export function renderGuide(entries, apiBase = "", ads = {}) {
151
154
  const groupOf = (one) => one.group ?? (one.kind === "video" ? "tv" : "radio");
152
155
  const cards = (group) => entries
153
156
  .filter((one) => groupOf(one) === group && /^https?:\/\//.test(one.page))
@@ -160,6 +163,14 @@ export function renderGuide(entries, apiBase = "") {
160
163
  .replace(`<h2 id="${group}-title" hidden>`, made ? `<h2 id="${group}-title">` : `<h2 id="${group}-title" hidden>`)
161
164
  .replace(`<div id="${group}" class="grid" aria-live="polite"></div>`, `<div id="${group}" class="grid" aria-live="polite">${made}</div>`);
162
165
  }
166
+ if (ads.site)
167
+ page = page.replace("</head>", `<script data-site="${escape(ads.site)}" src="${CRAWLPROOF}/stats.js" async></script>\n</head>`);
168
+ if (ads.slot) {
169
+ const format = ads.format ?? "banner_300x250";
170
+ const slot = ads.slot;
171
+ // The one ad goes at the top of the content, never against the footer.
172
+ page = page.replace('<p class="lead">Radio and TV, on the air now. Pick one and press play.</p>\n', (lead) => `${lead} <div class="ad-unit ad-${format}"><iframe src="${CRAWLPROOF}/api/ads/frame?slot=${encodeURIComponent(slot)}&amp;format=${format}" title="Advertisement" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" scrolling="no"></iframe></div>\n`);
173
+ }
163
174
  const api = /^https:\/\/[^\s"<>]+$/.test(apiBase) ? apiBase.replace(/\/+$/, "") : "";
164
175
  return (page.replace('<p id="empty" class="empty">', entries.length ? '<p id="empty" class="empty" hidden>' : '<p id="empty" class="empty">')).replace("<body>", api ? `<body data-api="${escape(api)}">` : "<body>");
165
176
  }
@@ -177,6 +188,8 @@ export const GUIDE_HTML = `<!doctype html>
177
188
  <main>
178
189
  <h1>Live</h1>
179
190
  <p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
191
+ <h2 id="rooms-title" hidden>Live rooms</h2>
192
+ <div id="rooms" class="grid" aria-live="polite"></div>
180
193
  <h2 id="ai-title" hidden>AI Radio</h2>
181
194
  <div id="ai" class="grid" aria-live="polite"></div>
182
195
  <h2 id="shared-title" hidden>Shared by listeners</h2>
@@ -208,16 +221,55 @@ export const GUIDE_HTML = `<!doctype html>
208
221
  <button type="submit">Share it</button>
209
222
  <div id="status" role="status" aria-live="polite"></div>
210
223
  </form>
211
- <footer>Played by <a href="https://nixamp.com">nixamp</a>, on the Moshpit.</footer>
224
+ <footer>Played by <a href="https://nixamp.com">nixamp</a>.</footer>
212
225
  </main>
213
226
  <script>${SCRIPT}</script>
214
227
  </body>
215
228
  </html>
216
229
  `;
217
- export const GUIDE_CSP = [
218
- "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src 'self' ${hash(STYLE)}`, "connect-src 'self' https:",
219
- "img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
220
- ].join("; ");
230
+ /**
231
+ * The guide's policy. With crawlproof configured (guideAds below) its tracker
232
+ * script and its ad frame are allowed from crawlproof.com and nothing else
233
+ * changes; the tracker reports by fetch, which connect-src https: already allows.
234
+ */
235
+ export function guideCsp(ads = {}) {
236
+ return [
237
+ "default-src 'none'", `script-src ${hash(SCRIPT)}${ads.site ? ` ${CRAWLPROOF}` : ""}`, `style-src 'self' ${hash(STYLE)}`, "connect-src 'self' https:",
238
+ `img-src 'self' data:${ads.site || ads.slot ? ` ${CRAWLPROOF}` : ""}`, ...(ads.slot ? [`frame-src ${CRAWLPROOF}`] : []),
239
+ "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
240
+ ].join("; ");
241
+ }
242
+ export const GUIDE_CSP = guideCsp();
243
+ const AD_ID = /^[A-Za-z0-9-]{1,64}$/;
244
+ /** A setting as the guide may use it: ids that are ids, a format it knows, or nothing. */
245
+ export function cleanGuideAds(value) {
246
+ if (typeof value !== "object" || value === null)
247
+ return {};
248
+ const one = value;
249
+ const site = typeof one["site"] === "string" && AD_ID.test(one["site"].trim()) ? one["site"].trim() : "";
250
+ const slot = typeof one["slot"] === "string" && AD_ID.test(one["slot"].trim()) ? one["slot"].trim() : "";
251
+ const format = one["format"] === "text_link" || one["format"] === "banner_300x250" ? one["format"] : undefined;
252
+ return { ...(site ? { site } : {}), ...(slot ? { slot } : {}), ...(format && slot ? { format } : {}) };
253
+ }
254
+ /**
255
+ * crawlproof on the guide: `guideAds` in config.json (`nixamp station guide-ads
256
+ * --site ID --slot ID`), each overridden by NIXAMP_GUIDE_CRAWLPROOF_SITE /
257
+ * NIXAMP_GUIDE_AD_SLOT / NIXAMP_GUIDE_AD_FORMAT. Unset is off: a guide on
258
+ * somebody's own machine carries nobody's tracker.
259
+ */
260
+ export function guideAds(env = process.env) {
261
+ const saved = (() => { try {
262
+ return readConfig().guideAds ?? {};
263
+ }
264
+ catch {
265
+ return {};
266
+ } })();
267
+ return cleanGuideAds({
268
+ site: env["NIXAMP_GUIDE_CRAWLPROOF_SITE"] ?? saved.site,
269
+ slot: env["NIXAMP_GUIDE_AD_SLOT"] ?? saved.slot,
270
+ format: env["NIXAMP_GUIDE_AD_FORMAT"] ?? saved.format,
271
+ });
272
+ }
221
273
  /**
222
274
  * The hosts whose front page is the guide: `guideHosts` in config.json
223
275
  * (`nixamp station guide-host NAME`, which survives restarts and updates),
@@ -4,4 +4,9 @@ export declare const STATION_PAGE_HTML = "<!doctype html>\n<html lang=\"en\">\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. */
7
+ /**
8
+ * A live room's page (/watch): the same built page shape, but what it plays and
9
+ * asks about is on the room's own server, any https nixamp server.
10
+ */
11
+ export declare const WATCH_APP_CSP: string;
7
12
  export declare const STATION_APP_CSP: string;
@@ -209,6 +209,21 @@ export const STATION_PAGE_CSP = [
209
209
  "frame-ancestors 'none'",
210
210
  ].join("; ");
211
211
  /** The built page: its own bundle, styles and player engines, only this server, never framed. */
212
+ /**
213
+ * A live room's page (/watch): the same built page shape, but what it plays and
214
+ * asks about is on the room's own server, any https nixamp server.
215
+ */
216
+ export const WATCH_APP_CSP = [
217
+ "default-src 'self'",
218
+ "style-src 'self' 'unsafe-inline'",
219
+ "media-src 'self' blob: https:",
220
+ "worker-src 'self' blob:",
221
+ "img-src 'self' data: blob: https:",
222
+ "connect-src 'self' https:",
223
+ "base-uri 'none'",
224
+ "form-action 'none'",
225
+ "frame-ancestors 'none'",
226
+ ].join("; ");
212
227
  export const STATION_APP_CSP = [
213
228
  "default-src 'self'",
214
229
  "style-src 'self' 'unsafe-inline'",
@@ -83,6 +83,8 @@ export interface StationContext {
83
83
  };
84
84
  /** The public stations other servers list (guide peers); absent when there are none. */
85
85
  peers?: () => Promise<unknown[]>;
86
+ /** Live rooms members published to this guide (guide-rooms.ts); absent when this server keeps none. */
87
+ rooms?: () => Promise<unknown[]>;
86
88
  }
87
89
  declare function links(ctx: StationContext, station: Station, base?: string): {
88
90
  page: string;
@@ -100,7 +100,9 @@ export async function handleStations(request, response, path, url, ctx) {
100
100
  const shown = new Set(own.map((one) => one.page));
101
101
  const others = url.searchParams.has("local") || !ctx.peers ? [] : (await ctx.peers())
102
102
  .filter((one) => !shown.has(one.page));
103
- ctx.json(response, 200, { stations: [...own, ...others] });
103
+ // Rooms are this guide's own; a peer asking for its stations does not list them twice.
104
+ const rooms = url.searchParams.has("local") || !ctx.rooms ? [] : await ctx.rooms();
105
+ ctx.json(response, 200, { stations: [...rooms, ...own, ...others] });
104
106
  return true;
105
107
  }
106
108
  const listen = LISTEN.exec(path);
@@ -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 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.";
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-ads [--site ID] [--slot ID] [--format banner_300x250|text_link] [--off]\n crawlproof analytics (project ID) and an ad unit (slot ID) on the guide\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.";
@@ -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-ads [--site ID] [--slot ID] [--format banner_300x250|text_link] [--off]
20
+ crawlproof analytics (project ID) and an ad unit (slot ID) on the guide
19
21
  nixamp station guide-peer URL [--remove]
20
22
  the guide also lists the public, listed stations of the server at URL
21
23
  nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.43.1",
3
+ "version": "0.45.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Live rooms on the station guide.
3
+ *
4
+ * A room is a channel, or the live stream, of any nixamp server: the address
5
+ * a Share or a "?play=" link carries, `https://host:port/api/channels/<id>?k=KEY`
6
+ * or `https://host:port/api/live?k=KEY`. A nixamp.com member publishes one with
7
+ * a rights basis, as a station is published; the guide lists it beside the
8
+ * stations, and its card opens /watch?play=<that address>, which plays it as
9
+ * a film or as radio, whichever it carries.
10
+ *
11
+ * Nothing here plays anything: the room's own server does, and this only asks
12
+ * it, now and then, whether the room is on the air and what is on it.
13
+ */
14
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { randomBytes } from "node:crypto";
17
+ import type { IncomingMessage, ServerResponse } from "node:http";
18
+ import { readConfig } from "./library.ts";
19
+ import type { GuideEntry } from "./station-guide.ts";
20
+
21
+ /** Why a room may be broadcast, the same five reasons a station gives. */
22
+ export const ROOM_BASES = ["owned", "permission", "licensed", "creative-commons", "public-domain"] as const;
23
+ export type RoomBasis = typeof ROOM_BASES[number];
24
+
25
+ export interface GuideRoom {
26
+ id: string;
27
+ /** The room's stream address, with its listen key. */
28
+ play: string;
29
+ name: string;
30
+ kind: "audio" | "video";
31
+ basis: RoomBasis;
32
+ note: string;
33
+ /** The nixamp.com account that published it ("" for this server's owner). */
34
+ by: string;
35
+ publishedAt: number;
36
+ }
37
+
38
+ /** Rooms one member may have on the guide at once, and rooms on it in all. */
39
+ export const ROOMS_PER_MEMBER = 5;
40
+ export const ROOMS_MAX = 200;
41
+ const STATE_TTL_MS = 15_000;
42
+ const PROBE_TIMEOUT_MS = 4_000;
43
+
44
+ /**
45
+ * A room's address taken apart, or null when it is not one: https (http only
46
+ * for a loopback address, for tests), a channel or the live stream, a key,
47
+ * and nothing else in the query.
48
+ */
49
+ export function roomAddress(input: string): { origin: string; channel: string | null; key: string; play: string } | null {
50
+ let url: URL;
51
+ try {
52
+ url = new URL(input.trim());
53
+ } catch {
54
+ return null;
55
+ }
56
+ const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost";
57
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null;
58
+ if (url.username || url.password || url.hash) return null;
59
+ const channel = /^\/api\/channels\/([A-Za-z0-9._~-]{1,120})\/?$/.exec(url.pathname);
60
+ const live = /^\/api\/live\/?$/.test(url.pathname);
61
+ if (!channel && !live) return null;
62
+ const key = url.searchParams.get("k") ?? "";
63
+ if (!/^[A-Za-z0-9_-]{8,128}$/.test(key) || [...url.searchParams.keys()].some((name) => name !== "k")) return null;
64
+ const path = channel ? `/api/channels/${channel[1]}` : "/api/live";
65
+ return { origin: url.origin, channel: channel ? channel[1]! : null, key, play: `${url.origin}${path}?k=${key}` };
66
+ }
67
+
68
+ /** Where the guide links a room's page: config `watchBase`, NIXAMP_WATCH_BASE, else this server. */
69
+ export function watchBase(fallback: string, env: Record<string, string | undefined> = process.env): string {
70
+ const saved = (() => { try { return readConfig().watchBase ?? ""; } catch { return ""; } })();
71
+ const chosen = (env["NIXAMP_WATCH_BASE"] || saved || fallback).trim().replace(/\/+$/, "");
72
+ return /^https?:\/\/[^\s"<>]+$/.test(chosen) ? chosen : fallback;
73
+ }
74
+
75
+ export const watchPage = (base: string, play: string): string => `${base}/watch?play=${encodeURIComponent(play)}`;
76
+
77
+ export class RoomStore {
78
+ private rooms: GuideRoom[] = [];
79
+ constructor(private readonly file: string) {
80
+ try {
81
+ if (existsSync(file)) {
82
+ const saved = JSON.parse(readFileSync(file, "utf8")) as { rooms?: GuideRoom[] };
83
+ this.rooms = (saved.rooms ?? []).filter((one) => roomAddress(one.play) !== null);
84
+ }
85
+ } catch {
86
+ // Unreadable is empty; the next write replaces it.
87
+ }
88
+ }
89
+
90
+ static fileFor(stateDir: string, port: number): string {
91
+ return join(stateDir, `guide-rooms-${port}.json`);
92
+ }
93
+
94
+ list(): GuideRoom[] {
95
+ return [...this.rooms];
96
+ }
97
+
98
+ get(id: string): GuideRoom | undefined {
99
+ return this.rooms.find((one) => one.id === id);
100
+ }
101
+
102
+ /** Publishes a room, or refreshes the one already published at that address. */
103
+ put(room: Omit<GuideRoom, "id" | "publishedAt">): GuideRoom {
104
+ const existing = this.rooms.find((one) => one.play === room.play);
105
+ const saved: GuideRoom = { ...room, id: existing?.id ?? randomBytes(6).toString("base64url"), publishedAt: Date.now() };
106
+ this.rooms = [...this.rooms.filter((one) => one.play !== room.play), saved];
107
+ this.save();
108
+ return saved;
109
+ }
110
+
111
+ remove(id: string): boolean {
112
+ const before = this.rooms.length;
113
+ this.rooms = this.rooms.filter((one) => one.id !== id);
114
+ if (this.rooms.length === before) return false;
115
+ this.save();
116
+ return true;
117
+ }
118
+
119
+ private save(): void {
120
+ writeFileSync(`${this.file}.part`, `${JSON.stringify({ version: 1, rooms: this.rooms }, null, 2)}\n`);
121
+ renameSync(`${this.file}.part`, this.file);
122
+ }
123
+ }
124
+
125
+ export interface RoomState {
126
+ /** Whether the room's server says it is on the air. */
127
+ live: boolean;
128
+ name: string;
129
+ kind: "audio" | "video";
130
+ nowPlaying: { title: string; artist: string } | null;
131
+ }
132
+
133
+ interface ChannelRow {
134
+ id: string;
135
+ name?: string;
136
+ kind?: string;
137
+ live?: boolean;
138
+ codecs?: { tags?: { title?: string; artist?: string } };
139
+ }
140
+
141
+ /** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
142
+ export class RoomProbe {
143
+ private cache = new Map<string, { at: number; state: RoomState | null }>();
144
+ /**
145
+ * `refuse` names why an address may not be fetched from this machine (a
146
+ * private or loopback network), or "": a member chooses the address, and
147
+ * this server must not become their way into its own network.
148
+ */
149
+ constructor(
150
+ private readonly fetcher: typeof fetch = fetch,
151
+ private readonly refuse: (link: string) => Promise<string> = async () => "",
152
+ ) {}
153
+
154
+ async state(play: string, fresh = false): Promise<RoomState | null> {
155
+ const cached = this.cache.get(play);
156
+ if (!fresh && cached && Date.now() - cached.at < STATE_TTL_MS) return cached.state;
157
+ const state = await this.ask(play).catch(() => null);
158
+ this.cache.set(play, { at: Date.now(), state });
159
+ if (this.cache.size > 500) this.cache.delete(this.cache.keys().next().value!);
160
+ return state;
161
+ }
162
+
163
+ private async ask(play: string): Promise<RoomState | null> {
164
+ const address = roomAddress(play);
165
+ if (!address || (await this.refuse(address.play)) !== "") return null;
166
+ if (address.channel !== null) {
167
+ const answer = await this.fetcher(`${address.origin}/api/channels?k=${address.key}`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), redirect: "error" });
168
+ if (!answer.ok) return null;
169
+ const body = (await answer.json()) as { channels?: ChannelRow[] };
170
+ const row = (body.channels ?? []).find((one) => one.id === address.channel);
171
+ if (!row) return null;
172
+ const tags = row.codecs?.tags;
173
+ return {
174
+ live: row.live !== false,
175
+ name: String(row.name ?? address.channel).replace(/\/+$/, "").slice(0, 120),
176
+ kind: row.kind === "video" ? "video" : "audio",
177
+ nowPlaying: tags && (tags.title || tags.artist) ? { title: String(tags.title ?? ""), artist: String(tags.artist ?? "") } : null,
178
+ };
179
+ }
180
+ // The live stream: on the air when it answers with media.
181
+ const controller = new AbortController();
182
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
183
+ try {
184
+ const answer = await this.fetcher(address.play, { signal: controller.signal, redirect: "error" });
185
+ const type = answer.headers.get("content-type") ?? "";
186
+ controller.abort();
187
+ if (!answer.ok) return null;
188
+ return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null };
189
+ } finally {
190
+ clearTimeout(timer);
191
+ }
192
+ }
193
+ }
194
+
195
+ /** The rooms as guide cards: the page to open, on the air or not, and what is on. */
196
+ export async function roomEntries(store: RoomStore, probe: RoomProbe, base: string): Promise<GuideEntry[]> {
197
+ return Promise.all(store.list().map(async (room) => {
198
+ const state = await probe.state(room.play);
199
+ return {
200
+ id: room.id,
201
+ name: room.name,
202
+ kind: state?.kind ?? room.kind,
203
+ group: "rooms" as const,
204
+ state: state?.live ? "live" : "offline",
205
+ page: watchPage(base, room.play),
206
+ nowPlaying: state?.nowPlaying ?? null,
207
+ };
208
+ }));
209
+ }
210
+
211
+ export interface RoomContext {
212
+ store: RoomStore;
213
+ probe: RoomProbe;
214
+ /** The page base for a room's card. */
215
+ base: string;
216
+ /** Whether the request holds this server's control key (or is its signed-in owner). */
217
+ canControl: boolean;
218
+ /** The nixamp.com account behind the request's session, or "". */
219
+ member: () => Promise<string>;
220
+ json: (response: ServerResponse, status: number, body: unknown) => void;
221
+ readBody: (request: IncomingMessage) => Promise<unknown>;
222
+ /** Refused by the caller's rate limit, or null. */
223
+ throttle?: () => { retryAfter: number } | null;
224
+ }
225
+
226
+ /** Answers /api/v1/guide/rooms; false when the path is not one. */
227
+ export async function handleRooms(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomContext): Promise<boolean> {
228
+ if (path !== "/api/v1/guide/rooms" && !path.startsWith("/api/v1/guide/rooms/")) return false;
229
+ const method = request.method ?? "GET";
230
+ if (path === "/api/v1/guide/rooms" && (method === "GET" || method === "HEAD")) {
231
+ ctx.json(response, 200, { rooms: await roomEntries(ctx.store, ctx.probe, ctx.base) });
232
+ return true;
233
+ }
234
+ const by = ctx.canControl ? "" : await ctx.member();
235
+ if (!ctx.canControl && by === "") {
236
+ ctx.json(response, 401, { error: "sign in to nixamp.com to publish a live room", code: "sign_in" });
237
+ return true;
238
+ }
239
+ const refused = ctx.throttle?.();
240
+ if (refused) {
241
+ response.setHeader("retry-after", String(refused.retryAfter));
242
+ ctx.json(response, 429, { error: "too many changes; wait a moment", code: "throttled" });
243
+ return true;
244
+ }
245
+ if (path === "/api/v1/guide/rooms" && method === "POST") {
246
+ const body = (await ctx.readBody(request).catch(() => null)) as { play?: unknown; basis?: unknown; note?: unknown; title?: unknown; agree?: unknown } | null;
247
+ const address = roomAddress(typeof body?.play === "string" ? body.play : "");
248
+ if (!address) {
249
+ ctx.json(response, 400, { error: "play must be a nixamp room's stream address: https://host/api/channels/<id>?k=KEY", code: "bad_address" });
250
+ return true;
251
+ }
252
+ const basis = ROOM_BASES.find((one) => one === body?.basis);
253
+ if (!basis || body?.agree !== true) {
254
+ ctx.json(response, 400, { error: "say why it may be broadcast (owned, permission, licensed, creative-commons or public-domain) and agree", code: "basis_required" });
255
+ return true;
256
+ }
257
+ const state = await ctx.probe.state(address.play, true);
258
+ if (!state) {
259
+ ctx.json(response, 404, { error: "that room is not on its server, or the key does not open it", code: "not_found" });
260
+ return true;
261
+ }
262
+ const all = ctx.store.list();
263
+ const mine = all.filter((one) => one.by === by && one.play !== address.play);
264
+ if (by !== "" && mine.length >= ROOMS_PER_MEMBER) {
265
+ ctx.json(response, 409, { error: `a member may have ${ROOMS_PER_MEMBER} rooms on the guide; take one off first`, code: "too_many" });
266
+ return true;
267
+ }
268
+ if (!all.some((one) => one.play === address.play) && all.length >= ROOMS_MAX) {
269
+ ctx.json(response, 409, { error: "the guide is full", code: "full" });
270
+ return true;
271
+ }
272
+ const taken = all.find((one) => one.play === address.play);
273
+ if (taken && taken.by !== by && !ctx.canControl) {
274
+ ctx.json(response, 409, { error: "somebody else published that room", code: "taken" });
275
+ return true;
276
+ }
277
+ const title = typeof body?.title === "string" ? body.title.trim().slice(0, 120) : "";
278
+ const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
279
+ const room = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, basis, note, by: taken?.by ?? by });
280
+ ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: watchPage(ctx.base, room.play) });
281
+ return true;
282
+ }
283
+ const one = /^\/api\/v1\/guide\/rooms\/([A-Za-z0-9_-]{1,32})$/.exec(path);
284
+ if (one && method === "DELETE") {
285
+ const room = ctx.store.get(one[1]!);
286
+ if (!room || (!ctx.canControl && room.by !== by)) {
287
+ ctx.json(response, 404, { error: "no such room of yours on the guide", code: "not_found" });
288
+ return true;
289
+ }
290
+ ctx.store.remove(room.id);
291
+ ctx.json(response, 200, { removed: room.id });
292
+ return true;
293
+ }
294
+ ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
295
+ return true;
296
+ }
package/src/library.ts CHANGED
@@ -31,6 +31,10 @@ export interface Config {
31
31
  guideHosts?: string[];
32
32
  /** Other servers (https base addresses) whose public, listed stations this guide shows too. */
33
33
  guidePeers?: string[];
34
+ /** Where the guide links a published live room's page (https://live.streaming.pit.world); this server's own address when unset. */
35
+ watchBase?: string;
36
+ /** crawlproof on the guide: tracker project id (site), ad slot and format (`nixamp station guide-ads`). */
37
+ guideAds?: { site?: string; slot?: string; format?: "banner_300x250" | "text_link" };
34
38
  /** Station limits kept across restarts (station-metering.ts). */
35
39
  limits?: Record<string, unknown>;
36
40
  /** Proxies whose X-Forwarded-For is believed for listener shares (`nixamp station shares trust ADDRESS`). */
@@ -53,6 +57,7 @@ export function readConfig(): Config {
53
57
  ...(typeof record["limits"] === "object" && record["limits"] !== null ? { limits: record["limits"] as Record<string, unknown> } : {}),
54
58
  ...(Array.isArray(record["trustedProxies"]) ? { trustedProxies: (record["trustedProxies"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
55
59
  ...(Array.isArray(record["guideHosts"]) ? { guideHosts: (record["guideHosts"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
60
+ ...(typeof record["guideAds"] === "object" && record["guideAds"] !== null ? { guideAds: record["guideAds"] as Config["guideAds"] } : {}),
56
61
  ...(Array.isArray(record["guidePeers"]) ? { guidePeers: (record["guidePeers"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
57
62
  };
58
63
  } catch {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The pit.world global footer (site name, search, ring, publish), fetched on
3
+ * the server and appended as the last thing in <body> of the nixamp.com pages.
4
+ *
5
+ * A module-level cache: the last good copy is served, and one older than an
6
+ * hour is refreshed in the background (3s timeout). A failed refresh keeps the
7
+ * old copy; with no copy at all the page goes out unchanged, so pit.world being
8
+ * down never breaks a page.
9
+ */
10
+ const PIT_FOOTER_URL = "https://pit.world/snippets/footer.html?site=nixamp.com";
11
+ const MAX_AGE_MS = 60 * 60 * 1000;
12
+
13
+ let cached = "";
14
+ let fetchedAt = 0;
15
+ let inflight: Promise<void> | null = null;
16
+
17
+ export function refreshPitFooter(): Promise<void> {
18
+ inflight ??= fetch(PIT_FOOTER_URL, { signal: AbortSignal.timeout(3000) })
19
+ .then(async (res) => {
20
+ const text = res.ok ? await res.text() : "";
21
+ if (!text.includes("data-pit-footer")) throw new Error(`pit footer: HTTP ${res.status}`);
22
+ cached = text;
23
+ fetchedAt = Date.now();
24
+ })
25
+ .catch(() => {
26
+ fetchedAt = Date.now() - MAX_AGE_MS + 60_000; // try again in a minute
27
+ })
28
+ .finally(() => {
29
+ inflight = null;
30
+ });
31
+ return inflight;
32
+ }
33
+
34
+ /** The page with the footer before its last </body>, or the page unchanged. */
35
+ export function withPitFooter(page: string): string {
36
+ if (Date.now() - fetchedAt > MAX_AGE_MS) void refreshPitFooter();
37
+ if (!cached || page.includes("data-pit-footer")) return page;
38
+ const at = page.lastIndexOf("</body>");
39
+ return at < 0 ? page : page.slice(0, at) + cached + page.slice(at);
40
+ }