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
@@ -0,0 +1,86 @@
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
+ const STYLE = `
11
+ :root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}
12
+ @media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}
13
+ *{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}
14
+ main{max-width:880px;margin:0 auto;padding:40px 16px}
15
+ h1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}
16
+ h2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}
17
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
18
+ a.station{display:block;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;color:inherit;text-decoration:none}
19
+ a.station:hover,a.station:focus-visible{border-color:var(--accent);outline:none}
20
+ .name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}
21
+ .live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}
22
+ footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
23
+ `;
24
+ const SCRIPT = `
25
+ (async () => {
26
+ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
27
+ const draw = (stations) => {
28
+ for (const [kind, box] of [["audio", "#radio"], ["video", "#tv"]]) {
29
+ const grid = document.querySelector(box);
30
+ grid.replaceChildren();
31
+ const list = stations.filter((s) => s.kind === kind);
32
+ document.querySelector(box + "-title").hidden = list.length === 0;
33
+ for (const s of list) {
34
+ const a = el("a", "station");
35
+ a.href = s.page;
36
+ a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
37
+ el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
38
+ grid.append(a);
39
+ }
40
+ }
41
+ document.querySelector("#empty").hidden = stations.length > 0;
42
+ };
43
+ const load = async () => {
44
+ try {
45
+ const r = await fetch("/api/v1/stations/guide", { cache: "no-store" });
46
+ if (r.ok) draw((await r.json()).stations);
47
+ } catch {}
48
+ };
49
+ await load();
50
+ setInterval(load, 15000);
51
+ })();
52
+ `;
53
+ const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
54
+ export const GUIDE_HTML = `<!doctype html>
55
+ <html lang="en">
56
+ <head>
57
+ <meta charset="utf-8">
58
+ <meta name="viewport" content="width=device-width, initial-scale=1">
59
+ <title>Live · stations</title>
60
+ <meta name="description" content="Radio and TV stations, live now.">
61
+ <link rel="icon" href="data:,">
62
+ <style>${STYLE}</style>
63
+ </head>
64
+ <body>
65
+ <main>
66
+ <h1>Live</h1>
67
+ <p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
68
+ <h2 id="radio-title" hidden>Radio</h2>
69
+ <div id="radio" class="grid" aria-live="polite"></div>
70
+ <h2 id="tv-title" hidden>TV</h2>
71
+ <div id="tv" class="grid" aria-live="polite"></div>
72
+ <p id="empty" class="empty">Nothing is on the air here yet.</p>
73
+ <footer>Played by <a href="https://nixamp.com">nixamp</a>, on the Moshpit.</footer>
74
+ </main>
75
+ <script>${SCRIPT}</script>
76
+ </body>
77
+ </html>
78
+ `;
79
+ export const GUIDE_CSP = [
80
+ "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
81
+ "img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
82
+ ].join("; ");
83
+ /** The hosts whose front page is the guide (NIXAMP_GUIDE_HOSTS, comma-separated). */
84
+ export function guideHosts(env = process.env) {
85
+ return new Set((env["NIXAMP_GUIDE_HOSTS"] ?? "").split(",").map((one) => one.trim().toLowerCase()).filter(Boolean));
86
+ }
@@ -22,6 +22,13 @@ export declare const STATION_TOOLS: ({
22
22
  listed?: undefined;
23
23
  basis?: undefined;
24
24
  note?: undefined;
25
+ video?: undefined;
26
+ minItems?: undefined;
27
+ minFilms?: undefined;
28
+ maxVideoRunning?: undefined;
29
+ breaksEvery?: undefined;
30
+ start?: undefined;
31
+ off?: undefined;
25
32
  id?: undefined;
26
33
  private?: undefined;
27
34
  };
@@ -55,6 +62,13 @@ export declare const STATION_TOOLS: ({
55
62
  listed?: undefined;
56
63
  basis?: undefined;
57
64
  note?: undefined;
65
+ video?: undefined;
66
+ minItems?: undefined;
67
+ minFilms?: undefined;
68
+ maxVideoRunning?: undefined;
69
+ breaksEvery?: undefined;
70
+ start?: undefined;
71
+ off?: undefined;
58
72
  private?: undefined;
59
73
  };
60
74
  required: string[];
@@ -110,6 +124,13 @@ export declare const STATION_TOOLS: ({
110
124
  listed?: undefined;
111
125
  basis?: undefined;
112
126
  note?: undefined;
127
+ video?: undefined;
128
+ minItems?: undefined;
129
+ minFilms?: undefined;
130
+ maxVideoRunning?: undefined;
131
+ breaksEvery?: undefined;
132
+ start?: undefined;
133
+ off?: undefined;
113
134
  id?: undefined;
114
135
  private?: undefined;
115
136
  };
@@ -163,6 +184,13 @@ export declare const STATION_TOOLS: ({
163
184
  listed?: undefined;
164
185
  basis?: undefined;
165
186
  note?: undefined;
187
+ video?: undefined;
188
+ minItems?: undefined;
189
+ minFilms?: undefined;
190
+ maxVideoRunning?: undefined;
191
+ breaksEvery?: undefined;
192
+ start?: undefined;
193
+ off?: undefined;
166
194
  private?: undefined;
167
195
  };
168
196
  required: string[];
@@ -198,6 +226,13 @@ export declare const STATION_TOOLS: ({
198
226
  listed?: undefined;
199
227
  basis?: undefined;
200
228
  note?: undefined;
229
+ video?: undefined;
230
+ minItems?: undefined;
231
+ minFilms?: undefined;
232
+ maxVideoRunning?: undefined;
233
+ breaksEvery?: undefined;
234
+ start?: undefined;
235
+ off?: undefined;
201
236
  private?: undefined;
202
237
  };
203
238
  required: string[];
@@ -241,6 +276,13 @@ export declare const STATION_TOOLS: ({
241
276
  note: {
242
277
  readonly type: "string";
243
278
  };
279
+ video?: undefined;
280
+ minItems?: undefined;
281
+ minFilms?: undefined;
282
+ maxVideoRunning?: undefined;
283
+ breaksEvery?: undefined;
284
+ start?: undefined;
285
+ off?: undefined;
244
286
  private?: undefined;
245
287
  };
246
288
  required: string[];
@@ -269,6 +311,63 @@ export declare const STATION_TOOLS: ({
269
311
  type: "string";
270
312
  description: string;
271
313
  };
314
+ video: {
315
+ type: string;
316
+ };
317
+ minItems: {
318
+ type: string;
319
+ };
320
+ minFilms: {
321
+ type: string;
322
+ };
323
+ maxVideoRunning: {
324
+ type: string;
325
+ };
326
+ breaksEvery: {
327
+ type: string;
328
+ };
329
+ start: {
330
+ type: string;
331
+ };
332
+ off: {
333
+ type: string;
334
+ };
335
+ id?: undefined;
336
+ private?: undefined;
337
+ };
338
+ required?: undefined;
339
+ };
340
+ } | {
341
+ name: string;
342
+ description: string;
343
+ inputSchema: {
344
+ type: string;
345
+ properties: {
346
+ playlist?: undefined;
347
+ idempotencyKey?: undefined;
348
+ name?: undefined;
349
+ description?: undefined;
350
+ folder?: undefined;
351
+ items?: undefined;
352
+ shuffle?: undefined;
353
+ repeat?: undefined;
354
+ bitrate?: undefined;
355
+ action?: undefined;
356
+ visibility?: undefined;
357
+ listed?: undefined;
358
+ basis?: undefined;
359
+ note?: undefined;
360
+ video?: undefined;
361
+ minItems?: undefined;
362
+ minFilms?: undefined;
363
+ maxVideoRunning?: undefined;
364
+ breaksEvery?: undefined;
365
+ start?: undefined;
366
+ off?: undefined;
367
+ server: {
368
+ type: "string";
369
+ description: string;
370
+ };
272
371
  id: {
273
372
  type: "string";
274
373
  description: string;
@@ -78,6 +78,18 @@ export const STATION_TOOLS = [
78
78
  required: ["id", "visibility"],
79
79
  },
80
80
  },
81
+ {
82
+ name: "station_auto",
83
+ 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.",
84
+ inputSchema: {
85
+ type: "object",
86
+ properties: {
87
+ server: SERVER,
88
+ video: { type: "boolean" }, minItems: { type: "integer" }, minFilms: { type: "integer" },
89
+ maxVideoRunning: { type: "integer" }, breaksEvery: { type: "integer" }, start: { type: "boolean" }, off: { type: "boolean" },
90
+ },
91
+ },
92
+ },
81
93
  {
82
94
  name: "station_unpublish",
83
95
  description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
@@ -134,6 +146,10 @@ export async function callStationTool(name, args, options = {}) {
134
146
  const all = answer.body["stations"] ?? [];
135
147
  return say(all.length === 0 ? "No stations on this server yet." : all.map(line).join("\n"));
136
148
  }
149
+ if (name === "station_auto") {
150
+ const answer = await call("POST", "/api/v1/stations/auto", pick(["video", "minItems", "minFilms", "maxVideoRunning", "breaksEvery", "start", "off"]));
151
+ return answer.ok ? say(JSON.stringify(answer.body["report"], null, 2)) : refused(answer.body);
152
+ }
137
153
  if (!/^[a-z0-9]{10}$/.test(id) && name !== "station_create")
138
154
  return say("Which station? Pass its id from station_list.", true);
139
155
  if (name === "station_get") {
@@ -0,0 +1,59 @@
1
+ export interface Limits {
2
+ stationListeners: number;
3
+ totalListeners: number;
4
+ runningStations: number;
5
+ /** Bytes per station per month; 0 is no allowance. */
6
+ monthlyBytes: number;
7
+ }
8
+ export declare function limitsFromEnv(env?: Record<string, string | undefined>): Limits;
9
+ /** Station control: start, stop, edit, publish. Thirty a minute is a person. */
10
+ export declare const STATION_CONTROL_LIMIT: {
11
+ allowed: number;
12
+ windowMs: number;
13
+ };
14
+ export interface MonthUsage {
15
+ bytes: number;
16
+ listenerSeconds: number;
17
+ peakListeners: number;
18
+ restarts: number;
19
+ failures: number;
20
+ }
21
+ export declare function monthOf(at: number): string;
22
+ export declare class StationUsage {
23
+ private readonly file;
24
+ private readonly now;
25
+ /** station -> month -> usage */
26
+ private usage;
27
+ private live;
28
+ private timer;
29
+ constructor(file: string, now?: () => number);
30
+ static fileFor(stateDir: string, port: number): string;
31
+ private month;
32
+ private touch;
33
+ flush(): void;
34
+ /** Somebody started listening; returns what to call when they stop. */
35
+ joined(station: string): () => void;
36
+ delivered(station: string, bytes: number): void;
37
+ restarted(station: string): void;
38
+ failed(station: string): void;
39
+ listeners(station: string): number;
40
+ totalListeners(): number;
41
+ thisMonth(station: string): MonthUsage;
42
+ history(station: string): Record<string, MonthUsage>;
43
+ forget(station: string): void;
44
+ /**
45
+ * Whether one more listener may join this station now: null to go
46
+ * ahead, or why not, with a code and how long to wait.
47
+ */
48
+ admit(station: string, limits: Limits): {
49
+ code: string;
50
+ error: string;
51
+ status: number;
52
+ retryAfter: number;
53
+ } | null;
54
+ }
55
+ /** Things an owner should know about a station, in words. */
56
+ export declare function alertsFor(station: {
57
+ desired: string;
58
+ failures: Record<string, number>;
59
+ }, state: string, offlineSince: number, usage: MonthUsage, limits: Limits, now?: number): string[];
@@ -0,0 +1,189 @@
1
+ /**
2
+ * What stations cost to run, and the limits that keep one from costing
3
+ * everything.
4
+ *
5
+ * Counted where it is spent: every byte a station's link delivers and every
6
+ * second somebody is connected, per station, per calendar month, kept in
7
+ * `usage-<port>.json`. Restarts and files that would not play are counted
8
+ * too, because those are what an operator is asked about.
9
+ *
10
+ * Limits are the operator's (environment, below), and each is a refusal a
11
+ * person can act on, never a silent cut:
12
+ *
13
+ * NIXAMP_STATION_MAX_LISTENERS per station (default 200)
14
+ * NIXAMP_MAX_LISTENERS across every station (default 500)
15
+ * NIXAMP_MAX_RUNNING_STATIONS stations on the air at once (default 10)
16
+ * NIXAMP_STATION_MONTHLY_GB delivered per station per month (default 0: no allowance)
17
+ *
18
+ * When an allowance runs out the policy is "pause": nobody new is admitted
19
+ * until the month turns or the allowance is raised; nobody already
20
+ * listening is cut off mid-song. Control requests are limited separately
21
+ * (STATION_CONTROL_LIMIT) so that a flood of listeners never locks the owner
22
+ * out, and a flood of control requests never starves listeners.
23
+ */
24
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
25
+ import { dirname, join } from "node:path";
26
+ export function limitsFromEnv(env = process.env) {
27
+ const whole = (name, fallback) => {
28
+ const value = Number(env[name]);
29
+ return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
30
+ };
31
+ return {
32
+ stationListeners: whole("NIXAMP_STATION_MAX_LISTENERS", 200),
33
+ totalListeners: whole("NIXAMP_MAX_LISTENERS", 500),
34
+ runningStations: whole("NIXAMP_MAX_RUNNING_STATIONS", 10),
35
+ monthlyBytes: Math.round(Number(env["NIXAMP_STATION_MONTHLY_GB"] ?? 0) * 1024 ** 3) || 0,
36
+ };
37
+ }
38
+ /** Station control: start, stop, edit, publish. Thirty a minute is a person. */
39
+ export const STATION_CONTROL_LIMIT = { allowed: 30, windowMs: 60_000 };
40
+ const BLANK = { bytes: 0, listenerSeconds: 0, peakListeners: 0, restarts: 0, failures: 0 };
41
+ export function monthOf(at) {
42
+ return new Date(at).toISOString().slice(0, 7);
43
+ }
44
+ export class StationUsage {
45
+ file;
46
+ now;
47
+ /** station -> month -> usage */
48
+ usage = {};
49
+ live = new Map();
50
+ timer = null;
51
+ constructor(file, now = Date.now) {
52
+ this.file = file;
53
+ this.now = now;
54
+ try {
55
+ this.usage = JSON.parse(readFileSync(file, "utf8"));
56
+ }
57
+ catch {
58
+ this.usage = {};
59
+ }
60
+ }
61
+ static fileFor(stateDir, port) {
62
+ return join(stateDir, `usage-${port}.json`);
63
+ }
64
+ month(station, at = this.now()) {
65
+ const byMonth = (this.usage[station] ??= {});
66
+ return (byMonth[monthOf(at)] ??= { ...BLANK });
67
+ }
68
+ touch() {
69
+ if (this.timer)
70
+ return;
71
+ this.timer = setTimeout(() => this.flush(), 5_000);
72
+ this.timer.unref?.();
73
+ }
74
+ flush() {
75
+ if (this.timer)
76
+ clearTimeout(this.timer);
77
+ this.timer = null;
78
+ // Listening still going on is counted up to now, then counted on from now.
79
+ const at = this.now();
80
+ for (const [station, listeners] of this.live) {
81
+ for (const [who, since] of listeners) {
82
+ this.month(station, at).listenerSeconds += Math.max(0, (at - since) / 1000);
83
+ listeners.set(who, at);
84
+ }
85
+ }
86
+ try {
87
+ mkdirSync(dirname(this.file), { recursive: true });
88
+ const temporary = `${this.file}.${process.pid}.tmp`;
89
+ writeFileSync(temporary, JSON.stringify(this.usage), { mode: 0o600 });
90
+ renameSync(temporary, this.file);
91
+ }
92
+ catch {
93
+ // Counting is not a reason to stop serving.
94
+ }
95
+ }
96
+ /** Somebody started listening; returns what to call when they stop. */
97
+ joined(station) {
98
+ const who = {};
99
+ let listeners = this.live.get(station);
100
+ if (!listeners) {
101
+ listeners = new Map();
102
+ this.live.set(station, listeners);
103
+ }
104
+ listeners.set(who, this.now());
105
+ const month = this.month(station);
106
+ month.peakListeners = Math.max(month.peakListeners, listeners.size);
107
+ this.touch();
108
+ return () => {
109
+ const since = listeners.get(who);
110
+ if (since === undefined)
111
+ return;
112
+ listeners.delete(who);
113
+ this.month(station).listenerSeconds += Math.max(0, (this.now() - since) / 1000);
114
+ this.touch();
115
+ };
116
+ }
117
+ delivered(station, bytes) {
118
+ this.month(station).bytes += bytes;
119
+ this.touch();
120
+ }
121
+ restarted(station) {
122
+ this.month(station).restarts += 1;
123
+ this.touch();
124
+ }
125
+ failed(station) {
126
+ this.month(station).failures += 1;
127
+ this.touch();
128
+ }
129
+ listeners(station) {
130
+ return this.live.get(station)?.size ?? 0;
131
+ }
132
+ totalListeners() {
133
+ let total = 0;
134
+ for (const listeners of this.live.values())
135
+ total += listeners.size;
136
+ return total;
137
+ }
138
+ thisMonth(station) {
139
+ return { ...this.month(station) };
140
+ }
141
+ history(station) {
142
+ return { ...(this.usage[station] ?? {}) };
143
+ }
144
+ forget(station) {
145
+ delete this.usage[station];
146
+ this.live.delete(station);
147
+ this.touch();
148
+ }
149
+ /**
150
+ * Whether one more listener may join this station now: null to go
151
+ * ahead, or why not, with a code and how long to wait.
152
+ */
153
+ admit(station, limits) {
154
+ if (limits.monthlyBytes > 0 && this.month(station).bytes >= limits.monthlyBytes) {
155
+ const now = new Date(this.now());
156
+ const next = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
157
+ return { code: "allowance_exhausted", error: "this station has used its delivery allowance for the month", status: 429, retryAfter: Math.ceil((next - this.now()) / 1000) };
158
+ }
159
+ if (limits.stationListeners > 0 && this.listeners(station) >= limits.stationListeners) {
160
+ return { code: "station_full", error: "this station is full; try again in a little while", status: 503, retryAfter: 60 };
161
+ }
162
+ if (limits.totalListeners > 0 && this.totalListeners() >= limits.totalListeners) {
163
+ return { code: "server_full", error: "this server is carrying as many listeners as it can; try again in a little while", status: 503, retryAfter: 60 };
164
+ }
165
+ return null;
166
+ }
167
+ }
168
+ /** Things an owner should know about a station, in words. */
169
+ export function alertsFor(station, state, offlineSince, usage, limits, now = Date.now()) {
170
+ const alerts = [];
171
+ if (station.desired === "running" && (state === "offline" || state === "recovering") && offlineSince > 0) {
172
+ const minutes = Math.floor((now - offlineSince) / 60_000);
173
+ if (minutes >= 2)
174
+ alerts.push(`Off the air for ${minutes} minutes while it should be running.`);
175
+ }
176
+ const left = Object.values(station.failures).filter((count) => count >= 3).length;
177
+ if (left > 0)
178
+ alerts.push(`${left} file${left === 1 ? " is" : "s are"} left out because ${left === 1 ? "it" : "they"} would not play.`);
179
+ if (usage.restarts >= 5)
180
+ alerts.push(`Restarted ${usage.restarts} times this month: something keeps stopping it.`);
181
+ if (limits.monthlyBytes > 0) {
182
+ const share = usage.bytes / limits.monthlyBytes;
183
+ if (share >= 1)
184
+ alerts.push("The delivery allowance for this month is used up: nobody new can join until it resets.");
185
+ else if (share >= 0.9)
186
+ alerts.push(`${Math.round(share * 100)}% of this month's delivery allowance is used.`);
187
+ }
188
+ return alerts;
189
+ }
@@ -1,5 +1,5 @@
1
1
  /** A station page's path: /station/<id>. */
2
2
  export declare const STATION_PAGE: RegExp;
3
- export declare const STATION_PAGE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<link rel=\"icon\" href=\"data:,\">\n<title>Station \u00B7 nixamp</title>\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}\n*{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}\nmain{max-width:560px;margin:0 auto;padding:48px 16px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}\nh1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}\n.about{color:var(--muted);margin:0 0 20px}\n.chip{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600;padding:4px 10px;border-radius:999px;border:1px solid var(--line)}\n.chip::before{content:\"\";width:8px;height:8px;border-radius:50%;background:currentColor}\n.live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}\n.now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}\n.controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}\nbutton{font:inherit;font-weight:600;border:0;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;cursor:pointer;min-width:120px}\nbutton:disabled{opacity:.5;cursor:not-allowed}\nbutton:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}\nlabel{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}\ninput[type=range]{width:120px;accent-color:var(--accent)}\n.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}\n.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\n@media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}\n@keyframes pulse{50%{opacity:.35}}\n</style>\n</head>\n<body>\n<main>\n <div class=\"card\">\n <span id=\"state\" class=\"chip unavailable\" role=\"status\">Loading</span>\n <h1 id=\"name\">&nbsp;</h1>\n <p id=\"about\" class=\"about\" hidden></p>\n <div class=\"now\" aria-live=\"polite\"><div id=\"title\" class=\"title\"></div><div id=\"by\" class=\"by\"></div></div>\n <div class=\"controls\">\n <button id=\"play\" type=\"button\" aria-pressed=\"false\" disabled>Play</button>\n <label>Volume <input id=\"volume\" type=\"range\" min=\"0\" max=\"1\" step=\"0.05\" value=\"1\"></label>\n </div>\n <p class=\"small\"><a id=\"m3u\" href=\"#\">Open in another player (.m3u)</a> \u00B7 Pausing here does not pause the station.</p>\n </div>\n <p class=\"small\">Played by <a href=\"https://nixamp.com\">nixamp</a>.</p>\n <div id=\"announce\" class=\"sr\" aria-live=\"assertive\"></div>\n</main>\n<script>\n(() => {\n const id = location.pathname.split(\"/\").filter(Boolean)[1] || \"\";\n const token = new URLSearchParams(location.search).get(\"t\") || \"\";\n const q = \"?t=\" + encodeURIComponent(token);\n const api = \"/api/v1/stations/\" + encodeURIComponent(id);\n const $ = (s) => document.querySelector(s);\n const audio = new Audio();\n audio.preload = \"none\";\n let wanted = false, retry = 0, timer = 0, last = null, live = false;\n const chip = (state, text) => { const c = $(\"#state\"); c.className = \"chip \" + state; c.textContent = text; };\n const say = (text) => { $(\"#announce\").textContent = text; };\n const draw = (s) => {\n last = s;\n document.title = s.name + \" \u00B7 nixamp\";\n $(\"#name\").textContent = s.name;\n $(\"#about\").textContent = s.description || \"\";\n $(\"#about\").hidden = !s.description;\n if (!live) title(s);\n if (wanted && retry > 0) chip(\"reconnecting\", \"Reconnecting\");\n else if (s.state === \"live\") chip(\"live\", \"Live\");\n else if (s.state === \"starting\" || s.state === \"recovering\") chip(\"reconnecting\", \"Coming back\");\n else if (s.state === \"stopped\") chip(\"stopped\", \"Off the air\");\n else chip(\"unavailable\", \"Unavailable\");\n $(\"#play\").disabled = !(s.state === \"live\" || s.state === \"recovering\" || s.state === \"starting\");\n $(\"#m3u\").href = api + \"/listen.m3u\" + q;\n };\n const title = (s) => {\n const playing = s.nowPlaying;\n $(\"#title\").textContent = playing ? (playing.title || \"Untitled\") : (s.state === \"live\" ? \"On the air\" : \"\");\n $(\"#by\").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(\" \u00B7 \") : \"\";\n };\n // What is ahead of the speaker: the sound the player holds and has not\n // played yet. A new title waits that long, so it changes with the song.\n const ahead = () => {\n if (!wanted || audio.paused || audio.buffered.length === 0) return 0;\n return Math.max(0, Math.min(30, audio.buffered.end(audio.buffered.length - 1) - audio.currentTime));\n };\n const listen = () => {\n if (!window.EventSource) return;\n const events = new EventSource(api + \"/events\" + q);\n events.addEventListener(\"hello\", (e) => { live = true; title(JSON.parse(e.data)); });\n events.addEventListener(\"now\", (e) => {\n const s = JSON.parse(e.data);\n setTimeout(() => { title(s); say(s.nowPlaying ? \"Now playing \" + (s.nowPlaying.title || \"\") : \"\"); }, ahead() * 1000);\n });\n events.addEventListener(\"state\", () => poll());\n events.onerror = () => { live = false; };\n };\n const poll = async () => {\n try {\n const r = await fetch(api + \"/state\" + q, { cache: \"no-store\" });\n if (r.status === 404) { $(\"#name\").textContent = \"This station is not available\"; chip(\"unavailable\", \"Unavailable\"); $(\"#play\").disabled = true; stop(); return; }\n if (r.ok) draw(await r.json());\n } catch {}\n };\n // Always the live edge: a fresh request, never a cached or paused one.\n const join = () => { audio.src = api + \"/stream\" + q + \"&n=\" + Date.now(); audio.play().catch(() => { wanted = false; button(); }); };\n const stop = () => { wanted = false; clearTimeout(timer); audio.pause(); audio.removeAttribute(\"src\"); audio.load(); button(); };\n const button = () => { const b = $(\"#play\"); b.textContent = wanted ? \"Stop\" : \"Play\"; b.setAttribute(\"aria-pressed\", String(wanted)); };\n const again = () => {\n if (!wanted) return;\n retry += 1;\n if (last) draw(last);\n clearTimeout(timer);\n timer = setTimeout(join, Math.min(15000, 1000 * 2 ** Math.min(retry, 4)));\n };\n audio.addEventListener(\"playing\", () => { if (retry) say(\"Back on\"); retry = 0; if (last) draw(last); });\n audio.addEventListener(\"error\", again);\n audio.addEventListener(\"ended\", again);\n audio.addEventListener(\"stalled\", () => { if (wanted && audio.readyState < 3) again(); });\n $(\"#play\").addEventListener(\"click\", () => { if (wanted) { stop(); say(\"Stopped\"); } else { wanted = true; retry = 0; button(); join(); say(\"Playing\"); } });\n $(\"#volume\").addEventListener(\"input\", (e) => { audio.volume = Number(e.target.value); });\n poll();\n listen();\n setInterval(poll, 5000);\n})();\n</script>\n</body>\n</html>\n";
3
+ export declare const STATION_PAGE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<link rel=\"icon\" href=\"data:,\">\n<title>Station \u00B7 nixamp</title>\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}\n*{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}\nmain{max-width:560px;margin:0 auto;padding:48px 16px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}\nh1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}\n.about{color:var(--muted);margin:0 0 20px}\n.chip{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600;padding:4px 10px;border-radius:999px;border:1px solid var(--line)}\n.chip::before{content:\"\";width:8px;height:8px;border-radius:50%;background:currentColor}\n.live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}\n.now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}\n.controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}\nbutton{font:inherit;font-weight:600;border:0;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;cursor:pointer;min-width:120px}\nbutton:disabled{opacity:.5;cursor:not-allowed}\nbutton:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}\nlabel{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}\ninput[type=range]{width:120px;accent-color:var(--accent)}\n.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}\n.screen{display:block;width:100%;aspect-ratio:16/9;background:#000;border-radius:10px;margin:12px 0}\n.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\n@media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}\n@keyframes pulse{50%{opacity:.35}}\n</style>\n</head>\n<body>\n<main>\n <div class=\"card\">\n <span id=\"state\" class=\"chip unavailable\" role=\"status\">Loading</span>\n <video id=\"screen\" class=\"screen\" playsinline preload=\"none\" hidden></video>\n <h1 id=\"name\">&nbsp;</h1>\n <p id=\"about\" class=\"about\" hidden></p>\n <div class=\"now\" aria-live=\"polite\"><div id=\"title\" class=\"title\"></div><div id=\"by\" class=\"by\"></div></div>\n <div class=\"controls\">\n <button id=\"play\" type=\"button\" aria-pressed=\"false\" disabled>Play</button>\n <label>Volume <input id=\"volume\" type=\"range\" min=\"0\" max=\"1\" step=\"0.05\" value=\"1\"></label>\n </div>\n <p class=\"small\"><a id=\"m3u\" href=\"#\">Open in another player (.m3u)</a> \u00B7 Pausing here does not pause the station.</p>\n </div>\n <p class=\"small\">Played by <a href=\"https://nixamp.com\">nixamp</a>.</p>\n <div id=\"announce\" class=\"sr\" aria-live=\"assertive\"></div>\n</main>\n<script>\n(() => {\n const id = location.pathname.split(\"/\").filter(Boolean)[1] || \"\";\n const token = new URLSearchParams(location.search).get(\"t\") || \"\";\n const q = \"?t=\" + encodeURIComponent(token);\n const api = \"/api/v1/stations/\" + encodeURIComponent(id);\n const $ = (s) => document.querySelector(s);\n // Sound, or a TV station's picture: the element is chosen by the first state.\n let audio = new Audio();\n audio.preload = \"none\";\n let wired = false;\n const wire = () => {\n if (wired) return;\n wired = true;\n audio.addEventListener(\"playing\", () => { if (retry) say(\"Back on\"); retry = 0; if (last) draw(last); });\n audio.addEventListener(\"error\", again);\n audio.addEventListener(\"ended\", again);\n audio.addEventListener(\"stalled\", () => { if (wanted && audio.readyState < 3) again(); });\n };\n let wanted = false, retry = 0, timer = 0, last = null, live = false;\n const chip = (state, text) => { const c = $(\"#state\"); c.className = \"chip \" + state; c.textContent = text; };\n const say = (text) => { $(\"#announce\").textContent = text; };\n const draw = (s) => {\n last = s;\n if (s.kind === \"video\" && audio.tagName !== \"VIDEO\") {\n audio = $(\"#screen\");\n audio.hidden = false;\n wired = false;\n wire();\n }\n document.title = s.name + \" \u00B7 nixamp\";\n $(\"#name\").textContent = s.name;\n $(\"#about\").textContent = s.description || \"\";\n $(\"#about\").hidden = !s.description;\n if (!live) title(s);\n if (wanted && retry > 0) chip(\"reconnecting\", \"Reconnecting\");\n else if (s.state === \"live\") chip(\"live\", \"Live\");\n else if (s.state === \"starting\" || s.state === \"recovering\") chip(\"reconnecting\", \"Coming back\");\n else if (s.state === \"stopped\") chip(\"stopped\", \"Off the air\");\n else chip(\"unavailable\", \"Unavailable\");\n $(\"#play\").disabled = !(s.state === \"live\" || s.state === \"recovering\" || s.state === \"starting\");\n $(\"#m3u\").href = api + \"/listen.m3u\" + q;\n };\n const title = (s) => {\n const playing = s.nowPlaying;\n $(\"#title\").textContent = playing ? (playing.title || \"Untitled\") : (s.state === \"live\" ? \"On the air\" : \"\");\n $(\"#by\").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(\" \u00B7 \") : \"\";\n };\n // What is ahead of the speaker: the sound the player holds and has not\n // played yet. A new title waits that long, so it changes with the song.\n const ahead = () => {\n if (!wanted || audio.paused || audio.buffered.length === 0) return 0;\n return Math.max(0, Math.min(30, audio.buffered.end(audio.buffered.length - 1) - audio.currentTime));\n };\n const listen = () => {\n if (!window.EventSource) return;\n const events = new EventSource(api + \"/events\" + q);\n events.addEventListener(\"hello\", (e) => { live = true; title(JSON.parse(e.data)); });\n events.addEventListener(\"now\", (e) => {\n const s = JSON.parse(e.data);\n setTimeout(() => { title(s); say(s.nowPlaying ? \"Now playing \" + (s.nowPlaying.title || \"\") : \"\"); }, ahead() * 1000);\n });\n events.addEventListener(\"state\", () => poll());\n events.onerror = () => { live = false; };\n };\n const poll = async () => {\n try {\n const r = await fetch(api + \"/state\" + q, { cache: \"no-store\" });\n if (r.status === 404) { $(\"#name\").textContent = \"This station is not available\"; chip(\"unavailable\", \"Unavailable\"); $(\"#play\").disabled = true; stop(); return; }\n if (r.ok) draw(await r.json());\n } catch {}\n };\n // Always the live edge: a fresh request, never a cached or paused one.\n const join = () => { audio.src = api + \"/stream\" + q + \"&n=\" + Date.now(); audio.play().catch(() => { wanted = false; button(); }); };\n const stop = () => { wanted = false; clearTimeout(timer); audio.pause(); audio.removeAttribute(\"src\"); audio.load(); button(); };\n const button = () => { const b = $(\"#play\"); b.textContent = wanted ? \"Stop\" : \"Play\"; b.setAttribute(\"aria-pressed\", String(wanted)); };\n const again = () => {\n if (!wanted) return;\n retry += 1;\n if (last) draw(last);\n clearTimeout(timer);\n timer = setTimeout(join, Math.min(15000, 1000 * 2 ** Math.min(retry, 4)));\n };\n wire();\n $(\"#play\").addEventListener(\"click\", () => { if (wanted) { stop(); say(\"Stopped\"); } else { wanted = true; retry = 0; button(); join(); say(\"Playing\"); } });\n $(\"#volume\").addEventListener(\"input\", (e) => { audio.volume = Number(e.target.value); });\n poll();\n listen();\n setInterval(poll, 5000);\n})();\n</script>\n</body>\n</html>\n";
4
4
  /** Only its own script and style, only its own server, never framed. */
5
5
  export declare const STATION_PAGE_CSP: string;
@@ -29,6 +29,7 @@ button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(-
29
29
  label{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}
30
30
  input[type=range]{width:120px;accent-color:var(--accent)}
31
31
  .small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}
32
+ .screen{display:block;width:100%;aspect-ratio:16/9;background:#000;border-radius:10px;margin:12px 0}
32
33
  .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
33
34
  @media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}
34
35
  @keyframes pulse{50%{opacity:.35}}
@@ -40,13 +41,29 @@ const SCRIPT = `
40
41
  const q = "?t=" + encodeURIComponent(token);
41
42
  const api = "/api/v1/stations/" + encodeURIComponent(id);
42
43
  const $ = (s) => document.querySelector(s);
43
- const audio = new Audio();
44
+ // Sound, or a TV station's picture: the element is chosen by the first state.
45
+ let audio = new Audio();
44
46
  audio.preload = "none";
47
+ let wired = false;
48
+ const wire = () => {
49
+ if (wired) return;
50
+ wired = true;
51
+ audio.addEventListener("playing", () => { if (retry) say("Back on"); retry = 0; if (last) draw(last); });
52
+ audio.addEventListener("error", again);
53
+ audio.addEventListener("ended", again);
54
+ audio.addEventListener("stalled", () => { if (wanted && audio.readyState < 3) again(); });
55
+ };
45
56
  let wanted = false, retry = 0, timer = 0, last = null, live = false;
46
57
  const chip = (state, text) => { const c = $("#state"); c.className = "chip " + state; c.textContent = text; };
47
58
  const say = (text) => { $("#announce").textContent = text; };
48
59
  const draw = (s) => {
49
60
  last = s;
61
+ if (s.kind === "video" && audio.tagName !== "VIDEO") {
62
+ audio = $("#screen");
63
+ audio.hidden = false;
64
+ wired = false;
65
+ wire();
66
+ }
50
67
  document.title = s.name + " · nixamp";
51
68
  $("#name").textContent = s.name;
52
69
  $("#about").textContent = s.description || "";
@@ -100,10 +117,7 @@ const SCRIPT = `
100
117
  clearTimeout(timer);
101
118
  timer = setTimeout(join, Math.min(15000, 1000 * 2 ** Math.min(retry, 4)));
102
119
  };
103
- audio.addEventListener("playing", () => { if (retry) say("Back on"); retry = 0; if (last) draw(last); });
104
- audio.addEventListener("error", again);
105
- audio.addEventListener("ended", again);
106
- audio.addEventListener("stalled", () => { if (wanted && audio.readyState < 3) again(); });
120
+ wire();
107
121
  $("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
108
122
  $("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
109
123
  poll();
@@ -126,6 +140,7 @@ const PAGE = `<!doctype html>
126
140
  <main>
127
141
  <div class="card">
128
142
  <span id="state" class="chip unavailable" role="status">Loading</span>
143
+ <video id="screen" class="screen" playsinline preload="none" hidden></video>
129
144
  <h1 id="name">&nbsp;</h1>
130
145
  <p id="about" class="about" hidden></p>
131
146
  <div class="now" aria-live="polite"><div id="title" class="title"></div><div id="by" class="by"></div></div>
@@ -15,6 +15,7 @@
15
15
  import type { IncomingMessage, ServerResponse } from "node:http";
16
16
  import type { Channels } from "./channels.ts";
17
17
  import type { StationRunner } from "./station-runner.ts";
18
+ import { type Limits, type StationUsage } from "./station-metering.ts";
18
19
  import { type OwnerStation, type PublicStation, type Station, type StationStore } from "./stations.ts";
19
20
  /** Whether this path is one a station's own link may open, without the server's key. */
20
21
  export declare function isStationListenPath(path: string): boolean;
@@ -43,6 +44,18 @@ export interface StationContext {
43
44
  readBody: (request: IncomingMessage) => Promise<string>;
44
45
  /** Headers every answer carries (CORS). */
45
46
  headers: Record<string, string>;
47
+ /** Metering and limits; absent in tests that do not care. */
48
+ usage?: StationUsage;
49
+ limits?: Limits;
50
+ /** Null to go ahead with a change, or how long to wait (control rate limit). */
51
+ throttle?: () => {
52
+ retryAfter: number;
53
+ } | null;
54
+ /** Auto stations: run a sync with these settings (and keep them), or read them. */
55
+ auto?: {
56
+ sync: (input: Record<string, unknown>) => Promise<unknown>;
57
+ setting: () => unknown;
58
+ };
46
59
  }
47
60
  declare function links(ctx: StationContext, station: Station): {
48
61
  page: string;