engine-dj-mcp 0.9.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/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/blobs/index.d.ts +337 -0
- package/dist/blobs/index.js +483 -0
- package/dist/blobs/qcompress.d.ts +44 -0
- package/dist/blobs/qcompress.js +146 -0
- package/dist/discovery.d.ts +36 -0
- package/dist/discovery.js +111 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +49 -0
- package/dist/guard.d.ts +31 -0
- package/dist/guard.js +236 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/library-select.d.ts +63 -0
- package/dist/library-select.js +97 -0
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +34 -0
- package/dist/probe.d.ts +7 -0
- package/dist/probe.js +20 -0
- package/dist/proc/query-client.d.ts +36 -0
- package/dist/proc/query-client.js +249 -0
- package/dist/proc/query-worker.d.ts +1 -0
- package/dist/proc/query-worker.js +72 -0
- package/dist/semantics.d.ts +43 -0
- package/dist/semantics.js +95 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +439 -0
- package/dist/sidecar/build.d.ts +19 -0
- package/dist/sidecar/build.js +85 -0
- package/dist/sidecar/schema.d.ts +25 -0
- package/dist/sidecar/schema.js +36 -0
- package/dist/store/connections.d.ts +28 -0
- package/dist/store/connections.js +116 -0
- package/dist/store/index-manager.d.ts +29 -0
- package/dist/store/index-manager.js +187 -0
- package/dist/tools/audit.d.ts +15 -0
- package/dist/tools/audit.js +148 -0
- package/dist/tools/libraries.d.ts +40 -0
- package/dist/tools/libraries.js +30 -0
- package/dist/tools/performance.d.ts +15 -0
- package/dist/tools/performance.js +47 -0
- package/dist/tools/refresh.d.ts +8 -0
- package/dist/tools/refresh.js +3 -0
- package/dist/tools/search.d.ts +60 -0
- package/dist/tools/search.js +328 -0
- package/dist/tools/sql.d.ts +14 -0
- package/dist/tools/sql.js +21 -0
- package/dist/tools/tracks.d.ts +12 -0
- package/dist/tools/tracks.js +49 -0
- package/package.json +53 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { discoverLibraries, defaultRoots, probeLibraries } from "./discovery.js";
|
|
7
|
+
import { libraryCandidates, sidecarDir } from "./paths.js";
|
|
8
|
+
import { LibraryArg, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
|
|
9
|
+
import { hasHotJournal } from "./store/connections.js";
|
|
10
|
+
import { QueryProcess } from "./proc/query-client.js";
|
|
11
|
+
import { IndexManager } from "./store/index-manager.js";
|
|
12
|
+
import { searchTracks, SearchInput } from "./tools/search.js";
|
|
13
|
+
import { getTracks, GetTracksInput } from "./tools/tracks.js";
|
|
14
|
+
import { getTrackPerformance, PerformanceInput } from "./tools/performance.js";
|
|
15
|
+
import { auditLibrary, AuditInput, AUDIT_CHECKS } from "./tools/audit.js";
|
|
16
|
+
import { runSql, RunSqlInput } from "./tools/sql.js";
|
|
17
|
+
import { listLibraries } from "./tools/libraries.js";
|
|
18
|
+
import { refreshIndex } from "./tools/refresh.js";
|
|
19
|
+
import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
|
|
20
|
+
const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
|
|
21
|
+
/**
|
|
22
|
+
* Appended to every tool description that takes a `library`. The argument's
|
|
23
|
+
* own schema description (see library-select.ts) is the authoritative text;
|
|
24
|
+
* this repeats the essentials in the description because some clients show a
|
|
25
|
+
* model the description and not the per-property schema documentation.
|
|
26
|
+
*/
|
|
27
|
+
const LIBRARY_SELECTION_NOTE = "With more than one library connected, pass `library` (a uuid or path from list_libraries, " +
|
|
28
|
+
"either the ~/... form or the absolute one) to choose which one; the default is the " +
|
|
29
|
+
"supported library with the most tracks.";
|
|
30
|
+
function reply(value) {
|
|
31
|
+
return {
|
|
32
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
33
|
+
structuredContent: value,
|
|
34
|
+
isError: isEngineError(value),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* discoverLibraries() reports only libraries it could actually read, by
|
|
39
|
+
* design (a permissions error on one candidate must not blank out every
|
|
40
|
+
* other one). That means a hot journal on the *only* library on this
|
|
41
|
+
* machine looks identical to no library existing at all -- both come back
|
|
42
|
+
* as an empty list, verified: opening it raises "attempt to write a
|
|
43
|
+
* readonly database", which readLibraryInfo currently folds into
|
|
44
|
+
* unsupported_schema and then drops entirely.
|
|
45
|
+
*
|
|
46
|
+
* This walks the same candidate paths independently, purely to tell those
|
|
47
|
+
* two cases apart, so `ready()` below can report library_needs_recovery
|
|
48
|
+
* instead of the misleading library_not_found -- never to open the file:
|
|
49
|
+
* recovering a hot journal requires a write, and this project never writes
|
|
50
|
+
* to the user's library, even to heal it.
|
|
51
|
+
*/
|
|
52
|
+
export function findHotJournalCandidate(roots) {
|
|
53
|
+
for (const root of roots) {
|
|
54
|
+
for (const candidate of libraryCandidates(root)) {
|
|
55
|
+
if (existsSync(candidate) && hasHotJournal(candidate))
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
export async function createServer(opts = {}) {
|
|
62
|
+
const server = new McpServer({ name: "engine-dj-mcp", version: "0.1.0" });
|
|
63
|
+
const libs = discoverLibraries(opts.roots);
|
|
64
|
+
// Shared by every tool for the "no primary library" case, so refresh_index
|
|
65
|
+
// cannot end up as the one call site that still flattens a hot journal
|
|
66
|
+
// into library_not_found while the rest correctly report
|
|
67
|
+
// library_needs_recovery.
|
|
68
|
+
const noLibraryError = () => {
|
|
69
|
+
const hotPath = findHotJournalCandidate(opts.roots ?? defaultRoots());
|
|
70
|
+
return hotPath
|
|
71
|
+
? libraryNeedsRecovery()
|
|
72
|
+
: err("library_not_found", "No supported Engine DJ library was found");
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Seeded from the start-time scan and grown by every list_libraries call
|
|
76
|
+
* after: once a candidate path has been read successfully, it stays here.
|
|
77
|
+
* That is what lets rescanLibraries() below keep reporting a library that
|
|
78
|
+
* a later scan catches locked, instead of discoverLibraries() silently
|
|
79
|
+
* dropping it -- the same library that "was discoverable before must not
|
|
80
|
+
* silently disappear because it is momentarily unreadable" (see
|
|
81
|
+
* tools/libraries.ts). Keyed by candidate path rather than uuid: a failed
|
|
82
|
+
* read has no fresh uuid to key on, only the path it was attempted at.
|
|
83
|
+
*/
|
|
84
|
+
const knownLibraries = new Map(libs.map((l) => [l.path, l]));
|
|
85
|
+
/**
|
|
86
|
+
* The re-scan behind the list_libraries tool. Every candidate path that
|
|
87
|
+
* still exists but failed to read this time is reported using its last
|
|
88
|
+
* known-good LibraryInfo, marked `unreadable` with the fresh error --
|
|
89
|
+
* present, but visibly not fine, rather than absent. A candidate that no
|
|
90
|
+
* longer exists at all (the drive itself is gone) is forgotten instead:
|
|
91
|
+
* that is a real disappearance, not a degraded state.
|
|
92
|
+
*/
|
|
93
|
+
const rescanLibraries = () => {
|
|
94
|
+
const roots = opts.roots ?? defaultRoots();
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
const entries = [];
|
|
97
|
+
for (const probe of probeLibraries(roots)) {
|
|
98
|
+
seen.add(probe.path);
|
|
99
|
+
if (probe.info) {
|
|
100
|
+
knownLibraries.set(probe.path, probe.info);
|
|
101
|
+
entries.push(probe.info);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const cached = knownLibraries.get(probe.path);
|
|
105
|
+
if (cached)
|
|
106
|
+
entries.push({ ...cached, unreadable: probe.error });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const path of knownLibraries.keys()) {
|
|
110
|
+
if (!seen.has(path))
|
|
111
|
+
knownLibraries.delete(path);
|
|
112
|
+
}
|
|
113
|
+
return entries;
|
|
114
|
+
};
|
|
115
|
+
/** Every library this server currently knows about, in root-scan order. */
|
|
116
|
+
const knownList = () => [...knownLibraries.values()];
|
|
117
|
+
/**
|
|
118
|
+
* Per-library state, keyed by the path of `m.db` rather than by uuid:
|
|
119
|
+
* copying a library to another drive copies its uuid too, so uuid is not
|
|
120
|
+
* unique across mounted volumes while the file's location always is.
|
|
121
|
+
*/
|
|
122
|
+
const states = new Map();
|
|
123
|
+
/**
|
|
124
|
+
* Sidecars live at `<base>/<uuid>/index.db`, which isolates two libraries
|
|
125
|
+
* from each other -- verified -- for as long as their uuids differ. They
|
|
126
|
+
* do not always differ: a library cloned onto a second drive (a normal
|
|
127
|
+
* thing for a DJ to do) carries the original's uuid, and both would then
|
|
128
|
+
* rebuild over the same index file on every call, thrashing forever.
|
|
129
|
+
*
|
|
130
|
+
* Only the *second and later* claimants of a uuid are moved aside, so the
|
|
131
|
+
* ordinary single-library layout on disk is exactly what it was, and the
|
|
132
|
+
* library that owns the uuid by root-scan order keeps it across restarts.
|
|
133
|
+
*/
|
|
134
|
+
const sidecarBaseFor = (lib) => {
|
|
135
|
+
const first = knownList().find((l) => l.uuid === lib.uuid);
|
|
136
|
+
if (!first || first.path === lib.path)
|
|
137
|
+
return opts.sidecarBaseDir;
|
|
138
|
+
const tag = createHash("sha256").update(lib.path).digest("hex").slice(0, 12);
|
|
139
|
+
return join(opts.sidecarBaseDir ?? sidecarDir(""), "duplicate-uuid", tag);
|
|
140
|
+
};
|
|
141
|
+
/** Lazily creates -- and thereafter reuses -- one query child per library. */
|
|
142
|
+
const stateFor = (lib) => {
|
|
143
|
+
const existing = states.get(lib.path);
|
|
144
|
+
if (existing)
|
|
145
|
+
return existing;
|
|
146
|
+
const qp = new QueryProcess(lib.path, null, 10_000);
|
|
147
|
+
const state = { lib, qp, mgr: new IndexManager(lib, qp, sidecarBaseFor(lib)) };
|
|
148
|
+
states.set(lib.path, state);
|
|
149
|
+
return state;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Turns the optional `library` argument into one specific library.
|
|
153
|
+
*
|
|
154
|
+
* A miss triggers a single re-scan before giving up: `list_libraries`
|
|
155
|
+
* re-discovers on every call precisely so a drive plugged in after this
|
|
156
|
+
* server started is visible, and a library a caller can see but cannot
|
|
157
|
+
* select is the defect this whole argument exists to close. The re-scan
|
|
158
|
+
* runs only on a miss, so the normal path stays a Map lookup.
|
|
159
|
+
*/
|
|
160
|
+
const selectLibrary = (requested) => {
|
|
161
|
+
if (requested === undefined)
|
|
162
|
+
return pickDefaultLibrary(knownList()) ?? noLibraryError();
|
|
163
|
+
const direct = findLibrary(knownList(), requested);
|
|
164
|
+
if (direct)
|
|
165
|
+
return direct;
|
|
166
|
+
rescanLibraries();
|
|
167
|
+
return findLibrary(knownList(), requested) ?? libraryNotFound(requested, knownList());
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* `index_stale` is swallowed only when an index is genuinely attached:
|
|
171
|
+
* "the previous index is still in use" is a reason to answer anyway, but
|
|
172
|
+
* "the index could not be built yet" is not. Every tool's SQL joins
|
|
173
|
+
* `side.track_derived`, so letting the call proceed with nothing attached
|
|
174
|
+
* turned the project's headline scenario -- a first run while Engine DJ
|
|
175
|
+
* holds a write lock -- into `invalid_argument` carrying the raw SQLite
|
|
176
|
+
* string "no such table: side.track_derived", instead of `index_stale`
|
|
177
|
+
* with a `retry_after_ms` the model can act on.
|
|
178
|
+
*/
|
|
179
|
+
const acquire = async (requested) => {
|
|
180
|
+
const lib = selectLibrary(requested);
|
|
181
|
+
if (isEngineError(lib))
|
|
182
|
+
return lib;
|
|
183
|
+
const state = stateFor(lib);
|
|
184
|
+
const fresh = await state.mgr.ensureFresh();
|
|
185
|
+
if (!isEngineError(fresh))
|
|
186
|
+
return state;
|
|
187
|
+
if (fresh.error === "index_stale" && state.qp.hasSidecar)
|
|
188
|
+
return state;
|
|
189
|
+
return fresh;
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Shared by the engine://libraries resource and the list_libraries tool so
|
|
193
|
+
* the two cannot drift in shape, while differing in exactly one respect:
|
|
194
|
+
* which library list they are given.
|
|
195
|
+
*
|
|
196
|
+
* The resource is passed the start-time snapshot, which is what the spec
|
|
197
|
+
* claims a resource is. The tool re-discovers on every call, because a
|
|
198
|
+
* USB drive plugged in after the server started is the ordinary case for
|
|
199
|
+
* a DJ, and "restart your assistant to see the drive you just plugged in"
|
|
200
|
+
* is not an answer.
|
|
201
|
+
*
|
|
202
|
+
* index_generation only appears once a sidecar has actually been built at
|
|
203
|
+
* least once in this process -- a never-built IndexManager still reports
|
|
204
|
+
* generation 0, which is not a real generation number and must read as
|
|
205
|
+
* null, not as "generation zero". A library nobody has queried yet has no
|
|
206
|
+
* IndexManager at all and reports null for the same reason: listing the
|
|
207
|
+
* libraries must not fork a query child per drive to fill in a number.
|
|
208
|
+
*/
|
|
209
|
+
const libraryReport = async (discovered) => {
|
|
210
|
+
const generations = new Map();
|
|
211
|
+
for (const state of states.values()) {
|
|
212
|
+
await state.mgr.ensureFresh(); // best effort: keeps a live library's generation accurate
|
|
213
|
+
if (state.mgr.generation > 0)
|
|
214
|
+
generations.set(state.lib.uuid, state.mgr.generation);
|
|
215
|
+
}
|
|
216
|
+
return listLibraries(generations, discovered);
|
|
217
|
+
};
|
|
218
|
+
server.registerResource("schema", "engine://schema", { title: "Engine DJ schema and semantics", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, text: SCHEMA_NOTE }] }));
|
|
219
|
+
server.registerResource("libraries", "engine://libraries", { title: "Discovered Engine DJ libraries", mimeType: "application/json" }, async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(await libraryReport(libs), null, 2) }] }));
|
|
220
|
+
server.registerTool("search_tracks", {
|
|
221
|
+
title: "Search tracks",
|
|
222
|
+
description: "Search the Engine DJ library by text, tempo, key, rating, play history and analysis " +
|
|
223
|
+
"flags. Set include_total for a count alongside the page: it is capped at 1000, and a " +
|
|
224
|
+
"capped result comes back as total: 1000 with total_capped: true -- treat that as " +
|
|
225
|
+
"'at least 1000', never as an exact count. " +
|
|
226
|
+
"flags.has_cues means a hot cue is actually set (the blob is decoded when the index " +
|
|
227
|
+
"is built), not merely that Engine analysed the track; flags.has_beatgrid means a " +
|
|
228
|
+
"beatData blob is present. " +
|
|
229
|
+
LIBRARY_SELECTION_NOTE,
|
|
230
|
+
inputSchema: { ...SearchInput.shape, library: LibraryArg },
|
|
231
|
+
annotations: RO,
|
|
232
|
+
}, async (args) => {
|
|
233
|
+
const state = await acquire(args.library);
|
|
234
|
+
if (isEngineError(state))
|
|
235
|
+
return reply(state);
|
|
236
|
+
return reply(await searchTracks(state.qp, args));
|
|
237
|
+
});
|
|
238
|
+
server.registerTool("get_tracks", {
|
|
239
|
+
title: "Get tracks by id",
|
|
240
|
+
description: "Fetch full metadata for specific track ids, in the order requested. " +
|
|
241
|
+
LIBRARY_SELECTION_NOTE,
|
|
242
|
+
inputSchema: { ...GetTracksInput.shape, library: LibraryArg },
|
|
243
|
+
annotations: RO,
|
|
244
|
+
}, async (args) => {
|
|
245
|
+
const state = await acquire(args.library);
|
|
246
|
+
if (isEngineError(state))
|
|
247
|
+
return reply(state);
|
|
248
|
+
return reply(await getTracks(state.qp, args));
|
|
249
|
+
});
|
|
250
|
+
server.registerTool("get_track_performance", {
|
|
251
|
+
title: "Get cues, loops and beatgrid",
|
|
252
|
+
description: "Decode PerformanceData for one track: hot cues, the main cue, saved loops, the " +
|
|
253
|
+
"beatgrid and a coarse waveform profile. Each field carries its own decode status " +
|
|
254
|
+
"and its own layout marker. " +
|
|
255
|
+
"layout: \"verified\" (cues, beatgrid, waveform_summary) means the binary layout was " +
|
|
256
|
+
"confirmed against a real Engine DJ library -- cue positions land inside the track, " +
|
|
257
|
+
"the beatgrid's implied tempo matches the analysed BPM, and the waveform's declared " +
|
|
258
|
+
"point spacing multiplies back out to the track's sample count -- so status: \"ok\" " +
|
|
259
|
+
"there is a claim about the values, not just about the parse. " +
|
|
260
|
+
"layout: \"unverified\" (loops) still means only that the bytes parsed: the loop slot " +
|
|
261
|
+
"structure is known, but no library was available with a loop actually saved, so " +
|
|
262
|
+
"loop bounds must not be reported to a user as fact. " +
|
|
263
|
+
"Positions are sample offsets; sample_rate at the top level converts them to " +
|
|
264
|
+
"seconds, and cue/loop items carry the seconds already. Only hot-cue and loop slots " +
|
|
265
|
+
"that hold something are listed -- slots is how many the track has in total, so " +
|
|
266
|
+
"items: [] with slots: 8 means an analysed track with no cues set. Items are capped " +
|
|
267
|
+
"at 64; total gives the full count and truncated says whether the cap was hit. " +
|
|
268
|
+
LIBRARY_SELECTION_NOTE,
|
|
269
|
+
inputSchema: { ...PerformanceInput.shape, library: LibraryArg },
|
|
270
|
+
annotations: RO,
|
|
271
|
+
}, async (args) => {
|
|
272
|
+
const state = await acquire(args.library);
|
|
273
|
+
if (isEngineError(state))
|
|
274
|
+
return reply(state);
|
|
275
|
+
return reply(await getTrackPerformance(state.qp, args));
|
|
276
|
+
});
|
|
277
|
+
server.registerTool("audit_library", {
|
|
278
|
+
title: "Audit the collection",
|
|
279
|
+
description: `Run collection health checks. Available: ${AUDIT_CHECKS.join(", ")}. ` +
|
|
280
|
+
`missing_files resolves each track against the selected library's own folder. ` +
|
|
281
|
+
`no_cues means "no hot cue is set" -- the quickCues blob is decoded for this, since ` +
|
|
282
|
+
`Engine writes one to every analysed track whether or not a pad is used -- while ` +
|
|
283
|
+
`no_beatgrid means the beatData blob is absent or empty. ` +
|
|
284
|
+
LIBRARY_SELECTION_NOTE,
|
|
285
|
+
inputSchema: { ...AuditInput.shape, library: LibraryArg },
|
|
286
|
+
annotations: RO,
|
|
287
|
+
}, async (args) => {
|
|
288
|
+
const state = await acquire(args.library);
|
|
289
|
+
if (isEngineError(state))
|
|
290
|
+
return reply(state);
|
|
291
|
+
// state.lib.path, never a captured "primary" path: missing_files
|
|
292
|
+
// resolves every relative Track.path against the grandparent of this
|
|
293
|
+
// argument, so the wrong library's path here would report a wrong
|
|
294
|
+
// answer rather than an error.
|
|
295
|
+
return reply(await auditLibrary(state.qp, state.lib.path, args));
|
|
296
|
+
});
|
|
297
|
+
server.registerTool("run_sql", {
|
|
298
|
+
title: "Run a read-only SQL query",
|
|
299
|
+
description: "Escape hatch for questions the other tools do not cover. Read-only is enforced by the " +
|
|
300
|
+
"kernel, not by this check alone. Use side.track_derived.camelot and side.track_derived.tempo " +
|
|
301
|
+
"in WHERE clauses rather than the camelot()/tempo() SQL functions, which run per row and defeat " +
|
|
302
|
+
"indexes. " +
|
|
303
|
+
LIBRARY_SELECTION_NOTE,
|
|
304
|
+
inputSchema: { ...RunSqlInput.shape, library: LibraryArg },
|
|
305
|
+
annotations: RO,
|
|
306
|
+
}, async (args) => {
|
|
307
|
+
const state = await acquire(args.library);
|
|
308
|
+
if (isEngineError(state))
|
|
309
|
+
return reply(state);
|
|
310
|
+
return reply(await runSql(state.qp, args));
|
|
311
|
+
});
|
|
312
|
+
server.registerTool("list_libraries", {
|
|
313
|
+
title: "List Engine DJ libraries",
|
|
314
|
+
description: "List every discovered library, including ones whose schema is unsupported. " +
|
|
315
|
+
"Re-scans on every call, so a drive plugged in after this server started is visible " +
|
|
316
|
+
"without a restart (the engine://libraries resource is a start-time snapshot). A " +
|
|
317
|
+
"library seen before but not readable right now (e.g. Engine DJ is writing to it) " +
|
|
318
|
+
"stays listed with status: \"unreadable\" and error set, instead of disappearing. " +
|
|
319
|
+
"Pass a listed uuid or path as the `library` argument of any other tool to act on that " +
|
|
320
|
+
"library; without it they use the supported library holding the most tracks.",
|
|
321
|
+
inputSchema: {},
|
|
322
|
+
annotations: RO,
|
|
323
|
+
}, async () => reply(await libraryReport(rescanLibraries())));
|
|
324
|
+
server.registerTool("refresh_index", {
|
|
325
|
+
title: "Refresh the search index",
|
|
326
|
+
description: "Rebuild the search index if the library has changed. " + LIBRARY_SELECTION_NOTE,
|
|
327
|
+
inputSchema: { library: LibraryArg },
|
|
328
|
+
annotations: RO,
|
|
329
|
+
}, async (args) => {
|
|
330
|
+
// Not gated through acquire(): this tool *is* the gate, so it reports
|
|
331
|
+
// ensureFresh's own result rather than swallowing index_stale.
|
|
332
|
+
const lib = selectLibrary(args.library);
|
|
333
|
+
if (isEngineError(lib))
|
|
334
|
+
return reply(lib);
|
|
335
|
+
return reply(await refreshIndex(stateFor(lib).mgr));
|
|
336
|
+
});
|
|
337
|
+
/**
|
|
338
|
+
* There was previously no way to shut this down at all: createServer
|
|
339
|
+
* forked a query child and handed back an McpServer whose close() knows
|
|
340
|
+
* only about the transport, so the child outlived every caller -- a leak
|
|
341
|
+
* in tests, and in a host that restarts its MCP servers a leak of one
|
|
342
|
+
* process per restart.
|
|
343
|
+
*
|
|
344
|
+
* close() is wrapped rather than replaced so a client disconnecting
|
|
345
|
+
* through the normal MCP path also releases the children; dispose() is
|
|
346
|
+
* exposed for a caller that owns the server directly. QueryProcess#kill
|
|
347
|
+
* tolerates being called with no live child, so both are idempotent --
|
|
348
|
+
* and it is *every* library's child now, not just the first one, or a
|
|
349
|
+
* session that touched two drives would leak one process per drive.
|
|
350
|
+
*/
|
|
351
|
+
const disposeAll = () => {
|
|
352
|
+
for (const state of states.values())
|
|
353
|
+
state.qp.dispose();
|
|
354
|
+
};
|
|
355
|
+
const closeTransport = server.close.bind(server);
|
|
356
|
+
server.dispose = disposeAll;
|
|
357
|
+
server.close = async () => {
|
|
358
|
+
try {
|
|
359
|
+
await closeTransport();
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
disposeAll();
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
return server;
|
|
366
|
+
}
|
|
367
|
+
const SCHEMA_NOTE = `# Engine DJ library — schema and semantics
|
|
368
|
+
|
|
369
|
+
Tables live in \`m.db\` (attached as \`main\`); the search index lives in \`side\`.
|
|
370
|
+
|
|
371
|
+
## Choosing a library
|
|
372
|
+
More than one library can be connected at once — the local one under
|
|
373
|
+
\`~/Music\` and one per USB drive. \`list_libraries\` reports each with a
|
|
374
|
+
\`uuid\` and a \`path\`, and every tool that reads library data
|
|
375
|
+
(\`search_tracks\`, \`get_tracks\`, \`get_track_performance\`,
|
|
376
|
+
\`audit_library\`, \`run_sql\`, \`refresh_index\`) takes an optional
|
|
377
|
+
\`library\` argument naming one of them: either the \`uuid\` or the
|
|
378
|
+
\`path\`, in the \`~/...\` form \`list_libraries\` prints or the absolute
|
|
379
|
+
one. A value matching neither comes back as \`library_not_found\` listing
|
|
380
|
+
the libraries that are selectable.
|
|
381
|
+
|
|
382
|
+
Omitting \`library\` selects the supported library holding the most tracks,
|
|
383
|
+
ties broken by scan order — so an empty local library does not shadow the
|
|
384
|
+
populated drive a DJ actually works from. Each library keeps its own search
|
|
385
|
+
index, and every query, audit and path resolution stays inside the library
|
|
386
|
+
selected for that call; nothing here compares two libraries against each
|
|
387
|
+
other.
|
|
388
|
+
|
|
389
|
+
## Field semantics
|
|
390
|
+
- \`Track.key\` is 0..23, \`-1\` means undetermined. The mapping to Camelot
|
|
391
|
+
notation is confirmed against Engine DJ's own display (key=20 shows as 6B,
|
|
392
|
+
exactly what the formula produces). Use \`side.track_derived.camelot\` for
|
|
393
|
+
filtering -- it is indexed. The SQL function \`camelot(key)\` exists but
|
|
394
|
+
runs per row and defeats indexes.
|
|
395
|
+
- Real tempo is \`COALESCE(bpmAnalyzed, bpm)\`. \`bpm\` is stored at face
|
|
396
|
+
value -- 102 means 102 BPM, not 10200. It is NOT scaled by 100; that is a
|
|
397
|
+
rekordbox convention, not an Engine one (confirmed against a real Engine
|
|
398
|
+
library: stored values of 102, 105, 128, 145, 147 each matched
|
|
399
|
+
\`bpmAnalyzed\` to within 0.68, and Engine's own interface displays 102 for
|
|
400
|
+
the track stored as 102). \`side.track_derived.tempo\` holds the resolved
|
|
401
|
+
value and is indexed.
|
|
402
|
+
- \`Track.path\` is relative to the \`Engine Library\` folder and usually
|
|
403
|
+
contains \`..\`. The SQL function \`abs_path(path)\` resolves it against
|
|
404
|
+
this library's location; the home prefix comes back folded to \`~\`.
|
|
405
|
+
- Playlists are singly linked lists: order lives in \`Playlist.nextListId\`
|
|
406
|
+
and \`PlaylistEntity.nextEntityId\`, not in any position column.
|
|
407
|
+
- A track's natural key across drives is \`(originDatabaseUuid, originTrackId)\`.
|
|
408
|
+
- \`PerformanceData\`'s blob columns are binary and cannot be read with SQL.
|
|
409
|
+
Engine writes \`quickCues\`, \`loops\`, \`beatData\` and
|
|
410
|
+
\`overviewWaveFormData\` to **every analysed track** whether or not the DJ
|
|
411
|
+
set anything, so \`quickCues IS NOT NULL\` means "analysed", not "has
|
|
412
|
+
cues": a track with no hot cues still carries a full eight-slot blob.
|
|
413
|
+
\`get_track_performance\` decodes one track's blobs; for the whole library,
|
|
414
|
+
\`side.track_derived.has_cues\` below holds the decoded answer.
|
|
415
|
+
|
|
416
|
+
## SQL functions
|
|
417
|
+
Registered on the query connection, all deterministic:
|
|
418
|
+
\`camelot(key)\`, \`key_name(key)\`, \`tempo(bpmAnalyzed, bpm)\`,
|
|
419
|
+
\`key_distance(a, b)\` and \`abs_path(path)\`. Each runs a callback per row,
|
|
420
|
+
so use them for projection and one-off questions, not in a \`WHERE\` clause
|
|
421
|
+
where \`side.track_derived\` is indexed and these are not.
|
|
422
|
+
|
|
423
|
+
## Sidecar tables
|
|
424
|
+
- \`side.fts_track\` — FTS5 over title, artist, album, genre, comment, label,
|
|
425
|
+
with diacritics folded. Join via \`side.fts_map(rowid, track_id)\`.
|
|
426
|
+
- \`side.track_derived(track_id, camelot, tempo, has_cues, has_grid)\` — indexed.
|
|
427
|
+
\`has_cues\` is **not** \`quickCues IS NOT NULL\`: the blob is decoded when
|
|
428
|
+
this index is built, and the column means "at least one hot cue is actually
|
|
429
|
+
set". It is the right column for "which tracks have no cues?", and the
|
|
430
|
+
matching \`audit_library\` check is \`no_cues\`. \`has_grid\` does mean "a
|
|
431
|
+
\`beatData\` blob is present and non-empty", which on a real library is the
|
|
432
|
+
same thing as an analysed beatgrid.
|
|
433
|
+
|
|
434
|
+
## Limits
|
|
435
|
+
The connection is read-only at the kernel level, not by convention: writes
|
|
436
|
+
are refused by SQLite itself. \`VACUUM\`, \`ATTACH\` and \`DETACH\` are
|
|
437
|
+
rejected. Only one statement per call. Queries are killed after 10 seconds.
|
|
438
|
+
\`search_tracks\`'s \`total\` (when requested) is capped at 1000; check
|
|
439
|
+
\`total_capped\` before treating it as exact.`;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface BuildArgs {
|
|
2
|
+
mdbPath: string;
|
|
3
|
+
outPath: string;
|
|
4
|
+
uuid: string;
|
|
5
|
+
schema: string;
|
|
6
|
+
generation?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Full rebuild. There is no incremental path on purpose: lastEditTime is only
|
|
10
|
+
* bumped by UPDATE of 21 Track columns, so it misses inserts, deletes, path
|
|
11
|
+
* changes and play events, while a full rebuild is ~240 ms at 50k rows (3 ms
|
|
12
|
+
* on the real 257-track library it was measured against). About 100 ms of the
|
|
13
|
+
* 50k figure is decoding quickCues for has_cues, which is the price of that
|
|
14
|
+
* column answering "a cue is set" instead of "Engine analysed this".
|
|
15
|
+
*/
|
|
16
|
+
export declare function buildSidecar(args: BuildArgs): {
|
|
17
|
+
indexed: number;
|
|
18
|
+
elapsed_ms: number;
|
|
19
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import { openSyncConnection } from "../store/connections.js";
|
|
3
|
+
import { readChangeCounter } from "../probe.js";
|
|
4
|
+
import { hasCueSet } from "../blobs/index.js";
|
|
5
|
+
import { SIDECAR_DDL, SIDECAR_INDEXES, SIDECAR_FORMAT } from "./schema.js";
|
|
6
|
+
/**
|
|
7
|
+
* Full rebuild. There is no incremental path on purpose: lastEditTime is only
|
|
8
|
+
* bumped by UPDATE of 21 Track columns, so it misses inserts, deletes, path
|
|
9
|
+
* changes and play events, while a full rebuild is ~240 ms at 50k rows (3 ms
|
|
10
|
+
* on the real 257-track library it was measured against). About 100 ms of the
|
|
11
|
+
* 50k figure is decoding quickCues for has_cues, which is the price of that
|
|
12
|
+
* column answering "a cue is set" instead of "Engine analysed this".
|
|
13
|
+
*/
|
|
14
|
+
export function buildSidecar(args) {
|
|
15
|
+
const started = Date.now();
|
|
16
|
+
const counter = readChangeCounter(args.mdbPath);
|
|
17
|
+
rmSync(args.outPath, { force: true });
|
|
18
|
+
const db = openSyncConnection(args.outPath, args.mdbPath);
|
|
19
|
+
try {
|
|
20
|
+
// Durability is irrelevant: the file is disposable and swapped in atomically.
|
|
21
|
+
db.exec("PRAGMA journal_mode = OFF");
|
|
22
|
+
db.exec("PRAGMA synchronous = OFF");
|
|
23
|
+
for (const ddl of SIDECAR_DDL)
|
|
24
|
+
db.exec(ddl);
|
|
25
|
+
// Registered here rather than in semantics.ts, which is the set of
|
|
26
|
+
// functions the *model* can call through run_sql: this one runs a zlib
|
|
27
|
+
// inflate and a blob walk per row, so exposing it to arbitrary SQL would
|
|
28
|
+
// hand out a way to make any query cost a full decode of the library.
|
|
29
|
+
// The rebuild is the one place that needs it, and it runs once per
|
|
30
|
+
// library change.
|
|
31
|
+
//
|
|
32
|
+
// hasCueSet never throws (nor does anything under it), so a corrupt blob
|
|
33
|
+
// yields 0 rather than aborting a build over one bad row.
|
|
34
|
+
db.function("has_cue_set", { deterministic: true }, (v) => hasCueSet(v instanceof Uint8Array ? Buffer.from(v) : null) ? 1 : 0);
|
|
35
|
+
db.exec("BEGIN");
|
|
36
|
+
db.exec(`INSERT INTO fts_track(rowid, title, artist, album, genre, comment, label)
|
|
37
|
+
SELECT id, title, artist, album, genre, comment, label FROM engine.Track`);
|
|
38
|
+
db.exec(`INSERT INTO fts_map(rowid, track_id) SELECT id, id FROM engine.Track`);
|
|
39
|
+
// has_cues decodes the blob; has_grid tests that one is there. The
|
|
40
|
+
// asymmetry is measured on 281 real blobs, not a shortcut:
|
|
41
|
+
//
|
|
42
|
+
// quickCues is written to every analysed track with all eight slots at
|
|
43
|
+
// the -1.0 "unused" sentinel, so "the blob exists" is true for 281 of
|
|
44
|
+
// 281 while a cue is set on 3. A DJ asking which tracks still need cue
|
|
45
|
+
// points was told none did, on a library where 255 of 257 do. Only
|
|
46
|
+
// decoding can tell those apart, and this is our code, not SQL.
|
|
47
|
+
//
|
|
48
|
+
// beatData carries no equivalent "analysed but empty" state. All 281
|
|
49
|
+
// blobs decode, all 281 carry two markers in each of the two grids,
|
|
50
|
+
// the present-flag byte is 1 on all 281, and every implied tempo
|
|
51
|
+
// matches Track.bpmAnalyzed to within 0.5 BPM. Presence and a real
|
|
52
|
+
// grid have not once disagreed, because a beatgrid is produced by
|
|
53
|
+
// analysis where a cue is placed by a human. Decoding it would cost
|
|
54
|
+
// ~570 ms per rebuild at 50k tracks (measured: 2.95 ms for 257 blobs)
|
|
55
|
+
// to re-derive an answer already correct on every row available.
|
|
56
|
+
//
|
|
57
|
+
// "Empty OR NULL", not "NOT NULL", still holds for has_grid: a
|
|
58
|
+
// zero-length blob carries no beatgrid. Reading it as present made a
|
|
59
|
+
// track has_beatgrid: 1 in search while get_track_performance reported
|
|
60
|
+
// the same blob as `empty`.
|
|
61
|
+
db.exec(`INSERT INTO track_derived(track_id, camelot, tempo, has_cues, has_grid)
|
|
62
|
+
SELECT t.id,
|
|
63
|
+
camelot(t.key),
|
|
64
|
+
tempo(t.bpmAnalyzed, t.bpm),
|
|
65
|
+
has_cue_set(p.quickCues),
|
|
66
|
+
CASE WHEN COALESCE(length(p.beatData), 0) = 0 THEN 0 ELSE 1 END
|
|
67
|
+
FROM engine.Track t
|
|
68
|
+
LEFT JOIN engine.PerformanceData p ON p.trackId = t.id`);
|
|
69
|
+
db.exec("COMMIT");
|
|
70
|
+
for (const ix of SIDECAR_INDEXES)
|
|
71
|
+
db.exec(ix);
|
|
72
|
+
const indexed = Number(db.prepare("SELECT COUNT(*) c FROM track_derived").get().c);
|
|
73
|
+
db.prepare(`INSERT INTO index_meta (library_uuid, schema_version, change_counter, built_at, generation, index_format)
|
|
74
|
+
VALUES (?,?,?,?,?,?)`)
|
|
75
|
+
.run(args.uuid, args.schema, counter, Math.floor(started / 1000), args.generation ?? 1, SIDECAR_FORMAT);
|
|
76
|
+
return { indexed, elapsed_ms: Date.now() - started };
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
rmSync(args.outPath, { force: true });
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
db.close();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version of *this* file's meaning, not of the Engine library's schema.
|
|
3
|
+
*
|
|
4
|
+
* The staleness probe compares the library's change counter, so a sidecar
|
|
5
|
+
* only gets rebuilt when the library changes. That is right for content and
|
|
6
|
+
* wrong for semantics: when a column starts meaning something different, an
|
|
7
|
+
* untouched library would keep serving the old meaning from disk forever.
|
|
8
|
+
* `index_meta.index_format` is stored on build and checked on load, so a
|
|
9
|
+
* sidecar written by a different version of this code is treated as stale.
|
|
10
|
+
*
|
|
11
|
+
* 1 — the original columns.
|
|
12
|
+
* 2 — `has_cues` changed from "a quickCues blob exists" to "a hot cue is
|
|
13
|
+
* actually set", which is a different answer for 278 of 281 real tracks.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SIDECAR_FORMAT = 2;
|
|
16
|
+
export declare const SIDECAR_DDL: readonly [`CREATE VIRTUAL TABLE fts_track USING fts5(
|
|
17
|
+
title, artist, album, genre, comment, label,
|
|
18
|
+
tokenize='unicode61 remove_diacritics 2')`, `CREATE TABLE fts_map (rowid INTEGER PRIMARY KEY, track_id INTEGER UNIQUE)`, `CREATE TABLE track_derived (
|
|
19
|
+
track_id INTEGER PRIMARY KEY,
|
|
20
|
+
camelot TEXT, tempo REAL,
|
|
21
|
+
has_cues INTEGER, has_grid INTEGER)`, `CREATE TABLE index_meta (
|
|
22
|
+
library_uuid TEXT, schema_version TEXT,
|
|
23
|
+
change_counter INTEGER, built_at INTEGER, generation INTEGER,
|
|
24
|
+
index_format INTEGER)`];
|
|
25
|
+
export declare const SIDECAR_INDEXES: readonly [`CREATE INDEX ix_derived_camelot ON track_derived(camelot)`, `CREATE INDEX ix_derived_tempo ON track_derived(tempo)`];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version of *this* file's meaning, not of the Engine library's schema.
|
|
3
|
+
*
|
|
4
|
+
* The staleness probe compares the library's change counter, so a sidecar
|
|
5
|
+
* only gets rebuilt when the library changes. That is right for content and
|
|
6
|
+
* wrong for semantics: when a column starts meaning something different, an
|
|
7
|
+
* untouched library would keep serving the old meaning from disk forever.
|
|
8
|
+
* `index_meta.index_format` is stored on build and checked on load, so a
|
|
9
|
+
* sidecar written by a different version of this code is treated as stale.
|
|
10
|
+
*
|
|
11
|
+
* 1 — the original columns.
|
|
12
|
+
* 2 — `has_cues` changed from "a quickCues blob exists" to "a hot cue is
|
|
13
|
+
* actually set", which is a different answer for 278 of 281 real tracks.
|
|
14
|
+
*/
|
|
15
|
+
export const SIDECAR_FORMAT = 2;
|
|
16
|
+
export const SIDECAR_DDL = [
|
|
17
|
+
`CREATE VIRTUAL TABLE fts_track USING fts5(
|
|
18
|
+
title, artist, album, genre, comment, label,
|
|
19
|
+
tokenize='unicode61 remove_diacritics 2')`,
|
|
20
|
+
`CREATE TABLE fts_map (rowid INTEGER PRIMARY KEY, track_id INTEGER UNIQUE)`,
|
|
21
|
+
// has_cues: a hot cue is actually set (the quickCues blob is decoded during
|
|
22
|
+
// the build); has_grid: a beatData blob is present. The asymmetry is
|
|
23
|
+
// measured, not an oversight -- see sidecar/build.ts.
|
|
24
|
+
`CREATE TABLE track_derived (
|
|
25
|
+
track_id INTEGER PRIMARY KEY,
|
|
26
|
+
camelot TEXT, tempo REAL,
|
|
27
|
+
has_cues INTEGER, has_grid INTEGER)`,
|
|
28
|
+
`CREATE TABLE index_meta (
|
|
29
|
+
library_uuid TEXT, schema_version TEXT,
|
|
30
|
+
change_counter INTEGER, built_at INTEGER, generation INTEGER,
|
|
31
|
+
index_format INTEGER)`,
|
|
32
|
+
];
|
|
33
|
+
export const SIDECAR_INDEXES = [
|
|
34
|
+
`CREATE INDEX ix_derived_camelot ON track_derived(camelot)`,
|
|
35
|
+
`CREATE INDEX ix_derived_tempo ON track_derived(tempo)`,
|
|
36
|
+
];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
/**
|
|
3
|
+
* True when `<mdbPath>-journal` exists and carries SQLite's real hot-journal
|
|
4
|
+
* magic, meaning a previous writer died mid-transaction with unflushed pages
|
|
5
|
+
* on disk. SQLite itself will refuse to open such a database read-only (it
|
|
6
|
+
* needs to roll the journal forward, which requires a write) and raises a
|
|
7
|
+
* raw, unhelpful "attempt to write a readonly database" error. This lets
|
|
8
|
+
* openQueryConnection detect the condition first and explain it instead.
|
|
9
|
+
*/
|
|
10
|
+
export declare function hasHotJournal(mdbPath: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Connection A — the one the model reaches through.
|
|
13
|
+
*
|
|
14
|
+
* m.db is the MAIN database and is opened readOnly, so the restriction is a
|
|
15
|
+
* property of the file descriptor rather than a PRAGMA. The inverse layout
|
|
16
|
+
* (sidecar as main, m.db attached read-only) was rejected: SQL-level
|
|
17
|
+
* `ATTACH '<m.db>' AS rw` escapes it and can write to the user's library.
|
|
18
|
+
* PRAGMA query_only was rejected too, because SQL can turn it back off.
|
|
19
|
+
*
|
|
20
|
+
* The attach path is bound as a parameter, not interpolated into the SQL
|
|
21
|
+
* text: a library directory containing an apostrophe (e.g. `Rock 'n' Roll`)
|
|
22
|
+
* breaks out of a string literal built by concatenation.
|
|
23
|
+
*/
|
|
24
|
+
export declare function openQueryConnection(mdbPath: string, sidecar: string | null): DatabaseSync;
|
|
25
|
+
/** Connection B — used only by our own rebuild code, never exposed to the model. */
|
|
26
|
+
export declare function openSyncConnection(sidecar: string, mdbPath: string): DatabaseSync;
|
|
27
|
+
/** Re-attach the sidecar after an atomic swap; rename() alone is invisible. */
|
|
28
|
+
export declare function reattachSidecar(db: DatabaseSync, sidecar: string): void;
|