nixamp 0.33.0 → 0.34.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 (41) hide show
  1. package/dist/handoff-routes.d.ts +17 -0
  2. package/dist/handoff-routes.js +64 -0
  3. package/dist/handoff.d.ts +89 -0
  4. package/dist/handoff.js +358 -0
  5. package/dist/library-index.d.ts +9 -0
  6. package/dist/library-index.js +19 -0
  7. package/dist/oauth-server.d.ts +1 -0
  8. package/dist/oauth-server.js +1 -0
  9. package/dist/owner.js +4 -1
  10. package/dist/server.d.ts +3 -0
  11. package/dist/server.js +49 -3
  12. package/package.json +1 -1
  13. package/src/handoff-routes.ts +73 -0
  14. package/src/handoff.ts +383 -0
  15. package/src/library-index.ts +21 -0
  16. package/src/oauth-server.ts +1 -0
  17. package/src/owner.ts +5 -2
  18. package/src/server.ts +52 -3
  19. package/web/dist/assets/{ar-4kD-mTIA.js → ar-DoW0wbjC.js} +1 -1
  20. package/web/dist/assets/{cs-CziXiWNY.js → cs-CjKqCjy5.js} +1 -1
  21. package/web/dist/assets/{da-CyjJ8UOr.js → da-BQERb7Qz.js} +1 -1
  22. package/web/dist/assets/{de-DxwYa667.js → de-CKKbRy6c.js} +1 -1
  23. package/web/dist/assets/{es-QgMTJeSc.js → es-CL3v5Og9.js} +1 -1
  24. package/web/dist/assets/{fi-Clj-jHhR.js → fi-DgV81qNU.js} +1 -1
  25. package/web/dist/assets/{fr-nH_gzSFv.js → fr-BGumytzF.js} +1 -1
  26. package/web/dist/assets/{hi-D7djQ6_C.js → hi-CAQ9elqo.js} +1 -1
  27. package/web/dist/assets/{hls-3VKVEQE3-BaWVouMg.js → hls-3VKVEQE3-Ceri0flt.js} +1 -1
  28. package/web/dist/assets/{hu-H5Q3L8bD.js → hu-UJNgJVWX.js} +1 -1
  29. package/web/dist/assets/{id-40z_EQdf.js → id-BqQOlrF-.js} +1 -1
  30. package/web/dist/assets/{it-C9Fcgywl.js → it-Cs8G6mvW.js} +1 -1
  31. package/web/dist/assets/{main-BJUyBy0h.js → main-Y1Rvn1GX.js} +2 -2
  32. package/web/dist/assets/{mpegts-LO6RVLD6-CAuth8C5.js → mpegts-LO6RVLD6-CPI8xaZ-.js} +1 -1
  33. package/web/dist/assets/{mpegts-BlmyNlBC.js → mpegts-jS_Diizz.js} +1 -1
  34. package/web/dist/assets/{nl-CrL4Psk9.js → nl-BJXQ9nqo.js} +1 -1
  35. package/web/dist/assets/{ru-C84JS0ch.js → ru-XmVkvNY8.js} +1 -1
  36. package/web/dist/assets/{sv-DtMcl0sf.js → sv-pVm9p66e.js} +1 -1
  37. package/web/dist/assets/{uk-dOLf99vZ.js → uk-4hcyaD3e.js} +1 -1
  38. package/web/dist/assets/{vi-BWeQVRZ8.js → vi-B7MipBlv.js} +1 -1
  39. package/web/dist/assets/{zh-Cnv3Qpux.js → zh-B5YVExOW.js} +1 -1
  40. package/web/dist/index.html +1 -1
  41. package/web/dist/sw.js +22 -22
package/dist/server.js CHANGED
@@ -58,6 +58,8 @@ import { handleStations, isStationListenPath } from "./station-routes.js";
58
58
  import { handleLibrary } from "./library-routes.js";
59
59
  import { LibraryIndex, probeItem, trackOf } from "./library-index.js";
60
60
  import { ListenerStore } from "./library-user.js";
61
+ import { Importer } from "./handoff.js";
62
+ import { handleHandoffs } from "./handoff-routes.js";
61
63
  import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
62
64
  import { StationRunner } from "./station-runner.js";
63
65
  import { StationError, StationStore, stationOfChannel } from "./stations.js";
@@ -2227,6 +2229,17 @@ export function createHandler(engine, options) {
2227
2229
  });
2228
2230
  return;
2229
2231
  }
