nixamp 0.35.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 (67) 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 +4 -0
  11. package/dist/server.js +89 -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-page.d.ts +1 -1
  20. package/dist/station-page.js +20 -5
  21. package/dist/station-routes.d.ts +5 -0
  22. package/dist/station-routes.js +30 -2
  23. package/dist/station-runner.d.ts +2 -0
  24. package/dist/station-runner.js +3 -0
  25. package/dist/station-usage.d.ts +1 -1
  26. package/dist/station-usage.js +2 -0
  27. package/dist/stations.d.ts +14 -0
  28. package/dist/stations.js +26 -0
  29. package/package.json +1 -1
  30. package/src/auto-stations.ts +171 -0
  31. package/src/channels.ts +1 -1
  32. package/src/library-index.ts +5 -0
  33. package/src/library.ts +3 -0
  34. package/src/playlist-input.ts +31 -8
  35. package/src/server.ts +93 -2
  36. package/src/station-breaks.ts +93 -0
  37. package/src/station-cli.ts +21 -0
  38. package/src/station-guide.ts +92 -0
  39. package/src/station-mcp.ts +16 -0
  40. package/src/station-page.ts +20 -5
  41. package/src/station-routes.ts +34 -2
  42. package/src/station-runner.ts +5 -0
  43. package/src/station-usage.ts +2 -0
  44. package/src/stations.ts +37 -0
  45. package/web/dist/assets/{ar-Doh06ZMu.js → ar-Ck-FmDpQ.js} +1 -1
  46. package/web/dist/assets/{cs-C_lZl4jI.js → cs-CPJY3K69.js} +1 -1
  47. package/web/dist/assets/{da-CSBBA_ZI.js → da-BnWtLL09.js} +1 -1
  48. package/web/dist/assets/{de-zorW0tO_.js → de-CHevIt93.js} +1 -1
  49. package/web/dist/assets/{es-DBUl3DGo.js → es-B6wpaUMv.js} +1 -1
  50. package/web/dist/assets/{fi-DpUx7XY-.js → fi-8fKiz2AA.js} +1 -1
  51. package/web/dist/assets/{fr-Cg_xppCf.js → fr-0GgY52Sm.js} +1 -1
  52. package/web/dist/assets/{hi-PD1kdcf2.js → hi-BlsrUAw-.js} +1 -1
  53. package/web/dist/assets/{hls-3VKVEQE3-BEEk729h.js → hls-3VKVEQE3-BZd3zjlr.js} +1 -1
  54. package/web/dist/assets/{hu-C1fGbjZO.js → hu-D3i_6i5w.js} +1 -1
  55. package/web/dist/assets/{id-DRBhZ8KM.js → id-CLLAXbOP.js} +1 -1
  56. package/web/dist/assets/{it-Ci3OZV2-.js → it-BU13BsSn.js} +1 -1
  57. package/web/dist/assets/{main-CEJhJH5y.js → main-C1ggd2H7.js} +2 -2
  58. package/web/dist/assets/{mpegts-y-HAxt1b.js → mpegts-BaoQnMCf.js} +1 -1
  59. package/web/dist/assets/{mpegts-LO6RVLD6-DPrv1ran.js → mpegts-LO6RVLD6-YBT-W6xc.js} +1 -1
  60. package/web/dist/assets/{nl-CwWOHKbv.js → nl-DEshyyt7.js} +1 -1
  61. package/web/dist/assets/{ru-BWlIGsoL.js → ru-D7sPOc6m.js} +1 -1
  62. package/web/dist/assets/{sv-Gb1cLNsc.js → sv-sdrsKAiG.js} +1 -1
  63. package/web/dist/assets/{uk-MZUnyyuR.js → uk-_lz6hkvo.js} +1 -1
  64. package/web/dist/assets/{vi-DNWS5fxc.js → vi-D81wl7uk.js} +1 -1
  65. package/web/dist/assets/{zh-BcOiladF.js → zh-BqMqEom6.js} +1 -1
  66. package/web/dist/index.html +1 -1
  67. package/web/dist/sw.js +22 -22
