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
@@ -0,0 +1,17 @@
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 { type Importer } from "./handoff.ts";
9
+ export interface HandoffContext {
10
+ importer: Importer;
11
+ canWrite: boolean;
12
+ /** Whose handoffs: the owner's account, or "owner" for the control key. */
13
+ owner: string;
14
+ json: (response: ServerResponse, status: number, body: unknown) => void;
15
+ readBody: (request: IncomingMessage) => Promise<string>;
16
+ }
17
+ export declare function handleHandoffs(request: IncomingMessage, response: ServerResponse, path: string, ctx: HandoffContext): Promise<boolean>;
@@ -0,0 +1,64 @@
1
+ import { publicHandoff } from "./handoff.js";
2
+ import { LibraryError } from "./library-index.js";
3
+ const BASE = "/api/v1/integrations/bittorrented/handoffs";
4
+ export async function handleHandoffs(request, response, path, ctx) {
5
+ if (path !== BASE && !path.startsWith(`${BASE}/`))
6
+ return false;
7
+ const method = request.method ?? "GET";
8
+ const fail = (status, code, error) => {
9
+ ctx.json(response, status, { error, code });
10
+ return true;
11
+ };
12
+ if (!ctx.canWrite)
13
+ return fail(403, "forbidden", "only this server's owner (or an app they gave the stations scope) hands music over");
14
+ const body = async () => {
15
+ const raw = await ctx.readBody(request);
16
+ if (raw.trim() === "")
17
+ return {};
18
+ const parsed = JSON.parse(raw);
19
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
20
+ throw new LibraryError("bad_json", "the body is not a JSON object");
21
+ return parsed;
22
+ };
23
+ try {
24
+ if (path === BASE) {
25
+ if (method === "GET") {
26
+ ctx.json(response, 200, { handoffs: ctx.importer.list(ctx.owner).map(publicHandoff) });
27
+ return true;
28
+ }
29
+ if (method === "POST") {
30
+ const { handoff, created } = ctx.importer.create(await body(), ctx.owner);
31
+ ctx.json(response, created ? 201 : 200, { handoff: publicHandoff(handoff), created });
32
+ return true;
33
+ }
34
+ return fail(405, "method_not_allowed", "GET or POST");
35
+ }
36
+ const match = /^\/api\/v1\/integrations\/bittorrented\/handoffs\/([0-9a-f]{16})(?:\/(retry))?$/.exec(path);
37
+ const handoff = match ? ctx.importer.get(match[1]) : undefined;
38
+ if (!match || !handoff || handoff.owner !== ctx.owner)
39
+ return fail(404, "not_found", "no such handoff");
40
+ if (match[2] === "retry") {
41
+ if (method !== "POST")
42
+ return fail(405, "method_not_allowed", "POST");
43
+ ctx.json(response, 200, { handoff: publicHandoff(ctx.importer.retry(handoff.id, await body())) });
44
+ return true;
45
+ }
46
+ if (method === "GET") {
47
+ ctx.json(response, 200, { handoff: publicHandoff(handoff) });
48
+ return true;
49
+ }
50
+ if (method === "DELETE") {
51
+ const removed = ctx.importer.remove(handoff.id);
52
+ ctx.json(response, 200, { deleted: handoff.id, copiesRemoved: removed });
53
+ return true;
54
+ }
55
+ return fail(405, "method_not_allowed", "GET or DELETE");
56
+ }
57
+ catch (error) {
58
+ if (error instanceof LibraryError)
59
+ return fail(error.status, error.code, error.message);
60
+ if (error instanceof SyntaxError)
61
+ return fail(400, "bad_json", "the body is not JSON");
62
+ return fail(500, "internal", error instanceof Error ? error.message : String(error));
63
+ }
64
+ }
@@ -0,0 +1,89 @@
1
+ import { type LibraryIndex, type Probed } from "./library-index.ts";
2
+ import type { StationStore } from "./stations.ts";
3
+ export type FileState = "pending" | "downloading" | "ready" | "failed" | "expired" | "unsupported";
4
+ export interface HandoffFile {
5
+ fileId: string;
6
+ name: string;
7
+ size: number;
8
+ mediaType: string;
9
+ /** Whether BitTorrented said the download was complete when it handed it over. */
10
+ complete: boolean;
11
+ state: FileState;
12
+ error: string;
13
+ attempts: number;
14
+ /** Where the copy is (or will be). Shown to the owner only. */
15
+ path: string;
16
+ bytes: number;
17
+ sha256: string;
18
+ /** The grant; kept until the copy is made, never shown. */
19
+ url: string;
20
+ expiresAt: number;
21
+ tags: {
22
+ title?: string;
23
+ artist?: string;
24
+ album?: string;
25
+ track?: number;
26
+ };
27
+ }
28
+ export interface Handoff {
29
+ id: string;
30
+ version: 1;
31
+ provider: "bittorrented";
32
+ idempotencyKey: string;
33
+ owner: string;
34
+ name: string;
35
+ source: {
36
+ torrentId: string;
37
+ infohash: string;
38
+ collectionId: string;
39
+ };
40
+ stationId: string;
41
+ files: HandoffFile[];
42
+ createdAt: number;
43
+ updatedAt: number;
44
+ }
45
+ export declare const MAX_FILES = 500;
46
+ export declare const MAX_FILE_BYTES: number;
47
+ export interface ImporterDeps {
48
+ index: LibraryIndex;
49
+ stations: StationStore;
50
+ /** The folder copies go into: a managed library source. */
51
+ root: string;
52
+ probe: (path: string) => Promise<Probed>;
53
+ /** "" when this address may be fetched, else why not (destinations.ts). */
54
+ guard: (url: string) => Promise<string>;
55
+ fetcher?: typeof fetch;
56
+ /** Plain http, for tests on loopback only. */
57
+ allowHttp?: boolean;
58
+ now?: () => number;
59
+ log?: (line: string) => void;
60
+ }
61
+ export declare function publicHandoff(handoff: Handoff): Record<string, unknown>;
62
+ export declare class Importer {
63
+ private readonly file;
64
+ private readonly deps;
65
+ private handoffs;
66
+ private running;
67
+ private sourceId;
68
+ constructor(file: string, deps: ImporterDeps);
69
+ private now;
70
+ private save;
71
+ private source;
72
+ list(owner: string | null): Handoff[];
73
+ get(id: string): Handoff | undefined;
74
+ /** A handoff, or the one this key already made. `created` says which. */
75
+ create(input: Record<string, unknown>, owner: string): {
76
+ handoff: Handoff;
77
+ created: boolean;
78
+ };
79
+ /** New links for files whose links ran out, and another try for the failed ones. */
80
+ retry(id: string, input: Record<string, unknown>): Handoff;
81
+ /** The copies this handoff made, removed; the station loses those files. */
82
+ remove(id: string): number;
83
+ /** Every handoff that was part-way when the server stopped, carried on. */
84
+ resume(): void;
85
+ private run;
86
+ private copy;
87
+ /** The bytes a ready copy takes on disk, for the owner's view. */
88
+ static sizeOn(path: string): number;
89
+ }
@@ -0,0 +1,358 @@
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 } from "./library-index.js";
24
+ import { playable } from "./playlist.js";
25
+ export const MAX_FILES = 500;
26
+ export const MAX_FILE_BYTES = 4 * 1024 ** 3;
27
+ const MAX_ATTEMPTS = 3;
28
+ const MAX_REDIRECTS = 3;
29
+ function safeName(name, at) {
30
+ const base = name.split(/[\\/]/).pop() ?? "";
31
+ const cleaned = base.replace(/[\u0000-\u001f\u007f<>:"|?*]/g, "_").replace(/^\.+/, "").trim().slice(0, 180);
32
+ return `${String(at + 1).padStart(3, "0")} ${cleaned || "file"}`;
33
+ }
34
+ export function publicHandoff(handoff) {
35
+ const counts = {};
36
+ for (const file of handoff.files)
37
+ counts[file.state] = (counts[file.state] ?? 0) + 1;
38
+ return {
39
+ id: handoff.id,
40
+ version: handoff.version,
41
+ provider: handoff.provider,
42
+ idempotencyKey: handoff.idempotencyKey,
43
+ name: handoff.name,
44
+ source: handoff.source,
45
+ stationId: handoff.stationId,
46
+ status: counts["pending"] || counts["downloading"] ? "importing" : (counts["ready"] ?? 0) === handoff.files.length ? "ready" : counts["ready"] ? "partial" : "failed",
47
+ counts,
48
+ files: handoff.files.map(({ url: _url, path: _path, ...file }) => file),
49
+ createdAt: handoff.createdAt,
50
+ updatedAt: handoff.updatedAt,
51
+ };
52
+ }
53
+ export class Importer {
54
+ file;
55
+ deps;
56
+ handoffs = new Map();
57
+ running = new Set();
58
+ sourceId = "";
59
+ constructor(file, deps) {
60
+ this.file = file;
61
+ this.deps = deps;
62
+ try {
63
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
64
+ for (const one of parsed.handoffs ?? [])
65
+ if (one?.id)
66
+ this.handoffs.set(one.id, one);
67
+ }
68
+ catch {
69
+ // Nothing yet.
70
+ }
71
+ }
72
+ now() {
73
+ return this.deps.now?.() ?? Date.now();
74
+ }
75
+ save() {
76
+ mkdirSync(dirname(this.file), { recursive: true });
77
+ const temporary = `${this.file}.${process.pid}.tmp`;
78
+ writeFileSync(temporary, JSON.stringify({ version: 1, handoffs: [...this.handoffs.values()] }), { mode: 0o600 });
79
+ renameSync(temporary, this.file);
80
+ }
81
+ source() {
82
+ if (this.sourceId && this.deps.index.source(this.sourceId))
83
+ return this.sourceId;
84
+ mkdirSync(this.deps.root, { recursive: true });
85
+ this.sourceId = this.deps.index.addManagedSource(this.deps.root).id;
86
+ return this.sourceId;
87
+ }
88
+ list(owner) {
89
+ return [...this.handoffs.values()].filter((one) => owner === null || one.owner === owner).sort((a, b) => b.createdAt - a.createdAt);
90
+ }
91
+ get(id) {
92
+ return this.handoffs.get(id);
93
+ }
94
+ /** A handoff, or the one this key already made. `created` says which. */
95
+ create(input, owner) {
96
+ if (input["version"] !== 1)
97
+ throw new LibraryError("unsupported_version", "this server understands handoff version 1");
98
+ const key = typeof input["idempotencyKey"] === "string" ? input["idempotencyKey"].trim().slice(0, 200) : "";
99
+ if (!key)
100
+ throw new LibraryError("idempotency_key_required", "a handoff needs an idempotencyKey, so a retry is not a second one");
101
+ const again = [...this.handoffs.values()].find((one) => one.idempotencyKey === key && one.owner === owner);
102
+ if (again)
103
+ return { handoff: again, created: false };
104
+ const files = input["files"];
105
+ if (!Array.isArray(files) || files.length === 0)
106
+ throw new LibraryError("files_required", "choose at least one file");
107
+ if (files.length > MAX_FILES)
108
+ throw new LibraryError("too_many_files", `at most ${MAX_FILES} files in one handoff`);
109
+ const id = randomBytes(8).toString("hex");
110
+ const folder = join(this.deps.root, id);
111
+ const now = this.now();
112
+ const seen = new Set();
113
+ const accepted = files.map((raw, at) => {
114
+ const one = (raw ?? {});
115
+ const fileId = typeof one["fileId"] === "string" ? one["fileId"].slice(0, 200) : "";
116
+ const name = typeof one["name"] === "string" ? one["name"] : "";
117
+ const size = Number(one["size"]);
118
+ const url = typeof one["url"] === "string" ? one["url"] : "";
119
+ const mediaType = typeof one["mediaType"] === "string" ? one["mediaType"].slice(0, 100) : "";
120
+ if (!fileId || seen.has(fileId))
121
+ throw new LibraryError("invalid_file", `file ${at + 1} needs a fileId of its own`);
122
+ seen.add(fileId);
123
+ if (!name)
124
+ throw new LibraryError("invalid_file", `file ${at + 1} needs a name`);
125
+ if (!Number.isInteger(size) || size <= 0 || size > MAX_FILE_BYTES)
126
+ throw new LibraryError("invalid_file", `file ${at + 1} needs a size in bytes, up to 4 GiB`);
127
+ if (!(mediaType.startsWith("audio/") || playable(name)))
128
+ throw new LibraryError("not_audio", `${name} is not a file a station can play`);
129
+ let parsed;
130
+ try {
131
+ parsed = new URL(url);
132
+ }
133
+ catch {
134
+ throw new LibraryError("invalid_file", `file ${at + 1} needs a url to fetch it from`);
135
+ }
136
+ if (parsed.protocol !== "https:" && !(this.deps.allowHttp && parsed.protocol === "http:")) {
137
+ throw new LibraryError("insecure_url", "files are fetched over https only");
138
+ }
139
+ const tags = (one["metadata"] ?? {});
140
+ return {
141
+ fileId, name, size, mediaType,
142
+ complete: one["complete"] === true,
143
+ state: "pending", error: "", attempts: 0,
144
+ path: join(folder, safeName(name, at) + (extname(name) ? "" : ".mp3")),
145
+ bytes: 0, sha256: "",
146
+ url,
147
+ expiresAt: Number.isFinite(Number(one["expiresAt"])) ? Number(one["expiresAt"]) : 0,
148
+ tags: {
149
+ ...(typeof tags["title"] === "string" ? { title: tags["title"].slice(0, 300) } : {}),
150
+ ...(typeof tags["artist"] === "string" ? { artist: tags["artist"].slice(0, 300) } : {}),
151
+ ...(typeof tags["album"] === "string" ? { album: tags["album"].slice(0, 300) } : {}),
152
+ ...(Number.isInteger(tags["track"]) ? { track: tags["track"] } : {}),
153
+ },
154
+ };
155
+ });
156
+ const sourceInput = (input["source"] ?? {});
157
+ const name = (typeof input["name"] === "string" && input["name"].trim() ? input["name"].trim() : "From BitTorrented").slice(0, 120);
158
+ this.source();
159
+ // The draft, private and stopped, with every file it will have: a file
160
+ // that is not copied yet is simply not there, and a station plays what is.
161
+ const station = this.deps.stations.create({ name }, accepted.map((one) => one.path), owner, now, `handoff:${key}`);
162
+ const handoff = {
163
+ id, version: 1, provider: "bittorrented", idempotencyKey: key, owner, name,
164
+ source: {
165
+ torrentId: String(sourceInput["torrentId"] ?? "").slice(0, 200),
166
+ infohash: String(sourceInput["infohash"] ?? "").slice(0, 80),
167
+ collectionId: String(sourceInput["collectionId"] ?? "").slice(0, 200),
168
+ },
169
+ stationId: station.id,
170
+ files: accepted,
171
+ createdAt: now,
172
+ updatedAt: now,
173
+ };
174
+ this.handoffs.set(id, handoff);
175
+ this.save();
176
+ void this.run(id);
177
+ return { handoff, created: true };
178
+ }
179
+ /** New links for files whose links ran out, and another try for the failed ones. */
180
+ retry(id, input) {
181
+ const handoff = this.handoffs.get(id);
182
+ if (!handoff)
183
+ throw new LibraryError("not_found", "no such handoff", 404);
184
+ const fresh = Array.isArray(input["files"]) ? input["files"] : [];
185
+ for (const file of handoff.files) {
186
+ const update = fresh.find((one) => one["fileId"] === file.fileId);
187
+ if (update && typeof update["url"] === "string") {
188
+ const parsed = new URL(update["url"]);
189
+ if (parsed.protocol !== "https:" && !(this.deps.allowHttp && parsed.protocol === "http:"))
190
+ throw new LibraryError("insecure_url", "files are fetched over https only");
191
+ file.url = update["url"];
192
+ file.expiresAt = Number(update["expiresAt"] ?? 0) || 0;
193
+ }
194
+ if (file.state === "failed" || file.state === "expired") {
195
+ file.state = "pending";
196
+ file.error = "";
197
+ file.attempts = 0;
198
+ }
199
+ }
200
+ handoff.updatedAt = this.now();
201
+ this.save();
202
+ void this.run(id);
203
+ return handoff;
204
+ }
205
+ /** The copies this handoff made, removed; the station loses those files. */
206
+ remove(id) {
207
+ const handoff = this.handoffs.get(id);
208
+ if (!handoff)
209
+ throw new LibraryError("not_found", "no such handoff", 404);
210
+ const removed = handoff.files.filter((one) => one.state === "ready").length;
211
+ rmSync(join(this.deps.root, id), { recursive: true, force: true });
212
+ this.handoffs.delete(id);
213
+ this.save();
214
+ void this.deps.index.scan(this.source(), this.deps.probe).done;
215
+ return removed;
216
+ }
217
+ /** Every handoff that was part-way when the server stopped, carried on. */
218
+ resume() {
219
+ for (const handoff of this.handoffs.values()) {
220
+ let again = false;
221
+ for (const file of handoff.files) {
222
+ if (file.state === "downloading")
223
+ file.state = "pending";
224
+ if (file.state === "pending")
225
+ again = true;
226
+ }
227
+ if (again)
228
+ void this.run(handoff.id);
229
+ }
230
+ }
231
+ async run(id) {
232
+ if (this.running.has(id))
233
+ return;
234
+ this.running.add(id);
235
+ try {
236
+ const handoff = this.handoffs.get(id);
237
+ if (!handoff)
238
+ return;
239
+ let next = 0;
240
+ const queue = handoff.files.filter((one) => one.state === "pending");
241
+ const worker = async () => {
242
+ while (next < queue.length) {
243
+ const file = queue[next++];
244
+ await this.copy(handoff, file);
245
+ handoff.updatedAt = this.now();
246
+ this.save();
247
+ }
248
+ };
249
+ await Promise.all([worker(), worker()]);
250
+ // What was copied, read and indexed; then each file says what it is.
251
+ await this.deps.index.scan(this.source(), this.deps.probe).done;
252
+ for (const file of handoff.files) {
253
+ if (file.state !== "ready")
254
+ continue;
255
+ const item = this.deps.index.itemAt(file.path);
256
+ if (!item || item.state === "failed") {
257
+ file.state = "failed";
258
+ file.error = item?.error || "it could not be read after copying";
259
+ }
260
+ else if (item.state === "unsupported") {
261
+ file.state = "unsupported";
262
+ file.error = "there is nothing in it a station can play";
263
+ }
264
+ else if (item.state === "ready") {
265
+ // What BitTorrented knew, where the file itself says nothing.
266
+ const patch = {};
267
+ if (!item.title && file.tags.title)
268
+ patch["title"] = file.tags.title;
269
+ if (!item.artist && file.tags.artist)
270
+ patch["artist"] = file.tags.artist;
271
+ if (!item.album && file.tags.album)
272
+ patch["album"] = file.tags.album;
273
+ if (Object.keys(patch).length)
274
+ this.deps.index.correct(item.id, patch);
275
+ }
276
+ }
277
+ handoff.updatedAt = this.now();
278
+ this.save();
279
+ }
280
+ finally {
281
+ this.running.delete(id);
282
+ }
283
+ }
284
+ async copy(handoff, file) {
285
+ if (file.expiresAt && file.expiresAt <= this.now()) {
286
+ file.state = "expired";
287
+ file.error = "the link to this file ran out before it was copied; ask BitTorrented for a new one";
288
+ return;
289
+ }
290
+ file.state = "downloading";
291
+ file.attempts += 1;
292
+ const send = this.deps.fetcher ?? fetch;
293
+ const part = `${file.path}.part`;
294
+ try {
295
+ let at = file.url;
296
+ let response = null;
297
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
298
+ const refused = await this.deps.guard(at);
299
+ if (refused)
300
+ throw new Error(refused);
301
+ response = await send(at, { redirect: "manual", headers: { "user-agent": "nixamp-handoff" } });
302
+ const location = response.headers.get("location");
303
+ if (response.status >= 300 && response.status < 400 && location) {
304
+ at = new URL(location, at).toString();
305
+ continue;
306
+ }
307
+ break;
308
+ }
309
+ if (!response)
310
+ throw new Error("no answer");
311
+ if ([401, 403, 410].includes(response.status)) {
312
+ file.state = "expired";
313
+ file.error = `BitTorrented no longer lets this file be fetched (HTTP ${response.status}); ask for a new link`;
314
+ return;
315
+ }
316
+ if (!response.ok || !response.body)
317
+ throw new Error(`HTTP ${response.status}`);
318
+ const declared = Number(response.headers.get("content-length") ?? "0");
319
+ if (declared && declared !== file.size)
320
+ throw new Error(`the file is ${declared} bytes, not the ${file.size} it was said to be`);
321
+ mkdirSync(dirname(file.path), { recursive: true });
322
+ const hash = createHash("sha256");
323
+ let bytes = 0;
324
+ const counted = Readable.fromWeb(response.body).on("data", (chunk) => {
325
+ bytes += chunk.length;
326
+ if (bytes > file.size)
327
+ counted.destroy(new Error(`more than the ${file.size} bytes it was said to be`));
328
+ hash.update(chunk);
329
+ });
330
+ await pipeline(counted, createWriteStream(part, { mode: 0o640 }));
331
+ if (bytes !== file.size)
332
+ throw new Error(`incomplete: ${bytes} of ${file.size} bytes`);
333
+ renameSync(part, file.path);
334
+ file.bytes = bytes;
335
+ file.sha256 = hash.digest("hex");
336
+ file.state = "ready";
337
+ file.error = "";
338
+ // The grant has done its job.
339
+ file.url = "";
340
+ }
341
+ catch (error) {
342
+ rmSync(part, { force: true });
343
+ file.error = error instanceof Error ? error.message : String(error);
344
+ file.state = file.attempts < MAX_ATTEMPTS ? "pending" : "failed";
345
+ if (file.state === "pending")
346
+ await this.copy(handoff, file);
347
+ }
348
+ }
349
+ /** The bytes a ready copy takes on disk, for the owner's view. */
350
+ static sizeOn(path) {
351
+ try {
352
+ return statSync(path).size;
353
+ }
354
+ catch {
355
+ return 0;
356
+ }
357
+ }
358
+ }
@@ -62,6 +62,8 @@ export interface LibrarySource {
62
62
  enabled: boolean;
63
63
  createdAt: number;
64
64
  lastScan: ScanReport | null;
65
+ /** A folder Nixamp fills itself (imports), allowed where a library may not be. */
66
+ managed?: boolean;
65
67
  }
66
68
  /** What one probe found. */
67
69
  export interface Probed {
@@ -139,6 +141,13 @@ export declare class LibraryIndex {
139
141
  roots(): string[];
140
142
  /** A folder as a source: refused when it is not one a library may be. */
141
143
  addSource(root: string, owner: string, host?: string): LibrarySource;
144
+ /**
145
+ * A folder Nixamp fills itself -- copies handed over from BitTorrented --
146
+ * as a source. It lives beside the state, which is hidden and so refused
147
+ * as a library; it is allowed here because nothing in it was put there by
148
+ * anybody but Nixamp.
149
+ */
150
+ addManagedSource(root: string, host?: string): LibrarySource;
142
151
  /** The folder this server was started on, as a source, made once. */
143
152
  ensureSource(root: string, owner: string): LibrarySource | null;
144
153
  setEnabled(id: string, enabled: boolean): LibrarySource;
@@ -338,6 +338,25 @@ export class LibraryIndex {
338
338
  this.touch();
339
339
  return source;
340
340
  }
341
+ /**
342
+ * A folder Nixamp fills itself -- copies handed over from BitTorrented --
343
+ * as a source. It lives beside the state, which is hidden and so refused
344
+ * as a library; it is allowed here because nothing in it was put there by
345
+ * anybody but Nixamp.
346
+ */
347
+ addManagedSource(root, host = hostname()) {
348
+ const full = resolve(root);
349
+ const existing = this.sources().find((one) => one.root === full);
350
+ if (existing)
351
+ return existing;
352
+ const source = {
353
+ id: idOf("source", host, full), kind: "folder", root: full, owner: "", host,
354
+ enabled: true, createdAt: this.now(), lastScan: null, managed: true,
355
+ };
356
+ this.sourceList.set(source.id, source);
357
+ this.touch();
358
+ return source;
359
+ }
341
360
  /** The folder this server was started on, as a source, made once. */
342
361
  ensureSource(root, owner) {
343
362
  try {
@@ -19,6 +19,7 @@ export declare const SCOPES: {
19
19
  readonly email: "the address on your account";
20
20
  readonly parties: "host and join watch parties as you";
21
21
  readonly streams: "see the servers you run on nixamp, and what is live on them";
22
+ readonly stations: "manage the library and stations on servers you run, and send them music to play";
22
23
  readonly offline_access: "stay connected without asking again";
23
24
  };
24
25
  export type Scope = keyof typeof SCOPES;
@@ -35,6 +35,7 @@ export const SCOPES = {
35
35
  email: "the address on your account",
36
36
  parties: "host and join watch parties as you",
37
37
  streams: "see the servers you run on nixamp, and what is live on them",
38
+ stations: "manage the library and stations on servers you run, and send them music to play",
38
39
  offline_access: "stay connected without asking again",
39
40
  };
40
41
  export const SCOPE_NAMES = Object.keys(SCOPES);
package/dist/owner.js CHANGED
@@ -52,7 +52,10 @@ export class Owner {
52
52
  return "";
53
53
  }
54
54
  const body = (await answer.json());
55
- const id = typeof body.account?.id === "string" ? body.account.id : "";
55
+ // A token some app holds for the person counts here only if they gave
56
+ // that app the stations scope; otherwise it is not them at this door.
57
+ const scoped = body.grant === undefined || (Array.isArray(body.grant.scope) && body.grant.scope.includes("stations"));
58
+ const id = scoped && typeof body.account?.id === "string" ? body.account.id : "";
56
59
  this.cache.set(token, { id, at: now });
57
60
  return id;
58
61
  }
package/dist/server.d.ts CHANGED
@@ -23,6 +23,7 @@ import { PartyLine } from "./partyline.ts";
23
23
  import { HlsPackagers } from "./hls.ts";
24
24
  import { LibraryIndex, type Probed } from "./library-index.ts";
25
25
  import { ListenerStore } from "./library-user.ts";
26
+ import { Importer } from "./handoff.ts";
26
27
  import { StationRunner } from "./station-runner.ts";
27
28
  import { StationStore } from "./stations.ts";
28
29
  import { CompressionService } from "./compression/service.ts";
@@ -575,6 +576,8 @@ export interface HandlerOptions {
575
576
  retag: (tracks: Track[]) => void;
576
577
  listeners?: ListenerStore;
577
578
  };
579
+ /** Music handed over from BitTorrented. */
580
+ importer?: Importer;
578
581
  /** This server's stations and what keeps them on the air. */
579
582
  stations?: {
580
583
  store: StationStore;