2232
+ if (options.importer && path.startsWith("/api/v1/integrations/bittorrented/")) {
2233
+ const control = await controlOf();
2234
+ await handleHandoffs(request, response, path, {
2235
+ importer: options.importer,
2236
+ canWrite: control.allowed,
2237
+ owner: control.account || "owner",
2238
+ json,
2239
+ readBody,
2240
+ });
2241
+ return;
2242
+ }
2230
2243
  if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
2231
2244
  const control = await controlOf();
2232
2245
  const encrypted = request.socket.encrypted === true;
@@ -2343,12 +2356,17 @@ export function createHandler(engine, options) {
2343
2356
  return;
2344
2357
  }
2345
2358
  if (path === "/api/v1/auth/me") {
2346
- const who = await accounts.whoIs(tokenFrom(request.headers));
2359
+ const token = tokenFrom(request.headers);
2360
+ const who = await accounts.whoIs(token);
2347
2361
  if (who === null) {
2348
2362
  json(response, 401, { error: "not signed in" });
2349
2363
  return;
2350
2364
  }
2351
- json(response, 200, { account: who });
2365
+ // An app acting for the person says so, with what it was allowed:
2366
+ // a server asking "is this my owner?" must not take a token handed to
2367
+ // some other app as the owner themselves.
2368
+ const grant = options.authServer ? await options.authServer.grantFor(token) : null;
2369
+ json(response, 200, { account: who, ...(grant ? { grant: { clientId: grant.clientId, scope: grant.scope } } : {}) });
2352
2370
  return;
2353
2371
  }
2354
2372
  /**
@@ -3599,11 +3617,28 @@ export function createHandler(engine, options) {
3599
3617
  // point: a share link in a terminal you closed is a server you have lost.
3600
3618
  if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers && options.accounts) {
3601
3619
  const servers = options.servers;
3602
- const who = await options.accounts.whoIs(tokenFrom(request.headers));
3620
+ const token = tokenFrom(request.headers);
3621
+ const who = await options.accounts.whoIs(token);
3603
3622
  if (who === null) {
3604
3623
  json(response, 401, { error: "not signed in" });
3605
3624
  return;
3606
3625
  }
3626
+ // An app acting for the person sees the list only with a scope that
3627
+ // says so, may not change it, and never gets a control key: those are
3628
+ // the person's, and an app that held one could drive the machine.
3629
+ const grant = options.authServer ? await options.authServer.grantFor(token) : null;
3630
+ if (grant) {
3631
+ if (!(grant.scope.includes("streams") || grant.scope.includes("stations"))) {
3632
+ json(response, 403, { error: "this app was not given access to your servers", code: "insufficient_scope" });
3633
+ return;
3634
+ }
3635
+ if (!(path === "/api/v1/servers" && request.method === "GET")) {
3636
+ json(response, 403, { error: "an app may list your servers, not change them", code: "insufficient_scope" });
3637
+ return;
3638
+ }
3639
+ json(response, 200, { servers: (await servers.list(who.id)).map(({ key: _key, ...shown }) => shown) });
3640
+ return;
3641
+ }
3607
3642
  if (path === "/api/v1/servers" && request.method === "GET") {
3608
3643
  json(response, 200, { servers: await servers.list(who.id) });
3609
3644
  return;
@@ -6147,6 +6182,16 @@ export async function serve(argv, version = "0.1.0") {
6147
6182
  log: (line) => console.log(line),
6148
6183
  });
6149
6184
  const stations = { store: stationStore, runner: stationRunner };
6185
+ // Music handed over from BitTorrented, copied into a folder of our own.
6186
+ const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
6187
+ index: libraryIndex,
6188
+ stations: stationStore,
6189
+ root: join(stateDir(), "imports", "bittorrented"),
6190
+ probe: probeFile,
6191
+ guard: (url) => refusedDestination(url),
6192
+ log: (line) => console.log(line),
6193
+ });
6194
+ importer.resume();
6150
6195
  // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
6151
6196
  // channel's fragments into short files while somebody is asking for them.
6152
6197
  // What things are, from nichedb.dev, remembered beside the keys so a
@@ -6727,6 +6772,7 @@ export async function serve(argv, version = "0.1.0") {
6727
6772
  channels,
6728
6773
  stations,
6729
6774
  library,
6775
+ importer,
6730
6776
  rememberChannels: remembering,
6731
6777
  catalogs,
6732
6778
  publishUrls: () => publishUrls,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.33.0",
3
+ "version": "0.34.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",
@@ -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
+ }
@@ -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 {
@@ -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
- const id = typeof body.account?.id === "string" ? body.account.id : "";
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 {