nixamp 0.57.1 → 0.58.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 (35) hide show
  1. package/dist/guide-rooms.d.ts +42 -0
  2. package/dist/guide-rooms.js +157 -7
  3. package/dist/radio-pages.d.ts +2 -2
  4. package/dist/radio-pages.js +4 -4
  5. package/dist/server.js +52 -1
  6. package/package.json +1 -1
  7. package/src/guide-rooms.ts +182 -8
  8. package/src/radio-pages.ts +4 -4
  9. package/src/server.ts +52 -1
  10. package/web/dist/assets/{ar-HteUbQ_m.js → ar-C8G_vLmh.js} +1 -1
  11. package/web/dist/assets/{cs-CjN_4DAJ.js → cs-7bYMjFS2.js} +1 -1
  12. package/web/dist/assets/{da-CzsSXVfk.js → da-DHY5Csp_.js} +1 -1
  13. package/web/dist/assets/{de-D7zVt1UT.js → de-YNBoxnLa.js} +1 -1
  14. package/web/dist/assets/{es-D8duNQiq.js → es-9F9kBzzh.js} +1 -1
  15. package/web/dist/assets/{fi-CSKCYE5E.js → fi-BqpzsfBb.js} +1 -1
  16. package/web/dist/assets/{fr-DnjuIwaN.js → fr-BKQXRwFk.js} +1 -1
  17. package/web/dist/assets/{hi-ByZe82O5.js → hi-CGNIGyR-.js} +1 -1
  18. package/web/dist/assets/{hu-B3iwlAuW.js → hu-anYPYX8d.js} +1 -1
  19. package/web/dist/assets/{id-B58CS7rH.js → id-BW3GasRn.js} +1 -1
  20. package/web/dist/assets/{it-CaVLbop_.js → it-C7w2DZ3R.js} +1 -1
  21. package/web/dist/assets/{main-yTRNIxIl.js → main-YkIgU0VD.js} +2 -2
  22. package/web/dist/assets/{nl-Dk0ss8Tf.js → nl-BNrkTQI2.js} +1 -1
  23. package/web/dist/assets/{ru-CGuA41ji.js → ru-CnKOo1uk.js} +1 -1
  24. package/web/dist/assets/station-M7G4NiE3.js +3 -0
  25. package/web/dist/assets/{sv-B309Td6Z.js → sv-BsknKdtx.js} +1 -1
  26. package/web/dist/assets/{uk-CCCKvR-U.js → uk-u_igTh6D.js} +1 -1
  27. package/web/dist/assets/{vi-DfVTbK_t.js → vi-DH0ApTua.js} +1 -1
  28. package/web/dist/assets/watch-Brz-RgjN.js +1 -0
  29. package/web/dist/assets/{zh-BU9_CD0f.js → zh-Bcnk2nIV.js} +1 -1
  30. package/web/dist/index.html +1 -1
  31. package/web/dist/station.html +1 -1
  32. package/web/dist/sw.js +21 -21
  33. package/web/dist/watch.html +1 -1
  34. package/web/dist/assets/station-D6sd4DqP.js +0 -3
  35. package/web/dist/assets/watch-Dxsz5gHd.js +0 -1
