nixamp 0.34.0 → 0.36.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.
Files changed (73) hide show
  1. package/dist/auto-stations.d.ts +66 -0
  2. package/dist/auto-stations.js +108 -0
  3. package/dist/channels.d.ts +1 -1
  4. package/dist/library-index.d.ts +2 -0
  5. package/dist/library-index.js +8 -0
  6. package/dist/library.d.ts +2 -0
  7. package/dist/library.js +1 -0
  8. package/dist/playlist-input.d.ts +9 -0
  9. package/dist/playlist-input.js +30 -8
  10. package/dist/server.d.ts +7 -0
  11. package/dist/server.js +102 -1
  12. package/dist/station-breaks.d.ts +19 -0
  13. package/dist/station-breaks.js +92 -0
  14. package/dist/station-cli.js +22 -0
  15. package/dist/station-guide.d.ts +4 -0
  16. package/dist/station-guide.js +86 -0
  17. package/dist/station-mcp.d.ts +99 -0
  18. package/dist/station-mcp.js +16 -0
  19. package/dist/station-metering.d.ts +59 -0
  20. package/dist/station-metering.js +189 -0
  21. package/dist/station-page.d.ts +1 -1
  22. package/dist/station-page.js +20 -5
  23. package/dist/station-routes.d.ts +13 -0
  24. package/dist/station-routes.js +90 -3
  25. package/dist/station-runner.d.ts +9 -0
  26. package/dist/station-runner.js +15 -0
  27. package/dist/station-usage.d.ts +1 -1
  28. package/dist/station-usage.js +2 -0
  29. package/dist/stations.d.ts +14 -0
  30. package/dist/stations.js +26 -0
  31. package/package.json +1 -1
  32. package/src/auto-stations.ts +171 -0
  33. package/src/channels.ts +1 -1
  34. package/src/library-index.ts +5 -0
  35. package/src/library.ts +3 -0
  36. package/src/playlist-input.ts +31 -8
  37. package/src/server.ts +106 -2
  38. package/src/station-breaks.ts +93 -0
  39. package/src/station-cli.ts +21 -0
  40. package/src/station-guide.ts +92 -0
  41. package/src/station-mcp.ts +16 -0
  42. package/src/station-metering.ts +218 -0
  43. package/src/station-page.ts +20 -5
  44. package/src/station-routes.ts +98 -3
  45. package/src/station-runner.ts +20 -0
  46. package/src/station-usage.ts +2 -0
  47. package/src/stations.ts +37 -0
  48. package/web/dist/assets/{ar-DoW0wbjC.js → ar-Ck-FmDpQ.js} +1 -1
  49. package/web/dist/assets/{cs-CjKqCjy5.js → cs-CPJY3K69.js} +1 -1
  50. package/web/dist/assets/{da-BQERb7Qz.js → da-BnWtLL09.js} +1 -1
  51. package/web/dist/assets/{de-CKKbRy6c.js → de-CHevIt93.js} +1 -1
  52. package/web/dist/assets/{es-CL3v5Og9.js → es-B6wpaUMv.js} +1 -1
  53. package/web/dist/assets/{fi-DgV81qNU.js → fi-8fKiz2AA.js} +1 -1
  54. package/web/dist/assets/{fr-BGumytzF.js → fr-0GgY52Sm.js} +1 -1
  55. package/web/dist/assets/{hi-CAQ9elqo.js → hi-BlsrUAw-.js} +1 -1
  56. package/web/dist/assets/{hls-3VKVEQE3-Ceri0flt.js → hls-3VKVEQE3-BZd3zjlr.js} +1 -1
  57. package/web/dist/assets/{hu-UJNgJVWX.js → hu-D3i_6i5w.js} +1 -1
  58. package/web/dist/assets/{id-BqQOlrF-.js → id-CLLAXbOP.js} +1 -1
  59. package/web/dist/assets/{it-Cs8G6mvW.js → it-BU13BsSn.js} +1 -1
  60. package/web/dist/assets/{main-Y1Rvn1GX.js → main-C1ggd2H7.js} +2 -2
  61. package/web/dist/assets/{mpegts-jS_Diizz.js → mpegts-BaoQnMCf.js} +1 -1
  62. package/web/dist/assets/{mpegts-LO6RVLD6-CPI8xaZ-.js → mpegts-LO6RVLD6-YBT-W6xc.js} +1 -1
  63. package/web/dist/assets/{nl-BJXQ9nqo.js → nl-DEshyyt7.js} +1 -1
  64. package/web/dist/assets/radio-BpH1qMMQ.js +1 -0
  65. package/web/dist/assets/{ru-XmVkvNY8.js → ru-D7sPOc6m.js} +1 -1
  66. package/web/dist/assets/{sv-pVm9p66e.js → sv-sdrsKAiG.js} +1 -1
  67. package/web/dist/assets/{uk-4hcyaD3e.js → uk-_lz6hkvo.js} +1 -1
  68. package/web/dist/assets/{vi-B7MipBlv.js → vi-D81wl7uk.js} +1 -1
  69. package/web/dist/assets/{zh-B5YVExOW.js → zh-BqMqEom6.js} +1 -1
  70. package/web/dist/index.html +1 -1
  71. package/web/dist/radio.html +1 -1
  72. package/web/dist/sw.js +23 -23
  73. package/web/dist/assets/radio-B58WgC1j.js +0 -1
