nixamp 0.33.0 → 0.35.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.
- package/dist/handoff-routes.d.ts +17 -0
- package/dist/handoff-routes.js +64 -0
- package/dist/handoff.d.ts +89 -0
- package/dist/handoff.js +358 -0
- package/dist/library-index.d.ts +9 -0
- package/dist/library-index.js +19 -0
- package/dist/oauth-server.d.ts +1 -0
- package/dist/oauth-server.js +1 -0
- package/dist/owner.js +4 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +63 -4
- package/dist/station-metering.d.ts +59 -0
- package/dist/station-metering.js +189 -0
- package/dist/station-routes.d.ts +8 -0
- package/dist/station-routes.js +60 -1
- package/dist/station-runner.d.ts +7 -0
- package/dist/station-runner.js +12 -0
- package/package.json +1 -1
- package/src/handoff-routes.ts +73 -0
- package/src/handoff.ts +383 -0
- package/src/library-index.ts +21 -0
- package/src/oauth-server.ts +1 -0
- package/src/owner.ts +5 -2
- package/src/server.ts +67 -5
- package/src/station-metering.ts +218 -0
- package/src/station-routes.ts +64 -1
- package/src/station-runner.ts +15 -0
- package/web/dist/assets/{ar-4kD-mTIA.js → ar-Doh06ZMu.js} +1 -1
- package/web/dist/assets/{cs-CziXiWNY.js → cs-C_lZl4jI.js} +1 -1
- package/web/dist/assets/{da-CyjJ8UOr.js → da-CSBBA_ZI.js} +1 -1
- package/web/dist/assets/{de-DxwYa667.js → de-zorW0tO_.js} +1 -1
- package/web/dist/assets/{es-QgMTJeSc.js → es-DBUl3DGo.js} +1 -1
- package/web/dist/assets/{fi-Clj-jHhR.js → fi-DpUx7XY-.js} +1 -1
- package/web/dist/assets/{fr-nH_gzSFv.js → fr-Cg_xppCf.js} +1 -1
- package/web/dist/assets/{hi-D7djQ6_C.js → hi-PD1kdcf2.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-BaWVouMg.js → hls-3VKVEQE3-BEEk729h.js} +1 -1
- package/web/dist/assets/{hu-H5Q3L8bD.js → hu-C1fGbjZO.js} +1 -1
- package/web/dist/assets/{id-40z_EQdf.js → id-DRBhZ8KM.js} +1 -1
- package/web/dist/assets/{it-C9Fcgywl.js → it-Ci3OZV2-.js} +1 -1
- package/web/dist/assets/{main-BJUyBy0h.js → main-CEJhJH5y.js} +2 -2
- package/web/dist/assets/{mpegts-LO6RVLD6-CAuth8C5.js → mpegts-LO6RVLD6-DPrv1ran.js} +1 -1
- package/web/dist/assets/{mpegts-BlmyNlBC.js → mpegts-y-HAxt1b.js} +1 -1
- package/web/dist/assets/{nl-CrL4Psk9.js → nl-CwWOHKbv.js} +1 -1
- package/web/dist/assets/radio-BpH1qMMQ.js +1 -0
- package/web/dist/assets/{ru-C84JS0ch.js → ru-BWlIGsoL.js} +1 -1
- package/web/dist/assets/{sv-DtMcl0sf.js → sv-Gb1cLNsc.js} +1 -1
- package/web/dist/assets/{uk-dOLf99vZ.js → uk-MZUnyyuR.js} +1 -1
- package/web/dist/assets/{vi-BWeQVRZ8.js → vi-DNWS5fxc.js} +1 -1
- package/web/dist/assets/{zh-Cnv3Qpux.js → zh-BcOiladF.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +1 -1
- package/web/dist/sw.js +23 -23
- package/web/dist/assets/radio-B58WgC1j.js +0 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/v1/integrations/bittorrented/handoffs: where BitTorrented hands music
|
|
3
|
+
* over. Only this server's owner -- the control key, the owner's session, or
|
|
4
|
+
* an app the owner gave the `stations` scope -- may hand anything over, list
|
|
5
|
+
* handoffs, retry one or delete its copies.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
8
|
+
import { publicHandoff, type Importer } from "./handoff.ts";
|
|
9
|
+
import { LibraryError } from "./library-index.ts";
|
|
10
|
+
|
|
11
|
+
export interface HandoffContext {
|
|
12
|
+
importer: Importer;
|
|
13
|
+
canWrite: boolean;
|
|
14
|
+
/** Whose handoffs: the owner's account, or "owner" for the control key. */
|
|
15
|
+
owner: string;
|
|
16
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
17
|
+
readBody: (request: IncomingMessage) => Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BASE = "/api/v1/integrations/bittorrented/handoffs";
|
|
21
|
+
|
|
22
|
+
export async function handleHandoffs(request: IncomingMessage, response: ServerResponse, path: string, ctx: HandoffContext): Promise<boolean> {
|
|
23
|
+
if (path !== BASE && !path.startsWith(`${BASE}/`)) return false;
|
|
24
|
+
const method = request.method ?? "GET";
|
|
25
|
+
const fail = (status: number, code: string, error: string): true => {
|
|
26
|
+
ctx.json(response, status, { error, code });
|
|
27
|
+
return true;
|
|
28
|
+
};
|
|
29
|
+
if (!ctx.canWrite) return fail(403, "forbidden", "only this server's owner (or an app they gave the stations scope) hands music over");
|
|
30
|
+
const body = async (): Promise<Record<string, unknown>> => {
|
|
31
|
+
const raw = await ctx.readBody(request);
|
|
32
|
+
if (raw.trim() === "") return {};
|
|
33
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
34
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new LibraryError("bad_json", "the body is not a JSON object");
|
|
35
|
+
return parsed as Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
if (path === BASE) {
|
|
39
|
+
if (method === "GET") {
|
|
40
|
+
ctx.json(response, 200, { handoffs: ctx.importer.list(ctx.owner).map(publicHandoff) });
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (method === "POST") {
|
|
44
|
+
const { handoff, created } = ctx.importer.create(await body(), ctx.owner);
|
|
45
|
+
ctx.json(response, created ? 201 : 200, { handoff: publicHandoff(handoff), created });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return fail(405, "method_not_allowed", "GET or POST");
|
|
49
|
+
}
|
|
50
|
+
const match = /^\/api\/v1\/integrations\/bittorrented\/handoffs\/([0-9a-f]{16})(?:\/(retry))?$/.exec(path);
|
|
51
|
+
const handoff = match ? ctx.importer.get(match[1]!) : undefined;
|
|
52
|
+
if (!match || !handoff || handoff.owner !== ctx.owner) return fail(404, "not_found", "no such handoff");
|
|
53
|
+
if (match[2] === "retry") {
|
|
54
|
+
if (method !== "POST") return fail(405, "method_not_allowed", "POST");
|
|
55
|
+
ctx.json(response, 200, { handoff: publicHandoff(ctx.importer.retry(handoff.id, await body())) });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (method === "GET") {
|
|
59
|
+
ctx.json(response, 200, { handoff: publicHandoff(handoff) });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (method === "DELETE") {
|
|
63
|
+
const removed = ctx.importer.remove(handoff.id);
|
|
64
|
+
ctx.json(response, 200, { deleted: handoff.id, copiesRemoved: removed });
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return fail(405, "method_not_allowed", "GET or DELETE");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof LibraryError) return fail(error.status, error.code, error.message);
|
|
70
|
+
if (error instanceof SyntaxError) return fail(400, "bad_json", "the body is not JSON");
|
|
71
|
+
return fail(500, "internal", error instanceof Error ? error.message : String(error));
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/handoff.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Music handed over from BitTorrented: copied here, checked, indexed, and
|
|
3
|
+
* made into a private station draft.
|
|
4
|
+
*
|
|
5
|
+
* BitTorrented keeps its own collections and downloads; Nixamp keeps what it
|
|
6
|
+
* was handed. A handoff names the files a person chose, each with a link that
|
|
7
|
+
* opens that one file for a short while and nothing else. Nixamp copies each
|
|
8
|
+
* file into its own imports folder, so a station plays from a copy on this
|
|
9
|
+
* machine and does not depend on anybody still seeding it. A file is "ready"
|
|
10
|
+
* only once the copy has the size it was said to have and ffprobe can read
|
|
11
|
+
* it; until then it is pending, and a station never plays it.
|
|
12
|
+
*
|
|
13
|
+
* Asking twice with the same idempotency key is one handoff and one station.
|
|
14
|
+
* Removing the item in BitTorrented does not touch the copy here; deleting the
|
|
15
|
+
* handoff here removes the copies it made (and only those), which is the one
|
|
16
|
+
* place a Nixamp action deletes files, so it is said wherever it is offered.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
19
|
+
import { createWriteStream, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, extname, join } from "node:path";
|
|
21
|
+
import { Readable } from "node:stream";
|
|
22
|
+
import { pipeline } from "node:stream/promises";
|
|
23
|
+
import { LibraryError, type LibraryIndex, type Probed } from "./library-index.ts";
|
|
24
|
+
import { playable } from "./playlist.ts";
|
|
25
|
+
import type { StationStore } from "./stations.ts";
|
|
26
|
+
|
|
27
|
+
export type FileState = "pending" | "downloading" | "ready" | "failed" | "expired" | "unsupported";
|
|
28
|
+
|
|
29
|
+
export interface HandoffFile {
|
|
30
|
+
fileId: string;
|
|
31
|
+
name: string;
|
|
32
|
+
size: number;
|
|
33
|
+
mediaType: string;
|
|
34
|
+
/** Whether BitTorrented said the download was complete when it handed it over. */
|
|
35
|
+
complete: boolean;
|
|
36
|
+
state: FileState;
|
|
37
|
+
error: string;
|
|
38
|
+
attempts: number;
|
|
39
|
+
/** Where the copy is (or will be). Shown to the owner only. */
|
|
40
|
+
path: string;
|
|
41
|
+
bytes: number;
|
|
42
|
+
sha256: string;
|
|
43
|
+
/** The grant; kept until the copy is made, never shown. */
|
|
44
|
+
url: string;
|
|
45
|
+
expiresAt: number;
|
|
46
|
+
tags: { title?: string; artist?: string; album?: string; track?: number };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Handoff {
|
|
50
|
+
id: string;
|
|
51
|
+
version: 1;
|
|
52
|
+
provider: "bittorrented";
|
|
53
|
+
idempotencyKey: string;
|
|
54
|
+
owner: string;
|
|
55
|
+
name: string;
|
|
56
|
+
source: { torrentId: string; infohash: string; collectionId: string };
|
|
57
|
+
stationId: string;
|
|
58
|
+
files: HandoffFile[];
|
|
59
|
+
createdAt: number;
|
|
60
|
+
updatedAt: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const MAX_FILES = 500;
|
|
64
|
+
export const MAX_FILE_BYTES = 4 * 1024 ** 3;
|
|
65
|
+
const MAX_ATTEMPTS = 3;
|
|
66
|
+
const MAX_REDIRECTS = 3;
|
|
67
|
+
|
|
68
|
+
export interface ImporterDeps {
|
|
69
|
+
index: LibraryIndex;
|
|
70
|
+
stations: StationStore;
|
|
71
|
+
/** The folder copies go into: a managed library source. */
|
|
72
|
+
root: string;
|
|
73
|
+
probe: (path: string) => Promise<Probed>;
|
|
74
|
+
/** "" when this address may be fetched, else why not (destinations.ts). */
|
|
75
|
+
guard: (url: string) => Promise<string>;
|
|
76
|
+
fetcher?: typeof fetch;
|
|
77
|
+
/** Plain http, for tests on loopback only. */
|
|
78
|
+
allowHttp?: boolean;
|
|
79
|
+
now?: () => number;
|
|
80
|
+
log?: (line: string) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeName(name: string, at: number): string {
|
|
84
|
+
const base = name.split(/[\\/]/).pop() ?? "";
|
|
85
|
+
const cleaned = base.replace(/[\u0000-\u001f\u007f<>:"|?*]/g, "_").replace(/^\.+/, "").trim().slice(0, 180);
|
|
86
|
+
return `${String(at + 1).padStart(3, "0")} ${cleaned || "file"}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function publicHandoff(handoff: Handoff): Record<string, unknown> {
|
|
90
|
+
const counts: Record<string, number> = {};
|
|
91
|
+
for (const file of handoff.files) counts[file.state] = (counts[file.state] ?? 0) + 1;
|
|
92
|
+
return {
|
|
93
|
+
id: handoff.id,
|
|
94
|
+
version: handoff.version,
|
|
95
|
+
provider: handoff.provider,
|
|
96
|
+
idempotencyKey: handoff.idempotencyKey,
|
|
97
|
+
name: handoff.name,
|
|
98
|
+
source: handoff.source,
|
|
99
|
+
stationId: handoff.stationId,
|
|
100
|
+
status: counts["pending"] || counts["downloading"] ? "importing" : (counts["ready"] ?? 0) === handoff.files.length ? "ready" : counts["ready"] ? "partial" : "failed",
|
|
101
|
+
counts,
|
|
102
|
+
files: handoff.files.map(({ url: _url, path: _path, ...file }) => file),
|
|
103
|
+
createdAt: handoff.createdAt,
|
|
104
|
+
updatedAt: handoff.updatedAt,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class Importer {
|
|
109
|
+
private handoffs = new Map<string, Handoff>();
|
|
110
|
+
private running = new Set<string>();
|
|
111
|
+
private sourceId = "";
|
|
112
|
+
|
|
113
|
+
constructor(private readonly file: string, private readonly deps: ImporterDeps) {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as { handoffs?: Handoff[] };
|
|
116
|
+
for (const one of parsed.handoffs ?? []) if (one?.id) this.handoffs.set(one.id, one);
|
|
117
|
+
} catch {
|
|
118
|
+
// Nothing yet.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private now(): number {
|
|
123
|
+
return this.deps.now?.() ?? Date.now();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private save(): void {
|
|
127
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
128
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
129
|
+
writeFileSync(temporary, JSON.stringify({ version: 1, handoffs: [...this.handoffs.values()] }), { mode: 0o600 });
|
|
130
|
+
renameSync(temporary, this.file);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private source(): string {
|
|
134
|
+
if (this.sourceId && this.deps.index.source(this.sourceId)) return this.sourceId;
|
|
135
|
+
mkdirSync(this.deps.root, { recursive: true });
|
|
136
|
+
this.sourceId = this.deps.index.addManagedSource(this.deps.root).id;
|
|
137
|
+
return this.sourceId;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
list(owner: string | null): Handoff[] {
|
|
141
|
+
return [...this.handoffs.values()].filter((one) => owner === null || one.owner === owner).sort((a, b) => b.createdAt - a.createdAt);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get(id: string): Handoff | undefined {
|
|
145
|
+
return this.handoffs.get(id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A handoff, or the one this key already made. `created` says which. */
|
|
149
|
+
create(input: Record<string, unknown>, owner: string): { handoff: Handoff; created: boolean } {
|
|
150
|
+
if (input["version"] !== 1) throw new LibraryError("unsupported_version", "this server understands handoff version 1");
|
|
151
|
+
const key = typeof input["idempotencyKey"] === "string" ? input["idempotencyKey"].trim().slice(0, 200) : "";
|
|
152
|
+
if (!key) throw new LibraryError("idempotency_key_required", "a handoff needs an idempotencyKey, so a retry is not a second one");
|
|
153
|
+
const again = [...this.handoffs.values()].find((one) => one.idempotencyKey === key && one.owner === owner);
|
|
154
|
+
if (again) return { handoff: again, created: false };
|
|
155
|
+
const files = input["files"];
|
|
156
|
+
if (!Array.isArray(files) || files.length === 0) throw new LibraryError("files_required", "choose at least one file");
|
|
157
|
+
if (files.length > MAX_FILES) throw new LibraryError("too_many_files", `at most ${MAX_FILES} files in one handoff`);
|
|
158
|
+
const id = randomBytes(8).toString("hex");
|
|
159
|
+
const folder = join(this.deps.root, id);
|
|
160
|
+
const now = this.now();
|
|
161
|
+
const seen = new Set<string>();
|
|
162
|
+
const accepted: HandoffFile[] = files.map((raw, at) => {
|
|
163
|
+
const one = (raw ?? {}) as Record<string, unknown>;
|
|
164
|
+
const fileId = typeof one["fileId"] === "string" ? one["fileId"].slice(0, 200) : "";
|
|
165
|
+
const name = typeof one["name"] === "string" ? one["name"] : "";
|
|
166
|
+
const size = Number(one["size"]);
|
|
167
|
+
const url = typeof one["url"] === "string" ? one["url"] : "";
|
|
168
|
+
const mediaType = typeof one["mediaType"] === "string" ? one["mediaType"].slice(0, 100) : "";
|
|
169
|
+
if (!fileId || seen.has(fileId)) throw new LibraryError("invalid_file", `file ${at + 1} needs a fileId of its own`);
|
|
170
|
+
seen.add(fileId);
|
|
171
|
+
if (!name) throw new LibraryError("invalid_file", `file ${at + 1} needs a name`);
|
|
172
|
+
if (!Number.isInteger(size) || size <= 0 || size > MAX_FILE_BYTES) throw new LibraryError("invalid_file", `file ${at + 1} needs a size in bytes, up to 4 GiB`);
|
|
173
|
+
if (!(mediaType.startsWith("audio/") || playable(name))) throw new LibraryError("not_audio", `${name} is not a file a station can play`);
|
|
174
|
+
let parsed: URL;
|
|
175
|
+
try {
|
|
176
|
+
parsed = new URL(url);
|
|
177
|
+
} catch {
|
|
178
|
+
throw new LibraryError("invalid_file", `file ${at + 1} needs a url to fetch it from`);
|
|
179
|
+
}
|
|
180
|
+
if (parsed.protocol !== "https:" && !(this.deps.allowHttp && parsed.protocol === "http:")) {
|
|
181
|
+
throw new LibraryError("insecure_url", "files are fetched over https only");
|
|
182
|
+
}
|
|
183
|
+
const tags = (one["metadata"] ?? {}) as Record<string, unknown>;
|
|
184
|
+
return {
|
|
185
|
+
fileId, name, size, mediaType,
|
|
186
|
+
complete: one["complete"] === true,
|
|
187
|
+
state: "pending", error: "", attempts: 0,
|
|
188
|
+
path: join(folder, safeName(name, at) + (extname(name) ? "" : ".mp3")),
|
|
189
|
+
bytes: 0, sha256: "",
|
|
190
|
+
url,
|
|
191
|
+
expiresAt: Number.isFinite(Number(one["expiresAt"])) ? Number(one["expiresAt"]) : 0,
|
|
192
|
+
tags: {
|
|
193
|
+
...(typeof tags["title"] === "string" ? { title: tags["title"].slice(0, 300) } : {}),
|
|
194
|
+
...(typeof tags["artist"] === "string" ? { artist: tags["artist"].slice(0, 300) } : {}),
|
|
195
|
+
...(typeof tags["album"] === "string" ? { album: tags["album"].slice(0, 300) } : {}),
|
|
196
|
+
...(Number.isInteger(tags["track"]) ? { track: tags["track"] as number } : {}),
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
const sourceInput = (input["source"] ?? {}) as Record<string, unknown>;
|
|
201
|
+
const name = (typeof input["name"] === "string" && input["name"].trim() ? input["name"].trim() : "From BitTorrented").slice(0, 120);
|
|
202
|
+
this.source();
|
|
203
|
+
// The draft, private and stopped, with every file it will have: a file
|
|
204
|
+
// that is not copied yet is simply not there, and a station plays what is.
|
|
205
|
+
const station = this.deps.stations.create({ name }, accepted.map((one) => one.path), owner, now, `handoff:${key}`);
|
|
206
|
+
const handoff: Handoff = {
|
|
207
|
+
id, version: 1, provider: "bittorrented", idempotencyKey: key, owner, name,
|
|
208
|
+
source: {
|
|
209
|
+
torrentId: String(sourceInput["torrentId"] ?? "").slice(0, 200),
|
|
210
|
+
infohash: String(sourceInput["infohash"] ?? "").slice(0, 80),
|
|
211
|
+
collectionId: String(sourceInput["collectionId"] ?? "").slice(0, 200),
|
|
212
|
+
},
|
|
213
|
+
stationId: station.id,
|
|
214
|
+
files: accepted,
|
|
215
|
+
createdAt: now,
|
|
216
|
+
updatedAt: now,
|
|
217
|
+
};
|
|
218
|
+
this.handoffs.set(id, handoff);
|
|
219
|
+
this.save();
|
|
220
|
+
void this.run(id);
|
|
221
|
+
return { handoff, created: true };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** New links for files whose links ran out, and another try for the failed ones. */
|
|
225
|
+
retry(id: string, input: Record<string, unknown>): Handoff {
|
|
226
|
+
const handoff = this.handoffs.get(id);
|
|
227
|
+
if (!handoff) throw new LibraryError("not_found", "no such handoff", 404);
|
|
228
|
+
const fresh = Array.isArray(input["files"]) ? (input["files"] as Array<Record<string, unknown>>) : [];
|
|
229
|
+
for (const file of handoff.files) {
|
|
230
|
+
const update = fresh.find((one) => one["fileId"] === file.fileId);
|
|
231
|
+
if (update && typeof update["url"] === "string") {
|
|
232
|
+
const parsed = new URL(update["url"]);
|
|
233
|
+
if (parsed.protocol !== "https:" && !(this.deps.allowHttp && parsed.protocol === "http:")) throw new LibraryError("insecure_url", "files are fetched over https only");
|
|
234
|
+
file.url = update["url"];
|
|
235
|
+
file.expiresAt = Number(update["expiresAt"] ?? 0) || 0;
|
|
236
|
+
}
|
|
237
|
+
if (file.state === "failed" || file.state === "expired") {
|
|
238
|
+
file.state = "pending";
|
|
239
|
+
file.error = "";
|
|
240
|
+
file.attempts = 0;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
handoff.updatedAt = this.now();
|
|
244
|
+
this.save();
|
|
245
|
+
void this.run(id);
|
|
246
|
+
return handoff;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The copies this handoff made, removed; the station loses those files. */
|
|
250
|
+
remove(id: string): number {
|
|
251
|
+
const handoff = this.handoffs.get(id);
|
|
252
|
+
if (!handoff) throw new LibraryError("not_found", "no such handoff", 404);
|
|
253
|
+
const removed = handoff.files.filter((one) => one.state === "ready").length;
|
|
254
|
+
rmSync(join(this.deps.root, id), { recursive: true, force: true });
|
|
255
|
+
this.handoffs.delete(id);
|
|
256
|
+
this.save();
|
|
257
|
+
void this.deps.index.scan(this.source(), this.deps.probe).done;
|
|
258
|
+
return removed;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Every handoff that was part-way when the server stopped, carried on. */
|
|
262
|
+
resume(): void {
|
|
263
|
+
for (const handoff of this.handoffs.values()) {
|
|
264
|
+
let again = false;
|
|
265
|
+
for (const file of handoff.files) {
|
|
266
|
+
if (file.state === "downloading") file.state = "pending";
|
|
267
|
+
if (file.state === "pending") again = true;
|
|
268
|
+
}
|
|
269
|
+
if (again) void this.run(handoff.id);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async run(id: string): Promise<void> {
|
|
274
|
+
if (this.running.has(id)) return;
|
|
275
|
+
this.running.add(id);
|
|
276
|
+
try {
|
|
277
|
+
const handoff = this.handoffs.get(id);
|
|
278
|
+
if (!handoff) return;
|
|
279
|
+
let next = 0;
|
|
280
|
+
const queue = handoff.files.filter((one) => one.state === "pending");
|
|
281
|
+
const worker = async (): Promise<void> => {
|
|
282
|
+
while (next < queue.length) {
|
|
283
|
+
const file = queue[next++]!;
|
|
284
|
+
await this.copy(handoff, file);
|
|
285
|
+
handoff.updatedAt = this.now();
|
|
286
|
+
this.save();
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
await Promise.all([worker(), worker()]);
|
|
290
|
+
// What was copied, read and indexed; then each file says what it is.
|
|
291
|
+
await this.deps.index.scan(this.source(), this.deps.probe).done;
|
|
292
|
+
for (const file of handoff.files) {
|
|
293
|
+
if (file.state !== "ready") continue;
|
|
294
|
+
const item = this.deps.index.itemAt(file.path);
|
|
295
|
+
if (!item || item.state === "failed") {
|
|
296
|
+
file.state = "failed";
|
|
297
|
+
file.error = item?.error || "it could not be read after copying";
|
|
298
|
+
} else if (item.state === "unsupported") {
|
|
299
|
+
file.state = "unsupported";
|
|
300
|
+
file.error = "there is nothing in it a station can play";
|
|
301
|
+
} else if (item.state === "ready") {
|
|
302
|
+
// What BitTorrented knew, where the file itself says nothing.
|
|
303
|
+
const patch: Record<string, unknown> = {};
|
|
304
|
+
if (!item.title && file.tags.title) patch["title"] = file.tags.title;
|
|
305
|
+
if (!item.artist && file.tags.artist) patch["artist"] = file.tags.artist;
|
|
306
|
+
if (!item.album && file.tags.album) patch["album"] = file.tags.album;
|
|
307
|
+
if (Object.keys(patch).length) this.deps.index.correct(item.id, patch);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
handoff.updatedAt = this.now();
|
|
311
|
+
this.save();
|
|
312
|
+
} finally {
|
|
313
|
+
this.running.delete(id);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private async copy(handoff: Handoff, file: HandoffFile): Promise<void> {
|
|
318
|
+
if (file.expiresAt && file.expiresAt <= this.now()) {
|
|
319
|
+
file.state = "expired";
|
|
320
|
+
file.error = "the link to this file ran out before it was copied; ask BitTorrented for a new one";
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
file.state = "downloading";
|
|
324
|
+
file.attempts += 1;
|
|
325
|
+
const send = this.deps.fetcher ?? fetch;
|
|
326
|
+
const part = `${file.path}.part`;
|
|
327
|
+
try {
|
|
328
|
+
let at = file.url;
|
|
329
|
+
let response: Response | null = null;
|
|
330
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
331
|
+
const refused = await this.deps.guard(at);
|
|
332
|
+
if (refused) throw new Error(refused);
|
|
333
|
+
response = await send(at, { redirect: "manual", headers: { "user-agent": "nixamp-handoff" } });
|
|
334
|
+
const location = response.headers.get("location");
|
|
335
|
+
if (response.status >= 300 && response.status < 400 && location) {
|
|
336
|
+
at = new URL(location, at).toString();
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
if (!response) throw new Error("no answer");
|
|
342
|
+
if ([401, 403, 410].includes(response.status)) {
|
|
343
|
+
file.state = "expired";
|
|
344
|
+
file.error = `BitTorrented no longer lets this file be fetched (HTTP ${response.status}); ask for a new link`;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
|
|
348
|
+
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
349
|
+
if (declared && declared !== file.size) throw new Error(`the file is ${declared} bytes, not the ${file.size} it was said to be`);
|
|
350
|
+
mkdirSync(dirname(file.path), { recursive: true });
|
|
351
|
+
const hash = createHash("sha256");
|
|
352
|
+
let bytes = 0;
|
|
353
|
+
const counted = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream).on("data", (chunk: Buffer) => {
|
|
354
|
+
bytes += chunk.length;
|
|
355
|
+
if (bytes > file.size) counted.destroy(new Error(`more than the ${file.size} bytes it was said to be`));
|
|
356
|
+
hash.update(chunk);
|
|
357
|
+
});
|
|
358
|
+
await pipeline(counted, createWriteStream(part, { mode: 0o640 }));
|
|
359
|
+
if (bytes !== file.size) throw new Error(`incomplete: ${bytes} of ${file.size} bytes`);
|
|
360
|
+
renameSync(part, file.path);
|
|
361
|
+
file.bytes = bytes;
|
|
362
|
+
file.sha256 = hash.digest("hex");
|
|
363
|
+
file.state = "ready";
|
|
364
|
+
file.error = "";
|
|
365
|
+
// The grant has done its job.
|
|
366
|
+
file.url = "";
|
|
367
|
+
} catch (error) {
|
|
368
|
+
rmSync(part, { force: true });
|
|
369
|
+
file.error = error instanceof Error ? error.message : String(error);
|
|
370
|
+
file.state = file.attempts < MAX_ATTEMPTS ? "pending" : "failed";
|
|
371
|
+
if (file.state === "pending") await this.copy(handoff, file);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The bytes a ready copy takes on disk, for the owner's view. */
|
|
376
|
+
static sizeOn(path: string): number {
|
|
377
|
+
try {
|
|
378
|
+
return statSync(path).size;
|
|
379
|
+
} catch {
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
package/src/library-index.ts
CHANGED
|
@@ -107,6 +107,8 @@ export interface LibrarySource {
|
|
|
107
107
|
enabled: boolean;
|
|
108
108
|
createdAt: number;
|
|
109
109
|
lastScan: ScanReport | null;
|
|
110
|
+
/** A folder Nixamp fills itself (imports), allowed where a library may not be. */
|
|
111
|
+
managed?: boolean;
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
/** What one probe found. */
|
|
@@ -423,6 +425,25 @@ export class LibraryIndex {
|
|
|
423
425
|
return source;
|
|
424
426
|
}
|
|
425
427
|
|
|
428
|
+
/**
|
|
429
|
+
* A folder Nixamp fills itself -- copies handed over from BitTorrented --
|
|
430
|
+
* as a source. It lives beside the state, which is hidden and so refused
|
|
431
|
+
* as a library; it is allowed here because nothing in it was put there by
|
|
432
|
+
* anybody but Nixamp.
|
|
433
|
+
*/
|
|
434
|
+
addManagedSource(root: string, host = hostname()): LibrarySource {
|
|
435
|
+
const full = resolve(root);
|
|
436
|
+
const existing = this.sources().find((one) => one.root === full);
|
|
437
|
+
if (existing) return existing;
|
|
438
|
+
const source: LibrarySource = {
|
|
439
|
+
id: idOf("source", host, full), kind: "folder", root: full, owner: "", host,
|
|
440
|
+
enabled: true, createdAt: this.now(), lastScan: null, managed: true,
|
|
441
|
+
};
|
|
442
|
+
this.sourceList.set(source.id, source);
|
|
443
|
+
this.touch();
|
|
444
|
+
return source;
|
|
445
|
+
}
|
|
446
|
+
|
|
426
447
|
/** The folder this server was started on, as a source, made once. */
|
|
427
448
|
ensureSource(root: string, owner: string): LibrarySource | null {
|
|
428
449
|
try {
|
package/src/oauth-server.ts
CHANGED
|
@@ -53,6 +53,7 @@ export const SCOPES = {
|
|
|
53
53
|
email: "the address on your account",
|
|
54
54
|
parties: "host and join watch parties as you",
|
|
55
55
|
streams: "see the servers you run on nixamp, and what is live on them",
|
|
56
|
+
stations: "manage the library and stations on servers you run, and send them music to play",
|
|
56
57
|
offline_access: "stay connected without asking again",
|
|
57
58
|
} as const;
|
|
58
59
|
|
package/src/owner.ts
CHANGED
|
@@ -69,8 +69,11 @@ export class Owner {
|
|
|
69
69
|
this.cache.set(token, { id: "", at: now });
|
|
70
70
|
return "";
|
|
71
71
|
}
|
|
72
|
-
const body = (await answer.json()) as { account?: { id?: string } };
|
|
73
|
-
|
|
72
|
+
const body = (await answer.json()) as { account?: { id?: string }; grant?: { scope?: unknown } };
|
|
73
|
+
// A token some app holds for the person counts here only if they gave
|
|
74
|
+
// that app the stations scope; otherwise it is not them at this door.
|
|
75
|
+
const scoped = body.grant === undefined || (Array.isArray(body.grant.scope) && body.grant.scope.includes("stations"));
|
|
76
|
+
const id = scoped && typeof body.account?.id === "string" ? body.account.id : "";
|
|
74
77
|
this.cache.set(token, { id, at: now });
|
|
75
78
|
return id;
|
|
76
79
|
} catch {
|
package/src/server.ts
CHANGED
|
@@ -75,9 +75,12 @@ import { handleStations, isStationListenPath } from "./station-routes.ts";
|
|
|
75
75
|
import { handleLibrary } from "./library-routes.ts";
|
|
76
76
|
import { LibraryIndex, probeItem, trackOf, type Probed } from "./library-index.ts";
|
|
77
77
|
import { ListenerStore } from "./library-user.ts";
|
|
78
|
+
import { Importer } from "./handoff.ts";
|
|
79
|
+
import { handleHandoffs } from "./handoff-routes.ts";
|
|
78
80
|
import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
|
|
79
81
|
import { StationRunner } from "./station-runner.ts";
|
|
80
82
|
import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
|
|
83
|
+
import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv, type Limits } from "./station-metering.ts";
|
|
81
84
|
import { CompressionService } from "./compression/service.ts";
|
|
82
85
|
import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
|
|
83
86
|
import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
|
|
@@ -1711,8 +1714,10 @@ async function readBytes(request: IncomingMessage, limit: number): Promise<Uint8
|
|
|
1711
1714
|
export interface HandlerOptions {
|
|
1712
1715
|
/** The library as an index: sources, items, search. */
|
|
1713
1716
|
library?: { index: LibraryIndex; probe: (path: string) => Promise<Probed>; retag: (tracks: Track[]) => void; listeners?: ListenerStore };
|
|
1717
|
+
/** Music handed over from BitTorrented. */
|
|
1718
|
+
importer?: Importer;
|
|
1714
1719
|
/** This server's stations and what keeps them on the air. */
|
|
1715
|
-
stations?: { store: StationStore; runner: StationRunner };
|
|
1720
|
+
stations?: { store: StationStore; runner: StationRunner; usage?: StationUsage; limits?: Limits };
|
|
1716
1721
|
web: string | null;
|
|
1717
1722
|
/** Branded clients on this same backend, keyed by an explicitly configured host. */
|
|
1718
1723
|
webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
|
|
@@ -2737,6 +2742,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2737
2742
|
return;
|
|
2738
2743
|
}
|
|
2739
2744
|
|
|
2745
|
+
if (options.importer && path.startsWith("/api/v1/integrations/bittorrented/")) {
|
|
2746
|
+
const control = await controlOf();
|
|
2747
|
+
await handleHandoffs(request, response, path, {
|
|
2748
|
+
importer: options.importer,
|
|
2749
|
+
canWrite: control.allowed,
|
|
2750
|
+
owner: control.account || "owner",
|
|
2751
|
+
json,
|
|
2752
|
+
readBody,
|
|
2753
|
+
});
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2740
2757
|
if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
|
|
2741
2758
|
const control = await controlOf();
|
|
2742
2759
|
const encrypted = (request.socket as { encrypted?: boolean }).encrypted === true;
|
|
@@ -2769,6 +2786,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2769
2786
|
json,
|
|
2770
2787
|
readBody,
|
|
2771
2788
|
headers: CORS,
|
|
2789
|
+
...(options.stations.usage ? { usage: options.stations.usage } : {}),
|
|
2790
|
+
...(options.stations.limits ? { limits: options.stations.limits } : {}),
|
|
2791
|
+
throttle: () => {
|
|
2792
|
+
const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
2793
|
+
return verdict.ok ? null : { retryAfter: verdict.retryAfter };
|
|
2794
|
+
},
|
|
2772
2795
|
});
|
|
2773
2796
|
return;
|
|
2774
2797
|
}
|
|
@@ -2842,12 +2865,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2842
2865
|
}
|
|
2843
2866
|
|
|
2844
2867
|
if (path === "/api/v1/auth/me") {
|
|
2845
|
-
const
|
|
2868
|
+
const token = tokenFrom(request.headers);
|
|
2869
|
+
const who = await accounts.whoIs(token);
|
|
2846
2870
|
if (who === null) {
|
|
2847
2871
|
json(response, 401, { error: "not signed in" });
|
|
2848
2872
|
return;
|
|
2849
2873
|
}
|
|
2850
|
-
|
|
2874
|
+
// An app acting for the person says so, with what it was allowed:
|
|
2875
|
+
// a server asking "is this my owner?" must not take a token handed to
|
|
2876
|
+
// some other app as the owner themselves.
|
|
2877
|
+
const grant = options.authServer ? await options.authServer.grantFor(token) : null;
|
|
2878
|
+
json(response, 200, { account: who, ...(grant ? { grant: { clientId: grant.clientId, scope: grant.scope } } : {}) });
|
|
2851
2879
|
return;
|
|
2852
2880
|
}
|
|
2853
2881
|
|
|
@@ -3995,11 +4023,28 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3995
4023
|
// point: a share link in a terminal you closed is a server you have lost.
|
|
3996
4024
|
if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers && options.accounts) {
|
|
3997
4025
|
const servers = options.servers;
|
|
3998
|
-
const
|
|
4026
|
+
const token = tokenFrom(request.headers);
|
|
4027
|
+
const who = await options.accounts.whoIs(token);
|
|
3999
4028
|
if (who === null) {
|
|
4000
4029
|
json(response, 401, { error: "not signed in" });
|
|
4001
4030
|
return;
|
|
4002
4031
|
}
|
|
4032
|
+
// An app acting for the person sees the list only with a scope that
|
|
4033
|
+
// says so, may not change it, and never gets a control key: those are
|
|
4034
|
+
// the person's, and an app that held one could drive the machine.
|
|
4035
|
+
const grant = options.authServer ? await options.authServer.grantFor(token) : null;
|
|
4036
|
+
if (grant) {
|
|
4037
|
+
if (!(grant.scope.includes("streams") || grant.scope.includes("stations"))) {
|
|
4038
|
+
json(response, 403, { error: "this app was not given access to your servers", code: "insufficient_scope" });
|
|
4039
|
+
return;
|
|
4040
|
+
}
|
|
4041
|
+
if (!(path === "/api/v1/servers" && request.method === "GET")) {
|
|
4042
|
+
json(response, 403, { error: "an app may list your servers, not change them", code: "insufficient_scope" });
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
json(response, 200, { servers: (await servers.list(who.id)).map(({ key: _key, ...shown }) => shown) });
|
|
4046
|
+
return;
|
|
4047
|
+
}
|
|
4003
4048
|
|
|
4004
4049
|
if (path === "/api/v1/servers" && request.method === "GET") {
|
|
4005
4050
|
json(response, 200, { servers: await servers.list(who.id) });
|
|
@@ -6614,7 +6659,13 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6614
6659
|
const listeners = new ListenerStore(ListenerStore.fileFor(stateDir(), options.port));
|
|
6615
6660
|
const library = { index: libraryIndex, probe: probeFile, retag: retagPlayer, listeners };
|
|
6616
6661
|
const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
|
|
6662
|
+
// What stations deliver, per month, and the operator's limits on it.
|
|
6663
|
+
const stationUsage = new StationUsage(StationUsage.fileFor(stateDir(), options.port));
|
|
6664
|
+
const stationLimits = limitsFromEnv();
|
|
6665
|
+
process.once("exit", () => stationUsage.flush());
|
|
6617
6666
|
stationRunner = new StationRunner({
|
|
6667
|
+
onRestart: (id) => stationUsage.restarted(id),
|
|
6668
|
+
onFailure: (id) => stationUsage.failed(id),
|
|
6618
6669
|
store: stationStore,
|
|
6619
6670
|
channels,
|
|
6620
6671
|
pull: async (channelId, name, files, known) =>
|
|
@@ -6628,7 +6679,17 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6628
6679
|
roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
|
|
6629
6680
|
log: (line) => console.log(line),
|
|
6630
6681
|
});
|
|
6631
|
-
const stations = { store: stationStore, runner: stationRunner };
|
|
6682
|
+
const stations = { store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits };
|
|
6683
|
+
// Music handed over from BitTorrented, copied into a folder of our own.
|
|
6684
|
+
const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
|
|
6685
|
+
index: libraryIndex,
|
|
6686
|
+
stations: stationStore,
|
|
6687
|
+
root: join(stateDir(), "imports", "bittorrented"),
|
|
6688
|
+
probe: probeFile,
|
|
6689
|
+
guard: (url) => refusedDestination(url),
|
|
6690
|
+
log: (line) => console.log(line),
|
|
6691
|
+
});
|
|
6692
|
+
importer.resume();
|
|
6632
6693
|
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
6633
6694
|
// channel's fragments into short files while somebody is asking for them.
|
|
6634
6695
|
// What things are, from nichedb.dev, remembered beside the keys so a
|
|
@@ -7222,6 +7283,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7222
7283
|
channels,
|
|
7223
7284
|
stations,
|
|
7224
7285
|
library,
|
|
7286
|
+
importer,
|
|
7225
7287
|
rememberChannels: remembering,
|
|
7226
7288
|
catalogs,
|
|
7227
7289
|
publishUrls: () => publishUrls,
|