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
@@ -0,0 +1,66 @@
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 } from "./library-index.ts";
19
+ import type { StationRunner } from "./station-runner.ts";
20
+ import type { StationStore } from "./stations.ts";
21
+ /** The genres a tag belongs to, recognisably named; [] when it is not music or not known. */
22
+ export declare function genreBuckets(raw: string): string[];
23
+ export interface AutoOptions {
24
+ /** Radio from tags; TV from nichedb's genres. */
25
+ video: boolean;
26
+ /** A genre needs at least this many files (radio) to get a station. */
27
+ minItems: number;
28
+ /** Films for a TV station. */
29
+ minFilms: number;
30
+ start: boolean;
31
+ /** TV stations running at once. */
32
+ maxVideoRunning: number;
33
+ /** Seconds between breaks on the stations this makes. */
34
+ breaksEvery: number;
35
+ }
36
+ export declare const DEFAULT_AUTO: AutoOptions;
37
+ export interface AutoReport {
38
+ radio: Array<{
39
+ genre: string;
40
+ id: string;
41
+ files: number;
42
+ made: boolean;
43
+ running: boolean;
44
+ }>;
45
+ tv: Array<{
46
+ genre: string;
47
+ id: string;
48
+ films: number;
49
+ made: boolean;
50
+ running: boolean;
51
+ }>;
52
+ skipped: string[];
53
+ }
54
+ export interface AutoDeps {
55
+ index: LibraryIndex;
56
+ store: StationStore;
57
+ runner: StationRunner;
58
+ owner: string;
59
+ /** nichedb's genres for a film, from the enrichment cache or a lookup; [] when unknown. */
60
+ filmGenres?: (title: string) => Promise<string[]>;
61
+ /** Whether another station may be started now (the server's running limit). */
62
+ mayStart: () => boolean;
63
+ log?: (line: string) => void;
64
+ }
65
+ /** Make or update a station per genre; start what the limits allow. */
66
+ export declare function syncAutoStations(deps: AutoDeps, options?: AutoOptions): Promise<AutoReport>;
@@ -0,0 +1,108 @@
1
+ import { shown } from "./library-index.js";
2
+ const RULES = [
3
+ [/metal|thrash|trash|doom|death|grind|neue deutsche|h(a|ä)rte|djent|metalcore/i, "Metal"],
4
+ [/punk|hardcore|emo|screamo/i, "Punk"],
5
+ [/hip.?hop|\brap\b|gangsta|trap|grime|drill/i, "Hip-Hop"],
6
+ [/industrial/i, "Industrial"],
7
+ [/electro|techno|house|edm|dance|trance|ambient|drum.?n.?bass|dubstep|synth/i, "Electronic"],
8
+ [/reggae|\bska\b|\bdub\b|dancehall/i, "Reggae"],
9
+ [/jazz|swing|bebop/i, "Jazz"],
10
+ [/blues/i, "Blues"],
11
+ [/classical|symphon|orchestr|baroque|opera/i, "Classical"],
12
+ [/country|folk|bluegrass|americana/i, "Country & Folk"],
13
+ [/soul|r&b|rnb|funk|motown|gospel/i, "Soul & R&B"],
14
+ [/latin|reggaeton|salsa|bachata|cumbia/i, "Latin"],
15
+ [/alternat|indie|ind(e|é)|grunge|shoegaze/i, "Alternative"],
16
+ [/rock/i, "Rock"],
17
+ [/pop/i, "Pop"],
18
+ ];
19
+ const NOT_MUSIC = /lecture|interview|audiobook|podcast|speech|spoken|talk|course|sermon|lesson|comedy/i;
20
+ /** The genres a tag belongs to, recognisably named; [] when it is not music or not known. */
21
+ export function genreBuckets(raw) {
22
+ if (!raw || NOT_MUSIC.test(raw))
23
+ return [];
24
+ const out = new Set();
25
+ for (const part of raw.split(/[,;/|]+|\s+&\s+/)) {
26
+ const hit = RULES.find(([pattern]) => pattern.test(part));
27
+ if (hit)
28
+ out.add(hit[1]);
29
+ }
30
+ return [...out];
31
+ }
32
+ export const DEFAULT_AUTO = { video: true, minItems: 10, minFilms: 3, start: true, maxVideoRunning: 2, breaksEvery: 600 };
33
+ function order(items) {
34
+ return [...items].sort((a, b) => {
35
+ const x = shown(a), y = shown(b);
36
+ 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);
37
+ });
38
+ }
39
+ async function upsert(deps, key, name, paths, video, options) {
40
+ const existing = deps.store.byAuto(key);
41
+ if (existing) {
42
+ const same = existing.items.length === paths.length && existing.items.every((one, at) => one === paths[at]);
43
+ return { station: same ? existing : deps.store.update(existing.id, {}, paths), made: false };
44
+ }
45
+ const station = deps.store.create({ name, shuffle: true, repeat: true, video, breaksEvery: options.breaksEvery }, paths, deps.owner);
46
+ deps.store.markAuto(station.id, key);
47
+ // A link, not a listing: unlisted needs no rights basis and publishes nothing.
48
+ deps.store.setAccess(station.id, { visibility: "unlisted" }, deps.owner || "auto");
49
+ return { station, made: true };
50
+ }
51
+ /** Make or update a station per genre; start what the limits allow. */
52
+ export async function syncAutoStations(deps, options = DEFAULT_AUTO) {
53
+ const report = { radio: [], tv: [], skipped: [] };
54
+ const ready = deps.index.search({ state: "ready", limit: 500, offset: 0 });
55
+ // search() pages at 500; walk them all.
56
+ const all = [...ready.items];
57
+ for (let offset = 500; offset < ready.total; offset += 500)
58
+ all.push(...deps.index.search({ state: "ready", limit: 500, offset }).items);
59
+ const radio = new Map();
60
+ for (const item of all) {
61
+ if (item.video)
62
+ continue;
63
+ for (const genre of genreBuckets(shown(item).genre)) {
64
+ const list = radio.get(genre) ?? [];
65
+ list.push(item);
66
+ radio.set(genre, list);
67
+ }
68
+ }
69
+ for (const [genre, items] of [...radio].sort((a, b) => b[1].length - a[1].length)) {
70
+ if (items.length < options.minItems) {
71
+ report.skipped.push(`${genre} radio (${items.length} files)`);
72
+ continue;
73
+ }
74
+ const { station, made } = await upsert(deps, `genre:audio:${genre.toLowerCase()}`, `${genre} Radio`, order(items).map((one) => one.path), false, options);
75
+ let running = station.desired === "running";
76
+ if (!running && options.start && deps.mayStart()) {
77
+ await deps.runner.start(station.id).then(() => { running = true; }, (error) => deps.log?.(` auto stations: ${genre} Radio did not start: ${error.message}`));
78
+ }
79
+ report.radio.push({ genre, id: station.id, files: items.length, made, running });
80
+ }
81
+ if (options.video && deps.filmGenres) {
82
+ const tv = new Map();
83
+ for (const item of all) {
84
+ if (!item.video || item.duration < 40 * 60)
85
+ continue;
86
+ const genres = await deps.filmGenres(shown(item).title).catch(() => []);
87
+ for (const genre of genres.slice(0, 2)) {
88
+ const list = tv.get(genre) ?? [];
89
+ list.push(item);
90
+ tv.set(genre, list);
91
+ }
92
+ }
93
+ let runningTv = deps.store.list().filter((one) => one.video && one.auto && one.desired === "running").length;
94
+ for (const [genre, films] of [...tv].sort((a, b) => b[1].length - a[1].length)) {
95
+ if (films.length < options.minFilms) {
96
+ report.skipped.push(`${genre} TV (${films.length} films)`);
97
+ continue;
98
+ }
99
+ const { station, made } = await upsert(deps, `genre:video:${genre.toLowerCase()}`, `${genre} TV`, films.map((one) => one.path), true, options);
100
+ let running = station.desired === "running";
101
+ if (!running && options.start && runningTv < options.maxVideoRunning && deps.mayStart()) {
102
+ await deps.runner.start(station.id).then(() => { running = true; runningTv += 1; }, (error) => deps.log?.(` auto stations: ${genre} TV did not start: ${error.message}`));
103
+ }
104
+ report.tv.push({ genre, id: station.id, films: films.length, made, running });
105
+ }
106
+ }
107
+ return report;
108
+ }
@@ -112,7 +112,7 @@ export interface PullResume {
112
112
  * lists of files on this machine; not remembered, because whoever owns the
113
113
  * rotation (the station) puts it back.
114
114
  */
115
- rotation?: Pick<PlaylistInput, "repeat" | "order" | "current" | "excluded" | "onFailure" | "passOver">;
115
+ rotation?: Pick<PlaylistInput, "repeat" | "order" | "current" | "excluded" | "onFailure" | "passOver" | "breaks">;
116
116
  }