@@ -34,6 +34,12 @@ export interface PlaylistInput {
34
34
  * still arriving, and the channel redials and waits for it.
35
35
  */
36
36
  passOver?: boolean;
37
+ /**
38
+ * A break every so often, between entries and never in the middle of one:
39
+ * `next` answers a file to play (an advert, an ident) or null for no break
40
+ * this time. The break is outside the rotation and the station's history.
41
+ */
42
+ breaks?: { everySeconds: number; next: () => Promise<string | null> };
37
43
  /** Told when an entry is passed over because it would not play. */
38
44
  onFailure?: (event: { at: number; file: string; reason: string }) => void;
39
45
  }
@@ -99,9 +105,10 @@ export class PlaylistInputStream {
99
105
  let files = o.current?.() ?? o.files;
100
106
  if (files.length === 0) throw new Error("The playlist is empty");
101
107
  let order = orderFor(files);
102
- let step = Math.min(o.at, order.length);
108
+ let step = Math.min(Math.max(0, o.at), order.length);
103
109
  let played = 0;
104
110
  let last = "";
111
+ let lastBreak = 0;
105
112
  for (;;) {
106
113
  if (signal.aborted) break;
107
114
  // Asked at every boundary, so an edit lands on the next track. The
@@ -128,11 +135,20 @@ export class PlaylistInputStream {
128
135
  continue;
129
136
  }
130
137
  {
131
- const at = order[step]!;
132
- step += 1;
133
- const file = files[at]!;
134
- last = file;
135
- if (o.excluded?.(file)) continue;
138
+ // A break, when one is due: in place of the next entry, which stays
139
+ // next. Not at the very start, and never inside an entry.
140
+ let breakFile: string | null = null;
141
+ if (o.breaks && !first && offset - lastBreak >= o.breaks.everySeconds) {
142
+ lastBreak = offset;
143
+ breakFile = await o.breaks.next().catch(() => null);
144
+ if (signal.aborted) return;
145
+ }
146
+ const isBreak = breakFile !== null;
147
+ const at = isBreak ? -1 : order[step]!;
148
+ if (!isBreak) step += 1;
149
+ const file = isBreak ? breakFile! : files[at]!;
150
+ if (!isBreak) last = file;
151
+ if (!isBreak && o.excluded?.(file)) continue;
136
152
  const position = first && step - 1 === o.at ? o.position : 0;
137
153
  first = false;
138
154
  let reached = 0;
@@ -200,13 +216,20 @@ export class PlaylistInputStream {
200
216
  abort();
201
217
  await closed;
202
218
  }
203
- failuresInARow = 0;
204
- played += 1;
219
+ if (!isBreak) {
220
+ failuresInARow = 0;
221
+ played += 1;
222
+ }
205
223
  // Match the frame grid used above so rounding cannot accumulate a
206
224
  // backwards timestamp over hundreds of short files.
207
225
  const length = skipped ? Math.min(duration, reached) : duration;
208
226
  offset += o.video ? Math.ceil(length * 30) / 30 : length;
227
+ // The next break is counted from the end of this one, so a spot
228
+ // longer than the gap cannot follow itself.
229
+ if (isBreak) lastBreak = offset;
209
230
  } catch (error) {
231
+ // A break that would not play is simply no break.
232
+ if (isBreak && !signal.aborted) continue;
210
233
  if (signal.aborted || !o.passOver) throw error;
211
234
  // Passed over, not dialled again: the next entry is what a
212
235
  // listener should hear, and a file that will not decode now will
package/src/server.ts CHANGED
@@ -78,8 +78,12 @@ import { ListenerStore } from "./library-user.ts";
78
78
  import { Importer } from "./handoff.ts";
79
79
  import { handleHandoffs } from "./handoff-routes.ts";
80
80
  import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
81
+ import { GUIDE_CSP, GUIDE_HTML, guideHosts } from "./station-guide.ts";
81
82
  import { StationRunner } from "./station-runner.ts";
82
83
  import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
84
+ import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv, type Limits } from "./station-metering.ts";
85
+ import { StationBreaks } from "./station-breaks.ts";
86
+ import { DEFAULT_AUTO, syncAutoStations, type AutoOptions } from "./auto-stations.ts";
83
87
  import { CompressionService } from "./compression/service.ts";
84
88
  import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
85
89
  import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
@@ -1716,7 +1720,13 @@ export interface HandlerOptions {
1716
1720
  /** Music handed over from BitTorrented. */
1717
1721
  importer?: Importer;
1718
1722
  /** This server's stations and what keeps them on the air. */
1719
- stations?: { store: StationStore; runner: StationRunner };
1723
+ stations?: {
1724
+ store: StationStore;
1725
+ runner: StationRunner;
1726
+ usage?: StationUsage;
1727
+ limits?: Limits;
1728
+ auto?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
1729
+ };
1720
1730
  web: string | null;
1721
1731
  /** Branded clients on this same backend, keyed by an explicitly configured host. */
1722
1732
  webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
@@ -2639,6 +2649,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2639
2649
  // port is open from another device before wondering whether the link is
2640
2650
  // wrong, and it says nothing about the library.
2641
2651
  const eventChannelRequest = Boolean(channelId && await eventForChannel());
2652
+ // The public station guide: at /guide, and at the front of a host named
2653
+ // for it (a Moshpit name such as live.streaming, NIXAMP_GUIDE_HOSTS).
2654
+ const requestHost = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "").split(",")[0]!.trim().replace(/:\d+$/, "").toLowerCase();
2655
+ const isGuidePage = Boolean(options.stations) && (request.method === "GET" || request.method === "HEAD") &&
2656
+ (path === "/guide" || (path === "/" && guideHosts().has(requestHost)));
2642
2657
  // Whether this request holds only the listen key. A listener may browse
2643
2658
  // and hear the library, but where it lives on this machine is the
2644
2659
  // owner's business: the absolute path names the user account and the disk.
@@ -2651,6 +2666,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2651
2666
  // A station's link is its own key, checked by the station.
2652
2667
  !isStationListenPath(path) &&
2653
2668
  !STATION_PAGE.test(path) &&
2669
+ !isGuidePage &&
2654
2670
  !eventChannelRequest
2655
2671
  ) {
2656
2672
  let scope = scopeOf(keyFrom(request, url), key, listenKey);
@@ -2703,6 +2719,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2703
2719
  return { allowed: false, account: "" };
2704
2720
  };
2705
2721
 
2722
+ if (isGuidePage) {
2723
+ response.writeHead(200, {
2724
+ "content-type": "text/html; charset=utf-8",
2725
+ "cache-control": "no-cache",
2726
+ "content-security-policy": GUIDE_CSP,
2727
+ "referrer-policy": "no-referrer",
2728
+ "x-content-type-options": "nosniff",
2729
+ });
2730
+ response.end(request.method === "HEAD" ? undefined : GUIDE_HTML);
2731
+ return;
2732
+ }
2733
+
2706
2734
  // The page a station's link opens. The same bytes for every station: it
2707
2735
  // reads its id and token from its own address.
2708
2736
  if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
@@ -2785,6 +2813,13 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2785
2813
  json,
2786
2814
  readBody,
2787
2815
  headers: CORS,
2816
+ ...(options.stations.usage ? { usage: options.stations.usage } : {}),
2817
+ ...(options.stations.limits ? { limits: options.stations.limits } : {}),
2818
+ ...(options.stations.auto ? { auto: options.stations.auto } : {}),
2819
+ throttle: () => {
2820
+ const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
2821
+ return verdict.ok ? null : { retryAfter: verdict.retryAfter };
2822
+ },
2788
2823
  });
