nixamp 0.6.4 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/handles.d.ts +58 -0
- package/dist/handles.js +117 -0
- package/dist/main.js +6 -0
- package/dist/opendirs.d.ts +43 -0
- package/dist/opendirs.js +133 -0
- package/dist/playlist.d.ts +13 -0
- package/dist/playlist.js +75 -3
- package/dist/server.d.ts +6 -0
- package/dist/server.js +126 -2
- package/dist/session.d.ts +8 -0
- package/dist/session.js +86 -0
- package/package.json +1 -1
- package/src/handles.ts +141 -0
- package/src/main.ts +6 -0
- package/src/opendirs.ts +158 -0
- package/src/playlist.ts +70 -3
- package/src/server.ts +137 -2
- package/src/session.ts +100 -0
- package/web/dist/index.html +1 -0
- package/web/dist/sw.js +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The name other people see.
|
|
3
|
+
*
|
|
4
|
+
* An account is keyed on an email address, because that is what the auth module
|
|
5
|
+
* authenticates. An address is a credential and a way to reach somebody, and it
|
|
6
|
+
* is not a name: putting it in a directory listing, an invite or a subdomain
|
|
7
|
+
* publishes something the account holder gave us to log in with.
|
|
8
|
+
*
|
|
9
|
+
* So there are two names. The address stays private and does the linking, and a
|
|
10
|
+
* handle is the one that appears in front of strangers. They are never the same
|
|
11
|
+
* field and never travel in the same response.
|
|
12
|
+
*/
|
|
13
|
+
import type { Queryable } from "./follows.ts";
|
|
14
|
+
/**
|
|
15
|
+
* What a handle may be.
|
|
16
|
+
*
|
|
17
|
+
* It ends up in a URL, a subdomain and a text message, so it is the intersection
|
|
18
|
+
* of what all three tolerate: lowercase letters, digits and hyphens, not
|
|
19
|
+
* starting or ending with one. Two to thirty characters, because a subdomain
|
|
20
|
+
* label cannot exceed sixty-three and nobody types thirty.
|
|
21
|
+
*/
|
|
22
|
+
export declare function cleanHandle(value: unknown): string;
|
|
23
|
+
export declare function isReserved(handle: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* A handle for somebody who has not chosen one.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately not derived from the address. "anthony@profullstack.com" turning
|
|
28
|
+
* into "anthony" is exactly the leak this whole file exists to avoid, and it
|
|
29
|
+
* would be a leak nobody noticed until it was already in a directory listing.
|
|
30
|
+
*/
|
|
31
|
+
export declare function anonymousHandle(random: (size: number) => Uint8Array): string;
|
|
32
|
+
export declare class Handles {
|
|
33
|
+
private readonly db;
|
|
34
|
+
private ready;
|
|
35
|
+
constructor(db: Queryable);
|
|
36
|
+
private ensure;
|
|
37
|
+
of(userId: string): Promise<string>;
|
|
38
|
+
/**
|
|
39
|
+
* Handles for a page of rows, in one query.
|
|
40
|
+
*
|
|
41
|
+
* A public listing names people, and naming them one query at a time is how a
|
|
42
|
+
* list of fifty becomes fifty round trips. Anyone without a handle is absent
|
|
43
|
+
* from the map rather than present as an empty string, so a caller decides
|
|
44
|
+
* what to show for somebody who never picked one.
|
|
45
|
+
*/
|
|
46
|
+
many(userIds: string[]): Promise<Map<string, string>>;
|
|
47
|
+
/** Who holds this handle, so a listing can name somebody without their address. */
|
|
48
|
+
holder(handle: string): Promise<string>;
|
|
49
|
+
/**
|
|
50
|
+
* Claim one. Answers the reason it could not be taken rather than a boolean,
|
|
51
|
+
* because "that is already somebody's" and "that is not a name" are different
|
|
52
|
+
* things to tell a person.
|
|
53
|
+
*/
|
|
54
|
+
claim(userId: string, wanted: unknown): Promise<{
|
|
55
|
+
handle: string;
|
|
56
|
+
error: string;
|
|
57
|
+
}>;
|
|
58
|
+
}
|
package/dist/handles.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const TABLE = "nixamp_handles";
|
|
2
|
+
const SCHEMA = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
4
|
+
user_id TEXT PRIMARY KEY,
|
|
5
|
+
handle TEXT NOT NULL,
|
|
6
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
7
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
8
|
+
);
|
|
9
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_lower ON ${TABLE} (lower(handle));
|
|
10
|
+
`;
|
|
11
|
+
/**
|
|
12
|
+
* What a handle may be.
|
|
13
|
+
*
|
|
14
|
+
* It ends up in a URL, a subdomain and a text message, so it is the intersection
|
|
15
|
+
* of what all three tolerate: lowercase letters, digits and hyphens, not
|
|
16
|
+
* starting or ending with one. Two to thirty characters, because a subdomain
|
|
17
|
+
* label cannot exceed sixty-three and nobody types thirty.
|
|
18
|
+
*/
|
|
19
|
+
export function cleanHandle(value) {
|
|
20
|
+
if (typeof value !== "string")
|
|
21
|
+
return "";
|
|
22
|
+
const wanted = value.trim().toLowerCase();
|
|
23
|
+
// Two characters minimum, thirty maximum: the trailing character is required,
|
|
24
|
+
// which is also what stops a handle ending in a hyphen.
|
|
25
|
+
if (!/^[a-z0-9][a-z0-9-]{0,28}[a-z0-9]$/.test(wanted))
|
|
26
|
+
return "";
|
|
27
|
+
// Doubled hyphens are how punycode marks an encoded label, so a handle with
|
|
28
|
+
// one in it can collide with an internationalised domain.
|
|
29
|
+
return wanted.includes("--") ? "" : wanted;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Names nobody may take, because a subdomain carrying one would impersonate the
|
|
33
|
+
* service or reach a machine we run.
|
|
34
|
+
*/
|
|
35
|
+
const RESERVED = new Set([
|
|
36
|
+
"www", "api", "admin", "root", "nixamp", "mail", "smtp", "imap", "ns1", "ns2",
|
|
37
|
+
"static", "cdn", "assets", "app", "dev", "staging", "test", "support", "help",
|
|
38
|
+
"status", "blog", "directory", "login", "signup", "account", "settings", "me",
|
|
39
|
+
]);
|
|
40
|
+
export function isReserved(handle) {
|
|
41
|
+
return RESERVED.has(handle);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A handle for somebody who has not chosen one.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately not derived from the address. "anthony@profullstack.com" turning
|
|
47
|
+
* into "anthony" is exactly the leak this whole file exists to avoid, and it
|
|
48
|
+
* would be a leak nobody noticed until it was already in a directory listing.
|
|
49
|
+
*/
|
|
50
|
+
export function anonymousHandle(random) {
|
|
51
|
+
const bytes = random(4);
|
|
52
|
+
let out = "";
|
|
53
|
+
for (const byte of bytes)
|
|
54
|
+
out += byte.toString(16).padStart(2, "0");
|
|
55
|
+
return `nixamp-${out}`;
|
|
56
|
+
}
|
|
57
|
+
export class Handles {
|
|
58
|
+
db;
|
|
59
|
+
ready = null;
|
|
60
|
+
constructor(db) {
|
|
61
|
+
this.db = db;
|
|
62
|
+
}
|
|
63
|
+
async ensure() {
|
|
64
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
65
|
+
await this.ready;
|
|
66
|
+
}
|
|
67
|
+
async of(userId) {
|
|
68
|
+
await this.ensure();
|
|
69
|
+
const { rows } = await this.db.query(`SELECT handle FROM ${TABLE} WHERE user_id = $1`, [userId]);
|
|
70
|
+
return rows[0] ? String(rows[0]["handle"] ?? "") : "";
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Handles for a page of rows, in one query.
|
|
74
|
+
*
|
|
75
|
+
* A public listing names people, and naming them one query at a time is how a
|
|
76
|
+
* list of fifty becomes fifty round trips. Anyone without a handle is absent
|
|
77
|
+
* from the map rather than present as an empty string, so a caller decides
|
|
78
|
+
* what to show for somebody who never picked one.
|
|
79
|
+
*/
|
|
80
|
+
async many(userIds) {
|
|
81
|
+
const wanted = [...new Set(userIds.filter(Boolean))];
|
|
82
|
+
if (wanted.length === 0)
|
|
83
|
+
return new Map();
|
|
84
|
+
await this.ensure();
|
|
85
|
+
const { rows } = await this.db.query(`SELECT user_id, handle FROM ${TABLE} WHERE user_id = ANY($1)`, [wanted]);
|
|
86
|
+
return new Map(rows.map((row) => [String(row["user_id"] ?? ""), String(row["handle"] ?? "")]));
|
|
87
|
+
}
|
|
88
|
+
/** Who holds this handle, so a listing can name somebody without their address. */
|
|
89
|
+
async holder(handle) {
|
|
90
|
+
const wanted = cleanHandle(handle);
|
|
91
|
+
if (!wanted)
|
|
92
|
+
return "";
|
|
93
|
+
await this.ensure();
|
|
94
|
+
const { rows } = await this.db.query(`SELECT user_id FROM ${TABLE} WHERE lower(handle) = $1`, [wanted]);
|
|
95
|
+
return rows[0] ? String(rows[0]["user_id"] ?? "") : "";
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Claim one. Answers the reason it could not be taken rather than a boolean,
|
|
99
|
+
* because "that is already somebody's" and "that is not a name" are different
|
|
100
|
+
* things to tell a person.
|
|
101
|
+
*/
|
|
102
|
+
async claim(userId, wanted) {
|
|
103
|
+
const handle = cleanHandle(wanted);
|
|
104
|
+
if (!handle) {
|
|
105
|
+
return { handle: "", error: "letters, digits and hyphens, 2 to 30 characters" };
|
|
106
|
+
}
|
|
107
|
+
if (isReserved(handle))
|
|
108
|
+
return { handle: "", error: "that one is reserved" };
|
|
109
|
+
await this.ensure();
|
|
110
|
+
const taken = await this.holder(handle);
|
|
111
|
+
if (taken && taken !== userId)
|
|
112
|
+
return { handle: "", error: "somebody already has that one" };
|
|
113
|
+
await this.db.query(`INSERT INTO ${TABLE} (user_id, handle) VALUES ($1, $2)
|
|
114
|
+
ON CONFLICT (user_id) DO UPDATE SET handle = EXCLUDED.handle, updated_at = NOW()`, [userId, handle]);
|
|
115
|
+
return { handle, error: "" };
|
|
116
|
+
}
|
|
117
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -52,6 +52,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
52
52
|
nixamp logout / whoami forget it, or check it
|
|
53
53
|
nixamp token create|list|revoke tokens for a machine that cannot sign in
|
|
54
54
|
nixamp server list|add|remove the machines you run, kept against your account
|
|
55
|
+
nixamp opendir list|add|remove folders found on the web, published for everyone
|
|
55
56
|
nixamp update [version] re-run the installer, keeping your choices
|
|
56
57
|
nixamp uninstall [--yes] remove everything the installer created
|
|
57
58
|
|
|
@@ -292,6 +293,11 @@ export async function main() {
|
|
|
292
293
|
process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
|
|
293
294
|
return;
|
|
294
295
|
}
|
|
296
|
+
if (first === "opendir" || first === "opendirs") {
|
|
297
|
+
const { opendirs } = await import("./session.js");
|
|
298
|
+
process.exitCode = await opendirs(rest);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
295
301
|
if (first === "server" || first === "servers") {
|
|
296
302
|
const { servers } = await import("./session.js");
|
|
297
303
|
process.exitCode = await servers(rest);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Queryable } from "./follows.ts";
|
|
2
|
+
export interface OpenDir {
|
|
3
|
+
id: string;
|
|
4
|
+
url: string;
|
|
5
|
+
name: string;
|
|
6
|
+
/** How many playable files were on the page when it was added. */
|
|
7
|
+
tracks: number;
|
|
8
|
+
/** The finder's public handle. Never their address. */
|
|
9
|
+
by: string;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The name to show for a folder nobody named.
|
|
14
|
+
*
|
|
15
|
+
* The last path segment, which for a folder of music is the album, written the
|
|
16
|
+
* way a person wrote it rather than the way a URL spells it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function nameOfDir(url: string): string;
|
|
19
|
+
export declare class OpenDirs {
|
|
20
|
+
private readonly db;
|
|
21
|
+
private ready;
|
|
22
|
+
constructor(db: Queryable);
|
|
23
|
+
private ensure;
|
|
24
|
+
/**
|
|
25
|
+
* The list, newest first, in pages.
|
|
26
|
+
*
|
|
27
|
+
* Keyset on the row's own creation time rather than an offset, because a list
|
|
28
|
+
* anybody may add to shifts under a reader who is paging through it, and an
|
|
29
|
+
* offset in a shifting list repeats and skips rows.
|
|
30
|
+
*/
|
|
31
|
+
list(limit?: number, before?: number): Promise<{
|
|
32
|
+
rows: Omit<OpenDir, "by">[];
|
|
33
|
+
addedBy: string[];
|
|
34
|
+
next: number;
|
|
35
|
+
}>;
|
|
36
|
+
/**
|
|
37
|
+
* Publish one. `tracks` is what the finder's server actually saw on the page,
|
|
38
|
+
* so a row nobody can play never reaches the list.
|
|
39
|
+
*/
|
|
40
|
+
add(userId: string, url: unknown, name: unknown, tracks: number): Promise<Omit<OpenDir, "by"> | null>;
|
|
41
|
+
/** Only the finder takes their own row down. */
|
|
42
|
+
remove(userId: string, id: string): Promise<boolean>;
|
|
43
|
+
}
|
package/dist/opendirs.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open directories somebody found, kept as a list anyone can read.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not the stream directory. That one lists what is playing right
|
|
5
|
+
* now: a heartbeat, a TTL, a room code, somebody at the other end. An open
|
|
6
|
+
* directory is the opposite in every one of those respects. It is always there,
|
|
7
|
+
* nobody is broadcasting it, and it is not the finder's to broadcast. Mixing
|
|
8
|
+
* the two would put "chovy is playing this, dial in" next to "here is a link
|
|
9
|
+
* to a stranger's file server" under one heading.
|
|
10
|
+
*
|
|
11
|
+
* Public to read and signed in to add, because a public list with nobody
|
|
12
|
+
* accountable for its rows is a public list of whatever anybody felt like
|
|
13
|
+
* putting there. What is shown against a row is the finder's handle, never the
|
|
14
|
+
* address they signed up with.
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { cleanUrl } from "./servers.js";
|
|
18
|
+
const TABLE = "nixamp_opendirs";
|
|
19
|
+
const SCHEMA = `
|
|
20
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
21
|
+
id TEXT PRIMARY KEY,
|
|
22
|
+
url TEXT NOT NULL,
|
|
23
|
+
name TEXT NOT NULL DEFAULT '',
|
|
24
|
+
added_by TEXT NOT NULL,
|
|
25
|
+
tracks INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
27
|
+
);
|
|
28
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_url ON ${TABLE} (url);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS ${TABLE}_added ON ${TABLE} (created_at DESC);
|
|
30
|
+
`;
|
|
31
|
+
/**
|
|
32
|
+
* The name to show for a folder nobody named.
|
|
33
|
+
*
|
|
34
|
+
* The last path segment, which for a folder of music is the album, written the
|
|
35
|
+
* way a person wrote it rather than the way a URL spells it.
|
|
36
|
+
*/
|
|
37
|
+
export function nameOfDir(url) {
|
|
38
|
+
try {
|
|
39
|
+
const parts = new URL(url).pathname.split("/").filter(Boolean);
|
|
40
|
+
const last = parts[parts.length - 1];
|
|
41
|
+
return last ? decodeURIComponent(last).slice(0, 120) : new URL(url).host;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return "an open directory";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function asTime(value) {
|
|
48
|
+
if (value instanceof Date)
|
|
49
|
+
return value.getTime();
|
|
50
|
+
if (typeof value === "string") {
|
|
51
|
+
const at = Date.parse(value);
|
|
52
|
+
return Number.isNaN(at) ? 0 : at;
|
|
53
|
+
}
|
|
54
|
+
return typeof value === "number" ? value : 0;
|
|
55
|
+
}
|
|
56
|
+
export class OpenDirs {
|
|
57
|
+
db;
|
|
58
|
+
ready = null;
|
|
59
|
+
constructor(db) {
|
|
60
|
+
this.db = db;
|
|
61
|
+
}
|
|
62
|
+
async ensure() {
|
|
63
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
64
|
+
await this.ready;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The list, newest first, in pages.
|
|
68
|
+
*
|
|
69
|
+
* Keyset on the row's own creation time rather than an offset, because a list
|
|
70
|
+
* anybody may add to shifts under a reader who is paging through it, and an
|
|
71
|
+
* offset in a shifting list repeats and skips rows.
|
|
72
|
+
*/
|
|
73
|
+
async list(limit = 50, before = 0) {
|
|
74
|
+
await this.ensure();
|
|
75
|
+
const size = Math.min(200, Math.max(1, limit));
|
|
76
|
+
const { rows } = await this.db.query(`SELECT id, url, name, added_by, tracks, created_at FROM ${TABLE}
|
|
77
|
+
${before > 0 ? "WHERE created_at < $2" : ""}
|
|
78
|
+
ORDER BY created_at DESC LIMIT $1`, before > 0 ? [size + 1, new Date(before).toISOString()] : [size + 1]);
|
|
79
|
+
// One more than asked for, so "is there another page" is a fact rather
|
|
80
|
+
// than a guess from a full one.
|
|
81
|
+
const page = rows.slice(0, size);
|
|
82
|
+
return {
|
|
83
|
+
rows: page.map((row) => ({
|
|
84
|
+
id: String(row["id"] ?? ""),
|
|
85
|
+
url: String(row["url"] ?? ""),
|
|
86
|
+
name: String(row["name"] ?? ""),
|
|
87
|
+
tracks: Number(row["tracks"] ?? 0),
|
|
88
|
+
createdAt: asTime(row["created_at"]),
|
|
89
|
+
})),
|
|
90
|
+
addedBy: page.map((row) => String(row["added_by"] ?? "")),
|
|
91
|
+
next: rows.length > size ? asTime(page[page.length - 1]?.["created_at"]) : 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Publish one. `tracks` is what the finder's server actually saw on the page,
|
|
96
|
+
* so a row nobody can play never reaches the list.
|
|
97
|
+
*/
|
|
98
|
+
async add(userId, url, name, tracks) {
|
|
99
|
+
const clean = typeof url === "string" ? url.trim() : "";
|
|
100
|
+
// Not cleanUrl's origin-only treatment: a folder is a path, and the path is
|
|
101
|
+
// the whole point of the row.
|
|
102
|
+
if (!/^https?:\/\//i.test(clean) || cleanUrl(clean) === "")
|
|
103
|
+
return null;
|
|
104
|
+
if (tracks <= 0)
|
|
105
|
+
return null;
|
|
106
|
+
await this.ensure();
|
|
107
|
+
const { rows } = await this.db.query(`INSERT INTO ${TABLE} (id, url, name, added_by, tracks) VALUES ($1, $2, $3, $4, $5)
|
|
108
|
+
ON CONFLICT (url) DO UPDATE SET name = EXCLUDED.name, tracks = EXCLUDED.tracks
|
|
109
|
+
RETURNING id, url, name, tracks, created_at`, [
|
|
110
|
+
randomBytes(8).toString("hex"),
|
|
111
|
+
clean,
|
|
112
|
+
(typeof name === "string" && name.trim() ? name.trim() : nameOfDir(clean)).slice(0, 120),
|
|
113
|
+
userId,
|
|
114
|
+
Math.min(100_000, tracks),
|
|
115
|
+
]);
|
|
116
|
+
const row = rows[0];
|
|
117
|
+
if (!row)
|
|
118
|
+
return null;
|
|
119
|
+
return {
|
|
120
|
+
id: String(row["id"] ?? ""),
|
|
121
|
+
url: String(row["url"] ?? ""),
|
|
122
|
+
name: String(row["name"] ?? ""),
|
|
123
|
+
tracks: Number(row["tracks"] ?? 0),
|
|
124
|
+
createdAt: asTime(row["created_at"]),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Only the finder takes their own row down. */
|
|
128
|
+
async remove(userId, id) {
|
|
129
|
+
await this.ensure();
|
|
130
|
+
const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1 AND added_by = $2 RETURNING id`, [id, userId]);
|
|
131
|
+
return rows.length > 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
package/dist/playlist.d.ts
CHANGED
|
@@ -10,6 +10,19 @@ export declare function findAudio(root: string): string[];
|
|
|
10
10
|
* ffmpeg is left to do what it is good at.
|
|
11
11
|
*/
|
|
12
12
|
export declare function readPlaylist(source: string): Promise<Entry[]>;
|
|
13
|
+
/**
|
|
14
|
+
* The audio linked from a directory listing a web server generated.
|
|
15
|
+
*
|
|
16
|
+
* A seedbox or a plain Apache with autoindex on serves a folder as an HTML page
|
|
17
|
+
* of relative links. Handed one of those, nixamp used to make a single track of
|
|
18
|
+
* the page itself and give it to ffmpeg, which is asked to decode HTML and says
|
|
19
|
+
* so in a way nobody reads. It is a folder; it should behave like one.
|
|
20
|
+
*
|
|
21
|
+
* Not recursive, deliberately: one page is one album, the subdirectory links are
|
|
22
|
+
* on it, and walking a stranger's whole tree from a text box is a different and
|
|
23
|
+
* much larger thing to ask for.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readRemoteIndex(source: string, send?: typeof fetch): Promise<Entry[]>;
|
|
13
26
|
/**
|
|
14
27
|
* Everything `nixamp <thing>` can be handed: a directory, a file, a playlist,
|
|
15
28
|
* or a URL to any of those.
|
package/dist/playlist.js
CHANGED
|
@@ -81,6 +81,69 @@ export async function readPlaylist(source) {
|
|
|
81
81
|
const entries = /\.pls$/i.test(source) ? parsePls(text, source) : parseM3u(text, source);
|
|
82
82
|
return entries;
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The audio linked from a directory listing a web server generated.
|
|
86
|
+
*
|
|
87
|
+
* A seedbox or a plain Apache with autoindex on serves a folder as an HTML page
|
|
88
|
+
* of relative links. Handed one of those, nixamp used to make a single track of
|
|
89
|
+
* the page itself and give it to ffmpeg, which is asked to decode HTML and says
|
|
90
|
+
* so in a way nobody reads. It is a folder; it should behave like one.
|
|
91
|
+
*
|
|
92
|
+
* Not recursive, deliberately: one page is one album, the subdirectory links are
|
|
93
|
+
* on it, and walking a stranger's whole tree from a text box is a different and
|
|
94
|
+
* much larger thing to ask for.
|
|
95
|
+
*/
|
|
96
|
+
export async function readRemoteIndex(source, send = fetch) {
|
|
97
|
+
let answer;
|
|
98
|
+
try {
|
|
99
|
+
answer = await send(source, {
|
|
100
|
+
redirect: "follow",
|
|
101
|
+
headers: { accept: "text/html,*/*" },
|
|
102
|
+
signal: AbortSignal.timeout(15_000),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
if (!answer.ok)
|
|
109
|
+
return [];
|
|
110
|
+
// Anything that is not a page is the thing itself, and the caller plays it.
|
|
111
|
+
if (!/^text\/html/i.test(answer.headers.get("content-type") ?? ""))
|
|
112
|
+
return [];
|
|
113
|
+
const html = await answer.text().catch(() => "");
|
|
114
|
+
const found = [];
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
for (const match of html.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
|
|
117
|
+
const href = match[1];
|
|
118
|
+
// The sort links an index puts at the top of every column, and anchors.
|
|
119
|
+
if (!href || href.startsWith("?") || href.startsWith("#"))
|
|
120
|
+
continue;
|
|
121
|
+
let url;
|
|
122
|
+
try {
|
|
123
|
+
// Relative to the page, which is how an index writes them.
|
|
124
|
+
url = new URL(href.replace(/&/g, "&"), source);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!isAudio(url.pathname))
|
|
130
|
+
continue;
|
|
131
|
+
const link = url.toString();
|
|
132
|
+
if (seen.has(link))
|
|
133
|
+
continue;
|
|
134
|
+
seen.add(link);
|
|
135
|
+
found.push({
|
|
136
|
+
source: link,
|
|
137
|
+
// The name as a person wrote it, not as a URL spells it.
|
|
138
|
+
title: decodeURIComponent(url.pathname.split("/").pop() ?? link),
|
|
139
|
+
duration: 0,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// Server order is by whatever column the index sorted on; by name is what
|
|
143
|
+
// somebody handing over an album meant.
|
|
144
|
+
found.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }));
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
84
147
|
/**
|
|
85
148
|
* Everything `nixamp <thing>` can be handed: a directory, a file, a playlist,
|
|
86
149
|
* or a URL to any of those.
|
|
@@ -104,10 +167,19 @@ export async function loadSource(tools, source, probeTags = true) {
|
|
|
104
167
|
? { ...probe(tools, entry.source), title: entry.title || probe(tools, entry.source).title }
|
|
105
168
|
: bare(entry));
|
|
106
169
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
170
|
+
if (isRemote(source)) {
|
|
171
|
+
// A URL that names no file is probably a folder, and a folder served over
|
|
172
|
+
// http is a page of links. Asked only when it could be one: a stream URL
|
|
173
|
+
// must not pay for a fetch that will tell us nothing.
|
|
174
|
+
if (!isAudio(new URL(source).pathname)) {
|
|
175
|
+
const listed = await readRemoteIndex(source);
|
|
176
|
+
if (listed.length > 0)
|
|
177
|
+
return listed.map(bare);
|
|
178
|
+
}
|
|
179
|
+
// A bare URL is one remote thing to play. Whether it is a song or a live
|
|
180
|
+
// stream is ffmpeg's problem, and it is good at it.
|
|
110
181
|
return [bare({ source, title: nameOf(source), duration: 0 })];
|
|
182
|
+
}
|
|
111
183
|
return loadPlaylist(tools, source, probeTags);
|
|
112
184
|
}
|
|
113
185
|
/**
|
package/dist/server.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast
|
|
|
4
4
|
import { Ingest } from "./ingest.ts";
|
|
5
5
|
import { Channels } from "./channels.ts";
|
|
6
6
|
import { Accounts } from "./accounts.ts";
|
|
7
|
+
import { Handles } from "./handles.ts";
|
|
8
|
+
import { OpenDirs } from "./opendirs.ts";
|
|
7
9
|
import { Servers } from "./servers.ts";
|
|
8
10
|
import { SignIn } from "./oauth.ts";
|
|
9
11
|
import { Owner } from "./owner.ts";
|
|
@@ -269,6 +271,10 @@ export interface HandlerOptions {
|
|
|
269
271
|
signIn?: SignIn;
|
|
270
272
|
/** The servers each account runs, on the instance that keeps accounts. */
|
|
271
273
|
servers?: Servers;
|
|
274
|
+
/** The name other people see, which is never the address they signed up with. */
|
|
275
|
+
handles?: Handles;
|
|
276
|
+
/** Open directories people have found, which anyone may read. */
|
|
277
|
+
openDirs?: OpenDirs;
|
|
272
278
|
/** True when this instance is reached over https, for the cookie's Secure. */
|
|
273
279
|
secureCookies?: boolean;
|
|
274
280
|
/** A certificate and key in PEM, when this server is to speak https itself. */
|