engine-dj-mcp 0.9.1 → 0.9.2

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.
@@ -307,6 +307,18 @@ export declare function decodeBeatgrid(buf: Buffer | null): BeatgridResult;
307
307
  * `duration_seconds` still comes from Track.length rather than from the
308
308
  * blob: the overview carries a sample *count* but no sample rate, so a
309
309
  * duration derived from it alone would be a guess.
310
+ *
311
+ * `buckets` has no caller today that passes anything but the default, so a
312
+ * bad value here is unreachable in practice -- guarded anyway so it stays
313
+ * that way for the next caller rather than becoming a trap. A non-finite or
314
+ * non-positive value (0, negative, NaN, +/-Infinity) falls back to the
315
+ * default instead of reaching the bucket-size arithmetic below: 0 or a
316
+ * negative value there would clamp the *denominator*, not the bucket count,
317
+ * which maximises bucket size and collapses the whole track into one giant
318
+ * bucket -- the opposite of "more buckets"; NaN propagates through to a
319
+ * single bucket reporting a peak of 0 regardless of the actual audio. Both
320
+ * still come back `status: "ok"` -- a confident, wrong answer, not a
321
+ * request this function visibly declined to honour.
310
322
  */
311
323
  export declare function summariseWaveform(buf: Buffer | null, buckets?: number, durationSeconds?: number | null): WaveformSummary;