2789
2824
  return;
2790
2825
  }
@@ -6652,7 +6687,21 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6652
6687
  const listeners = new ListenerStore(ListenerStore.fileFor(stateDir(), options.port));
6653
6688
  const library = { index: libraryIndex, probe: probeFile, retag: retagPlayer, listeners };
6654
6689
  const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
6690
+ // What stations deliver, per month, and the operator's limits on it.
6691
+ const stationUsage = new StationUsage(StationUsage.fileFor(stateDir(), options.port));
6692
+ const stationLimits = limitsFromEnv();
6693
+ process.once("exit", () => stationUsage.flush());
6694
+ // Breaks between songs and films: the network's spot, or a house one.
6695
+ const stationBreaks = new StationBreaks({
6696
+ dir: join(stateDir(), "ads"),
6697
+ site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
6698
+ hasSlot: Boolean(process.env["AD_SLOT"]),
6699
+ guard: (url) => refusedDestination(url),
6700
+ });
6655
6701
  stationRunner = new StationRunner({
6702
+ breaks: (video) => stationBreaks.next(video ? "video" : "audio"),
6703
+ onRestart: (id) => stationUsage.restarted(id),
6704
+ onFailure: (id) => stationUsage.failed(id),
6656
6705
  store: stationStore,
6657
6706
  channels,
6658
6707
  pull: async (channelId, name, files, known) =>
@@ -6666,7 +6715,62 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6666
6715
  roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
6667
6716
  log: (line) => console.log(line),
6668
6717
  });
