nixamp 0.7.5 → 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/audio.d.ts +11 -0
- package/dist/audio.js +10 -3
- package/dist/daemon.d.ts +20 -0
- package/dist/daemon.js +21 -1
- package/dist/invite.d.ts +6 -4
- package/dist/invite.js +9 -4
- package/dist/main.js +23 -3
- package/dist/partyline.d.ts +15 -10
- package/dist/partyline.js +19 -45
- package/dist/server.d.ts +48 -0
- package/dist/server.js +119 -27
- 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/audio.ts +25 -4
- package/src/daemon.ts +36 -1
- package/src/invite.ts +10 -5
- package/src/main.ts +22 -3
- package/src/partyline.ts +20 -47
- package/src/server.ts +153 -30
- package/src/session.ts +5 -1
- package/src/share.ts +29 -1
- package/web/dist/assets/{hls-3VKVEQE3-BsLZl7PK.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-DWMPccwQ.js → mpegts-C6TLllAG.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CviHHfb1.js → mpegts-LO6RVLD6-B_K_M921.js} +1 -1
- package/web/dist/index.html +30 -10
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-8D8K4vqw.css +0 -1
- package/web/dist/assets/index-a1aekKkP.js +0 -1
package/dist/server.js
CHANGED
|
@@ -292,7 +292,8 @@ export function toRemoteTracks(tracks) {
|
|
|
292
292
|
// Said out loud, because a remote cannot see the path and had been sending
|
|
293
293
|
// every track to the audio element -- a film's soundtrack over a blank
|
|
294
294
|
// panel, which is exactly what it looked like.
|
|
295
|
-
|
|
295
|
+
// The name when it says something, what ffprobe found when it does not.
|
|
296
|
+
...(t.picture ?? hasPicture(t.path) ? { video: true } : {}),
|
|
296
297
|
// Only for what was added; the library's own tracks say nothing, which is
|
|
297
298
|
// how a client knows they are the library.
|
|
298
299
|
...(t.group ? { group: t.group } : {}),
|
|
@@ -304,6 +305,24 @@ export function hasPicture(path) {
|
|
|
304
305
|
const dot = path.lastIndexOf(".");
|
|
305
306
|
return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
|
|
306
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* Whether the name of a source tells us anything about what is inside it.
|
|
310
|
+
*
|
|
311
|
+
* A remote address with no extension -- an IPTV channel, a stream key, a
|
|
312
|
+
* redirect -- is the case where it does not, and the only way to find out is
|
|
313
|
+
* to look.
|
|
314
|
+
*/
|
|
315
|
+
export function nameSaysNothing(path) {
|
|
316
|
+
if (!isRemote(path))
|
|
317
|
+
return false;
|
|
318
|
+
try {
|
|
319
|
+
const last = new URL(path).pathname.split("/").pop() ?? "";
|
|
320
|
+
return !last.includes(".");
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
307
326
|
/**
|
|
308
327
|
* The headless player: the terminal app's engine without the terminal.
|
|
309
328
|
* One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
|
|
@@ -565,6 +584,30 @@ export class PlayerEngine {
|
|
|
565
584
|
}
|
|
566
585
|
return seen;
|
|
567
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
|
+
}
|
|
568
611
|
retag(tracks, root) {
|
|
569
612
|
// Matched by path rather than by position, because the list is no longer
|
|
570
613
|
// required to be the one that was sent for tagging: somebody can add an
|
|
@@ -611,6 +654,7 @@ export class EmptyEngine {
|
|
|
611
654
|
return undefined;
|
|
612
655
|
}
|
|
613
656
|
replace() { }
|
|
657
|
+
fill() { }
|
|
614
658
|
add() {
|
|
615
659
|
return 0;
|
|
616
660
|
}
|
|
@@ -632,6 +676,14 @@ const CORS = {
|
|
|
632
676
|
"access-control-allow-headers": "content-type",
|
|
633
677
|
"access-control-max-age": "86400",
|
|
634
678
|
};
|
|
679
|
+
/**
|
|
680
|
+
* How many nameless addresses are worth an ffprobe when a source is added.
|
|
681
|
+
*
|
|
682
|
+
* One is the ordinary case -- somebody pasting a channel -- and a directory
|
|
683
|
+
* listing of thousands must not turn into thousands of probes for an answer
|
|
684
|
+
* that only changes which element a browser uses.
|
|
685
|
+
*/
|
|
686
|
+
const PROBE_BY_HAND = 8;
|
|
635
687
|
/** /api/v1/<provider>/oauth/start and .../callback, the house callback shape. */
|
|
636
688
|
const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
|
|
637
689
|
/**
|
|
@@ -1768,6 +1820,9 @@ export function createHandler(engine, options) {
|
|
|
1768
1820
|
active: tracker.active,
|
|
1769
1821
|
startedAt: started,
|
|
1770
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?.() ?? [],
|
|
1771
1826
|
});
|
|
1772
1827
|
return;
|
|
1773
1828
|
}
|
|
@@ -1872,11 +1927,23 @@ export function createHandler(engine, options) {
|
|
|
1872
1927
|
return;
|
|
1873
1928
|
}
|
|
1874
1929
|
try {
|
|
1875
|
-
|
|
1930
|
+
let tracks = await options.load(source);
|
|
1876
1931
|
if (tracks.length === 0) {
|
|
1877
1932
|
json(response, 422, { error: `nothing to play at ${source}` });
|
|
1878
1933
|
return;
|
|
1879
1934
|
}
|
|
1935
|
+
// A handful of addresses whose names say nothing get asked what they
|
|
1936
|
+
// are, so a live channel arrives as a picture rather than as its own
|
|
1937
|
+
// soundtrack. Capped, because a playlist of five thousand of them is
|
|
1938
|
+
// five thousand ffprobes and the answer only matters for the few a
|
|
1939
|
+
// person adds by hand.
|
|
1940
|
+
const looked = await Promise.all(tracks.map(async (track, at) => {
|
|
1941
|
+
if (at >= PROBE_BY_HAND || !nameSaysNothing(track.path))
|
|
1942
|
+
return track;
|
|
1943
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, track.path);
|
|
1944
|
+
return codecs.video === "" ? track : { ...track, picture: true };
|
|
1945
|
+
}));
|
|
1946
|
+
tracks = looked;
|
|
1880
1947
|
let added = tracks.length;
|
|
1881
1948
|
if (replacing) {
|
|
1882
1949
|
engine.replace(tracks, source);
|
|
@@ -1930,17 +1997,21 @@ export function createHandler(engine, options) {
|
|
|
1930
1997
|
const capKbps = Number.isFinite(asked) && asked > 0 ? Math.min(20_000, Math.max(200, asked)) : 0;
|
|
1931
1998
|
if (playsInBrowser(file) && capKbps === 0) {
|
|
1932
1999
|
sendFile(request, response, file);
|
|
2000
|
+
return;
|
|
1933
2001
|
}
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2002
|
+
// A film, or something whose name refuses to say. A live channel at
|
|
2003
|
+
// .../301 used to fall through to the audio branch and arrive as MP3
|
|
2004
|
+
// with `-vn` -- a match you could only listen to.
|
|
2005
|
+
if (hasPicture(file) || nameSaysNothing(file)) {
|
|
2006
|
+
// What ffprobe finds inside decides how little work it takes to keep
|
|
2007
|
+
// the picture, and whether there is a picture to keep at all.
|
|
1938
2008
|
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
2009
|
+
if (codecs.video !== "") {
|
|
2010
|
+
pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
1943
2013
|
}
|
|
2014
|
+
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
1944
2015
|
return;
|
|
1945
2016
|
}
|
|
1946
2017
|
// Whatever the source is, this comes back as MP3 a browser will play:
|
|
@@ -2298,10 +2369,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2298
2369
|
//
|
|
2299
2370
|
// So the filenames are enough to start: the server is up and answering in the
|
|
2300
2371
|
// time it takes to walk the directory, and the titles fill in behind it.
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
: 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);
|
|
2305
2375
|
const web = options.web !== null ? resolve(options.web) : defaultWebDir();
|
|
2306
2376
|
const key = options.key ? newKey() : null;
|
|
2307
2377
|
// Minted whether or not it is published, so `nixamp admin` and the operator
|
|
@@ -2500,11 +2570,16 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2500
2570
|
}
|
|
2501
2571
|
})()
|
|
2502
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 = [];
|
|
2503
2577
|
const server = createServer(engine, {
|
|
2504
2578
|
web,
|
|
2505
2579
|
media: options.media,
|
|
2506
2580
|
owner,
|
|
2507
2581
|
channels,
|
|
2582
|
+
publishUrls: () => publishUrls,
|
|
2508
2583
|
...(ingest ? { ingest } : {}),
|
|
2509
2584
|
broadcaster,
|
|
2510
2585
|
broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
|
|
@@ -2608,19 +2683,30 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2608
2683
|
guessedPublic: guessedPublic !== "",
|
|
2609
2684
|
}));
|
|
2610
2685
|
}
|
|
2611
|
-
//
|
|
2612
|
-
//
|
|
2613
|
-
//
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
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)
|
|
2617
2700
|
.then((tagged) => engine.retag(tagged, root))
|
|
2618
2701
|
.catch(() => {
|
|
2619
|
-
// Filenames are a working player. A failure here is worth nothing
|
|
2620
|
-
// 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.
|
|
2621
2704
|
});
|
|
2622
|
-
}
|
|
2623
|
-
|
|
2705
|
+
})
|
|
2706
|
+
.catch((error) => {
|
|
2707
|
+
console.log(`nixamp: could not read ${root}: ${error.message}`);
|
|
2708
|
+
});
|
|
2709
|
+
console.log(`nixamp serve — reading ${root}`);
|
|
2624
2710
|
console.log("");
|
|
2625
2711
|
const width = Math.max(...addresses.map((a) => a.label.length));
|
|
2626
2712
|
for (const { label, url } of addresses) {
|
|
@@ -2673,9 +2759,13 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2673
2759
|
}));
|
|
2674
2760
|
rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
|
|
2675
2761
|
rtmp.listen(slots);
|
|
2762
|
+
publishUrls = slots.map((slot) => ({
|
|
2763
|
+
id: slot.id,
|
|
2764
|
+
url: `rtmp://${host}:${slot.port}/live/${listenKey ?? "live"}`,
|
|
2765
|
+
}));
|
|
2676
2766
|
console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
|
|
2677
|
-
for (const
|
|
2678
|
-
console.log(`
|
|
2767
|
+
for (const entry of publishUrls) {
|
|
2768
|
+
console.log(` ${entry.url} -> "${entry.id}"`);
|
|
2679
2769
|
}
|
|
2680
2770
|
}
|
|
2681
2771
|
if (destinations.length > 0) {
|
|
@@ -2739,7 +2829,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2739
2829
|
name: options.name || hostname(),
|
|
2740
2830
|
url: listen,
|
|
2741
2831
|
audio,
|
|
2742
|
-
|
|
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,
|
|
2743
2835
|
// From `nixamp login`. The directory will not list a stream it cannot
|
|
2744
2836
|
// attribute to somebody, because a listing is now a phone code that
|
|
2745
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/audio.ts
CHANGED
|
@@ -314,6 +314,17 @@ export interface Codecs {
|
|
|
314
314
|
video: string;
|
|
315
315
|
/** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
|
|
316
316
|
audio: string;
|
|
317
|
+
/**
|
|
318
|
+
* What is wrapped around them: "mpegts", "matroska,webm", "mov,mp4,...".
|
|
319
|
+
*
|
|
320
|
+
* It matters for one reason. A transport stream -- which is what every IPTV
|
|
321
|
+
* channel is -- frames its AAC as ADTS, and copying that into MP4 needs a
|
|
322
|
+
* bitstream filter or ffmpeg refuses the whole muxing and writes nothing.
|
|
323
|
+
* They also tend to carry several audio tracks, so the one ffmpeg picks can
|
|
324
|
+
* be AC-3 on the same URL that offered AAC a minute earlier, and AC-3 in MP4
|
|
325
|
+
* is a track no browser will play.
|
|
326
|
+
*/
|
|
327
|
+
container: string;
|
|
317
328
|
}
|
|
318
329
|
|
|
319
330
|
/**
|
|
@@ -325,7 +336,7 @@ export interface Codecs {
|
|
|
325
336
|
*/
|
|
326
337
|
export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
|
|
327
338
|
const [cmd, ...rest] = tools.ffprobe;
|
|
328
|
-
const empty: Codecs = { video: "", audio: "" };
|
|
339
|
+
const empty: Codecs = { video: "", audio: "", container: "" };
|
|
329
340
|
if (!cmd) return empty;
|
|
330
341
|
|
|
331
342
|
return new Promise<Codecs>((done) => {
|
|
@@ -335,7 +346,7 @@ export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
|
|
|
335
346
|
...rest,
|
|
336
347
|
"-v", "quiet",
|
|
337
348
|
"-print_format", "json",
|
|
338
|
-
"-show_entries", "stream=codec_type,codec_name",
|
|
349
|
+
"-show_entries", "format=format_name:stream=codec_type,codec_name",
|
|
339
350
|
path,
|
|
340
351
|
],
|
|
341
352
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
@@ -347,11 +358,15 @@ export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
|
|
|
347
358
|
child.on("error", () => done(empty));
|
|
348
359
|
child.on("close", () => {
|
|
349
360
|
try {
|
|
350
|
-
const parsed = JSON.parse(out) as {
|
|
361
|
+
const parsed = JSON.parse(out) as {
|
|
362
|
+
streams?: { codec_type?: string; codec_name?: string }[];
|
|
363
|
+
format?: { format_name?: string };
|
|
364
|
+
};
|
|
351
365
|
const streams = parsed.streams ?? [];
|
|
352
366
|
return done({
|
|
353
367
|
video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
|
|
354
368
|
audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
|
|
369
|
+
container: parsed.format?.format_name ?? "",
|
|
355
370
|
});
|
|
356
371
|
} catch {
|
|
357
372
|
return done(empty);
|
|
@@ -376,7 +391,13 @@ export function videoArgs(codecs: Codecs, capKbps = 0): string[] {
|
|
|
376
391
|
|
|
377
392
|
// What a browser can play inside MP4 without help.
|
|
378
393
|
const keepVideo = codecs.video === "h264";
|
|
379
|
-
|
|
394
|
+
// A transport stream's audio is never copied. Its AAC is ADTS-framed, which
|
|
395
|
+
// MP4 refuses without a bitstream filter -- ffmpeg writes nothing at all and
|
|
396
|
+
// says "Malformed AAC bitstream detected" -- and the track ffmpeg picks off
|
|
397
|
+
// a channel with several of them can be AC-3, which that filter rejects and
|
|
398
|
+
// no browser plays. Re-encoding audio is cheap; this failing is total.
|
|
399
|
+
const transportStream = codecs.container.includes("mpegts");
|
|
400
|
+
const keepAudio = !transportStream && (codecs.audio === "aac" || codecs.audio === "mp3");
|
|
380
401
|
return [
|
|
381
402
|
"-c:v", keepVideo ? "copy" : "libx264",
|
|
382
403
|
...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
|
package/src/daemon.ts
CHANGED
|
@@ -38,6 +38,15 @@ export interface DaemonState {
|
|
|
38
38
|
* interface, so it names the router and not this port.
|
|
39
39
|
*/
|
|
40
40
|
guessedPublic?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* What it was started with, so it can be started that way again.
|
|
43
|
+
*
|
|
44
|
+
* A daemon serving TLS on a public name is six flags, and restarting it
|
|
45
|
+
* meant finding them again -- from shell history, or from `ps`, or not at
|
|
46
|
+
* all. Absent on a state file written by an older nixamp, which is why
|
|
47
|
+
* `restart` says so rather than starting something different.
|
|
48
|
+
*/
|
|
49
|
+
argv?: string[];
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
@@ -234,11 +243,37 @@ export async function start(argv: string[], entry: string): Promise<DaemonState>
|
|
|
234
243
|
throw new Error(`nixamp: the daemon did not start. See ${log}`);
|
|
235
244
|
}
|
|
236
245
|
|
|
237
|
-
const state: DaemonState = { ...announced, pid: child.pid, startedAt: Date.now(), log };
|
|
246
|
+
const state: DaemonState = { ...announced, pid: child.pid, startedAt: Date.now(), log, argv };
|
|
238
247
|
writeState(state);
|
|
239
248
|
return state;
|
|
240
249
|
}
|
|
241
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Stop it and start it again, the way it was started.
|
|
253
|
+
*
|
|
254
|
+
* The flags are replayed from the state file rather than retyped, because the
|
|
255
|
+
* interesting daemons are the ones with the most flags: a certificate, a key,
|
|
256
|
+
* a public URL. Given arguments of its own it uses those instead, which is how
|
|
257
|
+
* you change one thing without stopping and starting by hand.
|
|
258
|
+
*/
|
|
259
|
+
export async function restart(
|
|
260
|
+
argv: string[],
|
|
261
|
+
entry: string,
|
|
262
|
+
/** Injected so a test can see which arguments would be replayed. */
|
|
263
|
+
starter: typeof start = start,
|
|
264
|
+
): Promise<DaemonState> {
|
|
265
|
+
const { state } = status();
|
|
266
|
+
const before = state?.argv;
|
|
267
|
+
if (argv.length === 0 && before === undefined && state !== null) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
"nixamp: this daemon was started by an older nixamp, which did not record its flags. " +
|
|
270
|
+
"Stop it and start it again with the flags you want.",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
await stop();
|
|
274
|
+
return starter(argv.length > 0 ? argv : (before ?? []), entry);
|
|
275
|
+
}
|
|
276
|
+
|
|
242
277
|
/** Poll the log for the announce line. */
|
|
243
278
|
async function waitForAnnounce(
|
|
244
279
|
log: string,
|
package/src/invite.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* Asking somebody to watch, when that somebody is not technical.
|
|
3
3
|
*
|
|
4
4
|
* A share link is a URL with a key in it, which is fine for the person who
|
|
5
|
-
* runs the server and useless as a thing to text your mother. An invite is
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* runs the server and useless as a thing to text your mother. An invite is two
|
|
6
|
+
* things written as a sentence: a link that opens a player, and a phone number
|
|
7
|
+
* with a code, which is the line where everyone watching talks to each other.
|
|
8
|
+
* The phone is not another way to hear the stream -- it is the 800 number
|
|
9
|
+
* beside a podcast. The show is on the screen; the call is the company.
|
|
8
10
|
*
|
|
9
11
|
* The sender is signed in, because sending is an action with a cost: a text
|
|
10
12
|
* message is money and somebody's phone. The recipient signs in too, but only
|
|
@@ -22,7 +24,7 @@ export interface Invite {
|
|
|
22
24
|
link: string;
|
|
23
25
|
/** The phone number, when this stream is one the line knows about. */
|
|
24
26
|
phone: string;
|
|
25
|
-
/** The six digits that reach this stream,
|
|
27
|
+
/** The six digits that reach this stream's room, once it has been published. */
|
|
26
28
|
code: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -47,7 +49,10 @@ export function isEmail(value: string): boolean {
|
|
|
47
49
|
export function inviteText(invite: Invite): string {
|
|
48
50
|
const lines = [`${invite.name} is streaming.`, "", `Watch: ${invite.link}`];
|
|
49
51
|
if (invite.phone && invite.code) {
|
|
50
|
-
|
|
52
|
+
// "to talk about it", not "to listen": the line is a room full of the
|
|
53
|
+
// other people watching, and telling somebody they will hear the stream
|
|
54
|
+
// down the phone is telling them something that is not true.
|
|
55
|
+
lines.push("", `To talk about it: call ${invite.phone} and key ${invite.code}.`);
|
|
51
56
|
}
|
|
52
57
|
return lines.join("\n");
|
|
53
58
|
}
|
package/src/main.ts
CHANGED
|
@@ -69,7 +69,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
69
69
|
|
|
70
70
|
nixamp [source] play it in the terminal
|
|
71
71
|
nixamp serve [source] [options] play here, and hand out a browser remote
|
|
72
|
-
nixamp daemon start|stop|status serve in the background, and let go of it
|
|
72
|
+
nixamp daemon start|restart|stop|status serve in the background, and let go of it
|
|
73
73
|
nixamp attach put the player back in front of the daemon
|
|
74
74
|
nixamp admin [--url U] [--key K] who is connected, and re-stream to them
|
|
75
75
|
nixamp login [--with github] sign in to nixamp.com, in a browser or here
|
|
@@ -188,9 +188,13 @@ Signing out does not touch it: that is what it is for.
|
|
|
188
188
|
daemon: `nixamp daemon — a nixamp that outlives the terminal that started it.
|
|
189
189
|
|
|
190
190
|
nixamp daemon start [source] [serve options] start it, detached
|
|
191
|
+
nixamp daemon restart [source] [serve options] stop it and start it again
|
|
191
192
|
nixamp daemon status where it is, and how long
|
|
192
193
|
nixamp daemon stop stop it
|
|
193
194
|
|
|
195
|
+
Restart with no arguments replays the ones it was started with, certificate
|
|
196
|
+
and public URL included, so picking up a new version costs one command.
|
|
197
|
+
|
|
194
198
|
It is \`nixamp serve\` with nobody holding its terminal, so it keeps playing and
|
|
195
199
|
keeps serving its browser remote. One per user.
|
|
196
200
|
|
|
@@ -257,6 +261,17 @@ async function runDaemon(argv: string[]): Promise<number> {
|
|
|
257
261
|
return 0;
|
|
258
262
|
}
|
|
259
263
|
|
|
264
|
+
if (action === "restart") {
|
|
265
|
+
try {
|
|
266
|
+
const state = await d.restart(rest, entry);
|
|
267
|
+
for (const line of d.daemonLines(state)) console.log(line);
|
|
268
|
+
return 0;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.error((error as Error).message);
|
|
271
|
+
return 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
260
275
|
if (action === "status") {
|
|
261
276
|
const { running, state } = d.status();
|
|
262
277
|
if (!state) {
|
|
@@ -283,7 +298,7 @@ async function runDaemon(argv: string[]): Promise<number> {
|
|
|
283
298
|
return attach(rest);
|
|
284
299
|
}
|
|
285
300
|
|
|
286
|
-
console.error(`nixamp daemon: unknown action ${action}. Try start, stop, status or attach.`);
|
|
301
|
+
console.error(`nixamp daemon: unknown action ${action}. Try start, restart, stop, status or attach.`);
|
|
287
302
|
return 64;
|
|
288
303
|
}
|
|
289
304
|
|
|
@@ -619,7 +634,11 @@ export function view(
|
|
|
619
634
|
|
|
620
635
|
if (import.meta.main) {
|
|
621
636
|
main().catch((error) => {
|
|
622
|
-
|
|
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);
|
|
623
642
|
process.exit(1);
|
|
624
643
|
});
|
|
625
644
|
}
|