nixamp 0.34.0 → 0.35.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 (39) hide show
  1. package/dist/server.d.ts +3 -0
  2. package/dist/server.js +14 -1
  3. package/dist/station-metering.d.ts +59 -0
  4. package/dist/station-metering.js +189 -0
  5. package/dist/station-routes.d.ts +8 -0
  6. package/dist/station-routes.js +60 -1
  7. package/dist/station-runner.d.ts +7 -0
  8. package/dist/station-runner.js +12 -0
  9. package/package.json +1 -1
  10. package/src/server.ts +15 -2
  11. package/src/station-metering.ts +218 -0
  12. package/src/station-routes.ts +64 -1
  13. package/src/station-runner.ts +15 -0
  14. package/web/dist/assets/{ar-DoW0wbjC.js → ar-Doh06ZMu.js} +1 -1
  15. package/web/dist/assets/{cs-CjKqCjy5.js → cs-C_lZl4jI.js} +1 -1
  16. package/web/dist/assets/{da-BQERb7Qz.js → da-CSBBA_ZI.js} +1 -1
  17. package/web/dist/assets/{de-CKKbRy6c.js → de-zorW0tO_.js} +1 -1
  18. package/web/dist/assets/{es-CL3v5Og9.js → es-DBUl3DGo.js} +1 -1
  19. package/web/dist/assets/{fi-DgV81qNU.js → fi-DpUx7XY-.js} +1 -1
  20. package/web/dist/assets/{fr-BGumytzF.js → fr-Cg_xppCf.js} +1 -1
  21. package/web/dist/assets/{hi-CAQ9elqo.js → hi-PD1kdcf2.js} +1 -1
  22. package/web/dist/assets/{hls-3VKVEQE3-Ceri0flt.js → hls-3VKVEQE3-BEEk729h.js} +1 -1
  23. package/web/dist/assets/{hu-UJNgJVWX.js → hu-C1fGbjZO.js} +1 -1
  24. package/web/dist/assets/{id-BqQOlrF-.js → id-DRBhZ8KM.js} +1 -1
  25. package/web/dist/assets/{it-Cs8G6mvW.js → it-Ci3OZV2-.js} +1 -1
  26. package/web/dist/assets/{main-Y1Rvn1GX.js → main-CEJhJH5y.js} +2 -2
  27. package/web/dist/assets/{mpegts-LO6RVLD6-CPI8xaZ-.js → mpegts-LO6RVLD6-DPrv1ran.js} +1 -1
  28. package/web/dist/assets/{mpegts-jS_Diizz.js → mpegts-y-HAxt1b.js} +1 -1
  29. package/web/dist/assets/{nl-BJXQ9nqo.js → nl-CwWOHKbv.js} +1 -1
  30. package/web/dist/assets/radio-BpH1qMMQ.js +1 -0
  31. package/web/dist/assets/{ru-XmVkvNY8.js → ru-BWlIGsoL.js} +1 -1
  32. package/web/dist/assets/{sv-pVm9p66e.js → sv-Gb1cLNsc.js} +1 -1
  33. package/web/dist/assets/{uk-4hcyaD3e.js → uk-MZUnyyuR.js} +1 -1
  34. package/web/dist/assets/{vi-B7MipBlv.js → vi-DNWS5fxc.js} +1 -1
  35. package/web/dist/assets/{zh-B5YVExOW.js → zh-BcOiladF.js} +1 -1
  36. package/web/dist/index.html +1 -1
  37. package/web/dist/radio.html +1 -1
  38. package/web/dist/sw.js +23 -23
  39. package/web/dist/assets/radio-B58WgC1j.js +0 -1
package/dist/server.d.ts CHANGED
@@ -26,6 +26,7 @@ import { ListenerStore } from "./library-user.ts";
26
26
  import { Importer } from "./handoff.ts";
27
27
  import { StationRunner } from "./station-runner.ts";
28
28
  import { StationStore } from "./stations.ts";
