nixamp 0.30.3 → 0.33.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 (90) hide show
  1. package/dist/channels.d.ts +11 -2
  2. package/dist/channels.js +26 -4
  3. package/dist/library-cli.d.ts +3 -0
  4. package/dist/library-cli.js +198 -0
  5. package/dist/library-index.d.ts +190 -0
  6. package/dist/library-index.js +643 -0
  7. package/dist/library-mcp.d.ts +230 -0
  8. package/dist/library-mcp.js +139 -0
  9. package/dist/library-routes.d.ts +34 -0
  10. package/dist/library-routes.js +258 -0
  11. package/dist/library-usage.d.ts +3 -0
  12. package/dist/library-usage.js +20 -0
  13. package/dist/library-user.d.ts +67 -0
  14. package/dist/library-user.js +204 -0
  15. package/dist/main.js +8 -0
  16. package/dist/mcp.js +5 -2
  17. package/dist/server.d.ts +9 -0
  18. package/dist/server.js +87 -13
  19. package/dist/station-cli.js +4 -3
  20. package/dist/station-mcp.d.ts +17 -0
  21. package/dist/station-mcp.js +25 -19
  22. package/dist/station-page.d.ts +1 -1
  23. package/dist/station-page.js +25 -4
  24. package/dist/station-routes.js +54 -1
  25. package/dist/station-usage.d.ts +1 -1
  26. package/dist/station-usage.js +1 -1
  27. package/package.json +1 -1
  28. package/src/channels.ts +23 -4
  29. package/src/library-cli.ts +175 -0
  30. package/src/library-index.ts +709 -0
  31. package/src/library-mcp.ts +132 -0
  32. package/src/library-routes.ts +282 -0
  33. package/src/library-usage.ts +21 -0
  34. package/src/library-user.ts +218 -0
  35. package/src/main.ts +7 -0
  36. package/src/mcp.ts +5 -2
  37. package/src/server.ts +86 -14
  38. package/src/station-cli.ts +4 -3
  39. package/src/station-mcp.ts +22 -15
  40. package/src/station-page.ts +25 -4
  41. package/src/station-routes.ts +53 -1
  42. package/src/station-usage.ts +1 -1
  43. package/web/dist/assets/ar-4kD-mTIA.js +1 -0
  44. package/web/dist/assets/cs-CziXiWNY.js +1 -0
  45. package/web/dist/assets/da-CyjJ8UOr.js +1 -0
  46. package/web/dist/assets/de-DxwYa667.js +1 -0
  47. package/web/dist/assets/es-QgMTJeSc.js +1 -0
  48. package/web/dist/assets/fi-Clj-jHhR.js +1 -0
  49. package/web/dist/assets/fr-nH_gzSFv.js +1 -0
  50. package/web/dist/assets/hi-D7djQ6_C.js +1 -0
  51. package/web/dist/assets/hls-3VKVEQE3-BaWVouMg.js +1 -0
  52. package/web/dist/assets/hu-H5Q3L8bD.js +1 -0
  53. package/web/dist/assets/id-40z_EQdf.js +1 -0
  54. package/web/dist/assets/it-C9Fcgywl.js +1 -0
  55. package/web/dist/assets/{index-CDBIr8Tz.js → main-BJUyBy0h.js} +2 -2
  56. package/web/dist/assets/{mpegts-63ylMmge.js → mpegts-BlmyNlBC.js} +1 -1
  57. package/web/dist/assets/mpegts-LO6RVLD6-CAuth8C5.js +1 -0
  58. package/web/dist/assets/nl-CrL4Psk9.js +1 -0
  59. package/web/dist/assets/radio-B58WgC1j.js +1 -0
  60. package/web/dist/assets/radio-CC8ihiO_.css +1 -0
  61. package/web/dist/assets/ru-C84JS0ch.js +1 -0
  62. package/web/dist/assets/styles-D70tSrIe.js +1 -0
  63. package/web/dist/assets/styles-qC62Gona.css +1 -0
  64. package/web/dist/assets/sv-DtMcl0sf.js +1 -0
  65. package/web/dist/assets/uk-dOLf99vZ.js +1 -0
  66. package/web/dist/assets/vi-BWeQVRZ8.js +1 -0
  67. package/web/dist/assets/zh-Cnv3Qpux.js +1 -0
  68. package/web/dist/index.html +4 -2
  69. package/web/dist/radio.html +134 -0
  70. package/web/dist/sw.js +26 -23
  71. package/web/dist/assets/ar-ClCIWtVb.js +0 -1
  72. package/web/dist/assets/cs-CVG4U823.js +0 -1
  73. package/web/dist/assets/da-igdiQUk8.js +0 -1
  74. package/web/dist/assets/de-CHPsc6lc.js +0 -1
  75. package/web/dist/assets/es-AvbMspQX.js +0 -1
  76. package/web/dist/assets/fi-5JGUOm3W.js +0 -1
  77. package/web/dist/assets/fr-YfMwk1FR.js +0 -1
  78. package/web/dist/assets/hi-BUZuQTkp.js +0 -1
  79. package/web/dist/assets/hls-3VKVEQE3-BCXawNaW.js +0 -1
  80. package/web/dist/assets/hu-C7on-yUl.js +0 -1
  81. package/web/dist/assets/id-pIrzVeOi.js +0 -1
  82. package/web/dist/assets/index-Bo3utcDw.css +0 -1
  83. package/web/dist/assets/it-Bl9xk5B4.js +0 -1
  84. package/web/dist/assets/mpegts-LO6RVLD6-DBakOXU5.js +0 -1
  85. package/web/dist/assets/nl-b5BUih3i.js +0 -1
  86. package/web/dist/assets/ru-BlAkbEPp.js +0 -1
  87. package/web/dist/assets/sv-BEKvqELG.js +0 -1
  88. package/web/dist/assets/uk-JY8Dh56p.js +0 -1
  89. package/web/dist/assets/vi-DbtxuQPL.js +0 -1
  90. package/web/dist/assets/zh-4x0DI5jO.js +0 -1
