nixamp 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-keys.d.ts +1 -1
- package/dist/ai-keys.js +2 -0
- package/dist/ai-label.d.ts +210 -0
- package/dist/ai-label.js +725 -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 +15 -0
- package/dist/label-routes.js +255 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +28 -1
- package/dist/station-cli.js +109 -0
- package/dist/station-guide.d.ts +1 -1
- package/dist/station-guide.js +8 -3
- package/dist/station-mcp.d.ts +247 -20
- package/dist/station-mcp.js +36 -0
- package/dist/station-metering.js +2 -2
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +15 -1
- package/package.json +1 -1
- package/src/ai-keys.ts +2 -0
- package/src/ai-label.ts +826 -0
- package/src/ai-radio.ts +30 -13
- package/src/ai-text.ts +6 -2
- package/src/label-routes.ts +268 -0
- package/src/server.ts +30 -1
- package/src/station-cli.ts +102 -0
- package/src/station-guide.ts +8 -3
- package/src/station-mcp.ts +34 -0
- package/src/station-metering.ts +2 -2
- package/src/station-usage.ts +15 -1
- package/web/dist/assets/{ar-ropwGSSV.js → ar-BEVjqkqM.js} +1 -1
- package/web/dist/assets/{cs-Bi7uRYjd.js → cs-DjazXQ15.js} +1 -1
- package/web/dist/assets/{da-B8OELPch.js → da-DnyF6SYK.js} +1 -1
- package/web/dist/assets/{de-DJOTeXET.js → de--q1ph9Ez.js} +1 -1
- package/web/dist/assets/{es-BIupzuzS.js → es-Do3DkBwD.js} +1 -1
- package/web/dist/assets/{fi-A6MoWkzZ.js → fi-B7sJTx1N.js} +1 -1
- package/web/dist/assets/{fr-DerIDIwl.js → fr-Dy7f5rGi.js} +1 -1
- package/web/dist/assets/{hi-CyUtRBH4.js → hi-C9vSnTQ4.js} +1 -1
- package/web/dist/assets/{hu-DD1vrIhY.js → hu-h-eueQE8.js} +1 -1
- package/web/dist/assets/{id-C6OAMzii.js → id-D8Ffd-Pc.js} +1 -1
- package/web/dist/assets/{it-Xy86vmU7.js → it-BKM_2rj1.js} +1 -1
- package/web/dist/assets/main-Dl_uRj22.js +22 -0
- package/web/dist/assets/{nl-4Q-K2Jf1.js → nl-Cj3ETrcl.js} +1 -1
- package/web/dist/assets/radio-zPgyISLf.js +1 -0
- package/web/dist/assets/{ru-CpxwOhC4.js → ru-B5zmGSnG.js} +1 -1
- package/web/dist/assets/{sv-aRvu7MWa.js → sv-BMtjA1uI.js} +1 -1
- package/web/dist/assets/{uk-BCwwfngg.js → uk-B2vuB6Un.js} +1 -1
- package/web/dist/assets/{vi-tvWPiAH_.js → vi-CXam3lDh.js} +1 -1
- package/web/dist/assets/{zh-Bp2aLEyX.js → zh-DnYhyzqb.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +33 -1
- package/web/dist/sw.js +20 -20
- package/web/dist/assets/main-CuG8-ADK.js +0 -22
- package/web/dist/assets/radio-DI0H8flN.js +0 -1
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,268 @@
|
|
|
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 { StationError } from "./stations.ts";
|
|
14
|
+
|
|
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.
|
|
17
|
+
const FILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/([a-f0-9]{8})\/(\d{1,3}\.txt|cover\.jpg)$/;
|
|
18
|
+
|
|
19
|
+
/** Whether this path is the label's public face. */
|
|
20
|
+
export function isPublicLabelPath(path: string): boolean {
|
|
21
|
+
return path === "/label" || path === "/api/v1/label" || FILE.test(path);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LabelRoutesContext {
|
|
25
|
+
label: AiLabel;
|
|
26
|
+
canControl: boolean;
|
|
27
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
28
|
+
readBody: (request: IncomingMessage) => Promise<string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function jsonBody(ctx: LabelRoutesContext, request: IncomingMessage): Promise<Record<string, unknown>> {
|
|
32
|
+
const raw = await ctx.readBody(request);
|
|
33
|
+
if (raw.trim() === "") return {};
|
|
34
|
+
let parsed: unknown;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(raw);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new StationError("bad_json", "the body is not JSON");
|
|
39
|
+
}
|
|
40
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new StationError("bad_json", "the body is not a JSON object");
|
|
41
|
+
return parsed as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A file, whole or the byte range asked for, so a player can seek. */
|
|
45
|
+
function sendFile(request: IncomingMessage, response: ServerResponse, path: string, type: string, download: string): void {
|
|
46
|
+
let size = 0;
|
|
47
|
+
try {
|
|
48
|
+
size = statSync(path).size;
|
|
49
|
+
} catch {
|
|
50
|
+
response.writeHead(404, { "content-type": "text/plain" }).end("gone");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const headers: Record<string, string> = {
|
|
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") return void response.end();
|
|
70
|
+
createReadStream(path, { start, end }).pipe(response);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
response.writeHead(200, { ...headers, "content-length": String(size) });
|
|
74
|
+
if (request.method === "HEAD") return void response.end();
|
|
75
|
+
createReadStream(path).pipe(response);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Answers the paths above and the owner's; false when the path is neither. */
|
|
79
|
+
export async function handleLabelRoutes(
|
|
80
|
+
request: IncomingMessage,
|
|
81
|
+
response: ServerResponse,
|
|
82
|
+
path: string,
|
|
83
|
+
ctx: LabelRoutesContext,
|
|
84
|
+
): Promise<boolean> {
|
|
85
|
+
const method = request.method ?? "GET";
|
|
86
|
+
const label = ctx.label;
|
|
87
|
+
try {
|
|
88
|
+
if (isPublicLabelPath(path)) {
|
|
89
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
90
|
+
ctx.json(response, 405, { error: "not like that", code: "method_not_allowed" });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (path === "/label") {
|
|
94
|
+
const page = renderLabel(label.publicView());
|
|
95
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": LABEL_CSP, "cache-control": "public, max-age=300" });
|
|
96
|
+
response.end(method === "HEAD" ? undefined : page);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (path === "/api/v1/label") {
|
|
100
|
+
ctx.json(response, 200, label.publicView());
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
const [, artist, album, what] = FILE.exec(path)!;
|
|
104
|
+
const found = label.publicFile(artist!, album!, what!);
|
|
105
|
+
if (!found) {
|
|
106
|
+
ctx.json(response, 404, { error: "no such file on the label", code: "not_found" });
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
sendFile(request, response, found.path, found.type, what === "cover.jpg" ? "" : found.path.split("/").pop() ?? "");
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (path !== "/api/v1/stations/label" && !path.startsWith("/api/v1/stations/label/")) return false;
|
|
114
|
+
if (!ctx.canControl) {
|
|
115
|
+
ctx.json(response, 403, { error: "only this server's owner runs the label", code: "forbidden" });
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (path === "/api/v1/stations/label") {
|
|
119
|
+
if (method === "GET") {
|
|
120
|
+
ctx.json(response, 200, label.status());
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (method === "POST") {
|
|
124
|
+
ctx.json(response, 200, { settings: label.configure(await jsonBody(ctx, request)) });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (path === "/api/v1/stations/label/run" && method === "POST") {
|
|
129
|
+
void label.tick();
|
|
130
|
+
ctx.json(response, 202, label.status());
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
if (path === "/api/v1/stations/label/artists" && method === "POST") {
|
|
134
|
+
const input = await jsonBody(ctx, request);
|
|
135
|
+
if (input["roster"] === true) {
|
|
136
|
+
// The whole first roster at once, skipping whoever is already signed.
|
|
137
|
+
const signed = Object.keys(ROSTER).flatMap((preset) => {
|
|
138
|
+
try {
|
|
139
|
+
return [label.sign({ preset })];
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error instanceof StationError && error.code === "already_signed") return [];
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
ctx.json(response, 201, { artists: signed });
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
ctx.json(response, 201, { artist: label.sign(input) });
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const match = /^\/api\/v1\/stations\/label\/artists\/([a-f0-9]{10})(?:\/(album))?$/.exec(path);
|
|
152
|
+
if (match) {
|
|
153
|
+
const [, key, action] = match;
|
|
154
|
+
if (!action && method === "GET") {
|
|
155
|
+
const artist = label.get(key!);
|
|
156
|
+
if (!artist) throw new StationError("not_found", "no such artist on the label", 404);
|
|
157
|
+
ctx.json(response, 200, { artist });
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
if (!action && method === "PATCH") {
|
|
161
|
+
ctx.json(response, 200, { artist: label.update(key!, await jsonBody(ctx, request)) });
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (!action && method === "DELETE") {
|
|
165
|
+
await label.drop(key!, new URL(request.url ?? "/", "http://x").searchParams.get("files") === "1");
|
|
166
|
+
ctx.json(response, 200, { deleted: key });
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
if (action === "album" && method === "POST") {
|
|
170
|
+
const album = await label.plan(key!);
|
|
171
|
+
void label.tick();
|
|
172
|
+
ctx.json(response, 201, { album });
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
ctx.json(response, 404, { error: "no such label route", code: "not_found" });
|
|
177
|
+
return true;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error instanceof StationError) ctx.json(response, error.status, { error: error.message, code: error.code });
|
|
180
|
+
else ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function escape(text: string): string {
|
|
186
|
+
return text.replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]!);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function minutes(seconds: number): string {
|
|
190
|
+
return `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const STYLE = `
|
|
194
|
+
:root{color-scheme:light dark;--bg:#f6f7f9;--fg:#15171a;--muted:#5d6570;--card:#fff;--line:#dfe3e8;--accent:#0f7b4f}
|
|
195
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0d0f12;--fg:#e8eaee;--muted:#9aa3ad;--card:#161a1f;--line:#262c33;--accent:#3ddc84}}
|
|
196
|
+
*{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}
|
|
197
|
+
main{max-width:960px;margin:0 auto;padding:32px 16px 48px}h1{margin:0 0 4px;font-size:1.8rem}
|
|
198
|
+
.lead{color:var(--muted);margin:0 0 24px}a{color:var(--accent)}
|
|
199
|
+
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)}
|
|
200
|
+
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}
|
|
201
|
+
section.genre>h2 a{font-size:.9rem;font-weight:600}
|
|
202
|
+
.artist{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin:12px 0}
|
|
203
|
+
.artist h3{margin:0;font-size:1.2rem}.bio{color:var(--muted);margin:4px 0 0}
|
|
204
|
+
.album{display:grid;grid-template-columns:160px 1fr;gap:16px;margin-top:18px}
|
|
205
|
+
@media (max-width:560px){.album{grid-template-columns:1fr}}
|
|
206
|
+
.cover{width:160px;height:160px;border-radius:10px;object-fit:cover;background:var(--line);display:block}
|
|
207
|
+
.blank{width:160px;height:160px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--line))}
|
|
208
|
+
.album h4{margin:0;font-size:1.05rem}.concept{color:var(--muted);font-size:.9rem;margin:2px 0 8px}
|
|
209
|
+
ol.songs{list-style:none;margin:0;padding:0}ol.songs li{border-top:1px solid var(--line);padding:8px 0}
|
|
210
|
+
.song{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center}.song .t{flex:1 1 200px;font-weight:600;overflow-wrap:anywhere}
|
|
211
|
+
.song .n,.song .d{color:var(--muted);font-variant-numeric:tabular-nums}
|
|
212
|
+
audio{width:100%;max-width:360px;height:36px}
|
|
213
|
+
details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:.9rem}
|
|
214
|
+
pre.lyrics{white-space:pre-wrap;font:inherit;font-size:.95rem;margin:8px 0 0;padding:12px;border-radius:8px;background:var(--bg)}
|
|
215
|
+
.dl{font-size:.85rem}.soon{color:var(--muted);font-size:.85rem}
|
|
216
|
+
footer{margin-top:48px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
|
|
217
|
+
`;
|
|
218
|
+
|
|
219
|
+
function hash(text: string): string {
|
|
220
|
+
return `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const LABEL_CSP = [
|
|
224
|
+
"default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'",
|
|
225
|
+
"base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
|
|
226
|
+
].join("; ");
|
|
227
|
+
|
|
228
|
+
function renderAlbum(artist: PublicArtist, album: PublicArtist["albums"][number]): string {
|
|
229
|
+
const base = `/api/v1/label/${artist.key}/${album.key}`;
|
|
230
|
+
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>`;
|
|
231
|
+
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>`
|
|
232
|
+
+ `<details><summary>Lyrics</summary><pre class="lyrics">${escape(song.lyrics)}</pre></details>`
|
|
233
|
+
+ `<div class="dl"><a href="${base}/${song.n}.txt">lyrics.txt</a></div></li>`).join("");
|
|
234
|
+
return `<div class="album">${cover}<div><h4>${escape(album.title)} <span class="d">(${album.year})</span></h4>`
|
|
235
|
+
+ `${album.concept ? `<p class="concept">${escape(album.concept)}</p>` : ""}`
|
|
236
|
+
+ `${album.complete ? "" : `<p class="soon">Still being recorded: new songs land here as they are finished.</p>`}`
|
|
237
|
+
+ `<ol class="songs">${songs}</ol></div></div>`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The label's catalogue as a page: no script, plays in any browser. */
|
|
241
|
+
export function renderLabel(view: ReturnType<AiLabel["publicView"]>): string {
|
|
242
|
+
const label = view.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
|
|
243
|
+
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>` : "";
|
|
244
|
+
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, "-"))}">`
|
|
245
|
+
+ `<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>`
|
|
246
|
+
+ genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}${artist.albums.map((album) => renderAlbum(artist, album)).join("")}</article>`).join("")
|
|
247
|
+
+ `</section>`).join("");
|
|
248
|
+
return `<!doctype html>
|
|
249
|
+
<html lang="en">
|
|
250
|
+
<head>
|
|
251
|
+
<meta charset="utf-8">
|
|
252
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
253
|
+
<title>${escape(label)} AI Label</title>
|
|
254
|
+
<meta name="description" content="Original music by ${escape(label)}'s AI artists: every album, every song, with the words. New albums every month.">
|
|
255
|
+
<link rel="icon" href="data:,">
|
|
256
|
+
<style>${STYLE}</style>
|
|
257
|
+
</head>
|
|
258
|
+
<body>
|
|
259
|
+
<main>
|
|
260
|
+
<h1>${escape(label)} AI Label</h1>
|
|
261
|
+
<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>
|
|
262
|
+
${nav}
|
|
263
|
+
${sections || `<p class="soon">The first albums are being recorded. Check back soon.</p>`}
|
|
264
|
+
<footer>℗ ${new Date().getUTCFullYear()} ${escape(view.holder)}. Music and words made with AI. Played by <a href="https://nixamp.com">nixamp</a>.</footer>
|
|
265
|
+
</main>
|
|
266
|
+
</body>
|
|
267
|
+
</html>`;
|
|
268
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -78,6 +78,8 @@ import { ListenerStore } from "./library-user.ts";
|
|
|
78
78
|
import { Importer } from "./handoff.ts";
|
|
79
79
|
import { handleHandoffs } from "./handoff-routes.ts";
|
|
80
80
|
import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
|
|
81
|
+
import { AiLabel } from "./ai-label.ts";
|
|
82
|
+
import { handleLabelRoutes, isPublicLabelPath } from "./label-routes.ts";
|
|
81
83
|
import { AiRadio } from "./ai-radio.ts";
|
|
82
84
|
import { ListenerShares } from "./listener-shares.ts";
|
|
83
85
|
import { HolderStore, TagReader, factsFor } from "./track-facts.ts";
|
|
@@ -1746,6 +1748,8 @@ export interface HandlerOptions {
|
|
|
1746
1748
|
publicBase?: () => string;
|
|
1747
1749
|
/** Stations whose music is composed by AI. */
|
|
1748
1750
|
ai?: AiRadio;
|
|
1751
|
+
/** AI artists and their albums, a genre station each, and the catalogue at /label. */
|
|
1752
|
+
label?: AiLabel;
|
|
1749
1753
|
/** Tracks anybody may share, sorted onto the air by AI. */
|
|
1750
1754
|
shares?: ListenerShares;
|
|
1751
1755
|
/** What a listener may know about the track on the air. */
|
|
@@ -2722,6 +2726,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2722
2726
|
!isStationListenPath(path) &&
|
|
2723
2727
|
// Sharing a track and reporting one are for anybody the guide reaches.
|
|
2724
2728
|
!isPublicSharePath(path) &&
|
|
2729
|
+
// The AI label's catalogue, its songs and their words.
|
|
2730
|
+
!(isPublicLabelPath(path) && Boolean(options.stations?.label)) &&
|
|
2725
2731
|
// What is on, as MCP, for anybody.
|
|
2726
2732
|
path !== "/mcp/public" &&
|
|
2727
2733
|
// Supporting a copyright holder is for anybody listening.
|
|
@@ -2905,7 +2911,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2905
2911
|
? await roomEntries(options.stations!.rooms.store, options.stations!.rooms.probe, watchBase(options.stations!.publicBase?.() || `${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`))
|
|
2906
2912
|
: [];
|
|
2907
2913
|
const ads = guideAds();
|
|
2908
|
-
const
|
|
2914
|
+
const label = options.stations!.label;
|
|
2915
|
+
const page = renderGuide([...rooms, ...entries, ...others] as GuideEntry[], options.stations!.publicBase?.() ?? "", ads, label && label.publicView().genres.length > 0 ? "/label" : "");
|
|
2909
2916
|
response.writeHead(200, {
|
|
2910
2917
|
"content-type": "text/html; charset=utf-8",
|
|
2911
2918
|
"cache-control": "no-cache",
|
|
@@ -3067,6 +3074,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3067
3074
|
return;
|
|
3068
3075
|
}
|
|
3069
3076
|
|
|
3077
|
+
if (options.stations?.label && (isPublicLabelPath(path) || path === "/api/v1/stations/label" || path.startsWith("/api/v1/stations/label/"))) {
|
|
3078
|
+
const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
|
|
3079
|
+
if (await handleLabelRoutes(request, response, path, { label: options.stations.label, canControl: control.allowed, json, readBody })) return;
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3070
3082
|
if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares") || path.startsWith("/api/v1/stations/holders"))) {
|
|
3071
3083
|
const control = await controlOf();
|
|
3072
3084
|
// Behind a proxy we trust (dev2 for live.streaming), the sharer is the
|
|
@@ -7279,10 +7291,27 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7279
7291
|
ffmpeg: tools.ffmpeg,
|
|
7280
7292
|
root: join(stateDir(), "ai-music"),
|
|
7281
7293
|
owner: options.owner || "",
|
|
7294
|
+
holder: () => aiLabel.holder(),
|
|
7295
|
+
mayStart: mayStartStation,
|
|
7296
|
+
log: (line) => console.log(line),
|
|
7297
|
+
});
|
|
7298
|
+
// The AI label: artists who put out albums on their own, filed under the
|
|
7299
|
+
// library as ai/<genre>/<artist>/<year>-<album>/ (NIXAMP_LABEL_ROOT moves it).
|
|
7300
|
+
const labelHome = libraryIndex.sources().find((one) => one.enabled && !one.managed);
|
|
7301
|
+
const aiLabel = new AiLabel(AiLabel.fileFor(stateDir(), options.port), {
|
|
7302
|
+
index: libraryIndex,
|
|
7303
|
+
store: stationStore,
|
|
7304
|
+
runner: stationRunner,
|
|
7305
|
+
probe: probeFile,
|
|
7306
|
+
ffmpeg: tools.ffmpeg,
|
|
7307
|
+
root: process.env["NIXAMP_LABEL_ROOT"] || (labelHome ? join(labelHome.root, "ai") : join(stateDir(), "ai-label")),
|
|
7308
|
+
owner: options.owner || "",
|
|
7282
7309
|
mayStart: mayStartStation,
|
|
7283
7310
|
log: (line) => console.log(line),
|
|
7284
7311
|
});
|
|
7285
7312
|
aiRadio.startSchedule();
|
|
7313
|
+
aiLabel.startSchedule();
|
|
7314
|
+
stations.label = aiLabel;
|
|
7286
7315
|
// Tracks anybody shares from the guide, checked by AI before they air.
|
|
7287
7316
|
const listenerShares = new ListenerShares(ListenerShares.fileFor(stateDir(), options.port), {
|
|
7288
7317
|
index: libraryIndex,
|
package/src/station-cli.ts
CHANGED
|
@@ -272,6 +272,8 @@ export async function station(argv: string[]): Promise<number> {
|
|
|
272
272
|
}
|
|
273
273
|
case "ai":
|
|
274
274
|
return await aiCommand(argv.slice(1), call, show, json);
|
|
275
|
+
case "label":
|
|
276
|
+
return await labelCommand(argv.slice(1), call, show);
|
|
275
277
|
case "shares":
|
|
276
278
|
return await sharesCommand(argv.slice(1), call, show);
|
|
277
279
|
case "lyrics":
|
|
@@ -412,6 +414,106 @@ async function aiCommand(argv: string[], call: Call, show: Show, json: boolean):
|
|
|
412
414
|
}
|
|
413
415
|
}
|
|
414
416
|
|
|
417
|
+
/** `nixamp station label ...`: AI artists who put out an album a month (ai-label.ts). */
|
|
418
|
+
async function labelCommand(argv: string[], call: Call, show: Show): Promise<number> {
|
|
419
|
+
const [sub, key] = argv;
|
|
420
|
+
const when = (at: number): string => at === -1 ? "never (off)" : at === 0 ? "now" : new Date(at).toISOString().slice(0, 10);
|
|
421
|
+
const describe = (one: Record<string, unknown>): string => {
|
|
422
|
+
const albums = (one["albums"] as Array<Record<string, unknown>> | undefined) ?? [];
|
|
423
|
+
return [
|
|
424
|
+
`${String(one["name"])} [${String(one["key"])}] ${String(one["genre"])}${one["enabled"] ? "" : " (off)"}`,
|
|
425
|
+
...albums.map((album) => ` ${String(album["year"])} ${String(album["title"])} ${String(album["composed"])}/${String(album["songs"])} songs${album["doneAt"] ? "" : " (recording)"}`),
|
|
426
|
+
` next album ${when(Number(one["nextAlbumAt"] ?? -1))}`,
|
|
427
|
+
...(one["lastError"] ? [` problem ${String(one["lastError"])}`] : []),
|
|
428
|
+
].join("\n");
|
|
429
|
+
};
|
|
430
|
+
switch (sub) {
|
|
431
|
+
case undefined:
|
|
432
|
+
case "status":
|
|
433
|
+
case "list":
|
|
434
|
+
return show(await call("GET", "/api/v1/stations/label"), (body) => {
|
|
435
|
+
const settings = (body["settings"] ?? {}) as Record<string, unknown>;
|
|
436
|
+
const artists = (body["artists"] as Record<string, unknown>[] | undefined) ?? [];
|
|
437
|
+
const roster = (body["roster"] as Array<Record<string, unknown>> | undefined) ?? [];
|
|
438
|
+
return [
|
|
439
|
+
`holder ${String(settings["holder"])}${settings["enabled"] ? "" : " (off)"}`,
|
|
440
|
+
`files ${String(body["root"])}/<genre>/<artist>/<year>-<album>/`,
|
|
441
|
+
`composer ${String(body["composer"] ?? "none")}; words by ${String(body["writer"] ?? "nobody (no model key)")}`,
|
|
442
|
+
`this month ${String(body["secondsThisMonth"])} of ${Number(settings["monthlySeconds"]) === 0 ? "unlimited" : String(settings["monthlySeconds"])} s${body["busy"] ? `; recording ${String(body["busy"])}` : ""}`,
|
|
443
|
+
`albums ${String(settings["songsPerAlbum"])} songs of ${String(settings["songSeconds"])} s; ${String(settings["seedAlbums"])} to start, then one every ${String(settings["everyDays"])} days`,
|
|
444
|
+
...(body["waiting"] ? [`waiting ${String(body["waiting"])}`] : []),
|
|
445
|
+
"",
|
|
446
|
+
...(artists.length ? artists.map(describe) : ["Nobody signed yet. nixamp station label sign --roster"]),
|
|
447
|
+
"",
|
|
448
|
+
`roster ${roster.map((one) => `${String(one["key"])}${one["signed"] ? " (signed)" : ""}`).join(", ")}`,
|
|
449
|
+
].join("\n");
|
|
450
|
+
});
|
|
451
|
+
case "sign": {
|
|
452
|
+
const body = has(argv, "--roster") ? { roster: true } : {
|
|
453
|
+
...(flag(argv, "--preset") ? { preset: flag(argv, "--preset") } : {}),
|
|
454
|
+
...(flag(argv, "--name") ? { name: flag(argv, "--name") } : {}),
|
|
455
|
+
...(flag(argv, "--genre") ? { genre: flag(argv, "--genre") } : {}),
|
|
456
|
+
...(flag(argv, "--sound") ? { sound: flag(argv, "--sound") } : {}),
|
|
457
|
+
...(flag(argv, "--voice") ? { voice: flag(argv, "--voice") } : {}),
|
|
458
|
+
...(flag(argv, "--bio") ? { bio: flag(argv, "--bio") } : {}),
|
|
459
|
+
...(flag(argv, "--themes") ? { themes: flag(argv, "--themes") } : {}),
|
|
460
|
+
};
|
|
461
|
+
return show(await call("POST", "/api/v1/stations/label/artists", body), (answer) => {
|
|
462
|
+
const signed = (answer["artists"] as Array<Record<string, unknown>> | undefined) ?? (answer["artist"] ? [answer["artist"] as Record<string, unknown>] : []);
|
|
463
|
+
return signed.length ? `Signed ${signed.map((one) => `${String(one["name"])} (${String(one["genre"])})`).join(", ")}. Their first albums are planned and recorded from here.` : "Everybody on the roster is already signed.";
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
case "album": {
|
|
467
|
+
if (!key) { console.error("nixamp station label album needs the artist's key (nixamp station label)"); return 2; }
|
|
468
|
+
return show(await call("POST", `/api/v1/stations/label/artists/${key}/album`, {}), (answer) => {
|
|
469
|
+
const album = answer["album"] as Record<string, unknown>;
|
|
470
|
+
return `Planned "${String(album["title"])}" (${((album["songs"] as unknown[]) ?? []).length} songs); recording now, a song at a time.`;
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
case "set": {
|
|
474
|
+
if (!key) { console.error("nixamp station label set needs the artist's key"); return 2; }
|
|
475
|
+
const body = {
|
|
476
|
+
...(flag(argv, "--sound") ? { sound: flag(argv, "--sound") } : {}),
|
|
477
|
+
...(flag(argv, "--voice") ? { voice: flag(argv, "--voice") } : {}),
|
|
478
|
+
...(flag(argv, "--bio") ? { bio: flag(argv, "--bio") } : {}),
|
|
479
|
+
...(flag(argv, "--themes") ? { themes: flag(argv, "--themes") } : {}),
|
|
480
|
+
...(has(argv, "--off") ? { enabled: false } : has(argv, "--on") ? { enabled: true } : {}),
|
|
481
|
+
};
|
|
482
|
+
return show(await call("PATCH", `/api/v1/stations/label/artists/${key}`, body), (answer) => `${String((answer["artist"] as Record<string, unknown>)["name"])} updated.`);
|
|
483
|
+
}
|
|
484
|
+
case "drop": {
|
|
485
|
+
if (!key) { console.error("nixamp station label drop needs the artist's key"); return 2; }
|
|
486
|
+
const files = has(argv, "--files");
|
|
487
|
+
return show(await call("DELETE", `/api/v1/stations/label/artists/${key}${files ? "?files=1" : ""}`), () =>
|
|
488
|
+
files ? "Dropped, with their music." : "Dropped. Their albums stay on the air; nothing new will be recorded.");
|
|
489
|
+
}
|
|
490
|
+
case "config": {
|
|
491
|
+
const number = (name: string): number | undefined => (flag(argv, name) === undefined ? undefined : Number(flag(argv, name)));
|
|
492
|
+
const body = Object.fromEntries(Object.entries({
|
|
493
|
+
holder: flag(argv, "--holder"),
|
|
494
|
+
monthlySeconds: number("--budget"),
|
|
495
|
+
everyDays: number("--every-days"),
|
|
496
|
+
seedAlbums: number("--seed-albums"),
|
|
497
|
+
songsPerAlbum: number("--songs"),
|
|
498
|
+
songSeconds: number("--seconds"),
|
|
499
|
+
enabled: has(argv, "--off") ? false : has(argv, "--on") ? true : undefined,
|
|
500
|
+
publish: has(argv, "--unlisted") ? false : has(argv, "--public") ? true : undefined,
|
|
501
|
+
}).filter(([, value]) => value !== undefined));
|
|
502
|
+
return show(await call("POST", "/api/v1/stations/label", body), (answer) => {
|
|
503
|
+
const settings = answer["settings"] as Record<string, unknown>;
|
|
504
|
+
return `Label: ${String(settings["holder"])}, ${String(settings["songsPerAlbum"])} songs of ${String(settings["songSeconds"])} s an album, every ${String(settings["everyDays"])} days, at most ${Number(settings["monthlySeconds"]) === 0 ? "unlimited" : `${String(settings["monthlySeconds"])} s`} a month${settings["enabled"] ? "" : " (off)"}.`;
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
case "run":
|
|
508
|
+
return show(await call("POST", "/api/v1/stations/label/run", {}), (body) => body["waiting"] ? `Nothing to record now: ${String(body["waiting"])}.` : "Recording whatever is due.");
|
|
509
|
+
case "resume":
|
|
510
|
+
return show(await call("POST", "/api/v1/stations/label", { resume: true }), () => "Recording again.");
|
|
511
|
+
default:
|
|
512
|
+
console.error(`nixamp station label: no command "${sub}".\n\n${USAGE}`);
|
|
513
|
+
return 2;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
415
517
|
/** `nixamp station lyrics ...`: our own songs' words, published back to nichedb.dev. */
|
|
416
518
|
async function lyricsCommand(argv: string[], call: Call, show: Show, json: boolean): Promise<number> {
|
|
417
519
|
const [sub, id] = argv;
|
package/src/station-guide.ts
CHANGED
|
@@ -32,7 +32,7 @@ button.report:hover{color:var(--warn)}
|
|
|
32
32
|
#share .or{color:var(--muted);font-size:.85rem;margin:8px 0 0}
|
|
33
33
|
#status{margin-top:10px;font-size:.9rem;min-height:1.4em}
|
|
34
34
|
footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
|
|
35
|
-
.ad-unit{margin:
|
|
35
|
+
.ad-unit{margin:0 auto 16px;display:flex;justify-content:center}.ad-unit iframe{border:0;display:block;background:transparent}
|
|
36
36
|
.ad-banner_300x250 iframe{width:300px;height:250px;max-width:100%}.ad-text_link iframe{width:100%;height:40px}
|
|
37
37
|
`;
|
|
38
38
|
|
|
@@ -170,7 +170,7 @@ const GROUPS = ["rooms", "vod", "ai", "shared", "radio", "tv"] as const;
|
|
|
170
170
|
* gateway that cannot run its script against this server (the Moshpit's
|
|
171
171
|
* /n/<name>), and for a crawler. Everything from a station is escaped.
|
|
172
172
|
*/
|
|
173
|
-
export function renderGuide(entries: GuideEntry[], apiBase = "", ads: GuideAds = {}): string {
|
|
173
|
+
export function renderGuide(entries: GuideEntry[], apiBase = "", ads: GuideAds = {}, labelHref = ""): string {
|
|
174
174
|
const groupOf = (one: GuideEntry): string => one.group ?? (one.kind === "video" ? "tv" : "radio");
|
|
175
175
|
const cards = (group: string): string => entries
|
|
176
176
|
.filter((one) => groupOf(one) === group && /^https?:\/\//.test(one.page))
|
|
@@ -185,11 +185,16 @@ export function renderGuide(entries: GuideEntry[], apiBase = "", ads: GuideAds =
|
|
|
185
185
|
}
|
|
186
186
|
if (ads.site) page = page.replace("</head>", `<script data-site="${escape(ads.site)}" src="${CRAWLPROOF}/stats.js" async></script>\n</head>`);
|
|
187
187
|
if (ads.slot) {
|
|
188
|
-
|
|
188
|
+
// One line by default: a 300x250 box above the stations pushed the guide itself off the first screen.
|
|
189
|
+
const format = ads.format ?? "text_link";
|
|
189
190
|
const slot = ads.slot;
|
|
190
191
|
// The one ad goes at the top of the content, never against the footer.
|
|
191
192
|
page = page.replace('<p class="lead">Radio and TV, on the air now. Pick one and press play.</p>\n', (lead) => `${lead} <div class="ad-unit ad-${format}"><iframe src="${CRAWLPROOF}/api/ads/frame?slot=${encodeURIComponent(slot)}&format=${format}" title="Advertisement" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" scrolling="no"></iframe></div>\n`);
|
|
192
193
|
}
|
|
194
|
+
// The AI label's artists and albums, under the AI stations they play on.
|
|
195
|
+
if (/^\/[a-z]+$/.test(labelHref)) {
|
|
196
|
+
page = page.replace('<div id="ai" class="grid" aria-live="polite">', `<p class="lead"><a href="${labelHref}">The AI label: every artist, album and song, with the words →</a></p>\n <div id="ai" class="grid" aria-live="polite">`);
|
|
197
|
+
}
|
|
193
198
|
const api = /^https:\/\/[^\s"<>]+$/.test(apiBase) ? apiBase.replace(/\/+$/, "") : "";
|
|
194
199
|
return (page.replace('<p id="empty" class="empty">', entries.length ? '<p id="empty" class="empty" hidden>' : '<p id="empty" class="empty">')).replace("<body>", api ? `<body data-api="${escape(api)}">` : "<body>");
|
|
195
200
|
}
|