nixamp 0.7.6 → 0.7.7

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/admin.js CHANGED
@@ -21,7 +21,15 @@ export function resolveTarget(argv) {
21
21
  }
22
22
  const state = readState();
23
23
  if (state === null) {
24
- throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
24
+ // Named, with an example. "or pass --url" is only an instruction if you
25
+ // already know what belongs after it, and the whole point of this message
26
+ // is that you are looking at a machine you have no handle on.
27
+ throw new Error("nixamp: no daemon is running on this machine.\n" +
28
+ " Start one: nixamp daemon start ~/Music\n" +
29
+ " Or administer another machine, with its share link:\n" +
30
+ " nixamp admin --url https://server1.you.nixamp.com:4321 --key KEY\n" +
31
+ " The URL and key are the two halves of the link that server printed:\n" +
32
+ " https://host:4321/s/KEY");
25
33
  }
26
34
  const url_ = daemonUrl(state);
27
35
  // Talking to our own daemon, whose certificate names somewhere else. Nothing
package/dist/main.js CHANGED
@@ -621,7 +621,11 @@ export function view({ ui, theme, height }, state) {
621
621
  }
622
622
  if (import.meta.main) {
623
623
  main().catch((error) => {
624
- console.error(error);
624
+ // The same rule the installed launcher uses: a message we wrote is one the
625
+ // reader can act on, and printing a stack over it buries the sentence that
626
+ // says what to do.
627
+ const message = error instanceof Error ? error.message : String(error);
628
+ console.error(message.startsWith("nixamp:") ? message : error);
625
629
  process.exit(1);
626
630
  });
627
631
  }
package/dist/server.d.ts CHANGED
@@ -160,6 +160,8 @@ export interface Engine {
160
160
  * this server somewhere else", which throws the library away on purpose.
161
161
  */
162
162
  replace(tracks: Track[], root: string): void;
163
+ /** The library, arriving after the server was already listening. */
164
+ fill(tracks: Track[], root: string): void;
163
165
  /**
164
166
  * Play something as well as everything here.
165
167
  *
@@ -270,6 +272,21 @@ export declare class PlayerEngine implements Engine {
270
272
  */
271
273
  drop(group: string): number;
272
274
  groups(): string[];
275
+ /**
276
+ * The library, arriving after the server was already listening.
277
+ *
278
+ * Walking a directory is the slow part of starting -- eighty thousand files
279
+ * under a home directory takes far longer than the fifteen seconds
280
+ * `nixamp daemon start` waits for the server to say it is up, so starting a
281
+ * daemon with no source given looked exactly like hanging and then failed
282
+ * about a server that was working. The port opens first now and this puts
283
+ * the library in behind it.
284
+ *
285
+ * Unlike `replace` it stops nothing and clears no listeners: whoever
286
+ * connected in the first second is still connected, and simply sees the
287
+ * playlist appear.
288
+ */
289
+ fill(tracks: Loaded[], root: string): void;
273
290
  retag(tracks: Track[], root: string): void;
274
291
  }
275
292
  /** An engine with no library behind it, for the hosted PWA. */
@@ -281,6 +298,7 @@ export declare class EmptyEngine implements Engine {
281
298
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
282
299
  trackPath(): undefined;
283
300
  replace(): void;
301
+ fill(): void;
284
302
  add(): number;
285
303
  drop(): number;
286
304
  groups(): string[];
@@ -339,6 +357,18 @@ export interface HandlerOptions {
339
357
  destinations: Destination[];
340
358
  settings: EncoderSettings;
341
359
  };
360
+ /**
361
+ * Where OBS should point, one entry per stream this server will accept.
362
+ *
363
+ * There is deliberately no single link. ffmpeg's RTMP listener serves one
364
+ * connection per process, so three people going live at once is three ports
365
+ * and three URLs -- and a panel offering one address for all of them would
366
+ * be offering an address that works exactly once.
367
+ */
368
+ publishUrls?: () => {
369
+ id: string;
370
+ url: string;
371
+ }[];
342
372
  /** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
343
373
  accounts?: Accounts;
344
374
  /** Providers to sign in with, and the terminals waiting to be connected. */
package/dist/server.js CHANGED
@@ -584,6 +584,30 @@ export class PlayerEngine {
584
584
  }
585
585
  return seen;
586
586
  }
