nixamp 0.28.44 → 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 (89) 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/live-interpreter.d.ts +1 -72
  13. package/dist/live-interpreter.js +1 -269
  14. package/dist/live-recovery.d.ts +1 -6
  15. package/dist/live-recovery.js +1 -11
  16. package/dist/live-voice.d.ts +6 -76
  17. package/dist/live-voice.js +6 -352
  18. package/dist/owner.js +4 -0
  19. package/dist/pcm-wav.d.ts +1 -2
  20. package/dist/pcm-wav.js +1 -27
  21. package/dist/playlist-input.d.ts +46 -0
  22. package/dist/playlist-input.js +158 -54
  23. package/dist/publish-choice.d.ts +21 -0
  24. package/dist/publish-choice.js +17 -0
  25. package/dist/server.js +66 -11
  26. package/dist/share.d.ts +1 -1
  27. package/dist/share.js +5 -4
  28. package/dist/shared-translation.d.ts +6 -53
  29. package/dist/shared-translation.js +7 -336
  30. package/dist/speaker-turns.d.ts +1 -32
  31. package/dist/speaker-turns.js +1 -30
  32. package/dist/speech.d.ts +3 -5
  33. package/dist/speech.js +4 -7
  34. package/dist/translation-passes.d.ts +5 -125
  35. package/dist/translation-passes.js +5 -294
  36. package/dist/upgrade-allowance.d.ts +1 -23
  37. package/dist/upgrade-allowance.js +1 -73
  38. package/dist/voice-profile.d.ts +1 -7
  39. package/dist/voice-profile.js +1 -53
  40. package/dist/voice-provider.d.ts +1 -9
  41. package/dist/voice-provider.js +1 -27
  42. package/package.json +2 -1
  43. package/src/audio.ts +3 -0
  44. package/src/channels.ts +26 -4
  45. package/src/decoder-policy.ts +33 -0
  46. package/src/destinations.ts +78 -0
  47. package/src/library.ts +6 -1
  48. package/src/links.ts +23 -4
  49. package/src/live-interpreter.ts +1 -213
  50. package/src/live-recovery.ts +1 -10
  51. package/src/live-voice.ts +6 -304
  52. package/src/owner.ts +3 -0
  53. package/src/pcm-wav.ts +1 -26
  54. package/src/playlist-input.ts +172 -50
  55. package/src/publish-choice.ts +35 -0
  56. package/src/server.ts +58 -11
  57. package/src/share.ts +5 -4
  58. package/src/shared-translation.ts +7 -222
  59. package/src/speaker-turns.ts +1 -34
  60. package/src/speech.ts +4 -5
  61. package/src/translation-passes.ts +6 -292
  62. package/src/upgrade-allowance.ts +1 -72
  63. package/src/voice-profile.ts +1 -46
  64. package/src/voice-provider.ts +1 -22
  65. package/web/dist/assets/{ar-CLNm1oA7.js → ar-D6_ZFDan.js} +1 -1
  66. package/web/dist/assets/{cs-Cptn8RbB.js → cs-DEPGOGEV.js} +1 -1
  67. package/web/dist/assets/{da-CRbNCFOu.js → da-CQw7SPC9.js} +1 -1
  68. package/web/dist/assets/{de-D4DnC5NQ.js → de-OWnobbah.js} +1 -1
  69. package/web/dist/assets/{es-DAHnD9bs.js → es-qMpyH9bM.js} +1 -1
  70. package/web/dist/assets/{fi-Csbl41Vz.js → fi-DpqqwJTD.js} +1 -1
  71. package/web/dist/assets/{fr-jZYQJed8.js → fr-BiO_ceen.js} +1 -1
  72. package/web/dist/assets/{hi-DjT9u0v_.js → hi-D-q5wZuY.js} +1 -1
  73. package/web/dist/assets/{hls-3VKVEQE3-BjAVnvGY.js → hls-3VKVEQE3-D2W4K8VR.js} +1 -1
  74. package/web/dist/assets/{hu-CVrU8gC4.js → hu-FIjHyhVC.js} +1 -1
  75. package/web/dist/assets/{id-B_gN24X5.js → id-gmB6lu56.js} +1 -1
  76. package/web/dist/assets/{index-BueBHHcv.css → index-Bo3utcDw.css} +1 -1
  77. package/web/dist/assets/index-UmGvzuun.js +22 -0
  78. package/web/dist/assets/{it-BUC1dMpV.js → it-Dxfo713A.js} +1 -1
  79. package/web/dist/assets/{mpegts-CnqmXTu2.js → mpegts-BT4XGr_E.js} +1 -1
  80. package/web/dist/assets/{mpegts-LO6RVLD6-B4QgG3Xb.js → mpegts-LO6RVLD6-CpZnlq0W.js} +1 -1
  81. package/web/dist/assets/{nl-CGi-aIrz.js → nl-mk5eN3Io.js} +1 -1
  82. package/web/dist/assets/{ru-YIbS63cK.js → ru-B2psU0kN.js} +1 -1
  83. package/web/dist/assets/{sv-CuJUpOCk.js → sv-Cg6waoNZ.js} +1 -1
  84. package/web/dist/assets/{uk-DLfG8YhV.js → uk-BcFkukLd.js} +1 -1
  85. package/web/dist/assets/{vi-nv2siY2T.js → vi-CnqIC1Sz.js} +1 -1
  86. package/web/dist/assets/{zh-P5x8w1OW.js → zh-CvnXIFvN.js} +1 -1
  87. package/web/dist/index.html +7 -12
  88. package/web/dist/sw.js +23 -23
  89. package/web/dist/assets/index-c3uxZS8_.js +0 -3
