nixamp 0.33.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 (53) hide show
  1. package/dist/handoff-routes.d.ts +17 -0
  2. package/dist/handoff-routes.js +64 -0
  3. package/dist/handoff.d.ts +89 -0
  4. package/dist/handoff.js +358 -0
  5. package/dist/library-index.d.ts +9 -0
  6. package/dist/library-index.js +19 -0
  7. package/dist/oauth-server.d.ts +1 -0
  8. package/dist/oauth-server.js +1 -0
  9. package/dist/owner.js +4 -1
  10. package/dist/server.d.ts +6 -0
  11. package/dist/server.js +63 -4
  12. package/dist/station-metering.d.ts +59 -0
  13. package/dist/station-metering.js +189 -0
  14. package/dist/station-routes.d.ts +8 -0
  15. package/dist/station-routes.js +60 -1
  16. package/dist/station-runner.d.ts +7 -0
  17. package/dist/station-runner.js +12 -0
  18. package/package.json +1 -1
  19. package/src/handoff-routes.ts +73 -0
  20. package/src/handoff.ts +383 -0
  21. package/src/library-index.ts +21 -0
  22. package/src/oauth-server.ts +1 -0
  23. package/src/owner.ts +5 -2
  24. package/src/server.ts +67 -5
  25. package/src/station-metering.ts +218 -0
  26. package/src/station-routes.ts +64 -1
  27. package/src/station-runner.ts +15 -0
  28. package/web/dist/assets/{ar-4kD-mTIA.js → ar-Doh06ZMu.js} +1 -1
  29. package/web/dist/assets/{cs-CziXiWNY.js → cs-C_lZl4jI.js} +1 -1
  30. package/web/dist/assets/{da-CyjJ8UOr.js → da-CSBBA_ZI.js} +1 -1
  31. package/web/dist/assets/{de-DxwYa667.js → de-zorW0tO_.js} +1 -1
  32. package/web/dist/assets/{es-QgMTJeSc.js → es-DBUl3DGo.js} +1 -1
  33. package/web/dist/assets/{fi-Clj-jHhR.js → fi-DpUx7XY-.js} +1 -1
  34. package/web/dist/assets/{fr-nH_gzSFv.js → fr-Cg_xppCf.js} +1 -1
  35. package/web/dist/assets/{hi-D7djQ6_C.js → hi-PD1kdcf2.js} +1 -1
  36. package/web/dist/assets/{hls-3VKVEQE3-BaWVouMg.js → hls-3VKVEQE3-BEEk729h.js} +1 -1
  37. package/web/dist/assets/{hu-H5Q3L8bD.js → hu-C1fGbjZO.js} +1 -1
  38. package/web/dist/assets/{id-40z_EQdf.js → id-DRBhZ8KM.js} +1 -1
  39. package/web/dist/assets/{it-C9Fcgywl.js → it-Ci3OZV2-.js} +1 -1
  40. package/web/dist/assets/{main-BJUyBy0h.js → main-CEJhJH5y.js} +2 -2
  41. package/web/dist/assets/{mpegts-LO6RVLD6-CAuth8C5.js → mpegts-LO6RVLD6-DPrv1ran.js} +1 -1
  42. package/web/dist/assets/{mpegts-BlmyNlBC.js → mpegts-y-HAxt1b.js} +1 -1
  43. package/web/dist/assets/{nl-CrL4Psk9.js → nl-CwWOHKbv.js} +1 -1
  44. package/web/dist/assets/radio-BpH1qMMQ.js +1 -0
  45. package/web/dist/assets/{ru-C84JS0ch.js → ru-BWlIGsoL.js} +1 -1
  46. package/web/dist/assets/{sv-DtMcl0sf.js → sv-Gb1cLNsc.js} +1 -1
  47. package/web/dist/assets/{uk-dOLf99vZ.js → uk-MZUnyyuR.js} +1 -1
  48. package/web/dist/assets/{vi-BWeQVRZ8.js → vi-DNWS5fxc.js} +1 -1
  49. package/web/dist/assets/{zh-Cnv3Qpux.js → zh-BcOiladF.js} +1 -1
  50. package/web/dist/index.html +1 -1
  51. package/web/dist/radio.html +1 -1
  52. package/web/dist/sw.js +23 -23
  53. package/web/dist/assets/radio-B58WgC1j.js +0 -1
package/dist/server.js CHANGED
@@ -58,9 +58,12 @@ import { handleStations, isStationListenPath } from "./station-routes.js";
58
58
  import { handleLibrary } from "./library-routes.js";
59
59
  import { LibraryIndex, probeItem, trackOf } from "./library-index.js";
60
60
  import { ListenerStore } from "./library-user.js";
61
+ import { Importer } from "./handoff.js";
62
+ import { handleHandoffs } from "./handoff-routes.js";
61
63
  import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
62
64
  import { StationRunner } from "./station-runner.js";
63
65
  import { StationError, StationStore, stationOfChannel } from "./stations.js";
66
+ import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv } from "./station-metering.js";
64
67
  import { CompressionService } from "./compression/service.js";
65
68
  import { handleChannelCompression, handleCompressionApi, handleStaticRelay } from "./compression/routes.js";
66
69
  import { DEFAULT_SITE as NICHEDB, Enricher, FIXTURE_TTL_MS } from "./enrich.js";
@@ -2227,6 +2230,17 @@ export function createHandler(engine, options) {
2227
2230
  });
2228
2231
  return;
2229
2232
  }
