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.
- package/dist/ai-keys.d.ts +1 -1
- package/dist/ai-keys.js +2 -0
- package/dist/ai-label.d.ts +239 -0
- package/dist/ai-label.js +774 -0
- package/dist/ai-radio.d.ts +11 -5
- package/dist/ai-radio.js +27 -11
- package/dist/ai-text.d.ts +2 -0
- package/dist/ai-text.js +4 -2
- package/dist/label-routes.d.ts +28 -0
- package/dist/label-routes.js +314 -0
- package/dist/music-store.d.ts +121 -0
- package/dist/music-store.js +289 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +99 -11
- package/dist/station-cli.js +109 -0
- package/dist/station-guide.d.ts +1 -1
- package/dist/station-guide.js +5 -1
- package/dist/station-mcp.d.ts +247 -20
- package/dist/station-mcp.js +36 -0
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +15 -1
- package/dist/store-routes.d.ts +16 -0
- package/dist/store-routes.js +189 -0
- package/dist/support.d.ts +14 -0
- package/dist/support.js +41 -0
- package/dist/track-facts.d.ts +8 -0
- package/dist/zip-stream.d.ts +13 -0
- package/dist/zip-stream.js +119 -0
- package/package.json +1 -1
- package/src/ai-keys.ts +2 -0
- package/src/ai-label.ts +873 -0
- package/src/ai-radio.ts +30 -13
- package/src/ai-text.ts +6 -2
- package/src/label-routes.ts +336 -0
- package/src/music-store.ts +333 -0
- package/src/server.ts +91 -3
- package/src/station-cli.ts +102 -0
- package/src/station-guide.ts +5 -1
- package/src/station-mcp.ts +34 -0
- package/src/station-usage.ts +15 -1
- package/src/store-routes.ts +204 -0
- package/src/support.ts +39 -0
- package/src/track-facts.ts +2 -0
- package/src/zip-stream.ts +128 -0
- package/web/dist/assets/{ar-BvPTr_2x.js → ar-CC_uYRlC.js} +1 -1
- package/web/dist/assets/{cs-g4ii7Tkl.js → cs-k5C-jvfX.js} +1 -1
- package/web/dist/assets/{da-D9vYCFH7.js → da-CdBzU_fF.js} +1 -1
- package/web/dist/assets/{de-lYElbebQ.js → de-B-UWa0W7.js} +1 -1
- package/web/dist/assets/{es-CiDbfxFy.js → es-Dod8aCau.js} +1 -1
- package/web/dist/assets/{fi-BWlqMMWx.js → fi-C36bBPFW.js} +1 -1
- package/web/dist/assets/{fr-D2n5nXQv.js → fr-DWUxs2qv.js} +1 -1
- package/web/dist/assets/{hi-DM9Nh04K.js → hi-DjLqhfs8.js} +1 -1
- package/web/dist/assets/{hu-Bzb0X4rk.js → hu-CgsOlcq8.js} +1 -1
- package/web/dist/assets/{id-j0QazySW.js → id-ZA8yTWX9.js} +1 -1
- package/web/dist/assets/{it-BVvva3a6.js → it-DfFhS34g.js} +1 -1
- package/web/dist/assets/{main-DPfBhzsz.js → main-CjgQp9um.js} +2 -2
- package/web/dist/assets/{nl-CrJQ6obW.js → nl-C257i24U.js} +1 -1
- package/web/dist/assets/radio-CDUEoM0y.js +1 -0
- package/web/dist/assets/{ru-DFIjjKvG.js → ru--30I7BNC.js} +1 -1
- package/web/dist/assets/{station-BKmoJ0Sk.css → station-Be-z7jQ2.css} +1 -1
- package/web/dist/assets/{station-X8pffqCW.js → station-D769XVG0.js} +1 -1
- package/web/dist/assets/{sv-BO9QPL4K.js → sv-CxzBeXfk.js} +1 -1
- package/web/dist/assets/{uk-DwmG3Owv.js → uk-DXHpMk4i.js} +1 -1
- package/web/dist/assets/{vi-C-2EzgMe.js → vi-Dg2LT4x3.js} +1 -1
- package/web/dist/assets/{zh-BlnCenyZ.js → zh-D3pvejo6.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +44 -1
- package/web/dist/station.html +7 -2
- package/web/dist/sw.js +25 -25
- package/web/dist/vod.html +2 -2
- package/web/dist/watch.html +2 -2
- package/web/dist/assets/radio-DI0H8flN.js +0 -1
- /package/web/dist/assets/{vod-CdfPMa1u.js → vod-bM1DvPEx.js} +0 -0
- /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
|
|
101
|
-
export
|
|
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:
|
|
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({
|
|
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}
|
|
136
|
-
*
|
|
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
|
|
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
|
-
|
|
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:
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
+
}
|