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.
- package/dist/handoff-routes.d.ts +17 -0
- package/dist/handoff-routes.js +64 -0
- package/dist/handoff.d.ts +89 -0
- package/dist/handoff.js +358 -0
- package/dist/library-index.d.ts +9 -0
- package/dist/library-index.js +19 -0
- package/dist/oauth-server.d.ts +1 -0
- package/dist/oauth-server.js +1 -0
- package/dist/owner.js +4 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +63 -4
- 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/handoff-routes.ts +73 -0
- package/src/handoff.ts +383 -0
- package/src/library-index.ts +21 -0
- package/src/oauth-server.ts +1 -0
- package/src/owner.ts +5 -2
- package/src/server.ts +67 -5
- 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-4kD-mTIA.js → ar-Doh06ZMu.js} +1 -1
- package/web/dist/assets/{cs-CziXiWNY.js → cs-C_lZl4jI.js} +1 -1
- package/web/dist/assets/{da-CyjJ8UOr.js → da-CSBBA_ZI.js} +1 -1
- package/web/dist/assets/{de-DxwYa667.js → de-zorW0tO_.js} +1 -1
- package/web/dist/assets/{es-QgMTJeSc.js → es-DBUl3DGo.js} +1 -1
- package/web/dist/assets/{fi-Clj-jHhR.js → fi-DpUx7XY-.js} +1 -1
- package/web/dist/assets/{fr-nH_gzSFv.js → fr-Cg_xppCf.js} +1 -1
- package/web/dist/assets/{hi-D7djQ6_C.js → hi-PD1kdcf2.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-BaWVouMg.js → hls-3VKVEQE3-BEEk729h.js} +1 -1
- package/web/dist/assets/{hu-H5Q3L8bD.js → hu-C1fGbjZO.js} +1 -1
- package/web/dist/assets/{id-40z_EQdf.js → id-DRBhZ8KM.js} +1 -1
- package/web/dist/assets/{it-C9Fcgywl.js → it-Ci3OZV2-.js} +1 -1
- package/web/dist/assets/{main-BJUyBy0h.js → main-CEJhJH5y.js} +2 -2
- package/web/dist/assets/{mpegts-LO6RVLD6-CAuth8C5.js → mpegts-LO6RVLD6-DPrv1ran.js} +1 -1
- package/web/dist/assets/{mpegts-BlmyNlBC.js → mpegts-y-HAxt1b.js} +1 -1
- package/web/dist/assets/{nl-CrL4Psk9.js → nl-CwWOHKbv.js} +1 -1
- package/web/dist/assets/radio-BpH1qMMQ.js +1 -0
- package/web/dist/assets/{ru-C84JS0ch.js → ru-BWlIGsoL.js} +1 -1
- package/web/dist/assets/{sv-DtMcl0sf.js → sv-Gb1cLNsc.js} +1 -1
- package/web/dist/assets/{uk-dOLf99vZ.js → uk-MZUnyyuR.js} +1 -1
- package/web/dist/assets/{vi-BWeQVRZ8.js → vi-DNWS5fxc.js} +1 -1
- package/web/dist/assets/{zh-Cnv3Qpux.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.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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
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
|
}
|