nixamp 0.35.0 → 0.36.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 (70) hide show
  1. package/dist/auto-stations.d.ts +67 -0
  2. package/dist/auto-stations.js +116 -0
  3. package/dist/channels.d.ts +1 -1
  4. package/dist/enrich.d.ts +6 -0
  5. package/dist/enrich.js +16 -0
  6. package/dist/library-index.d.ts +2 -0
  7. package/dist/library-index.js +8 -0
  8. package/dist/library.d.ts +4 -0
  9. package/dist/library.js +2 -0
  10. package/dist/playlist-input.d.ts +9 -0
  11. package/dist/playlist-input.js +30 -8
  12. package/dist/server.d.ts +4 -0
  13. package/dist/server.js +96 -1
  14. package/dist/station-breaks.d.ts +19 -0
  15. package/dist/station-breaks.js +92 -0
  16. package/dist/station-cli.js +49 -0
  17. package/dist/station-guide.d.ts +8 -0
  18. package/dist/station-guide.js +97 -0
  19. package/dist/station-mcp.d.ts +99 -0
  20. package/dist/station-mcp.js +16 -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 +5 -0
  24. package/dist/station-routes.js +30 -2
  25. package/dist/station-runner.d.ts +2 -0
  26. package/dist/station-runner.js +3 -0
  27. package/dist/station-usage.d.ts +1 -1
  28. package/dist/station-usage.js +4 -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 +180 -0
  33. package/src/channels.ts +1 -1
  34. package/src/enrich.ts +15 -0
  35. package/src/library-index.ts +5 -0
  36. package/src/library.ts +6 -0
  37. package/src/playlist-input.ts +31 -8
  38. package/src/server.ts +100 -2
  39. package/src/station-breaks.ts +93 -0
  40. package/src/station-cli.ts +45 -0
  41. package/src/station-guide.ts +98 -0
  42. package/src/station-mcp.ts +16 -0
  43. package/src/station-page.ts +20 -5
  44. package/src/station-routes.ts +34 -2
  45. package/src/station-runner.ts +5 -0
  46. package/src/station-usage.ts +4 -0
  47. package/src/stations.ts +37 -0
  48. package/web/dist/assets/{ar-Doh06ZMu.js → ar-D3Jhup9Q.js} +1 -1
  49. package/web/dist/assets/{cs-C_lZl4jI.js → cs-dhVCsOeL.js} +1 -1
  50. package/web/dist/assets/{da-CSBBA_ZI.js → da-DwfG3DeD.js} +1 -1
  51. package/web/dist/assets/{de-zorW0tO_.js → de-BtBDFO3Q.js} +1 -1
  52. package/web/dist/assets/{es-DBUl3DGo.js → es-Bf-T9sCu.js} +1 -1
  53. package/web/dist/assets/{fi-DpUx7XY-.js → fi-Dp5UyT8g.js} +1 -1
  54. package/web/dist/assets/{fr-Cg_xppCf.js → fr-D_usw45L.js} +1 -1
  55. package/web/dist/assets/{hi-PD1kdcf2.js → hi-DK-QMrPr.js} +1 -1
  56. package/web/dist/assets/{hls-3VKVEQE3-BEEk729h.js → hls-3VKVEQE3-DtHIQi0a.js} +1 -1
  57. package/web/dist/assets/{hu-C1fGbjZO.js → hu-DB1881vz.js} +1 -1
  58. package/web/dist/assets/{id-DRBhZ8KM.js → id-DhzgwICo.js} +1 -1
  59. package/web/dist/assets/{it-Ci3OZV2-.js → it-C2_ScULL.js} +1 -1
  60. package/web/dist/assets/{main-CEJhJH5y.js → main-BYT6Kgoi.js} +2 -2
  61. package/web/dist/assets/{mpegts-LO6RVLD6-DPrv1ran.js → mpegts-LO6RVLD6-CIaN13nn.js} +1 -1
  62. package/web/dist/assets/{mpegts-y-HAxt1b.js → mpegts-m4dTmfgt.js} +1 -1
  63. package/web/dist/assets/{nl-CwWOHKbv.js → nl-BFAcO0yy.js} +1 -1
  64. package/web/dist/assets/{ru-BWlIGsoL.js → ru-Nbvhzngr.js} +1 -1
  65. package/web/dist/assets/{sv-Gb1cLNsc.js → sv-DEZlQblR.js} +1 -1
  66. package/web/dist/assets/{uk-MZUnyyuR.js → uk-CUhXQE5f.js} +1 -1
  67. package/web/dist/assets/{vi-DNWS5fxc.js → vi-0uN7oQuT.js} +1 -1
  68. package/web/dist/assets/{zh-BcOiladF.js → zh-bsMdPHHB.js} +1 -1
  69. package/web/dist/index.html +1 -1
  70. package/web/dist/sw.js +22 -22
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.35.0",
3
+ "version": "0.36.1",
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,180 @@
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
+ /** Never on a genre TV station, whatever nichedb calls it. */
44
+ const ADULT = /\b(xxx|porn|nsfw|onlyfans|brazzers|blacked|bangbros|milf|hentai|sex|erotic|adult)\b/i;
45
+
46
+ export function adultTitle(title: string, genres: string[] = []): boolean {
47
+ return ADULT.test(title) || genres.some((one) => /adult|erotic|porn/i.test(one));
48
+ }
49
+
50
+ /** The genres a tag belongs to, recognisably named; [] when it is not music or not known. */
51
+ export function genreBuckets(raw: string): string[] {
52
+ if (!raw || NOT_MUSIC.test(raw)) return [];
53
+ const out = new Set<string>();
54
+ for (const part of raw.split(/[,;/|]+|\s+&\s+/)) {
55
+ const hit = RULES.find(([pattern]) => pattern.test(part));
56
+ if (hit) out.add(hit[1]);
57
+ }
58
+ return [...out];
59
+ }
60
+
61
+ export interface AutoOptions {
62
+ /** Radio from tags; TV from nichedb's genres. */
63
+ video: boolean;
64
+ /** A genre needs at least this many files (radio) to get a station. */
65
+ minItems: number;
66
+ /** Films for a TV station. */
67
+ minFilms: number;
68
+ start: boolean;
69
+ /** TV stations running at once. */
70
+ maxVideoRunning: number;
71
+ /** Seconds between breaks on the stations this makes. */
72
+ breaksEvery: number;
73
+ }
74
+
75
+ export const DEFAULT_AUTO: AutoOptions = { video: true, minItems: 10, minFilms: 3, start: true, maxVideoRunning: 2, breaksEvery: 600 };
76
+
77
+ export interface AutoReport {
78
+ radio: Array<{ genre: string; id: string; files: number; made: boolean; running: boolean }>;
79
+ tv: Array<{ genre: string; id: string; films: number; made: boolean; running: boolean }>;
80
+ skipped: string[];
81
+ }
82
+
83
+ export interface AutoDeps {
84
+ index: LibraryIndex;
85
+ store: StationStore;
86
+ runner: StationRunner;
87
+ owner: string;
88
+ /** nichedb's genres for a film, from the enrichment cache or a lookup; [] when unknown. */
89
+ filmGenres?: (title: string) => Promise<string[]>;
90
+ /** Whether another station may be started now (the server's running limit). */
91
+ mayStart: () => boolean;
92
+ log?: (line: string) => void;
93
+ }
94
+
95
+ function order(items: LibraryItem[]): LibraryItem[] {
96
+ return [...items].sort((a, b) => {
97
+ const x = shown(a), y = shown(b);
98
+ 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);
99
+ });
100
+ }
101
+
102
+ async function upsert(
103
+ deps: AutoDeps,
104
+ key: string,
105
+ name: string,
106
+ paths: string[],
107
+ video: boolean,
108
+ options: AutoOptions,
109
+ ): Promise<{ station: Station; made: boolean }> {
110
+ const existing = deps.store.byAuto(key);
111
+ if (existing) {
112
+ const same = existing.items.length === paths.length && existing.items.every((one, at) => one === paths[at]);
113
+ return { station: same ? existing : deps.store.update(existing.id, {}, paths), made: false };
114
+ }
115
+ const station = deps.store.create({ name, shuffle: true, repeat: true, video, breaksEvery: options.breaksEvery }, paths, deps.owner);
116
+ deps.store.markAuto(station.id, key);
117
+ // A link, not a listing: unlisted needs no rights basis and publishes nothing.
118
+ deps.store.setAccess(station.id, { visibility: "unlisted" }, deps.owner || "auto");
119
+ return { station, made: true };
120
+ }
121
+
122
+ /** Make or update a station per genre; start what the limits allow. */
123
+ export async function syncAutoStations(deps: AutoDeps, options: AutoOptions = DEFAULT_AUTO): Promise<AutoReport> {
124
+ const report: AutoReport = { radio: [], tv: [], skipped: [] };
125
+ const ready = deps.index.search({ state: "ready", limit: 500, offset: 0 });
126
+ // search() pages at 500; walk them all.
127
+ const all: LibraryItem[] = [...ready.items];
128
+ for (let offset = 500; offset < ready.total; offset += 500) all.push(...deps.index.search({ state: "ready", limit: 500, offset }).items);
129
+
130
+ const radio = new Map<string, LibraryItem[]>();
131
+ for (const item of all) {
132
+ if (item.video) continue;
133
+ for (const genre of genreBuckets(shown(item).genre)) {
134
+ const list = radio.get(genre) ?? [];
135
+ list.push(item);
136
+ radio.set(genre, list);
137
+ }
138
+ }
139
+ for (const [genre, items] of [...radio].sort((a, b) => b[1].length - a[1].length)) {
140
+ if (items.length < options.minItems) {
141
+ report.skipped.push(`${genre} radio (${items.length} files)`);
142
+ continue;
143
+ }
144
+ const { station, made } = await upsert(deps, `genre:audio:${genre.toLowerCase()}`, `${genre} Radio`, order(items).map((one) => one.path), false, options);
145
+ let running = station.desired === "running";
146
+ if (!running && options.start && deps.mayStart()) {
147
+ await deps.runner.start(station.id).then(() => { running = true; }, (error: Error) => deps.log?.(` auto stations: ${genre} Radio did not start: ${error.message}`));
148
+ }
149
+ report.radio.push({ genre, id: station.id, files: items.length, made, running });
150
+ }
151
+
152
+ if (options.video && deps.filmGenres) {
153
+ const tv = new Map<string, LibraryItem[]>();
154
+ for (const item of all) {
155
+ if (!item.video || item.duration < 40 * 60) continue;
156
+ const title = shown(item).title;
157
+ const genres = await deps.filmGenres(title).catch(() => []);
158
+ if (adultTitle(`${title} ${item.rel}`, genres)) continue;
159
+ for (const genre of genres.slice(0, 2)) {
160
+ const list = tv.get(genre) ?? [];
161
+ list.push(item);
162
+ tv.set(genre, list);
163
+ }
164
+ }
165
+ let runningTv = deps.store.list().filter((one) => one.video && one.auto && one.desired === "running").length;
166
+ for (const [genre, films] of [...tv].sort((a, b) => b[1].length - a[1].length)) {
167
+ if (films.length < options.minFilms) {
168
+ report.skipped.push(`${genre} TV (${films.length} films)`);
169
+ continue;
170
+ }
171
+ const { station, made } = await upsert(deps, `genre:video:${genre.toLowerCase()}`, `${genre} TV`, films.map((one) => one.path), true, options);
172
+ let running = station.desired === "running";
173
+ if (!running && options.start && runningTv < options.maxVideoRunning && deps.mayStart()) {
174
+ await deps.runner.start(station.id).then(() => { running = true; runningTv += 1; }, (error: Error) => deps.log?.(` auto stations: ${genre} TV did not start: ${error.message}`));
175
+ }
176
+ report.tv.push({ genre, id: station.id, films: films.length, made, running });
177
+ }
178
+ }
179
+ return report;
180
+ }
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/enrich.ts CHANGED
@@ -221,6 +221,21 @@ export class Enricher {
221
221
  * channel however its name reads. `auto` lets nichedb's parser decide from
222
222
  * the name itself, which is right for a file.
223
223
  */
224
+ /**
225
+ * What is already known about a name, without asking anybody: for work
226
+ * that runs over a whole library and must not send nichedb a thousand
227
+ * questions at once.
228
+ */
229
+ known(name: string): Enriched | null {
230
+ const asked = String(name ?? "").trim();
231
+ if (asked === "") return null;
232
+ for (const kind of ["auto", "title"] as EnrichKind[]) {
233
+ const had = this.cache.get(cacheKey(asked, kind, null));
234
+ if (had?.hit) return had.hit;
235
+ }
236
+ return null;
237
+ }
238
+
224
239
  async lookup(name: string, kind: EnrichKind = "auto", year: number | null = null): Promise<Enriched | null> {
225
240
  const asked = String(name ?? "").trim();
226
241
  if (asked === "") return null;
@@ -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,10 @@ 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>;
30
+ /** Hosts (e.g. Moshpit names) whose front page is the public station guide. */
31
+ guideHosts?: string[];
28
32
  }
29
33
 
30
34
  export function configPath(): string {
@@ -39,6 +43,8 @@ export function readConfig(): Config {
39
43
  return {
40
44
  ...(typeof record["library"] === "string" ? { library: record["library"] } : {}),
41
45
  ...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
46
+ ...(typeof record["autoStations"] === "object" && record["autoStations"] !== null ? { autoStations: record["autoStations"] as Record<string, unknown> } : {}),
47
+ ...(Array.isArray(record["guideHosts"]) ? { guideHosts: (record["guideHosts"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
42
48
  };
43
49
  } catch {
44
50
  return {};
@@ -34,6 +34,12 @@ export interface PlaylistInput {
34
34
  * still arriving, and the channel redials and waits for it.
35
35
  */
36
36
  passOver?: boolean;
37
+ /**
38
+ * A break every so often, between entries and never in the middle of one:
39
+ * `next` answers a file to play (an advert, an ident) or null for no break
40
+ * this time. The break is outside the rotation and the station's history.
41
+ */
42
+ breaks?: { everySeconds: number; next: () => Promise<string | null> };
37
43
  /** Told when an entry is passed over because it would not play. */
38
44
  onFailure?: (event: { at: number; file: string; reason: string }) => void;
39
45
  }
@@ -99,9 +105,10 @@ export class PlaylistInputStream {
99
105
  let files = o.current?.() ?? o.files;
100
106
  if (files.length === 0) throw new Error("The playlist is empty");
101
107
  let order = orderFor(files);
102
- let step = Math.min(o.at, order.length);
108
+ let step = Math.min(Math.max(0, o.at), order.length);
103
109
  let played = 0;
104
110
  let last = "";
111
+ let lastBreak = 0;
105
112
  for (;;) {
106
113
  if (signal.aborted) break;
107
114
  // Asked at every boundary, so an edit lands on the next track. The
@@ -128,11 +135,20 @@ export class PlaylistInputStream {
128
135
  continue;
129
136
  }
130
137
  {
131
- const at = order[step]!;
132
- step += 1;
133
- const file = files[at]!;
134
- last = file;
135
- if (o.excluded?.(file)) continue;
138
+ // A break, when one is due: in place of the next entry, which stays
139
+ // next. Not at the very start, and never inside an entry.
140
+ let breakFile: string | null = null;
141
+ if (o.breaks && !first && offset - lastBreak >= o.breaks.everySeconds) {
142
+ lastBreak = offset;
143
+ breakFile = await o.breaks.next().catch(() => null);
144
+ if (signal.aborted) return;
145
+ }
146
+ const isBreak = breakFile !== null;
147
+ const at = isBreak ? -1 : order[step]!;
148
+ if (!isBreak) step += 1;
149
+ const file = isBreak ? breakFile! : files[at]!;
150
+ if (!isBreak) last = file;
151
+ if (!isBreak && o.excluded?.(file)) continue;
136
152
  const position = first && step - 1 === o.at ? o.position : 0;
137
153
  first = false;
138
154
  let reached = 0;
@@ -200,13 +216,20 @@ export class PlaylistInputStream {
200
216
  abort();
201
217
  await closed;
202
218
  }
203
- failuresInARow = 0;
204
- played += 1;
219
+ if (!isBreak) {
220
+ failuresInARow = 0;
221
+ played += 1;
222
+ }
205
223
  // Match the frame grid used above so rounding cannot accumulate a
206
224
  // backwards timestamp over hundreds of short files.
207
225
  const length = skipped ? Math.min(duration, reached) : duration;
208
226
  offset += o.video ? Math.ceil(length * 30) / 30 : length;
227
+ // The next break is counted from the end of this one, so a spot
228
+ // longer than the gap cannot follow itself.
229
+ if (isBreak) lastBreak = offset;
209
230
  } catch (error) {
231
+ // A break that would not play is simply no break.
232
+ if (isBreak && !signal.aborted) continue;
210
233
  if (signal.aborted || !o.passOver) throw error;
211
234
  // Passed over, not dialled again: the next entry is what a
212
235
  // listener should hear, and a file that will not decode now will
package/src/server.ts CHANGED
@@ -78,9 +78,12 @@ import { ListenerStore } from "./library-user.ts";
78
78
  import { Importer } from "./handoff.ts";
79
79
  import { handleHandoffs } from "./handoff-routes.ts";
80
80
  import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
81
+ import { GUIDE_CSP, GUIDE_HTML, guideHosts } from "./station-guide.ts";
81
82
  import { StationRunner } from "./station-runner.ts";
82
83
  import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
83
84
  import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv, type Limits } from "./station-metering.ts";
85
+ import { StationBreaks } from "./station-breaks.ts";
86
+ import { DEFAULT_AUTO, syncAutoStations, type AutoOptions } from "./auto-stations.ts";
84
87
  import { CompressionService } from "./compression/service.ts";
85
88
  import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
86
89
  import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
@@ -1717,7 +1720,13 @@ export interface HandlerOptions {
1717
1720
  /** Music handed over from BitTorrented. */
1718
1721
  importer?: Importer;
1719
1722
  /** This server's stations and what keeps them on the air. */
1720
- stations?: { store: StationStore; runner: StationRunner; usage?: StationUsage; limits?: Limits };
1723
+ stations?: {
1724
+ store: StationStore;
1725
+ runner: StationRunner;
1726
+ usage?: StationUsage;
1727
+ limits?: Limits;
1728
+ auto?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
1729
+ };
1721
1730
  web: string | null;
1722
1731
  /** Branded clients on this same backend, keyed by an explicitly configured host. */
1723
1732
  webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
@@ -2640,6 +2649,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2640
2649
  // port is open from another device before wondering whether the link is
2641
2650
  // wrong, and it says nothing about the library.
2642
2651
  const eventChannelRequest = Boolean(channelId && await eventForChannel());
2652
+ // The public station guide: at /guide, and at the front of a host named
2653
+ // for it (a Moshpit name such as live.streaming, NIXAMP_GUIDE_HOSTS).
2654
+ const requestHost = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "").split(",")[0]!.trim().replace(/:\d+$/, "").toLowerCase();
2655
+ const isGuidePage = Boolean(options.stations) && (request.method === "GET" || request.method === "HEAD") &&
2656
+ (path === "/guide" || (path === "/" && guideHosts().has(requestHost)));
2643
2657
  // Whether this request holds only the listen key. A listener may browse
2644
2658
  // and hear the library, but where it lives on this machine is the
2645
2659
  // owner's business: the absolute path names the user account and the disk.
@@ -2652,6 +2666,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2652
2666
  // A station's link is its own key, checked by the station.
2653
2667
  !isStationListenPath(path) &&
2654
2668
  !STATION_PAGE.test(path) &&
2669
+ !isGuidePage &&
2655
2670
  !eventChannelRequest
2656
2671
  ) {
2657
2672
  let scope = scopeOf(keyFrom(request, url), key, listenKey);
@@ -2704,6 +2719,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2704
2719
  return { allowed: false, account: "" };
2705
2720
  };
2706
2721
 
2722
+ if (isGuidePage) {
2723
+ response.writeHead(200, {
2724
+ "content-type": "text/html; charset=utf-8",
2725
+ "cache-control": "no-cache",
2726
+ "content-security-policy": GUIDE_CSP,
2727
+ "referrer-policy": "no-referrer",
2728
+ "x-content-type-options": "nosniff",
2729
+ });
2730
+ response.end(request.method === "HEAD" ? undefined : GUIDE_HTML);
2731
+ return;
2732
+ }
2733
+
2707
2734
  // The page a station's link opens. The same bytes for every station: it
2708
2735
  // reads its id and token from its own address.
2709
2736
  if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
@@ -2788,6 +2815,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2788
2815
  headers: CORS,
2789
2816
  ...(options.stations.usage ? { usage: options.stations.usage } : {}),
2790
2817
  ...(options.stations.limits ? { limits: options.stations.limits } : {}),
2818
+ ...(options.stations.auto ? { auto: options.stations.auto } : {}),
2791
2819
  throttle: () => {
2792
2820
  const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
2793
2821
  return verdict.ok ? null : { retryAfter: verdict.retryAfter };
@@ -6663,7 +6691,15 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6663
6691
  const stationUsage = new StationUsage(StationUsage.fileFor(stateDir(), options.port));
6664
6692
  const stationLimits = limitsFromEnv();
6665
6693
  process.once("exit", () => stationUsage.flush());
6694
+ // Breaks between songs and films: the network's spot, or a house one.
6695
+ const stationBreaks = new StationBreaks({
6696
+ dir: join(stateDir(), "ads"),
6697
+ site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
6698
+ hasSlot: Boolean(process.env["AD_SLOT"]),
6699
+ guard: (url) => refusedDestination(url),
6700
+ });
6666
6701
  stationRunner = new StationRunner({
6702
+ breaks: (video) => stationBreaks.next(video ? "video" : "audio"),
6667
6703
  onRestart: (id) => stationUsage.restarted(id),
6668
6704
  onFailure: (id) => stationUsage.failed(id),
6669
6705
  store: stationStore,
@@ -6679,7 +6715,69 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6679
6715
  roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
6680
6716
  log: (line) => console.log(line),
6681
6717
  });
6682
- const stations = { store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits };
6718
+ // Auto stations: a station per genre, kept in step with the library.
6719
+ const autoSetting = (): AutoOptions | null => {
6720
+ const saved = readConfig().autoStations;
6721
+ if (!saved) return null;
6722
+ const pick = (name: keyof AutoOptions) => (saved[name] === undefined ? DEFAULT_AUTO[name] : saved[name]);
6723
+ return {
6724
+ video: pick("video") !== false,
6725
+ minItems: Number(pick("minItems")) || DEFAULT_AUTO.minItems,
6726
+ minFilms: Number(pick("minFilms")) || DEFAULT_AUTO.minFilms,
6727
+ start: pick("start") !== false,
6728
+ maxVideoRunning: Number(pick("maxVideoRunning")) >= 0 ? Number(pick("maxVideoRunning")) : DEFAULT_AUTO.maxVideoRunning,
6729
+ breaksEvery: Number(pick("breaksEvery")) >= 0 ? Number(pick("breaksEvery")) : DEFAULT_AUTO.breaksEvery,
6730
+ };
6731
+ };
6732
+ let autoRunning: Promise<unknown> | null = null;
6733
+ let autoLast: { at: number; report: unknown } | null = null;
6734
+ const runAuto = (settings: AutoOptions): Promise<unknown> => {
6735
+ if (autoRunning) return autoRunning;
6736
+ autoRunning = syncAutoStations({
6737
+ index: libraryIndex,
6738
+ store: stationStore,
6739
+ runner: stationRunner!,
6740
+ owner: options.owner || "",
6741
+ mayStart: () => stationLimits.runningStations === 0 || stationStore.list().filter((one) => one.desired === "running").length < stationLimits.runningStations,
6742
+ // Only what is already known: a sync over a whole library must not ask
6743
+ // nichedb a question per file (it queued minutes of queries there).
6744
+ filmGenres: async (title) => {
6745
+ const hit = enricher.known(title);
6746
+ const genres = hit?.data?.["genres"];
6747
+ return Array.isArray(genres) ? genres.filter((one): one is string => typeof one === "string") : [];
6748
+ },
6749
+ log: (line) => console.log(line),
6750
+ }, settings).then((report) => {
6751
+ const made = report as { radio: unknown[]; tv: unknown[] };
6752
+ console.log(` auto stations: ${made.radio.length} radio, ${made.tv.length} TV.`);
6753
+ autoLast = { at: Date.now(), report };
6754
+ return report;
6755
+ }).finally(() => { autoRunning = null; });
6756
+ return autoRunning;
6757
+ };
6758
+ libraryIndex.onScanned = () => {
6759
+ const settings = autoSetting();
6760
+ if (settings) void runAuto(settings);
6761
+ };
6762
+ const stationAuto = {
6763
+ setting: () => ({ ...(autoSetting() ?? { off: true }), running: autoRunning !== null, last: autoLast }),
6764
+ sync: async (input: Record<string, unknown>) => {
6765
+ if (input["off"] === true) {
6766
+ writeConfig({ autoStations: undefined });
6767
+ return { off: true };
6768
+ }
6769
+ const settings: AutoOptions = { ...DEFAULT_AUTO, ...(autoSetting() ?? {}) };
6770
+ for (const name of Object.keys(DEFAULT_AUTO) as (keyof AutoOptions)[]) {
6771
+ if (input[name] !== undefined) (settings as unknown as Record<string, unknown>)[name] = input[name];
6772
+ }
6773
+ writeConfig({ autoStations: settings as unknown as Record<string, unknown> });
6774
+ // In the background: a big library takes a while, and an HTTP request
6775
+ // that waits for it times out before it is done.
6776
+ void runAuto(settings);
6777
+ return { started: true, settings };
6778
+ },
6779
+ };
6780
+ const stations = { store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits, auto: stationAuto };
6683
6781
  // Music handed over from BitTorrented, copied into a folder of our own.
6684
6782
  const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
6685
6783
  index: libraryIndex,
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The break: a short spot between songs or films, instead of commercials.
3
+ *
4
+ * Asked for once per break, never per listener, and never in the middle of
5
+ * anything (playlist-input.ts places it between entries). Where it comes
6
+ * from, in order:
7
+ *
8
+ * 1. the ad network, through this server's own AD_SLOT (ads.ts), or
9
+ * through nixamp.com's /api/ads/next when this server has none, so a
10
+ * self-hosted station carries the same spot the site does and the
11
+ * network meters the impression;
12
+ * 2. a house spot the operator dropped in $XDG_STATE_HOME/nixamp/ads/
13
+ * (spot.mp4 for TV, spot.mp3 for radio);
14
+ * 3. nothing: no break this time, and the music goes on.
15
+ *
16
+ * A creative is fetched once per URL into ads/cache and played from disk:
17
+ * https only, public addresses only, at most 50 MB.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
21
+ import { extname, join } from "node:path";
22
+ import { Readable } from "node:stream";
23
+ import { pipeline } from "node:stream/promises";
24
+ import { nextAdvert, type Advert } from "./ads.ts";
25
+
26
+ const MAX_BYTES = 50 * 1024 * 1024;
27
+
28
+ export interface BreakOptions {
29
+ dir: string;
30
+ /** nixamp.com, when this server has no slot of its own. */
31
+ site: string;
32
+ hasSlot: boolean;
33
+ guard: (url: string) => Promise<string>;
34
+ fetcher?: typeof fetch;
35
+ /** Injected by tests in place of the network. */
36
+ ask?: (kind: "audio" | "video") => Promise<Advert>;
37
+ }
38
+
39
+ export class StationBreaks {
40
+ constructor(private readonly options: BreakOptions) {}
41
+
42
+ private async ask(kind: "audio" | "video"): Promise<Advert> {
43
+ if (this.options.ask) return this.options.ask(kind);
44
+ if (this.options.hasSlot) return nextAdvert(kind);
45
+ const send = this.options.fetcher ?? fetch;
46
+ try {
47
+ const answer = await send(`${this.options.site.replace(/\/+$/, "")}/api/ads/next?kind=${kind}`, { signal: AbortSignal.timeout(2_000) });
48
+ if (!answer.ok) return { url: null };
49
+ const body = (await answer.json()) as { url?: unknown; kind?: unknown };
50
+ return typeof body.url === "string" ? { url: body.url, kind: body.kind === "video" ? "video" : "audio" } : { url: null };
51
+ } catch {
52
+ return { url: null };
53
+ }
54
+ }
55
+
56
+ /** A file to play for this break, or null for none. */
57
+ async next(kind: "audio" | "video"): Promise<string | null> {
58
+ const advert = await this.ask(kind);
59
+ if (advert.url) {
60
+ const cached = await this.cache(advert.url).catch(() => null);
61
+ if (cached) return cached;
62
+ }
63
+ const house = join(this.options.dir, kind === "video" ? "spot.mp4" : "spot.mp3");
64
+ return existsSync(house) ? house : null;
65
+ }
66
+
67
+ private async cache(url: string): Promise<string | null> {
68
+ const parsed = new URL(url);
69
+ if (parsed.protocol !== "https:") return null;
70
+ const folder = join(this.options.dir, "cache");
71
+ const file = join(folder, `${createHash("sha1").update(url).digest("hex")}${extname(parsed.pathname).slice(0, 6) || ".mp4"}`);
72
+ if (existsSync(file)) return file;
73
+ if (await this.options.guard(url)) return null;
74
+ const answer = await (this.options.fetcher ?? fetch)(url, { signal: AbortSignal.timeout(20_000) });
75
+ if (!answer.ok || !answer.body) return null;
76
+ if (Number(answer.headers.get("content-length") ?? 0) > MAX_BYTES) return null;
77
+ mkdirSync(folder, { recursive: true });
78
+ const part = `${file}.part`;
79
+ let bytes = 0;
80
+ const counted = Readable.fromWeb(answer.body as import("node:stream/web").ReadableStream).on("data", (chunk: Buffer) => {
81
+ bytes += chunk.length;
82
+ if (bytes > MAX_BYTES) counted.destroy(new Error("too big"));
83
+ });
84
+ try {
85
+ await pipeline(counted, createWriteStream(part));
86
+ renameSync(part, file);
87
+ return file;
88
+ } catch {
89
+ rmSync(part, { force: true });
90
+ return null;
91
+ }
92
+ }
93
+ }