nixamp 0.48.1 → 0.50.1

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 (74) hide show
  1. package/dist/ai-keys.d.ts +1 -1
  2. package/dist/ai-keys.js +2 -0
  3. package/dist/ai-label.d.ts +239 -0
  4. package/dist/ai-label.js +774 -0
  5. package/dist/ai-radio.d.ts +11 -5
  6. package/dist/ai-radio.js +27 -11
  7. package/dist/ai-text.d.ts +2 -0
  8. package/dist/ai-text.js +4 -2
  9. package/dist/label-routes.d.ts +28 -0
  10. package/dist/label-routes.js +314 -0
  11. package/dist/music-store.d.ts +121 -0
  12. package/dist/music-store.js +289 -0
  13. package/dist/server.d.ts +6 -0
  14. package/dist/server.js +99 -11
  15. package/dist/station-cli.js +109 -0
  16. package/dist/station-guide.d.ts +1 -1
  17. package/dist/station-guide.js +5 -1
  18. package/dist/station-mcp.d.ts +247 -20
  19. package/dist/station-mcp.js +36 -0
  20. package/dist/station-usage.d.ts +1 -1
  21. package/dist/station-usage.js +15 -1
  22. package/dist/store-routes.d.ts +16 -0
  23. package/dist/store-routes.js +189 -0
  24. package/dist/support.d.ts +14 -0
  25. package/dist/support.js +41 -0
  26. package/dist/track-facts.d.ts +8 -0
  27. package/dist/zip-stream.d.ts +13 -0
  28. package/dist/zip-stream.js +119 -0
  29. package/package.json +1 -1
  30. package/src/ai-keys.ts +2 -0
  31. package/src/ai-label.ts +873 -0
  32. package/src/ai-radio.ts +30 -13
  33. package/src/ai-text.ts +6 -2
  34. package/src/label-routes.ts +336 -0
  35. package/src/music-store.ts +333 -0
  36. package/src/server.ts +91 -3
  37. package/src/station-cli.ts +102 -0
  38. package/src/station-guide.ts +5 -1
  39. package/src/station-mcp.ts +34 -0
  40. package/src/station-usage.ts +15 -1
  41. package/src/store-routes.ts +204 -0
  42. package/src/support.ts +39 -0
  43. package/src/track-facts.ts +2 -0
  44. package/src/zip-stream.ts +128 -0
  45. package/web/dist/assets/{ar-BvPTr_2x.js → ar-CC_uYRlC.js} +1 -1
  46. package/web/dist/assets/{cs-g4ii7Tkl.js → cs-k5C-jvfX.js} +1 -1
  47. package/web/dist/assets/{da-D9vYCFH7.js → da-CdBzU_fF.js} +1 -1
  48. package/web/dist/assets/{de-lYElbebQ.js → de-B-UWa0W7.js} +1 -1
  49. package/web/dist/assets/{es-CiDbfxFy.js → es-Dod8aCau.js} +1 -1
  50. package/web/dist/assets/{fi-BWlqMMWx.js → fi-C36bBPFW.js} +1 -1
  51. package/web/dist/assets/{fr-D2n5nXQv.js → fr-DWUxs2qv.js} +1 -1
  52. package/web/dist/assets/{hi-DM9Nh04K.js → hi-DjLqhfs8.js} +1 -1
  53. package/web/dist/assets/{hu-Bzb0X4rk.js → hu-CgsOlcq8.js} +1 -1
  54. package/web/dist/assets/{id-j0QazySW.js → id-ZA8yTWX9.js} +1 -1
  55. package/web/dist/assets/{it-BVvva3a6.js → it-DfFhS34g.js} +1 -1
  56. package/web/dist/assets/{main-DPfBhzsz.js → main-CjgQp9um.js} +2 -2
  57. package/web/dist/assets/{nl-CrJQ6obW.js → nl-C257i24U.js} +1 -1
  58. package/web/dist/assets/radio-CDUEoM0y.js +1 -0
  59. package/web/dist/assets/{ru-DFIjjKvG.js → ru--30I7BNC.js} +1 -1
  60. package/web/dist/assets/{station-BKmoJ0Sk.css → station-Be-z7jQ2.css} +1 -1
  61. package/web/dist/assets/{station-X8pffqCW.js → station-D769XVG0.js} +1 -1
  62. package/web/dist/assets/{sv-BO9QPL4K.js → sv-CxzBeXfk.js} +1 -1
  63. package/web/dist/assets/{uk-DwmG3Owv.js → uk-DXHpMk4i.js} +1 -1
  64. package/web/dist/assets/{vi-C-2EzgMe.js → vi-Dg2LT4x3.js} +1 -1
  65. package/web/dist/assets/{zh-BlnCenyZ.js → zh-D3pvejo6.js} +1 -1
  66. package/web/dist/index.html +1 -1
  67. package/web/dist/radio.html +44 -1
  68. package/web/dist/station.html +7 -2
  69. package/web/dist/sw.js +25 -25
  70. package/web/dist/vod.html +2 -2
  71. package/web/dist/watch.html +2 -2
  72. package/web/dist/assets/radio-DI0H8flN.js +0 -1
  73. /package/web/dist/assets/{vod-CdfPMa1u.js → vod-bM1DvPEx.js} +0 -0
  74. /package/web/dist/assets/{watch-hdfnhm3C.js → watch-BbhPO0HS.js} +0 -0
