nixamp 0.5.10 → 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/attach.js CHANGED
@@ -23,7 +23,11 @@ import { KEY_HEADER } from "./share.js";
23
23
  const RECONNECT_MS = 1000;
24
24
  /** A remote track has no path, because no filesystem path leaves the machine. */
25
25
  export function applySnapshot(state, snapshot) {
26
- state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
26
+ // A frame without a list is not an empty library, it is a frame with nothing
27
+ // new to say about it -- which is every frame but the first.
28
+ if (snapshot.tracks !== undefined) {
29
+ state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
30
+ }
27
31
  state.index = snapshot.index;
28
32
  state.playing = snapshot.playing;
29
33
  state.position = snapshot.position;
package/dist/main.js CHANGED
@@ -72,6 +72,9 @@ Options for serve:
72
72
  its own interfaces. Also NIXAMP_PUBLIC_URL
73
73
  --no-lookup do not ask ipinfo.io what this machine's public address is
74
74
  when nothing local looks public
75
+ --tls-cert FILE --tls-key FILE serve https rather than http. Needed by
76
+ anyone opening this from a page that is itself https, since
77
+ a browser refuses every request from https to http
75
78
  --ingest accept a live stream in at POST /api/ingest
76
79
  --rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
77
80
  --rtmp-streams N how many may publish at once (default 3, a port each)
@@ -25,7 +25,24 @@ export interface RemoteTrack {
25
25
  export interface Snapshot {
26
26
  /** Bumped on every push so a client can drop an out-of-order frame. */
27
27
  revision: number;
28
- tracks: RemoteTrack[];
28
+ /**
29
+ * The library, sent when it is news and left out when it is not.
30
+ *
31
+ * It used to ride in every frame. At twelve frames a second over a library of
32
+ * five thousand, that is five megabytes a second of JSON for a client to
33
+ * parse on the thread that is also decoding the audio -- which is exactly
34
+ * what it sounded like. It is sent on the first frame of a subscription and
35
+ * again whenever the list actually changes; absent means "the same as
36
+ * before", and a client keeps what it had.
37
+ */
38
+ tracks?: RemoteTrack[];
39
+ /**
40
+ * How many tracks there are, in every frame.
41
+ *
42
+ * A client that has not received a list yet still has to draw something, and
43
+ * a count is four bytes rather than half a megabyte.
44
+ */
45
+ trackCount: number;
29
46
  index: number;
30
47
  playing: boolean;
31
48
  position: number;
@@ -61,4 +78,19 @@ export declare const COMMAND_TYPES: readonly ["play", "toggle", "stop", "next",
61
78
  export declare function parseCommand(input: unknown): Command | null;
62
79
  /** The name a remote shows for a track. */
63
80
  export declare function remoteName(track: RemoteTrack): string;
64
- export declare function emptySnapshot(): Snapshot;
81
+ export declare function emptySnapshot(): FullSnapshot;
82
+ /**
83
+ * Fold a frame into what the client already had.
84
+ *
85
+ * A frame without `tracks` is not a frame with no tracks: it is a frame that
86
+ * had nothing new to say about them. Every client needs this, so none of them
87
+ * should write it twice.
88
+ */
89
+ export declare function merge(previous: FullSnapshot, incoming: Snapshot): FullSnapshot;
90
+ /**
91
+ * A snapshot a client has already folded, so the library is known to be there.
92
+ * Every reader wants this one; only the wire carries the other.
93
+ */
94
+ export type FullSnapshot = Snapshot & {
95
+ tracks: RemoteTrack[];
96
+ };
package/dist/protocol.js CHANGED
@@ -41,6 +41,7 @@ export function emptySnapshot() {
41
41
  return {
42
42
  revision: 0,
43
43
  tracks: [],
44
+ trackCount: 0,
44
45
  index: 0,
45
46
  playing: false,
46
47
  position: 0,
@@ -51,3 +52,13 @@ export function emptySnapshot() {
51
52
  root: "",
52
53
  };
53
54
  }
55
+ /**
56
+ * Fold a frame into what the client already had.
57
+ *
58
+ * A frame without `tracks` is not a frame with no tracks: it is a frame that
59
+ * had nothing new to say about them. Every client needs this, so none of them
60
+ * should write it twice.
61
+ */
62
+ export function merge(previous, incoming) {
63
+ return { ...incoming, tracks: incoming.tracks ?? previous.tracks };
64
+ }
package/dist/server.d.ts CHANGED
@@ -56,6 +56,18 @@ export interface ServeOptions {
56
56
  * listing is skipped and the printed links only work inside the house.
57
57
  */
58
58
  publicUrl: string;
59
+ /**
60
+ * A certificate and its key, to serve https rather than http.
61
+ *
62
+ * Needed by anybody whose nixamp is opened from a page that is itself https:
63
+ * a browser refuses every request from an https page to an http one --
64
+ * fetch, event stream and media alike -- and no header on either side lifts
65
+ * that. It is deliberately not required: a nixamp on 192.168.1.5 cannot have
66
+ * a certificate for that address, and forcing one would put a browser
67
+ * warning in front of everybody at home to fix a problem they do not have.
68
+ */
69
+ tlsCert: string;
70
+ tlsKey: string;
59
71
  /**
60
72
  * Ask an outside service what this machine's public address is, when no
61
73
  * interface holds one and none was given. Behind NAT that is the only way to
@@ -111,7 +123,8 @@ export declare function parseRange(header: string | undefined, size: number): By
111
123
  export declare function safeJoin(rootDir: string, urlPath: string): string | null;
112
124
  /** What the HTTP layer needs from a player. Tests hand it a fake. */
113
125
  export interface Engine {
114
- snapshot(): Snapshot;
126
+ /** `withTracks` false leaves the library out, for a frame that is only motion. */
127
+ snapshot(withTracks?: boolean): Snapshot;
115
128
  command(command: Command): void;
116
129
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
117
130
  /** Absolute path of a track, or undefined when the index is not one. */
@@ -161,7 +174,11 @@ export declare class PlayerEngine implements Engine {
161
174
  fps?: number);
162
175
  private readonly silent;
163
176
  private consume;
164
- snapshot(): Snapshot;
177
+ /**
178
+ * The current state. `withTracks` carries the library, which is worth half a
179
+ * megabyte on a real one and is only news when it has changed.
180
+ */
181
+ snapshot(withTracks?: boolean): Snapshot;
165
182
  trackPath(index: number): string | undefined;
166
183
  command(command: Command): void;
167
184
  private clamp;
@@ -169,6 +186,13 @@ export declare class PlayerEngine implements Engine {
169
186
  private start;
170
187
  private halt;
171
188
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
189
+ /**
190
+ * Send the state to everyone watching.
191
+ *
192
+ * The library goes only when `listChanged` says it has, which is what turned
193
+ * five megabytes a second into a few kilobytes: an analyser tick has nothing
194
+ * to say about the track list, and it fires twelve times a second.
195
+ */
172
196
  private push;
173
197
  stop(): void;
174
198
  replace(tracks: Track[], root: string): void;
@@ -238,6 +262,11 @@ export interface HandlerOptions {
238
262
  signIn?: SignIn;
239
263
  /** True when this instance is reached over https, for the cookie's Secure. */
240
264
  secureCookies?: boolean;
265
+ /** A certificate and key in PEM, when this server is to speak https itself. */
266
+ tls?: {
267
+ cert: string;
268
+ key: string;
269
+ };
241
270
  /**
242
271
  * True when a proxy sits in front, so `x-forwarded-for` names the caller.
243
272
  * False everywhere else on purpose: the header is trivially forged, and
package/dist/server.js CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { createReadStream, statSync } from "node:fs";
13
13
  import { createServer as createHttpServer } from "node:http";
14
+ import { createServer as createHttpsServer } from "node:https";
14
15
  import { hostname } from "node:os";
15
16
  import { spawn, spawnSync } from "node:child_process";
16
17
  import { readFileSync } from "node:fs";
@@ -70,6 +71,8 @@ export function parseServeArgs(argv) {
70
71
  name: "",
71
72
  publicUrl: process.env["NIXAMP_PUBLIC_URL"] ?? "",
72
73
  lookup: true,
74
+ tlsCert: process.env["NIXAMP_TLS_CERT"] ?? "",
75
+ tlsKey: process.env["NIXAMP_TLS_KEY"] ?? "",
73
76
  x402: false,
74
77
  owner: "",
75
78
  ingest: false,
@@ -78,6 +81,11 @@ export function parseServeArgs(argv) {
78
81
  rtmp: [],
79
82
  };
80
83
  let sawRoot = false;
84
+ const bothOrNeither = () => {
85
+ if (Boolean(options.tlsCert) !== Boolean(options.tlsKey)) {
86
+ throw new Error("nixamp serve: --tls-cert and --tls-key go together");
87
+ }
88
+ };
81
89
  for (let i = 0; i < argv.length; i++) {
82
90
  const arg = argv[i];
83
91
  const value = () => {
@@ -130,6 +138,12 @@ export function parseServeArgs(argv) {
130
138
  }
131
139
  options.publicUrl = given.replace(/\/+$/, "");
132
140
  }
141
+ else if (arg === "--tls-cert") {
142
+ options.tlsCert = value();
143
+ }
144
+ else if (arg === "--tls-key") {
145
+ options.tlsKey = value();
146
+ }
133
147
  else if (arg === "--no-lookup") {
134
148
  options.lookup = false;
135
149
  }
@@ -175,6 +189,7 @@ export function parseServeArgs(argv) {
175
189
  sawRoot = true;
176
190
  }
177
191
  }
192
+ bothOrNeither();
178
193
  return options;
179
194
  }
180
195
  const TYPES = {
@@ -348,10 +363,15 @@ export class PlayerEngine {
348
363
  this.pending = joined.subarray(at);
349
364
  this.dirty = true;
350
365
  }
351
- snapshot() {
366
+ /**
367
+ * The current state. `withTracks` carries the library, which is worth half a
368
+ * megabyte on a real one and is only news when it has changed.
369
+ */
370
+ snapshot(withTracks = true) {
352
371
  return {
353
372
  revision: this.revision,
354
- tracks: toRemoteTracks(this.tracks),
373
+ ...(withTracks ? { tracks: toRemoteTracks(this.tracks) } : {}),
374
+ trackCount: this.tracks.length,
355
375
  index: this.state.index,
356
376
  playing: this.state.playing,
357
377
  position: this.state.position,
@@ -445,11 +465,18 @@ export class PlayerEngine {
445
465
  }
446
466
  };
447
467
  }
448
- push() {
468
+ /**
469
+ * Send the state to everyone watching.
470
+ *
471
+ * The library goes only when `listChanged` says it has, which is what turned
472
+ * five megabytes a second into a few kilobytes: an analyser tick has nothing
473
+ * to say about the track list, and it fires twelve times a second.
474
+ */
475
+ push(listChanged = false) {
449
476
  this.revision++;
450
477
  if (this.listeners.size === 0)
451
478
  return;
452
- const snapshot = this.snapshot();
479
+ const snapshot = this.snapshot(listChanged);
453
480
  for (const listener of this.listeners)
454
481
  listener(snapshot);
455
482
  }
@@ -468,7 +495,7 @@ export class PlayerEngine {
468
495
  this.state.index = 0;
469
496
  this.state.position = 0;
470
497
  this.state.note = "";
471
- this.push();
498
+ this.push(true);
472
499
  }
473
500
  retag(tracks, root) {
474
501
  // Dropped rather than applied if the library moved underneath: somebody
@@ -480,8 +507,9 @@ export class PlayerEngine {
480
507
  return;
481
508
  this.tracks = tracks;
482
509
  // No stop, no index reset: the only thing that changes is what the titles
483
- // say, and every remote finds out because a snapshot goes out.
484
- this.push();
510
+ // say, and every remote finds out because a snapshot goes out -- carrying
511
+ // the list, since the titles are the whole point of this one.
512
+ this.push(true);
485
513
  }
486
514
  }
487
515
  /** An engine with no library behind it, for the hosted PWA. */
@@ -1540,7 +1568,7 @@ export function createHandler(engine, options) {
1540
1568
  json(response, 404, { error: "no such track" });
1541
1569
  return;
1542
1570
  }
1543
- watch(request, response, "media", engine.snapshot().tracks[index]?.title ?? file);
1571
+ watch(request, response, "media", engine.snapshot().tracks?.[index]?.title ?? file);
1544
1572
  // A browser asks for every track here, and a matroska or an avi handed
1545
1573
  // to it raw is bytes it cannot play. Seeking is what this route is for
1546
1574
  // and transcoding gives it up, but an unseekable film beats a silent
@@ -1572,11 +1600,11 @@ export function createHandler(engine, options) {
1572
1600
  return;
1573
1601
  }
1574
1602
  const current = engine.snapshot();
1575
- if (current.tracks.length === 0) {
1603
+ if (current.trackCount === 0) {
1576
1604
  json(response, 404, { error: "nothing is playing" });
1577
1605
  return;
1578
1606
  }
1579
- watch(request, response, "stream", current.tracks[current.index]?.title ?? "live");
1607
+ watch(request, response, "stream", current.tracks?.[current.index]?.title ?? "live");
1580
1608
  liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"]);
1581
1609
  return;
1582
1610
  }
@@ -1591,7 +1619,7 @@ export function createHandler(engine, options) {
1591
1619
  json(response, 403, { error: "media streaming is off" });
1592
1620
  return;
1593
1621
  }
1594
- watch(request, response, "stream", engine.snapshot().tracks[index]?.title ?? source);
1622
+ watch(request, response, "stream", engine.snapshot().tracks?.[index]?.title ?? source);
1595
1623
  transcode(request, response, source, options.ffmpeg ?? ["ffmpeg"]);
1596
1624
  return;
1597
1625
  }
@@ -1885,14 +1913,20 @@ function sendFile(request, response, file) {
1885
1913
  }
1886
1914
  export function createServer(engine, options) {
1887
1915
  const handle = createHandler(engine, options);
1888
- return createHttpServer((request, response) => {
1916
+ const onRequest = (request, response) => {
1889
1917
  handle(request, response).catch(() => {
1890
1918
  if (!response.headersSent)
1891
1919
  json(response, 500, { error: "server error" });
1892
1920
  else
1893
1921
  response.end();
1894
1922
  });
1895
- });
1923
+ };
1924
+ // https when there is a certificate to serve it with, and the same handler
1925
+ // either way: nothing above this line knows or cares which it got.
1926
+ if (options.tls) {
1927
+ return createHttpsServer({ cert: options.tls.cert, key: options.tls.key }, onRequest);
1928
+ }
1929
+ return createHttpServer(onRequest);
1896
1930
  }
1897
1931
  export async function serve(argv, version = "0.1.0") {
1898
1932
  const options = parseServeArgs(argv);
@@ -2099,6 +2133,18 @@ export async function serve(argv, version = "0.1.0") {
2099
2133
  });
2100
2134
  });
2101
2135
  }
2136
+ // Read before listening, so a missing or unreadable certificate is a sentence
2137
+ // now rather than a connection that resets later.
2138
+ const tls = options.tlsCert
2139
+ ? (() => {
2140
+ try {
2141
+ return { cert: readFileSync(options.tlsCert, "utf8"), key: readFileSync(options.tlsKey, "utf8") };
2142
+ }
2143
+ catch (error) {
2144
+ throw new Error(`nixamp serve: could not read the certificate: ${error.message}`);
2145
+ }
2146
+ })()
2147
+ : undefined;
2102
2148
  const server = createServer(engine, {
2103
2149
  web,
2104
2150
  media: options.media,
@@ -2114,6 +2160,7 @@ export async function serve(argv, version = "0.1.0") {
2114
2160
  paywall,
2115
2161
  ffmpeg: tools.ffmpeg,
2116
2162
  ffprobe: tools.ffprobe,
2163
+ ...(tls ? { tls } : {}),
2117
2164
  load: (next) => loadSource(tools, next),
2118
2165
  ...(directory ? { directory } : {}),
2119
2166
  ...(follows ? { follows, vapidPublicKey } : {}),
@@ -2168,12 +2215,12 @@ export async function serve(argv, version = "0.1.0") {
2168
2215
  // is and nothing local looks public, ask. What comes back is a fact about the
2169
2216
  // router and not about this port -- the port still has to be forwarded -- so
2170
2217
  // it is marked as a guess and everything that prints it says so.
2171
- const localAddresses = reachableAddresses(options.host, port, options.publicUrl);
2218
+ const localAddresses = reachableAddresses(options.host, port, options.publicUrl, tls ? "https" : "http");
2172
2219
  const guessedPublic = options.lookup && !options.publicUrl && !localAddresses.some((a) => a.label === "on the internet")
2173
2220
  ? await lookupPublicIp()
2174
2221
  : "";
2175
2222
  const addresses = guessedPublic
2176
- ? reachableAddresses(options.host, port, `http://${guessedPublic.includes(":") ? `[${guessedPublic}]` : guessedPublic}:${port}`)
2223
+ ? reachableAddresses(options.host, port, `${tls ? "https" : "http"}://${guessedPublic.includes(":") ? `[${guessedPublic}]` : guessedPublic}:${port}`, tls ? "https" : "http")
2177
2224
  : localAddresses;
2178
2225
  // Listening on every interface proves the socket is open here and nothing
2179
2226
  // about the path between here and the phone.
@@ -2341,7 +2388,7 @@ export async function serve(argv, version = "0.1.0") {
2341
2388
  },
2342
2389
  nowPlaying: () => {
2343
2390
  const snapshot = engine.snapshot();
2344
- return snapshot.tracks[snapshot.index]?.title ?? "";
2391
+ return snapshot.tracks?.[snapshot.index]?.title ?? "";
2345
2392
  },
2346
2393
  onConfig: (remote) => {
2347
2394
  const next = applyRemoteConfig(paywallConfig, remote?.x402);
package/dist/share.d.ts CHANGED
@@ -45,7 +45,7 @@ export declare function lookupPublicIp(send?: typeof fetch, timeoutMs?: number):
45
45
  * somewhere else can open. It is labelled for what it is, because the key in
46
46
  * the link is then the only thing between a stranger and the library.
47
47
  */
48
- export declare function reachableAddresses(host: string, port: number, publicUrl?: string): {
48
+ export declare function reachableAddresses(host: string, port: number, publicUrl?: string, scheme?: "http" | "https"): {
49
49
  label: string;
50
50
  url: string;
51
51
  }[];
package/dist/share.js CHANGED
@@ -115,11 +115,11 @@ export async function lookupPublicIp(send = fetch, timeoutMs = 2500) {
115
115
  * somewhere else can open. It is labelled for what it is, because the key in
116
116
  * the link is then the only thing between a stranger and the library.
117
117
  */
118
- export function reachableAddresses(host, port, publicUrl = "") {
118
+ export function reachableAddresses(host, port, publicUrl = "", scheme = "http") {
119
119
  const link = (address) => {
120
120
  // A bare IPv6 address needs brackets before it is a URL.
121
121
  const authority = address.includes(":") ? `[${address}]` : address;
122
- return `http://${authority}:${port}`;
122
+ return `${scheme}://${authority}:${port}`;
123
123
  };
124
124
  // An address somebody told us about, because it is one this machine cannot
125
125
  // know: a tunnel, a reverse proxy, or a router forwarding a port. It goes
@@ -145,7 +145,7 @@ export function reachableAddresses(host, port, publicUrl = "") {
145
145
  found.sort((x, y) => order[x.kind] - order[y.kind]);
146
146
  return [
147
147
  ...told,
148
- { label: "here", url: `http://localhost:${port}` },
148
+ { label: "here", url: `${scheme}://localhost:${port}` },
149
149
  ...found.map(({ label, url }) => ({ label, url })),
150
150
  ];
151
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
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/attach.ts CHANGED
@@ -26,7 +26,11 @@ const RECONNECT_MS = 1000;
26
26
 
27
27
  /** A remote track has no path, because no filesystem path leaves the machine. */
28
28
  export function applySnapshot(state: State, snapshot: Snapshot): void {
29
- state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
29
+ // A frame without a list is not an empty library, it is a frame with nothing
30
+ // new to say about it -- which is every frame but the first.
31
+ if (snapshot.tracks !== undefined) {
32
+ state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
33
+ }
30
34
  state.index = snapshot.index;
31
35
  state.playing = snapshot.playing;
32
36
  state.position = snapshot.position;
package/src/main.ts CHANGED
@@ -96,6 +96,9 @@ Options for serve:
96
96
  its own interfaces. Also NIXAMP_PUBLIC_URL
97
97
  --no-lookup do not ask ipinfo.io what this machine's public address is
98
98
  when nothing local looks public
99
+ --tls-cert FILE --tls-key FILE serve https rather than http. Needed by
100
+ anyone opening this from a page that is itself https, since
101
+ a browser refuses every request from https to http
99
102
  --ingest accept a live stream in at POST /api/ingest
100
103
  --rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
101
104
  --rtmp-streams N how many may publish at once (default 3, a port each)
package/src/protocol.ts CHANGED
@@ -27,7 +27,24 @@ export interface RemoteTrack {
27
27
  export interface Snapshot {
28
28
  /** Bumped on every push so a client can drop an out-of-order frame. */
29
29
  revision: number;
30
- tracks: RemoteTrack[];
30
+ /**
31
+ * The library, sent when it is news and left out when it is not.
32
+ *
33
+ * It used to ride in every frame. At twelve frames a second over a library of
34
+ * five thousand, that is five megabytes a second of JSON for a client to
35
+ * parse on the thread that is also decoding the audio -- which is exactly
36
+ * what it sounded like. It is sent on the first frame of a subscription and
37
+ * again whenever the list actually changes; absent means "the same as
38
+ * before", and a client keeps what it had.
39
+ */
40
+ tracks?: RemoteTrack[];
41
+ /**
42
+ * How many tracks there are, in every frame.
43
+ *
44
+ * A client that has not received a list yet still has to draw something, and
45
+ * a count is four bytes rather than half a megabyte.
46
+ */
47
+ trackCount: number;
31
48
  index: number;
32
49
  playing: boolean;
33
50
  position: number;
@@ -83,10 +100,11 @@ export function remoteName(track: RemoteTrack): string {
83
100
  return track.artist ? `${track.artist} — ${track.title}` : track.title;
84
101
  }
85
102
 
86
- export function emptySnapshot(): Snapshot {
103
+ export function emptySnapshot(): FullSnapshot {
87
104
  return {
88
105
  revision: 0,
89
106
  tracks: [],
107
+ trackCount: 0,
90
108
  index: 0,
91
109
  playing: false,
92
110
  position: 0,
@@ -97,3 +115,20 @@ export function emptySnapshot(): Snapshot {
97
115
  root: "",
98
116
  };
99
117
  }
118
+
119
+ /**
120
+ * Fold a frame into what the client already had.
121
+ *
122
+ * A frame without `tracks` is not a frame with no tracks: it is a frame that
123
+ * had nothing new to say about them. Every client needs this, so none of them
124
+ * should write it twice.
125
+ */
126
+ export function merge(previous: FullSnapshot, incoming: Snapshot): FullSnapshot {
127
+ return { ...incoming, tracks: incoming.tracks ?? previous.tracks };
128
+ }
129
+
130
+ /**
131
+ * A snapshot a client has already folded, so the library is known to be there.
132
+ * Every reader wants this one; only the wire carries the other.
133
+ */
134
+ export type FullSnapshot = Snapshot & { tracks: RemoteTrack[] };