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.
Files changed (53) hide show
  1. package/dist/audio.js +3 -0
  2. package/dist/channels.d.ts +14 -0
  3. package/dist/channels.js +16 -3
  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/library.d.ts +2 -0
  9. package/dist/library.js +4 -1
  10. package/dist/links.d.ts +4 -0
  11. package/dist/links.js +20 -2
  12. package/dist/playlist-input.d.ts +46 -0
  13. package/dist/playlist-input.js +158 -54
  14. package/dist/publish-choice.d.ts +21 -0
  15. package/dist/publish-choice.js +17 -0
  16. package/dist/server.js +66 -11
  17. package/dist/share.d.ts +1 -1
  18. package/dist/share.js +5 -4
  19. package/package.json +1 -1
  20. package/src/audio.ts +3 -0
  21. package/src/channels.ts +26 -4
  22. package/src/decoder-policy.ts +33 -0
  23. package/src/destinations.ts +78 -0
  24. package/src/library.ts +6 -1
  25. package/src/links.ts +23 -4
  26. package/src/playlist-input.ts +172 -50
  27. package/src/publish-choice.ts +35 -0
  28. package/src/server.ts +58 -11
  29. package/src/share.ts +5 -4
  30. package/web/dist/assets/{ar-8fDonUoB.js → ar-D6_ZFDan.js} +1 -1
  31. package/web/dist/assets/{cs-DCClolTy.js → cs-DEPGOGEV.js} +1 -1
  32. package/web/dist/assets/{da-C3RPhXUd.js → da-CQw7SPC9.js} +1 -1
  33. package/web/dist/assets/{de-CYBoEomk.js → de-OWnobbah.js} +1 -1
  34. package/web/dist/assets/{es-DS3ls4xT.js → es-qMpyH9bM.js} +1 -1
  35. package/web/dist/assets/{fi-DbCZpO89.js → fi-DpqqwJTD.js} +1 -1
  36. package/web/dist/assets/{fr-D0z3UpdL.js → fr-BiO_ceen.js} +1 -1
  37. package/web/dist/assets/{hi-BTU3sRlQ.js → hi-D-q5wZuY.js} +1 -1
  38. package/web/dist/assets/{hls-3VKVEQE3-CQXTVHn_.js → hls-3VKVEQE3-D2W4K8VR.js} +1 -1
  39. package/web/dist/assets/{hu-B37Q8ldT.js → hu-FIjHyhVC.js} +1 -1
  40. package/web/dist/assets/{id-4pdXNa4G.js → id-gmB6lu56.js} +1 -1
  41. package/web/dist/assets/index-UmGvzuun.js +22 -0
  42. package/web/dist/assets/{it-CTe69-UY.js → it-Dxfo713A.js} +1 -1
  43. package/web/dist/assets/{mpegts-BBmkDppm.js → mpegts-BT4XGr_E.js} +1 -1
  44. package/web/dist/assets/{mpegts-LO6RVLD6-CUVdjYFi.js → mpegts-LO6RVLD6-CpZnlq0W.js} +1 -1
  45. package/web/dist/assets/{nl-CxrmHrMn.js → nl-mk5eN3Io.js} +1 -1
  46. package/web/dist/assets/{ru-zjVCfIKq.js → ru-B2psU0kN.js} +1 -1
  47. package/web/dist/assets/{sv-CEoiT2Lr.js → sv-Cg6waoNZ.js} +1 -1
  48. package/web/dist/assets/{uk-ZC6JzQvD.js → uk-BcFkukLd.js} +1 -1
  49. package/web/dist/assets/{vi-D9anF-cl.js → vi-CnqIC1Sz.js} +1 -1
  50. package/web/dist/assets/{zh-BeLYdC0I.js → zh-CvnXIFvN.js} +1 -1
  51. package/web/dist/index.html +1 -1
  52. package/web/dist/sw.js +22 -22
  53. package/web/dist/assets/index-BDBbN5Qy.js +0 -22
@@ -1,5 +1,12 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { protocolArgs } from "./decoder-policy.js";
2
3
  import { codecsOf } from "./audio.js";
