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.
- package/dist/channels.d.ts +11 -2
- package/dist/channels.js +26 -4
- package/dist/library-cli.d.ts +3 -0
- package/dist/library-cli.js +198 -0
- package/dist/library-index.d.ts +190 -0
- package/dist/library-index.js +643 -0
- package/dist/library-mcp.d.ts +230 -0
- package/dist/library-mcp.js +139 -0
- package/dist/library-routes.d.ts +34 -0
- package/dist/library-routes.js +258 -0
- package/dist/library-usage.d.ts +3 -0
- package/dist/library-usage.js +20 -0
- package/dist/library-user.d.ts +67 -0
- package/dist/library-user.js +204 -0
- package/dist/main.js +8 -0
- package/dist/mcp.js +5 -2
- package/dist/server.d.ts +9 -0
- package/dist/server.js +87 -13
- package/dist/station-cli.js +4 -3
- package/dist/station-mcp.d.ts +17 -0
- package/dist/station-mcp.js +25 -19
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +25 -4
- package/dist/station-routes.js +54 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +1 -1
- package/package.json +1 -1
- package/src/channels.ts +23 -4
- package/src/library-cli.ts +175 -0
- package/src/library-index.ts +709 -0
- package/src/library-mcp.ts +132 -0
- package/src/library-routes.ts +282 -0
- package/src/library-usage.ts +21 -0
- package/src/library-user.ts +218 -0
- package/src/main.ts +7 -0
- package/src/mcp.ts +5 -2
- package/src/server.ts +86 -14
- package/src/station-cli.ts +4 -3
- package/src/station-mcp.ts +22 -15
- package/src/station-page.ts +25 -4
- package/src/station-routes.ts +53 -1
- package/src/station-usage.ts +1 -1
- package/web/dist/assets/ar-4kD-mTIA.js +1 -0
- package/web/dist/assets/cs-CziXiWNY.js +1 -0
- package/web/dist/assets/da-CyjJ8UOr.js +1 -0
- package/web/dist/assets/de-DxwYa667.js +1 -0
- package/web/dist/assets/es-QgMTJeSc.js +1 -0
- package/web/dist/assets/fi-Clj-jHhR.js +1 -0
- package/web/dist/assets/fr-nH_gzSFv.js +1 -0
- package/web/dist/assets/hi-D7djQ6_C.js +1 -0
- package/web/dist/assets/hls-3VKVEQE3-BaWVouMg.js +1 -0
- package/web/dist/assets/hu-H5Q3L8bD.js +1 -0
- package/web/dist/assets/id-40z_EQdf.js +1 -0
- package/web/dist/assets/it-C9Fcgywl.js +1 -0
- package/web/dist/assets/{index-CDBIr8Tz.js → main-BJUyBy0h.js} +2 -2
- package/web/dist/assets/{mpegts-63ylMmge.js → mpegts-BlmyNlBC.js} +1 -1
- package/web/dist/assets/mpegts-LO6RVLD6-CAuth8C5.js +1 -0
- package/web/dist/assets/nl-CrL4Psk9.js +1 -0
- package/web/dist/assets/radio-B58WgC1j.js +1 -0
- package/web/dist/assets/radio-CC8ihiO_.css +1 -0
- package/web/dist/assets/ru-C84JS0ch.js +1 -0
- package/web/dist/assets/styles-D70tSrIe.js +1 -0
- package/web/dist/assets/styles-qC62Gona.css +1 -0
- package/web/dist/assets/sv-DtMcl0sf.js +1 -0
- package/web/dist/assets/uk-dOLf99vZ.js +1 -0
- package/web/dist/assets/vi-BWeQVRZ8.js +1 -0
- package/web/dist/assets/zh-Cnv3Qpux.js +1 -0
- package/web/dist/index.html +4 -2
- package/web/dist/radio.html +134 -0
- package/web/dist/sw.js +26 -23
- package/web/dist/assets/ar-ClCIWtVb.js +0 -1
- package/web/dist/assets/cs-CVG4U823.js +0 -1
- package/web/dist/assets/da-igdiQUk8.js +0 -1
- package/web/dist/assets/de-CHPsc6lc.js +0 -1
- package/web/dist/assets/es-AvbMspQX.js +0 -1
- package/web/dist/assets/fi-5JGUOm3W.js +0 -1
- package/web/dist/assets/fr-YfMwk1FR.js +0 -1
- package/web/dist/assets/hi-BUZuQTkp.js +0 -1
- package/web/dist/assets/hls-3VKVEQE3-BCXawNaW.js +0 -1
- package/web/dist/assets/hu-C7on-yUl.js +0 -1
- package/web/dist/assets/id-pIrzVeOi.js +0 -1
- package/web/dist/assets/index-Bo3utcDw.css +0 -1
- package/web/dist/assets/it-Bl9xk5B4.js +0 -1
- package/web/dist/assets/mpegts-LO6RVLD6-DBakOXU5.js +0 -1
- package/web/dist/assets/nl-b5BUih3i.js +0 -1
- package/web/dist/assets/ru-BlAkbEPp.js +0 -1
- package/web/dist/assets/sv-BEKvqELG.js +0 -1
- package/web/dist/assets/uk-JY8Dh56p.js +0 -1
- package/web/dist/assets/vi-DbtxuQPL.js +0 -1
- package/web/dist/assets/zh-4x0DI5jO.js +0 -1
|
@@ -0,0 +1,132 @@
|
|
|
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, type StationToolOptions } from "./station-mcp.ts";
|
|
8
|
+
|
|
9
|
+
const STRING = { type: "string" } as const;
|
|
10
|
+
const SERVER = { ...STRING, description: "A nixamp server's address; this machine's daemon when omitted." };
|
|
11
|
+
|
|
12
|
+
export const LIBRARY_TOOLS = [
|
|
13
|
+
{
|
|
14
|
+
name: "library_status",
|
|
15
|
+
description: "How many items the library has in each state (ready, pending, failed, unsupported, unavailable), its sources, and their last scans.",
|
|
16
|
+
inputSchema: { type: "object", properties: { server: SERVER } },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "library_search",
|
|
20
|
+
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.",
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
server: SERVER,
|
|
25
|
+
q: STRING, artist: STRING, album: STRING, genre: STRING, folder: STRING,
|
|
26
|
+
state: { type: "string", enum: ["ready", "pending", "failed", "unsupported", "unavailable", "all"] },
|
|
27
|
+
limit: { type: "integer" }, offset: { type: "integer" },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "library_browse",
|
|
33
|
+
description: "The library grouped: every artist, album, genre or top-level folder, with counts.",
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: { server: SERVER, kind: { type: "string", enum: ["artists", "albums", "genres", "folders"] }, q: STRING, limit: { type: "integer" } },
|
|
37
|
+
required: ["kind"],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "playlist_list",
|
|
42
|
+
description: "This listener's playlists on the server: id, name, how many items.",
|
|
43
|
+
inputSchema: { type: "object", properties: { server: SERVER } },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "playlist_create",
|
|
47
|
+
description: "A new playlist from library item ids (from library_search), in order. A playlist can then be a station: station_create with playlist.",
|
|
48
|
+
inputSchema: { type: "object", properties: { server: SERVER, name: STRING, items: { type: "array", items: STRING } }, required: ["name"] },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "playlist_add",
|
|
52
|
+
description: "Add library items to a playlist, at the end or at a place.",
|
|
53
|
+
inputSchema: { type: "object", properties: { server: SERVER, id: STRING, items: { type: "array", items: STRING }, at: { type: "integer" } }, required: ["id", "items"] },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "library_scan",
|
|
57
|
+
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.",
|
|
58
|
+
inputSchema: { type: "object", properties: { server: SERVER, source: STRING, force: { type: "boolean" } } },
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export function isLibraryTool(name: string): boolean {
|
|
63
|
+
return LIBRARY_TOOLS.some((tool) => tool.name === name);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Result = { content: { type: "text"; text: string }[]; isError?: boolean };
|
|
67
|
+
const say = (text: string, isError = false): Result => ({ content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) });
|
|
68
|
+
|
|
69
|
+
export async function callLibraryTool(name: string, args: Record<string, unknown>, options: StationToolOptions = {}): Promise<Result> {
|
|
70
|
+
const send = options.fetcher ?? fetch;
|
|
71
|
+
const target = connect(args, options);
|
|
72
|
+
if ("error" in target) return say(target.error, true);
|
|
73
|
+
const get = async (method: string, path: string, body?: unknown): Promise<{ ok: boolean; body: Record<string, unknown> }> => {
|
|
74
|
+
const response = await send(`${target.base}${path}`, { method, headers: target.headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
75
|
+
return { ok: response.ok, body: (await response.json().catch(() => ({}))) as Record<string, unknown> };
|
|
76
|
+
};
|
|
77
|
+
const refused = (body: Record<string, unknown>): Result => say(`${String(body["error"] ?? "refused")} (${String(body["code"] ?? "error")})`, true);
|
|
78
|
+
try {
|
|
79
|
+
if (name === "library_status") {
|
|
80
|
+
const answer = await get("GET", "/api/v1/library");
|
|
81
|
+
return answer.ok ? say(JSON.stringify(answer.body, null, 2)) : refused(answer.body);
|
|
82
|
+
}
|
|
83
|
+
if (name === "library_search") {
|
|
84
|
+
const query = new URLSearchParams();
|
|
85
|
+
for (const key of ["q", "artist", "album", "genre", "folder", "state"]) if (typeof args[key] === "string") query.set(key, args[key] as string);
|
|
86
|
+
for (const key of ["limit", "offset"]) if (typeof args[key] === "number") query.set(key, String(args[key]));
|
|
87
|
+
const answer = await get("GET", `/api/v1/library/items?${query}`);
|
|
88
|
+
if (!answer.ok) return refused(answer.body);
|
|
89
|
+
const items = (answer.body["items"] as Record<string, unknown>[]) ?? [];
|
|
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") query.set("q", args["q"]);
|
|
97
|
+
if (typeof args["limit"] === "number") query.set("limit", String(args["limit"]));
|
|
98
|
+
const answer = await get("GET", `/api/v1/library/browse/${encodeURIComponent(kind)}?${query}`);
|
|
99
|
+
if (!answer.ok) return refused(answer.body);
|
|
100
|
+
const groups = (answer.body["groups"] as Array<{ name: string; count: number; artist?: string }>) ?? [];
|
|
101
|
+
return say(groups.map((one) => `${one.name}${one.artist ? ` — ${one.artist}` : ""} (${one.count})`).join("\n") || "Nothing yet.");
|
|
102
|
+
}
|
|
103
|
+
if (name === "playlist_list") {
|
|
104
|
+
const answer = await get("GET", "/api/v1/library/playlists");
|
|
105
|
+
if (!answer.ok) return refused(answer.body);
|
|
106
|
+
const lists = (answer.body["playlists"] as Array<Record<string, unknown>>) ?? [];
|
|
107
|
+
return say(lists.map((one) => `${String(one["id"])} ${String(one["name"])} (${String(one["count"])})`).join("\n") || "No playlists yet.");
|
|
108
|
+
}
|
|
109
|
+
if (name === "playlist_create" || name === "playlist_add") {
|
|
110
|
+
const answer = name === "playlist_create"
|
|
111
|
+
? await get("POST", "/api/v1/library/playlists", { name: args["name"], items: args["items"] ?? [] })
|
|
112
|
+
: await get("POST", `/api/v1/library/playlists/${encodeURIComponent(String(args["id"] ?? ""))}/items`, { items: args["items"], ...(typeof args["at"] === "number" ? { at: args["at"] } : {}) });
|
|
113
|
+
if (!answer.ok) return refused(answer.body);
|
|
114
|
+
const list = answer.body["playlist"] as Record<string, unknown>;
|
|
115
|
+
return say(`${String(list["id"])} ${String(list["name"])}: ${String(list["count"])} items, revision ${String(list["revision"])}`);
|
|
116
|
+
}
|
|
117
|
+
if (name === "library_scan") {
|
|
118
|
+
let source = typeof args["source"] === "string" ? args["source"] : "";
|
|
119
|
+
if (!source) {
|
|
120
|
+
const listed = await get("GET", "/api/v1/library/sources");
|
|
121
|
+
const all = (listed.body["sources"] as Record<string, unknown>[]) ?? [];
|
|
122
|
+
if (all.length !== 1) return say("Which source? library_status lists them.", true);
|
|
123
|
+
source = String(all[0]!["id"]);
|
|
124
|
+
}
|
|
125
|
+
const answer = await get("POST", `/api/v1/library/sources/${source}/scan`, { force: args["force"] === true });
|
|
126
|
+
return answer.ok ? say(`Scanning; job ${String((answer.body["job"] as Record<string, unknown>)["jobId"])}.`) : refused(answer.body);
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return say(`Could not reach ${target.base}: ${(error as Error).message}`, true);
|
|
130
|
+
}
|
|
131
|
+
return say(`No such tool: ${name}`, true);
|
|
132
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
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 { hostname } from "node:os";
|
|
13
|
+
import {
|
|
14
|
+
LibraryError, shown, trackOf, type Browse, type ItemState, type LibraryIndex, type LibraryItem, type Probed, type SearchQuery,
|
|
15
|
+
} from "./library-index.ts";
|
|
16
|
+
import type { Track } from "./audio.ts";
|
|
17
|
+
import type { ListenerStore, Playlist, Queue } from "./library-user.ts";
|
|
18
|
+
|
|
19
|
+
export interface LibraryContext {
|
|
20
|
+
index: LibraryIndex;
|
|
21
|
+
/** Past the server's key: may read. */
|
|
22
|
+
canRead: boolean;
|
|
23
|
+
/** The control key or the owner: may change things. */
|
|
24
|
+
canWrite: boolean;
|
|
25
|
+
account: string;
|
|
26
|
+
/** Who this is, for their playlists and queue; null when nobody in particular. */
|
|
27
|
+
principal: string | null;
|
|
28
|
+
listeners?: ListenerStore;
|
|
29
|
+
probe: (path: string) => Promise<Probed>;
|
|
30
|
+
/** Tell the player about tags that changed. */
|
|
31
|
+
retag: (tracks: Track[]) => void;
|
|
32
|
+
sendFile: (request: IncomingMessage, response: ServerResponse, file: string, cache: string) => void;
|
|
33
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
34
|
+
readBody: (request: IncomingMessage) => Promise<string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** An item as a caller may see it: the path only for the owner. */
|
|
38
|
+
export function itemView(item: LibraryItem, owner: boolean): Record<string, unknown> {
|
|
39
|
+
const tags = shown(item);
|
|
40
|
+
return {
|
|
41
|
+
id: item.id,
|
|
42
|
+
sourceId: item.sourceId,
|
|
43
|
+
rel: item.rel,
|
|
44
|
+
...(owner ? { path: item.path, override: item.override, size: item.size } : {}),
|
|
45
|
+
state: item.state,
|
|
46
|
+
...(item.error && owner ? { error: item.error } : {}),
|
|
47
|
+
...tags,
|
|
48
|
+
duration: item.duration,
|
|
49
|
+
format: { codec: item.codec, bitrateKbps: item.bitrate, sampleRate: item.sampleRate, channels: item.channels },
|
|
50
|
+
video: item.video,
|
|
51
|
+
hasArt: item.hasArt,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const STATES = new Set(["pending", "ready", "unsupported", "failed", "unavailable", "all"]);
|
|
56
|
+
const SORTS = new Set(["path", "title", "artist", "album", "recent"]);
|
|
57
|
+
const KINDS = new Set(["artists", "albums", "genres", "folders"]);
|
|
58
|
+
|
|
59
|
+
export async function handleLibrary(
|
|
60
|
+
request: IncomingMessage,
|
|
61
|
+
response: ServerResponse,
|
|
62
|
+
path: string,
|
|
63
|
+
url: URL,
|
|
64
|
+
ctx: LibraryContext,
|
|
65
|
+
): Promise<boolean> {
|
|
66
|
+
if (path !== "/api/v1/library" && !path.startsWith("/api/v1/library/")) return false;
|
|
67
|
+
const method = request.method ?? "GET";
|
|
68
|
+
const { index } = ctx;
|
|
69
|
+
const fail = (status: number, code: string, error: string): true => {
|
|
70
|
+
ctx.json(response, status, { error, code });
|
|
71
|
+
return true;
|
|
72
|
+
};
|
|
73
|
+
if (!ctx.canRead) return fail(401, "unauthorized", "this library needs its link, or sign in as its owner");
|
|
74
|
+
const needWrite = (): boolean => {
|
|
75
|
+
if (ctx.canWrite) return true;
|
|
76
|
+
fail(403, "forbidden", "only this server's owner changes its library");
|
|
77
|
+
return false;
|
|
78
|
+
};
|
|
79
|
+
const body = async (): Promise<Record<string, unknown>> => {
|
|
80
|
+
const raw = await ctx.readBody(request);
|
|
81
|
+
if (raw.trim() === "") return {};
|
|
82
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
83
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new LibraryError("bad_json", "the body is not a JSON object");
|
|
84
|
+
return parsed as Record<string, unknown>;
|
|
85
|
+
};
|
|
86
|
+
const number = (name: string): number | undefined => {
|
|
87
|
+
const value = url.searchParams.get(name);
|
|
88
|
+
return value === null ? undefined : Number(value) || 0;
|
|
89
|
+
};
|
|
90
|
+
const text = (name: string): string | undefined => url.searchParams.get(name) ?? undefined;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
if (path === "/api/v1/library" && method === "GET") {
|
|
94
|
+
ctx.json(response, 200, {
|
|
95
|
+
items: index.size,
|
|
96
|
+
counts: index.counts(),
|
|
97
|
+
sources: index.sources().map((one) => ({ id: one.id, enabled: one.enabled, lastScan: one.lastScan, ...(ctx.canWrite ? { root: one.root, host: one.host } : {}) })),
|
|
98
|
+
});
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (path === "/api/v1/library/sources") {
|
|
103
|
+
if (method === "GET") {
|
|
104
|
+
ctx.json(response, 200, { sources: index.sources().map((one) => (ctx.canWrite ? one : { id: one.id, enabled: one.enabled, lastScan: one.lastScan })) });
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
if (method === "POST") {
|
|
108
|
+
if (!needWrite()) return true;
|
|
109
|
+
const input = await body();
|
|
110
|
+
if (typeof input["root"] !== "string" || input["root"].trim() === "") throw new LibraryError("root_required", "say which folder: {\"root\": \"/path\"}");
|
|
111
|
+
const { existsSync, statSync } = await import("node:fs");
|
|
112
|
+
const root = input["root"].trim();
|
|
113
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) throw new LibraryError("not_a_folder", "that is not a folder on this machine");
|
|
114
|
+
const source = index.addSource(root, ctx.account, hostname());
|
|
115
|
+
const { report } = index.scan(source.id, ctx.probe, { onProgress: (changed) => ctx.retag(changed.filter((one) => one.state === "ready").map(trackOf)) });
|
|
116
|
+
ctx.json(response, 201, { source, job: report });
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return fail(405, "method_not_allowed", "GET or POST");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const sourceMatch = /^\/api\/v1\/library\/sources\/([0-9a-f]{16})(?:\/(scan))?$/.exec(path);
|
|
123
|
+
if (sourceMatch) {
|
|
124
|
+
const id = sourceMatch[1]!;
|
|
125
|
+
if (sourceMatch[2] === "scan") {
|
|
126
|
+
if (method !== "POST") return fail(405, "method_not_allowed", "POST");
|
|
127
|
+
if (!needWrite()) return true;
|
|
128
|
+
const input = await body();
|
|
129
|
+
const { report } = index.scan(id, ctx.probe, {
|
|
130
|
+
force: input["force"] === true,
|
|
131
|
+
onProgress: (changed) => ctx.retag(changed.filter((one) => one.state === "ready").map(trackOf)),
|
|
132
|
+
});
|
|
133
|
+
ctx.json(response, 202, { job: report });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (method === "GET") {
|
|
137
|
+
const source = index.source(id);
|
|
138
|
+
if (!source) return fail(404, "not_found", "no such source");
|
|
139
|
+
ctx.json(response, 200, { source: ctx.canWrite ? source : { id: source.id, enabled: source.enabled, lastScan: source.lastScan } });
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
if (!needWrite()) return true;
|
|
143
|
+
if (method === "PATCH") {
|
|
144
|
+
const input = await body();
|
|
145
|
+
if (typeof input["enabled"] !== "boolean") throw new LibraryError("invalid_value", "enabled must be true or false");
|
|
146
|
+
ctx.json(response, 200, { source: index.setEnabled(id, input["enabled"]) });
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (method === "DELETE") {
|
|
150
|
+
const forgotten = index.removeSource(id);
|
|
151
|
+
ctx.json(response, 200, { deleted: id, forgotten, filesTouched: 0 });
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
return fail(405, "method_not_allowed", "GET, PATCH or DELETE");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (path === "/api/v1/library/queue" || path === "/api/v1/library/playlists" || path.startsWith("/api/v1/library/playlists/")) {
|
|
158
|
+
const listeners = ctx.listeners;
|
|
159
|
+
if (!listeners) return fail(404, "not_found", "nothing here");
|
|
160
|
+
const who = ctx.principal;
|
|
161
|
+
if (who === null) return fail(401, "sign_in_required", "sign in to nixamp.com to keep playlists and pick up where you left off");
|
|
162
|
+
const known = (id: string): boolean => index.item(id) !== undefined;
|
|
163
|
+
const resolved = (ids: string[]): Array<Record<string, unknown>> => ids.map((id) => {
|
|
164
|
+
const item = index.item(id);
|
|
165
|
+
return item ? itemView(item, ctx.canWrite) : { id, state: "unavailable" };
|
|
166
|
+
});
|
|
167
|
+
const playlistView = (one: Playlist, withItems: boolean): Record<string, unknown> => ({
|
|
168
|
+
id: one.id, name: one.name, count: one.items.length, revision: one.revision, createdAt: one.createdAt, updatedAt: one.updatedAt,
|
|
169
|
+
...(withItems ? { items: resolved(one.items) } : {}),
|
|
170
|
+
});
|
|
171
|
+
const queueView = (queue: Queue): Record<string, unknown> => ({ ...queue, items: resolved(queue.items), current: queue.items[queue.at] ?? null });
|
|
172
|
+
|
|
173
|
+
if (path === "/api/v1/library/queue") {
|
|
174
|
+
if (method === "GET") {
|
|
175
|
+
ctx.json(response, 200, { queue: queueView(listeners.queueOf(who)) });
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
if (method === "PUT" || method === "PATCH") {
|
|
179
|
+
ctx.json(response, 200, { queue: queueView(listeners.setQueue(who, await body(), known)) });
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
return fail(405, "method_not_allowed", "GET, PUT or PATCH");
|
|
183
|
+
}
|
|
184
|
+
if (path === "/api/v1/library/playlists") {
|
|
185
|
+
if (method === "GET") {
|
|
186
|
+
ctx.json(response, 200, { playlists: listeners.playlistsOf(who).map((one) => playlistView(one, false)) });
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
if (method === "POST") {
|
|
190
|
+
ctx.json(response, 201, { playlist: playlistView(listeners.create(who, await body(), known), true) });
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
return fail(405, "method_not_allowed", "GET or POST");
|
|
194
|
+
}
|
|
195
|
+
const playlistMatch = /^\/api\/v1\/library\/playlists\/([0-9a-f]{16})(?:\/(items))?$/.exec(path);
|
|
196
|
+
if (!playlistMatch) return fail(404, "not_found", "no such playlist");
|
|
197
|
+
const id = playlistMatch[1]!;
|
|
198
|
+
if (playlistMatch[2] === "items") {
|
|
199
|
+
if (method !== "POST") return fail(405, "method_not_allowed", "POST");
|
|
200
|
+
ctx.json(response, 200, { playlist: playlistView(listeners.add(who, id, await body(), known), true) });
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
if (method === "GET") {
|
|
204
|
+
ctx.json(response, 200, { playlist: playlistView(listeners.playlist(who, id), true) });
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
if (method === "PATCH") {
|
|
208
|
+
ctx.json(response, 200, { playlist: playlistView(listeners.update(who, id, await body(), known), true) });
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
if (method === "DELETE") {
|
|
212
|
+
listeners.remove(who, id);
|
|
213
|
+
ctx.json(response, 200, { deleted: id });
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
return fail(405, "method_not_allowed", "GET, PATCH or DELETE");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const jobMatch = /^\/api\/v1\/library\/jobs\/([0-9a-f]{16})$/.exec(path);
|
|
220
|
+
if (jobMatch) {
|
|
221
|
+
const job = index.job(jobMatch[1]!);
|
|
222
|
+
if (!job) return fail(404, "not_found", "no such job (jobs are kept until the server restarts)");
|
|
223
|
+
ctx.json(response, 200, { job });
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (path === "/api/v1/library/items" && method === "GET") {
|
|
228
|
+
const state = text("state");
|
|
229
|
+
const sort = text("sort");
|
|
230
|
+
const query: SearchQuery = {
|
|
231
|
+
q: text("q"), artist: text("artist"), album: text("album"), genre: text("genre"), folder: text("folder"), sourceId: text("source"),
|
|
232
|
+
...(state && STATES.has(state) ? { state: state as ItemState | "all" } : {}),
|
|
233
|
+
...(sort && SORTS.has(sort) ? { sort: sort as SearchQuery["sort"] } : {}),
|
|
234
|
+
limit: number("limit"), offset: number("offset"),
|
|
235
|
+
};
|
|
236
|
+
const { items, total } = index.search(query);
|
|
237
|
+
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) });
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const browseMatch = /^\/api\/v1\/library\/browse\/([a-z]+)$/.exec(path);
|
|
242
|
+
if (browseMatch && method === "GET") {
|
|
243
|
+
const kind = browseMatch[1]!;
|
|
244
|
+
if (!KINDS.has(kind)) return fail(404, "not_found", "browse artists, albums, genres or folders");
|
|
245
|
+
ctx.json(response, 200, index.browse(kind as Browse, { q: text("q"), limit: number("limit"), offset: number("offset") }));
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const itemMatch = /^\/api\/v1\/library\/items\/([0-9a-f]{16})(?:\/(stream))?$/.exec(path);
|
|
250
|
+
if (itemMatch) {
|
|
251
|
+
const item = index.item(itemMatch[1]!);
|
|
252
|
+
if (!item) return fail(404, "not_found", "no such item");
|
|
253
|
+
if (itemMatch[2] === "stream") {
|
|
254
|
+
if (method !== "GET" && method !== "HEAD") return fail(405, "method_not_allowed", "GET");
|
|
255
|
+
if (item.state !== "ready") return fail(409, "not_ready", `that item is ${item.state}`);
|
|
256
|
+
const source = index.source(item.sourceId);
|
|
257
|
+
if (!source?.enabled) return fail(409, "source_disabled", "that item's source is turned off");
|
|
258
|
+
// Private: a browser may keep it, a shared cache may not.
|
|
259
|
+
ctx.sendFile(request, response, item.path, "private, max-age=3600");
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (method === "GET") {
|
|
263
|
+
ctx.json(response, 200, { item: itemView(item, ctx.canWrite) });
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
if (method === "PATCH") {
|
|
267
|
+
if (!needWrite()) return true;
|
|
268
|
+
const corrected = index.correct(item.id, await body());
|
|
269
|
+
if (corrected.state === "ready") ctx.retag([trackOf(corrected)]);
|
|
270
|
+
ctx.json(response, 200, { item: itemView(corrected, true) });
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
return fail(405, "method_not_allowed", "GET or PATCH");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return fail(404, "not_found", "nothing here");
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error instanceof LibraryError) return fail(error.status, error.code, error.message);
|
|
279
|
+
if (error instanceof SyntaxError) return fail(400, "bad_json", "the body is not JSON");
|
|
280
|
+
return fail(500, "internal", error instanceof Error ? error.message : String(error));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** `nixamp library` subcommands and usage, apart so help does not load the client. */
|
|
2
|
+
export const LIBRARY_COMMANDS = new Set(["status", "sources", "add", "scan", "search", "show", "fix", "remove", "playlists", "playlist"]);
|
|
3
|
+
|
|
4
|
+
export const LIBRARY_USAGE = `nixamp library — what this server has, indexed
|
|
5
|
+
|
|
6
|
+
nixamp library PATH the folder the daemon serves (set once)
|
|
7
|
+
nixamp library status items by state, sources, last scans
|
|
8
|
+
nixamp library sources
|
|
9
|
+
nixamp library add PATH index another folder (inside your home, not hidden)
|
|
10
|
+
nixamp library scan [SOURCE] [--force] [--wait]
|
|
11
|
+
nixamp library search WORDS [--artist A] [--album A] [--state failed] [--limit 20]
|
|
12
|
+
nixamp library show ITEM
|
|
13
|
+
nixamp library fix ITEM --title T [--artist A] [--album A] [--genre G] [--track N] [--year N]
|
|
14
|
+
nixamp library remove SOURCE forget a folder; its files are not touched
|
|
15
|
+
nixamp library playlists
|
|
16
|
+
nixamp library playlist new NAME [ITEM...]
|
|
17
|
+
nixamp library playlist show|delete PLAYLIST
|
|
18
|
+
nixamp library playlist add PLAYLIST ITEM...
|
|
19
|
+
|
|
20
|
+
--json machine-readable output
|
|
21
|
+
--url, --key another server, as with \`nixamp admin\``;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What belongs to one listener of a library: their playlists, and where
|
|
3
|
+
* they are in what they were playing.
|
|
4
|
+
*
|
|
5
|
+
* A listener here is somebody this server can recognise next time from
|
|
6
|
+
* another device: a nixamp.com account, or the owner holding the control key
|
|
7
|
+
* (who is "owner"). Somebody with only the listen link is not anybody in
|
|
8
|
+
* particular, so they get a player and no memory, which is what the PRD means
|
|
9
|
+
* by resuming "after authentication".
|
|
10
|
+
*
|
|
11
|
+
* Playlists hold item ids, not paths: a playlist survives a rescan, and an
|
|
12
|
+
* item that went missing stays in it, shown as unavailable, until somebody
|
|
13
|
+
* takes it out. Every change bumps a revision; an edit that names an older
|
|
14
|
+
* revision is refused rather than quietly overwriting what another device
|
|
15
|
+
* just did.
|
|
16
|
+
*
|
|
17
|
+
* The queue is one per listener, written whenever playback moves on, so a
|
|
18
|
+
* phone picks up where the laptop stopped.
|
|
19
|
+
*/
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { LibraryError } from "./library-index.ts";
|
|
24
|
+
|
|
25
|
+
export interface Playlist {
|
|
26
|
+
id: string;
|
|
27
|
+
owner: string;
|
|
28
|
+
name: string;
|
|
29
|
+
items: string[];
|
|
30
|
+
revision: number;
|
|
31
|
+
createdAt: number;
|
|
32
|
+
updatedAt: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type Repeat = "off" | "all" | "one";
|
|
36
|
+
|
|
37
|
+
export interface Queue {
|
|
38
|
+
owner: string;
|
|
39
|
+
items: string[];
|
|
40
|
+
/** Index into items of what is on. */
|
|
41
|
+
at: number;
|
|
42
|
+
/** Seconds into it. */
|
|
43
|
+
position: number;
|
|
44
|
+
shuffle: boolean;
|
|
45
|
+
repeat: Repeat;
|
|
46
|
+
/** Which device moved it last, as it named itself. */
|
|
47
|
+
device: string;
|
|
48
|
+
revision: number;
|
|
49
|
+
updatedAt: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const MAX_PLAYLISTS = 500;
|
|
53
|
+
const MAX_ITEMS = 10_000;
|
|
54
|
+
|
|
55
|
+
export class ListenerStore {
|
|
56
|
+
private playlists = new Map<string, Playlist>();
|
|
57
|
+
private queues = new Map<string, Queue>();
|
|
58
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
private readonly file: string,
|
|
62
|
+
private readonly io: { read: (path: string) => string | null; write: (path: string, body: string) => void } = {
|
|
63
|
+
read: (path) => { try { return readFileSync(path, "utf8"); } catch { return null; } },
|
|
64
|
+
write: (path, body) => {
|
|
65
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
66
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
67
|
+
writeFileSync(temporary, body, { mode: 0o600 });
|
|
68
|
+
renameSync(temporary, path);
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
private readonly now: () => number = Date.now,
|
|
72
|
+
) {
|
|
73
|
+
const body = io.read(file);
|
|
74
|
+
if (body === null) return;
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(body) as { playlists?: Playlist[]; queues?: Queue[] };
|
|
77
|
+
for (const one of parsed.playlists ?? []) if (one?.id) this.playlists.set(one.id, one);
|
|
78
|
+
for (const one of parsed.queues ?? []) if (one?.owner) this.queues.set(one.owner, one);
|
|
79
|
+
} catch {
|
|
80
|
+
// Started over.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static fileFor(stateDir: string, port: number): string {
|
|
85
|
+
return join(stateDir, `listeners-${port}.json`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private touch(): void {
|
|
89
|
+
if (this.timer) return;
|
|
90
|
+
this.timer = setTimeout(() => this.flush(), 1_000);
|
|
91
|
+
this.timer.unref?.();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
flush(): void {
|
|
95
|
+
if (this.timer) clearTimeout(this.timer);
|
|
96
|
+
this.timer = null;
|
|
97
|
+
this.io.write(this.file, JSON.stringify({ version: 1, playlists: [...this.playlists.values()], queues: [...this.queues.values()] }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
playlistsOf(owner: string): Playlist[] {
|
|
101
|
+
return [...this.playlists.values()].filter((one) => one.owner === owner).sort((a, b) => a.createdAt - b.createdAt);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Somebody's own playlist; anybody else's is the same "not found". */
|
|
105
|
+
playlist(owner: string, id: string): Playlist {
|
|
106
|
+
const found = this.playlists.get(id);
|
|
107
|
+
if (!found || found.owner !== owner) throw new LibraryError("not_found", "no such playlist", 404);
|
|
108
|
+
return found;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private items(value: unknown, known: (id: string) => boolean): string[] {
|
|
112
|
+
if (!Array.isArray(value) || value.some((one) => typeof one !== "string")) throw new LibraryError("invalid_items", "items is a list of item ids");
|
|
113
|
+
if (value.length > MAX_ITEMS) throw new LibraryError("too_many_items", `a list holds at most ${MAX_ITEMS} items`);
|
|
114
|
+
const unknown = (value as string[]).filter((one) => !known(one));
|
|
115
|
+
if (unknown.length) throw new LibraryError("unknown_items", `${unknown.length} of those items are not in this library`);
|
|
116
|
+
return value as string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private name(value: unknown): string {
|
|
120
|
+
const name = typeof value === "string" ? value.trim().replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 120) : "";
|
|
121
|
+
if (!name) throw new LibraryError("name_required", "a playlist needs a name");
|
|
122
|
+
return name;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
create(owner: string, input: { name?: unknown; items?: unknown }, known: (id: string) => boolean): Playlist {
|
|
126
|
+
if (this.playlistsOf(owner).length >= MAX_PLAYLISTS) throw new LibraryError("too_many_playlists", `at most ${MAX_PLAYLISTS} playlists each`, 409);
|
|
127
|
+
const now = this.now();
|
|
128
|
+
const playlist: Playlist = {
|
|
129
|
+
id: randomBytes(8).toString("hex"),
|
|
130
|
+
owner,
|
|
131
|
+
name: this.name(input.name),
|
|
132
|
+
items: input.items === undefined ? [] : this.items(input.items, known),
|
|
133
|
+
revision: 1,
|
|
134
|
+
createdAt: now,
|
|
135
|
+
updatedAt: now,
|
|
136
|
+
};
|
|
137
|
+
this.playlists.set(playlist.id, playlist);
|
|
138
|
+
this.touch();
|
|
139
|
+
return playlist;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Rename, reorder or replace the items; `revision` must be the one the caller saw. */
|
|
143
|
+
update(owner: string, id: string, input: { name?: unknown; items?: unknown; revision?: unknown }, known: (id: string) => boolean): Playlist {
|
|
144
|
+
const playlist = this.playlist(owner, id);
|
|
145
|
+
if (input.revision !== undefined && input.revision !== playlist.revision) {
|
|
146
|
+
throw new LibraryError("stale_revision", `the playlist changed (revision ${playlist.revision}); fetch it and try again`, 409);
|
|
147
|
+
}
|
|
148
|
+
if (input.name !== undefined) playlist.name = this.name(input.name);
|
|
149
|
+
if (input.items !== undefined) playlist.items = this.items(input.items, known);
|
|
150
|
+
playlist.revision += 1;
|
|
151
|
+
playlist.updatedAt = this.now();
|
|
152
|
+
this.touch();
|
|
153
|
+
return playlist;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Items added, at the end or at a place. */
|
|
157
|
+
add(owner: string, id: string, input: { items?: unknown; at?: unknown }, known: (id: string) => boolean): Playlist {
|
|
158
|
+
const playlist = this.playlist(owner, id);
|
|
159
|
+
const added = this.items(input.items, known);
|
|
160
|
+
const at = typeof input.at === "number" && Number.isInteger(input.at) ? Math.max(0, Math.min(playlist.items.length, input.at)) : playlist.items.length;
|
|
161
|
+
if (playlist.items.length + added.length > MAX_ITEMS) throw new LibraryError("too_many_items", `a list holds at most ${MAX_ITEMS} items`);
|
|
162
|
+
playlist.items.splice(at, 0, ...added);
|
|
163
|
+
playlist.revision += 1;
|
|
164
|
+
playlist.updatedAt = this.now();
|
|
165
|
+
this.touch();
|
|
166
|
+
return playlist;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
remove(owner: string, id: string): void {
|
|
170
|
+
this.playlist(owner, id);
|
|
171
|
+
this.playlists.delete(id);
|
|
172
|
+
this.touch();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
queueOf(owner: string): Queue {
|
|
176
|
+
return this.queues.get(owner) ?? { owner, items: [], at: 0, position: 0, shuffle: false, repeat: "off", device: "", revision: 0, updatedAt: 0 };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A new queue (items given) or where playback has got to (at/position).
|
|
181
|
+
* Last writer wins for where you are -- that is what moving to another
|
|
182
|
+
* device means -- but a whole new list names the revision it replaces.
|
|
183
|
+
*/
|
|
184
|
+
setQueue(owner: string, input: Record<string, unknown>, known: (id: string) => boolean): Queue {
|
|
185
|
+
const queue = { ...this.queueOf(owner) };
|
|
186
|
+
if (input["items"] !== undefined) {
|
|
187
|
+
if (input["revision"] !== undefined && input["revision"] !== queue.revision) {
|
|
188
|
+
throw new LibraryError("stale_revision", `the queue changed on another device (revision ${queue.revision})`, 409);
|
|
189
|
+
}
|
|
190
|
+
queue.items = this.items(input["items"], known);
|
|
191
|
+
queue.at = 0;
|
|
192
|
+
queue.position = 0;
|
|
193
|
+
}
|
|
194
|
+
if (input["at"] !== undefined) {
|
|
195
|
+
if (!Number.isInteger(input["at"]) || (input["at"] as number) < 0 || (input["at"] as number) >= Math.max(1, queue.items.length)) {
|
|
196
|
+
throw new LibraryError("invalid_value", "at is a place in the queue");
|
|
197
|
+
}
|
|
198
|
+
queue.at = input["at"] as number;
|
|
199
|
+
if (input["position"] === undefined) queue.position = 0;
|
|
200
|
+
}
|
|
201
|
+
if (input["position"] !== undefined) {
|
|
202
|
+
const position = Number(input["position"]);
|
|
203
|
+
if (!Number.isFinite(position) || position < 0) throw new LibraryError("invalid_value", "position is seconds into the item");
|
|
204
|
+
queue.position = Math.round(position * 10) / 10;
|
|
205
|
+
}
|
|
206
|
+
if (input["shuffle"] !== undefined) queue.shuffle = input["shuffle"] === true;
|
|
207
|
+
if (input["repeat"] !== undefined) {
|
|
208
|
+
if (!["off", "all", "one"].includes(String(input["repeat"]))) throw new LibraryError("invalid_value", "repeat is off, all or one");
|
|
209
|
+
queue.repeat = input["repeat"] as Repeat;
|
|
210
|
+
}
|
|
211
|
+
if (typeof input["device"] === "string") queue.device = input["device"].slice(0, 80);
|
|
212
|
+
queue.revision += 1;
|
|
213
|
+
queue.updatedAt = this.now();
|
|
214
|
+
this.queues.set(owner, queue);
|
|
215
|
+
this.touch();
|
|
216
|
+
return queue;
|
|
217
|
+
}
|
|
218
|
+
}
|