2233
+ if (options.importer && path.startsWith("/api/v1/integrations/bittorrented/")) {
2234
+ const control = await controlOf();
2235
+ await handleHandoffs(request, response, path, {
2236
+ importer: options.importer,
2237
+ canWrite: control.allowed,
2238
+ owner: control.account || "owner",
2239
+ json,
2240
+ readBody,
2241
+ });
2242
+ return;
2243
+ }
2230
2244
  if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
2231
2245
  const control = await controlOf();
2232
2246
  const encrypted = request.socket.encrypted === true;
@@ -2261,6 +2275,12 @@ export function createHandler(engine, options) {
2261
2275
  json,
2262
2276
  readBody,
2263
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
+ },
2264
2284
  });
2265
2285
  return;
2266
2286
  }
@@ -2343,12 +2363,17 @@ export function createHandler(engine, options) {
2343
2363
  return;
2344
2364
  }
2345
2365
  if (path === "/api/v1/auth/me") {
2346
- const who = await accounts.whoIs(tokenFrom(request.headers));
2366
+ const token = tokenFrom(request.headers);
2367
+ const who = await accounts.whoIs(token);
2347
2368
  if (who === null) {
2348
2369
  json(response, 401, { error: "not signed in" });
2349
2370
  return;
2350
2371
  }
2351
- json(response, 200, { account: who });
2372
+ // An app acting for the person says so, with what it was allowed:
2373
+ // a server asking "is this my owner?" must not take a token handed to
2374
+ // some other app as the owner themselves.
2375
+ const grant = options.authServer ? await options.authServer.grantFor(token) : null;
2376
+ json(response, 200, { account: who, ...(grant ? { grant: { clientId: grant.clientId, scope: grant.scope } } : {}) });
2352
2377
  return;
2353
2378
  }
2354
2379
  /**
@@ -3599,11 +3624,28 @@ export function createHandler(engine, options) {
3599
3624
  // point: a share link in a terminal you closed is a server you have lost.
3600
3625
  if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers && options.accounts) {
3601
3626
  const servers = options.servers;
3602
- const who = await options.accounts.whoIs(tokenFrom(request.headers));
3627
+ const token = tokenFrom(request.headers);
3628
+ const who = await options.accounts.whoIs(token);
3603
3629
  if (who === null) {
3604
3630
  json(response, 401, { error: "not signed in" });
3605
3631
  return;
3606
3632
  }
3633
+ // An app acting for the person sees the list only with a scope that
3634
+ // says so, may not change it, and never gets a control key: those are
3635
+ // the person's, and an app that held one could drive the machine.
3636
+ const grant = options.authServer ? await options.authServer.grantFor(token) : null;
3637
+ if (grant) {
3638
+ if (!(grant.scope.includes("streams") || grant.scope.includes("stations"))) {
3639
+ json(response, 403, { error: "this app was not given access to your servers", code: "insufficient_scope" });
3640
+ return;
3641
+ }
3642
+ if (!(path === "/api/v1/servers" && request.method === "GET")) {
3643
+ json(response, 403, { error: "an app may list your servers, not change them", code: "insufficient_scope" });
3644
+ return;
3645
+ }
3646
+ json(response, 200, { servers: (await servers.list(who.id)).map(({ key: _key, ...shown }) => shown) });
3647
+ return;
3648
+ }
3607
3649
  if (path === "/api/v1/servers" && request.method === "GET") {
3608
3650
  json(response, 200, { servers: await servers.list(who.id) });
3609
3651
  return;
@@ -6133,7 +6175,13 @@ export async function serve(argv, version = "0.1.0") {
6133
6175
  const listeners = new ListenerStore(ListenerStore.fileFor(stateDir(), options.port));
6134
6176
  const library = { index: libraryIndex, probe: probeFile, retag: retagPlayer, listeners };
6135
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());
6136
6182
  stationRunner = new StationRunner({
6183
+ onRestart: (id) => stationUsage.restarted(id),
6184
+ onFailure: (id) => stationUsage.failed(id),
6137
6185
  store: stationStore,
6138
6186
  channels,
6139
6187
  pull: async (channelId, name, files, known) => Boolean(await pullChannel(channels, tools.ffprobe, channelId, name, files[0], [], "", {
@@ -6146,7 +6194,17 @@ export async function serve(argv, version = "0.1.0") {
6146
6194
  roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
6147
6195
  log: (line) => console.log(line),
6148
6196
  });
6149
- const stations = { store: stationStore, runner: stationRunner };
6197
+ const stations = { store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits };
6198
+ // Music handed over from BitTorrented, copied into a folder of our own.
6199
+ const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
6200
+ index: libraryIndex,
6201
+ stations: stationStore,
6202
+ root: join(stateDir(), "imports", "bittorrented"),
6203
+ probe: probeFile,
6204
+ guard: (url) => refusedDestination(url),
6205
+ log: (line) => console.log(line),
6206
+ });
6207
+ importer.resume();
6150
6208
  // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
6151
6209
  // channel's fragments into short files while somebody is asking for them.
6152
6210
  // What things are, from nichedb.dev, remembered beside the keys so a
@@ -6727,6 +6785,7 @@ export async function serve(argv, version = "0.1.0") {
6727
6785
  channels,
6728
6786
  stations,
6729
6787
  library,
6788
+ importer,
6730
6789
  rememberChannels: remembering,
6731
6790
  catalogs,
6732
6791
  publishUrls: () => publishUrls,
@@ -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.33.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",