@@ -32,6 +32,18 @@ export declare function roomAddress(input: string): {
32
32
  /** Where the guide links a room's page: config `watchBase`, NIXAMP_WATCH_BASE, else this server. */
33
33
  export declare function watchBase(fallback: string, env?: Record<string, string | undefined>): string;
34
34
  export declare const watchPage: (base: string, play: string) => string;
35
+ /** A room's page under its name, on this server. */
36
+ export declare const roomPage: (base: string, slug: string) => string;
37
+ export declare const ROOM_PAGE: RegExp;
38
+ export declare const ROOM_SUB: RegExp;
39
+ export declare function isRoomPath(path: string): boolean;
40
+ /**
41
+ * The rooms by name: "Dropkick Murphys" is /room/dropkick-murphys. Oldest
42
+ * first, so a name stays with the room that had it; a second one is -2.
43
+ */
44
+ export declare function roomSlugs(rooms: GuideRoom[]): Map<string, GuideRoom>;
45
+ /** A room's name here, or "". */
46
+ export declare function roomSlugOf(slugs: Map<string, GuideRoom>, id: string): string;
35
47
  export declare class RoomStore {
36
48
  private readonly file;
37
49
  private rooms;
@@ -53,6 +65,9 @@ export interface RoomState {
53
65
  title: string;
54
66
  artist: string;
55
67
  } | null;
68
+ /** A folder or a playlist (more than one file): HLS plays it better than the endless MP4. */
69
+ folder?: boolean;
70
+ album?: string;
56
71
  }
57
72
  /** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
58
73
  export declare class RoomProbe {
@@ -89,3 +104,30 @@ export interface RoomContext {
89
104
  }
90
105
  /** Answers /api/v1/guide/rooms; false when the path is not one. */
91
106
  export declare function handleRooms(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomContext): Promise<boolean>;
107
+ export interface RoomProxyContext {
108
+ store: RoomStore;
109
+ probe: RoomProbe;
110
+ /** Why an address may not be fetched from this machine, or "" (as the probe's). */
111
+ refuse: (link: string) => Promise<string>;
112
+ json: (response: ServerResponse, status: number, body: unknown) => void;
113
+ fetcher?: typeof fetch;
114
+ }
115
+ /** What /room/<name>/state answers: the room as its page shows it, no address or key. */
116
+ export interface RoomView {
117
+ name: string;
118
+ kind: "audio" | "video";
119
+ live: boolean;
120
+ folder: boolean;
121
+ hls: boolean;
122
+ title: string;
123
+ artist: string;
124
+ album: string;
125
+ }
126
+ /** The room's origin and key, gone from a text answer (a playlist, an error). */
127
+ export declare function roomRewriter(play: string, slug: string): (text: string) => string;
128
+ /**
129
+ * Answers /room/<name>/state, /stream and /hls/<file>, asked of the room's
130
+ * server with its key; false when the path is not one. The page itself is
131
+ * the caller's (it is the built watch page).
132
+ */
133
+ export declare function handleRoomProxy(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomProxyContext): Promise<boolean>;
@@ -5,16 +5,20 @@
5
5
  * a Share or a "?play=" link carries, `https://host:port/api/channels/<id>?k=KEY`
6
6
  * or `https://host:port/api/live?k=KEY`. A nixamp.com member publishes one with
7
7
  * a rights basis, as a station is published; the guide lists it beside the
8
- * stations, and its card opens /watch?play=<that address>, which plays it as
9
- * a film or as radio, whichever it carries.
8
+ * stations; its card opens /room/<name>, which plays it as a film or as
9
+ * radio, whichever it carries.
10
10
  *
11
- * Nothing here plays anything: the room's own server does, and this only asks
12
- * it, now and then, whether the room is on the air and what is on it.
11
+ * /room/<name> is the room under a readable name on this server, which asks
12
+ * the room's own server for it with the key held here: the page, its state,
13
+ * the stream and HLS all come from this origin, so no page names the machine
14
+ * the room is on or carries its key. The old /watch?play=<address> links
15
+ * still play, straight from the room's server.
13
16
  */
14
17
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
18
  import { join } from "node:path";
16
19
  import { randomBytes } from "node:crypto";
17
20
  import { readConfig } from "./library.js";
21
+ import { slugify } from "./radio-slug.js";
18
22
  /** Why a room may be broadcast, the same five reasons a station gives. */
19
23
  export const ROOM_BASES = ["owned", "permission", "licensed", "creative-commons", "public-domain"];
20
24
  /** Rooms one member may have on the guide at once, and rooms on it in all. */
@@ -62,6 +66,36 @@ export function watchBase(fallback, env = process.env) {
62
66
  return /^https?:\/\/[^\s"<>]+$/.test(chosen) ? chosen : fallback;
63
67
  }
64
68
  export const watchPage = (base, play) => `${base}/watch?play=${encodeURIComponent(play)}`;
69
+ /** A room's page under its name, on this server. */
70
+ export const roomPage = (base, slug) => `${base}/room/${slug}`;
71
+ export const ROOM_PAGE = /^\/room\/([A-Za-z0-9_-]{1,60})\/?$/;
72
+ export const ROOM_SUB = /^\/room\/([a-z0-9-]{1,60})\/(state|stream|hls\/[A-Za-z0-9._-]{1,64})$/;
73
+ export function isRoomPath(path) {
74
+ return ROOM_PAGE.test(path) || ROOM_SUB.test(path);
75
+ }
76
+ /**
77
+ * The rooms by name: "Dropkick Murphys" is /room/dropkick-murphys. Oldest
78
+ * first, so a name stays with the room that had it; a second one is -2.
79
+ */
80
+ export function roomSlugs(rooms) {
81
+ const out = new Map();
82
+ const sorted = [...rooms].sort((a, b) => (a.publishedAt - b.publishedAt) || a.id.localeCompare(b.id));
83
+ for (const room of sorted) {
84
+ const base = slugify(room.name) || "room";
85
+ let slug = base;
86
+ for (let n = 2; out.has(slug); n += 1)
87
+ slug = `${base}-${n}`;
88
+ out.set(slug, room);
89
+ }
90
+ return out;
91
+ }
92
+ /** A room's name here, or "". */
93
+ export function roomSlugOf(slugs, id) {
94
+ for (const [slug, one] of slugs)
95
+ if (one.id === id)
96
+ return slug;
97
+ return "";
98
+ }
65
99
  export class RoomStore {
66
100
  file;
67
101
  rooms = [];
@@ -149,6 +183,8 @@ export class RoomProbe {
149
183
  name: String(row.name ?? address.channel).replace(/\/+$/, "").slice(0, 120),
150
184
  kind: row.kind === "video" ? "video" : "audio",
151
185
  nowPlaying: tags && (tags.title || tags.artist) ? { title: String(tags.title ?? ""), artist: String(tags.artist ?? "") } : null,
186
+ folder: address.channel.startsWith("folder-") || (Array.isArray(row.playlist) && row.playlist.length > 1),
187
+ album: String(tags?.album ?? "").slice(0, 200),
152
188
  };
153
189
  }
154
190
  // The live stream: on the air when it answers with media.
@@ -160,7 +196,7 @@ export class RoomProbe {
160
196
  controller.abort();
161
197
  if (!answer.ok)
162
198
  return null;
163
- return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null };
199
+ return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null, folder: false, album: "" };
164
200
  }
