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.
Files changed (45) hide show
  1. package/dist/ai-routes.js +2 -1
  2. package/dist/ai-text.js +70 -51
  3. package/dist/playlist-input.d.ts +1 -1
  4. package/dist/playlist-input.js +2 -1
  5. package/dist/server.js +1 -0
  6. package/dist/station-cli.js +15 -2
  7. package/dist/station-routes.d.ts +2 -0
  8. package/dist/station-routes.js +9 -2
  9. package/dist/station-runner.js +4 -2
  10. package/dist/station-usage.d.ts +1 -1
  11. package/dist/station-usage.js +2 -1
  12. package/dist/stations.js +2 -0
  13. package/package.json +1 -1
  14. package/src/ai-routes.ts +2 -1
  15. package/src/ai-text.ts +26 -11
  16. package/src/playlist-input.ts +3 -2
  17. package/src/server.ts +1 -0
  18. package/src/station-cli.ts +14 -2
  19. package/src/station-routes.ts +11 -2
  20. package/src/station-runner.ts +4 -2
  21. package/src/station-usage.ts +2 -1
  22. package/src/stations.ts +1 -0
  23. package/web/dist/assets/{ar-B2m5eyoV.js → ar-xZN5A1rt.js} +1 -1
  24. package/web/dist/assets/{cs-Cc5pOkWw.js → cs-5IKqyBbE.js} +1 -1
  25. package/web/dist/assets/{da-10XVgmQy.js → da-DBaFekyx.js} +1 -1
  26. package/web/dist/assets/{de-DIBX68fo.js → de-B6H3VK_j.js} +1 -1
  27. package/web/dist/assets/{es-B5wXCbD_.js → es-V5VmiR3I.js} +1 -1
  28. package/web/dist/assets/{fi-CfqtJU25.js → fi-t-5zi-lm.js} +1 -1
  29. package/web/dist/assets/{fr-DVpDbDWM.js → fr-BhBdY55R.js} +1 -1
  30. package/web/dist/assets/{hi-iQgB7g2O.js → hi-d6OYzuim.js} +1 -1
  31. package/web/dist/assets/{hls-3VKVEQE3-Hp3aQLSm.js → hls-3VKVEQE3-CTaQJ-LR.js} +1 -1
  32. package/web/dist/assets/{hu-cL8uAJW0.js → hu-Cir_0ybI.js} +1 -1
  33. package/web/dist/assets/{id-DAEQSrx8.js → id-hi53eroQ.js} +1 -1
  34. package/web/dist/assets/{it-DARHJ2-F.js → it-D1Tv8Xj4.js} +1 -1
  35. package/web/dist/assets/{main-Bdbkg8vi.js → main-EJFH7bJ6.js} +2 -2
  36. package/web/dist/assets/{mpegts-vNdAhc1w.js → mpegts-DiGKkSCx.js} +1 -1
  37. package/web/dist/assets/{mpegts-LO6RVLD6-Be6JzNGd.js → mpegts-LO6RVLD6-D0IhHZh9.js} +1 -1
  38. package/web/dist/assets/{nl-BNskTvYD.js → nl-DzWSQp-N.js} +1 -1
  39. package/web/dist/assets/{ru-PA8keJv_.js → ru-C3DkT-7u.js} +1 -1
  40. package/web/dist/assets/{sv-sZu9UVEg.js → sv-v2rOrROy.js} +1 -1
  41. package/web/dist/assets/{uk-FmHT0lvK.js → uk-DYcssTxg.js} +1 -1
  42. package/web/dist/assets/{vi-BV81HbEM.js → vi-BTGfoUk5.js} +1 -1
  43. package/web/dist/assets/{zh-CwzApCu7.js → zh-C3GUwves.js} +1 -1
  44. package/web/dist/index.html +1 -1
  45. 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