587
+ /**
588
+ * The library, arriving after the server was already listening.
589
+ *
590
+ * Walking a directory is the slow part of starting -- eighty thousand files
591
+ * under a home directory takes far longer than the fifteen seconds
592
+ * `nixamp daemon start` waits for the server to say it is up, so starting a
593
+ * daemon with no source given looked exactly like hanging and then failed
594
+ * about a server that was working. The port opens first now and this puts
595
+ * the library in behind it.
596
+ *
597
+ * Unlike `replace` it stops nothing and clears no listeners: whoever
598
+ * connected in the first second is still connected, and simply sees the
599
+ * playlist appear.
600
+ */
601
+ fill(tracks, root) {
602
+ // Something is already loaded, so this is a scan that finished after
603
+ // somebody pointed the server elsewhere. Theirs wins.
604
+ if (this.tracks.length > 0)
605
+ return;
606
+ this.tracks = tracks;
607
+ this.root = root;
608
+ this.state.note = tracks.length === 0 ? `No audio files under ${root}.` : "";
609
+ this.push(true);
610
+ }
587
611
  retag(tracks, root) {
588
612
  // Matched by path rather than by position, because the list is no longer
589
613
  // required to be the one that was sent for tagging: somebody can add an
@@ -630,6 +654,7 @@ export class EmptyEngine {
630
654
  return undefined;
631
655
  }
632
656
  replace() { }
657
+ fill() { }
633
658
  add() {
634
659
  return 0;
635
660
  }
@@ -1795,6 +1820,9 @@ export function createHandler(engine, options) {
1795
1820
  active: tracker.active,
1796
1821
  startedAt: started,
1797
1822
  now: Date.now(),
1823
+ // Where to point OBS. Printed at startup since RTMP was added, which
1824
+ // is no use at all to somebody looking at the admin panel a day later.
1825
+ publish: options.publishUrls?.() ?? [],
1798
1826
  });
1799
1827
  return;
1800
1828
  }
@@ -2341,10 +2369,9 @@ export async function serve(argv, version = "0.1.0") {
2341
2369
  //
2342
2370
  // So the filenames are enough to start: the server is up and answering in the
2343
2371
  // time it takes to walk the directory, and the titles fill in behind it.
2344
- const tracks = await loadSource(tools, root, false);
2345
- const engine = tracks.length > 0
2346
- ? new PlayerEngine(tracks, root, tools)
2347
- : new EmptyEngine(`No audio files under ${root}.`);
2372
+ // Empty on purpose. The walk happens below, once the port is open: it is
2373
+ // the slowest part of starting and nothing about it needs to happen first.
2374
+ const engine = new PlayerEngine([], root, tools);
2348
2375
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
2349
2376
  const key = options.key ? newKey() : null;
2350
2377
  // Minted whether or not it is published, so `nixamp admin` and the operator
@@ -2543,11 +2570,16 @@ export async function serve(argv, version = "0.1.0") {
2543
2570
  }
2544
2571
  })()
2545
2572
  : undefined;
2573
+ // Filled in below, when the RTMP listeners are opened. Read through a
2574
+ // function so the handler sees the list rather than the empty array it was
2575
+ // built with.
2576
+ let publishUrls = [];
2546
2577
  const server = createServer(engine, {
2547
2578
  web,
2548
2579
  media: options.media,
2549
2580
  owner,
2550
2581
  channels,
2582
+ publishUrls: () => publishUrls,
2551
2583
  ...(ingest ? { ingest } : {}),
2552
2584
  broadcaster,
2553
2585
  broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
@@ -2651,19 +2683,30 @@ export async function serve(argv, version = "0.1.0") {
2651
2683
  guessedPublic: guessedPublic !== "",
2652
2684
  }));
2653
2685
  }
2654
- // The other half of "names now, tags later". It runs while the banner is
2655
- // printed and while the publish prompt waits, and it is deliberately not
2656
- // awaited: nothing downstream needs it, and a library that takes a minute to
2657
- // read should cost nobody a minute of silence.
2658
- if (tracks.length > 0 && !isRemote(root)) {
2659
- void loadTagged(tools, root)
2686
+ // Names now, tags later, and both after the door is open. Not awaited:
2687
+ // nothing below needs the library, and a directory that takes a minute to
2688
+ // walk should cost nobody a minute of not being able to connect.
2689
+ void loadSource(tools, root, false)
2690
+ .then((found) => {
2691
+ engine.fill(found, root);
2692
+ if (found.length === 0) {
2693
+ console.log(`nixamp serve — no audio files under ${root}`);
2694
+ return;
2695
+ }
2696
+ console.log(`nixamp serve — ${found.length} tracks under ${root}`);
2697
+ if (isRemote(root))
2698
+ return;
2699
+ return loadTagged(tools, root)
2660
2700
  .then((tagged) => engine.retag(tagged, root))
2661
2701
  .catch(() => {
2662
- // Filenames are a working player. A failure here is worth nothing but
2663
- // titles that stay as they are.
2702
+ // Filenames are a working player. A failure here is worth nothing
2703
+ // but titles that stay as they are.
2664
2704
  });
2665
- }
2666
- console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
2705
+ })
2706
+ .catch((error) => {
2707
+ console.log(`nixamp: could not read ${root}: ${error.message}`);
2708
+ });
2709
+ console.log(`nixamp serve — reading ${root}`);
2667
2710
  console.log("");
