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
|
@@ -0,0 +1,513 @@
|
|
|
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 type { ServerResponse } from "node:http";
|
|
23
|
+
import type { Station } from "./stations.ts";
|
|
24
|
+
import { albumSlug, artistSlug, stationSlug } from "./radio-slug.ts";
|
|
25
|
+
|
|
26
|
+
/** A station's own paths, as the station link opens them. */
|
|
27
|
+
export const RADIO_SUB = /^\/radio\/([a-z0-9-]{1,60})\/(stream|state|listen\.m3u|events|now|art|lyrics)$/;
|
|
28
|
+
export const RADIO_STATION = /^\/radio\/([a-z0-9-]{1,60})\/?$/;
|
|
29
|
+
export const RADIO_ARTIST = /^\/radio\/artist\/([a-z0-9-]{1,90})\/?$/;
|
|
30
|
+
export const RADIO_ALBUM = /^\/radio\/album\/([a-z0-9-]{1,100})(\/cover\.jpg)?\/?$/;
|
|
31
|
+
|
|
32
|
+
export function isRadioPath(path: string): boolean {
|
|
33
|
+
return path === "/radio" || path === "/radio/" || RADIO_SUB.test(path) || RADIO_STATION.test(path) || RADIO_ARTIST.test(path) || RADIO_ALBUM.test(path);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const RESERVED = new Set(["artist", "album", "artists", "albums"]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Public, listed stations by name. Oldest first, so a name stays with the
|
|
40
|
+
* station that had it; a second "Jazz" is jazz-2.
|
|
41
|
+
*/
|
|
42
|
+
export function radioStations(list: Station[]): Map<string, Station> {
|
|
43
|
+
const out = new Map<string, Station>();
|
|
44
|
+
const sorted = list
|
|
45
|
+
.filter((one) => one.visibility === "public" && one.listed)
|
|
46
|
+
.sort((a, b) => (a.createdAt - b.createdAt) || a.id.localeCompare(b.id));
|
|
47
|
+
for (const station of sorted) {
|
|
48
|
+
let base = stationSlug(station.name);
|
|
49
|
+
if (RESERVED.has(base) || /^[a-z0-9]{10}$/.test(base)) base = `${base}-radio`;
|
|
50
|
+
let slug = base;
|
|
51
|
+
for (let n = 2; out.has(slug); n += 1) slug = `${base}-${n}`;
|
|
52
|
+
out.set(slug, station);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A station's /radio name, or "" when it is not public and listed. */
|
|
58
|
+
export function radioSlugOf(stations: Map<string, Station>, id: string): string {
|
|
59
|
+
for (const [slug, one] of stations) if (one.id === id) return slug;
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- the catalogue -----------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export interface RadioTrack { title: string; seconds: number; n: number; lyrics?: string }
|
|
66
|
+
|
|
67
|
+
export interface RadioAlbum {
|
|
68
|
+
slug: string;
|
|
69
|
+
title: string;
|
|
70
|
+
year: number;
|
|
71
|
+
artist: string;
|
|
72
|
+
artistName: string;
|
|
73
|
+
concept: string;
|
|
74
|
+
complete: boolean;
|
|
75
|
+
/** The AI label's keys: its cover and its store. */
|
|
76
|
+
label?: { artist: string; album: string; cover: boolean };
|
|
77
|
+
/** A file of this album's with a picture in it (never shown; read for /cover.jpg). */
|
|
78
|
+
coverFile?: string;
|
|
79
|
+
tracks: RadioTrack[];
|
|
80
|
+
stations: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface RadioArtist {
|
|
84
|
+
slug: string;
|
|
85
|
+
name: string;
|
|
86
|
+
label?: { key: string; genre: string; bio: string };
|
|
87
|
+
albums: string[];
|
|
88
|
+
/** Songs with no album in their tags. */
|
|
89
|
+
singles: RadioTrack[];
|
|
90
|
+
stations: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface RadioCatalogue {
|
|
94
|
+
artists: Map<string, RadioArtist>;
|
|
95
|
+
albums: Map<string, RadioAlbum>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface LabelView {
|
|
99
|
+
holder: string;
|
|
100
|
+
genres: Array<{ genre: string; station: string; artists: Array<{ key: string; name: string; genre: string; bio: string; albums: Array<{ key: string; title: string; year: number; concept: string; cover: boolean; complete: boolean; songs: Array<{ n: number; title: string; seconds: number; lyrics: string }> }> }> }>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface FileTags { title: string; artist: string; album: string; year: number; track: number; duration: number; hasArt: boolean }
|
|
104
|
+
|
|
105
|
+
export interface CatalogueInput {
|
|
106
|
+
stations: Map<string, Station>;
|
|
107
|
+
label?: LabelView;
|
|
108
|
+
/** The label song at a file (after audioFor), or null. */
|
|
109
|
+
songAt?: (file: string) => { artistName: string; albumTitle: string } | null;
|
|
110
|
+
/** A label music video's song file; the file itself otherwise. */
|
|
111
|
+
audioFor?: (file: string) => string;
|
|
112
|
+
/** What the library knows about a file, or null. */
|
|
113
|
+
tags: (file: string) => FileTags | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const MAX_TRACKS = 300;
|
|
117
|
+
|
|
118
|
+
const add = (list: string[], one: string): void => { if (one && !list.includes(one)) list.push(one); };
|
|
119
|
+
|
|
120
|
+
export function buildCatalogue(input: CatalogueInput): RadioCatalogue {
|
|
121
|
+
const artists = new Map<string, RadioArtist>();
|
|
122
|
+
const albums = new Map<string, RadioAlbum>();
|
|
123
|
+
const artistFor = (name: string): RadioArtist | null => {
|
|
124
|
+
const slug = artistSlug(name);
|
|
125
|
+
if (!slug) return null;
|
|
126
|
+
let one = artists.get(slug);
|
|
127
|
+
if (!one) {
|
|
128
|
+
one = { slug, name: name.split(/\s*;\s*/)[0]!.trim(), albums: [], singles: [], stations: [] };
|
|
129
|
+
artists.set(slug, one);
|
|
130
|
+
}
|
|
131
|
+
return one;
|
|
132
|
+
};
|
|
133
|
+
// The label first: every released album, whether or not a station has reached it yet.
|
|
134
|
+
for (const genre of input.label?.genres ?? []) {
|
|
135
|
+
for (const artist of genre.artists) {
|
|
136
|
+
const one = artistFor(artist.name);
|
|
137
|
+
if (!one) continue;
|
|
138
|
+
one.label = { key: artist.key, genre: artist.genre, bio: artist.bio };
|
|
139
|
+
for (const album of artist.albums) {
|
|
140
|
+
const slug = albumSlug(artist.name, album.title);
|
|
141
|
+
if (!slug || albums.has(slug)) continue;
|
|
142
|
+
albums.set(slug, {
|
|
143
|
+
slug, title: album.title, year: album.year, artist: one.slug, artistName: one.name, concept: album.concept, complete: album.complete,
|
|
144
|
+
label: { artist: artist.key, album: album.key, cover: album.cover },
|
|
145
|
+
tracks: album.songs.map((song) => ({ title: song.title, seconds: song.seconds, n: song.n, lyrics: song.lyrics })),
|
|
146
|
+
stations: [],
|
|
147
|
+
});
|
|
148
|
+
add(one.albums, slug);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const [stationSlugName, station] of input.stations) {
|
|
153
|
+
for (const file of station.items) {
|
|
154
|
+
const audio = input.audioFor ? input.audioFor(file) : file;
|
|
155
|
+
const song = input.songAt?.(audio) ?? null;
|
|
156
|
+
if (song) {
|
|
157
|
+
const artist = artists.get(artistSlug(song.artistName));
|
|
158
|
+
if (artist) add(artist.stations, stationSlugName);
|
|
159
|
+
const album = albums.get(albumSlug(song.artistName, song.albumTitle));
|
|
160
|
+
if (album) add(album.stations, stationSlugName);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const tags = input.tags(file);
|
|
164
|
+
if (!tags || !tags.artist.trim()) continue;
|
|
165
|
+
const artist = artistFor(tags.artist);
|
|
166
|
+
if (!artist) continue;
|
|
167
|
+
add(artist.stations, stationSlugName);
|
|
168
|
+
const track: RadioTrack = { title: tags.title || file.split("/").pop()!.replace(/\.[a-z0-9]{2,4}$/i, ""), seconds: Math.round(tags.duration || 0), n: tags.track || 0 };
|
|
169
|
+
if (!tags.album.trim()) {
|
|
170
|
+
if (artist.singles.length < MAX_TRACKS && !artist.singles.some((one) => one.title === track.title)) artist.singles.push(track);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const slug = albumSlug(artist.name, tags.album);
|
|
174
|
+
let album = albums.get(slug);
|
|
175
|
+
if (!album) {
|
|
176
|
+
album = { slug, title: tags.album.trim(), year: tags.year || 0, artist: artist.slug, artistName: artist.name, concept: "", complete: true, tracks: [], stations: [] };
|
|
177
|
+
albums.set(slug, album);
|
|
178
|
+
add(artist.albums, slug);
|
|
179
|
+
}
|
|
180
|
+
add(album.stations, stationSlugName);
|
|
181
|
+
if (!album.label) {
|
|
182
|
+
if (!album.coverFile && tags.hasArt) album.coverFile = file;
|
|
183
|
+
if (!album.year && tags.year) album.year = tags.year;
|
|
184
|
+
if (album.tracks.length < MAX_TRACKS && !album.tracks.some((one) => one.title === track.title)) album.tracks.push(track);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const album of albums.values()) {
|
|
189
|
+
if (!album.label) album.tracks.sort((a, b) => (a.n || 999) - (b.n || 999) || a.title.localeCompare(b.title));
|
|
190
|
+
}
|
|
191
|
+
return { artists, albums };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- answering through the station's own routes ---------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Rewrites what a station's routes answer, for /radio/<name>/...: the
|
|
198
|
+
* station's own addresses become the /radio ones and its token goes. Only
|
|
199
|
+
* text (JSON, events, a playlist); sound and pictures pass as they are.
|
|
200
|
+
*/
|
|
201
|
+
export function rewriteResponse(response: ServerResponse, rewrite: (text: string) => string): void {
|
|
202
|
+
let text = false;
|
|
203
|
+
const writeHead = response.writeHead.bind(response) as (...args: unknown[]) => ServerResponse;
|
|
204
|
+
(response as unknown as { writeHead: (...args: unknown[]) => ServerResponse }).writeHead = (...args: unknown[]) => {
|
|
205
|
+
const headers = args.find((one) => typeof one === "object" && one !== null && !Array.isArray(one)) as Record<string, unknown> | undefined;
|
|
206
|
+
const type = String(headers?.["content-type"] ?? response.getHeader("content-type") ?? "");
|
|
207
|
+
text = /json|event-stream|mpegurl|^text\//i.test(type);
|
|
208
|
+
if (text && headers) {
|
|
209
|
+
for (const key of Object.keys(headers)) if (key.toLowerCase() === "content-length") delete headers[key];
|
|
210
|
+
response.removeHeader("content-length");
|
|
211
|
+
}
|
|
212
|
+
return writeHead(...args);
|
|
213
|
+
};
|
|
214
|
+
const write = response.write.bind(response) as (...args: unknown[]) => boolean;
|
|
215
|
+
const end = response.end.bind(response) as (...args: unknown[]) => ServerResponse;
|
|
216
|
+
const swap = (chunk: unknown): unknown => {
|
|
217
|
+
if (!text || chunk === undefined || chunk === null || typeof chunk === "function") return chunk;
|
|
218
|
+
if (typeof chunk === "string") return rewrite(chunk);
|
|
219
|
+
if (chunk instanceof Uint8Array) return rewrite(Buffer.from(chunk).toString("utf8"));
|
|
220
|
+
return chunk;
|
|
221
|
+
};
|
|
222
|
+
(response as unknown as { write: (...args: unknown[]) => boolean }).write = (chunk: unknown, ...rest: unknown[]) => write(swap(chunk), ...rest);
|
|
223
|
+
(response as unknown as { end: (...args: unknown[]) => ServerResponse }).end = (chunk?: unknown, ...rest: unknown[]) => end(swap(chunk), ...rest);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The station's addresses and token, as /radio/<name> says them instead. */
|
|
227
|
+
export function radioRewriter(station: { id: string; listenToken: string }, slug: string): (text: string) => string {
|
|
228
|
+
const token = station.listenToken;
|
|
229
|
+
const encoded = encodeURIComponent(token);
|
|
230
|
+
return (text) => {
|
|
231
|
+
let out = text.split(`/api/v1/stations/${station.id}/`).join(`/radio/${slug}/`);
|
|
232
|
+
for (const one of new Set([encoded, token])) {
|
|
233
|
+
out = out.split(`?t=${one}&`).join("?").split(`&t=${one}`).join("").split(`?t=${one}`).join("").split(one).join("");
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- pages ------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
function escape(text: string): string {
|
|
242
|
+
return String(text).replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]!);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function minutes(seconds: number): string {
|
|
246
|
+
if (!seconds) return "";
|
|
247
|
+
const whole = Math.round(seconds);
|
|
248
|
+
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function price(cents: number): string {
|
|
252
|
+
return cents < 100 ? `${cents}¢` : `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The live.streaming header every /radio page carries, so a listener can get anywhere from one. */
|
|
256
|
+
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}
|
|
257
|
+
.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}
|
|
258
|
+
.radio-nav .brand{font-weight:800;opacity:1;margin-right:auto}`;
|
|
259
|
+
|
|
260
|
+
export function radioNav(home: string, current: "live" | "radio" | "label" | "vod" | "" = "radio"): string {
|
|
261
|
+
const link = (href: string, text: string, key: string): string => `<a href="${escape(href)}"${current === key ? ' aria-current="page"' : ""}>${text}</a>`;
|
|
262
|
+
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>`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const STYLE = `
|
|
266
|
+
:root{color-scheme:light dark;--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}
|
|
267
|
+
@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}
|
|
268
|
+
*{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}
|
|
269
|
+
main{max-width:960px;margin:0 auto;padding:16px 16px 48px}
|
|
270
|
+
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}
|
|
271
|
+
.lead{color:var(--muted);margin:0 0 16px}a{color:var(--accent)}
|
|
272
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
|
|
273
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px}
|
|
274
|
+
.card a.whole{color:inherit;text-decoration:none;display:block}.card:hover,.card:focus-within{border-color:var(--accent)}
|
|
275
|
+
.name{font-weight:700;overflow-wrap:anywhere}.now{color:var(--muted);font-size:.9rem;margin-top:4px;overflow-wrap:anywhere}
|
|
276
|
+
.live{color:var(--live);font-size:.8rem;font-weight:700}.off{color:var(--muted);font-size:.8rem;font-weight:700}
|
|
277
|
+
.head{display:grid;grid-template-columns:200px 1fr;gap:20px;align-items:start}
|
|
278
|
+
@media (max-width:600px){.head{grid-template-columns:1fr}}
|
|
279
|
+
.cover{width:200px;height:200px;max-width:100%;border-radius:12px;object-fit:cover;background:var(--line);display:block}
|
|
280
|
+
.blank{width:200px;height:200px;max-width:100%;border-radius:12px;background:linear-gradient(135deg,var(--accent),var(--line))}
|
|
281
|
+
.thumb{width:100%;aspect-ratio:1;border-radius:10px;object-fit:cover;background:var(--line);display:block;margin-bottom:8px}
|
|
282
|
+
.tblank{width:100%;aspect-ratio:1;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--line));margin-bottom:8px}
|
|
283
|
+
.badge{display:inline-block;font-size:.75rem;font-weight:700;border:1px solid var(--line);border-radius:999px;padding:2px 8px;color:var(--muted)}
|
|
284
|
+
.actions{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0}
|
|
285
|
+
a.btn{display:inline-block;font-weight:700;text-decoration:none;border:1px solid var(--accent);border-radius:999px;padding:6px 14px}
|
|
286
|
+
a.btn.primary{background:var(--accent);color:#fff}
|
|
287
|
+
ol.songs{list-style:none;margin:0;padding:0}ol.songs li{border-top:1px solid var(--line);padding:8px 0}
|
|
288
|
+
.song{display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center}.song .t{flex:1 1 200px;font-weight:600;overflow-wrap:anywhere}
|
|
289
|
+
.song .n,.song .d{color:var(--muted);font-variant-numeric:tabular-nums}.song a.btn{padding:2px 10px;font-size:.85rem}
|
|
290
|
+
details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:.9rem}
|
|
291
|
+
pre.lyrics{white-space:pre-wrap;font:inherit;font-size:.95rem;margin:8px 0 0;padding:12px;border-radius:8px;background:var(--bg)}
|
|
292
|
+
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)}
|
|
293
|
+
footer{margin-top:48px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
|
|
294
|
+
${NAV_STYLE}
|
|
295
|
+
`;
|
|
296
|
+
|
|
297
|
+
const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
298
|
+
|
|
299
|
+
/** No script at all: the pages are links and text. */
|
|
300
|
+
export const RADIO_CSP = [
|
|
301
|
+
"default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'",
|
|
302
|
+
"base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
|
|
303
|
+
].join("; ");
|
|
304
|
+
|
|
305
|
+
export interface PageMeta { title: string; description: string; url: string; image?: string; type?: string }
|
|
306
|
+
|
|
307
|
+
function document(meta: PageMeta, nav: string, body: string): string {
|
|
308
|
+
return `<!doctype html>
|
|
309
|
+
<html lang="en">
|
|
310
|
+
<head>
|
|
311
|
+
<meta charset="utf-8">
|
|
312
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
313
|
+
<title>${escape(meta.title)}</title>
|
|
314
|
+
<meta name="description" content="${escape(meta.description)}">
|
|
315
|
+
<link rel="canonical" href="${escape(meta.url)}">
|
|
316
|
+
<meta property="og:title" content="${escape(meta.title)}">
|
|
317
|
+
<meta property="og:description" content="${escape(meta.description)}">
|
|
318
|
+
<meta property="og:url" content="${escape(meta.url)}">
|
|
319
|
+
<meta property="og:type" content="${escape(meta.type ?? "website")}">
|
|
320
|
+
<meta property="og:site_name" content="live.streaming">
|
|
321
|
+
${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">`}
|
|
322
|
+
<link rel="icon" href="/favicon.png" type="image/png">
|
|
323
|
+
<style>${STYLE}</style>
|
|
324
|
+
</head>
|
|
325
|
+
<body>
|
|
326
|
+
${nav}
|
|
327
|
+
<main>
|
|
328
|
+
${body}
|
|
329
|
+
<footer>Played by <a href="https://nixamp.com">nixamp</a>. Listening on the live stations is always free.</footer>
|
|
330
|
+
</main>
|
|
331
|
+
</body>
|
|
332
|
+
</html>`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface DirectoryEntry {
|
|
336
|
+
slug: string;
|
|
337
|
+
name: string;
|
|
338
|
+
description: string;
|
|
339
|
+
state: string;
|
|
340
|
+
kind: "audio" | "video";
|
|
341
|
+
group: string;
|
|
342
|
+
nowPlaying: { title: string; artist: string } | null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const GROUP_TITLES: Record<string, string> = { ai: "AI radio", shared: "Shared by listeners", radio: "Radio", tv: "TV" };
|
|
346
|
+
|
|
347
|
+
export function renderDirectory(entries: DirectoryEntry[], base: string, nav: string): string {
|
|
348
|
+
const groups = ["ai", "radio", "shared", "tv"];
|
|
349
|
+
const card = (one: DirectoryEntry): string => `<div class="card"><a class="whole" href="/radio/${escape(one.slug)}">`
|
|
350
|
+
+ `<div class="${one.state === "live" ? "live" : "off"}">${one.state === "live" ? "● LIVE" : "off the air"}</div>`
|
|
351
|
+
+ `<div class="name">${escape(one.name)}</div>`
|
|
352
|
+
+ `<div class="now">${escape(one.nowPlaying ? [one.nowPlaying.title, one.nowPlaying.artist].filter(Boolean).join(" — ") : one.description)}</div></a></div>`;
|
|
353
|
+
const sections = groups.map((group) => {
|
|
354
|
+
const list = entries.filter((one) => (groups.includes(one.group) ? one.group : one.kind === "video" ? "tv" : "radio") === group);
|
|
355
|
+
return list.length ? `<h2>${GROUP_TITLES[group]}</h2><div class="grid">${list.map(card).join("")}</div>` : "";
|
|
356
|
+
}).join("");
|
|
357
|
+
return document(
|
|
358
|
+
{ 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` },
|
|
359
|
+
nav,
|
|
360
|
+
`<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>`}`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export interface Shop {
|
|
365
|
+
/** nixamp.com, where the store and accounts are. */
|
|
366
|
+
site: string;
|
|
367
|
+
/** buyLink(...) bound to this server. */
|
|
368
|
+
buy: (kind: "song" | "album" | "artist", artist: string, album?: string, n?: number) => string;
|
|
369
|
+
prices: { song: number; album: number; artist: number };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function coverOf(album: RadioAlbum): string {
|
|
373
|
+
if (album.label?.cover) return `/api/v1/label/${album.label.artist}/${album.label.album}/cover.jpg`;
|
|
374
|
+
if (album.coverFile) return `/radio/album/${album.slug}/cover.jpg`;
|
|
375
|
+
return "";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function stationChips(slugs: string[], names: Map<string, string>): string {
|
|
379
|
+
const known = slugs.filter((one) => names.has(one));
|
|
380
|
+
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>` : "";
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function songList(album: RadioAlbum, shop: Shop | null): string {
|
|
384
|
+
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>`
|
|
385
|
+
+ `${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>`
|
|
386
|
+
+ `${song.lyrics ? `<details><summary>Lyrics</summary><pre class="lyrics">${escape(song.lyrics)}</pre></details>` : ""}</li>`).join("")}</ol>`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function followLink(site: string, slug: string, name: string, back: string): string {
|
|
390
|
+
return `${site}/follow?${new URLSearchParams({ id: `artist:${slug}`, name, back }).toString()}`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function renderArtist(catalogue: RadioCatalogue, artist: RadioArtist, ctx: { base: string; nav: string; shop: Shop | null; site: string; stationNames: Map<string, string> }): string {
|
|
394
|
+
const url = `${ctx.base}/radio/artist/${artist.slug}`;
|
|
395
|
+
const albums = artist.albums.map((one) => catalogue.albums.get(one)).filter((one): one is RadioAlbum => Boolean(one))
|
|
396
|
+
.sort((a, b) => (b.year || 0) - (a.year || 0));
|
|
397
|
+
const first = albums.find((one) => coverOf(one));
|
|
398
|
+
const cover = first ? coverOf(first) : "";
|
|
399
|
+
const shop = artist.label ? ctx.shop : null;
|
|
400
|
+
const albumCards = albums.map((album) => {
|
|
401
|
+
const art = coverOf(album);
|
|
402
|
+
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>`}`
|
|
403
|
+
+ `<div class="name">${escape(album.title)}</div><div class="now">${album.year ? album.year : ""}${album.tracks.length ? ` · ${album.tracks.length} songs` : ""}</div></a></div>`;
|
|
404
|
+
}).join("");
|
|
405
|
+
const singles = artist.singles.length
|
|
406
|
+
? `<h2>Songs</h2>${songList({ slug: "", title: "", year: 0, artist: artist.slug, artistName: artist.name, concept: "", complete: true, tracks: artist.singles, stations: [] }, null)}`
|
|
407
|
+
: "";
|
|
408
|
+
const about = artist.label
|
|
409
|
+
? `<span class="badge">AI artist · ${escape(artist.label.genre)}</span>${artist.label.bio ? `<p class="lead">${escape(artist.label.bio)}</p>` : ""}`
|
|
410
|
+
: `<p class="lead">${albums.length} album${albums.length === 1 ? "" : "s"} on the air here.</p>`;
|
|
411
|
+
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>`
|
|
412
|
+
+ `<h1>${escape(artist.name)}</h1>${about}`
|
|
413
|
+
+ `<div class="actions"><a class="btn primary" href="${escape(followLink(ctx.site, artist.slug, artist.name, url))}">Follow</a>`
|
|
414
|
+
+ `${shop && artist.label ? `<a class="btn" href="${escape(shop.buy("artist", artist.label.key))}">Everything by them · ${price(shop.prices.artist)}</a>` : ""}`
|
|
415
|
+
+ `${artist.stations[0] && ctx.stationNames.has(artist.stations[0]) ? `<a class="btn" href="/radio/${escape(artist.stations[0])}">Listen live</a>` : ""}</div></div></div>`
|
|
416
|
+
+ `${albumCards ? `<h2>Albums</h2><div class="grid">${albumCards}</div>` : ""}${singles}${stationChips(artist.stations, ctx.stationNames)}`;
|
|
417
|
+
return document({
|
|
418
|
+
title: `${artist.name} · live.streaming`,
|
|
419
|
+
description: artist.label?.bio || `${artist.name}: albums, songs and the stations playing them, live and free.`,
|
|
420
|
+
url, type: "music.musician",
|
|
421
|
+
...(cover ? { image: `${ctx.base}${cover}` } : {}),
|
|
422
|
+
}, ctx.nav, body);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function renderAlbum(catalogue: RadioCatalogue, album: RadioAlbum, ctx: { base: string; nav: string; shop: Shop | null; site: string; stationNames: Map<string, string> }): string {
|
|
426
|
+
const url = `${ctx.base}/radio/album/${album.slug}`;
|
|
427
|
+
const cover = coverOf(album);
|
|
428
|
+
const artist = catalogue.artists.get(album.artist);
|
|
429
|
+
const shop = album.label ? ctx.shop : null;
|
|
430
|
+
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>`
|
|
431
|
+
+ `<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>`
|
|
432
|
+
+ `${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>` : ""}`
|
|
433
|
+
+ `<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>` : ""}`
|
|
434
|
+
+ `${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>` : ""}`
|
|
435
|
+
+ `${album.stations[0] && ctx.stationNames.has(album.stations[0]) ? `<a class="btn" href="/radio/${escape(album.stations[0])}">Listen live</a>` : ""}</div></div></div>`
|
|
436
|
+
+ `<h2>Songs</h2>${songList(album, shop)}${stationChips(album.stations, ctx.stationNames)}`;
|
|
437
|
+
return document({
|
|
438
|
+
title: `${album.title} by ${album.artistName} · live.streaming`,
|
|
439
|
+
description: album.concept || `${album.title}, ${album.artistName}${album.year ? ` (${album.year})` : ""}: the songs and where they are on the air.`,
|
|
440
|
+
url, type: "music.album",
|
|
441
|
+
...(cover ? { image: `${ctx.base}${cover}` } : {}),
|
|
442
|
+
}, ctx.nav, body);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function renderMissing(what: string, base: string, nav: string): string {
|
|
446
|
+
return document({ title: `Not here · live.streaming`, description: "Nothing by that name is on the air here.", url: `${base}/radio` }, nav,
|
|
447
|
+
`<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>`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** A station page's head and header, for the built station app served at /radio/<name>. */
|
|
451
|
+
/** What a station page's head says: a station of ours or a peer's. */
|
|
452
|
+
export interface StationFace { name: string; description?: string; video?: boolean }
|
|
453
|
+
|
|
454
|
+
export function stationHead(station: StationFace, slug: string, base: string): string {
|
|
455
|
+
const url = `${base}/radio/${slug}`;
|
|
456
|
+
const description = station.description || `${station.name}, live now. Free to listen; tap the artist or album on the air to see more.`;
|
|
457
|
+
return `<title>${escape(station.name)} · live.streaming</title>
|
|
458
|
+
<meta name="description" content="${escape(description)}" />
|
|
459
|
+
<link rel="canonical" href="${escape(url)}" />
|
|
460
|
+
<meta property="og:title" content="${escape(station.name)}" />
|
|
461
|
+
<meta property="og:description" content="${escape(description)}" />
|
|
462
|
+
<meta property="og:url" content="${escape(url)}" />
|
|
463
|
+
<meta property="og:type" content="${station.video ? "video.tv_show" : "music.radio_station"}" />
|
|
464
|
+
<meta property="og:site_name" content="live.streaming" />
|
|
465
|
+
<meta name="twitter:card" content="summary" />
|
|
466
|
+
<style>${NAV_STYLE}</style>`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
|
|
470
|
+
export function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string): string {
|
|
471
|
+
return html
|
|
472
|
+
.replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base))
|
|
473
|
+
.replace(/\s*<meta name="robots" content="noindex"\s*\/?>/, "")
|
|
474
|
+
.replace(/<body>/, `<body>\n${nav}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// --- the guide's peers -----------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
/** A station another server lists on this guide (a guide peer), as /radio reaches it. */
|
|
480
|
+
export interface PeerRadio {
|
|
481
|
+
origin: string;
|
|
482
|
+
id: string;
|
|
483
|
+
token: string;
|
|
484
|
+
name: string;
|
|
485
|
+
kind: "audio" | "video";
|
|
486
|
+
group: string;
|
|
487
|
+
state: string;
|
|
488
|
+
nowPlaying: { title: string; artist: string } | null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Peer stations by name, after this server's own (which keep theirs). Only a
|
|
493
|
+
* peer's own station link (its origin, /station/<id>?t=) is taken; the token
|
|
494
|
+
* stays on this server, which asks the peer for the listener.
|
|
495
|
+
*/
|
|
496
|
+
export function radioPeers(own: Iterable<string>, entries: Array<{ id?: string; name: string; kind: "audio" | "video"; group?: string; state: string; page: string; nowPlaying: { title: string; artist: string } | null }>): Map<string, PeerRadio> {
|
|
497
|
+
const taken = new Set(own);
|
|
498
|
+
const out = new Map<string, PeerRadio>();
|
|
499
|
+
for (const entry of entries) {
|
|
500
|
+
let url: URL;
|
|
501
|
+
try { url = new URL(entry.page); } catch { continue; }
|
|
502
|
+
const id = /^\/station\/([a-z0-9]{10})\/?$/.exec(url.pathname)?.[1] ?? "";
|
|
503
|
+
const token = url.searchParams.get("t") ?? "";
|
|
504
|
+
if (url.protocol !== "https:" || !id || !token || (entry.id && entry.id !== id)) continue;
|
|
505
|
+
let base = stationSlug(entry.name);
|
|
506
|
+
if (RESERVED.has(base) || /^[a-z0-9]{10}$/.test(base)) base = `${base}-radio`;
|
|
507
|
+
let slug = base;
|
|
508
|
+
for (let n = 2; taken.has(slug); n += 1) slug = `${base}-${n}`;
|
|
509
|
+
taken.add(slug);
|
|
510
|
+
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 });
|
|
511
|
+
}
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
|
|
9
|
+
/** Letters NFKD does not take apart. */
|
|
10
|
+
const SPELLED: Record<string, string> = { þ: "th", ð: "d", ø: "o", æ: "ae", œ: "oe", ß: "ss", đ: "d", ł: "l", ı: "i", "&": " and " };
|
|
11
|
+
|
|
12
|
+
/** Lower-case ASCII words joined by "-", at most `max` characters, cut at a word. */
|
|
13
|
+
export function slugify(text: string, max = 60): string {
|
|
14
|
+
const plain = String(text ?? "")
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[þðøæœßđłı&]/g, (one) => SPELLED[one] ?? one)
|
|
17
|
+
.normalize("NFKD")
|
|
18
|
+
.replace(/[̀-ͯ]/g, "")
|
|
19
|
+
.replace(/['’`]/g, "")
|
|
20
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
21
|
+
.replace(/^-+|-+$/g, "");
|
|
22
|
+
if (plain.length <= max) return plain;
|
|
23
|
+
const cut = plain.slice(0, max + 1);
|
|
24
|
+
const at = cut.lastIndexOf("-");
|
|
25
|
+
return (at > 8 ? cut.slice(0, at) : plain.slice(0, max)).replace(/-+$/, "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A station's name as a path: "Profullstack AI · Metal" is ai-metal, "Roaring
|
|
30
|
+
* Twenties: Jazz, Blues & Dance" is roaring-twenties (what comes before a
|
|
31
|
+
* colon, when that is two words or more), "Public Domain Classical" is
|
|
32
|
+
* public-domain-classical.
|
|
33
|
+
*/
|
|
34
|
+
export function stationSlug(name: string): string {
|
|
35
|
+
const text = String(name ?? "").trim();
|
|
36
|
+
const ai = /^.{0,60}?\bAI\s*[·:|•\-–—]\s*(.+)$/i.exec(text);
|
|
37
|
+
if (ai) return slugify(`ai ${ai[1]}`, 48) || "station";
|
|
38
|
+
const colon = /^([^:]{3,}):\s*\S/.exec(text);
|
|
39
|
+
if (colon && colon[1]!.trim().split(/\s+/).length >= 2) return slugify(colon[1]!, 48) || "station";
|
|
40
|
+
return slugify(text, 48) || "station";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const artistSlug = (artist: string): string => slugify(String(artist ?? "").split(/\s*;\s*/)[0] ?? "", 60);
|
|
44
|
+
|
|
45
|
+
export const albumSlug = (artist: string, album: string): string => slugify(`${artistSlug(artist)} ${album}`, 90);
|