6669
- const stations = { store: stationStore, runner: stationRunner };
6718
+ // Auto stations: a station per genre, kept in step with the library.
6719
+ const autoSetting = (): AutoOptions | null => {
6720
+ const saved = readConfig().autoStations;
6721
+ if (!saved) return null;
6722
+ const pick = (name: keyof AutoOptions) => (saved[name] === undefined ? DEFAULT_AUTO[name] : saved[name]);
6723
+ return {
6724
+ video: pick("video") !== false,
6725
+ minItems: Number(pick("minItems")) || DEFAULT_AUTO.minItems,
6726
+ minFilms: Number(pick("minFilms")) || DEFAULT_AUTO.minFilms,
6727
+ start: pick("start") !== false,
6728
+ maxVideoRunning: Number(pick("maxVideoRunning")) >= 0 ? Number(pick("maxVideoRunning")) : DEFAULT_AUTO.maxVideoRunning,
6729
+ breaksEvery: Number(pick("breaksEvery")) >= 0 ? Number(pick("breaksEvery")) : DEFAULT_AUTO.breaksEvery,
6730
+ };
6731
+ };
6732
+ let autoRunning: Promise<unknown> | null = null;
6733
+ const runAuto = (settings: AutoOptions): Promise<unknown> => {
6734
+ if (autoRunning) return autoRunning;
6735
+ autoRunning = syncAutoStations({
6736
+ index: libraryIndex,
6737
+ store: stationStore,
6738
+ runner: stationRunner!,
6739
+ owner: options.owner || "",
6740
+ mayStart: () => stationLimits.runningStations === 0 || stationStore.list().filter((one) => one.desired === "running").length < stationLimits.runningStations,
6741
+ filmGenres: async (title) => {
6742
+ const hit = await enricher.lookup(title, "title");
6743
+ const genres = hit?.data?.["genres"];
6744
+ return Array.isArray(genres) ? genres.filter((one): one is string => typeof one === "string") : [];
6745
+ },
6746
+ log: (line) => console.log(line),
6747
+ }, settings).then((report) => {
6748
+ const made = report as { radio: unknown[]; tv: unknown[] };
6749
+ console.log(` auto stations: ${made.radio.length} radio, ${made.tv.length} TV.`);
6750
+ return report;
6751
+ }).finally(() => { autoRunning = null; });
6752
+ return autoRunning;
6753
+ };
6754
+ libraryIndex.onScanned = () => {
6755
+ const settings = autoSetting();
6756
+ if (settings) void runAuto(settings);
6757
+ };
6758
+ const stationAuto = {
6759
+ setting: () => autoSetting(),
6760
+ sync: async (input: Record<string, unknown>) => {
6761
+ if (input["off"] === true) {
6762
+ writeConfig({ autoStations: undefined });
6763
+ return { off: true };
6764
+ }
6765
+ const settings: AutoOptions = { ...DEFAULT_AUTO, ...(autoSetting() ?? {}) };
6766
+ for (const name of Object.keys(DEFAULT_AUTO) as (keyof AutoOptions)[]) {
6767
+ if (input[name] !== undefined) (settings as unknown as Record<string, unknown>)[name] = input[name];
6768
+ }
6769
+ writeConfig({ autoStations: settings as unknown as Record<string, unknown> });
6770
+ return runAuto(settings);
6771
+ },
6772
+ };
6773
+ const stations = { store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits, auto: stationAuto };
6670
6774
  // Music handed over from BitTorrented, copied into a folder of our own.