4
+ /**
5
+ * How many entries in a row may fail before the list is given up on. A
6
+ * corrupt file is skipped; a list where nothing plays is an error, said once,
7
+ * not a loop.
8
+ */
9
+ export const MAX_FAILURES_IN_A_ROW = 5;
3
10
  /** Decode each file independently; feed one persistent muxer a fixed stream
4
11
  * profile and continuous timestamps. A concat demuxer cannot decode a list
5
12
  * whose input codecs differ. Only one entry is encoded at a time, with pipe
@@ -12,11 +19,26 @@ export class PlaylistInputStream {
12
19
  constructor(options) {
13
20
  this.options = options;
14
21
  }
22
+ /** Every entry that has started, in the order it started, with where it sits in the stream. */
23
+ get history() {
24
+ return this.entries;
25
+ }
15
26
  place(seconds) {
16
27
  const entry = this.entries.findLast(one => seconds >= one.start) ?? this.entries[0];
17
28
  return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position) }
18
29
  : { at: this.options.at, position: this.options.position };
19
30
  }
31
+ /** The decoder for the entry that is on, so skip() can end it early. */
32
+ stopCurrent = null;
33
+ skipping = false;
34
+ /** End the entry that is on and go to the next. False when nothing is on. */
35
+ skip() {
36
+ if (!this.stopCurrent)
37
+ return false;
38
+ this.skipping = true;
39
+ this.stopCurrent();
40
+ return true;
41
+ }
20
42
  async *open(signal) {
21
43
  const o = this.options;
22
44
  const [command, ...prefix] = o.ffmpeg;
@@ -24,66 +46,148 @@ export class PlaylistInputStream {
24
46
  const width = Math.max(2, Math.floor((o.width || 1280) * ratio / 2) * 2);
25
47
  const height = Math.max(2, Math.floor((o.height || 720) * ratio / 2) * 2);
26
48
  let offset = 0;
49
+ let failuresInARow = 0;
50
+ let first = true;
27
51
  try {
28
- for (let at = o.at; at < o.files.length && !signal.aborted; at++) {
29
- const file = o.files[at];
30
- const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)]));
52
+ const orderFor = (list) => o.order ? o.order(list.length).filter((one) => one >= 0 && one < list.length) : list.map((_, index) => index);
53
+ let files = o.current?.() ?? o.files;
54
+ if (files.length === 0)
55
+ throw new Error("The playlist is empty");
56
+ let order = orderFor(files);
57
+ let step = Math.min(o.at, order.length);
58
+ let played = 0;
59
+ let last = "";
60
+ for (;;) {
31
61
  if (signal.aborted)
32
- return;
33
- if (!codecs.audio && !codecs.video)
34
- throw new Error(`Cannot decode playlist entry ${at + 1}`);
35
- const position = at === o.at ? o.position : 0;
36
- const duration = (codecs.duration ?? 0) - position;
37
- if (duration <= 0)
38
- throw new Error(`No playable duration for playlist entry ${at + 1}`);
39
- this.entries.push({ at, start: offset, position });
40
- const hasVideo = Boolean(codecs.video);
41
- const extra = o.video && !hasVideo
42
- ? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
43
- : o.video && !codecs.audio ? ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"] : [];
44
- const args = [
45
- ...prefix, "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "2",
46
- "-threads", "2", ...(position > 0 ? ["-ss", String(position)] : []), "-i", file,
47
- ...extra, "-t", String(duration),
48
- ...(o.video ? [
49
- "-map", hasVideo ? "0:v:0" : "1:v:0", "-map", codecs.audio ? "0:a:0" : "1:a:0",
50
- "-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`,
51
- "-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency",
52
- "-crf", "23", "-pix_fmt", "yuv420p", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
53
- ] : ["-map", "0:a:0", "-vn"]),
54
- "-af", "aresample=48000,asetpts=PTS-STARTPTS", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
55
- "-mpegts_flags", "+initial_discontinuity", "-output_ts_offset", String(offset), "-mpegts_copyts", "1", "-muxdelay", "0", "-muxpreload", "0",
56
- "-f", "mpegts", "pipe:1",
57
- ];
58
- const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
59
- let tail = "";
60
- child.stderr.on("data", chunk => { tail = (tail + chunk.toString()).slice(-1500); });
61
- const closed = new Promise((resolve) => {
62
- child.once("error", error => { tail = error.message; resolve(-1); });
63
- child.once("close", code => resolve(code));
64
- });
65
- const abort = () => { child.kill("SIGKILL"); };
66
- signal.addEventListener("abort", abort, { once: true });
67
- try {
68
- for await (const chunk of child.stdout) {
62
+ break;
63
+ // Asked at every boundary, so an edit lands on the next track. The
64
+ // list goes on after the file that just played, where the new list
65
+ // has it; a shuffled list draws a new order.
66
+ if (o.current) {
67
+ const fresh = o.current();
68
+ if (fresh.length !== files.length || fresh.some((one, index) => one !== files[index])) {
69
+ if (fresh.length === 0)
70
+ throw new Error("The playlist is empty");
71
+ files = fresh;
72
+ order = orderFor(files);
73
+ const where = last ? files.indexOf(last) : -1;
74
+ step = o.order ? 0 : where >= 0 ? where + 1 : Math.min(step, files.length);
75
+ }
76
+ }
77
+ if (step >= order.length) {
78
+ if (!o.repeat)
79
+ break;
80
+ // A pass where everything was passed over is a list with nothing
81
+ // to play, and repeating it would be the loop this exists to avoid.
82
+ if (played === 0)
83
+ throw new Error("Nothing in this playlist would play");
84
+ played = 0;
85
+ order = orderFor(files);
86
+ step = 0;
87
+ continue;
88
+ }
89
+ {
90
+ const at = order[step];
91
+ step += 1;
92
+ const file = files[at];
93
+ last = file;
94
+ if (o.excluded?.(file))
95
+ continue;
96
+ const position = first && step - 1 === o.at ? o.position : 0;
97
+ first = false;
98
+ let reached = 0;
99
+ try {
100
+ const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)]));
69
101
  if (signal.aborted)
70
102
  return;
71
- yield chunk;
103
+ if (!codecs.audio && !codecs.video)
104
+ throw new Error(`Cannot decode playlist entry ${at + 1}`);
105
+ const duration = (codecs.duration ?? 0) - position;
106
+ if (duration <= 0)
107
+ throw new Error(`No playable duration for playlist entry ${at + 1}`);
108
+ this.entries.push({ at, file, start: offset, position });
109
+ const hasVideo = Boolean(codecs.video);
110
+ const extra = o.video && !hasVideo
111
+ ? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
112
+ : o.video && !codecs.audio ? ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"] : [];
113
+ const args = [
114
+ ...prefix, "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "2",
115
+ // How far this entry got, so an entry cut short -- skipped, or
116
+ // broken halfway -- moves the clock by what was played and the
117
+ // next one starts where this one stopped, not after a gap.
118
+ "-progress", "pipe:3", "-stats_period", "0.5",
119
+ "-threads", "2", ...(position > 0 ? ["-ss", String(position)] : []), ...protocolArgs(file), "-i", file,
120
+ ...extra, "-t", String(duration),
121
+ ...(o.video ? [
122
+ "-map", hasVideo ? "0:v:0" : "1:v:0", "-map", codecs.audio ? "0:a:0" : "1:a:0",
123
+ "-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`,
124
+ "-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency",
125
+ "-crf", "23", "-pix_fmt", "yuv420p", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
126
+ ] : ["-map", "0:a:0", "-vn"]),
127
+ "-af", "aresample=48000,asetpts=PTS-STARTPTS", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
128
+ "-mpegts_flags", "+initial_discontinuity", "-output_ts_offset", String(offset), "-mpegts_copyts", "1", "-muxdelay", "0", "-muxpreload", "0",
129
+ "-f", "mpegts", "pipe:1",
130
+ ];
131
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe", "pipe"] });
132
+ let tail = "";
133
+ child.stderr.on("data", chunk => { tail = (tail + chunk.toString()).slice(-1500); });
134
+ child.stdio[3]?.on("data", (chunk) => {
135
+ const found = /out_time_us=(\d+)/g;
136
+ let match;
137
+ while ((match = found.exec(chunk.toString())) !== null)
138
+ reached = Math.max(reached, Number(match[1]) / 1e6);
139
+ });
140
+ const closed = new Promise((resolve) => {
141
+ child.once("error", error => { tail = error.message; resolve(-1); });
142
+ child.once("close", code => resolve(code));
143
+ });
144
+ const abort = () => { child.kill("SIGKILL"); };
145
+ signal.addEventListener("abort", abort, { once: true });
146
+ this.stopCurrent = abort;
147
+ let skipped = false;
148
+ try {
149
+ for await (const chunk of child.stdout) {
150
+ if (signal.aborted)
151
+ return;
152
+ yield chunk;
153
+ }
154
+ const code = await closed;
155
+ if (signal.aborted)
156
+ return;
157
+ skipped = this.skipping;
158
+ if (code !== 0 && !skipped)
159
+ throw new Error(`Playlist entry ${at + 1} failed: ${tail.trim()}`);
160
+ }
161
+ finally {
162
+ this.stopCurrent = null;
163
+ this.skipping = false;
164
+ signal.removeEventListener("abort", abort);
165
+ abort();
166
+ await closed;
167
+ }
168
+ failuresInARow = 0;
169
+ played += 1;
170
+ // Match the frame grid used above so rounding cannot accumulate a
171
+ // backwards timestamp over hundreds of short files.
172
+ const length = skipped ? Math.min(duration, reached) : duration;
173
+ offset += o.video ? Math.ceil(length * 30) / 30 : length;
174
+ }
175
+ catch (error) {
176
+ if (signal.aborted || !o.passOver)
177
+ throw error;
178
+ // Passed over, not dialled again: the next entry is what a
179
+ // listener should hear, and a file that will not decode now will
180
+ // not decode in two seconds either.
181
+ const reason = error instanceof Error ? error.message : String(error);
182
+ o.onFailure?.({ at, file, reason });
183
+ if (reached > 0)
184
+ offset += o.video ? Math.ceil(reached * 30) / 30 : reached;
185
+ failuresInARow += 1;
186
+ if (failuresInARow >= Math.min(MAX_FAILURES_IN_A_ROW, Math.max(1, files.length))) {
187
+ throw new Error(`Nothing in this playlist would play: ${reason}`);
188
+ }
72
189
  }
