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.
Files changed (44) hide show
  1. package/dist/follow-page.d.ts +23 -0
  2. package/dist/follow-page.js +111 -0
  3. package/dist/radio-pages.d.ts +211 -0
  4. package/dist/radio-pages.js +415 -0
  5. package/dist/radio-slug.d.ts +18 -0
  6. package/dist/radio-slug.js +43 -0
  7. package/dist/server.js +242 -3
  8. package/dist/station-guide.d.ts +2 -0
  9. package/dist/station-guide.js +5 -3
  10. package/dist/station-page.d.ts +1 -1
  11. package/dist/station-page.js +4 -2
  12. package/dist/station-routes.d.ts +1 -0
  13. package/dist/station-routes.js +28 -3
  14. package/package.json +1 -1
  15. package/src/follow-page.ts +127 -0
  16. package/src/radio-pages.ts +513 -0
  17. package/src/radio-slug.ts +45 -0
  18. package/src/server.ts +232 -3
  19. package/src/station-guide.ts +7 -3
  20. package/src/station-page.ts +4 -2
  21. package/src/station-routes.ts +28 -4
  22. package/web/dist/assets/{ar-DqeYPqVe.js → ar-CHJqwLbG.js} +1 -1
  23. package/web/dist/assets/{cs-D4zQN1oV.js → cs-CmwqmJKg.js} +1 -1
  24. package/web/dist/assets/{da-DKIaXNAT.js → da-Xdzrm0AY.js} +1 -1
  25. package/web/dist/assets/{de-D1lMnV7x.js → de-h2ntHz8Y.js} +1 -1
  26. package/web/dist/assets/{es-CjjnXT7v.js → es-DgbBXAxw.js} +1 -1
  27. package/web/dist/assets/{fi-Dp56MD45.js → fi-Bso9G00g.js} +1 -1
  28. package/web/dist/assets/{fr-CeifYZP1.js → fr-6Q3UZKIG.js} +1 -1
  29. package/web/dist/assets/{hi-DsBa_glA.js → hi-T7_WxQ11.js} +1 -1
  30. package/web/dist/assets/{hu-SYFZGXA2.js → hu-BjOppQVq.js} +1 -1
  31. package/web/dist/assets/{id-91576EGD.js → id-BIkZDUv5.js} +1 -1
  32. package/web/dist/assets/{it-Cjmfw8XJ.js → it-CHOmOHju.js} +1 -1
  33. package/web/dist/assets/{main-CBsLIRa4.js → main-BpsUYSCl.js} +2 -2
  34. package/web/dist/assets/{nl-XqBXZghJ.js → nl-CHQR6VFb.js} +1 -1
  35. package/web/dist/assets/{ru-CYT8xDxB.js → ru-Du7NQrxe.js} +1 -1
  36. package/web/dist/assets/station-xTLbhzsu.js +3 -0
  37. package/web/dist/assets/{sv-BlpGbShs.js → sv-B6AFovY0.js} +1 -1
  38. package/web/dist/assets/{uk-g-QbP-e_.js → uk-LXJz37Kp.js} +1 -1
  39. package/web/dist/assets/{vi-CF206cCy.js → vi-C4taTvjH.js} +1 -1
  40. package/web/dist/assets/{zh-D4QH6alE.js → zh-dBiC2V5a.js} +1 -1
  41. package/web/dist/index.html +1 -1
  42. package/web/dist/station.html +1 -1
  43. package/web/dist/sw.js +20 -20
  44. package/web/dist/assets/station-DUt85zQk.js +0 -3