6671
6775
  const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
6672
6776
  index: libraryIndex,
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The break: a short spot between songs or films, instead of commercials.
3
+ *
4
+ * Asked for once per break, never per listener, and never in the middle of
5
+ * anything (playlist-input.ts places it between entries). Where it comes
6
+ * from, in order:
7
+ *
8
+ * 1. the ad network, through this server's own AD_SLOT (ads.ts), or
9
+ * through nixamp.com's /api/ads/next when this server has none, so a
10
+ * self-hosted station carries the same spot the site does and the
11
+ * network meters the impression;
12
+ * 2. a house spot the operator dropped in $XDG_STATE_HOME/nixamp/ads/
13
+ * (spot.mp4 for TV, spot.mp3 for radio);
14
+ * 3. nothing: no break this time, and the music goes on.
15
+ *
16
+ * A creative is fetched once per URL into ads/cache and played from disk:
17
+ * https only, public addresses only, at most 50 MB.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
21
+ import { extname, join } from "node:path";
22
+ import { Readable } from "node:stream";
23
+ import { pipeline } from "node:stream/promises";
24
+ import { nextAdvert, type Advert } from "./ads.ts";
25
+
26
+ const MAX_BYTES = 50 * 1024 * 1024;
27
+
28
+ export interface BreakOptions {
29
+ dir: string;
30
+ /** nixamp.com, when this server has no slot of its own. */
31
+ site: string;
32
+ hasSlot: boolean;
33
+ guard: (url: string) => Promise<string>;
34
+ fetcher?: typeof fetch;
35
+ /** Injected by tests in place of the network. */
36
+ ask?: (kind: "audio" | "video") => Promise<Advert>;
37
+ }
38
+
39
+ export class StationBreaks {
40
+ constructor(private readonly options: BreakOptions) {}
41
+
42
+ private async ask(kind: "audio" | "video"): Promise<Advert> {
43
+ if (this.options.ask) return this.options.ask(kind);
44
+ if (this.options.hasSlot) return nextAdvert(kind);
45
+ const send = this.options.fetcher ?? fetch;
46
+ try {
47
+ const answer = await send(`${this.options.site.replace(/\/+$/, "")}/api/ads/next?kind=${kind}`, { signal: AbortSignal.timeout(2_000) });
48
+ if (!answer.ok) return { url: null };
49
+ const body = (await answer.json()) as { url?: unknown; kind?: unknown };
50
+ return typeof body.url === "string" ? { url: body.url, kind: body.kind === "video" ? "video" : "audio" } : { url: null };
51
+ } catch {
52
+ return { url: null };
53
+ }
54
+ }
55
+
56
+ /** A file to play for this break, or null for none. */
57
+ async next(kind: "audio" | "video"): Promise<string | null> {
58
+ const advert = await this.ask(kind);
59
+ if (advert.url) {
60
+ const cached = await this.cache(advert.url).catch(() => null);
61
+ if (cached) return cached;
62
+ }
63
+ const house = join(this.options.dir, kind === "video" ? "spot.mp4" : "spot.mp3");
64
+ return existsSync(house) ? house : null;
65
+ }
66
+
67
+ private async cache(url: string): Promise<string | null> {
68
+ const parsed = new URL(url);
69
+ if (parsed.protocol !== "https:") return null;
70
+ const folder = join(this.options.dir, "cache");
71
+ const file = join(folder, `${createHash("sha1").update(url).digest("hex")}${extname(parsed.pathname).slice(0, 6) || ".mp4"}`);
72
+ if (existsSync(file)) return file;
73
+ if (await this.options.guard(url)) return null;
74
+ const answer = await (this.options.fetcher ?? fetch)(url, { signal: AbortSignal.timeout(20_000) });
75
+ if (!answer.ok || !answer.body) return null;
76
+ if (Number(answer.headers.get("content-length") ?? 0) > MAX_BYTES) return null;
77
+ mkdirSync(folder, { recursive: true });
78
+ const part = `${file}.part`;
79
+ let bytes = 0;
80
+ const counted = Readable.fromWeb(answer.body as import("node:stream/web").ReadableStream).on("data", (chunk: Buffer) => {
81
+ bytes += chunk.length;
82
+ if (bytes > MAX_BYTES) counted.destroy(new Error("too big"));
83
+ });
84
+ try {
85
+ await pipeline(counted, createWriteStream(part));
86
+ renameSync(part, file);
87
+ return file;
88
+ } catch {
89
+ rmSync(part, { force: true });
90
+ return null;
91
+ }
92
+ }
93
+ }
@@ -177,6 +177,27 @@ export async function station(argv: string[]): Promise<number> {
177
177
  return [`page ${links["page"]}`, `stream ${links["stream"]}`, `m3u ${links["m3u"]}`].join("\n");
178
178
  });
