engine-dj-mcp 0.11.0 → 0.11.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.
package/README.md CHANGED
@@ -3,13 +3,15 @@
3
3
  [![npm](https://img.shields.io/npm/v/engine-dj-mcp)](https://www.npmjs.com/package/engine-dj-mcp)
4
4
  [![licence](https://img.shields.io/npm/l/engine-dj-mcp)](./LICENSE)
5
5
 
6
- An MCP server that lets an AI assistant search and audit your **Engine DJ**
7
- libraries — the one on your computer and the ones on your USB drives.
6
+ An MCP server that gives an AI assistant your **Engine DJ** libraries — the
7
+ one on your computer and the ones on your USB drives. It searches and audits
8
+ them, reads the cues and beatgrids Engine stored, and builds playlists when
9
+ you ask it to.
8
10
 
9
11
  > **Not affiliated with, endorsed by, or sponsored by inMusic Brands, Denon
10
12
  > DJ, or the Engine DJ product.** "Engine DJ" is used here only to name the
11
- > software whose library this tool reads. No logos or brand artwork from
12
- > inMusic or Denon DJ are used in this project.
13
+ > software whose library this tool reads and writes. No logos or brand
14
+ > artwork from inMusic or Denon DJ are used in this project.
13
15
 
14
16
  Your library is opened **read-only at the operating-system level**. It is
15
17
  never written to unless you start the server with `--allow-writes` — see
@@ -22,10 +22,12 @@ import type { QueryProcess } from "./proc/query-client.js";
22
22
  * never read by this project.
23
23
  *
24
24
  * Everything here therefore walks the chain — defensively. These are linked
25
- * lists inside a file this server does not own and never writes: a
26
- * half-completed Engine write, a sync conflict or a partially restored
27
- * backup can leave a cycle, a link to a row that is gone, or two
28
- * disconnected runs. None of those may hang the walk, and none may come
25
+ * lists inside a file this server does not own, and which nothing in this
26
+ * module writes: a half-completed Engine write, a sync conflict or a
27
+ * partially restored backup can leave a cycle, a link to a row that is gone,
28
+ * or two disconnected runs. (Since 0.11.0 the server can append a playlist
29
+ * under --allow-writes — see src/store/write.ts — but only ever through
30
+ * Engine's own chain triggers, and never from here.) None of those may hang the walk, and none may come
29
31
  * back as a silently short list that reads like a complete one.
30
32
  */
31
33
  /**
package/dist/playlists.js CHANGED
@@ -22,10 +22,12 @@ import { err, isEngineError } from "./errors.js";
22
22
  * never read by this project.
23
23
  *
24
24
  * Everything here therefore walks the chain — defensively. These are linked
25
- * lists inside a file this server does not own and never writes: a
26
- * half-completed Engine write, a sync conflict or a partially restored
27
- * backup can leave a cycle, a link to a row that is gone, or two
28
- * disconnected runs. None of those may hang the walk, and none may come
25
+ * lists inside a file this server does not own, and which nothing in this
26
+ * module writes: a half-completed Engine write, a sync conflict or a
27
+ * partially restored backup can leave a cycle, a link to a row that is gone,
28
+ * or two disconnected runs. (Since 0.11.0 the server can append a playlist
29
+ * under --allow-writes — see src/store/write.ts — but only ever through
30
+ * Engine's own chain triggers, and never from here.) None of those may hang the walk, and none may come
29
31
  * back as a silently short list that reads like a complete one.
30
32
  */
31
33
  /**
package/dist/server.d.ts CHANGED
@@ -32,4 +32,14 @@ export declare function createServer(opts?: {
32
32
  roots?: string[];
33
33
  sidecarBaseDir?: string;
34
34
  allowWrites?: boolean;
35
+ /**
36
+ * Where pre-write snapshots go. Defaults to ~/.engine-dj-mcp/backups.
37
+ *
38
+ * An option rather than a constant because a test that writes through
39
+ * this server would otherwise deposit a full copy of its throwaway
40
+ * fixture in the real home directory -- and under a fresh tag each run,
41
+ * since every fixture gets a new temp path, so rotation could never
42
+ * reclaim them and they accumulated without bound.
43
+ */
44
+ backupBaseDir?: string;
35
45
  }): Promise<EngineDjMcpServer>;
package/dist/server.js CHANGED
@@ -444,7 +444,7 @@ export async function createServer(opts = {}) {
444
444
  const state = await acquire(args.library);
445
445
  if (isEngineError(state))
446
446
  return reply(state);
447
- return reply(await runCreatePlaylist(state.lib.path, state.lib.uuid, args, join(homedir(), ".engine-dj-mcp", "backups")));
447
+ return reply(await runCreatePlaylist(state.lib.path, state.lib.uuid, args, opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups")));
448
448
  });
449
449
  }
450
450
  /**
@@ -50,8 +50,18 @@ export async function snapshotLibrary(mdbPath, uuid, baseDir) {
50
50
  await backup(src, dest);
51
51
  src.close();
52
52
  src = undefined;
53
+ // Snapshots this library owns: the tagged shape above, plus the untagged
54
+ // `${uuid}-${stamp}.db` an earlier version wrote. Without the second,
55
+ // those sat outside every namespace and were never reclaimed -- up to
56
+ // KEEP full copies of a library, kept forever, on any upgrading user.
57
+ // They predate the tag and therefore predate everything written since,
58
+ // which is why folding them into one window evicts them first. Two
59
+ // libraries sharing a uuid is precisely why the tag exists, and an
60
+ // untagged file cannot say which of them it came from -- ageing them out
61
+ // under whichever library writes next is the only thing left to do.
62
+ const legacy = new RegExp(`^${uuid}-\\d{4}-`);
53
63
  const mine = readdirSync(baseDir)
54
- .filter((f) => f.startsWith(prefix) && f.endsWith(".db"))
64
+ .filter((f) => f.endsWith(".db") && (f.startsWith(prefix) || legacy.test(f)))
55
65
  .sort();
56
66
  for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) {
57
67
  rmSync(join(baseDir, old), { force: true });
@@ -131,6 +131,24 @@ export function walkFrom(db, listId, headId) {
131
131
  }
132
132
  return out;
133
133
  }
134
+ /**
135
+ * Roll back, swallowing a failure of the rollback itself.
136
+ *
137
+ * Every caller is already returning a specific error -- the chain did not read
138
+ * back, the library is busy -- and a ROLLBACK that throws on the way out would
139
+ * replace that reason with its own, telling the user about a failed rollback
140
+ * instead of what actually went wrong. Nothing is lost by ignoring it:
141
+ * db.close() in the finally block ends any transaction still open, and SQLite
142
+ * discards an uncommitted one on close.
143
+ */
144
+ function rollback(db) {
145
+ try {
146
+ db.exec("ROLLBACK");
147
+ }
148
+ catch {
149
+ /* no transaction in progress, or the connection is already gone */
150
+ }
151
+ }
134
152
  export function sameOrder(a, b) {
135
153
  return a.length === b.length && a.every((x, i) => x.uuid === b[i].uuid && x.trackId === b[i].trackId);
136
154
  }
@@ -303,32 +321,20 @@ export async function createPlaylist(mdbPath, uuid, input, opts) {
303
321
  }
304
322
  for (let i = 0; i + 1 < ids.length; i++)
305
323
  link.run(ids[i + 1], ids[i]);
306
- // Spec §6.3's foreign-key gate, asserted about the rows *this transaction
307
- // wrote* rather than about the table they went into.
308
- //
309
- // PlaylistEntity carries exactly one foreign key -- listId -> Playlist(id)
310
- // -- so this query is that key, checked on our own rows. `PRAGMA
311
- // foreign_key_check(PlaylistEntity)` looked equivalent and is not: it
312
- // reports every orphan in the table, and one PlaylistEntity row left
313
- // behind by a deleted playlist (measured: a pre-existing orphan comes back
314
- // from the scoped pragma inside an unrelated transaction) would fail every
315
- // create_playlist call on that library forever, accusing this write of
316
- // damage that predates it in words the user cannot tell apart from a real
317
- // violation. The cross-library debris these libraries do accumulate --
318
- // entries naming a third library's (databaseUuid, trackId), see
319
- // src/playlists.ts:52-58 -- is not a foreign key at all and no pragma
320
- // scoping ever protected against it.
321
- const orphan = db
322
- .prepare("SELECT 1 FROM PlaylistEntity WHERE listId = ? AND listId NOT IN (SELECT id FROM Playlist)")
323
- .get(listId);
324
- if (orphan) {
325
- db.exec("ROLLBACK");
326
- return err("library_unreadable", `Writing "${title}" would have broken a foreign key; nothing was changed.`, {
327
- detail: NOT_COMMITTED,
328
- });
329
- }
324
+ // No foreign-key gate here, though spec §6.3 asks for one. PlaylistEntity
325
+ // carries exactly one foreign key -- listId -> Playlist(id) -- it is not
326
+ // DEFERRABLE, and this connection sets PRAGMA foreign_keys = ON, so a bad
327
+ // listId is refused by SQLite at the INSERT above and never reaches a
328
+ // check. The listId asked about would in any case be the one this
329
+ // transaction just inserted, which exists by construction. A gate whose
330
+ // condition cannot become true is not a safety net; it reads as one,
331
+ // which is worse than its absence. (`PRAGMA foreign_key_check(...)` is
332
+ // not the alternative: it reports every orphan in the table, so a
333
+ // PlaylistEntity row left behind by some earlier deleted playlist would
334
+ // fail every create_playlist call on that library forever, blaming this
335
+ // write for damage that predates it.)
330
336
  if (ids.length > 0 && !sameOrder(walkFrom(db, listId, ids[0]), refs)) {
331
- db.exec("ROLLBACK");
337
+ rollback(db);
332
338
  return err("library_unreadable", `The entry chain for "${title}" did not read back as written; nothing was changed.`, { detail: NOT_COMMITTED });
333
339
  }
334
340
  // No check that exactly one Playlist row has nextListId = 0: the schema's
@@ -365,14 +371,8 @@ export async function createPlaylist(mdbPath, uuid, input, opts) {
365
371
  // so it is a plain retry, not an unverified write.
366
372
  const busyOnCommit = commit === "maybe" && /SQLITE_BUSY|database is locked/i.test(e?.message ?? "");
367
373
  if (commit === "not yet" || busyOnCommit) {
368
- if (open && db) {
369
- try {
370
- db.exec("ROLLBACK");
371
- }
372
- catch {
373
- /* no transaction in progress */
374
- }
375
- }
374
+ if (open && db)
375
+ rollback(db);
376
376
  return mapWriteError(e, title, mdbPath);
377
377
  }
378
378
  // Past the point of no return. No ROLLBACK: after a successful COMMIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "engine-dj-mcp",
3
- "version": "0.11.0",
4
- "description": "Read-only MCP server for searching and auditing an Engine DJ library. Not affiliated with inMusic or Denon DJ.",
3
+ "version": "0.11.2",
4
+ "description": "MCP server for an Engine DJ library: search and audit it, read cues and beatgrids, and build playlists when you ask. Not affiliated with inMusic or Denon DJ.",
5
5
  "keywords": [
6
6
  "mcp",
7
7
  "model-context-protocol",
@@ -11,7 +11,9 @@
11
11
  "sqlite",
12
12
  "library-audit",
13
13
  "claude",
14
- "ai"
14
+ "ai",
15
+ "playlist",
16
+ "harmonic-mixing"
15
17
  ],
16
18
  "license": "MIT",
17
19
  "author": "Mikhail Chereshnev <venuttv@gmail.com>",