165
201
  finally {
166
202
  clearTimeout(timer);
@@ -169,6 +205,7 @@ export class RoomProbe {
169
205
  }
170
206
  /** The rooms as guide cards: the page to open, on the air or not, and what is on. */
171
207
  export async function roomEntries(store, probe, base) {
208
+ const slugs = roomSlugs(store.list());
172
209
  return Promise.all(store.list().map(async (room) => {
173
210
  const state = await probe.state(room.play);
174
211
  return {
@@ -177,7 +214,7 @@ export async function roomEntries(store, probe, base) {
177
214
  kind: state?.kind ?? room.kind,
178
215
  group: "rooms",
179
216
  state: state?.live ? "live" : "offline",
180
- page: watchPage(base, room.play),
217
+ page: roomPage(base, roomSlugOf(slugs, room.id) || room.id),
181
218
  nowPlaying: state?.nowPlaying ?? null,
182
219
  };
183
220
  }));
@@ -245,7 +282,7 @@ export async function handleRooms(request, response, path, ctx) {
245
282
  const title = typeof body?.title === "string" ? body.title.trim().slice(0, 120) : "";
246
283
  const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
247
284
  const room = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, basis, note, by: taken?.by ?? by });
248
- ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: watchPage(ctx.base, room.play) });
285
+ ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: roomPage(ctx.base, roomSlugOf(roomSlugs(ctx.store.list()), room.id) || room.id) });
249
286
  return true;
250
287
  }
251
288
  const one = /^\/api\/v1\/guide\/rooms\/([A-Za-z0-9_-]{1,32})$/.exec(path);
@@ -262,3 +299,116 @@ export async function handleRooms(request, response, path, ctx) {
262
299
  ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
263
300
  return true;
264
301
  }