73
- const code = await closed;
74
- if (signal.aborted)
75
- return;
76
- if (code !== 0)
77
- throw new Error(`Playlist entry ${at + 1} failed: ${tail.trim()}`);
78
- }
79
- finally {
80
- signal.removeEventListener("abort", abort);
81
- abort();
82
- await closed;
83
190
  }
84
- // Match the frame grid used above so rounding cannot accumulate a
85
- // backwards timestamp over hundreds of short files.
86
- offset += o.video ? Math.ceil(duration * 30) / 30 : duration;
87
191
  }
88
192
  this.complete = !signal.aborted;
89
193
  }
@@ -0,0 +1,21 @@
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
+ * "yes" or "no" when the answer is already known, "ask" when there is a
17
+ * person at the terminal to ask.
18
+ */
19
+ export declare function publishChoice(flag: PublishFlag, recorded: boolean | undefined, interactive: boolean, established: boolean): "yes" | "no" | "ask";
20
+ /** Whether to write the answer down: only a choice nobody had made yet. */
21
+ export declare function shouldRecord(flag: PublishFlag, recorded: boolean | undefined): boolean;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * "yes" or "no" when the answer is already known, "ask" when there is a
3
+ * person at the terminal to ask.
4
+ */
5
+ export function publishChoice(flag, recorded, interactive, established) {
6
+ if (flag !== "ask")
7
+ return flag;
8
+ if (recorded !== undefined)
9
+ return recorded ? "yes" : "no";
10
+ if (interactive)
11
+ return "ask";
12
+ return established ? "yes" : "no";
13
+ }
14
+ /** Whether to write the answer down: only a choice nobody had made yet. */
15
+ export function shouldRecord(flag, recorded) {
16
+ return flag === "ask" && recorded === undefined;
17
+ }
package/dist/server.js CHANGED
@@ -9,7 +9,9 @@
9
9
  * a WebSocket because SSE is plain HTTP: no dependency, and it reconnects on
