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/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"] });
@@ -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
- .filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id))
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
+ }
@@ -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 { ...(typeof record["library"] === "string" ? { library: record["library"] } : {}) };
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
- const answer = await get(url, { signal: abort.signal, headers: { "user-agent": "nixamp" }, redirect: "follow" });
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);
@@ -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
  }