179
179
  }
180
+ case "auto": {
181
+ const body: Record<string, unknown> = argv.includes("--off") ? { off: true } : {
182
+ ...(argv.includes("--no-video") ? { video: false } : {}),
183
+ ...(argv.includes("--no-start") ? { start: false } : {}),
184
+ ...(flag(argv, "--min") ? { minItems: Number(flag(argv, "--min")) } : {}),
185
+ ...(flag(argv, "--films") ? { minFilms: Number(flag(argv, "--films")) } : {}),
186
+ ...(flag(argv, "--tv") ? { maxVideoRunning: Number(flag(argv, "--tv")) } : {}),
187
+ ...(flag(argv, "--breaks") ? { breaksEvery: Number(flag(argv, "--breaks")) } : {}),
188
+ };
189
+ return show(await call("POST", "/api/v1/stations/auto", body), (answer) => {
190
+ const report = answer["report"] as { off?: boolean; radio?: Array<Record<string, unknown>>; tv?: Array<Record<string, unknown>>; skipped?: string[] };
191
+ if (report.off) return "Auto stations are off. The stations already made stay as they are.";
192
+ const line = (one: Record<string, unknown>, unit: string) => ` ${String(one["genre"]).padEnd(16)} ${String(one["files"] ?? one["films"]).padStart(5)} ${unit} ${one["running"] ? "on air" : "stopped"}${one["made"] ? " (new)" : ""} [${String(one["id"])}]`;
193
+ return [
194
+ "Radio:", ...(report.radio ?? []).map((one) => line(one, "files")),
195
+ "TV:", ...((report.tv ?? []).length ? (report.tv ?? []).map((one) => line(one, "films")) : [" none (no film genres known yet)"]),
196
+ ...((report.skipped ?? []).length ? [`Too small for a station: ${(report.skipped ?? []).join(", ")}`] : []),
197
+ "Every library scan keeps these up to date. They are unlisted: `nixamp station link ID` for a link.",
198
+ ].join("\n");
199
+ });
200
+ }
180
201
  case "delete": {
181
202
  const station = needId();
182
203
  if (!station) return 2;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The station guide: every station on this server its owner made public
3
+ * and listed, what is on each, and a way in. What a Moshpit name like
4
+ * live.streaming opens.
5
+ *
6
+ * Like the station page, the same bytes for every server: it asks
7
+ * /api/v1/stations/guide for the list and writes it in as text.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+
11
+ const STYLE = `
12
+ :root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}
13
+ @media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}
14
+ *{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
15
+ main{max-width:880px;margin:0 auto;padding:40px 16px}
16
+ h1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}
17
+ h2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}
18
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
19
+ a.station{display:block;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;color:inherit;text-decoration:none}
20
+ a.station:hover,a.station:focus-visible{border-color:var(--accent);outline:none}
21
+ .name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}
22
+ .live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}
23
+ footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
24
+ `;
25
+
26
+ const SCRIPT = `
27
+ (async () => {
28
+ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
29
+ const draw = (stations) => {
30
+ for (const [kind, box] of [["audio", "#radio"], ["video", "#tv"]]) {
31
+ const grid = document.querySelector(box);
32
+ grid.replaceChildren();
33
+ const list = stations.filter((s) => s.kind === kind);
34
+ document.querySelector(box + "-title").hidden = list.length === 0;
35
+ for (const s of list) {
36
+ const a = el("a", "station");
37
+ a.href = s.page;
38
+ a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
39
+ el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
40
+ grid.append(a);
41
+ }
42
+ }
43
+ document.querySelector("#empty").hidden = stations.length > 0;
44
+ };
45
+ const load = async () => {
46
+ try {
47
+ const r = await fetch("/api/v1/stations/guide", { cache: "no-store" });
48
+ if (r.ok) draw((await r.json()).stations);
49
+ } catch {}
50
+ };
51
+ await load();
52
+ setInterval(load, 15000);
53
+ })();
54
+ `;
55
+
56
+ const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
57
+
58
+ export const GUIDE_HTML = `<!doctype html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8">
62
+ <meta name="viewport" content="width=device-width, initial-scale=1">
63
+ <title>Live · stations</title>
64
+ <meta name="description" content="Radio and TV stations, live now.">
65
+ <link rel="icon" href="data:,">
66
+ <style>${STYLE}</style>
67
+ </head>
68
+ <body>
69
+ <main>
70
+ <h1>Live</h1>
71
+ <p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
72
+ <h2 id="radio-title" hidden>Radio</h2>
73
+ <div id="radio" class="grid" aria-live="polite"></div>
74
+ <h2 id="tv-title" hidden>TV</h2>
75
+ <div id="tv" class="grid" aria-live="polite"></div>
76
+ <p id="empty" class="empty">Nothing is on the air here yet.</p>
77
+ <footer>Played by <a href="https://nixamp.com">nixamp</a>, on the Moshpit.</footer>
78
+ </main>
79
+ <script>${SCRIPT}</script>
80
+ </body>
81
+ </html>
82
+ `;
83
+
84
+ export const GUIDE_CSP = [
85
+ "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
86
+ "img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
87
+ ].join("; ");
88
+
89
+ /** The hosts whose front page is the guide (NIXAMP_GUIDE_HOSTS, comma-separated). */
90
+ export function guideHosts(env: Record<string, string | undefined> = process.env): Set<string> {
91
+ return new Set((env["NIXAMP_GUIDE_HOSTS"] ?? "").split(",").map((one) => one.trim().toLowerCase()).filter(Boolean));
92
+ }
@@ -80,6 +80,18 @@ export const STATION_TOOLS = [
80
80
  required: ["id", "visibility"],
81
81
  },
82
82
  },