10
10
  * its own when the laptop running the remote goes to sleep.
11
11
  */
12
- import { createReadStream, statSync } from "node:fs";
12
+ import { publishChoice, shouldRecord } from "./publish-choice.js";
13
+ import { protocolArgs } from "./decoder-policy.js";
14
+ import { createReadStream, existsSync, statSync } from "node:fs";
13
15
  import { createServer as createHttpServer } from "node:http";
14
16
  import { createServer as createHttpsServer } from "node:https";
15
17
  import { randomBytes } from "node:crypto";
@@ -51,6 +53,7 @@ import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
51
53
  import { ArtCache, coverArtOf, stillFrom } from "./art.js";
52
54
  import { PartyLine, telnyxSms } from "./partyline.js";
53
55
  import { HlsPackagers, segmentType, withKey } from "./hls.js";
56
+ import { refusedDestination } from "./destinations.js";
54
57
  import { CompressionService } from "./compression/service.js";
55
58
  import { handleChannelCompression, handleCompressionApi, handleStaticRelay } from "./compression/routes.js";
56
59
  import { DEFAULT_SITE as NICHEDB, Enricher, FIXTURE_TTL_MS } from "./enrich.js";
@@ -65,7 +68,7 @@ import { Porkbun, isIPv4, isIPv6 } from "./dns.js";
65
68
  import { NameError, Names } from "./names.js";
