engine-dj-mcp 0.12.0 → 0.15.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/README.md CHANGED
@@ -54,11 +54,15 @@ the configuration you are reading:
54
54
  ```json
55
55
  {
56
56
  "mcpServers": {
57
- "engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp", "--allow-writes"] }
57
+ "engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp@0.15.0", "--allow-writes"] }
58
58
  }
59
59
  }
60
60
  ```
61
61
 
62
+ Pin the version in this one. Unpinned, `npx` fetches whatever is newest at
63
+ every launch, and this configuration gives that code write access to your
64
+ library. Pinned, a new release reaches it only when you change the number.
65
+
62
66
  **Requirements:** Node.js 22.16 or newer (`node:sqlite` stopped needing a
63
67
  flag in 22.13, but the pre-write snapshot uses its `backup()`, added in
64
68
  22.16;
@@ -156,7 +160,7 @@ Positions are sample offsets; cue and loop items also carry seconds.
156
160
 
157
161
  ### `audit_library`
158
162
 
159
- Ten collection health checks. Returns a count and a small sample of ids per
163
+ Eleven collection health checks. Returns a count and a small sample of ids per
160
164
  check, never the full result set — a library with thousands of unanalysed
161
165
  tracks should not fill an assistant's context.
162
166
 
@@ -169,11 +173,12 @@ tracks should not fill an assistant's context.
169
173
  | `no_beatgrid` | Tracks with no beatgrid data |
170
174
  | `missing_key` | Tracks with no key detected |
171
175
  | `suspicious_bpm` | Analysed and tagged tempo disagree, or tempo is outside 60–200 |
172
- | `duplicates` | Same artist and title, or same size and length |
176
+ | `duplicates` | Same artist and title, compared regardless of case in any script |
173
177
  | `empty_metadata` | No artist or no title |
174
178
  | `orphan_entries` | Playlist entries pointing at tracks not in this library — `get_playlist_tracks` shows where each one sits |
179
+ | `path_form_mismatch` | The file is on disk, but its name there — or a folder's on the way — is in a different Unicode form from the path Engine stored. macOS finds it anyway; Linux does not (measured on the kernel's exFAT driver), and Engine OS on a player is Linux, so these may fail to load on hardware. Differences in case alone are not counted: exFAT and Windows ignore case |
175
180
 
176
- `checks` — omit it to run all ten.
181
+ `checks` — omit it to run all eleven.
177
182
 
178
183
  ### `run_sql`
179
184
 
@@ -224,19 +229,14 @@ Each entry stores the track's origin identity — `(originDatabaseUuid,
224
229
  originTrackId)`, the pair Engine matches on — not the local row id, so a
225
230
  playlist built here reads the same way Engine's own does.
226
231
 
227
- The result carries `playlist_id`, `tracks_added` and `backup_path`. **To undo
232
+ The result carries `playlist_id`, `tracks_added`, `library` and `backup_path`. **To undo
228
233
  it, delete the playlist in Engine DJ**; `backup_path` is a whole-library
229
234
  snapshot for the case where something went wrong at a lower level, not an
230
235
  undo — see [Restoring a snapshot](#restoring-a-snapshot).
231
236
 
232
- Refusals name themselves: `playlist_exists` for a taken title,
233
- `unknown_track` for an id this library does not have, `duplicate_track` for
234
- the same id twice, `library_busy` if something else holds a conflicting lock
235
- right then, `library_needs_recovery` if Engine DJ left an unrecovered
236
- journal behind. Every error also carries `detail`: `not_committed` means the
237
- library is exactly what it was, and `committed_unverified` — the rare one —
238
- means the write may have landed but could not be verified afterwards, and is
239
- the only case that hands back a `backup_path`.
237
+ Its own refusals: `playlist_exists` for a taken title, `unknown_track` for an
238
+ id this library does not have, `duplicate_track` for the same id twice — plus
239
+ the ones [every write tool shares](#refusals-every-write-tool-shares).
240
240
 
241
241
  ### `add_tracks_to_playlist`
242
242
 
@@ -258,15 +258,18 @@ way.
258
258
  | `at` | Where the new tracks land, against the playlist's current 1-based positions (the same numbering `get_playlist_tracks` reports): `"start"`, `"end"` (the default), or `{ after_position: n }`. |
259
259
 
260
260
  The result carries `playlist_id`, `tracks_added`, `positions` — where the
261
- new tracks landed — `undo`, `undo_complete` (always `true` here) and
262
- `backup_path`. `undo` is the exact `remove_tracks_from_playlist` call that
261
+ new tracks landed — `undo`, `undo_complete` (always `true` here), `library`
262
+ and `backup_path`. `undo` is the exact `remove_tracks_from_playlist` call that
263
263
  reverses this edit: the positions the tracks landed at, plus
264
264
  `expect_track_ids` naming the tracks that landed there, so a playlist
265
265
  something else changed in the meantime is refused rather than having the
266
266
  wrong rows removed. Call it to undo rather than restoring `backup_path` —
267
- see [Restoring a snapshot](#restoring-a-snapshot). Refusals add
268
- `playlist_not_found`, `playlist_chain_damaged` and `invalid_position` to
269
- `create_playlist`'s own list; `detail` works the same way.
267
+ see [Restoring a snapshot](#restoring-a-snapshot), and
268
+ [An undo covers one library](#an-undo-covers-one-library) for what it does
269
+ not reach. Its own refusals: `playlist_not_found`, `playlist_chain_damaged`,
270
+ `invalid_position`, and `unknown_track` / `duplicate_track` as for
271
+ `create_playlist` — plus the ones
272
+ [every write tool shares](#refusals-every-write-tool-shares).
270
273
 
271
274
  ### `remove_tracks_from_playlist`
272
275
 
@@ -282,8 +285,8 @@ repaired.
282
285
  | `expect_track_ids` | Optional, one entry per position: verifies each named position still holds the track expected before anything is removed, refusing the whole call otherwise. `null` means "this position should hold an entry whose track is missing", not "no expectation". |
283
286
 
284
287
  The result carries `playlist_id`, `tracks_removed`, `removed` — each
285
- position's `track_id`, `null` for a missing one — `undo`, `undo_complete` and
286
- `backup_path`. `undo` is a **sequence** of `add_tracks_to_playlist` calls,
288
+ position's `track_id`, `null` for a missing one — `undo`, `undo_complete`,
289
+ `library` and `backup_path`. `undo` is a **sequence** of `add_tracks_to_playlist` calls,
287
290
  one per removed track that can be restored. Run them in the order given,
288
291
  never in parallel and never reversed — each step's target position is
289
292
  computed against the list as it stands after the previous step has already
@@ -297,9 +300,10 @@ have, so no `add_tracks_to_playlist` call can put it back, and an
297
300
  still restore everything else; the missing entries are recoverable only from
298
301
  `backup_path`, which reverts the whole library.
299
302
 
300
- Refusals: `playlist_not_found`, `playlist_chain_damaged`, and
303
+ Its own refusals: `playlist_not_found`, `playlist_chain_damaged`, and
301
304
  `invalid_position` — for a repeated or out-of-range position, or one that
302
- does not hold what `expect_track_ids` expected.
305
+ does not hold what `expect_track_ids` expected — plus the ones
306
+ [every write tool shares](#refusals-every-write-tool-shares).
303
307
 
304
308
  `playlist_chain_damaged` always means the same thing for all three edit
305
309
  tools: the playlist's entry chain was already broken **before** the edit,
@@ -323,15 +327,43 @@ repaired.
323
327
  | `order` | A full permutation of `1..n`, `n` being the playlist's current entry count. `order[i]` names the *current* 1-based position (from `get_playlist_tracks`) of the track that should end up at position `i + 1`. A partial "move x to y" instruction is not accepted — name every position, including ones that do not move. |
324
328
 
325
329
  The result carries `playlist_id`, `undo`, `undo_complete` (always `true`
326
- here) and `backup_path`. `undo` is the exact inverse permutation, as a single
327
- `reorder_playlist` call. Refusals: `playlist_not_found`,
330
+ here), `library` and `backup_path`. `undo` is the exact inverse permutation, as a single
331
+ `reorder_playlist` call. Its own refusals: `playlist_not_found`,
328
332
  `playlist_chain_damaged`, and `invalid_position` if `order` is not a full
329
- permutation of the playlist's current positions.
333
+ permutation of the playlist's current positions — plus the ones
334
+ [every write tool shares](#refusals-every-write-tool-shares).
330
335
 
331
336
  Reordering to the order a playlist is already in is accepted and rewrites no
332
337
  entry: it still stamps the playlist's `lastEditTime`, and still costs this
333
338
  session's snapshot if nothing had been written yet.
334
339
 
340
+ ### Refusals every write tool shares
341
+
342
+ These come from what happens before the write itself — choosing the library,
343
+ bringing its index up to date, resolving the playlist — and from the write's
344
+ own checks.
345
+
346
+ | Code | Means | Nothing written? |
347
+ | --- | --- | --- |
348
+ | `invalid_argument` | The arguments do not make sense — both `playlist_id` and `playlist_name`, an empty list where one is required, or a `playlist_name` that matches several playlists (every candidate is listed). | yes |
349
+ | `library_not_found` | `library` names nothing connected — the refusal lists what is — or the library's header could not be read. | yes |
350
+ | `ambiguous_library` | No `library` given, and two libraries tie for the default. Lists both — see [Choosing a library](#choosing-a-library). | yes |
351
+ | `unsupported_schema` | The library's version is outside what this server supports. | yes |
352
+ | `library_needs_recovery` | Engine DJ left an unrecovered journal. Launch Engine once. | yes |
353
+ | `library_busy` | Something holds a conflicting lock right now. Retry. | yes |
354
+ | `index_stale` | The index could not be built yet, typically because Engine holds a lock on a first run. Carries `retry_after_ms`. | yes |
355
+ | `query_timeout`, `query_process_crashed` | The lookup that resolves a playlist failed. Edit tools only. | yes |
356
+ | `library_unreadable` | The library could not be read; the snapshot taken before the first write could not be made (a full disk, or a Node older than 22.16); or a write's own read-back disagreed with what it wrote, and it was rolled back. | see `detail` |
357
+
358
+ **`detail` on these errors.** Once the write itself has started, `detail` is
359
+ exactly one of two strings, and a client can read it to decide whether the
360
+ library changed: `not_committed` — the library is what it was — or
361
+ `committed_unverified` — the rare one: the write may have landed but could not
362
+ be confirmed, and only this case hands back a `backup_path`. Refusals raised
363
+ *before* that point — every row above marked "yes" — never opened the library
364
+ for writing, whatever their `detail` says: it may be absent, `not_committed`,
365
+ or explanatory text such as the candidates an ambiguous `playlist_name` lists.
366
+
335
367
  ## Resources
336
368
 
337
369
  - **`engine://schema`** — the field semantics an assistant needs before
