nixamp 0.7.6 → 0.7.8
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 +9 -1
- package/dist/main.js +5 -1
- package/dist/owner.js +10 -0
- package/dist/server.d.ts +55 -0
- package/dist/server.js +187 -50
- package/dist/session.js +3 -1
- package/dist/share.d.ts +10 -0
- package/dist/share.js +36 -1
- package/package.json +1 -1
- package/src/admin.ts +11 -1
- package/src/main.ts +5 -1
- package/src/owner.ts +10 -0
- package/src/server.ts +196 -31
- package/src/session.ts +5 -1
- package/src/share.ts +33 -1
- package/web/dist/assets/{hls-3VKVEQE3-CKpUWwhK.js → hls-3VKVEQE3-CtqtOX7p.js} +1 -1
- package/web/dist/assets/index-B5ijv1SY.css +1 -0
- package/web/dist/assets/index-BL-Q9Q2e.js +1 -0
- package/web/dist/assets/{mpegts-6hEDu2OP.js → mpegts-DtUWYYid.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-BAhx7mvs.js → mpegts-LO6RVLD6-DIRN6lqd.js} +1 -1
- package/web/dist/index.html +17 -11
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-Bs7oVbTk.js +0 -1
- package/web/dist/assets/index-CTgfCs0m.css +0 -1
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
|
-
|
|
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
|
-
|
|
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/owner.js
CHANGED
|
@@ -85,7 +85,17 @@ export const ADMIN_PATHS = [
|
|
|
85
85
|
"/api/ingest",
|
|
86
86
|
"/api/admin",
|
|
87
87
|
];
|
|
88
|
+
/**
|
|
89
|
+
* Going live and coming back off, which is administering a server.
|
|
90
|
+
*
|
|
91
|
+
* Listed separately from ADMIN_PATHS on purpose: those match by prefix, and
|
|
92
|
+
* `/api/live` itself is the public listen address -- the one the phone line is
|
|
93
|
+
* handed. Gating it by prefix would shut the front door to lock the office.
|
|
94
|
+
*/
|
|
95
|
+
const LIVE_CONTROL = ["/api/live/state", "/api/live/start", "/api/live/stop"];
|
|
88
96
|
export function needsAdmin(path, method = "GET") {
|
|
97
|
+
if (LIVE_CONTROL.includes(path))
|
|
98
|
+
return true;
|
|
89
99
|
if (ADMIN_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)))
|
|
90
100
|
return true;
|
|
91
101
|
// Publishing to a channel, or ending one, is administering the server.
|
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,43 @@ export interface HandlerOptions {
|
|
|
339
357
|
destinations: Destination[];
|
|
340
358
|
settings: EncoderSettings;
|
|
341
359
|
};
|
|
360
|
+
/**
|
|
361
|
+
* Going live: whether this server is listed, and how to change that.
|
|
362
|
+
*
|
|
363
|
+
* Listing used to be a question asked once at startup and never again, so a
|
|
364
|
+
* server started with --no-publish had no listing, no phone code, and no
|
|
365
|
+
* link to hand anybody -- and no way to change its mind short of stopping
|
|
366
|
+
* and starting it. It is an action now, because that is what it is.
|
|
367
|
+
*/
|
|
368
|
+
live?: {
|
|
369
|
+
status: () => {
|
|
370
|
+
live: boolean;
|
|
371
|
+
code: string;
|
|
372
|
+
name: string;
|
|
373
|
+
url: string;
|
|
374
|
+
possible: boolean;
|
|
375
|
+
};
|
|
376
|
+
start: () => Promise<{
|
|
377
|
+
live: boolean;
|
|
378
|
+
code: string;
|
|
379
|
+
name: string;
|
|
380
|
+
url: string;
|
|
381
|
+
error?: string;
|
|
382
|
+
}>;
|
|
383
|
+
stop: () => Promise<void>;
|
|
384
|
+
};
|
|
385
|
+
/**
|
|
386
|
+
* Where OBS should point, one entry per stream this server will accept.
|
|
387
|
+
*
|
|
388
|
+
* There is deliberately no single link. ffmpeg's RTMP listener serves one
|
|
389
|
+
* connection per process, so three people going live at once is three ports
|
|
390
|
+
* and three URLs -- and a panel offering one address for all of them would
|
|
391
|
+
* be offering an address that works exactly once.
|
|
392
|
+
*/
|
|
393
|
+
publishUrls?: () => {
|
|
394
|
+
id: string;
|
|
395
|
+
url: string;
|
|
396
|
+
}[];
|
|
342
397
|
/** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
|
|
343
398
|
accounts?: Accounts;
|
|
344
399
|
/** Providers to sign in with, and the terminals waiting to be connected. */
|
package/dist/server.js
CHANGED
|
@@ -41,7 +41,7 @@ import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
|
41
41
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
42
42
|
import { isRemote, playsInBrowser, sourceLabel } from "./sources.js";
|
|
43
43
|
import { codecsOf, videoArgs } from "./audio.js";
|
|
44
|
-
import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
44
|
+
import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
45
45
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
46
46
|
import { fileURLToPath } from "node:url";
|
|
47
47
|
import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
@@ -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
|
}
|
|
@@ -1789,12 +1814,44 @@ export function createHandler(engine, options) {
|
|
|
1789
1814
|
}
|
|
1790
1815
|
// Everything the admin view draws, in one request: who is connected, and
|
|
1791
1816
|
// what this server is.
|
|
1817
|
+
// Going live, and coming back off. Admin-gated by ADMIN_PATHS, because
|
|
1818
|
+
// listing somebody's machine in a public directory is not a thing a
|
|
1819
|
+
// listener gets to do.
|
|
1820
|
+
if (path === "/api/live/state") {
|
|
1821
|
+
if (!options.live) {
|
|
1822
|
+
json(response, 200, { live: false, possible: false, code: "", name: "", url: "" });
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
json(response, 200, options.live.status());
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
if (path === "/api/live/start" || path === "/api/live/stop") {
|
|
1829
|
+
if (request.method !== "POST") {
|
|
1830
|
+
json(response, 405, { error: "POST only" });
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
if (!options.live) {
|
|
1834
|
+
json(response, 409, { error: "this nixamp cannot be listed; it has no address the world can reach" });
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
if (path === "/api/live/stop") {
|
|
1838
|
+
await options.live.stop();
|
|
1839
|
+
json(response, 200, options.live.status());
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
const started = await options.live.start();
|
|
1843
|
+
json(response, started.error ? 502 : 200, started);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1792
1846
|
if (path === "/api/connections") {
|
|
1793
1847
|
json(response, 200, {
|
|
1794
1848
|
connections: tracker.list(),
|
|
1795
1849
|
active: tracker.active,
|
|
1796
1850
|
startedAt: started,
|
|
1797
1851
|
now: Date.now(),
|
|
1852
|
+
// Where to point OBS. Printed at startup since RTMP was added, which
|
|
1853
|
+
// is no use at all to somebody looking at the admin panel a day later.
|
|
1854
|
+
publish: options.publishUrls?.() ?? [],
|
|
1798
1855
|
});
|
|
1799
1856
|
return;
|
|
1800
1857
|
}
|
|
@@ -2341,10 +2398,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2341
2398
|
//
|
|
2342
2399
|
// So the filenames are enough to start: the server is up and answering in the
|
|
2343
2400
|
// time it takes to walk the directory, and the titles fill in behind it.
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
: new EmptyEngine(`No audio files under ${root}.`);
|
|
2401
|
+
// Empty on purpose. The walk happens below, once the port is open: it is
|
|
2402
|
+
// the slowest part of starting and nothing about it needs to happen first.
|
|
2403
|
+
const engine = new PlayerEngine([], root, tools);
|
|
2348
2404
|
const web = options.web !== null ? resolve(options.web) : defaultWebDir();
|
|
2349
2405
|
const key = options.key ? newKey() : null;
|
|
2350
2406
|
// Minted whether or not it is published, so `nixamp admin` and the operator
|
|
@@ -2543,11 +2599,59 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2543
2599
|
}
|
|
2544
2600
|
})()
|
|
2545
2601
|
: undefined;
|
|
2602
|
+
// Filled in below, when the RTMP listeners are opened. Read through a
|
|
2603
|
+
// function so the handler sees the list rather than the empty array it was
|
|
2604
|
+
// built with.
|
|
2605
|
+
let publishUrls = [];
|
|
2606
|
+
// Declared up here, not beside the publishing below: the port opens before
|
|
2607
|
+
// that code runs, so an admin asking to go live in the first moments would
|
|
2608
|
+
// otherwise reach a binding that has not been initialised.
|
|
2609
|
+
let publisher = null;
|
|
2610
|
+
let listing = null;
|
|
2611
|
+
let publishable_;
|
|
2546
2612
|
const server = createServer(engine, {
|
|
2547
2613
|
web,
|
|
2548
2614
|
media: options.media,
|
|
2549
2615
|
owner,
|
|
2550
2616
|
channels,
|
|
2617
|
+
publishUrls: () => publishUrls,
|
|
2618
|
+
live: {
|
|
2619
|
+
status: () => ({
|
|
2620
|
+
live: publisher !== null,
|
|
2621
|
+
code: listing?.code ?? "",
|
|
2622
|
+
name: listing?.name ?? (options.name || hostname()),
|
|
2623
|
+
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey) : ""),
|
|
2624
|
+
// Whether going live is even possible here. A laptop behind a router
|
|
2625
|
+
// with no address the world can reach cannot be listed, and a button
|
|
2626
|
+
// that could only fail is worse than one that is not offered.
|
|
2627
|
+
possible: publishable_ !== undefined,
|
|
2628
|
+
}),
|
|
2629
|
+
start: async () => {
|
|
2630
|
+
if (publisher === null)
|
|
2631
|
+
publisher = makePublisher();
|
|
2632
|
+
if (publisher === null) {
|
|
2633
|
+
return { live: false, code: "", name: "", url: "", error: "this machine has no address the world can reach" };
|
|
2634
|
+
}
|
|
2635
|
+
const first = await publisher.start();
|
|
2636
|
+
if (first === null) {
|
|
2637
|
+
// Nothing was listed, so nothing should claim to be: a publisher
|
|
2638
|
+
// left running here would heartbeat at a directory that refused it.
|
|
2639
|
+
await publisher.stop();
|
|
2640
|
+
publisher = null;
|
|
2641
|
+
return {
|
|
2642
|
+
live: false, code: "", name: "", url: "",
|
|
2643
|
+
error: `${DEFAULT_DIRECTORY} would not list this stream. Run \`nixamp login\` on that machine.`,
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
listing = first;
|
|
2647
|
+
return { live: true, code: first.code, name: first.name, url: first.url };
|
|
2648
|
+
},
|
|
2649
|
+
stop: async () => {
|
|
2650
|
+
await publisher?.stop();
|
|
2651
|
+
publisher = null;
|
|
2652
|
+
listing = null;
|
|
2653
|
+
},
|
|
2654
|
+
},
|
|
2551
2655
|
...(ingest ? { ingest } : {}),
|
|
2552
2656
|
broadcaster,
|
|
2553
2657
|
broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
|
|
@@ -2651,19 +2755,30 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2651
2755
|
guessedPublic: guessedPublic !== "",
|
|
2652
2756
|
}));
|
|
2653
2757
|
}
|
|
2654
|
-
//
|
|
2655
|
-
//
|
|
2656
|
-
//
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2758
|
+
// Names now, tags later, and both after the door is open. Not awaited:
|
|
2759
|
+
// nothing below needs the library, and a directory that takes a minute to
|
|
2760
|
+
// walk should cost nobody a minute of not being able to connect.
|
|
2761
|
+
void loadSource(tools, root, false)
|
|
2762
|
+
.then((found) => {
|
|
2763
|
+
engine.fill(found, root);
|
|
2764
|
+
if (found.length === 0) {
|
|
2765
|
+
console.log(`nixamp serve — no audio files under ${root}`);
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
console.log(`nixamp serve — ${found.length} tracks under ${root}`);
|
|
2769
|
+
if (isRemote(root))
|
|
2770
|
+
return;
|
|
2771
|
+
return loadTagged(tools, root)
|
|
2660
2772
|
.then((tagged) => engine.retag(tagged, root))
|
|
2661
2773
|
.catch(() => {
|
|
2662
|
-
// Filenames are a working player. A failure here is worth nothing
|
|
2663
|
-
// titles that stay as they are.
|
|
2774
|
+
// Filenames are a working player. A failure here is worth nothing
|
|
2775
|
+
// but titles that stay as they are.
|
|
2664
2776
|
});
|
|
2665
|
-
}
|
|
2666
|
-
|
|
2777
|
+
})
|
|
2778
|
+
.catch((error) => {
|
|
2779
|
+
console.log(`nixamp: could not read ${root}: ${error.message}`);
|
|
2780
|
+
});
|
|
2781
|
+
console.log(`nixamp serve — reading ${root}`);
|
|
2667
2782
|
console.log("");
|
|
2668
2783
|
const width = Math.max(...addresses.map((a) => a.label.length));
|
|
2669
2784
|
for (const { label, url } of addresses) {
|
|
@@ -2716,9 +2831,13 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2716
2831
|
}));
|
|
2717
2832
|
rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
|
|
2718
2833
|
rtmp.listen(slots);
|
|
2834
|
+
publishUrls = slots.map((slot) => ({
|
|
2835
|
+
id: slot.id,
|
|
2836
|
+
url: `rtmp://${host}:${slot.port}/live/${listenKey ?? "live"}`,
|
|
2837
|
+
}));
|
|
2719
2838
|
console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
|
|
2720
|
-
for (const
|
|
2721
|
-
console.log(`
|
|
2839
|
+
for (const entry of publishUrls) {
|
|
2840
|
+
console.log(` ${entry.url} -> "${entry.id}"`);
|
|
2722
2841
|
}
|
|
2723
2842
|
}
|
|
2724
2843
|
if (destinations.length > 0) {
|
|
@@ -2764,49 +2883,67 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2764
2883
|
}
|
|
2765
2884
|
// The listing carries the listen link, and only ever a public address: an
|
|
2766
2885
|
// entry pointing at 192.168.1.5 is one nobody outside that house can open.
|
|
2767
|
-
|
|
2886
|
+
// Never a bare IP over https: a certificate is issued for a name, so a
|
|
2887
|
+
// listing pointing at one is a listing nobody can open.
|
|
2888
|
+
publishable_ = addresses.find((a) => a.label === "on the internet" && certifiable(a.url))
|
|
2889
|
+
?? addresses.find((a) => a.label === "on tailscale" && certifiable(a.url))
|
|
2890
|
+
?? addresses.find((a) => a.label === "on the internet")
|
|
2768
2891
|
?? addresses.find((a) => a.label === "on tailscale");
|
|
2769
|
-
|
|
2770
|
-
|
|
2892
|
+
/**
|
|
2893
|
+
* Make a publisher for this server. Called at startup when the operator says
|
|
2894
|
+
* yes, and again whenever somebody goes live from the admin panel.
|
|
2895
|
+
*
|
|
2896
|
+
* A declaration rather than an assignment, so it exists from the moment the
|
|
2897
|
+
* function is entered -- the port is open well before this line is reached.
|
|
2898
|
+
*/
|
|
2899
|
+
function makePublisher() {
|
|
2900
|
+
if (!publishable_)
|
|
2901
|
+
return null;
|
|
2771
2902
|
const listen = shareLink(publishable_.url, listenKey);
|
|
2772
2903
|
// Announced next to the listen link, not instead of it: one is for a person
|
|
2773
2904
|
// with a browser, the other for the phone line and anything else that is
|
|
2774
2905
|
// handed one address and expected to play it.
|
|
2775
2906
|
const audio = audioLink(publishable_.url, listenKey);
|
|
2907
|
+
return new Publisher({
|
|
2908
|
+
directory: DEFAULT_DIRECTORY,
|
|
2909
|
+
name: options.name || hostname(),
|
|
2910
|
+
url: listen,
|
|
2911
|
+
audio,
|
|
2912
|
+
// Asked of the engine rather than a variable, because the library is
|
|
2913
|
+
// now read after the port opens and may still be arriving.
|
|
2914
|
+
tracks: engine.snapshot(false).trackCount,
|
|
2915
|
+
// From `nixamp login`. The directory will not list a stream it cannot
|
|
2916
|
+
// attribute to somebody, because a listing is now a phone code that
|
|
2917
|
+
// costs money to answer.
|
|
2918
|
+
...(session?.token ? { token: session.token } : {}),
|
|
2919
|
+
onRefused: () => {
|
|
2920
|
+
console.log("");
|
|
2921
|
+
console.log(" nixamp.com would not list this stream: it needs an account.");
|
|
2922
|
+
console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
|
|
2923
|
+
},
|
|
2924
|
+
nowPlaying: () => {
|
|
2925
|
+
const snapshot = engine.snapshot();
|
|
2926
|
+
return snapshot.tracks?.[snapshot.index]?.title ?? "";
|
|
2927
|
+
},
|
|
2928
|
+
onConfig: (remote) => {
|
|
2929
|
+
const next = applyRemoteConfig(paywallConfig, remote?.x402);
|
|
2930
|
+
if (JSON.stringify(next) === JSON.stringify(paywallConfig))
|
|
2931
|
+
return;
|
|
2932
|
+
paywallConfig = next;
|
|
2933
|
+
console.log(next.enabled
|
|
2934
|
+
? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
|
|
2935
|
+
: " nixamp.com turned paid listening off.");
|
|
2936
|
+
},
|
|
2937
|
+
});
|
|
2938
|
+
}
|
|
2939
|
+
if (options.publish !== "no" && publishable_) {
|
|
2940
|
+
const listen = shareLink(publishable_.url, listenKey);
|
|
2776
2941
|
const wanted = options.publish === "yes"
|
|
2777
2942
|
? true
|
|
2778
2943
|
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
2779
2944
|
if (wanted) {
|
|
2780
|
-
publisher =
|
|
2781
|
-
|
|
2782
|
-
name: options.name || hostname(),
|
|
2783
|
-
url: listen,
|
|
2784
|
-
audio,
|
|
2785
|
-
tracks: tracks.length,
|
|
2786
|
-
// From `nixamp login`. The directory will not list a stream it cannot
|
|
2787
|
-
// attribute to somebody, because a listing is now a phone code that
|
|
2788
|
-
// costs money to answer.
|
|
2789
|
-
...(session?.token ? { token: session.token } : {}),
|
|
2790
|
-
onRefused: () => {
|
|
2791
|
-
console.log("");
|
|
2792
|
-
console.log(" nixamp.com would not list this stream: it needs an account.");
|
|
2793
|
-
console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
|
|
2794
|
-
},
|
|
2795
|
-
nowPlaying: () => {
|
|
2796
|
-
const snapshot = engine.snapshot();
|
|
2797
|
-
return snapshot.tracks?.[snapshot.index]?.title ?? "";
|
|
2798
|
-
},
|
|
2799
|
-
onConfig: (remote) => {
|
|
2800
|
-
const next = applyRemoteConfig(paywallConfig, remote?.x402);
|
|
2801
|
-
if (JSON.stringify(next) === JSON.stringify(paywallConfig))
|
|
2802
|
-
return;
|
|
2803
|
-
paywallConfig = next;
|
|
2804
|
-
console.log(next.enabled
|
|
2805
|
-
? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
|
|
2806
|
-
: " nixamp.com turned paid listening off.");
|
|
2807
|
-
},
|
|
2808
|
-
});
|
|
2809
|
-
const listing = await publisher.start();
|
|
2945
|
+
publisher = makePublisher();
|
|
2946
|
+
listing = (await publisher?.start()) ?? null;
|
|
2810
2947
|
console.log("");
|
|
2811
2948
|
console.log(listing
|
|
2812
2949
|
? ` Listed at ${DEFAULT_DIRECTORY}/directory as "${listing.name}". It leaves the list when this stops.`
|
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
|
|
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
|
-
|
|
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) {
|
|
@@ -187,6 +217,11 @@ export function allowedForListening(path) {
|
|
|
187
217
|
// album out of somebody else's playlist.
|
|
188
218
|
if (path === "/api/source" || path.startsWith("/api/source/"))
|
|
189
219
|
return false;
|
|
220
|
+
// Listing this machine in a public directory is not listening to it. The
|
|
221
|
+
// listen address itself, /api/live, stays open: that is the thing a listen
|
|
222
|
+
// key is for.
|
|
223
|
+
if (path === "/api/live/state" || path === "/api/live/start" || path === "/api/live/stop")
|
|
224
|
+
return false;
|
|
190
225
|
return true;
|
|
191
226
|
}
|
|
192
227
|
/**
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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/owner.ts
CHANGED
|
@@ -104,7 +104,17 @@ export const ADMIN_PATHS = [
|
|
|
104
104
|
"/api/admin",
|
|
105
105
|
];
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Going live and coming back off, which is administering a server.
|
|
109
|
+
*
|
|
110
|
+
* Listed separately from ADMIN_PATHS on purpose: those match by prefix, and
|
|
111
|
+
* `/api/live` itself is the public listen address -- the one the phone line is
|
|
112
|
+
* handed. Gating it by prefix would shut the front door to lock the office.
|
|
113
|
+
*/
|
|
114
|
+
const LIVE_CONTROL = ["/api/live/state", "/api/live/start", "/api/live/stop"];
|
|
115
|
+
|
|
107
116
|
export function needsAdmin(path: string, method = "GET"): boolean {
|
|
117
|
+
if (LIVE_CONTROL.includes(path)) return true;
|
|
108
118
|
if (ADMIN_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) return true;
|
|
109
119
|
// Publishing to a channel, or ending one, is administering the server.
|
|
110
120
|
// Listening to a channel is not: that is what the share link is for.
|