66
69
  import { AcmeIssuer, Certs } from "./certs.js";
67
70
  import { claimName, fetchCert, humanizeSource, labelFor, readCertFiles, writeCertFiles } from "./naming.js";
68
- import { forbiddenLibrary, readLibrary } from "./library.js";
71
+ import { forbiddenLibrary, readConfig, readLibrary, writeConfig } from "./library.js";
69
72
  import { createThrottle, presentedCredential } from "@profullstack/throttle";
70
73
  import { Durable } from "./durable.js";
71
74
  import { notifyAll, resendEmail, webPush } from "./notify.js";
@@ -1469,7 +1472,7 @@ export function createHandler(engine, options) {
1469
1472
  json(response, 404, { error: "not found" });
1470
1473
  return;
1471
1474
  }
1472
- response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered), location: "/" });
1475
+ response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered, request.socket.encrypted === true || (options.secureCookies ?? false)), location: "/" });
1473
1476
  response.end();
1474
1477
  return;
1475
1478
  }
@@ -2061,12 +2064,18 @@ export function createHandler(engine, options) {
2061
2064
  // port is open from another device before wondering whether the link is
2062
2065
  // wrong, and it says nothing about the library.
2063
2066
  const eventChannelRequest = Boolean(channelId && await eventForChannel());
2067
+ // Whether this request holds only the listen key. A listener may browse
2068
+ // and hear the library, but where it lives on this machine is the
2069
+ // owner's business: the absolute path names the user account and the disk.
2070
+ let listening = false;
2064
2071
  if (key !== null &&
2065
2072
  path !== "/api/health" &&
2066
2073
  path !== "/api/directory" &&
2067
2074
  !isSignInPath(path) &&
2068
2075
  !eventChannelRequest) {
2069
2076
  let scope = scopeOf(keyFrom(request, url), key, listenKey);
2077
+ // Kept for the routes below that answer a listener with less.
2078
+ const settle = () => { listening = scope === "listen"; };
2070
2079
  // A key is how somebody who was invited proves it. It is not the only way
2071
2080
  // to be allowed in: the person who owns this server is allowed in whether
2072
2081
  // or not they still have the link, and their nixamp.com session says who
@@ -2093,6 +2102,7 @@ export function createHandler(engine, options) {
2093
2102
  });
2094
2103
  return;
2095
2104
  }
2105
+ settle();
2096
2106
  if (scope === "listen" && !allowedForListening(path)) {
2097
2107
  json(response, 403, { error: "this link can listen, not drive" });
2098
2108
  return;
@@ -4204,11 +4214,32 @@ export function createHandler(engine, options) {
4204
4214
  json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
4205
4215
  return;
4206
4216
  }
4207
- const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "" });
4217
+ // A member's link is fetched from inside the owner's network, so it has
4218
+ // to lead somewhere public: the link, every hop of a playlist, and
4219
+ // whatever yt-dlp says the media is. The owner's is theirs to aim.
4220
+ const guard = liveBy && !known ? (one) => refusedDestination(one) : undefined;
4221
+ if (guard) {
4222
+ const refused = await guard(link);
4223
+ if (refused) {
4224
+ json(response, 403, { error: refused });
4225
+ return;
4226
+ }
4227
+ }
4228
+ const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "", guard });
4208
4229
  if ("error" in resolved) {
4209
- json(response, 422, { error: resolved.error });
4230
+ json(response, guard && resolved.error.includes("private network") ? 403 : 422, { error: resolved.error });
4210
4231
  return;
4211
4232
  }
