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 +9 -1
- package/dist/main.js +5 -1
- package/dist/server.d.ts +30 -0
- package/dist/server.js +66 -17
- package/dist/session.js +3 -1
- package/dist/share.d.ts +10 -0
- package/dist/share.js +31 -1
- package/package.json +1 -1
- package/src/admin.ts +11 -1
- package/src/main.ts +5 -1
- package/src/server.ts +81 -20
- package/src/session.ts +5 -1
- package/src/share.ts +29 -1
- package/web/dist/assets/{hls-3VKVEQE3-CKpUWwhK.js → hls-3VKVEQE3-BM7Le4NN.js} +1 -1
- package/web/dist/assets/index-B5ijv1SY.css +1 -0
- package/web/dist/assets/index-CGXkk7_z.js +1 -0
- package/web/dist/assets/{mpegts-6hEDu2OP.js → mpegts-C6TLllAG.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-BAhx7mvs.js → mpegts-LO6RVLD6-B_K_M921.js} +1 -1
- package/web/dist/index.html +13 -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/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
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
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
|
-
//
|
|
2655
|
-
//
|
|
2656
|
-
//
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2721
|
-
console.log(`
|
|
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
|
-
|
|
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
|
|
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) {
|
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/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
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
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
|
-
//
|
|
3081
|
-
//
|
|
3082
|
-
//
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
.
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
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 —
|
|
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
|
|
3150
|
-
console.log(`
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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-
|
|
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}
|