29
+ import { StationUsage, type Limits } from "./station-metering.ts";
29
30
  import { CompressionService } from "./compression/service.ts";
30
31
  import { Enricher } from "./enrich.ts";
31
32
  import { Follows } from "./follows.ts";
@@ -582,6 +583,8 @@ export interface HandlerOptions {
582
583
  stations?: {
583
584
  store: StationStore;
584
585
  runner: StationRunner;
586
+ usage?: StationUsage;
587
+ limits?: Limits;
585
588
  };
586
589
  web: string | null;
587
590
  /** Branded clients on this same backend, keyed by an explicitly configured host. */
package/dist/server.js CHANGED
@@ -63,6 +63,7 @@ import { handleHandoffs } from "./handoff-routes.js";
63
63
  import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
64
64
  import { StationRunner } from "./station-runner.js";
65
65
  import { StationError, StationStore, stationOfChannel } from "./stations.js";
66
+ import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv } from "./station-metering.js";
66
67
  import { CompressionService } from "./compression/service.js";
67
68
  import { handleChannelCompression, handleCompressionApi, handleStaticRelay } from "./compression/routes.js";
68
69
  import { DEFAULT_SITE as NICHEDB, Enricher, FIXTURE_TTL_MS } from "./enrich.js";
@@ -2274,6 +2275,12 @@ export function createHandler(engine, options) {
2274
2275
  json,
2275
2276
  readBody,
2276
2277
  headers: CORS,
2278
+ ...(options.stations.usage ? { usage: options.stations.usage } : {}),
2279
+ ...(options.stations.limits ? { limits: options.stations.limits } : {}),
2280
+ throttle: () => {
2281
+ const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
2282
+ return verdict.ok ? null : { retryAfter: verdict.retryAfter };
2283
+ },
2277
2284
  });
2278
2285
  return;
2279
2286
  }
