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
@@ -0,0 +1,29 @@
1
+ export interface Session {
2
+ site: string;
3
+ email: string;
4
+ token: string;
5
+ signedInAt: number;
6
+ }
7
+ export declare function sessionPath(): string;
8
+ export declare function readSession(): Session | null;
9
+ export declare function writeSession(session: Session): void;
10
+ export declare function clearSession(): void;
11
+ /**
12
+ * Ask without echoing. Node has no "read a password" call, so the terminal is
13
+ * put in raw mode and the keystrokes are collected by hand.
14
+ */
15
+ export declare function askSecret(prompt: string): Promise<string>;
16
+ export interface LoginOptions {
17
+ site: string;
18
+ email: string;
19
+ /** Create the account rather than signing in to one. */
20
+ signUp: boolean;
21
+ fetcher?: typeof fetch;
22
+ }
23
+ /** Read the flags `nixamp login` accepts. */
24
+ export declare function parseLoginArgs(argv: string[]): LoginOptions;
25
+ /** `nixamp login` / `nixamp signup`. */
26
+ export declare function login(argv: string[]): Promise<number>;
27
+ export declare function logout(): number;
28
+ /** `nixamp whoami`, which asks the server rather than trusting the file. */
29
+ export declare function whoami(fetcher?: typeof fetch): Promise<number>;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Being signed in, from a terminal.
3
+ *
4
+ * `nixamp login` asks for an address and a password, and keeps the token it
5
+ * gets back beside the daemon's state. The desktop app bundles this same CLI,
6
+ * so signing in there and signing in here are the same thing on disk.
7
+ *
8
+ * The password is read with the echo turned off and is never written down: the
9
+ * token is what is kept, and it can be revoked without changing anything the
10
+ * person has to remember.
11
+ */
12
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { createInterface } from "node:readline/promises";
14
+ import { dirname, join } from "node:path";
15
+ import { stateDir } from "./daemon.js";
16
+ import { DEFAULT_DIRECTORY } from "./directory.js";
17
+ export function sessionPath() {
18
+ return join(stateDir(), "session.json");
19
+ }
20
+ export function readSession() {
21
+ try {
22
+ return JSON.parse(readFileSync(sessionPath(), "utf8"));
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ export function writeSession(session) {
29
+ const path = sessionPath();
30
+ mkdirSync(dirname(path), { recursive: true });
31
+ writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`);
32
+ // A bearer token is as good as the password for as long as it lives, so it
33
+ // is not left readable by everyone with an account on the machine.
34
+ chmodSync(path, 0o600);
35
+ }
36
+ export function clearSession() {
37
+ rmSync(sessionPath(), { force: true });
38
+ }
39
+ /**
40
+ * Ask without echoing. Node has no "read a password" call, so the terminal is
41
+ * put in raw mode and the keystrokes are collected by hand.
42
+ */
43
+ export async function askSecret(prompt) {
44
+ const input = process.stdin;
45
+ if (!input.isTTY) {
46
+ // A pipe has no echo to turn off, and reading a line is what a script
47
+ // wants anyway.
48
+ const rl = createInterface({ input, output: process.stdout });
49
+ try {
50
+ return await rl.question("");
51
+ }
52
+ finally {
53
+ rl.close();
54
+ }
55
+ }
56
+ process.stdout.write(prompt);
57
+ input.setRawMode(true);
58
+ input.resume();
59
+ input.setEncoding("utf8");
60
+ return new Promise((done) => {
61
+ let typed = "";
62
+ const onData = (key) => {
63
+ switch (key) {
64
+ case "\u0003": // ctrl-c
65
+ input.setRawMode(false);
66
+ input.pause();
67
+ process.stdout.write("\n");
68
+ process.exit(130);
69
+ return;
70
+ case "\r":
71
+ case "\n":
72
+ case "\u0004": // ctrl-d
73
+ input.setRawMode(false);
74
+ input.pause();
75
+ input.off("data", onData);
76
+ process.stdout.write("\n");
77
+ done(typed);
78
+ return;
79
+ case "\u007f": // backspace
80
+ case "\b":
81
+ typed = typed.slice(0, -1);
82
+ return;
83
+ default:
84
+ // One printable character. An arrow key arrives as a whole escape
85
+ // sequence, which would otherwise be appended as several characters
86
+ // of password nobody typed.
87
+ if (key.length === 1 && key >= " " && key !== "\u007f")
88
+ typed += key;
89
+ }
90
+ };
91
+ input.on("data", onData);
92
+ });
93
+ }
94
+ async function ask(prompt) {
95
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
96
+ try {
97
+ return (await rl.question(prompt)).trim();
98
+ }
99
+ finally {
100
+ rl.close();
101
+ }
102
+ }
103
+ /** Read the flags `nixamp login` accepts. */
104
+ export function parseLoginArgs(argv) {
105
+ const at = (flag) => {
106
+ const index = argv.indexOf(flag);
107
+ return index === -1 ? undefined : argv[index + 1];
108
+ };
109
+ return {
110
+ site: (at("--site") ?? DEFAULT_DIRECTORY).replace(/\/+$/, ""),
111
+ email: at("--email") ?? argv.find((a) => !a.startsWith("-") && a.includes("@")) ?? "",
112
+ signUp: argv.includes("--signup") || argv.includes("--sign-up"),
113
+ };
114
+ }
115
+ /** `nixamp login` / `nixamp signup`. */
116
+ export async function login(argv) {
117
+ const options = parseLoginArgs(argv);
118
+ const send = options.fetcher ?? fetch;
119
+ const email = options.email || (await ask("Email: "));
120
+ if (!email) {
121
+ console.error("nixamp: no email given");
122
+ return 64;
123
+ }
124
+ const password = await askSecret("Password: ");
125
+ if (!password) {
126
+ console.error("nixamp: no password given");
127
+ return 64;
128
+ }
129
+ const where = `${options.site}/api/v1/auth/${options.signUp ? "signup" : "login"}`;
130
+ let answer;
131
+ try {
132
+ answer = await send(where, {
133
+ method: "POST",
134
+ headers: { "content-type": "application/json" },
135
+ body: JSON.stringify({ email, password }),
136
+ });
137
+ }
138
+ catch (error) {
139
+ console.error(`nixamp: could not reach ${options.site}: ${error.message}`);
140
+ return 69;
141
+ }
142
+ const body = (await answer.json().catch(() => ({})));
143
+ if (!answer.ok || !body.token) {
144
+ console.error(`nixamp: ${body.error ?? `signing in failed (${answer.status})`}`);
145
+ return 1;
146
+ }
147
+ writeSession({ site: options.site, email, token: body.token, signedInAt: Date.now() });
148
+ console.log(`Signed in to ${options.site} as ${email}.`);
149
+ return 0;
150
+ }
151
+ export function logout() {
152
+ const session = readSession();
153
+ clearSession();
154
+ console.log(session ? `Signed out of ${session.site}.` : "nixamp: you were not signed in.");
155
+ return 0;
156
+ }
157
+ /** `nixamp whoami`, which asks the server rather than trusting the file. */
158
+ export async function whoami(fetcher = fetch) {
159
+ const session = readSession();
160
+ if (session === null) {
161
+ console.log("nixamp: not signed in. Try `nixamp login`.");
162
+ return 1;
163
+ }
164
+ try {
165
+ const answer = await fetcher(`${session.site}/api/v1/auth/me`, {
166
+ headers: { authorization: `Bearer ${session.token}` },
167
+ });
168
+ if (!answer.ok) {
169
+ // The token outlived its welcome, which is worth saying plainly rather
170
+ // than leaving a stale file to confuse the next command.
171
+ console.log(`nixamp: signed in as ${session.email}, but ${session.site} no longer accepts it.`);
172
+ console.log(" Run `nixamp login` again.");
173
+ return 1;
174
+ }
175
+ const body = (await answer.json());
176
+ console.log(`${body.account?.email ?? session.email} at ${session.site}`);
177
+ return 0;
178
+ }
179
+ catch {
180
+ // Offline is not signed out: the token is still good, we just cannot ask.
181
+ console.log(`${session.email} at ${session.site} (could not reach it to check)`);
182
+ return 0;
183
+ }
184
+ }
@@ -0,0 +1,74 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ /**
3
+ * Two keys, two scopes.
4
+ *
5
+ * The full key drives the player: it can skip, stop, and point the server at a
6
+ * different source. The listen key can only hear it. A stream published to the
7
+ * public directory hands out the listen key, because a link that lets a
8
+ * stranger pause your music is not a link you can publish.
9
+ */
10
+ export type Scope = "control" | "listen";
11
+ /** The cookie, and the query parameter that sets it. */
12
+ export declare const KEY_COOKIE = "nixamp_key";
13
+ export declare const KEY_QUERY = "k";
14
+ export declare const KEY_HEADER = "x-nixamp-key";
15
+ /**
16
+ * 128 bits, base64url. Long enough that guessing is not a strategy, short
17
+ * enough to read down a phone screen when someone types it by hand.
18
+ */
19
+ export declare function newKey(): string;
20
+ /** Compare without leaking where two keys first differ. */
21
+ export declare function keysMatch(a: string, b: string): boolean;
22
+ /** Every place a key is accepted from, in the order they are looked for. */
23
+ export declare function keyFrom(request: IncomingMessage, url: URL): string | null;
24
+ /** The Set-Cookie for a browser that just opened the link. */
25
+ export declare function keyCookie(key: string): string;
26
+ /** Where an address actually goes, which is not always where you would like. */
27
+ export declare function classify(address: string): "private" | "cgnat" | "public";
28
+ /**
29
+ * The addresses another device could actually reach this machine on, nearest
30
+ * first. On a server the public one is the point: it is the address a phone
31
+ * somewhere else can open. It is labelled for what it is, because the key in
32
+ * the link is then the only thing between a stranger and the library.
33
+ */
34
+ export declare function reachableAddresses(host: string, port: number): {
35
+ label: string;
36
+ url: string;
37
+ }[];
38
+ /** The full link, key and all. */
39
+ export declare function shareLink(base: string, key: string | null): string;
40
+ /**
41
+ * What a key is allowed to do. An unknown key is allowed nothing, which is the
42
+ * same answer as no key at all.
43
+ */
44
+ export declare function scopeOf(offered: string | null, control: string | null, listen: string | null): Scope | null;
45
+ /** Paths a listen key may have. Everything else needs the control key. */
46
+ export declare function allowedForListening(path: string): boolean;
47
+ /** How to run a command, so the tests never touch a real firewall. */
48
+ export interface Runner {
49
+ read(path: string): string | null;
50
+ run(command: string, args: string[]): {
51
+ status: number | null;
52
+ stdout: string;
53
+ };
54
+ }
55
+ /** Which firewall is in the way, if any. */
56
+ export type Firewall = "ufw" | "firewalld";
57
+ /**
58
+ * Whether a firewall is running that would keep the port closed to other
59
+ * devices. Listening on 0.0.0.0 proves the socket is open on this machine and
60
+ * nothing more, so this is the difference between "it works" and "it works
61
+ * here".
62
+ */
63
+ export declare function firewallInUse(io: Runner): Firewall | null;
64
+ /** The commands that open and close a port, for each firewall we know. */
65
+ export declare function portCommands(firewall: Firewall, port: number): {
66
+ open: string[];
67
+ close: string[];
68
+ };
69
+ /**
70
+ * Root runs it directly; anyone else goes through sudo, and only when sudo
71
+ * will not stop to ask. A server that hangs on an invisible password prompt is
72
+ * worse than one that tells you the command to run yourself.
73
+ */
74
+ export declare function elevate(io: Runner, command: string[]): string[] | null;
package/dist/share.js ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The share link.
3
+ *
4
+ * `nixamp serve` listens on every interface so the phone in your pocket can
5
+ * reach it, and that is only reasonable because the address is not enough on
6
+ * its own: every request has to carry a key that is printed once, in the link.
7
+ * Someone else on the coffee shop wifi can find the port and gets nothing.
8
+ *
9
+ * The key travels in a cookie, set by opening the link. Nothing in the PWA had
10
+ * to change for that: a browser sends a same-origin cookie with every fetch,
11
+ * every EventSource and every `<audio src>` on its own.
12
+ */
13
+ import { randomBytes, timingSafeEqual } from "node:crypto";
14
+ import { networkInterfaces } from "node:os";
15
+ /** The cookie, and the query parameter that sets it. */
16
+ export const KEY_COOKIE = "nixamp_key";
17
+ export const KEY_QUERY = "k";
18
+ export const KEY_HEADER = "x-nixamp-key";
19
+ /**
20
+ * 128 bits, base64url. Long enough that guessing is not a strategy, short
21
+ * enough to read down a phone screen when someone types it by hand.
22
+ */
23
+ export function newKey() {
24
+ return randomBytes(16).toString("base64url");
25
+ }
26
+ /** Compare without leaking where two keys first differ. */
27
+ export function keysMatch(a, b) {
28
+ const left = Buffer.from(a);
29
+ const right = Buffer.from(b);
30
+ // timingSafeEqual throws on a length mismatch, which is itself the answer.
31
+ return left.length === right.length && timingSafeEqual(left, right);
32
+ }
33
+ /** Every place a key is accepted from, in the order they are looked for. */
34
+ export function keyFrom(request, url) {
35
+ const query = url.searchParams.get(KEY_QUERY);
36
+ if (query)
37
+ return query;
38
+ const header = request.headers[KEY_HEADER];
39
+ if (typeof header === "string" && header)
40
+ return header;
41
+ for (const part of (request.headers.cookie ?? "").split(";")) {
42
+ const [name, ...rest] = part.trim().split("=");
43
+ if (name === KEY_COOKIE && rest.length > 0)
44
+ return decodeURIComponent(rest.join("="));
45
+ }
46
+ return null;
47
+ }
48
+ /** The Set-Cookie for a browser that just opened the link. */
49
+ export function keyCookie(key) {
50
+ // HttpOnly because nothing in the page reads it: the browser attaches it to
51
+ // every same-origin request by itself. No Secure, because the whole point is
52
+ // a plain-http address on your own network.
53
+ return `${KEY_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`;
54
+ }
55
+ /**
56
+ * Interfaces that exist for containers and virtual machines. An address on one
57
+ * of these reaches a bridge, not the phone on the sofa, and listing six of them
58
+ * buries the one line someone actually needed.
59
+ */
60
+ const VIRTUAL = /^(docker|br-|veth|virbr|vmnet|vboxnet|lo)/;
61
+ /** Where an address actually goes, which is not always where you would like. */
62
+ export function classify(address) {
63
+ const [a, b] = address.split(".").map(Number);
64
+ if (a === 10)
65
+ return "private";
66
+ if (a === 192 && b === 168)
67
+ return "private";
68
+ if (a === 172 && b >= 16 && b <= 31)
69
+ return "private";
70
+ if (a === 169 && b === 254)
71
+ return "private";
72
+ // 100.64/10 is carrier-grade NAT, which in practice means Tailscale here.
73
+ if (a === 100 && b >= 64 && b <= 127)
74
+ return "cgnat";
75
+ return "public";
76
+ }
77
+ /**
78
+ * The addresses another device could actually reach this machine on, nearest
79
+ * first. On a server the public one is the point: it is the address a phone
80
+ * somewhere else can open. It is labelled for what it is, because the key in
81
+ * the link is then the only thing between a stranger and the library.
82
+ */
83
+ export function reachableAddresses(host, port) {
84
+ const link = (address) => {
85
+ // A bare IPv6 address needs brackets before it is a URL.
86
+ const authority = address.includes(":") ? `[${address}]` : address;
87
+ return `http://${authority}:${port}`;
88
+ };
89
+ if (host !== "0.0.0.0" && host !== "::")
90
+ return [{ label: "here", url: link(host) }];
91
+ const LABELS = { private: "on your network", cgnat: "on tailscale", public: "on the internet" };
92
+ const found = [];
93
+ for (const [name, entries] of Object.entries(networkInterfaces())) {
94
+ if (VIRTUAL.test(name))
95
+ continue;
96
+ for (const entry of entries ?? []) {
97
+ // Link-local v6 needs a scope id to be usable, and nobody types those in.
98
+ if (entry.internal || entry.family !== "IPv4")
99
+ continue;
100
+ const kind = classify(entry.address);
101
+ found.push({ label: LABELS[kind], url: link(entry.address), kind });
102
+ }
103
+ }
104
+ const order = { private: 0, cgnat: 1, public: 2 };
105
+ found.sort((x, y) => order[x.kind] - order[y.kind]);
106
+ return [
107
+ { label: "here", url: `http://localhost:${port}` },
108
+ ...found.map(({ label, url }) => ({ label, url })),
109
+ ];
110
+ }
111
+ /** The full link, key and all. */
112
+ export function shareLink(base, key) {
113
+ return key === null ? base : `${base}/s/${key}`;
114
+ }
115
+ /**
116
+ * What a key is allowed to do. An unknown key is allowed nothing, which is the
117
+ * same answer as no key at all.
118
+ */
119
+ export function scopeOf(offered, control, listen) {
120
+ if (offered === null)
121
+ return null;
122
+ if (control !== null && keysMatch(offered, control))
123
+ return "control";
124
+ if (listen !== null && keysMatch(offered, listen))
125
+ return "listen";
126
+ return null;
127
+ }
128
+ /** Paths a listen key may have. Everything else needs the control key. */
129
+ export function allowedForListening(path) {
130
+ if (path === "/api/command" || path === "/api/source")
131
+ return false;
132
+ return true;
133
+ }
134
+ /**
135
+ * Whether a firewall is running that would keep the port closed to other
136
+ * devices. Listening on 0.0.0.0 proves the socket is open on this machine and
137
+ * nothing more, so this is the difference between "it works" and "it works
138
+ * here".
139
+ */
140
+ export function firewallInUse(io) {
141
+ if (process.platform !== "linux")
142
+ return null;
143
+ // ufw keeps its state in a file, so asking needs no privileges.
144
+ const ufw = io.read("/etc/ufw/ufw.conf");
145
+ if (ufw && /^ENABLED=yes/im.test(ufw))
146
+ return "ufw";
147
+ const firewalld = io.run("systemctl", ["is-active", "firewalld"]);
148
+ if (firewalld.status === 0 && firewalld.stdout.trim() === "active")
149
+ return "firewalld";
150
+ return null;
151
+ }
152
+ /** The commands that open and close a port, for each firewall we know. */
153
+ export function portCommands(firewall, port) {
154
+ return firewall === "ufw"
155
+ ? { open: ["ufw", "allow", `${port}/tcp`], close: ["ufw", "delete", "allow", `${port}/tcp`] }
156
+ : {
157
+ open: ["firewall-cmd", `--add-port=${port}/tcp`],
158
+ close: ["firewall-cmd", `--remove-port=${port}/tcp`],
159
+ };
160
+ }
161
+ /**
162
+ * Root runs it directly; anyone else goes through sudo, and only when sudo
163
+ * will not stop to ask. A server that hangs on an invisible password prompt is
164
+ * worse than one that tells you the command to run yourself.
165
+ */
166
+ export function elevate(io, command) {
167
+ const [head, ...rest] = command;
168
+ if (typeof process.getuid === "function" && process.getuid() === 0)
169
+ return [head, ...rest];
170
+ const canSudo = io.run("sudo", ["-n", "true"]);
171
+ return canSudo.status === 0 ? ["sudo", "-n", head, ...rest] : null;
172
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Where a playlist comes from.
3
+ *
4
+ * A directory, a file, an .m3u, or a URL to any of those. ffmpeg reads a URL as
5
+ * happily as a path, so a remote track needs no special case once it is in the
6
+ * list; what needs care is telling the four apart, and telling an .m3u that
7
+ * lists tracks from an HLS playlist that *is* one track.
8
+ */
9
+ /** http and https only. ffmpeg speaks more, but these are what a link is. */
10
+ export declare function isRemote(source: string): boolean;
11
+ export declare function isPlaylistFile(source: string): boolean;
12
+ /**
13
+ * An HLS playlist describes one stream in segments; an .m3u describes a list of
14
+ * things to play. Both are "m3u8" on disk, and the tags are the only honest way
15
+ * to tell them apart. Expanding an HLS playlist into a track per segment would
16
+ * turn one song into four hundred.
17
+ */
18
+ export declare function isHls(text: string): boolean;
19
+ export interface Entry {
20
+ /** A path or a URL, whichever the playlist gave us. */
21
+ source: string;
22
+ title: string;
23
+ /** Seconds, from #EXTINF. Zero when it did not say, and for live. */
24
+ duration: number;
25
+ }
26
+ /** Resolve a playlist line against the playlist's own location. */
27
+ export declare function resolveEntry(base: string, entry: string): string;
28
+ /**
29
+ * Parse an .m3u or .m3u8. `#EXTINF:<seconds>,<title>` decorates the line after
30
+ * it; everything else beginning with # is a comment or a tag we do not need.
31
+ */
32
+ export declare function parseM3u(text: string, base: string): Entry[];
33
+ /** A .pls, which Shoutcast and Icecast hand out as often as an .m3u. */
34
+ export declare function parsePls(text: string, base: string): Entry[];
35
+ /** The last useful part of a path or URL, for when nothing named the track. */
36
+ export declare function nameOf(source: string): string;
37
+ export declare function playsInBrowser(source: string): boolean;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Where a playlist comes from.
3
+ *
4
+ * A directory, a file, an .m3u, or a URL to any of those. ffmpeg reads a URL as
5
+ * happily as a path, so a remote track needs no special case once it is in the
6
+ * list; what needs care is telling the four apart, and telling an .m3u that
7
+ * lists tracks from an HLS playlist that *is* one track.
8
+ */
9
+ /** http and https only. ffmpeg speaks more, but these are what a link is. */
10
+ export function isRemote(source) {
11
+ return /^https?:\/\//i.test(source);
12
+ }
13
+ export function isPlaylistFile(source) {
14
+ const path = isRemote(source) ? new URL(source).pathname : source;
15
+ return /\.(m3u|m3u8|pls)$/i.test(path);
16
+ }
17
+ /**
18
+ * An HLS playlist describes one stream in segments; an .m3u describes a list of
19
+ * things to play. Both are "m3u8" on disk, and the tags are the only honest way
20
+ * to tell them apart. Expanding an HLS playlist into a track per segment would
21
+ * turn one song into four hundred.
22
+ */
23
+ export function isHls(text) {
24
+ return /^#EXT-X-(?:STREAM-INF|TARGETDURATION|MEDIA-SEQUENCE|PLAYLIST-TYPE|ENDLIST)/im.test(text);
25
+ }
26
+ /** Resolve a playlist line against the playlist's own location. */
27
+ export function resolveEntry(base, entry) {
28
+ if (isRemote(entry))
29
+ return entry;
30
+ if (isRemote(base))
31
+ return new URL(entry, base).toString();
32
+ if (entry.startsWith("/"))
33
+ return entry;
34
+ const dir = base.slice(0, Math.max(0, base.lastIndexOf("/")));
35
+ return dir ? `${dir}/${entry}` : entry;
36
+ }
37
+ /**
38
+ * Parse an .m3u or .m3u8. `#EXTINF:<seconds>,<title>` decorates the line after
39
+ * it; everything else beginning with # is a comment or a tag we do not need.
40
+ */
41
+ export function parseM3u(text, base) {
42
+ const out = [];
43
+ let duration = 0;
44
+ let title = "";
45
+ for (const raw of text.split(/\r?\n/)) {
46
+ const line = raw.trim();
47
+ if (line === "")
48
+ continue;
49
+ if (line.startsWith("#")) {
50
+ const info = /^#EXTINF:\s*(-?[\d.]+)\s*(?:,(.*))?$/i.exec(line);
51
+ if (info) {
52
+ const seconds = Number(info[1]);
53
+ // -1 is the conventional "unknown", which is also what live means.
54
+ duration = Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
55
+ title = (info[2] ?? "").trim();
56
+ }
57
+ continue;
58
+ }
59
+ const source = resolveEntry(base, line);
60
+ out.push({ source, duration, title: title || nameOf(source) });
61
+ duration = 0;
62
+ title = "";
63
+ }
64
+ return out;
65
+ }
66
+ /** A .pls, which Shoutcast and Icecast hand out as often as an .m3u. */
67
+ export function parsePls(text, base) {
68
+ const files = new Map();
69
+ const titles = new Map();
70
+ const lengths = new Map();
71
+ for (const raw of text.split(/\r?\n/)) {
72
+ const line = raw.trim();
73
+ const match = /^(File|Title|Length)(\d+)\s*=\s*(.*)$/i.exec(line);
74
+ if (!match)
75
+ continue;
76
+ const [, kind, index, value] = match;
77
+ if (/^file$/i.test(kind))
78
+ files.set(index, value);
79
+ else if (/^title$/i.test(kind))
80
+ titles.set(index, value);
81
+ else
82
+ lengths.set(index, Number(value));
83
+ }
84
+ return [...files.entries()]
85
+ .sort((a, b) => Number(a[0]) - Number(b[0]))
86
+ .map(([index, file]) => {
87
+ const source = resolveEntry(base, file);
88
+ const seconds = lengths.get(index) ?? 0;
89
+ return {
90
+ source,
91
+ title: titles.get(index)?.trim() || nameOf(source),
92
+ duration: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
93
+ };
94
+ });
95
+ }
96
+ /** The last useful part of a path or URL, for when nothing named the track. */
97
+ export function nameOf(source) {
98
+ const remote = isRemote(source);
99
+ const path = remote ? new URL(source).pathname : source;
100
+ const last = path.split("/").filter(Boolean).pop() ?? source;
101
+ // Percent-decoding is a URL's business. A file on disk called
102
+ // `Some%20Song.mp3` is called exactly that, and renaming it in the display
103
+ // would be a lie about what is in the directory.
104
+ if (!remote)
105
+ return last || source;
106
+ try {
107
+ return decodeURIComponent(last) || source;
108
+ }
109
+ catch {
110
+ return last || source;
111
+ }
112
+ }
113
+ /**
114
+ * Formats a browser will play as-is. Anything else gets transcoded on the way
115
+ * out, which is the difference between a library that plays on a phone and one
116
+ * that plays on the machine it lives on.
117
+ */
118
+ const WEB_READY = new Set([".mp3", ".m4a", ".aac", ".ogg", ".oga", ".opus", ".webm", ".mp4", ".wav"]);
119
+ export function playsInBrowser(source) {
120
+ if (isRemote(source))
121
+ return false;
122
+ const path = source.toLowerCase();
123
+ const dot = path.lastIndexOf(".");
124
+ return dot > 0 && WEB_READY.has(path.slice(dot));
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,7 +39,10 @@
39
39
  "pack:cli": "bun scripts/pack-cli.ts"
40
40
  },
41
41
  "dependencies": {
42
- "@profullstack/hqtui": "^0.3.0"
42
+ "@profullstack/auth-system": "^0.6.0",
43
+ "@profullstack/hqtui": "^0.3.0",
44
+ "@profullstack/x402-gateway": "^0.4.0",
45
+ "pg": "^8.23.0"
43
46
  },
44
47
  "devDependencies": {
45
48
  "@types/node": "^26",