nixamp 0.58.0 → 0.59.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 (69) hide show
  1. package/dist/ai-label.d.ts +6 -0
  2. package/dist/ai-label.js +1 -0
  3. package/dist/channels.d.ts +20 -0
  4. package/dist/channels.js +38 -0
  5. package/dist/follow-page.js +1 -1
  6. package/dist/label-routes.js +3 -1
  7. package/dist/music-hub.d.ts +97 -0
  8. package/dist/music-hub.js +215 -0
  9. package/dist/music-store.d.ts +2 -0
  10. package/dist/music-store.js +4 -2
  11. package/dist/radio-pages.js +1 -1
  12. package/dist/server.d.ts +9 -0
  13. package/dist/server.js +92 -6
  14. package/dist/station-routes.d.ts +9 -1
  15. package/dist/station-routes.js +63 -3
  16. package/dist/store-routes.js +5 -4
  17. package/package.json +1 -1
  18. package/src/ai-label.ts +3 -0
  19. package/src/channels.ts +43 -0
  20. package/src/follow-page.ts +1 -1
  21. package/src/label-routes.ts +3 -1
  22. package/src/music-hub.ts +210 -0
  23. package/src/music-store.ts +6 -2
  24. package/src/radio-pages.ts +1 -1
  25. package/src/server.ts +85 -6
  26. package/src/station-routes.ts +69 -4
  27. package/src/store-routes.ts +5 -4
  28. package/web/dist/assets/{ar-C8G_vLmh.js → ar-BGz59p0i.js} +1 -1
  29. package/web/dist/assets/chunk-Q2XQMA4B-BaoaSqsD.js +1 -0
  30. package/web/dist/assets/{cs-7bYMjFS2.js → cs-DKsmGQfn.js} +1 -1
  31. package/web/dist/assets/{da-DHY5Csp_.js → da-VBBFYTgq.js} +1 -1
  32. package/web/dist/assets/{de-YNBoxnLa.js → de-DocjfCJV.js} +1 -1
  33. package/web/dist/assets/{es-9F9kBzzh.js → es-ScAWdKuT.js} +1 -1
  34. package/web/dist/assets/{fi-BqpzsfBb.js → fi-BCjOujwG.js} +1 -1
  35. package/web/dist/assets/{fr-BKQXRwFk.js → fr-EO9zGot_.js} +1 -1
  36. package/web/dist/assets/{hi-CGNIGyR-.js → hi-DOv37S1y.js} +1 -1
  37. package/web/dist/assets/{hls-OR5FY6YP-CxI-FwpW.js → hls-OR5FY6YP-B-70I17r.js} +1 -1
  38. package/web/dist/assets/{hu-anYPYX8d.js → hu-BMxwsXKm.js} +1 -1
  39. package/web/dist/assets/{id-BW3GasRn.js → id-DDfdVuoK.js} +1 -1
  40. package/web/dist/assets/{it-C7w2DZ3R.js → it-BbbFjt9-.js} +1 -1
  41. package/web/dist/assets/{main-YkIgU0VD.js → main-BMYkP1N_.js} +2 -2
  42. package/web/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  43. package/web/dist/assets/{mpegts-7CN75AI2-D-6xFZI-.js → mpegts-7CN75AI2-D-bdbDNw.js} +1 -1
  44. package/web/dist/assets/{mpegts-D2ZKeEby.js → mpegts-C_-pZIIG.js} +1 -1
  45. package/web/dist/assets/music-CmI2KJ1Y.js +1 -0
  46. package/web/dist/assets/music-jouma3VD.css +1 -0
  47. package/web/dist/assets/{nl-BNrkTQI2.js → nl-CO-MkYd-.js} +1 -1
  48. package/web/dist/assets/radio-DmVc3wpH.js +1 -0
  49. package/web/dist/assets/{ru-CnKOo1uk.js → ru-C48YQfIR.js} +1 -1
  50. package/web/dist/assets/{station-CSnjPeod.css → station-6b_smJHe.css} +1 -1
  51. package/web/dist/assets/station-DtY1LOm5.js +3 -0
  52. package/web/dist/assets/{sv-BsknKdtx.js → sv--HR26-tY.js} +1 -1
  53. package/web/dist/assets/{uk-u_igTh6D.js → uk-p55LkwrK.js} +1 -1
  54. package/web/dist/assets/{vi-DH0ApTua.js → vi-DPS8isVc.js} +1 -1
  55. package/web/dist/assets/vod-BzQvQVLd.js +1 -0
  56. package/web/dist/assets/watch-B9LW8D1C.js +1 -0
  57. package/web/dist/assets/{zh-Bcnk2nIV.js → zh-Dw9Ji9b2.js} +1 -1
  58. package/web/dist/index.html +4 -3
  59. package/web/dist/music.html +43 -0
  60. package/web/dist/radio.html +3 -2
  61. package/web/dist/station.html +4 -3
  62. package/web/dist/sw.js +32 -29
  63. package/web/dist/vod.html +4 -3
  64. package/web/dist/watch.html +4 -3
  65. package/web/dist/assets/chunk-Q2XQMA4B-PTuZPMen.js +0 -1
  66. package/web/dist/assets/radio-DV3h4YZS.js +0 -1
  67. package/web/dist/assets/station-M7G4NiE3.js +0 -3
  68. package/web/dist/assets/vod-Br9Ooz6-.js +0 -1
  69. package/web/dist/assets/watch-Brz-RgjN.js +0 -1