302
+ /** The room's origin and key, gone from a text answer (a playlist, an error). */
303
+ export function roomRewriter(play, slug) {
304
+ const address = roomAddress(play);
305
+ if (!address)
306
+ return (text) => text;
307
+ const channelPath = address.channel !== null ? `/api/channels/${address.channel}` : "/api/live";
308
+ return (text) => text
309
+ .split(`${address.origin}${channelPath}/hls/`).join(`/room/${slug}/hls/`)
310
+ .split(`${address.origin}${channelPath}`).join(`/room/${slug}/stream`)
311
+ .split(address.origin).join("")
312
+ .split(`?k=${address.key}&`).join("?").split(`&k=${address.key}`).join("").split(`?k=${address.key}`).join("")
313
+ .split(address.key).join("");
314
+ }
315
+ /**
316
+ * Answers /room/<name>/state, /stream and /hls/<file>, asked of the room's
317
+ * server with its key; false when the path is not one. The page itself is
318
+ * the caller's (it is the built watch page).
319
+ */
320
+ export async function handleRoomProxy(request, response, path, ctx) {
321
+ const sub = ROOM_SUB.exec(path);
322
+ if (!sub)
323
+ return false;
324
+ const method = request.method ?? "GET";
325
+ if (method !== "GET" && method !== "HEAD") {
326
+ ctx.json(response, 405, { error: "a room's page can watch, not drive", code: "method_not_allowed" });
327
+ return true;
328
+ }
329
+ const slug = sub[1];
330
+ const room = roomSlugs(ctx.store.list()).get(slug);
331
+ const address = room ? roomAddress(room.play) : null;
332
+ if (!room || !address) {
333
+ ctx.json(response, 404, { error: "no room by that name", code: "not_found" });
334
+ return true;
335
+ }
336
+ const what = sub[2];
337
+ if (what === "state") {
338
+ const state = await ctx.probe.state(room.play);
339
+ if (!state) {
340
+ ctx.json(response, 404, { error: "that room is not on the air", code: "unavailable" });
341
+ return true;
342
+ }
343
+ const view = {
344
+ name: room.name || state.name,
345
+ kind: state.kind,
346
+ live: state.live,
347
+ folder: state.folder === true,
348
+ hls: address.channel !== null,
349
+ title: state.nowPlaying?.title ?? "",
350
+ artist: state.nowPlaying?.artist ?? "",
351
+ album: state.album ?? "",
352
+ };
353
+ response.setHeader("cache-control", "no-store");
354
+ ctx.json(response, 200, view);
355
+ return true;
356
+ }
357
+ let upstream;
358
+ if (what === "stream")
359
+ upstream = address.play;
360
+ else {
361
+ if (address.channel === null) {
362
+ ctx.json(response, 404, { error: "the live stream has no HLS", code: "not_found" });
363
+ return true;
364
+ }
365
+ upstream = `${address.origin}/api/channels/${address.channel}/${what}?k=${address.key}`;
366
+ }
367
+ if ((await ctx.refuse(upstream).catch(() => "refused")) !== "") {
368
+ ctx.json(response, 502, { error: "that room's server cannot be reached from here", code: "upstream" });
369
+ return true;
370
+ }
371
+ const stop = new AbortController();
372
+ response.on("close", () => stop.abort());
373
+ const answer = await (ctx.fetcher ?? fetch)(upstream, {
374
+ method, signal: stop.signal, redirect: "error",
375
+ headers: { ...(request.headers.range ? { range: String(request.headers.range) } : {}), accept: String(request.headers.accept ?? "*/*") },
376
+ }).catch(() => null);
377
+ if (!answer) {
378
+ ctx.json(response, 502, { error: "that room's server did not answer", code: "upstream" });
379
+ return true;
380
+ }
381
+ const type = answer.headers.get("content-type") ?? "";
382
+ const text = /json|mpegurl|^text\//i.test(type);
383
+ const headers = { "cache-control": "no-store", "x-content-type-options": "nosniff" };
384
+ for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "x-accel-buffering", "retry-after"]) {
385
+ const value = answer.headers.get(name);
386
+ if (value && !(text && name === "content-length"))
387
+ headers[name] = value;
388
+ }
389
+ if (text) {
390
+ // A playlist (or an error) is small: read whole, the room's address and key taken out.
391
+ const body = roomRewriter(room.play, slug)(await answer.text().catch(() => ""));
392
+ response.writeHead(answer.status, { ...headers, "content-length": String(Buffer.byteLength(body)) });
393
+ response.end(method === "HEAD" ? undefined : body);
394
+ return true;
395
+ }
396
+ response.writeHead(answer.status, headers);
397
+ if (!answer.body || method === "HEAD") {
398
+ response.end();
399
+ return true;
400
+ }
401
+ try {
402
+ for await (const chunk of answer.body) {
403
+ if (response.destroyed)
404
+ break;
405
+ if (!response.write(chunk))
406
+ await new Promise((done) => { response.once("drain", done); response.once("close", done); });
407
+ }
408
+ }
409
+ catch {
410
+ // The viewer left, or the room's server did.
411
+ }
412
+ response.end();
413
+ return true;
414
+ }
@@ -188,9 +188,9 @@ export interface StationFace {
188
188
  description?: string;
189
189
  video?: boolean;
190
190
  }
191
- export declare function stationHead(station: StationFace, slug: string, base: string): string;
191
+ export declare function stationHead(station: StationFace, slug: string, base: string, path?: string): string;
192
192
  /** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
193
- export declare function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string): string;
193
+ export declare function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string, path?: string): string;
194
194
  /** A station another server lists on this guide (a guide peer), as /radio reaches it. */
