nixamp 0.30.3 → 0.32.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 (57) hide show
  1. package/dist/library-cli.d.ts +3 -0
  2. package/dist/library-cli.js +198 -0
  3. package/dist/library-index.d.ts +190 -0
  4. package/dist/library-index.js +643 -0
  5. package/dist/library-mcp.d.ts +230 -0
  6. package/dist/library-mcp.js +139 -0
  7. package/dist/library-routes.d.ts +34 -0
  8. package/dist/library-routes.js +258 -0
  9. package/dist/library-usage.d.ts +3 -0
  10. package/dist/library-usage.js +20 -0
  11. package/dist/library-user.d.ts +67 -0
  12. package/dist/library-user.js +204 -0
  13. package/dist/main.js +8 -0
  14. package/dist/mcp.js +5 -2
  15. package/dist/server.d.ts +9 -0
  16. package/dist/server.js +83 -12
  17. package/dist/station-cli.js +4 -3
  18. package/dist/station-mcp.d.ts +17 -0
  19. package/dist/station-mcp.js +25 -19
  20. package/dist/station-usage.d.ts +1 -1
  21. package/dist/station-usage.js +1 -1
  22. package/package.json +1 -1
  23. package/src/library-cli.ts +175 -0
  24. package/src/library-index.ts +709 -0
  25. package/src/library-mcp.ts +132 -0
  26. package/src/library-routes.ts +282 -0
  27. package/src/library-usage.ts +21 -0
  28. package/src/library-user.ts +218 -0
  29. package/src/main.ts +7 -0
  30. package/src/mcp.ts +5 -2
  31. package/src/server.ts +83 -13
  32. package/src/station-cli.ts +4 -3
  33. package/src/station-mcp.ts +22 -15
  34. package/src/station-usage.ts +1 -1
  35. package/web/dist/assets/{ar-ClCIWtVb.js → ar-C_4gIadE.js} +1 -1
  36. package/web/dist/assets/{cs-CVG4U823.js → cs-B18R5adY.js} +1 -1
  37. package/web/dist/assets/{da-igdiQUk8.js → da-B6w5URQ2.js} +1 -1
  38. package/web/dist/assets/{de-CHPsc6lc.js → de-Dlhr9ikG.js} +1 -1
  39. package/web/dist/assets/{es-AvbMspQX.js → es-BIpArFOC.js} +1 -1
  40. package/web/dist/assets/{fi-5JGUOm3W.js → fi-o9W4XS3X.js} +1 -1
  41. package/web/dist/assets/{fr-YfMwk1FR.js → fr-BgVwhPsP.js} +1 -1
  42. package/web/dist/assets/{hi-BUZuQTkp.js → hi-zIXqrXNC.js} +1 -1
  43. package/web/dist/assets/{hls-3VKVEQE3-BCXawNaW.js → hls-3VKVEQE3-5pAjDDLr.js} +1 -1
  44. package/web/dist/assets/{hu-C7on-yUl.js → hu-Dpn_DIRi.js} +1 -1
  45. package/web/dist/assets/{id-pIrzVeOi.js → id-Cu1V6gAm.js} +1 -1
  46. package/web/dist/assets/{index-CDBIr8Tz.js → index-D-PZ4nGC.js} +2 -2
  47. package/web/dist/assets/{it-Bl9xk5B4.js → it-BNcoJEYQ.js} +1 -1
  48. package/web/dist/assets/{mpegts-63ylMmge.js → mpegts-CabjhxTI.js} +1 -1
  49. package/web/dist/assets/{mpegts-LO6RVLD6-DBakOXU5.js → mpegts-LO6RVLD6-CRSxiR1J.js} +1 -1
  50. package/web/dist/assets/{nl-b5BUih3i.js → nl-gvivlDEE.js} +1 -1
  51. package/web/dist/assets/{ru-BlAkbEPp.js → ru-BxQnVrHu.js} +1 -1
  52. package/web/dist/assets/{sv-BEKvqELG.js → sv-BP1JhAR8.js} +1 -1
  53. package/web/dist/assets/{uk-JY8Dh56p.js → uk-ChYhgzlf.js} +1 -1
  54. package/web/dist/assets/{vi-DbtxuQPL.js → vi-CD7vzLwP.js} +1 -1
  55. package/web/dist/assets/{zh-4x0DI5jO.js → zh-BmGS-RcE.js} +1 -1
  56. package/web/dist/index.html +1 -1
  57. package/web/dist/sw.js +22 -22
