nixamp 0.7.4 → 0.7.6
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/README.md +8 -2
- package/dist/admin.d.ts +2 -0
- package/dist/admin.js +32 -8
- 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 +18 -2
- package/dist/partyline.d.ts +15 -10
- package/dist/partyline.js +19 -45
- package/dist/protocol.d.ts +8 -0
- package/dist/server.d.ts +79 -5
- package/dist/server.js +215 -26
- package/dist/share.js +6 -1
- package/dist/sources.d.ts +9 -0
- package/dist/sources.js +24 -0
- package/package.json +1 -1
- package/src/admin.ts +35 -10
- package/src/audio.ts +25 -4
- package/src/daemon.ts +36 -1
- package/src/invite.ts +10 -5
- package/src/main.ts +17 -2
- package/src/partyline.ts +20 -47
- package/src/protocol.ts +8 -0
- package/src/server.ts +261 -29
- package/src/share.ts +5 -1
- package/src/sources.ts +21 -0
- package/web/dist/assets/{hls-3VKVEQE3-BirljKil.js → hls-3VKVEQE3-CKpUWwhK.js} +1 -1
- package/web/dist/assets/index-Bs7oVbTk.js +1 -0
- package/web/dist/assets/index-CTgfCs0m.css +1 -0
- package/web/dist/assets/{mpegts-Dd6YyA19.js → mpegts-6hEDu2OP.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Cl9mowBJ.js → mpegts-LO6RVLD6-BAhx7mvs.js} +1 -1
- package/web/dist/index.html +35 -7
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-DSIDSSPF.css +0 -1
- package/web/dist/assets/index-U2odRmpd.js +0 -1
package/dist/server.js
CHANGED
|
@@ -39,7 +39,7 @@ import { Durable } from "./durable.js";
|
|
|
39
39
|
import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
40
40
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
41
41
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
42
|
-
import { isRemote, playsInBrowser } from "./sources.js";
|
|
42
|
+
import { isRemote, playsInBrowser, sourceLabel } from "./sources.js";
|
|
43
43
|
import { codecsOf, videoArgs } from "./audio.js";
|
|
44
44
|
import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
45
45
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
@@ -292,7 +292,11 @@ 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 } : {}),
|
|
297
|
+
// Only for what was added; the library's own tracks say nothing, which is
|
|
298
|
+
// how a client knows they are the library.
|
|
299
|
+
...(t.group ? { group: t.group } : {}),
|
|
296
300
|
}));
|
|
297
301
|
}
|
|
298
302
|
/** Video containers, as opposed to the songs that are most of a library. */
|
|
@@ -301,6 +305,24 @@ export function hasPicture(path) {
|
|
|
301
305
|
const dot = path.lastIndexOf(".");
|
|
302
306
|
return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
|
|
303
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
|
+
}
|
|
304
326
|
/**
|
|
305
327
|
* The headless player: the terminal app's engine without the terminal.
|
|
306
328
|
* One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
|
|
@@ -501,15 +523,89 @@ export class PlayerEngine {
|
|
|
501
523
|
this.state.note = "";
|
|
502
524
|
this.push(true);
|
|
503
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* Load something as well as what is already here.
|
|
528
|
+
*
|
|
529
|
+
* Adding a folder used to be `replace`, so pointing a server at an album on
|
|
530
|
+
* the web threw away the music on its disk: the playlist you were looking at
|
|
531
|
+
* turned into somebody else's twenty-eight tracks, and clicking your own
|
|
532
|
+
* files played theirs. Nothing about playback changes here -- whatever was
|
|
533
|
+
* playing keeps playing, at the same index, because the new tracks go on the
|
|
534
|
+
* end.
|
|
535
|
+
*
|
|
536
|
+
* Paths already loaded are skipped, so adding the same album twice is not
|
|
537
|
+
* two copies of it.
|
|
538
|
+
*/
|
|
539
|
+
add(tracks, from) {
|
|
540
|
+
const group = sourceLabel(from);
|
|
541
|
+
const known = new Set(this.tracks.map((track) => track.path));
|
|
542
|
+
const fresh = tracks.filter((track) => !known.has(track.path)).map((track) => ({ ...track, group }));
|
|
543
|
+
if (fresh.length === 0)
|
|
544
|
+
return 0;
|
|
545
|
+
this.tracks = [...this.tracks, ...fresh];
|
|
546
|
+
// The list itself changed, so it has to ride this frame; a count nobody
|
|
547
|
+
// can index into is worse than no news at all.
|
|
548
|
+
this.push(true);
|
|
549
|
+
return fresh.length;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Take an added source back out.
|
|
553
|
+
*
|
|
554
|
+
* The track that is playing is followed rather than an index: removing an
|
|
555
|
+
* album from above the current track would otherwise slide the playlist out
|
|
556
|
+
* from under a listener mid-song. If the playing track is itself in what is
|
|
557
|
+
* being removed, playback stops -- there is nothing to keep playing.
|
|
558
|
+
*/
|
|
559
|
+
drop(group) {
|
|
560
|
+
if (group === "")
|
|
561
|
+
return 0;
|
|
562
|
+
const playingPath = this.tracks[this.state.index]?.path;
|
|
563
|
+
const kept = this.tracks.filter((track) => track.group !== group);
|
|
564
|
+
const removed = this.tracks.length - kept.length;
|
|
565
|
+
if (removed === 0)
|
|
566
|
+
return 0;
|
|
567
|
+
this.tracks = kept;
|
|
568
|
+
const stillThere = kept.findIndex((track) => track.path === playingPath);
|
|
569
|
+
if (stillThere === -1) {
|
|
570
|
+
this.halt();
|
|
571
|
+
this.state.index = this.clamp(this.state.index);
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
this.state.index = stillThere;
|
|
575
|
+
}
|
|
576
|
+
this.push(true);
|
|
577
|
+
return removed;
|
|
578
|
+
}
|
|
579
|
+
groups() {
|
|
580
|
+
const seen = [];
|
|
581
|
+
for (const track of this.tracks) {
|
|
582
|
+
if (track.group && !seen.includes(track.group))
|
|
583
|
+
seen.push(track.group);
|
|
584
|
+
}
|
|
585
|
+
return seen;
|
|
586
|
+
}
|
|
504
587
|
retag(tracks, root) {
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
588
|
+
// Matched by path rather than by position, because the list is no longer
|
|
589
|
+
// required to be the one that was sent for tagging: somebody can add an
|
|
590
|
+
// album while a library's tags are still being read, and an exact-shape
|
|
591
|
+
// check would throw away every tag for it. Tags that describe tracks which
|
|
592
|
+
// are no longer here simply match nothing, which is the same protection
|
|
593
|
+
// the shape check was giving.
|
|
594
|
+
void root;
|
|
595
|
+
const byPath = new Map(tracks.map((track) => [track.path, track]));
|
|
596
|
+
let changed = false;
|
|
597
|
+
const merged = this.tracks.map((track) => {
|
|
598
|
+
const tagged = byPath.get(track.path);
|
|
599
|
+
if (!tagged || tagged === track)
|
|
600
|
+
return track;
|
|
601
|
+
changed = true;
|
|
602
|
+
// The group is ours, not the tagger's: it knows what a track is called,
|
|
603
|
+
// not which pile it is in.
|
|
604
|
+
return { ...tagged, ...(track.group ? { group: track.group } : {}) };
|
|
605
|
+
});
|
|
606
|
+
if (!changed)
|
|
511
607
|
return;
|
|
512
|
-
this.tracks =
|
|
608
|
+
this.tracks = merged;
|
|
513
609
|
// No stop, no index reset: the only thing that changes is what the titles
|
|
514
610
|
// say, and every remote finds out because a snapshot goes out -- carrying
|
|
515
611
|
// the list, since the titles are the whole point of this one.
|
|
@@ -534,6 +630,15 @@ export class EmptyEngine {
|
|
|
534
630
|
return undefined;
|
|
535
631
|
}
|
|
536
632
|
replace() { }
|
|
633
|
+
add() {
|
|
634
|
+
return 0;
|
|
635
|
+
}
|
|
636
|
+
drop() {
|
|
637
|
+
return 0;
|
|
638
|
+
}
|
|
639
|
+
groups() {
|
|
640
|
+
return [];
|
|
641
|
+
}
|
|
537
642
|
retag() { }
|
|
538
643
|
stop() { }
|
|
539
644
|
}
|
|
@@ -546,6 +651,14 @@ const CORS = {
|
|
|
546
651
|
"access-control-allow-headers": "content-type",
|
|
547
652
|
"access-control-max-age": "86400",
|
|
548
653
|
};
|
|
654
|
+
/**
|
|
655
|
+
* How many nameless addresses are worth an ffprobe when a source is added.
|
|
656
|
+
*
|
|
657
|
+
* One is the ordinary case -- somebody pasting a channel -- and a directory
|
|
658
|
+
* listing of thousands must not turn into thousands of probes for an answer
|
|
659
|
+
* that only changes which element a browser uses.
|
|
660
|
+
*/
|
|
661
|
+
const PROBE_BY_HAND = 8;
|
|
549
662
|
/** /api/v1/<provider>/oauth/start and .../callback, the house callback shape. */
|
|
550
663
|
const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
|
|
551
664
|
/**
|
|
@@ -845,7 +958,18 @@ export function createHandler(engine, options) {
|
|
|
845
958
|
path !== "/api/health" &&
|
|
846
959
|
path !== "/api/directory" &&
|
|
847
960
|
!isSignInPath(path)) {
|
|
848
|
-
|
|
961
|
+
let scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
962
|
+
// A key is how somebody who was invited proves it. It is not the only way
|
|
963
|
+
// to be allowed in: the person who owns this server is allowed in whether
|
|
964
|
+
// or not they still have the link, and their nixamp.com session says who
|
|
965
|
+
// they are. Without this, signing in as yourself and opening your own
|
|
966
|
+
// server was refused, and the address of a machine you administer was
|
|
967
|
+
// useless without a link you had to go and find.
|
|
968
|
+
if (scope === null && options.owner) {
|
|
969
|
+
const check = await options.owner.check(false, tokenFrom(request.headers));
|
|
970
|
+
if (check.allowed)
|
|
971
|
+
scope = "control";
|
|
972
|
+
}
|
|
849
973
|
if (scope === null) {
|
|
850
974
|
// Counted, not because a 128-bit key falls to guessing, but because
|
|
851
975
|
// somebody hammering one should stop costing this server anything.
|
|
@@ -856,7 +980,9 @@ export function createHandler(engine, options) {
|
|
|
856
980
|
response.end(JSON.stringify({ error: "too many attempts; wait a moment" }));
|
|
857
981
|
return;
|
|
858
982
|
}
|
|
859
|
-
json(response, 401, {
|
|
983
|
+
json(response, 401, {
|
|
984
|
+
error: "this nixamp needs the key from its share link, or sign in as its owner",
|
|
985
|
+
});
|
|
860
986
|
return;
|
|
861
987
|
}
|
|
862
988
|
if (scope === "listen" && !allowedForListening(path)) {
|
|
@@ -1719,16 +1845,50 @@ export function createHandler(engine, options) {
|
|
|
1719
1845
|
json(response, 200, engine.snapshot());
|
|
1720
1846
|
return;
|
|
1721
1847
|
}
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1848
|
+
// Take an added source back out of the playlist. The library it was added
|
|
1849
|
+
// to is untouched -- there is no group name that names it.
|
|
1850
|
+
if (path === "/api/source/remove") {
|
|
1851
|
+
if (request.method !== "POST") {
|
|
1852
|
+
json(response, 405, { error: "POST only" });
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
let group = "";
|
|
1856
|
+
try {
|
|
1857
|
+
group = String(JSON.parse(await readBody(request)).group ?? "");
|
|
1858
|
+
}
|
|
1859
|
+
catch {
|
|
1860
|
+
json(response, 400, { error: "bad JSON" });
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
if (!group) {
|
|
1864
|
+
json(response, 400, { error: "no group given" });
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
const removed = engine.drop(group);
|
|
1868
|
+
if (removed === 0) {
|
|
1869
|
+
json(response, 404, { error: `nothing here came from ${group}` });
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
json(response, 200, { ...engine.snapshot(), removed, groups: engine.groups() });
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
// Hand the running server another source. The listeners stay connected;
|
|
1876
|
+
// by default they get more to listen to, and only an explicit `replace`
|
|
1877
|
+
// swaps what this server is for something else.
|
|
1724
1878
|
if (path === "/api/source") {
|
|
1725
1879
|
if (request.method !== "POST") {
|
|
1726
1880
|
json(response, 405, { error: "POST only" });
|
|
1727
1881
|
return;
|
|
1728
1882
|
}
|
|
1729
1883
|
let source = "";
|
|
1884
|
+
let replacing = false;
|
|
1730
1885
|
try {
|
|
1731
|
-
|
|
1886
|
+
const body = JSON.parse(await readBody(request));
|
|
1887
|
+
source = String(body.source ?? "");
|
|
1888
|
+
// Adding is what somebody means by putting a folder in a box, so it is
|
|
1889
|
+
// the default. Replacing is the much larger claim that this server now
|
|
1890
|
+
// serves that instead, so it is the one you have to ask for.
|
|
1891
|
+
replacing = body.replace === true;
|
|
1732
1892
|
}
|
|
1733
1893
|
catch {
|
|
1734
1894
|
json(response, 400, { error: "bad JSON" });
|
|
@@ -1739,13 +1899,38 @@ export function createHandler(engine, options) {
|
|
|
1739
1899
|
return;
|
|
1740
1900
|
}
|
|
1741
1901
|
try {
|
|
1742
|
-
|
|
1902
|
+
let tracks = await options.load(source);
|
|
1743
1903
|
if (tracks.length === 0) {
|
|
1744
1904
|
json(response, 422, { error: `nothing to play at ${source}` });
|
|
1745
1905
|
return;
|
|
1746
1906
|
}
|
|
1747
|
-
|
|
1748
|
-
//
|
|
1907
|
+
// A handful of addresses whose names say nothing get asked what they
|
|
1908
|
+
// are, so a live channel arrives as a picture rather than as its own
|
|
1909
|
+
// soundtrack. Capped, because a playlist of five thousand of them is
|
|
1910
|
+
// five thousand ffprobes and the answer only matters for the few a
|
|
1911
|
+
// person adds by hand.
|
|
1912
|
+
const looked = await Promise.all(tracks.map(async (track, at) => {
|
|
1913
|
+
if (at >= PROBE_BY_HAND || !nameSaysNothing(track.path))
|
|
1914
|
+
return track;
|
|
1915
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, track.path);
|
|
1916
|
+
return codecs.video === "" ? track : { ...track, picture: true };
|
|
1917
|
+
}));
|
|
1918
|
+
tracks = looked;
|
|
1919
|
+
let added = tracks.length;
|
|
1920
|
+
if (replacing) {
|
|
1921
|
+
engine.replace(tracks, source);
|
|
1922
|
+
}
|
|
1923
|
+
else {
|
|
1924
|
+
added = engine.add(tracks, source);
|
|
1925
|
+
if (added === 0) {
|
|
1926
|
+
// Everything there was already here. Not an error -- the playlist
|
|
1927
|
+
// is exactly what the caller asked for -- but worth saying, so a
|
|
1928
|
+
// client can tell that apart from having added an album.
|
|
1929
|
+
json(response, 200, { ...engine.snapshot(), added: 0, groups: engine.groups() });
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
// Names now, tags later, here as much as at startup: loading a
|
|
1749
1934
|
// directory of five thousand files used to read every tag before it
|
|
1750
1935
|
// answered, with the event loop held the whole time.
|
|
1751
1936
|
if (options.tag) {
|
|
@@ -1754,7 +1939,7 @@ export function createHandler(engine, options) {
|
|
|
1754
1939
|
.then((tagged) => engine.retag(tagged, source))
|
|
1755
1940
|
.catch(() => { });
|
|
1756
1941
|
}
|
|
1757
|
-
json(response, 200, engine.snapshot());
|
|
1942
|
+
json(response, 200, { ...engine.snapshot(), added, replaced: replacing, groups: engine.groups() });
|
|
1758
1943
|
}
|
|
1759
1944
|
catch (error) {
|
|
1760
1945
|
json(response, 422, { error: error.message.replace(/^nixamp: /, "") });
|
|
@@ -1784,17 +1969,21 @@ export function createHandler(engine, options) {
|
|
|
1784
1969
|
const capKbps = Number.isFinite(asked) && asked > 0 ? Math.min(20_000, Math.max(200, asked)) : 0;
|
|
1785
1970
|
if (playsInBrowser(file) && capKbps === 0) {
|
|
1786
1971
|
sendFile(request, response, file);
|
|
1972
|
+
return;
|
|
1787
1973
|
}
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1974
|
+
// A film, or something whose name refuses to say. A live channel at
|
|
1975
|
+
// .../301 used to fall through to the audio branch and arrive as MP3
|
|
1976
|
+
// with `-vn` -- a match you could only listen to.
|
|
1977
|
+
if (hasPicture(file) || nameSaysNothing(file)) {
|
|
1978
|
+
// What ffprobe finds inside decides how little work it takes to keep
|
|
1979
|
+
// the picture, and whether there is a picture to keep at all.
|
|
1792
1980
|
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1981
|
+
if (codecs.video !== "") {
|
|
1982
|
+
pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1797
1985
|
}
|
|
1986
|
+
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
1798
1987
|
return;
|
|
1799
1988
|
}
|
|
1800
1989
|
// Whatever the source is, this comes back as MP3 a browser will play:
|
package/dist/share.js
CHANGED
|
@@ -180,7 +180,12 @@ export function scopeOf(offered, control, listen) {
|
|
|
180
180
|
}
|
|
181
181
|
/** Paths a listen key may have. Everything else needs the control key. */
|
|
182
182
|
export function allowedForListening(path) {
|
|
183
|
-
if (path === "/api/command"
|
|
183
|
+
if (path === "/api/command")
|
|
184
|
+
return false;
|
|
185
|
+
// Prefix, not equality: everything under this changes what the server plays,
|
|
186
|
+
// and an exact check let a listen key reach /api/source/remove and delete an
|
|
187
|
+
// album out of somebody else's playlist.
|
|
188
|
+
if (path === "/api/source" || path.startsWith("/api/source/"))
|
|
184
189
|
return false;
|
|
185
190
|
return true;
|
|
186
191
|
}
|
package/dist/sources.d.ts
CHANGED
|
@@ -33,5 +33,14 @@ export declare function parseM3u(text: string, base: string): Entry[];
|
|
|
33
33
|
/** A .pls, which Shoutcast and Icecast hand out as often as an .m3u. */
|
|
34
34
|
export declare function parsePls(text: string, base: string): Entry[];
|
|
35
35
|
/** The last useful part of a path or URL, for when nothing named the track. */
|
|
36
|
+
/**
|
|
37
|
+
* What to call a whole source, as a heading over the tracks it brought.
|
|
38
|
+
*
|
|
39
|
+
* `nameOf` answers for a file; this answers for the thing a person added --
|
|
40
|
+
* usually the last segment either way, but a URL that is only a host has no
|
|
41
|
+
* segment to take, and "the album at that address" reads better as the host
|
|
42
|
+
* than as the whole URL repeated over every row.
|
|
43
|
+
*/
|
|
44
|
+
export declare function sourceLabel(source: string): string;
|
|
36
45
|
export declare function nameOf(source: string): string;
|
|
37
46
|
export declare function playsInBrowser(source: string): boolean;
|
package/dist/sources.js
CHANGED
|
@@ -94,6 +94,30 @@ export function parsePls(text, base) {
|
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
96
|
/** The last useful part of a path or URL, for when nothing named the track. */
|
|
97
|
+
/**
|
|
98
|
+
* What to call a whole source, as a heading over the tracks it brought.
|
|
99
|
+
*
|
|
100
|
+
* `nameOf` answers for a file; this answers for the thing a person added --
|
|
101
|
+
* usually the last segment either way, but a URL that is only a host has no
|
|
102
|
+
* segment to take, and "the album at that address" reads better as the host
|
|
103
|
+
* than as the whole URL repeated over every row.
|
|
104
|
+
*/
|
|
105
|
+
export function sourceLabel(source) {
|
|
106
|
+
const trimmed = source.replace(/\/+$/, "");
|
|
107
|
+
if (trimmed === "")
|
|
108
|
+
return source;
|
|
109
|
+
const named = nameOf(trimmed);
|
|
110
|
+
if (named !== trimmed && named !== "")
|
|
111
|
+
return named;
|
|
112
|
+
if (!isRemote(trimmed))
|
|
113
|
+
return trimmed;
|
|
114
|
+
try {
|
|
115
|
+
return new URL(trimmed).host;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return trimmed;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
97
121
|
export function nameOf(source) {
|
|
98
122
|
const remote = isRemote(source);
|
|
99
123
|
const path = remote ? new URL(source).pathname : source;
|
package/package.json
CHANGED
package/src/admin.ts
CHANGED
|
@@ -146,6 +146,9 @@ export async function admin(argv: string[]): Promise<void> {
|
|
|
146
146
|
let snapshot: Snapshot | null = null;
|
|
147
147
|
let error = "";
|
|
148
148
|
let restreaming = "";
|
|
149
|
+
// Which of the two things typing a source means. Adding is the ordinary one
|
|
150
|
+
// and has its own key; replacing throws the library away, so it has another.
|
|
151
|
+
let replacing = false;
|
|
149
152
|
let typing = false;
|
|
150
153
|
|
|
151
154
|
const app = await createApp({ theme: themes.matrix, title: "nixamp admin", quitKeys: ["ctrl+c"] });
|
|
@@ -164,24 +167,27 @@ export async function admin(argv: string[]): Promise<void> {
|
|
|
164
167
|
app.on("key", (event: KeyEvent) => {
|
|
165
168
|
const key = event.key;
|
|
166
169
|
if (typing) {
|
|
167
|
-
if (key === "escape") { typing = false; restreaming = ""; }
|
|
170
|
+
if (key === "escape") { typing = false; restreaming = ""; replacing = false; }
|
|
168
171
|
else if (key === "enter") {
|
|
169
172
|
const url = restreaming.trim();
|
|
173
|
+
const asReplacement = replacing;
|
|
170
174
|
typing = false;
|
|
171
175
|
restreaming = "";
|
|
172
|
-
|
|
176
|
+
replacing = false;
|
|
177
|
+
if (url) void restream(target, headers, url, asReplacement).then(() => refresh());
|
|
173
178
|
} else if (key === "backspace") restreaming = restreaming.slice(0, -1);
|
|
174
179
|
else restreaming += typed(key);
|
|
175
180
|
app.invalidate();
|
|
176
181
|
return;
|
|
177
182
|
}
|
|
178
183
|
if (key === "q") { app.quit(); return; }
|
|
179
|
-
if (key === "
|
|
184
|
+
if (key === "a") { typing = true; replacing = false; app.invalidate(); }
|
|
185
|
+
if (key === "r") { typing = true; replacing = true; app.invalidate(); }
|
|
180
186
|
});
|
|
181
187
|
|
|
182
188
|
app.on("exit", () => clearInterval(timer));
|
|
183
189
|
app.render(({ ui, theme }) => draw(ui, theme, {
|
|
184
|
-
url: target.url, report, snapshot, error, typing, restreaming,
|
|
190
|
+
url: target.url, report, snapshot, error, typing, restreaming, replacing,
|
|
185
191
|
links: target.links, key: target.key, source: target.source,
|
|
186
192
|
}));
|
|
187
193
|
|
|
@@ -189,13 +195,24 @@ export async function admin(argv: string[]): Promise<void> {
|
|
|
189
195
|
clearInterval(timer);
|
|
190
196
|
}
|
|
191
197
|
|
|
192
|
-
/**
|
|
193
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Hand the server something else to play.
|
|
200
|
+
*
|
|
201
|
+
* Two different asks down one route: adding puts an album on the end of the
|
|
202
|
+
* playlist, replacing points the server somewhere else entirely. The server
|
|
203
|
+
* adds unless told otherwise, so only the second one says anything.
|
|
204
|
+
*/
|
|
205
|
+
async function restream(
|
|
206
|
+
target: AdminOptions,
|
|
207
|
+
headers: Record<string, string>,
|
|
208
|
+
url: string,
|
|
209
|
+
replacing = false,
|
|
210
|
+
): Promise<void> {
|
|
194
211
|
try {
|
|
195
212
|
await fetch(`${target.url}/api/source`, {
|
|
196
213
|
method: "POST",
|
|
197
214
|
headers: { ...headers, "content-type": "application/json" },
|
|
198
|
-
body: JSON.stringify({ source: url }),
|
|
215
|
+
body: JSON.stringify({ source: url, ...(replacing ? { replace: true } : {}) }),
|
|
199
216
|
});
|
|
200
217
|
} catch {
|
|
201
218
|
// The next refresh reports the server being unreachable; this is not the
|
|
@@ -210,6 +227,8 @@ export interface View {
|
|
|
210
227
|
error: string;
|
|
211
228
|
typing: boolean;
|
|
212
229
|
restreaming: string;
|
|
230
|
+
/** Whether what is being typed replaces the playlist rather than joining it. */
|
|
231
|
+
replacing?: boolean;
|
|
213
232
|
/** Labelled addresses, and the key that makes them work. */
|
|
214
233
|
links: { label: string; url: string }[];
|
|
215
234
|
key: string | null;
|
|
@@ -296,15 +315,21 @@ export function draw(ui: Container, theme: Theme, view: View): void {
|
|
|
296
315
|
});
|
|
297
316
|
|
|
298
317
|
if (view.typing) {
|
|
299
|
-
ui.panel({
|
|
318
|
+
ui.panel({
|
|
319
|
+
title: view.replacing ? "Replace the playlist with a URL or a path" : "Add a URL or a path",
|
|
320
|
+
size: 4,
|
|
321
|
+
}, (p) => {
|
|
300
322
|
p.text(`${view.restreaming}_`, { fg: theme.accent });
|
|
301
|
-
p.label(
|
|
323
|
+
p.label(view.replacing
|
|
324
|
+
? "Enter drops this library and serves that instead. Escape forgets it."
|
|
325
|
+
: "Enter adds it to the playlist. Escape forgets it.");
|
|
302
326
|
});
|
|
303
327
|
}
|
|
304
328
|
|
|
305
329
|
ui.statusBar({
|
|
306
330
|
items: [
|
|
307
|
-
{ key: "
|
|
331
|
+
{ key: "a", label: "Add" },
|
|
332
|
+
{ key: "r", label: "Replace" },
|
|
308
333
|
{ key: "q", label: "Quit" },
|
|
309
334
|
],
|
|
310
335
|
right: [{ key: "", label: report ? `${report.connections.length} seen` : "connecting" }],
|
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,
|