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/dist/audio.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* same bytes to the output process. Running two decoders instead would drift
|
|
7
7
|
* apart within seconds and the bars would stop matching what you hear.
|
|
8
8
|
*/
|
|
9
|
+
import { protocolArgs } from "./decoder-policy.js";
|
|
9
10
|
import { spawn, spawnSync } from "node:child_process";
|
|
10
11
|
import { readdirSync } from "node:fs";
|
|
11
12
|
import { homedir } from "node:os";
|
|
@@ -109,6 +110,7 @@ export function probe(tools, path) {
|
|
|
109
110
|
...rest,
|
|
110
111
|
"-v", "quiet", "-print_format", "json",
|
|
111
112
|
"-show_format", "-show_entries", "format_tags=title,artist,album",
|
|
113
|
+
...protocolArgs(path),
|
|
112
114
|
path,
|
|
113
115
|
], { encoding: "utf8", timeout: 20_000, maxBuffer: 4 * 1024 * 1024 });
|
|
114
116
|
if (result.error || result.status !== 0)
|
|
@@ -190,6 +192,7 @@ export class Stream {
|
|
|
190
192
|
// being on disk. Measured with that condition in place: still 13.8x.
|
|
191
193
|
"-re",
|
|
192
194
|
...(from > 0 ? ["-ss", String(from)] : []),
|
|
195
|
+
...protocolArgs(track.path),
|
|
193
196
|
"-i", track.path,
|
|
194
197
|
"-f", "f32le", "-ac", String(CHANNELS), "-ar", String(RATE), "-",
|
|
195
198
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
package/dist/channels.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PlaylistInput } from "./playlist-input.ts";
|
|
1
2
|
import type { Readable } from "node:stream";
|
|
2
3
|
/** Somewhere for a channel's audio to go. A response, in practice. */
|
|
3
4
|
export interface Listener {
|
|
@@ -105,6 +106,13 @@ export interface PullResume {
|
|
|
105
106
|
/** For a list of things rather than one: every entry, in order. */
|
|
106
107
|
playlist?: string[];
|
|
107
108
|
playlistAt?: number;
|
|
109
|
+
/**
|
|
110
|
+
* How a station plays its list: round and round, in a shuffled order, with
|
|
111
|
+
* edits landing on the next track and broken entries passed over. Only for
|
|
112
|
+
* lists of files on this machine; not remembered, because whoever owns the
|
|
113
|
+
* rotation (the station) puts it back.
|
|
114
|
+
*/
|
|
115
|
+
rotation?: Pick<PlaylistInput, "repeat" | "order" | "current" | "excluded" | "onFailure" | "passOver">;
|
|
108
116
|
}
|
|
109
117
|
/**
|
|
110
118
|
* A source read by us rather than by ffmpeg: what to tell ffmpeg it is,
|
|
@@ -220,6 +228,10 @@ export declare class Channel {
|
|
|
220
228
|
private redial;
|
|
221
229
|
/** For a channel playing a list: move to the next entry. Null when there is no list. */
|
|
222
230
|
private advance;
|
|
231
|
+
/** The list being played entry by entry, when it is one; what skip() moves on. */
|
|
232
|
+
private playlistInput;
|
|
233
|
+
/** On to the next entry of a list of files. False when this is not one. */
|
|
234
|
+
skip(): boolean;
|
|
223
235
|
private failures;
|
|
224
236
|
private timer;
|
|
225
237
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
@@ -485,6 +497,8 @@ export declare class Channels {
|
|
|
485
497
|
setThrough(provider: ThroughProvider | null): void;
|
|
486
498
|
/** The kind of a channel, for a relay to say what it is carrying. */
|
|
487
499
|
kindOf(id: string): "audio" | "video" | undefined;
|
|
500
|
+
/** The next entry, now, on a channel playing a list of files. */
|
|
501
|
+
skip(id: string): boolean;
|
|
488
502
|
stop(id: string): boolean;
|
|
489
503
|
stopAll(): void;
|
|
490
504
|
}
|
package/dist/channels.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* what live means. MP3 frames are self-describing, so a player finds the next
|
|
15
15
|
* frame boundary and carries on; there is nothing to catch up on.
|
|
16
16
|
*/
|
|
17
|
+
import { protocolArgs } from "./decoder-policy.js";
|
|
17
18
|
import { spawn } from "node:child_process";
|
|
18
19
|
import { randomBytes } from "node:crypto";
|
|
19
20
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -134,6 +135,12 @@ export class Channel {
|
|
|
134
135
|
redial = null;
|
|
135
136
|
/** For a channel playing a list: move to the next entry. Null when there is no list. */
|
|
136
137
|
advance = null;
|
|
138
|
+
/** The list being played entry by entry, when it is one; what skip() moves on. */
|
|
139
|
+
playlistInput = null;
|
|
140
|
+
/** On to the next entry of a list of files. False when this is not one. */
|
|
141
|
+
skip() {
|
|
142
|
+
return this.playlistInput?.skip() ?? false;
|
|
143
|
+
}
|
|
137
144
|
failures = 0;
|
|
138
145
|
timer = null;
|
|
139
146
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
@@ -236,7 +243,7 @@ export class Channel {
|
|
|
236
243
|
// A list plays entry by entry: the one that is on is what gets dialled,
|
|
237
244
|
// and an entry that ended is followed by the next, at once.
|
|
238
245
|
const list = resume.playlist && resume.playlist.length > 0 ? resume.playlist : null;
|
|
239
|
-
const localPlaylist = list && list.length > 1 && list.every(one => !/^https?:\/\//i.test(one)) ? list : null;
|
|
246
|
+
const localPlaylist = list && (list.length > 1 || resume.rotation) && list.every(one => !/^https?:\/\//i.test(one)) ? list : null;
|
|
240
247
|
let at = 0;
|
|
241
248
|
if (list) {
|
|
242
249
|
this.info.playlist = list;
|
|
@@ -287,7 +294,9 @@ export class Channel {
|
|
|
287
294
|
ffprobe: this.options.ffprobe ?? this.options.ffmpeg.map(one => one.replace(/ffmpeg(?=(?:\.exe)?$)/, "ffprobe")),
|
|
288
295
|
files: localPlaylist, at: this.info.playlistAt ?? 0, position: from,
|
|
289
296
|
video: this.info.kind === "video", width: this.info.codecs?.width, height: this.info.codecs?.height,
|
|
297
|
+
...resume.rotation,
|
|
290
298
|
}) : null;
|
|
299
|
+
this.playlistInput = playlistInput;
|
|
291
300
|
const through = this.outroOn ? null : playlistInput
|
|
292
301
|
? { format: "mpegts", open: async (signal) => playlistInput.open(signal) }
|
|
293
302
|
: this.options.through?.(this.info, from, input, audio) ?? null;
|
|
@@ -338,10 +347,10 @@ export class Channel {
|
|
|
338
347
|
// What the source's site expects on the request: a user agent, a
|
|
339
348
|
// referer, a cookie. A link resolved by yt-dlp comes with these,
|
|
340
349
|
// and a CDN that got them from yt-dlp and not from us answers 403.
|
|
341
|
-
...input, ...seek, "-i", current,
|
|
350
|
+
...input, ...seek, ...protocolArgs(current), "-i", current,
|
|
342
351
|
// The sound, when the site keeps it apart from the picture: a
|
|
343
352
|
// second input, dialled the same way, that the encode maps in.
|
|
344
|
-
...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
|
|
353
|
+
...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, ...protocolArgs(audio), "-i", audio] : []),
|
|
345
354
|
]),
|
|
346
355
|
...(playlistEncode ?? encode),
|
|
347
356
|
"pipe:1",
|
|
@@ -1159,6 +1168,10 @@ export class Channels {
|
|
|
1159
1168
|
kindOf(id) {
|
|
1160
1169
|
return this.open.get(id)?.info.kind;
|
|
1161
1170
|
}
|
|
1171
|
+
/** The next entry, now, on a channel playing a list of files. */
|
|
1172
|
+
skip(id) {
|
|
1173
|
+
return this.open.get(id)?.skip() ?? false;
|
|
1174
|
+
}
|
|
1162
1175
|
stop(id) {
|
|
1163
1176
|
const channel = this.open.get(id);
|
|
1164
1177
|
if (!channel)
|
|
@@ -1239,7 +1252,10 @@ export function rememberedChannels(dir, port) {
|
|
|
1239
1252
|
*/
|
|
1240
1253
|
export function rememberedNow(channels) {
|
|
1241
1254
|
return channels.list()
|
|
1242
|
-
|
|
1255
|
+
// A station's channel is put back by its station, which knows whether it
|
|
1256
|
+
// is still wanted; remembering it here too would bring back one its
|
|
1257
|
+
// owner had stopped.
|
|
1258
|
+
.filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id) && !one.id.startsWith("station-"))
|
|
1243
1259
|
.map((one) => {
|
|
1244
1260
|
const kept = { id: one.id, name: one.name, source: one.source };
|
|
1245
1261
|
if (one.kind)
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
/** A file in a library: itself, and the pipes the pipeline is built from. */
|
|
16
|
+
export declare const LOCAL_PROTOCOLS = "file,pipe,fd";
|
|
17
|
+
/** A link: the network, as streams use it, and never `file`. */
|
|
18
|
+
export declare const REMOTE_PROTOCOLS: string;
|
|
19
|
+
/** The `-protocol_whitelist` to put before this source's `-i`, or nothing. */
|
|
20
|
+
export declare function protocolArgs(source: string): string[];
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
/** A file in a library: itself, and the pipes the pipeline is built from. */
|
|
16
|
+
export const LOCAL_PROTOCOLS = "file,pipe,fd";
|
|
17
|
+
/** A link: the network, as streams use it, and never `file`. */
|
|
18
|
+
export const REMOTE_PROTOCOLS = [
|
|
19
|
+
"http", "https", "tcp", "tls", "crypto", "data", "httpproxy",
|
|
20
|
+
"rtmp", "rtmps", "rtmpt", "rtmpts", "rtmpe", "rtmpte", "ffrtmpcrypt", "ffrtmphttp",
|
|
21
|
+
].join(",");
|
|
22
|
+
/** The `-protocol_whitelist` to put before this source's `-i`, or nothing. */
|
|
23
|
+
export function protocolArgs(source) {
|
|
24
|
+
if (source === "-" || source.startsWith("pipe:"))
|
|
25
|
+
return ["-protocol_whitelist", LOCAL_PROTOCOLS];
|
|
26
|
+
if (/^(https?|rtmp[a-z]*):\/\//i.test(source))
|
|
27
|
+
return ["-protocol_whitelist", REMOTE_PROTOCOLS];
|
|
28
|
+
if (/^file:/i.test(source))
|
|
29
|
+
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))
|
|
32
|
+
return [];
|
|
33
|
+
return ["-protocol_whitelist", LOCAL_PROTOCOLS];
|
|
34
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type Lookup = (host: string) => Promise<string[]>;
|
|
2
|
+
/** Not somewhere on the public internet: loopback, private, link-local, reserved. */
|
|
3
|
+
export declare function isPrivateAddress(address: string): boolean;
|
|
4
|
+
/** "" when this link may be fetched for a member, else why not. */
|
|
5
|
+
export declare function refusedDestination(link: string, lookup?: Lookup): Promise<string>;
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
const defaultLookup = async (host) => (await dnsLookup(host, { all: true, verbatim: true })).map((one) => one.address);
|
|
20
|
+
function v4Number(address) {
|
|
21
|
+
return address.split(".").reduce((sum, part) => sum * 256 + Number(part), 0);
|
|
22
|
+
}
|
|
23
|
+
function inV4(address, base, bits) {
|
|
24
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
25
|
+
return ((v4Number(address) & mask) >>> 0) === ((v4Number(base) & mask) >>> 0);
|
|
26
|
+
}
|
|
27
|
+
const PRIVATE_V4 = [
|
|
28
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
|
|
29
|
+
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
|
|
30
|
+
["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24],
|
|
31
|
+
["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
32
|
+
];
|
|
33
|
+
/** Not somewhere on the public internet: loopback, private, link-local, reserved. */
|
|
34
|
+
export function isPrivateAddress(address) {
|
|
35
|
+
const kind = isIP(address);
|
|
36
|
+
if (kind === 4)
|
|
37
|
+
return PRIVATE_V4.some(([base, bits]) => inV4(address, base, bits));
|
|
38
|
+
if (kind !== 6)
|
|
39
|
+
return true;
|
|
40
|
+
const lower = address.toLowerCase();
|
|
41
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
|
42
|
+
if (mapped)
|
|
43
|
+
return isPrivateAddress(mapped[1]);
|
|
44
|
+
if (lower === "::" || lower === "::1")
|
|
45
|
+
return true;
|
|
46
|
+
const first = parseInt(lower.split(":")[0] || "0", 16);
|
|
47
|
+
// fc00::/7 unique local, fe80::/10 link-local, ff00::/8 multicast.
|
|
48
|
+
if ((first & 0xfe00) === 0xfc00)
|
|
49
|
+
return true;
|
|
50
|
+
if ((first & 0xffc0) === 0xfe80)
|
|
51
|
+
return true;
|
|
52
|
+
if ((first & 0xff00) === 0xff00)
|
|
53
|
+
return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
/** "" when this link may be fetched for a member, else why not. */
|
|
57
|
+
export async function refusedDestination(link, lookup = defaultLookup) {
|
|
58
|
+
let url;
|
|
59
|
+
try {
|
|
60
|
+
url = new URL(link);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return "that is not a link";
|
|
64
|
+
}
|
|
65
|
+
if (!/^(https?|rtmps?):$/i.test(url.protocol))
|
|
66
|
+
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))
|
|
69
|
+
return "that link points at this machine";
|
|
70
|
+
let addresses;
|
|
71
|
+
try {
|
|
72
|
+
addresses = isIP(host) ? [host] : await lookup(host);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return "that link's host does not resolve";
|
|
76
|
+
}
|
|
77
|
+
if (addresses.length === 0)
|
|
78
|
+
return "that link's host does not resolve";
|
|
79
|
+
if (addresses.some(isPrivateAddress))
|
|
80
|
+
return "that link points inside a private network, which only this server's owner may play";
|
|
81
|
+
return "";
|
|
82
|
+
}
|
package/dist/directory.d.ts
CHANGED
|
@@ -146,6 +146,8 @@ export interface LineupEntry {
|
|
|
146
146
|
kind: "audio" | "video";
|
|
147
147
|
art: string;
|
|
148
148
|
about: string;
|
|
149
|
+
/** A station's own listening page, on the same server; only for a station its owner listed. */
|
|
150
|
+
listen?: string;
|
|
149
151
|
}
|
|
150
152
|
/** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
|
|
151
153
|
export declare function clean(value: unknown, max: number): string;
|
|
@@ -158,7 +160,7 @@ export declare function clean(value: unknown, max: number): string;
|
|
|
158
160
|
export declare function publishable(raw: string): URL | null;
|
|
159
161
|
export declare function parseAnnouncement(input: unknown): Announcement | null;
|
|
160
162
|
/** One lineup entry as a publisher sent it, cleaned, or null when it is not one. */
|
|
161
|
-
export declare function parseLineupEntry(input: unknown): LineupEntry | null;
|
|
163
|
+
export declare function parseLineupEntry(input: unknown, origin?: string): LineupEntry | null;
|
|
162
164
|
/**
|
|
163
165
|
* The registry. In memory on purpose: see the note at the top of the file.
|
|
164
166
|
* One entry per URL, so a publisher restarting does not leave a ghost of
|
package/dist/directory.js
CHANGED
|
@@ -95,12 +95,12 @@ export function parseAnnouncement(input) {
|
|
|
95
95
|
}
|
|
96
96
|
: {}),
|
|
97
97
|
...(Array.isArray(record["lineup"])
|
|
98
|
-
? { lineup: record["lineup"].map(parseLineupEntry).filter((one) => one !== null).slice(0, MAX_CHANNELS) }
|
|
98
|
+
? { lineup: record["lineup"].map((one) => parseLineupEntry(one, listen.origin)).filter((one) => one !== null).slice(0, MAX_CHANNELS) }
|
|
99
99
|
: {}),
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
/** One lineup entry as a publisher sent it, cleaned, or null when it is not one. */
|
|
103
|
-
export function parseLineupEntry(input) {
|
|
103
|
+
export function parseLineupEntry(input, origin = "") {
|
|
104
104
|
if (typeof input !== "object" || input === null)
|
|
105
105
|
return null;
|
|
106
106
|
const record = input;
|
|
@@ -120,6 +120,12 @@ export function parseLineupEntry(input) {
|
|
|
120
120
|
// page in a tag, and javascript: is a page in a tag that runs.
|
|
121
121
|
art: publishable(art) !== null ? art : "",
|
|
122
122
|
about: clean(record["about"], MAX_ABOUT),
|
|
123
|
+
// The same server as the listing, or nothing, like the audio address.
|
|
124
|
+
...(() => {
|
|
125
|
+
const offered = typeof record["listen"] === "string" ? record["listen"].slice(0, MAX_ART) : "";
|
|
126
|
+
const parsed = offered && origin ? publishable(offered) : null;
|
|
127
|
+
return parsed !== null && parsed.origin === origin ? { listen: offered } : {};
|
|
128
|
+
})(),
|
|
123
129
|
};
|
|
124
130
|
}
|
|
125
131
|
/**
|
package/dist/library.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export interface Config {
|
|
2
2
|
/** The folder the daemon serves when it is not told which. */
|
|
3
3
|
library?: string;
|
|
4
|
+
/** Whether this machine is listed in the public directory; unset until chosen. */
|
|
5
|
+
publish?: boolean;
|
|
4
6
|
}
|
|
5
7
|
export declare function configPath(): string;
|
|
6
8
|
export declare function readConfig(): Config;
|
package/dist/library.js
CHANGED
|
@@ -28,7 +28,10 @@ export function readConfig() {
|
|
|
28
28
|
if (typeof parsed !== "object" || parsed === null)
|
|
29
29
|
return {};
|
|
30
30
|
const record = parsed;
|
|
31
|
-
return {
|
|
31
|
+
return {
|
|
32
|
+
...(typeof record["library"] === "string" ? { library: record["library"] } : {}),
|
|
33
|
+
...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
|
|
34
|
+
};
|
|
32
35
|
}
|
|
33
36
|
catch {
|
|
34
37
|
return {};
|
package/dist/links.d.ts
CHANGED
|
@@ -70,6 +70,8 @@ export declare function playlistFrom(text: string, base: string): {
|
|
|
70
70
|
sources: string[];
|
|
71
71
|
image: string;
|
|
72
72
|
};
|
|
73
|
+
/** Asked of every address before it is fetched: "" to go ahead, else why not. */
|
|
74
|
+
export type Guard = (url: string) => Promise<string>;
|
|
73
75
|
/**
|
|
74
76
|
* A pasted .m3u, read and turned into a channel's worth of entries. Fetched
|
|
75
77
|
* here rather than by yt-dlp, which would take the first entry and stop, or
|
|
@@ -78,6 +80,7 @@ export declare function playlistFrom(text: string, base: string): {
|
|
|
78
80
|
export declare function resolvePlaylist(url: string, options?: {
|
|
79
81
|
timeoutMs?: number;
|
|
80
82
|
fetcher?: typeof fetch;
|
|
83
|
+
guard?: Guard;
|
|
81
84
|
}): Promise<ResolvedLink | {
|
|
82
85
|
error: string;
|
|
83
86
|
}>;
|
|
@@ -133,6 +136,7 @@ export declare function resolveLink(ytdlp: string[] | null, url: string, options
|
|
|
133
136
|
cookies?: string;
|
|
134
137
|
timeoutMs?: number;
|
|
135
138
|
format?: string;
|
|
139
|
+
guard?: Guard;
|
|
136
140
|
}): Promise<ResolvedLink | {
|
|
137
141
|
error: string;
|
|
138
142
|
}>;
|
package/dist/links.js
CHANGED
|
@@ -106,6 +106,7 @@ export function playlistFrom(text, base) {
|
|
|
106
106
|
const image = entries.map((entry) => pictureUrl(entry.logo)).find((one) => one !== "") ?? "";
|
|
107
107
|
return { hls: false, sources: entries.map((entry) => entry.source), image };
|
|
108
108
|
}
|
|
109
|
+
const PLAYLIST_MAX_REDIRECTS = 5;
|
|
109
110
|
/**
|
|
110
111
|
* A pasted .m3u, read and turned into a channel's worth of entries. Fetched
|
|
111
112
|
* here rather than by yt-dlp, which would take the first entry and stop, or
|
|
@@ -117,7 +118,24 @@ export async function resolvePlaylist(url, options = {}) {
|
|
|
117
118
|
const timer = setTimeout(() => abort.abort(), options.timeoutMs ?? PLAYLIST_FETCH_TIMEOUT_MS);
|
|
118
119
|
let text = "";
|
|
119
120
|
try {
|
|
120
|
-
|
|
121
|
+
// Followed here, one hop at a time, when there is a guard: a public
|
|
122
|
+
// address that redirects to the router is the router.
|
|
123
|
+
let at = url;
|
|
124
|
+
let answer;
|
|
125
|
+
for (let hop = 0;; hop += 1) {
|
|
126
|
+
if (options.guard) {
|
|
127
|
+
const refused = await options.guard(at);
|
|
128
|
+
if (refused)
|
|
129
|
+
return { error: refused };
|
|
130
|
+
}
|
|
131
|
+
answer = await get(at, { signal: abort.signal, headers: { "user-agent": "nixamp" }, redirect: options.guard ? "manual" : "follow" });
|
|
132
|
+
const next = answer.headers.get("location");
|
|
133
|
+
if (!options.guard || answer.status < 300 || answer.status >= 400 || !next)
|
|
134
|
+
break;
|
|
135
|
+
if (hop >= PLAYLIST_MAX_REDIRECTS)
|
|
136
|
+
return { error: "that playlist redirects too many times" };
|
|
137
|
+
at = new URL(next, at).toString();
|
|
138
|
+
}
|
|
121
139
|
if (!answer.ok)
|
|
122
140
|
return { error: `that playlist could not be fetched (HTTP ${answer.status})` };
|
|
123
141
|
text = (await answer.text()).slice(0, PLAYLIST_MAX_BYTES);
|
|
@@ -442,7 +460,7 @@ export async function resolveLink(ytdlp, url, options = {}) {
|
|
|
442
460
|
if (isDirectMedia(url))
|
|
443
461
|
return directLink(url);
|
|
444
462
|
if (isPlaylistLink(url))
|
|
445
|
-
return resolvePlaylist(url, { timeoutMs: options.timeoutMs });
|
|
463
|
+
return resolvePlaylist(url, { timeoutMs: options.timeoutMs, guard: options.guard });
|
|
446
464
|
// Without yt-dlp, a link is taken as the media it may well be: an IPTV
|
|
447
465
|
// feed has no extension and no page behind it, and ffprobe -- asked next,
|
|
448
466
|
// before anything goes on the air -- tells a stream from a web page in a
|
package/dist/main.d.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* nixamp — it really whips the terminal's ass.
|
|
3
|
-
*
|
|
4
|
-
* bunx nixamp ~/Music
|
|
5
|
-
* bunx nixamp track.flac
|
|
6
|
-
*
|
|
7
|
-
* ffmpeg decodes; we read every sample on its way to the speakers and draw it.
|
|
8
|
-
*/
|
|
9
1
|
import { type BrailleCanvas, type Container, type Theme } from "@profullstack/hqtui";
|
|
10
2
|
import { type Track } from "./audio.ts";
|
|
11
3
|
export declare const BAND_COUNT = 24;
|
package/dist/main.js
CHANGED
|
@@ -7,6 +7,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.js"
|
|
|
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.js";
|
|
10
11
|
import { createApp, themes } from "@profullstack/hqtui";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { resolve } from "node:path";
|
|
@@ -206,6 +207,7 @@ keeps serving its browser remote. One per user.
|
|
|
206
207
|
|
|
207
208
|
nixamp attach put the player back in front of it
|
|
208
209
|
nixamp admin who is connected, and re-stream to them
|
|
210
|
+
nixamp station make a folder into a station others can join (see: nixamp station help)
|
|
209
211
|
|
|
210
212
|
From inside the player, d hands the music to a daemon without stopping it.
|
|
211
213
|
`,
|
|
@@ -367,6 +369,8 @@ q or d leaves; neither stops anything. \`nixamp daemon stop\` is what stops it.
|
|
|
367
369
|
`,
|
|
368
370
|
};
|
|
369
371
|
export function helpFor(topic) {
|
|
372
|
+
if (topic === "station" || topic === "stations")
|
|
373
|
+
return STATION_USAGE;
|
|
370
374
|
return (topic ? TOPICS[topic] : undefined) ?? HELP;
|
|
371
375
|
}
|
|
372
376
|
/**
|
|
@@ -524,6 +528,11 @@ export async function main() {
|
|
|
524
528
|
process.exitCode = await runDaemon(rest);
|
|
525
529
|
return;
|
|
526
530
|
}
|
|
531
|
+
if (first === "station" || first === "stations") {
|
|
532
|
+
const { station } = await import("./station-cli.js");
|
|
533
|
+
process.exitCode = await station(rest);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
527
536
|
if (first === "admin") {
|
|
528
537
|
const { admin } = await import("./admin.js");
|
|
529
538
|
await admin(rest);
|
package/dist/playlist-input.d.ts
CHANGED
|
@@ -7,7 +7,41 @@ export interface PlaylistInput {
|
|
|
7
7
|
video: boolean;
|
|
8
8
|
width?: number;
|
|
9
9
|
height?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Back to the top when the end is reached, instead of finishing: a
|
|
12
|
+
* station, which plays until somebody stops it.
|
|
13
|
+
*/
|
|
14
|
+
repeat?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* The order to play a list of this length in, asked at the top of every
|
|
17
|
+
* pass: a station-wide shuffle is one order for everybody listening, drawn
|
|
18
|
+
* again each time round. Unset is the list's own order.
|
|
19
|
+
*/
|
|
20
|
+
order?: (length: number) => number[];
|
|
21
|
+
/** Read at every entry boundary, so an edit to the list lands on the next track. */
|
|
22
|
+
current?: () => string[];
|
|
23
|
+
/** Entries already known not to play, passed over without being tried. */
|
|
24
|
+
excluded?: (file: string) => boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Pass over an entry that will not play, instead of failing the stream.
|
|
27
|
+
* For a station, where the next track is what a listener should hear. A
|
|
28
|
+
* folder live leaves it unset: there a missing entry is usually a file
|
|
29
|
+
* still arriving, and the channel redials and waits for it.
|
|
30
|
+
*/
|
|
31
|
+
passOver?: boolean;
|
|
32
|
+
/** Told when an entry is passed over because it would not play. */
|
|
33
|
+
onFailure?: (event: {
|
|
34
|
+
at: number;
|
|
35
|
+
file: string;
|
|
36
|
+
reason: string;
|
|
37
|
+
}) => void;
|
|
10
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* How many entries in a row may fail before the list is given up on. A
|
|
41
|
+
* corrupt file is skipped; a list where nothing plays is an error, said once,
|
|
42
|
+
* not a loop.
|
|
43
|
+
*/
|
|
44
|
+
export declare const MAX_FAILURES_IN_A_ROW = 5;
|
|
11
45
|
/** Decode each file independently; feed one persistent muxer a fixed stream
|
|
12
46
|
* profile and continuous timestamps. A concat demuxer cannot decode a list
|
|
13
47
|
* whose input codecs differ. Only one entry is encoded at a time, with pipe
|
|
@@ -18,9 +52,21 @@ export declare class PlaylistInputStream {
|
|
|
18
52
|
error: Error | null;
|
|
19
53
|
complete: boolean;
|
|
20
54
|
constructor(options: PlaylistInput);
|
|
55
|
+
/** Every entry that has started, in the order it started, with where it sits in the stream. */
|
|
56
|
+
get history(): ReadonlyArray<{
|
|
57
|
+
at: number;
|
|
58
|
+
file: string;
|
|
59
|
+
start: number;
|
|
60
|
+
position: number;
|
|
61
|
+
}>;
|
|
21
62
|
place(seconds: number): {
|
|
22
63
|
at: number;
|
|
23
64
|
position: number;
|
|
24
65
|
};
|
|
66
|
+
/** The decoder for the entry that is on, so skip() can end it early. */
|
|
67
|
+
private stopCurrent;
|
|
68
|
+
private skipping;
|
|
69
|
+
/** End the entry that is on and go to the next. False when nothing is on. */
|
|
70
|
+
skip(): boolean;
|
|
25
71
|
open(signal: AbortSignal): AsyncGenerator<Buffer>;
|
|
26
72
|
}
|