117
117
  /**
118
118
  * A source read by us rather than by ffmpeg: what to tell ffmpeg it is,
@@ -127,6 +127,8 @@ export declare class LibraryIndex {
127
127
  private timer;
128
128
  private dirty;
129
129
  private jobs;
130
+ /** Told when any scan finishes, for whatever keeps up with the library (auto stations). */
131
+ onScanned: ((report: ScanReport) => void) | null;
130
132
  constructor(file: string, io?: {
131
133
  read: (path: string) => string | null;
132
134
  write: (path: string, body: string) => void;
@@ -243,6 +243,8 @@ export class LibraryIndex {
243
243
  timer = null;
244
244
  dirty = false;
245
245
  jobs = new Map();
246
+ /** Told when any scan finishes, for whatever keeps up with the library (auto stations). */
247
+ onScanned = null;
246
248
  constructor(file, io = {
247
249
  read: (path) => { try {
248
250
  return readFileSync(path, "utf8");
@@ -531,6 +533,12 @@ export class LibraryIndex {
531
533
  report.status = signal.aborted ? "cancelled" : "done";
532
534
  report.finishedAt = this.now();
533
535
  this.touch();
536
+ if (report.status === "done") {
537
+ try {
538
+ this.onScanned?.(report);
539
+ }
540
+ catch { /* a listener's trouble is not the scan's */ }
541
+ }
534
542
  return report;
535
543
  }
536
544
  /** The owner's corrections; an empty value clears that field's correction. */
package/dist/library.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface Config {
3
3
  library?: string;
4
4
  /** Whether this machine is listed in the public directory; unset until chosen. */
5
5
  publish?: boolean;
6
+ /** Auto stations: a station per genre, re-synced after every library scan. */
7
+ autoStations?: Record<string, unknown>;
6
8
  }
7
9
  export declare function configPath(): string;
8
10
  export declare function readConfig(): Config;
package/dist/library.js CHANGED
@@ -31,6 +31,7 @@ export function readConfig() {
31
31
  return {
32
32
  ...(typeof record["library"] === "string" ? { library: record["library"] } : {}),
33
33
  ...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
34
+ ...(typeof record["autoStations"] === "object" && record["autoStations"] !== null ? { autoStations: record["autoStations"] } : {}),
34
35
  };
35
36
  }
36
37
  catch {
@@ -29,6 +29,15 @@ export interface PlaylistInput {
29
29
  * still arriving, and the channel redials and waits for it.
30
30
  */
31
31
  passOver?: boolean;
32
+ /**
33
+ * A break every so often, between entries and never in the middle of one:
34
+ * `next` answers a file to play (an advert, an ident) or null for no break
35
+ * this time. The break is outside the rotation and the station's history.
36
+ */
37
+ breaks?: {
38
+ everySeconds: number;
39
+ next: () => Promise<string | null>;
40
+ };
32
41
  /** Told when an entry is passed over because it would not play. */
33
42
  onFailure?: (event: {
34
43
  at: number;
@@ -60,9 +60,10 @@ export class PlaylistInputStream {
60
60
  if (files.length === 0)
61
61
  throw new Error("The playlist is empty");
62
62
  let order = orderFor(files);
63
- let step = Math.min(o.at, order.length);
63
+ let step = Math.min(Math.max(0, o.at), order.length);
64
64
  let played = 0;
65
65
  let last = "";
66
+ let lastBreak = 0;
66
67
  for (;;) {
67
68
  if (signal.aborted)
68
69
  break;
@@ -93,11 +94,23 @@ export class PlaylistInputStream {
93
94
  continue;
94
95
  }
95
96
  {
96
- const at = order[step];
97
- step += 1;
98
- const file = files[at];
99
- last = file;
100
- if (o.excluded?.(file))
97
+ // A break, when one is due: in place of the next entry, which stays
98
+ // next. Not at the very start, and never inside an entry.
99
+ let breakFile = null;
100
+ if (o.breaks && !first && offset - lastBreak >= o.breaks.everySeconds) {
101
+ lastBreak = offset;
102
+ breakFile = await o.breaks.next().catch(() => null);
103
+ if (signal.aborted)
104
+ return;
105
+ }
106
+ const isBreak = breakFile !== null;
107
+ const at = isBreak ? -1 : order[step];
108
+ if (!isBreak)
109
+ step += 1;
110
+ const file = isBreak ? breakFile : files[at];
111
+ if (!isBreak)
112
+ last = file;
113
+ if (!isBreak && o.excluded?.(file))
101
114
  continue;
102
115
  const position = first && step - 1 === o.at ? o.position : 0;
103
116
  first = false;
@@ -174,14 +187,23 @@ export class PlaylistInputStream {
174
187
  abort();
175
188
  await closed;
176
189
  }
177
- failuresInARow = 0;
178
- played += 1;
190
+ if (!isBreak) {
191
+ failuresInARow = 0;
192
+ played += 1;
193
+ }
179
194
  // Match the frame grid used above so rounding cannot accumulate a
180
195
  // backwards timestamp over hundreds of short files.
181
196
  const length = skipped ? Math.min(duration, reached) : duration;
182
197
  offset += o.video ? Math.ceil(length * 30) / 30 : length;
198
+ // The next break is counted from the end of this one, so a spot
199
+ // longer than the gap cannot follow itself.
200
+ if (isBreak)
201
+ lastBreak = offset;
183
202
  }
184
203
  catch (error) {
204
+ // A break that would not play is simply no break.
205
+ if (isBreak && !signal.aborted)
206
+ continue;
185
207
  if (signal.aborted || !o.passOver)
186
208
  throw error;
187
209
  // Passed over, not dialled again: the next entry is what a
package/dist/server.d.ts CHANGED
@@ -26,6 +26,7 @@ import { ListenerStore } from "./library-user.ts";
26
26
  import { Importer } from "./handoff.ts";
27
27
  import { StationRunner } from "./station-runner.ts";
28
28
  import { StationStore } from "./stations.ts";
29
+ import { StationUsage, type Limits } from "./station-metering.ts";
29
30
  import { CompressionService } from "./compression/service.ts";
30
31
  import { Enricher } from "./enrich.ts";
31
32
  import { Follows } from "./follows.ts";
@@ -582,6 +583,12 @@ export interface HandlerOptions {
582
583
  stations?: {
583
584
  store: StationStore;
584
585
  runner: StationRunner;
586
+ usage?: StationUsage;
587
+ limits?: Limits;
588
+ auto?: {
589
+ sync: (input: Record<string, unknown>) => Promise<unknown>;
590
+ setting: () => unknown;
591
+ };
585
592
  };
586
593
  web: string | null;
587
594
  /** Branded clients on this same backend, keyed by an explicitly configured host. */
package/dist/server.js CHANGED
@@ -61,8 +61,12 @@ import { ListenerStore } from "./library-user.js";
61
61
  import { Importer } from "./handoff.js";
62
62
  import { handleHandoffs } from "./handoff-routes.js";
63
63
  import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
64
+ import { GUIDE_CSP, GUIDE_HTML, guideHosts } from "./station-guide.js";
64
65
  import { StationRunner } from "./station-runner.js";
65
66
  import { StationError, StationStore, stationOfChannel } from "./stations.js";
67
+ import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv } from "./station-metering.js";
68
+ import { StationBreaks } from "./station-breaks.js";
69
+ import { DEFAULT_AUTO, syncAutoStations } from "./auto-stations.js";
66
70
  import { CompressionService } from "./compression/service.js";
67
71
  import { handleChannelCompression, handleCompressionApi, handleStaticRelay } from "./compression/routes.js";
68
72
  import { DEFAULT_SITE as NICHEDB, Enricher, FIXTURE_TTL_MS } from "./enrich.js";
@@ -2130,6 +2134,11 @@ export function createHandler(engine, options) {
2130
2134
  // port is open from another device before wondering whether the link is
2131
2135
  // wrong, and it says nothing about the library.
2132
2136
  const eventChannelRequest = Boolean(channelId && await eventForChannel());
2137
+ // The public station guide: at /guide, and at the front of a host named
2138
+ // for it (a Moshpit name such as live.streaming, NIXAMP_GUIDE_HOSTS).
2139
+ const requestHost = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "").split(",")[0].trim().replace(/:\d+$/, "").toLowerCase();
2140
+ const isGuidePage = Boolean(options.stations) && (request.method === "GET" || request.method === "HEAD") &&
2141
+ (path === "/guide" || (path === "/" && guideHosts().has(requestHost)));
2133
2142
  // Whether this request holds only the listen key. A listener may browse
2134
2143
  // and hear the library, but where it lives on this machine is the
2135
2144
  // owner's business: the absolute path names the user account and the disk.
@@ -2141,6 +2150,7 @@ export function createHandler(engine, options) {
2141
2150
  // A station's link is its own key, checked by the station.
2142
2151
  !isStationListenPath(path) &&
2143
2152
  !STATION_PAGE.test(path) &&
2153
+ !isGuidePage &&
2144
2154
  !eventChannelRequest) {
2145
2155
  let scope = scopeOf(keyFrom(request, url), key, listenKey);
2146
2156
  // Kept for the routes below that answer a listener with less.
@@ -2193,6 +2203,17 @@ export function createHandler(engine, options) {
2193
2203
  }
2194
2204
  return { allowed: false, account: "" };
2195
2205
  };
2206
+ if (isGuidePage) {
2207
+ response.writeHead(200, {
2208
+ "content-type": "text/html; charset=utf-8",
2209
+ "cache-control": "no-cache",
2210
+ "content-security-policy": GUIDE_CSP,
2211
+ "referrer-policy": "no-referrer",
2212
+ "x-content-type-options": "nosniff",
2213
+ });
2214
+ response.end(request.method === "HEAD" ? undefined : GUIDE_HTML);
2215
+ return;
2216
+ }
2196
2217
  // The page a station's link opens. The same bytes for every station: it
2197
2218
  // reads its id and token from its own address.
2198
2219
  if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
@@ -2274,6 +2295,13 @@ export function createHandler(engine, options) {
2274
2295
  json,
2275
2296
  readBody,
2276
2297
  headers: CORS,
2298
+ ...(options.stations.usage ? { usage: options.stations.usage } : {}),
2299
+ ...(options.stations.limits ? { limits: options.stations.limits } : {}),
2300
+ ...(options.stations.auto ? { auto: options.stations.auto } : {}),
2301
+ throttle: () => {
2302
+ const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
2303
+ return verdict.ok ? null : { retryAfter: verdict.retryAfter };
2304
+ },
2277
2305
  });
2278
2306
  return;
2279
2307
  }
@@ -6168,7 +6196,21 @@ export async function serve(argv, version = "0.1.0") {
6168
6196
  const listeners = new ListenerStore(ListenerStore.fileFor(stateDir(), options.port));
6169
6197
  const library = { index: libraryIndex, probe: probeFile, retag: retagPlayer, listeners };
6170
6198
  const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
6199
+ // What stations deliver, per month, and the operator's limits on it.
6200
+ const stationUsage = new StationUsage(StationUsage.fileFor(stateDir(), options.port));
6201
+ const stationLimits = limitsFromEnv();
6202
+ process.once("exit", () => stationUsage.flush());
6203
+ // Breaks between songs and films: the network's spot, or a house one.
6204
+ const stationBreaks = new StationBreaks({
6205
+ dir: join(stateDir(), "ads"),
6206
+ site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
6207
+ hasSlot: Boolean(process.env["AD_SLOT"]),
6208
+ guard: (url) => refusedDestination(url),
6209
+ });
6171
6210
  stationRunner = new StationRunner({
6211
+ breaks: (video) => stationBreaks.next(video ? "video" : "audio"),
6212
+ onRestart: (id) => stationUsage.restarted(id),
6213
+ onFailure: (id) => stationUsage.failed(id),
6172
6214
  store: stationStore,
6173
6215
  channels,
6174
6216
  pull: async (channelId, name, files, known) => Boolean(await pullChannel(channels, tools.ffprobe, channelId, name, files[0], [], "", {
@@ -6181,7 +6223,66 @@ export async function serve(argv, version = "0.1.0") {
6181
6223
  roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
6182
6224
  log: (line) => console.log(line),
6183
6225
  });
6184
- const stations = { store: stationStore, runner: stationRunner };
6226
+ // Auto stations: a station per genre, kept in step with the library.
6227
+ const autoSetting = () => {
6228
+ const saved = readConfig().autoStations;
6229
+ if (!saved)
6230
+ return null;
6231
+ const pick = (name) => (saved[name] === undefined ? DEFAULT_AUTO[name] : saved[name]);
6232
+ return {
6233
+ video: pick("video") !== false,
6234
+ minItems: Number(pick("minItems")) || DEFAULT_AUTO.minItems,
6235
+ minFilms: Number(pick("minFilms")) || DEFAULT_AUTO.minFilms,
6236
+ start: pick("start") !== false,
6237
+ maxVideoRunning: Number(pick("maxVideoRunning")) >= 0 ? Number(pick("maxVideoRunning")) : DEFAULT_AUTO.maxVideoRunning,
6238
+ breaksEvery: Number(pick("breaksEvery")) >= 0 ? Number(pick("breaksEvery")) : DEFAULT_AUTO.breaksEvery,
6239
+ };
6240
+ };
6241
+ let autoRunning = null;
6242
+ const runAuto = (settings) => {
6243
+ if (autoRunning)
6244
+ return autoRunning;
6245
+ autoRunning = syncAutoStations({
6246
+ index: libraryIndex,
6247
+ store: stationStore,
6248
+ runner: stationRunner,
6249
+ owner: options.owner || "",
6250
+ mayStart: () => stationLimits.runningStations === 0 || stationStore.list().filter((one) => one.desired === "running").length < stationLimits.runningStations,
6251
+ filmGenres: async (title) => {
6252
+ const hit = await enricher.lookup(title, "title");
6253
+ const genres = hit?.data?.["genres"];
6254
+ return Array.isArray(genres) ? genres.filter((one) => typeof one === "string") : [];
6255
+ },
6256
+ log: (line) => console.log(line),
6257
+ }, settings).then((report) => {
6258
+ const made = report;
6259
+ console.log(` auto stations: ${made.radio.length} radio, ${made.tv.length} TV.`);
6260
+ return report;
6261
+ }).finally(() => { autoRunning = null; });
6262
+ return autoRunning;
6263
+ };
6264
+ libraryIndex.onScanned = () => {
6265
+ const settings = autoSetting();
6266
+ if (settings)
6267
+ void runAuto(settings);
6268
+ };
6269
+ const stationAuto = {
6270
+ setting: () => autoSetting(),
6271
+ sync: async (input) => {
6272
+ if (input["off"] === true) {
6273
+ writeConfig({ autoStations: undefined });
6274
+ return { off: true };
6275
+ }
6276
+ const settings = { ...DEFAULT_AUTO, ...(autoSetting() ?? {}) };
6277
+ for (const name of Object.keys(DEFAULT_AUTO)) {
6278
+ if (input[name] !== undefined)
6279
+ settings[name] = input[name];
6280
+ }
6281
+ writeConfig({ autoStations: settings });
6282
+ return runAuto(settings);
6283
+ },
6284
+ };
6285
+ const stations = { store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits, auto: stationAuto };
6185
6286
  // Music handed over from BitTorrented, copied into a folder of our own.
6186
6287
  const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
6187
6288
  index: libraryIndex,
@@ -0,0 +1,19 @@
1
+ import { type Advert } from "./ads.ts";
2
+ export interface BreakOptions {
3
+ dir: string;
4
+ /** nixamp.com, when this server has no slot of its own. */
5
+ site: string;
6
+ hasSlot: boolean;
7
+ guard: (url: string) => Promise<string>;
8
+ fetcher?: typeof fetch;
9
+ /** Injected by tests in place of the network. */
10
+ ask?: (kind: "audio" | "video") => Promise<Advert>;
11
+ }
12
+ export declare class StationBreaks {
13
+ private readonly options;
14
+ constructor(options: BreakOptions);
15
+ private ask;
16
+ /** A file to play for this break, or null for none. */
17
+ next(kind: "audio" | "video"): Promise<string | null>;
18
+ private cache;
19
+ }
@@ -0,0 +1,92 @@
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 } from "./ads.js";
25
+ const MAX_BYTES = 50 * 1024 * 1024;
26
+ export class StationBreaks {
27
+ options;
28
+ constructor(options) {
29
+ this.options = options;
30
+ }
31
+ async ask(kind) {
32
+ if (this.options.ask)
33
+ return this.options.ask(kind);
34
+ if (this.options.hasSlot)
35
+ return nextAdvert(kind);
36
+ const send = this.options.fetcher ?? fetch;
37
+ try {
38
+ const answer = await send(`${this.options.site.replace(/\/+$/, "")}/api/ads/next?kind=${kind}`, { signal: AbortSignal.timeout(2_000) });
39
+ if (!answer.ok)
40
+ return { url: null };
41
+ const body = (await answer.json());
42
+ return typeof body.url === "string" ? { url: body.url, kind: body.kind === "video" ? "video" : "audio" } : { url: null };
43
+ }
44
+ catch {
45
+ return { url: null };
46
+ }
47
+ }
48
+ /** A file to play for this break, or null for none. */
49
+ async next(kind) {
50
+ const advert = await this.ask(kind);
51
+ if (advert.url) {
52
+ const cached = await this.cache(advert.url).catch(() => null);
53
+ if (cached)
54
+ return cached;
55
+ }
56
+ const house = join(this.options.dir, kind === "video" ? "spot.mp4" : "spot.mp3");
57
+ return existsSync(house) ? house : null;
58
+ }
59
+ async cache(url) {
60
+ const parsed = new URL(url);
61
+ if (parsed.protocol !== "https:")
62
+ return null;
63
+ const folder = join(this.options.dir, "cache");
64
+ const file = join(folder, `${createHash("sha1").update(url).digest("hex")}${extname(parsed.pathname).slice(0, 6) || ".mp4"}`);
65
+ if (existsSync(file))
66
+ return file;
67
+ if (await this.options.guard(url))
68
+ return null;
69
+ const answer = await (this.options.fetcher ?? fetch)(url, { signal: AbortSignal.timeout(20_000) });
70
+ if (!answer.ok || !answer.body)
71
+ return null;
72
+ if (Number(answer.headers.get("content-length") ?? 0) > MAX_BYTES)
73
+ return null;
74
+ mkdirSync(folder, { recursive: true });
75
+ const part = `${file}.part`;
76
+ let bytes = 0;
77
+ const counted = Readable.fromWeb(answer.body).on("data", (chunk) => {
78
+ bytes += chunk.length;
79
+ if (bytes > MAX_BYTES)
80
+ counted.destroy(new Error("too big"));
81
+ });
82
+ try {
83
+ await pipeline(counted, createWriteStream(part));
84
+ renameSync(part, file);
85
+ return file;
86
+ }
87
+ catch {
88
+ rmSync(part, { force: true });
89
+ return null;
90
+ }
91
+ }
92
+ }
@@ -181,6 +181,28 @@ export async function station(argv) {
181
181
  return [`page ${links["page"]}`, `stream ${links["stream"]}`, `m3u ${links["m3u"]}`].join("\n");
182
182
  });
183
183
  }
184
+ case "auto": {
185
+ const body = argv.includes("--off") ? { off: true } : {
186
+ ...(argv.includes("--no-video") ? { video: false } : {}),
187
+ ...(argv.includes("--no-start") ? { start: false } : {}),
188
+ ...(flag(argv, "--min") ? { minItems: Number(flag(argv, "--min")) } : {}),
189
+ ...(flag(argv, "--films") ? { minFilms: Number(flag(argv, "--films")) } : {}),
190
+ ...(flag(argv, "--tv") ? { maxVideoRunning: Number(flag(argv, "--tv")) } : {}),
191
+ ...(flag(argv, "--breaks") ? { breaksEvery: Number(flag(argv, "--breaks")) } : {}),
192
+ };
193
+ return show(await call("POST", "/api/v1/stations/auto", body), (answer) => {
194
+ const report = answer["report"];
195
+ if (report.off)
196
+ return "Auto stations are off. The stations already made stay as they are.";
197
+ const line = (one, unit) => ` ${String(one["genre"]).padEnd(16)} ${String(one["files"] ?? one["films"]).padStart(5)} ${unit} ${one["running"] ? "on air" : "stopped"}${one["made"] ? " (new)" : ""} [${String(one["id"])}]`;
198
+ return [
199
+ "Radio:", ...(report.radio ?? []).map((one) => line(one, "files")),
200
+ "TV:", ...((report.tv ?? []).length ? (report.tv ?? []).map((one) => line(one, "films")) : [" none (no film genres known yet)"]),
201
+ ...((report.skipped ?? []).length ? [`Too small for a station: ${(report.skipped ?? []).join(", ")}`] : []),
202
+ "Every library scan keeps these up to date. They are unlisted: `nixamp station link ID` for a link.",
203
+ ].join("\n");
204
+ });
205
+ }
184
206
  case "delete": {
185
207
  const station = needId();
186
208
  if (!station)
@@ -0,0 +1,4 @@
1
+ export declare const GUIDE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Live \u00B7 stations</title>\n<meta name=\"description\" content=\"Radio and TV stations, live now.\">\n<link rel=\"icon\" href=\"data:,\">\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}\n*{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}\nmain{max-width:880px;margin:0 auto;padding:40px 16px}\nh1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}\nh2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}\na.station{display:block;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;color:inherit;text-decoration:none}\na.station:hover,a.station:focus-visible{border-color:var(--accent);outline:none}\n.name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}\n.live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}\nfooter{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}\n</style>\n</head>\n<body>\n<main>\n <h1>Live</h1>\n <p class=\"lead\">Radio and TV, on the air now. Pick one and press play.</p>\n <h2 id=\"radio-title\" hidden>Radio</h2>\n <div id=\"radio\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"tv-title\" hidden>TV</h2>\n <div id=\"tv\" class=\"grid\" aria-live=\"polite\"></div>\n <p id=\"empty\" class=\"empty\">Nothing is on the air here yet.</p>\n <footer>Played by <a href=\"https://nixamp.com\">nixamp</a>, on the Moshpit.</footer>\n</main>\n<script>\n(async () => {\n const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };\n const draw = (stations) => {\n for (const [kind, box] of [[\"audio\", \"#radio\"], [\"video\", \"#tv\"]]) {\n const grid = document.querySelector(box);\n grid.replaceChildren();\n const list = stations.filter((s) => s.kind === kind);\n document.querySelector(box + \"-title\").hidden = list.length === 0;\n for (const s of list) {\n const a = el(\"a\", \"station\");\n a.href = s.page;\n a.append(el(\"div\", \"live\", s.state === \"live\" ? \"\u25CF LIVE\" : \"off the air\"), el(\"div\", \"name\", s.name),\n el(\"div\", \"now\", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(\" \u2014 \") : \"\"));\n grid.append(a);\n }\n }\n document.querySelector(\"#empty\").hidden = stations.length > 0;\n };\n const load = async () => {\n try {\n const r = await fetch(\"/api/v1/stations/guide\", { cache: \"no-store\" });\n if (r.ok) draw((await r.json()).stations);\n } catch {}\n };\n await load();\n setInterval(load, 15000);\n})();\n</script>\n</body>\n</html>\n";
2
+ export declare const GUIDE_CSP: string;
3
+ /** The hosts whose front page is the guide (NIXAMP_GUIDE_HOSTS, comma-separated). */
4
+ export declare function guideHosts(env?: Record<string, string | undefined>): Set<string>;