nixamp 0.28.45 → 0.29.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 +16 -3
- 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/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/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.js +66 -11
- package/dist/share.d.ts +1 -1
- package/dist/share.js +5 -4
- package/package.json +1 -1
- package/src/audio.ts +3 -0
- package/src/channels.ts +26 -4
- package/src/decoder-policy.ts +33 -0
- package/src/destinations.ts +78 -0
- package/src/library.ts +6 -1
- package/src/links.ts +23 -4
- package/src/playlist-input.ts +172 -50
- package/src/publish-choice.ts +35 -0
- package/src/server.ts +58 -11
- package/src/share.ts +5 -4
- package/web/dist/assets/{ar-8fDonUoB.js → ar-D6_ZFDan.js} +1 -1
- package/web/dist/assets/{cs-DCClolTy.js → cs-DEPGOGEV.js} +1 -1
- package/web/dist/assets/{da-C3RPhXUd.js → da-CQw7SPC9.js} +1 -1
- package/web/dist/assets/{de-CYBoEomk.js → de-OWnobbah.js} +1 -1
- package/web/dist/assets/{es-DS3ls4xT.js → es-qMpyH9bM.js} +1 -1
- package/web/dist/assets/{fi-DbCZpO89.js → fi-DpqqwJTD.js} +1 -1
- package/web/dist/assets/{fr-D0z3UpdL.js → fr-BiO_ceen.js} +1 -1
- package/web/dist/assets/{hi-BTU3sRlQ.js → hi-D-q5wZuY.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-CQXTVHn_.js → hls-3VKVEQE3-D2W4K8VR.js} +1 -1
- package/web/dist/assets/{hu-B37Q8ldT.js → hu-FIjHyhVC.js} +1 -1
- package/web/dist/assets/{id-4pdXNa4G.js → id-gmB6lu56.js} +1 -1
- package/web/dist/assets/index-UmGvzuun.js +22 -0
- package/web/dist/assets/{it-CTe69-UY.js → it-Dxfo713A.js} +1 -1
- package/web/dist/assets/{mpegts-BBmkDppm.js → mpegts-BT4XGr_E.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CUVdjYFi.js → mpegts-LO6RVLD6-CpZnlq0W.js} +1 -1
- package/web/dist/assets/{nl-CxrmHrMn.js → nl-mk5eN3Io.js} +1 -1
- package/web/dist/assets/{ru-zjVCfIKq.js → ru-B2psU0kN.js} +1 -1
- package/web/dist/assets/{sv-CEoiT2Lr.js → sv-Cg6waoNZ.js} +1 -1
- package/web/dist/assets/{uk-ZC6JzQvD.js → uk-BcFkukLd.js} +1 -1
- package/web/dist/assets/{vi-D9anF-cl.js → vi-CnqIC1Sz.js} +1 -1
- package/web/dist/assets/{zh-BeLYdC0I.js → zh-CvnXIFvN.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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a decoder may open while reading one source.
|
|
3
|
+
*
|
|
4
|
+
* ffmpeg follows references inside what it reads: an .m3u8 names segments, a
|
|
5
|
+
* concat list names files, a playlist on disk can name a URL. A file in
|
|
6
|
+
* somebody's library is untrusted input, and without a list of protocols it
|
|
7
|
+
* can make this machine fetch any address it likes, on the LAN included. So
|
|
8
|
+
* a file on disk may open files and pipes and nothing else, and a link may
|
|
9
|
+
* open the network protocols a stream needs and not the local file system.
|
|
10
|
+
*
|
|
11
|
+
* Sources this does not recognise (an ingest URL, a lavfi graph) are left
|
|
12
|
+
* alone rather than broken: the list is for what comes out of a library or a
|
|
13
|
+
* pasted link, which is where untrusted references come from.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A file in a library: itself, and the pipes the pipeline is built from. */
|
|
17
|
+
export const LOCAL_PROTOCOLS = "file,pipe,fd";
|
|
18
|
+
|
|
19
|
+
/** A link: the network, as streams use it, and never `file`. */
|
|
20
|
+
export const REMOTE_PROTOCOLS = [
|
|
21
|
+
"http", "https", "tcp", "tls", "crypto", "data", "httpproxy",
|
|
22
|
+
"rtmp", "rtmps", "rtmpt", "rtmpts", "rtmpe", "rtmpte", "ffrtmpcrypt", "ffrtmphttp",
|
|
23
|
+
].join(",");
|
|
24
|
+
|
|
25
|
+
/** The `-protocol_whitelist` to put before this source's `-i`, or nothing. */
|
|
26
|
+
export function protocolArgs(source: string): string[] {
|
|
27
|
+
if (source === "-" || source.startsWith("pipe:")) return ["-protocol_whitelist", LOCAL_PROTOCOLS];
|
|
28
|
+
if (/^(https?|rtmp[a-z]*):\/\//i.test(source)) return ["-protocol_whitelist", REMOTE_PROTOCOLS];
|
|
29
|
+
if (/^file:/i.test(source)) return ["-protocol_whitelist", LOCAL_PROTOCOLS];
|
|
30
|
+
// Any other scheme (two letters or more, so `C:\` is still a path).
|
|
31
|
+
if (/^[a-z][a-z0-9+.-]+:/i.test(source)) return [];
|
|
32
|
+
return ["-protocol_whitelist", LOCAL_PROTOCOLS];
|
|
33
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a link somebody else pasted may be fetched from this machine.
|
|
3
|
+
*
|
|
4
|
+
* A member -- any nixamp.com account -- may put a link on the air on a server
|
|
5
|
+
* that is not theirs. Fetching it is a request from inside the owner's
|
|
6
|
+
* network, so a link to the router, the cloud metadata address or a service
|
|
7
|
+
* on localhost would be the member reaching things only the owner can. The
|
|
8
|
+
* owner may fetch what they like: it is their network. A member's link has to
|
|
9
|
+
* resolve to a public address, every address it resolves to, at every hop
|
|
10
|
+
* this code follows.
|
|
11
|
+
*
|
|
12
|
+
* What it cannot see: a redirect yt-dlp or ffmpeg follows on its own, and a
|
|
13
|
+
* name that resolves differently a second later. The decoder's protocol list
|
|
14
|
+
* (decoder-policy.ts) keeps those off the file system; keeping them off the
|
|
15
|
+
* LAN entirely needs the fetch to happen here, which is later work.
|
|
16
|
+
*/
|
|
17
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
18
|
+
import { isIP } from "node:net";
|
|
19
|
+
|
|
20
|
+
export type Lookup = (host: string) => Promise<string[]>;
|
|
21
|
+
|
|
22
|
+
const defaultLookup: Lookup = async (host) =>
|
|
23
|
+
(await dnsLookup(host, { all: true, verbatim: true })).map((one) => one.address);
|
|
24
|
+
|
|
25
|
+
function v4Number(address: string): number {
|
|
26
|
+
return address.split(".").reduce((sum, part) => sum * 256 + Number(part), 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function inV4(address: string, base: string, bits: number): boolean {
|
|
30
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
31
|
+
return ((v4Number(address) & mask) >>> 0) === ((v4Number(base) & mask) >>> 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PRIVATE_V4: Array<[string, number]> = [
|
|
35
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
|
|
36
|
+
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
|
|
37
|
+
["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24],
|
|
38
|
+
["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Not somewhere on the public internet: loopback, private, link-local, reserved. */
|
|
42
|
+
export function isPrivateAddress(address: string): boolean {
|
|
43
|
+
const kind = isIP(address);
|
|
44
|
+
if (kind === 4) return PRIVATE_V4.some(([base, bits]) => inV4(address, base, bits));
|
|
45
|
+
if (kind !== 6) return true;
|
|
46
|
+
const lower = address.toLowerCase();
|
|
47
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
|
48
|
+
if (mapped) return isPrivateAddress(mapped[1]!);
|
|
49
|
+
if (lower === "::" || lower === "::1") return true;
|
|
50
|
+
const first = parseInt(lower.split(":")[0] || "0", 16);
|
|
51
|
+
// fc00::/7 unique local, fe80::/10 link-local, ff00::/8 multicast.
|
|
52
|
+
if ((first & 0xfe00) === 0xfc00) return true;
|
|
53
|
+
if ((first & 0xffc0) === 0xfe80) return true;
|
|
54
|
+
if ((first & 0xff00) === 0xff00) return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** "" when this link may be fetched for a member, else why not. */
|
|
59
|
+
export async function refusedDestination(link: string, lookup: Lookup = defaultLookup): Promise<string> {
|
|
60
|
+
let url: URL;
|
|
61
|
+
try {
|
|
62
|
+
url = new URL(link);
|
|
63
|
+
} catch {
|
|
64
|
+
return "that is not a link";
|
|
65
|
+
}
|
|
66
|
+
if (!/^(https?|rtmps?):$/i.test(url.protocol)) return "only http, https and rtmp links can be played here";
|
|
67
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
68
|
+
if (host === "" || /^localhost$/i.test(host) || /\.localhost$/i.test(host)) return "that link points at this machine";
|
|
69
|
+
let addresses: string[];
|
|
70
|
+
try {
|
|
71
|
+
addresses = isIP(host) ? [host] : await lookup(host);
|
|
72
|
+
} catch {
|
|
73
|
+
return "that link's host does not resolve";
|
|
74
|
+
}
|
|
75
|
+
if (addresses.length === 0) return "that link's host does not resolve";
|
|
76
|
+
if (addresses.some(isPrivateAddress)) return "that link points inside a private network, which only this server's owner may play";
|
|
77
|
+
return "";
|
|
78
|
+
}
|
package/src/library.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { stateDir } from "./daemon.ts";
|
|
|
23
23
|
export interface Config {
|
|
24
24
|
/** The folder the daemon serves when it is not told which. */
|
|
25
25
|
library?: string;
|
|
26
|
+
/** Whether this machine is listed in the public directory; unset until chosen. */
|
|
27
|
+
publish?: boolean;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export function configPath(): string {
|
|
@@ -34,7 +36,10 @@ export function readConfig(): Config {
|
|
|
34
36
|
const parsed = JSON.parse(readFileSync(configPath(), "utf8")) as unknown;
|
|
35
37
|
if (typeof parsed !== "object" || parsed === null) return {};
|
|
36
38
|
const record = parsed as Record<string, unknown>;
|
|
37
|
-
return {
|
|
39
|
+
return {
|
|
40
|
+
...(typeof record["library"] === "string" ? { library: record["library"] } : {}),
|
|
41
|
+
...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
|
|
42
|
+
};
|
|
38
43
|
} catch {
|
|
39
44
|
return {};
|
|
40
45
|
}
|
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/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
|
+
}
|