@@ -0,0 +1,3 @@
1
+ import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.ts";
2
+ export { LIBRARY_COMMANDS, LIBRARY_USAGE };
3
+ export declare function libraryTool(argv: string[]): Promise<number>;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `nixamp library <command>`: the index, from a terminal or a script.
3
+ *
4
+ * `nixamp library ~/Music` still sets the folder a daemon serves (library.ts);
5
+ * these are the questions you ask once it is running. Same conventions as
6
+ * `nixamp station`: the daemon on this machine unless --url/--key, --json for
7
+ * scripts, exit 0 done, 1 refused, 2 usage, 3 unreachable.
8
+ */
9
+ import { resolveTarget } from "./admin.js";
10
+ import { KEY_HEADER } from "./share.js";
11
+ import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.js";
12
+ export { LIBRARY_COMMANDS, LIBRARY_USAGE };
13
+ function flag(argv, name) {
14
+ const at = argv.indexOf(name);
15
+ return at === -1 ? undefined : argv[at + 1];
16
+ }
17
+ /** Words that are not flags or flag values. */
18
+ function words(argv) {
19
+ const out = [];
20
+ for (let at = 0; at < argv.length; at++) {
21
+ const one = argv[at];
22
+ if (one.startsWith("--")) {
23
+ if (!["--json", "--force", "--wait"].includes(one))
24
+ at += 1;
25
+ continue;
26
+ }
27
+ out.push(one);
28
+ }
29
+ return out;
30
+ }
31
+ export async function libraryTool(argv) {
32
+ const [command] = argv;
33
+ const json = argv.includes("--json");
34
+ let target;
35
+ try {
36
+ target = resolveTarget(argv);
37
+ }
38
+ catch (error) {
39
+ console.error(error.message);
40
+ return 3;
41
+ }
42
+ const headers = { "content-type": "application/json", ...(target.key ? { [KEY_HEADER]: target.key } : {}) };
43
+ const call = async (method, path, body) => {
44
+ let response;
45
+ try {
46
+ response = await fetch(`${target.url}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
47
+ }
48
+ catch (error) {
49
+ throw Object.assign(new Error(`could not reach ${target.url}: ${error.message}`), { unreachable: true });
50
+ }
51
+ const parsed = (await response.json().catch(() => ({})));
52
+ return { status: response.status, body: parsed };
53
+ };
54
+ const out = (answer, line) => {
55
+ if (json)
56
+ console.log(JSON.stringify(answer.body, null, 2));
57
+ else if (answer.status < 400)
58
+ console.log(line(answer.body));
59
+ if (answer.status >= 400) {
60
+ if (!json)
61
+ console.error(`nixamp: ${String(answer.body["error"] ?? "refused")} (${String(answer.body["code"] ?? answer.status)})`);
62
+ return 1;
63
+ }
64
+ return 0;
65
+ };
66
+ const scanLine = (scan) => scan ? `${String(scan["status"])}: ${String(scan["found"])} found, ${String(scan["unchanged"])} unchanged, ${String(scan["probed"])} read, ${String(scan["failed"])} failed, ${String(scan["missing"])} gone` : "never scanned";
67
+ const itemLine = (item) => `${String(item["id"])} ${String(item["artist"] || "?")} — ${String(item["title"])}${item["album"] ? ` (${String(item["album"])})` : ""}${item["state"] !== "ready" ? ` [${String(item["state"])}]` : ""}`;
68
+ const rest = words(argv.slice(1));
69
+ try {
70
+ switch (command) {
71
+ case "status":
72
+ return out(await call("GET", "/api/v1/library"), (body) => {
73
+ const counts = body["counts"];
74
+ const sources = body["sources"] ?? [];
75
+ return [
76
+ `${String(body["items"])} items: ${Object.entries(counts).filter(([, n]) => n > 0).map(([state, n]) => `${n} ${state}`).join(", ") || "none"}`,
77
+ ...sources.map((one) => ` ${String(one["id"])} ${String(one["root"] ?? "")}${one["enabled"] ? "" : " (off)"}\n last scan ${scanLine(one["lastScan"])}`),
78
+ ].join("\n");
79
+ });
80
+ case "sources":
81
+ return out(await call("GET", "/api/v1/library/sources"), (body) => (body["sources"] ?? []).map((one) => `${String(one["id"])} ${String(one["root"] ?? "")}${one["enabled"] ? "" : " (off)"}`).join("\n") || "No sources.");
82
+ case "add": {
83
+ const [path] = rest;
84
+ if (!path) {
85
+ console.error("nixamp library add needs a folder");
86
+ return 2;
87
+ }
88
+ const { resolve } = await import("node:path");
89
+ const { homedir } = await import("node:os");
90
+ return out(await call("POST", "/api/v1/library/sources", { root: resolve(path.replace(/^~(?=$|\/)/, homedir())) }), (body) => `Indexing ${String(body["source"]["root"])} as ${String(body["source"]["id"])}.`);
91
+ }
92
+ case "scan": {
93
+ let [source] = rest;
94
+ if (!source) {
95
+ const listed = await call("GET", "/api/v1/library/sources");
96
+ const all = listed.body["sources"] ?? [];
97
+ if (all.length !== 1) {
98
+ console.error("Which source? `nixamp library sources` lists them.");
99
+ return 2;
100
+ }
101
+ source = String(all[0]["id"]);
102
+ }
103
+ const started = await call("POST", `/api/v1/library/sources/${source}/scan`, { force: argv.includes("--force") });
104
+ if (started.status >= 400 || !argv.includes("--wait"))
105
+ return out(started, (body) => `Scanning: job ${String(body["job"]["jobId"])}.`);
106
+ const jobId = String(started.body["job"]["jobId"]);
107
+ for (;;) {
108
+ const polled = await call("GET", `/api/v1/library/jobs/${jobId}`);
109
+ const job = polled.body["job"];
110
+ if (polled.status >= 400 || !job || job["status"] !== "running")
111
+ return out(polled, () => scanLine(job));
112
+ await new Promise((next) => setTimeout(next, 1000));
113
+ }
114
+ }
115
+ case "search": {
116
+ const query = new URLSearchParams({ q: rest.join(" "), limit: flag(argv, "--limit") ?? "20", sort: "artist" });
117
+ for (const name of ["artist", "album", "genre", "state", "folder"]) {
118
+ const value = flag(argv, `--${name}`);
119
+ if (value)
120
+ query.set(name, value);
121
+ }
122
+ return out(await call("GET", `/api/v1/library/items?${query}`), (body) => {
123
+ const items = body["items"] ?? [];
124
+ return items.length ? `${items.map(itemLine).join("\n")}\n${String(body["total"])} in all` : "Nothing matches.";
125
+ });
126
+ }
127
+ case "show": {
128
+ const [item] = rest;
129
+ if (!item) {
130
+ console.error("nixamp library show needs an item id");
131
+ return 2;
132
+ }
133
+ return out(await call("GET", `/api/v1/library/items/${item}`), (body) => JSON.stringify(body["item"], null, 2));
134
+ }
135
+ case "fix": {
136
+ const [item] = rest;
137
+ if (!item) {
138
+ console.error("nixamp library fix needs an item id");
139
+ return 2;
140
+ }
141
+ const patch = {};
142
+ for (const name of ["title", "artist", "album", "albumArtist", "genre"]) {
143
+ const value = flag(argv, `--${name}`);
144
+ if (value !== undefined)
145
+ patch[name] = value;
146
+ }
147
+ for (const name of ["track", "disc", "year"]) {
148
+ const value = flag(argv, `--${name}`);
149
+ if (value !== undefined)
150
+ patch[name] = Number(value);
151
+ }
152
+ if (Object.keys(patch).length === 0) {
153
+ console.error("nixamp library fix needs at least one of --title --artist --album --genre --track --disc --year");
154
+ return 2;
155
+ }
156
+ return out(await call("PATCH", `/api/v1/library/items/${item}`, patch), (body) => itemLine(body["item"]));
157
+ }
158
+ case "remove": {
159
+ const [source] = rest;
160
+ if (!source) {
161
+ console.error("nixamp library remove needs a source id");
162
+ return 2;
163
+ }
164
+ return out(await call("DELETE", `/api/v1/library/sources/${source}`), (body) => `Forgot ${String(body["forgotten"])} items. No file was touched.`);
165
+ }
166
+ case "playlists":
167
+ return out(await call("GET", "/api/v1/library/playlists"), (body) => (body["playlists"] ?? []).map((one) => `${String(one["id"])} ${String(one["name"])} (${String(one["count"])})`).join("\n") || "No playlists yet.");
168
+ case "playlist": {
169
+ const [verb, first, ...others] = rest;
170
+ const showList = (body) => {
171
+ const list = body["playlist"];
172
+ const items = list["items"] ?? [];
173
+ return [`${String(list["id"])} ${String(list["name"])} (revision ${String(list["revision"])})`, ...items.map((one, at) => ` ${at + 1}. ${itemLine(one)}`)].join("\n");
174
+ };
175
+ if (verb === "new" && first)
176
+ return out(await call("POST", "/api/v1/library/playlists", { name: first, items: others }), showList);
177
+ if (verb === "show" && first)
178
+ return out(await call("GET", `/api/v1/library/playlists/${first}`), showList);
179
+ if (verb === "add" && first && others.length)
180
+ return out(await call("POST", `/api/v1/library/playlists/${first}/items`, { items: others }), showList);
181
+ if (verb === "delete" && first)
182
+ return out(await call("DELETE", `/api/v1/library/playlists/${first}`), () => `Deleted playlist ${first}.`);
183
+ console.error("nixamp library playlist new NAME [ITEM...] | show ID | add ID ITEM... | delete ID");
184
+ return 2;
185
+ }
186
+ default:
187
+ console.log(LIBRARY_USAGE);
188
+ return 2;
189
+ }
190
+ }
191
+ catch (error) {
192
+ if (error.unreachable) {
193
+ console.error(`nixamp: ${error.message}`);
194
+ return 3;
195
+ }
196
+ throw error;
197
+ }
198
+ }
@@ -0,0 +1,190 @@
1
+ import type { Track, Tools } from "./audio.ts";
2
+ export type ItemState = "pending" | "ready" | "unsupported" | "failed" | "unavailable";
3
+ export interface Tags {
4
+ title: string;
5
+ artist: string;
6
+ album: string;
7
+ albumArtist: string;
8
+ track: number;
9
+ disc: number;
10
+ genre: string;
11
+ year: number;
12
+ }
13
+ export declare const TAG_FIELDS: readonly (keyof Tags)[];
14
+ export interface LibraryItem extends Tags {
15
+ id: string;
16
+ sourceId: string;
17
+ /** Absolute; never shown to anybody but the owner. */
18
+ path: string;
19
+ /** Inside the source, with forward slashes. */
20
+ rel: string;
21
+ size: number;
22
+ mtimeMs: number;
23
+ state: ItemState;
24
+ error: string;
25
+ /** Seconds; 0 until known. */
26
+ duration: number;
27
+ codec: string;
28
+ bitrate: number;
29
+ sampleRate: number;
30
+ channels: number;
31
+ video: boolean;
32
+ hasArt: boolean;
33
+ /** The owner's corrections, applied over what the file says. */
34
+ override: Partial<Tags>;
35
+ firstSeen: number;
36
+ lastSeen: number;
37
+ probedAt: number;
38
+ }
39
+ export interface ScanReport {
40
+ jobId: string;
41
+ sourceId: string;
42
+ status: "running" | "done" | "failed" | "cancelled";
43
+ startedAt: number;
44
+ finishedAt: number;
45
+ found: number;
46
+ unchanged: number;
47
+ probed: number;
48
+ ready: number;
49
+ failed: number;
50
+ unsupported: number;
51
+ missing: number;
52
+ /** Paths that lead outside the source (symlinks), left out. */
53
+ outside: number;
54
+ error: string;
55
+ }
56
+ export interface LibrarySource {
57
+ id: string;
58
+ kind: "folder";
59
+ root: string;
60
+ owner: string;
61
+ host: string;
62
+ enabled: boolean;
63
+ createdAt: number;
64
+ lastScan: ScanReport | null;
65
+ }
66
+ /** What one probe found. */
67
+ export interface Probed {
68
+ state: "ready" | "unsupported" | "failed";
69
+ error: string;
70
+ tags: Partial<Tags>;
71
+ duration: number;
72
+ codec: string;
73
+ bitrate: number;
74
+ sampleRate: number;
75
+ channels: number;
76
+ video: boolean;
77
+ hasArt: boolean;
78
+ }
79
+ export declare class LibraryError extends Error {
80
+ readonly code: string;
81
+ readonly status: number;
82
+ constructor(code: string, message: string, status?: number);
83
+ }
84
+ /** Tags as ffprobe names them, in any case, from the container or the first audio stream. */
85
+ export declare function tagsFrom(raw: Record<string, string>): Partial<Tags>;
86
+ /** What ffprobe says about one file, read as a library needs it. */
87
+ export declare function readProbe(json: string): Probed;
88
+ /** Ask ffprobe about one file, without holding the event loop and never for ever. */
89
+ export declare function probeItem(tools: Tools, path: string, timeoutMs?: number): Promise<Probed>;
90
+ /**
91
+ * Every playable file under a root, in name order, without leaving it.
92
+ *
93
+ * Directory symlinks are followed only when they land inside the root and
94
+ * have not been seen before, so a link back to a parent is not a loop and a
95
+ * link to somewhere else is not a door. Counted, so the scan can say how many
96
+ * were left out.
97
+ */
98
+ export declare function walkSource(root: string, every?: number): Promise<{
99
+ files: string[];
100
+ outside: number;
101
+ }>;
102
+ export interface SearchQuery {
103
+ q?: string;
104
+ artist?: string;
105
+ album?: string;
106
+ genre?: string;
107
+ folder?: string;
108
+ sourceId?: string;
109
+ state?: ItemState | "all";
110
+ sort?: "path" | "title" | "artist" | "album" | "recent";
111
+ limit?: number;
112
+ offset?: number;
113
+ }
114
+ export type Browse = "artists" | "albums" | "genres" | "folders";
115
+ /** An item as it should read: the owner's corrections over the file's tags over its name. */
116
+ export declare function shown(item: LibraryItem): Tags;
117
+ /** The library of one server, in one file beside its keys. */
118
+ export declare class LibraryIndex {
119
+ private readonly file;
120
+ private readonly io;
121
+ private readonly now;
122
+ private items;
123
+ private byPath;
124
+ private sourceList;
125
+ private timer;
126
+ private dirty;
127
+ private jobs;
128
+ constructor(file: string, io?: {
129
+ read: (path: string) => string | null;
130
+ write: (path: string, body: string) => void;
131
+ }, now?: () => number);
132
+ static fileFor(stateDir: string, port: number): string;
133
+ /** Written soon, not now: a scan changes thousands of items. */
134
+ private touch;
135
+ flush(): void;
136
+ sources(): LibrarySource[];
137
+ source(id: string): LibrarySource | undefined;
138
+ /** The folders this server's files have to be inside: every enabled source. */
139
+ roots(): string[];
140
+ /** A folder as a source: refused when it is not one a library may be. */
141
+ addSource(root: string, owner: string, host?: string): LibrarySource;
142
+ /** The folder this server was started on, as a source, made once. */
143
+ ensureSource(root: string, owner: string): LibrarySource | null;
144
+ setEnabled(id: string, enabled: boolean): LibrarySource;
145
+ /** Forget a source and what was learned from it. Its files are not touched. */
146
+ removeSource(id: string): number;
147
+ item(id: string): LibraryItem | undefined;
148
+ itemAt(path: string): LibraryItem | undefined;
149
+ get size(): number;
150
+ job(id: string): ScanReport | undefined;
151
+ running(sourceId: string): ScanReport | undefined;
152
+ /**
153
+ * Look at a source again: new files are found, changed ones asked about,
154
+ * gone ones marked unavailable. Returns at once with the job; `done`
155
+ * settles when it is finished. One scan per source at a time.
156
+ */
157
+ scan(sourceId: string, probe: (path: string) => Promise<Probed>, options?: {
158
+ concurrency?: number;
159
+ onProgress?: (changed: LibraryItem[]) => void;
160
+ force?: boolean;
161
+ }): {
162
+ report: ScanReport;
163
+ done: Promise<ScanReport>;
164
+ };
165
+ private runScan;
166
+ /** The owner's corrections; an empty value clears that field's correction. */
167
+ correct(id: string, patch: Record<string, unknown>): LibraryItem;
168
+ search(query?: SearchQuery): {
169
+ items: LibraryItem[];
170
+ total: number;
171
+ };
172
+ /** Grouped: every artist, album, genre or top-level folder, with how many ready items. */
173
+ browse(kind: Browse, options?: {
174
+ q?: string;
175
+ limit?: number;
176
+ offset?: number;
177
+ }): {
178
+ groups: Array<{
179
+ name: string;
180
+ count: number;
181
+ artist?: string;
182
+ }>;
183
+ total: number;
184
+ };
185
+ /** How the player should name each file it already has, from what is known. */
186
+ tracksFor(paths: string[]): Track[];
187
+ counts(): Record<ItemState, number>;
188
+ }
189
+ /** A library item as the player's Track. */
190
+ export declare function trackOf(item: LibraryItem): Track;