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
package/src/links.ts CHANGED
@@ -156,6 +156,11 @@ export function playlistFrom(text: string, base: string): { hls: boolean; source
156
156
  return { hls: false, sources: entries.map((entry) => entry.source), image };
157
157
  }
158
158
 
159
+ /** Asked of every address before it is fetched: "" to go ahead, else why not. */
160
+ export type Guard = (url: string) => Promise<string>;
161
+
162
+ const PLAYLIST_MAX_REDIRECTS = 5;
163
+
159
164
  /**
160
165
  * A pasted .m3u, read and turned into a channel's worth of entries. Fetched
161
166
  * here rather than by yt-dlp, which would take the first entry and stop, or
@@ -163,14 +168,28 @@ export function playlistFrom(text: string, base: string): { hls: boolean; source
163
168
  */
164
169
  export async function resolvePlaylist(
165
170
  url: string,
166
- options: { timeoutMs?: number; fetcher?: typeof fetch } = {},
171
+ options: { timeoutMs?: number; fetcher?: typeof fetch; guard?: Guard } = {},
167
172
  ): Promise<ResolvedLink | { error: string }> {
168
173
  const get = options.fetcher ?? fetch;
169
174
  const abort = new AbortController();
170
175
  const timer = setTimeout(() => abort.abort(), options.timeoutMs ?? PLAYLIST_FETCH_TIMEOUT_MS);
171
176
  let text = "";
172
177
  try {
173
- const answer = await get(url, { signal: abort.signal, headers: { "user-agent": "nixamp" }, redirect: "follow" });
178
+ // Followed here, one hop at a time, when there is a guard: a public
179
+ // address that redirects to the router is the router.
180
+ let at = url;
181
+ let answer: Response;
182
+ for (let hop = 0; ; hop += 1) {
183
+ if (options.guard) {
184
+ const refused = await options.guard(at);
185
+ if (refused) return { error: refused };
186
+ }
187
+ answer = await get(at, { signal: abort.signal, headers: { "user-agent": "nixamp" }, redirect: options.guard ? "manual" : "follow" });
188
+ const next = answer.headers.get("location");
189
+ if (!options.guard || answer.status < 300 || answer.status >= 400 || !next) break;
190
+ if (hop >= PLAYLIST_MAX_REDIRECTS) return { error: "that playlist redirects too many times" };
191
+ at = new URL(next, at).toString();
192
+ }
174
193
  if (!answer.ok) return { error: `that playlist could not be fetched (HTTP ${answer.status})` };
175
194
  text = (await answer.text()).slice(0, PLAYLIST_MAX_BYTES);
176
195
  } catch (error) {
@@ -493,10 +512,10 @@ export const RESOLVE_TIMEOUT_MS = 60_000;
493
512
  export async function resolveLink(
494
513
  ytdlp: string[] | null,
495
514
  url: string,
496
- options: { cookies?: string; timeoutMs?: number; format?: string } = {},
515
+ options: { cookies?: string; timeoutMs?: number; format?: string; guard?: Guard } = {},
497
516
  ): Promise<ResolvedLink | { error: string }> {
498
517
  if (isDirectMedia(url)) return directLink(url);
499
- if (isPlaylistLink(url)) return resolvePlaylist(url, { timeoutMs: options.timeoutMs });
518
+ if (isPlaylistLink(url)) return resolvePlaylist(url, { timeoutMs: options.timeoutMs, guard: options.guard });
500
519
  // Without yt-dlp, a link is taken as the media it may well be: an IPTV
501
520
  // feed has no extension and no page behind it, and ffprobe -- asked next,
502
521
  // before anything goes on the air -- tells a stream from a web page in a
package/src/main.ts CHANGED
@@ -7,6 +7,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.ts"
7
7
  *
8
8
  * ffmpeg decodes; we read every sample on its way to the speakers and draw it.
9
9
  */
10
+ import { USAGE as STATION_USAGE } from "./station-usage.ts";
10
11
  import { createApp, themes, type BrailleCanvas, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
11
12
  import { fileURLToPath } from "node:url";
12
13
  import { resolve } from "node:path";
@@ -232,6 +233,7 @@ keeps serving its browser remote. One per user.
232
233
 
233
234
  nixamp attach put the player back in front of it
234
235
  nixamp admin who is connected, and re-stream to them
236
+ nixamp station make a folder into a station others can join (see: nixamp station help)
235
237
 
236
238
  From inside the player, d hands the music to a daemon without stopping it.
237
239
  `,
@@ -394,6 +396,7 @@ q or d leaves; neither stops anything. \`nixamp daemon stop\` is what stops it.
394
396
  };
395
397
 
396
398
  export function helpFor(topic: string | undefined): string {
399
+ if (topic === "station" || topic === "stations") return STATION_USAGE;
397
400
  return (topic ? TOPICS[topic] : undefined) ?? HELP;
398
401
  }
399
402
 
@@ -553,6 +556,11 @@ export async function main(): Promise<void> {
553
556
  process.exitCode = await runDaemon(rest);
554
557
  return;
555
558
  }
559
+ if (first === "station" || first === "stations") {
560
+ const { station } = await import("./station-cli.ts");
561
+ process.exitCode = await station(rest);
562
+ return;
563
+ }
556
564
  if (first === "admin") {
557
565
  const { admin } = await import("./admin.ts");
558
566
  await admin(rest);
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { protocolArgs } from "./decoder-policy.ts";
2
3
  import type { Readable } from "node:stream";
3
4
  import { codecsOf } from "./audio.ts";
4
5
 
@@ -11,24 +12,72 @@ export interface PlaylistInput {
11
12
  video: boolean;
12
13
  width?: number;
13
14
  height?: number;
15
+ /**
16
+ * Back to the top when the end is reached, instead of finishing: a
17
+ * station, which plays until somebody stops it.
18
+ */
19
+ repeat?: boolean;
20
+ /**
21
+ * The order to play a list of this length in, asked at the top of every
22
+ * pass: a station-wide shuffle is one order for everybody listening, drawn
23
+ * again each time round. Unset is the list's own order.
24
+ */
25
+ order?: (length: number) => number[];
26
+ /** Read at every entry boundary, so an edit to the list lands on the next track. */
27
+ current?: () => string[];
28
+ /** Entries already known not to play, passed over without being tried. */
29
+ excluded?: (file: string) => boolean;
30
+ /**
31
+ * Pass over an entry that will not play, instead of failing the stream.
32
+ * For a station, where the next track is what a listener should hear. A
33
+ * folder live leaves it unset: there a missing entry is usually a file
34
+ * still arriving, and the channel redials and waits for it.
35
+ */
36
+ passOver?: boolean;
37
+ /** Told when an entry is passed over because it would not play. */
38
+ onFailure?: (event: { at: number; file: string; reason: string }) => void;
14
39
  }
15
40
 
41
+ /**
42
+ * How many entries in a row may fail before the list is given up on. A
43
+ * corrupt file is skipped; a list where nothing plays is an error, said once,
44
+ * not a loop.
45
+ */
46
+ export const MAX_FAILURES_IN_A_ROW = 5;
47
+
16
48
  /** Decode each file independently; feed one persistent muxer a fixed stream
17
49
  * profile and continuous timestamps. A concat demuxer cannot decode a list
18
50
  * whose input codecs differ. Only one entry is encoded at a time, with pipe
19
51
  * backpressure bounding read-ahead independently of the directory's size. */
20
52
  export class PlaylistInputStream {
21
- private entries: { at: number; start: number; position: number }[] = [];
53
+ private entries: { at: number; file: string; start: number; position: number }[] = [];
22
54
  error: Error | null = null;
23
55
  complete = false;
24
56
  constructor(private readonly options: PlaylistInput) {}
25
57
 
58
+ /** Every entry that has started, in the order it started, with where it sits in the stream. */
59
+ get history(): ReadonlyArray<{ at: number; file: string; start: number; position: number }> {
60
+ return this.entries;
61
+ }
62
+
26
63
  place(seconds: number): { at: number; position: number } {
27
64
  const entry = this.entries.findLast(one => seconds >= one.start) ?? this.entries[0];
28
65
  return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position) }
29
66
  : { at: this.options.at, position: this.options.position };
30
67
  }
31
68
 
69
+ /** The decoder for the entry that is on, so skip() can end it early. */
70
+ private stopCurrent: (() => void) | null = null;
71
+ private skipping = false;
72
+
73
+ /** End the entry that is on and go to the next. False when nothing is on. */
74
+ skip(): boolean {
75
+ if (!this.stopCurrent) return false;
76
+ this.skipping = true;
77
+ this.stopCurrent();
78
+ return true;
79
+ }
80
+
32
81
  async *open(signal: AbortSignal): AsyncGenerator<Buffer> {
33
82
  const o = this.options;
34
83
  const [command, ...prefix] = o.ffmpeg as [string, ...string[]];
@@ -36,59 +85,132 @@ export class PlaylistInputStream {
36
85
  const width = Math.max(2, Math.floor((o.width || 1280) * ratio / 2) * 2);
37
86
  const height = Math.max(2, Math.floor((o.height || 720) * ratio / 2) * 2);
38
87
  let offset = 0;
88
+ let failuresInARow = 0;
89
+ let first = true;
39
90
  try {
40
- for (let at = o.at; at < o.files.length && !signal.aborted; at++) {
41
- const file = o.files[at]!;
42
- const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)]));
43
- if (signal.aborted) return;
44
- if (!codecs.audio && !codecs.video) throw new Error(`Cannot decode playlist entry ${at + 1}`);
45
- const position = at === o.at ? o.position : 0;
46
- const duration = (codecs.duration ?? 0) - position;
47
- if (duration <= 0) throw new Error(`No playable duration for playlist entry ${at + 1}`);
48
- this.entries.push({ at, start: offset, position });
49
- const hasVideo = Boolean(codecs.video);
50
- const extra = o.video && !hasVideo
51
- ? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
52
- : o.video && !codecs.audio ? ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"] : [];
53
- const args = [
54
- ...prefix, "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "2",
55
- "-threads", "2", ...(position > 0 ? ["-ss", String(position)] : []), "-i", file,
56
- ...extra, "-t", String(duration),
57
- ...(o.video ? [
58
- "-map", hasVideo ? "0:v:0" : "1:v:0", "-map", codecs.audio ? "0:a:0" : "1:a:0",
59
- "-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`,
60
- "-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency",
61
- "-crf", "23", "-pix_fmt", "yuv420p", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
62
- ] : ["-map", "0:a:0", "-vn"]),
63
- "-af", "aresample=48000,asetpts=PTS-STARTPTS", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
64
- "-mpegts_flags", "+initial_discontinuity", "-output_ts_offset", String(offset), "-mpegts_copyts", "1", "-muxdelay", "0", "-muxpreload", "0",
65
- "-f", "mpegts", "pipe:1",
66
- ];
67
- const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
68
- let tail = "";
69
- child.stderr.on("data", chunk => { tail = (tail + chunk.toString()).slice(-1500); });
70
- const closed = new Promise<number | null>((resolve) => {
71
- child.once("error", error => { tail = error.message; resolve(-1); });
72
- child.once("close", code => resolve(code));
73
- });
74
- const abort = (): void => { child.kill("SIGKILL"); };
75
- signal.addEventListener("abort", abort, { once: true });
76
- try {
77
- for await (const chunk of child.stdout as Readable) {
91
+ const orderFor = (list: string[]): number[] =>
92
+ o.order ? o.order(list.length).filter((one) => one >= 0 && one < list.length) : list.map((_, index) => index);
93
+ let files = o.current?.() ?? o.files;
94
+ if (files.length === 0) throw new Error("The playlist is empty");
95
+ let order = orderFor(files);
96
+ let step = Math.min(o.at, order.length);
97
+ let played = 0;
98
+ let last = "";
99
+ for (;;) {
100
+ if (signal.aborted) break;
101
+ // Asked at every boundary, so an edit lands on the next track. The
102
+ // list goes on after the file that just played, where the new list
103
+ // has it; a shuffled list draws a new order.
104
+ if (o.current) {
105
+ const fresh = o.current();
106
+ if (fresh.length !== files.length || fresh.some((one, index) => one !== files[index])) {
107
+ if (fresh.length === 0) throw new Error("The playlist is empty");
108
+ files = fresh;
109
+ order = orderFor(files);
110
+ const where = last ? files.indexOf(last) : -1;
111
+ step = o.order ? 0 : where >= 0 ? where + 1 : Math.min(step, files.length);
112
+ }
113
+ }
114
+ if (step >= order.length) {
115
+ if (!o.repeat) break;
116
+ // A pass where everything was passed over is a list with nothing
117
+ // to play, and repeating it would be the loop this exists to avoid.
118
+ if (played === 0) throw new Error("Nothing in this playlist would play");
119
+ played = 0;
120
+ order = orderFor(files);
121
+ step = 0;
122
+ continue;
123
+ }
124
+ {
125
+ const at = order[step]!;
126
+ step += 1;
127
+ const file = files[at]!;
128
+ last = file;
129
+ if (o.excluded?.(file)) continue;
130
+ const position = first && step - 1 === o.at ? o.position : 0;
131
+ first = false;
132
+ let reached = 0;
133
+ try {
134
+ const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)]));
78
135
  if (signal.aborted) return;
79
- yield chunk as Buffer;
136
+ if (!codecs.audio && !codecs.video) throw new Error(`Cannot decode playlist entry ${at + 1}`);
137
+ const duration = (codecs.duration ?? 0) - position;
138
+ if (duration <= 0) throw new Error(`No playable duration for playlist entry ${at + 1}`);
139
+ this.entries.push({ at, file, start: offset, position });
140
+ const hasVideo = Boolean(codecs.video);
141
+ const extra = o.video && !hasVideo
142
+ ? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
143
+ : o.video && !codecs.audio ? ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"] : [];
144
+ const args = [
145
+ ...prefix, "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "2",
146
+ // How far this entry got, so an entry cut short -- skipped, or
147
+ // broken halfway -- moves the clock by what was played and the
148
+ // next one starts where this one stopped, not after a gap.
149
+ "-progress", "pipe:3", "-stats_period", "0.5",
150
+ "-threads", "2", ...(position > 0 ? ["-ss", String(position)] : []), ...protocolArgs(file), "-i", file,
151
+ ...extra, "-t", String(duration),
152
+ ...(o.video ? [
153
+ "-map", hasVideo ? "0:v:0" : "1:v:0", "-map", codecs.audio ? "0:a:0" : "1:a:0",
154
+ "-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`,
155
+ "-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency",
156
+ "-crf", "23", "-pix_fmt", "yuv420p", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
157
+ ] : ["-map", "0:a:0", "-vn"]),
158
+ "-af", "aresample=48000,asetpts=PTS-STARTPTS", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
159
+ "-mpegts_flags", "+initial_discontinuity", "-output_ts_offset", String(offset), "-mpegts_copyts", "1", "-muxdelay", "0", "-muxpreload", "0",
160
+ "-f", "mpegts", "pipe:1",
161
+ ];
162
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe", "pipe"] });
163
+ let tail = "";
164
+ child.stderr!.on("data", chunk => { tail = (tail + chunk.toString()).slice(-1500); });
165
+ (child.stdio[3] as Readable | null)?.on("data", (chunk: Buffer) => {
166
+ const found = /out_time_us=(\d+)/g;
167
+ let match: RegExpExecArray | null;
168
+ while ((match = found.exec(chunk.toString())) !== null) reached = Math.max(reached, Number(match[1]) / 1e6);
169
+ });
170
+ const closed = new Promise<number | null>((resolve) => {
171
+ child.once("error", error => { tail = error.message; resolve(-1); });
172
+ child.once("close", code => resolve(code));
173
+ });
174
+ const abort = (): void => { child.kill("SIGKILL"); };
175
+ signal.addEventListener("abort", abort, { once: true });
176
+ this.stopCurrent = abort;
177
+ let skipped = false;
178
+ try {
179
+ for await (const chunk of child.stdout as Readable) {
180
+ if (signal.aborted) return;
181
+ yield chunk as Buffer;
182
+ }
183
+ const code = await closed;
184
+ if (signal.aborted) return;
185
+ skipped = this.skipping;
186
+ if (code !== 0 && !skipped) throw new Error(`Playlist entry ${at + 1} failed: ${tail.trim()}`);
187
+ } finally {
188
+ this.stopCurrent = null;
189
+ this.skipping = false;
190
+ signal.removeEventListener("abort", abort);
191
+ abort();
192
+ await closed;
193
+ }
194
+ failuresInARow = 0;
195
+ played += 1;
196
+ // Match the frame grid used above so rounding cannot accumulate a
197
+ // backwards timestamp over hundreds of short files.
198
+ const length = skipped ? Math.min(duration, reached) : duration;
199
+ offset += o.video ? Math.ceil(length * 30) / 30 : length;
200
+ } catch (error) {
201
+ if (signal.aborted || !o.passOver) throw error;
202
+ // Passed over, not dialled again: the next entry is what a
203
+ // listener should hear, and a file that will not decode now will
204
+ // not decode in two seconds either.
205
+ const reason = error instanceof Error ? error.message : String(error);
206
+ o.onFailure?.({ at, file, reason });
207
+ if (reached > 0) offset += o.video ? Math.ceil(reached * 30) / 30 : reached;
208
+ failuresInARow += 1;
209
+ if (failuresInARow >= Math.min(MAX_FAILURES_IN_A_ROW, Math.max(1, files.length))) {
210
+ throw new Error(`Nothing in this playlist would play: ${reason}`);
211
+ }
80
212
  }
81
- const code = await closed;
82
- if (signal.aborted) return;
83
- if (code !== 0) throw new Error(`Playlist entry ${at + 1} failed: ${tail.trim()}`);
84
- } finally {
85
- signal.removeEventListener("abort", abort);
86
- abort();
87
- await closed;
88
213
  }
89
- // Match the frame grid used above so rounding cannot accumulate a
90
- // backwards timestamp over hundreds of short files.
91
- offset += o.video ? Math.ceil(duration * 30) / 30 : duration;
92
214
  }
93
215
  this.complete = !signal.aborted;
94
216
  } catch (error) {
@@ -0,0 +1,35 @@
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
+ /**
17
+ * "yes" or "no" when the answer is already known, "ask" when there is a
18
+ * person at the terminal to ask.
19
+ */
20
+ export function publishChoice(
21
+ flag: PublishFlag,
22
+ recorded: boolean | undefined,
23
+ interactive: boolean,
24
+ established: boolean,
25
+ ): "yes" | "no" | "ask" {
26
+ if (flag !== "ask") return flag;
27
+ if (recorded !== undefined) return recorded ? "yes" : "no";
28
+ if (interactive) return "ask";
29
+ return established ? "yes" : "no";
30
+ }
31
+
32
+ /** Whether to write the answer down: only a choice nobody had made yet. */
33
+ export function shouldRecord(flag: PublishFlag, recorded: boolean | undefined): boolean {
34
+ return flag === "ask" && recorded === undefined;
35
+ }