@@ -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
@@ -1,56 +1,9 @@
1
- import { type Caption } from "./live-interpreter.ts";
2
- import { LiveVoice } from "./live-voice.ts";
3
- import type { Translator } from "./translate.ts";
4
- import type { TranslationMeter } from "./translation-passes.ts";
5
- export declare function publicStreamAddress(address: string): boolean;
1
+ /** Shared live translation, from @profullstack/live-translate, limited to nixamp channels. */
2
+ import { SharedTranslations as Base, type OpenLiveInput } from "@profullstack/live-translate";
3
+ export { publicStreamAddress, type LiveInput, type OpenLiveInput, type SharedEvent } from "@profullstack/live-translate";
4
+ /** Only a live nixamp channel (`/api/channels/<id>`, optionally `?k=`). */
6
5
  export declare function sharedSource(raw: string): URL;
7
- export interface LiveInput {
8
- stop(): void;
9
- }
10
- export type OpenLiveInput = (source: URL, pcm: (bytes: Buffer) => void, ended: () => void, signal: AbortSignal) => Promise<LiveInput>;
11
- /** Resolve once and pin that public address; no redirects or ffmpeg URL fetches. */
12
6
  export declare function liveInput(ffmpeg?: string[]): OpenLiveInput;
13
- export type SharedEvent = {
14
- type: "status";
15
- listeners: number;
16
- } | {
17
- type: "line";
18
- id: string;
19
- line: Caption;
20
- } | {
21
- type: "audio";
22
- id: string;
23
- data: string;
24
- } | {
25
- type: "end";
26
- id: string;
27
- } | {
28
- type: "error";
29
- error: string;
30
- retryable?: boolean;
31
- };
32
- type Member = {
33
- by: string;
34
- send: (event: SharedEvent) => void;
35
- close: () => void;
36
- };
37
- export declare class SharedTranslations {
38
- private readonly options;
39
- private readonly feeds;
40
- constructor(options: {
41
- voice: LiveVoice;
42
- translator: Pick<Translator, "translate">;
43
- billing?: TranslationMeter;
44
- open?: OpenLiveInput;
45
- });
46
- get size(): number;
47
- join(raw: string, language: string, by: string, send: Member["send"], close: () => void): Promise<() => void>;
48
- private leave;
49
- private checkMembers;
50
- private broadcast;
51
- private stop;
52
- private make;
53
- private start;
54
- private speak;
7
+ export declare class SharedTranslations extends Base {
8
+ constructor(options: ConstructorParameters<typeof Base>[0]);
55
9
  }
56
- export {};