nixamp 0.53.4 → 0.54.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 (46) hide show
  1. package/dist/follow-page.d.ts +23 -0
  2. package/dist/follow-page.js +111 -0
  3. package/dist/radio-pages.d.ts +211 -0
  4. package/dist/radio-pages.js +415 -0
  5. package/dist/radio-slug.d.ts +18 -0
  6. package/dist/radio-slug.js +43 -0
  7. package/dist/server.js +242 -3
  8. package/dist/station-guide.d.ts +2 -0
  9. package/dist/station-guide.js +5 -3
  10. package/dist/station-page.d.ts +1 -1
  11. package/dist/station-page.js +4 -2
  12. package/dist/station-routes.d.ts +1 -0
  13. package/dist/station-routes.js +28 -3
  14. package/package.json +1 -1
  15. package/src/follow-page.ts +127 -0
  16. package/src/radio-pages.ts +513 -0
  17. package/src/radio-slug.ts +45 -0
  18. package/src/server.ts +232 -3
  19. package/src/station-guide.ts +7 -3
  20. package/src/station-page.ts +4 -2
  21. package/src/station-routes.ts +28 -4
  22. package/web/dist/assets/{ar-BGKEfNq-.js → ar-CHJqwLbG.js} +1 -1
  23. package/web/dist/assets/{cs-Dx24OOlK.js → cs-CmwqmJKg.js} +1 -1
  24. package/web/dist/assets/{da-BZRngpeI.js → da-Xdzrm0AY.js} +1 -1
  25. package/web/dist/assets/{de-xiTdxcZl.js → de-h2ntHz8Y.js} +1 -1
  26. package/web/dist/assets/{es-pBVKJbia.js → es-DgbBXAxw.js} +1 -1
  27. package/web/dist/assets/{fi-CSNMJEFS.js → fi-Bso9G00g.js} +1 -1
  28. package/web/dist/assets/{fr-wC7GpLQl.js → fr-6Q3UZKIG.js} +1 -1
  29. package/web/dist/assets/{hi-CW3SNBLY.js → hi-T7_WxQ11.js} +1 -1
  30. package/web/dist/assets/{hu-BZn2GCJZ.js → hu-BjOppQVq.js} +1 -1
  31. package/web/dist/assets/{id-CKbxwyrb.js → id-BIkZDUv5.js} +1 -1
  32. package/web/dist/assets/{it-Dh6zCRD3.js → it-CHOmOHju.js} +1 -1
  33. package/web/dist/assets/{main-k28oOkQm.js → main-BpsUYSCl.js} +2 -2
  34. package/web/dist/assets/{nl-ComGsk8m.js → nl-CHQR6VFb.js} +1 -1
  35. package/web/dist/assets/{radio-ZiViiR-a.js → radio-yD_-qpS7.js} +1 -1
  36. package/web/dist/assets/{ru-BWPBy8Lk.js → ru-Du7NQrxe.js} +1 -1
  37. package/web/dist/assets/station-xTLbhzsu.js +3 -0
  38. package/web/dist/assets/{sv-BuxreVB9.js → sv-B6AFovY0.js} +1 -1
  39. package/web/dist/assets/{uk-CTjTEDpa.js → uk-LXJz37Kp.js} +1 -1
  40. package/web/dist/assets/{vi-C2M22xyj.js → vi-C4taTvjH.js} +1 -1
  41. package/web/dist/assets/{zh-Cf-VkaC-.js → zh-dBiC2V5a.js} +1 -1
  42. package/web/dist/index.html +1 -1
  43. package/web/dist/radio.html +1 -1
  44. package/web/dist/station.html +1 -1
  45. package/web/dist/sw.js +21 -21
  46. package/web/dist/assets/station-DUt85zQk.js +0 -3
package/dist/server.js CHANGED
@@ -63,7 +63,7 @@ import { handleHandoffs } from "./handoff-routes.js";
63
63
  import { handleAiRoutes, isPublicSharePath } from "./ai-routes.js";
64
64
  import { AiLabel } from "./ai-label.js";
65
65
  import { buyLink, handleLabelRoutes, isPublicLabelPath } from "./label-routes.js";
66
- import { GrantVerifier, MusicStore } from "./music-store.js";
66
+ import { GrantVerifier, MusicStore, PRICES } from "./music-store.js";
67
67
  import { handleStoreRoutes, isStorePath } from "./store-routes.js";
68
68
  import { AiRadio } from "./ai-radio.js";
69
69
  import { ListenerShares } from "./listener-shares.js";
@@ -76,6 +76,8 @@ import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML, WAT
76
76
  import { handleRooms, RoomProbe, roomEntries, RoomStore, watchBase } from "./guide-rooms.js";
77
77
  import { handleVods, VodProbe, vodEntries, VodStore, vodView } from "./guide-vods.js";
78
78
  import { withPitFooter } from "./pit-footer.js";