@@ -355,6 +387,26 @@ tracks**. That matters: the local library Engine DJ creates on install is
355
387
  scanned first and is often empty, so "the first one found" would hide the
356
388
  drive you actually work from.
357
389
 
390
+ When two supported libraries hold the *same* highest number of tracks, that
391
+ rule names no winner — and a tie is the ordinary case, not an exotic one: a
392
+ USB drive and its copy on the computer tie precisely because one is a copy of
393
+ the other. Measured 2026-09-01, both real libraries reported 257 tracks.
394
+
395
+ **A read still chooses for itself.** Tied libraries hold the same tracks, so
396
+ either answer is very nearly the same answer, and making you name a library
397
+ you have no reason to care about would be noise.
398
+
399
+ **A write refuses**, with `ambiguous_library` listing every candidate and its
400
+ track count, and `detail: "not_committed"`. The choice decides which physical
401
+ disk changes, and one of the two may be the drive you perform from; scan
402
+ order is not a reason to pick it. Name a library and the write goes through,
403
+ tie or not — the ambiguity being refused is the server's, not yours.
404
+
405
+ The refusal tells the assistant to **ask you** rather than choose. Otherwise
406
+ "pass `library`, here are the two" is an invitation to take the first one,
407
+ which puts the write back on an arbitrary disk and makes the refusal
408
+ pointless.
409
+
358
410
  Each library gets its own index and its own connection, opened the first time
359
411
  you ask that library something. Comparing two libraries against each other —
360
412
  *"what is on this drive but not that one?"* — is **not** something this server
@@ -418,9 +470,49 @@ and without `--allow-writes` not even this.
418
470
  The write takes SQLite's own write lock for the length of one transaction and
419
471
  does not wait for it: if something else — Engine DJ mid-save, a player — is
420
472
  holding a conflicting lock at that moment, the write is refused with
421
- `library_busy` and nothing is changed. Merely having Engine DJ *open* is not
422
- usually a conflict, and the write normally succeeds with Engine running;
423
- Engine will show the new playlist after it next re-reads the library.
473
+ `library_busy` and nothing is changed.
474
+
475
+ **Quit Engine DJ before writing.** Having Engine open is not usually a lock
476
+ conflict, so the write itself will normally go through — but what Engine then
477
+ does with a change made underneath it has never been measured here. Every
478
+ acceptance check of a write was run with Engine closed. What *has* been
479
+ measured is that Engine does its own work on the library as it loads: it
480
+ renumbers playlist entries, and it copies playlist changes to another
481
+ connected library (see below). Quit, write, relaunch — Engine reads the
482
+ library on startup and shows the change.
483
+
484
+ Quit, not close. On macOS, closing Engine's window leaves the application
485
+ running: observed 2026-09-01 with the main process and seven
486
+ `OfflineAnalyzer` workers — which write to the database — still alive
487
+ afterwards. Use ⌘Q.
488
+
489
+ ### An undo covers one library
490
+
491
+ Every write result carries a `library` field — the `uuid` and `path` of the
492
+ library the write actually landed in. Two libraries connected at once is the
493
+ ordinary setup: a USB drive and its copy on the computer. This is where you
494
+ check which of them a write went to.
495
+
496
+ **`undo` reverses the edit in that one library, and only there.** Engine DJ
497
+ moves playlist changes between connected libraries by itself, so a copy of
498
+ your edit can end up somewhere `undo` cannot reach.
499
+
500
+ Measured 2026-09-01. A track was added to a playlist in the library on the
501
+ computer. Engine DJ was then launched with the USB drive attached, and the
502
+ same playlist on the USB came back with the same track added — the copy
503
+ carrying the very `lastEditTime` this server's `INSERT` had written. The
504
+ `undo` was then run and reversed the edit on the computer. The USB kept it.
505
+
506
+ Nothing was damaged: both libraries stayed sound. But the two had diverged,
507
+ and the `undo` reported success, correctly, because within its own library it
508
+ did exactly what it promised.
509
+
510
+ So: if a second library is connected, look at the `library` field, and undo
511
+ against each library separately. Undoing before Engine DJ next runs avoids
512
+ the problem entirely.
513
+
514
+ Which library a change propagates to, and in which direction, is Engine's own
515
+ business — this project does not model it and will not guess at it.
424
516
 
425
517
  ### Restoring a snapshot
426
518
 
@@ -431,6 +523,13 @@ Engine DJ has written since is discarded along with the one edit you wanted
431
523
  gone. Reach for it only if the library itself is damaged — the case where a
432
524
  write comes back with `detail: "committed_unverified"`.
433
525
 
526
+ Snapshots live in `~/.engine-dj-mcp/backups/`, ten per library. Only a name
527
+ ending in `.db` is a snapshot. A file ending in `.partial-<number>` — with or
528
+ without `-journal` after it — is a copy still being written, or one whose
529
+ process died before it finished: **never restore one of those**. A copy is
530
+ renamed to its `.db` name only once it is complete, and an abandoned one is
531
+ cleared the next time that library is snapshotted.
532
+
434
533
  **To undo a playlist you created, delete it in Engine DJ.** Engine's own
435
534
  delete trigger repairs the playlist chain and cascades the entries away,
436
535
  which is exactly what removing it should do and is not something restoring
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
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", "playlist_chain_damaged", "playlist_not_found", "invalid_position"];
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", "playlist_chain_damaged", "playlist_not_found", "invalid_position", "ambiguous_library"];
2
2
  export type ErrorCode = (typeof ERROR_CODES)[number];