312
324
  export interface PerformanceRow {
@@ -413,10 +413,23 @@ export function decodeBeatgrid(buf) {
413
413
  * `duration_seconds` still comes from Track.length rather than from the
414
414
  * blob: the overview carries a sample *count* but no sample rate, so a
415
415
  * duration derived from it alone would be a guess.
416
+ *
417
+ * `buckets` has no caller today that passes anything but the default, so a
418
+ * bad value here is unreachable in practice -- guarded anyway so it stays
419
+ * that way for the next caller rather than becoming a trap. A non-finite or
420
+ * non-positive value (0, negative, NaN, +/-Infinity) falls back to the
421
+ * default instead of reaching the bucket-size arithmetic below: 0 or a
422
+ * negative value there would clamp the *denominator*, not the bucket count,
423
+ * which maximises bucket size and collapses the whole track into one giant
424
+ * bucket -- the opposite of "more buckets"; NaN propagates through to a
425
+ * single bucket reporting a peak of 0 regardless of the actual audio. Both
426
+ * still come back `status: "ok"` -- a confident, wrong answer, not a
427
+ * request this function visibly declined to honour.
416
428
  */
417
429
  export function summariseWaveform(buf, buckets = 32, durationSeconds = null) {
418
430
  if (!buf || buf.length === 0)
419
431
  return { layout: LAYOUT_VERIFIED, status: "empty" };
432
+ const safeBuckets = Number.isFinite(buckets) && buckets > 0 ? buckets : 32;
420
433
  try {
421
434
  const data = qUncompress(buf);
422
435
  if (data.length === 0)
@@ -431,7 +444,7 @@ export function summariseWaveform(buf, buckets = 32, durationSeconds = null) {
431
444
  // bytes(): bounds-checked, so a count larger than the blob is refused
432
445
  // here rather than producing a silently short waveform.
433
446
  const points = r.bytes(entries * 3);
434
- const size = Math.max(1, Math.ceil(entries / Math.max(1, buckets)));
447
+ const size = Math.max(1, Math.ceil(entries / safeBuckets));
435
448
  const profile = [];
436
449
  for (let i = 0; i < entries; i += size) {
437
450
  let peak = 0;
package/dist/discovery.js CHANGED
@@ -2,13 +2,25 @@ import { existsSync, readdirSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
- import { err } from "./errors.js";
5
+ import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
6
+ import { hasHotJournal } from "./store/connections.js";
6
7
  import { libraryCandidates } from "./paths.js";
7
8
  export const SUPPORTED_SCHEMAS = ["3.0.0", "3.0.1", "3.0.2"];
8
9
  export function readLibraryInfo(mdbPath) {
9
10
  if (!existsSync(mdbPath)) {
10
11
  return err("library_not_found", `No Engine library database at ${mdbPath}`);
11
12
  }
13
+ if (hasHotJournal(mdbPath)) {
14
+ // Same check openQueryConnection makes before opening (store/connections.ts):
15
+ // recovering a hot journal needs a write, which this project never
16
+ // performs, even to probe a library. Caught here first so the specific,
17
+ // actionable library_needs_recovery reaches the caller instead of the
18
+ // SELECT below failing with the raw "attempt to write a readonly
19
+ // database" and landing in the generic library_unreadable catch --
20
+ // that conflation is exactly what made this condition hard to diagnose
21
+ // in practice.
22
+ return libraryNeedsRecovery();
23
+ }
12
24
  let db;
13
25
  try {
14
26
  // A plain path, never a hand-built "file:" URI. SQLite's URI syntax
@@ -39,8 +51,9 @@ export function readLibraryInfo(mdbPath) {
39
51
  // with Number(); nothing here forces an oversized column through it.
40
52
  stmt.setReadBigInts(true);
41
53
  const row = stmt.get();
54
+ // Not a version problem -- there is no row to read a version from.
42
55
  if (!row)
43
- return err("unsupported_schema", "Information table is empty");
56
+ return err("library_unreadable", "Information table is empty");
44
57
  const schema = [
45
58
  Number(row.schemaVersionMajor ?? 0),
46
59
  Number(row.schemaVersionMinor ?? 0),
@@ -57,7 +70,12 @@ export function readLibraryInfo(mdbPath) {
57
70
  return { path: mdbPath, uuid: String(row.uuid ?? ""), schema, supported, trackCount };
58
71
  }
59
72
  catch (e) {
60
- return err("unsupported_schema", "Could not read Information", {
73
+ // Whatever this is -- corruption, a permissions problem, a lock the
74
+ // open survived but the read did not -- it was never about the schema
75
+ // version: that is only known once this SELECT has actually returned a
76
+ // row, which it did not. The hot-journal case is carved out above, so
77
+ // this is the genuine remainder.
78
+ return err("library_unreadable", "Could not read Information", {
61
79
  detail: String(e.message),
62
80
  });
63
81
  }
@@ -91,7 +109,7 @@ export function probeLibraries(roots = defaultRoots()) {
91
109
  if (!existsSync(candidate))
92
110
  continue;
93
111
  const info = readLibraryInfo(candidate);
94
- out.push("error" in info
112
+ out.push(isEngineError(info)
95
113
  ? { path: candidate, info: null, error: info }
96
114
  : { path: candidate, info, error: null });
97
115
  }
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery"];
1
+ export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "library_unreadable", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery"];
2
2
  export type ErrorCode = (typeof ERROR_CODES)[number];
3
3
  export interface EngineError {
4
4
  error: ErrorCode;
package/dist/errors.js CHANGED
@@ -1,6 +1,17 @@
1
1
  export const ERROR_CODES = [
2
2
  "library_busy",
3
3
  "library_not_found",
4
+ // The library was found and opened, but a read against it failed for a
5
+ // reason that has nothing to do with schema version -- corruption, a
6
+ // permissions problem, an oversized column node:sqlite refuses to convert.
7
+ // Distinct from unsupported_schema (below), which is specifically "this
8
+ // version is outside the allowlist", and from library_needs_recovery,
9
+ // which is specifically a hot journal: discovery.ts checks for that first
10
+ // and reports it precisely, so this is only the remainder. Previously
11
+ // every one of these landed on unsupported_schema, which sent debugging
12
+ // toward "check the schema version" for a failure that was never about
13
+ // the schema at all.
14
+ "library_unreadable",
4
15
  "unsupported_schema",
5
16
  "query_timeout",
6
17
  "query_process_crashed",
package/dist/server.d.ts CHANGED
@@ -4,9 +4,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  * design (a permissions error on one candidate must not blank out every
5
5
  * other one). That means a hot journal on the *only* library on this
6
6
  * machine looks identical to no library existing at all -- both come back
7
- * as an empty list, verified: opening it raises "attempt to write a
8
- * readonly database", which readLibraryInfo currently folds into
9
- * unsupported_schema and then drops entirely.
7
+ * as an empty list, verified: readLibraryInfo (discovery.ts) does report a
8
+ * hot journal precisely, as library_needs_recovery, but discoverLibraries()
9
+ * still drops it along with every other unreadable candidate, by that same
10
+ * design.
10
11
  *
11
12
  * This walks the same candidate paths independently, purely to tell those
12
13
  * two cases apart, so `ready()` below can report library_needs_recovery
package/dist/server.js CHANGED
@@ -54,9 +54,10 @@ function reply(value) {
54
54
  * design (a permissions error on one candidate must not blank out every
55
55
  * other one). That means a hot journal on the *only* library on this
56
56
  * machine looks identical to no library existing at all -- both come back
57
- * as an empty list, verified: opening it raises "attempt to write a
58
- * readonly database", which readLibraryInfo currently folds into
59
- * unsupported_schema and then drops entirely.
57
+ * as an empty list, verified: readLibraryInfo (discovery.ts) does report a
58
+ * hot journal precisely, as library_needs_recovery, but discoverLibraries()
59
+ * still drops it along with every other unreadable candidate, by that same
60
+ * design.
60
61
  *
61
62
  * This walks the same candidate paths independently, purely to tell those
62
63
  * two cases apart, so `ready()` below can report library_needs_recovery
@@ -220,18 +221,30 @@ export async function createServer(opts = {}) {
220
221
  * null, not as "generation zero". A library nobody has queried yet has no
221
222
  * IndexManager at all and reports null for the same reason: listing the
222
223
  * libraries must not fork a query child per drive to fill in a number.
224
+ *
225
+ * Reads each known IndexManager's generation via peekGeneration(), not
226
+ * ensureFresh(): ensureFresh() also rebuilds when the library has
227
+ * changed, which is exactly right for a tool that is about to query the
228
+ * index and exactly wrong here. list_libraries is what a user reaches
229
+ * for when something looks broken, and list_libraries re-scans on every
230
+ * call (see below) -- so making it pay for a first, or renewed, index
231
+ * build on a big or currently-locked library would make the one
232
+ * diagnostic tool that must stay fast the one most likely to block.
233
+ * peekGeneration() only reads the sidecar already on disk, so this stays
234
+ * honest (a real, current generation number, never a fabricated one) and
235
+ * never forces work list_libraries does not itself need to answer.
223
236
  */
224
- const libraryReport = async (discovered) => {
237
+ const libraryReport = (discovered) => {
225
238
  const generations = new Map();
226
239
  for (const state of states.values()) {
227
- await state.mgr.ensureFresh(); // best effort: keeps a live library's generation accurate
228
- if (state.mgr.generation > 0)
229
- generations.set(state.lib.uuid, state.mgr.generation);
240
+ const generation = state.mgr.peekGeneration();
241
+ if (generation > 0)
242
+ generations.set(state.lib.uuid, generation);
230
243
  }
231
244
  return listLibraries(generations, discovered);
232
245
  };
233
246
  server.registerResource("schema", "engine://schema", { title: "Engine DJ schema and semantics", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, text: SCHEMA_NOTE }] }));
234
- 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) }] }));
247
+ server.registerResource("libraries", "engine://libraries", { title: "Discovered Engine DJ libraries", mimeType: "application/json" }, async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(libraryReport(libs), null, 2) }] }));
235
248
  server.registerTool("search_tracks", {
236
249
  title: "Search tracks",
237
250
  description: "Search the Engine DJ library by text, tempo, key, rating, play history and analysis " +
@@ -335,7 +348,7 @@ export async function createServer(opts = {}) {
335
348
  "library; without it they use the supported library holding the most tracks.",
336
349
  inputSchema: {},
337
350
  annotations: RO,
338
- }, async () => reply(await libraryReport(rescanLibraries())));
351
+ }, async () => reply(libraryReport(rescanLibraries())));
339
352
  server.registerTool("refresh_index", {
340
353
  title: "Refresh the search index",
341
354
  description: "Rebuild the search index if the library has changed. " + LIBRARY_SELECTION_NOTE,
@@ -25,5 +25,17 @@ export declare class IndexManager {
25
25
  constructor(lib: LibraryInfo, qp: QueryProcess, baseDir?: string);
26
26
  get generation(): number;
27
27
  get path(): string;
28
+ /**
29
+ * The generation of whatever index currently exists on disk, read
30
+ * directly from the sidecar's own index_meta table -- never built,
31
+ * rebuilt or attached. This is the cheap half of ensureFresh() (the part
32
+ * that does not open the main library at all), used by list_libraries so
33
+ * that asking "what libraries do I have" is never the call that pays for
34
+ * a first (or renewed) index build: that cost belongs to whichever tool
35
+ * call actually needs a fresh index, through ensureFresh(). Returns 0,
36
+ * the same "not a real generation" value ensureFresh() reports before
37
+ * ever building one, when no usable sidecar exists yet.
38
+ */
39
+ peekGeneration(): number;
28
40
  ensureFresh(): Promise<FreshResult | EngineError>;
29
41
  }
@@ -33,6 +33,21 @@ export class IndexManager {
33
33
  get path() {
34
34
  return join(this.baseDir, this.lib.uuid, "index.db");
35
35
  }
36
+ /**
37
+ * The generation of whatever index currently exists on disk, read
38
+ * directly from the sidecar's own index_meta table -- never built,
39
+ * rebuilt or attached. This is the cheap half of ensureFresh() (the part
40
+ * that does not open the main library at all), used by list_libraries so
41
+ * that asking "what libraries do I have" is never the call that pays for
42
+ * a first (or renewed) index build: that cost belongs to whichever tool
43
+ * call actually needs a fresh index, through ensureFresh(). Returns 0,
44
+ * the same "not a real generation" value ensureFresh() reports before
45
+ * ever building one, when no usable sidecar exists yet.
46
+ */
47
+ peekGeneration() {
48
+ this.#storedCounter();
49
+ return this.#generation;
50
+ }
36
51
  /**
37
52
  * The change counter the sidecar on disk was built from, or null when
38
53
  * there is no usable sidecar — which forces a rebuild.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engine-dj-mcp",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Read-only MCP server for searching and auditing an Engine DJ library. Not affiliated with inMusic or Denon DJ.",
5
5
  "keywords": [
6
6
  "mcp",