nixamp 0.7.14 → 0.7.16
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 +1 -1
- package/dist/admin.js +3 -3
- package/dist/audio.d.ts +2 -1
- package/dist/audio.js +44 -1
- package/dist/daemon.js +3 -2
- package/dist/main.js +2 -1
- package/dist/server.js +35 -7
- package/dist/session.js +3 -2
- package/dist/share.d.ts +14 -2
- package/dist/share.js +30 -3
- package/package.json +1 -1
- package/src/admin.ts +3 -3
- package/src/audio.ts +43 -1
- package/src/daemon.ts +3 -2
- package/src/main.ts +2 -1
- package/src/server.ts +35 -6
- package/src/session.ts +3 -2
- package/src/share.ts +29 -3
- package/web/dist/assets/{hls-3VKVEQE3-DJsrviTx.js → hls-3VKVEQE3-IlqvYfvF.js} +1 -1
- package/web/dist/assets/index-BIeMVcOg.js +1 -0
- package/web/dist/assets/{mpegts-j_GWIdsg.js → mpegts-BvAPLY0u.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-N4VL8Y4W.js → mpegts-LO6RVLD6-CiS20_Z-.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-eSMbwh8n.js +0 -1
package/README.md
CHANGED
|
@@ -147,7 +147,7 @@ publish:
|
|
|
147
147
|
|
|
148
148
|
```
|
|
149
149
|
List this stream at https://nixamp.com/directory so anyone can find it?
|
|
150
|
-
It publishes http://198.51.100.7:4321/
|
|
150
|
+
It publishes http://198.51.100.7:4321/v/Lk1EM_mP977e1VT — listen only,
|
|
151
151
|
not the controls. [Y/n]
|
|
152
152
|
```
|
|
153
153
|
|
package/dist/admin.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { createApp, themes } from "@profullstack/hqtui";
|
|
9
9
|
import { daemonUrl, isLoopbackTls, readState } from "./daemon.js";
|
|
10
|
-
import { KEY_HEADER } from "./share.js";
|
|
10
|
+
import { KEY_HEADER, shareLink } from "./share.js";
|
|
11
11
|
/** Where to point, from the flags or from the daemon that is running. */
|
|
12
12
|
export function resolveTarget(argv) {
|
|
13
13
|
const at = argv.findIndex((a) => a === "--url" || a === "-u");
|
|
@@ -29,7 +29,7 @@ export function resolveTarget(argv) {
|
|
|
29
29
|
" Or administer another machine, with its share link:\n" +
|
|
30
30
|
" nixamp admin --url https://server1.you.nixamp.com:4321 --key KEY\n" +
|
|
31
31
|
" The URL and key are the two halves of the link that server printed:\n" +
|
|
32
|
-
" https://host:4321/
|
|
32
|
+
" https://host:4321/a/KEY");
|
|
33
33
|
}
|
|
34
34
|
const url_ = daemonUrl(state);
|
|
35
35
|
// Talking to our own daemon, whose certificate names somewhere else. Nothing
|
|
@@ -231,7 +231,7 @@ export function draw(ui, theme, view) {
|
|
|
231
231
|
const width = Math.max(...view.links.map((link) => link.label.length));
|
|
232
232
|
ui.panel({ title: "Share links", size: view.links.length + (view.source ? 3 : 2) }, (p) => {
|
|
233
233
|
for (const link of view.links) {
|
|
234
|
-
const full =
|
|
234
|
+
const full = shareLink(link.url, view.key);
|
|
235
235
|
p.text(`${link.label.padEnd(width)} ${full}`, {
|
|
236
236
|
fg: link.label === "on the internet" ? theme.accent : theme.foreground,
|
|
237
237
|
});
|
package/dist/audio.d.ts
CHANGED
|
@@ -17,7 +17,8 @@ export interface Tools {
|
|
|
17
17
|
/**
|
|
18
18
|
* Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
|
|
19
19
|
* on developer machines and need `mise exec` because the shim itself fails when
|
|
20
|
-
* no version is pinned.
|
|
20
|
+
* no version is pinned. Failing both, the places these are actually installed
|
|
21
|
+
* are looked in directly, because a detached daemon's PATH is not the operator's.
|
|
21
22
|
*/
|
|
22
23
|
export declare function detectTools(): Tools;
|
|
23
24
|
export declare function probe(tools: Tools, path: string): Track;
|
package/dist/audio.js
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* apart within seconds and the bars would stop matching what you hear.
|
|
8
8
|
*/
|
|
9
9
|
import { spawn, spawnSync } from "node:child_process";
|
|
10
|
+
import { readdirSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
10
13
|
export const RATE = 44100;
|
|
11
14
|
export const CHANNELS = 2;
|
|
12
15
|
function works(argv) {
|
|
@@ -16,15 +19,55 @@ function works(argv) {
|
|
|
16
19
|
const r = spawnSync(cmd, [...rest, "-version"], { encoding: "utf8", timeout: 10_000 });
|
|
17
20
|
return !r.error && r.status === 0;
|
|
18
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Everywhere a version manager or a package manager tends to leave ffmpeg.
|
|
24
|
+
*
|
|
25
|
+
* A daemon is started detached and inherits whatever environment happened to
|
|
26
|
+
* be around, which on a machine that installs ffmpeg through mise is a PATH
|
|
27
|
+
* with neither `ffmpeg` nor `mise` on it. Every probe then fails, and a
|
|
28
|
+
* failed probe means "no video stream" -- so a television channel arrived as
|
|
29
|
+
* its own soundtrack and nothing anywhere said why. Looking is cheaper than
|
|
30
|
+
* asking somebody to fix their PATH for a process they did not start.
|
|
31
|
+
*
|
|
32
|
+
* Newest first, so a machine with several installed uses the one it would
|
|
33
|
+
* have used anyway.
|
|
34
|
+
*/
|
|
35
|
+
function installedElsewhere(name) {
|
|
36
|
+
const home = homedir();
|
|
37
|
+
const roots = [
|
|
38
|
+
join(home, ".local", "share", "mise", "installs", "ffmpeg"),
|
|
39
|
+
join(home, ".asdf", "installs", "ffmpeg"),
|
|
40
|
+
];
|
|
41
|
+
const found = [];
|
|
42
|
+
for (const root of roots) {
|
|
43
|
+
let versions;
|
|
44
|
+
try {
|
|
45
|
+
versions = readdirSync(root).sort().reverse();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
for (const version of versions)
|
|
51
|
+
found.push([join(root, version, "bin", name)]);
|
|
52
|
+
}
|
|
53
|
+
// The ordinary absolute places, for a PATH that has been emptied rather than
|
|
54
|
+
// merely shortened.
|
|
55
|
+
for (const dir of ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", "/snap/bin"]) {
|
|
56
|
+
found.push([join(dir, name)]);
|
|
57
|
+
}
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
19
60
|
/**
|
|
20
61
|
* Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
|
|
21
62
|
* on developer machines and need `mise exec` because the shim itself fails when
|
|
22
|
-
* no version is pinned.
|
|
63
|
+
* no version is pinned. Failing both, the places these are actually installed
|
|
64
|
+
* are looked in directly, because a detached daemon's PATH is not the operator's.
|
|
23
65
|
*/
|
|
24
66
|
export function detectTools() {
|
|
25
67
|
const candidates = (name) => [
|
|
26
68
|
[name],
|
|
27
69
|
["mise", "exec", `ffmpeg@latest`, "--", name],
|
|
70
|
+
...installedElsewhere(name),
|
|
28
71
|
];
|
|
29
72
|
const pick = (name) => candidates(name).find((argv) => works(argv)) ?? null;
|
|
30
73
|
const ffmpeg = pick("ffmpeg");
|
package/dist/daemon.js
CHANGED
|
@@ -10,7 +10,7 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
|
-
import { portCommands } from "./share.js";
|
|
13
|
+
import { portCommands, shareLink } from "./share.js";
|
|
14
14
|
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
15
15
|
export function stateDir() {
|
|
16
16
|
const base = process.env["XDG_STATE_HOME"] || join(homedir(), ".local", "state");
|
|
@@ -121,7 +121,8 @@ function spell(ms) {
|
|
|
121
121
|
* typed the command.
|
|
122
122
|
*/
|
|
123
123
|
export function daemonLines(state, uptimeMs) {
|
|
124
|
-
|
|
124
|
+
// The daemon's own key is the one that administers, so it is an /a/ link.
|
|
125
|
+
const link = (url) => shareLink(url, state.key ?? null);
|
|
125
126
|
// A state file written by an older nixamp has no list, so host and port
|
|
126
127
|
// still stand in rather than printing nothing at all.
|
|
127
128
|
const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
|
package/dist/main.js
CHANGED
|
@@ -15,6 +15,7 @@ import { version } from "./meta.js";
|
|
|
15
15
|
import { displayName, loadSource, loadTagged } from "./playlist.js";
|
|
16
16
|
import { isRemote } from "./sources.js";
|
|
17
17
|
import { DEFAULT_PORT } from "./server.js";
|
|
18
|
+
import { shareLink } from "./share.js";
|
|
18
19
|
const FFT_SIZE = 2048;
|
|
19
20
|
export const BAND_COUNT = 24;
|
|
20
21
|
export function createState(tracks, root, silent) {
|
|
@@ -510,7 +511,7 @@ export async function main() {
|
|
|
510
511
|
const handed = handoff.to;
|
|
511
512
|
if (handed !== null) {
|
|
512
513
|
console.log(`Detached. Still playing as pid ${handed.daemon.pid}.`);
|
|
513
|
-
console.log(` ${handed.
|
|
514
|
+
console.log(` ${shareLink(handed.url, handed.daemon.key ?? null)}`);
|
|
514
515
|
console.log(" nixamp attach come back to it");
|
|
515
516
|
console.log(" nixamp daemon stop when you are done");
|
|
516
517
|
}
|
package/dist/server.js
CHANGED
|
@@ -42,7 +42,7 @@ import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
|
42
42
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
43
43
|
import { isRemote, playsInBrowser, sourceLabel } from "./sources.js";
|
|
44
44
|
import { codecsOf, probeAsync, videoArgs } from "./audio.js";
|
|
45
|
-
import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, rememberedKeys, keyFrom, lookupPublicIp, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
45
|
+
import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, keyInPath, rememberedKeys, keyFrom, lookupPublicIp, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
46
46
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
47
47
|
import { fileURLToPath } from "node:url";
|
|
48
48
|
import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
@@ -775,8 +775,9 @@ export function createHandler(engine, options) {
|
|
|
775
775
|
// cookie, so every later fetch, EventSource and <audio src> carries it
|
|
776
776
|
// without the page knowing anything about keys. Either key works here, and
|
|
777
777
|
// which one was used decides what the browser can then do.
|
|
778
|
-
|
|
779
|
-
|
|
778
|
+
const offeredInPath = key !== null ? keyInPath(path) : null;
|
|
779
|
+
if (offeredInPath !== null) {
|
|
780
|
+
const offered = offeredInPath;
|
|
780
781
|
if (scopeOf(offered, key, listenKey) === null) {
|
|
781
782
|
json(response, 404, { error: "not found" });
|
|
782
783
|
return;
|
|
@@ -2644,7 +2645,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2644
2645
|
live: publisher !== null,
|
|
2645
2646
|
code: listing?.code ?? "",
|
|
2646
2647
|
name: listing?.name ?? (options.name || hostname()),
|
|
2647
|
-
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey) : ""),
|
|
2648
|
+
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
|
|
2648
2649
|
// Whether going live is even possible here. A laptop behind a router
|
|
2649
2650
|
// with no address the world can reach cannot be listed, and a button
|
|
2650
2651
|
// that could only fail is worse than one that is not offered.
|
|
@@ -2829,7 +2830,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2829
2830
|
for (const { label, url } of addresses) {
|
|
2830
2831
|
if (label === "here")
|
|
2831
2832
|
continue;
|
|
2832
|
-
console.log(` ${shareLink(url, listenKey)}`);
|
|
2833
|
+
console.log(` ${shareLink(url, listenKey, false)}`);
|
|
2833
2834
|
}
|
|
2834
2835
|
}
|
|
2835
2836
|
}
|
|
@@ -2926,7 +2927,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2926
2927
|
function makePublisher() {
|
|
2927
2928
|
if (!publishable_)
|
|
2928
2929
|
return null;
|
|
2929
|
-
const listen = shareLink(publishable_.url, listenKey);
|
|
2930
|
+
const listen = shareLink(publishable_.url, listenKey, false);
|
|
2930
2931
|
// Announced next to the listen link, not instead of it: one is for a person
|
|
2931
2932
|
// with a browser, the other for the phone line and anything else that is
|
|
2932
2933
|
// handed one address and expected to play it.
|
|
@@ -2964,7 +2965,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2964
2965
|
});
|
|
2965
2966
|
}
|
|
2966
2967
|
if (options.publish !== "no" && publishable_) {
|
|
2967
|
-
const listen = shareLink(publishable_.url, listenKey);
|
|
2968
|
+
const listen = shareLink(publishable_.url, listenKey, false);
|
|
2968
2969
|
const wanted = options.publish === "yes"
|
|
2969
2970
|
? true
|
|
2970
2971
|
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
@@ -2981,6 +2982,33 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2981
2982
|
console.log("");
|
|
2982
2983
|
console.log(" --publish needs an address the world can reach. This machine has none.");
|
|
2983
2984
|
}
|
|
2985
|
+
// Remember this machine on the account it belongs to, without being asked.
|
|
2986
|
+
//
|
|
2987
|
+
// `nixamp server add --here` existed and did exactly this, which is a manual
|
|
2988
|
+
// step for something the daemon already knows: it has just worked out its
|
|
2989
|
+
// own address and minted its own key, and the session file says whose it is.
|
|
2990
|
+
// Somebody who has signed in on this machine has said which account it is;
|
|
2991
|
+
// being on their list is what they meant by that.
|
|
2992
|
+
//
|
|
2993
|
+
// Idempotent: adding the same URL again updates the row rather than making
|
|
2994
|
+
// a second one, so this is safe on every start. Silent about failure, since
|
|
2995
|
+
// nothing here is worth stopping a player for.
|
|
2996
|
+
if (session?.token && publishable_) {
|
|
2997
|
+
const remembered = shareLink(publishable_.url, key);
|
|
2998
|
+
void fetch(`${DEFAULT_DIRECTORY}/api/v1/servers`, {
|
|
2999
|
+
method: "POST",
|
|
3000
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${session.token}` },
|
|
3001
|
+
body: JSON.stringify({ url: publishable_.url, name: options.name || hostname(), key: key ?? "" }),
|
|
3002
|
+
})
|
|
3003
|
+
.then((answer) => {
|
|
3004
|
+
if (answer.ok)
|
|
3005
|
+
console.log(` Remembered on your account at ${DEFAULT_DIRECTORY}.`);
|
|
3006
|
+
})
|
|
3007
|
+
.catch(() => {
|
|
3008
|
+
// The directory being unreachable is not a reason to stop serving.
|
|
3009
|
+
});
|
|
3010
|
+
void remembered;
|
|
3011
|
+
}
|
|
2984
3012
|
// A last resort, not a licence.
|
|
2985
3013
|
//
|
|
2986
3014
|
// Every throw reachable from a request should be caught where it happens,
|
package/dist/session.js
CHANGED
|
@@ -23,6 +23,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
23
23
|
import { dirname, join } from "node:path";
|
|
24
24
|
import { stateDir } from "./daemon.js";
|
|
25
25
|
import { DEFAULT_DIRECTORY } from "./directory.js";
|
|
26
|
+
import { shareLink } from "./share.js";
|
|
26
27
|
export function sessionPath() {
|
|
27
28
|
return join(stateDir(), "session.json");
|
|
28
29
|
}
|
|
@@ -514,7 +515,7 @@ export async function servers(argv, fetcher = fetch) {
|
|
|
514
515
|
if (state === null) {
|
|
515
516
|
console.error("nixamp: no daemon is running here.\n" +
|
|
516
517
|
" Start one: nixamp daemon start ~/Music\n" +
|
|
517
|
-
" Or give the share link of the one you mean: https://host:4321/
|
|
518
|
+
" Or give the share link of the one you mean: https://host:4321/a/KEY");
|
|
518
519
|
return 1;
|
|
519
520
|
}
|
|
520
521
|
// The address worth remembering is the one somebody else can open.
|
|
@@ -567,7 +568,7 @@ export async function servers(argv, fetcher = fetch) {
|
|
|
567
568
|
}
|
|
568
569
|
const width = Math.max(...rows.map((row) => row.name.length));
|
|
569
570
|
for (const row of rows) {
|
|
570
|
-
const link = row.
|
|
571
|
+
const link = shareLink(row.url, row.key ?? null);
|
|
571
572
|
console.log(`${row.id} ${row.name.padEnd(width)} ${link}`);
|
|
572
573
|
}
|
|
573
574
|
return 0;
|
package/dist/share.d.ts
CHANGED
|
@@ -84,8 +84,20 @@ export declare function reachableAddresses(host: string, port: number, publicUrl
|
|
|
84
84
|
label: string;
|
|
85
85
|
url: string;
|
|
86
86
|
}[];
|
|
87
|
-
/**
|
|
88
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The two paths a key can arrive on, and what each one says about itself.
|
|
89
|
+
*
|
|
90
|
+
* `/a/` administers and `/v/` only views. There used to be a `/s/` that meant
|
|
91
|
+
* neither -- just "here is a key" -- which is why somebody handed one of two
|
|
92
|
+
* identical-looking links had no way to tell which they were holding, and
|
|
93
|
+
* reported the controls as missing when they were never going to be there.
|
|
94
|
+
* A link should say what it is before anybody clicks it.
|
|
95
|
+
*/
|
|
96
|
+
export declare const KEY_PATHS: readonly ["/a/", "/v/"];
|
|
97
|
+
/** The key in a share link, whichever of the three shapes it came in. */
|
|
98
|
+
export declare function keyInPath(path: string): string | null;
|
|
99
|
+
/** The full link, key and all. `admin` picks the shape that says which it is. */
|
|
100
|
+
export declare function shareLink(base: string, key: string | null, admin?: boolean): string;
|
|
89
101
|
/**
|
|
90
102
|
* The same stream, as bytes rather than as a page.
|
|
91
103
|
*
|
package/dist/share.js
CHANGED
|
@@ -241,9 +241,36 @@ export function reachableAddresses(host, port, publicUrl = "", scheme = "http")
|
|
|
241
241
|
.map((entry) => ({ label: `${entry.label} (no certificate for an IP)`, url: entry.url })),
|
|
242
242
|
];
|
|
243
243
|
}
|
|
244
|
-
/**
|
|
245
|
-
|
|
246
|
-
|
|
244
|
+
/**
|
|
245
|
+
* The two paths a key can arrive on, and what each one says about itself.
|
|
246
|
+
*
|
|
247
|
+
* `/a/` administers and `/v/` only views. There used to be a `/s/` that meant
|
|
248
|
+
* neither -- just "here is a key" -- which is why somebody handed one of two
|
|
249
|
+
* identical-looking links had no way to tell which they were holding, and
|
|
250
|
+
* reported the controls as missing when they were never going to be there.
|
|
251
|
+
* A link should say what it is before anybody clicks it.
|
|
252
|
+
*/
|
|
253
|
+
export const KEY_PATHS = ["/a/", "/v/"];
|
|
254
|
+
/** The key in a share link, whichever of the three shapes it came in. */
|
|
255
|
+
export function keyInPath(path) {
|
|
256
|
+
for (const prefix of KEY_PATHS) {
|
|
257
|
+
if (!path.startsWith(prefix))
|
|
258
|
+
continue;
|
|
259
|
+
const rest = path.slice(prefix.length).replace(/\/+$/, "");
|
|
260
|
+
if (rest === "" || rest.includes("/"))
|
|
261
|
+
return null;
|
|
262
|
+
try {
|
|
263
|
+
return decodeURIComponent(rest);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return rest;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
/** The full link, key and all. `admin` picks the shape that says which it is. */
|
|
272
|
+
export function shareLink(base, key, admin = true) {
|
|
273
|
+
return key === null ? base : `${base}${admin ? "/a/" : "/v/"}${key}`;
|
|
247
274
|
}
|
|
248
275
|
/**
|
|
249
276
|
* The same stream, as bytes rather than as a page.
|
package/package.json
CHANGED
package/src/admin.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { createApp, themes, type Container, type KeyEvent, type Theme } from "@p
|
|
|
9
9
|
import type { Color } from "@profullstack/hqtui";
|
|
10
10
|
import type { Connection } from "./connections.ts";
|
|
11
11
|
import { daemonUrl, isLoopbackTls, readState } from "./daemon.ts";
|
|
12
|
-
import { KEY_HEADER } from "./share.ts";
|
|
12
|
+
import { KEY_HEADER, shareLink } from "./share.ts";
|
|
13
13
|
|
|
14
14
|
interface Report {
|
|
15
15
|
connections: Connection[];
|
|
@@ -66,7 +66,7 @@ export function resolveTarget(argv: string[]): AdminOptions {
|
|
|
66
66
|
" Or administer another machine, with its share link:\n" +
|
|
67
67
|
" nixamp admin --url https://server1.you.nixamp.com:4321 --key KEY\n" +
|
|
68
68
|
" The URL and key are the two halves of the link that server printed:\n" +
|
|
69
|
-
" https://host:4321/
|
|
69
|
+
" https://host:4321/a/KEY",
|
|
70
70
|
);
|
|
71
71
|
}
|
|
72
72
|
const url_ = daemonUrl(state);
|
|
@@ -278,7 +278,7 @@ export function draw(ui: Container, theme: Theme, view: View): void {
|
|
|
278
278
|
const width = Math.max(...view.links.map((link) => link.label.length));
|
|
279
279
|
ui.panel({ title: "Share links", size: view.links.length + (view.source ? 3 : 2) }, (p) => {
|
|
280
280
|
for (const link of view.links) {
|
|
281
|
-
const full =
|
|
281
|
+
const full = shareLink(link.url, view.key);
|
|
282
282
|
p.text(`${link.label.padEnd(width)} ${full}`, {
|
|
283
283
|
fg: link.label === "on the internet" ? theme.accent : theme.foreground,
|
|
284
284
|
});
|
package/src/audio.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* apart within seconds and the bars would stop matching what you hear.
|
|
8
8
|
*/
|
|
9
9
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
10
|
+
import { readdirSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
10
13
|
|
|
11
14
|
export const RATE = 44100;
|
|
12
15
|
export const CHANNELS = 2;
|
|
@@ -34,15 +37,54 @@ function works(argv: string[]): boolean {
|
|
|
34
37
|
return !r.error && r.status === 0;
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Everywhere a version manager or a package manager tends to leave ffmpeg.
|
|
42
|
+
*
|
|
43
|
+
* A daemon is started detached and inherits whatever environment happened to
|
|
44
|
+
* be around, which on a machine that installs ffmpeg through mise is a PATH
|
|
45
|
+
* with neither `ffmpeg` nor `mise` on it. Every probe then fails, and a
|
|
46
|
+
* failed probe means "no video stream" -- so a television channel arrived as
|
|
47
|
+
* its own soundtrack and nothing anywhere said why. Looking is cheaper than
|
|
48
|
+
* asking somebody to fix their PATH for a process they did not start.
|
|
49
|
+
*
|
|
50
|
+
* Newest first, so a machine with several installed uses the one it would
|
|
51
|
+
* have used anyway.
|
|
52
|
+
*/
|
|
53
|
+
function installedElsewhere(name: string): string[][] {
|
|
54
|
+
const home = homedir();
|
|
55
|
+
const roots = [
|
|
56
|
+
join(home, ".local", "share", "mise", "installs", "ffmpeg"),
|
|
57
|
+
join(home, ".asdf", "installs", "ffmpeg"),
|
|
58
|
+
];
|
|
59
|
+
const found: string[][] = [];
|
|
60
|
+
for (const root of roots) {
|
|
61
|
+
let versions: string[];
|
|
62
|
+
try {
|
|
63
|
+
versions = readdirSync(root).sort().reverse();
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const version of versions) found.push([join(root, version, "bin", name)]);
|
|
68
|
+
}
|
|
69
|
+
// The ordinary absolute places, for a PATH that has been emptied rather than
|
|
70
|
+
// merely shortened.
|
|
71
|
+
for (const dir of ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", "/snap/bin"]) {
|
|
72
|
+
found.push([join(dir, name)]);
|
|
73
|
+
}
|
|
74
|
+
return found;
|
|
75
|
+
}
|
|
76
|
+
|
|
37
77
|
/**
|
|
38
78
|
* Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
|
|
39
79
|
* on developer machines and need `mise exec` because the shim itself fails when
|
|
40
|
-
* no version is pinned.
|
|
80
|
+
* no version is pinned. Failing both, the places these are actually installed
|
|
81
|
+
* are looked in directly, because a detached daemon's PATH is not the operator's.
|
|
41
82
|
*/
|
|
42
83
|
export function detectTools(): Tools {
|
|
43
84
|
const candidates = (name: string): string[][] => [
|
|
44
85
|
[name],
|
|
45
86
|
["mise", "exec", `ffmpeg@latest`, "--", name],
|
|
87
|
+
...installedElsewhere(name),
|
|
46
88
|
];
|
|
47
89
|
const pick = (name: string): string[] | null =>
|
|
48
90
|
candidates(name).find((argv) => works(argv)) ?? null;
|
package/src/daemon.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
|
-
import { type Firewall, portCommands } from "./share.ts";
|
|
13
|
+
import { type Firewall, portCommands, shareLink } from "./share.ts";
|
|
14
14
|
|
|
15
15
|
export interface DaemonState {
|
|
16
16
|
pid: number;
|
|
@@ -164,7 +164,8 @@ function spell(ms: number): string {
|
|
|
164
164
|
* typed the command.
|
|
165
165
|
*/
|
|
166
166
|
export function daemonLines(state: DaemonState, uptimeMs?: number): string[] {
|
|
167
|
-
|
|
167
|
+
// The daemon's own key is the one that administers, so it is an /a/ link.
|
|
168
|
+
const link = (url: string): string => shareLink(url, state.key ?? null);
|
|
168
169
|
// A state file written by an older nixamp has no list, so host and port
|
|
169
170
|
// still stand in rather than printing nothing at all.
|
|
170
171
|
const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
|
package/src/main.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { displayName, loadSource, loadTagged } from "./playlist.ts";
|
|
|
19
19
|
import { isRemote } from "./sources.ts";
|
|
20
20
|
import { DEFAULT_PORT } from "./server.ts";
|
|
21
21
|
import type { DaemonState } from "./daemon.ts";
|
|
22
|
+
import { shareLink } from "./share.ts";
|
|
22
23
|
|
|
23
24
|
const FFT_SIZE = 2048;
|
|
24
25
|
export const BAND_COUNT = 24;
|
|
@@ -515,7 +516,7 @@ export async function main(): Promise<void> {
|
|
|
515
516
|
const handed = handoff.to;
|
|
516
517
|
if (handed !== null) {
|
|
517
518
|
console.log(`Detached. Still playing as pid ${handed.daemon.pid}.`);
|
|
518
|
-
console.log(` ${handed.
|
|
519
|
+
console.log(` ${shareLink(handed.url, handed.daemon.key ?? null)}`);
|
|
519
520
|
console.log(" nixamp attach come back to it");
|
|
520
521
|
console.log(" nixamp daemon stop when you are done");
|
|
521
522
|
}
|
package/src/server.ts
CHANGED
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
firewallInUse,
|
|
69
69
|
certifiable,
|
|
70
70
|
keyCookie,
|
|
71
|
+
keyInPath,
|
|
71
72
|
rememberedKeys,
|
|
72
73
|
keyFrom,
|
|
73
74
|
keysMatch,
|
|
@@ -1073,8 +1074,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1073
1074
|
// cookie, so every later fetch, EventSource and <audio src> carries it
|
|
1074
1075
|
// without the page knowing anything about keys. Either key works here, and
|
|
1075
1076
|
// which one was used decides what the browser can then do.
|
|
1076
|
-
|
|
1077
|
-
|
|
1077
|
+
const offeredInPath = key !== null ? keyInPath(path) : null;
|
|
1078
|
+
if (offeredInPath !== null) {
|
|
1079
|
+
const offered = offeredInPath;
|
|
1078
1080
|
if (scopeOf(offered, key, listenKey) === null) {
|
|
1079
1081
|
json(response, 404, { error: "not found" });
|
|
1080
1082
|
return;
|
|
@@ -3078,7 +3080,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3078
3080
|
live: publisher !== null,
|
|
3079
3081
|
code: listing?.code ?? "",
|
|
3080
3082
|
name: listing?.name ?? (options.name || hostname()),
|
|
3081
|
-
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey) : ""),
|
|
3083
|
+
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
|
|
3082
3084
|
// Whether going live is even possible here. A laptop behind a router
|
|
3083
3085
|
// with no address the world can reach cannot be listed, and a button
|
|
3084
3086
|
// that could only fail is worse than one that is not offered.
|
|
@@ -3285,7 +3287,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3285
3287
|
console.log(" A listen-only link, for someone you want to hear it but not drive it:");
|
|
3286
3288
|
for (const { label, url } of addresses) {
|
|
3287
3289
|
if (label === "here") continue;
|
|
3288
|
-
console.log(` ${shareLink(url, listenKey)}`);
|
|
3290
|
+
console.log(` ${shareLink(url, listenKey, false)}`);
|
|
3289
3291
|
}
|
|
3290
3292
|
}
|
|
3291
3293
|
}
|
|
@@ -3382,7 +3384,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3382
3384
|
*/
|
|
3383
3385
|
function makePublisher(): Publisher | null {
|
|
3384
3386
|
if (!publishable_) return null;
|
|
3385
|
-
const listen = shareLink(publishable_.url, listenKey);
|
|
3387
|
+
const listen = shareLink(publishable_.url, listenKey, false);
|
|
3386
3388
|
// Announced next to the listen link, not instead of it: one is for a person
|
|
3387
3389
|
// with a browser, the other for the phone line and anything else that is
|
|
3388
3390
|
// handed one address and expected to play it.
|
|
@@ -3420,7 +3422,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3420
3422
|
}
|
|
3421
3423
|
|
|
3422
3424
|
if (options.publish !== "no" && publishable_) {
|
|
3423
|
-
const listen = shareLink(publishable_.url, listenKey);
|
|
3425
|
+
const listen = shareLink(publishable_.url, listenKey, false);
|
|
3424
3426
|
const wanted = options.publish === "yes"
|
|
3425
3427
|
? true
|
|
3426
3428
|
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
@@ -3438,6 +3440,33 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3438
3440
|
console.log(" --publish needs an address the world can reach. This machine has none.");
|
|
3439
3441
|
}
|
|
3440
3442
|
|
|
3443
|
+
// Remember this machine on the account it belongs to, without being asked.
|
|
3444
|
+
//
|
|
3445
|
+
// `nixamp server add --here` existed and did exactly this, which is a manual
|
|
3446
|
+
// step for something the daemon already knows: it has just worked out its
|
|
3447
|
+
// own address and minted its own key, and the session file says whose it is.
|
|
3448
|
+
// Somebody who has signed in on this machine has said which account it is;
|
|
3449
|
+
// being on their list is what they meant by that.
|
|
3450
|
+
//
|
|
3451
|
+
// Idempotent: adding the same URL again updates the row rather than making
|
|
3452
|
+
// a second one, so this is safe on every start. Silent about failure, since
|
|
3453
|
+
// nothing here is worth stopping a player for.
|
|
3454
|
+
if (session?.token && publishable_) {
|
|
3455
|
+
const remembered = shareLink(publishable_.url, key);
|
|
3456
|
+
void fetch(`${DEFAULT_DIRECTORY}/api/v1/servers`, {
|
|
3457
|
+
method: "POST",
|
|
3458
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${session.token}` },
|
|
3459
|
+
body: JSON.stringify({ url: publishable_.url, name: options.name || hostname(), key: key ?? "" }),
|
|
3460
|
+
})
|
|
3461
|
+
.then((answer) => {
|
|
3462
|
+
if (answer.ok) console.log(` Remembered on your account at ${DEFAULT_DIRECTORY}.`);
|
|
3463
|
+
})
|
|
3464
|
+
.catch(() => {
|
|
3465
|
+
// The directory being unreachable is not a reason to stop serving.
|
|
3466
|
+
});
|
|
3467
|
+
void remembered;
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3441
3470
|
// A last resort, not a licence.
|
|
3442
3471
|
//
|
|
3443
3472
|
// Every throw reachable from a request should be caught where it happens,
|
package/src/session.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
23
23
|
import { dirname, join } from "node:path";
|
|
24
24
|
import { stateDir } from "./daemon.ts";
|
|
25
25
|
import { DEFAULT_DIRECTORY } from "./directory.ts";
|
|
26
|
+
import { shareLink } from "./share.ts";
|
|
26
27
|
|
|
27
28
|
export interface Session {
|
|
28
29
|
site: string;
|
|
@@ -585,7 +586,7 @@ export async function servers(argv: string[], fetcher: typeof fetch = fetch): Pr
|
|
|
585
586
|
console.error(
|
|
586
587
|
"nixamp: no daemon is running here.\n" +
|
|
587
588
|
" Start one: nixamp daemon start ~/Music\n" +
|
|
588
|
-
" Or give the share link of the one you mean: https://host:4321/
|
|
589
|
+
" Or give the share link of the one you mean: https://host:4321/a/KEY",
|
|
589
590
|
);
|
|
590
591
|
return 1;
|
|
591
592
|
}
|
|
@@ -642,7 +643,7 @@ export async function servers(argv: string[], fetcher: typeof fetch = fetch): Pr
|
|
|
642
643
|
}
|
|
643
644
|
const width = Math.max(...rows.map((row) => row.name.length));
|
|
644
645
|
for (const row of rows) {
|
|
645
|
-
const link = row.
|
|
646
|
+
const link = shareLink(row.url, row.key ?? null);
|
|
646
647
|
console.log(`${row.id} ${row.name.padEnd(width)} ${link}`);
|
|
647
648
|
}
|
|
648
649
|
return 0;
|
package/src/share.ts
CHANGED
|
@@ -271,9 +271,35 @@ export function reachableAddresses(
|
|
|
271
271
|
];
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
/**
|
|
275
|
-
|
|
276
|
-
|
|
274
|
+
/**
|
|
275
|
+
* The two paths a key can arrive on, and what each one says about itself.
|
|
276
|
+
*
|
|
277
|
+
* `/a/` administers and `/v/` only views. There used to be a `/s/` that meant
|
|
278
|
+
* neither -- just "here is a key" -- which is why somebody handed one of two
|
|
279
|
+
* identical-looking links had no way to tell which they were holding, and
|
|
280
|
+
* reported the controls as missing when they were never going to be there.
|
|
281
|
+
* A link should say what it is before anybody clicks it.
|
|
282
|
+
*/
|
|
283
|
+
export const KEY_PATHS = ["/a/", "/v/"] as const;
|
|
284
|
+
|
|
285
|
+
/** The key in a share link, whichever of the three shapes it came in. */
|
|
286
|
+
export function keyInPath(path: string): string | null {
|
|
287
|
+
for (const prefix of KEY_PATHS) {
|
|
288
|
+
if (!path.startsWith(prefix)) continue;
|
|
289
|
+
const rest = path.slice(prefix.length).replace(/\/+$/, "");
|
|
290
|
+
if (rest === "" || rest.includes("/")) return null;
|
|
291
|
+
try {
|
|
292
|
+
return decodeURIComponent(rest);
|
|
293
|
+
} catch {
|
|
294
|
+
return rest;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The full link, key and all. `admin` picks the shape that says which it is. */
|
|
301
|
+
export function shareLink(base: string, key: string | null, admin = true): string {
|
|
302
|
+
return key === null ? base : `${base}${admin ? "/a/" : "/v/"}${key}`;
|
|
277
303
|
}
|
|
278
304
|
|
|
279
305
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-BIeMVcOg.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};
|