2668
2711
  const width = Math.max(...addresses.map((a) => a.label.length));
2669
2712
  for (const { label, url } of addresses) {
@@ -2716,9 +2759,13 @@ export async function serve(argv, version = "0.1.0") {
2716
2759
  }));
2717
2760
  rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
2718
2761
  rtmp.listen(slots);
2762
+ publishUrls = slots.map((slot) => ({
2763
+ id: slot.id,
2764
+ url: `rtmp://${host}:${slot.port}/live/${listenKey ?? "live"}`,
2765
+ }));
2719
2766
  console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
2720
- for (const slot of slots) {
2721
- console.log(` rtmp://${host}:${slot.port}/live/${listenKey ?? "live"} -> "${slot.id}"`);
2767
+ for (const entry of publishUrls) {
2768
+ console.log(` ${entry.url} -> "${entry.id}"`);
2722
2769
  }
2723
2770
  }
2724
2771
  if (destinations.length > 0) {
@@ -2782,7 +2829,9 @@ export async function serve(argv, version = "0.1.0") {
2782
2829
  name: options.name || hostname(),
2783
2830
  url: listen,
2784
2831
  audio,
2785
- tracks: tracks.length,
2832
+ // Asked of the engine rather than a variable, because the library is
2833
+ // now read after the port opens and may still be arriving.
2834
+ tracks: engine.snapshot(false).trackCount,
2786
2835
  // From `nixamp login`. The directory will not list a stream it cannot
2787
2836
  // attribute to somebody, because a listing is now a phone code that
2788
2837
  // costs money to answer.
package/dist/session.js CHANGED
@@ -512,7 +512,9 @@ export async function servers(argv, fetcher = fetch) {
512
512
  const daemon = await import("./daemon.js");
513
513
  const state = daemon.readState();
514
514
  if (state === null) {
515
- console.error("nixamp: no daemon is running here. Start one, or pass a URL.");
515
+ console.error("nixamp: no daemon is running here.\n" +
516
+ " Start one: nixamp daemon start ~/Music\n" +
517
+ " Or give the share link of the one you mean: https://host:4321/s/KEY");
516
518
  return 1;
517
519
  }
518
520
  // The address worth remembering is the one somebody else can open.
package/dist/share.d.ts CHANGED
@@ -45,6 +45,16 @@ 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
+ /**
49
+ * Whether a browser could ever verify a certificate for this address.
50
+ *
51
+ * A certificate is issued for a name. Handed an https link to a bare IP, a
52
+ * browser has nothing to match it against and refuses the connection before it
53
+ * asks anything -- and from the page it looks identical to a machine that is
54
+ * switched off. Offering such a link is offering one that cannot work, so the
55
+ * links are labelled and the ones that can work go first.
56
+ */
57
+ export declare function certifiable(url: string): boolean;
48
58
  export declare function reachableAddresses(host: string, port: number, publicUrl?: string, scheme?: "http" | "https"): {
49
59
  label: string;
50
60
  url: string;
package/dist/share.js CHANGED
@@ -115,6 +115,25 @@ 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
+ /**
119
+ * Whether a browser could ever verify a certificate for this address.
120
+ *
121
+ * A certificate is issued for a name. Handed an https link to a bare IP, a
122
+ * browser has nothing to match it against and refuses the connection before it
123
+ * asks anything -- and from the page it looks identical to a machine that is
124
+ * switched off. Offering such a link is offering one that cannot work, so the
125
+ * links are labelled and the ones that can work go first.
126
+ */
127
+ export function certifiable(url) {
128
+ if (!url.startsWith("https://"))
129
+ return true;
130
+ try {
131
+ return !isIpAddress(new URL(url).hostname.replace(/^\[|\]$/g, ""));
132
+ }
133
+ catch {
134
+ return true;
135
+ }
136
+ }
118
137
  export function reachableAddresses(host, port, publicUrl = "", scheme = "http") {
119
138
  const link = (address) => {
120
139
  // A bare IPv6 address needs brackets before it is a URL.
@@ -143,11 +162,22 @@ export function reachableAddresses(host, port, publicUrl = "", scheme = "http")
143
162
  }
144
163
  const order = { private: 0, cgnat: 1, public: 2 };
145
164
  found.sort((x, y) => order[x.kind] - order[y.kind]);
146
- return [
165
+ const all = [
147
166
  ...told,
148
167
  { label: "here", url: `${scheme}://localhost:${port}` },
149
168
  ...found.map(({ label, url }) => ({ label, url })),
150
169
  ];
170
+ // Serving https, an address that is a bare IP cannot be verified by anyone,
171
+ // so it is said last and said differently rather than handed out as though
172
+ // it were a link somebody could use.
173
+ if (scheme !== "https")
174
+ return all;
175
+ return [
176
+ ...all.filter((entry) => certifiable(entry.url)),
177
+ ...all
178
+ .filter((entry) => !certifiable(entry.url))
179
+ .map((entry) => ({ label: `${entry.label} (no certificate for an IP)`, url: entry.url })),
180
+ ];
151
181
  }
152
182
  /** The full link, key and all. */
153
183
  export function shareLink(base, key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
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/admin.ts CHANGED
@@ -57,7 +57,17 @@ export function resolveTarget(argv: string[]): AdminOptions {
57
57
 
58
58
  const state = readState();
59
59
  if (state === null) {
60
- throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
60
+ // Named, with an example. "or pass --url" is only an instruction if you
61
+ // already know what belongs after it, and the whole point of this message
62
+ // is that you are looking at a machine you have no handle on.
63
+ throw new Error(
64
+ "nixamp: no daemon is running on this machine.\n" +
65
+ " Start one: nixamp daemon start ~/Music\n" +
66
+ " Or administer another machine, with its share link:\n" +
67
+ " nixamp admin --url https://server1.you.nixamp.com:4321 --key KEY\n" +
68
+ " The URL and key are the two halves of the link that server printed:\n" +
69
+ " https://host:4321/s/KEY",
70
+ );
61
71
  }
62
72
  const url_ = daemonUrl(state);
63
73
  // Talking to our own daemon, whose certificate names somewhere else. Nothing
package/src/main.ts CHANGED
@@ -634,7 +634,11 @@ export function view(
634
634
 
635
635
  if (import.meta.main) {
636
636
  main().catch((error) => {
637
- console.error(error);
637
+ // The same rule the installed launcher uses: a message we wrote is one the
638
+ // reader can act on, and printing a stack over it buries the sentence that
639
+ // says what to do.
640
+ const message = error instanceof Error ? error.message : String(error);
641
+ console.error(message.startsWith("nixamp:") ? message : error);
638
642
  process.exit(1);
639
643
  });
640
644
  }
package/src/server.ts CHANGED
@@ -426,6 +426,8 @@ export interface Engine {
426
426
  * this server somewhere else", which throws the library away on purpose.
427
427
  */
428
428
  replace(tracks: Track[], root: string): void;
429
+ /** The library, arriving after the server was already listening. */
430
+ fill(tracks: Track[], root: string): void;
429
431
  /**
430
432
  * Play something as well as everything here.
431
433
  *
@@ -757,6 +759,30 @@ export class PlayerEngine implements Engine {
757
759
  return seen;
758
760
  }
759
761
 
762
+ /**
763
+ * The library, arriving after the server was already listening.
764
+ *
765
+ * Walking a directory is the slow part of starting -- eighty thousand files
766
+ * under a home directory takes far longer than the fifteen seconds
767
+ * `nixamp daemon start` waits for the server to say it is up, so starting a
768
+ * daemon with no source given looked exactly like hanging and then failed
769
+ * about a server that was working. The port opens first now and this puts
770
+ * the library in behind it.
771
+ *
772
+ * Unlike `replace` it stops nothing and clears no listeners: whoever
773
+ * connected in the first second is still connected, and simply sees the
774
+ * playlist appear.
775
+ */
776
+ fill(tracks: Loaded[], root: string): void {
777
+ // Something is already loaded, so this is a scan that finished after
778
+ // somebody pointed the server elsewhere. Theirs wins.
779
+ if (this.tracks.length > 0) return;
780
+ this.tracks = tracks;
781
+ this.root = root;
782
+ this.state.note = tracks.length === 0 ? `No audio files under ${root}.` : "";
783
+ this.push(true);
784
+ }
785
+
760
786
  retag(tracks: Track[], root: string): void {
761
787
  // Matched by path rather than by position, because the list is no longer
762
788
  // required to be the one that was sent for tagging: somebody can add an
@@ -800,6 +826,7 @@ export class EmptyEngine implements Engine {
800
826
  return undefined;
801
827
  }
802
828
  replace(): void {}
829
+ fill(): void {}
803
830
  add(): number {
804
831
  return 0;
805
832
  }
@@ -930,6 +957,15 @@ export interface HandlerOptions {
930
957
  broadcaster?: Broadcaster;
931
958
  /** Where a broadcast should send, and what it should look like. */
932
959
  broadcast?: () => { destinations: Destination[]; settings: EncoderSettings };
960
+ /**
961
+ * Where OBS should point, one entry per stream this server will accept.
962
+ *
963
+ * There is deliberately no single link. ffmpeg's RTMP listener serves one
964
+ * connection per process, so three people going live at once is three ports
965
+ * and three URLs -- and a panel offering one address for all of them would
966
+ * be offering an address that works exactly once.
967
+ */
968
+ publishUrls?: () => { id: string; url: string }[];
933
969
  /** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
934
970
  accounts?: Accounts;
935
971
  /** Providers to sign in with, and the terminals waiting to be connected. */
@@ -2141,6 +2177,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2141
2177
  active: tracker.active,
2142
2178
  startedAt: started,
2143
2179
  now: Date.now(),
2180
+ // Where to point OBS. Printed at startup since RTMP was added, which
2181
+ // is no use at all to somebody looking at the admin panel a day later.
2182
+ publish: options.publishUrls?.() ?? [],
2144
2183
  });
2145
2184
  return;
2146
2185
  }
@@ -2726,10 +2765,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2726
2765
  //
2727
2766
  // So the filenames are enough to start: the server is up and answering in the
2728
2767
  // time it takes to walk the directory, and the titles fill in behind it.
2729
- const tracks = await loadSource(tools, root, false);
2730
- const engine: Engine = tracks.length > 0
2731
- ? new PlayerEngine(tracks, root, tools)
2732
- : new EmptyEngine(`No audio files under ${root}.`);
2768
+ // Empty on purpose. The walk happens below, once the port is open: it is
2769
+ // the slowest part of starting and nothing about it needs to happen first.
2770
+ const engine = new PlayerEngine([], root, tools);
2733
2771
 
2734
2772
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
2735
2773
  const key = options.key ? newKey() : null;
@@ -2947,11 +2985,17 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2947
2985
  })()
2948
2986
  : undefined;
2949
2987
 
2988
+ // Filled in below, when the RTMP listeners are opened. Read through a
2989
+ // function so the handler sees the list rather than the empty array it was
2990
+ // built with.
2991
+ let publishUrls: { id: string; url: string }[] = [];
2992
+
2950
2993
  const server = createServer(engine, {
2951
2994
  web,
2952
2995
  media: options.media,
2953
2996
  owner,
2954
2997
  channels,
2998
+ publishUrls: () => publishUrls,
2955
2999
  ...(ingest ? { ingest } : {}),
2956
3000
  broadcaster,
2957
3001
  broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
@@ -3077,20 +3121,30 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3077
3121
  );
3078
3122
  }
3079
3123
 
3080
- // The other half of "names now, tags later". It runs while the banner is
3081
- // printed and while the publish prompt waits, and it is deliberately not
3082
- // awaited: nothing downstream needs it, and a library that takes a minute to
3083
- // read should cost nobody a minute of silence.
3084
- if (tracks.length > 0 && !isRemote(root)) {
3085
- void loadTagged(tools, root)
3086
- .then((tagged) => engine.retag(tagged, root))
3087
- .catch(() => {
3088
- // Filenames are a working player. A failure here is worth nothing but
3089
- // titles that stay as they are.
3090
- });
3091
- }
3124
+ // Names now, tags later, and both after the door is open. Not awaited:
3125
+ // nothing below needs the library, and a directory that takes a minute to
3126
+ // walk should cost nobody a minute of not being able to connect.
3127
+ void loadSource(tools, root, false)
3128
+ .then((found) => {
3129
+ engine.fill(found, root);
3130
+ if (found.length === 0) {
3131
+ console.log(`nixamp serve — no audio files under ${root}`);
3132
+ return;
3133
+ }
3134
+ console.log(`nixamp serve — ${found.length} tracks under ${root}`);
3135
+ if (isRemote(root)) return;
3136
+ return loadTagged(tools, root)
3137
+ .then((tagged) => engine.retag(tagged, root))
3138
+ .catch(() => {
3139
+ // Filenames are a working player. A failure here is worth nothing
3140
+ // but titles that stay as they are.
3141
+ });
3142
+ })
3143
+ .catch((error: unknown) => {
3144
+ console.log(`nixamp: could not read ${root}: ${(error as Error).message}`);
3145
+ });
3092
3146
 
3093
- console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
3147
+ console.log(`nixamp serve — reading ${root}`);
3094
3148
  console.log("");
3095
3149
 
3096
3150
  const width = Math.max(...addresses.map((a) => a.label.length));
@@ -3145,9 +3199,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3145
3199
  rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
3146
3200
  rtmp.listen(slots);
3147
3201
 
3202
+ publishUrls = slots.map((slot) => ({
3203
+ id: slot.id,
3204
+ url: `rtmp://${host}:${slot.port}/live/${listenKey ?? "live"}`,
3205
+ }));
3206
+
3148
3207
  console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
3149
- for (const slot of slots) {
3150
- console.log(` rtmp://${host}:${slot.port}/live/${listenKey ?? "live"} -> "${slot.id}"`);
3208
+ for (const entry of publishUrls) {
3209
+ console.log(` ${entry.url} -> "${entry.id}"`);
3151
3210
  }
3152
3211
  }
3153
3212
  if (destinations.length > 0) {
@@ -3211,7 +3270,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3211
3270
  name: options.name || hostname(),
3212
3271
  url: listen,
3213
3272
  audio,
3214
- tracks: tracks.length,
3273
+ // Asked of the engine rather than a variable, because the library is
3274
+ // now read after the port opens and may still be arriving.
3275
+ tracks: engine.snapshot(false).trackCount,
3215
3276
  // From `nixamp login`. The directory will not list a stream it cannot
3216
3277
  // attribute to somebody, because a listing is now a phone code that
3217
3278
  // costs money to answer.
package/src/session.ts CHANGED
@@ -582,7 +582,11 @@ export async function servers(argv: string[], fetcher: typeof fetch = fetch): Pr
582
582
  const daemon = await import("./daemon.ts");
583
583
  const state = daemon.readState();
584
584
  if (state === null) {
585
- console.error("nixamp: no daemon is running here. Start one, or pass a URL.");
585
+ console.error(
586
+ "nixamp: no daemon is running here.\n" +
587
+ " Start one: nixamp daemon start ~/Music\n" +
588
+ " Or give the share link of the one you mean: https://host:4321/s/KEY",
589
+ );
586
590
  return 1;
587
591
  }
588
592
  // The address worth remembering is the one somebody else can open.
package/src/share.ts CHANGED
@@ -128,6 +128,24 @@ export async function lookupPublicIp(send: typeof fetch = fetch, timeoutMs = 250
128
128
  * somewhere else can open. It is labelled for what it is, because the key in
129
129
  * the link is then the only thing between a stranger and the library.
130
130
  */
131
+ /**
132
+ * Whether a browser could ever verify a certificate for this address.
133
+ *
134
+ * A certificate is issued for a name. Handed an https link to a bare IP, a
135
+ * browser has nothing to match it against and refuses the connection before it
136
+ * asks anything -- and from the page it looks identical to a machine that is
137
+ * switched off. Offering such a link is offering one that cannot work, so the
138
+ * links are labelled and the ones that can work go first.
139
+ */
140
+ export function certifiable(url: string): boolean {
141
+ if (!url.startsWith("https://")) return true;
142
+ try {
143
+ return !isIpAddress(new URL(url).hostname.replace(/^\[|\]$/g, ""));
144
+ } catch {
145
+ return true;
146
+ }
147
+ }
148
+
131
149
  export function reachableAddresses(
132
150
  host: string,
133
151
  port: number,
@@ -161,11 +179,21 @@ export function reachableAddresses(
161
179
  }
162
180
  const order = { private: 0, cgnat: 1, public: 2 } as const;
163
181
  found.sort((x, y) => order[x.kind] - order[y.kind]);
164
- return [
182
+ const all = [
165
183
  ...told,
166
184
  { label: "here", url: `${scheme}://localhost:${port}` },
167
185
  ...found.map(({ label, url }) => ({ label, url })),
168
186
  ];
187
+ // Serving https, an address that is a bare IP cannot be verified by anyone,
188
+ // so it is said last and said differently rather than handed out as though
189
+ // it were a link somebody could use.
190
+ if (scheme !== "https") return all;
191
+ return [
192
+ ...all.filter((entry) => certifiable(entry.url)),
193
+ ...all
194
+ .filter((entry) => !certifiable(entry.url))
195
+ .map((entry) => ({ label: `${entry.label} (no certificate for an IP)`, url: entry.url })),
196
+ ];
169
197
  }
170
198
 
171
199
  /** The full link, key and all. */
@@ -1 +1 @@
1
- import{t as e}from"./index-Bs7oVbTk.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
1
+ import{t as e}from"./index-CGXkk7_z.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
@@ -0,0 +1 @@
1
+ :root{--bg:#080c09;--panel:#0c120e;--edge:#1d2c22;--green:#4af689;--green-dim:#227a4a;--fg:#cfe8d8;--muted:#6d8a79;--warn:#e8c35a;--accent:#7ef0c4;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}[hidden]{display:none!important}html,body{background:var(--bg);min-height:100%;color:var(--fg);margin:0;font:14px/1.45 ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace}body{background-image:repeating-linear-gradient(#4af68906 0 1px,#0000 1px 3px)}#app{max-width:1100px;padding:calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));flex-direction:column;gap:10px;margin:0 auto;display:flex}.bar{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.brand{color:var(--green);letter-spacing:.14em;font-weight:700}.status{color:var(--muted)}.status[data-playing=true]{color:var(--green)}.chip{color:var(--muted);border:1px solid var(--edge);text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:46vw;margin-left:auto;padding:1px 10px;font-size:12px;overflow:hidden}.panel{border:1px solid var(--edge);background:var(--panel);border-radius:6px;min-width:0;padding:14px 12px 12px;position:relative}.panel:before{content:attr(data-title);background:var(--panel);color:var(--green-dim);letter-spacing:.06em;padding:0 6px;font-size:12px;position:absolute;top:-.72em;left:10px}.split{grid-template-columns:1.3fr 1fr;gap:10px;display:grid}.split>.col-a{grid-column:1}.split>.col-b{grid-column:2}@media (max-width:720px){.split{grid-template-columns:1fr}.split>.col-a,.split>.col-b{grid-column:auto}}.track-title{color:var(--accent);text-overflow:ellipsis;white-space:nowrap;font-weight:700;overflow:hidden}.track-sub{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}#video{border:1px solid var(--edge);background:#000;border-radius:4px;width:100%;max-height:46vh;margin-bottom:8px}.scrub{align-items:center;gap:10px;margin-top:6px;display:flex}.time{color:var(--fg);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}input[type=range]{appearance:none;cursor:pointer;background:0 0;flex:1;min-width:0;height:14px}input[type=range]::-webkit-slider-runnable-track{background:linear-gradient(var(--edge), var(--edge));border:1px solid var(--edge);border-radius:3px;height:6px}input[type=range]::-moz-range-track{background:var(--edge);border-radius:3px;height:6px}input[type=range]::-webkit-slider-thumb{appearance:none;background:var(--green);border-radius:2px;width:10px;height:16px;margin-top:-6px}input[type=range]::-moz-range-thumb{background:var(--green);border:0;border-radius:2px;width:10px;height:16px}input[type=range]:disabled{opacity:.45;cursor:default}#spectrum{border:1px solid var(--edge);background:#060a07;border-radius:4px;width:100%;height:190px;display:block}.meters{align-items:baseline;gap:10px;margin-top:6px;display:flex;overflow:hidden}.glyphs{color:var(--green);letter-spacing:1px;white-space:nowrap;min-height:1.4em;overflow:hidden}.levelmeter{color:var(--accent);white-space:nowrap;margin-left:auto}.playlist{scrollbar-color:var(--green-dim) transparent;max-height:214px;margin:0;padding:0;list-style:none;overflow-y:auto}.row{cursor:pointer;white-space:nowrap;border-radius:3px;gap:8px;padding:2px 6px;display:flex}.row:hover{background:#142019}.row.selected{background:#16241c}.row.selected .name{color:var(--accent)}.row.playing .name{color:var(--green)}.group{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;border-top:1px solid var(--edge);align-items:center;gap:8px;margin-top:4px;padding:6px 6px 2px;font-size:11px;display:flex}.group:first-child{border-top:0;margin-top:0}.group-name{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.group-remove{border:1px solid var(--edge);color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;line-height:1.4}.group-remove:hover{color:var(--accent);border-color:var(--accent)}.row .n{color:var(--muted);text-align:right;flex:none;width:2.4em}.row .name{text-overflow:ellipsis;flex:1;overflow:hidden}.row .time{color:var(--muted);flex:none}.transport{border:1px solid var(--edge);background:var(--panel);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;display:flex}button{font:inherit;color:var(--fg);border:1px solid var(--edge);cursor:pointer;background:#121a15;border-radius:4px;padding:6px 12px}button:hover{border-color:var(--green-dim);color:var(--green)}button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--green);outline-offset:2px}button.primary{color:var(--green);border-color:var(--green-dim);min-width:64px}button.ghost{color:var(--muted);background:0 0}.volume{flex:1;align-items:center;gap:8px;min-width:120px;max-width:220px;margin-left:auto;display:flex}.vol{color:var(--muted);letter-spacing:.08em;font-size:12px}.picker{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.button{border:1px solid var(--edge);cursor:pointer;color:var(--fg);background:#121a15;border-radius:4px;padding:6px 12px;display:inline-block}.button:hover{border-color:var(--green-dim);color:var(--green)}.button input[type=file]{display:none}#remote-url{font:inherit;min-width:12ch;color:var(--fg);border:1px solid var(--edge);background:#060a07;border-radius:4px;flex:1;padding:6px 10px}.check{color:var(--muted);align-items:center;gap:8px;margin-top:10px;display:flex}.check input{accent-color:var(--green)}.hint{color:var(--muted);margin:0 0 8px;font-size:13px}.hint code{color:var(--accent)}#remote-state[data-status=live]{color:var(--green)}#remote-state[data-status=connecting],#remote-state[data-status=error]{color:var(--warn)}.note{color:var(--warn);border-left:2px solid var(--warn);margin:0;padding-left:8px}.statusbar{color:var(--muted);border-top:1px solid var(--edge);flex-wrap:wrap;gap:14px;padding-top:8px;font-size:12px;display:flex}.statusbar b{color:var(--green);font-weight:700}.statusbar .spacer{flex:1}.statusbar a{color:var(--muted)}.directory{border-top:1px solid var(--line);margin-top:.6rem;padding-top:.6rem}.directory-list{max-height:12rem;margin:0;padding:0;list-style:none;overflow-y:auto}.directory-list li+li{margin-top:.3rem}.directory-list button{border:1px solid var(--line);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:4px;padding:.4rem .5rem}.directory-list button:hover,.directory-list button:focus-visible{border-color:var(--accent);background:#ffffff0a}.directory-list .name{color:var(--accent);display:block}.directory-list .detail{opacity:.7;text-overflow:ellipsis;white-space:nowrap;font-size:.85em;display:block;overflow:hidden}.publish-list{margin:.3rem 0 0;padding:0;list-style:none}.publish-list li{align-items:center;gap:8px;margin-top:.3rem;display:flex}.publish-list .slot{color:var(--muted);flex:none;min-width:4.5em;font-size:12px}.publish-list input{flex:auto;min-width:0}.directory-list li.offline .recent-label{opacity:.55}.directory-list li.offline .detail{color:var(--warn)}.share-line{align-items:center;gap:8px;margin:.4rem 0;display:flex}.share-what{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;flex:none;font-size:12px}.share-line input{flex:auto;min-width:0}#share-phone{color:var(--fg);font-size:1.05em}#share-phone b{color:var(--accent)}.admin-table{border-collapse:collapse;width:100%;max-height:14rem;margin:.4rem 0;font-size:.85em;display:block;overflow-y:auto}.admin-table th{text-align:left;opacity:.6;padding:.2rem .4rem .2rem 0;font-weight:400}.admin-table td{white-space:nowrap;text-overflow:ellipsis;max-width:12rem;padding:.2rem .4rem .2rem 0;overflow:hidden}.admin-table td.network-public{color:var(--warning,#e0b341)}.admin-table td.network-private{color:var(--success,#7fd18b)}.admin-table tr.ended{opacity:.45}body.route-directory .player-only{display:none}.directory-list li{align-items:stretch;gap:.4rem;display:flex}.directory-list li>:first-child{flex:auto;min-width:0}.directory-list li>button:first-child{text-align:left;width:100%}.directory-list li>.button,.directory-list li>.ghost{white-space:nowrap;flex:none;align-items:center;display:flex}.directory-list .follow{white-space:nowrap;width:auto;color:var(--muted);flex:none;padding-inline:.6rem}.directory-list .follow[data-following=yes]{border-color:var(--accent);color:var(--accent)}.toggle{color:var(--muted);cursor:pointer;align-items:center;gap:.35rem;display:inline-flex}.toggle input{accent-color:var(--accent)}.recent-label{border:1px solid var(--line);border-radius:4px;flex:auto;min-width:0;padding:.4rem .5rem}