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
@@ -52,22 +52,26 @@ export declare const DEFAULTS: {
52
52
  export interface Composer {
53
53
  /** e.g. "elevenlabs/music_v1". */
54
54
  name: string;
55
- /** Writes an MP3 to `out`. Throws OutOfCredit when the account cannot pay. */
55
+ /** Writes an MP3 to `out`. Throws OutOfCredit when the account cannot pay. `lyrics` are the words to sing, when there are any. */
56
56
  compose(input: {
57
57
  prompt: string;
58
58
  seconds: number;
59
59
  vocals: boolean;
60
60
  out: string;
61
+ lyrics?: string;
61
62
  }): Promise<void>;
62
63
  }
63
64
  export declare class OutOfCredit extends Error {
64
65
  }
65
- /** ElevenLabs Music: a paid plan's music may be broadcast. */
66
- export declare function elevenLabsComposer(key: string, fetcher?: typeof fetch): Composer;
66
+ /** ElevenLabs' music models; NIXAMP_MUSIC_MODEL picks one, the newest by default. */
67
+ export declare const MUSIC_MODELS: readonly ["music_v1", "music_v2", "music_v2_5"];
68
+ /** ElevenLabs Music: a paid plan's music may be broadcast. Given lyrics, it sings those words. */
69
+ export declare function elevenLabsComposer(key: string, fetcher?: typeof fetch, model?: string): Composer;
67
70
  /**
68
71
  * A model of the owner's own (MusicGen, ACE-Step...): NIXAMP_AI_MUSIC_COMMAND
69
- * is run with {prompt}, {seconds}, {vocals} and {out} replaced, each as one
70
- * argument, and must leave an MP3 at {out}.
72
+ * is run with {prompt}, {seconds}, {vocals}, {lyrics} (a file of the words to
73
+ * sing, "" when there are none) and {out} replaced, each as one argument, and
74
+ * must leave an MP3 at {out}.
71
75
  */
72
76
  export declare function commandComposer(template: string): Composer;
73
77
  /** The composer this server can use, or null with the reason. */
@@ -92,6 +96,8 @@ export interface AiRadioDeps {
92
96
  /** The folder the music goes in: a managed library source. */
93
97
  root: string;
94
98
  owner: string;
99
+ /** Who holds the rights in what is made ("Profullstack, Inc."), written as publisher and copyright; "" for none. */
100
+ holder?: () => string;
95
101
  /** Whether another station may be started now (the server's running limit). */
96
102
  mayStart: () => boolean;
97
103
  composer?: () => Composer | null;
package/dist/ai-radio.js CHANGED
@@ -43,16 +43,22 @@ const MAX_TRACKS = 5_000;
43
43
  const BACKOFF_MS = [5 * 60_000, 10 * 60_000, 20 * 60_000, 40 * 60_000, 80 * 60_000, 6 * 3600_000];
44
44
  export class OutOfCredit extends Error {
45
45
  }
46
- /** ElevenLabs Music: a paid plan's music may be broadcast. */
47
- export function elevenLabsComposer(key, fetcher = fetch) {
46
+ /** ElevenLabs' music models; NIXAMP_MUSIC_MODEL picks one, the newest by default. */
47
+ export const MUSIC_MODELS = ["music_v1", "music_v2", "music_v2_5"];
48
+ /** ElevenLabs Music: a paid plan's music may be broadcast. Given lyrics, it sings those words. */
49
+ export function elevenLabsComposer(key, fetcher = fetch, model = "music_v2_5") {
48
50
  return {
49
- name: "elevenlabs/music_v1",
50
- async compose({ prompt, seconds, vocals, out }) {
51
+ name: `elevenlabs/${model}`,
52
+ async compose({ prompt, seconds, vocals, out, lyrics }) {
53
+ const words = vocals && lyrics?.trim() ? lyrics.trim() : "";
51
54
  const ask = async (text) => fetcher("https://api.elevenlabs.io/v1/music?output_format=mp3_44100_192", {
52
55
  method: "POST",
53
56
  signal: AbortSignal.timeout(10 * 60_000),
54
57
  headers: { "content-type": "application/json", "xi-api-key": key },
55
- body: JSON.stringify({ prompt: text, music_length_ms: Math.round(seconds * 1000), model_id: "music_v1", force_instrumental: !vocals }),
58
+ body: JSON.stringify({
59
+ prompt: text, music_length_ms: Math.round(seconds * 1000), model_id: model,
60
+ ...(words ? { lyrics_text: words } : { force_instrumental: !vocals }),
61
+ }),
56
62
  });
57
63
  let response = await ask(prompt);
58
64
  if (response.status === 400) {
@@ -81,16 +87,22 @@ export function elevenLabsComposer(key, fetcher = fetch) {
81
87
  }
82
88
  /**
83
89
  * A model of the owner's own (MusicGen, ACE-Step...): NIXAMP_AI_MUSIC_COMMAND
84
- * is run with {prompt}, {seconds}, {vocals} and {out} replaced, each as one
85
- * argument, and must leave an MP3 at {out}.
90
+ * is run with {prompt}, {seconds}, {vocals}, {lyrics} (a file of the words to
91
+ * sing, "" when there are none) and {out} replaced, each as one argument, and
92
+ * must leave an MP3 at {out}.
86
93
  */
87
94
  export function commandComposer(template) {
88
95
  const words = template.trim().split(/\s+/);
89
96
  return {
90
97
  name: `command/${words[0]?.split("/").pop() ?? "music"}`,
91
- compose: ({ prompt, seconds, vocals, out }) => new Promise((resolve, reject) => {
92
- const argv = words.map((one) => one.replace("{prompt}", prompt).replace("{seconds}", String(seconds)).replace("{vocals}", vocals ? "1" : "0").replace("{out}", out));
98
+ compose: ({ prompt, seconds, vocals, out, lyrics }) => new Promise((resolve, reject) => {
99
+ const lyricsFile = lyrics?.trim() ? `${out}.lyrics.txt` : "";
100
+ if (lyricsFile)
101
+ writeFileSync(lyricsFile, `${lyrics.trim()}\n`);
102
+ const argv = words.map((one) => one.replace("{prompt}", prompt).replace("{seconds}", String(seconds)).replace("{vocals}", vocals ? "1" : "0").replace("{lyrics}", lyricsFile).replace("{out}", out));
93
103
  execFile(argv[0], argv.slice(1), { timeout: 30 * 60_000, maxBuffer: 4 * 1024 * 1024 }, (error) => {
104
+ if (lyricsFile)
105
+ rmSync(lyricsFile, { force: true });
94
106
  if (error)
95
107
  return reject(new Error(`the music command failed: ${error.message}`));
96
108
  try {
@@ -111,7 +123,8 @@ export function composerFromEnv(env = process.env) {
111
123
  if (command)
112
124
  return commandComposer(command);
113
125
  const key = aiKey("ELEVENLABS_API_KEY", env);
114
- return key ? elevenLabsComposer(key) : null;
126
+ const model = aiKey("NIXAMP_MUSIC_MODEL", env);
127
+ return key ? elevenLabsComposer(key, fetch, MUSIC_MODELS.includes(model) ? model : "music_v2_5") : null;
115
128
  }
116
129
  const MOODS = ["dreamy", "driving", "melancholy", "bright", "hazy", "tense", "playful", "warm", "nocturnal", "triumphant"];
117
130
  const WORDS = ["Neon", "Harbor", "Velvet", "Static", "Paper", "Lantern", "Satellite", "Ember", "Glass", "Tide", "Orchard", "Signal"];
@@ -417,13 +430,16 @@ export class AiRadio {
417
430
  const month = monthOf(this.now());
418
431
  this.usage[month] = (this.usage[month] ?? 0) + station.trackSeconds;
419
432
  const file = join(folder, `${String(station.tracks.length + 1).padStart(4, "0")} ${slug(brief.title)}.mp3`);
433
+ const year = new Date(this.now()).getUTCFullYear().toString();
434
+ const holder = this.deps.holder?.() ?? "";
420
435
  await (this.deps.tag ?? ((from, to, tags) => tag(this.deps.ffmpeg, from, to, tags)))(raw, file, {
421
436
  title: brief.title,
422
437
  artist: `${station.name} (AI)`,
423
438
  album: station.name,
424
439
  genre: "AI",
425
440
  comment: `Composed by ${composer.name} from a brief by ${brief.model}`,
426
- date: new Date(this.now()).getUTCFullYear().toString(),
441
+ date: year,
442
+ ...(holder ? { publisher: holder, copyright: `℗ ${year} ${holder}` } : {}),
427
443
  });
428
444
  const track = { file, title: brief.title, prompt: brief.prompt, seconds: station.trackSeconds, made: `${brief.model} + ${composer.name}`, createdAt: this.now() };
429
445
  station.tracks.push(track);
package/dist/ai-text.d.ts CHANGED
@@ -5,6 +5,8 @@ export interface AskOptions {
5
5
  prompt: string;
6
6
  /** Milliseconds before giving up (default 30 s). */
7
7
  timeoutMs?: number;
8
+ /** The longest answer, in tokens (default 1024): a song's words need more than a title does. */
9
+ maxTokens?: number;
8
10
  fetcher?: typeof fetch;
9
11
  env?: Record<string, string | undefined>;
10
12
  }
package/dist/ai-text.js CHANGED
@@ -58,7 +58,7 @@ export async function askJson(options) {
58
58
  body: JSON.stringify({
59
59
  systemInstruction: { parts: [{ text: options.system }] },
60
60
  contents: [{ role: "user", parts: [{ text: options.prompt }] }],
61
- generationConfig: { responseMimeType: "application/json", temperature: 0.9 },
61
+ generationConfig: { responseMimeType: "application/json", temperature: 0.9, maxOutputTokens: options.maxTokens ?? 1024 },
62
62
  }),
63
63
  });
64
64
  if (!response.ok) {
@@ -82,6 +82,8 @@ export async function askJson(options) {
82
82
  headers: { "content-type": "application/json", authorization: `Bearer ${openai}` },
83
83
  body: JSON.stringify({
84
84
  model,
85
+ // Workers AI stops at 256 tokens unless it is told more.
86
+ max_tokens: options.maxTokens ?? 1024,
85
87
  // Not every compatible server knows json_object; the answer is read for its JSON either way.
86
88
  ...(official ? { response_format: { type: "json_object" } } : {}),
87
89
  messages: [{ role: "system", content: options.system }, { role: "user", content: options.prompt }],
@@ -101,7 +103,7 @@ export async function askJson(options) {
101
103
  headers: { "content-type": "application/json", "x-api-key": anthropic, "anthropic-version": "2023-06-01" },
102
104
  body: JSON.stringify({
103
105
  model,
104
- max_tokens: 1024,
106
+ max_tokens: options.maxTokens ?? 1024,
105
107
  system: `${options.system}\nAnswer with one JSON object and nothing else.`,
106
108
  messages: [{ role: "user", content: options.prompt }],
107
109
  }),
@@ -0,0 +1,28 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type AiLabel } from "./ai-label.ts";
3
+ import { type GrantVerifier } from "./music-store.ts";
4
+ /** Whether this path is the label's public face (an MP3 or the zip still asks for its grant). */
5
+ export declare function isPublicLabelPath(path: string): boolean;
6
+ /** nixamp.com's page that sells this: a song, an album or everything by the artist. */
7
+ export declare function buyLink(site: string, server: string, kind: "song" | "album" | "artist", artist: string, album?: string, n?: number): string;
8
+ export interface LabelRoutesContext {
9
+ label: AiLabel;
10
+ canControl: boolean;
11
+ /** Checks a store grant with nixamp.com; without it nothing is sold or handed out. */
12
+ grants?: GrantVerifier;
13
+ /** nixamp.com, where the store is, and this server's public address (for Buy links). */
14
+ site?: string;
15
+ publicBase?: string;
16
+ json: (response: ServerResponse, status: number, body: unknown) => void;
17
+ readBody: (request: IncomingMessage) => Promise<string>;
18
+ }
19
+ /** Answers the paths above and the owner's; false when the path is neither. */
20
+ export declare function handleLabelRoutes(request: IncomingMessage, response: ServerResponse, path: string, ctx: LabelRoutesContext): Promise<boolean>;
21
+ export declare const LABEL_CSP: string;
22
+ type Shop = {
23
+ site: string;
24
+ server: string;
25
+ } | undefined;
26
+ /** The label's catalogue as a page: no script, plays in any browser. */
27
+ export declare function renderLabel(view: ReturnType<AiLabel["publicView"]>, shop?: Shop): string;
28
+ export {};
@@ -0,0 +1,314 @@
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 { ROSTER } from "./ai-label.js";
12
+ import { covers, PRICES } from "./music-store.js";
13
+ import { writeZip, zipSize } from "./zip-stream.js";
14
+ import { StationError } from "./stations.js";
15
+ // Listening is free on the live stations (with their breaks); the songs as
16
+ // files are sold, so only the words and the cover are public here, and an
17
+ // MP3 or a zip needs a grant from nixamp.com's store (music-store.ts).
18
+ const FILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/([a-f0-9]{8})\/(\d{1,3}\.(?:txt|mp3)|cover\.jpg)$/;
19
+ /** Whether this path is the label's public face (an MP3 or the zip still asks for its grant). */
20
+ export function isPublicLabelPath(path) {
21
+ return path === "/label" || path === "/api/v1/label" || path === "/api/v1/label/zip" || FILE.test(path);
22
+ }
23
+ /** nixamp.com's page that sells this: a song, an album or everything by the artist. */
24
+ export function buyLink(site, server, kind, artist, album = "", n = 0) {
25
+ const query = new URLSearchParams({ server, kind, artist, ...(kind !== "artist" ? { album } : {}), ...(kind === "song" ? { n: String(n) } : {}) });
26
+ return `${site}/buy?${query.toString()}`;
27
+ }
28
+ async function jsonBody(ctx, request) {
29
+ const raw = await ctx.readBody(request);
30
+ if (raw.trim() === "")
31
+ return {};
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(raw);
35
+ }
36
+ catch {
37
+ throw new StationError("bad_json", "the body is not JSON");
38
+ }
39
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
40
+ throw new StationError("bad_json", "the body is not a JSON object");
41
+ return parsed;
42
+ }
43
+ /** A file, whole or the byte range asked for, so a player can seek. */
44
+ function sendFile(request, response, path, type, download) {
45
+ let size = 0;
46
+ try {
47
+ size = statSync(path).size;
48
+ }
49
+ catch {
50
+ response.writeHead(404, { "content-type": "text/plain" }).end("gone");
51
+ return;
52
+ }
53
+ const headers = {
54
+ "content-type": type,
55
+ "accept-ranges": "bytes",
56
+ "cache-control": "public, max-age=3600",
57
+ "access-control-allow-origin": "*",
58
+ ...(download ? { "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(download)}` } : {}),
59
+ };
60
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(request.headers.range ?? ""));
61
+ if (range && size > 0) {
62
+ const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2] || 0));
63
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
64
+ if (start > end || start >= size) {
65
+ response.writeHead(416, { "content-range": `bytes */${size}` }).end();
66
+ return;
67
+ }
68
+ response.writeHead(206, { ...headers, "content-range": `bytes ${start}-${end}/${size}`, "content-length": String(end - start + 1) });
69
+ if (request.method === "HEAD")
70
+ return void response.end();
71
+ createReadStream(path, { start, end }).pipe(response);
72
+ return;
73
+ }
74
+ response.writeHead(200, { ...headers, "content-length": String(size) });
75
+ if (request.method === "HEAD")
76
+ return void response.end();
77
+ createReadStream(path).pipe(response);
78
+ }
79
+ /** Answers the paths above and the owner's; false when the path is neither. */
80
+ export async function handleLabelRoutes(request, response, path, ctx) {
81
+ const method = request.method ?? "GET";
82
+ const label = ctx.label;
83
+ try {
84
+ if (isPublicLabelPath(path)) {
85
+ if (method !== "GET" && method !== "HEAD") {
86
+ ctx.json(response, 405, { error: "not like that", code: "method_not_allowed" });
87
+ return true;
88
+ }
89
+ if (path === "/label") {
90
+ const shop = ctx.grants && ctx.site && /^https:\/\//.test(ctx.publicBase ?? "") ? { site: ctx.site, server: ctx.publicBase } : undefined;
91
+ const page = renderLabel(label.publicView(), shop);
92
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": LABEL_CSP, "cache-control": "public, max-age=300" });
93
+ response.end(method === "HEAD" ? undefined : page);
94
+ return true;
95
+ }
96
+ if (path === "/api/v1/label") {
97
+ ctx.json(response, 200, label.publicView());
98
+ return true;
99
+ }
100
+ const url = new URL(request.url ?? "/", "http://x");
101
+ const grant = url.searchParams.get("grant") ?? "";
102
+ if (path === "/api/v1/label/zip") {
103
+ const scope = ctx.grants ? await ctx.grants.check(grant) : null;
104
+ if (!scope) {
105
+ 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" });
106
+ return true;
107
+ }
108
+ const zip = label.zipFor(scope);
109
+ if (!zip) {
110
+ ctx.json(response, 404, { error: "nothing to download yet", code: "not_found" });
111
+ return true;
112
+ }
113
+ response.writeHead(200, {
114
+ "content-type": "application/zip",
115
+ "content-length": String(zipSize(zip.entries)),
116
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(zip.name)}`,
117
+ "cache-control": "private, no-store",
118
+ });
119
+ if (method === "HEAD") {
120
+ response.end();
121
+ return true;
122
+ }
123
+ await writeZip(response, zip.entries).catch(() => response.destroy());
124
+ response.end();
125
+ return true;
126
+ }
127
+ const [, artist, album, what] = FILE.exec(path);
128
+ if (what.endsWith(".mp3")) {
129
+ const scope = ctx.grants && grant ? await ctx.grants.check(grant) : null;
130
+ const n = Number(what.split(".")[0]);
131
+ const file = scope && covers(scope, artist, album, n) ? label.songFile(artist, album, n) : "";
132
+ if (!file) {
133
+ 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" });
134
+ return true;
135
+ }
136
+ sendFile(request, response, file, "audio/mpeg", file.split("/").pop() ?? "");
137
+ return true;
138
+ }
139
+ const found = label.publicFile(artist, album, what);
140
+ if (!found) {
141
+ ctx.json(response, 404, { error: "no such file on the label", code: "not_found" });
142
+ return true;
143
+ }
144
+ sendFile(request, response, found.path, found.type, what === "cover.jpg" ? "" : found.path.split("/").pop() ?? "");
145
+ return true;
146
+ }
147
+ if (path !== "/api/v1/stations/label" && !path.startsWith("/api/v1/stations/label/"))
148
+ return false;
149
+ if (!ctx.canControl) {
150
+ ctx.json(response, 403, { error: "only this server's owner runs the label", code: "forbidden" });
151
+ return true;
152
+ }
153
+ if (path === "/api/v1/stations/label") {
154
+ if (method === "GET") {
155
+ ctx.json(response, 200, label.status());
156
+ return true;
157
+ }
158
+ if (method === "POST") {
159
+ ctx.json(response, 200, { settings: label.configure(await jsonBody(ctx, request)) });
160
+ return true;
161
+ }
162
+ }
163
+ if (path === "/api/v1/stations/label/run" && method === "POST") {
164
+ void label.tick();
165
+ ctx.json(response, 202, label.status());
166
+ return true;
167
+ }
168
+ if (path === "/api/v1/stations/label/artists" && method === "POST") {
169
+ const input = await jsonBody(ctx, request);
170
+ if (input["roster"] === true) {
171
+ // The whole first roster at once, skipping whoever is already signed.
172
+ const signed = Object.keys(ROSTER).flatMap((preset) => {
173
+ try {
174
+ return [label.sign({ preset })];
175
+ }
176
+ catch (error) {
177
+ if (error instanceof StationError && error.code === "already_signed")
178
+ return [];
179
+ throw error;
180
+ }
181
+ });
182
+ ctx.json(response, 201, { artists: signed });
183
+ return true;
184
+ }
185
+ ctx.json(response, 201, { artist: label.sign(input) });
186
+ return true;
187
+ }
188
+ const match = /^\/api\/v1\/stations\/label\/artists\/([a-f0-9]{10})(?:\/(album))?$/.exec(path);
189
+ if (match) {
190
+ const [, key, action] = match;
191
+ if (!action && method === "GET") {
192
+ const artist = label.get(key);
193
+ if (!artist)
194
+ throw new StationError("not_found", "no such artist on the label", 404);
195
+ ctx.json(response, 200, { artist });
196
+ return true;
197
+ }
198
+ if (!action && method === "PATCH") {
199
+ ctx.json(response, 200, { artist: label.update(key, await jsonBody(ctx, request)) });
200
+ return true;
201
+ }
202
+ if (!action && method === "DELETE") {
203
+ await label.drop(key, new URL(request.url ?? "/", "http://x").searchParams.get("files") === "1");
204
+ ctx.json(response, 200, { deleted: key });
205
+ return true;
206
+ }
207
+ if (action === "album" && method === "POST") {
208
+ const album = await label.plan(key);
209
+ void label.tick();
210
+ ctx.json(response, 201, { album });
211
+ return true;
212
+ }
213
+ }
214
+ ctx.json(response, 404, { error: "no such label route", code: "not_found" });
215
+ return true;
216
+ }
217
+ catch (error) {
218
+ if (error instanceof StationError)
219
+ ctx.json(response, error.status, { error: error.message, code: error.code });
220
+ else
221
+ ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
222
+ return true;
223
+ }
224
+ }
225
+ function escape(text) {
226
+ return text.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]);
227
+ }
228
+ function minutes(seconds) {
229
+ return `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
230
+ }
231
+ const STYLE = `
232
+ :root{color-scheme:light dark;--bg:#f6f7f9;--fg:#15171a;--muted:#5d6570;--card:#fff;--line:#dfe3e8;--accent:#0f7b4f}
233
+ @media (prefers-color-scheme:dark){:root{--bg:#0d0f12;--fg:#e8eaee;--muted:#9aa3ad;--card:#161a1f;--line:#262c33;--accent:#3ddc84}}
234
+ *{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}
235
+ main{max-width:960px;margin:0 auto;padding:32px 16px 48px}h1{margin:0 0 4px;font-size:1.8rem}
236
+ .lead{color:var(--muted);margin:0 0 24px}a{color:var(--accent)}
237
+ 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)}
238
+ 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}
239
+ section.genre>h2 a{font-size:.9rem;font-weight:600}
240
+ .artist{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin:12px 0}
241
+ .artist h3{margin:0;font-size:1.2rem}.bio{color:var(--muted);margin:4px 0 0}
242
+ .album{display:grid;grid-template-columns:160px 1fr;gap:16px;margin-top:18px}
243
+ @media (max-width:560px){.album{grid-template-columns:1fr}}
244
+ .cover{width:160px;height:160px;border-radius:10px;object-fit:cover;background:var(--line);display:block}
245
+ .blank{width:160px;height:160px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--line))}
246
+ .album h4{margin:0;font-size:1.05rem}.concept{color:var(--muted);font-size:.9rem;margin:2px 0 8px}
247
+ ol.songs{list-style:none;margin:0;padding:0}ol.songs li{border-top:1px solid var(--line);padding:8px 0}
248
+ .song{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center}.song .t{flex:1 1 200px;font-weight:600;overflow-wrap:anywhere}
249
+ .song .n,.song .d{color:var(--muted);font-variant-numeric:tabular-nums}
250
+ audio{width:100%;max-width:360px;height:36px}
251
+ details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:.9rem}
252
+ pre.lyrics{white-space:pre-wrap;font:inherit;font-size:.95rem;margin:8px 0 0;padding:12px;border-radius:8px;background:var(--bg)}
253
+ .dl{font-size:.85rem}.soon{color:var(--muted);font-size:.85rem}
254
+ a.buy{display:inline-block;font-weight:700;text-decoration:none;border:1px solid var(--accent);border-radius:999px;padding:2px 10px}
255
+ footer{margin-top:48px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
256
+ `;
257
+ function hash(text) {
258
+ return `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
259
+ }
260
+ export const LABEL_CSP = [
261
+ "default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'",
262
+ "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
263
+ ].join("; ");
264
+ function price(cents) {
265
+ return cents < 100 ? `${cents}¢` : `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
266
+ }
267
+ function buy(shop, kind, artist, album = "", n = 0, what = "") {
268
+ if (!shop)
269
+ return "";
270
+ return `<a class="buy" href="${escape(buyLink(shop.site, shop.server, kind, artist, album, n))}">Download ${what || kind} · ${price(PRICES[kind])}</a>`;
271
+ }
272
+ function renderAlbum(artist, album, shop) {
273
+ const base = `/api/v1/label/${artist.key}/${album.key}`;
274
+ 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>`;
275
+ 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>`
276
+ + `<details><summary>Lyrics</summary><pre class="lyrics">${escape(song.lyrics)}</pre></details>`
277
+ + `<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("");
278
+ return `<div class="album">${cover}<div><h4>${escape(album.title)} <span class="d">(${album.year})</span></h4>`
279
+ + `${album.concept ? `<p class="concept">${escape(album.concept)}</p>` : ""}`
280
+ + `${album.complete ? "" : `<p class="soon">Still being recorded: new songs land here as they are finished.</p>`}`
281
+ + `${shop ? `<p>${buy(shop, "album", artist.key, album.key, 0, "the album, MP3s with art and lyrics")}</p>` : ""}`
282
+ + `<ol class="songs">${songs}</ol></div></div>`;
283
+ }
284
+ /** The label's catalogue as a page: no script, plays in any browser. */
285
+ export function renderLabel(view, shop) {
286
+ const label = view.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
287
+ 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>` : "";
288
+ 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, "-"))}">`
289
+ + `<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>`
290
+ + genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}`
291
+ + `${shop ? `<p>${buy(shop, "artist", artist.key, "", 0, "everything by them")}</p>` : ""}`
292
+ + `${artist.albums.map((album) => renderAlbum(artist, album, shop)).join("")}</article>`).join("")
293
+ + `</section>`).join("");
294
+ return `<!doctype html>
295
+ <html lang="en">
296
+ <head>
297
+ <meta charset="utf-8">
298
+ <meta name="viewport" content="width=device-width, initial-scale=1">
299
+ <title>${escape(label)} AI Label</title>
300
+ <meta name="description" content="Original music by ${escape(label)}'s AI artists: every album, every song, with the words. New albums every month.">
301
+ <link rel="icon" href="data:,">
302
+ <style>${STYLE}</style>
303
+ </head>
304
+ <body>
305
+ <main>
306
+ <h1>${escape(label)} AI Label</h1>
307
+ <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>
308
+ ${nav}
309
+ ${sections || `<p class="soon">The first albums are being recorded. Check back soon.</p>`}
310
+ <footer>℗ ${new Date().getUTCFullYear()} ${escape(view.holder)}. Music and words made with AI. Played by <a href="https://nixamp.com">nixamp</a>.</footer>
311
+ </main>
312
+ </body>
313
+ </html>`;
314
+ }
@@ -0,0 +1,121 @@
1
+ import type { Db, Support } from "./support.ts";
2
+ export type Kind = "song" | "album" | "artist";
3
+ /** In cents. */
4
+ export declare const PRICES: Record<Kind, number>;
5
+ export interface Scope {
6
+ server: string;
7
+ kind: Kind;
8
+ artist: string;
9
+ album: string;
10
+ n: number;
11
+ }
12
+ export interface Purchase extends Scope {
13
+ id: string;
14
+ token: string;
15
+ email: string;
16
+ userId: string;
17
+ title: string;
18
+ artistName: string;
19
+ amountCents: number;
20
+ coin: string;
21
+ paymentId: string;
22
+ status: "pending" | "paid";
23
+ createdAt: number;
24
+ paidAt: number;
25
+ }
26
+ /** A server's public address as given: https, no path, no trailing slash. */
27
+ export declare function serverBase(value: unknown): string;
28
+ /** `g1.<exp>.<kind>.<artist>.<album|->.<n>.<server, base64url>.<sig>` */
29
+ export declare function makeGrant(secret: string, scope: Scope, exp: number): string;
30
+ export declare function readGrant(secret: string, grant: string, now?: number): (Scope & {
31
+ exp: number;
32
+ }) | null;
33
+ /** Whether a grant for `scope` covers this file of the catalogue. */
34
+ export declare function covers(scope: Pick<Scope, "kind" | "artist" | "album" | "n">, artist: string, album: string, n?: number): boolean;
35
+ /**
36
+ * On a server that holds the music: whether nixamp.com signed this grant,
37
+ * asked at /api/v1/store/grant and remembered (a good one until it expires,
38
+ * a refused one for 5 minutes). nixamp.com itself checks with its secret.
39
+ */
40
+ export declare class GrantVerifier {
41
+ private readonly options;
42
+ private known;
43
+ constructor(options: {
44
+ site: string;
45
+ secret?: string;
46
+ fetcher?: typeof fetch;
47
+ now?: () => number;
48
+ });
49
+ check(grant: string): Promise<(Scope & {
50
+ exp: number;
51
+ }) | null>;
52
+ }
53
+ export interface MusicStoreOptions {
54
+ db: Db;
55
+ support: Support;
56
+ /** Resend; null leaves the receipt unsent (and says so in the log). */
57
+ mail: {
58
+ apiKey: string;
59
+ from: string;
60
+ } | null;
61
+ site: string;
62
+ /** Signs the grants; derived from NIXAMP_JWT_SECRET. */
63
+ secret: string;
64
+ /** The servers whose catalogues may be sold here (the directory and the guide's peers). */
65
+ servers: () => string[];
66
+ fetcher?: typeof fetch;
67
+ now?: () => number;
68
+ log?: (line: string) => void;
69
+ }
70
+ export declare class MusicStore {
71
+ private readonly options;
72
+ private schema;
73
+ constructor(options: MusicStoreOptions);
74
+ private now;
75
+ private ready;
76
+ /** A server's catalogue, as it publishes it at /api/v1/label. */
77
+ private catalogue;
78
+ /** What is for sale at this address, with its name and price; refused when there is no such thing. */
79
+ describe(input: Record<string, unknown>): Promise<Scope & {
80
+ title: string;
81
+ artistName: string;
82
+ amountCents: number;
83
+ songs: number;
84
+ }>;
85
+ /** A purchase waiting for its payment, and where to pay. Signed in, it is the account's too. */
86
+ checkout(input: Record<string, unknown>, account: {
87
+ id: string;
88
+ email: string;
89
+ } | null): Promise<{
90
+ url: string;
91
+ id: string;
92
+ token: string;
93
+ }>;
94
+ /** The purchase's own page, which is also the receipt link. */
95
+ link(id: string, token: string): string;
96
+ /** A purchase by its link; a pending one asks CoinPay whether it has been paid since. */
97
+ get(id: string, token: string): Promise<Purchase | null>;
98
+ /** CoinPay's webhook: a payment of ours that is now paid. False when it is not a music purchase. */
99
+ settle(paymentId: string): Promise<{
100
+ settled: boolean;
101
+ purchase?: string;
102
+ }>;
103
+ /** Whether CoinPay's webhook is about one of ours. */
104
+ owns(paymentId: string): Promise<boolean>;
105
+ /** Marks it paid once (the first to get there sends the receipt). */
106
+ private paid;
107
+ /** Every paid purchase of this account: made signed in, or to its email. */
108
+ forAccount(account: {
109
+ id: string;
110
+ email: string;
111
+ }): Promise<Purchase[]>;
112
+ grant(purchase: Scope): string;
113
+ check(grant: string): (Scope & {
114
+ exp: number;
115
+ }) | null;
116
+ /** Where the zip of a purchase is made: on the server that holds the music. */
117
+ zipUrl(purchase: Purchase): string;
118
+ /** What anybody with the purchase's link (or the account) is shown. */
119
+ view(purchase: Purchase): Record<string, unknown>;
120
+ private mail;
121
+ }