nixamp 0.34.0 → 0.36.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/auto-stations.d.ts +66 -0
- package/dist/auto-stations.js +108 -0
- package/dist/channels.d.ts +1 -1
- package/dist/library-index.d.ts +2 -0
- package/dist/library-index.js +8 -0
- package/dist/library.d.ts +2 -0
- package/dist/library.js +1 -0
- package/dist/playlist-input.d.ts +9 -0
- package/dist/playlist-input.js +30 -8
- package/dist/server.d.ts +7 -0
- package/dist/server.js +102 -1
- package/dist/station-breaks.d.ts +19 -0
- package/dist/station-breaks.js +92 -0
- package/dist/station-cli.js +22 -0
- package/dist/station-guide.d.ts +4 -0
- package/dist/station-guide.js +86 -0
- package/dist/station-mcp.d.ts +99 -0
- package/dist/station-mcp.js +16 -0
- package/dist/station-metering.d.ts +59 -0
- package/dist/station-metering.js +189 -0
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +20 -5
- package/dist/station-routes.d.ts +13 -0
- package/dist/station-routes.js +90 -3
- package/dist/station-runner.d.ts +9 -0
- package/dist/station-runner.js +15 -0
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +2 -0
- package/dist/stations.d.ts +14 -0
- package/dist/stations.js +26 -0
- package/package.json +1 -1
- package/src/auto-stations.ts +171 -0
- package/src/channels.ts +1 -1
- package/src/library-index.ts +5 -0
- package/src/library.ts +3 -0
- package/src/playlist-input.ts +31 -8
- package/src/server.ts +106 -2
- package/src/station-breaks.ts +93 -0
- package/src/station-cli.ts +21 -0
- package/src/station-guide.ts +92 -0
- package/src/station-mcp.ts +16 -0
- package/src/station-metering.ts +218 -0
- package/src/station-page.ts +20 -5
- package/src/station-routes.ts +98 -3
- package/src/station-runner.ts +20 -0
- package/src/station-usage.ts +2 -0
- package/src/stations.ts +37 -0
- package/web/dist/assets/{ar-DoW0wbjC.js → ar-Ck-FmDpQ.js} +1 -1
- package/web/dist/assets/{cs-CjKqCjy5.js → cs-CPJY3K69.js} +1 -1
- package/web/dist/assets/{da-BQERb7Qz.js → da-BnWtLL09.js} +1 -1
- package/web/dist/assets/{de-CKKbRy6c.js → de-CHevIt93.js} +1 -1
- package/web/dist/assets/{es-CL3v5Og9.js → es-B6wpaUMv.js} +1 -1
- package/web/dist/assets/{fi-DgV81qNU.js → fi-8fKiz2AA.js} +1 -1
- package/web/dist/assets/{fr-BGumytzF.js → fr-0GgY52Sm.js} +1 -1
- package/web/dist/assets/{hi-CAQ9elqo.js → hi-BlsrUAw-.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-Ceri0flt.js → hls-3VKVEQE3-BZd3zjlr.js} +1 -1
- package/web/dist/assets/{hu-UJNgJVWX.js → hu-D3i_6i5w.js} +1 -1
- package/web/dist/assets/{id-BqQOlrF-.js → id-CLLAXbOP.js} +1 -1
- package/web/dist/assets/{it-Cs8G6mvW.js → it-BU13BsSn.js} +1 -1
- package/web/dist/assets/{main-Y1Rvn1GX.js → main-C1ggd2H7.js} +2 -2
- package/web/dist/assets/{mpegts-jS_Diizz.js → mpegts-BaoQnMCf.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CPI8xaZ-.js → mpegts-LO6RVLD6-YBT-W6xc.js} +1 -1
- package/web/dist/assets/{nl-BJXQ9nqo.js → nl-DEshyyt7.js} +1 -1
- package/web/dist/assets/radio-BpH1qMMQ.js +1 -0
- package/web/dist/assets/{ru-XmVkvNY8.js → ru-D7sPOc6m.js} +1 -1
- package/web/dist/assets/{sv-pVm9p66e.js → sv-sdrsKAiG.js} +1 -1
- package/web/dist/assets/{uk-4hcyaD3e.js → uk-_lz6hkvo.js} +1 -1
- package/web/dist/assets/{vi-B7MipBlv.js → vi-D81wl7uk.js} +1 -1
- package/web/dist/assets/{zh-B5YVExOW.js → zh-BqMqEom6.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/station-routes.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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)$/;
|
|
4
5
|
/** Whether this path is one a station's own link may open, without the server's key. */
|
|
5
6
|
export function isStationListenPath(path) {
|
|
6
|
-
return LISTEN.test(path);
|
|
7
|
+
return LISTEN.test(path) || path === "/api/v1/stations/guide";
|
|
7
8
|
}
|
|
8
9
|
function fail(ctx, response, error) {
|
|
9
10
|
if (error instanceof StationError) {
|
|
@@ -42,7 +43,10 @@ export function publicView(ctx, station) {
|
|
|
42
43
|
state,
|
|
43
44
|
// Tags, or nothing: a bare file name is the owner's, not the public's.
|
|
44
45
|
nowPlaying: track && (track.title || track.artist) ? track : null,
|
|
45
|
-
|
|
46
|
+
kind: station.video ? "video" : "audio",
|
|
47
|
+
format: state === "stopped" || state === "offline" ? null : station.video
|
|
48
|
+
? { codec: "h264+aac", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 }
|
|
49
|
+
: { codec: "mp3", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 },
|
|
46
50
|
listeners: ctx.runner.listeners(station.id),
|
|
47
51
|
};
|
|
48
52
|
}
|
|
@@ -56,6 +60,10 @@ export function ownerView(ctx, station) {
|
|
|
56
60
|
error: ctx.runner.error(station.id),
|
|
57
61
|
links: links(ctx, station),
|
|
58
62
|
nowPlaying: nowPlayingFile(ctx, station),
|
|
63
|
+
...(ctx.usage && ctx.limits ? {
|
|
64
|
+
metrics: { listeners: ctx.usage.listeners(station.id), month: ctx.usage.thisMonth(station.id), limits: ctx.limits },
|
|
65
|
+
alerts: alertsFor(station, ctx.runner.observed(station.id), ctx.runner.downSince(station.id), ctx.usage.thisMonth(station.id), ctx.limits),
|
|
66
|
+
} : {}),
|
|
59
67
|
};
|
|
60
68
|
}
|
|
61
69
|
/** Answers anything under /api/v1/stations; false when the path is not one. */
|
|
@@ -63,6 +71,15 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
63
71
|
if (path !== "/api/v1/stations" && !path.startsWith("/api/v1/stations/"))
|
|
64
72
|
return false;
|
|
65
73
|
const method = request.method ?? "GET";
|
|
74
|
+
// The public guide: only stations their owner made public and listed, with
|
|
75
|
+
// their links, because public is what that means. Nothing else, ever.
|
|
76
|
+
if (path === "/api/v1/stations/guide" && (method === "GET" || method === "HEAD")) {
|
|
77
|
+
const listed = ctx.store.list().filter((one) => one.visibility === "public" && one.listed);
|
|
78
|
+
ctx.json(response, 200, {
|
|
79
|
+
stations: listed.map((one) => ({ ...publicView(ctx, one), page: links(ctx, one).page, m3u: links(ctx, one).m3u })),
|
|
80
|
+
});
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
66
83
|
const listen = LISTEN.exec(path);
|
|
67
84
|
if (listen) {
|
|
68
85
|
const station = ctx.store.get(listen[1]);
|
|
@@ -153,20 +170,46 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
153
170
|
ctx.json(response, 503, { error: "the station is not on the air", code: "not_live", state: ctx.runner.observed(station.id) });
|
|
154
171
|
return true;
|
|
155
172
|
}
|
|
173
|
+
// Admitted only within the station's and the server's limits, and the
|
|
174
|
+
// month's allowance; refused with what to do about it.
|
|
175
|
+
const refused = method === "GET" && ctx.usage && ctx.limits ? ctx.usage.admit(station.id, ctx.limits) : null;
|
|
176
|
+
if (refused) {
|
|
177
|
+
response.writeHead(refused.status, { ...ctx.headers, "content-type": "application/json; charset=utf-8", "retry-after": String(refused.retryAfter) });
|
|
178
|
+
response.end(JSON.stringify({ error: refused.error, code: refused.code, retryAfter: refused.retryAfter }));
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
156
181
|
response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
|
|
157
182
|
if (method === "HEAD") {
|
|
158
183
|
response.end();
|
|
159
184
|
return true;
|
|
160
185
|
}
|
|
161
|
-
|
|
186
|
+
// Every byte that goes out through the link is counted against the station.
|
|
187
|
+
const usage = ctx.usage;
|
|
188
|
+
const counted = usage
|
|
189
|
+
? {
|
|
190
|
+
write: (chunk) => {
|
|
191
|
+
usage.delivered(station.id, chunk.length);
|
|
192
|
+
return response.write(chunk);
|
|
193
|
+
},
|
|
194
|
+
end: () => { response.end(); },
|
|
195
|
+
pending: () => response.writableLength,
|
|
196
|
+
}
|
|
197
|
+
: response;
|
|
198
|
+
const detach = ctx.channels.listen(channelId, counted);
|
|
162
199
|
if (detach === null) {
|
|
163
200
|
response.end();
|
|
164
201
|
return true;
|
|
165
202
|
}
|
|
203
|
+
const left = usage?.joined(station.id);
|
|
166
204
|
const untrack = ctx.runner.track(station.id, { end: () => response.destroy() });
|
|
205
|
+
let gone = false;
|
|
167
206
|
const leave = () => {
|
|
207
|
+
if (gone)
|
|
208
|
+
return;
|
|
209
|
+
gone = true;
|
|
168
210
|
detach();
|
|
169
211
|
untrack();
|
|
212
|
+
left?.();
|
|
170
213
|
};
|
|
171
214
|
request.on("close", leave);
|
|
172
215
|
response.on("close", leave);
|
|
@@ -177,6 +220,15 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
177
220
|
ctx.json(response, 403, { error: "only this server's owner manages its stations", code: "forbidden" });
|
|
178
221
|
return true;
|
|
179
222
|
}
|
|
223
|
+
// Changes are limited apart from listening, so neither starves the other.
|
|
224
|
+
if (method !== "GET" && ctx.throttle) {
|
|
225
|
+
const wait = ctx.throttle();
|
|
226
|
+
if (wait) {
|
|
227
|
+
response.setHeader("retry-after", String(wait.retryAfter));
|
|
228
|
+
ctx.json(response, 429, { error: "too many changes at once; wait a moment", code: "rate_limited", retryAfter: wait.retryAfter });
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
180
232
|
const body = async () => {
|
|
181
233
|
if (method === "GET" || method === "DELETE")
|
|
182
234
|
return {};
|
|
@@ -221,6 +273,22 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
221
273
|
ctx.json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
|
|
222
274
|
return true;
|
|
223
275
|
}
|
|
276
|
+
if (path === "/api/v1/stations/auto") {
|
|
277
|
+
if (!ctx.auto) {
|
|
278
|
+
ctx.json(response, 404, { error: "auto stations are not available here", code: "not_found" });
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
if (method === "GET") {
|
|
282
|
+
ctx.json(response, 200, { setting: ctx.auto.setting() });
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
if (method === "POST") {
|
|
286
|
+
ctx.json(response, 200, { report: await ctx.auto.sync(await body()) });
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
ctx.json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
224
292
|
const match = /^\/api\/v1\/stations\/([a-z0-9]{10})(?:\/([a-z-]+))?$/.exec(path);
|
|
225
293
|
if (!match) {
|
|
226
294
|
ctx.json(response, 404, { error: "no such station", code: "not_found" });
|
|
@@ -253,11 +321,30 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
253
321
|
ctx.json(response, 405, { error: "GET, PATCH or DELETE", code: "method_not_allowed" });
|
|
254
322
|
return true;
|
|
255
323
|
}
|
|
324
|
+
if (action === "metrics" && method === "GET") {
|
|
325
|
+
if (!ctx.usage || !ctx.limits) {
|
|
326
|
+
ctx.json(response, 404, { error: "metering is off on this server", code: "not_found" });
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
ctx.json(response, 200, {
|
|
330
|
+
listeners: ctx.usage.listeners(station.id),
|
|
331
|
+
month: ctx.usage.thisMonth(station.id),
|
|
332
|
+
history: ctx.usage.history(station.id),
|
|
333
|
+
limits: ctx.limits,
|
|
334
|
+
alerts: alertsFor(station, ctx.runner.observed(station.id), ctx.runner.downSince(station.id), ctx.usage.thisMonth(station.id), ctx.limits),
|
|
335
|
+
});
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
256
338
|
if (method !== "POST") {
|
|
257
339
|
ctx.json(response, 405, { error: "POST", code: "method_not_allowed" });
|
|
258
340
|
return true;
|
|
259
341
|
}
|
|
260
342
|
if (action === "start") {
|
|
343
|
+
const running = ctx.store.list().filter((one) => one.desired === "running" && one.id !== id).length;
|
|
344
|
+
if (ctx.limits && ctx.limits.runningStations > 0 && running >= ctx.limits.runningStations) {
|
|
345
|
+
ctx.json(response, 409, { error: `this server runs at most ${ctx.limits.runningStations} stations at once; stop one first`, code: "too_many_running" });
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
261
348
|
const started = await ctx.runner.start(id);
|
|
262
349
|
ctx.json(response, 200, { station: ownerView(ctx, started) });
|
|
263
350
|
return true;
|
package/dist/station-runner.d.ts
CHANGED
|
@@ -23,6 +23,11 @@ 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;
|
|
29
|
+
/** A file for a break on a radio (false) or TV (true) station, or null for none. */
|
|
30
|
+
breaks?: (video: boolean) => Promise<string | null>;
|
|
26
31
|
}
|
|
27
32
|
/** Something a listener is connected through, that can be ended. */
|
|
28
33
|
export interface Ending {
|
|
@@ -37,6 +42,8 @@ export declare class StationRunner {
|
|
|
37
42
|
private starting;
|
|
38
43
|
private errors;
|
|
39
44
|
private listening;
|
|
45
|
+
/** When a station that should be on went off, for "off the air for N minutes". */
|
|
46
|
+
private down;
|
|
40
47
|
constructor(deps: RunnerDeps);
|
|
41
48
|
private now;
|
|
42
49
|
private log;
|
|
@@ -58,6 +65,8 @@ export declare class StationRunner {
|
|
|
58
65
|
private scheduleRecovery;
|
|
59
66
|
private cancelRecovery;
|
|
60
67
|
observed(id: string): Observed;
|
|
68
|
+
/** When it went off while wanted, or 0 when it is not off. */
|
|
69
|
+
downSince(id: string): number;
|
|
61
70
|
error(id: string): string;
|
|
62
71
|
/** A listener on a station's link, ended if the link is revoked. */
|
|
63
72
|
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);
|
|
@@ -104,8 +107,12 @@ export class StationRunner {
|
|
|
104
107
|
return now ? this.playable(now) : [];
|
|
105
108
|
},
|
|
106
109
|
excluded: (file) => store.isExcluded(id, file),
|
|
110
|
+
...(station.breaksEvery > 0 && this.deps.breaks
|
|
111
|
+
? { breaks: { everySeconds: station.breaksEvery, next: () => this.deps.breaks(station.video) } }
|
|
112
|
+
: {}),
|
|
107
113
|
onFailure: ({ file, reason }) => {
|
|
108
114
|
this.errors.set(id, reason);
|
|
115
|
+
this.deps.onFailure?.(id);
|
|
109
116
|
if (store.recordFailure(id, file)) {
|
|
110
117
|
this.log(` Station "${station.name}": left out a file that failed ${EXCLUDE_AFTER} times.`);
|
|
111
118
|
}
|
|
@@ -118,6 +125,7 @@ export class StationRunner {
|
|
|
118
125
|
return false;
|
|
119
126
|
}
|
|
120
127
|
this.startedAt.set(id, this.now());
|
|
128
|
+
this.down.delete(id);
|
|
121
129
|
this.deps.channels.keep(channelId);
|
|
122
130
|
return true;
|
|
123
131
|
}
|
|
@@ -147,6 +155,9 @@ export class StationRunner {
|
|
|
147
155
|
this.errors.delete(id);
|
|
148
156
|
return;
|
|
149
157
|
}
|
|
158
|
+
if (!this.down.has(id))
|
|
159
|
+
this.down.set(id, this.now());
|
|
160
|
+
this.deps.onRestart?.(id);
|
|
150
161
|
const ran = this.now() - (this.startedAt.get(id) ?? 0);
|
|
151
162
|
if (ran >= HEALTHY_RUN_MS)
|
|
152
163
|
this.attempts.delete(id);
|
|
@@ -185,6 +196,10 @@ export class StationRunner {
|
|
|
185
196
|
return info.redials ? "recovering" : "live";
|
|
186
197
|
return (this.attempts.get(id) ?? 0) <= RECOVERY_DELAYS_MS.length && this.timers.has(id) ? "recovering" : "offline";
|
|
187
198
|
}
|
|
199
|
+
/** When it went off while wanted, or 0 when it is not off. */
|
|
200
|
+
downSince(id) {
|
|
201
|
+
return this.down.get(id) ?? 0;
|
|
202
|
+
}
|
|
188
203
|
error(id) {
|
|
189
204
|
return this.errors.get(id) ?? "";
|
|
190
205
|
}
|
package/dist/station-usage.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** `nixamp station` usage, apart so `nixamp help station` does not load the client. */
|
|
2
|
-
export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
|
|
2
|
+
export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
|
package/dist/station-usage.js
CHANGED
|
@@ -13,6 +13,8 @@ export const USAGE = `nixamp station — make a collection into a station
|
|
|
13
13
|
nixamp station unpublish ID [--private]
|
|
14
14
|
nixamp station link ID [--rotate]
|
|
15
15
|
nixamp station delete ID
|
|
16
|
+
nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]
|
|
17
|
+
a station per genre, kept in step with the library (--off stops it)
|
|
16
18
|
|
|
17
19
|
--json machine-readable output
|
|
18
20
|
--url, --key another server, as with \`nixamp admin\`
|
package/dist/stations.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface Station {
|
|
|
26
26
|
listenToken: string;
|
|
27
27
|
/** Audio bitrate of the station's rendition, in kbps. */
|
|
28
28
|
bitrate: number;
|
|
29
|
+
/** A TV station: pictures as well as sound. */
|
|
30
|
+
video: boolean;
|
|
31
|
+
/** A break (advert or ident) at most this often, in seconds; 0 for none. */
|
|
32
|
+
breaksEvery: number;
|
|
33
|
+
/** Made and kept up to date by auto stations (e.g. "genre:audio:rock"); "" for one a person made. */
|
|
34
|
+
auto: string;
|
|
29
35
|
rights: Rights | null;
|
|
30
36
|
/** nixamp.com account that made it, "" when made with the control key. */
|
|
31
37
|
owner: string;
|
|
@@ -59,6 +65,8 @@ export interface StationInput {
|
|
|
59
65
|
shuffle?: unknown;
|
|
60
66
|
repeat?: unknown;
|
|
61
67
|
bitrate?: unknown;
|
|
68
|
+
video?: unknown;
|
|
69
|
+
breaksEvery?: unknown;
|
|
62
70
|
}
|
|
63
71
|
/** The stations of one server, kept in one file beside its keys. */
|
|
64
72
|
export declare class StationStore {
|
|
@@ -77,6 +85,10 @@ export declare class StationStore {
|
|
|
77
85
|
/** The station a create with this key already made. */
|
|
78
86
|
byIdempotencyKey(key: string): Station | undefined;
|
|
79
87
|
create(input: StationInput, items: string[], owner: string, now?: number, idempotencyKey?: string): Station;
|
|
88
|
+
private breaksFrom;
|
|
89
|
+
/** Mark a station as made by auto stations, so a later sync updates it rather than making another. */
|
|
90
|
+
markAuto(id: string, key: string): Station;
|
|
91
|
+
byAuto(key: string): Station | undefined;
|
|
80
92
|
private bitrateFrom;
|
|
81
93
|
/** Name, words, files and rotation. Who may listen is changed by publish/unpublish. */
|
|
82
94
|
update(id: string, input: StationInput, items: string[] | null, now?: number): Station;
|
|
@@ -126,6 +138,8 @@ export interface PublicStation {
|
|
|
126
138
|
description: string;
|
|
127
139
|
visibility: Visibility;
|
|
128
140
|
state: Observed;
|
|
141
|
+
/** Radio or TV. */
|
|
142
|
+
kind: "audio" | "video";
|
|
129
143
|
nowPlaying: {
|
|
130
144
|
title: string;
|
|
131
145
|
artist: string;
|
package/dist/stations.js
CHANGED
|
@@ -121,6 +121,9 @@ export class StationStore {
|
|
|
121
121
|
owner: one.owner ?? "",
|
|
122
122
|
description: one.description ?? "",
|
|
123
123
|
idempotencyKey: one.idempotencyKey ?? "",
|
|
124
|
+
video: one.video ?? false,
|
|
125
|
+
breaksEvery: one.breaksEvery ?? 0,
|
|
126
|
+
auto: one.auto ?? "",
|
|
124
127
|
});
|
|
125
128
|
}
|
|
126
129
|
}
|
|
@@ -169,6 +172,9 @@ export class StationStore {
|
|
|
169
172
|
desired: "stopped",
|
|
170
173
|
listenToken: newToken(),
|
|
171
174
|
bitrate: this.bitrateFrom(input.bitrate),
|
|
175
|
+
video: input.video === true,
|
|
176
|
+
breaksEvery: this.breaksFrom(input.breaksEvery),
|
|
177
|
+
auto: "",
|
|
172
178
|
rights: null,
|
|
173
179
|
owner,
|
|
174
180
|
failures: {},
|
|
@@ -180,6 +186,24 @@ export class StationStore {
|
|
|
180
186
|
this.save();
|
|
181
187
|
return station;
|
|
182
188
|
}
|
|
189
|
+
breaksFrom(value) {
|
|
190
|
+
if (value === undefined || value === null || value === 0)
|
|
191
|
+
return 0;
|
|
192
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 120 || value > 86_400) {
|
|
193
|
+
throw new StationError("invalid_breaks", "breaksEvery is seconds between breaks, from 120 to 86400, or 0 for none");
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
/** Mark a station as made by auto stations, so a later sync updates it rather than making another. */
|
|
198
|
+
markAuto(id, key) {
|
|
199
|
+
const station = this.require(id);
|
|
200
|
+
station.auto = key;
|
|
201
|
+
this.save();
|
|
202
|
+
return station;
|
|
203
|
+
}
|
|
204
|
+
byAuto(key) {
|
|
205
|
+
return [...this.stations.values()].find((one) => one.auto === key);
|
|
206
|
+
}
|
|
183
207
|
bitrateFrom(value) {
|
|
184
208
|
if (value === undefined)
|
|
185
209
|
return DEFAULT_BITRATE;
|
|
@@ -205,6 +229,8 @@ export class StationStore {
|
|
|
205
229
|
station.repeat = input.repeat !== false;
|
|
206
230
|
if (input.bitrate !== undefined)
|
|
207
231
|
station.bitrate = this.bitrateFrom(input.bitrate);
|
|
232
|
+
if (input.breaksEvery !== undefined)
|
|
233
|
+
station.breaksEvery = this.breaksFrom(input.breaksEvery);
|
|
208
234
|
if (items !== null) {
|
|
209
235
|
if (items.length === 0)
|
|
210
236
|
throw new StationError("empty_station", "a station needs at least one file that plays");
|
package/package.json
CHANGED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto stations: a station for every genre the library has, kept up to date.
|
|
3
|
+
*
|
|
4
|
+
* Radio from the music's own genre tags, folded into genres a listener would
|
|
5
|
+
* recognise ("Rap/Hip-Hop", "Hip Hop" and "Gangsta" are all Hip-Hop; "Pop,
|
|
6
|
+
* Rock, Metal" is in three). TV from the genres nichedb knows for each film.
|
|
7
|
+
* Lectures, interviews and talk are not music and stay out of radio.
|
|
8
|
+
*
|
|
9
|
+
* Each genre's station is found again by its key on the next sync and its
|
|
10
|
+
* files replaced, so new music lands in the right station by itself and a
|
|
11
|
+
* station's link never changes. Nothing is published: an auto station is
|
|
12
|
+
* unlisted, a link for whoever the owner hands it to. Making one public is
|
|
13
|
+
* the owner's decision, with the rights basis that asks for.
|
|
14
|
+
*
|
|
15
|
+
* TV costs a core per running station (it is re-encoded), so only the
|
|
16
|
+
* biggest few run; the rest are made and wait, stopped.
|
|
17
|
+
*/
|
|
18
|
+
import type { LibraryIndex, LibraryItem } from "./library-index.ts";
|
|
19
|
+
import { shown } from "./library-index.ts";
|
|
20
|
+
import type { StationRunner } from "./station-runner.ts";
|
|
21
|
+
import type { Station, StationStore } from "./stations.ts";
|
|
22
|
+
|
|
23
|
+
const RULES: Array<[RegExp, string]> = [
|
|
24
|
+
[/metal|thrash|trash|doom|death|grind|neue deutsche|h(a|ä)rte|djent|metalcore/i, "Metal"],
|
|
25
|
+
[/punk|hardcore|emo|screamo/i, "Punk"],
|
|
26
|
+
[/hip.?hop|\brap\b|gangsta|trap|grime|drill/i, "Hip-Hop"],
|
|
27
|
+
[/industrial/i, "Industrial"],
|
|
28
|
+
[/electro|techno|house|edm|dance|trance|ambient|drum.?n.?bass|dubstep|synth/i, "Electronic"],
|
|
29
|
+
[/reggae|\bska\b|\bdub\b|dancehall/i, "Reggae"],
|
|
30
|
+
[/jazz|swing|bebop/i, "Jazz"],
|
|
31
|
+
[/blues/i, "Blues"],
|
|
32
|
+
[/classical|symphon|orchestr|baroque|opera/i, "Classical"],
|
|
33
|
+
[/country|folk|bluegrass|americana/i, "Country & Folk"],
|
|
34
|
+
[/soul|r&b|rnb|funk|motown|gospel/i, "Soul & R&B"],
|
|
35
|
+
[/latin|reggaeton|salsa|bachata|cumbia/i, "Latin"],
|
|
36
|
+
[/alternat|indie|ind(e|é)|grunge|shoegaze/i, "Alternative"],
|
|
37
|
+
[/rock/i, "Rock"],
|
|
38
|
+
[/pop/i, "Pop"],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const NOT_MUSIC = /lecture|interview|audiobook|podcast|speech|spoken|talk|course|sermon|lesson|comedy/i;
|
|
42
|
+
|
|
43
|
+
/** The genres a tag belongs to, recognisably named; [] when it is not music or not known. */
|
|
44
|
+
export function genreBuckets(raw: string): string[] {
|
|
45
|
+
if (!raw || NOT_MUSIC.test(raw)) return [];
|
|
46
|
+
const out = new Set<string>();
|
|
47
|
+
for (const part of raw.split(/[,;/|]+|\s+&\s+/)) {
|
|
48
|
+
const hit = RULES.find(([pattern]) => pattern.test(part));
|
|
49
|
+
if (hit) out.add(hit[1]);
|
|
50
|
+
}
|
|
51
|
+
return [...out];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AutoOptions {
|
|
55
|
+
/** Radio from tags; TV from nichedb's genres. */
|
|
56
|
+
video: boolean;
|
|
57
|
+
/** A genre needs at least this many files (radio) to get a station. */
|
|
58
|
+
minItems: number;
|
|
59
|
+
/** Films for a TV station. */
|
|
60
|
+
minFilms: number;
|
|
61
|
+
start: boolean;
|
|
62
|
+
/** TV stations running at once. */
|
|
63
|
+
maxVideoRunning: number;
|
|
64
|
+
/** Seconds between breaks on the stations this makes. */
|
|
65
|
+
breaksEvery: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const DEFAULT_AUTO: AutoOptions = { video: true, minItems: 10, minFilms: 3, start: true, maxVideoRunning: 2, breaksEvery: 600 };
|
|
69
|
+
|
|
70
|
+
export interface AutoReport {
|
|
71
|
+
radio: Array<{ genre: string; id: string; files: number; made: boolean; running: boolean }>;
|
|
72
|
+
tv: Array<{ genre: string; id: string; films: number; made: boolean; running: boolean }>;
|
|
73
|
+
skipped: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AutoDeps {
|
|
77
|
+
index: LibraryIndex;
|
|
78
|
+
store: StationStore;
|
|
79
|
+
runner: StationRunner;
|
|
80
|
+
owner: string;
|
|
81
|
+
/** nichedb's genres for a film, from the enrichment cache or a lookup; [] when unknown. */
|
|
82
|
+
filmGenres?: (title: string) => Promise<string[]>;
|
|
83
|
+
/** Whether another station may be started now (the server's running limit). */
|
|
84
|
+
mayStart: () => boolean;
|
|
85
|
+
log?: (line: string) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function order(items: LibraryItem[]): LibraryItem[] {
|
|
89
|
+
return [...items].sort((a, b) => {
|
|
90
|
+
const x = shown(a), y = shown(b);
|
|
91
|
+
return (x.albumArtist || x.artist).localeCompare(y.albumArtist || y.artist) || x.album.localeCompare(y.album) || x.disc - y.disc || x.track - y.track || a.rel.localeCompare(b.rel);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function upsert(
|
|
96
|
+
deps: AutoDeps,
|
|
97
|
+
key: string,
|
|
98
|
+
name: string,
|
|
99
|
+
paths: string[],
|
|
100
|
+
video: boolean,
|
|
101
|
+
options: AutoOptions,
|
|
102
|
+
): Promise<{ station: Station; made: boolean }> {
|
|
103
|
+
const existing = deps.store.byAuto(key);
|
|
104
|
+
if (existing) {
|
|
105
|
+
const same = existing.items.length === paths.length && existing.items.every((one, at) => one === paths[at]);
|
|
106
|
+
return { station: same ? existing : deps.store.update(existing.id, {}, paths), made: false };
|
|
107
|
+
}
|
|
108
|
+
const station = deps.store.create({ name, shuffle: true, repeat: true, video, breaksEvery: options.breaksEvery }, paths, deps.owner);
|
|
109
|
+
deps.store.markAuto(station.id, key);
|
|
110
|
+
// A link, not a listing: unlisted needs no rights basis and publishes nothing.
|
|
111
|
+
deps.store.setAccess(station.id, { visibility: "unlisted" }, deps.owner || "auto");
|
|
112
|
+
return { station, made: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Make or update a station per genre; start what the limits allow. */
|
|
116
|
+
export async function syncAutoStations(deps: AutoDeps, options: AutoOptions = DEFAULT_AUTO): Promise<AutoReport> {
|
|
117
|
+
const report: AutoReport = { radio: [], tv: [], skipped: [] };
|
|
118
|
+
const ready = deps.index.search({ state: "ready", limit: 500, offset: 0 });
|
|
119
|
+
// search() pages at 500; walk them all.
|
|
120
|
+
const all: LibraryItem[] = [...ready.items];
|
|
121
|
+
for (let offset = 500; offset < ready.total; offset += 500) all.push(...deps.index.search({ state: "ready", limit: 500, offset }).items);
|
|
122
|
+
|
|
123
|
+
const radio = new Map<string, LibraryItem[]>();
|
|
124
|
+
for (const item of all) {
|
|
125
|
+
if (item.video) continue;
|
|
126
|
+
for (const genre of genreBuckets(shown(item).genre)) {
|
|
127
|
+
const list = radio.get(genre) ?? [];
|
|
128
|
+
list.push(item);
|
|
129
|
+
radio.set(genre, list);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const [genre, items] of [...radio].sort((a, b) => b[1].length - a[1].length)) {
|
|
133
|
+
if (items.length < options.minItems) {
|
|
134
|
+
report.skipped.push(`${genre} radio (${items.length} files)`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const { station, made } = await upsert(deps, `genre:audio:${genre.toLowerCase()}`, `${genre} Radio`, order(items).map((one) => one.path), false, options);
|
|
138
|
+
let running = station.desired === "running";
|
|
139
|
+
if (!running && options.start && deps.mayStart()) {
|
|
140
|
+
await deps.runner.start(station.id).then(() => { running = true; }, (error: Error) => deps.log?.(` auto stations: ${genre} Radio did not start: ${error.message}`));
|
|
141
|
+
}
|
|
142
|
+
report.radio.push({ genre, id: station.id, files: items.length, made, running });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (options.video && deps.filmGenres) {
|
|
146
|
+
const tv = new Map<string, LibraryItem[]>();
|
|
147
|
+
for (const item of all) {
|
|
148
|
+
if (!item.video || item.duration < 40 * 60) continue;
|
|
149
|
+
const genres = await deps.filmGenres(shown(item).title).catch(() => []);
|
|
150
|
+
for (const genre of genres.slice(0, 2)) {
|
|
151
|
+
const list = tv.get(genre) ?? [];
|
|
152
|
+
list.push(item);
|
|
153
|
+
tv.set(genre, list);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
let runningTv = deps.store.list().filter((one) => one.video && one.auto && one.desired === "running").length;
|
|
157
|
+
for (const [genre, films] of [...tv].sort((a, b) => b[1].length - a[1].length)) {
|
|
158
|
+
if (films.length < options.minFilms) {
|
|
159
|
+
report.skipped.push(`${genre} TV (${films.length} films)`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const { station, made } = await upsert(deps, `genre:video:${genre.toLowerCase()}`, `${genre} TV`, films.map((one) => one.path), true, options);
|
|
163
|
+
let running = station.desired === "running";
|
|
164
|
+
if (!running && options.start && runningTv < options.maxVideoRunning && deps.mayStart()) {
|
|
165
|
+
await deps.runner.start(station.id).then(() => { running = true; runningTv += 1; }, (error: Error) => deps.log?.(` auto stations: ${genre} TV did not start: ${error.message}`));
|
|
166
|
+
}
|
|
167
|
+
report.tv.push({ genre, id: station.id, films: films.length, made, running });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return report;
|
|
171
|
+
}
|
package/src/channels.ts
CHANGED
|
@@ -129,7 +129,7 @@ export interface PullResume {
|
|
|
129
129
|
* lists of files on this machine; not remembered, because whoever owns the
|
|
130
130
|
* rotation (the station) puts it back.
|
|
131
131
|
*/
|
|
132
|
-
rotation?: Pick<PlaylistInput, "repeat" | "order" | "current" | "excluded" | "onFailure" | "passOver">;
|
|
132
|
+
rotation?: Pick<PlaylistInput, "repeat" | "order" | "current" | "excluded" | "onFailure" | "passOver" | "breaks">;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/**
|
package/src/library-index.ts
CHANGED
|
@@ -335,6 +335,8 @@ export class LibraryIndex {
|
|
|
335
335
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
336
336
|
private dirty = false;
|
|
337
337
|
private jobs = new Map<string, { report: ScanReport; abort: AbortController }>();
|
|
338
|
+
/** Told when any scan finishes, for whatever keeps up with the library (auto stations). */
|
|
339
|
+
onScanned: ((report: ScanReport) => void) | null = null;
|
|
338
340
|
|
|
339
341
|
constructor(
|
|
340
342
|
private readonly file: string,
|
|
@@ -622,6 +624,9 @@ export class LibraryIndex {
|
|
|
622
624
|
report.status = signal.aborted ? "cancelled" : "done";
|
|
623
625
|
report.finishedAt = this.now();
|
|
624
626
|
this.touch();
|
|
627
|
+
if (report.status === "done") {
|
|
628
|
+
try { this.onScanned?.(report); } catch { /* a listener's trouble is not the scan's */ }
|
|
629
|
+
}
|
|
625
630
|
return report;
|
|
626
631
|
}
|
|
627
632
|
|
package/src/library.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface Config {
|
|
|
25
25
|
library?: string;
|
|
26
26
|
/** Whether this machine is listed in the public directory; unset until chosen. */
|
|
27
27
|
publish?: boolean;
|
|
28
|
+
/** Auto stations: a station per genre, re-synced after every library scan. */
|
|
29
|
+
autoStations?: Record<string, unknown>;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export function configPath(): string {
|
|
@@ -39,6 +41,7 @@ export function readConfig(): Config {
|
|
|
39
41
|
return {
|
|
40
42
|
...(typeof record["library"] === "string" ? { library: record["library"] } : {}),
|
|
41
43
|
...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
|
|
44
|
+
...(typeof record["autoStations"] === "object" && record["autoStations"] !== null ? { autoStations: record["autoStations"] as Record<string, unknown> } : {}),
|
|
42
45
|
};
|
|
43
46
|
} catch {
|
|
44
47
|
return {};
|