- ctx.json(response, 200, { setting: shares.configure(await jsonBody(ctx, request)) });
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
- if (gemini) {
49
- const model = env["NIXAMP_GEMINI_MODEL"] || "gemini-flash-latest";
50
- const response = await send(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
51
- method: "POST",
52
- signal,
53
- headers: { "content-type": "application/json", "x-goog-api-key": gemini },
54
- body: JSON.stringify({
55
- systemInstruction: { parts: [{ text: options.system }] },
56
- contents: [{ role: "user", parts: [{ text: options.prompt }] }],
57
- generationConfig: { responseMimeType: "application/json", temperature: 0.9 },
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
- if (!response.ok)
61
- throw new Error(`gemini answered HTTP ${response.status}`);
62
- const body = (await response.json());
63
- const text = body.candidates?.[0]?.content?.parts?.map((one) => one.text ?? "").join("") ?? "";
64
- return { model, value: firstObject(text) };
65
- }
66
- if (openai) {
67
- const model = env["NIXAMP_OPENAI_MODEL"] || "gpt-4.1-mini";
68
- const response = await send("https://api.openai.com/v1/chat/completions", {
69
- method: "POST",
70
- signal,
71
- headers: { "content-type": "application/json", authorization: `Bearer ${openai}` },
72
- body: JSON.stringify({
73
- model,
74
- response_format: { type: "json_object" },
75
- messages: [{ role: "system", content: options.system }, { role: "user", content: options.prompt }],
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
- if (!response.ok)
79
- throw new Error(`openai answered HTTP ${response.status}`);
80
- const body = (await response.json());
81
- return { model, value: firstObject(body.choices?.[0]?.message?.content ?? "") };
82
- }
83
- if (anthropic) {
84
- const model = env["NIXAMP_ANTHROPIC_MODEL"] || "claude-haiku-5-5";
85
- const response = await send("https://api.anthropic.com/v1/messages", {
86
- method: "POST",
87
- signal,
88
- headers: { "content-type": "application/json", "x-api-key": anthropic, "anthropic-version": "2023-06-01" },
89
- body: JSON.stringify({
90
- model,
91
- max_tokens: 1024,
92
- system: `${options.system}\nAnswer with one JSON object and nothing else.`,
93
- messages: [{ role: "user", content: options.prompt }],
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
- if (!response.ok)
97
- throw new Error(`anthropic answered HTTP ${response.status}`);
98
- const body = (await response.json());
99
- return { model, value: firstObject(body.content?.filter((one) => one.type === "text").map((one) => one.text ?? "").join("") ?? "") };
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 NoModel();
120
+ throw new Error(failures.join("; "));
102
121
  }
@@ -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. */
@@ -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
- if (o.breaks && !first && offset - lastBreak >= o.breaks.everySeconds) {
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,
@@ -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 ${String(one["tracks"] ?? one["tracks"]?.length ?? 0)} of ${String(one["trackSeconds"])} s, ${String(one["perDay"])} a day${one["vocals"] ? ", with vocals" : ""}`,
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({ ...answer["station"], tracks: answer["station"]["tracks"].length }));
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) {
@@ -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
  /**
@@ -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: station.video ? "video" : "audio",
47
- format: state === "stopped" || state === "offline" ? null : station.video
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;
@@ -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
- ...(station.breaksEvery > 0 && this.deps.breaks
111
- ? { breaks: { everySeconds: station.breaksEvery, next: () => this.deps.breaks(station.video) } }
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);
@@ -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.";
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.37.0",
3
+ "version": "0.37.2",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- ctx.json(response, 200, { setting: shares.configure(await jsonBody(ctx, request)) });
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
- if (gemini) {
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) throw new Error(`gemini answered HTTP ${response.status}`);
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 NoModel();
131
+ throw new Error(failures.join("; "));
117
132
  }
@@ -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
- if (o.breaks && !first && offset - lastBreak >= o.breaks.everySeconds) {
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,
@@ -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 ${String(one["tracks"] ?? (one["tracks"] as unknown[] | undefined)?.length ?? 0)} of ${String(one["trackSeconds"])} s, ${String(one["perDay"])} a day${one["vocals"] ? ", with vocals" : ""}`,
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({ ...(answer["station"] as Record<string, unknown>), tracks: ((answer["station"] as Record<string, unknown>)["tracks"] as unknown[]).length }));
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; }
@@ -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: station.video ? "video" : "audio",
104
- format: state === "stopped" || state === "offline" ? null : station.video
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;
@@ -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
- ...(station.breaksEvery > 0 && this.deps.breaks
149
- ? { breaks: { everySeconds: station.breaksEvery, next: () => this.deps.breaks!(station.video) } }
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);
@@ -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`);