nixamp 0.1.0 → 0.3.0

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.
Files changed (65) hide show
  1. package/README.md +225 -0
  2. package/bin/nixamp.mjs +4 -1
  3. package/dist/accounts.d.ts +54 -0
  4. package/dist/accounts.js +160 -0
  5. package/dist/admin.d.ts +47 -0
  6. package/dist/admin.js +209 -0
  7. package/dist/broadcast.d.ts +96 -0
  8. package/dist/broadcast.js +193 -0
  9. package/dist/channels.d.ts +94 -0
  10. package/dist/channels.js +235 -0
  11. package/dist/connections.d.ts +72 -0
  12. package/dist/connections.js +128 -0
  13. package/dist/daemon.d.ts +39 -0
  14. package/dist/daemon.js +170 -0
  15. package/dist/directory.d.ts +63 -0
  16. package/dist/directory.js +111 -0
  17. package/dist/ingest.d.ts +80 -0
  18. package/dist/ingest.js +252 -0
  19. package/dist/main.js +103 -6
  20. package/dist/manage.js +30 -7
  21. package/dist/owner.d.ts +53 -0
  22. package/dist/owner.js +96 -0
  23. package/dist/paywall.d.ts +60 -0
  24. package/dist/paywall.js +162 -0
  25. package/dist/playlist.d.ts +16 -0
  26. package/dist/playlist.js +57 -2
  27. package/dist/publish.d.ts +36 -0
  28. package/dist/publish.js +90 -0
  29. package/dist/rtmp-in.d.ts +22 -0
  30. package/dist/rtmp-in.js +79 -0
  31. package/dist/server.d.ts +117 -4
  32. package/dist/server.js +923 -24
  33. package/dist/session.d.ts +29 -0
  34. package/dist/session.js +184 -0
  35. package/dist/share.d.ts +74 -0
  36. package/dist/share.js +172 -0
  37. package/dist/sources.d.ts +37 -0
  38. package/dist/sources.js +125 -0
  39. package/package.json +5 -2
  40. package/src/accounts.ts +193 -0
  41. package/src/admin.ts +243 -0
  42. package/src/broadcast.ts +264 -0
  43. package/src/channels.ts +281 -0
  44. package/src/connections.ts +158 -0
  45. package/src/daemon.ts +193 -0
  46. package/src/directory.ts +135 -0
  47. package/src/ingest.ts +297 -0
  48. package/src/main.ts +107 -6
  49. package/src/manage.ts +35 -7
  50. package/src/owner.ts +113 -0
  51. package/src/paywall.ts +198 -0
  52. package/src/playlist.ts +68 -2
  53. package/src/publish.ts +101 -0
  54. package/src/rtmp-in.ts +90 -0
  55. package/src/server.ts +1087 -23
  56. package/src/session.ts +209 -0
  57. package/src/share.ts +193 -0
  58. package/src/sources.ts +136 -0
  59. package/src/types/auth-system.d.ts +77 -0
  60. package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
  61. package/web/dist/assets/index-WYJ6R4uF.js +1 -0
  62. package/web/dist/index.html +37 -6
  63. package/web/dist/install.ps1 +214 -0
  64. package/web/dist/sw.js +3 -3
  65. package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/dist/manage.js CHANGED
