engine-dj-mcp 0.10.0 → 0.11.1

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
@@ -12,7 +12,8 @@ libraries — the one on your computer and the ones on your USB drives.
12
12
  > inMusic or Denon DJ are used in this project.
13
13
 
14
14
  Your library is opened **read-only at the operating-system level**. It is
15
- never written to — see [Safety](#safety).
15
+ never written to unless you start the server with `--allow-writes` — see
16
+ [Safety](#safety).
16
17
 
17
18
  ## What you can ask
18
19
 
@@ -25,6 +26,7 @@ Once connected, these are ordinary questions in chat:
25
26
  - *"Anything around 128 in ACID Beach?"*
26
27
  - *"What's broken in my collection — missing files, duplicates, bad tempos?"*
27
28
  - *"Where are the cue points on this track, and what tempo did Engine analyse?"*
29
+ - *"Build me a playlist of everything in 5A from 140 BPM up."* (needs `--allow-writes`)
28
30
 
29
31
  ## Install
30
32
 
@@ -42,14 +44,28 @@ Claude Desktop — add to your configuration:
42
44
  }
43
45
  ```
44
46
 
47
+ To let the assistant create playlists as well, add `--allow-writes` — a flag
48
+ in `args` rather than an environment variable precisely so it is visible in
49
+ the configuration you are reading:
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp", "--allow-writes"] }
55
+ }
56
+ }
57
+ ```
58
+
45
59
  **Requirements:** Node.js 22.13 or newer (for the unflagged `node:sqlite`;
46
60
  there are no native dependencies), and an Engine DJ library at schema 3.0.0
47
61
  through 3.0.2 — Engine DJ 4.5 and 5.x.
48
62
 
49
63
  ## Tools
50
64
 
51
- Nine tools, all read-only. Every tool that reads library data also accepts
52
- an optional `library` argument see [Choosing a library](#choosing-a-library).
65
+ Nine read-only tools, and a tenth `create_playlist` that appears only
66
+ when you start the server with `--allow-writes`. Every tool that reads
67
+ library data also accepts an optional `library` argument — see
68
+ [Choosing a library](#choosing-a-library).
53
69
 
54
70
  ### `search_tracks`
55
71
 
@@ -181,6 +197,41 @@ No arguments.
181
197
  Rebuilds the search index if the library changed. Normally unnecessary; the
182
198
  server checks staleness itself before answering.
183
199
 
200
+ ### `create_playlist`
201
+
202
+ The only tool that writes, and the only one that is not registered at all
203
+ unless the server was started with `--allow-writes`.
204
+
205
+ Creates one new top-level playlist from track ids — `track_ids` sets both
206
+ what is in it and the order it is in, so a list built by `search_tracks`
207
+ arrives in Engine DJ in the order the assistant chose. Nothing else changes:
208
+ no playlist is renamed, reordered, emptied or deleted, and no track, cue or
209
+ beatgrid is touched. The one existing row that moves is the previous last
210
+ playlist's link, and Engine's own insert trigger is what moves it.
211
+
212
+ | Argument | What it does |
213
+ | --- | --- |
214
+ | `title` | Name of the new playlist. Must not already be taken at the top level — Engine allows one name per folder. |
215
+ | `track_ids` | Ids from `search_tracks` or `get_tracks`, in playlist order. May be empty, for an empty playlist. A track may appear at most once, which is Engine's own rule. |
216
+
217
+ Each entry stores the track's origin identity — `(originDatabaseUuid,
218
+ originTrackId)`, the pair Engine matches on — not the local row id, so a
219
+ playlist built here reads the same way Engine's own does.
220
+
221
+ The result carries `playlist_id`, `tracks_added` and `backup_path`. **To undo
222
+ it, delete the playlist in Engine DJ**; `backup_path` is a whole-library
223
+ snapshot for the case where something went wrong at a lower level, not an
224
+ undo — see [Restoring a snapshot](#restoring-a-snapshot).
225
+
226
+ Refusals name themselves: `playlist_exists` for a taken title,
227
+ `unknown_track` for an id this library does not have, `duplicate_track` for
228
+ the same id twice, `library_busy` if something else holds a conflicting lock
229
+ right then, `library_needs_recovery` if Engine DJ left an unrecovered
230
+ journal behind. Every error also carries `detail`: `not_committed` means the
231
+ library is exactly what it was, and `committed_unverified` — the rare one —
232
+ means the write may have landed but could not be verified afterwards, and is
233
+ the only case that hands back a `backup_path`.
234
+
184
235
  ## Resources
185
236
 
186
237
  - **`engine://schema`** — the field semantics an assistant needs before
@@ -213,16 +264,66 @@ does.
213
264
 
214
265
  Your library is opened **read-only at the operating-system level**, not by
215
266
  convention and not by a `PRAGMA` a query could turn back off. Writes are
216
- refused by SQLite itself, and no file is ever created inside your `Engine
217
- Library` folder. The search index lives in `~/.engine-dj-mcp/`.
267
+ refused by SQLite itself, and without `--allow-writes` no file is ever
268
+ created inside your `Engine Library` folder. The search index lives in
269
+ `~/.engine-dj-mcp/`.
270
+
271
+ ### Writing
272
+
273
+ Without `--allow-writes` the server has no tool that can write, and the
274
+ paragraph above holds exactly as written: SQLite itself refuses.
275
+
276
+ With the flag, one tool appears — `create_playlist`. It adds a new playlist
277
+ and nothing else: no existing playlist is renamed, reordered, emptied or
278
+ deleted, and no track, cue or beatgrid is touched. The single change to an
279
+ existing row is the previous last playlist's link, made by Engine's own
280
+ trigger.
281
+
282
+ Before the first write of a session the database is snapshotted to
283
+ `~/.engine-dj-mcp/backups/`, and every write of that session returns its
284
+ path. Ten snapshots are kept per library — per library *file*, so a library
285
+ and its clone on another drive do not share the ten.
286
+
287
+ One file *is* created inside your `Engine Library` folder while a write is in
288
+ progress: SQLite's rollback journal, `m.db-journal`, next to `m.db`. It is
289
+ removed when the transaction commits, and it is what makes the write
290
+ all-or-nothing. If the process is killed mid-transaction the journal is left
291
+ behind, and both this server and Engine DJ then treat the library as needing
292
+ recovery — this server reports `library_needs_recovery` and refuses to touch
293
+ the library, including for reads, until you have launched Engine DJ once so
294
+ it can roll the journal back. Nothing else is ever written in that folder,
295
+ and without `--allow-writes` not even this.
296
+
297
+ The write takes SQLite's own write lock for the length of one transaction and
298
+ does not wait for it: if something else — Engine DJ mid-save, a player — is
299
+ holding a conflicting lock at that moment, the write is refused with
300
+ `library_busy` and nothing is changed. Merely having Engine DJ *open* is not
301
+ usually a conflict, and the write normally succeeds with Engine running;
302
+ Engine will show the new playlist after it next re-reads the library.
303
+
304
+ ### Restoring a snapshot
305
+
306
+ `backup_path` is not an undo. It is a copy of the **whole** `m.db` from
307
+ before the session's first write, so putting it back reverts the entire
308
+ library to that moment: every play count, import, cue, beatgrid and rating
309
+ Engine DJ has written since is discarded along with the playlist you wanted
310
+ gone. Reach for it only if the library itself is damaged — the case where
311
+ `create_playlist` comes back with `detail: "committed_unverified"`.
312
+
313
+ **To undo a playlist, delete it in Engine DJ.** Engine's own delete trigger
314
+ repairs the playlist chain and cascades the entries away, which is exactly
315
+ what removing it should do and is not something restoring a snapshot does
316
+ better.
218
317
 
219
318
  `run_sql` accepts arbitrary SQL, but only the first statement is ever
220
319
  executed, and `VACUUM`, `ATTACH` and `DETACH` are rejected outright, so a
221
320
  chained or exfiltrating statement cannot slip past the read-only connection.
222
321
 
223
322
  If Engine DJ was closed uncleanly and left an unrecovered journal, this
224
- server will not open the library writably to "fix" it that would break the
225
- one guarantee this project makes. It reports `library_needs_recovery` and
323
+ server will not open the library to "fix" it, with or without
324
+ `--allow-writes` rolling a journal forward is a repair on someone else's
325
+ file, and `create_playlist` refuses such a library outright rather than
326
+ letting SQLite do it on the way in. It reports `library_needs_recovery` and
226
327
  asks you to launch Engine DJ once so it can recover its own library.
227
328
 
228
329
  ## Limitations
@@ -266,8 +367,11 @@ changes. The track's **main cue** does not count towards it — Engine sets
266
367
  that as a playback marker rather than the DJ placing it. `has_beatgrid` does
267
368
  still test for the blob: `beatData` has no "written but empty" state.
268
369
 
269
- **It never writes to your library.** Not to add a cue, not to fix a tag, not
270
- even to recover a journal Engine DJ left behind.
370
+ **It writes nothing but playlists, and only when you ask for it.** Without
371
+ `--allow-writes` the library is opened read-only at the OS level and there is
372
+ no tool that could write. With the flag, `create_playlist` adds playlists —
373
+ and that is the whole list. Not a cue, not a tag, not a rating, and not even
374
+ the recovery of a journal Engine DJ left behind.
271
375
 
272
376
  **It does not read play history.** `Track.timeLastPlayed` answers "what have I
273
377
  not played in six months?", but the separate Engine history database —
@@ -283,9 +387,11 @@ flag — a folder is simply a playlist that other playlists sit under — so
283
387
  `is_folder` means "has child lists". A folder you have emptied is
284
388
  indistinguishable from a playlist with no tracks.
285
389
 
286
- **It reads playlists; it does not write them.** No creating, reordering,
287
- renaming or adding to a playlist, and no set lists or suggested transitions.
288
- It answers questions about the collection; the mixing is yours.
390
+ **Playlists can be created, not edited.** With `--allow-writes` a new
391
+ playlist can be added; there is no reordering, renaming, deleting, or adding
392
+ a track to a playlist that already exists, and no set lists or suggested
393
+ transitions. It answers questions about the collection and writes down the
394
+ answer if you ask; the mixing is yours.
289
395
 
290
396
  **Schema 3.0.0 through 3.0.2 only.** Older and newer libraries are listed with
291
397
  their version and reported as unsupported rather than read on a guess.
package/dist/discovery.js CHANGED
@@ -12,8 +12,9 @@ export function readLibraryInfo(mdbPath) {
12
12
  }
13
13
  if (hasHotJournal(mdbPath)) {
14
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,
15
+ // recovering a hot journal needs a write, and discovery never opens a
16
+ // library writably -- not to probe one, and not to heal one. Caught here
17
+ // first so the specific,
17
18
  // actionable library_needs_recovery reaches the caller instead of the
18
19
  // SELECT below failing with the raw "attempt to write a readonly
19
20
  // database" and landing in the generic library_unreadable catch --
package/dist/errors.d.ts CHANGED
@@ -1,10 +1,29 @@
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"];
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", "playlist_exists", "unknown_track", "duplicate_track"];
2
2
  export type ErrorCode = (typeof ERROR_CODES)[number];
3
3
  export interface EngineError {
4
4
  error: ErrorCode;
5
5
  message: string;
6
+ /**
7
+ * Free text almost everywhere (store/index-manager.ts passes a raw
8
+ * `e.message` through it), with one exception that is part of the tool
9
+ * contract: on an error from the write path (store/write.ts) this is
10
+ * always exactly `"not_committed"` -- the library is byte-for-byte what it
11
+ * was -- or `"committed_unverified"` -- the write may have landed and could
12
+ * not be confirmed, and `backup_path` below is then set. Those two strings
13
+ * are reserved on that path and must stay stable, because a client reads
14
+ * them to decide whether their library changed.
15
+ */
6
16
  detail?: string;
7
17
  retry_after_ms?: number;
18
+ /**
19
+ * Path to a pre-write snapshot the caller can restore from. Only ever set
20
+ * by the write path, and only on the errors a client cannot safely ignore:
21
+ * `detail === "committed_unverified"`. It is a whole-database snapshot, so
22
+ * restoring it is a recovery route for a damaged library and not an undo
23
+ * of one playlist -- it reverts everything Engine DJ wrote since it was
24
+ * taken.
25
+ */
26
+ backup_path?: string;
8
27
  }
9
28
  export declare function err(error: ErrorCode, message: string, extra?: Omit<EngineError, "error" | "message">): EngineError;
10
29
  /**
package/dist/errors.js CHANGED
@@ -19,6 +19,14 @@ export const ERROR_CODES = [
19
19
  "decode_failed",
20
20
  "invalid_argument",
21
21
  "library_needs_recovery",
22
+ // Write-path codes. There is no writes_not_enabled code: the tool is not
23
+ // registered at all without --allow-writes, and the MCP SDK's own
24
+ // dispatcher rejects a call to an unregistered tool name before any
25
+ // handler in this project runs, so this project never gets the chance to
26
+ // report that condition itself.
27
+ "playlist_exists",
28
+ "unknown_track",
29
+ "duplicate_track",
22
30
  ];
23
31
  export function err(error, message, extra = {}) {
24
32
  return { error, message, ...extra };
package/dist/index.js CHANGED
@@ -3,11 +3,17 @@
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { createServer } from "./server.js";
5
5
  async function main() {
6
- const server = await createServer();
6
+ // MCP clients configure a server as a command plus an args array, so a flag
7
+ // is visible in that configuration and greppable; an environment variable
8
+ // would not be. Writes are off unless this is present.
9
+ const allowWrites = process.argv.includes("--allow-writes");
10
+ // stderr is not the protocol channel, so this is safe for stdio transport,
11
+ // and it puts the mode in the client's log where a user can check it.
12
+ console.error(`engine-dj-mcp: writes ${allowWrites ? "ENABLED (--allow-writes)" : "disabled"}`);
13
+ const server = await createServer({ allowWrites });
7
14
  await server.connect(new StdioServerTransport());
8
15
  }
9
16
  main().catch((e) => {
10
- // stderr is not the protocol channel, so this is safe for stdio transport.
11
17
  console.error("engine-dj-mcp failed to start:", e);
12
18
  process.exit(1);
13
19
  });
package/dist/paths.d.ts CHANGED
@@ -1,4 +1,15 @@
1
1
  export declare function sidecarDir(uuid: string): string;
2
+ /**
3
+ * A short, stable tag for one library *file*, for use wherever a uuid alone
4
+ * would collide. A library copied onto a second drive carries the original's
5
+ * uuid -- an ordinary thing for a DJ to do -- so uuid is not unique across
6
+ * mounted volumes while the path of `m.db` always is.
7
+ *
8
+ * Shared by the sidecar layout (server.ts's sidecarBaseFor) and the backup
9
+ * filenames (store/backup.ts) so the two cannot drift into different ideas
10
+ * of which library they are talking about.
11
+ */
12
+ export declare function libraryTag(mdbPath: string): string;
2
13
  /** Engine stores Track.path relative to the `Engine Library` folder, usually with `..`. */
3
14
  export declare function absTrackPath(mdbPath: string, relative: string): string;
4
15
  /** Candidate locations of `m.db` beneath a filesystem root. */
package/dist/paths.js CHANGED
@@ -1,8 +1,22 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { homedir } from "node:os";
2
3
  import { join, dirname, resolve } from "node:path";
3
4
  export function sidecarDir(uuid) {
4
5
  return join(homedir(), ".engine-dj-mcp", uuid);
5
6
  }
7
+ /**
8
+ * A short, stable tag for one library *file*, for use wherever a uuid alone
9
+ * would collide. A library copied onto a second drive carries the original's
10
+ * uuid -- an ordinary thing for a DJ to do -- so uuid is not unique across
11
+ * mounted volumes while the path of `m.db` always is.
12
+ *
13
+ * Shared by the sidecar layout (server.ts's sidecarBaseFor) and the backup
14
+ * filenames (store/backup.ts) so the two cannot drift into different ideas
15
+ * of which library they are talking about.
16
+ */
17
+ export function libraryTag(mdbPath) {
18
+ return createHash("sha256").update(mdbPath).digest("hex").slice(0, 12);
19
+ }
6
20
  /** Engine stores Track.path relative to the `Engine Library` folder, usually with `..`. */
7
21
  export function absTrackPath(mdbPath, relative) {
8
22
  const engineLibrary = dirname(dirname(mdbPath)); // .../Engine Library/Database2/m.db
@@ -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
@@ -12,8 +12,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
12
  * This walks the same candidate paths independently, purely to tell those
13
13
  * two cases apart, so `ready()` below can report library_needs_recovery
14
14
  * instead of the misleading library_not_found -- never to open the file:
15
- * recovering a hot journal requires a write, and this project never writes
16
- * to the user's library, even to heal it.
15
+ * recovering a hot journal requires a write, and nothing here opens a
16
+ * library writably to heal one. Not even create_playlist, which refuses a
17
+ * library in this state outright (store/write.ts) rather than letting
18
+ * SQLite roll the journal forward on its way in.
17
19
  */
18
20
  export declare function findHotJournalCandidate(roots: string[]): string | null;
19
21
  /**
@@ -29,4 +31,15 @@ export type EngineDjMcpServer = McpServer & {
29
31
  export declare function createServer(opts?: {
30
32
  roots?: string[];
31
33
  sidecarBaseDir?: string;
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;
32
45
  }): Promise<EngineDjMcpServer>;
package/dist/server.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // src/server.ts
2
2
  import { existsSync, readFileSync } from "node:fs";
3
- import { createHash } from "node:crypto";
3
+ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
6
  import { discoverLibraries, defaultRoots, probeLibraries } from "./discovery.js";
7
- import { libraryCandidates, sidecarDir } from "./paths.js";
7
+ import { libraryCandidates, libraryTag, sidecarDir } from "./paths.js";
8
8
  import { LibraryArg, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
9
9
  import { hasHotJournal } from "./store/connections.js";
10
10
  import { QueryProcess } from "./proc/query-client.js";
@@ -17,8 +17,10 @@ import { auditLibrary, AuditInput, AUDIT_CHECKS } from "./tools/audit.js";
17
17
  import { runSql, RunSqlInput } from "./tools/sql.js";
18
18
  import { listLibraries } from "./tools/libraries.js";
19
19
  import { refreshIndex } from "./tools/refresh.js";
20
+ import { CreatePlaylistInput, runCreatePlaylist } from "./tools/write-playlist.js";
20
21
  import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
21
22
  const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
23
+ const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false };
22
24
  /**
23
25
  * name/version reported to every client on initialize. Read from
24
26
  * package.json rather than typed here, so the two cannot re-diverge the way
@@ -63,8 +65,10 @@ function reply(value) {
63
65
  * This walks the same candidate paths independently, purely to tell those
64
66
  * two cases apart, so `ready()` below can report library_needs_recovery
65
67
  * instead of the misleading library_not_found -- never to open the file:
66
- * recovering a hot journal requires a write, and this project never writes
67
- * to the user's library, even to heal it.
68
+ * recovering a hot journal requires a write, and nothing here opens a
69
+ * library writably to heal one. Not even create_playlist, which refuses a
70
+ * library in this state outright (store/write.ts) rather than letting
71
+ * SQLite roll the journal forward on its way in.
68
72
  */
69
73
  export function findHotJournalCandidate(roots) {
70
74
  for (const root of roots) {
@@ -152,8 +156,7 @@ export async function createServer(opts = {}) {
152
156
  const first = knownList().find((l) => l.uuid === lib.uuid);
153
157
  if (!first || first.path === lib.path)
154
158
  return opts.sidecarBaseDir;
155
- const tag = createHash("sha256").update(lib.path).digest("hex").slice(0, 12);
156
- return join(opts.sidecarBaseDir ?? sidecarDir(""), "duplicate-uuid", tag);
159
+ return join(opts.sidecarBaseDir ?? sidecarDir(""), "duplicate-uuid", libraryTag(lib.path));
157
160
  };
158
161
  /** Lazily creates -- and thereafter reuses -- one query child per library. */
159
162
  const stateFor = (lib) => {
@@ -410,6 +413,40 @@ export async function createServer(opts = {}) {
410
413
  return reply(lib);
411
414
  return reply(await refreshIndex(stateFor(lib).mgr));
412
415
  });
416
+ // Registered only under --allow-writes. A client that never enables it sees
417
+ // exactly the read-only server it saw before this feature existed, which is
418
+ // what keeps the README's promise true by default.
419
+ if (opts.allowWrites) {
420
+ server.registerTool("create_playlist", {
421
+ title: "Create a playlist",
422
+ description: "Create a new playlist in this Engine DJ library from track ids returned by " +
423
+ "search_tracks or get_tracks -- track_ids sets both membership and order. " +
424
+ "Unlike every other tool here, this WRITES to the library, so do not call it " +
425
+ "speculatively: only call it once you actually intend to add the playlist. " +
426
+ "To undo it, delete the playlist in Engine DJ -- Engine's own delete trigger and " +
427
+ "cascade remove the playlist and its entries cleanly. " +
428
+ "backup_path in the result names a whole-database snapshot taken before the first " +
429
+ "write of this session; it is a recovery route for a damaged library, NOT an undo. " +
430
+ "Restoring it reverts the entire library to that moment, discarding everything " +
431
+ "Engine DJ has written since (play counts, imports, cue and beatgrid edits). " +
432
+ "No existing playlist or entry is ever modified; this only adds a new one. " +
433
+ "Fails with playlist_exists if a top-level playlist already has that title, and " +
434
+ "with library_busy if Engine DJ or a player is holding a conflicting lock on the " +
435
+ "library right then -- nothing is written in that case, so retry rather than " +
436
+ "treating it as permanent. On any error, `detail` is \"not_committed\" when the " +
437
+ "library is unchanged and \"committed_unverified\" when the write may have gone " +
438
+ "through but could not be verified. track_ids may be empty (an empty playlist); a " +
439
+ "track id may appear at most once. " +
440
+ LIBRARY_SELECTION_NOTE,
441
+ inputSchema: { ...CreatePlaylistInput.shape, library: LibraryArg },
442
+ annotations: RW,
443
+ }, async (args) => {
444
+ const state = await acquire(args.library);
445
+ if (isEngineError(state))
446
+ return reply(state);
447
+ return reply(await runCreatePlaylist(state.lib.path, state.lib.uuid, args, opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups")));
448
+ });
449
+ }
413
450
  /**
414
451
  * There was previously no way to shut this down at all: createServer
415
452
  * forked a query child and handed back an McpServer whose close() knows
@@ -451,7 +488,8 @@ More than one library can be connected at once — the local one under
451
488
  (\`search_tracks\`, \`get_tracks\`, \`get_playlists\`,
452
489
  \`get_playlist_tracks\`, \`get_track_performance\`, \`audit_library\`,
453
490
  \`run_sql\`, \`refresh_index\`) takes an optional
454
- \`library\` argument naming one of them: either the \`uuid\` or the
491
+ \`library\` argument naming one of them as does \`create_playlist\`, when
492
+ the server was started with \`--allow-writes\` — either the \`uuid\` or the
455
493
  \`path\`, in the \`~/...\` form \`list_libraries\` prints or the absolute
456
494
  one. A value matching neither comes back as \`library_not_found\` listing
457
495
  the libraries that are selectable.
@@ -0,0 +1,2 @@
1
+ import { type EngineError } from "../errors.js";
2
+ export declare function snapshotLibrary(mdbPath: string, uuid: string, baseDir: string): Promise<string | EngineError>;
@@ -0,0 +1,82 @@
1
+ // src/store/backup.ts
2
+ //
3
+ // A snapshot taken before the first write of a session, so a user who does
4
+ // not like the result has a known way back. It is taken through SQLite's own
5
+ // backup API rather than a file copy: that is correct even while Engine DJ
6
+ // holds the database open, which a cp is not.
7
+ //
8
+ // Snapshots live under ~/.engine-dj-mcp/backups/ and never inside the user's
9
+ // Engine Library folder -- the rule that no file is created there holds for
10
+ // writes exactly as it did for reads.
11
+ import { DatabaseSync, backup } from "node:sqlite";
12
+ import { mkdirSync, readdirSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { err } from "../errors.js";
15
+ import { libraryTag } from "../paths.js";
16
+ /** How many snapshots to keep per library before deleting the oldest. */
17
+ const KEEP = 10;
18
+ /**
19
+ * Monotonic counter to ensure unique, sortable filenames even in tight loops.
20
+ * The ISO string alone provides only millisecond precision, so rapid-fire calls
21
+ * in the same millisecond would collide. The counter serves as an infallible
22
+ * tiebreaker: calls within the same millisecond are ordered by counter value,
23
+ * and calls across milliseconds are already separated by the ISO string. This
24
+ * ordering is essential: rotation immediately deletes the text-sorted oldest,
25
+ * so an inverted sort would delete the snapshot we just handed back.
26
+ */
27
+ let counter = 0;
28
+ /**
29
+ * A filename-safe, sortable stamp. Sorting the directory listing as text
30
+ * therefore orders snapshots by age, which is what rotation relies on.
31
+ */
32
+ function stamp() {
33
+ const iso = new Date().toISOString().replace(/[:.]/g, "-");
34
+ return `${iso}-${String(++counter).padStart(10, "0")}`;
35
+ }
36
+ export async function snapshotLibrary(mdbPath, uuid, baseDir) {
37
+ let src;
38
+ try {
39
+ mkdirSync(baseDir, { recursive: true });
40
+ src = new DatabaseSync(mdbPath, { readOnly: true });
41
+ // uuid *and* a hash of the file's own path. Keyed on uuid alone, a
42
+ // library and its clone on a second drive -- same uuid, different drive,
43
+ // an ordinary thing for a DJ to have -- shared one namespace and one
44
+ // KEEP-slot window: writes to either evicted the other's snapshots, and
45
+ // a returned backup_path did not say which drive it came from. This is
46
+ // the same tag server.ts's sidecarBaseFor uses to keep two such
47
+ // libraries' indexes apart (see paths.ts).
48
+ const prefix = `${uuid}-${libraryTag(mdbPath)}-`;
49
+ const dest = join(baseDir, `${prefix}${stamp()}.db`);
50
+ await backup(src, dest);
51
+ src.close();
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}-`);
63
+ const mine = readdirSync(baseDir)
64
+ .filter((f) => f.endsWith(".db") && (f.startsWith(prefix) || legacy.test(f)))
65
+ .sort();
66
+ for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) {
67
+ rmSync(join(baseDir, old), { force: true });
68
+ }
69
+ return dest;
70
+ }
71
+ catch (e) {
72
+ return err("library_unreadable", `Could not snapshot ${mdbPath} before writing: ${String(e)}`);
73
+ }
74
+ finally {
75
+ try {
76
+ src?.close();
77
+ }
78
+ catch {
79
+ /* already closed */
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,37 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { type EngineError } from "../errors.js";
3
+ export interface CreatePlaylistResult {
4
+ playlist_id: number;
5
+ title: string;
6
+ tracks_added: number;
7
+ backup_path: string;
8
+ }
9
+ export interface OriginRef {
10
+ uuid: string;
11
+ trackId: number;
12
+ }
13
+ /** Test seam only: forget this process's snapshots so a test can start clean. */
14
+ export declare function resetSessionSnapshots(): void;
15
+ /**
16
+ * Read the chain back starting from a row we know is the head, because we
17
+ * inserted it first. Re-deriving the head as "the row nothing points at"
18
+ * would be the same assumption the write just made, so it could not catch a
19
+ * write that made it wrongly.
20
+ *
21
+ * This, together with `sameOrder`, confirms that the *links* survived the
22
+ * round trip in the order given -- it does not independently confirm the
23
+ * *values* are correct. The comparison target is `refs`, the same array
24
+ * `resolveOrigins` produced and the write consumed, so a `resolveOrigins`
25
+ * that resolved every id wrongly (e.g. to the local row id instead of the
26
+ * origin pair) would write wrong values, read the same wrong values back,
27
+ * and pass this check. Catching that class of bug is what the
28
+ * re-originated-track test is for, not this readback.
29
+ */
30
+ export declare function walkFrom(db: DatabaseSync, listId: number, headId: number): OriginRef[];
31
+ export declare function sameOrder(a: OriginRef[], b: OriginRef[]): boolean;
32
+ export declare function createPlaylist(mdbPath: string, uuid: string, input: {
33
+ title: string;
34
+ trackIds: number[];
35
+ }, opts: {
36
+ backupDir: string;
37
+ }): Promise<CreatePlaylistResult | EngineError>;
@@ -0,0 +1,397 @@
1
+ // src/store/write.ts
2
+ //
3
+ // The only code in this project that writes to a user's Engine library, and
4
+ // it runs only when the server was started with --allow-writes.
5
+ //
6
+ // The read path is deliberately not reused. Queries run in a forked child
7
+ // whose connection is opened readOnly: true, and that guarantee is the
8
+ // product's core promise -- teaching it to write would dissolve it for reads
9
+ // as well. Writes therefore get their own short-lived connection here:
10
+ // validate read-only, snapshot, open, take the write lock, one transaction,
11
+ // verify, commit, check, close.
12
+ import { existsSync } from "node:fs";
13
+ import { DatabaseSync } from "node:sqlite";
14
+ import { err, libraryNeedsRecovery } from "../errors.js";
15
+ import { snapshotLibrary } from "./backup.js";
16
+ import { hasHotJournal } from "./connections.js";
17
+ /**
18
+ * `detail` discriminator values for the EngineError this module returns.
19
+ * Stable across releases so a caller can decide "is the library still what
20
+ * it was" without parsing message prose. Everything before COMMIT --
21
+ * including validation that never reaches the database at all -- collapses
22
+ * to the same NOT_COMMITTED answer. COMMITTED_UNVERIFIED is produced by
23
+ * exactly two things, and both mean "the playlist may be on disk": the
24
+ * post-commit check reporting anything but "ok", and anything thrown from
25
+ * the COMMIT itself onwards. It is also the only case that carries
26
+ * backup_path, because it is the only case where restoring from a snapshot
27
+ * is ever the right next step.
28
+ *
29
+ * These two strings are part of the tool's contract; see src/errors.ts.
30
+ */
31
+ const NOT_COMMITTED = "not_committed";
32
+ const COMMITTED_UNVERIFIED = "committed_unverified";
33
+ /**
34
+ * One snapshot per library per process, which is what "before the first write
35
+ * of a session" means in the spec (§6.1) and in the README.
36
+ *
37
+ * Keyed by backup directory, library path *and* uuid so a test (or a second
38
+ * configured backup root) cannot silently reuse a snapshot that lives
39
+ * somewhere else -- and so a different library that lands at the same path
40
+ * (a second USB stick sharing a volume label, an m.db replaced in place)
41
+ * cannot hit another library's cached entry and hand back its snapshot as
42
+ * this session's way back. The value is only ever a snapshot that actually
43
+ * landed on disk; a failed snapshot is not cached, so the next write tries
44
+ * again.
45
+ */
46
+ const sessionSnapshots = new Map();
47
+ /** Test seam only: forget this process's snapshots so a test can start clean. */
48
+ export function resetSessionSnapshots() {
49
+ sessionSnapshots.clear();
50
+ }
51
+ /**
52
+ * The snapshot for this library, taken once per process.
53
+ *
54
+ * Called before the write connection is even opened (see createPlaylist),
55
+ * so it never holds SQLite's RESERVED lock and never blocks a write Engine
56
+ * DJ or a second concurrent call in this process is trying to make at the
57
+ * same moment. The hot-journal check and the read-only pre-check have
58
+ * already run by the time this is called, so a library needing recovery or
59
+ * failing the title/track validation never spends a snapshot slot; a
60
+ * library that turns out to be busy still does, once, and the memo above is
61
+ * what keeps a session that only ever hits library_busy at exactly one
62
+ * snapshot rather than one per retry.
63
+ */
64
+ async function sessionSnapshot(mdbPath, uuid, backupDir) {
65
+ const key = `${backupDir}\u0000${mdbPath}\u0000${uuid}`;
66
+ // existsSync, not a bare Map hit: a user who cleared ~/.engine-dj-mcp/backups
67
+ // mid-session must get a real snapshot back, not a path to a deleted file.
68
+ const cached = sessionSnapshots.get(key);
69
+ if (cached && existsSync(cached))
70
+ return cached;
71
+ const fresh = await snapshotLibrary(mdbPath, uuid, backupDir);
72
+ if (typeof fresh === "string")
73
+ sessionSnapshots.set(key, fresh);
74
+ return fresh;
75
+ }
76
+ /**
77
+ * Engine stores a playlist entry's track as the pair the track was *born*
78
+ * with, not as a local row id. On both libraries measured, originTrackId
79
+ * happens to equal id -- which is exactly why this translation has to be
80
+ * explicit and tested against re-originated rows: the naive version is
81
+ * invisible in normal use and wrong on any library that has travelled.
82
+ */
83
+ function resolveOrigins(db, trackIds) {
84
+ const seen = new Set();
85
+ for (const id of trackIds) {
86
+ if (seen.has(id)) {
87
+ return err("duplicate_track", `Track ${id} appears more than once; Engine allows a track in a playlist only once.`, { detail: NOT_COMMITTED });
88
+ }
89
+ seen.add(id);
90
+ }
91
+ const stmt = db.prepare("SELECT originDatabaseUuid AS uuid, originTrackId AS trackId FROM Track WHERE id = ?");
92
+ const refs = [];
93
+ for (const id of trackIds) {
94
+ const row = stmt.get(id);
95
+ // == null, not a falsy check: originTrackId = 0 or originDatabaseUuid =
96
+ // "" are real values a track can legitimately carry, not "not found".
97
+ if (!row || row.uuid == null || row.trackId == null) {
98
+ return err("unknown_track", `No track with id ${id} in this library.`, { detail: NOT_COMMITTED });
99
+ }
100
+ refs.push({ uuid: row.uuid, trackId: row.trackId });
101
+ }
102
+ return refs;
103
+ }
104
+ /**
105
+ * Read the chain back starting from a row we know is the head, because we
106
+ * inserted it first. Re-deriving the head as "the row nothing points at"
107
+ * would be the same assumption the write just made, so it could not catch a
108
+ * write that made it wrongly.
109
+ *
110
+ * This, together with `sameOrder`, confirms that the *links* survived the
111
+ * round trip in the order given -- it does not independently confirm the
112
+ * *values* are correct. The comparison target is `refs`, the same array
113
+ * `resolveOrigins` produced and the write consumed, so a `resolveOrigins`
114
+ * that resolved every id wrongly (e.g. to the local row id instead of the
115
+ * origin pair) would write wrong values, read the same wrong values back,
116
+ * and pass this check. Catching that class of bug is what the
117
+ * re-originated-track test is for, not this readback.
118
+ */
119
+ export function walkFrom(db, listId, headId) {
120
+ const rows = db
121
+ .prepare("SELECT id, trackId, databaseUuid, nextEntityId FROM PlaylistEntity WHERE listId = ?")
122
+ .all(listId);
123
+ const byId = new Map(rows.map((r) => [r.id, r]));
124
+ const out = [];
125
+ const seen = new Set();
126
+ let cur = byId.get(headId);
127
+ while (cur && !seen.has(cur.id)) {
128
+ seen.add(cur.id);
129
+ out.push({ uuid: cur.databaseUuid, trackId: cur.trackId });
130
+ cur = byId.get(cur.nextEntityId);
131
+ }
132
+ return out;
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
+ }
152
+ export function sameOrder(a, b) {
153
+ return a.length === b.length && a.every((x, i) => x.uuid === b[i].uuid && x.trackId === b[i].trackId);
154
+ }
155
+ /**
156
+ * Turns whatever node:sqlite throws into an EngineError. Shared between the
157
+ * read-only pre-check and the write transaction below it: both open a
158
+ * connection to the same file and can hit the same failure modes (the
159
+ * library gone missing mid-session, Engine holding the lock, a foreign or
160
+ * corrupt schema), and a caller whose promise is typed
161
+ * `Promise<CreatePlaylistResult | EngineError>` must never see one of them
162
+ * escape as a rejection instead.
163
+ *
164
+ * Every path that reaches this function is one where the library is
165
+ * unchanged: the transaction either never opened or is rolled back by the
166
+ * caller, and a failure at or after COMMIT is answered before this is ever
167
+ * called (see createPlaylist's catch). That is what lets the fallback below
168
+ * say "nothing was changed" without qualification -- it used to say
169
+ * `Writing "X" failed`, which reads as a half-write even when the failure was
170
+ * "file is not a database" and not one byte was attempted.
171
+ */
172
+ function mapWriteError(e, title, mdbPath) {
173
+ const msg = e.message ?? String(e);
174
+ const isUniqueViolation = /UNIQUE constraint failed/i.test(msg);
175
+ // The constraint's *name* never appears in the message SQLite raises --
176
+ // only the column list does, e.g. "Playlist.title, Playlist.parentListId"
177
+ // -- so the two conditions are checked independently rather than as one
178
+ // pattern that happens to work only because title leads that index today.
179
+ if (isUniqueViolation && /\bPlaylist\.title\b/.test(msg)) {
180
+ return err("playlist_exists", `A playlist called "${title}" already exists in this library.`, {
181
+ detail: NOT_COMMITTED,
182
+ });
183
+ }
184
+ if (isUniqueViolation && /\bPlaylistEntity\./.test(msg)) {
185
+ return err("duplicate_track", `A track in "${title}" collided with an existing playlist entry; Engine allows a track in a playlist only once.`, { detail: NOT_COMMITTED });
186
+ }
187
+ if (/SQLITE_BUSY|database is locked/i.test(msg)) {
188
+ return err("library_busy", "The library is locked by Engine DJ or a player. Close it and try again.", {
189
+ detail: NOT_COMMITTED,
190
+ });
191
+ }
192
+ if (/readonly|attempt to write a readonly database/i.test(msg)) {
193
+ return err("library_unreadable", `The library at ${mdbPath} cannot be written to.`, { detail: NOT_COMMITTED });
194
+ }
195
+ // The volume can go away between discovery and this call -- a USB drive
196
+ // pulled mid-set is the live-performance version of this. node:sqlite's
197
+ // message for that is generic ("unable to open database file"), so this
198
+ // is matched by wording rather than an errno, the same tradeoff every
199
+ // other branch here makes.
200
+ if (/unable to open database file/i.test(msg)) {
201
+ return err("library_not_found", `No Engine library database at ${mdbPath}.`, { detail: NOT_COMMITTED });
202
+ }
203
+ return err("library_unreadable", `Could not write "${title}": ${msg}. Nothing was changed.`, {
204
+ detail: NOT_COMMITTED,
205
+ });
206
+ }
207
+ export async function createPlaylist(mdbPath, uuid, input, opts) {
208
+ const title = input.title.trim();
209
+ if (!title)
210
+ return err("invalid_argument", "A playlist needs a non-empty title.", { detail: NOT_COMMITTED });
211
+ // A hot journal is a mandatory refusal reason (spec §6.2), and it has to be
212
+ // checked here rather than left to whatever opens the file first: SQLite
213
+ // refuses to open such a database *read-only* (rolling the journal forward
214
+ // is a write) with the raw "attempt to write a readonly database", which
215
+ // this module would otherwise map to library_unreadable -- the wrong code,
216
+ // and actively false, since the library can be written to perfectly well
217
+ // once Engine DJ has recovered it. Nor does the caller's acquire() cover
218
+ // it: IndexManager.ensureFresh reads the header change counter as raw
219
+ // bytes and returns "fresh" without opening the database at all, so a
220
+ // journal left behind after an earlier successful read reaches this
221
+ // function untouched.
222
+ if (hasHotJournal(mdbPath))
223
+ return { ...libraryNeedsRecovery(), detail: NOT_COMMITTED };
224
+ // Validate against a short-lived read-only connection before opening the
225
+ // library for writing at all. This pass only rules out the common case
226
+ // cheaply -- another writer can still create the same title (or, in
227
+ // principle, the same entry) between this check and the INSERT below, so
228
+ // the UNIQUE-constraint catch further down stays in place as the backstop
229
+ // for that race and must still report it correctly, not as a generic
230
+ // failure.
231
+ let refs;
232
+ {
233
+ // The constructor is inside the try, not just the statements after it:
234
+ // a missing file, an unmounted volume, or Engine holding the lock all
235
+ // fail right here, and this connection must report those exactly like
236
+ // the write connection below does rather than let them throw past
237
+ // createPlaylist's Promise<CreatePlaylistResult | EngineError> contract.
238
+ let precheck;
239
+ try {
240
+ precheck = new DatabaseSync(mdbPath, { readOnly: true });
241
+ const exists = precheck.prepare("SELECT 1 FROM Playlist WHERE title = ? AND parentListId = 0").get(title);
242
+ if (exists) {
243
+ return err("playlist_exists", `A playlist called "${title}" already exists in this library.`, {
244
+ detail: NOT_COMMITTED,
245
+ });
246
+ }
247
+ refs = resolveOrigins(precheck, input.trackIds);
248
+ }
249
+ catch (e) {
250
+ return mapWriteError(e, title, mdbPath);
251
+ }
252
+ finally {
253
+ try {
254
+ precheck?.close();
255
+ }
256
+ catch {
257
+ /* never opened, or already closed */
258
+ }
259
+ }
260
+ }
261
+ if (!Array.isArray(refs))
262
+ return refs;
263
+ // Snapshot here, before the write connection is even opened, not after
264
+ // BEGIN IMMEDIATE. Taking it with RESERVED held meant a full-database copy
265
+ // -- tens of seconds on a multi-gigabyte USB library -- ran while every
266
+ // write Engine DJ attempted failed with SQLITE_BUSY, and a second
267
+ // concurrent create_playlist call in this process (the MCP SDK dispatches
268
+ // concurrently) was told the library was locked by Engine DJ when it was
269
+ // this server holding the lock. Snapshotting before BEGIN IMMEDIATE used
270
+ // to mean a call that turned out to be busy spent a slot on every retry --
271
+ // ten busy retries, ten full copies, evicting every genuine pre-write
272
+ // snapshot from backup.ts's KEEP window. The per-session memo
273
+ // (sessionSnapshot, above) is what makes moving it here safe: a session
274
+ // that only ever gets library_busy now leaves exactly one snapshot, not
275
+ // one per retry, so nothing is evicted. The hot-journal check and the
276
+ // read-only pre-check above still run first, so a call doomed by either of
277
+ // those still never copies anything.
278
+ const snapshot = await sessionSnapshot(mdbPath, uuid, opts.backupDir);
279
+ // snapshotLibrary sets no detail of its own (src/store/backup.ts); this is
280
+ // still a pre-commit failure, so the discriminator applies here too.
281
+ if (typeof snapshot !== "string")
282
+ return { ...snapshot, detail: NOT_COMMITTED };
283
+ const backupPath = snapshot;
284
+ let db;
285
+ let open = false;
286
+ /**
287
+ * "not yet" until COMMIT is reached; "maybe" for the moment COMMIT is in
288
+ * flight; "yes" once it returned. Anything thrown while this is not "not
289
+ * yet" may have left the playlist on disk -- COMMIT can fail at fsync with
290
+ * SQLITE_IOERR or SQLITE_FULL after the pages are already there, and the
291
+ * post-commit check below runs against a database that has definitely
292
+ * changed. Reporting those as not_committed (which is what a single catch
293
+ * calling mapWriteError did) inverts the one discriminator a client uses to
294
+ * decide whether their library still is what it was, and drops the snapshot
295
+ * path in exactly the case where it is the only way back.
296
+ */
297
+ let commit = "not yet";
298
+ try {
299
+ db = new DatabaseSync(mdbPath);
300
+ open = true;
301
+ db.exec("PRAGMA foreign_keys = ON");
302
+ db.exec("BEGIN IMMEDIATE");
303
+ // nextListId = 0 appends: Engine's own insert triggers move the tail
304
+ // marker off the previous last row and point it at this one.
305
+ const ins = db
306
+ .prepare(`INSERT INTO Playlist (title, parentListId, isPersisted, nextListId, lastEditTime, isExplicitlyExported)
307
+ VALUES (?, 0, 1, 0, datetime('now'), 0)`)
308
+ .run(title);
309
+ const listId = Number(ins.lastInsertRowid);
310
+ // One row at a time, linked by the id the insert actually returned.
311
+ // A single INSERT ... SELECT ... ORDER BY would depend on SQLite
312
+ // assigning AUTOINCREMENT in sort order, which it does today and does not
313
+ // promise; the failure mode is a playlist with the right tracks in the
314
+ // wrong order, which looks like success.
315
+ const insEntity = db.prepare(`INSERT INTO PlaylistEntity (listId, trackId, databaseUuid, nextEntityId, membershipReference)
316
+ VALUES (?, ?, ?, 0, 0)`);
317
+ const link = db.prepare("UPDATE PlaylistEntity SET nextEntityId = ? WHERE id = ?");
318
+ const ids = [];
319
+ for (const ref of refs) {
320
+ ids.push(Number(insEntity.run(listId, ref.trackId, ref.uuid).lastInsertRowid));
321
+ }
322
+ for (let i = 0; i + 1 < ids.length; i++)
323
+ link.run(ids[i + 1], ids[i]);
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.)
336
+ if (ids.length > 0 && !sameOrder(walkFrom(db, listId, ids[0]), refs)) {
337
+ rollback(db);
338
+ return err("library_unreadable", `The entry chain for "${title}" did not read back as written; nothing was changed.`, { detail: NOT_COMMITTED });
339
+ }
340
+ // No check that exactly one Playlist row has nextListId = 0: the schema's
341
+ // own C_NEXT_LIST_ID_UNIQUE_FOR_PARENT constraint already permits at most
342
+ // one per parent, and this insert always uses nextListId = 0, so more
343
+ // than one tail is not a state this transaction can produce. Restating
344
+ // that as a runtime check would be a tautology, not a safety net.
345
+ commit = "maybe";
346
+ db.exec("COMMIT");
347
+ commit = "yes";
348
+ // quick_check, not integrity_check: both walk every page -- the
349
+ // difference is that integrity_check additionally cross-checks every
350
+ // index against its table's actual content, and that cross-check is
351
+ // what dominates the cost on a library with hundreds of thousands of
352
+ // tracks. quick_check skips only that verification and still catches
353
+ // the on-disk structural damage (a malformed b-tree page, say) that a
354
+ // check running right after a write exists to catch.
355
+ //
356
+ // check?.quick_check, not check.quick_check: the pragma is documented to
357
+ // return at least one row, but a `.get()` that came back undefined here
358
+ // would raise a TypeError *after* a successful commit, and that lands in
359
+ // the catch below as an error about a library that has in fact already
360
+ // changed. Reading it as "not ok" says the same true thing without
361
+ // depending on the throw being classified correctly.
362
+ const check = db.prepare("PRAGMA quick_check").get();
363
+ if (check?.quick_check !== "ok") {
364
+ return err("library_unreadable", `The database reports "${check?.quick_check ?? "no result"}" after writing "${title}". A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
365
+ }
366
+ return { playlist_id: listId, title, tracks_added: refs.length, backup_path: backupPath };
367
+ }
368
+ catch (e) {
369
+ // A COMMIT that returned SQLITE_BUSY is the one in-flight failure SQLite
370
+ // defines precisely: the transaction stays open and nothing was written,
371
+ // so it is a plain retry, not an unverified write.
372
+ const busyOnCommit = commit === "maybe" && /SQLITE_BUSY|database is locked/i.test(e?.message ?? "");
373
+ if (commit === "not yet" || busyOnCommit) {
374
+ if (open && db)
375
+ rollback(db);
376
+ return mapWriteError(e, title, mdbPath);
377
+ }
378
+ // Past the point of no return. No ROLLBACK: after a successful COMMIT
379
+ // there is no transaction to roll back, and after a COMMIT that failed
380
+ // mid-flight there is no state we can reason about well enough to undo
381
+ // by hand -- db.close() in the finally block ends anything still open.
382
+ // The honest answer is that the write may have gone through, plus the
383
+ // path of the snapshot from before this session's first write, which is
384
+ // the only case where restoring one is ever the right next step.
385
+ const msg = e?.message ?? String(e);
386
+ return err("library_unreadable", `Writing "${title}" may have gone through: the library could not be verified afterwards (${msg}). ` +
387
+ `Check the library in Engine DJ. A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
388
+ }
389
+ finally {
390
+ try {
391
+ db?.close();
392
+ }
393
+ catch {
394
+ /* already closed */
395
+ }
396
+ }
397
+ }
@@ -0,0 +1,11 @@
1
+ import { z } from "zod";
2
+ import { type CreatePlaylistResult } from "../store/write.js";
3
+ import { type EngineError } from "../errors.js";
4
+ export declare const CreatePlaylistInput: z.ZodObject<{
5
+ title: z.ZodString;
6
+ track_ids: z.ZodArray<z.ZodNumber>;
7
+ }, z.core.$strip>;
8
+ export declare function runCreatePlaylist(mdbPath: string, uuid: string, args: {
9
+ title: string;
10
+ track_ids: number[];
11
+ }, backupDir: string): Promise<CreatePlaylistResult | EngineError>;
@@ -0,0 +1,13 @@
1
+ // src/tools/write-playlist.ts
2
+ import { z } from "zod";
3
+ import { createPlaylist } from "../store/write.js";
4
+ export const CreatePlaylistInput = z.object({
5
+ title: z.string().min(1).describe("Name for the new playlist. Must not already exist in this library."),
6
+ track_ids: z
7
+ .array(z.number().int().positive())
8
+ .max(10_000)
9
+ .describe("Track ids from search_tracks, in the order they should appear in the playlist. May be empty."),
10
+ });
11
+ export async function runCreatePlaylist(mdbPath, uuid, args, backupDir) {
12
+ return createPlaylist(mdbPath, uuid, { title: args.title, trackIds: args.track_ids }, { backupDir });
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engine-dj-mcp",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
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",