nixamp 0.28.45 → 0.30.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 (78) hide show
  1. package/dist/audio.js +3 -0
  2. package/dist/channels.d.ts +14 -0
  3. package/dist/channels.js +20 -4
  4. package/dist/decoder-policy.d.ts +20 -0
  5. package/dist/decoder-policy.js +34 -0
  6. package/dist/destinations.d.ts +5 -0
  7. package/dist/destinations.js +82 -0
  8. package/dist/directory.d.ts +3 -1
  9. package/dist/directory.js +8 -2
  10. package/dist/library.d.ts +2 -0
  11. package/dist/library.js +4 -1
  12. package/dist/links.d.ts +4 -0
  13. package/dist/links.js +20 -2
  14. package/dist/main.d.ts +0 -8
  15. package/dist/main.js +9 -0
  16. package/dist/playlist-input.d.ts +46 -0
  17. package/dist/playlist-input.js +158 -54
  18. package/dist/publish-choice.d.ts +21 -0
  19. package/dist/publish-choice.js +17 -0
  20. package/dist/server.d.ts +27 -1
  21. package/dist/server.js +228 -17
  22. package/dist/share.d.ts +1 -1
  23. package/dist/share.js +5 -4
  24. package/dist/station-cli.d.ts +3 -0
  25. package/dist/station-cli.js +201 -0
  26. package/dist/station-page.d.ts +5 -0
  27. package/dist/station-page.js +137 -0
  28. package/dist/station-routes.d.ts +60 -0
  29. package/dist/station-routes.js +249 -0
  30. package/dist/station-runner.d.ts +67 -0
  31. package/dist/station-runner.js +219 -0
  32. package/dist/station-usage.d.ts +2 -0
  33. package/dist/station-usage.js +20 -0
  34. package/dist/stations.d.ts +148 -0
  35. package/dist/stations.js +331 -0
  36. package/package.json +1 -1
  37. package/src/audio.ts +3 -0
  38. package/src/channels.ts +30 -5
  39. package/src/decoder-policy.ts +33 -0
  40. package/src/destinations.ts +78 -0
  41. package/src/directory.ts +10 -2
  42. package/src/library.ts +6 -1
  43. package/src/links.ts +23 -4
  44. package/src/main.ts +8 -0
  45. package/src/playlist-input.ts +172 -50
  46. package/src/publish-choice.ts +35 -0
  47. package/src/server.ts +227 -18
  48. package/src/share.ts +5 -4
  49. package/src/station-cli.ts +195 -0
  50. package/src/station-page.ts +144 -0
  51. package/src/station-routes.ts +301 -0
  52. package/src/station-runner.ts +244 -0
  53. package/src/station-usage.ts +20 -0
  54. package/src/stations.ts +407 -0
  55. package/web/dist/assets/{ar-8fDonUoB.js → ar-CTUDxy1J.js} +1 -1
  56. package/web/dist/assets/{cs-DCClolTy.js → cs-m4JTaO3o.js} +1 -1
  57. package/web/dist/assets/{da-C3RPhXUd.js → da-DHnJElYB.js} +1 -1
  58. package/web/dist/assets/{de-CYBoEomk.js → de-DkxPohnf.js} +1 -1
  59. package/web/dist/assets/{es-DS3ls4xT.js → es-oY_G83d7.js} +1 -1
  60. package/web/dist/assets/{fi-DbCZpO89.js → fi-fCO8XRdg.js} +1 -1
  61. package/web/dist/assets/{fr-D0z3UpdL.js → fr-CSupf-IN.js} +1 -1
  62. package/web/dist/assets/{hi-BTU3sRlQ.js → hi-Dve7PJsC.js} +1 -1
  63. package/web/dist/assets/{hls-3VKVEQE3-CQXTVHn_.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
  64. package/web/dist/assets/{hu-B37Q8ldT.js → hu-D1KYamzV.js} +1 -1
  65. package/web/dist/assets/{id-4pdXNa4G.js → id-Cofp_4aQ.js} +1 -1
  66. package/web/dist/assets/index-7mCQZGNF.js +22 -0
  67. package/web/dist/assets/{it-CTe69-UY.js → it-62iXQWQD.js} +1 -1
  68. package/web/dist/assets/{mpegts-BBmkDppm.js → mpegts-BT5ESacM.js} +1 -1
  69. package/web/dist/assets/{mpegts-LO6RVLD6-CUVdjYFi.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
  70. package/web/dist/assets/{nl-CxrmHrMn.js → nl-CzRRG-CN.js} +1 -1
  71. package/web/dist/assets/{ru-zjVCfIKq.js → ru-OykC1LJQ.js} +1 -1
  72. package/web/dist/assets/{sv-CEoiT2Lr.js → sv-B8FXBDHm.js} +1 -1
  73. package/web/dist/assets/{uk-ZC6JzQvD.js → uk-DhAJBjwH.js} +1 -1
  74. package/web/dist/assets/{vi-D9anF-cl.js → vi-CdBSRReC.js} +1 -1
  75. package/web/dist/assets/{zh-BeLYdC0I.js → zh-wGWNyBEZ.js} +1 -1
  76. package/web/dist/index.html +1 -1
  77. package/web/dist/sw.js +22 -22
  78. package/web/dist/assets/index-BDBbN5Qy.js +0 -22
@@ -1,5 +1,12 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { protocolArgs } from "./decoder-policy.js";
2
3
  import { codecsOf } from "./audio.js";
4
+ /**
5
+ * How many entries in a row may fail before the list is given up on. A
6
+ * corrupt file is skipped; a list where nothing plays is an error, said once,
7
+ * not a loop.
8
+ */
9
+ export const MAX_FAILURES_IN_A_ROW = 5;
3
10
  /** Decode each file independently; feed one persistent muxer a fixed stream
4
11
  * profile and continuous timestamps. A concat demuxer cannot decode a list
5
12
  * whose input codecs differ. Only one entry is encoded at a time, with pipe
@@ -12,11 +19,26 @@ export class PlaylistInputStream {
12
19
  constructor(options) {
13
20
  this.options = options;
14
21
  }
22
+ /** Every entry that has started, in the order it started, with where it sits in the stream. */
23
+ get history() {
24
+ return this.entries;
25
+ }
15
26
  place(seconds) {
16
27
  const entry = this.entries.findLast(one => seconds >= one.start) ?? this.entries[0];
17
28
  return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position) }
18
29
  : { at: this.options.at, position: this.options.position };
19
30
  }