@@ -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 {};
@@ -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,62 @@ 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
+ const runAuto = (settings: AutoOptions): Promise<unknown> => {
6734
+ if (autoRunning) return autoRunning;
6735
+ autoRunning = syncAutoStations({
6736
+ index: libraryIndex,
6737
+ store: stationStore,
6738
+ runner: stationRunner!,
6739
+ owner: options.owner || "",
6740
+ mayStart: () => stationLimits.runningStations === 0 || stationStore.list().filter((one) => one.desired === "running").length < stationLimits.runningStations,
6741
+ filmGenres: async (title) => {
6742
+ const hit = await enricher.lookup(title, "title");
6743
+ const genres = hit?.data?.["genres"];
6744
+ return Array.isArray(genres) ? genres.filter((one): one is string => typeof one === "string") : [];
6745
+ },
6746
+ log: (line) => console.log(line),
6747
+ }, settings).then((report) => {
6748
+ const made = report as { radio: unknown[]; tv: unknown[] };
6749
+ console.log(` auto stations: ${made.radio.length} radio, ${made.tv.length} TV.`);
6750
+ return report;
6751
+ }).finally(() => { autoRunning = null; });
6752
+ return autoRunning;
6753
+ };
6754
+ libraryIndex.onScanned = () => {
6755
+ const settings = autoSetting();
6756
+ if (settings) void runAuto(settings);
6757
+ };
6758
+ const stationAuto = {
6759
+ setting: () => autoSetting(),
6760
+ sync: async (input: Record<string, unknown>) => {
6761
+ if (input["off"] === true) {
6762
+ writeConfig({ autoStations: undefined });
6763
+ return { off: true };
6764
+ }
6765
+ const settings: AutoOptions = { ...DEFAULT_AUTO, ...(autoSetting() ?? {}) };
6766
+ for (const name of Object.keys(DEFAULT_AUTO) as (keyof AutoOptions)[]) {
6767
+ if (input[name] !== undefined) (settings as unknown as Record<string, unknown>)[name] = input[name];
6768
+ }
6769
+ writeConfig({ autoStations: settings as unknown as Record<string, unknown> });
6770
+ return runAuto(settings);
6771
+ },
6772
+ };
6773
+ const stations = { store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits, auto: stationAuto };
6683
6774
  // Music handed over from BitTorrented, copied into a folder of our own.
6684
6775
  const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
6685
6776
  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
+ }
@@ -177,6 +177,27 @@ export async function station(argv: string[]): Promise<number> {
177
177
  return [`page ${links["page"]}`, `stream ${links["stream"]}`, `m3u ${links["m3u"]}`].join("\n");
178
178
  });
179
179
  }
