nixamp 0.53.5 → 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.
- package/dist/follow-page.d.ts +23 -0
- package/dist/follow-page.js +111 -0
- package/dist/radio-pages.d.ts +211 -0
- package/dist/radio-pages.js +415 -0
- package/dist/radio-slug.d.ts +18 -0
- package/dist/radio-slug.js +43 -0
- package/dist/server.js +242 -3
- package/dist/station-guide.d.ts +2 -0
- package/dist/station-guide.js +5 -3
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +4 -2
- package/dist/station-routes.d.ts +1 -0
- package/dist/station-routes.js +28 -3
- package/package.json +1 -1
- package/src/follow-page.ts +127 -0
- package/src/radio-pages.ts +513 -0
- package/src/radio-slug.ts +45 -0
- package/src/server.ts +232 -3
- package/src/station-guide.ts +7 -3
- package/src/station-page.ts +4 -2
- package/src/station-routes.ts +28 -4
- package/web/dist/assets/{ar-DqeYPqVe.js → ar-CHJqwLbG.js} +1 -1
- package/web/dist/assets/{cs-D4zQN1oV.js → cs-CmwqmJKg.js} +1 -1
- package/web/dist/assets/{da-DKIaXNAT.js → da-Xdzrm0AY.js} +1 -1
- package/web/dist/assets/{de-D1lMnV7x.js → de-h2ntHz8Y.js} +1 -1
- package/web/dist/assets/{es-CjjnXT7v.js → es-DgbBXAxw.js} +1 -1
- package/web/dist/assets/{fi-Dp56MD45.js → fi-Bso9G00g.js} +1 -1
- package/web/dist/assets/{fr-CeifYZP1.js → fr-6Q3UZKIG.js} +1 -1
- package/web/dist/assets/{hi-DsBa_glA.js → hi-T7_WxQ11.js} +1 -1
- package/web/dist/assets/{hu-SYFZGXA2.js → hu-BjOppQVq.js} +1 -1
- package/web/dist/assets/{id-91576EGD.js → id-BIkZDUv5.js} +1 -1
- package/web/dist/assets/{it-Cjmfw8XJ.js → it-CHOmOHju.js} +1 -1
- package/web/dist/assets/{main-CBsLIRa4.js → main-BpsUYSCl.js} +2 -2
- package/web/dist/assets/{nl-XqBXZghJ.js → nl-CHQR6VFb.js} +1 -1
- package/web/dist/assets/{ru-CYT8xDxB.js → ru-Du7NQrxe.js} +1 -1
- package/web/dist/assets/station-xTLbhzsu.js +3 -0
- package/web/dist/assets/{sv-BlpGbShs.js → sv-B6AFovY0.js} +1 -1
- package/web/dist/assets/{uk-g-QbP-e_.js → uk-LXJz37Kp.js} +1 -1
- package/web/dist/assets/{vi-CF206cCy.js → vi-C4taTvjH.js} +1 -1
- package/web/dist/assets/{zh-D4QH6alE.js → zh-dBiC2V5a.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +1 -1
- package/web/dist/sw.js +20 -20
- 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
|
-
|
|
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
|
-
|
|
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");
|
package/dist/station-guide.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/station-guide.js
CHANGED
|
@@ -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>
|
package/dist/station-page.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** A station page's path: /station/<id>. */
|
|
2
2
|
export declare const STATION_PAGE: RegExp;
|
|
3
|
-
export declare const STATION_PAGE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<link rel=\"icon\" href=\"data:,\">\n<title>Station \u00B7 nixamp</title>\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}\n*{box-sizing:border-box}[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\"> </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\"> </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. */
|
package/dist/station-page.js
CHANGED
|
@@ -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
|
package/dist/station-routes.d.ts
CHANGED
|
@@ -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>;
|
package/dist/station-routes.js
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
+
}
|