31
+ /** The decoder for the entry that is on, so skip() can end it early. */
32
+ stopCurrent = null;
33
+ skipping = false;
34
+ /** End the entry that is on and go to the next. False when nothing is on. */
35
+ skip() {
36
+ if (!this.stopCurrent)
37
+ return false;
38
+ this.skipping = true;
39
+ this.stopCurrent();
40
+ return true;
41
+ }
20
42
  async *open(signal) {
21
43
  const o = this.options;
22
44
  const [command, ...prefix] = o.ffmpeg;
@@ -24,66 +46,148 @@ export class PlaylistInputStream {
24
46
  const width = Math.max(2, Math.floor((o.width || 1280) * ratio / 2) * 2);
25
47
  const height = Math.max(2, Math.floor((o.height || 720) * ratio / 2) * 2);
26
48
  let offset = 0;
49
+ let failuresInARow = 0;
50
+ let first = true;
27
51
  try {
28
- for (let at = o.at; at < o.files.length && !signal.aborted; at++) {
29
- const file = o.files[at];
30
- const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)]));
52
+ const orderFor = (list) => o.order ? o.order(list.length).filter((one) => one >= 0 && one < list.length) : list.map((_, index) => index);
53
+ let files = o.current?.() ?? o.files;
54
+ if (files.length === 0)
55
+ throw new Error("The playlist is empty");
56
+ let order = orderFor(files);
57
+ let step = Math.min(o.at, order.length);
58
+ let played = 0;
59
+ let last = "";
60
+ for (;;) {
31
61
  if (signal.aborted)
32
- return;
33
- if (!codecs.audio && !codecs.video)
34
- throw new Error(`Cannot decode playlist entry ${at + 1}`);
35
- const position = at === o.at ? o.position : 0;
36
- const duration = (codecs.duration ?? 0) - position;
37
- if (duration <= 0)
38
- throw new Error(`No playable duration for playlist entry ${at + 1}`);
39
- this.entries.push({ at, start: offset, position });
40
- const hasVideo = Boolean(codecs.video);
41
- const extra = o.video && !hasVideo
42
- ? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
43
- : o.video && !codecs.audio ? ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"] : [];
44
- const args = [
45
- ...prefix, "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "2",
46
- "-threads", "2", ...(position > 0 ? ["-ss", String(position)] : []), "-i", file,
47
- ...extra, "-t", String(duration),
48
- ...(o.video ? [
49
- "-map", hasVideo ? "0:v:0" : "1:v:0", "-map", codecs.audio ? "0:a:0" : "1:a:0",
50
- "-vf", `fps=30,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,setpts=PTS-STARTPTS`,
51
- "-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency",
52
- "-crf", "23", "-pix_fmt", "yuv420p", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
53
- ] : ["-map", "0:a:0", "-vn"]),
54
- "-af", "aresample=48000,asetpts=PTS-STARTPTS", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
55
- "-mpegts_flags", "+initial_discontinuity", "-output_ts_offset", String(offset), "-mpegts_copyts", "1", "-muxdelay", "0", "-muxpreload", "0",
56
- "-f", "mpegts", "pipe:1",
57
- ];
58
- const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
59
- let tail = "";
60
- child.stderr.on("data", chunk => { tail = (tail + chunk.toString()).slice(-1500); });
61
- const closed = new Promise((resolve) => {
62
- child.once("error", error => { tail = error.message; resolve(-1); });
63
- child.once("close", code => resolve(code));
64
- });
65
- const abort = () => { child.kill("SIGKILL"); };
66
- signal.addEventListener("abort", abort, { once: true });
67
- try {
68
- for await (const chunk of child.stdout) {
62
+ break;
63
+ // Asked at every boundary, so an edit lands on the next track. The
64
+ // list goes on after the file that just played, where the new list
65
+ // has it; a shuffled list draws a new order.
66
+ if (o.current) {
67
+ const fresh = o.current();
68
+ if (fresh.length !== files.length || fresh.some((one, index) => one !== files[index])) {
69
+ if (fresh.length === 0)
70
+ throw new Error("The playlist is empty");
71
+ files = fresh;
72
+ order = orderFor(files);
73
+ const where = last ? files.indexOf(last) : -1;
74
+ step = o.order ? 0 : where >= 0 ? where + 1 : Math.min(step, files.length);
75
+ }
76
+ }
77
+ if (step >= order.length) {
78
+ if (!o.repeat)
79
+ break;
80
+ // A pass where everything was passed over is a list with nothing
81
+ // to play, and repeating it would be the loop this exists to avoid.
82
+ if (played === 0)
83
+ throw new Error("Nothing in this playlist would play");
84
+ played = 0;
85
+ order = orderFor(files);
86
+ step = 0;
87
+ continue;
88
+ }
89
+ {
90
+ const at = order[step];
91
+ step += 1;
92
+ const file = files[at];
93
+ last = file;
94
+ if (o.excluded?.(file))
95
+ continue;
96
+ const position = first && step - 1 === o.at ? o.position : 0;
97
+ first = false;
98
+ let reached = 0;
99
+ try {
100
+ const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)]));
69
101
  if (signal.aborted)
70
102
  return;
71
- yield chunk;
103
+ if (!codecs.audio && !codecs.video)
104
+ throw new Error(`Cannot decode playlist entry ${at + 1}`);
105
+ const duration = (codecs.duration ?? 0) - position;
106
+ if (duration <= 0)
107
+ throw new Error(`No playable duration for playlist entry ${at + 1}`);
108
+ this.entries.push({ at, file, start: offset, position });
109
+ const hasVideo = Boolean(codecs.video);
110
+ const extra = o.video && !hasVideo
111
+ ? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
112
+ : o.video && !codecs.audio ? ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"] : [];
113
+ const args = [
114
+ ...prefix, "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "2",
115
+ // How far this entry got, so an entry cut short -- skipped, or
116
+ // broken halfway -- moves the clock by what was played and the
117
+ // next one starts where this one stopped, not after a gap.
118
+ "-progress", "pipe:3", "-stats_period", "0.5",
119
+ "-threads", "2", ...(position > 0 ? ["-ss", String(position)] : []), ...protocolArgs(file), "-i", file,
120
+ ...extra, "-t", String(duration),
121
+ ...(o.video ? [
122
+ "-map", hasVideo ? "0:v:0" : "1:v:0", "-map", codecs.audio ? "0:a:0" : "1:a:0",
123
+ "-vf", `fps=30,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,setpts=PTS-STARTPTS`,
124
+ "-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency",
125
+ "-crf", "23", "-pix_fmt", "yuv420p", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
126
+ ] : ["-map", "0:a:0", "-vn"]),
127
+ "-af", "aresample=48000,asetpts=PTS-STARTPTS", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
128
+ "-mpegts_flags", "+initial_discontinuity", "-output_ts_offset", String(offset), "-mpegts_copyts", "1", "-muxdelay", "0", "-muxpreload", "0",
129
+ "-f", "mpegts", "pipe:1",
130
+ ];
131
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe", "pipe"] });
132
+ let tail = "";
133
+ child.stderr.on("data", chunk => { tail = (tail + chunk.toString()).slice(-1500); });
134
+ child.stdio[3]?.on("data", (chunk) => {
135
+ const found = /out_time_us=(\d+)/g;
136
+ let match;
137
+ while ((match = found.exec(chunk.toString())) !== null)
138
+ reached = Math.max(reached, Number(match[1]) / 1e6);
139
+ });
140
+ const closed = new Promise((resolve) => {
141
+ child.once("error", error => { tail = error.message; resolve(-1); });
142
+ child.once("close", code => resolve(code));
143
+ });
144
+ const abort = () => { child.kill("SIGKILL"); };
145
+ signal.addEventListener("abort", abort, { once: true });
146
+ this.stopCurrent = abort;
147
+ let skipped = false;
148
+ try {
149
+ for await (const chunk of child.stdout) {
150
+ if (signal.aborted)
151
+ return;
152
+ yield chunk;
153
+ }
154
+ const code = await closed;
155
+ if (signal.aborted)
156
+ return;
157
+ skipped = this.skipping;
158
+ if (code !== 0 && !skipped)
159
+ throw new Error(`Playlist entry ${at + 1} failed: ${tail.trim()}`);
160
+ }
161
+ finally {
162
+ this.stopCurrent = null;
163
+ this.skipping = false;
164
+ signal.removeEventListener("abort", abort);
165
+ abort();
166
+ await closed;
167
+ }
168
+ failuresInARow = 0;
169
+ played += 1;
170
+ // Match the frame grid used above so rounding cannot accumulate a
171
+ // backwards timestamp over hundreds of short files.
172
+ const length = skipped ? Math.min(duration, reached) : duration;
173
+ offset += o.video ? Math.ceil(length * 30) / 30 : length;
174
+ }
175
+ catch (error) {
176
+ if (signal.aborted || !o.passOver)
177
+ throw error;
178
+ // Passed over, not dialled again: the next entry is what a
179
+ // listener should hear, and a file that will not decode now will
180
+ // not decode in two seconds either.
181
+ const reason = error instanceof Error ? error.message : String(error);
182
+ o.onFailure?.({ at, file, reason });
183
+ if (reached > 0)
184
+ offset += o.video ? Math.ceil(reached * 30) / 30 : reached;
185
+ failuresInARow += 1;
186
+ if (failuresInARow >= Math.min(MAX_FAILURES_IN_A_ROW, Math.max(1, files.length))) {
187
+ throw new Error(`Nothing in this playlist would play: ${reason}`);
188
+ }
72
189
  }