@@ -0,0 +1,415 @@
1
+ /**
2
+ * /radio: this server's public, listed stations under readable names, and the
3
+ * artists and albums they play.
4
+ *
5
+ * /radio every public, listed station, what is on each
6
+ * /radio/<name> one station's page (the station app), e.g. /radio/ai-metal
7
+ * /radio/<name>/<what> its stream, state, events, now, art, lyrics and .m3u,
8
+ * answered here with the station's token held server-side
9
+ * /radio/artist/<name> an artist: albums, songs, the stations that play them,
10
+ * Follow, and Buy where our AI label sells them
11
+ * /radio/album/<name> an album: cover, songs (with the words, for the label's)
12
+ * /radio/album/<name>/cover.jpg a non-label album's cover, read from its files
13
+ *
14
+ * The point is a link worth sharing: a station's raw link names the machine it
15
+ * runs on and carries its listen token; these name neither. Only stations
16
+ * their owner made public and listed are here, the same rule as the guide.
17
+ * Artists and albums come from those stations' files and the AI label's
18
+ * catalogue, never from the rest of the library: a page here says nothing a
19
+ * public station is not already playing.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ import { albumSlug, artistSlug, stationSlug } from "./radio-slug.js";
23
+ /** A station's own paths, as the station link opens them. */
24
+ export const RADIO_SUB = /^\/radio\/([a-z0-9-]{1,60})\/(stream|state|listen\.m3u|events|now|art|lyrics)$/;
25
+ export const RADIO_STATION = /^\/radio\/([a-z0-9-]{1,60})\/?$/;
26
+ export const RADIO_ARTIST = /^\/radio\/artist\/([a-z0-9-]{1,90})\/?$/;
27
+ export const RADIO_ALBUM = /^\/radio\/album\/([a-z0-9-]{1,100})(\/cover\.jpg)?\/?$/;
28
+ export function isRadioPath(path) {
29
+ return path === "/radio" || path === "/radio/" || RADIO_SUB.test(path) || RADIO_STATION.test(path) || RADIO_ARTIST.test(path) || RADIO_ALBUM.test(path);
30
+ }
31
+ const RESERVED = new Set(["artist", "album", "artists", "albums"]);
32
+ /**
33
+ * Public, listed stations by name. Oldest first, so a name stays with the
34
+ * station that had it; a second "Jazz" is jazz-2.
35
+ */
36
+ export function radioStations(list) {
37
+ const out = new Map();
38
+ const sorted = list
39
+ .filter((one) => one.visibility === "public" && one.listed)
40
+ .sort((a, b) => (a.createdAt - b.createdAt) || a.id.localeCompare(b.id));
41
+ for (const station of sorted) {
42
+ let base = stationSlug(station.name);
43
+ if (RESERVED.has(base) || /^[a-z0-9]{10}$/.test(base))
44
+ base = `${base}-radio`;
45
+ let slug = base;
46
+ for (let n = 2; out.has(slug); n += 1)
47
+ slug = `${base}-${n}`;
48
+ out.set(slug, station);
49
+ }
50
+ return out;
51
+ }
52
+ /** A station's /radio name, or "" when it is not public and listed. */
53
+ export function radioSlugOf(stations, id) {
54
+ for (const [slug, one] of stations)
55
+ if (one.id === id)
56
+ return slug;
57
+ return "";
58
+ }
59
+ const MAX_TRACKS = 300;
60
+ const add = (list, one) => { if (one && !list.includes(one))
61
+ list.push(one); };
62
+ export function buildCatalogue(input) {
63
+ const artists = new Map();
64
+ const albums = new Map();
65
+ const artistFor = (name) => {
66
+ const slug = artistSlug(name);
67
+ if (!slug)
68
+ return null;
69
+ let one = artists.get(slug);
70
+ if (!one) {
71
+ one = { slug, name: name.split(/\s*;\s*/)[0].trim(), albums: [], singles: [], stations: [] };
72
+ artists.set(slug, one);
73
+ }
74
+ return one;
75
+ };
76
+ // The label first: every released album, whether or not a station has reached it yet.
77
+ for (const genre of input.label?.genres ?? []) {
78
+ for (const artist of genre.artists) {
79
+ const one = artistFor(artist.name);
80
+ if (!one)
81
+ continue;
82
+ one.label = { key: artist.key, genre: artist.genre, bio: artist.bio };
83
+ for (const album of artist.albums) {
84
+ const slug = albumSlug(artist.name, album.title);
85
+ if (!slug || albums.has(slug))
86
+ continue;
87
+ albums.set(slug, {
88
+ slug, title: album.title, year: album.year, artist: one.slug, artistName: one.name, concept: album.concept, complete: album.complete,
89
+ label: { artist: artist.key, album: album.key, cover: album.cover },
90
+ tracks: album.songs.map((song) => ({ title: song.title, seconds: song.seconds, n: song.n, lyrics: song.lyrics })),
91
+ stations: [],
92
+ });
93
+ add(one.albums, slug);
94
+ }
95
+ }
96
+ }
97
+ for (const [stationSlugName, station] of input.stations) {
98
+ for (const file of station.items) {
99
+ const audio = input.audioFor ? input.audioFor(file) : file;
100
+ const song = input.songAt?.(audio) ?? null;
101
+ if (song) {
102
+ const artist = artists.get(artistSlug(song.artistName));
103
+ if (artist)
104
+ add(artist.stations, stationSlugName);
105
+ const album = albums.get(albumSlug(song.artistName, song.albumTitle));
106
+ if (album)
107
+ add(album.stations, stationSlugName);
108
+ continue;
109
+ }
110
+ const tags = input.tags(file);
111
+ if (!tags || !tags.artist.trim())
112
+ continue;
113
+ const artist = artistFor(tags.artist);
114
+ if (!artist)
115
+ continue;
116
+ add(artist.stations, stationSlugName);
117
+ const track = { title: tags.title || file.split("/").pop().replace(/\.[a-z0-9]{2,4}$/i, ""), seconds: Math.round(tags.duration || 0), n: tags.track || 0 };
118
+ if (!tags.album.trim()) {
119
+ if (artist.singles.length < MAX_TRACKS && !artist.singles.some((one) => one.title === track.title))
120
+ artist.singles.push(track);
121
+ continue;
122
+ }
123
+ const slug = albumSlug(artist.name, tags.album);
124
+ let album = albums.get(slug);
125
+ if (!album) {
126
+ album = { slug, title: tags.album.trim(), year: tags.year || 0, artist: artist.slug, artistName: artist.name, concept: "", complete: true, tracks: [], stations: [] };
127
+ albums.set(slug, album);
128
+ add(artist.albums, slug);
129
+ }
130
+ add(album.stations, stationSlugName);
131
+ if (!album.label) {
132
+ if (!album.coverFile && tags.hasArt)
133
+ album.coverFile = file;
134
+ if (!album.year && tags.year)
135
+ album.year = tags.year;
136
+ if (album.tracks.length < MAX_TRACKS && !album.tracks.some((one) => one.title === track.title))
137
+ album.tracks.push(track);
138
+ }
139
+ }
140
+ }
141
+ for (const album of albums.values()) {
142
+ if (!album.label)
143
+ album.tracks.sort((a, b) => (a.n || 999) - (b.n || 999) || a.title.localeCompare(b.title));
144
+ }
145
+ return { artists, albums };
146
+ }
147
+ // --- answering through the station's own routes ---------------------------------
148
+ /**
149
+ * Rewrites what a station's routes answer, for /radio/<name>/...: the
150
+ * station's own addresses become the /radio ones and its token goes. Only
151
+ * text (JSON, events, a playlist); sound and pictures pass as they are.
152
+ */
153
+ export function rewriteResponse(response, rewrite) {
154
+ let text = false;
155
+ const writeHead = response.writeHead.bind(response);
156
+ response.writeHead = (...args) => {
157
+ const headers = args.find((one) => typeof one === "object" && one !== null && !Array.isArray(one));
158
+ const type = String(headers?.["content-type"] ?? response.getHeader("content-type") ?? "");
159
+ text = /json|event-stream|mpegurl|^text\//i.test(type);
160
+ if (text && headers) {
161
+ for (const key of Object.keys(headers))
162
+ if (key.toLowerCase() === "content-length")
163
+ delete headers[key];
164
+ response.removeHeader("content-length");
165
+ }
166
+ return writeHead(...args);
167
+ };
168
+ const write = response.write.bind(response);
169
+ const end = response.end.bind(response);
170
+ const swap = (chunk) => {
171
+ if (!text || chunk === undefined || chunk === null || typeof chunk === "function")
172
+ return chunk;
173
+ if (typeof chunk === "string")
174
+ return rewrite(chunk);
175
+ if (chunk instanceof Uint8Array)
176
+ return rewrite(Buffer.from(chunk).toString("utf8"));
177
+ return chunk;
178
+ };
179
+ response.write = (chunk, ...rest) => write(swap(chunk), ...rest);
180
+ response.end = (chunk, ...rest) => end(swap(chunk), ...rest);
181
+ }
182
+ /** The station's addresses and token, as /radio/<name> says them instead. */
183
+ export function radioRewriter(station, slug) {
184
+ const token = station.listenToken;
185
+ const encoded = encodeURIComponent(token);
186
+ return (text) => {
187
+ let out = text.split(`/api/v1/stations/${station.id}/`).join(`/radio/${slug}/`);
188
+ for (const one of new Set([encoded, token])) {
189
+ out = out.split(`?t=${one}&`).join("?").split(`&t=${one}`).join("").split(`?t=${one}`).join("").split(one).join("");
190
+ }
191
+ return out;
192
+ };
193
+ }
194
+ // --- pages ------------------------------------------------------------------------
195
+ function escape(text) {
196
+ return String(text).replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]);
197
+ }
198
+ function minutes(seconds) {
199
+ if (!seconds)
200
+ return "";
201
+ const whole = Math.round(seconds);
202
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
203
+ }
204
+ function price(cents) {
205
+ return cents < 100 ? `${cents}¢` : `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
206
+ }
207
+ /** The live.streaming header every /radio page carries, so a listener can get anywhere from one. */
208
+ export const NAV_STYLE = `.radio-nav{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;max-width:960px;margin:0 auto;padding:14px 16px;font:600 15px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif}
209
+ .radio-nav a{color:inherit;text-decoration:none;opacity:.8}.radio-nav a:hover,.radio-nav a[aria-current=page]{opacity:1;text-decoration:underline}
210
+ .radio-nav .brand{font-weight:800;opacity:1;margin-right:auto}`;
211
+ export function radioNav(home, current = "radio") {
212
+ const link = (href, text, key) => `<a href="${escape(href)}"${current === key ? ' aria-current="page"' : ""}>${text}</a>`;
213
+ return `<nav class="radio-nav" aria-label="live.streaming"><a class="brand" href="${escape(home)}">live.streaming</a>${link(home, "Live now", "live")}${link("/radio", "Radio", "radio")}${link("/label", "AI label", "label")}${link("/vod", "On demand", "vod")}</nav>`;
214
+ }
215
+ const STYLE = `
216
+ :root{color-scheme:light dark;--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}
217
+ @media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}
218
+ *{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}
219
+ main{max-width:960px;margin:0 auto;padding:16px 16px 48px}
220
+ h1{margin:8px 0 4px;font-size:1.8rem;line-height:1.2;overflow-wrap:anywhere}h2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:32px 0 10px}
221
+ .lead{color:var(--muted);margin:0 0 16px}a{color:var(--accent)}
222
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
223
+ .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px}
224
+ .card a.whole{color:inherit;text-decoration:none;display:block}.card:hover,.card:focus-within{border-color:var(--accent)}
225
+ .name{font-weight:700;overflow-wrap:anywhere}.now{color:var(--muted);font-size:.9rem;margin-top:4px;overflow-wrap:anywhere}
226
+ .live{color:var(--live);font-size:.8rem;font-weight:700}.off{color:var(--muted);font-size:.8rem;font-weight:700}
227
+ .head{display:grid;grid-template-columns:200px 1fr;gap:20px;align-items:start}
228
+ @media (max-width:600px){.head{grid-template-columns:1fr}}
229
+ .cover{width:200px;height:200px;max-width:100%;border-radius:12px;object-fit:cover;background:var(--line);display:block}
230
+ .blank{width:200px;height:200px;max-width:100%;border-radius:12px;background:linear-gradient(135deg,var(--accent),var(--line))}
231
+ .thumb{width:100%;aspect-ratio:1;border-radius:10px;object-fit:cover;background:var(--line);display:block;margin-bottom:8px}
232
+ .tblank{width:100%;aspect-ratio:1;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--line));margin-bottom:8px}
233
+ .badge{display:inline-block;font-size:.75rem;font-weight:700;border:1px solid var(--line);border-radius:999px;padding:2px 8px;color:var(--muted)}
234
+ .actions{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0}
235
+ a.btn{display:inline-block;font-weight:700;text-decoration:none;border:1px solid var(--accent);border-radius:999px;padding:6px 14px}
236
+ a.btn.primary{background:var(--accent);color:#fff}
237
+ ol.songs{list-style:none;margin:0;padding:0}ol.songs li{border-top:1px solid var(--line);padding:8px 0}
238
+ .song{display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center}.song .t{flex:1 1 200px;font-weight:600;overflow-wrap:anywhere}
239
+ .song .n,.song .d{color:var(--muted);font-variant-numeric:tabular-nums}.song a.btn{padding:2px 10px;font-size:.85rem}
240
+ details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:.9rem}
241
+ pre.lyrics{white-space:pre-wrap;font:inherit;font-size:.95rem;margin:8px 0 0;padding:12px;border-radius:8px;background:var(--bg)}
242
+ ul.chips{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;gap:8px}ul.chips a{display:inline-block;border:1px solid var(--line);border-radius:999px;padding:4px 12px;text-decoration:none;color:var(--fg);background:var(--card)}
243
+ footer{margin-top:48px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
244
+ ${NAV_STYLE}
245
+ `;
246
+ const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
247
+ /** No script at all: the pages are links and text. */
248
+ export const RADIO_CSP = [
249
+ "default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'",
250
+ "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
251
+ ].join("; ");
252
+ function document(meta, nav, body) {
253
+ return `<!doctype html>
254
+ <html lang="en">
255
+ <head>
256
+ <meta charset="utf-8">
257
+ <meta name="viewport" content="width=device-width, initial-scale=1">
258
+ <title>${escape(meta.title)}</title>
259
+ <meta name="description" content="${escape(meta.description)}">
260
+ <link rel="canonical" href="${escape(meta.url)}">
261
+ <meta property="og:title" content="${escape(meta.title)}">
262
+ <meta property="og:description" content="${escape(meta.description)}">
263
+ <meta property="og:url" content="${escape(meta.url)}">
264
+ <meta property="og:type" content="${escape(meta.type ?? "website")}">
265
+ <meta property="og:site_name" content="live.streaming">
266
+ ${meta.image ? `<meta property="og:image" content="${escape(meta.image)}">\n<meta name="twitter:card" content="summary_large_image">` : `<meta name="twitter:card" content="summary">`}
267
+ <link rel="icon" href="/favicon.png" type="image/png">
268
+ <style>${STYLE}</style>
269
+ </head>
270
+ <body>
271
+ ${nav}
272
+ <main>
273
+ ${body}
274
+ <footer>Played by <a href="https://nixamp.com">nixamp</a>. Listening on the live stations is always free.</footer>
275
+ </main>
276
+ </body>
277
+ </html>`;
278
+ }
279
+ const GROUP_TITLES = { ai: "AI radio", shared: "Shared by listeners", radio: "Radio", tv: "TV" };
280
+ export function renderDirectory(entries, base, nav) {
281
+ const groups = ["ai", "radio", "shared", "tv"];
282
+ const card = (one) => `<div class="card"><a class="whole" href="/radio/${escape(one.slug)}">`
283
+ + `<div class="${one.state === "live" ? "live" : "off"}">${one.state === "live" ? "● LIVE" : "off the air"}</div>`
284
+ + `<div class="name">${escape(one.name)}</div>`
285
+ + `<div class="now">${escape(one.nowPlaying ? [one.nowPlaying.title, one.nowPlaying.artist].filter(Boolean).join(" — ") : one.description)}</div></a></div>`;
286
+ const sections = groups.map((group) => {
287
+ const list = entries.filter((one) => (groups.includes(one.group) ? one.group : one.kind === "video" ? "tv" : "radio") === group);
288
+ return list.length ? `<h2>${GROUP_TITLES[group]}</h2><div class="grid">${list.map(card).join("")}</div>` : "";
289
+ }).join("");
290
+ return document({ title: "Radio · live.streaming", description: `${entries.length} stations on the air: AI-made music, public-domain classics and more. Free to listen, links to share.`, url: `${base}/radio` }, nav, `<h1>Radio</h1><p class="lead">Every public station, under a name you can share. Pick one and press play; tap an artist or an album to see more.</p>${sections || `<p class="lead">Nothing is on the air here yet.</p>`}`);
291
+ }
292
+ function coverOf(album) {
293
+ if (album.label?.cover)
294
+ return `/api/v1/label/${album.label.artist}/${album.label.album}/cover.jpg`;
295
+ if (album.coverFile)
296
+ return `/radio/album/${album.slug}/cover.jpg`;
297
+ return "";
298
+ }
299
+ function stationChips(slugs, names) {
300
+ const known = slugs.filter((one) => names.has(one));
301
+ return known.length ? `<h2>On the air on</h2><ul class="chips">${known.map((one) => `<li><a href="/radio/${escape(one)}">${escape(names.get(one))}</a></li>`).join("")}</ul>` : "";
302
+ }
303
+ function songList(album, shop) {
304
+ return `<ol class="songs">${album.tracks.map((song) => `<li><div class="song"><span class="n">${song.n ? `${song.n}.` : "·"}</span><span class="t">${escape(song.title)}</span><span class="d">${minutes(song.seconds)}</span>`
305
+ + `${shop && album.label && song.n ? `<a class="btn" href="${escape(shop.buy("song", album.label.artist, album.label.album, song.n))}">MP3 · ${price(shop.prices.song)}</a>` : ""}</div>`
306
+ + `${song.lyrics ? `<details><summary>Lyrics</summary><pre class="lyrics">${escape(song.lyrics)}</pre></details>` : ""}</li>`).join("")}</ol>`;
307
+ }
308
+ export function followLink(site, slug, name, back) {
309
+ return `${site}/follow?${new URLSearchParams({ id: `artist:${slug}`, name, back }).toString()}`;
310
+ }
311
+ export function renderArtist(catalogue, artist, ctx) {
312
+ const url = `${ctx.base}/radio/artist/${artist.slug}`;
313
+ const albums = artist.albums.map((one) => catalogue.albums.get(one)).filter((one) => Boolean(one))
314
+ .sort((a, b) => (b.year || 0) - (a.year || 0));
315
+ const first = albums.find((one) => coverOf(one));
316
+ const cover = first ? coverOf(first) : "";
317
+ const shop = artist.label ? ctx.shop : null;
318
+ const albumCards = albums.map((album) => {
319
+ const art = coverOf(album);
320
+ return `<div class="card"><a class="whole" href="/radio/album/${escape(album.slug)}">${art ? `<img class="thumb" src="${escape(art)}" alt="" loading="lazy">` : `<div class="tblank" aria-hidden="true"></div>`}`
321
+ + `<div class="name">${escape(album.title)}</div><div class="now">${album.year ? album.year : ""}${album.tracks.length ? ` · ${album.tracks.length} songs` : ""}</div></a></div>`;
322
+ }).join("");
323
+ const singles = artist.singles.length
324
+ ? `<h2>Songs</h2>${songList({ slug: "", title: "", year: 0, artist: artist.slug, artistName: artist.name, concept: "", complete: true, tracks: artist.singles, stations: [] }, null)}`
325
+ : "";
326
+ const about = artist.label
327
+ ? `<span class="badge">AI artist · ${escape(artist.label.genre)}</span>${artist.label.bio ? `<p class="lead">${escape(artist.label.bio)}</p>` : ""}`
328
+ : `<p class="lead">${albums.length} album${albums.length === 1 ? "" : "s"} on the air here.</p>`;
329
+ const body = `<div class="head">${cover ? `<img class="cover" src="${escape(cover)}" alt="" width="200" height="200">` : `<div class="blank" aria-hidden="true"></div>`}<div>`
330
+ + `<h1>${escape(artist.name)}</h1>${about}`
331
+ + `<div class="actions"><a class="btn primary" href="${escape(followLink(ctx.site, artist.slug, artist.name, url))}">Follow</a>`
332
+ + `${shop && artist.label ? `<a class="btn" href="${escape(shop.buy("artist", artist.label.key))}">Everything by them · ${price(shop.prices.artist)}</a>` : ""}`
333
+ + `${artist.stations[0] && ctx.stationNames.has(artist.stations[0]) ? `<a class="btn" href="/radio/${escape(artist.stations[0])}">Listen live</a>` : ""}</div></div></div>`
334
+ + `${albumCards ? `<h2>Albums</h2><div class="grid">${albumCards}</div>` : ""}${singles}${stationChips(artist.stations, ctx.stationNames)}`;
335
+ return document({
336
+ title: `${artist.name} · live.streaming`,
337
+ description: artist.label?.bio || `${artist.name}: albums, songs and the stations playing them, live and free.`,
338
+ url, type: "music.musician",
339
+ ...(cover ? { image: `${ctx.base}${cover}` } : {}),
340
+ }, ctx.nav, body);
341
+ }
342
+ export function renderAlbum(catalogue, album, ctx) {
343
+ const url = `${ctx.base}/radio/album/${album.slug}`;
344
+ const cover = coverOf(album);
345
+ const artist = catalogue.artists.get(album.artist);
346
+ const shop = album.label ? ctx.shop : null;
347
+ const body = `<div class="head">${cover ? `<img class="cover" src="${escape(cover)}" alt="Cover of ${escape(album.title)}" width="200" height="200">` : `<div class="blank" aria-hidden="true"></div>`}<div>`
348
+ + `<h1>${escape(album.title)}</h1><p class="lead">by <a href="/radio/artist/${escape(album.artist)}">${escape(album.artistName)}</a>${album.year ? ` · ${album.year}` : ""}${album.tracks.length ? ` · ${album.tracks.length} songs` : ""}</p>`
349
+ + `${album.concept ? `<p>${escape(album.concept)}</p>` : ""}${album.label && !album.complete ? `<p class="lead">Still being recorded: new songs land here as they are finished.</p>` : ""}`
350
+ + `<div class="actions">${artist ? `<a class="btn primary" href="${escape(followLink(ctx.site, artist.slug, artist.name, `${ctx.base}/radio/artist/${artist.slug}`))}">Follow ${escape(artist.name)}</a>` : ""}`
351
+ + `${shop && album.label ? `<a class="btn" href="${escape(shop.buy("album", album.label.artist, album.label.album))}">Download the album · ${price(shop.prices.album)}</a>` : ""}`
352
+ + `${album.stations[0] && ctx.stationNames.has(album.stations[0]) ? `<a class="btn" href="/radio/${escape(album.stations[0])}">Listen live</a>` : ""}</div></div></div>`
353
+ + `<h2>Songs</h2>${songList(album, shop)}${stationChips(album.stations, ctx.stationNames)}`;
354
+ return document({
355
+ title: `${album.title} by ${album.artistName} · live.streaming`,
356
+ description: album.concept || `${album.title}, ${album.artistName}${album.year ? ` (${album.year})` : ""}: the songs and where they are on the air.`,
357
+ url, type: "music.album",
358
+ ...(cover ? { image: `${ctx.base}${cover}` } : {}),
359
+ }, ctx.nav, body);
360
+ }
361
+ export function renderMissing(what, base, nav) {
362
+ 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>`);
363
+ }
364
+ export function stationHead(station, slug, base) {
365
+ const url = `${base}/radio/${slug}`;
366
+ const description = station.description || `${station.name}, live now. Free to listen; tap the artist or album on the air to see more.`;
367
+ return `<title>${escape(station.name)} · live.streaming</title>
368
+ <meta name="description" content="${escape(description)}" />
369
+ <link rel="canonical" href="${escape(url)}" />
370
+ <meta property="og:title" content="${escape(station.name)}" />
371
+ <meta property="og:description" content="${escape(description)}" />
372
+ <meta property="og:url" content="${escape(url)}" />
373
+ <meta property="og:type" content="${station.video ? "video.tv_show" : "music.radio_station"}" />
374
+ <meta property="og:site_name" content="live.streaming" />
375
+ <meta name="twitter:card" content="summary" />
376
+ <style>${NAV_STYLE}</style>`;
377
+ }
378
+ /** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
379
+ export function asRadioPage(html, station, slug, base, nav) {
380
+ return html
381
+ .replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base))
382
+ .replace(/\s*<meta name="robots" content="noindex"\s*\/?>/, "")
383
+ .replace(/<body>/, `<body>\n${nav}`);
384
+ }
385
+ /**
386
+ * Peer stations by name, after this server's own (which keep theirs). Only a
387
+ * peer's own station link (its origin, /station/<id>?t=) is taken; the token
388
+ * stays on this server, which asks the peer for the listener.
389
+ */
390
+ export function radioPeers(own, entries) {
391
+ const taken = new Set(own);
392
+ const out = new Map();
393
+ for (const entry of entries) {
394
+ let url;
395
+ try {
396
+ url = new URL(entry.page);
397
+ }
398
+ catch {
399
+ continue;
400
+ }
401
+ const id = /^\/station\/([a-z0-9]{10})\/?$/.exec(url.pathname)?.[1] ?? "";
402
+ const token = url.searchParams.get("t") ?? "";
403
+ if (url.protocol !== "https:" || !id || !token || (entry.id && entry.id !== id))
404
+ continue;
405
+ let base = stationSlug(entry.name);
406
+ if (RESERVED.has(base) || /^[a-z0-9]{10}$/.test(base))
407
+ base = `${base}-radio`;
408
+ let slug = base;
409
+ for (let n = 2; taken.has(slug); n += 1)
410
+ slug = `${base}-${n}`;
411
+ taken.add(slug);
412
+ out.set(slug, { origin: url.origin, id, token, name: entry.name, kind: entry.kind, group: entry.group ?? (entry.kind === "video" ? "tv" : "radio"), state: entry.state, nowPlaying: entry.nowPlaying });
413
+ }
414
+ return out;
415
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The readable names /radio pages go by: a station, an artist, an album.
3
+ *
4
+ * Shared by the server (which owns the names) and the station page's script
5
+ * (which links the artist and album on the air), so the two always agree.
6
+ * No imports: the web bundle carries this file as it is.
7
+ */
8
+ /** Lower-case ASCII words joined by "-", at most `max` characters, cut at a word. */
9
+ export declare function slugify(text: string, max?: number): string;
10
+ /**
11
+ * A station's name as a path: "Profullstack AI · Metal" is ai-metal, "Roaring
12
+ * Twenties: Jazz, Blues & Dance" is roaring-twenties (what comes before a
13
+ * colon, when that is two words or more), "Public Domain Classical" is
14
+ * public-domain-classical.
15
+ */
16
+ export declare function stationSlug(name: string): string;
17
+ export declare const artistSlug: (artist: string) => string;
18
+ export declare const albumSlug: (artist: string, album: string) => string;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The readable names /radio pages go by: a station, an artist, an album.
3
+ *
4
+ * Shared by the server (which owns the names) and the station page's script
5
+ * (which links the artist and album on the air), so the two always agree.
6
+ * No imports: the web bundle carries this file as it is.
7
+ */
8
+ /** Letters NFKD does not take apart. */
9
+ const SPELLED = { þ: "th", ð: "d", ø: "o", æ: "ae", œ: "oe", ß: "ss", đ: "d", ł: "l", ı: "i", "&": " and " };
10
+ /** Lower-case ASCII words joined by "-", at most `max` characters, cut at a word. */
11
+ export function slugify(text, max = 60) {
12
+ const plain = String(text ?? "")
13
+ .toLowerCase()
14
+ .replace(/[þðøæœßđłı&]/g, (one) => SPELLED[one] ?? one)
15
+ .normalize("NFKD")
16
+ .replace(/[̀-ͯ]/g, "")
17
+ .replace(/['’`]/g, "")
18
+ .replace(/[^a-z0-9]+/g, "-")
19
+ .replace(/^-+|-+$/g, "");
20
+ if (plain.length <= max)
21
+ return plain;
22
+ const cut = plain.slice(0, max + 1);
23
+ const at = cut.lastIndexOf("-");
24
+ return (at > 8 ? cut.slice(0, at) : plain.slice(0, max)).replace(/-+$/, "");
25
+ }
26
+ /**
27
+ * A station's name as a path: "Profullstack AI · Metal" is ai-metal, "Roaring
28
+ * Twenties: Jazz, Blues & Dance" is roaring-twenties (what comes before a
29
+ * colon, when that is two words or more), "Public Domain Classical" is
30
+ * public-domain-classical.
31
+ */
32
+ export function stationSlug(name) {
33
+ const text = String(name ?? "").trim();
34
+ const ai = /^.{0,60}?\bAI\s*[·:|•\-–—]\s*(.+)$/i.exec(text);
35
+ if (ai)
36
+ return slugify(`ai ${ai[1]}`, 48) || "station";
37
+ const colon = /^([^:]{3,}):\s*\S/.exec(text);
38
+ if (colon && colon[1].trim().split(/\s+/).length >= 2)
39
+ return slugify(colon[1], 48) || "station";
40
+ return slugify(text, 48) || "station";
41
+ }
42
+ export const artistSlug = (artist) => slugify(String(artist ?? "").split(/\s*;\s*/)[0] ?? "", 60);
43
+ export const albumSlug = (artist, album) => slugify(`${artistSlug(artist)} ${album}`, 90);