180
+ case "auto": {
181
+ const body: Record<string, unknown> = argv.includes("--off") ? { off: true } : {
182
+ ...(argv.includes("--no-video") ? { video: false } : {}),
183
+ ...(argv.includes("--no-start") ? { start: false } : {}),
184
+ ...(flag(argv, "--min") ? { minItems: Number(flag(argv, "--min")) } : {}),
185
+ ...(flag(argv, "--films") ? { minFilms: Number(flag(argv, "--films")) } : {}),
186
+ ...(flag(argv, "--tv") ? { maxVideoRunning: Number(flag(argv, "--tv")) } : {}),
187
+ ...(flag(argv, "--breaks") ? { breaksEvery: Number(flag(argv, "--breaks")) } : {}),
188
+ };
189
+ return show(await call("POST", "/api/v1/stations/auto", body), (answer) => {
190
+ const report = answer["report"] as { off?: boolean; radio?: Array<Record<string, unknown>>; tv?: Array<Record<string, unknown>>; skipped?: string[] };
191
+ if (report.off) return "Auto stations are off. The stations already made stay as they are.";
192
+ const line = (one: Record<string, unknown>, unit: string) => ` ${String(one["genre"]).padEnd(16)} ${String(one["files"] ?? one["films"]).padStart(5)} ${unit} ${one["running"] ? "on air" : "stopped"}${one["made"] ? " (new)" : ""} [${String(one["id"])}]`;
193
+ return [
194
+ "Radio:", ...(report.radio ?? []).map((one) => line(one, "files")),
195
+ "TV:", ...((report.tv ?? []).length ? (report.tv ?? []).map((one) => line(one, "films")) : [" none (no film genres known yet)"]),
196
+ ...((report.skipped ?? []).length ? [`Too small for a station: ${(report.skipped ?? []).join(", ")}`] : []),
197
+ "Every library scan keeps these up to date. They are unlisted: `nixamp station link ID` for a link.",
198
+ ].join("\n");
199
+ });
200
+ }
180
201
  case "delete": {
181
202
  const station = needId();
182
203
  if (!station) return 2;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The station guide: every station on this server its owner made public
3
+ * and listed, what is on each, and a way in. What a Moshpit name like
4
+ * live.streaming opens.
5
+ *
6
+ * Like the station page, the same bytes for every server: it asks
7
+ * /api/v1/stations/guide for the list and writes it in as text.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+
11
+ const STYLE = `
12
+ :root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}
13
+ @media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}
14
+ *{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
15
+ main{max-width:880px;margin:0 auto;padding:40px 16px}
16
+ h1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}
17
+ h2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}
18
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
19
+ a.station{display:block;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;color:inherit;text-decoration:none}
20
+ a.station:hover,a.station:focus-visible{border-color:var(--accent);outline:none}
21
+ .name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}
22
+ .live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}
23
+ footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
24
+ `;
25
+
26
+ const SCRIPT = `
27
+ (async () => {
28
+ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
29
+ const draw = (stations) => {
30
+ for (const [kind, box] of [["audio", "#radio"], ["video", "#tv"]]) {
31
+ const grid = document.querySelector(box);
32
+ grid.replaceChildren();
33
+ const list = stations.filter((s) => s.kind === kind);
34
+ document.querySelector(box + "-title").hidden = list.length === 0;
35
+ for (const s of list) {
36
+ const a = el("a", "station");
37
+ a.href = s.page;
38
+ a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
39
+ el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
40
+ grid.append(a);
41
+ }
42
+ }
43
+ document.querySelector("#empty").hidden = stations.length > 0;
44
+ };
45
+ const load = async () => {
46
+ try {
47
+ const r = await fetch("/api/v1/stations/guide", { cache: "no-store" });
48
+ if (r.ok) draw((await r.json()).stations);
49
+ } catch {}
50
+ };
51
+ await load();
52
+ setInterval(load, 15000);
53
+ })();
54
+ `;
55
+
56
+ const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
57
+
58
+ export const GUIDE_HTML = `<!doctype html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8">
62
+ <meta name="viewport" content="width=device-width, initial-scale=1">
63
+ <title>Live · stations</title>
64
+ <meta name="description" content="Radio and TV stations, live now.">
65
+ <link rel="icon" href="data:,">
66
+ <style>${STYLE}</style>
67
+ </head>
68
+ <body>
69
+ <main>
70
+ <h1>Live</h1>
71
+ <p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
72
+ <h2 id="radio-title" hidden>Radio</h2>
73
+ <div id="radio" class="grid" aria-live="polite"></div>
74
+ <h2 id="tv-title" hidden>TV</h2>
75
+ <div id="tv" class="grid" aria-live="polite"></div>
76
+ <p id="empty" class="empty">Nothing is on the air here yet.</p>
77
+ <footer>Played by <a href="https://nixamp.com">nixamp</a>, on the Moshpit.</footer>
78
+ </main>
79
+ <script>${SCRIPT}</script>
80
+ </body>
81
+ </html>
82
+ `;
83
+
84
+ export const GUIDE_CSP = [
85
+ "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
86
+ "img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
87
+ ].join("; ");
88
+
89
+ /** The hosts whose front page is the guide (NIXAMP_GUIDE_HOSTS, comma-separated). */
90
+ export function guideHosts(env: Record<string, string | undefined> = process.env): Set<string> {
91
+ return new Set((env["NIXAMP_GUIDE_HOSTS"] ?? "").split(",").map((one) => one.trim().toLowerCase()).filter(Boolean));
92
+ }