73
- const code = await closed;
74
- if (signal.aborted)
75
- return;
76
- if (code !== 0)
77
- throw new Error(`Playlist entry ${at + 1} failed: ${tail.trim()}`);
78
- }
79
- finally {
80
- signal.removeEventListener("abort", abort);
81
- abort();
82
- await closed;
83
190
  }
84
- // Match the frame grid used above so rounding cannot accumulate a
85
- // backwards timestamp over hundreds of short files.
86
- offset += o.video ? Math.ceil(duration * 30) / 30 : duration;
87
191
  }
88
192
  this.complete = !signal.aborted;
89
193
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Whether this server goes in the public directory.
3
+ *
4
+ * A listing is a phone code and a link anybody can open, so it is a thing
5
+ * somebody chose, not a thing that happens because a daemon had no terminal
6
+ * to ask on. The choice is asked once, or taken from a flag, and written into
7
+ * config.json beside the library, and every later start reads it back.
8
+ *
9
+ * Servers that were listed before the choice was written down keep being
10
+ * listed: a daemon without a terminal used to publish by itself, and quietly
11
+ * taking it off the directory on an update would be changing somebody's
12
+ * choice for them. A server new to this machine is not listed until asked.
13
+ */
14
+ export type PublishFlag = "ask" | "yes" | "no";
15
+ /**
16
+ * "yes" or "no" when the answer is already known, "ask" when there is a
17
+ * person at the terminal to ask.
18
+ */
19
+ export declare function publishChoice(flag: PublishFlag, recorded: boolean | undefined, interactive: boolean, established: boolean): "yes" | "no" | "ask";
20
+ /** Whether to write the answer down: only a choice nobody had made yet. */
21
+ export declare function shouldRecord(flag: PublishFlag, recorded: boolean | undefined): boolean;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * "yes" or "no" when the answer is already known, "ask" when there is a
3
+ * person at the terminal to ask.
4
+ */
5
+ export function publishChoice(flag, recorded, interactive, established) {
6
+ if (flag !== "ask")
7
+ return flag;
8
+ if (recorded !== undefined)
9
+ return recorded ? "yes" : "no";
10
+ if (interactive)
11
+ return "ask";
12
+ return established ? "yes" : "no";
13
+ }
14
+ /** Whether to write the answer down: only a choice nobody had made yet. */
15
+ export function shouldRecord(flag, recorded) {
16
+ return flag === "ask" && recorded === undefined;
17
+ }
package/dist/server.d.ts CHANGED
@@ -5,7 +5,7 @@ import { LiveVoice } from "./live-voice.ts";
5
5
  import { Connections } from "./connections.ts";