@@ -209,6 +209,12 @@ export interface AiLabelDeps {
209
209
  }) => Promise<void>;
210
210
  /** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
211
211
  image?: (prompt: string) => Promise<Buffer | null>;
212
+ /** Tells the world an album landed (nixamp.com tells the artist's followers); never awaited. */
213
+ announce?: (release: {
214
+ artist: string;
215
+ title: string;
216
+ kind: "album" | "song";
217
+ }) => void;
212
218
  /** The day's pick (artist, genre, new or not); Math.random by default. */
213
219
  random?: () => number;
214
220
  now?: () => number;
package/dist/ai-label.js CHANGED
@@ -1273,6 +1273,7 @@ export class AiLabel {
1273
1273
  if (album.songs.every((one) => one.file)) {
1274
1274
  album.doneAt = this.now();
1275
1275
  this.log(`${artist.name} released "${album.title}"`);
1276
+ this.deps.announce?.({ artist: artist.key, title: album.title, kind: "album" });
1276
1277
  void this.ensureProfile(artist).catch(() => undefined);
1277
1278
  }
1278
1279
  this.save();
@@ -215,6 +215,8 @@ export interface ChannelOptions {
215
215
  /** Whether a pulled source is read here and piped to ffmpeg. See `Channels.setThrough`. */
216
216
  through?: ThroughProvider;
217
217
  }
218
+ /** The byte count a chunk began at, for one a list channel put out; undefined for any other. */
219
+ export declare function chunkStart(chunk: Buffer): number | undefined;
218
220
  /**
219
221
  * One live source, and its audience.
220
222
  *
@@ -257,6 +259,20 @@ export declare class Channel {
257
259
  } | null;
258
260
  /** Whether listeners would get sound (and, for a picture, its opening) if they joined now. */
259
261
  get flowing(): boolean;
262
+ /**
263
+ * What was going out at this point of the byte count: the list's file (""
264
+ * in a break) and how far into it, with its tags. Null for a byte this
265
+ * channel no longer knows, or for a channel that is not a radio list.
266
+ */
267
+ placeAt(bytes: number): {
268
+ file: string;
269
+ position: number;
270
+ tags?: {
271
+ title: string;
272
+ artist: string;
273
+ album: string;
274
+ };
275
+ } | null;
260
276
  /** An entry's tags, as read when it was put on. */
261
277
  tagsOf(file: string): {
262
278
  title: string;
@@ -290,6 +306,8 @@ export declare class Channel {
290
306
  private breakOn;
291
307
  /** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
292
308
  private music;
309
+ /** The last few dials of a radio list, newest last, for placeAt(). */
310
+ private dials;
293
311
  private musicBytes;
294
312
  private fragmentClock;
295
313
  private fragmentTimes;
@@ -543,6 +561,8 @@ export declare class Channels {
543
561
  setThrough(provider: ThroughProvider | null): void;
544
562
  /** The kind of a channel, for a relay to say what it is carrying. */
545
563
  kindOf(id: string): "audio" | "video" | undefined;
564
+ /** What a radio list channel was putting out at a point of its byte count (Channel.placeAt). */
565
+ placeAt(id: string, bytes: number): ReturnType<Channel["placeAt"]>;
546
566
  /** An entry's tags on a channel playing a list of files. */
547
567
  tagsOf(id: string, file: string): {
548
568
  title: string;
package/dist/channels.js CHANGED
@@ -122,6 +122,16 @@ export function cleanId(value, fallback = "main") {
122
122
  const id = value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
123
123
  return id.slice(0, 40) || fallback;
124
124
  }
125
+ /**
126
+ * Where each chunk a channel put out begins in its byte count (ChannelInfo.bytes),
127
+ * so a listener's own bytes can be laid onto the channel's: a backlog, a
128
+ * skipped break and a redial all keep the chunk objects they were sent as.
129
+ */
130
+ const chunkStarts = new WeakMap();
131
+ /** The byte count a chunk began at, for one a list channel put out; undefined for any other. */
132
+ export function chunkStart(chunk) {
133
+ return chunkStarts.get(chunk);
134
+ }
125
135
  /**
126
136
  * One live source, and its audience.
127
137
  *
@@ -179,6 +189,20 @@ export class Channel {
179
189
  get flowing() {
180
190
  return this.info.bytes > 0 && (!this.fragments || this.fragments.ready);
181
191
  }
192
+ /**
193
+ * What was going out at this point of the byte count: the list's file (""
194
+ * in a break) and how far into it, with its tags. Null for a byte this
195
+ * channel no longer knows, or for a channel that is not a radio list.
196
+ */
197
+ placeAt(bytes) {
198
+ const dial = this.dials.findLast((one) => one.base <= bytes);
199
+ if (!dial)
200
+ return null;
201
+ const place = dial.input.place(((bytes - dial.base) * 8) / (dial.kbps * 1000));
202
+ const file = place.at >= 0 ? place.list[place.at] ?? "" : "";
203
+ const tags = file ? dial.input.tags.get(file) : undefined;
204
+ return { file, position: place.position, ...(tags ? { tags } : {}) };
205
+ }
182
206
  /** An entry's tags, as read when it was put on. */
183
207
  tagsOf(file) {
184
208
  return this.playlistInput?.tags.get(file);
@@ -210,6 +234,8 @@ export class Channel {
210
234
  breakOn = false;
211
235
  /** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
212
236
  music = [];
237
+ /** The last few dials of a radio list, newest last, for placeAt(). */
238
+ dials = [];
213
239
  musicBytes = 0;
214
240
  fragmentClock = new FragmentClock();
215
241
  fragmentTimes = new WeakMap();
@@ -452,9 +478,17 @@ export class Channel {
452
478
  if (!sent)
453
479
  this.info.error = undefined;
454
480
  sent = true;
481
+ const at = this.info.bytes;
455
482
  this.info.bytes += chunk.byteLength;
456
483
  this.rearm(child);
457
484
  if (kbps > 0 && playlistInput) {
485
+ // Constant-bitrate MP3: a byte of this dial is exactly a time on its list's clock.
486
+ if (written === 0) {
487
+ this.dials.push({ base: at, input: playlistInput, kbps });
488
+ if (this.dials.length > 8)
489
+ this.dials.shift();
490
+ }
491
+ chunkStarts.set(chunk, at);
458
492
  this.breakOn = playlistInput.place((written * 8) / (kbps * 1000)).at === -1;
459
493
  written += chunk.byteLength;
460
494
  }
@@ -1291,6 +1325,10 @@ export class Channels {
1291
1325
  kindOf(id) {
1292
1326
  return this.open.get(id)?.info.kind;
1293
1327
  }
1328
+ /** What a radio list channel was putting out at a point of its byte count (Channel.placeAt). */
1329
+ placeAt(id, bytes) {
1330
+ return this.open.get(id)?.placeAt(bytes) ?? null;
1331
+ }
1294
1332
  /** An entry's tags on a channel playing a list of files. */
1295
1333
  tagsOf(id, file) {
1296
1334
  return this.open.get(id)?.tagsOf(file);
@@ -96,7 +96,7 @@ export async function handleFollowPage(request, response, path, url, ctx) {
96
96
  if (!me) {
97
97
  send(response, 200, page(`Follow ${target.name}`, `<h1>Follow ${escape(target.name)}</h1>
98
98
  <p class="by">Sign in to nixamp.com to follow the artists you hear on the radio.</p>
99
- <a class="go" href="/radio#music">Sign in to nixamp.com</a>
99
+ <a class="go" href="/music/me">Sign in to nixamp.com</a>
100
100
  <small>Signed in, open this page again (or press Follow on the artist's page) to follow.</small>${back}`), method);
101
101
  return true;
102
102
  }
@@ -10,6 +10,7 @@ import { createHash } from "node:crypto";
10
10
  import { createReadStream, statSync } from "node:fs";
11
11
  import { ROSTER } from "./ai-label.js";
12
12
  import { covers, PRICES } from "./music-store.js";
13
+ import { serverId } from "./music-hub.js";
13
14
  import { writeZip, zipSize } from "./zip-stream.js";
14
15
  import { StationError } from "./stations.js";
15
16
  import { adCsp, withPageAd } from "./station-guide.js";
@@ -25,7 +26,8 @@ export function isPublicLabelPath(path) {
25
26
  }
26
27
  /** nixamp.com's page that sells this: a song, an album or everything by the artist. */
27
28
  export function buyLink(site, server, kind, artist, album = "", n = 0) {
28
- const query = new URLSearchParams({ server, kind, artist, ...(kind !== "artist" ? { album } : {}), ...(kind === "song" ? { n: String(n) } : {}) });
29
+ // The server goes by its opaque id (music-hub.ts): no link names the machine the music is on.
30
+ const query = new URLSearchParams({ server: /^https:\/\//.test(server) ? serverId(server) : server, kind, artist, ...(kind !== "artist" ? { album } : {}), ...(kind === "song" ? { n: String(n) } : {}) });
29
31
  return `${site}/buy?${query.toString()}`;
30
32
  }
31
33
  async function jsonBody(ctx, request) {
@@ -0,0 +1,97 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ /** A server's opaque id: the first 10 hex of the SHA-256 of its https origin. Both sides can work it out. */
3
+ export declare function serverId(base: string): string;
4
+ /** The followable id of an artist, as /follow and live.streaming's /radio use it. */
5
+ export declare function artistFollowId(name: string): string;
6
+ interface LabelArtist {
7
+ key: string;
8
+ name: string;
9
+ genre: string;
10
+ bio: string;
11
+ members?: Array<{
12
+ slug: string;
13
+ name: string;
14
+ role: string;
15
+ bio: string;
16
+ }>;
17
+ albums: Array<{
18
+ key: string;
19
+ title: string;
20
+ year: number;
21
+ concept: string;
22
+ cover: boolean;
23
+ complete: boolean;
24
+ songs: Array<{
25
+ n: number;
26
+ title: string;
27
+ seconds: number;
28
+ lyrics?: string;
29
+ }>;
30
+ }>;
31
+ }
32
+ export interface HubStation {
33
+ server: string;
34
+ id: string;
35
+ name: string;
36
+ state: string;
37
+ listen: string;
38
+ token: string;
39
+ nowPlaying: {
40
+ title: string;
41
+ artist: string;
42
+ } | null;
43
+ }
44
+ export interface HubArtist extends LabelArtist {
45
+ server: string;
46
+ follow: string;
47
+ }
48
+ export interface HubCatalogue {
49
+ holders: string[];
50
+ genres: Array<{
51
+ genre: string;
52
+ stations: HubStation[];
53
+ artists: HubArtist[];
54
+ }>;
55
+ /** Every AI station on the servers, by genre or not (lo-fi, the bands...). */
56
+ stations: HubStation[];
57
+ }
58
+ export interface MusicHubOptions {
59
+ /** The servers whose labels are gathered: the directory and the guide's peers. */
60
+ peers: () => string[];
61
+ fetcher?: typeof fetch;
62
+ now?: () => number;
63
+ }
64
+ export declare class MusicHub {
65
+ private readonly options;
66
+ private cache;
67
+ private bases;
68
+ constructor(options: MusicHubOptions);
69
+ private now;
70
+ /** The https origin behind an opaque id, among the servers known now. */
71
+ resolve(id: string): string;
72
+ /** The catalogue, gathered at most once a minute (a slow or gone server is left out, not waited on). */
73
+ catalogue(): Promise<HubCatalogue>;
74
+ private read;
75
+ private gather;
76
+ }
77
+ /**
78
+ * live.streaming's /radio/artist/<slug> and /radio/album/<slug> addresses, on
79
+ * nixamp.com: the /music page they name (radio-slug.ts's slugs), or "" when
80
+ * the catalogue has no such artist or album.
81
+ */
82
+ export declare function musicPageFor(hub: MusicHub, path: string): Promise<string>;
83
+ export declare function isMusicPath(path: string): boolean;
84
+ export interface MusicRoutesContext {
85
+ hub: MusicHub;
86
+ json: (response: ServerResponse, status: number, body: unknown) => void;
87
+ readBody: (request: IncomingMessage) => Promise<string>;
88
+ /** The page shell (web/music.html), or null when the web build is absent. */
89
+ page: () => string | null;
90
+ /** A server says something of its label landed; null when nobody may say so. */
91
+ released?: (request: IncomingMessage, body: Record<string, unknown>) => Promise<{
92
+ status: number;
93
+ body: unknown;
94
+ }>;
95
+ }
96
+ export declare function handleMusicRoutes(request: IncomingMessage, response: ServerResponse, path: string, ctx: MusicRoutesContext): Promise<boolean>;
97
+ export {};
@@ -0,0 +1,215 @@
1
+ /**
2
+ * nixamp.com/music: every AI label's catalogue in one place.
3
+ *
4
+ * The music lives on the servers that make it (server1 and any other nixamp
5
+ * with a label); nixamp.com is where people listen, browse, follow and buy.
6
+ * This gathers each server's /api/v1/label and its public stations, gives
7
+ * every server a short opaque id (so no page or link names the machine), and
8
+ * hands covers, portraits and words through itself.
9
+ *
10
+ * GET /api/v1/music the merged catalogue
11
+ * GET /api/v1/music/file/<server>/<path> a cover, portrait, profile or words, through nixamp.com
12
+ * POST /api/v1/music/released a server says an album or song landed: its followers hear
13
+ */
14
+ import { createHash } from "node:crypto";
15
+ /** A server's opaque id: the first 10 hex of the SHA-256 of its https origin. Both sides can work it out. */
16
+ export function serverId(base) {
17
+ return createHash("sha256").update(base.replace(/\/+$/, "")).digest("hex").slice(0, 10);
18
+ }
19
+ /** The followable id of an artist, as /follow and live.streaming's /radio use it. */
20
+ export function artistFollowId(name) {
21
+ const slug = name.split(/\s*;\s*/)[0].normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "artist";
22
+ return `artist:${slug}`;
23
+ }
24
+ export class MusicHub {
25
+ options;
26
+ cache = null;
27
+ bases = new Map();
28
+ constructor(options) {
29
+ this.options = options;
30
+ }
31
+ now() {
32
+ return this.options.now?.() ?? Date.now();
33
+ }
34
+ /** The https origin behind an opaque id, among the servers known now. */
35
+ resolve(id) {
36
+ if (!/^[a-f0-9]{10}$/.test(id))
37
+ return "";
38
+ if (!this.bases.has(id))
39
+ for (const base of this.options.peers())
40
+ this.bases.set(serverId(base), base);
41
+ return this.bases.get(id) ?? "";
42
+ }
43
+ /** The catalogue, gathered at most once a minute (a slow or gone server is left out, not waited on). */
44
+ catalogue() {
45
+ if (this.cache && this.now() - this.cache.at < 60_000)
46
+ return this.cache.value;
47
+ const value = this.gather().catch((error) => { this.cache = null; throw error; });
48
+ this.cache = { at: this.now(), value };
49
+ return value;
50
+ }
51
+ async read(url) {
52
+ try {
53
+ const response = await (this.options.fetcher ?? fetch)(url, { signal: AbortSignal.timeout(6_000), headers: { accept: "application/json" } });
54
+ return response.ok ? await response.json() : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ async gather() {
61
+ const peers = [...new Set(this.options.peers())];
62
+ const holders = new Set();
63
+ const genres = new Map();
64
+ const stations = [];
65
+ await Promise.all(peers.map(async (base) => {
66
+ const id = serverId(base);
67
+ this.bases.set(id, base);
68
+ const [label, guide] = await Promise.all([
69
+ this.read(`${base}/api/v1/label`),
70
+ this.read(`${base}/api/v1/stations/guide?local=1`),
71
+ ]);
72
+ // A station is heard through its own server; the page's token is its listen key.
73
+ const byId = new Map();
74
+ for (const one of guide?.stations ?? []) {
75
+ if (one.group && one.group !== "ai")
76
+ continue;
77
+ let page;
78
+ try {
79
+ page = new URL(one.page);
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ if (page.origin !== base)
85
+ continue;
86
+ const station = { server: id, id: one.id, name: one.name, state: one.state, listen: `${base}/api/v1/stations/${one.id}`, token: page.searchParams.get("t") ?? "", nowPlaying: one.nowPlaying };
87
+ byId.set(one.id, station);
88
+ stations.push(station);
89
+ }
90
+ if (!label)
91
+ return;
92
+ holders.add(label.holder);
93
+ for (const genre of label.genres) {
94
+ const slot = genres.get(genre.genre) ?? { genre: genre.genre, stations: [], artists: [] };
95
+ const station = byId.get(genre.station);
96
+ if (station)
97
+ slot.stations.push(station);
98
+ for (const artist of genre.artists)
99
+ slot.artists.push({ ...artist, server: id, follow: artistFollowId(artist.name) });
100
+ genres.set(genre.genre, slot);
101
+ }
102
+ }));
103
+ return {
104
+ holders: [...holders],
105
+ genres: [...genres.values()].sort((a, b) => a.genre.localeCompare(b.genre)),
106
+ stations: stations.sort((a, b) => a.name.localeCompare(b.name)),
107
+ };
108
+ }
109
+ }
110
+ const FILE = /^\/api\/v1\/music\/file\/([a-f0-9]{10})\/([a-f0-9]{10}\/(?:[a-f0-9]{8}\/(?:\d{1,3}\.txt|cover\.jpg)|artist\.md|members\/[a-z0-9-]{1,40}\.(?:md|jpg)))$/;
111
+ const slugOf = (text, max) => text.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, max);
112
+ /**
113
+ * live.streaming's /radio/artist/<slug> and /radio/album/<slug> addresses, on
114
+ * nixamp.com: the /music page they name (radio-slug.ts's slugs), or "" when
115
+ * the catalogue has no such artist or album.
116
+ */
117
+ export async function musicPageFor(hub, path) {
118
+ const match = /^\/radio\/(artist|album)\/([a-z0-9-]{1,100})\/?$/.exec(path);
119
+ if (!match)
120
+ return "";
121
+ const catalogue = await hub.catalogue();
122
+ const artists = catalogue.genres.flatMap((one) => one.artists);
123
+ if (match[1] === "artist") {
124
+ const artist = artists.find((one) => one.follow === `artist:${match[2]}`);
125
+ return artist ? `/music/artist/${artist.server}/${artist.key}` : "";
126
+ }
127
+ for (const artist of artists) {
128
+ const album = artist.albums.find((one) => slugOf(`${artist.follow.slice(7)} ${one.title}`, 90) === match[2]);
129
+ if (album)
130
+ return `/music/album/${artist.server}/${artist.key}/${album.key}`;
131
+ }
132
+ return "";
133
+ }
134
+ export function isMusicPath(path) {
135
+ return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released";
136
+ }
137
+ export async function handleMusicRoutes(request, response, path, ctx) {
138
+ if (!isMusicPath(path))
139
+ return false;
140
+ const method = request.method ?? "GET";
141
+ if (path === "/api/v1/music/released") {
142
+ if (method !== "POST" || !ctx.released) {
143
+ ctx.json(response, 405, { error: "not like that" });
144
+ return true;
145
+ }
146
+ let body = {};
147
+ try {
148
+ body = JSON.parse(await ctx.readBody(request));
149
+ }
150
+ catch {
151
+ ctx.json(response, 400, { error: "the body is not JSON" });
152
+ return true;
153
+ }
154
+ const answer = await ctx.released(request, body);
155
+ ctx.json(response, answer.status, answer.body);
156
+ return true;
157
+ }
158
+ if (method !== "GET" && method !== "HEAD") {
159
+ ctx.json(response, 405, { error: "not like that" });
160
+ return true;
161
+ }
162
+ if (path === "/api/v1/music") {
163
+ ctx.json(response, 200, await ctx.hub.catalogue());
164
+ return true;
165
+ }
166
+ const file = FILE.exec(path);
167
+ if (file) {
168
+ const base = ctx.hub.resolve(file[1]);
169
+ if (!base) {
170
+ ctx.json(response, 404, { error: "no such server" });
171
+ return true;
172
+ }
173
+ try {
174
+ const upstream = await fetch(`${base}/api/v1/label/${file[2]}`, { signal: AbortSignal.timeout(15_000) });
175
+ if (!upstream.ok || !upstream.body) {
176
+ ctx.json(response, upstream.status === 404 ? 404 : 502, { error: "not there" });
177
+ return true;
178
+ }
179
+ response.writeHead(200, {
180
+ "content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
181
+ "cache-control": "public, max-age=3600",
182
+ ...(upstream.headers.get("content-length") ? { "content-length": upstream.headers.get("content-length") } : {}),
183
+ });
184
+ if (method === "HEAD") {
185
+ response.end();
186
+ return true;
187
+ }
188
+ const reader = upstream.body.getReader();
189
+ for (;;) {
190
+ const { done, value } = await reader.read();
191
+ if (done)
192
+ break;
193
+ if (!response.write(value))
194
+ await new Promise((resolve) => response.once("drain", resolve));
195
+ }
196
+ response.end();
197
+ }
198
+ catch {
199
+ if (!response.headersSent)
200
+ ctx.json(response, 502, { error: "the server holding it did not answer" });
201
+ else
202
+ response.destroy();
203
+ }
204
+ return true;
205
+ }
206
+ // /music and everything under it is the app; it reads /api/v1/music.
207
+ const page = ctx.page();
208
+ if (!page) {
209
+ ctx.json(response, 404, { error: "the music app is not built here" });
210
+ return true;
211
+ }
212
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
213
+ response.end(method === "HEAD" ? undefined : page);
214
+ return true;
215
+ }
@@ -63,6 +63,8 @@ export interface MusicStoreOptions {
63
63
  secret: string;
64
64
  /** The servers whose catalogues may be sold here (the directory and the guide's peers). */
65
65
  servers: () => string[];
66
+ /** The https origin behind an opaque server id, when nixamp.com keeps a hub. */
67
+ resolveServer?: (id: string) => string;
66
68
  fetcher?: typeof fetch;
67
69
  now?: () => number;
68
70
  log?: (line: string) => void;
@@ -151,7 +151,9 @@ export class MusicStore {
151
151
  }
152
152
  /** What is for sale at this address, with its name and price; refused when there is no such thing. */
153
153
  async describe(input) {
154
- const server = serverBase(input["server"]);
154
+ // An opaque server id (music-hub.ts), or an https origin.
155
+ const asked = String(input["server"] ?? "");
156
+ const server = /^[a-f0-9]{10}$/.test(asked) ? (this.options.resolveServer?.(asked) ?? "") : serverBase(asked);
155
157
  // A server in the directory or the guide's peers, or one named under nixamp.com (whose names
156
158
  // and certificates nixamp.com issues): the directory empties on every restart of nixamp.com.
157
159
  const named = server ? new URL(server).hostname.endsWith(".nixamp.com") : false;
@@ -240,7 +242,7 @@ export class MusicStore {
240
242
  await this.mail(purchase.email, `Your music: ${purchase.title}`, [
241
243
  `Thank you. "${purchase.title}" by ${purchase.artistName} is yours: MP3s with the cover art and the lyrics, in one zip.`,
242
244
  `Download it, now or any time: ${this.link(purchase.id, purchase.token)}`,
243
- `Sign in to nixamp.com with ${purchase.email} and everything you have bought is under My music on ${this.options.site}/radio#music, to play or download again.`,
245
+ `Sign in to nixamp.com with ${purchase.email} and everything you have bought is under My music on ${this.options.site}/music/me, to play or download again.`,
244
246
  ]);
245
247
  return purchase;
246
248
  }
@@ -21,7 +21,7 @@
21
21
  import { createHash } from "node:crypto";
22
22
  import { albumSlug, artistSlug, stationSlug } from "./radio-slug.js";
23
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)$/;
24
+ export const RADIO_SUB = /^\/radio\/([a-z0-9-]{1,60})\/(stream|state|listen\.m3u|events|now|art|lyrics|clock)$/;
25
25
  export const RADIO_STATION = /^\/radio\/([a-z0-9-]{1,60})\/?$/;
26
26
  export const RADIO_ARTIST = /^\/radio\/artist\/([a-z0-9-]{1,90})\/?$/;
27
27
  export const RADIO_ALBUM = /^\/radio\/album\/([a-z0-9-]{1,100})(\/cover\.jpg)?\/?$/;
package/dist/server.d.ts CHANGED
@@ -26,6 +26,7 @@ import { ListenerStore } from "./library-user.ts";
26
26
  import { Importer } from "./handoff.ts";
27
27
  import { AiLabel } from "./ai-label.ts";
28
28
  import { MusicStore } from "./music-store.ts";
29
+ import { MusicHub } from "./music-hub.ts";
29
30
  import { AiRadio } from "./ai-radio.ts";
30
31
  import { ListenerShares } from "./listener-shares.ts";
31
32
  import { Support, SupportSubscriptions } from "./support.ts";
@@ -833,6 +834,14 @@ export interface HandlerOptions {
833
834
  coinpayWebhookSecret?: string;
834
835
  /** The AI label's songs for sale, nixamp.com only (music-store.ts). */
835
836
  musicStore?: MusicStore;
837
+ /** nixamp.com/music: every server's label in one app (music-hub.ts). */
838
+ music?: {
839
+ hub: MusicHub;
840
+ released: (request: IncomingMessage, body: Record<string, unknown>) => Promise<{
841
+ status: number;
842
+ body: unknown;
843
+ }>;
844
+ };
836
845
  sharedTranslations?: SharedTranslations;
837
846
  /** Translation: texts in another language, by a model here. The same optional library. */
838
847
  translator?: Translator;