nixamp 0.29.1 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/channels.js +4 -1
  2. package/dist/directory.d.ts +3 -1
  3. package/dist/directory.js +8 -2
  4. package/dist/main.d.ts +0 -8
  5. package/dist/main.js +9 -0
  6. package/dist/server.d.ts +27 -1
  7. package/dist/server.js +162 -6
  8. package/dist/station-cli.d.ts +3 -0
  9. package/dist/station-cli.js +201 -0
  10. package/dist/station-page.d.ts +5 -0
  11. package/dist/station-page.js +137 -0
  12. package/dist/station-routes.d.ts +60 -0
  13. package/dist/station-routes.js +249 -0
  14. package/dist/station-runner.d.ts +67 -0
  15. package/dist/station-runner.js +219 -0
  16. package/dist/station-usage.d.ts +2 -0
  17. package/dist/station-usage.js +20 -0
  18. package/dist/stations.d.ts +148 -0
  19. package/dist/stations.js +331 -0
  20. package/package.json +1 -1
  21. package/src/channels.ts +4 -1
  22. package/src/directory.ts +10 -2
  23. package/src/main.ts +8 -0
  24. package/src/server.ts +169 -7
  25. package/src/station-cli.ts +195 -0
  26. package/src/station-page.ts +144 -0
  27. package/src/station-routes.ts +301 -0
  28. package/src/station-runner.ts +244 -0
  29. package/src/station-usage.ts +20 -0
  30. package/src/stations.ts +407 -0
  31. package/web/dist/assets/{ar-D6_ZFDan.js → ar-CTUDxy1J.js} +1 -1
  32. package/web/dist/assets/{cs-DEPGOGEV.js → cs-m4JTaO3o.js} +1 -1
  33. package/web/dist/assets/{da-CQw7SPC9.js → da-DHnJElYB.js} +1 -1
  34. package/web/dist/assets/{de-OWnobbah.js → de-DkxPohnf.js} +1 -1
  35. package/web/dist/assets/{es-qMpyH9bM.js → es-oY_G83d7.js} +1 -1
  36. package/web/dist/assets/{fi-DpqqwJTD.js → fi-fCO8XRdg.js} +1 -1
  37. package/web/dist/assets/{fr-BiO_ceen.js → fr-CSupf-IN.js} +1 -1
  38. package/web/dist/assets/{hi-D-q5wZuY.js → hi-Dve7PJsC.js} +1 -1
  39. package/web/dist/assets/{hls-3VKVEQE3-D2W4K8VR.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
  40. package/web/dist/assets/{hu-FIjHyhVC.js → hu-D1KYamzV.js} +1 -1
  41. package/web/dist/assets/{id-gmB6lu56.js → id-Cofp_4aQ.js} +1 -1
  42. package/web/dist/assets/{index-UmGvzuun.js → index-7mCQZGNF.js} +2 -2
  43. package/web/dist/assets/{it-Dxfo713A.js → it-62iXQWQD.js} +1 -1
  44. package/web/dist/assets/{mpegts-BT4XGr_E.js → mpegts-BT5ESacM.js} +1 -1
  45. package/web/dist/assets/{mpegts-LO6RVLD6-CpZnlq0W.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
  46. package/web/dist/assets/{nl-mk5eN3Io.js → nl-CzRRG-CN.js} +1 -1
  47. package/web/dist/assets/{ru-B2psU0kN.js → ru-OykC1LJQ.js} +1 -1
  48. package/web/dist/assets/{sv-Cg6waoNZ.js → sv-B8FXBDHm.js} +1 -1
  49. package/web/dist/assets/{uk-BcFkukLd.js → uk-DhAJBjwH.js} +1 -1
  50. package/web/dist/assets/{vi-CnqIC1Sz.js → vi-CdBSRReC.js} +1 -1
  51. package/web/dist/assets/{zh-CvnXIFvN.js → zh-wGWNyBEZ.js} +1 -1
  52. package/web/dist/index.html +1 -1
  53. package/web/dist/sw.js +22 -22
@@ -0,0 +1,301 @@
1
+ /**
2
+ * /api/v1/stations: making, running and sharing stations.
3
+ *
4
+ * Two kinds of caller, kept apart. The owner -- the control key, or the
5
+ * nixamp.com account that owns this server -- manages: create, edit, start,
6
+ * stop, skip, share. A listener holds one station's link and may do three
7
+ * things with it: hear the stream, ask what is on, and fetch an .m3u for
8
+ * another player. A listener's link never opens the library, another station,
9
+ * or anything that changes what is playing.
10
+ *
11
+ * Errors carry a stable `code` beside the sentence, so the page, the CLI and
12
+ * an agent over MCP can each tell "rights_required" from "not_found" without
13
+ * reading English.
14
+ */
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+ import type { Channels } from "./channels.ts";
17
+ import type { StationRunner } from "./station-runner.ts";
18
+ import {
19
+ StationError, channelOf, tokensMatch, type OwnerStation, type PublicStation, type Station, type StationStore,
20
+ } from "./stations.ts";
21
+
22
+ /** The three things a station's link opens. */
23
+ const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u)$/;
24
+
25
+ /** Whether this path is one a station's own link may open, without the server's key. */
26
+ export function isStationListenPath(path: string): boolean {
27
+ return LISTEN.test(path);
28
+ }
29
+
30
+ export interface StationContext {
31
+ store: StationStore;
32
+ runner: StationRunner;
33
+ channels: Channels;
34
+ /** Holds the control key, or is signed in as this server's owner. */
35
+ canControl: boolean;
36
+ /** The nixamp.com account asking, "" when it is the control key. */
37
+ account: string;
38
+ /** Files chosen by track index or by folder; null when neither was given. */
39
+ select: (body: { items?: unknown; folder?: unknown }) => string[] | null;
40
+ /** What the library knows about a file, for now playing. */
41
+ trackFor: (file: string) => { title: string; artist: string; album: string } | null;
42
+ /** This server's address as the asker reached it, for links. */
43
+ base: string;
44
+ json: (response: ServerResponse, status: number, body: unknown) => void;
45
+ readBody: (request: IncomingMessage) => Promise<string>;
46
+ /** Headers every answer carries (CORS). */
47
+ headers: Record<string, string>;
48
+ }
49
+
50
+ function fail(ctx: StationContext, response: ServerResponse, error: unknown): void {
51
+ if (error instanceof StationError) {
52
+ ctx.json(response, error.status, { error: error.message, code: error.code });
53
+ return;
54
+ }
55
+ ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
56
+ }
57
+
58
+ function links(ctx: StationContext, station: Station): { page: string; stream: string; m3u: string; state: string } {
59
+ const query = `t=${encodeURIComponent(station.listenToken)}`;
60
+ const at = `${ctx.base}/api/v1/stations/${station.id}`;
61
+ return {
62
+ page: `${ctx.base}/station/${station.id}?${query}`,
63
+ stream: `${at}/stream?${query}`,
64
+ m3u: `${at}/listen.m3u?${query}`,
65
+ state: `${at}/state?${query}`,
66
+ };
67
+ }
68
+
69
+ /** The file on the air now, as the channel's own encoder last reported it. */
70
+ function nowPlayingFile(ctx: StationContext, station: Station): string {
71
+ const info = ctx.channels.info(channelOf(station.id));
72
+ if (!info?.playlist?.length) return "";
73
+ return info.playlist[info.playlistAt ?? 0] ?? "";
74
+ }
75
+
76
+ export function publicView(ctx: StationContext, station: Station): PublicStation {
77
+ const state = ctx.runner.observed(station.id);
78
+ const file = state === "live" || state === "recovering" ? nowPlayingFile(ctx, station) : "";
79
+ const track = file ? ctx.trackFor(file) : null;
80
+ return {
81
+ id: station.id,
82
+ name: station.name,
83
+ description: station.description,
84
+ visibility: station.visibility,
85
+ state,
86
+ // Tags, or nothing: a bare file name is the owner's, not the public's.
87
+ nowPlaying: track && (track.title || track.artist) ? track : null,
88
+ format: state === "stopped" || state === "offline" ? null : { codec: "mp3", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 },
89
+ listeners: ctx.runner.listeners(station.id),
90
+ };
91
+ }
92
+
93
+ export function ownerView(ctx: StationContext, station: Station): OwnerStation & { links: ReturnType<typeof links>; nowPlaying: string } {
94
+ const { failures, idempotencyKey: _key, ...rest } = station;
95
+ return {
96
+ ...rest,
97
+ state: ctx.runner.observed(station.id),
98
+ excluded: Object.entries(failures).filter(([, count]) => count >= 3).map(([file]) => file),
99
+ listeners: ctx.runner.listeners(station.id),
100
+ error: ctx.runner.error(station.id),
101
+ links: links(ctx, station),
102
+ nowPlaying: nowPlayingFile(ctx, station),
103
+ };
104
+ }
105
+
106
+ /** Answers anything under /api/v1/stations; false when the path is not one. */
107
+ export async function handleStations(
108
+ request: IncomingMessage,
109
+ response: ServerResponse,
110
+ path: string,
111
+ url: URL,
112
+ ctx: StationContext,
113
+ ): Promise<boolean> {
114
+ if (path !== "/api/v1/stations" && !path.startsWith("/api/v1/stations/")) return false;
115
+ const method = request.method ?? "GET";
116
+
117
+ const listen = LISTEN.exec(path);
118
+ if (listen) {
119
+ const station = ctx.store.get(listen[1]!);
120
+ const token = url.searchParams.get("t") ?? "";
121
+ // The same answer for "no such station" and "not with that link", so a
122
+ // link cannot be used to find out which stations exist.
123
+ const allowed = station !== undefined &&
124
+ (ctx.canControl || (station.visibility !== "private" && token !== "" && tokensMatch(token, station.listenToken)));
125
+ if (!station || !allowed) {
126
+ ctx.json(response, 404, { error: "no such station, or not with that link", code: "not_found" });
127
+ return true;
128
+ }
129
+ if (method !== "GET" && method !== "HEAD") {
130
+ ctx.json(response, 405, { error: "a station's link can listen, not drive", code: "method_not_allowed" });
131
+ return true;
132
+ }
133
+ const what = listen[2];
134
+ if (what === "state") {
135
+ ctx.json(response, 200, publicView(ctx, station));
136
+ return true;
137
+ }
138
+ if (what === "listen.m3u") {
139
+ const stream = links(ctx, station).stream;
140
+ response.writeHead(200, {
141
+ ...ctx.headers,
142
+ "content-type": "audio/x-mpegurl; charset=utf-8",
143
+ "content-disposition": `inline; filename="${station.id}.m3u"`,
144
+ "cache-control": "no-store",
145
+ });
146
+ response.end(`#EXTM3U\n#EXTINF:-1,${station.name.replace(/[\r\n]/g, " ")}\n${stream}\n`);
147
+ return true;
148
+ }
149
+ const channelId = channelOf(station.id);
150
+ if (!ctx.channels.has(channelId)) {
151
+ ctx.json(response, 503, { error: "the station is not on the air", code: "not_live", state: ctx.runner.observed(station.id) });
152
+ return true;
153
+ }
154
+ response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
155
+ if (method === "HEAD") {
156
+ response.end();
157
+ return true;
158
+ }
159
+ const detach = ctx.channels.listen(channelId, response);
160
+ if (detach === null) {
161
+ response.end();
162
+ return true;
163
+ }
164
+ const untrack = ctx.runner.track(station.id, { end: () => response.destroy() });
165
+ const leave = (): void => {
166
+ detach();
167
+ untrack();
168
+ };
169
+ request.on("close", leave);
170
+ response.on("close", leave);
171
+ return true;
172
+ }
173
+
174
+ // Everything else is the owner's.
175
+ if (!ctx.canControl) {
176
+ ctx.json(response, 403, { error: "only this server's owner manages its stations", code: "forbidden" });
177
+ return true;
178
+ }
179
+
180
+ const body = async (): Promise<Record<string, unknown>> => {
181
+ if (method === "GET" || method === "DELETE") return {};
182
+ const raw = await ctx.readBody(request);
183
+ if (raw.trim() === "") return {};
184
+ let parsed: unknown;
185
+ try {
186
+ parsed = JSON.parse(raw);
187
+ } catch {
188
+ throw new StationError("bad_json", "the body is not JSON");
189
+ }
190
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new StationError("bad_json", "the body is not a JSON object");
191
+ return parsed as Record<string, unknown>;
192
+ };
193
+
194
+ try {
195
+ if (path === "/api/v1/stations") {
196
+ if (method === "GET") {
197
+ const all = ctx.store.list();
198
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit")) || 50));
199
+ const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
200
+ ctx.json(response, 200, { stations: all.slice(offset, offset + limit).map((one) => ownerView(ctx, one)), total: all.length, limit, offset });
201
+ return true;
202
+ }
203
+ if (method === "POST") {
204
+ const input = await body();
205
+ const key = String(request.headers["idempotency-key"] ?? input["idempotencyKey"] ?? "").trim();
206
+ const again = ctx.store.byIdempotencyKey(key);
207
+ if (again) {
208
+ ctx.json(response, 200, { station: ownerView(ctx, again), created: false });
209
+ return true;
210
+ }
211
+ const items = ctx.select(input);
212
+ if (items === null) throw new StationError("items_required", "choose files by track index (items) or a folder");
213
+ const station = ctx.store.create(input, items, ctx.account, Date.now(), key);
214
+ ctx.json(response, 201, { station: ownerView(ctx, station), created: true });
215
+ return true;
216
+ }
217
+ ctx.json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
218
+ return true;
219
+ }
220
+
221
+ const match = /^\/api\/v1\/stations\/([a-z0-9]{10})(?:\/([a-z-]+))?$/.exec(path);
222
+ if (!match) {
223
+ ctx.json(response, 404, { error: "no such station", code: "not_found" });
224
+ return true;
225
+ }
226
+ const id = match[1]!;
227
+ const action = match[2];
228
+ const station = ctx.store.get(id);
229
+ if (!station) {
230
+ ctx.json(response, 404, { error: "no such station", code: "not_found" });
231
+ return true;
232
+ }
233
+
234
+ if (action === undefined) {
235
+ if (method === "GET") {
236
+ ctx.json(response, 200, { station: ownerView(ctx, station) });
237
+ return true;
238
+ }
239
+ if (method === "PATCH") {
240
+ const input = await body();
241
+ const items = ctx.select(input);
242
+ const updated = ctx.store.update(id, input, items);
243
+ ctx.json(response, 200, { station: ownerView(ctx, updated) });
244
+ return true;
245
+ }
246
+ if (method === "DELETE") {
247
+ ctx.runner.remove(id);
248
+ ctx.json(response, 200, { deleted: id });
249
+ return true;
250
+ }
251
+ ctx.json(response, 405, { error: "GET, PATCH or DELETE", code: "method_not_allowed" });
252
+ return true;
253
+ }
254
+
255
+ if (method !== "POST") {
256
+ ctx.json(response, 405, { error: "POST", code: "method_not_allowed" });
257
+ return true;
258
+ }
259
+ if (action === "start") {
260
+ const started = await ctx.runner.start(id);
261
+ ctx.json(response, 200, { station: ownerView(ctx, started) });
262
+ return true;
263
+ }
264
+ if (action === "stop") {
265
+ ctx.json(response, 200, { station: ownerView(ctx, ctx.runner.stop(id)) });
266
+ return true;
267
+ }
268
+ if (action === "skip") {
269
+ ctx.runner.skip(id);
270
+ ctx.json(response, 200, { station: ownerView(ctx, station) });
271
+ return true;
272
+ }
273
+ if (action === "retry") {
274
+ ctx.json(response, 200, { station: ownerView(ctx, ctx.store.clearFailures(id)) });
275
+ return true;
276
+ }
277
+ if (action === "rotate-link") {
278
+ const rotated = ctx.store.rotateToken(id);
279
+ const dropped = ctx.runner.revoke(id);
280
+ ctx.json(response, 200, { station: ownerView(ctx, rotated), dropped });
281
+ return true;
282
+ }
283
+ if (action === "access") {
284
+ const input = await body();
285
+ const rights = input["rights"];
286
+ const { station: changed, revoked } = ctx.store.setAccess(id, {
287
+ visibility: input["visibility"],
288
+ listed: input["listed"],
289
+ rights: typeof rights === "object" && rights !== null ? rights as { basis?: unknown; note?: unknown } : null,
290
+ }, ctx.account || "control-key");
291
+ const dropped = revoked ? ctx.runner.revoke(id) : 0;
292
+ ctx.json(response, 200, { station: ownerView(ctx, changed), dropped });
293
+ return true;
294
+ }
295
+ ctx.json(response, 404, { error: "no such action", code: "not_found" });
296
+ return true;
297
+ } catch (error) {
298
+ fail(ctx, response, error);
299
+ return true;
300
+ }
301
+ }
@@ -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.`;