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.
- package/dist/ai-label.d.ts +6 -0
- package/dist/ai-label.js +1 -0
- package/dist/channels.d.ts +20 -0
- package/dist/channels.js +38 -0
- package/dist/follow-page.js +1 -1
- package/dist/label-routes.js +3 -1
- package/dist/music-hub.d.ts +97 -0
- package/dist/music-hub.js +215 -0
- package/dist/music-store.d.ts +2 -0
- package/dist/music-store.js +4 -2
- package/dist/radio-pages.js +1 -1
- package/dist/server.d.ts +9 -0
- package/dist/server.js +92 -6
- package/dist/station-routes.d.ts +9 -1
- package/dist/station-routes.js +63 -3
- package/dist/store-routes.js +5 -4
- package/package.json +1 -1
- package/src/ai-label.ts +3 -0
- package/src/channels.ts +43 -0
- package/src/follow-page.ts +1 -1
- package/src/label-routes.ts +3 -1
- package/src/music-hub.ts +210 -0
- package/src/music-store.ts +6 -2
- package/src/radio-pages.ts +1 -1
- package/src/server.ts +85 -6
- package/src/station-routes.ts +69 -4
- package/src/store-routes.ts +5 -4
- package/web/dist/assets/{ar-C8G_vLmh.js → ar-BGz59p0i.js} +1 -1
- package/web/dist/assets/chunk-Q2XQMA4B-BaoaSqsD.js +1 -0
- package/web/dist/assets/{cs-7bYMjFS2.js → cs-DKsmGQfn.js} +1 -1
- package/web/dist/assets/{da-DHY5Csp_.js → da-VBBFYTgq.js} +1 -1
- package/web/dist/assets/{de-YNBoxnLa.js → de-DocjfCJV.js} +1 -1
- package/web/dist/assets/{es-9F9kBzzh.js → es-ScAWdKuT.js} +1 -1
- package/web/dist/assets/{fi-BqpzsfBb.js → fi-BCjOujwG.js} +1 -1
- package/web/dist/assets/{fr-BKQXRwFk.js → fr-EO9zGot_.js} +1 -1
- package/web/dist/assets/{hi-CGNIGyR-.js → hi-DOv37S1y.js} +1 -1
- package/web/dist/assets/{hls-OR5FY6YP-CxI-FwpW.js → hls-OR5FY6YP-B-70I17r.js} +1 -1
- package/web/dist/assets/{hu-anYPYX8d.js → hu-BMxwsXKm.js} +1 -1
- package/web/dist/assets/{id-BW3GasRn.js → id-DDfdVuoK.js} +1 -1
- package/web/dist/assets/{it-C7w2DZ3R.js → it-BbbFjt9-.js} +1 -1
- package/web/dist/assets/{main-YkIgU0VD.js → main-BMYkP1N_.js} +2 -2
- package/web/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/web/dist/assets/{mpegts-7CN75AI2-D-6xFZI-.js → mpegts-7CN75AI2-D-bdbDNw.js} +1 -1
- package/web/dist/assets/{mpegts-D2ZKeEby.js → mpegts-C_-pZIIG.js} +1 -1
- package/web/dist/assets/music-CmI2KJ1Y.js +1 -0
- package/web/dist/assets/music-jouma3VD.css +1 -0
- package/web/dist/assets/{nl-BNrkTQI2.js → nl-CO-MkYd-.js} +1 -1
- package/web/dist/assets/radio-DmVc3wpH.js +1 -0
- package/web/dist/assets/{ru-CnKOo1uk.js → ru-C48YQfIR.js} +1 -1
- package/web/dist/assets/{station-CSnjPeod.css → station-6b_smJHe.css} +1 -1
- package/web/dist/assets/station-DtY1LOm5.js +3 -0
- package/web/dist/assets/{sv-BsknKdtx.js → sv--HR26-tY.js} +1 -1
- package/web/dist/assets/{uk-u_igTh6D.js → uk-p55LkwrK.js} +1 -1
- package/web/dist/assets/{vi-DH0ApTua.js → vi-DPS8isVc.js} +1 -1
- package/web/dist/assets/vod-BzQvQVLd.js +1 -0
- package/web/dist/assets/watch-B9LW8D1C.js +1 -0
- package/web/dist/assets/{zh-Bcnk2nIV.js → zh-Dw9Ji9b2.js} +1 -1
- package/web/dist/index.html +4 -3
- package/web/dist/music.html +43 -0
- package/web/dist/radio.html +3 -2
- package/web/dist/station.html +4 -3
- package/web/dist/sw.js +32 -29
- package/web/dist/vod.html +4 -3
- package/web/dist/watch.html +4 -3
- package/web/dist/assets/chunk-Q2XQMA4B-PTuZPMen.js +0 -1
- package/web/dist/assets/radio-DV3h4YZS.js +0 -1
- package/web/dist/assets/station-M7G4NiE3.js +0 -3
- package/web/dist/assets/vod-Br9Ooz6-.js +0 -1
- package/web/dist/assets/watch-Brz-RgjN.js +0 -1
package/dist/ai-label.d.ts
CHANGED
|
@@ -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();
|
package/dist/channels.d.ts
CHANGED
|
@@ -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);
|
package/dist/follow-page.js
CHANGED
|
@@ -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="/
|
|
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
|
}
|
package/dist/label-routes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/music-store.d.ts
CHANGED
|
@@ -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;
|
package/dist/music-store.js
CHANGED
|
@@ -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
|
-
|
|
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}/
|
|
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
|
}
|
package/dist/radio-pages.js
CHANGED
|
@@ -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;
|