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.
- package/dist/audio.js +3 -0
- package/dist/channels.d.ts +14 -0
- package/dist/channels.js +20 -4
- package/dist/decoder-policy.d.ts +20 -0
- package/dist/decoder-policy.js +34 -0
- package/dist/destinations.d.ts +5 -0
- package/dist/destinations.js +82 -0
- package/dist/directory.d.ts +3 -1
- package/dist/directory.js +8 -2
- package/dist/library.d.ts +2 -0
- package/dist/library.js +4 -1
- package/dist/links.d.ts +4 -0
- package/dist/links.js +20 -2
- package/dist/main.d.ts +0 -8
- package/dist/main.js +9 -0
- package/dist/playlist-input.d.ts +46 -0
- package/dist/playlist-input.js +158 -54
- package/dist/publish-choice.d.ts +21 -0
- package/dist/publish-choice.js +17 -0
- package/dist/server.d.ts +27 -1
- package/dist/server.js +228 -17
- package/dist/share.d.ts +1 -1
- package/dist/share.js +5 -4
- package/dist/station-cli.d.ts +3 -0
- package/dist/station-cli.js +201 -0
- package/dist/station-page.d.ts +5 -0
- package/dist/station-page.js +137 -0
- package/dist/station-routes.d.ts +60 -0
- package/dist/station-routes.js +249 -0
- package/dist/station-runner.d.ts +67 -0
- package/dist/station-runner.js +219 -0
- package/dist/station-usage.d.ts +2 -0
- package/dist/station-usage.js +20 -0
- package/dist/stations.d.ts +148 -0
- package/dist/stations.js +331 -0
- package/package.json +1 -1
- package/src/audio.ts +3 -0
- package/src/channels.ts +30 -5
- package/src/decoder-policy.ts +33 -0
- package/src/destinations.ts +78 -0
- package/src/directory.ts +10 -2
- package/src/library.ts +6 -1
- package/src/links.ts +23 -4
- package/src/main.ts +8 -0
- package/src/playlist-input.ts +172 -50
- package/src/publish-choice.ts +35 -0
- package/src/server.ts +227 -18
- package/src/share.ts +5 -4
- package/src/station-cli.ts +195 -0
- package/src/station-page.ts +144 -0
- package/src/station-routes.ts +301 -0
- package/src/station-runner.ts +244 -0
- package/src/station-usage.ts +20 -0
- package/src/stations.ts +407 -0
- package/web/dist/assets/{ar-8fDonUoB.js → ar-CTUDxy1J.js} +1 -1
- package/web/dist/assets/{cs-DCClolTy.js → cs-m4JTaO3o.js} +1 -1
- package/web/dist/assets/{da-C3RPhXUd.js → da-DHnJElYB.js} +1 -1
- package/web/dist/assets/{de-CYBoEomk.js → de-DkxPohnf.js} +1 -1
- package/web/dist/assets/{es-DS3ls4xT.js → es-oY_G83d7.js} +1 -1
- package/web/dist/assets/{fi-DbCZpO89.js → fi-fCO8XRdg.js} +1 -1
- package/web/dist/assets/{fr-D0z3UpdL.js → fr-CSupf-IN.js} +1 -1
- package/web/dist/assets/{hi-BTU3sRlQ.js → hi-Dve7PJsC.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-CQXTVHn_.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
- package/web/dist/assets/{hu-B37Q8ldT.js → hu-D1KYamzV.js} +1 -1
- package/web/dist/assets/{id-4pdXNa4G.js → id-Cofp_4aQ.js} +1 -1
- package/web/dist/assets/index-7mCQZGNF.js +22 -0
- package/web/dist/assets/{it-CTe69-UY.js → it-62iXQWQD.js} +1 -1
- package/web/dist/assets/{mpegts-BBmkDppm.js → mpegts-BT5ESacM.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CUVdjYFi.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
- package/web/dist/assets/{nl-CxrmHrMn.js → nl-CzRRG-CN.js} +1 -1
- package/web/dist/assets/{ru-zjVCfIKq.js → ru-OykC1LJQ.js} +1 -1
- package/web/dist/assets/{sv-CEoiT2Lr.js → sv-B8FXBDHm.js} +1 -1
- package/web/dist/assets/{uk-ZC6JzQvD.js → uk-DhAJBjwH.js} +1 -1
- package/web/dist/assets/{vi-D9anF-cl.js → vi-CdBSRReC.js} +1 -1
- package/web/dist/assets/{zh-BeLYdC0I.js → zh-wGWNyBEZ.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
- 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
|
-
|
|
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);
|
package/src/playlist-input.ts
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
+
}
|