nixamp 0.37.0 → 0.37.2
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-routes.js +2 -1
- package/dist/ai-text.js +70 -51
- package/dist/playlist-input.d.ts +1 -1
- package/dist/playlist-input.js +2 -1
- package/dist/server.js +1 -0
- package/dist/station-cli.js +15 -2
- package/dist/station-routes.d.ts +2 -0
- package/dist/station-routes.js +9 -2
- package/dist/station-runner.js +4 -2
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +2 -1
- package/dist/stations.js +2 -0
- package/package.json +1 -1
- package/src/ai-routes.ts +2 -1
- package/src/ai-text.ts +26 -11
- package/src/playlist-input.ts +3 -2
- package/src/server.ts +1 -0
- package/src/station-cli.ts +14 -2
- package/src/station-routes.ts +11 -2
- package/src/station-runner.ts +4 -2
- package/src/station-usage.ts +2 -1
- package/src/stations.ts +1 -0
- package/web/dist/assets/{ar-B2m5eyoV.js → ar-xZN5A1rt.js} +1 -1
- package/web/dist/assets/{cs-Cc5pOkWw.js → cs-5IKqyBbE.js} +1 -1
- package/web/dist/assets/{da-10XVgmQy.js → da-DBaFekyx.js} +1 -1
- package/web/dist/assets/{de-DIBX68fo.js → de-B6H3VK_j.js} +1 -1
- package/web/dist/assets/{es-B5wXCbD_.js → es-V5VmiR3I.js} +1 -1
- package/web/dist/assets/{fi-CfqtJU25.js → fi-t-5zi-lm.js} +1 -1
- package/web/dist/assets/{fr-DVpDbDWM.js → fr-BhBdY55R.js} +1 -1
- package/web/dist/assets/{hi-iQgB7g2O.js → hi-d6OYzuim.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-Hp3aQLSm.js → hls-3VKVEQE3-CTaQJ-LR.js} +1 -1
- package/web/dist/assets/{hu-cL8uAJW0.js → hu-Cir_0ybI.js} +1 -1
- package/web/dist/assets/{id-DAEQSrx8.js → id-hi53eroQ.js} +1 -1
- package/web/dist/assets/{it-DARHJ2-F.js → it-D1Tv8Xj4.js} +1 -1
- package/web/dist/assets/{main-Bdbkg8vi.js → main-EJFH7bJ6.js} +2 -2
- package/web/dist/assets/{mpegts-vNdAhc1w.js → mpegts-DiGKkSCx.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Be6JzNGd.js → mpegts-LO6RVLD6-D0IhHZh9.js} +1 -1
- package/web/dist/assets/{nl-BNskTvYD.js → nl-DzWSQp-N.js} +1 -1
- package/web/dist/assets/{ru-PA8keJv_.js → ru-C3DkT-7u.js} +1 -1
- package/web/dist/assets/{sv-sZu9UVEg.js → sv-v2rOrROy.js} +1 -1
- package/web/dist/assets/{uk-FmHT0lvK.js → uk-DYcssTxg.js} +1 -1
- package/web/dist/assets/{vi-BV81HbEM.js → vi-BTGfoUk5.js} +1 -1
- package/web/dist/assets/{zh-CwzApCu7.js → zh-C3GUwves.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
package/dist/ai-routes.js
CHANGED
|
@@ -94,7 +94,8 @@ export async function handleAiRoutes(request, response, path, url, ctx) {
|
|
|
94
94
|
return true;
|
|
95
95
|
}
|
|
96
96
|
if (method === "POST") {
|
|
97
|
-
|
|
97
|
+
shares.configure(await jsonBody(ctx, request));
|
|
98
|
+
ctx.json(response, 200, { setting: shares.setting() });
|
|
98
99
|
return true;
|
|
99
100
|
}
|
|
100
101
|
}
|
package/dist/ai-text.js
CHANGED
|
@@ -41,62 +41,81 @@ export function hasModel(env = process.env) {
|
|
|
41
41
|
export async function askJson(options) {
|
|
42
42
|
const env = options.env ?? process.env;
|
|
43
43
|
const send = options.fetcher ?? fetch;
|
|
44
|
-
const signal = AbortSignal.timeout(options.timeoutMs ?? 30_000);
|
|
45
44
|
const gemini = aiKey("GEMINI_API_KEY", env);
|
|
46
45
|
const openai = aiKey("OPENAI_API_KEY", env);
|
|
47
46
|
const anthropic = aiKey("ANTHROPIC_API_KEY", env);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
47
|
+
// Each model with a key, in turn: one that refuses this server (Gemini
|
|
48
|
+
// answers 400 "User location is not supported" from some networks) or is
|
|
49
|
+
// down is not a reason to go without when another can answer.
|
|
50
|
+
const tries = [];
|
|
51
|
+
if (gemini)
|
|
52
|
+
tries.push(async () => {
|
|
53
|
+
const model = env["NIXAMP_GEMINI_MODEL"] || "gemini-flash-latest";
|
|
54
|
+
const response = await send(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
|
57
|
+
headers: { "content-type": "application/json", "x-goog-api-key": gemini },
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
systemInstruction: { parts: [{ text: options.system }] },
|
|
60
|
+
contents: [{ role: "user", parts: [{ text: options.prompt }] }],
|
|
61
|
+
generationConfig: { responseMimeType: "application/json", temperature: 0.9 },
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const said = (await response.json().catch(() => ({})));
|
|
66
|
+
throw new Error(`gemini answered HTTP ${response.status}${said.error?.message ? `: ${said.error.message}` : ""}`);
|
|
67
|
+
}
|
|
68
|
+
const body = (await response.json());
|
|
69
|
+
const text = body.candidates?.[0]?.content?.parts?.map((one) => one.text ?? "").join("") ?? "";
|
|
70
|
+
return { model, value: firstObject(text) };
|
|
59
71
|
});
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
72
|
+
if (openai)
|
|
73
|
+
tries.push(async () => {
|
|
74
|
+
const model = env["NIXAMP_OPENAI_MODEL"] || "gpt-4.1-mini";
|
|
75
|
+
const response = await send("https://api.openai.com/v1/chat/completions", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
|
78
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${openai}` },
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
model,
|
|
81
|
+
response_format: { type: "json_object" },
|
|
82
|
+
messages: [{ role: "system", content: options.system }, { role: "user", content: options.prompt }],
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
if (!response.ok)
|
|
86
|
+
throw new Error(`openai answered HTTP ${response.status}`);
|
|
87
|
+
const body = (await response.json());
|
|
88
|
+
return { model, value: firstObject(body.choices?.[0]?.message?.content ?? "") };
|
|
77
89
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
if (anthropic)
|
|
91
|
+
tries.push(async () => {
|
|
92
|
+
const model = env["NIXAMP_ANTHROPIC_MODEL"] || "claude-haiku-5-5";
|
|
93
|
+
const response = await send("https://api.anthropic.com/v1/messages", {
|
|
94
|
+
method: "POST",
|
|
95
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
|
96
|
+
headers: { "content-type": "application/json", "x-api-key": anthropic, "anthropic-version": "2023-06-01" },
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
model,
|
|
99
|
+
max_tokens: 1024,
|
|
100
|
+
system: `${options.system}\nAnswer with one JSON object and nothing else.`,
|
|
101
|
+
messages: [{ role: "user", content: options.prompt }],
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
if (!response.ok)
|
|
105
|
+
throw new Error(`anthropic answered HTTP ${response.status}`);
|
|
106
|
+
const body = (await response.json());
|
|
107
|
+
return { model, value: firstObject(body.content?.filter((one) => one.type === "text").map((one) => one.text ?? "").join("") ?? "") };
|
|
95
108
|
});
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
if (tries.length === 0)
|
|
110
|
+
throw new NoModel();
|
|
111
|
+
const failures = [];
|
|
112
|
+
for (const attempt of tries) {
|
|
113
|
+
try {
|
|
114
|
+
return await attempt();
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
failures.push(error.message);
|
|
118
|
+
}
|
|
100
119
|
}
|
|
101
|
-
throw new
|
|
120
|
+
throw new Error(failures.join("; "));
|
|
102
121
|
}
|
package/dist/playlist-input.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export interface PlaylistInput {
|
|
|
35
35
|
* this time. The break is outside the rotation and the station's history.
|
|
36
36
|
*/
|
|
37
37
|
breaks?: {
|
|
38
|
-
everySeconds: number;
|
|
38
|
+
everySeconds: number | (() => number);
|
|
39
39
|
next: () => Promise<string | null>;
|
|
40
40
|
};
|
|
41
41
|
/** Told when an entry is passed over because it would not play. */
|
package/dist/playlist-input.js
CHANGED
|
@@ -97,7 +97,8 @@ export class PlaylistInputStream {
|
|
|
97
97
|
// A break, when one is due: in place of the next entry, which stays
|
|
98
98
|
// next. Not at the very start, and never inside an entry.
|
|
99
99
|
let breakFile = null;
|
|
100
|
-
|
|
100
|
+
const every = typeof o.breaks?.everySeconds === "function" ? o.breaks.everySeconds() : (o.breaks?.everySeconds ?? 0);
|
|
101
|
+
if (o.breaks && every > 0 && !first && offset - lastBreak >= every) {
|
|
101
102
|
lastBreak = offset;
|
|
102
103
|
breakFile = await o.breaks.next().catch(() => null);
|
|
103
104
|
if (signal.aborted)
|
package/dist/server.js
CHANGED
|
@@ -2335,6 +2335,7 @@ export function createHandler(engine, options) {
|
|
|
2335
2335
|
return selectStationFiles(engine, body);
|
|
2336
2336
|
},
|
|
2337
2337
|
trackFor: (file) => trackFor(engine, file),
|
|
2338
|
+
...(options.library ? { isVideo: (file) => options.library.index.itemAt(file)?.video === true } : {}),
|
|
2338
2339
|
base: `${scheme}://${request.headers.host ?? "localhost"}`,
|
|
2339
2340
|
publicBase: options.stations.publicBase?.() ?? "",
|
|
2340
2341
|
json,
|
package/dist/station-cli.js
CHANGED
|
@@ -87,6 +87,7 @@ export async function station(argv) {
|
|
|
87
87
|
` state ${String(station["state"])} (wanted ${String(station["desired"])})`,
|
|
88
88
|
` visibility ${String(station["visibility"])}${station["listed"] ? ", listed in the directory" : ""}`,
|
|
89
89
|
` rotation ${station["shuffle"] ? "shuffle" : "in order"}${station["repeat"] ? ", repeating" : ", once"}; ${station["items"]?.length ?? 0} files at ${String(station["bitrate"])} kbps`,
|
|
90
|
+
` breaks ${Number(station["breaksEvery"] ?? 0) > 0 ? `a spot every ${Math.round(Number(station["breaksEvery"]) / 60)} min, between entries` : "none"}`,
|
|
90
91
|
...(station["error"] ? [` problem ${String(station["error"])}`] : []),
|
|
91
92
|
...(station["excluded"]?.length ? [` left out ${station["excluded"].length} files (nixamp station retry)`] : []),
|
|
92
93
|
...(station["visibility"] !== "private" && links["page"] ? [` listen ${links["page"]}`] : []),
|
|
@@ -127,6 +128,7 @@ export async function station(argv) {
|
|
|
127
128
|
shuffle: has(argv, "--shuffle"),
|
|
128
129
|
repeat: !has(argv, "--no-repeat"),
|
|
129
130
|
...(bitrate ? { bitrate: Number(bitrate) } : {}),
|
|
131
|
+
...(flag(argv, "--breaks") ? { breaksEvery: Number(flag(argv, "--breaks")) } : {}),
|
|
130
132
|
});
|
|
131
133
|
return show(answer, (body) => `Made (private, stopped):\n${describe(body["station"])}`);
|
|
132
134
|
}
|
|
@@ -146,6 +148,17 @@ export async function station(argv) {
|
|
|
146
148
|
return 2;
|
|
147
149
|
return show(await call("POST", `/api/v1/stations/${station}/${command}`, {}), (body) => describe(body["station"]));
|
|
148
150
|
}
|
|
151
|
+
case "breaks": {
|
|
152
|
+
const station = needId();
|
|
153
|
+
if (!station)
|
|
154
|
+
return 2;
|
|
155
|
+
const seconds = argv.includes("--off") ? 0 : Number(argv[argv.indexOf(station) + 1]);
|
|
156
|
+
if (!Number.isFinite(seconds)) {
|
|
157
|
+
console.error("nixamp station breaks ID SECONDS (or --off)");
|
|
158
|
+
return 2;
|
|
159
|
+
}
|
|
160
|
+
return show(await call("PATCH", `/api/v1/stations/${station}`, { breaksEvery: seconds }), (body) => describe(body["station"]));
|
|
161
|
+
}
|
|
149
162
|
case "publish": {
|
|
150
163
|
const station = needId();
|
|
151
164
|
if (!station)
|
|
@@ -265,7 +278,7 @@ async function aiCommand(argv, call, show, json) {
|
|
|
265
278
|
const describe = (one) => [
|
|
266
279
|
`${String(one["name"])} [${String(one["key"])}]${one["enabled"] ? "" : " (off)"}`,
|
|
267
280
|
` style ${String(one["style"])}`,
|
|
268
|
-
` tracks ${
|
|
281
|
+
` tracks ${Array.isArray(one["tracks"]) ? one["tracks"].length : Number(one["tracks"] ?? 0)} of ${String(one["trackSeconds"])} s, ${String(one["perDay"])} a day${one["vocals"] ? ", with vocals" : ""}`,
|
|
269
282
|
` station ${one["stationId"] ? String(one["stationId"]) : "made with its first track"}${one["publish"] ? ", public on the guide" : ", unlisted"}`,
|
|
270
283
|
...(one["lastError"] ? [` problem ${String(one["lastError"])}`] : []),
|
|
271
284
|
].join("\n");
|
|
@@ -325,7 +338,7 @@ async function aiCommand(argv, call, show, json) {
|
|
|
325
338
|
...(has(argv, "--off") ? { enabled: false } : has(argv, "--on") ? { enabled: true } : {}),
|
|
326
339
|
...(has(argv, "--vocals") ? { vocals: true } : has(argv, "--instrumental") ? { vocals: false } : {}),
|
|
327
340
|
};
|
|
328
|
-
return show(await call("PATCH", `/api/v1/stations/ai/stations/${key}`, body), (answer) => describe(
|
|
341
|
+
return show(await call("PATCH", `/api/v1/stations/ai/stations/${key}`, body), (answer) => describe(answer["station"]));
|
|
329
342
|
}
|
|
330
343
|
case "remove": {
|
|
331
344
|
if (!key) {
|
package/dist/station-routes.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface StationContext {
|
|
|
38
38
|
artist: string;
|
|
39
39
|
album: string;
|
|
40
40
|
} | null;
|
|
41
|
+
/** Whether the library knows this file as moving picture; absent in tests that do not care. */
|
|
42
|
+
isVideo?: (file: string) => boolean;
|
|
41
43
|
/** This server's address as the asker reached it, for links. */
|
|
42
44
|
base: string;
|
|
43
45
|
/**
|
package/dist/station-routes.js
CHANGED
|
@@ -35,6 +35,9 @@ export function publicView(ctx, station) {
|
|
|
35
35
|
const file = state === "live" || state === "recovering" ? nowPlayingFile(ctx, station) : "";
|
|
36
36
|
// The tags the player read when it put this file on, else the library's.
|
|
37
37
|
const track = file ? (ctx.channels.tagsOf(channelOf(station.id), file) ?? ctx.trackFor(file)) : null;
|
|
38
|
+
// What the channel is carrying wins over the flag: a station made by hand
|
|
39
|
+
// from films streams video whether or not anyone said so.
|
|
40
|
+
const video = station.video || ctx.channels.kindOf(channelOf(station.id)) === "video";
|
|
38
41
|
return {
|
|
39
42
|
id: station.id,
|
|
40
43
|
name: station.name,
|
|
@@ -43,8 +46,8 @@ export function publicView(ctx, station) {
|
|
|
43
46
|
state,
|
|
44
47
|
// Tags, or nothing: a bare file name is the owner's, not the public's.
|
|
45
48
|
nowPlaying: track && (track.title || track.artist) ? track : null,
|
|
46
|
-
kind:
|
|
47
|
-
format: state === "stopped" || state === "offline" ? null :
|
|
49
|
+
kind: video ? "video" : "audio",
|
|
50
|
+
format: state === "stopped" || state === "offline" ? null : video
|
|
48
51
|
? { codec: "h264+aac", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 }
|
|
49
52
|
: { codec: "mp3", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 },
|
|
50
53
|
listeners: ctx.runner.listeners(station.id),
|
|
@@ -277,6 +280,10 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
277
280
|
const items = ctx.select(input);
|
|
278
281
|
if (items === null)
|
|
279
282
|
throw new StationError("items_required", "choose files by track index (items) or a folder");
|
|
283
|
+
// Unsaid, a station is TV when most of its files are.
|
|
284
|
+
if (input["video"] === undefined && ctx.isVideo) {
|
|
285
|
+
input["video"] = items.filter((file) => ctx.isVideo(file)).length * 2 > items.length;
|
|
286
|
+
}
|
|
280
287
|
const station = ctx.store.create(input, items, ctx.account, Date.now(), key);
|
|
281
288
|
ctx.json(response, 201, { station: ownerView(ctx, station), created: true });
|
|
282
289
|
return true;
|
package/dist/station-runner.js
CHANGED
|
@@ -107,8 +107,10 @@ export class StationRunner {
|
|
|
107
107
|
return now ? this.playable(now) : [];
|
|
108
108
|
},
|
|
109
109
|
excluded: (file) => store.isExcluded(id, file),
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
// Read at every boundary, so setting breaks on a running station
|
|
111
|
+
// lands without a restart; 0 is none.
|
|
112
|
+
...(this.deps.breaks
|
|
113
|
+
? { breaks: { everySeconds: () => store.get(id)?.breaksEvery ?? 0, next: () => this.deps.breaks(station.video) } }
|
|
112
114
|
: {}),
|
|
113
115
|
onFailure: ({ file, reason }) => {
|
|
114
116
|
this.errors.set(id, reason);
|
package/dist/station-usage.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** `nixamp station` usage, apart so `nixamp help station` does not load the client. */
|
|
2
|
-
export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY, from stdin\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
|
|
2
|
+
export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY, from stdin\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
|
package/dist/station-usage.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
export const USAGE = `nixamp station — make a collection into a station
|
|
3
3
|
|
|
4
4
|
nixamp station list
|
|
5
|
-
nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]
|
|
5
|
+
nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600]
|
|
6
6
|
nixamp station show ID
|
|
7
7
|
nixamp station status ID
|
|
8
8
|
nixamp station start ID
|
|
9
9
|
nixamp station stop ID
|
|
10
10
|
nixamp station skip ID
|
|
11
|
+
nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)
|
|
11
12
|
nixamp station retry ID try every left-out file again
|
|
12
13
|
nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]
|
|
13
14
|
nixamp station unpublish ID [--private]
|
package/dist/stations.js
CHANGED
|
@@ -231,6 +231,8 @@ export class StationStore {
|
|
|
231
231
|
station.bitrate = this.bitrateFrom(input.bitrate);
|
|
232
232
|
if (input.breaksEvery !== undefined)
|
|
233
233
|
station.breaksEvery = this.breaksFrom(input.breaksEvery);
|
|
234
|
+
if (input.video !== undefined)
|
|
235
|
+
station.video = input.video === true;
|
|
234
236
|
if (items !== null) {
|
|
235
237
|
if (items.length === 0)
|
|
236
238
|
throw new StationError("empty_station", "a station needs at least one file that plays");
|
package/package.json
CHANGED
package/src/ai-routes.ts
CHANGED
|
@@ -127,7 +127,8 @@ export async function handleAiRoutes(
|
|
|
127
127
|
return true;
|
|
128
128
|
}
|
|
129
129
|
if (method === "POST") {
|
|
130
|
-
|
|
130
|
+
shares.configure(await jsonBody(ctx, request));
|
|
131
|
+
ctx.json(response, 200, { setting: shares.setting() });
|
|
131
132
|
return true;
|
|
132
133
|
}
|
|
133
134
|
}
|
package/src/ai-text.ts
CHANGED
|
@@ -59,15 +59,18 @@ export function hasModel(env: Record<string, string | undefined> = process.env):
|
|
|
59
59
|
export async function askJson(options: AskOptions): Promise<Answer> {
|
|
60
60
|
const env = options.env ?? process.env;
|
|
61
61
|
const send = options.fetcher ?? fetch;
|
|
62
|
-
const signal = AbortSignal.timeout(options.timeoutMs ?? 30_000);
|
|
63
62
|
const gemini = aiKey("GEMINI_API_KEY", env);
|
|
64
63
|
const openai = aiKey("OPENAI_API_KEY", env);
|
|
65
64
|
const anthropic = aiKey("ANTHROPIC_API_KEY", env);
|
|
66
|
-
|
|
65
|
+
// Each model with a key, in turn: one that refuses this server (Gemini
|
|
66
|
+
// answers 400 "User location is not supported" from some networks) or is
|
|
67
|
+
// down is not a reason to go without when another can answer.
|
|
68
|
+
const tries: Array<() => Promise<Answer>> = [];
|
|
69
|
+
if (gemini) tries.push(async () => {
|
|
67
70
|
const model = env["NIXAMP_GEMINI_MODEL"] || "gemini-flash-latest";
|
|
68
71
|
const response = await send(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
|
|
69
72
|
method: "POST",
|
|
70
|
-
signal,
|
|
73
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
|
71
74
|
headers: { "content-type": "application/json", "x-goog-api-key": gemini },
|
|
72
75
|
body: JSON.stringify({
|
|
73
76
|
systemInstruction: { parts: [{ text: options.system }] },
|
|
@@ -75,16 +78,19 @@ export async function askJson(options: AskOptions): Promise<Answer> {
|
|
|
75
78
|
generationConfig: { responseMimeType: "application/json", temperature: 0.9 },
|
|
76
79
|
}),
|
|
77
80
|
});
|
|
78
|
-
if (!response.ok)
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const said = (await response.json().catch(() => ({}))) as { error?: { message?: string } };
|
|
83
|
+
throw new Error(`gemini answered HTTP ${response.status}${said.error?.message ? `: ${said.error.message}` : ""}`);
|
|
84
|
+
}
|
|
79
85
|
const body = (await response.json()) as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> };
|
|
80
86
|
const text = body.candidates?.[0]?.content?.parts?.map((one) => one.text ?? "").join("") ?? "";
|
|
81
87
|
return { model, value: firstObject(text) };
|
|
82
|
-
}
|
|
83
|
-
if (openai) {
|
|
88
|
+
});
|
|
89
|
+
if (openai) tries.push(async () => {
|
|
84
90
|
const model = env["NIXAMP_OPENAI_MODEL"] || "gpt-4.1-mini";
|
|
85
91
|
const response = await send("https://api.openai.com/v1/chat/completions", {
|
|
86
92
|
method: "POST",
|
|
87
|
-
signal,
|
|
93
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
|
88
94
|
headers: { "content-type": "application/json", authorization: `Bearer ${openai}` },
|
|
89
95
|
body: JSON.stringify({
|
|
90
96
|
model,
|
|
@@ -95,12 +101,12 @@ export async function askJson(options: AskOptions): Promise<Answer> {
|
|
|
95
101
|
if (!response.ok) throw new Error(`openai answered HTTP ${response.status}`);
|
|
96
102
|
const body = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
|
97
103
|
return { model, value: firstObject(body.choices?.[0]?.message?.content ?? "") };
|
|
98
|
-
}
|
|
99
|
-
if (anthropic) {
|
|
104
|
+
});
|
|
105
|
+
if (anthropic) tries.push(async () => {
|
|
100
106
|
const model = env["NIXAMP_ANTHROPIC_MODEL"] || "claude-haiku-5-5";
|
|
101
107
|
const response = await send("https://api.anthropic.com/v1/messages", {
|
|
102
108
|
method: "POST",
|
|
103
|
-
signal,
|
|
109
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
|
104
110
|
headers: { "content-type": "application/json", "x-api-key": anthropic, "anthropic-version": "2023-06-01" },
|
|
105
111
|
body: JSON.stringify({
|
|
106
112
|
model,
|
|
@@ -112,6 +118,15 @@ export async function askJson(options: AskOptions): Promise<Answer> {
|
|
|
112
118
|
if (!response.ok) throw new Error(`anthropic answered HTTP ${response.status}`);
|
|
113
119
|
const body = (await response.json()) as { content?: Array<{ type: string; text?: string }> };
|
|
114
120
|
return { model, value: firstObject(body.content?.filter((one) => one.type === "text").map((one) => one.text ?? "").join("") ?? "") };
|
|
121
|
+
});
|
|
122
|
+
if (tries.length === 0) throw new NoModel();
|
|
123
|
+
const failures: string[] = [];
|
|
124
|
+
for (const attempt of tries) {
|
|
125
|
+
try {
|
|
126
|
+
return await attempt();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
failures.push((error as Error).message);
|
|
129
|
+
}
|
|
115
130
|
}
|
|
116
|
-
throw new
|
|
131
|
+
throw new Error(failures.join("; "));
|
|
117
132
|
}
|
package/src/playlist-input.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface PlaylistInput {
|
|
|
39
39
|
* `next` answers a file to play (an advert, an ident) or null for no break
|
|
40
40
|
* this time. The break is outside the rotation and the station's history.
|
|
41
41
|
*/
|
|
42
|
-
breaks?: { everySeconds: number; next: () => Promise<string | null> };
|
|
42
|
+
breaks?: { everySeconds: number | (() => number); next: () => Promise<string | null> };
|
|
43
43
|
/** Told when an entry is passed over because it would not play. */
|
|
44
44
|
onFailure?: (event: { at: number; file: string; reason: string }) => void;
|
|
45
45
|
}
|
|
@@ -138,7 +138,8 @@ export class PlaylistInputStream {
|
|
|
138
138
|
// A break, when one is due: in place of the next entry, which stays
|
|
139
139
|
// next. Not at the very start, and never inside an entry.
|
|
140
140
|
let breakFile: string | null = null;
|
|
141
|
-
|
|
141
|
+
const every = typeof o.breaks?.everySeconds === "function" ? o.breaks.everySeconds() : (o.breaks?.everySeconds ?? 0);
|
|
142
|
+
if (o.breaks && every > 0 && !first && offset - lastBreak >= every) {
|
|
142
143
|
lastBreak = offset;
|
|
143
144
|
breakFile = await o.breaks.next().catch(() => null);
|
|
144
145
|
if (signal.aborted) return;
|
package/src/server.ts
CHANGED
|
@@ -2861,6 +2861,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2861
2861
|
return selectStationFiles(engine, body);
|
|
2862
2862
|
},
|
|
2863
2863
|
trackFor: (file) => trackFor(engine, file),
|
|
2864
|
+
...(options.library ? { isVideo: (file: string) => options.library!.index.itemAt(file)?.video === true } : {}),
|
|
2864
2865
|
base: `${scheme}://${request.headers.host ?? "localhost"}`,
|
|
2865
2866
|
publicBase: options.stations.publicBase?.() ?? "",
|
|
2866
2867
|
json,
|
package/src/station-cli.ts
CHANGED
|
@@ -88,6 +88,7 @@ export async function station(argv: string[]): Promise<number> {
|
|
|
88
88
|
` state ${String(station["state"])} (wanted ${String(station["desired"])})`,
|
|
89
89
|
` visibility ${String(station["visibility"])}${station["listed"] ? ", listed in the directory" : ""}`,
|
|
90
90
|
` rotation ${station["shuffle"] ? "shuffle" : "in order"}${station["repeat"] ? ", repeating" : ", once"}; ${(station["items"] as unknown[] | undefined)?.length ?? 0} files at ${String(station["bitrate"])} kbps`,
|
|
91
|
+
` breaks ${Number(station["breaksEvery"] ?? 0) > 0 ? `a spot every ${Math.round(Number(station["breaksEvery"]) / 60)} min, between entries` : "none"}`,
|
|
91
92
|
...(station["error"] ? [` problem ${String(station["error"])}`] : []),
|
|
92
93
|
...((station["excluded"] as unknown[] | undefined)?.length ? [` left out ${(station["excluded"] as unknown[]).length} files (nixamp station retry)`] : []),
|
|
93
94
|
...(station["visibility"] !== "private" && links["page"] ? [` listen ${links["page"]}`] : []),
|
|
@@ -128,6 +129,7 @@ export async function station(argv: string[]): Promise<number> {
|
|
|
128
129
|
shuffle: has(argv, "--shuffle"),
|
|
129
130
|
repeat: !has(argv, "--no-repeat"),
|
|
130
131
|
...(bitrate ? { bitrate: Number(bitrate) } : {}),
|
|
132
|
+
...(flag(argv, "--breaks") ? { breaksEvery: Number(flag(argv, "--breaks")) } : {}),
|
|
131
133
|
});
|
|
132
134
|
return show(answer, (body) => `Made (private, stopped):\n${describe(body["station"] as Record<string, unknown>)}`);
|
|
133
135
|
}
|
|
@@ -145,6 +147,16 @@ export async function station(argv: string[]): Promise<number> {
|
|
|
145
147
|
if (!station) return 2;
|
|
146
148
|
return show(await call("POST", `/api/v1/stations/${station}/${command}`, {}), (body) => describe(body["station"] as Record<string, unknown>));
|
|
147
149
|
}
|
|
150
|
+
case "breaks": {
|
|
151
|
+
const station = needId();
|
|
152
|
+
if (!station) return 2;
|
|
153
|
+
const seconds = argv.includes("--off") ? 0 : Number(argv[argv.indexOf(station) + 1]);
|
|
154
|
+
if (!Number.isFinite(seconds)) {
|
|
155
|
+
console.error("nixamp station breaks ID SECONDS (or --off)");
|
|
156
|
+
return 2;
|
|
157
|
+
}
|
|
158
|
+
return show(await call("PATCH", `/api/v1/stations/${station}`, { breaksEvery: seconds }), (body) => describe(body["station"] as Record<string, unknown>));
|
|
159
|
+
}
|
|
148
160
|
case "publish": {
|
|
149
161
|
const station = needId();
|
|
150
162
|
if (!station) return 2;
|
|
@@ -259,7 +271,7 @@ async function aiCommand(argv: string[], call: Call, show: Show, json: boolean):
|
|
|
259
271
|
const describe = (one: Record<string, unknown>): string => [
|
|
260
272
|
`${String(one["name"])} [${String(one["key"])}]${one["enabled"] ? "" : " (off)"}`,
|
|
261
273
|
` style ${String(one["style"])}`,
|
|
262
|
-
` tracks ${
|
|
274
|
+
` tracks ${Array.isArray(one["tracks"]) ? one["tracks"].length : Number(one["tracks"] ?? 0)} of ${String(one["trackSeconds"])} s, ${String(one["perDay"])} a day${one["vocals"] ? ", with vocals" : ""}`,
|
|
263
275
|
` station ${one["stationId"] ? String(one["stationId"]) : "made with its first track"}${one["publish"] ? ", public on the guide" : ", unlisted"}`,
|
|
264
276
|
...(one["lastError"] ? [` problem ${String(one["lastError"])}`] : []),
|
|
265
277
|
].join("\n");
|
|
@@ -313,7 +325,7 @@ async function aiCommand(argv: string[], call: Call, show: Show, json: boolean):
|
|
|
313
325
|
...(has(argv, "--off") ? { enabled: false } : has(argv, "--on") ? { enabled: true } : {}),
|
|
314
326
|
...(has(argv, "--vocals") ? { vocals: true } : has(argv, "--instrumental") ? { vocals: false } : {}),
|
|
315
327
|
};
|
|
316
|
-
return show(await call("PATCH", `/api/v1/stations/ai/stations/${key}`, body), (answer) => describe(
|
|
328
|
+
return show(await call("PATCH", `/api/v1/stations/ai/stations/${key}`, body), (answer) => describe(answer["station"] as Record<string, unknown>));
|
|
317
329
|
}
|
|
318
330
|
case "remove": {
|
|
319
331
|
if (!key) { console.error("nixamp station ai remove needs the AI station's key"); return 2; }
|
package/src/station-routes.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface StationContext {
|
|
|
40
40
|
select: (body: { items?: unknown; folder?: unknown }) => string[] | null;
|
|
41
41
|
/** What the library knows about a file, for now playing. */
|
|
42
42
|
trackFor: (file: string) => { title: string; artist: string; album: string } | null;
|
|
43
|
+
/** Whether the library knows this file as moving picture; absent in tests that do not care. */
|
|
44
|
+
isVideo?: (file: string) => boolean;
|
|
43
45
|
/** This server's address as the asker reached it, for links. */
|
|
44
46
|
base: string;
|
|
45
47
|
/**
|
|
@@ -92,6 +94,9 @@ export function publicView(ctx: StationContext, station: Station): PublicStation
|
|
|
92
94
|
const file = state === "live" || state === "recovering" ? nowPlayingFile(ctx, station) : "";
|
|
93
95
|
// The tags the player read when it put this file on, else the library's.
|
|
94
96
|
const track = file ? (ctx.channels.tagsOf(channelOf(station.id), file) ?? ctx.trackFor(file)) : null;
|
|
97
|
+
// What the channel is carrying wins over the flag: a station made by hand
|
|
98
|
+
// from films streams video whether or not anyone said so.
|
|
99
|
+
const video = station.video || ctx.channels.kindOf(channelOf(station.id)) === "video";
|
|
95
100
|
return {
|
|
96
101
|
id: station.id,
|
|
97
102
|
name: station.name,
|
|
@@ -100,8 +105,8 @@ export function publicView(ctx: StationContext, station: Station): PublicStation
|
|
|
100
105
|
state,
|
|
101
106
|
// Tags, or nothing: a bare file name is the owner's, not the public's.
|
|
102
107
|
nowPlaying: track && (track.title || track.artist) ? track : null,
|
|
103
|
-
kind:
|
|
104
|
-
format: state === "stopped" || state === "offline" ? null :
|
|
108
|
+
kind: video ? "video" : "audio",
|
|
109
|
+
format: state === "stopped" || state === "offline" ? null : video
|
|
105
110
|
? { codec: "h264+aac", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 }
|
|
106
111
|
: { codec: "mp3", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 },
|
|
107
112
|
listeners: ctx.runner.listeners(station.id),
|
|
@@ -342,6 +347,10 @@ export async function handleStations(
|
|
|
342
347
|
}
|
|
343
348
|
const items = ctx.select(input);
|
|
344
349
|
if (items === null) throw new StationError("items_required", "choose files by track index (items) or a folder");
|
|
350
|
+
// Unsaid, a station is TV when most of its files are.
|
|
351
|
+
if (input["video"] === undefined && ctx.isVideo) {
|
|
352
|
+
input["video"] = items.filter((file) => ctx.isVideo!(file)).length * 2 > items.length;
|
|
353
|
+
}
|
|
345
354
|
const station = ctx.store.create(input, items, ctx.account, Date.now(), key);
|
|
346
355
|
ctx.json(response, 201, { station: ownerView(ctx, station), created: true });
|
|
347
356
|
return true;
|
package/src/station-runner.ts
CHANGED
|
@@ -145,8 +145,10 @@ export class StationRunner {
|
|
|
145
145
|
return now ? this.playable(now) : [];
|
|
146
146
|
},
|
|
147
147
|
excluded: (file) => store.isExcluded(id, file),
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
// Read at every boundary, so setting breaks on a running station
|
|
149
|
+
// lands without a restart; 0 is none.
|
|
150
|
+
...(this.deps.breaks
|
|
151
|
+
? { breaks: { everySeconds: () => store.get(id)?.breaksEvery ?? 0, next: () => this.deps.breaks!(station.video) } }
|
|
150
152
|
: {}),
|
|
151
153
|
onFailure: ({ file, reason }) => {
|
|
152
154
|
this.errors.set(id, reason);
|
package/src/station-usage.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
export const USAGE = `nixamp station — make a collection into a station
|
|
3
3
|
|
|
4
4
|
nixamp station list
|
|
5
|
-
nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]
|
|
5
|
+
nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600]
|
|
6
6
|
nixamp station show ID
|
|
7
7
|
nixamp station status ID
|
|
8
8
|
nixamp station start ID
|
|
9
9
|
nixamp station stop ID
|
|
10
10
|
nixamp station skip ID
|
|
11
|
+
nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)
|
|
11
12
|
nixamp station retry ID try every left-out file again
|
|
12
13
|
nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]
|
|
13
14
|
nixamp station unpublish ID [--private]
|
package/src/stations.ts
CHANGED
|
@@ -284,6 +284,7 @@ export class StationStore {
|
|
|
284
284
|
if (input.repeat !== undefined) station.repeat = input.repeat !== false;
|
|
285
285
|
if (input.bitrate !== undefined) station.bitrate = this.bitrateFrom(input.bitrate);
|
|
286
286
|
if (input.breaksEvery !== undefined) station.breaksEvery = this.breaksFrom(input.breaksEvery);
|
|
287
|
+
if (input.video !== undefined) station.video = input.video === true;
|
|
287
288
|
if (items !== null) {
|
|
288
289
|
if (items.length === 0) throw new StationError("empty_station", "a station needs at least one file that plays");
|
|
289
290
|
if (items.length > MAX_ITEMS) throw new StationError("too_many_items", `a station holds at most ${MAX_ITEMS} files`);
|