4233
+ if (guard) {
4234
+ const reached = [resolved.media, resolved.audio, ...(resolved.playlist ?? [])].filter((one) => one && /^(https?|rtmps?):\/\//i.test(one));
4235
+ for (const one of [...new Set(reached)].slice(0, 200)) {
4236
+ const refused = await guard(one);
4237
+ if (refused) {
4238
+ json(response, 403, { error: refused });
4239
+ return;
4240
+ }
4241
+ }
4242
+ }
4212
4243
  links.set(link, resolved);
4213
4244
  // A channel already on -- put back by a restart, which forgets where
4214
4245
  // it came from -- learns its picture and its line from the link pasted
@@ -5068,7 +5099,7 @@ export function createHandler(engine, options) {
5068
5099
  return;
5069
5100
  }
5070
5101
  if (path === "/api/state") {
5071
- json(response, 200, engine.snapshot());
5102
+ json(response, 200, listening ? { ...engine.snapshot(), root: "" } : engine.snapshot());
5072
5103
  return;
5073
5104
  }
5074
5105
  // Everything the admin view draws, in one request: who is connected, and
@@ -5132,7 +5163,7 @@ export function createHandler(engine, options) {
5132
5163
  "x-accel-buffering": "no",
5133
5164
  });
5134
5165
  const send = (snapshot) => {
5135
- response.write(`data: ${JSON.stringify(snapshot)}\n\n`);
5166
+ response.write(`data: ${JSON.stringify(listening ? { ...snapshot, root: "" } : snapshot)}\n\n`);
5136
5167
  };
5137
5168
  const unsubscribe = engine.subscribe(send);
5138
5169
  // A comment line keeps proxies from closing an idle stream.
