nixamp 0.32.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.
- package/dist/channels.d.ts +11 -2
- package/dist/channels.js +26 -4
- 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 +3 -0
- package/dist/server.js +53 -4
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +25 -4
- package/dist/station-routes.js +54 -1
- package/package.json +1 -1
- package/src/channels.ts +23 -4
- 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 +55 -4
- package/src/station-page.ts +25 -4
- package/src/station-routes.ts +53 -1
- package/web/dist/assets/ar-DoW0wbjC.js +1 -0
- package/web/dist/assets/cs-CjKqCjy5.js +1 -0
- package/web/dist/assets/da-BQERb7Qz.js +1 -0
- package/web/dist/assets/de-CKKbRy6c.js +1 -0
- package/web/dist/assets/es-CL3v5Og9.js +1 -0
- package/web/dist/assets/fi-DgV81qNU.js +1 -0
- package/web/dist/assets/fr-BGumytzF.js +1 -0
- package/web/dist/assets/hi-CAQ9elqo.js +1 -0
- package/web/dist/assets/hls-3VKVEQE3-Ceri0flt.js +1 -0
- package/web/dist/assets/hu-UJNgJVWX.js +1 -0
- package/web/dist/assets/id-BqQOlrF-.js +1 -0
- package/web/dist/assets/it-Cs8G6mvW.js +1 -0
- package/web/dist/assets/{index-D-PZ4nGC.js → main-Y1Rvn1GX.js} +2 -2
- package/web/dist/assets/mpegts-LO6RVLD6-CPI8xaZ-.js +1 -0
- package/web/dist/assets/{mpegts-CabjhxTI.js → mpegts-jS_Diizz.js} +1 -1
- package/web/dist/assets/nl-BJXQ9nqo.js +1 -0
- package/web/dist/assets/radio-B58WgC1j.js +1 -0
- package/web/dist/assets/radio-CC8ihiO_.css +1 -0
- package/web/dist/assets/ru-XmVkvNY8.js +1 -0
- package/web/dist/assets/styles-D70tSrIe.js +1 -0
- package/web/dist/assets/styles-qC62Gona.css +1 -0
- package/web/dist/assets/sv-pVm9p66e.js +1 -0
- package/web/dist/assets/uk-4hcyaD3e.js +1 -0
- package/web/dist/assets/vi-B7MipBlv.js +1 -0
- package/web/dist/assets/zh-B5YVExOW.js +1 -0
- package/web/dist/index.html +4 -2
- package/web/dist/radio.html +134 -0
- package/web/dist/sw.js +26 -23
- package/web/dist/assets/ar-C_4gIadE.js +0 -1
- package/web/dist/assets/cs-B18R5adY.js +0 -1
- package/web/dist/assets/da-B6w5URQ2.js +0 -1
- package/web/dist/assets/de-Dlhr9ikG.js +0 -1
- package/web/dist/assets/es-BIpArFOC.js +0 -1
- package/web/dist/assets/fi-o9W4XS3X.js +0 -1
- package/web/dist/assets/fr-BgVwhPsP.js +0 -1
- package/web/dist/assets/hi-zIXqrXNC.js +0 -1
- package/web/dist/assets/hls-3VKVEQE3-5pAjDDLr.js +0 -1
- package/web/dist/assets/hu-Dpn_DIRi.js +0 -1
- package/web/dist/assets/id-Cu1V6gAm.js +0 -1
- package/web/dist/assets/index-Bo3utcDw.css +0 -1
- package/web/dist/assets/it-BNcoJEYQ.js +0 -1
- package/web/dist/assets/mpegts-LO6RVLD6-CRSxiR1J.js +0 -1
- package/web/dist/assets/nl-gvivlDEE.js +0 -1
- package/web/dist/assets/ru-BxQnVrHu.js +0 -1
- package/web/dist/assets/sv-BP1JhAR8.js +0 -1
- package/web/dist/assets/uk-ChYhgzlf.js +0 -1
- package/web/dist/assets/vi-CD7vzLwP.js +0 -1
- package/web/dist/assets/zh-BmGS-RcE.js +0 -1
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,6 +75,8 @@ 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";
|
|
@@ -1711,6 +1713,8 @@ async function readBytes(request: IncomingMessage, limit: number): Promise<Uint8
|
|
|
1711
1713
|
export interface HandlerOptions {
|
|
1712
1714
|
/** The library as an index: sources, items, search. */
|
|
1713
1715
|
library?: { index: LibraryIndex; probe: (path: string) => Promise<Probed>; retag: (tracks: Track[]) => void; listeners?: ListenerStore };
|
|
1716
|
+
/** Music handed over from BitTorrented. */
|
|
1717
|
+
importer?: Importer;
|
|
1714
1718
|
/** This server's stations and what keeps them on the air. */
|
|
1715
1719
|
stations?: { store: StationStore; runner: StationRunner };
|
|
1716
1720
|
web: string | null;
|
|
@@ -2737,6 +2741,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2737
2741
|
return;
|
|
2738
2742
|
}
|
|
2739
2743
|
|
|
2744
|
+
if (options.importer && path.startsWith("/api/v1/integrations/bittorrented/")) {
|
|
2745
|
+
const control = await controlOf();
|
|
2746
|
+
await handleHandoffs(request, response, path, {
|
|
2747
|
+
importer: options.importer,
|
|
2748
|
+
canWrite: control.allowed,
|
|
2749
|
+
owner: control.account || "owner",
|
|
2750
|
+
json,
|
|
2751
|
+
readBody,
|
|
2752
|
+
});
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2740
2756
|
if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
|
|
2741
2757
|
const control = await controlOf();
|
|
2742
2758
|
const encrypted = (request.socket as { encrypted?: boolean }).encrypted === true;
|
|
@@ -2842,12 +2858,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2842
2858
|
}
|
|
2843
2859
|
|
|
2844
2860
|
if (path === "/api/v1/auth/me") {
|
|
2845
|
-
const
|
|
2861
|
+
const token = tokenFrom(request.headers);
|
|
2862
|
+
const who = await accounts.whoIs(token);
|
|
2846
2863
|
if (who === null) {
|
|
2847
2864
|
json(response, 401, { error: "not signed in" });
|
|
2848
2865
|
return;
|
|
2849
2866
|
}
|
|
2850
|
-
|
|
2867
|
+
// An app acting for the person says so, with what it was allowed:
|
|
2868
|
+
// a server asking "is this my owner?" must not take a token handed to
|
|
2869
|
+
// some other app as the owner themselves.
|
|
2870
|
+
const grant = options.authServer ? await options.authServer.grantFor(token) : null;
|
|
2871
|
+
json(response, 200, { account: who, ...(grant ? { grant: { clientId: grant.clientId, scope: grant.scope } } : {}) });
|
|
2851
2872
|
return;
|
|
2852
2873
|
}
|
|
2853
2874
|
|
|
@@ -3995,11 +4016,28 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3995
4016
|
// point: a share link in a terminal you closed is a server you have lost.
|
|
3996
4017
|
if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers && options.accounts) {
|
|
3997
4018
|
const servers = options.servers;
|
|
3998
|
-
const
|
|
4019
|
+
const token = tokenFrom(request.headers);
|
|
4020
|
+
const who = await options.accounts.whoIs(token);
|
|
3999
4021
|
if (who === null) {
|
|
4000
4022
|
json(response, 401, { error: "not signed in" });
|
|
4001
4023
|
return;
|
|
4002
4024
|
}
|
|
4025
|
+
// An app acting for the person sees the list only with a scope that
|
|
4026
|
+
// says so, may not change it, and never gets a control key: those are
|
|
4027
|
+
// the person's, and an app that held one could drive the machine.
|
|
4028
|
+
const grant = options.authServer ? await options.authServer.grantFor(token) : null;
|
|
4029
|
+
if (grant) {
|
|
4030
|
+
if (!(grant.scope.includes("streams") || grant.scope.includes("stations"))) {
|
|
4031
|
+
json(response, 403, { error: "this app was not given access to your servers", code: "insufficient_scope" });
|
|
4032
|
+
return;
|
|
4033
|
+
}
|
|
4034
|
+
if (!(path === "/api/v1/servers" && request.method === "GET")) {
|
|
4035
|
+
json(response, 403, { error: "an app may list your servers, not change them", code: "insufficient_scope" });
|
|
4036
|
+
return;
|
|
4037
|
+
}
|
|
4038
|
+
json(response, 200, { servers: (await servers.list(who.id)).map(({ key: _key, ...shown }) => shown) });
|
|
4039
|
+
return;
|
|
4040
|
+
}
|
|
4003
4041
|
|
|
4004
4042
|
if (path === "/api/v1/servers" && request.method === "GET") {
|
|
4005
4043
|
json(response, 200, { servers: await servers.list(who.id) });
|
|
@@ -6048,7 +6086,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
6048
6086
|
return;
|
|
6049
6087
|
}
|
|
6050
6088
|
let file: string | null = null;
|
|
6051
|
-
|
|
6089
|
+
// The library and radio console is a page of its own, at its own names.
|
|
6090
|
+
if (/^\/(radio|library)\/?$/.test(path) && isFile(join(web, "radio.html"))) file = join(web, "radio.html");
|
|
6091
|
+
else if (isFile(direct)) file = direct;
|
|
6052
6092
|
else if (isFile(join(direct, "index.html"))) file = join(direct, "index.html");
|
|
6053
6093
|
// A single-page app: any unknown path is the shell, and the client routes.
|
|
6054
6094
|
else if (isFile(join(web, "index.html"))) file = join(web, "index.html");
|
|
@@ -6627,6 +6667,16 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6627
6667
|
log: (line) => console.log(line),
|
|
6628
6668
|
});
|
|
6629
6669
|
const stations = { store: stationStore, runner: stationRunner };
|
|
6670
|
+
// Music handed over from BitTorrented, copied into a folder of our own.
|
|
6671
|
+
const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
|
|
6672
|
+
index: libraryIndex,
|
|
6673
|
+
stations: stationStore,
|
|
6674
|
+
root: join(stateDir(), "imports", "bittorrented"),
|
|
6675
|
+
probe: probeFile,
|
|
6676
|
+
guard: (url) => refusedDestination(url),
|
|
6677
|
+
log: (line) => console.log(line),
|
|
6678
|
+
});
|
|
6679
|
+
importer.resume();
|
|
6630
6680
|
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
6631
6681
|
// channel's fragments into short files while somebody is asking for them.
|
|
6632
6682
|
// What things are, from nichedb.dev, remembered beside the keys so a
|
|
@@ -7220,6 +7270,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7220
7270
|
channels,
|
|
7221
7271
|
stations,
|
|
7222
7272
|
library,
|
|
7273
|
+
importer,
|
|
7223
7274
|
rememberChannels: remembering,
|
|
7224
7275
|
catalogs,
|
|
7225
7276
|
publishUrls: () => publishUrls,
|
package/src/station-page.ts
CHANGED
|
@@ -44,7 +44,7 @@ const SCRIPT = `
|
|
|
44
44
|
const $ = (s) => document.querySelector(s);
|
|
45
45
|
const audio = new Audio();
|
|
46
46
|
audio.preload = "none";
|
|
47
|
-
let wanted = false, retry = 0, timer = 0, last = null;
|
|
47
|
+
let wanted = false, retry = 0, timer = 0, last = null, live = false;
|
|
48
48
|
const chip = (state, text) => { const c = $("#state"); c.className = "chip " + state; c.textContent = text; };
|
|
49
49
|
const say = (text) => { $("#announce").textContent = text; };
|
|
50
50
|
const draw = (s) => {
|
|
@@ -53,9 +53,7 @@ const SCRIPT = `
|
|
|
53
53
|
$("#name").textContent = s.name;
|
|
54
54
|
$("#about").textContent = s.description || "";
|
|
55
55
|
$("#about").hidden = !s.description;
|
|
56
|
-
|
|
57
|
-
$("#title").textContent = playing ? (playing.title || "Untitled") : (s.state === "live" ? "On the air" : "");
|
|
58
|
-
$("#by").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(" · ") : "";
|
|
56
|
+
if (!live) title(s);
|
|
59
57
|
if (wanted && retry > 0) chip("reconnecting", "Reconnecting");
|
|
60
58
|
else if (s.state === "live") chip("live", "Live");
|
|
61
59
|
else if (s.state === "starting" || s.state === "recovering") chip("reconnecting", "Coming back");
|
|
@@ -64,6 +62,28 @@ const SCRIPT = `
|
|
|
64
62
|
$("#play").disabled = !(s.state === "live" || s.state === "recovering" || s.state === "starting");
|
|
65
63
|
$("#m3u").href = api + "/listen.m3u" + q;
|
|
66
64
|
};
|
|
65
|
+
const title = (s) => {
|
|
66
|
+
const playing = s.nowPlaying;
|
|
67
|
+
$("#title").textContent = playing ? (playing.title || "Untitled") : (s.state === "live" ? "On the air" : "");
|
|
68
|
+
$("#by").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(" · ") : "";
|
|
69
|
+
};
|
|
70
|
+
// What is ahead of the speaker: the sound the player holds and has not
|
|
71
|
+
// played yet. A new title waits that long, so it changes with the song.
|
|
72
|
+
const ahead = () => {
|
|
73
|
+
if (!wanted || audio.paused || audio.buffered.length === 0) return 0;
|
|
74
|
+
return Math.max(0, Math.min(30, audio.buffered.end(audio.buffered.length - 1) - audio.currentTime));
|
|
75
|
+
};
|
|
76
|
+
const listen = () => {
|
|
77
|
+
if (!window.EventSource) return;
|
|
78
|
+
const events = new EventSource(api + "/events" + q);
|
|
79
|
+
events.addEventListener("hello", (e) => { live = true; title(JSON.parse(e.data)); });
|
|
80
|
+
events.addEventListener("now", (e) => {
|
|
81
|
+
const s = JSON.parse(e.data);
|
|
82
|
+
setTimeout(() => { title(s); say(s.nowPlaying ? "Now playing " + (s.nowPlaying.title || "") : ""); }, ahead() * 1000);
|
|
83
|
+
});
|
|
84
|
+
events.addEventListener("state", () => poll());
|
|
85
|
+
events.onerror = () => { live = false; };
|
|
86
|
+
};
|
|
67
87
|
const poll = async () => {
|
|
68
88
|
try {
|
|
69
89
|
const r = await fetch(api + "/state" + q, { cache: "no-store" });
|
|
@@ -89,6 +109,7 @@ const SCRIPT = `
|
|
|
89
109
|
$("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
|
|
90
110
|
$("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
|
|
91
111
|
poll();
|
|
112
|
+
listen();
|
|
92
113
|
setInterval(poll, 5000);
|
|
93
114
|
})();
|
|
94
115
|
`;
|