3
3
  export interface EngineError {
4
4
  error: ErrorCode;
package/dist/errors.js CHANGED
@@ -33,6 +33,12 @@ export const ERROR_CODES = [
33
33
  "playlist_chain_damaged",
34
34
  "playlist_not_found",
35
35
  "invalid_position",
36
+ // No `library` was passed and the default rule names no single winner --
37
+ // two supported libraries hold the same, highest track count. Its own code
38
+ // rather than invalid_argument because the useful client response is
39
+ // specific: ask which drive, then retry with `library` set. Only writes
40
+ // raise it; see library-select.ts for why reads still choose.
41
+ "ambiguous_library",
36
42
  ];
37
43
  export function err(error, message, extra = {}) {
38
44
  return { error, message, ...extra };
@@ -34,6 +34,42 @@ export declare const LibraryArg: z.ZodOptional<z.ZodString>;
34
34
  * supported library" would otherwise collapse into.
35
35
  */
36
36
  export declare function pickDefaultLibrary(libs: readonly LibraryInfo[]): LibraryInfo | null;
37
+ /**
38
+ * The supported libraries tied for the default pick -- more than one holding
39
+ * the same, highest track count. Empty when the default is unambiguous, which
40
+ * includes the single-library case every DJ starts from.
41
+ *
42
+ * A tie is not an exotic shape: it is what a USB drive and its copy on the
43
+ * computer produce, and they tie *because* one is a copy of the other.
44
+ * Measured 2026-09-01, both real libraries reported 257 tracks.
45
+ *
46
+ * For a read, either answer is very nearly the same answer -- the two are
47
+ * copies -- so `pickDefaultLibrary` keeps choosing, and a read never has to
48
+ * name a library it does not care about. For a write the choice decides which
49
+ * physical disk changes, and one of them is the drive the DJ performs from.
50
+ * Hence: reads choose, writes refuse. Callers wanting the strict behaviour
51
+ * check this first.
52
+ *
53
+ * Skips unsupported libraries for the same reason `pickDefaultLibrary` does:
54
+ * one can never be chosen while a supported library exists, so it is not a
55
+ * competing candidate and must not make a write refuse.
56
+ */
57
+ export declare function defaultLibraryTies(libs: readonly LibraryInfo[]): LibraryInfo[];
58
+ /**
59
+ * The refusal for an ambiguous default on a write.
60
+ *
61
+ * Every candidate is named, with its track count, because the caller has to
62
+ * pick one and cannot do that from "it was ambiguous" -- the same reason
63
+ * get_playlist_tracks lists candidates for an ambiguous playlist name.
64
+ *
65
+ * The list goes in `message`, never in `detail`. This is only ever returned
66
+ * from a write tool, and on that path `detail` carries exactly
67
+ * `"not_committed"` or `"committed_unverified"` -- a client reads it to
68
+ * decide whether its library changed. Prose there would break that read for
69
+ * the one error whose answer is least in doubt: nothing was opened, let alone
70
+ * written.
71
+ */
72
+ export declare function ambiguousLibrary(tied: readonly LibraryInfo[]): EngineError;
37
73
  /**
38
74
  * Resolves a caller-supplied `library` value: uuid first, then filesystem
39
75
  * path. Returns null when it matches neither -- the caller decides what
@@ -14,7 +14,10 @@ import { expandHome, redactPath } from "./paths.js";
14
14
  */
15
15
  export const LIBRARY_ARG_DESCRIPTION = "Which library to use: either the uuid or the path reported by list_libraries " +
16
16
  "(the reported ~/... form is accepted, as is the absolute path). Omit it to use " +
17
- "the supported library holding the most tracks.";
17
+ "the supported library holding the most tracks. If two supported libraries hold " +
18
+ "the same most tracks -- what a USB drive and its copy on the computer produce -- " +
19
+ "a read still picks one, but a WRITE refuses with ambiguous_library listing both, " +
20
+ "since the choice decides which disk changes; ask the user which, then pass it here.";
18
21
  export const LibraryArg = z.string().min(1).optional().describe(LIBRARY_ARG_DESCRIPTION);
19
22
  /**
20
23
  * The default when no `library` was given: the supported library with the
@@ -47,6 +50,55 @@ export function pickDefaultLibrary(libs) {
47
50
  }
48
51
  return best ?? libs[0] ?? null;
49
52
  }
53
+ /**
54
+ * The supported libraries tied for the default pick -- more than one holding
55
+ * the same, highest track count. Empty when the default is unambiguous, which
56
+ * includes the single-library case every DJ starts from.
57
+ *
58
+ * A tie is not an exotic shape: it is what a USB drive and its copy on the
59
+ * computer produce, and they tie *because* one is a copy of the other.
60
+ * Measured 2026-09-01, both real libraries reported 257 tracks.
61
+ *
62
+ * For a read, either answer is very nearly the same answer -- the two are
63
+ * copies -- so `pickDefaultLibrary` keeps choosing, and a read never has to
64
+ * name a library it does not care about. For a write the choice decides which
65
+ * physical disk changes, and one of them is the drive the DJ performs from.
66
+ * Hence: reads choose, writes refuse. Callers wanting the strict behaviour
67
+ * check this first.
68
+ *
69
+ * Skips unsupported libraries for the same reason `pickDefaultLibrary` does:
70
+ * one can never be chosen while a supported library exists, so it is not a
71
+ * competing candidate and must not make a write refuse.
72
+ */
73
+ export function defaultLibraryTies(libs) {
74
+ const supported = libs.filter((l) => l.supported);
75
+ if (supported.length < 2)
76
+ return [];
77
+ const best = Math.max(...supported.map((l) => l.trackCount ?? -1));
78
+ const tied = supported.filter((l) => (l.trackCount ?? -1) === best);
79
+ return tied.length > 1 ? tied : [];
80
+ }
81
+ /**
82
+ * The refusal for an ambiguous default on a write.
83
+ *
84
+ * Every candidate is named, with its track count, because the caller has to
85
+ * pick one and cannot do that from "it was ambiguous" -- the same reason
86
+ * get_playlist_tracks lists candidates for an ambiguous playlist name.
87
+ *
88
+ * The list goes in `message`, never in `detail`. This is only ever returned
89
+ * from a write tool, and on that path `detail` carries exactly
90
+ * `"not_committed"` or `"committed_unverified"` -- a client reads it to
91
+ * decide whether its library changed. Prose there would break that read for
92
+ * the one error whose answer is least in doubt: nothing was opened, let alone
93
+ * written.
94
+ */
95
+ export function ambiguousLibrary(tied) {
96
+ const list = tied.map((l) => `${l.uuid} -- ${redactPath(l.path)} (${l.trackCount} tracks)`).join("; ");
97
+ return err("ambiguous_library", `More than one library holds the most tracks, so there is no default to write to: ${list}. ` +
98
+ `Nothing was written. ASK which one to write to, then retry with \`library\` set -- ` +
99
+ `do not choose for them. These are usually a USB drive and its copy on the computer, ` +
100
+ `and one of them may be the drive they perform from.`, { detail: "not_committed" });
101
+ }
50
102
  /**
51
103
  * Resolves a caller-supplied `library` value: uuid first, then filesystem
52
104
  * path. Returns null when it matches neither -- the caller decides what
package/dist/paths.d.ts CHANGED
@@ -19,6 +19,17 @@ export declare function libraryCandidates(root: string): string[];
19
19
  * shipped to a model provider, so the home prefix is folded to `~` by default.
20
20
  */
21
21
  export declare function redactPath(p: string): string;
22
+ /**
23
+ * redactPath for a URI. A uri carries its path after a scheme and usually
24
+ * percent-encoded -- the home directory shows up as `%2FUsers%2F<name>`, not
25
+ * as a leading `/Users/<name>` -- so redactPath's prefix check would never fire
26
+ * on one, and a "redacted" uri would leak exactly what redaction is there to
27
+ * hide. Folds every occurrence, raw or encoded, either case of hex digit since
28
+ * encoders differ. Only a whole path component: the home directory must be
29
+ * followed by a separator or the end, so a sibling account whose name merely
30
+ * starts the same is left alone.
31
+ */
32
+ export declare function redactUri(u: string): string;
22
33
  /**
23
34
  * The inverse of redactPath, for values coming back *in*. Every library path
24
35
  * this server reports has been through redactPath, so the most obvious way
package/dist/paths.js CHANGED
@@ -34,6 +34,26 @@ export function redactPath(p) {
34
34
  const home = homedir();
35
35
  return p === home || p.startsWith(home + "/") ? "~" + p.slice(home.length) : p;
36
36
  }
37
+ /**
38
+ * redactPath for a URI. A uri carries its path after a scheme and usually
39
+ * percent-encoded -- the home directory shows up as `%2FUsers%2F<name>`, not
40
+ * as a leading `/Users/<name>` -- so redactPath's prefix check would never fire
41
+ * on one, and a "redacted" uri would leak exactly what redaction is there to
42
+ * hide. Folds every occurrence, raw or encoded, either case of hex digit since
43
+ * encoders differ. Only a whole path component: the home directory must be
44
+ * followed by a separator or the end, so a sibling account whose name merely
45
+ * starts the same is left alone.
46
+ */
47
+ export function redactUri(u) {
48
+ const home = homedir();
49
+ if (!home || home === "/")
50
+ return u;
51
+ const esc = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
+ const raw = new RegExp(`${esc(home)}(?=/|$)`, "g");
53
+ const encoded = esc(encodeURIComponent(home)).replace(/%([0-9A-F])([0-9A-F])/g, (_, a, b) => `%[${a}${a.toLowerCase()}][${b}${b.toLowerCase()}]`);
54
+ const enc = new RegExp(`${encoded}(?=%2[Ff]|$)`, "g");
55
+ return u.replace(raw, "~").replace(enc, "~");
56
+ }
37
57
  /**
38
58
  * The inverse of redactPath, for values coming back *in*. Every library path
39
59
  * this server reports has been through redactPath, so the most obvious way
package/dist/semantics.js CHANGED
@@ -85,6 +85,11 @@ export function keyDistance(a, b) {
85
85
  export function registerFunctions(db, mdbPath) {
86
86
  const opts = { deterministic: true };
87
87
  db.function("camelot", opts, (key) => camelot(key === null ? null : Number(key)));
88
+ // A comparison key for text: one Unicode normalization form, lower-cased by
89
+ // Unicode rules. SQLite's own LOWER folds ASCII only -- LOWER('ЭЙФОРИЯ')
90
+ // comes back unchanged -- which made audit_library's duplicates find a
91
+ // capitalised Latin title and miss a capitalised Cyrillic one (#9).
92
+ db.function("fold", opts, (text) => text === null || text === undefined ? null : String(text).normalize("NFC").toLowerCase());
88
93
  db.function("key_name", opts, (key) => keyName(key === null ? null : Number(key)));
89
94
  db.function("tempo", opts, (a, b) => tempo(a === null ? null : Number(a), b === null ? null : Number(b)));
90
95
  db.function("key_distance", opts, (a, b) => a === null || b === null ? null : keyDistance(String(a), String(b)));
package/dist/server.js CHANGED
@@ -5,7 +5,7 @@ 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
7
  import { libraryCandidates, libraryTag, sidecarDir } from "./paths.js";
8
- import { LibraryArg, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
8
+ import { LibraryArg, ambiguousLibrary, defaultLibraryTies, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
9
9
  import { hasHotJournal } from "./store/connections.js";
10
10
  import { QueryProcess } from "./proc/query-client.js";
11
11
  import { IndexManager } from "./store/index-manager.js";
@@ -56,9 +56,34 @@ const PACKAGE_INFO = JSON.parse(readFileSync(new URL("../package.json", import.m
56
56
  * this repeats the essentials in the description because some clients show a
57
57
  * model the description and not the per-property schema documentation.
58
58
  */
59
+ /**
60
+ * Every write result names the library it landed in, and every undo is scoped
61
+ * to that one library. Engine DJ propagates a playlist change to another
62
+ * connected library by itself -- measured 2026-09-01: an edit made to the
63
+ * library on the computer appeared on the USB drive after Engine was next
64
+ * launched, the copy carrying the very timestamp this server's INSERT had
65
+ * written. An undo call cannot reach that copy, and reports success anyway,
66
+ * because within its own library it did exactly what it promised.
67
+ *
68
+ * Stated in the description, not just the README, because the caller who has
69
+ * to act on it is the model holding the undo.
70
+ */
71
+ const UNDO_SCOPE_NOTE = "`undo` reverses this edit in ONE library: the one the result's `library` field names. " +
72
+ "Engine DJ copies playlist changes between connected libraries on its own, so launching it " +
73
+ "with a second library attached can leave a copy of this edit there -- and no undo call " +
74
+ "reaches that copy. With two libraries connected (a USB drive and its copy on the computer " +
75
+ "is the usual case), undo separately against each. ";
59
76
  const LIBRARY_SELECTION_NOTE = "With more than one library connected, pass `library` (a uuid or path from list_libraries, " +
60
77
  "either the ~/... form or the absolute one) to choose which one; the default is the " +
61
78
  "supported library with the most tracks.";
79
+ /**
80
+ * Appended to the write tools only. A tie in the default rule is a refusal
81
+ * there and a free choice on the read side, so the shared note above cannot
82
+ * carry it without being wrong for one of the two.
83
+ */
84
+ const WRITE_LIBRARY_TIE_NOTE = " If two supported libraries hold the same most tracks -- what a USB drive and its copy on " +
85
+ "the computer produce -- this tool refuses with ambiguous_library rather than picking one, " +
86
+ "and lists both; nothing is written. Ask the user which one, then retry with `library` set -- do not pick for them, since one of the two may be the drive they perform from.";
62
87
  function reply(value) {
63
88
  return {
64
89
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
@@ -222,6 +247,47 @@ export async function createServer(opts = {}) {
222
247
  return state;
223
248
  return fresh;
224
249
  };
250
+ /**
251
+ * `acquire` for the write tools: identical, except that an omitted
252
+ * `library` must resolve to exactly one candidate.
253
+ *
254
+ * `pickDefaultLibrary` breaks a tie on root-scan order, which is
255
+ * deterministic and, for a read, fine -- libraries tie because one is a
256
+ * copy of the other, so either answer is very nearly the same answer, and
257
+ * making a read demand a `library` it does not care about would be noise.
258
+ *
259
+ * A write is not that. The choice decides which physical disk changes, and
260
+ * one of the two is the drive the DJ performs from; root-scan order is not
261
+ * a reason to pick it. Measured 2026-09-01: the computer's library and the
262
+ * USB drive both held 257 tracks, tied precisely because one was a copy of
263
+ * the other.
264
+ *
265
+ * Only the omitted case refuses. A caller who named a library gets it, tie
266
+ * or no tie -- the ambiguity being refused here is the server's, not theirs.
267
+ *
268
+ * Rescans first, because `knownList()` is a cache that deliberately keeps a
269
+ * library a later scan cannot see -- so a momentarily locked drive does not
270
+ * vanish from list_libraries. For a tie check that is wrong in the
271
+ * direction that bites: pull the USB drive and one library is left, but the
272
+ * cache still holds two, and the write is refused naming a drive that is no
273
+ * longer there. rescanLibraries() forgets a candidate whose path is gone,
274
+ * which is exactly the distinction wanted here, and it also lets a drive
275
+ * plugged in mid-session be seen at all.
276
+ *
277
+ * The cost is one filesystem probe per write, against a write that is about
278
+ * to copy the entire database for its pre-write snapshot. Reads are left
279
+ * alone: they run far more often and a stale pick between two copies is not
280
+ * worth a probe apiece.
281
+ */
282
+ const acquireForWrite = async (requested) => {
283
+ if (requested === undefined) {
284
+ rescanLibraries();
285
+ const tied = defaultLibraryTies(knownList());
286
+ if (tied.length > 1)
287
+ return ambiguousLibrary(tied);
288
+ }
289
+ return acquire(requested);
290
+ };
225
291
  /**
226
292
  * Shared by the engine://libraries resource and the list_libraries tool so
227
293
  * the two cannot drift in shape, while differing in exactly one respect:
@@ -371,6 +437,10 @@ export async function createServer(opts = {}) {
371
437
  title: "Audit the collection",
372
438
  description: `Run collection health checks. Available: ${AUDIT_CHECKS.join(", ")}. ` +
373
439
  `missing_files resolves each track against the selected library's own folder. ` +
440
+ `path_form_mismatch finds files that are there, but under a name in a different Unicode ` +
441
+ `normalization form from the stored path -- macOS opens them anyway, Linux does not ` +
442
+ `(measured on its exFAT driver), and Engine OS on a player is Linux, so these may fail ` +
443
+ `to load on hardware while missing_files on a Mac reports nothing. ` +
374
444
  `no_cues means "no hot cue is set" -- the quickCues blob is decoded for this, since ` +
375
445
  `Engine writes one to every analysed track whether or not a pad is used -- while ` +
376
446
  `no_beatgrid means the beatData blob is absent or empty. ` +
@@ -443,6 +513,10 @@ export async function createServer(opts = {}) {
443
513
  "write of this session; it is a recovery route for a damaged library, NOT an undo. " +
444
514
  "Restoring it reverts the entire library to that moment, discarding everything " +
445
515
  "Engine DJ has written since (play counts, imports, cue and beatgrid edits). " +
516
+ "The result's `library` field names which library this went into. Engine DJ copies " +
517
+ "playlist changes between connected libraries on its own (measured for an edit to an " +
518
+ "existing playlist), so with a second library attached the new playlist may appear " +
519
+ "there too. " +
446
520
  "No existing playlist is renamed, reordered, emptied or deleted, and no track, cue or " +
447
521
  "beatgrid is touched. The one existing row that moves is the previous last playlist's " +
448
522
  "link, and Engine's own insert trigger is what moves it. " +
@@ -453,11 +527,11 @@ export async function createServer(opts = {}) {
453
527
  "library is unchanged and \"committed_unverified\" when the write may have gone " +
454
528
  "through but could not be verified. track_ids may be empty (an empty playlist); a " +
455
529
  "track id may appear at most once. " +
456
- LIBRARY_SELECTION_NOTE,
530
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
457
531
  inputSchema: { ...CreatePlaylistInput.shape, library: LibraryArg },
458
532
  annotations: RW,
459
533
  }, async (args) => {
460
- const state = await acquire(args.library);
534
+ const state = await acquireForWrite(args.library);
461
535
  if (isEngineError(state))
462
536
  return reply(state);
463
537
  return reply(await runCreatePlaylist(state.lib.path, state.lib.uuid, args, opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups")));
@@ -486,11 +560,12 @@ export async function createServer(opts = {}) {
486
560
  "session's first write, discarding every play count, import, cue and beatgrid change " +
487
561
  "Engine DJ has recorded since -- not just this one edit. backup_path is only a " +
488
562
  "last-resort recovery route for a damaged library, never an undo. " +
489
- LIBRARY_SELECTION_NOTE,
563
+ UNDO_SCOPE_NOTE +
564
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
490
565
  inputSchema: { ...AddTracksToPlaylistInput.shape, library: LibraryArg },
491
566
  annotations: RW,
492
567
  }, async (args) => {
493
- const state = await acquire(args.library);
568
+ const state = await acquireForWrite(args.library);
494
569
  if (isEngineError(state))
495
570
  return reply(state);
496
571
  return reply(await runAddTracksToPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
@@ -524,11 +599,12 @@ export async function createServer(opts = {}) {
524
599
  "else. Preferred over restoring " +
525
600
  "backup_path, which reverts the WHOLE library to before this session's first write, " +
526
601
  "discarding everything Engine DJ has recorded since -- not just this edit. " +
527
- LIBRARY_SELECTION_NOTE,
602
+ UNDO_SCOPE_NOTE +
603
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
528
604
  inputSchema: { ...RemoveTracksFromPlaylistInput.shape, library: LibraryArg },
529
605
  annotations: RW_DESTRUCTIVE,
530
606
  }, async (args) => {
531
- const state = await acquire(args.library);
607
+ const state = await acquireForWrite(args.library);
532
608
  if (isEngineError(state))
533
609
  return reply(state);
534
610
  return reply(await runRemoveTracksFromPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
@@ -550,11 +626,12 @@ export async function createServer(opts = {}) {
550
626
  "backup_path, which reverts the " +
551
627
  "WHOLE library to before this session's first write, discarding everything Engine DJ " +
552
628
  "has recorded since -- not just this reorder. " +
553
- LIBRARY_SELECTION_NOTE,
629
+ UNDO_SCOPE_NOTE +
630
+ LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
554
631
  inputSchema: { ...ReorderPlaylistInput.shape, library: LibraryArg },
555
632
  annotations: RW_DESTRUCTIVE,
556
633
  }, async (args) => {
557
- const state = await acquire(args.library);
634
+ const state = await acquireForWrite(args.library);
558
635
  if (isEngineError(state))
559
636
  return reply(state);
560
637
  return reply(await runReorderPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
@@ -630,6 +707,18 @@ other.
630
707
  - \`Track.path\` is relative to the \`Engine Library\` folder and usually
631
708
  contains \`..\`. The SQL function \`abs_path(path)\` resolves it against
632
709
  this library's location; the home prefix comes back folded to \`~\`.
710
+ - \`Track.streamingSource\`, \`uri\` and \`streamingFlags\` are reported to
711
+ decide whether Engine OS streams a track (from Dropbox) instead of reading
712
+ the file. Not measured here: on both reference libraries \`streamingSource\`
713
+ and \`uri\` are NULL on every track, and \`streamingFlags\` is 5 on about
714
+ half of them -- tracks that load and play -- so \`streamingFlags\` on its own
715
+ says nothing about whether a track will load. Selectable as the fields
716
+ \`streaming_source\`, \`streaming_flags\` and \`uri\`; \`uri\` is redacted like
717
+ \`path\`, including a home directory percent-encoded inside it.
718
+ - SQLite's \`LOWER()\` folds ASCII only: \`LOWER('ЭЙФОРИЯ')\` comes back
719
+ unchanged. To compare names regardless of case in any script, use
720
+ \`fold(text)\` -- one Unicode normalization form, lower-cased by Unicode
721
+ rules. It runs per row, like every function here.
633
722
  - A track's natural key across drives is \`(originDatabaseUuid, originTrackId)\`.
634
723
  - \`PerformanceData\`'s blob columns are binary and cannot be read with SQL.
635
724
  Engine writes \`quickCues\`, \`loops\`, \`beatData\` and
@@ -1,3 +1,4 @@
1
+ import { backup } from "node:sqlite";
1
2
  import { type EngineError } from "../errors.js";
2
3
  /**
3
4
  * The snapshots to delete, oldest first, from a directory listing.
@@ -23,4 +24,21 @@ import { type EngineError } from "../errors.js";
23
24
  * predate the tagged scheme, so they predate anything written since.
24
25
  */
25
26
  export declare function evictable(names: string[], uuid: string, tag: string): string[];
26
- export declare function snapshotLibrary(mdbPath: string, uuid: string, baseDir: string): Promise<string | EngineError>;
27
+ /**
28
+ * Partial copies in this library's namespace whose writer is gone.
29
+ *
30
+ * A snapshot is copied to `<final name>.partial-<pid>` and renamed into place
31
+ * only once backup() has resolved (see snapshotLibrary). A process killed
32
+ * mid-copy never gets to clean up after itself, so its partial stays behind:
33
+ * harmless to rotation, which counts only `.db` names, but a full-size file
34
+ * nobody will ever finish.
35
+ *
36
+ * The pid is what separates such an orphan from another server's copy that is
37
+ * being written right now -- two servers can snapshot one library at once --
38
+ * so only a pid with no live process behind it is reclaimed. `prefix` keeps it
39
+ * to this library, exactly as rotation is kept to it.
40
+ */
41
+ export declare function abandonedPartials(names: string[], prefix: string, isAlive?: (pid: number) => boolean): string[];
42
+ export declare function snapshotLibrary(mdbPath: string, uuid: string, baseDir: string, deps?: {
43
+ backup?: typeof backup;
44
+ }): Promise<string | EngineError>;
@@ -9,7 +9,7 @@
9
9
  // Engine Library folder -- the rule that no file is created there holds for
10
10
  // writes exactly as it did for reads.
11
11
  import { DatabaseSync, backup } from "node:sqlite";
12
- import { mkdirSync, readdirSync, rmSync } from "node:fs";
12
+ import { mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { err } from "../errors.js";
15
15
  import { libraryTag } from "../paths.js";
@@ -74,7 +74,49 @@ export function evictable(names, uuid, tag) {
74
74
  mine.sort((a, b) => (a.old !== b.old ? (a.old ? -1 : 1) : a.stamp < b.stamp ? -1 : a.stamp > b.stamp ? 1 : 0));
75
75
  return mine.slice(0, Math.max(0, mine.length - KEEP)).map((x) => x.name);
76
76
  }
77
- export async function snapshotLibrary(mdbPath, uuid, baseDir) {
77
+ /** Whether a process with this pid exists. EPERM means it does and is not ours to signal. */
78
+ function processAlive(pid) {
79
+ try {
80
+ process.kill(pid, 0);
81
+ return true;
82
+ }
83
+ catch (e) {
84
+ return e.code === "EPERM";
85
+ }
86
+ }
87
+ /**
88
+ * Partial copies in this library's namespace whose writer is gone.
89
+ *
90
+ * A snapshot is copied to `<final name>.partial-<pid>` and renamed into place
91
+ * only once backup() has resolved (see snapshotLibrary). A process killed
92
+ * mid-copy never gets to clean up after itself, so its partial stays behind:
93
+ * harmless to rotation, which counts only `.db` names, but a full-size file
94
+ * nobody will ever finish.
95
+ *
96
+ * The pid is what separates such an orphan from another server's copy that is
97
+ * being written right now -- two servers can snapshot one library at once --
98
+ * so only a pid with no live process behind it is reclaimed. `prefix` keeps it
99
+ * to this library, exactly as rotation is kept to it.
100
+ */
101
+ export function abandonedPartials(names, prefix, isAlive = processAlive) {
102
+ // With or without `-journal`: SQLite keeps a rollback journal beside the
103
+ // copy while backup() runs (measured -- it exists mid-copy and is gone once
104
+ // backup() resolves), so a process killed partway leaves both behind. They
105
+ // share the pid, so a live copy's journal is spared along with the copy.
106
+ const partial = /\.db\.partial-(\d+)(-journal)?$/;
107
+ return names.filter((name) => {
108
+ if (!name.startsWith(prefix))
109
+ return false;
110
+ const m = partial.exec(name);
111
+ return m !== null && !isAlive(Number(m[1]));
112
+ });
113
+ }
114
+ export async function snapshotLibrary(mdbPath, uuid, baseDir,
115
+ // Injectable so a test can make the copy fail after it has started writing
116
+ // -- the one failure that matters here, and one no real error reachable
117
+ // from a test produces (they all fail before the destination is touched).
118
+ deps = {}) {
119
+ const copy = deps.backup ?? backup;
78
120
  // node:sqlite stopped needing a flag in 22.13, which is where this
79
121
  // project's floor used to sit -- but backup() only arrived in 22.16. On
80
122
  // 22.13 through 22.15 the read path works perfectly and this one throws
@@ -82,11 +124,12 @@ export async function snapshotLibrary(mdbPath, uuid, baseDir) {
82
124
  // run against the declared floor. `engines` now says 22.16, and npm only
83
125
  // enforces that under engine-strict, so the check is here too: a version
84
126
  // number a user can act on beats a TypeError from inside a dependency.
85
- if (typeof backup !== "function") {
127
+ if (typeof copy !== "function") {
86
128
  return err("library_unreadable", `This Node cannot snapshot a library before writing to it: node:sqlite gained backup() in ` +
87
129
  `22.16.0 and this is ${process.version}. Upgrade Node, or run without --allow-writes.`);
88
130
  }
89
131
  let src;
132
+ let partial;
90
133
  try {
91
134
  mkdirSync(baseDir, { recursive: true });
92
135
  src = new DatabaseSync(mdbPath, { readOnly: true });
@@ -98,16 +141,41 @@ export async function snapshotLibrary(mdbPath, uuid, baseDir) {
98
141
  // the same tag server.ts's sidecarBaseFor uses to keep two such
99
142
  // libraries' indexes apart (see paths.ts).
100
143
  const prefix = `${uuid}-${libraryTag(mdbPath)}-`;
144
+ // Before copying, not after: a copy that died because the disk filled up
145
+ // left its partial behind, and this is exactly when that space is wanted.
146
+ for (const dead of abandonedPartials(readdirSync(baseDir), prefix)) {
147
+ rmSync(join(baseDir, dead), { force: true });
148
+ }
149
+ // Copied under a name rotation does not count and nobody would restore,
150
+ // then renamed into place only once backup() has resolved. Written
151
+ // straight to the final name, a copy that died partway was an incomplete
152
+ // database indistinguishable from a good one: rotation took it for the
153
+ // newest and evicted a real snapshot for it, and "restore the latest
154
+ // backup" would have restored it (#3). rename() within one directory is
155
+ // atomic, so the final name only ever holds a finished copy.
101
156
  const dest = join(baseDir, `${prefix}${stamp()}.db`);
102
- await backup(src, dest);
157
+ partial = `${dest}.partial-${process.pid}`;
158
+ await copy(src, partial);
103
159
  src.close();
104
160
  src = undefined;
161
+ renameSync(partial, dest);
162
+ partial = undefined;
105
163
  for (const stale of evictable(readdirSync(baseDir), uuid, libraryTag(mdbPath))) {
106
164
  rmSync(join(baseDir, stale), { force: true });
107
165
  }
108
166
  return dest;
109
167
  }
110
168
  catch (e) {
169
+ // Safe to delete, which the destination itself never was: this name
170
+ // carries this process's pid and a stamp whose counter never repeats
171
+ // within a process, so whatever is there was created by this call. The
172
+ // earlier objection to cleaning up on failure -- deleting a file at a path
173
+ // we may not have created -- does not apply to a path no one else can
174
+ // produce.
175
+ if (partial) {
176
+ rmSync(partial, { force: true });
177
+ rmSync(`${partial}-journal`, { force: true });
178
+ }
111
179
  return err("library_unreadable", `Could not snapshot ${mdbPath} before writing: ${String(e)}`);
112
180
  }
113
181
  finally {
@@ -4,8 +4,24 @@ export interface CreatePlaylistResult {
4
4
  playlist_id: number;
5
5
  title: string;
6
6
  tracks_added: number;
7
+ library: LibraryRef;
7
8
  backup_path: string;
8
9
  }
10
+ /**
11
+ * Which library a write actually landed in. Two connected libraries -- a USB
12
+ * drive and its copy on the computer -- is the ordinary setup, so "which one
13
+ * did that go to" is a question every write result has to answer on its own,
14
+ * without the caller re-deriving it from an argument it may not have passed.
15
+ *
16
+ * It is also the context `undo` needs: an undo reverses the edit in this
17
+ * library and cannot reach a copy Engine DJ has since propagated to another
18
+ * one (measured 2026-09-01, see README).
19
+ */
20
+ export interface LibraryRef {
21
+ uuid: string;
22
+ /** The m.db path, in the `~/...` form list_libraries prints. */
23
+ path: string;
24
+ }
9
25
  /**
10
26
  * What editing an existing playlist's entries returns. One shape for
11
27
  * add/remove/reorder alike -- each op leaves the fields it did not touch
@@ -37,6 +53,7 @@ export interface EditResult {
37
53
  undo_complete: boolean;
38
54
  /** Set only when `undo_complete` is false: which positions have no way back, and why. */
39
55
  undo_note?: string;
56
+ library: LibraryRef;
40
57
  backup_path: string;
41
58
  }
42
59
  /** One step of the tool call that would undo an edit, in the shape a client replays it. */
@@ -14,6 +14,7 @@ import { DatabaseSync } from "node:sqlite";
14
14
  import { err, isEngineError, libraryNeedsRecovery } from "../errors.js";
15
15
  import { snapshotLibrary } from "./backup.js";
16
16
  import { hasHotJournal } from "./connections.js";
17
+ import { redactPath } from "../paths.js";
17
18
  /**
18
19
  * `detail` discriminator values for the EngineError this module returns.
19
20
  * Stable across releases so a caller can decide "is the library still what
@@ -449,7 +450,10 @@ async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
449
450
  const verifyErr = verifyAfterCommit(db, subject, backupPath);
450
451
  if (verifyErr)
451
452
  return verifyErr;
452
- return { ...result, backup_path: backupPath };
453
+ // Filled in here, alongside backup_path, for the same reason: it is the
454
+ // one place that knows the write succeeded, and doing it per-op would let
455
+ // a new op ship without it.
456
+ return { ...result, library: { uuid, path: redactPath(mdbPath) }, backup_path: backupPath };
453
457
  }
454
458
  catch (e) {
455
459
  return classifyWriteFailure(e, commit, subject, mdbPath, backupPath, db, open);
@@ -1157,8 +1161,10 @@ export async function reorderPlaylist(mdbPath, uuid, input, opts) {
1157
1161
  return withWriteTransaction(mdbPath, uuid, subject, opts, (db) => {
1158
1162
  // Re-read: BEGIN IMMEDIATE is the first moment nothing else can change
1159
1163
  // the chain, and gating on the pre-check's read alone would be trusting
1160
- // one that could already be stale.
1161
- const gate = gateChain(db, listId);
1164
+ // one that could already be stale. Read once and kept: the links rewritten
1165
+ // below are exactly the ones this check passed, not a second read of them.
1166
+ const rows = readChain(db, listId);
1167
+ const gate = checkChain(rows);
1162
1168
  if (!gate.ok) {
1163
1169
  rollback(db);
1164
1170
  return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
@@ -1183,7 +1189,7 @@ export async function reorderPlaylist(mdbPath, uuid, input, opts) {
1183
1189
  // returns; each entry's new successor is the id that follows it there,
1184
1190
  // or 0 for the new tail.
1185
1191
  const newSeq = requestedOrder.map((p) => gate.order[p - 1]);
1186
- const currentNext = new Map(readChain(db, listId).map((r) => [r.id, r.next]));
1192
+ const currentNext = new Map(rows.map((r) => [r.id, r.next]));
1187
1193
  const link = db.prepare("UPDATE PlaylistEntity SET nextEntityId = ? WHERE id = ?");
1188
1194
  for (let i = 0; i < newSeq.length; i++) {
1189
1195
  const entryId = newSeq[i];
@@ -1,7 +1,35 @@
1
1
  import { z } from "zod";
2
2
  import { type EngineError } from "../errors.js";
3
3
  import type { QueryProcess } from "../proc/query-client.js";
4
- export declare const AUDIT_CHECKS: readonly ["missing_files", "unavailable", "unanalyzed", "no_cues", "no_beatgrid", "missing_key", "suspicious_bpm", "duplicates", "empty_metadata", "orphan_entries"];
4
+ export declare const AUDIT_CHECKS: readonly ["missing_files", "unavailable", "unanalyzed", "no_cues", "no_beatgrid", "missing_key", "suspicious_bpm", "duplicates", "empty_metadata", "orphan_entries", "path_form_mismatch"];
5
+ /**
6
+ * The checks that read the filesystem rather than the database: each needs
7
+ * the stored paths themselves, and each answers something no SQL can.
8
+ */
9
+ export declare const FILESYSTEM_CHECKS: readonly ["missing_files", "path_form_mismatch"];
10
+ /**
11
+ * Whether a stored path resolves on disk only by ignoring Unicode
12
+ * normalization.
13
+ *
14
+ * Measured 2026-09-11 on Linux 6.17's in-kernel exFAT driver: a path in NFC
15
+ * does not find a file whose name was written in NFD, while a path that
16
+ * differs only in case does. macOS forgives both, which is why missing_files
17
+ * -- asking the host's own lookup -- reports nothing on a Mac for a file that
18
+ * Linux, and so plausibly Engine OS on a player, would not open. On the
19
+ * maintainer's USB drive that was 8 tracks: file and folder names macOS wrote
20
+ * in NFD behind the NFC paths Engine stored.
21
+ *
22
+ * Walks the path one component at a time against the real listings, so it
23
+ * answers the same on every host. A component matching exactly, or in case
24
+ * only, is fine -- exFAT and Windows ignore case, so reporting it would be
25
+ * noise. One found only when both sides are brought to NFC marks the path.
26
+ * One not found at all ends the walk unmarked: that file is missing, which is
27
+ * missing_files' business, not this check's.
28
+ *
29
+ * `listDir` is injected so the comparison can be exercised against an exact
30
+ * listing on any host; the audit passes a cached readdirSync.
31
+ */
32
+ export declare function onlyFoundByIgnoringForm(absPath: string, listDir: (dir: string) => string[] | null): boolean;
5
33
  export declare const AuditInput: z.ZodObject<{
6
34
  checks: z.ZodOptional<z.ZodArray<z.ZodString>>;
7
35
  }, z.core.$strip>;
@@ -1,5 +1,6 @@
1
1
  // src/tools/audit.ts
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { join, parse, sep } from "node:path";
3
4
  import { z } from "zod";
4
5
  import { err, isEngineError } from "../errors.js";
5
6
  import { absTrackPath } from "../paths.js";
@@ -15,7 +16,56 @@ export const AUDIT_CHECKS = [
15
16
  "duplicates",
16
17
  "empty_metadata",
17
18
  "orphan_entries",
19
+ "path_form_mismatch",
18
20
  ];
21
+ /**
22
+ * The checks that read the filesystem rather than the database: each needs
23
+ * the stored paths themselves, and each answers something no SQL can.
24
+ */
25
+ export const FILESYSTEM_CHECKS = ["missing_files", "path_form_mismatch"];
26
+ /**
27
+ * Whether a stored path resolves on disk only by ignoring Unicode
28
+ * normalization.
29
+ *
30
+ * Measured 2026-09-11 on Linux 6.17's in-kernel exFAT driver: a path in NFC
31
+ * does not find a file whose name was written in NFD, while a path that
32
+ * differs only in case does. macOS forgives both, which is why missing_files
33
+ * -- asking the host's own lookup -- reports nothing on a Mac for a file that
34
+ * Linux, and so plausibly Engine OS on a player, would not open. On the
35
+ * maintainer's USB drive that was 8 tracks: file and folder names macOS wrote
36
+ * in NFD behind the NFC paths Engine stored.
37
+ *
38
+ * Walks the path one component at a time against the real listings, so it
39
+ * answers the same on every host. A component matching exactly, or in case
40
+ * only, is fine -- exFAT and Windows ignore case, so reporting it would be
41
+ * noise. One found only when both sides are brought to NFC marks the path.
42
+ * One not found at all ends the walk unmarked: that file is missing, which is
43
+ * missing_files' business, not this check's.
44
+ *
45
+ * `listDir` is injected so the comparison can be exercised against an exact
46
+ * listing on any host; the audit passes a cached readdirSync.
47
+ */
48
+ export function onlyFoundByIgnoringForm(absPath, listDir) {
49
+ const { root } = parse(absPath);
50
+ let dir = root;
51
+ let formDiffered = false;
52
+ for (const want of absPath.slice(root.length).split(sep).filter(Boolean)) {
53
+ const names = listDir(dir);
54
+ if (!names)
55
+ return false;
56
+ const upper = want.toUpperCase();
57
+ let hit = names.find((n) => n === want) ?? names.find((n) => n.toUpperCase() === upper);
58
+ if (hit === undefined) {
59
+ const key = want.normalize("NFC").toUpperCase();
60
+ hit = names.find((n) => n.normalize("NFC").toUpperCase() === key);
61
+ if (hit === undefined)
62
+ return false;
63
+ formDiffered = true;
64
+ }
65
+ dir = join(dir, hit);
66
+ }
67
+ return formDiffered;
68
+ }
19
69
  export const AuditInput = z.object({ checks: z.array(z.string()).optional() });
20
70
  /**
21
71
  * Counts plus a small sample, never rows: this result lands in an LLM's
@@ -79,8 +129,10 @@ const SQL_CHECKS = {
79
129
  },
80
130
  duplicates: {
81
131
  id: "t.id",
82
- body: `FROM Track t WHERE LOWER(TRIM(t.artist)) || '|' || LOWER(TRIM(t.title)) IN (
83
- SELECT LOWER(TRIM(artist)) || '|' || LOWER(TRIM(title)) FROM Track
132
+ // fold(), not LOWER(): LOWER is ASCII-only, so a Cyrillic title and the
133
+ // same title in capitals were not grouped (#9). See semantics.ts.
134
+ body: `FROM Track t WHERE fold(TRIM(t.artist)) || '|' || fold(TRIM(t.title)) IN (
135
+ SELECT fold(TRIM(artist)) || '|' || fold(TRIM(title)) FROM Track
84
136
  WHERE artist IS NOT NULL AND title IS NOT NULL
85
137
  GROUP BY 1 HAVING COUNT(*) > 1)`,
86
138
  },
@@ -138,6 +190,33 @@ export async function auditLibrary(qp, mdbPath, raw) {
138
190
  out.push({ name, count: missing.length, sample_ids: missing.slice(0, SAMPLE) });
139
191
  continue;
140
192
  }
193
+ if (name === "path_form_mismatch") {
194
+ // Only a path with a character outside printable ASCII can have a second
195
+ // normalization form, so only those cross the process boundary -- 22 of
196
+ // 257 on the reference library -- and each directory is listed once.
197
+ const res = await qp.run(`SELECT id, path FROM Track WHERE path GLOB '*[^ -~]*' ORDER BY id`);
198
+ if (isEngineError(res))
199
+ return res;
200
+ const listings = new Map();
201
+ const listDir = (d) => {
202
+ if (!listings.has(d)) {
203
+ try {
204
+ listings.set(d, readdirSync(d));
205
+ }
206
+ catch {
207
+ listings.set(d, null);
208
+ }
209
+ }
210
+ return listings.get(d);
211
+ };
212
+ const marked = [];
213
+ for (const row of res.rows) {
214
+ if (onlyFoundByIgnoringForm(absTrackPath(mdbPath, String(row[1])), listDir))
215
+ marked.push(Number(row[0]));
216
+ }
217
+ out.push({ name, count: marked.length, sample_ids: marked.slice(0, SAMPLE) });
218
+ continue;
219
+ }
141
220
  const check = SQL_CHECKS[name];
142
221
  const counted = await qp.run(`SELECT COUNT(*) AS c ${check.body}`);
143
222
  if (isEngineError(counted))
@@ -1,9 +1,8 @@
1
1
  // src/tools/playlists.ts
2
2
  import { z } from "zod";
3
3
  import { err, isEngineError } from "../errors.js";
4
- import { redactPath } from "../paths.js";
5
4
  import { loadPlaylistEntries, loadPlaylistTree, resolvePlaylist, } from "../playlists.js";
6
- import { DEFAULT_FIELDS, FIELD_SQL } from "./search.js";
5
+ import { DEFAULT_FIELDS, FIELD_SQL, presentField } from "./search.js";
7
6
  /**
8
7
  * Playlists returned in one call. Higher than any real library needs (the
9
8
  * reference library has 16) but the result still goes into a model's
@@ -160,15 +159,7 @@ export async function getPlaylistTracks(qp, raw) {
160
159
  return res;
161
160
  const idx = Object.fromEntries(res.columns.map((c, i) => [c, i]));
162
161
  for (const row of res.rows) {
163
- const track = Object.fromEntries(fields.map((f) => {
164
- const value = row[idx[f]];
165
- return [
166
- f,
167
- input.redact_paths && f === "path" && typeof value === "string"
168
- ? redactPath(value)
169
- : value,
170
- ];
171
- }));
162
+ const track = Object.fromEntries(fields.map((f) => [f, presentField(f, row[idx[f]], input.redact_paths)]));
172
163
  byKey.set(entryKey(String(row[idx.__uuid]), Number(row[idx.__origin])), track);
173
164
  }
174
165
  }
@@ -10,6 +10,13 @@ export declare const DEFAULT_FIELDS: readonly ["id", "artist", "title", "bpm", "
10
10
  * validating fields can never drift from this one.
11
11
  */
12
12
  export declare const FIELD_SQL: Record<string, string>;
13
+ /**
14
+ * How one projected value is handed back. The single place a path-bearing
15
+ * field is redacted: search_tracks, get_tracks and get_playlist_tracks each
16
+ * carried their own copy of the `path` check, which is how a new path-bearing
17
+ * field ends up redacted in two of the three.
18
+ */
19
+ export declare function presentField(field: string, value: unknown, redact: boolean): unknown;
13
20
  export declare const SearchInput: z.ZodObject<{
14
21
  q: z.ZodOptional<z.ZodString>;
15
22
  bpm: z.ZodOptional<z.ZodObject<{
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { createHash } from "node:crypto";
4
4
  import { err, isEngineError } from "../errors.js";
5
5
  import { camelotNeighbours } from "../semantics.js";
6
- import { redactPath } from "../paths.js";
6
+ import { redactPath, redactUri } from "../paths.js";
7
7
  import { ENTRY_TRACK_MATCH, resolvePlaylist } from "../playlists.js";
8
8
  export const DEFAULT_FIELDS = ["id", "artist", "title", "bpm", "camelot", "rating"];
9
9
  const MAX_LIMIT = 200;
@@ -47,7 +47,29 @@ export const FIELD_SQL = {
47
47
  date_added: "t.dateAdded",
48
48
  last_played: "t.timeLastPlayed",
49
49
  is_analyzed: "t.isAnalyzed",
50
+ // Reported to decide whether Engine OS streams a track (from Dropbox)
51
+ // rather than reading it from disk -- not measured here: NULL, NULL and a
52
+ // mix of NULL/0/5 on both reference libraries, whose tracks all load. Opt-in
53
+ // only, for diagnosing a track that will not load (#8).
54
+ streaming_source: "t.streamingSource",
55
+ streaming_flags: "t.streamingFlags",
56
+ uri: "t.uri",
50
57
  };
58
+ /**
59
+ * How one projected value is handed back. The single place a path-bearing
60
+ * field is redacted: search_tracks, get_tracks and get_playlist_tracks each
61
+ * carried their own copy of the `path` check, which is how a new path-bearing
62
+ * field ends up redacted in two of the three.
63
+ */
64
+ export function presentField(field, value, redact) {
65
+ if (!redact || typeof value !== "string")
66
+ return value;
67
+ if (field === "path")
68
+ return redactPath(value);
69
+ if (field === "uri")
70
+ return redactUri(value);
71
+ return value;
72
+ }
51
73
  export const SearchInput = z.object({
52
74
  q: z.string().optional(),
53
75
  bpm: z
@@ -354,12 +376,7 @@ export async function searchTracks(qp, raw) {
354
376
  if (isEngineError(res))
355
377
  return res;
356
378
  const idx = Object.fromEntries(res.columns.map((c, i) => [c, i]));
357
- const tracks = res.rows.map((row) => Object.fromEntries(fields.map((f) => {
358
- const value = row[idx[f]];
359
- return [f, input.redact_paths && f === "path" && typeof value === "string"
360
- ? redactPath(value)
361
- : value];
362
- })));
379
+ const tracks = res.rows.map((row) => Object.fromEntries(fields.map((f) => [f, presentField(f, row[idx[f]], input.redact_paths)])));
363
380
  let next_cursor;
364
381
  if (res.rows.length === limit) {
365
382
  const last = res.rows[res.rows.length - 1];
@@ -1,8 +1,7 @@
1
1
  // src/tools/tracks.ts
2
2
  import { z } from "zod";
3
3
  import { err, isEngineError } from "../errors.js";
4
- import { DEFAULT_FIELDS, FIELD_SQL } from "./search.js";
5
- import { redactPath } from "../paths.js";
4
+ import { DEFAULT_FIELDS, FIELD_SQL, presentField } from "./search.js";
6
5
  export const GetTracksInput = z.object({
7
6
  ids: z.array(z.number().int().positive()).min(1).max(200),
8
7
  fields: z.array(z.string()).optional(),
@@ -38,10 +37,7 @@ export async function getTracks(qp, raw) {
38
37
  const idx = Object.fromEntries(res.columns.map((c, i) => [c, i]));
39
38
  const byId = new Map();
40
39
  for (const row of res.rows) {
41
- const track = Object.fromEntries(fields.map((f) => {
42
- const value = row[idx[f]];
43
- return [f, redact_paths && f === "path" && typeof value === "string" ? redactPath(value) : value];
44
- }));
40
+ const track = Object.fromEntries(fields.map((f) => [f, presentField(f, row[idx[f]], redact_paths)]));
45
41
  byId.set(Number(row[idx.__id]), track);
46
42
  }
47
43
  // Preserve the caller's ordering; missing ids are simply absent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engine-dj-mcp",
3
- "version": "0.12.0",
3
+ "version": "0.15.0",
4
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",