@@ -5577,6 +5608,7 @@ async function liveAudio(request, response, engine, ffmpeg, ffprobe) {
5577
5608
  // A live source has no beginning to seek from.
5578
5609
  ...(from > 1 && !isRemote(source) ? ["-ss", String(from)] : []),
5579
5610
  "-re",
5611
+ ...protocolArgs(source),
5580
5612
  "-i", source,
5581
5613
  ...(asVideo
5582
5614
  // Copied where it can be, because a room full of viewers is a room
@@ -5681,6 +5713,7 @@ inputArgs = []) {
5681
5713
  // when they are handed to it for a file on disk.
5682
5714
  ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
5683
5715
  ...inputArgs,
5716
+ ...protocolArgs(source),
5684
5717
  "-i", source,
5685
5718
  ...outputArgs,
5686
5719
  "-",
@@ -5860,6 +5893,15 @@ export async function serve(argv, version = "0.1.0") {
5860
5893
  // still works after a restart -- and a server is restarted to pick up a new
5861
5894
  // version, which is to say often. `--new-key` mints a fresh pair and forgets
5862
5895
  // the old one, which is the way to revoke a link that got out.
5896
+ // Whether this port had keys before this start: a server that has run
5897
+ // here before, whose directory listing (see publishChoice) is kept.
5898
+ const established = existsSync(join(stateDir(), "keys.json")) &&
5899
+ (() => { try {
5900
+ return String(options.port) in JSON.parse(readFileSync(join(stateDir(), "keys.json"), "utf8"));
5901
+ }
5902
+ catch {
5903
+ return false;
5904
+ } })();
5863
5905
  const remembered = options.key ? rememberedKeys(stateDir(), options.port, options.newKey) : null;
5864
5906
  const key = remembered?.key ?? null;
5865
5907
  // Kept whether or not it is published, so `nixamp admin` and the operator
@@ -6959,10 +7001,23 @@ export async function serve(argv, version = "0.1.0") {
6959
7001
  // public at all.
6960
7002
  //
6961
7003
  // Still asked when a person is at the terminal to answer, because that is
6962
- // somebody who can say no.
6963
- const wanted = options.publish === "yes" || !process.stdin.isTTY
6964
- ? true
6965
- : await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
7004
+ // somebody who can say no -- and since 0.29 the answer is kept, so a
7005
+ // daemon started later does what the person said, not what it guesses.
7006
+ const recorded = readConfig().publish;
7007
+ const choice = publishChoice(options.publish, recorded, Boolean(process.stdin.isTTY), established);
7008
+ const wanted = choice === "ask"
7009
+ ? await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`)
7010
+ : choice === "yes";
7011
+ if (shouldRecord(options.publish, recorded)) {
7012
+ try {
7013
+ writeConfig({ publish: wanted });
7014
+ }
7015
+ catch { /* unwritable state: asked again next time */ }
7016
+ }
7017
+ if (!wanted && options.publish === "ask") {
7018
+ console.log("");
7019
+ console.log(` Not listed at ${DEFAULT_DIRECTORY}/directory. Start with --publish to list it.`);
7020
+ }
6966
7021
  if (wanted) {
6967
7022
  publisher = makePublisher();
6968
7023
  listing = (await publisher?.start()) ?? null;
package/dist/share.d.ts CHANGED
@@ -47,7 +47,7 @@ export declare function keysMatch(a: string, b: string): boolean;
47
47
  /** Every place a key is accepted from, in the order they are looked for. */
48
48
  export declare function keyFrom(request: IncomingMessage, url: URL): string | null;
49
49
  /** The Set-Cookie for a browser that just opened the link. */
50
- export declare function keyCookie(key: string): string;
50
+ export declare function keyCookie(key: string, secure?: boolean): string;
51
51
  /** Where an address actually goes, which is not always where you would like. */
52
52
  export declare function classify(address: string): "private" | "cgnat" | "public";
53
53
  /** Looks like an address, rather than an error page or a rate-limit notice. */
package/dist/share.js CHANGED
@@ -108,11 +108,12 @@ export function keyFrom(request, url) {
108
108
  return null;
109
109
  }
110
110
  /** The Set-Cookie for a browser that just opened the link. */
111
- export function keyCookie(key) {
111
+ export function keyCookie(key, secure = false) {
112
112
  // HttpOnly because nothing in the page reads it: the browser attaches it to
113
- // every same-origin request by itself. No Secure, because the whole point is
114
- // a plain-http address on your own network.
115
- return `${KEY_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`;
113
+ // every same-origin request by itself. Secure only when the link was opened
114
+ // over https: a plain-http address on your own network is still a thing,
115
+ // and a Secure cookie there would never be sent back.
116
+ return `${KEY_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secure ? "; Secure" : ""}`;
116
117
  }
117
118
  /**
118
119
  * Interfaces that exist for containers and virtual machines. An address on one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.28.45",
3
+ "version": "0.29.1",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/audio.ts 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.ts";
9
10
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
10
11
  import { readdirSync } from "node:fs";
11
12
  import { homedir } from "node:os";
@@ -136,6 +137,7 @@ export function probe(tools: Tools, path: string): Track {
136
137
  ...rest,
137
138
  "-v", "quiet", "-print_format", "json",
138
139
  "-show_format", "-show_entries", "format_tags=title,artist,album",
140
+ ...protocolArgs(path),
139
141
  path,
140
142
  ], { encoding: "utf8", timeout: 20_000, maxBuffer: 4 * 1024 * 1024 });
141
143
  if (result.error || result.status !== 0) return fallback;
@@ -224,6 +226,7 @@ export class Stream {
224
226
  // being on disk. Measured with that condition in place: still 13.8x.
225
227
  "-re",
226
228
  ...(from > 0 ? ["-ss", String(from)] : []),
229
+ ...protocolArgs(track.path),
227
230
  "-i", track.path,
228
231
  "-f", "f32le", "-ac", String(CHANNELS), "-ar", String(RATE), "-",
229
232
  ], { stdio: ["ignore", "pipe", "pipe"] });
package/src/channels.ts CHANGED
@@ -14,11 +14,12 @@
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.ts";
17
18
  import { spawn, type ChildProcess } from "node:child_process";
18
19
  import { randomBytes } from "node:crypto";
19
20
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
21
  import { dirname, join } from "node:path";
21
- import { PlaylistInputStream } from "./playlist-input.ts";
22
+ import { PlaylistInputStream, type PlaylistInput } from "./playlist-input.ts";
22
23
  import type { Readable } from "node:stream";
23
24
  import { FragmentClock, Fragments, isOpening } from "./fragments.ts";
24
25
 
@@ -122,6 +123,13 @@ export interface PullResume {
122
123
  /** For a list of things rather than one: every entry, in order. */
123
124
  playlist?: string[];
124
125
  playlistAt?: number;
126
+ /**
127
+ * How a station plays its list: round and round, in a shuffled order, with
128
+ * edits landing on the next track and broken entries passed over. Only for
129
+ * lists of files on this machine; not remembered, because whoever owns the
130
+ * rotation (the station) puts it back.
131
+ */
132
+ rotation?: Pick<PlaylistInput, "repeat" | "order" | "current" | "excluded" | "onFailure" | "passOver">;
125
133
  }
126
134
 
127
135
  /**
@@ -284,6 +292,13 @@ export class Channel {
284
292
  private redial: (() => void) | null = null;
285
293
  /** For a channel playing a list: move to the next entry. Null when there is no list. */
286
294
  private advance: (() => boolean) | null = null;
295
+ /** The list being played entry by entry, when it is one; what skip() moves on. */
296
+ private playlistInput: PlaylistInputStream | null = null;
297
+
298
+ /** On to the next entry of a list of files. False when this is not one. */
299
+ skip(): boolean {
300
+ return this.playlistInput?.skip() ?? false;
301
+ }
287
302
  private failures = 0;
288
303
  private timer: ReturnType<typeof setTimeout> | null = null;
289
304
  /** Fires when a pulled source has said nothing for STALL. */
@@ -400,7 +415,7 @@ export class Channel {
400
415
  // A list plays entry by entry: the one that is on is what gets dialled,
401
416
  // and an entry that ended is followed by the next, at once.
402
417
  const list = resume.playlist && resume.playlist.length > 0 ? resume.playlist : null;
403
- const localPlaylist = list && list.length > 1 && list.every(one => !/^https?:\/\//i.test(one)) ? list : null;
418
+ const localPlaylist = list && (list.length > 1 || resume.rotation) && list.every(one => !/^https?:\/\//i.test(one)) ? list : null;
404
419
  let at = 0;
405
420
  if (list) {
406
421
  this.info.playlist = list;
@@ -450,7 +465,9 @@ export class Channel {
450
465
  ffprobe: this.options.ffprobe ?? this.options.ffmpeg.map(one => one.replace(/ffmpeg(?=(?:\.exe)?$)/, "ffprobe")),
451
466
  files: localPlaylist, at: this.info.playlistAt ?? 0, position: from,
452
467
  video: this.info.kind === "video", width: this.info.codecs?.width, height: this.info.codecs?.height,
468
+ ...resume.rotation,
453
469
  }) : null;
470
+ this.playlistInput = playlistInput;
454
471
  const through = this.outroOn ? null : playlistInput
455
472
  ? { format: "mpegts", open: async (signal: AbortSignal) => playlistInput.open(signal) }
456
473
  : this.options.through?.(this.info, from, input, audio) ?? null;
@@ -503,10 +520,10 @@ export class Channel {
503
520
  // What the source's site expects on the request: a user agent, a
504
521
  // referer, a cookie. A link resolved by yt-dlp comes with these,
505
522
  // and a CDN that got them from yt-dlp and not from us answers 403.
506
- ...input, ...seek, "-i", current,
523
+ ...input, ...seek, ...protocolArgs(current), "-i", current,
507
524
  // The sound, when the site keeps it apart from the picture: a
508
525
  // second input, dialled the same way, that the encode maps in.
509
- ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
526
+ ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, ...protocolArgs(audio), "-i", audio] : []),
510
527
  ]),
511
528
  ...(playlistEncode ?? encode),
512
529
  "pipe:1",
@@ -1324,6 +1341,11 @@ export class Channels {
1324
1341
  return this.open.get(id)?.info.kind;
1325
1342
  }
1326
1343
 
1344
+ /** The next entry, now, on a channel playing a list of files. */
1345
+ skip(id: string): boolean {
1346
+ return this.open.get(id)?.skip() ?? false;
1347
+ }
1348
+
1327
1349
  stop(id: string): boolean {
1328
1350
  const channel = this.open.get(id);
1329
1351
  if (!channel) return false;