6
6
  import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast.ts";
7
7
  import { Ingest } from "./ingest.ts";
8
- import { Channels, type Channel, type ChannelInfo, type RememberedChannel } from "./channels.ts";
8
+ import { Channels, type Channel, type ChannelInfo, type PullResume, type RememberedChannel } from "./channels.ts";
9
9
  import { Accounts } from "./accounts.ts";
10
10
  import { type ResetMail } from "./password-reset.ts";
11
11
  import { Handles } from "./handles.ts";
@@ -21,6 +21,8 @@ import { Directory, type LineupEntry } from "./directory.ts";
21
21
  import { ArtCache } from "./art.ts";
22
22
  import { PartyLine } from "./partyline.ts";
23
23
  import { HlsPackagers } from "./hls.ts";
24
+ import { StationRunner } from "./station-runner.ts";
25
+ import { StationStore } from "./stations.ts";
24
26
  import { CompressionService } from "./compression/service.ts";
25
27
  import { Enricher } from "./enrich.ts";
26
28
  import { Follows } from "./follows.ts";
@@ -438,7 +440,26 @@ export interface KnownSource {
438
440
  /** A picture and a line about it, from the site or the list that named it. */
439
441
  art?: string;
440
442
  about?: string;
443
+ /** A station's rotation (see PullResume.rotation). */
444
+ rotation?: PullResume["rotation"];
445
+ /** kbps for an audio rendition; 192 when unset. */
446
+ audioBitrate?: number;
441
447
  }