@@ -9,13 +9,16 @@
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { dirname, join, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
12
13
  const SITE = "https://nixamp.com";
14
+ /** Windows has its own installer, its own shim and its own removal script. */
15
+ const windows = process.platform === "win32";
13
16
  /**
14
17
  * Where the installer put things. `NIXAMP_HOME` is exported by the shim it
15
18
  * wrote, which is the only thing that knows for certain; the walk up from this
16
19
  * file covers a shim from an older install that did not set it.
17
20
  */
18
- export function installRoot(from = new URL(".", import.meta.url).pathname) {
21
+ export function installRoot(from = fileURLToPath(new URL(".", import.meta.url))) {
19
22
  const declared = process.env["NIXAMP_HOME"];
20
23
  if (declared && existsSync(join(declared, "manifest.json")))
21
24
  return declared;
@@ -53,7 +56,9 @@ function notInstalled(what) {
53
56
  console.error("");
54
57
  console.error(" Installed with npm or bun: npm uninstall -g nixamp");
55
58
  console.error(" Running from a checkout: delete the checkout");
56
- console.error(` Wanted the installed one: curl -fsSL ${SITE}/install.sh | sh`);
59
+ console.error(windows
60
+ ? ` Wanted the installed one: irm ${SITE}/install.ps1 | iex`
61
+ : ` Wanted the installed one: curl -fsSL ${SITE}/install.sh | sh`);
57
62
  return 69;
58
63
  }
59
64
  /**
@@ -66,12 +71,24 @@ export function update(argv) {
66
71
  const manifest = root ? readManifest(root) : null;
67
72
  if (!root || !manifest)
68
73
  return notInstalled("update");
69
- const installer = manifest.installer || `${SITE}/install.sh`;
70
- const args = ["-s", "--", manifest.desktop ? "--desktop" : "--cli-only", "--prefix", manifest.prefix];
74
+ const installer = manifest.installer || `${SITE}/${windows ? "install.ps1" : "install.sh"}`;
71
75
  const wanted = argv.find((a) => !a.startsWith("-"));
76
+ console.log(`nixamp ${manifest.version} is installed. Fetching the installer...`);
77
+ if (windows) {
78
+ // PowerShell fetches and runs it in one expression, which is also the
79
+ // documented install line, so an update takes a fresh install's path.
80
+ const flags = [manifest.desktop ? "" : "-CliOnly", "-Prefix", quote(manifest.prefix)];
81
+ if (wanted)
82
+ flags.push("-Version", quote(wanted));
83
+ const expression = `& ([scriptblock]::Create((irm ${installer}))) ${flags.filter(Boolean).join(" ")}`;
84
+ const run = spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", expression], {
85
+ stdio: "inherit",
86
+ });
87
+ return run.status ?? 1;
88
+ }
89
+ const args = ["-s", "--", manifest.desktop ? "--desktop" : "--cli-only", "--prefix", manifest.prefix];
72
90
  if (wanted)
73
91
  args.push("--version", wanted);
74
- console.log(`nixamp ${manifest.version} is installed. Fetching the installer...`);
75
92
  const fetcher = which("curl") ? ["curl", "-fsSL", installer] : which("wget") ? ["wget", "-qO-", installer] : null;
76
93
  if (!fetcher) {
77
94
  console.error("nixamp: curl or wget is required to update.");
@@ -103,15 +120,21 @@ export function uninstall(argv) {
103
120
  console.log("Your music is not touched. Run `nixamp uninstall --yes` to go ahead.");
104
121
  return 0;
105
122
  }
106
- const script = join(root, "uninstall.sh");
123
+ const script = join(root, windows ? "uninstall.ps1" : "uninstall.sh");
107
124
  if (!existsSync(script)) {
108
125
  console.error(`nixamp: ${script} is missing, so removal cannot be exact.`);
109
126
  console.error(` The manifest lists: ${manifest.paths.join(", ")}`);
110
127
  return 1;
111
128
  }
112
- const run = spawnSync("sh", [script], { stdio: "inherit" });
129
+ const run = windows
130
+ ? spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script], { stdio: "inherit" })
131
+ : spawnSync("sh", [script], { stdio: "inherit" });
113
132
  return run.status ?? 1;
114
133
  }
134
+ /** A PowerShell single-quoted string: the only escape inside one is a doubled quote. */
135
+ function quote(value) {
136
+ return `'${value.replace(/'/g, "''")}'`;
137
+ }
115
138
  function which(command) {
116
139
  return spawnSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" }).status === 0;
117
140
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Who may administer this server.
3
+ *
4
+ * Two ways to be allowed, and they answer different questions:
5
+ *
6
+ * - You hold the control key. That is possession of the share link the server
7
+ * printed, which means you are at the machine or someone at it told you.
8
+ * - You are signed in to nixamp.com as the account that owns this server. That
9
+ * is identity, and it works from a phone on the other side of the world.
10
+ *
11
+ * The server cannot check a nixamp.com token itself -- it has no part of that
12
+ * secret, and it should not. So it asks nixamp.com who the token belongs to and
13
+ * compares the answer to the owner it recorded at startup. Delegating identity
14
+ * and keeping authorisation local is what lets a nixamp on a laptop trust an
15
+ * account it has never seen.
16
+ */
17
+ /** How long an answer from nixamp.com is trusted before asking again. */
18
+ export declare const CACHE_MS = 60000;
19
+ export interface OwnerOptions {
20
+ /** The account id that owns this server, from the CLI session at startup. */
21
+ ownerId: string;
22
+ /** Where to ask about a token. */
23
+ site: string;
24
+ fetcher?: typeof fetch;
25
+ now?: () => number;
26
+ }
27
+ export interface AdminCheck {
28
+ /** May this caller administer the server? */
29
+ allowed: boolean;
30
+ /** How they proved it, for the admin view to show. */
31
+ as: "key" | "owner" | null;
32
+ }
33
+ /**
34
+ * Ask nixamp.com who a token belongs to, and remember the answer briefly.
35
+ *
36
+ * Briefly, because an admin request should not cost a round trip to another
37
+ * host every time, and not for long, because a revoked session should stop
38
+ * working in about a minute rather than whenever the process restarts.
39
+ */
40
+ export declare class Owner {
41
+ private readonly options;
42
+ private readonly cache;
43
+ constructor(options: OwnerOptions);
44
+ get claimed(): boolean;
45
+ /** The account a token belongs to, or "" for one nixamp.com does not accept. */
46
+ accountFor(token: string): Promise<string>;
47
+ check(hasControlKey: boolean, token: string): Promise<AdminCheck>;
48
+ /** Forget everything remembered, so a sign-out takes effect at once. */
49
+ forget(): void;
50
+ }
51
+ /** Paths only an administrator may reach. */
52
+ export declare const ADMIN_PATHS: string[];
53
+ export declare function needsAdmin(path: string, method?: string): boolean;
package/dist/owner.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Who may administer this server.
3
+ *
4
+ * Two ways to be allowed, and they answer different questions:
5
+ *
6
+ * - You hold the control key. That is possession of the share link the server
7
+ * printed, which means you are at the machine or someone at it told you.
8
+ * - You are signed in to nixamp.com as the account that owns this server. That
9
+ * is identity, and it works from a phone on the other side of the world.
10
+ *
11
+ * The server cannot check a nixamp.com token itself -- it has no part of that
12
+ * secret, and it should not. So it asks nixamp.com who the token belongs to and
13
+ * compares the answer to the owner it recorded at startup. Delegating identity
14
+ * and keeping authorisation local is what lets a nixamp on a laptop trust an
15
+ * account it has never seen.
16
+ */
17
+ /** How long an answer from nixamp.com is trusted before asking again. */
18
+ export const CACHE_MS = 60_000;
19
+ /**
20
+ * Ask nixamp.com who a token belongs to, and remember the answer briefly.
21
+ *
22
+ * Briefly, because an admin request should not cost a round trip to another
23
+ * host every time, and not for long, because a revoked session should stop
24
+ * working in about a minute rather than whenever the process restarts.
25
+ */
26
+ export class Owner {
27
+ options;
28
+ cache = new Map();
29
+ constructor(options) {
30
+ this.options = options;
31
+ }
32
+ get claimed() {
33
+ return this.options.ownerId !== "";
34
+ }
35
+ /** The account a token belongs to, or "" for one nixamp.com does not accept. */
36
+ async accountFor(token) {
37
+ if (!token)
38
+ return "";
39
+ const now = (this.options.now ?? Date.now)();
40
+ const remembered = this.cache.get(token);
41
+ if (remembered && now - remembered.at < CACHE_MS)
42
+ return remembered.id;
43
+ const send = this.options.fetcher ?? fetch;
44
+ try {
45
+ const answer = await send(`${this.options.site}/api/v1/auth/me`, {
46
+ headers: { authorization: `Bearer ${token}` },
47
+ });
48
+ if (!answer.ok) {
49
+ // Remember the refusal too, or a wrong token costs a round trip on
50
+ // every request it is presented with.
51
+ this.cache.set(token, { id: "", at: now });
52
+ return "";
53
+ }
54
+ const body = (await answer.json());
55
+ const id = typeof body.account?.id === "string" ? body.account.id : "";
56
+ this.cache.set(token, { id, at: now });
57
+ return id;
58
+ }
59
+ catch {
60
+ // nixamp.com being unreachable must not turn into "everyone is the
61
+ // owner". It turns into "nobody is", and the control key still works.
62
+ return "";
63
+ }
64
+ }
65
+ async check(hasControlKey, token) {
66
+ if (hasControlKey)
67
+ return { allowed: true, as: "key" };
68
+ if (!this.claimed)
69
+ return { allowed: false, as: null };
70
+ const account = await this.accountFor(token);
71
+ return account !== "" && account === this.options.ownerId
72
+ ? { allowed: true, as: "owner" }
73
+ : { allowed: false, as: null };
74
+ }
75
+ /** Forget everything remembered, so a sign-out takes effect at once. */
76
+ forget() {
77
+ this.cache.clear();
78
+ }
79
+ }
80
+ /** Paths only an administrator may reach. */
81
+ export const ADMIN_PATHS = [
82
+ "/api/connections",
83
+ "/api/source",
84
+ "/api/broadcast",
85
+ "/api/ingest",
86
+ "/api/admin",
87
+ ];
88
+ export function needsAdmin(path, method = "GET") {
89
+ if (ADMIN_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)))
90
+ return true;
91
+ // Publishing to a channel, or ending one, is administering the server.
92
+ // Listening to a channel is not: that is what the share link is for.
93
+ if (path.startsWith("/api/channels/") && method !== "GET")
94
+ return true;
95
+ return false;
96
+ }
@@ -0,0 +1,60 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ /** Listeners who get in for nothing. The sixth is the one who pays. */
3
+ export declare const FREE_LISTENERS = 5;
4
+ export interface PaywallConfig {
5
+ enabled: boolean;
6
+ /** EVM address that receives the USDC. Without one there is nothing to pay to. */
7
+ payTo: string;
8
+ /** A scoped CoinPay key with payments:create. */
9
+ coinpayKey: string;
10
+ priceCents: number;
11
+ /** What one price buys. 1440 is a day. */
12
+ passMinutes: number;
13
+ }
14
+ export declare const DEFAULT_PAYWALL: PaywallConfig;
15
+ /** Only the audio is behind the gate. */
16
+ export declare const GATED: string[];
17
+ export declare function isGated(path: string): boolean;
18
+ /**
19
+ * Whether this request should be charged at all. Configuration first, because
20
+ * a disabled paywall must never answer 402; then the path, then how busy the
21
+ * stream is.
22
+ */
23
+ export declare function shouldCharge(config: PaywallConfig, path: string, liveListeners: number): boolean;
24
+ export interface PaywallOptions {
25
+ config: () => PaywallConfig;
26
+ /** How many listeners are on the audio routes right now. */
27
+ liveListeners: () => number;
28
+ /**
29
+ * The origin a payer is quoted, which has to be one they can reach. A
30
+ * function because the port is not known until the socket is bound.
31
+ */
32
+ siteUrl: () => string;
33
+ /** True for the operator's own browser, which never pays to hear its own music. */
34
+ exempt: (request: IncomingMessage) => boolean;
35
+ }
36
+ /**
37
+ * A node http shim over the gateway's Fetch-API handler. Returns true when it
38
+ * answered the request, so the server's own routing can stop.
39
+ */
40
+ export declare function createPaywall(options: PaywallOptions): (request: IncomingMessage, response: ServerResponse, path: string) => Promise<boolean>;
41
+ /**
42
+ * The gateway sells crawl access, and says so: its 402 tells the caller that
43
+ * "payment is required for training crawlers". Someone trying to hear a song
44
+ * is not a crawler, so the sentence is replaced on the way out. The offer
45
+ * itself is untouched -- only the words are ours.
46
+ */
47
+ export declare function rewrite(answer: Response, siteUrl: string): Promise<Buffer>;
48
+ /**
49
+ * Enough of a Fetch Request for the gateway: it reads the URL, the method and
50
+ * headers. A body would need streaming, and nothing it gates has one.
51
+ */
52
+ export declare function toRequest(request: IncomingMessage, siteUrl: string): Request;
53
+ /** Read a paywall out of the environment, for an operator who runs one by hand. */
54
+ export declare function paywallFromEnv(env?: NodeJS.ProcessEnv): PaywallConfig;
55
+ /**
56
+ * The directory's answer may carry a configuration, which is how a server is
57
+ * turned on and off from nixamp.com. Anything missing keeps what it had, and
58
+ * a payTo the operator did not set is never invented here.
59
+ */
60
+ export declare function applyRemoteConfig(current: PaywallConfig, remote: unknown): PaywallConfig;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Paying to listen, once a stream is busy.
3
+ *
4
+ * A nixamp serving a handful of friends should cost nothing and ask nothing.
5
+ * Past that it is bandwidth someone is paying for, so the gate opens: over the
6
+ * free listener count, a new listener is answered 402 with an x402 offer and a
7
+ * dollar buys a day.
8
+ *
9
+ * Three things are deliberate. The count is of *live* listeners, so a stream
10
+ * quietens back to free on its own. Only the audio is gated, because a 402 on
11
+ * /api/state would break the page that has to render the offer. And nobody is
12
+ * ever cut off mid-track: the gate is asked once, when a request arrives, and
13
+ * a listener who got in stays in.
14
+ */
15
+ import { createGateway } from "@profullstack/x402-gateway";
16
+ /** Listeners who get in for nothing. The sixth is the one who pays. */
17
+ export const FREE_LISTENERS = 5;
18
+ export const DEFAULT_PAYWALL = {
19
+ enabled: false,
20
+ payTo: "",
21
+ coinpayKey: "",
22
+ priceCents: 100,
23
+ passMinutes: 1440,
24
+ };
25
+ /** Only the audio is behind the gate. */
26
+ export const GATED = ["/api/stream/", "/api/media/"];
27
+ export function isGated(path) {
28
+ return GATED.some((prefix) => path.startsWith(prefix));
29
+ }
30
+ /**
31
+ * Whether this request should be charged at all. Configuration first, because
32
+ * a disabled paywall must never answer 402; then the path, then how busy the
33
+ * stream is.
34
+ */
35
+ export function shouldCharge(config, path, liveListeners) {
36
+ if (!config.enabled || !config.payTo)
37
+ return false;
38
+ if (!isGated(path))
39
+ return false;
40
+ return liveListeners > FREE_LISTENERS;
41
+ }
42
+ /**
43
+ * A node http shim over the gateway's Fetch-API handler. Returns true when it
44
+ * answered the request, so the server's own routing can stop.
45
+ */
46
+ export function createPaywall(options) {
47
+ let built = null;
48
+ /** Rebuilt when the configuration changes, because it can change at runtime. */
49
+ const gateway = (config, siteUrl) => {
50
+ const key = `${siteUrl} ${JSON.stringify(config)}`;
51
+ if (built?.key === key)
52
+ return built.handle;
53
+ const gate = createGateway({
54
+ siteUrl,
55
+ siteName: "nixamp",
56
+ payTo: config.payTo,
57
+ priceCents: config.priceCents,
58
+ passMinutes: config.passMinutes,
59
+ coinpay: { apiKey: config.coinpayKey },
60
+ header: "x-nixamp-pass",
61
+ path: "/listen/pay",
62
+ // Whether to charge is about how busy this stream is, not about who is
63
+ // asking, so the agent lists play no part.
64
+ isPaidAgent: () => true,
65
+ benefits: ["Listen to this stream for a day, as many tracks as you like."],
66
+ });
67
+ built = { key, handle: (request) => gate.handle(request) };
68
+ return built.handle;
69
+ };
70
+ return async function paywall(request, response, path) {
71
+ const config = options.config();
72
+ // The sales page answers whenever the paywall is configured, so a listener
73
+ // can buy a pass before the stream is busy enough to need one.
74
+ const selling = config.enabled && config.payTo !== "" && path.startsWith("/listen/pay");
75
+ if (!selling && !shouldCharge(config, path, options.liveListeners()))
76
+ return false;
77
+ if (options.exempt(request))
78
+ return false;
79
+ const siteUrl = options.siteUrl();
80
+ const answer = await gateway(config, siteUrl)(toRequest(request, siteUrl));
81
+ if (answer === null)
82
+ return false;
83
+ const body = await rewrite(answer, siteUrl);
84
+ response.writeHead(answer.status, {
85
+ ...Object.fromEntries(answer.headers),
86
+ "content-length": String(body.byteLength),
87
+ });
88
+ response.end(body);
89
+ return true;
90
+ };
91
+ }
92
+ /**
93
+ * The gateway sells crawl access, and says so: its 402 tells the caller that
94
+ * "payment is required for training crawlers". Someone trying to hear a song
95
+ * is not a crawler, so the sentence is replaced on the way out. The offer
96
+ * itself is untouched -- only the words are ours.
97
+ */
98
+ export async function rewrite(answer, siteUrl) {
99
+ const raw = Buffer.from(await answer.arrayBuffer());
100
+ if (answer.status !== 402 || !(answer.headers.get("content-type") ?? "").includes("json"))
101
+ return raw;
102
+ try {
103
+ const parsed = JSON.parse(raw.toString("utf8"));
104
+ parsed["error"] =
105
+ `This stream has more than ${FREE_LISTENERS} people listening. ` +
106
+ `A pass is ${parsed.pass?.price ?? "$1"} for a day: ${siteUrl}/listen/pay`;
107
+ return Buffer.from(JSON.stringify(parsed));
108
+ }
109
+ catch {
110
+ // Not JSON after all: send exactly what the gateway produced.
111
+ return raw;
112
+ }
113
+ }
114
+ /**
115
+ * Enough of a Fetch Request for the gateway: it reads the URL, the method and
116
+ * headers. A body would need streaming, and nothing it gates has one.
117
+ */
118
+ export function toRequest(request, siteUrl) {
119
+ const headers = new Headers();
120
+ for (const [name, value] of Object.entries(request.headers)) {
121
+ if (value === undefined)
122
+ continue;
123
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
124
+ }
125
+ return new Request(new URL(request.url ?? "/", siteUrl), {
126
+ method: request.method ?? "GET",
127
+ headers,
128
+ });
129
+ }
130
+ /** Read a paywall out of the environment, for an operator who runs one by hand. */
131
+ export function paywallFromEnv(env = process.env) {
132
+ const price = Number(env["NIXAMP_PRICE_CENTS"]);
133
+ const minutes = Number(env["NIXAMP_PASS_MINUTES"]);
134
+ return {
135
+ enabled: env["NIXAMP_X402"] === "1",
136
+ payTo: env["NIXAMP_PAY_TO"] ?? "",
137
+ coinpayKey: env["COINPAY_X402_KEY"] ?? "",
138
+ priceCents: Number.isFinite(price) && price > 0 ? Math.floor(price) : DEFAULT_PAYWALL.priceCents,
139
+ passMinutes: Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : DEFAULT_PAYWALL.passMinutes,
140
+ };
141
+ }
142
+ /**
143
+ * The directory's answer may carry a configuration, which is how a server is
144
+ * turned on and off from nixamp.com. Anything missing keeps what it had, and
145
+ * a payTo the operator did not set is never invented here.
146
+ */
147
+ export function applyRemoteConfig(current, remote) {
148
+ if (typeof remote !== "object" || remote === null)
149
+ return current;
150
+ const record = remote;
151
+ const price = Number(record["priceCents"]);
152
+ const minutes = Number(record["passMinutes"]);
153
+ return {
154
+ enabled: typeof record["enabled"] === "boolean" ? record["enabled"] : current.enabled,
155
+ payTo: typeof record["payTo"] === "string" && record["payTo"] ? record["payTo"] : current.payTo,
156
+ coinpayKey: typeof record["coinpayKey"] === "string" && record["coinpayKey"]
157
+ ? record["coinpayKey"]
158
+ : current.coinpayKey,
159
+ priceCents: Number.isFinite(price) && price > 0 ? Math.floor(price) : current.priceCents,
160
+ passMinutes: Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : current.passMinutes,
161
+ };
162
+ }
@@ -1,8 +1,24 @@
1
1
  import { type Tools, type Track } from "./audio.ts";
2
+ import { type Entry } from "./sources.ts";
2
3
  export declare const AUDIO_EXTENSIONS: Set<string>;
3
4
  export declare function isAudio(path: string): boolean;
4
5
  /** Every audio file under `root`, depth first. A single file is a playlist of one. */
5
6
  export declare function findAudio(root: string): string[];
7
+ /**
8
+ * Read a playlist, from disk or over the network. An HLS playlist is not a list
9
+ * of tracks but one stream in segments, so it comes back as a single entry and
10
+ * ffmpeg is left to do what it is good at.
11
+ */
12
+ export declare function readPlaylist(source: string): Promise<Entry[]>;
13
+ /**
14
+ * Everything `nixamp <thing>` can be handed: a directory, a file, a playlist,
15
+ * or a URL to any of those.
16
+ *
17
+ * Reading tags means an ffprobe per file, which is slow for a large library and
18
+ * slower still over the network, so it is only done for local files the caller
19
+ * asked about.
20
+ */
21
+ export declare function loadSource(tools: Tools, source: string, probeTags?: boolean): Promise<Track[]>;
6
22
  /**
7
23
  * Reading tags means an ffprobe per file, which is slow for a large library, so
8
24
  * the caller decides when to pay for it. Untagged entries still play.
package/dist/playlist.js CHANGED
@@ -1,7 +1,8 @@
1
- /** The playlist: audio files found on disk, in a stable order. */
2
- import { readdirSync, statSync } from "node:fs";
1
+ /** The playlist: audio found on disk or named by a playlist, in a stable order. */
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { probe } from "./audio.js";
5
+ import { isHls, isPlaylistFile, isRemote, nameOf, parseM3u, parsePls, } from "./sources.js";
5
6
  export const AUDIO_EXTENSIONS = new Set([
6
7
  ".mp3", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".aac",
7
8
  ".wav", ".wma", ".aiff", ".aif", ".alac", ".mp4", ".webm",
@@ -50,6 +51,60 @@ export function findAudio(root) {
50
51
  walk(root);
51
52
  return out;
52
53
  }
54
+ /** An entry that was never probed: playable, just not described. */
55
+ function bare(entry) {
56
+ return { path: entry.source, title: entry.title, artist: "", album: "", duration: entry.duration };
57
+ }
58
+ /**
59
+ * Read a playlist, from disk or over the network. An HLS playlist is not a list
60
+ * of tracks but one stream in segments, so it comes back as a single entry and
61
+ * ffmpeg is left to do what it is good at.
62
+ */
63
+ export async function readPlaylist(source) {
64
+ let text;
65
+ if (isRemote(source)) {
66
+ const response = await fetch(source, { redirect: "follow" });
67
+ if (!response.ok)
68
+ throw new Error(`nixamp: ${source} answered ${response.status}`);
69
+ text = await response.text();
70
+ }
71
+ else {
72
+ text = readFileSync(source, "utf8");
73
+ }
74
+ if (isHls(text))
75
+ return [{ source, title: nameOf(source), duration: 0 }];
76
+ const entries = /\.pls$/i.test(source) ? parsePls(text, source) : parseM3u(text, source);
77
+ return entries;
78
+ }
79
+ /**
80
+ * Everything `nixamp <thing>` can be handed: a directory, a file, a playlist,
81
+ * or a URL to any of those.
82
+ *
83
+ * Reading tags means an ffprobe per file, which is slow for a large library and
84
+ * slower still over the network, so it is only done for local files the caller
85
+ * asked about.
86
+ */
87
+ export async function loadSource(tools, source, probeTags = true) {
88
+ if (isPlaylistFile(source)) {
89
+ let entries;
90
+ try {
91
+ entries = await readPlaylist(source);
92
+ }
93
+ catch (error) {
94
+ // The playlist is the whole argument, so failing to read it is fatal --
95
+ // but it is fatal with a sentence, not a stack.
96
+ throw new Error(`nixamp: could not read ${source}: ${error.message.replace(/^nixamp: /, "")}`);
97
+ }
98
+ return entries.map((entry) => probeTags && !isRemote(entry.source) && entry.duration === 0
99
+ ? { ...probe(tools, entry.source), title: entry.title || probe(tools, entry.source).title }
100
+ : bare(entry));
101
+ }
102
+ // A bare URL is one remote thing to play. Whether it is a song or a live
103
+ // stream is ffmpeg's problem, and it is good at it.
104
+ if (isRemote(source))
105
+ return [bare({ source, title: nameOf(source), duration: 0 })];
106
+ return loadPlaylist(tools, source, probeTags);
107
+ }
53
108
  /**
54
109
  * Reading tags means an ffprobe per file, which is slow for a large library, so
55
110
  * the caller decides when to pay for it. Untagged entries still play.
@@ -0,0 +1,36 @@
1
+ import { DEFAULT_DIRECTORY, type Listing } from "./directory.ts";
2
+ export interface PublishTarget {
3
+ directory: string;
4
+ name: string;
5
+ /** The listen link: what a stranger opens. Never the control key. */
6
+ url: string;
7
+ tracks: number;
8
+ nowPlaying: () => string;
9
+ /**
10
+ * Called with whatever configuration the directory sent back. This is how
11
+ * nixamp.com turns x402 on and off for a server without it restarting.
12
+ */
13
+ onConfig?: (config: unknown) => void;
14
+ }
15
+ /**
16
+ * Ask, with yes as the default. Returns false without asking when there is no
17
+ * terminal on the other end, which is the case for the daemon and for CI.
18
+ */
19
+ export declare function confirm(question: string, tty?: boolean): Promise<boolean>;
20
+ /**
21
+ * Announce, then keep announcing. The directory forgets an entry that stops
22
+ * renewing, so stopping the heartbeat is how a stream leaves the list even if
23
+ * the process dies without saying goodbye.
24
+ */
25
+ export declare class Publisher {
26
+ private readonly target;
27
+ private readonly fetcher;
28
+ private id;
29
+ private timer;
30
+ constructor(target: PublishTarget, fetcher?: typeof fetch);
31
+ start(): Promise<Listing | null>;
32
+ announce(): Promise<Listing | null>;
33
+ /** Leave the list now rather than waiting to be forgotten. */
34
+ stop(): Promise<void>;
35
+ }
36
+ export { DEFAULT_DIRECTORY };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Publishing to the directory, and asking first.
3
+ *
4
+ * Listing a stream tells the world an address it can reach you on, so it is
5
+ * never done silently. The prompt defaults to yes; a terminal that cannot ask
6
+ * defaults to no, because "there was nobody to ask" is not consent.
7
+ */
8
+ import { createInterface } from "node:readline/promises";
9
+ import { DEFAULT_DIRECTORY, HEARTBEAT_MS } from "./directory.js";
10
+ /**
11
+ * Ask, with yes as the default. Returns false without asking when there is no
12
+ * terminal on the other end, which is the case for the daemon and for CI.
13
+ */
14
+ export async function confirm(question, tty = process.stdin.isTTY === true) {
15
+ if (!tty)
16
+ return false;
17
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
18
+ try {
19
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
20
+ return answer === "" || answer === "y" || answer === "yes";
21
+ }
22
+ finally {
23
+ rl.close();
24
+ }
25
+ }
26
+ /**
27
+ * Announce, then keep announcing. The directory forgets an entry that stops
28
+ * renewing, so stopping the heartbeat is how a stream leaves the list even if
29
+ * the process dies without saying goodbye.
30
+ */
31
+ export class Publisher {
32
+ target;
33
+ fetcher;
34
+ id = null;
35
+ timer = null;
36
+ constructor(target, fetcher = fetch) {
37
+ this.target = target;
38
+ this.fetcher = fetcher;
39
+ }
40
+ async start() {
41
+ const first = await this.announce();
42
+ this.timer = setInterval(() => void this.announce(), HEARTBEAT_MS);
43
+ this.timer.unref?.();
44
+ return first;
45
+ }
46
+ async announce() {
47
+ try {
48
+ const response = await this.fetcher(`${this.target.directory}/api/directory`, {
49
+ method: "POST",
50
+ headers: { "content-type": "application/json" },
51
+ body: JSON.stringify({
52
+ ...(this.id ? { id: this.id } : {}),
53
+ name: this.target.name,
54
+ url: this.target.url,
55
+ tracks: this.target.tracks,
56
+ nowPlaying: this.target.nowPlaying(),
57
+ }),
58
+ });
59
+ if (!response.ok)
60
+ return null;
61
+ const listing = (await response.json());
62
+ this.id = listing.id;
63
+ if (listing.config !== undefined)
64
+ this.target.onConfig?.(listing.config);
65
+ return listing;
66
+ }
67
+ catch {
68
+ // The directory being down is not a reason for a player to stop playing.
69
+ return null;
70
+ }
71
+ }
72
+ /** Leave the list now rather than waiting to be forgotten. */
73
+ async stop() {
74
+ if (this.timer)
75
+ clearInterval(this.timer);
76
+ this.timer = null;
77
+ if (this.id === null)
78
+ return;
79
+ try {
80
+ await this.fetcher(`${this.target.directory}/api/directory?id=${encodeURIComponent(this.id)}`, {
81
+ method: "DELETE",
82
+ });
83
+ }
84
+ catch {
85
+ // It expires on its own within the TTL, which is the point of the TTL.
86
+ }
87
+ this.id = null;
88
+ }
89
+ }
90
+ export { DEFAULT_DIRECTORY };