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.
- package/dist/server.d.ts +3 -0
- package/dist/server.js +14 -1
- package/dist/station-metering.d.ts +59 -0
- package/dist/station-metering.js +189 -0
- package/dist/station-routes.d.ts +8 -0
- package/dist/station-routes.js +60 -1
- package/dist/station-runner.d.ts +7 -0
- package/dist/station-runner.js +12 -0
- package/package.json +1 -1
- package/src/server.ts +15 -2
- package/src/station-metering.ts +218 -0
- package/src/station-routes.ts +64 -1
- package/src/station-runner.ts +15 -0
- package/web/dist/assets/{ar-DoW0wbjC.js → ar-Doh06ZMu.js} +1 -1
- package/web/dist/assets/{cs-CjKqCjy5.js → cs-C_lZl4jI.js} +1 -1
- package/web/dist/assets/{da-BQERb7Qz.js → da-CSBBA_ZI.js} +1 -1
- package/web/dist/assets/{de-CKKbRy6c.js → de-zorW0tO_.js} +1 -1
- package/web/dist/assets/{es-CL3v5Og9.js → es-DBUl3DGo.js} +1 -1
- package/web/dist/assets/{fi-DgV81qNU.js → fi-DpUx7XY-.js} +1 -1
- package/web/dist/assets/{fr-BGumytzF.js → fr-Cg_xppCf.js} +1 -1
- package/web/dist/assets/{hi-CAQ9elqo.js → hi-PD1kdcf2.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-Ceri0flt.js → hls-3VKVEQE3-BEEk729h.js} +1 -1
- package/web/dist/assets/{hu-UJNgJVWX.js → hu-C1fGbjZO.js} +1 -1
- package/web/dist/assets/{id-BqQOlrF-.js → id-DRBhZ8KM.js} +1 -1
- package/web/dist/assets/{it-Cs8G6mvW.js → it-Ci3OZV2-.js} +1 -1
- package/web/dist/assets/{main-Y1Rvn1GX.js → main-CEJhJH5y.js} +2 -2
- package/web/dist/assets/{mpegts-LO6RVLD6-CPI8xaZ-.js → mpegts-LO6RVLD6-DPrv1ran.js} +1 -1
- package/web/dist/assets/{mpegts-jS_Diizz.js → mpegts-y-HAxt1b.js} +1 -1
- package/web/dist/assets/{nl-BJXQ9nqo.js → nl-CwWOHKbv.js} +1 -1
- package/web/dist/assets/radio-BpH1qMMQ.js +1 -0
- package/web/dist/assets/{ru-XmVkvNY8.js → ru-BWlIGsoL.js} +1 -1
- package/web/dist/assets/{sv-pVm9p66e.js → sv-Gb1cLNsc.js} +1 -1
- package/web/dist/assets/{uk-4hcyaD3e.js → uk-MZUnyyuR.js} +1 -1
- package/web/dist/assets/{vi-B7MipBlv.js → vi-DNWS5fxc.js} +1 -1
- package/web/dist/assets/{zh-B5YVExOW.js → zh-BcOiladF.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +1 -1
- package/web/dist/sw.js +23 -23
- 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
|
+
}
|
package/dist/station-routes.d.ts
CHANGED
|
@@ -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;
|
package/dist/station-routes.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/station-runner.d.ts
CHANGED
|
@@ -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;
|
package/dist/station-runner.js
CHANGED
|
@@ -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
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,
|