83
+ {
84
+ name: "station_auto",
85
+ description: "A station for every genre in the library (radio from music tags, TV from film genres), kept up to date after every library scan, with a short break between songs and films. Unlisted; publishing stays a separate step. off: true stops the automation.",
86
+ inputSchema: {
87
+ type: "object",
88
+ properties: {
89
+ server: SERVER,
90
+ video: { type: "boolean" }, minItems: { type: "integer" }, minFilms: { type: "integer" },
91
+ maxVideoRunning: { type: "integer" }, breaksEvery: { type: "integer" }, start: { type: "boolean" }, off: { type: "boolean" },
92
+ },
93
+ },
94
+ },
83
95
  {
84
96
  name: "station_unpublish",
85
97
  description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
@@ -149,6 +161,10 @@ export async function callStationTool(name: string, args: Record<string, unknown
149
161
  const all = (answer.body["stations"] as Record<string, unknown>[] | undefined) ?? [];
150
162
  return say(all.length === 0 ? "No stations on this server yet." : all.map(line).join("\n"));
151
163
  }
164
+ if (name === "station_auto") {
165
+ const answer = await call("POST", "/api/v1/stations/auto", pick(["video", "minItems", "minFilms", "maxVideoRunning", "breaksEvery", "start", "off"]));
166
+ return answer.ok ? say(JSON.stringify(answer.body["report"], null, 2)) : refused(answer.body);
167
+ }
152
168
  if (!/^[a-z0-9]{10}$/.test(id) && name !== "station_create") return say("Which station? Pass its id from station_list.", true);
153
169
  if (name === "station_get") {
154
170
  const answer = await call("GET", `/api/v1/stations/${id}`);