79
+ import { handleFollowPage } from "./follow-page.js";
80
+ import { asRadioPage, buildCatalogue, isRadioPath, NAV_STYLE, RADIO_ALBUM, RADIO_ARTIST, RADIO_CSP, RADIO_STATION, RADIO_SUB, radioNav, radioPeers, radioRewriter, radioStations, renderAlbum, renderArtist, renderDirectory, renderMissing, rewriteResponse, } from "./radio-pages.js";
79
81
  import { guideAds, guideCsp, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
80
82
  import { StationRunner } from "./station-runner.js";
81
83
  import { StationError, StationStore, channelOf, stationOfChannel } from "./stations.js";
@@ -1496,7 +1498,102 @@ export function createHandler(engine, options) {
1496
1498
  // The music store lives on nixamp.com; a server holding the label's music asks it about grants.
1497
1499
  const storeSite = (process.env["NIXAMP_SITE"] || "https://nixamp.com").replace(/\/+$/, "");
1498
1500
  const storeGrants = new GrantVerifier({ site: storeSite });
1501
+ // /radio's artists and albums, from the public stations' files and the label (radio-pages.ts), a minute at a time.
1502
+ let radioCache = null;
1503
+ const radioCatalogue = () => {
1504
+ if (radioCache && Date.now() - radioCache.at < 60_000)
1505
+ return radioCache.value;
1506
+ const label = options.stations?.label;
1507
+ const index = options.library?.index;
1508
+ const value = buildCatalogue({
1509
+ stations: radioStations(options.stations?.store.list() ?? []),
1510
+ ...(label ? { label: label.publicView(), songAt: (file) => label.songAt(file), audioFor: (file) => label.audioFor(file) } : {}),
1511
+ tags: (file) => {
1512
+ const item = index?.itemAt(file);
1513
+ if (!item)
1514
+ return null;
1515
+ const tags = { ...item, ...item.override };
1516
+ return { title: tags.title, artist: tags.artist || tags.albumArtist, album: tags.album, year: tags.year, track: tags.track, duration: item.duration, hasArt: item.hasArt };
1517
+ },
1518
+ });
1519
+ radioCache = { at: Date.now(), value };
1520
+ return value;
1521
+ };
1499
1522
  return async function handle(request, response) {
1523
+ // A station's own paths under its /radio name (radio-pages.ts): asked of
1524
+ // the station's routes with its token, which never leaves this server,
1525
+ // and answered with the /radio addresses in place of the station's.
1526
+ if (options.stations && (request.url ?? "").startsWith("/radio/")) {
1527
+ const asked = new URL(request.url ?? "/", "http://localhost");
1528
+ const sub = RADIO_SUB.exec(asked.pathname);
1529
+ if (sub) {
1530
+ const own = radioStations(options.stations.store.list());
1531
+ const station = own.get(sub[1]);
1532
+ const peer = station || !options.stations.peers ? undefined : radioPeers(own.keys(), await options.stations.peers.entries()).get(sub[1]);
1533
+ if (!station && !peer) {
1534
+ json(response, 404, { error: "no station by that name", code: "not_found" });
1535
+ return;
1536
+ }
1537
+ if (sub[2] === "listen.m3u") {
1538
+ const name = (station ?? peer).name;
1539
+ const base = radioBaseFor(request, options.stations.publicBase?.() ?? "");
1540
+ response.writeHead(200, { ...CORS, "content-type": "audio/x-mpegurl; charset=utf-8", "content-disposition": `inline; filename="${sub[1]}.m3u"`, "cache-control": "no-store" });
1541
+ response.end(request.method === "HEAD" ? undefined : `#EXTM3U\n#EXTINF:-1,${name.replace(/[\r\n]/g, " ")}\n${base}/radio/${sub[1]}/stream\n`);
1542
+ return;
1543
+ }
1544
+ if (peer) {
1545
+ // A peer's station: asked of the peer with its token, passed on as it comes.
1546
+ if (request.method !== "GET" && request.method !== "HEAD") {
1547
+ json(response, 405, { error: "a station's link can listen, not drive", code: "method_not_allowed" });
1548
+ return;
1549
+ }
1550
+ const query = new URLSearchParams(asked.search);
1551
+ query.delete("t");
1552
+ query.set("t", peer.token);
1553
+ const stop = new AbortController();
1554
+ response.on("close", () => stop.abort());
1555
+ const answer = await fetch(`${peer.origin}/api/v1/stations/${peer.id}/${sub[2]}?${query.toString()}`, {
1556
+ method: request.method, signal: stop.signal,
1557
+ headers: { ...(request.headers.range ? { range: String(request.headers.range) } : {}), accept: String(request.headers.accept ?? "*/*") },
1558
+ }).catch(() => null);
1559
+ if (!answer) {
1560
+ json(response, 502, { error: "that station's server did not answer", code: "upstream" });
1561
+ return;
1562
+ }
1563
+ rewriteResponse(response, radioRewriter({ id: peer.id, listenToken: peer.token }, sub[1]));
1564
+ const headers = { ...CORS };
1565
+ for (const name of ["content-type", "cache-control", "content-length", "content-range", "accept-ranges", "x-accel-buffering", "retry-after"]) {
1566
+ const value = answer.headers.get(name);
1567
+ if (value)
1568
+ headers[name] = value;
1569
+ }
1570
+ response.writeHead(answer.status, headers);
1571
+ if (!answer.body || request.method === "HEAD") {
1572
+ response.end();
1573
+ return;
1574
+ }
1575
+ try {
1576
+ for await (const chunk of answer.body) {
1577
+ if (response.destroyed)
1578
+ break;
1579
+ if (!response.write(chunk))
1580
+ await new Promise((done) => { response.once("drain", done); response.once("close", done); });
1581
+ }
1582
+ }
1583
+ catch {
1584
+ // The listener left, or the peer did.
1585
+ }
1586
+ response.end();
1587
+ return;
1588
+ }
1589
+ const query = new URLSearchParams(asked.search);
1590
+ query.delete("t");
1591
+ query.set("t", station.listenToken);
1592
+ request.url = `/api/v1/stations/${station.id}/${sub[2]}?${query.toString()}`;
1593
+ rewriteResponse(response, radioRewriter(station, sub[1]));
1594
+ return handle(request, response);
1595
+ }
1596
+ }
1500
1597
  const url = new URL(request.url ?? "/", "http://localhost");
1501
1598
  const path = url.pathname;
1502
1599
  const webSite = options.webSites?.get((request.headers.host ?? "").toLowerCase());
@@ -1884,6 +1981,16 @@ export function createHandler(engine, options) {
1884
1981
  json(response, reply.status, reply.body);
1885
1982
  return;
1886
1983
  }
1984
+ // Following an artist from a /radio page, where the nixamp.com session is first-party (follow-page.ts).
1985
+ if (path === "/follow" && options.follows && options.accounts) {
1986
+ const accounts = options.accounts;
1987
+ if (await handleFollowPage(request, response, path, url, {
1988
+ account: () => accounts.whoIs(tokenFrom(request.headers)),
1989
+ follows: options.follows,
1990
+ readBody: (body) => readBody(body, 8 * 1024),
1991
+ }))
1992
+ return;
1993
+ }
1887
1994
  if (path.startsWith("/api/v1/follows") && options.follows && options.accounts) {
1888
1995
  const me = await options.accounts.whoIs(tokenFrom(request.headers));
1889
1996
  if (me === null) {
@@ -2214,6 +2321,10 @@ export function createHandler(engine, options) {
2214
2321
  // On demand: a file's page and the guide's list of them (guide-vods.ts); the file's own key travels in its play address.
2215
2322
  !((path === "/vod" || /^\/vod\/[A-Za-z0-9_-]{1,32}$/.test(path) || path === "/api/v1/guide/vods" || path.startsWith("/api/v1/guide/vods/")) && Boolean(options.stations?.vods)) &&
2216
2323
  !STATION_PAGE.test(path) &&
2324
+ // Public, listed stations under readable names, and their artists and albums (radio-pages.ts).
2325
+ !(isRadioPath(path) && Boolean(options.stations)) &&
2326
+ // Following an artist from those pages, signed in at nixamp.com.
2327
+ !(path === "/follow" && Boolean(options.follows && options.accounts)) &&
2217
2328
  !isPublicAsset &&
2218
2329
  !isGuidePage &&
2219
2330
  !eventChannelRequest) {
@@ -2366,7 +2477,15 @@ export function createHandler(engine, options) {
2366
2477
  json(response, 404, { error: "no public station with that id", code: "not_found" });
2367
2478
  return;
2368
2479
  }
2369
- response.writeHead(302, { location: found.page, "cache-control": "no-store", "referrer-policy": "no-referrer" });
2480
+ // Its /radio name, ours or a peer's (radio-pages.ts): no server address or token in the link it lands on.
2481
+ let location = "radio" in found && typeof found.radio === "string" && /^https?:\/\//.test(found.radio) ? found.radio : "";
2482
+ if (!location && options.stations.peers) {
2483
+ const named = radioPeers(radioStations(options.stations.store.list()).keys(), await options.stations.peers.entries());
2484
+ const slug = [...named].find(([, one]) => one.id === stationShortLink[1])?.[0];
2485
+ if (slug)
2486
+ location = `${watchBase(options.stations.publicBase?.() || `https://${request.headers.host ?? "localhost"}`)}/radio/${slug}`;
2487
+ }
2488
+ response.writeHead(302, { location: location || found.page, "cache-control": "no-store", "referrer-policy": "no-referrer" });
2370
2489
  response.end();
2371
2490
  return;
2372
2491
  }
@@ -2393,7 +2512,24 @@ export function createHandler(engine, options) {
2393
2512
  : [];
2394
2513
  const ads = guideAds();
2395
2514
  const label = options.stations.label;
2396
- const page = renderGuide([...rooms, ...entries, ...others], options.stations.publicBase?.() ?? "", ads, label && label.publicView().genres.length > 0 ? "/label" : "");
2515
+ // A peer's station by its /radio name here too, asked of the peer by this server (radio-pages.ts).
2516
+ const radioBase = watchBase(options.stations.publicBase?.() || `https://${request.headers.host ?? "localhost"}`);
2517
+ const peerNames = radioPeers(radioStations(options.stations.store.list()).keys(), others);
2518
+ const peerRadio = new Map([...peerNames].map(([slug, one]) => [`${one.origin}/station/${one.id}`, `${radioBase}/radio/${slug}`]));
2519
+ const named = others.map((one) => {
2520
+ const at = (() => { try {
2521
+ const u = new URL(one.page);
2522
+ return `${u.origin}${u.pathname.replace(/\/+$/, "")}`;
2523
+ }
2524
+ catch {
2525
+ return "";
2526
+ } })();
2527
+ const radio = peerRadio.get(at);
2528
+ return radio ? { ...one, radio } : one;
2529
+ });
2530
+ // Behind a guide name (live.streaming) the page asks its own origin, so no server address shows in it.
2531
+ const apiBase = guideHosts().has(requestHost) ? "" : options.stations.publicBase?.() ?? "";
2532
+ const page = renderGuide([...rooms, ...entries, ...named], apiBase, ads, label && label.publicView().genres.length > 0 ? "/label" : "");
2397
2533
  response.writeHead(200, {
2398
2534
  "content-type": "text/html; charset=utf-8",
2399
2535
  "cache-control": "no-cache",
@@ -2452,6 +2588,101 @@ export function createHandler(engine, options) {
2452
2588
  response.end(request.method === "HEAD" ? undefined : built.replace(/<title>[^<]*<\/title>/, head));
2453
2589
  return;
2454
2590
  }
2591
+ // /radio: public, listed stations under readable names, and the artists and
2592
+ // albums they play (radio-pages.ts). A station's own paths under its name
2593
+ // were answered at the top of this handler.
2594
+ if (options.stations && isRadioPath(path) && (request.method === "GET" || request.method === "HEAD")) {
2595
+ const stations = options.stations;
2596
+ const named = radioStations(stations.store.list());
2597
+ const publicBase = stations.publicBase?.() ?? "";
2598
+ const base = radioBaseFor(request, publicBase);
2599
+ const nav = radioNav(guideHosts().has(requestHost) ? "/" : "/guide");
2600
+ const send = (status, page, csp = RADIO_CSP) => {
2601
+ response.writeHead(status, {
2602
+ "content-type": "text/html; charset=utf-8",
2603
+ "cache-control": status === 200 ? "public, max-age=60" : "no-cache",
2604
+ "content-security-policy": csp,
2605
+ "referrer-policy": "strict-origin-when-cross-origin",
2606
+ "x-content-type-options": "nosniff",
2607
+ });
2608
+ response.end(request.method === "HEAD" ? undefined : page);
2609
+ };
2610
+ const shop = stations.label && /^https:\/\//.test(publicBase)
2611
+ ? { site: storeSite, buy: (kind, artist, album = "", n = 0) => buyLink(storeSite, publicBase, kind, artist, album, n), prices: PRICES }
2612
+ : null;
2613
+ const peers = stations.peers ? radioPeers(named.keys(), await stations.peers.entries()) : new Map();
2614
+ const stationNames = new Map([...named].map(([slug, one]) => [slug, one.name]));
2615
+ if (path === "/radio" || path === "/radio/") {
2616
+ const encrypted = request.socket.encrypted === true;
2617
+ const entries = guideEntries({
2618
+ store: stations.store, runner: stations.runner, channels: options.channels, canControl: false, account: "",
2619
+ select: () => null, trackFor: (file) => trackFor(engine, file),
2620
+ base: `${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`,
2621
+ publicBase, json, readBody, headers: CORS,
2622
+ });
2623
+ send(200, renderDirectory([
2624
+ ...entries.map((one) => ({
2625
+ slug: one.radio.split("/radio/").pop() ?? "", name: one.name, description: one.description, state: one.state, kind: one.kind, group: one.group,
2626
+ nowPlaying: one.nowPlaying,
2627
+ })),
2628
+ ...[...peers].map(([slug, one]) => ({ slug, name: one.name, description: "", state: one.state, kind: one.kind, group: one.group, nowPlaying: one.nowPlaying })),
2629
+ ], base, nav));
2630
+ return;
2631
+ }
2632
+ const artistPath = RADIO_ARTIST.exec(path);
2633
+ if (artistPath) {
2634
+ const catalogue = radioCatalogue();
2635
+ const artist = catalogue.artists.get(artistPath[1]);
2636
+ if (!artist)
2637
+ return send(404, renderMissing("artist", base, nav));
2638
+ return send(200, renderArtist(catalogue, artist, { base, nav, shop, site: storeSite, stationNames }));
2639
+ }
2640
+ const albumPath = RADIO_ALBUM.exec(path);
2641
+ if (albumPath) {
2642
+ const catalogue = radioCatalogue();
2643
+ const album = catalogue.albums.get(albumPath[1]);
2644
+ if (albumPath[2]) {
2645
+ const picture = album?.coverFile && stations.cover ? await stations.cover(album.coverFile).catch(() => null) : null;
2646
+ if (!picture) {
2647
+ json(response, 404, { error: "no cover for that album", code: "not_found" });
2648
+ return;
2649
+ }
2650
+ response.writeHead(200, { "content-type": picture.type, "content-length": picture.bytes.length, "cache-control": "public, max-age=86400", "x-content-type-options": "nosniff" });
2651
+ response.end(request.method === "HEAD" ? undefined : picture.bytes);
2652
+ return;
2653
+ }
2654
+ if (!album)
2655
+ return send(404, renderMissing("album", base, nav));
2656
+ return send(200, renderAlbum(catalogue, album, { base, nav, shop, site: storeSite, stationNames }));
2657
+ }
2658
+ const stationPath = RADIO_STATION.exec(path);
2659
+ if (stationPath) {
2660
+ const slug = stationPath[1];
2661
+ const station = named.get(slug) ?? (peers.has(slug) ? { name: peers.get(slug).name, description: "", video: peers.get(slug).kind === "video" } : undefined);
2662
+ if (!station) {
2663
+ // A station's id finds its name: /radio/5ccqcjn5hg goes to /radio/ai-metal.
2664
+ const byId = [...named].find(([, one]) => one.id === slug);
2665
+ if (byId) {
2666
+ response.writeHead(301, { location: `/radio/${byId[0]}`, "cache-control": "public, max-age=300" });
2667
+ response.end();
2668
+ return;
2669
+ }
2670
+ return send(404, renderMissing("station", base, nav));
2671
+ }
2672
+ const built = web !== null && isFile(join(web, "station.html")) ? readFileSync(join(web, "station.html"), "utf8") : null;
2673
+ const page = asRadioPage(built ?? STATION_PAGE_HTML, station, slug, base, nav);
2674
+ response.writeHead(200, {
2675
+ "content-type": "text/html; charset=utf-8",
2676
+ "cache-control": "no-cache",
2677
+ // The inline page names its styles by hash; the header's is one more.
2678
+ "content-security-policy": built ? STATION_APP_CSP : STATION_PAGE_CSP.replace("style-src ", `style-src 'sha256-${sha("sha256").update(NAV_STYLE).digest("base64")}' `),
2679
+ "referrer-policy": "strict-origin-when-cross-origin",
2680
+ "x-content-type-options": "nosniff",
2681
+ });
2682
+ response.end(request.method === "HEAD" ? undefined : page);
2683
+ return;
2684
+ }
2685
+ }
2455
2686
  if (options.stations?.vods && (path === "/api/v1/guide/vods" || path.startsWith("/api/v1/guide/vods/"))) {
2456
2687
  const control = await controlOf();
2457
2688
  const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
@@ -6159,6 +6390,14 @@ export function createHandler(engine, options) {
6159
6390
  };
6160
6391
  }
6161
6392
  /** Read a file, or null. The firewall probe asks about files it may not have. */
6393
+ /**
6394
+ * The public address /radio links are written for: the guide's (watchBase,
6395
+ * https://live.streaming.pit.world on server1), else this server's own.
6396
+ */
6397
+ function radioBaseFor(request, publicBase) {
6398
+ const host = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "localhost").split(",")[0].trim();
6399
+ return watchBase(publicBase || `https://${host}`);
6400
+ }
6162
6401
  function readIfPossible(path) {
6163
6402
  try {
6164
6403
  return readFileSync(path, "utf8");
@@ -19,6 +19,8 @@ export interface GuideEntry {
19
19
  title: string;
20
20
  artist: string;
21
21
  } | null;
22
+ /** Its shareable page, /radio/<name> on the guide's address (radio-pages.ts); own stations only. */
23
+ radio?: string;
22
24
  /** Listed by another server (a guide peer); its own page, its own reports. */
23
25
  peer?: boolean;
24
26
  }
@@ -30,6 +30,7 @@ button.report:hover{color:var(--warn)}
30
30
  #share button[type=submit]{margin-top:14px;background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 16px;font:inherit;font-weight:600;cursor:pointer}
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
+ nav.top{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 16px}nav.top a{border:1px solid var(--line);border-radius:999px;padding:4px 12px;text-decoration:none;color:var(--fg);background:var(--card);font-weight:600;font-size:.9rem}nav.top a:hover{border-color:var(--accent)}
33
34
  footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
34
35
  .ad-unit{margin:0 auto 16px;display:flex;justify-content:center}.ad-unit iframe{border:0;display:block;background:transparent}
35
36
  .ad-banner_300x250 iframe{width:300px;height:250px;max-width:100%}.ad-text_link iframe{width:100%;height:40px}
@@ -73,7 +74,7 @@ const SCRIPT = `
73
74
  for (const s of list) {
74
75
  const card = el("div", "card");
75
76
  const a = el("a", "station");
76
- a.href = s.page;
77
+ a.href = s.radio || s.page;
77
78
  a.append(el("div", "live", s.state === "live" ? "● LIVE" : s.state === "vod" ? "▶ ON DEMAND" : "off the air"), el("div", "name", s.name),
78
79
  el("div", "now", nowLine(s.nowPlaying)));
79
80
  card.append(a);
@@ -90,7 +91,7 @@ const SCRIPT = `
90
91
  };
91
92
  const load = async () => {
92
93
  try {
93
- const r = await fetch(API + "/api/v1/stations/guide", { cache: "no-store" });
94
+ const r = await fetch(API + "/api/v1/stations/guide?radio=1", { cache: "no-store" });
94
95
  if (r.ok && (r.headers.get("content-type") || "").includes("json")) draw((await r.json()).stations);
95
96
  } catch {}
96
97
  };
@@ -154,7 +155,7 @@ export function renderGuide(entries, apiBase = "", ads = {}, labelHref = "") {
154
155
  const groupOf = (one) => one.group ?? (one.kind === "video" ? "tv" : "radio");
155
156
  const cards = (group) => entries
156
157
  .filter((one) => groupOf(one) === group && /^https?:\/\//.test(one.page))
157
- .map((one) => `<div class="card"><a class="station" href="${escape(one.page)}"><div class="live">${one.state === "live" ? "● LIVE" : one.state === "vod" ? "▶ ON DEMAND" : "off the air"}</div><div class="name">${escape(one.name)}</div><div class="now">${escape(nowLine(one.nowPlaying))}</div></a></div>`)
158
+ .map((one) => `<div class="card"><a class="station" href="${escape(one.radio && /^https?:\/\//.test(one.radio) ? one.radio : one.page)}"><div class="live">${one.state === "live" ? "● LIVE" : one.state === "vod" ? "▶ ON DEMAND" : "off the air"}</div><div class="name">${escape(one.name)}</div><div class="now">${escape(nowLine(one.nowPlaying))}</div></a></div>`)
158
159
  .join("");
159
160
  let page = GUIDE_HTML;
160
161
  for (const group of GROUPS) {
@@ -191,6 +192,7 @@ export const GUIDE_HTML = `<!doctype html>
191
192
  </head>
192
193
  <body>
193
194
  <main>
195
+ <nav class="top" aria-label="live.streaming"><a href="/radio">Radio: every station by name</a><a href="/label">AI label</a><a href="/vod">On demand</a></nav>
194
196
  <h1>Live</h1>
195
197
  <p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
196
198
  <h2 id="rooms-title" hidden>Live rooms</h2>
@@ -1,6 +1,6 @@
1
1
  /** A station page's path: /station/<id>. */
2
2
  export declare const STATION_PAGE: RegExp;
3
- export declare const STATION_PAGE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<link rel=\"icon\" href=\"data:,\">\n<title>Station \u00B7 nixamp</title>\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}\n*{box-sizing:border-box}[hidden]{display:none!important}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif}\nmain{max-width:560px;margin:0 auto;padding:48px 16px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}\nh1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}\n.about{color:var(--muted);margin:0 0 20px}\n.chip{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600;padding:4px 10px;border-radius:999px;border:1px solid var(--line)}\n.chip::before{content:\"\";width:8px;height:8px;border-radius:50%;background:currentColor}\n.live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}\n.now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}\n.controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}\n.play{display:inline-block;font-weight:600;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;text-decoration:none;cursor:pointer}\n.play[aria-disabled=true]{opacity:.5;pointer-events:none}\na:focus-visible{outline:3px solid var(--accent);outline-offset:3px}\n.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}\n.lyrics{margin:20px 0 0;border-top:1px solid var(--line);padding-top:12px}\n.lyrics summary{cursor:pointer;font-weight:600;min-height:24px}\n.lyrics .for{font-weight:400;color:var(--muted);font-size:.9rem}\n.words{margin-top:10px;line-height:1.6}\n.words p{margin:0 0 1em;white-space:pre-wrap;overflow-wrap:anywhere}\n.words p.sec{margin:1.2em 0 .3em;font-size:.75rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}\n.words p.sec:first-child{margin-top:0}\n.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 <h1 id=\"name\">&nbsp;</h1>\n <p id=\"about\" class=\"about\" hidden></p>\n <div class=\"now\" aria-live=\"polite\"><div id=\"title\" class=\"title\"></div><div id=\"by\" class=\"by\"></div></div>\n <div class=\"controls\">\n <a id=\"play\" class=\"play\" aria-disabled=\"true\">Play in your player (.m3u)</a>\n </div>\n <section id=\"lyrics\" class=\"lyrics\" aria-label=\"Lyrics\" hidden>\n <details id=\"lyrics-box\" open><summary>Lyrics<span id=\"lyrics-for\" class=\"for\"></span></summary><div id=\"words\" class=\"words\"></div></details>\n </section>\n <p class=\"small\">This server was started without the web build, so the station plays in VLC, mpv or any player that opens an .m3u. Run <code>bun run web:build</code> to play it on this page.</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 // No media element here: every player in the fleet is @profullstack/player,\n // and this page has no bundle to carry it. Play hands the station to the\n // listener's own player through its .m3u; the built page plays in place.\n let 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 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 (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 const on = s.state === \"live\" || s.state === \"recovering\" || s.state === \"starting\";\n $(\"#play\").href = api + \"/listen.m3u\" + q;\n $(\"#play\").setAttribute(\"aria-disabled\", String(!on));\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 // The words of what is on, line by line; \"[Verse 1]\" as a small header. Text only, never HTML.\n let words = null;\n const lyrics = async () => {\n try {\n const r = await fetch(api + \"/lyrics\" + q, { cache: \"no-store\" });\n const got = r.ok ? (await r.json()).lyrics : null;\n const key = got ? got.title + \"\\n\" + got.text : \"\";\n if (key === words) return;\n words = key;\n $(\"#lyrics\").hidden = !got;\n if (!got) return;\n const out = [];\n let stanza = [];\n const flush = () => { if (stanza.length) { const p = document.createElement(\"p\"); p.textContent = stanza.join(\"\\n\"); out.push(p); stanza = []; } };\n for (const line of got.text.split(\"\\n\")) {\n if (/^\\s*\\[[^\\]]{1,160}\\]\\s*$/.test(line)) { flush(); const h = document.createElement(\"p\"); h.className = \"sec\"; h.textContent = line.trim().slice(1, -1); out.push(h); }\n else if (line.trim() === \"\") flush();\n else stanza.push(line);\n }\n flush();\n $(\"#words\").replaceChildren(...out);\n $(\"#lyrics-for\").textContent = got.title ? \" \u00B7 \" + got.title : \"\";\n } catch {}\n };\n const listen = () => {\n if (!window.EventSource) return;\n const events = new EventSource(api + \"/events\" + q);\n events.addEventListener(\"hello\", (e) => { live = true; title(JSON.parse(e.data)); lyrics(); });\n events.addEventListener(\"now\", (e) => {\n const s = JSON.parse(e.data);\n title(s); lyrics(); say(s.nowPlaying ? \"Now playing \" + (s.nowPlaying.title || \"\") : \"\");\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\").removeAttribute(\"href\"); $(\"#play\").setAttribute(\"aria-disabled\", \"true\"); return; }\n if (r.ok) draw(await r.json());\n } catch {}\n };\n if (window.matchMedia && matchMedia(\"(max-width: 640px)\").matches) $(\"#lyrics-box\").open = false;\n poll();\n lyrics();\n listen();\n setInterval(poll, 5000);\n})();\n</script>\n</body>\n</html>\n";
3
+ export declare const STATION_PAGE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<link rel=\"icon\" href=\"data:,\">\n<title>Station \u00B7 nixamp</title>\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}\n*{box-sizing:border-box}[hidden]{display:none!important}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif}\nmain{max-width:560px;margin:0 auto;padding:48px 16px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}\nh1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}\n.about{color:var(--muted);margin:0 0 20px}\n.chip{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600;padding:4px 10px;border-radius:999px;border:1px solid var(--line)}\n.chip::before{content:\"\";width:8px;height:8px;border-radius:50%;background:currentColor}\n.live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}\n.now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}\n.controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}\n.play{display:inline-block;font-weight:600;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;text-decoration:none;cursor:pointer}\n.play[aria-disabled=true]{opacity:.5;pointer-events:none}\na:focus-visible{outline:3px solid var(--accent);outline-offset:3px}\n.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}\n.lyrics{margin:20px 0 0;border-top:1px solid var(--line);padding-top:12px}\n.lyrics summary{cursor:pointer;font-weight:600;min-height:24px}\n.lyrics .for{font-weight:400;color:var(--muted);font-size:.9rem}\n.words{margin-top:10px;line-height:1.6}\n.words p{margin:0 0 1em;white-space:pre-wrap;overflow-wrap:anywhere}\n.words p.sec{margin:1.2em 0 .3em;font-size:.75rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}\n.words p.sec:first-child{margin-top:0}\n.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 <h1 id=\"name\">&nbsp;</h1>\n <p id=\"about\" class=\"about\" hidden></p>\n <div class=\"now\" aria-live=\"polite\"><div id=\"title\" class=\"title\"></div><div id=\"by\" class=\"by\"></div></div>\n <div class=\"controls\">\n <a id=\"play\" class=\"play\" aria-disabled=\"true\">Play in your player (.m3u)</a>\n </div>\n <section id=\"lyrics\" class=\"lyrics\" aria-label=\"Lyrics\" hidden>\n <details id=\"lyrics-box\" open><summary>Lyrics<span id=\"lyrics-for\" class=\"for\"></span></summary><div id=\"words\" class=\"words\"></div></details>\n </section>\n <p class=\"small\">This server was started without the web build, so the station plays in VLC, mpv or any player that opens an .m3u. Run <code>bun run web:build</code> to play it on this page.</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 // /radio/<name> is the same station through a public name: its own paths, no token in sight.\n const radio = /^/radio/[a-z0-9-]+/?$/.test(location.pathname);\n const id = location.pathname.split(\"/\").filter(Boolean)[1] || \"\";\n const token = new URLSearchParams(location.search).get(\"t\") || \"\";\n const q = radio ? \"\" : \"?t=\" + encodeURIComponent(token);\n const api = radio ? location.pathname.replace(//+$/, \"\") : \"/api/v1/stations/\" + encodeURIComponent(id);\n const $ = (s) => document.querySelector(s);\n // No media element here: every player in the fleet is @profullstack/player,\n // and this page has no bundle to carry it. Play hands the station to the\n // listener's own player through its .m3u; the built page plays in place.\n let 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 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 (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 const on = s.state === \"live\" || s.state === \"recovering\" || s.state === \"starting\";\n $(\"#play\").href = api + \"/listen.m3u\" + q;\n $(\"#play\").setAttribute(\"aria-disabled\", String(!on));\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 // The words of what is on, line by line; \"[Verse 1]\" as a small header. Text only, never HTML.\n let words = null;\n const lyrics = async () => {\n try {\n const r = await fetch(api + \"/lyrics\" + q, { cache: \"no-store\" });\n const got = r.ok ? (await r.json()).lyrics : null;\n const key = got ? got.title + \"\\n\" + got.text : \"\";\n if (key === words) return;\n words = key;\n $(\"#lyrics\").hidden = !got;\n if (!got) return;\n const out = [];\n let stanza = [];\n const flush = () => { if (stanza.length) { const p = document.createElement(\"p\"); p.textContent = stanza.join(\"\\n\"); out.push(p); stanza = []; } };\n for (const line of got.text.split(\"\\n\")) {\n if (/^\\s*\\[[^\\]]{1,160}\\]\\s*$/.test(line)) { flush(); const h = document.createElement(\"p\"); h.className = \"sec\"; h.textContent = line.trim().slice(1, -1); out.push(h); }\n else if (line.trim() === \"\") flush();\n else stanza.push(line);\n }\n flush();\n $(\"#words\").replaceChildren(...out);\n $(\"#lyrics-for\").textContent = got.title ? \" \u00B7 \" + got.title : \"\";\n } catch {}\n };\n const listen = () => {\n if (!window.EventSource) return;\n const events = new EventSource(api + \"/events\" + q);\n events.addEventListener(\"hello\", (e) => { live = true; title(JSON.parse(e.data)); lyrics(); });\n events.addEventListener(\"now\", (e) => {\n const s = JSON.parse(e.data);\n title(s); lyrics(); say(s.nowPlaying ? \"Now playing \" + (s.nowPlaying.title || \"\") : \"\");\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\").removeAttribute(\"href\"); $(\"#play\").setAttribute(\"aria-disabled\", \"true\"); return; }\n if (r.ok) draw(await r.json());\n } catch {}\n };\n if (window.matchMedia && matchMedia(\"(max-width: 640px)\").matches) $(\"#lyrics-box\").open = false;\n poll();\n lyrics();\n listen();\n setInterval(poll, 5000);\n})();\n</script>\n</body>\n</html>\n";
4
4
  /** Only its own script and style, only its own server, never framed. */
5
5
  export declare const STATION_PAGE_CSP: string;
6
6
  /** The built page: its own bundle, styles and player engines, only this server, never framed. */
@@ -40,10 +40,12 @@ a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
40
40
  `;
41
41
  const SCRIPT = `
42
42
  (() => {
43
+ // /radio/<name> is the same station through a public name: its own paths, no token in sight.
44
+ const radio = /^\/radio\/[a-z0-9-]+\/?$/.test(location.pathname);
43
45
  const id = location.pathname.split("/").filter(Boolean)[1] || "";
44
46
  const token = new URLSearchParams(location.search).get("t") || "";
45
- const q = "?t=" + encodeURIComponent(token);
46
- const api = "/api/v1/stations/" + encodeURIComponent(id);
47
+ const q = radio ? "" : "?t=" + encodeURIComponent(token);
48
+ const api = radio ? location.pathname.replace(/\/+$/, "") : "/api/v1/stations/" + encodeURIComponent(id);
47
49
  const $ = (s) => document.querySelector(s);
48
50
  // No media element here: every player in the fleet is @profullstack/player,
49
51
  // and this page has no bundle to carry it. Play hands the station to the
@@ -107,6 +107,7 @@ export declare function guideEntries(ctx: StationContext): Array<PublicStation &
107
107
  page: string;
108
108
  m3u: string;
109
109
  group: GuideGroup;
110
+ radio: string;
110
111
  }>;
111
112
  export declare function ownerView(ctx: StationContext, station: Station): OwnerStation & {
112
113
  links: ReturnType<typeof links>;
@@ -2,6 +2,8 @@ import { alertsFor } from "./station-metering.js";
2
2
  import { aiFromTags } from "./track-facts.js";
3
3
  import { quietDuringBreaks } from "./break-silence.js";
4
4
  import { PASS_PARAM } from "./listen-pass.js";
5
+ import { watchBase } from "./guide-rooms.js";
6
+ import { radioPeers, radioStations } from "./radio-pages.js";
5
7
  import { StationError, channelOf, tokensMatch, } from "./stations.js";
6
8
  /** The three things a station's link opens. */
7
9
  const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now|art|lyrics)$/;
@@ -74,9 +76,10 @@ export function guideGroup(station) {
74
76
  /** What the public guide shows: public, listed stations, linked at this server's public address. */
75
77
  export function guideEntries(ctx) {
76
78
  const base = ctx.publicBase || ctx.base;
77
- return ctx.store.list()
78
- .filter((one) => one.visibility === "public" && one.listed)
79
- .map((one) => ({ ...publicView(ctx, one), page: links(ctx, one, base).page, m3u: links(ctx, one, base).m3u, group: guideGroup(one) }));
79
+ // Its shareable page too: /radio/<name> on the guide's public address (radio-pages.ts).
80
+ const radioBase = watchBase(base);
81
+ const names = radioStations(ctx.store.list());
82
+ return [...names].map(([slug, one]) => ({ ...publicView(ctx, one), page: links(ctx, one, base).page, m3u: links(ctx, one, base).m3u, group: guideGroup(one), radio: `${radioBase}/radio/${slug}` }));
80
83
  }
81
84
  export function ownerView(ctx, station) {
82
85
  const { failures, idempotencyKey: _key, ...rest } = station;
@@ -109,6 +112,28 @@ export async function handleStations(request, response, path, url, ctx) {
109
112
  .filter((one) => !shown.has(one.page));
110
113
  // Rooms are this guide's own; a peer asking for its stations does not list them twice.
111
114
  const rooms = url.searchParams.has("local") || !ctx.rooms ? [] : await ctx.rooms();
115
+ if (url.searchParams.has("radio")) {
116
+ // The public guide's own list: every station by its /radio name, no
117
+ // server address or token in it (radio-pages.ts). Peers ask without it.
118
+ const radioBase = watchBase(ctx.publicBase || ctx.base);
119
+ const peerNames = radioPeers(radioStations(ctx.store.list()).keys(), others);
120
+ const peerPage = new Map([...peerNames].map(([slug, one]) => [`${one.origin}/${one.id}`, `${radioBase}/radio/${slug}`]));
121
+ const pageOf = (page) => {
122
+ try {
123
+ const at = new URL(page);
124
+ return peerPage.get(`${at.origin}/${at.pathname.split("/")[2] ?? ""}`) ?? "";
125
+ }
126
+ catch {
127
+ return "";
128
+ }
129
+ };
130
+ ctx.json(response, 200, { stations: [
131
+ ...rooms,
132
+ ...own.map(({ m3u: _m3u, page: _page, ...one }) => ({ ...one, page: one.radio })),
133
+ ...others.map((one) => ({ ...one, page: pageOf(one.page) })).filter((one) => one.page),
134
+ ] });
135
+ return true;
136
+ }
112
137
  ctx.json(response, 200, { stations: [...rooms, ...own, ...others] });
113
138
  return true;
114
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.53.4",
3
+ "version": "0.54.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,127 @@
1
+ /**
2
+ * /follow on nixamp.com: follow an artist from a /radio page (radio-pages.ts).
3
+ *
4
+ * The /radio pages are on another site (live.streaming.pit.world), where a
5
+ * nixamp.com session cookie is a third-party cookie and no browser sends it.
6
+ * So Follow there is a link here, where the session is first-party: this page
7
+ * says who you are following, and a same-site form (SameSite=Lax keeps a
8
+ * cross-site POST from carrying the session) follows or unfollows. The follow
9
+ * is the same follows row as following a streamer, keyed "artist:<name>".
10
+ *
11
+ * GET /follow?id=artist:<name>&name=<shown>&back=<https page>
12
+ * POST /follow id, name, back, act=follow|unfollow -> 303 back to the GET
13
+ */
14
+ import { createHash } from "node:crypto";
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+
17
+ export interface FollowPageContext {
18
+ /** The signed-in account, or null. */
19
+ account: () => Promise<{ id: string } | null>;
20
+ follows: {
21
+ follow: (follower: string, streamer: string) => Promise<boolean>;
22
+ unfollow: (follower: string, streamer: string) => Promise<void>;
23
+ isFollowing: (follower: string, streamer: string) => Promise<boolean>;
24
+ followerCount: (streamer: string) => Promise<number>;
25
+ };
26
+ readBody: (request: IncomingMessage) => Promise<string>;
27
+ }
28
+
29
+ const ID = /^artist:[a-z0-9-]{1,90}$/;
30
+
31
+ function escape(text: string): string {
32
+ return String(text).replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]!);
33
+ }
34
+
35
+ const STYLE = `
36
+ :root{--bg:#080c09;--fg:#e8efe9;--muted:#9aa79d;--card:#0e1410;--line:#1f2a22;--accent:#3ddc84}
37
+ *{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}
38
+ main{max-width:520px;margin:0 auto;padding:40px 16px}
39
+ .card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
40
+ h1{margin:0 0 4px;font-size:1.5rem;overflow-wrap:anywhere}.by{color:var(--muted);margin:0 0 16px}
41
+ button,a.go{display:block;text-align:center;text-decoration:none;margin-top:18px;width:100%;padding:12px;border:0;border-radius:999px;background:var(--accent);color:#06120a;font:inherit;font-weight:800;cursor:pointer}
42
+ button.quiet{background:transparent;color:var(--fg);border:1px solid var(--line)}
43
+ a{color:var(--accent)}a:focus-visible,button:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
44
+ small{display:block;margin-top:18px;color:var(--muted)}
45
+ `;
46
+
47
+ const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
48
+
49
+ export const FOLLOW_CSP = ["default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'", "form-action 'self'", "base-uri 'none'", "frame-ancestors 'none'"].join("; ");
50
+
51
+ function page(title: string, body: string): string {
52
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
53
+ <title>${escape(title)} · nixamp</title><meta name="robots" content="noindex"><link rel="icon" href="/favicon.png" type="image/png"><style>${STYLE}</style></head>
54
+ <body><main><div class="card">${body}</div></main></body></html>`;
55
+ }
56
+
57
+ function send(response: ServerResponse, status: number, html: string, method: string): void {
58
+ response.writeHead(status, { "content-type": "text/html; charset=utf-8", "content-security-policy": FOLLOW_CSP, "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff" });
59
+ response.end(method === "HEAD" ? undefined : html);
60
+ }
61
+
62
+ /** What the link says, cleaned: an artist id, a name to show, and an https page to go back to. */
63
+ export function followTarget(input: URLSearchParams): { id: string; name: string; back: string } | null {
64
+ const id = (input.get("id") ?? "").trim();
65
+ if (!ID.test(id)) return null;
66
+ const name = (input.get("name") ?? "").replace(/[\u0000-\u001f]/g, " ").trim().slice(0, 120) || id.slice("artist:".length);
67
+ let back = "";
68
+ try {
69
+ const url = new URL(input.get("back") ?? "");
70
+ if (url.protocol === "https:" && !url.username && !url.password) back = url.href;
71
+ } catch {
72
+ back = "";
73
+ }
74
+ return { id, name, back };
75
+ }
76
+
77
+ /** Answers /follow; false for any other path. */
78
+ export async function handleFollowPage(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: FollowPageContext): Promise<boolean> {
79
+ if (path !== "/follow") return false;
80
+ const method = request.method ?? "GET";
81
+ if (method === "POST") {
82
+ // Same site only: a form on another site gets no session (SameSite=Lax), and is told so here too.
83
+ const origin = String(request.headers.origin ?? "");
84
+ const host = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "").split(",")[0]!.trim();
85
+ if (origin && origin !== "null" && new URL(origin).host !== host) {
86
+ send(response, 403, page("Not from here", `<h1>Not from here</h1><p class="by">Follow from nixamp.com's own page.</p>`), method);
87
+ return true;
88
+ }
89
+ const form = new URLSearchParams(await ctx.readBody(request));
90
+ const target = followTarget(form);
91
+ const me = await ctx.account().catch(() => null);
92
+ if (target && me) {
93
+ if (form.get("act") === "unfollow") await ctx.follows.unfollow(me.id, target.id);
94
+ else await ctx.follows.follow(me.id, target.id);
95
+ }
96
+ const again = target ? `/follow?${new URLSearchParams({ id: target.id, name: target.name, ...(target.back ? { back: target.back } : {}) }).toString()}` : "/follow";
97
+ response.writeHead(303, { location: again, "cache-control": "no-store" });
98
+ response.end();
99
+ return true;
100
+ }
101
+ if (method !== "GET" && method !== "HEAD") {
102
+ response.writeHead(405, { allow: "GET, POST" }).end();
103
+ return true;
104
+ }
105
+ const target = followTarget(url.searchParams);
106
+ if (!target) {
107
+ send(response, 404, page("Nobody to follow", `<h1>Nobody to follow</h1><p class="by">That link does not name an artist.</p>`), method);
108
+ return true;
109
+ }
110
+ const back = target.back ? `<small><a href="${escape(target.back)}">← Back to ${escape(target.name)}</a></small>` : "";
111
+ const me = await ctx.account().catch(() => null);
112
+ if (!me) {
113
+ send(response, 200, page(`Follow ${target.name}`, `<h1>Follow ${escape(target.name)}</h1>
114
+ <p class="by">Sign in to nixamp.com to follow the artists you hear on the radio.</p>
115
+ <a class="go" href="/radio#music">Sign in to nixamp.com</a>
116
+ <small>Signed in, open this page again (or press Follow on the artist's page) to follow.</small>${back}`), method);
117
+ return true;
118
+ }
119
+ const [following, count] = await Promise.all([ctx.follows.isFollowing(me.id, target.id), ctx.follows.followerCount(target.id).catch(() => 0)]);
120
+ const hidden = `<input type="hidden" name="id" value="${escape(target.id)}"><input type="hidden" name="name" value="${escape(target.name)}">${target.back ? `<input type="hidden" name="back" value="${escape(target.back)}">` : ""}`;
121
+ send(response, 200, page(`Follow ${target.name}`, `<h1>${escape(target.name)}</h1>
122
+ <p class="by">${following ? "You follow them." : "Follow them to keep them with your follows on nixamp.com."} ${count} follower${count === 1 ? "" : "s"}.</p>
123
+ <form method="post" action="/follow">${hidden}<input type="hidden" name="act" value="${following ? "unfollow" : "follow"}">
124
+ <button type="submit"${following ? ' class="quiet"' : ""}>${following ? "Unfollow" : `Follow ${escape(target.name)}`}</button></form>
125
+ ${back}`), method);
126
+ return true;
127
+ }