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.
Files changed (73) hide show
  1. package/dist/auto-stations.d.ts +66 -0
  2. package/dist/auto-stations.js +108 -0
  3. package/dist/channels.d.ts +1 -1
  4. package/dist/library-index.d.ts +2 -0
  5. package/dist/library-index.js +8 -0
  6. package/dist/library.d.ts +2 -0
  7. package/dist/library.js +1 -0
  8. package/dist/playlist-input.d.ts +9 -0
  9. package/dist/playlist-input.js +30 -8
  10. package/dist/server.d.ts +7 -0
  11. package/dist/server.js +102 -1
  12. package/dist/station-breaks.d.ts +19 -0
  13. package/dist/station-breaks.js +92 -0
  14. package/dist/station-cli.js +22 -0
  15. package/dist/station-guide.d.ts +4 -0
  16. package/dist/station-guide.js +86 -0
  17. package/dist/station-mcp.d.ts +99 -0
  18. package/dist/station-mcp.js +16 -0
  19. package/dist/station-metering.d.ts +59 -0
  20. package/dist/station-metering.js +189 -0
  21. package/dist/station-page.d.ts +1 -1
  22. package/dist/station-page.js +20 -5
  23. package/dist/station-routes.d.ts +13 -0
  24. package/dist/station-routes.js +90 -3
  25. package/dist/station-runner.d.ts +9 -0
  26. package/dist/station-runner.js +15 -0
  27. package/dist/station-usage.d.ts +1 -1
  28. package/dist/station-usage.js +2 -0
  29. package/dist/stations.d.ts +14 -0
  30. package/dist/stations.js +26 -0
  31. package/package.json +1 -1
  32. package/src/auto-stations.ts +171 -0
  33. package/src/channels.ts +1 -1
  34. package/src/library-index.ts +5 -0
  35. package/src/library.ts +3 -0
  36. package/src/playlist-input.ts +31 -8
  37. package/src/server.ts +106 -2
  38. package/src/station-breaks.ts +93 -0
  39. package/src/station-cli.ts +21 -0
  40. package/src/station-guide.ts +92 -0
  41. package/src/station-mcp.ts +16 -0
  42. package/src/station-metering.ts +218 -0
  43. package/src/station-page.ts +20 -5
  44. package/src/station-routes.ts +98 -3
  45. package/src/station-runner.ts +20 -0
  46. package/src/station-usage.ts +2 -0
  47. package/src/stations.ts +37 -0
  48. package/web/dist/assets/{ar-DoW0wbjC.js → ar-Ck-FmDpQ.js} +1 -1
  49. package/web/dist/assets/{cs-CjKqCjy5.js → cs-CPJY3K69.js} +1 -1
  50. package/web/dist/assets/{da-BQERb7Qz.js → da-BnWtLL09.js} +1 -1
  51. package/web/dist/assets/{de-CKKbRy6c.js → de-CHevIt93.js} +1 -1
  52. package/web/dist/assets/{es-CL3v5Og9.js → es-B6wpaUMv.js} +1 -1
  53. package/web/dist/assets/{fi-DgV81qNU.js → fi-8fKiz2AA.js} +1 -1
  54. package/web/dist/assets/{fr-BGumytzF.js → fr-0GgY52Sm.js} +1 -1
  55. package/web/dist/assets/{hi-CAQ9elqo.js → hi-BlsrUAw-.js} +1 -1
  56. package/web/dist/assets/{hls-3VKVEQE3-Ceri0flt.js → hls-3VKVEQE3-BZd3zjlr.js} +1 -1
  57. package/web/dist/assets/{hu-UJNgJVWX.js → hu-D3i_6i5w.js} +1 -1
  58. package/web/dist/assets/{id-BqQOlrF-.js → id-CLLAXbOP.js} +1 -1
  59. package/web/dist/assets/{it-Cs8G6mvW.js → it-BU13BsSn.js} +1 -1
  60. package/web/dist/assets/{main-Y1Rvn1GX.js → main-C1ggd2H7.js} +2 -2
  61. package/web/dist/assets/{mpegts-jS_Diizz.js → mpegts-BaoQnMCf.js} +1 -1
  62. package/web/dist/assets/{mpegts-LO6RVLD6-CPI8xaZ-.js → mpegts-LO6RVLD6-YBT-W6xc.js} +1 -1
  63. package/web/dist/assets/{nl-BJXQ9nqo.js → nl-DEshyyt7.js} +1 -1
  64. package/web/dist/assets/radio-BpH1qMMQ.js +1 -0
  65. package/web/dist/assets/{ru-XmVkvNY8.js → ru-D7sPOc6m.js} +1 -1
  66. package/web/dist/assets/{sv-pVm9p66e.js → sv-sdrsKAiG.js} +1 -1
  67. package/web/dist/assets/{uk-4hcyaD3e.js → uk-_lz6hkvo.js} +1 -1
  68. package/web/dist/assets/{vi-B7MipBlv.js → vi-D81wl7uk.js} +1 -1
  69. package/web/dist/assets/{zh-B5YVExOW.js → zh-BqMqEom6.js} +1 -1
  70. package/web/dist/index.html +1 -1
  71. package/web/dist/radio.html +1 -1
  72. package/web/dist/sw.js +23 -23
  73. package/web/dist/assets/radio-B58WgC1j.js +0 -1
@@ -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
- format: state === "stopped" || state === "offline" ? null : { codec: "mp3", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 },
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
- const detach = ctx.channels.listen(channelId, response);
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;
@@ -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;
@@ -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
  }
@@ -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.";
@@ -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\`
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
  /**
@@ -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 {};