448
+ /**
449
+ * A station's files, chosen by track index (as /api/folders/live takes them)
450
+ * or by a folder under the library; null when neither was given. Only files
451
+ * on this machine and inside the library: a station never reaches past it.
452
+ */
453
+ export declare function selectStationFiles(engine: Engine, body: {
454
+ items?: unknown;
455
+ folder?: unknown;
456
+ }): string[] | null;
457
+ /** What the library knows about one file: its tags, for a now-playing line. */
458
+ export declare function trackFor(engine: Engine, file: string): {
459
+ title: string;
460
+ artist: string;
461
+ album: string;
462
+ } | null;
442
463
  /** How many times a source that answers nothing is asked, and how far apart. */
443
464
  export declare const PROBE_TRIES = 3;
444
465
  export declare const PROBE_RETRY_MS = 1500;
@@ -545,6 +566,11 @@ export declare function joinSubject(url: URL, options: Pick<HandlerOptions, "dir
545
566
  /** The shell, titled for what a join link opens, with its picture on the card. */
546
567
  export declare function joinDocument(shell: string, subject: JoinSubject, site: string): string;
547
568
  export interface HandlerOptions {
569
+ /** This server's stations and what keeps them on the air. */
570
+ stations?: {
571
+ store: StationStore;
572
+ runner: StationRunner;
573
+ };
548
574
  web: string | null;
549
575
  /** Branded clients on this same backend, keyed by an explicitly configured host. */
550
576
  webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;