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
@@ -0,0 +1,230 @@
1
+ /**
2
+ * The library as MCP tools: what is here, find something, look again.
3
+ *
4
+ * Same target and credential rules as the station tools (station-mcp.ts):
5
+ * this machine's daemon by its control key, or another server as its owner.
6
+ */
7
+ import { type StationToolOptions } from "./station-mcp.ts";
8
+ export declare const LIBRARY_TOOLS: ({
9
+ name: string;
10
+ description: string;
11
+ inputSchema: {
12
+ type: string;
13
+ properties: {
14
+ server: {
15
+ type: "string";
16
+ description: string;
17
+ };
18
+ artist?: undefined;
19
+ album?: undefined;
20
+ genre?: undefined;
21
+ folder?: undefined;
22
+ state?: undefined;
23
+ offset?: undefined;
24
+ kind?: undefined;
25
+ q?: undefined;
26
+ limit?: undefined;
27
+ name?: undefined;
28
+ id?: undefined;
29
+ items?: undefined;
30
+ at?: undefined;
31
+ source?: undefined;
32
+ force?: undefined;
33
+ };
34
+ required?: undefined;
35
+ };
36
+ } | {
37
+ name: string;
38
+ description: string;
39
+ inputSchema: {
40
+ type: string;
41
+ properties: {
42
+ server: {
43
+ type: "string";
44
+ description: string;
45
+ };
46
+ q: {
47
+ readonly type: "string";
48
+ };
49
+ artist: {
50
+ readonly type: "string";
51
+ };
52
+ album: {
53
+ readonly type: "string";
54
+ };
55
+ genre: {
56
+ readonly type: "string";
57
+ };
58
+ folder: {
59
+ readonly type: "string";
60
+ };
61
+ state: {
62
+ type: string;
63
+ enum: string[];
64
+ };
65
+ limit: {
66
+ type: string;
67
+ };
68
+ offset: {
69
+ type: string;
70
+ };
71
+ kind?: undefined;
72
+ name?: undefined;
73
+ id?: undefined;
74
+ items?: undefined;
75
+ at?: undefined;
76
+ source?: undefined;
77
+ force?: undefined;
78
+ };
79
+ required?: undefined;
80
+ };
81
+ } | {
82
+ name: string;
83
+ description: string;
84
+ inputSchema: {
85
+ type: string;
86
+ properties: {
87
+ artist?: undefined;
88
+ album?: undefined;
89
+ genre?: undefined;
90
+ folder?: undefined;
91
+ state?: undefined;
92
+ offset?: undefined;
93
+ server: {
94
+ type: "string";
95
+ description: string;
96
+ };
97
+ kind: {
98
+ type: string;
99
+ enum: string[];
100
+ };
101
+ q: {
102
+ readonly type: "string";
103
+ };
104
+ limit: {
105
+ type: string;
106
+ };
107
+ name?: undefined;
108
+ id?: undefined;
109
+ items?: undefined;
110
+ at?: undefined;
111
+ source?: undefined;
112
+ force?: undefined;
113
+ };
114
+ required: string[];
115
+ };
116
+ } | {
117
+ name: string;
118
+ description: string;
119
+ inputSchema: {
120
+ type: string;
121
+ properties: {
122
+ artist?: undefined;
123
+ album?: undefined;
124
+ genre?: undefined;
125
+ folder?: undefined;
126
+ state?: undefined;
127
+ offset?: undefined;
128
+ kind?: undefined;
129
+ q?: undefined;
130
+ limit?: undefined;
131
+ server: {
132
+ type: "string";
133
+ description: string;
134
+ };
135
+ name: {
136
+ readonly type: "string";
137
+ };
138
+ items: {
139
+ type: string;
140
+ items: {
141
+ readonly type: "string";
142
+ };
143
+ };
144
+ id?: undefined;
145
+ at?: undefined;
146
+ source?: undefined;
147
+ force?: undefined;
148
+ };
149
+ required: string[];
150
+ };
151
+ } | {
152
+ name: string;
153
+ description: string;
154
+ inputSchema: {
155
+ type: string;
156
+ properties: {
157
+ artist?: undefined;
158
+ album?: undefined;
159
+ genre?: undefined;
160
+ folder?: undefined;
161
+ state?: undefined;
162
+ offset?: undefined;
163
+ kind?: undefined;
164
+ q?: undefined;
165
+ limit?: undefined;
166
+ name?: undefined;
167
+ server: {
168
+ type: "string";
169
+ description: string;
170
+ };
171
+ id: {
172
+ readonly type: "string";
173
+ };
174
+ items: {
175
+ type: string;
176
+ items: {
177
+ readonly type: "string";
178
+ };
179
+ };
180
+ at: {
181
+ type: string;
182
+ };
183
+ source?: undefined;
184
+ force?: undefined;
185
+ };
186
+ required: string[];
187
+ };
188
+ } | {
189
+ name: string;
190
+ description: string;
191
+ inputSchema: {
192
+ required?: undefined;
193
+ type: string;
194
+ properties: {
195
+ artist?: undefined;
196
+ album?: undefined;
197
+ genre?: undefined;
198
+ folder?: undefined;
199
+ state?: undefined;
200
+ offset?: undefined;
201
+ kind?: undefined;
202
+ q?: undefined;
203
+ limit?: undefined;
204
+ name?: undefined;
205
+ id?: undefined;
206
+ items?: undefined;
207
+ at?: undefined;
208
+ server: {
209
+ type: "string";
210
+ description: string;
211
+ };
212
+ source: {
213
+ readonly type: "string";
214
+ };
215
+ force: {
216
+ type: string;
217
+ };
218
+ };
219
+ };
220
+ })[];
221
+ export declare function isLibraryTool(name: string): boolean;
222
+ type Result = {
223
+ content: {
224
+ type: "text";
225
+ text: string;
226
+ }[];
227
+ isError?: boolean;
228
+ };
229
+ export declare function callLibraryTool(name: string, args: Record<string, unknown>, options?: StationToolOptions): Promise<Result>;
230
+ export {};
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The library as MCP tools: what is here, find something, look again.
3
+ *
4
+ * Same target and credential rules as the station tools (station-mcp.ts):
5
+ * this machine's daemon by its control key, or another server as its owner.
6
+ */
7
+ import { connect } from "./station-mcp.js";
8
+ const STRING = { type: "string" };
9
+ const SERVER = { ...STRING, description: "A nixamp server's address; this machine's daemon when omitted." };
10
+ export const LIBRARY_TOOLS = [
11
+ {
12
+ name: "library_status",
13
+ description: "How many items the library has in each state (ready, pending, failed, unsupported, unavailable), its sources, and their last scans.",
14
+ inputSchema: { type: "object", properties: { server: SERVER } },
15
+ },
16
+ {
17
+ name: "library_search",
18
+ description: "Find items in the library by words (title, artist, album, genre, folder) and filters. Returns ids usable for stations (as track paths) and corrections.",
19
+ inputSchema: {
20
+ type: "object",
21
+ properties: {
22
+ server: SERVER,
23
+ q: STRING, artist: STRING, album: STRING, genre: STRING, folder: STRING,
24
+ state: { type: "string", enum: ["ready", "pending", "failed", "unsupported", "unavailable", "all"] },
25
+ limit: { type: "integer" }, offset: { type: "integer" },
26
+ },
27
+ },
28
+ },
29
+ {
30
+ name: "library_browse",
31
+ description: "The library grouped: every artist, album, genre or top-level folder, with counts.",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: { server: SERVER, kind: { type: "string", enum: ["artists", "albums", "genres", "folders"] }, q: STRING, limit: { type: "integer" } },
35
+ required: ["kind"],
36
+ },
37
+ },
38
+ {
39
+ name: "playlist_list",
40
+ description: "This listener's playlists on the server: id, name, how many items.",
41
+ inputSchema: { type: "object", properties: { server: SERVER } },
42
+ },
43
+ {
44
+ name: "playlist_create",
45
+ description: "A new playlist from library item ids (from library_search), in order. A playlist can then be a station: station_create with playlist.",
46
+ inputSchema: { type: "object", properties: { server: SERVER, name: STRING, items: { type: "array", items: STRING } }, required: ["name"] },
47
+ },
48
+ {
49
+ name: "playlist_add",
50
+ description: "Add library items to a playlist, at the end or at a place.",
51
+ inputSchema: { type: "object", properties: { server: SERVER, id: STRING, items: { type: "array", items: STRING }, at: { type: "integer" } }, required: ["id", "items"] },
52
+ },
53
+ {
54
+ name: "library_scan",
55
+ description: "Look at a source again: new files are read, changed ones re-read, gone ones marked unavailable. Returns a job id; library_status shows when it is done.",
56
+ inputSchema: { type: "object", properties: { server: SERVER, source: STRING, force: { type: "boolean" } } },
57
+ },
58
+ ];
59
+ export function isLibraryTool(name) {
60
+ return LIBRARY_TOOLS.some((tool) => tool.name === name);
61
+ }
62
+ const say = (text, isError = false) => ({ content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) });
63
+ export async function callLibraryTool(name, args, options = {}) {
64
+ const send = options.fetcher ?? fetch;
65
+ const target = connect(args, options);
66
+ if ("error" in target)
67
+ return say(target.error, true);
68
+ const get = async (method, path, body) => {
69
+ const response = await send(`${target.base}${path}`, { method, headers: target.headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
70
+ return { ok: response.ok, body: (await response.json().catch(() => ({}))) };
71
+ };
72
+ const refused = (body) => say(`${String(body["error"] ?? "refused")} (${String(body["code"] ?? "error")})`, true);
73
+ try {
74
+ if (name === "library_status") {
75
+ const answer = await get("GET", "/api/v1/library");
76
+ return answer.ok ? say(JSON.stringify(answer.body, null, 2)) : refused(answer.body);
77
+ }
78
+ if (name === "library_search") {
79
+ const query = new URLSearchParams();
80
+ for (const key of ["q", "artist", "album", "genre", "folder", "state"])
81
+ if (typeof args[key] === "string")
82
+ query.set(key, args[key]);
83
+ for (const key of ["limit", "offset"])
84
+ if (typeof args[key] === "number")
85
+ query.set(key, String(args[key]));
86
+ const answer = await get("GET", `/api/v1/library/items?${query}`);
87
+ if (!answer.ok)
88
+ return refused(answer.body);
89
+ const items = answer.body["items"] ?? [];
90
+ const lines = items.map((one) => `${String(one["id"])} ${String(one["artist"] || "?")} — ${String(one["title"])}${one["album"] ? ` (${String(one["album"])})` : ""} ${String(one["rel"])}`);
91
+ return say(lines.length ? `${lines.join("\n")}\n${String(answer.body["total"])} in all` : "Nothing matches.");
92
+ }
93
+ if (name === "library_browse") {
94
+ const kind = String(args["kind"] ?? "");
95
+ const query = new URLSearchParams();
96
+ if (typeof args["q"] === "string")
97
+ query.set("q", args["q"]);
98
+ if (typeof args["limit"] === "number")
99
+ query.set("limit", String(args["limit"]));
100
+ const answer = await get("GET", `/api/v1/library/browse/${encodeURIComponent(kind)}?${query}`);
101
+ if (!answer.ok)
102
+ return refused(answer.body);
103
+ const groups = answer.body["groups"] ?? [];
104
+ return say(groups.map((one) => `${one.name}${one.artist ? ` — ${one.artist}` : ""} (${one.count})`).join("\n") || "Nothing yet.");
105
+ }
106
+ if (name === "playlist_list") {
107
+ const answer = await get("GET", "/api/v1/library/playlists");
108
+ if (!answer.ok)
109
+ return refused(answer.body);
110
+ const lists = answer.body["playlists"] ?? [];
111
+ return say(lists.map((one) => `${String(one["id"])} ${String(one["name"])} (${String(one["count"])})`).join("\n") || "No playlists yet.");
112
+ }
113
+ if (name === "playlist_create" || name === "playlist_add") {
114
+ const answer = name === "playlist_create"
115
+ ? await get("POST", "/api/v1/library/playlists", { name: args["name"], items: args["items"] ?? [] })
116
+ : await get("POST", `/api/v1/library/playlists/${encodeURIComponent(String(args["id"] ?? ""))}/items`, { items: args["items"], ...(typeof args["at"] === "number" ? { at: args["at"] } : {}) });
117
+ if (!answer.ok)
118
+ return refused(answer.body);
119
+ const list = answer.body["playlist"];
120
+ return say(`${String(list["id"])} ${String(list["name"])}: ${String(list["count"])} items, revision ${String(list["revision"])}`);
121
+ }
122
+ if (name === "library_scan") {
123
+ let source = typeof args["source"] === "string" ? args["source"] : "";
124
+ if (!source) {
125
+ const listed = await get("GET", "/api/v1/library/sources");
126
+ const all = listed.body["sources"] ?? [];
127
+ if (all.length !== 1)
128
+ return say("Which source? library_status lists them.", true);
129
+ source = String(all[0]["id"]);
130
+ }
131
+ const answer = await get("POST", `/api/v1/library/sources/${source}/scan`, { force: args["force"] === true });
132
+ return answer.ok ? say(`Scanning; job ${String(answer.body["job"]["jobId"])}.`) : refused(answer.body);
133
+ }
134
+ }
135
+ catch (error) {
136
+ return say(`Could not reach ${target.base}: ${error.message}`, true);
137
+ }
138
+ return say(`No such tool: ${name}`, true);
139
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * /api/v1/library: the owner's files, as sources, items and groups.
3
+ *
4
+ * Reading -- search, browse, an item, its stream -- is for whoever got past
5
+ * the server's key: the owner, or somebody the owner gave the listen link to,
6
+ * which has always been how a library is shared. Only the owner sees where a
7
+ * file lives (`path`); everybody else gets its place inside the library.
8
+ * Changing anything -- sources, scans, corrections -- is the owner's.
9
+ * A station's link reaches none of this.
10
+ */
11
+ import type { IncomingMessage, ServerResponse } from "node:http";
12
+ import { type LibraryIndex, type LibraryItem, type Probed } from "./library-index.ts";
13
+ import type { Track } from "./audio.ts";
14
+ import type { ListenerStore } from "./library-user.ts";
15
+ export interface LibraryContext {
16
+ index: LibraryIndex;
17
+ /** Past the server's key: may read. */
18
+ canRead: boolean;
19
+ /** The control key or the owner: may change things. */
20
+ canWrite: boolean;
21
+ account: string;
22
+ /** Who this is, for their playlists and queue; null when nobody in particular. */
23
+ principal: string | null;
24
+ listeners?: ListenerStore;
25
+ probe: (path: string) => Promise<Probed>;
26
+ /** Tell the player about tags that changed. */
27
+ retag: (tracks: Track[]) => void;
28
+ sendFile: (request: IncomingMessage, response: ServerResponse, file: string, cache: string) => void;
29
+ json: (response: ServerResponse, status: number, body: unknown) => void;
30
+ readBody: (request: IncomingMessage) => Promise<string>;
31
+ }
32
+ /** An item as a caller may see it: the path only for the owner. */
33
+ export declare function itemView(item: LibraryItem, owner: boolean): Record<string, unknown>;
34
+ export declare function handleLibrary(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: LibraryContext): Promise<boolean>;
@@ -0,0 +1,258 @@
1
+ import { hostname } from "node:os";
2
+ import { LibraryError, shown, trackOf, } from "./library-index.js";
3
+ /** An item as a caller may see it: the path only for the owner. */
4
+ export function itemView(item, owner) {
5
+ const tags = shown(item);
6
+ return {
7
+ id: item.id,
8
+ sourceId: item.sourceId,
9
+ rel: item.rel,
10
+ ...(owner ? { path: item.path, override: item.override, size: item.size } : {}),
11
+ state: item.state,
12
+ ...(item.error && owner ? { error: item.error } : {}),
13
+ ...tags,
14
+ duration: item.duration,
15
+ format: { codec: item.codec, bitrateKbps: item.bitrate, sampleRate: item.sampleRate, channels: item.channels },
16
+ video: item.video,
17
+ hasArt: item.hasArt,
18
+ };
19
+ }
20
+ const STATES = new Set(["pending", "ready", "unsupported", "failed", "unavailable", "all"]);
21
+ const SORTS = new Set(["path", "title", "artist", "album", "recent"]);
22
+ const KINDS = new Set(["artists", "albums", "genres", "folders"]);
23
+ export async function handleLibrary(request, response, path, url, ctx) {
24
+ if (path !== "/api/v1/library" && !path.startsWith("/api/v1/library/"))
25
+ return false;
26
+ const method = request.method ?? "GET";
27
+ const { index } = ctx;
28
+ const fail = (status, code, error) => {
29
+ ctx.json(response, status, { error, code });
30
+ return true;
31
+ };
32
+ if (!ctx.canRead)
33
+ return fail(401, "unauthorized", "this library needs its link, or sign in as its owner");
34
+ const needWrite = () => {
35
+ if (ctx.canWrite)
36
+ return true;
37
+ fail(403, "forbidden", "only this server's owner changes its library");
38
+ return false;
39
+ };
40
+ const body = async () => {
41
+ const raw = await ctx.readBody(request);
42
+ if (raw.trim() === "")
43
+ return {};
44
+ const parsed = JSON.parse(raw);
45
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
46
+ throw new LibraryError("bad_json", "the body is not a JSON object");
47
+ return parsed;
48
+ };
49
+ const number = (name) => {
50
+ const value = url.searchParams.get(name);
51
+ return value === null ? undefined : Number(value) || 0;
52
+ };
53
+ const text = (name) => url.searchParams.get(name) ?? undefined;
54
+ try {
55
+ if (path === "/api/v1/library" && method === "GET") {
56
+ ctx.json(response, 200, {
57
+ items: index.size,
58
+ counts: index.counts(),
59
+ sources: index.sources().map((one) => ({ id: one.id, enabled: one.enabled, lastScan: one.lastScan, ...(ctx.canWrite ? { root: one.root, host: one.host } : {}) })),
60
+ });
61
+ return true;
62
+ }
63
+ if (path === "/api/v1/library/sources") {
64
+ if (method === "GET") {
65
+ ctx.json(response, 200, { sources: index.sources().map((one) => (ctx.canWrite ? one : { id: one.id, enabled: one.enabled, lastScan: one.lastScan })) });
66
+ return true;
67
+ }
68
+ if (method === "POST") {
69
+ if (!needWrite())
70
+ return true;
71
+ const input = await body();
72
+ if (typeof input["root"] !== "string" || input["root"].trim() === "")
73
+ throw new LibraryError("root_required", "say which folder: {\"root\": \"/path\"}");
74
+ const { existsSync, statSync } = await import("node:fs");
75
+ const root = input["root"].trim();
76
+ if (!existsSync(root) || !statSync(root).isDirectory())
77
+ throw new LibraryError("not_a_folder", "that is not a folder on this machine");
78
+ const source = index.addSource(root, ctx.account, hostname());
79
+ const { report } = index.scan(source.id, ctx.probe, { onProgress: (changed) => ctx.retag(changed.filter((one) => one.state === "ready").map(trackOf)) });
80
+ ctx.json(response, 201, { source, job: report });
81
+ return true;
82
+ }
83
+ return fail(405, "method_not_allowed", "GET or POST");
84
+ }
85
+ const sourceMatch = /^\/api\/v1\/library\/sources\/([0-9a-f]{16})(?:\/(scan))?$/.exec(path);
86
+ if (sourceMatch) {
87
+ const id = sourceMatch[1];
88
+ if (sourceMatch[2] === "scan") {
89
+ if (method !== "POST")
90
+ return fail(405, "method_not_allowed", "POST");
91
+ if (!needWrite())
92
+ return true;
93
+ const input = await body();
94
+ const { report } = index.scan(id, ctx.probe, {
95
+ force: input["force"] === true,
96
+ onProgress: (changed) => ctx.retag(changed.filter((one) => one.state === "ready").map(trackOf)),
97
+ });
98
+ ctx.json(response, 202, { job: report });
99
+ return true;
100
+ }
101
+ if (method === "GET") {
102
+ const source = index.source(id);
103
+ if (!source)
104
+ return fail(404, "not_found", "no such source");
105
+ ctx.json(response, 200, { source: ctx.canWrite ? source : { id: source.id, enabled: source.enabled, lastScan: source.lastScan } });
106
+ return true;
107
+ }
108
+ if (!needWrite())
109
+ return true;
110
+ if (method === "PATCH") {
111
+ const input = await body();
112
+ if (typeof input["enabled"] !== "boolean")
113
+ throw new LibraryError("invalid_value", "enabled must be true or false");
114
+ ctx.json(response, 200, { source: index.setEnabled(id, input["enabled"]) });
115
+ return true;
116
+ }
117
+ if (method === "DELETE") {
118
+ const forgotten = index.removeSource(id);
119
+ ctx.json(response, 200, { deleted: id, forgotten, filesTouched: 0 });
120
+ return true;
121
+ }
122
+ return fail(405, "method_not_allowed", "GET, PATCH or DELETE");
123
+ }
124
+ if (path === "/api/v1/library/queue" || path === "/api/v1/library/playlists" || path.startsWith("/api/v1/library/playlists/")) {
125
+ const listeners = ctx.listeners;
126
+ if (!listeners)
127
+ return fail(404, "not_found", "nothing here");
128
+ const who = ctx.principal;
129
+ if (who === null)
130
+ return fail(401, "sign_in_required", "sign in to nixamp.com to keep playlists and pick up where you left off");
131
+ const known = (id) => index.item(id) !== undefined;
132
+ const resolved = (ids) => ids.map((id) => {
133
+ const item = index.item(id);
134
+ return item ? itemView(item, ctx.canWrite) : { id, state: "unavailable" };
135
+ });
136
+ const playlistView = (one, withItems) => ({
137
+ id: one.id, name: one.name, count: one.items.length, revision: one.revision, createdAt: one.createdAt, updatedAt: one.updatedAt,
138
+ ...(withItems ? { items: resolved(one.items) } : {}),
139
+ });
140
+ const queueView = (queue) => ({ ...queue, items: resolved(queue.items), current: queue.items[queue.at] ?? null });
141
+ if (path === "/api/v1/library/queue") {
142
+ if (method === "GET") {
143
+ ctx.json(response, 200, { queue: queueView(listeners.queueOf(who)) });
144
+ return true;
145
+ }
146
+ if (method === "PUT" || method === "PATCH") {
147
+ ctx.json(response, 200, { queue: queueView(listeners.setQueue(who, await body(), known)) });
148
+ return true;
149
+ }
150
+ return fail(405, "method_not_allowed", "GET, PUT or PATCH");
151
+ }
152
+ if (path === "/api/v1/library/playlists") {
153
+ if (method === "GET") {
154
+ ctx.json(response, 200, { playlists: listeners.playlistsOf(who).map((one) => playlistView(one, false)) });
155
+ return true;
156
+ }
157
+ if (method === "POST") {
158
+ ctx.json(response, 201, { playlist: playlistView(listeners.create(who, await body(), known), true) });
159
+ return true;
160
+ }
161
+ return fail(405, "method_not_allowed", "GET or POST");
162
+ }
163
+ const playlistMatch = /^\/api\/v1\/library\/playlists\/([0-9a-f]{16})(?:\/(items))?$/.exec(path);
164
+ if (!playlistMatch)
165
+ return fail(404, "not_found", "no such playlist");
166
+ const id = playlistMatch[1];
167
+ if (playlistMatch[2] === "items") {
168
+ if (method !== "POST")
169
+ return fail(405, "method_not_allowed", "POST");
170
+ ctx.json(response, 200, { playlist: playlistView(listeners.add(who, id, await body(), known), true) });
171
+ return true;
172
+ }
173
+ if (method === "GET") {
174
+ ctx.json(response, 200, { playlist: playlistView(listeners.playlist(who, id), true) });
175
+ return true;
176
+ }
177
+ if (method === "PATCH") {
178
+ ctx.json(response, 200, { playlist: playlistView(listeners.update(who, id, await body(), known), true) });
179
+ return true;
180
+ }
181
+ if (method === "DELETE") {
182
+ listeners.remove(who, id);
183
+ ctx.json(response, 200, { deleted: id });
184
+ return true;
185
+ }
186
+ return fail(405, "method_not_allowed", "GET, PATCH or DELETE");
187
+ }
188
+ const jobMatch = /^\/api\/v1\/library\/jobs\/([0-9a-f]{16})$/.exec(path);
189
+ if (jobMatch) {
190
+ const job = index.job(jobMatch[1]);
191
+ if (!job)
192
+ return fail(404, "not_found", "no such job (jobs are kept until the server restarts)");
193
+ ctx.json(response, 200, { job });
194
+ return true;
195
+ }
196
+ if (path === "/api/v1/library/items" && method === "GET") {
197
+ const state = text("state");
198
+ const sort = text("sort");
199
+ const query = {
200
+ q: text("q"), artist: text("artist"), album: text("album"), genre: text("genre"), folder: text("folder"), sourceId: text("source"),
201
+ ...(state && STATES.has(state) ? { state: state } : {}),
202
+ ...(sort && SORTS.has(sort) ? { sort: sort } : {}),
203
+ limit: number("limit"), offset: number("offset"),
204
+ };
205
+ const { items, total } = index.search(query);
206
+ ctx.json(response, 200, { items: items.map((one) => itemView(one, ctx.canWrite)), total, limit: Math.min(500, Math.max(1, query.limit ?? 50)), offset: Math.max(0, query.offset ?? 0) });
207
+ return true;
208
+ }
209
+ const browseMatch = /^\/api\/v1\/library\/browse\/([a-z]+)$/.exec(path);
210
+ if (browseMatch && method === "GET") {
211
+ const kind = browseMatch[1];
212
+ if (!KINDS.has(kind))
213
+ return fail(404, "not_found", "browse artists, albums, genres or folders");
214
+ ctx.json(response, 200, index.browse(kind, { q: text("q"), limit: number("limit"), offset: number("offset") }));
215
+ return true;
216
+ }
217
+ const itemMatch = /^\/api\/v1\/library\/items\/([0-9a-f]{16})(?:\/(stream))?$/.exec(path);
218
+ if (itemMatch) {
219
+ const item = index.item(itemMatch[1]);
220
+ if (!item)
221
+ return fail(404, "not_found", "no such item");
222
+ if (itemMatch[2] === "stream") {
223
+ if (method !== "GET" && method !== "HEAD")
224
+ return fail(405, "method_not_allowed", "GET");
225
+ if (item.state !== "ready")
226
+ return fail(409, "not_ready", `that item is ${item.state}`);
227
+ const source = index.source(item.sourceId);
228
+ if (!source?.enabled)
229
+ return fail(409, "source_disabled", "that item's source is turned off");
230
+ // Private: a browser may keep it, a shared cache may not.
231
+ ctx.sendFile(request, response, item.path, "private, max-age=3600");
232
+ return true;
233
+ }
234
+ if (method === "GET") {
235
+ ctx.json(response, 200, { item: itemView(item, ctx.canWrite) });
236
+ return true;
237
+ }
238
+ if (method === "PATCH") {
239
+ if (!needWrite())
240
+ return true;
241
+ const corrected = index.correct(item.id, await body());
242
+ if (corrected.state === "ready")
243
+ ctx.retag([trackOf(corrected)]);
244
+ ctx.json(response, 200, { item: itemView(corrected, true) });
245
+ return true;
246
+ }
247
+ return fail(405, "method_not_allowed", "GET or PATCH");
248
+ }
249
+ return fail(404, "not_found", "nothing here");
250
+ }
251
+ catch (error) {
252
+ if (error instanceof LibraryError)
253
+ return fail(error.status, error.code, error.message);
254
+ if (error instanceof SyntaxError)
255
+ return fail(400, "bad_json", "the body is not JSON");
256
+ return fail(500, "internal", error instanceof Error ? error.message : String(error));
257
+ }
258
+ }
@@ -0,0 +1,3 @@
1
+ /** `nixamp library` subcommands and usage, apart so help does not load the client. */
2
+ export declare const LIBRARY_COMMANDS: Set<string>;
3
+ export declare const LIBRARY_USAGE = "nixamp library \u2014 what this server has, indexed\n\n nixamp library PATH the folder the daemon serves (set once)\n nixamp library status items by state, sources, last scans\n nixamp library sources\n nixamp library add PATH index another folder (inside your home, not hidden)\n nixamp library scan [SOURCE] [--force] [--wait]\n nixamp library search WORDS [--artist A] [--album A] [--state failed] [--limit 20]\n nixamp library show ITEM\n nixamp library fix ITEM --title T [--artist A] [--album A] [--genre G] [--track N] [--year N]\n nixamp library remove SOURCE forget a folder; its files are not touched\n nixamp library playlists\n nixamp library playlist new NAME [ITEM...]\n nixamp library playlist show|delete PLAYLIST\n nixamp library playlist add PLAYLIST ITEM...\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`";