195
195
  export interface PeerRadio {
196
196
  origin: string;
@@ -388,8 +388,8 @@ export function renderAlbum(catalogue, album, ctx) {
388
388
  export function renderMissing(what, base, nav) {
389
389
  return document({ title: `Not here · live.streaming`, description: "Nothing by that name is on the air here.", url: `${base}/radio` }, nav, `<h1>No ${escape(what)} by that name</h1><p class="lead">Nothing by that name is on the air here now. <a href="/radio">See every station</a>.</p>`);
390
390
  }
391
- export function stationHead(station, slug, base) {
392
- const url = `${base}/radio/${slug}`;
391
+ export function stationHead(station, slug, base, path = `/radio/${slug}`) {
392
+ const url = `${base}${path}`;
393
393
  const description = station.description || `${station.name}, live now. Free to listen; tap the artist or album on the air to see more.`;
394
394
  return `<title>${escape(station.name)} · live.streaming</title>
395
395
  <meta name="description" content="${escape(description)}" />
@@ -403,9 +403,9 @@ export function stationHead(station, slug, base) {
403
403
  <style>${NAV_STYLE}</style>`;
404
404
  }
405
405
  /** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
406
- export function asRadioPage(html, station, slug, base, nav) {
406
+ export function asRadioPage(html, station, slug, base, nav, path) {
407
407
  return html
408
- .replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base))
408
+ .replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base, path))
409
409
  .replace(/\s*<meta name="robots" content="noindex"\s*\/?>/, "")
410
410
  .replace(/<body>/, `<body>\n${nav}`);
411
411
  }
package/dist/server.js CHANGED
@@ -73,7 +73,7 @@ import { LyricsFinder } from "./lyrics.js";
73
73
  import { LyricsPublisher } from "./lyrics-publish.js";
74
74
  import { aiKey } from "./ai-keys.js";
75
75
  import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML, WATCH_APP_CSP } from "./station-page.js";
76
- import { handleRooms, RoomProbe, roomEntries, RoomStore, watchBase } from "./guide-rooms.js";
76
+ import { handleRoomProxy, handleRooms, isRoomPath, ROOM_PAGE, roomAddress, RoomProbe, roomEntries, roomSlugOf, roomSlugs, 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
79
  import { handleFollowPage } from "./follow-page.js";
@@ -2317,6 +2317,8 @@ export function createHandler(engine, options) {
2317
2317
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2318
2318
  // A live room's page: the room's own key travels in ?play=, checked by its server.
2319
2319
  !(path === "/watch" && Boolean(options.stations?.rooms)) &&
2320
+ // A live room under its name here, asked of its server with the key held here (guide-rooms.ts).
2321
+ !(isRoomPath(path) && Boolean(options.stations?.rooms)) &&
2320
2322
  // Live rooms on the guide: listed for anybody, published by a member (guide-rooms.ts).
2321
2323
  !((path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/")) && Boolean(options.stations?.rooms)) &&
2322
2324
  // 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.
@@ -2546,7 +2548,56 @@ export function createHandler(engine, options) {
2546
2548
  // A live room's page: /watch?play=<a nixamp server's channel or live
2547
2549
  // stream address>. The built page plays it in @profullstack/player, as a
2548
2550
  // film or as radio; nothing about the room passes through here.
2551
+ if (options.stations?.rooms && isRoomPath(path)) {
2552
+ const rooms = options.stations.rooms;
2553
+ if (await handleRoomProxy(request, response, path, { store: rooms.store, probe: rooms.probe, refuse: (link) => refusedDestination(link), json }))
2554
+ return;
2555
+ const asked = ROOM_PAGE.exec(path)[1];
2556
+ const slugs = roomSlugs(rooms.store.list());
2557
+ const room = slugs.get(asked);
2558
+ if (!room) {
2559
+ // A room's id finds its name: /room/<id> goes to /room/<name>.
2560
+ const named = roomSlugOf(slugs, asked);
2561
+ if (named) {
2562
+ response.writeHead(301, { location: `/room/${named}`, "cache-control": "public, max-age=300" });
2563
+ response.end();
2564
+ return;
2565
+ }
2566
+ }
2567
+ if (request.method !== "GET" && request.method !== "HEAD") {
2568
+ json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
2569
+ return;
2570
+ }
2571
+ const built = web !== null && isFile(join(web, "watch.html")) ? readFileSync(join(web, "watch.html"), "utf8") : null;
2572
+ if (!built) {
2573
+ json(response, 404, { error: "this server was started without its web pages" });
2574
+ return;
2575
+ }
2576
+ const base = radioBaseFor(request, options.stations.publicBase?.() ?? "");
2577
+ const nav = radioNav(guideHosts().has(requestHost) ? "/" : "/guide", "live");
2578
+ const face = room ? { name: room.name, description: room.note || `${room.name}, live now on live.streaming.`, video: room.kind === "video" } : { name: "No such room" };
2579
+ response.writeHead(room ? 200 : 404, {
2580
+ "content-type": "text/html; charset=utf-8",
2581
+ "cache-control": "no-cache",
2582
+ "content-security-policy": adCsp(WATCH_APP_CSP, guideAds()),
2583
+ "referrer-policy": "strict-origin-when-cross-origin",
2584
+ "x-content-type-options": "nosniff",
2585
+ });
2586
+ response.end(request.method === "HEAD" ? undefined : withPageAd(asRadioPage(built, face, asked, base, nav, `/room/${asked}`), guideAds()));
2587
+ return;
2588
+ }
2549
2589
  if (options.stations?.rooms && path === "/watch" && (request.method === "GET" || request.method === "HEAD")) {
2590
+ // A room published here plays under its name: the old link keeps working and lands there.
2591
+ const play = roomAddress(url.searchParams.get("play") ?? "");
2592
+ if (play) {
2593
+ const slugs = roomSlugs(options.stations.rooms.store.list());
2594
+ const published = [...slugs].find(([, one]) => one.play === play.play)?.[0];
2595
+ if (published) {
2596
+ response.writeHead(302, { location: `/room/${published}`, "cache-control": "no-store", "referrer-policy": "no-referrer" });
2597
+ response.end();
2598
+ return;
2599
+ }
2600
+ }
2550
2601
  const built = web !== null && isFile(join(web, "watch.html")) ? withPageAd(readFileSync(join(web, "watch.html"), "utf8"), guideAds()) : null;
2551
2602
  if (!built) {
2552
2603
  json(response, 404, { error: "this server was started without its web pages" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.57.1",
3
+ "version": "0.58.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",
@@ -5,11 +5,14 @@
5
5
  * a Share or a "?play=" link carries, `https://host:port/api/channels/<id>?k=KEY`
6
6
  * or `https://host:port/api/live?k=KEY`. A nixamp.com member publishes one with
7
7
  * a rights basis, as a station is published; the guide lists it beside the
8
- * stations, and its card opens /watch?play=<that address>, which plays it as
9
- * a film or as radio, whichever it carries.
8
+ * stations; its card opens /room/<name>, which plays it as a film or as
9
+ * radio, whichever it carries.
10
10
  *
11
- * Nothing here plays anything: the room's own server does, and this only asks
12
- * it, now and then, whether the room is on the air and what is on it.
11
+ * /room/<name> is the room under a readable name on this server, which asks
12
+ * the room's own server for it with the key held here: the page, its state,
13
+ * the stream and HLS all come from this origin, so no page names the machine
14
+ * the room is on or carries its key. The old /watch?play=<address> links
15
+ * still play, straight from the room's server.
13
16
  */
14
17
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
18
  import { join } from "node:path";
@@ -17,6 +20,7 @@ import { randomBytes } from "node:crypto";
17
20
  import type { IncomingMessage, ServerResponse } from "node:http";
18
21
  import { readConfig } from "./library.ts";
19
22
  import type { GuideEntry } from "./station-guide.ts";
23
+ import { slugify } from "./radio-slug.ts";
20
24
 
21
25
  /** Why a room may be broadcast, the same five reasons a station gives. */
22
26
  export const ROOM_BASES = ["owned", "permission", "licensed", "creative-commons", "public-domain"] as const;
@@ -74,6 +78,38 @@ export function watchBase(fallback: string, env: Record<string, string | undefin
74
78
 
75
79
  export const watchPage = (base: string, play: string): string => `${base}/watch?play=${encodeURIComponent(play)}`;
76
80
 
81
+ /** A room's page under its name, on this server. */
82
+ export const roomPage = (base: string, slug: string): string => `${base}/room/${slug}`;
83
+
84
+ export const ROOM_PAGE = /^\/room\/([A-Za-z0-9_-]{1,60})\/?$/;
85
+ export const ROOM_SUB = /^\/room\/([a-z0-9-]{1,60})\/(state|stream|hls\/[A-Za-z0-9._-]{1,64})$/;
86
+
87
+ export function isRoomPath(path: string): boolean {
88
+ return ROOM_PAGE.test(path) || ROOM_SUB.test(path);
89
+ }
90
+
91
+ /**
92
+ * The rooms by name: "Dropkick Murphys" is /room/dropkick-murphys. Oldest
93
+ * first, so a name stays with the room that had it; a second one is -2.
94
+ */
95
+ export function roomSlugs(rooms: GuideRoom[]): Map<string, GuideRoom> {
96
+ const out = new Map<string, GuideRoom>();
97
+ const sorted = [...rooms].sort((a, b) => (a.publishedAt - b.publishedAt) || a.id.localeCompare(b.id));
98
+ for (const room of sorted) {
99
+ const base = slugify(room.name) || "room";
100
+ let slug = base;
101
+ for (let n = 2; out.has(slug); n += 1) slug = `${base}-${n}`;
102
+ out.set(slug, room);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** A room's name here, or "". */
108
+ export function roomSlugOf(slugs: Map<string, GuideRoom>, id: string): string {
109
+ for (const [slug, one] of slugs) if (one.id === id) return slug;
110
+ return "";
111
+ }
112
+
77
113
  export class RoomStore {
78
114
  private rooms: GuideRoom[] = [];
79
115
  constructor(private readonly file: string) {
@@ -128,6 +164,9 @@ export interface RoomState {
128
164
  name: string;
129
165
  kind: "audio" | "video";
130
166
  nowPlaying: { title: string; artist: string } | null;
167
+ /** A folder or a playlist (more than one file): HLS plays it better than the endless MP4. */
168
+ folder?: boolean;
169
+ album?: string;
131
170
  }
132
171
 
133
172
  interface ChannelRow {
@@ -135,7 +174,8 @@ interface ChannelRow {
135
174
  name?: string;
136
175
  kind?: string;
137
176
  live?: boolean;
138
- codecs?: { tags?: { title?: string; artist?: string } };
177
+ playlist?: unknown[];
178
+ codecs?: { tags?: { title?: string; artist?: string; album?: string } };
139
179
  }
140
180
 
141
181
  /** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
@@ -175,6 +215,8 @@ export class RoomProbe {
175
215
  name: String(row.name ?? address.channel).replace(/\/+$/, "").slice(0, 120),
176
216
  kind: row.kind === "video" ? "video" : "audio",
177
217
  nowPlaying: tags && (tags.title || tags.artist) ? { title: String(tags.title ?? ""), artist: String(tags.artist ?? "") } : null,
218
+ folder: address.channel.startsWith("folder-") || (Array.isArray(row.playlist) && row.playlist.length > 1),
219
+ album: String(tags?.album ?? "").slice(0, 200),
178
220
  };
179
221
  }
180
222
  // The live stream: on the air when it answers with media.
@@ -185,7 +227,7 @@ export class RoomProbe {
185
227
  const type = answer.headers.get("content-type") ?? "";
186
228
  controller.abort();
187
229
  if (!answer.ok) return null;
188
- return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null };
230
+ return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null, folder: false, album: "" };
189
231
  } finally {
190
232
  clearTimeout(timer);
191
233
  }
@@ -194,6 +236,7 @@ export class RoomProbe {
194
236
 
195
237
  /** The rooms as guide cards: the page to open, on the air or not, and what is on. */
196
238
  export async function roomEntries(store: RoomStore, probe: RoomProbe, base: string): Promise<GuideEntry[]> {
239
+ const slugs = roomSlugs(store.list());
197
240
  return Promise.all(store.list().map(async (room) => {
198
241
  const state = await probe.state(room.play);
199
242
  return {
@@ -202,7 +245,7 @@ export async function roomEntries(store: RoomStore, probe: RoomProbe, base: stri
202
245
  kind: state?.kind ?? room.kind,
203
246
  group: "rooms" as const,
204
247
  state: state?.live ? "live" : "offline",
205
- page: watchPage(base, room.play),
248
+ page: roomPage(base, roomSlugOf(slugs, room.id) || room.id),
206
249
  nowPlaying: state?.nowPlaying ?? null,
207
250
  };
208
251
  }));
@@ -281,7 +324,7 @@ export async function handleRooms(request: IncomingMessage, response: ServerResp
281
324
  const title = typeof body?.title === "string" ? body.title.trim().slice(0, 120) : "";
282
325
  const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
283
326
  const room = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, basis, note, by: taken?.by ?? by });
284
- ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: watchPage(ctx.base, room.play) });
327
+ ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: roomPage(ctx.base, roomSlugOf(roomSlugs(ctx.store.list()), room.id) || room.id) });
285
328
  return true;
286
329
  }
287
330
  const one = /^\/api\/v1\/guide\/rooms\/([A-Za-z0-9_-]{1,32})$/.exec(path);
@@ -298,3 +341,134 @@ export async function handleRooms(request: IncomingMessage, response: ServerResp
298
341
  ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
299
342
  return true;
300
343
  }
344
+
345
+ // --- /room/<name>: the room through this server ----------------------------------
346
+
347
+ export interface RoomProxyContext {
348
+ store: RoomStore;
349
+ probe: RoomProbe;
350
+ /** Why an address may not be fetched from this machine, or "" (as the probe's). */
351
+ refuse: (link: string) => Promise<string>;
352
+ json: (response: ServerResponse, status: number, body: unknown) => void;
353
+ fetcher?: typeof fetch;
354
+ }
355
+
356
+ /** What /room/<name>/state answers: the room as its page shows it, no address or key. */
357
+ export interface RoomView {
358
+ name: string;
359
+ kind: "audio" | "video";
360
+ live: boolean;
361
+ folder: boolean;
362
+ hls: boolean;
363
+ title: string;
364
+ artist: string;
365
+ album: string;
366
+ }
367
+
368
+ /** The room's origin and key, gone from a text answer (a playlist, an error). */
369
+ export function roomRewriter(play: string, slug: string): (text: string) => string {
370
+ const address = roomAddress(play);
371
+ if (!address) return (text) => text;
372
+ const channelPath = address.channel !== null ? `/api/channels/${address.channel}` : "/api/live";
373
+ return (text) => text
374
+ .split(`${address.origin}${channelPath}/hls/`).join(`/room/${slug}/hls/`)
375
+ .split(`${address.origin}${channelPath}`).join(`/room/${slug}/stream`)
376
+ .split(address.origin).join("")
377
+ .split(`?k=${address.key}&`).join("?").split(`&k=${address.key}`).join("").split(`?k=${address.key}`).join("")
378
+ .split(address.key).join("");
379
+ }
380
+
381
+ /**
382
+ * Answers /room/<name>/state, /stream and /hls/<file>, asked of the room's
383
+ * server with its key; false when the path is not one. The page itself is
384
+ * the caller's (it is the built watch page).
385
+ */
386
+ export async function handleRoomProxy(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomProxyContext): Promise<boolean> {
387
+ const sub = ROOM_SUB.exec(path);
388
+ if (!sub) return false;
389
+ const method = request.method ?? "GET";
390
+ if (method !== "GET" && method !== "HEAD") {
391
+ ctx.json(response, 405, { error: "a room's page can watch, not drive", code: "method_not_allowed" });
392
+ return true;
393
+ }
394
+ const slug = sub[1]!;
395
+ const room = roomSlugs(ctx.store.list()).get(slug);
396
+ const address = room ? roomAddress(room.play) : null;
397
+ if (!room || !address) {
398
+ ctx.json(response, 404, { error: "no room by that name", code: "not_found" });
399
+ return true;
400
+ }
401
+ const what = sub[2]!;
402
+ if (what === "state") {
403
+ const state = await ctx.probe.state(room.play);
404
+ if (!state) {
405
+ ctx.json(response, 404, { error: "that room is not on the air", code: "unavailable" });
406
+ return true;
407
+ }
408
+ const view: RoomView = {
409
+ name: room.name || state.name,
410
+ kind: state.kind,
411
+ live: state.live,
412
+ folder: state.folder === true,
413
+ hls: address.channel !== null,
414
+ title: state.nowPlaying?.title ?? "",
415
+ artist: state.nowPlaying?.artist ?? "",
416
+ album: state.album ?? "",
417
+ };
418
+ response.setHeader("cache-control", "no-store");
419
+ ctx.json(response, 200, view);
420
+ return true;
421
+ }
422
+ let upstream: string;
423
+ if (what === "stream") upstream = address.play;
424
+ else {
425
+ if (address.channel === null) {
426
+ ctx.json(response, 404, { error: "the live stream has no HLS", code: "not_found" });
427
+ return true;
428
+ }
429
+ upstream = `${address.origin}/api/channels/${address.channel}/${what}?k=${address.key}`;
430
+ }
431
+ if ((await ctx.refuse(upstream).catch(() => "refused")) !== "") {
432
+ ctx.json(response, 502, { error: "that room's server cannot be reached from here", code: "upstream" });
433
+ return true;
434
+ }
435
+ const stop = new AbortController();
436
+ response.on("close", () => stop.abort());
437
+ const answer = await (ctx.fetcher ?? fetch)(upstream, {
438
+ method, signal: stop.signal, redirect: "error",
439
+ headers: { ...(request.headers.range ? { range: String(request.headers.range) } : {}), accept: String(request.headers.accept ?? "*/*") },
440
+ }).catch(() => null);
441
+ if (!answer) {
442
+ ctx.json(response, 502, { error: "that room's server did not answer", code: "upstream" });
443
+ return true;
444
+ }
445
+ const type = answer.headers.get("content-type") ?? "";
446
+ const text = /json|mpegurl|^text\//i.test(type);
447
+ const headers: Record<string, string> = { "cache-control": "no-store", "x-content-type-options": "nosniff" };
448
+ for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "x-accel-buffering", "retry-after"]) {
449
+ const value = answer.headers.get(name);
450
+ if (value && !(text && name === "content-length")) headers[name] = value;
451
+ }
452
+ if (text) {
453
+ // A playlist (or an error) is small: read whole, the room's address and key taken out.
454
+ const body = roomRewriter(room.play, slug)(await answer.text().catch(() => ""));
455
+ response.writeHead(answer.status, { ...headers, "content-length": String(Buffer.byteLength(body)) });
456
+ response.end(method === "HEAD" ? undefined : body);
457
+ return true;
458
+ }
459
+ response.writeHead(answer.status, headers);
460
+ if (!answer.body || method === "HEAD") {
461
+ response.end();
462
+ return true;
463
+ }
464
+ try {
465
+ for await (const chunk of answer.body as unknown as AsyncIterable<Uint8Array>) {
466
+ if (response.destroyed) break;
467
+ if (!response.write(chunk)) await new Promise((done) => { response.once("drain", done); response.once("close", done); });
468
+ }
469
+ } catch {
470
+ // The viewer left, or the room's server did.
471
+ }
472
+ response.end();
473
+ return true;
474
+ }