nixamp 0.30.1 → 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 (69) hide show
  1. package/dist/channels.d.ts +12 -0
  2. package/dist/channels.js +8 -0
  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.d.ts +3 -0
  17. package/dist/mcp.js +10 -1
  18. package/dist/playlist-input.d.ts +10 -0
  19. package/dist/playlist-input.js +9 -0
  20. package/dist/server.d.ts +9 -0
  21. package/dist/server.js +83 -12
  22. package/dist/station-cli.js +4 -3
  23. package/dist/station-mcp.d.ts +311 -0
  24. package/dist/station-mcp.js +179 -0
  25. package/dist/station-page.d.ts +1 -1
  26. package/dist/station-page.js +2 -1
  27. package/dist/station-routes.js +2 -1
  28. package/dist/station-usage.d.ts +1 -1
  29. package/dist/station-usage.js +1 -1
  30. package/package.json +1 -1
  31. package/src/channels.ts +10 -0
  32. package/src/library-cli.ts +175 -0
  33. package/src/library-index.ts +709 -0
  34. package/src/library-mcp.ts +132 -0
  35. package/src/library-routes.ts +282 -0
  36. package/src/library-usage.ts +21 -0
  37. package/src/library-user.ts +218 -0
  38. package/src/main.ts +7 -0
  39. package/src/mcp.ts +12 -1
  40. package/src/playlist-input.ts +9 -0
  41. package/src/server.ts +83 -13
  42. package/src/station-cli.ts +4 -3
  43. package/src/station-mcp.ts +190 -0
  44. package/src/station-page.ts +2 -1
  45. package/src/station-routes.ts +2 -1
  46. package/src/station-usage.ts +1 -1
  47. package/web/dist/assets/{ar-CTUDxy1J.js → ar-C_4gIadE.js} +1 -1
  48. package/web/dist/assets/{cs-m4JTaO3o.js → cs-B18R5adY.js} +1 -1
  49. package/web/dist/assets/{da-DHnJElYB.js → da-B6w5URQ2.js} +1 -1
  50. package/web/dist/assets/{de-DkxPohnf.js → de-Dlhr9ikG.js} +1 -1
  51. package/web/dist/assets/{es-oY_G83d7.js → es-BIpArFOC.js} +1 -1
  52. package/web/dist/assets/{fi-fCO8XRdg.js → fi-o9W4XS3X.js} +1 -1
  53. package/web/dist/assets/{fr-CSupf-IN.js → fr-BgVwhPsP.js} +1 -1
  54. package/web/dist/assets/{hi-Dve7PJsC.js → hi-zIXqrXNC.js} +1 -1
  55. package/web/dist/assets/{hls-3VKVEQE3-D5-AkKSR.js → hls-3VKVEQE3-5pAjDDLr.js} +1 -1
  56. package/web/dist/assets/{hu-D1KYamzV.js → hu-Dpn_DIRi.js} +1 -1
  57. package/web/dist/assets/{id-Cofp_4aQ.js → id-Cu1V6gAm.js} +1 -1
  58. package/web/dist/assets/{index-7mCQZGNF.js → index-D-PZ4nGC.js} +2 -2
  59. package/web/dist/assets/{it-62iXQWQD.js → it-BNcoJEYQ.js} +1 -1
  60. package/web/dist/assets/{mpegts-BT5ESacM.js → mpegts-CabjhxTI.js} +1 -1
  61. package/web/dist/assets/{mpegts-LO6RVLD6-BgHd8PFT.js → mpegts-LO6RVLD6-CRSxiR1J.js} +1 -1
  62. package/web/dist/assets/{nl-CzRRG-CN.js → nl-gvivlDEE.js} +1 -1
  63. package/web/dist/assets/{ru-OykC1LJQ.js → ru-BxQnVrHu.js} +1 -1
  64. package/web/dist/assets/{sv-B8FXBDHm.js → sv-BP1JhAR8.js} +1 -1
  65. package/web/dist/assets/{uk-DhAJBjwH.js → uk-ChYhgzlf.js} +1 -1
  66. package/web/dist/assets/{vi-CdBSRReC.js → vi-CD7vzLwP.js} +1 -1
  67. package/web/dist/assets/{zh-wGWNyBEZ.js → zh-BmGS-RcE.js} +1 -1
  68. package/web/dist/index.html +1 -1
  69. package/web/dist/sw.js +22 -22
@@ -232,6 +232,12 @@ export declare class Channel {
232
232
  private playlistInput;
233
233
  /** On to the next entry of a list of files. False when this is not one. */
234
234
  skip(): boolean;
235
+ /** An entry's tags, as read when it was put on. */
236
+ tagsOf(file: string): {
237
+ title: string;
238
+ artist: string;
239
+ album: string;
240
+ } | undefined;
235
241
  private failures;
236
242
  private timer;
237
243
  /** Fires when a pulled source has said nothing for STALL. */
@@ -497,6 +503,12 @@ export declare class Channels {
497
503
  setThrough(provider: ThroughProvider | null): void;
498
504
  /** The kind of a channel, for a relay to say what it is carrying. */
499
505
  kindOf(id: string): "audio" | "video" | undefined;
506
+ /** An entry's tags on a channel playing a list of files. */
507
+ tagsOf(id: string, file: string): {
508
+ title: string;
509
+ artist: string;
510
+ album: string;
511
+ } | undefined;
500
512
  /** The next entry, now, on a channel playing a list of files. */
501
513
  skip(id: string): boolean;
502
514
  stop(id: string): boolean;
package/dist/channels.js CHANGED
@@ -141,6 +141,10 @@ export class Channel {
141
141
  skip() {
142
142
  return this.playlistInput?.skip() ?? false;
143
143
  }
144
+ /** An entry's tags, as read when it was put on. */
145
+ tagsOf(file) {
146
+ return this.playlistInput?.tags.get(file);
147
+ }
144
148
  failures = 0;
145
149
  timer = null;
146
150
  /** Fires when a pulled source has said nothing for STALL. */
@@ -1168,6 +1172,10 @@ export class Channels {
1168
1172
  kindOf(id) {
1169
1173
  return this.open.get(id)?.info.kind;
1170
1174
  }
1175
+ /** An entry's tags on a channel playing a list of files. */
1176
+ tagsOf(id, file) {
1177
+ return this.open.get(id)?.tagsOf(file);
1178
+ }
1171
1179
  /** The next entry, now, on a channel playing a list of files. */
1172
1180
  skip(id) {
1173
1181
  return this.open.get(id)?.skip() ?? false;
@@ -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;