@@ -0,0 +1,643 @@
1
+ /**
2
+ * The library, written down.
3
+ *
4
+ * The player used to learn its library afresh at every start: walk the tree,
5
+ * then ffprobe every file one by one, and only when the last file had
6
+ * answered hand any tags to the player. On a library of a few thousand files
7
+ * that is a long time of file names, every restart. Here what was learned is
8
+ * kept beside the keys (`library-<port>.json`) and a rescan asks only about
9
+ * files that are new or have changed (size or modification time), so a
10
+ * restart has its tags at once and a rescan of an unchanged library probes
11
+ * nothing.
12
+ *
13
+ * Every file is an item with an id that stays the same for as long as the
14
+ * file stays where it is, and a state:
15
+ *
16
+ * pending found, not yet asked about
17
+ * ready decodes; has a duration
18
+ * unsupported ffprobe answered, but there is nothing in it to play
19
+ * failed ffprobe could not read it (the reason is kept)
20
+ * unavailable was here, is not now (kept, with its corrections, in case
21
+ * the disk comes back)
22
+ *
23
+ * Corrections an owner makes to tags are kept apart from what the file says
24
+ * and survive any number of rescans. Removing a source forgets its items; it
25
+ * never touches a file.
26
+ *
27
+ * Sources are folders on this machine, inside approved roots: never the
28
+ * whole filesystem, the home directory or a hidden folder (library.ts says
29
+ * why), and a symlink is followed only when it lands back inside the source.
30
+ */
31
+ import { createHash } from "node:crypto";
32
+ import { spawn } from "node:child_process";
33
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
34
+ import { lstat, readdir, realpath, stat } from "node:fs/promises";
35
+ import { hostname } from "node:os";
36
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
37
+ import { protocolArgs } from "./decoder-policy.js";
38
+ import { forbiddenLibrary } from "./library.js";
39
+ import { compareNames, playable } from "./playlist.js";
40
+ export const TAG_FIELDS = ["title", "artist", "album", "albumArtist", "track", "disc", "genre", "year"];
41
+ export class LibraryError extends Error {
42
+ code;
43
+ status;
44
+ constructor(code, message, status = 400) {
45
+ super(message);
46
+ this.code = code;
47
+ this.status = status;
48
+ }
49
+ }
50
+ const BLANK_TAGS = { title: "", artist: "", album: "", albumArtist: "", track: 0, disc: 0, genre: "", year: 0 };
51
+ function idOf(...parts) {
52
+ return createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, 16);
53
+ }
54
+ function fold(text) {
55
+ return text.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase();
56
+ }
57
+ /** "3/12" is 3; "" is 0. */
58
+ function leadingNumber(value) {
59
+ const match = /^\s*(\d+)/.exec(value ?? "");
60
+ return match ? Number(match[1]) : 0;
61
+ }
62
+ /** Tags as ffprobe names them, in any case, from the container or the first audio stream. */
63
+ export function tagsFrom(raw) {
64
+ const get = (...names) => {
65
+ for (const name of names) {
66
+ const hit = Object.entries(raw).find(([key]) => key.toLowerCase() === name);
67
+ if (hit && hit[1].trim())
68
+ return hit[1].trim().slice(0, 300);
69
+ }
70
+ return "";
71
+ };
72
+ return {
73
+ title: get("title"),
74
+ artist: get("artist", "album_artist", "albumartist"),
75
+ album: get("album"),
76
+ albumArtist: get("album_artist", "albumartist", "album artist"),
77
+ track: leadingNumber(get("track", "tracknumber")),
78
+ disc: leadingNumber(get("disc", "discnumber")),
79
+ genre: get("genre"),
80
+ year: leadingNumber(get("date", "year", "originaldate")),
81
+ };
82
+ }
83
+ /** What ffprobe says about one file, read as a library needs it. */
84
+ export function readProbe(json) {
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(json);
88
+ }
89
+ catch {
90
+ return { state: "failed", error: "ffprobe said something that is not JSON", tags: {}, duration: 0, codec: "", bitrate: 0, sampleRate: 0, channels: 0, video: false, hasArt: false };
91
+ }
92
+ const streams = parsed.streams ?? [];
93
+ const audio = streams.find((one) => one.codec_type === "audio");
94
+ const pictures = streams.filter((one) => one.codec_type === "video");
95
+ const art = pictures.some((one) => one.disposition?.attached_pic === 1);
96
+ const moving = pictures.some((one) => one.disposition?.attached_pic !== 1);
97
+ const duration = Number(parsed.format?.duration ?? 0);
98
+ const tags = tagsFrom({ ...(audio?.tags ?? {}), ...(parsed.format?.tags ?? {}) });
99
+ if (!audio && !moving) {
100
+ return { state: "unsupported", error: "no sound or picture in it to play", tags, duration: 0, codec: "", bitrate: 0, sampleRate: 0, channels: 0, video: false, hasArt: art };
101
+ }
102
+ return {
103
+ state: "ready",
104
+ error: "",
105
+ tags,
106
+ duration: Number.isFinite(duration) && duration > 0 ? duration : 0,
107
+ codec: audio?.codec_name ?? "",
108
+ bitrate: Math.round(Number(audio?.bit_rate ?? parsed.format?.bit_rate ?? 0) / 1000) || 0,
109
+ sampleRate: Number(audio?.sample_rate ?? 0) || 0,
110
+ channels: audio?.channels ?? 0,
111
+ video: moving,
112
+ hasArt: art,
113
+ };
114
+ }
115
+ /** Ask ffprobe about one file, without holding the event loop and never for ever. */
116
+ export function probeItem(tools, path, timeoutMs = 20_000) {
117
+ const [command, ...prefix] = tools.ffprobe;
118
+ const failed = (error) => ({ state: "failed", error, tags: {}, duration: 0, codec: "", bitrate: 0, sampleRate: 0, channels: 0, video: false, hasArt: false });
119
+ if (!command)
120
+ return Promise.resolve(failed("no ffprobe on this machine"));
121
+ return new Promise((done) => {
122
+ const child = spawn(command, [
123
+ ...prefix, "-v", "error", "-print_format", "json", "-show_format", "-show_streams",
124
+ ...protocolArgs(path), path,
125
+ ], { stdio: ["ignore", "pipe", "pipe"] });
126
+ let out = "";
127
+ let err = "";
128
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
129
+ timer.unref?.();
130
+ child.stdout.on("data", (chunk) => { if (out.length < 4 * 1024 * 1024)
131
+ out += chunk.toString("utf8"); });
132
+ child.stderr.on("data", (chunk) => { if (err.length < 2000)
133
+ err += chunk.toString("utf8"); });
134
+ child.on("error", (error) => { clearTimeout(timer); done(failed(error.message)); });
135
+ child.on("close", (code, signal) => {
136
+ clearTimeout(timer);
137
+ if (signal)
138
+ return done(failed("ffprobe took too long"));
139
+ if (code !== 0)
140
+ return done(failed(err.trim().split("\n").pop()?.slice(0, 300) || `ffprobe exited ${code}`));
141
+ done(readProbe(out));
142
+ });
143
+ });
144
+ }
145
+ /**
146
+ * Every playable file under a root, in name order, without leaving it.
147
+ *
148
+ * Directory symlinks are followed only when they land inside the root and
149
+ * have not been seen before, so a link back to a parent is not a loop and a
150
+ * link to somewhere else is not a door. Counted, so the scan can say how many
151
+ * were left out.
152
+ */
153
+ export async function walkSource(root, every = 200) {
154
+ const base = await realpath(root);
155
+ const inside = (real) => real === base || real.startsWith(base.endsWith(sep) ? base : base + sep);
156
+ const seen = new Set([base]);
157
+ const files = [];
158
+ let outside = 0;
159
+ let since = 0;
160
+ const walk = async (dir) => {
161
+ let entries;
162
+ try {
163
+ entries = (await readdir(dir)).sort(compareNames);
164
+ }
165
+ catch {
166
+ return;
167
+ }
168
+ for (const entry of entries) {
169
+ if (entry.startsWith("."))
170
+ continue;
171
+ if (++since >= every) {
172
+ since = 0;
173
+ await new Promise((next) => setImmediate(next));
174
+ }
175
+ const full = join(dir, entry);
176
+ let link;
177
+ try {
178
+ link = await lstat(full);
179
+ }
180
+ catch {
181
+ continue;
182
+ }
183
+ if (link.isSymbolicLink()) {
184
+ let real;
185
+ try {
186
+ real = await realpath(full);
187
+ }
188
+ catch {
189
+ continue;
190
+ }
191
+ if (!inside(real)) {
192
+ outside += 1;
193
+ continue;
194
+ }
195
+ let target;
196
+ try {
197
+ target = await stat(real);
198
+ }
199
+ catch {
200
+ continue;
201
+ }
202
+ if (target.isDirectory()) {
203
+ if (seen.has(real))
204
+ continue;
205
+ seen.add(real);
206
+ await walk(full);
207
+ }
208
+ else if (target.isFile() && playable(full)) {
209
+ files.push(full);
210
+ }
211
+ continue;
212
+ }
213
+ if (link.isDirectory()) {
214
+ await walk(full);
215
+ }
216
+ else if (link.isFile() && playable(full)) {
217
+ files.push(full);
218
+ }
219
+ }
220
+ };
221
+ await walk(root);
222
+ return { files, outside };
223
+ }
224
+ /** An item as it should read: the owner's corrections over the file's tags over its name. */
225
+ export function shown(item) {
226
+ const tags = { ...BLANK_TAGS };
227
+ for (const field of TAG_FIELDS) {
228
+ const corrected = item.override[field];
229
+ tags[field] = corrected !== undefined && corrected !== "" ? corrected : item[field];
230
+ }
231
+ if (!tags.title)
232
+ tags.title = basename(item.path);
233
+ return tags;
234
+ }
235
+ /** The library of one server, in one file beside its keys. */
236
+ export class LibraryIndex {
237
+ file;
238
+ io;
239
+ now;
240
+ items = new Map();
241
+ byPath = new Map();
242
+ sourceList = new Map();
243
+ timer = null;
244
+ dirty = false;
245
+ jobs = new Map();
246
+ constructor(file, io = {
247
+ read: (path) => { try {
248
+ return readFileSync(path, "utf8");
249
+ }
250
+ catch {
251
+ return null;
252
+ } },
253
+ write: (path, body) => {
254
+ mkdirSync(dirname(path), { recursive: true });
255
+ const temporary = `${path}.${process.pid}.tmp`;
256
+ writeFileSync(temporary, body, { mode: 0o600 });
257
+ renameSync(temporary, path);
258
+ },
259
+ }, now = Date.now) {
260
+ this.file = file;
261
+ this.io = io;
262
+ this.now = now;
263
+ const body = io.read(file);
264
+ if (body === null)
265
+ return;
266
+ try {
267
+ const parsed = JSON.parse(body);
268
+ for (const source of parsed.sources ?? []) {
269
+ // A scan that was running when the server stopped did not finish.
270
+ if (source.lastScan?.status === "running")
271
+ source.lastScan = { ...source.lastScan, status: "cancelled" };
272
+ this.sourceList.set(source.id, source);
273
+ }
274
+ for (const item of parsed.items ?? []) {
275
+ if (!item || typeof item.id !== "string" || !this.sourceList.has(item.sourceId))
276
+ continue;
277
+ this.items.set(item.id, { ...BLANK_TAGS, ...item, override: item.override ?? {}, error: item.error ?? "" });
278
+ this.byPath.set(item.path, item.id);
279
+ }
280
+ }
281
+ catch {
282
+ // Unreadable: started over, and rewritten at the next save.
283
+ }
284
+ }
285
+ static fileFor(stateDir, port) {
286
+ return join(stateDir, `library-${port}.json`);
287
+ }
288
+ /** Written soon, not now: a scan changes thousands of items. */
289
+ touch() {
290
+ this.dirty = true;
291
+ if (this.timer)
292
+ return;
293
+ this.timer = setTimeout(() => this.flush(), 2_000);
294
+ this.timer.unref?.();
295
+ }
296
+ flush() {
297
+ if (this.timer)
298
+ clearTimeout(this.timer);
299
+ this.timer = null;
300
+ if (!this.dirty)
301
+ return;
302
+ this.dirty = false;
303
+ this.io.write(this.file, JSON.stringify({ version: 1, sources: [...this.sourceList.values()], items: [...this.items.values()] }));
304
+ }
305
+ sources() {
306
+ return [...this.sourceList.values()].sort((a, b) => a.createdAt - b.createdAt);
307
+ }
308
+ source(id) {
309
+ return this.sourceList.get(id);
310
+ }
311
+ /** The folders this server's files have to be inside: every enabled source. */
312
+ roots() {
313
+ return this.sources().filter((one) => one.enabled).map((one) => one.root);
314
+ }
315
+ /** A folder as a source: refused when it is not one a library may be. */
316
+ addSource(root, owner, host = hostname()) {
317
+ const full = resolve(root);
318
+ const why = forbiddenLibrary(full);
319
+ if (why)
320
+ throw new LibraryError("forbidden_root", `will not index ${why}`);
321
+ const existing = this.sources().find((one) => one.root === full);
322
+ if (existing)
323
+ return existing;
324
+ const nested = this.sources().find((one) => full.startsWith(one.root + sep) || one.root.startsWith(full + sep));
325
+ if (nested)
326
+ throw new LibraryError("overlapping_source", `that folder overlaps the source ${nested.root}`, 409);
327
+ const source = {
328
+ id: idOf("source", host, full),
329
+ kind: "folder",
330
+ root: full,
331
+ owner,
332
+ host,
333
+ enabled: true,
334
+ createdAt: this.now(),
335
+ lastScan: null,
336
+ };
337
+ this.sourceList.set(source.id, source);
338
+ this.touch();
339
+ return source;
340
+ }
341
+ /** The folder this server was started on, as a source, made once. */
342
+ ensureSource(root, owner) {
343
+ try {
344
+ return this.addSource(root, owner);
345
+ }
346
+ catch {
347
+ return null;
348
+ }
349
+ }
350
+ setEnabled(id, enabled) {
351
+ const source = this.sourceList.get(id);
352
+ if (!source)
353
+ throw new LibraryError("not_found", "no such source", 404);
354
+ source.enabled = enabled;
355
+ this.touch();
356
+ return source;
357
+ }
358
+ /** Forget a source and what was learned from it. Its files are not touched. */
359
+ removeSource(id) {
360
+ if (!this.sourceList.has(id))
361
+ throw new LibraryError("not_found", "no such source", 404);
362
+ this.jobs.forEach((job) => { if (job.report.sourceId === id)
363
+ job.abort.abort(); });
364
+ let forgotten = 0;
365
+ for (const item of [...this.items.values()]) {
366
+ if (item.sourceId !== id)
367
+ continue;
368
+ this.items.delete(item.id);
369
+ this.byPath.delete(item.path);
370
+ forgotten += 1;
371
+ }
372
+ this.sourceList.delete(id);
373
+ this.touch();
374
+ return forgotten;
375
+ }
376
+ item(id) {
377
+ return this.items.get(id);
378
+ }
379
+ itemAt(path) {
380
+ const id = this.byPath.get(path);
381
+ return id ? this.items.get(id) : undefined;
382
+ }
383
+ get size() {
384
+ return this.items.size;
385
+ }
386
+ job(id) {
387
+ return this.jobs.get(id)?.report;
388
+ }
389
+ running(sourceId) {
390
+ return [...this.jobs.values()].map((one) => one.report).find((one) => one.sourceId === sourceId && one.status === "running");
391
+ }
392
+ /**
393
+ * Look at a source again: new files are found, changed ones asked about,
394
+ * gone ones marked unavailable. Returns at once with the job; `done`
395
+ * settles when it is finished. One scan per source at a time.
396
+ */
397
+ scan(sourceId, probe, options = {}) {
398
+ const source = this.sourceList.get(sourceId);
399
+ if (!source)
400
+ throw new LibraryError("not_found", "no such source", 404);
401
+ if (!source.enabled)
402
+ throw new LibraryError("source_disabled", "that source is turned off", 409);
403
+ const already = this.running(sourceId);
404
+ if (already)
405
+ return { report: already, done: Promise.resolve(already) };
406
+ const report = {
407
+ jobId: idOf("scan", sourceId, String(this.now()), String(Math.random())),
408
+ sourceId, status: "running", startedAt: this.now(), finishedAt: 0,
409
+ found: 0, unchanged: 0, probed: 0, ready: 0, failed: 0, unsupported: 0, missing: 0, outside: 0, error: "",
410
+ };
411
+ const abort = new AbortController();
412
+ this.jobs.set(report.jobId, { report, abort });
413
+ source.lastScan = report;
414
+ const done = this.runScan(source, report, probe, abort.signal, options).catch((error) => {
415
+ report.status = "failed";
416
+ report.error = error instanceof Error ? error.message : String(error);
417
+ report.finishedAt = this.now();
418
+ this.touch();
419
+ return report;
420
+ });
421
+ return { report, done };
422
+ }
423
+ async runScan(source, report, probe, signal, options) {
424
+ const { files, outside } = await walkSource(source.root);
425
+ report.found = files.length;
426
+ report.outside = outside;
427
+ const now = this.now();
428
+ const present = new Set();
429
+ const toProbe = [];
430
+ for (const path of files) {
431
+ if (signal.aborted)
432
+ break;
433
+ let info;
434
+ try {
435
+ info = await stat(path);
436
+ }
437
+ catch {
438
+ continue;
439
+ }
440
+ present.add(path);
441
+ const rel = relative(source.root, path).split(sep).join("/");
442
+ const id = idOf("item", source.id, rel);
443
+ const known = this.items.get(id);
444
+ if (known && !options.force && known.size === info.size && known.mtimeMs === info.mtimeMs && known.state !== "pending" && known.state !== "unavailable") {
445
+ known.lastSeen = now;
446
+ report.unchanged += 1;
447
+ continue;
448
+ }
449
+ const item = known ?? {
450
+ ...BLANK_TAGS, id, sourceId: source.id, path, rel, size: 0, mtimeMs: 0, state: "pending", error: "",
451
+ duration: 0, codec: "", bitrate: 0, sampleRate: 0, channels: 0, video: false, hasArt: false,
452
+ override: {}, firstSeen: now, lastSeen: now, probedAt: 0,
453
+ };
454
+ item.path = path;
455
+ item.size = info.size;
456
+ item.mtimeMs = info.mtimeMs;
457
+ item.lastSeen = now;
458
+ item.state = "pending";
459
+ this.items.set(id, item);
460
+ this.byPath.set(path, id);
461
+ toProbe.push(item);
462
+ }
463
+ for (const item of this.items.values()) {
464
+ if (item.sourceId !== source.id || present.has(item.path) || signal.aborted)
465
+ continue;
466
+ if (item.state !== "unavailable") {
467
+ item.state = "unavailable";
468
+ item.error = "the file is not there any more";
469
+ report.missing += 1;
470
+ }
471
+ }
472
+ this.touch();
473
+ // A couple at a time: enough to get through a library, not so many that
474
+ // a stream being served at the same time is starved of the disk.
475
+ const width = Math.max(1, Math.min(4, options.concurrency ?? 2));
476
+ let next = 0;
477
+ let batch = [];
478
+ const worker = async () => {
479
+ while (next < toProbe.length && !signal.aborted) {
480
+ const item = toProbe[next++];
481
+ const found = await probe(item.path);
482
+ item.state = found.state;
483
+ item.error = found.error;
484
+ for (const field of TAG_FIELDS)
485
+ item[field] = found.tags[field] ?? BLANK_TAGS[field];
486
+ item.duration = found.duration;
487
+ item.codec = found.codec;
488
+ item.bitrate = found.bitrate;
489
+ item.sampleRate = found.sampleRate;
490
+ item.channels = found.channels;
491
+ item.video = found.video;
492
+ item.hasArt = found.hasArt;
493
+ item.probedAt = this.now();
494
+ report.probed += 1;
495
+ if (found.state === "ready")
496
+ report.ready += 1;
497
+ else if (found.state === "failed")
498
+ report.failed += 1;
499
+ else
500
+ report.unsupported += 1;
501
+ batch.push(item);
502
+ if (batch.length >= 200) {
503
+ options.onProgress?.(batch);
504
+ batch = [];
505
+ this.touch();
506
+ }
507
+ }
508
+ };
509
+ await Promise.all(Array.from({ length: width }, worker));
510
+ if (batch.length)
511
+ options.onProgress?.(batch);
512
+ report.status = signal.aborted ? "cancelled" : "done";
513
+ report.finishedAt = this.now();
514
+ this.touch();
515
+ return report;
516
+ }
517
+ /** The owner's corrections; an empty value clears that field's correction. */
518
+ correct(id, patch) {
519
+ const item = this.items.get(id);
520
+ if (!item)
521
+ throw new LibraryError("not_found", "no such item", 404);
522
+ for (const [field, value] of Object.entries(patch)) {
523
+ if (!TAG_FIELDS.includes(field))
524
+ throw new LibraryError("invalid_field", `${field} is not a tag that can be corrected`);
525
+ const numeric = field === "track" || field === "disc" || field === "year";
526
+ if (value === null || value === "" || value === 0) {
527
+ delete item.override[field];
528
+ continue;
529
+ }
530
+ if (numeric ? !(Number.isInteger(value) && value >= 0 && value < 100_000) : typeof value !== "string") {
531
+ throw new LibraryError("invalid_value", `${field} must be ${numeric ? "a whole number" : "text"}`);
532
+ }
533
+ item.override[field] = numeric ? value : value.trim().slice(0, 300);
534
+ }
535
+ this.touch();
536
+ return item;
537
+ }
538
+ search(query = {}) {
539
+ const words = fold(query.q ?? "").split(/\s+/).filter(Boolean);
540
+ const state = query.state ?? "ready";
541
+ const exact = (value, against) => value === undefined || fold(against) === fold(value);
542
+ const folder = query.folder?.replace(/^\/+|\/+$/g, "");
543
+ const hits = [];
544
+ for (const item of this.items.values()) {
545
+ if (state !== "all" && item.state !== state)
546
+ continue;
547
+ if (query.sourceId && item.sourceId !== query.sourceId)
548
+ continue;
549
+ if (folder !== undefined && folder !== "" && !item.rel.startsWith(`${folder}/`))
550
+ continue;
551
+ const tags = shown(item);
552
+ if (!exact(query.artist, tags.artist) || !exact(query.album, tags.album) || !exact(query.genre, tags.genre))
553
+ continue;
554
+ if (words.length) {
555
+ const haystack = fold(`${tags.title} ${tags.artist} ${tags.albumArtist} ${tags.album} ${tags.genre} ${item.rel}`);
556
+ if (!words.every((word) => haystack.includes(word)))
557
+ continue;
558
+ }
559
+ hits.push(item);
560
+ }
561
+ const sort = query.sort ?? "path";
562
+ const by = (pick) => (a, b) => {
563
+ const x = pick(a), y = pick(b);
564
+ return typeof x === "number" && typeof y === "number" ? x - y : compareNames(String(x), String(y));
565
+ };
566
+ if (sort === "recent")
567
+ hits.sort((a, b) => b.firstSeen - a.firstSeen);
568
+ else if (sort === "title")
569
+ hits.sort(by((one) => shown(one).title));
570
+ else if (sort === "artist")
571
+ hits.sort((a, b) => by((one) => shown(one).artist)(a, b) || by((one) => shown(one).album)(a, b) || by((one) => shown(one).disc)(a, b) || by((one) => shown(one).track)(a, b));
572
+ else if (sort === "album")
573
+ hits.sort((a, b) => by((one) => shown(one).album)(a, b) || by((one) => shown(one).disc)(a, b) || by((one) => shown(one).track)(a, b) || by((one) => one.rel)(a, b));
574
+ else
575
+ hits.sort(by((one) => one.rel));
576
+ const limit = Math.min(500, Math.max(1, query.limit ?? 50));
577
+ const offset = Math.max(0, query.offset ?? 0);
578
+ return { items: hits.slice(offset, offset + limit), total: hits.length };
579
+ }
580
+ /** Grouped: every artist, album, genre or top-level folder, with how many ready items. */
581
+ browse(kind, options = {}) {
582
+ const counts = new Map();
583
+ for (const item of this.items.values()) {
584
+ if (item.state !== "ready")
585
+ continue;
586
+ const tags = shown(item);
587
+ let name;
588
+ let key;
589
+ let artist;
590
+ if (kind === "artists") {
591
+ name = tags.albumArtist || tags.artist;
592
+ key = fold(name);
593
+ }
594
+ else if (kind === "albums") {
595
+ name = tags.album;
596
+ artist = tags.albumArtist || tags.artist;
597
+ key = `${fold(name)}\0${fold(artist)}`;
598
+ }
599
+ else if (kind === "genres") {
600
+ name = tags.genre;
601
+ key = fold(name);
602
+ }
603
+ else {
604
+ name = item.rel.includes("/") ? item.rel.slice(0, item.rel.indexOf("/")) : "";
605
+ key = name;
606
+ }
607
+ if (!name)
608
+ continue;
609
+ const entry = counts.get(key) ?? { name, count: 0, ...(artist !== undefined ? { artist } : {}) };
610
+ entry.count += 1;
611
+ counts.set(key, entry);
612
+ }
613
+ const words = fold(options.q ?? "").split(/\s+/).filter(Boolean);
614
+ const groups = [...counts.values()]
615
+ .filter((one) => words.every((word) => fold(`${one.name} ${one.artist ?? ""}`).includes(word)))
616
+ .sort((a, b) => compareNames(a.name, b.name));
617
+ const limit = Math.min(500, Math.max(1, options.limit ?? 100));
618
+ const offset = Math.max(0, options.offset ?? 0);
619
+ return { groups: groups.slice(offset, offset + limit), total: groups.length };
620
+ }
621
+ /** How the player should name each file it already has, from what is known. */
622
+ tracksFor(paths) {
623
+ const out = [];
624
+ for (const path of paths) {
625
+ const item = this.itemAt(path);
626
+ if (!item || item.state !== "ready")
627
+ continue;
628
+ out.push(trackOf(item));
629
+ }
630
+ return out;
631
+ }
632
+ counts() {
633
+ const totals = { pending: 0, ready: 0, unsupported: 0, failed: 0, unavailable: 0 };
634
+ for (const item of this.items.values())
635
+ totals[item.state] += 1;
636
+ return totals;
637
+ }
638
+ }
639
+ /** A library item as the player's Track. */
640
+ export function trackOf(item) {
641
+ const tags = shown(item);
642
+ return { path: item.path, title: tags.title, artist: tags.artist, album: tags.album, duration: item.duration };
643
+ }