package/src/ai-radio.ts CHANGED
@@ -91,22 +91,29 @@ const BACKOFF_MS = [5 * 60_000, 10 * 60_000, 20 * 60_000, 40 * 60_000, 80 * 60_0
91
91
  export interface Composer {
92
92
  /** e.g. "elevenlabs/music_v1". */
93
93
  name: string;
94
- /** Writes an MP3 to `out`. Throws OutOfCredit when the account cannot pay. */
95
- compose(input: { prompt: string; seconds: number; vocals: boolean; out: string }): Promise<void>;
94
+ /** Writes an MP3 to `out`. Throws OutOfCredit when the account cannot pay. `lyrics` are the words to sing, when there are any. */
95
+ compose(input: { prompt: string; seconds: number; vocals: boolean; out: string; lyrics?: string }): Promise<void>;
96
96
  }
97
97
 
98
98
  export class OutOfCredit extends Error {}
99
99
 
100
- /** ElevenLabs Music: a paid plan's music may be broadcast. */
101
- export function elevenLabsComposer(key: string, fetcher: typeof fetch = fetch): Composer {
100
+ /** ElevenLabs' music models; NIXAMP_MUSIC_MODEL picks one, the newest by default. */
101
+ export const MUSIC_MODELS = ["music_v1", "music_v2", "music_v2_5"] as const;
102
+
103
+ /** ElevenLabs Music: a paid plan's music may be broadcast. Given lyrics, it sings those words. */
104
+ export function elevenLabsComposer(key: string, fetcher: typeof fetch = fetch, model = "music_v2_5"): Composer {
102
105
  return {
103
- name: "elevenlabs/music_v1",
104
- async compose({ prompt, seconds, vocals, out }) {
106
+ name: `elevenlabs/${model}`,
107
+ async compose({ prompt, seconds, vocals, out, lyrics }) {
108
+ const words = vocals && lyrics?.trim() ? lyrics.trim() : "";
105
109
  const ask = async (text: string): Promise<Response> => fetcher("https://api.elevenlabs.io/v1/music?output_format=mp3_44100_192", {
106
110
  method: "POST",
107
111
  signal: AbortSignal.timeout(10 * 60_000),
108
112
  headers: { "content-type": "application/json", "xi-api-key": key },
109
- body: JSON.stringify({ prompt: text, music_length_ms: Math.round(seconds * 1000), model_id: "music_v1", force_instrumental: !vocals }),
113
+ body: JSON.stringify({
114
+ prompt: text, music_length_ms: Math.round(seconds * 1000), model_id: model,
115
+ ...(words ? { lyrics_text: words } : { force_instrumental: !vocals }),
116
+ }),
110
117
  });
111
118
  let response = await ask(prompt);
112
119
  if (response.status === 400) {
@@ -132,16 +139,20 @@ export function elevenLabsComposer(key: string, fetcher: typeof fetch = fetch):
132
139
 
133
140
  /**
134
141
  * A model of the owner's own (MusicGen, ACE-Step...): NIXAMP_AI_MUSIC_COMMAND
135
- * is run with {prompt}, {seconds}, {vocals} and {out} replaced, each as one
136
- * argument, and must leave an MP3 at {out}.
142
+ * is run with {prompt}, {seconds}, {vocals}, {lyrics} (a file of the words to
143
+ * sing, "" when there are none) and {out} replaced, each as one argument, and
144
+ * must leave an MP3 at {out}.
137
145
  */
138
146
  export function commandComposer(template: string): Composer {
139
147
  const words = template.trim().split(/\s+/);
140
148
  return {
141
149
  name: `command/${words[0]?.split("/").pop() ?? "music"}`,
142
- compose: ({ prompt, seconds, vocals, out }) => new Promise((resolve, reject) => {
143
- const argv = words.map((one) => one.replace("{prompt}", prompt).replace("{seconds}", String(seconds)).replace("{vocals}", vocals ? "1" : "0").replace("{out}", out));
150
+ compose: ({ prompt, seconds, vocals, out, lyrics }) => new Promise((resolve, reject) => {
151
+ const lyricsFile = lyrics?.trim() ? `${out}.lyrics.txt` : "";
152
+ if (lyricsFile) writeFileSync(lyricsFile, `${lyrics!.trim()}\n`);
153
+ const argv = words.map((one) => one.replace("{prompt}", prompt).replace("{seconds}", String(seconds)).replace("{vocals}", vocals ? "1" : "0").replace("{lyrics}", lyricsFile).replace("{out}", out));
144
154
  execFile(argv[0]!, argv.slice(1), { timeout: 30 * 60_000, maxBuffer: 4 * 1024 * 1024 }, (error) => {
155
+ if (lyricsFile) rmSync(lyricsFile, { force: true });
145
156
  if (error) return reject(new Error(`the music command failed: ${error.message}`));
146
157
  try {
147
158
  if (statSync(out).size < 1024) return reject(new Error("the music command left no music"));
@@ -159,7 +170,8 @@ export function composerFromEnv(env: Record<string, string | undefined> = proces
159
170
  const command = (env["NIXAMP_AI_MUSIC_COMMAND"] ?? "").trim();
160
171
  if (command) return commandComposer(command);
161
172
  const key = aiKey("ELEVENLABS_API_KEY", env);
162
- return key ? elevenLabsComposer(key) : null;
173
+ const model = aiKey("NIXAMP_MUSIC_MODEL", env);
174
+ return key ? elevenLabsComposer(key, fetch, (MUSIC_MODELS as readonly string[]).includes(model) ? model : "music_v2_5") : null;
163
175
  }
164
176
 
165
177
  const MOODS = ["dreamy", "driving", "melancholy", "bright", "hazy", "tense", "playful", "warm", "nocturnal", "triumphant"];
@@ -217,6 +229,8 @@ export interface AiRadioDeps {
217
229
  /** The folder the music goes in: a managed library source. */
218
230
  root: string;
219
231
  owner: string;
232
+ /** Who holds the rights in what is made ("Profullstack, Inc."), written as publisher and copyright; "" for none. */
233
+ holder?: () => string;
220
234
  /** Whether another station may be started now (the server's running limit). */
221
235
  mayStart: () => boolean;
222
236
  composer?: () => Composer | null;
@@ -480,13 +494,16 @@ export class AiRadio {
480
494
  const month = monthOf(this.now());
481
495
  this.usage[month] = (this.usage[month] ?? 0) + station.trackSeconds;
482
496
  const file = join(folder, `${String(station.tracks.length + 1).padStart(4, "0")} ${slug(brief.title)}.mp3`);
497
+ const year = new Date(this.now()).getUTCFullYear().toString();
498
+ const holder = this.deps.holder?.() ?? "";
483
499
  await (this.deps.tag ?? ((from, to, tags) => tag(this.deps.ffmpeg, from, to, tags)))(raw, file, {
484
500
  title: brief.title,
485
501
  artist: `${station.name} (AI)`,
486
502
  album: station.name,
487
503
  genre: "AI",
488
504
  comment: `Composed by ${composer.name} from a brief by ${brief.model}`,
489
- date: new Date(this.now()).getUTCFullYear().toString(),
505
+ date: year,
506
+ ...(holder ? { publisher: holder, copyright: `℗ ${year} ${holder}` } : {}),
490
507
  });
491
508
  const track: AiTrack = { file, title: brief.title, prompt: brief.prompt, seconds: station.trackSeconds, made: `${brief.model} + ${composer.name}`, createdAt: this.now() };
492
509
  station.tracks.push(track);
package/src/ai-text.ts CHANGED
@@ -17,6 +17,8 @@ export interface AskOptions {
17
17
  prompt: string;
18
18
  /** Milliseconds before giving up (default 30 s). */
19
19
  timeoutMs?: number;
20
+ /** The longest answer, in tokens (default 1024): a song's words need more than a title does. */
21
+ maxTokens?: number;
20
22
  fetcher?: typeof fetch;
21
23
  env?: Record<string, string | undefined>;
22
24
  }
@@ -75,7 +77,7 @@ export async function askJson(options: AskOptions): Promise<Answer> {
75
77
  body: JSON.stringify({
76
78
  systemInstruction: { parts: [{ text: options.system }] },
77
79
  contents: [{ role: "user", parts: [{ text: options.prompt }] }],
78
- generationConfig: { responseMimeType: "application/json", temperature: 0.9 },
80
+ generationConfig: { responseMimeType: "application/json", temperature: 0.9, maxOutputTokens: options.maxTokens ?? 1024 },
79
81
  }),
80
82
  });
81
83
  if (!response.ok) {
@@ -98,6 +100,8 @@ export async function askJson(options: AskOptions): Promise<Answer> {
98
100
  headers: { "content-type": "application/json", authorization: `Bearer ${openai}` },
99
101
  body: JSON.stringify({
100
102
  model,
103
+ // Workers AI stops at 256 tokens unless it is told more.
104
+ max_tokens: options.maxTokens ?? 1024,
101
105
  // Not every compatible server knows json_object; the answer is read for its JSON either way.
102
106
  ...(official ? { response_format: { type: "json_object" } } : {}),
103
107
  messages: [{ role: "system", content: options.system }, { role: "user", content: options.prompt }],
@@ -115,7 +119,7 @@ export async function askJson(options: AskOptions): Promise<Answer> {
115
119
  headers: { "content-type": "application/json", "x-api-key": anthropic, "anthropic-version": "2023-06-01" },
116
120
  body: JSON.stringify({
117
121
  model,
118
- max_tokens: 1024,
122
+ max_tokens: options.maxTokens ?? 1024,
119
123
  system: `${options.system}\nAnswer with one JSON object and nothing else.`,
120
124
  messages: [{ role: "user", content: options.prompt }],
121
125
  }),
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The AI label over HTTP (ai-label.ts).
3
+ *
4
+ * Public, no key: `/label` (the catalogue as a page: every artist, album and
5
+ * song, playable, with its words), `/api/v1/label` (the same as JSON) and
6
+ * `/api/v1/label/<artist>/<album>/<n>.mp3|<n>.txt|cover.jpg` (the released
7
+ * files themselves). The owner's are under /api/v1/stations/label.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { createReadStream, statSync } from "node:fs";
11
+ import type { IncomingMessage, ServerResponse } from "node:http";
12
+ import { ROSTER, type AiLabel, type PublicArtist } from "./ai-label.ts";
13
+ import { covers, PRICES, type GrantVerifier } from "./music-store.ts";
14
+ import { writeZip, zipSize } from "./zip-stream.ts";
15
+ import { StationError } from "./stations.ts";
16
+
17
+ // Listening is free on the live stations (with their breaks); the songs as
18
+ // files are sold, so only the words and the cover are public here, and an
19
+ // MP3 or a zip needs a grant from nixamp.com's store (music-store.ts).
20
+ const FILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/([a-f0-9]{8})\/(\d{1,3}\.(?:txt|mp3)|cover\.jpg)$/;
21
+
22
+ /** Whether this path is the label's public face (an MP3 or the zip still asks for its grant). */
23
+ export function isPublicLabelPath(path: string): boolean {
24
+ return path === "/label" || path === "/api/v1/label" || path === "/api/v1/label/zip" || FILE.test(path);
25
+ }
26
+
27
+ /** nixamp.com's page that sells this: a song, an album or everything by the artist. */
28
+ export function buyLink(site: string, server: string, kind: "song" | "album" | "artist", artist: string, album = "", n = 0): string {
29
+ const query = new URLSearchParams({ server, kind, artist, ...(kind !== "artist" ? { album } : {}), ...(kind === "song" ? { n: String(n) } : {}) });
30
+ return `${site}/buy?${query.toString()}`;
31
+ }
32
+
33
+ export interface LabelRoutesContext {
34
+ label: AiLabel;
35
+ canControl: boolean;
36
+ /** Checks a store grant with nixamp.com; without it nothing is sold or handed out. */
37
+ grants?: GrantVerifier;
38
+ /** nixamp.com, where the store is, and this server's public address (for Buy links). */
39
+ site?: string;
40
+ publicBase?: string;
41
+ json: (response: ServerResponse, status: number, body: unknown) => void;
42
+ readBody: (request: IncomingMessage) => Promise<string>;
43
+ }
44
+
45
+ async function jsonBody(ctx: LabelRoutesContext, request: IncomingMessage): Promise<Record<string, unknown>> {
46
+ const raw = await ctx.readBody(request);
47
+ if (raw.trim() === "") return {};
48
+ let parsed: unknown;
49
+ try {
50
+ parsed = JSON.parse(raw);
51
+ } catch {
52
+ throw new StationError("bad_json", "the body is not JSON");
53
+ }
54
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new StationError("bad_json", "the body is not a JSON object");
55
+ return parsed as Record<string, unknown>;
56
+ }
57
+
58
+ /** A file, whole or the byte range asked for, so a player can seek. */
59
+ function sendFile(request: IncomingMessage, response: ServerResponse, path: string, type: string, download: string): void {
60
+ let size = 0;
61
+ try {
62
+ size = statSync(path).size;
63
+ } catch {
64
+ response.writeHead(404, { "content-type": "text/plain" }).end("gone");
65
+ return;
66
+ }
67
+ const headers: Record<string, string> = {
68
+ "content-type": type,
69
+ "accept-ranges": "bytes",
70
+ "cache-control": "public, max-age=3600",
71
+ "access-control-allow-origin": "*",
72
+ ...(download ? { "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(download)}` } : {}),
73
+ };
74
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(request.headers.range ?? ""));
75
+ if (range && size > 0) {
76
+ const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2] || 0));
77
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
78
+ if (start > end || start >= size) {
79
+ response.writeHead(416, { "content-range": `bytes */${size}` }).end();
80
+ return;
81
+ }
82
+ response.writeHead(206, { ...headers, "content-range": `bytes ${start}-${end}/${size}`, "content-length": String(end - start + 1) });
83
+ if (request.method === "HEAD") return void response.end();
84
+ createReadStream(path, { start, end }).pipe(response);
85
+ return;
86
+ }
87
+ response.writeHead(200, { ...headers, "content-length": String(size) });
88
+ if (request.method === "HEAD") return void response.end();
89
+ createReadStream(path).pipe(response);
90
+ }
91
+
92
+ /** Answers the paths above and the owner's; false when the path is neither. */
93
+ export async function handleLabelRoutes(
94
+ request: IncomingMessage,
95
+ response: ServerResponse,
96
+ path: string,
97
+ ctx: LabelRoutesContext,
98
+ ): Promise<boolean> {
99
+ const method = request.method ?? "GET";
100
+ const label = ctx.label;
101
+ try {
102
+ if (isPublicLabelPath(path)) {
103
+ if (method !== "GET" && method !== "HEAD") {
104
+ ctx.json(response, 405, { error: "not like that", code: "method_not_allowed" });
105
+ return true;
106
+ }
107
+ if (path === "/label") {
108
+ const shop = ctx.grants && ctx.site && /^https:\/\//.test(ctx.publicBase ?? "") ? { site: ctx.site, server: ctx.publicBase! } : undefined;
109
+ const page = renderLabel(label.publicView(), shop);
110
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": LABEL_CSP, "cache-control": "public, max-age=300" });
111
+ response.end(method === "HEAD" ? undefined : page);
112
+ return true;
113
+ }
114
+ if (path === "/api/v1/label") {
115
+ ctx.json(response, 200, label.publicView());
116
+ return true;
117
+ }
118
+ const url = new URL(request.url ?? "/", "http://x");
119
+ const grant = url.searchParams.get("grant") ?? "";
120
+ if (path === "/api/v1/label/zip") {
121
+ const scope = ctx.grants ? await ctx.grants.check(grant) : null;
122
+ if (!scope) {
123
+ ctx.json(response, 403, { error: "that download link has expired or is not one of ours: open your purchase again for a fresh one", code: "bad_grant" });
124
+ return true;
125
+ }
126
+ const zip = label.zipFor(scope);
127
+ if (!zip) {
128
+ ctx.json(response, 404, { error: "nothing to download yet", code: "not_found" });
129
+ return true;
130
+ }
131
+ response.writeHead(200, {
132
+ "content-type": "application/zip",
133
+ "content-length": String(zipSize(zip.entries)),
134
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(zip.name)}`,
135
+ "cache-control": "private, no-store",
136
+ });
137
+ if (method === "HEAD") {
138
+ response.end();
139
+ return true;
140
+ }
141
+ await writeZip(response, zip.entries).catch(() => response.destroy());
142
+ response.end();
143
+ return true;
144
+ }
145
+ const [, artist, album, what] = FILE.exec(path)!;
146
+ if (what!.endsWith(".mp3")) {
147
+ const scope = ctx.grants && grant ? await ctx.grants.check(grant) : null;
148
+ const n = Number(what!.split(".")[0]);
149
+ const file = scope && covers(scope, artist!, album!, n) ? label.songFile(artist!, album!, n) : "";
150
+ if (!file) {
151
+ ctx.json(response, 403, { error: "songs as files are for the people who bought them; listening on the live stations is free", code: "not_bought" });
152
+ return true;
153
+ }
154
+ sendFile(request, response, file, "audio/mpeg", file.split("/").pop() ?? "");
155
+ return true;
156
+ }
157
+ const found = label.publicFile(artist!, album!, what!);
158
+ if (!found) {
159
+ ctx.json(response, 404, { error: "no such file on the label", code: "not_found" });
160
+ return true;
161
+ }
162
+ sendFile(request, response, found.path, found.type, what === "cover.jpg" ? "" : found.path.split("/").pop() ?? "");
163
+ return true;
164
+ }
165
+
166
+ if (path !== "/api/v1/stations/label" && !path.startsWith("/api/v1/stations/label/")) return false;
167
+ if (!ctx.canControl) {
168
+ ctx.json(response, 403, { error: "only this server's owner runs the label", code: "forbidden" });
169
+ return true;
170
+ }
171
+ if (path === "/api/v1/stations/label") {
172
+ if (method === "GET") {
173
+ ctx.json(response, 200, label.status());
174
+ return true;
175
+ }
176
+ if (method === "POST") {
177
+ ctx.json(response, 200, { settings: label.configure(await jsonBody(ctx, request)) });
178
+ return true;
179
+ }
180
+ }
181
+ if (path === "/api/v1/stations/label/run" && method === "POST") {
182
+ void label.tick();
183
+ ctx.json(response, 202, label.status());
184
+ return true;
185
+ }
186
+ if (path === "/api/v1/stations/label/artists" && method === "POST") {
187
+ const input = await jsonBody(ctx, request);
188
+ if (input["roster"] === true) {
189
+ // The whole first roster at once, skipping whoever is already signed.
190
+ const signed = Object.keys(ROSTER).flatMap((preset) => {
191
+ try {
192
+ return [label.sign({ preset })];
193
+ } catch (error) {
194
+ if (error instanceof StationError && error.code === "already_signed") return [];
195
+ throw error;
196
+ }
197
+ });
198
+ ctx.json(response, 201, { artists: signed });
199
+ return true;
200
+ }
201
+ ctx.json(response, 201, { artist: label.sign(input) });
202
+ return true;
203
+ }
204
+ const match = /^\/api\/v1\/stations\/label\/artists\/([a-f0-9]{10})(?:\/(album))?$/.exec(path);
205
+ if (match) {
206
+ const [, key, action] = match;
207
+ if (!action && method === "GET") {
208
+ const artist = label.get(key!);
209
+ if (!artist) throw new StationError("not_found", "no such artist on the label", 404);
210
+ ctx.json(response, 200, { artist });
211
+ return true;
212
+ }
213
+ if (!action && method === "PATCH") {
214
+ ctx.json(response, 200, { artist: label.update(key!, await jsonBody(ctx, request)) });
215
+ return true;
216
+ }
217
+ if (!action && method === "DELETE") {
218
+ await label.drop(key!, new URL(request.url ?? "/", "http://x").searchParams.get("files") === "1");
219
+ ctx.json(response, 200, { deleted: key });
220
+ return true;
221
+ }
222
+ if (action === "album" && method === "POST") {
223
+ const album = await label.plan(key!);
224
+ void label.tick();
225
+ ctx.json(response, 201, { album });
226
+ return true;
227
+ }
228
+ }
229
+ ctx.json(response, 404, { error: "no such label route", code: "not_found" });
230
+ return true;
231
+ } catch (error) {
232
+ if (error instanceof StationError) ctx.json(response, error.status, { error: error.message, code: error.code });
233
+ else ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
234
+ return true;
235
+ }
236
+ }
237
+
238
+ function escape(text: string): string {
239
+ return text.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]!);
240
+ }
241
+
242
+ function minutes(seconds: number): string {
243
+ return `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
244
+ }
245
+
246
+ const STYLE = `
247
+ :root{color-scheme:light dark;--bg:#f6f7f9;--fg:#15171a;--muted:#5d6570;--card:#fff;--line:#dfe3e8;--accent:#0f7b4f}
248
+ @media (prefers-color-scheme:dark){:root{--bg:#0d0f12;--fg:#e8eaee;--muted:#9aa3ad;--card:#161a1f;--line:#262c33;--accent:#3ddc84}}
249
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
250
+ main{max-width:960px;margin:0 auto;padding:32px 16px 48px}h1{margin:0 0 4px;font-size:1.8rem}
251
+ .lead{color:var(--muted);margin:0 0 24px}a{color:var(--accent)}
252
+ nav.genres{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 8px}nav.genres a{border:1px solid var(--line);border-radius:999px;padding:4px 12px;text-decoration:none;color:var(--fg);background:var(--card)}
253
+ section.genre{margin-top:36px}section.genre>h2{display:flex;flex-wrap:wrap;align-items:baseline;gap:12px;margin:0 0 12px;font-size:1.4rem}
254
+ section.genre>h2 a{font-size:.9rem;font-weight:600}
255
+ .artist{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin:12px 0}
256
+ .artist h3{margin:0;font-size:1.2rem}.bio{color:var(--muted);margin:4px 0 0}
257
+ .album{display:grid;grid-template-columns:160px 1fr;gap:16px;margin-top:18px}
258
+ @media (max-width:560px){.album{grid-template-columns:1fr}}
259
+ .cover{width:160px;height:160px;border-radius:10px;object-fit:cover;background:var(--line);display:block}
260
+ .blank{width:160px;height:160px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--line))}
261
+ .album h4{margin:0;font-size:1.05rem}.concept{color:var(--muted);font-size:.9rem;margin:2px 0 8px}
262
+ ol.songs{list-style:none;margin:0;padding:0}ol.songs li{border-top:1px solid var(--line);padding:8px 0}
263
+ .song{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center}.song .t{flex:1 1 200px;font-weight:600;overflow-wrap:anywhere}
264
+ .song .n,.song .d{color:var(--muted);font-variant-numeric:tabular-nums}
265
+ audio{width:100%;max-width:360px;height:36px}
266
+ details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:.9rem}
267
+ pre.lyrics{white-space:pre-wrap;font:inherit;font-size:.95rem;margin:8px 0 0;padding:12px;border-radius:8px;background:var(--bg)}
268
+ .dl{font-size:.85rem}.soon{color:var(--muted);font-size:.85rem}
269
+ a.buy{display:inline-block;font-weight:700;text-decoration:none;border:1px solid var(--accent);border-radius:999px;padding:2px 10px}
270
+ footer{margin-top:48px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
271
+ `;
272
+
273
+ function hash(text: string): string {
274
+ return `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
275
+ }
276
+
277
+ export const LABEL_CSP = [
278
+ "default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'",
279
+ "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
280
+ ].join("; ");
281
+
282
+ type Shop = { site: string; server: string } | undefined;
283
+
284
+ function price(cents: number): string {
285
+ return cents < 100 ? `${cents}¢` : `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
286
+ }
287
+
288
+ function buy(shop: Shop, kind: "song" | "album" | "artist", artist: string, album = "", n = 0, what = ""): string {
289
+ if (!shop) return "";
290
+ return `<a class="buy" href="${escape(buyLink(shop.site, shop.server, kind, artist, album, n))}">Download ${what || kind} · ${price(PRICES[kind])}</a>`;
291
+ }
292
+
293
+ function renderAlbum(artist: PublicArtist, album: PublicArtist["albums"][number], shop: Shop): string {
294
+ const base = `/api/v1/label/${artist.key}/${album.key}`;
295
+ const cover = album.cover ? `<img class="cover" src="${base}/cover.jpg" alt="Cover of ${escape(album.title)}" width="160" height="160" loading="lazy">` : `<div class="blank" aria-hidden="true"></div>`;
296
+ const songs = album.songs.map((song) => `<li><div class="song"><span class="n">${song.n}.</span><span class="t">${escape(song.title)}</span><span class="d">${minutes(song.seconds)}</span></div>`
297
+ + `<details><summary>Lyrics</summary><pre class="lyrics">${escape(song.lyrics)}</pre></details>`
298
+ + `<div class="dl"><a href="${base}/${song.n}.txt">lyrics.txt</a>${shop ? ` · ${buy(shop, "song", artist.key, album.key, song.n, "MP3")}` : ""}</div></li>`).join("");
299
+ return `<div class="album">${cover}<div><h4>${escape(album.title)} <span class="d">(${album.year})</span></h4>`
300
+ + `${album.concept ? `<p class="concept">${escape(album.concept)}</p>` : ""}`
301
+ + `${album.complete ? "" : `<p class="soon">Still being recorded: new songs land here as they are finished.</p>`}`
302
+ + `${shop ? `<p>${buy(shop, "album", artist.key, album.key, 0, "the album, MP3s with art and lyrics")}</p>` : ""}`
303
+ + `<ol class="songs">${songs}</ol></div></div>`;
304
+ }
305
+
306
+ /** The label's catalogue as a page: no script, plays in any browser. */
307
+ export function renderLabel(view: ReturnType<AiLabel["publicView"]>, shop?: Shop): string {
308
+ const label = view.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
309
+ const nav = view.genres.length > 1 ? `<nav class="genres" aria-label="Genres">${view.genres.map((one) => `<a href="#${escape(one.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">${escape(one.genre)}</a>`).join("")}</nav>` : "";
310
+ const sections = view.genres.map((genre) => `<section class="genre" id="${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}" aria-labelledby="g-${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">`
311
+ + `<h2 id="g-${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">${escape(genre.genre)}${genre.station ? ` <a href="/watch/${escape(genre.station)}">Listen live, free →</a>` : ""}</h2>`
312
+ + genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}`
313
+ + `${shop ? `<p>${buy(shop, "artist", artist.key, "", 0, "everything by them")}</p>` : ""}`
314
+ + `${artist.albums.map((album) => renderAlbum(artist, album, shop)).join("")}</article>`).join("")
315
+ + `</section>`).join("");
316
+ return `<!doctype html>
317
+ <html lang="en">
318
+ <head>
319
+ <meta charset="utf-8">
320
+ <meta name="viewport" content="width=device-width, initial-scale=1">
321
+ <title>${escape(label)} AI Label</title>
322
+ <meta name="description" content="Original music by ${escape(label)}'s AI artists: every album, every song, with the words. New albums every month.">
323
+ <link rel="icon" href="data:,">
324
+ <style>${STYLE}</style>
325
+ </head>
326
+ <body>
327
+ <main>
328
+ <h1>${escape(label)} AI Label</h1>
329
+ <p class="lead">Original artists, written and sung by AI, released by ${escape(view.holder)}. Every album, every song, with the words. New albums land every month. Listening on the live stations is always free.</p>
330
+ ${nav}
331
+ ${sections || `<p class="soon">The first albums are being recorded. Check back soon.</p>`}
332
+ <footer>℗ ${new Date().getUTCFullYear()} ${escape(view.holder)}. Music and words made with AI. Played by <a href="https://nixamp.com">nixamp</a>.</footer>
333
+ </main>
334
+ </body>
335
+ </html>`;
336
+ }