nixamp 0.29.1 → 0.30.3
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/channels.d.ts +12 -0
- package/dist/channels.js +12 -1
- package/dist/directory.d.ts +3 -1
- package/dist/directory.js +8 -2
- package/dist/main.d.ts +0 -8
- package/dist/main.js +9 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +7 -1
- package/dist/playlist-input.d.ts +10 -0
- package/dist/playlist-input.js +9 -0
- package/dist/server.d.ts +27 -1
- package/dist/server.js +162 -6
- package/dist/station-cli.d.ts +3 -0
- package/dist/station-cli.js +201 -0
- package/dist/station-mcp.d.ts +294 -0
- package/dist/station-mcp.js +173 -0
- package/dist/station-page.d.ts +5 -0
- package/dist/station-page.js +138 -0
- package/dist/station-routes.d.ts +60 -0
- package/dist/station-routes.js +250 -0
- package/dist/station-runner.d.ts +67 -0
- package/dist/station-runner.js +219 -0
- package/dist/station-usage.d.ts +2 -0
- package/dist/station-usage.js +20 -0
- package/dist/stations.d.ts +148 -0
- package/dist/stations.js +331 -0
- package/package.json +1 -1
- package/src/channels.ts +14 -1
- package/src/directory.ts +10 -2
- package/src/main.ts +8 -0
- package/src/mcp.ts +9 -1
- package/src/playlist-input.ts +9 -0
- package/src/server.ts +169 -7
- package/src/station-cli.ts +195 -0
- package/src/station-mcp.ts +183 -0
- package/src/station-page.ts +145 -0
- package/src/station-routes.ts +302 -0
- package/src/station-runner.ts +244 -0
- package/src/station-usage.ts +20 -0
- package/src/stations.ts +407 -0
- package/web/dist/assets/{ar-D6_ZFDan.js → ar-ClCIWtVb.js} +1 -1
- package/web/dist/assets/{cs-DEPGOGEV.js → cs-CVG4U823.js} +1 -1
- package/web/dist/assets/{da-CQw7SPC9.js → da-igdiQUk8.js} +1 -1
- package/web/dist/assets/{de-OWnobbah.js → de-CHPsc6lc.js} +1 -1
- package/web/dist/assets/{es-qMpyH9bM.js → es-AvbMspQX.js} +1 -1
- package/web/dist/assets/{fi-DpqqwJTD.js → fi-5JGUOm3W.js} +1 -1
- package/web/dist/assets/{fr-BiO_ceen.js → fr-YfMwk1FR.js} +1 -1
- package/web/dist/assets/{hi-D-q5wZuY.js → hi-BUZuQTkp.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-D2W4K8VR.js → hls-3VKVEQE3-BCXawNaW.js} +1 -1
- package/web/dist/assets/{hu-FIjHyhVC.js → hu-C7on-yUl.js} +1 -1
- package/web/dist/assets/{id-gmB6lu56.js → id-pIrzVeOi.js} +1 -1
- package/web/dist/assets/{index-UmGvzuun.js → index-CDBIr8Tz.js} +2 -2
- package/web/dist/assets/{it-Dxfo713A.js → it-Bl9xk5B4.js} +1 -1
- package/web/dist/assets/{mpegts-BT4XGr_E.js → mpegts-63ylMmge.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CpZnlq0W.js → mpegts-LO6RVLD6-DBakOXU5.js} +1 -1
- package/web/dist/assets/{nl-mk5eN3Io.js → nl-b5BUih3i.js} +1 -1
- package/web/dist/assets/{ru-B2psU0kN.js → ru-BlAkbEPp.js} +1 -1
- package/web/dist/assets/{sv-Cg6waoNZ.js → sv-BEKvqELG.js} +1 -1
- package/web/dist/assets/{uk-BcFkukLd.js → uk-JY8Dh56p.js} +1 -1
- package/web/dist/assets/{vi-CnqIC1Sz.js → vi-DbtxuQPL.js} +1 -1
- package/web/dist/assets/{zh-CvnXIFvN.js → zh-4x0DI5jO.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping stations on the air: start, stop, skip, and coming back after
|
|
3
|
+
* something went wrong.
|
|
4
|
+
*
|
|
5
|
+
* The runner owns the gap between what the owner asked for and what is
|
|
6
|
+
* happening. A station that should be running and is not is restarted with a
|
|
7
|
+
* growing delay (RECOVERY_DELAYS_MS), then tried every OFFLINE_RETRY_MS for as
|
|
8
|
+
* long as it is still wanted, so a disk that comes back or a file put back in
|
|
9
|
+
* place brings it back under the same id and link. A station its owner
|
|
10
|
+
* stopped is never restarted by anything here.
|
|
11
|
+
*/
|
|
12
|
+
import type { Channels, ChannelInfo, PullResume } from "./channels.ts";
|
|
13
|
+
import {
|
|
14
|
+
EXCLUDE_AFTER, OFFLINE_RETRY_MS, RECOVERY_DELAYS_MS, StationError, channelOf, insideRoots, shuffled,
|
|
15
|
+
stationOfChannel, type Observed, type Station, type StationStore,
|
|
16
|
+
} from "./stations.ts";
|
|
17
|
+
|
|
18
|
+
export interface RunnerDeps {
|
|
19
|
+
store: StationStore;
|
|
20
|
+
channels: Channels;
|
|
21
|
+
/** Put a list of files on the air as this channel; false when it could not. */
|
|
22
|
+
pull: (channelId: string, name: string, files: string[], known: { rotation: NonNullable<PullResume["rotation"]>; audioBitrate: number }) => Promise<boolean>;
|
|
23
|
+
/** The folders a station's files have to be inside. */
|
|
24
|
+
roots: () => string[];
|
|
25
|
+
log?: (line: string) => void;
|
|
26
|
+
now?: () => number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Something a listener is connected through, that can be ended. */
|
|
30
|
+
export interface Ending {
|
|
31
|
+
end(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A run shorter than this that ends badly does not reset the backoff. */
|
|
35
|
+
const HEALTHY_RUN_MS = 60_000;
|
|
36
|
+
|
|
37
|
+
export class StationRunner {
|
|
38
|
+
private attempts = new Map<string, number>();
|
|
39
|
+
private timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
40
|
+
private startedAt = new Map<string, number>();
|
|
41
|
+
private stopping = new Set<string>();
|
|
42
|
+
private starting = new Set<string>();
|
|
43
|
+
private errors = new Map<string, string>();
|
|
44
|
+
private listening = new Map<string, Set<Ending>>();
|
|
45
|
+
|
|
46
|
+
constructor(private readonly deps: RunnerDeps) {}
|
|
47
|
+
|
|
48
|
+
private now(): number {
|
|
49
|
+
return this.deps.now?.() ?? Date.now();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private log(line: string): void {
|
|
53
|
+
this.deps.log?.(line);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The station's files that are still where they were, inside the library. */
|
|
57
|
+
playable(station: Station): string[] {
|
|
58
|
+
const roots = this.deps.roots();
|
|
59
|
+
return station.items.filter((file) => insideRoots(file, roots));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Wanted on, and put on. */
|
|
63
|
+
async start(id: string): Promise<Station> {
|
|
64
|
+
const station = this.deps.store.get(id);
|
|
65
|
+
if (!station) throw new StationError("not_found", "no such station", 404);
|
|
66
|
+
if (this.playable(station).length === 0) {
|
|
67
|
+
throw new StationError("nothing_playable", "none of this station's files can be found in the library", 409);
|
|
68
|
+
}
|
|
69
|
+
this.deps.store.setDesired(id, "running", this.now());
|
|
70
|
+
this.cancelRecovery(id);
|
|
71
|
+
this.attempts.delete(id);
|
|
72
|
+
await this.launch(id);
|
|
73
|
+
return this.deps.store.get(id)!;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Wanted off, and taken off. Stays off across restarts. */
|
|
77
|
+
stop(id: string): Station {
|
|
78
|
+
const station = this.deps.store.setDesired(id, "stopped", this.now());
|
|
79
|
+
this.cancelRecovery(id);
|
|
80
|
+
this.attempts.delete(id);
|
|
81
|
+
this.errors.delete(id);
|
|
82
|
+
const channelId = channelOf(id);
|
|
83
|
+
if (this.deps.channels.has(channelId)) {
|
|
84
|
+
this.stopping.add(channelId);
|
|
85
|
+
this.deps.channels.stop(channelId);
|
|
86
|
+
}
|
|
87
|
+
return station;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The next track, now. */
|
|
91
|
+
skip(id: string): void {
|
|
92
|
+
if (!this.deps.store.get(id)) throw new StationError("not_found", "no such station", 404);
|
|
93
|
+
if (!this.deps.channels.skip(channelOf(id))) throw new StationError("not_live", "the station is not playing", 409);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Stopped and forgotten. */
|
|
97
|
+
remove(id: string): void {
|
|
98
|
+
if (!this.deps.store.get(id)) throw new StationError("not_found", "no such station", 404);
|
|
99
|
+
this.stop(id);
|
|
100
|
+
this.revoke(id);
|
|
101
|
+
this.deps.store.remove(id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Put back every station that was running when this server last stopped. */
|
|
105
|
+
restore(): void {
|
|
106
|
+
for (const station of this.deps.store.list()) {
|
|
107
|
+
if (station.desired !== "running") continue;
|
|
108
|
+
this.log(` Putting station "${station.name}" back on the air.`);
|
|
109
|
+
void this.launch(station.id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async launch(id: string): Promise<boolean> {
|
|
114
|
+
const station = this.deps.store.get(id);
|
|
115
|
+
if (!station || station.desired !== "running") return false;
|
|
116
|
+
const channelId = channelOf(id);
|
|
117
|
+
if (this.deps.channels.has(channelId) || this.starting.has(id)) return true;
|
|
118
|
+
const files = this.playable(station);
|
|
119
|
+
if (files.length === 0) {
|
|
120
|
+
this.errors.set(id, "none of this station's files can be found in the library");
|
|
121
|
+
this.scheduleRecovery(id);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
this.starting.add(id);
|
|
125
|
+
const store = this.deps.store;
|
|
126
|
+
try {
|
|
127
|
+
const ok = await this.deps.pull(channelId, station.name, files, {
|
|
128
|
+
audioBitrate: station.bitrate,
|
|
129
|
+
rotation: {
|
|
130
|
+
repeat: station.repeat,
|
|
131
|
+
passOver: true,
|
|
132
|
+
// Read at every pass, so turning shuffle on or off lands then.
|
|
133
|
+
order: (length) => (store.get(id)?.shuffle ? shuffled(length) : [...Array(length).keys()]),
|
|
134
|
+
// Read at every track, so an edit lands on the next one.
|
|
135
|
+
current: () => {
|
|
136
|
+
const now = store.get(id);
|
|
137
|
+
return now ? this.playable(now) : [];
|
|
138
|
+
},
|
|
139
|
+
excluded: (file) => store.isExcluded(id, file),
|
|
140
|
+
onFailure: ({ file, reason }) => {
|
|
141
|
+
this.errors.set(id, reason);
|
|
142
|
+
if (store.recordFailure(id, file)) {
|
|
143
|
+
this.log(` Station "${station.name}": left out a file that failed ${EXCLUDE_AFTER} times.`);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
if (!ok) {
|
|
149
|
+
this.errors.set(id, "the station could not start");
|
|
150
|
+
this.scheduleRecovery(id);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
this.startedAt.set(id, this.now());
|
|
154
|
+
this.deps.channels.keep(channelId);
|
|
155
|
+
return true;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
this.errors.set(id, error instanceof Error ? error.message : String(error));
|
|
158
|
+
this.scheduleRecovery(id);
|
|
159
|
+
return false;
|
|
160
|
+
} finally {
|
|
161
|
+
this.starting.delete(id);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Told by the server when any channel ends. */
|
|
166
|
+
onChannelEnd(info: ChannelInfo): void {
|
|
167
|
+
const id = stationOfChannel(info.id);
|
|
168
|
+
if (!id) return;
|
|
169
|
+
if (this.stopping.delete(info.id)) return;
|
|
170
|
+
const station = this.deps.store.get(id);
|
|
171
|
+
if (!station || station.desired !== "running") return;
|
|
172
|
+
// A list that is not repeated played through: that is the end of it,
|
|
173
|
+
// which is the owner's choice, not a failure.
|
|
174
|
+
if (info.ended && !station.repeat) {
|
|
175
|
+
this.deps.store.setDesired(id, "stopped", this.now());
|
|
176
|
+
this.errors.delete(id);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const ran = this.now() - (this.startedAt.get(id) ?? 0);
|
|
180
|
+
if (ran >= HEALTHY_RUN_MS) this.attempts.delete(id);
|
|
181
|
+
this.errors.set(id, info.error || "the station stopped");
|
|
182
|
+
this.scheduleRecovery(id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private scheduleRecovery(id: string): void {
|
|
186
|
+
if (this.timers.has(id)) return;
|
|
187
|
+
const attempt = this.attempts.get(id) ?? 0;
|
|
188
|
+
const delay = attempt < RECOVERY_DELAYS_MS.length ? RECOVERY_DELAYS_MS[attempt]! : OFFLINE_RETRY_MS;
|
|
189
|
+
this.attempts.set(id, attempt + 1);
|
|
190
|
+
const timer = setTimeout(() => {
|
|
191
|
+
this.timers.delete(id);
|
|
192
|
+
void this.launch(id);
|
|
193
|
+
}, delay);
|
|
194
|
+
timer.unref?.();
|
|
195
|
+
this.timers.set(id, timer);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private cancelRecovery(id: string): void {
|
|
199
|
+
const timer = this.timers.get(id);
|
|
200
|
+
if (timer) clearTimeout(timer);
|
|
201
|
+
this.timers.delete(id);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
observed(id: string): Observed {
|
|
205
|
+
const station = this.deps.store.get(id);
|
|
206
|
+
if (!station) return "stopped";
|
|
207
|
+
if (station.desired === "stopped") return "stopped";
|
|
208
|
+
if (this.starting.has(id)) return "starting";
|
|
209
|
+
const info = this.deps.channels.info(channelOf(id));
|
|
210
|
+
if (info) return info.redials ? "recovering" : "live";
|
|
211
|
+
return (this.attempts.get(id) ?? 0) <= RECOVERY_DELAYS_MS.length && this.timers.has(id) ? "recovering" : "offline";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
error(id: string): string {
|
|
215
|
+
return this.errors.get(id) ?? "";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A listener on a station's link, ended if the link is revoked. */
|
|
219
|
+
track(id: string, listener: Ending): () => void {
|
|
220
|
+
let set = this.listening.get(id);
|
|
221
|
+
if (!set) {
|
|
222
|
+
set = new Set();
|
|
223
|
+
this.listening.set(id, set);
|
|
224
|
+
}
|
|
225
|
+
set.add(listener);
|
|
226
|
+
return () => set!.delete(listener);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
listeners(id: string): number {
|
|
230
|
+
return this.listening.get(id)?.size ?? 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Everybody listening through a link that no longer works is let go. */
|
|
234
|
+
revoke(id: string): number {
|
|
235
|
+
const set = this.listening.get(id);
|
|
236
|
+
if (!set) return 0;
|
|
237
|
+
const count = set.size;
|
|
238
|
+
for (const one of [...set]) {
|
|
239
|
+
try { one.end(); } catch { /* already gone */ }
|
|
240
|
+
}
|
|
241
|
+
set.clear();
|
|
242
|
+
return count;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** `nixamp station` usage, apart so `nixamp help station` does not load the client. */
|
|
2
|
+
export const USAGE = `nixamp station — make a collection into a station
|
|
3
|
+
|
|
4
|
+
nixamp station list
|
|
5
|
+
nixamp station create --name NAME (--folder PATH | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]
|
|
6
|
+
nixamp station show ID
|
|
7
|
+
nixamp station status ID
|
|
8
|
+
nixamp station start ID
|
|
9
|
+
nixamp station stop ID
|
|
10
|
+
nixamp station skip ID
|
|
11
|
+
nixamp station retry ID try every left-out file again
|
|
12
|
+
nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]
|
|
13
|
+
nixamp station unpublish ID [--private]
|
|
14
|
+
nixamp station link ID [--rotate]
|
|
15
|
+
nixamp station delete ID
|
|
16
|
+
|
|
17
|
+
--json machine-readable output
|
|
18
|
+
--url, --key another server, as with \`nixamp admin\`
|
|
19
|
+
|
|
20
|
+
A new station is private and stopped. Starting it does not publish it.`;
|
package/src/stations.ts
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stations: a collection turned into a program that keeps playing.
|
|
3
|
+
*
|
|
4
|
+
* A channel is a running decoder, named for what it plays, and gone when it
|
|
5
|
+
* stops. A station is the thing a person made and named: which files, in what
|
|
6
|
+
* rotation, who may hear it, and whether it should be on. Its id is random
|
|
7
|
+
* and never changes, so its link survives a rename, a restart, a moved file
|
|
8
|
+
* and a renderer that died; the channel it plays through (`station-<id>`) is
|
|
9
|
+
* only how it happens to be delivered right now.
|
|
10
|
+
*
|
|
11
|
+
* Two states, kept apart. `desired` is what the owner asked for and is
|
|
12
|
+
* written down; a station deliberately stopped stays stopped after a restart,
|
|
13
|
+
* and one left running comes back. `observed` is what is actually happening
|
|
14
|
+
* and is never written down, because it is only true now.
|
|
15
|
+
*
|
|
16
|
+
* Who may listen is the station's own business, not the server's. A new
|
|
17
|
+
* station is private, stopped and unlisted. Its listen token opens its stream,
|
|
18
|
+
* its now-playing and its picture, and nothing of the library behind it.
|
|
19
|
+
* Listing it in the public directory is a separate choice from making it
|
|
20
|
+
* public, and making it public asks first on what basis it may be broadcast.
|
|
21
|
+
*/
|
|
22
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
23
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join, sep } from "node:path";
|
|
25
|
+
|
|
26
|
+
export type Visibility = "private" | "unlisted" | "public";
|
|
27
|
+
export type Desired = "running" | "stopped";
|
|
28
|
+
export type Observed = "stopped" | "starting" | "live" | "recovering" | "offline";
|
|
29
|
+
|
|
30
|
+
/** Why the owner may broadcast this publicly. A declaration, not a clearance. */
|
|
31
|
+
export type RightsBasis = "owned" | "permission" | "licensed" | "public-domain";
|
|
32
|
+
export const RIGHTS_BASES: readonly RightsBasis[] = ["owned", "permission", "licensed", "public-domain"];
|
|
33
|
+
|
|
34
|
+
export interface Rights {
|
|
35
|
+
basis: RightsBasis;
|
|
36
|
+
note: string;
|
|
37
|
+
declaredAt: number;
|
|
38
|
+
declaredBy: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Station {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
/** Files on this machine, absolute, in the station's own order. */
|
|
46
|
+
items: string[];
|
|
47
|
+
shuffle: boolean;
|
|
48
|
+
repeat: boolean;
|
|
49
|
+
visibility: Visibility;
|
|
50
|
+
/** In the public directory. Only a public station can be. */
|
|
51
|
+
listed: boolean;
|
|
52
|
+
desired: Desired;
|
|
53
|
+
/** Opens this station's stream and nothing else. Rotated to revoke a link. */
|
|
54
|
+
listenToken: string;
|
|
55
|
+
/** Audio bitrate of the station's rendition, in kbps. */
|
|
56
|
+
bitrate: number;
|
|
57
|
+
rights: Rights | null;
|
|
58
|
+
/** nixamp.com account that made it, "" when made with the control key. */
|
|
59
|
+
owner: string;
|
|
60
|
+
/** Files that would not play, and how many times in a row. */
|
|
61
|
+
failures: Record<string, number>;
|
|
62
|
+
/** What the caller said made this one, so asking twice makes it once. */
|
|
63
|
+
idempotencyKey: string;
|
|
64
|
+
createdAt: number;
|
|
65
|
+
updatedAt: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Times an item may fail in a row before rotation leaves it out. */
|
|
69
|
+
export const EXCLUDE_AFTER = 3;
|
|
70
|
+
export const DEFAULT_BITRATE = 128;
|
|
71
|
+
export const BITRATES = [64, 96, 128, 160, 192, 256, 320];
|
|
72
|
+
const MAX_ITEMS = 20_000;
|
|
73
|
+
const MAX_NAME = 120;
|
|
74
|
+
const MAX_DESCRIPTION = 500;
|
|
75
|
+
|
|
76
|
+
/** A station's channel id. */
|
|
77
|
+
export function channelOf(id: string): string {
|
|
78
|
+
return `station-${id}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The station a channel id belongs to, or "" for any other channel. */
|
|
82
|
+
export function stationOfChannel(channelId: string): string {
|
|
83
|
+
return channelId.startsWith("station-") ? channelId.slice("station-".length) : "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function newId(): string {
|
|
87
|
+
// Ten characters of base32: short enough to read out, far too many to guess.
|
|
88
|
+
const alphabet = "abcdefghijkmnpqrstuvwxyz23456789";
|
|
89
|
+
return [...randomBytes(10)].map((byte) => alphabet[byte % alphabet.length]).join("");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function newToken(): string {
|
|
93
|
+
return randomBytes(16).toString("base64url");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function tokensMatch(offered: string, held: string): boolean {
|
|
97
|
+
const a = Buffer.from(offered);
|
|
98
|
+
const b = Buffer.from(held);
|
|
99
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A path inside one of these roots, after symlinks: never outside the library. */
|
|
103
|
+
export function insideRoots(path: string, roots: string[]): boolean {
|
|
104
|
+
let real: string;
|
|
105
|
+
try {
|
|
106
|
+
real = realpathSync(path);
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return roots.some((root) => {
|
|
111
|
+
let base: string;
|
|
112
|
+
try {
|
|
113
|
+
base = realpathSync(root);
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
return real === base || real.startsWith(base.endsWith(sep) ? base : base + sep);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class StationError extends Error {
|
|
122
|
+
constructor(readonly code: string, message: string, readonly status = 400) {
|
|
123
|
+
super(message);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface StationInput {
|
|
128
|
+
name?: unknown;
|
|
129
|
+
description?: unknown;
|
|
130
|
+
items?: unknown;
|
|
131
|
+
shuffle?: unknown;
|
|
132
|
+
repeat?: unknown;
|
|
133
|
+
bitrate?: unknown;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function text(value: unknown, max: number, field: string): string {
|
|
137
|
+
if (typeof value !== "string") throw new StationError("invalid_" + field, `${field} must be text`);
|
|
138
|
+
return value.trim().replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, max);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The stations of one server, kept in one file beside its keys. */
|
|
142
|
+
export class StationStore {
|
|
143
|
+
private stations = new Map<string, Station>();
|
|
144
|
+
|
|
145
|
+
constructor(
|
|
146
|
+
private readonly file: string,
|
|
147
|
+
private readonly io: { read: (path: string) => string | null; write: (path: string, body: string) => void } = {
|
|
148
|
+
read: (path) => { try { return readFileSync(path, "utf8"); } catch { return null; } },
|
|
149
|
+
write: (path, body) => {
|
|
150
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
151
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
152
|
+
writeFileSync(temporary, body, { mode: 0o600 });
|
|
153
|
+
renameSync(temporary, path);
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
) {
|
|
157
|
+
const body = this.io.read(file);
|
|
158
|
+
if (body === null) return;
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(body) as { stations?: Station[] };
|
|
161
|
+
for (const one of parsed.stations ?? []) {
|
|
162
|
+
if (one && typeof one.id === "string" && Array.isArray(one.items)) {
|
|
163
|
+
this.stations.set(one.id, {
|
|
164
|
+
...one,
|
|
165
|
+
failures: one.failures ?? {},
|
|
166
|
+
bitrate: one.bitrate ?? DEFAULT_BITRATE,
|
|
167
|
+
rights: one.rights ?? null,
|
|
168
|
+
owner: one.owner ?? "",
|
|
169
|
+
description: one.description ?? "",
|
|
170
|
+
idempotencyKey: one.idempotencyKey ?? "",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// A file that cannot be read is not a reason to refuse to serve. It is
|
|
176
|
+
// left where it is, and the next write replaces it.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
static fileFor(stateDir: string, port: number): string {
|
|
181
|
+
return join(stateDir, `stations-${port}.json`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private save(): void {
|
|
185
|
+
this.io.write(this.file, `${JSON.stringify({ version: 1, stations: [...this.stations.values()] }, null, 2)}\n`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
list(): Station[] {
|
|
189
|
+
return [...this.stations.values()].sort((a, b) => a.createdAt - b.createdAt);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
get(id: string): Station | undefined {
|
|
193
|
+
return this.stations.get(id);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** New, and always private, stopped and unlisted (RAD-02). */
|
|
197
|
+
/** The station a create with this key already made. */
|
|
198
|
+
byIdempotencyKey(key: string): Station | undefined {
|
|
199
|
+
if (key === "") return undefined;
|
|
200
|
+
return [...this.stations.values()].find((one) => one.idempotencyKey === key);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
create(input: StationInput, items: string[], owner: string, now = Date.now(), idempotencyKey = ""): Station {
|
|
204
|
+
const name = text(input.name ?? "", MAX_NAME, "name");
|
|
205
|
+
if (name === "") throw new StationError("name_required", "a station needs a name");
|
|
206
|
+
if (items.length === 0) throw new StationError("empty_station", "choose at least one file that plays");
|
|
207
|
+
if (items.length > MAX_ITEMS) throw new StationError("too_many_items", `a station holds at most ${MAX_ITEMS} files`);
|
|
208
|
+
const station: Station = {
|
|
209
|
+
id: newId(),
|
|
210
|
+
name,
|
|
211
|
+
description: input.description === undefined ? "" : text(input.description, MAX_DESCRIPTION, "description"),
|
|
212
|
+
items,
|
|
213
|
+
shuffle: input.shuffle === true,
|
|
214
|
+
repeat: input.repeat !== false,
|
|
215
|
+
visibility: "private",
|
|
216
|
+
listed: false,
|
|
217
|
+
desired: "stopped",
|
|
218
|
+
listenToken: newToken(),
|
|
219
|
+
bitrate: this.bitrateFrom(input.bitrate),
|
|
220
|
+
rights: null,
|
|
221
|
+
owner,
|
|
222
|
+
failures: {},
|
|
223
|
+
idempotencyKey: idempotencyKey.slice(0, 200),
|
|
224
|
+
createdAt: now,
|
|
225
|
+
updatedAt: now,
|
|
226
|
+
};
|
|
227
|
+
this.stations.set(station.id, station);
|
|
228
|
+
this.save();
|
|
229
|
+
return station;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private bitrateFrom(value: unknown): number {
|
|
233
|
+
if (value === undefined) return DEFAULT_BITRATE;
|
|
234
|
+
if (typeof value !== "number" || !BITRATES.includes(value)) {
|
|
235
|
+
throw new StationError("invalid_bitrate", `bitrate is one of ${BITRATES.join(", ")} kbps`);
|
|
236
|
+
}
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Name, words, files and rotation. Who may listen is changed by publish/unpublish. */
|
|
241
|
+
update(id: string, input: StationInput, items: string[] | null, now = Date.now()): Station {
|
|
242
|
+
const station = this.require(id);
|
|
243
|
+
if (input.name !== undefined) {
|
|
244
|
+
const name = text(input.name, MAX_NAME, "name");
|
|
245
|
+
if (name === "") throw new StationError("name_required", "a station needs a name");
|
|
246
|
+
station.name = name;
|
|
247
|
+
}
|
|
248
|
+
if (input.description !== undefined) station.description = text(input.description, MAX_DESCRIPTION, "description");
|
|
249
|
+
if (input.shuffle !== undefined) station.shuffle = input.shuffle === true;
|
|
250
|
+
if (input.repeat !== undefined) station.repeat = input.repeat !== false;
|
|
251
|
+
if (input.bitrate !== undefined) station.bitrate = this.bitrateFrom(input.bitrate);
|
|
252
|
+
if (items !== null) {
|
|
253
|
+
if (items.length === 0) throw new StationError("empty_station", "a station needs at least one file that plays");
|
|
254
|
+
if (items.length > MAX_ITEMS) throw new StationError("too_many_items", `a station holds at most ${MAX_ITEMS} files`);
|
|
255
|
+
station.items = items;
|
|
256
|
+
// A file taken out and put back is given another chance.
|
|
257
|
+
station.failures = Object.fromEntries(Object.entries(station.failures).filter(([file]) => items.includes(file)));
|
|
258
|
+
}
|
|
259
|
+
station.updatedAt = now;
|
|
260
|
+
this.save();
|
|
261
|
+
return station;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
remove(id: string): boolean {
|
|
265
|
+
const had = this.stations.delete(id);
|
|
266
|
+
if (had) this.save();
|
|
267
|
+
return had;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
setDesired(id: string, desired: Desired, now = Date.now()): Station {
|
|
271
|
+
const station = this.require(id);
|
|
272
|
+
station.desired = desired;
|
|
273
|
+
station.updatedAt = now;
|
|
274
|
+
this.save();
|
|
275
|
+
return station;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Who may hear it, and whether the directory lists it. Public needs a
|
|
280
|
+
* declared basis; listed needs public. Going private mints a new token, so
|
|
281
|
+
* every link handed out before stops working, and the caller drops the
|
|
282
|
+
* listeners who used one.
|
|
283
|
+
*/
|
|
284
|
+
setAccess(
|
|
285
|
+
id: string,
|
|
286
|
+
access: { visibility?: unknown; listed?: unknown; rights?: { basis?: unknown; note?: unknown } | null },
|
|
287
|
+
by: string,
|
|
288
|
+
now = Date.now(),
|
|
289
|
+
): { station: Station; revoked: boolean } {
|
|
290
|
+
const station = this.require(id);
|
|
291
|
+
const visibility = access.visibility === undefined ? station.visibility : access.visibility;
|
|
292
|
+
if (visibility !== "private" && visibility !== "unlisted" && visibility !== "public") {
|
|
293
|
+
throw new StationError("invalid_visibility", "visibility is private, unlisted or public");
|
|
294
|
+
}
|
|
295
|
+
let listed = access.listed === undefined ? station.listed : access.listed === true;
|
|
296
|
+
if (visibility !== "public") listed = false;
|
|
297
|
+
if (access.rights) {
|
|
298
|
+
const basis = access.rights.basis;
|
|
299
|
+
if (typeof basis !== "string" || !RIGHTS_BASES.includes(basis as RightsBasis)) {
|
|
300
|
+
throw new StationError("invalid_rights_basis", `the basis is one of ${RIGHTS_BASES.join(", ")}`);
|
|
301
|
+
}
|
|
302
|
+
station.rights = {
|
|
303
|
+
basis: basis as RightsBasis,
|
|
304
|
+
note: access.rights.note === undefined ? "" : text(access.rights.note, MAX_DESCRIPTION, "note"),
|
|
305
|
+
declaredAt: now,
|
|
306
|
+
declaredBy: by,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
if (visibility === "public" && station.rights === null) {
|
|
310
|
+
throw new StationError(
|
|
311
|
+
"rights_required",
|
|
312
|
+
"before a station is public, say on what basis it may be broadcast: owned, permission, licensed or public-domain",
|
|
313
|
+
409,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const revoked = visibility === "private" && station.visibility !== "private";
|
|
317
|
+
station.visibility = visibility;
|
|
318
|
+
station.listed = listed;
|
|
319
|
+
if (revoked) station.listenToken = newToken();
|
|
320
|
+
station.updatedAt = now;
|
|
321
|
+
this.save();
|
|
322
|
+
return { station, revoked };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A new listen link; every earlier one stops working. */
|
|
326
|
+
rotateToken(id: string, now = Date.now()): Station {
|
|
327
|
+
const station = this.require(id);
|
|
328
|
+
station.listenToken = newToken();
|
|
329
|
+
station.updatedAt = now;
|
|
330
|
+
this.save();
|
|
331
|
+
return station;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** One more failure in a row for this file; true when it is now left out. */
|
|
335
|
+
recordFailure(id: string, file: string): boolean {
|
|
336
|
+
const station = this.stations.get(id);
|
|
337
|
+
if (!station) return false;
|
|
338
|
+
const count = (station.failures[file] ?? 0) + 1;
|
|
339
|
+
station.failures[file] = count;
|
|
340
|
+
this.save();
|
|
341
|
+
return count === EXCLUDE_AFTER;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Played through: its failures are forgotten. */
|
|
345
|
+
recordSuccess(id: string, file: string): void {
|
|
346
|
+
const station = this.stations.get(id);
|
|
347
|
+
if (!station || station.failures[file] === undefined) return;
|
|
348
|
+
delete station.failures[file];
|
|
349
|
+
this.save();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Every left-out file tried again (the owner's "retry"). */
|
|
353
|
+
clearFailures(id: string): Station {
|
|
354
|
+
const station = this.require(id);
|
|
355
|
+
station.failures = {};
|
|
356
|
+
this.save();
|
|
357
|
+
return station;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
isExcluded(id: string, file: string): boolean {
|
|
361
|
+
return (this.stations.get(id)?.failures[file] ?? 0) >= EXCLUDE_AFTER;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private require(id: string): Station {
|
|
365
|
+
const station = this.stations.get(id);
|
|
366
|
+
if (!station) throw new StationError("not_found", "no such station", 404);
|
|
367
|
+
return station;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A station-wide shuffle: one order for everybody, Fisher-Yates. */
|
|
372
|
+
export function shuffled(length: number, random: () => number = Math.random): number[] {
|
|
373
|
+
const order = [...Array(length).keys()];
|
|
374
|
+
for (let index = order.length - 1; index > 0; index--) {
|
|
375
|
+
const other = Math.floor(random() * (index + 1));
|
|
376
|
+
[order[index], order[other]] = [order[other]!, order[index]!];
|
|
377
|
+
}
|
|
378
|
+
return order;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Restart after an unexpected end: 2 s, doubling, at most a minute apart.
|
|
383
|
+
* After the last of these the station is offline but still wanted, and is
|
|
384
|
+
* tried again every OFFLINE_RETRY_MS so a source that comes back is found.
|
|
385
|
+
*/
|
|
386
|
+
export const RECOVERY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 32_000, 60_000];
|
|
387
|
+
export const OFFLINE_RETRY_MS = 5 * 60_000;
|
|
388
|
+
|
|
389
|
+
/** What a listener with the link may see. Never a path, never a token. */
|
|
390
|
+
export interface PublicStation {
|
|
391
|
+
id: string;
|
|
392
|
+
name: string;
|
|
393
|
+
description: string;
|
|
394
|
+
visibility: Visibility;
|
|
395
|
+
state: Observed;
|
|
396
|
+
nowPlaying: { title: string; artist: string; album: string } | null;
|
|
397
|
+
format: { codec: string; bitrateKbps: number; sampleRate: number; channels: number } | null;
|
|
398
|
+
listeners: number;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** What the owner sees: everything but the files' contents. */
|
|
402
|
+
export interface OwnerStation extends Omit<Station, "failures" | "idempotencyKey"> {
|
|
403
|
+
state: Observed;
|
|
404
|
+
excluded: string[];
|
|
405
|
+
listeners: number;
|
|
406
|
+
error: string;
|
|
407
|
+
}
|