@@ -6168,7 +6175,13 @@ export async function serve(argv, version = "0.1.0") {
6168
6175
  const listeners = new ListenerStore(ListenerStore.fileFor(stateDir(), options.port));
6169
6176
  const library = { index: libraryIndex, probe: probeFile, retag: retagPlayer, listeners };
6170
6177
  const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
6178
+ // What stations deliver, per month, and the operator's limits on it.
6179
+ const stationUsage = new StationUsage(StationUsage.fileFor(stateDir(), options.port));
6180
+ const stationLimits = limitsFromEnv();
6181
+ process.once("exit", () => stationUsage.flush());
6171
6182
  stationRunner = new StationRunner({
6183
+ onRestart: (id) => stationUsage.restarted(id),
6184
+ onFailure: (id) => stationUsage.failed(id),
6172
6185
  store: stationStore,
6173
6186
  channels,
6174
6187
  pull: async (channelId, name, files, known) => Boolean(await pullChannel(channels, tools.ffprobe, channelId, name, files[0], [], "", {
@@ -6181,7 +6194,7 @@ export async function serve(argv, version = "0.1.0") {
6181
6194
  roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
6182
6195
  log: (line) => console.log(line),
6183
6196
  });
6184
- const stations = { store: stationStore, runner: stationRunner };
6197
+ const stations = { store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits };
6185
6198
  // Music handed over from BitTorrented, copied into a folder of our own.
6186
6199
  const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
6187
6200
  index: libraryIndex,
@@ -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
+ }
@@ -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,13 @@ 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;
46
54
  }
47
55
  declare function links(ctx: StationContext, station: Station): {
48
56
  page: string;
@@ -1,3 +1,4 @@
1
+ import { alertsFor } from "./station-metering.js";
1
2
  import { StationError, channelOf, tokensMatch, } from "./stations.js";
2
3
  /** The three things a station's link opens. */
3
4
  const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events)$/;
@@ -56,6 +57,10 @@ export function ownerView(ctx, station) {
56
57
  error: ctx.runner.error(station.id),
57
58
  links: links(ctx, station),
58
59
  nowPlaying: nowPlayingFile(ctx, station),
60
+ ...(ctx.usage && ctx.limits ? {
61
+ metrics: { listeners: ctx.usage.listeners(station.id), month: ctx.usage.thisMonth(station.id), limits: ctx.limits },
62
+ alerts: alertsFor(station, ctx.runner.observed(station.id), ctx.runner.downSince(station.id), ctx.usage.thisMonth(station.id), ctx.limits),
63
+ } : {}),
59
64
  };
60
65
  }
61
66
  /** Answers anything under /api/v1/stations; false when the path is not one. */
@@ -153,20 +158,46 @@ export async function handleStations(request, response, path, url, ctx) {
153
158
  ctx.json(response, 503, { error: "the station is not on the air", code: "not_live", state: ctx.runner.observed(station.id) });
154
159
  return true;
155
160
  }
161
+ // Admitted only within the station's and the server's limits, and the
162
+ // month's allowance; refused with what to do about it.
163
+ const refused = method === "GET" && ctx.usage && ctx.limits ? ctx.usage.admit(station.id, ctx.limits) : null;
164
+ if (refused) {
165
+ response.writeHead(refused.status, { ...ctx.headers, "content-type": "application/json; charset=utf-8", "retry-after": String(refused.retryAfter) });
166
+ response.end(JSON.stringify({ error: refused.error, code: refused.code, retryAfter: refused.retryAfter }));
167
+ return true;
168
+ }
156
169
  response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
157
170
  if (method === "HEAD") {
158
171
  response.end();
159
172
  return true;
160
173
  }
161
- const detach = ctx.channels.listen(channelId, response);
174
+ // Every byte that goes out through the link is counted against the station.
175
+ const usage = ctx.usage;
176
+ const counted = usage
177
+ ? {
178
+ write: (chunk) => {
179
+ usage.delivered(station.id, chunk.length);
180
+ return response.write(chunk);
181
+ },
182
+ end: () => { response.end(); },
183
+ pending: () => response.writableLength,
184
+ }
185
+ : response;
186
+ const detach = ctx.channels.listen(channelId, counted);
162
187
  if (detach === null) {
163
188
  response.end();
164
189
  return true;
165
190
  }
191
+ const left = usage?.joined(station.id);
166
192
  const untrack = ctx.runner.track(station.id, { end: () => response.destroy() });
193
+ let gone = false;
167
194
  const leave = () => {
195
+ if (gone)
196
+ return;
197
+ gone = true;
168
198
  detach();
169
199
  untrack();
200
+ left?.();
170
201
  };
171
202
  request.on("close", leave);
172
203
  response.on("close", leave);
@@ -177,6 +208,15 @@ export async function handleStations(request, response, path, url, ctx) {
177
208
  ctx.json(response, 403, { error: "only this server's owner manages its stations", code: "forbidden" });
178
209
  return true;
179
210
  }
211
+ // Changes are limited apart from listening, so neither starves the other.
212
+ if (method !== "GET" && ctx.throttle) {
213
+ const wait = ctx.throttle();
214
+ if (wait) {
215
+ response.setHeader("retry-after", String(wait.retryAfter));
216
+ ctx.json(response, 429, { error: "too many changes at once; wait a moment", code: "rate_limited", retryAfter: wait.retryAfter });
217
+ return true;
218
+ }
219
+ }
180
220
  const body = async () => {
181
221
  if (method === "GET" || method === "DELETE")
182
222
  return {};
@@ -253,11 +293,30 @@ export async function handleStations(request, response, path, url, ctx) {
253
293
  ctx.json(response, 405, { error: "GET, PATCH or DELETE", code: "method_not_allowed" });
254
294
  return true;
255
295
  }
296
+ if (action === "metrics" && method === "GET") {
297
+ if (!ctx.usage || !ctx.limits) {
298
+ ctx.json(response, 404, { error: "metering is off on this server", code: "not_found" });
299
+ return true;
300
+ }
301
+ ctx.json(response, 200, {
302
+ listeners: ctx.usage.listeners(station.id),
303
+ month: ctx.usage.thisMonth(station.id),
304
+ history: ctx.usage.history(station.id),
305
+ limits: ctx.limits,
306
+ alerts: alertsFor(station, ctx.runner.observed(station.id), ctx.runner.downSince(station.id), ctx.usage.thisMonth(station.id), ctx.limits),
307
+ });
308
+ return true;
309
+ }
256
310
  if (method !== "POST") {
257
311
  ctx.json(response, 405, { error: "POST", code: "method_not_allowed" });
258
312
  return true;
259
313
  }
260
314
  if (action === "start") {
315
+ const running = ctx.store.list().filter((one) => one.desired === "running" && one.id !== id).length;
316
+ if (ctx.limits && ctx.limits.runningStations > 0 && running >= ctx.limits.runningStations) {
317
+ ctx.json(response, 409, { error: `this server runs at most ${ctx.limits.runningStations} stations at once; stop one first`, code: "too_many_running" });
318
+ return true;
319
+ }
261
320
  const started = await ctx.runner.start(id);
262
321
  ctx.json(response, 200, { station: ownerView(ctx, started) });
263
322
  return true;
@@ -23,6 +23,9 @@ export interface RunnerDeps {
23
23
  roots: () => string[];
24
24
  log?: (line: string) => void;
25
25
  now?: () => number;
26
+ /** Told when a station is restarted after an unexpected end, and when a file will not play. */
27
+ onRestart?: (id: string) => void;
28
+ onFailure?: (id: string) => void;
26
29
  }
27
30
  /** Something a listener is connected through, that can be ended. */
28
31
  export interface Ending {
@@ -37,6 +40,8 @@ export declare class StationRunner {
37
40
  private starting;
38
41
  private errors;
39
42
  private listening;
43
+ /** When a station that should be on went off, for "off the air for N minutes". */
44
+ private down;
40
45
  constructor(deps: RunnerDeps);
41
46
  private now;
42
47
  private log;
@@ -58,6 +63,8 @@ export declare class StationRunner {
58
63
  private scheduleRecovery;
59
64
  private cancelRecovery;
60
65
  observed(id: string): Observed;
66
+ /** When it went off while wanted, or 0 when it is not off. */
67
+ downSince(id: string): number;
61
68
  error(id: string): string;
62
69
  /** A listener on a station's link, ended if the link is revoked. */
63
70
  track(id: string, listener: Ending): () => void;
@@ -10,6 +10,8 @@ export class StationRunner {
10
10
  starting = new Set();
11
11
  errors = new Map();
12
12
  listening = new Map();
13
+ /** When a station that should be on went off, for "off the air for N minutes". */
14
+ down = new Map();
13
15
  constructor(deps) {
14
16
  this.deps = deps;
15
17
  }
@@ -41,6 +43,7 @@ export class StationRunner {
41
43
  /** Wanted off, and taken off. Stays off across restarts. */
42
44
  stop(id) {
43
45
  const station = this.deps.store.setDesired(id, "stopped", this.now());
46
+ this.down.delete(id);
44
47
  this.cancelRecovery(id);
45
48
  this.attempts.delete(id);
46
49
  this.errors.delete(id);
@@ -106,6 +109,7 @@ export class StationRunner {
106
109
  excluded: (file) => store.isExcluded(id, file),
107
110
  onFailure: ({ file, reason }) => {
108
111
  this.errors.set(id, reason);
112
+ this.deps.onFailure?.(id);
109
113
  if (store.recordFailure(id, file)) {
110
114
  this.log(` Station "${station.name}": left out a file that failed ${EXCLUDE_AFTER} times.`);
111
115
  }
@@ -118,6 +122,7 @@ export class StationRunner {
118
122
  return false;
119
123
  }
120
124
  this.startedAt.set(id, this.now());
125
+ this.down.delete(id);
121
126
  this.deps.channels.keep(channelId);
122
127
  return true;
123
128
  }
@@ -147,6 +152,9 @@ export class StationRunner {
147
152
  this.errors.delete(id);
148
153
  return;
149
154
  }
155
+ if (!this.down.has(id))
156
+ this.down.set(id, this.now());
157
+ this.deps.onRestart?.(id);
150
158
  const ran = this.now() - (this.startedAt.get(id) ?? 0);
151
159
  if (ran >= HEALTHY_RUN_MS)
152
160
  this.attempts.delete(id);
@@ -185,6 +193,10 @@ export class StationRunner {
185
193
  return info.redials ? "recovering" : "live";
186
194
  return (this.attempts.get(id) ?? 0) <= RECOVERY_DELAYS_MS.length && this.timers.has(id) ? "recovering" : "offline";
187
195
  }
196
+ /** When it went off while wanted, or 0 when it is not off. */
197
+ downSince(id) {
198
+ return this.down.get(id) ?? 0;
199
+ }
188
200
  error(id) {
189
201
  return this.errors.get(id) ?? "";
190
202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
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/server.ts CHANGED
@@ -80,6 +80,7 @@ import { handleHandoffs } from "./handoff-routes.ts";
80
80
  import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
81
81
  import { StationRunner } from "./station-runner.ts";
82
82
  import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
83
+ import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv, type Limits } from "./station-metering.ts";
83
84
  import { CompressionService } from "./compression/service.ts";
84
85
  import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
85
86
  import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
@@ -1716,7 +1717,7 @@ export interface HandlerOptions {
1716
1717
  /** Music handed over from BitTorrented. */
1717
1718
  importer?: Importer;
1718
1719
  /** This server's stations and what keeps them on the air. */
1719
- stations?: { store: StationStore; runner: StationRunner };
1720
+ stations?: { store: StationStore; runner: StationRunner; usage?: StationUsage; limits?: Limits };
1720
1721
  web: string | null;
1721
1722
  /** Branded clients on this same backend, keyed by an explicitly configured host. */
1722
1723
  webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
@@ -2785,6 +2786,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2785
2786
  json,
2786
2787
  readBody,
2787
2788
  headers: CORS,
2789
+ ...(options.stations.usage ? { usage: options.stations.usage } : {}),
2790
+ ...(options.stations.limits ? { limits: options.stations.limits } : {}),
2791
+ throttle: () => {
2792
+ const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
2793
+ return verdict.ok ? null : { retryAfter: verdict.retryAfter };
2794
+ },
2788
2795
  });
2789
2796
  return;
2790
2797
  }
@@ -6652,7 +6659,13 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6652
6659
  const listeners = new ListenerStore(ListenerStore.fileFor(stateDir(), options.port));
6653
6660
  const library = { index: libraryIndex, probe: probeFile, retag: retagPlayer, listeners };
6654
6661
  const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
6662
+ // What stations deliver, per month, and the operator's limits on it.
6663
+ const stationUsage = new StationUsage(StationUsage.fileFor(stateDir(), options.port));
6664
+ const stationLimits = limitsFromEnv();
6665
+ process.once("exit", () => stationUsage.flush());
6655
6666
  stationRunner = new StationRunner({
6667
+ onRestart: (id) => stationUsage.restarted(id),
6668
+ onFailure: (id) => stationUsage.failed(id),
6656
6669
  store: stationStore,
6657
6670
  channels,
6658
6671
  pull: async (channelId, name, files, known) =>
@@ -6666,7 +6679,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6666
6679
  roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
6667
6680
  log: (line) => console.log(line),
6668
6681
  });
6669
- const stations = { store: stationStore, runner: stationRunner };
6682
+ const stations = { store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits };
6670
6683
  // Music handed over from BitTorrented, copied into a folder of our own.
6671
6684
  const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
6672
6685
  index: libraryIndex,