engine-dj-mcp 0.12.0 → 0.17.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.17.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;
@@ -83,7 +87,7 @@ tempo, key, rating, when a track was added and when it was last played.
83
87
  | `q` | Full text over title, artist, album, genre, comment and label. Diacritics are folded, so `bjork` matches `Björk` — Engine's own search does not. |
84
88
  | `bpm` | `{ min, max }` or `{ around, tolerance_pct }`. Resolved tempo, so an analysed BPM wins over the tag. |
85
89
  | `key` | `{ camelot: [...] }` for exact keys, `{ compatible_with: "8A" }` for harmonic neighbours, `{ mode: "minor" }` for a whole side of the wheel. |
86
- | `rating` | `{ min, max }`, 0–5. |
90
+ | `rating` | `{ min, max }`, in stars, 0–5. Engine stores 0, 20, 40, 60, 80, 100; the filter converts, so `{ min: 4 }` means four stars and up. The `rating` field hands back the stored number, and `rating_stars` the same thing in stars. |
87
91
  | `played` | `{ never: true }`, or `{ before, after }` taking an ISO date or a relative form like `-6 months`. |
88
92
  | `added` | `{ before, after }`, same date forms. |
89
93
  | `flags` | `analyzed`, `available`, `has_cues`, `has_beatgrid`. `has_cues` means a hot cue is genuinely set — see [Limitations](#limitations). |
@@ -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
 
@@ -205,7 +210,7 @@ server checks staleness itself before answering.
205
210
 
206
211
  ### `create_playlist`
207
212
 
208
- The first of the four tools that write, none of which is registered at all
213
+ The first of the five tools that write, none of which is registered at all
209
214
  unless the server was started with `--allow-writes`.
210
215
 
211
216
  Creates one new top-level playlist from track ids — `track_ids` sets both
@@ -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,103 @@ 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
+ ### `update_track_metadata`
341
+
342
+ Changes genre, comment, label, year or rating on tracks — the values Engine
343
+ DJ shows in its columns. It writes to Engine's database, **not to the audio files' tags**. Engine itself writes a comment into the file when you edit it
344
+ there, but not a genre or a rating, so other software reading the tags will
345
+ not see these edits either way.
346
+
347
+ | Argument | |
348
+ | --- | --- |
349
+ | `updates` | Up to 200 entries, each `{ id, genre?, comment?, label?, year?, rating_stars? }`. Only the named fields change. `""` clears a text field; `year: 0` means unknown, as Engine stores it; `rating_stars` is 0–5. |
350
+ | `library` | Required when more than one library is connected — see [Choosing a library](#choosing-a-library). |
351
+
352
+ A track that already holds the requested values is not written, so repeating
353
+ a call changes nothing; it is counted in `unchanged`. The result carries
354
+ `updated`, `unchanged`, `changed` — which fields changed on which tracks —
355
+ `undo`, `undo_complete` (always `true`), `library`, and `backup_path` whenever
356
+ the write transaction ran — which can include `updated: 0`, if the tracks had
357
+ already changed to the requested values by the time the write lock was taken.
358
+
359
+ **Undo.** `undo` is one `update_track_metadata` call that restores the
360
+ previous values of exactly the fields that changed, and names the library.
361
+ It restores values, not `lastEditTime`: Engine's own trigger stamps every
362
+ edit, the undo included. Each entry carries `expect` set to what this call
363
+ wrote, so an undo replayed after someone edited the track again is refused as
364
+ `stale_value` instead of overwriting that edit. `rating_raw` and `expect`
365
+ exist for this; an ordinary edit needs neither.
366
+
367
+ Keep the undo from the first response. Repeating a call that already went
368
+ through finds nothing to change and returns an empty undo. For work spread
369
+ over several calls, replay the undos in reverse order.
370
+
371
+ Its own refusals: `unknown_track`; `track_not_editable` — a track whose origin
372
+ is empty (Engine's trigger rewrites an empty origin on any update, which would
373
+ detach it from playlist entries on other drives), or a field holding a value
374
+ this tool could not put back, such as a rating outside 0–255; `stale_value` —
375
+ the track changed after the values in `expect` were read (up to 20 mismatches
376
+ come back in a structured `mismatches` field, with the total count in the
377
+ prose message); and `invalid_argument`. On `stale_value`, tell the user which
378
+ tracks and fields changed — do not rebuild `expect` from a fresh read to force
379
+ the write without the user's consent, or it silently overwrites the edit the
380
+ DJ made since. Plus the ones
381
+ [every write tool shares](#refusals-every-write-tool-shares), except
382
+ `index_stale` and the query errors: this tool addresses tracks by id and never
383
+ touches the search index.
384
+
385
+ **Searching right after an edit.** Genre, comment and label are in the search
386
+ index, which is rebuilt on the next read. While Engine DJ holds the library
387
+ open it cannot be rebuilt, so a search can keep showing the old values, and
388
+ `refresh_index` cannot help until Engine lets go. The edit itself is in the
389
+ database.
390
+
391
+ **Smart playlists.** A smart playlist whose rules match on genre changes what
392
+ it contains when a genre is renamed, though none of its own rows were touched.
393
+
394
+ **Two connected libraries.** Do not assume a tag edit propagates the way a
395
+ playlist edit does (see [An undo covers one library](#an-undo-covers-one-library)):
396
+ measured once, a tag edit made on the USB library was not copied to the
397
+ computer's library on a fresh Engine DJ launch. The other direction has not
398
+ been measured for tags. Edited tracks are marked for sync (`isMetadataOfPackedTrackChanged`) the same way Engine DJ marks its own tag edits — measured 2026-09-15. That an explicit sync to a drive then carries the edit is what the flag appears to be for, but it has not been measured.
399
+
400
+ ### Refusals every write tool shares
401
+
402
+ These come from what happens before the write itself — choosing the library,
403
+ bringing its index up to date, resolving the playlist — and from the write's
404
+ own checks.
405
+
406
+ | Code | Means | Nothing written? |
407
+ | --- | --- | --- |
408
+ | `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 |
409
+ | `library_not_found` | `library` names nothing connected — the refusal lists what is — or the library's header could not be read. | yes |
410
+ | `ambiguous_library` | No `library` given, and more than one supported library is connected. Lists them — see [Choosing a library](#choosing-a-library). | yes |
411
+ | `unsupported_schema` | The library's version is outside what this server supports. | yes |
412
+ | `library_needs_recovery` | Engine DJ left an unrecovered journal. Launch Engine once. | yes |
413
+ | `library_busy` | Something holds a conflicting lock right now. Retry. | yes |
414
+ | `index_stale` | The index could not be built yet, typically because Engine holds a lock on a first run. Carries `retry_after_ms`. | yes |
415
+ | `query_timeout`, `query_process_crashed` | The lookup that resolves a playlist failed. Edit tools only. | yes |
416
+ | `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` |
417
+
418
+ **`detail` on these errors.** Once the write itself has started, `detail` is
419
+ exactly one of two strings, and a client can read it to decide whether the
420
+ library changed: `not_committed` — the library is what it was — or
421
+ `committed_unverified` — the rare one: the write may have landed but could not
422
+ be confirmed, and only this case hands back a `backup_path`. Refusals raised
423
+ *before* that point — every row above marked "yes" — never opened the library
424
+ for writing, whatever their `detail` says: it may be absent, `not_committed`,
425
+ or explanatory text such as the candidates an ambiguous `playlist_name` lists.
426
+
335
427
  ## Resources
336
428
 
337
429
  - **`engine://schema`** — the field semantics an assistant needs before
@@ -355,6 +447,28 @@ tracks**. That matters: the local library Engine DJ creates on install is
355
447
  scanned first and is often empty, so "the first one found" would hide the
356
448
  drive you actually work from.
357
449
 
450
+ That rule is enough for a read, which changes nothing: with two libraries
451
+ connected a read picks one, and the `library` field in the result says which.
452
+
453
+ **A write refuses instead**, as soon as more than one supported library is
454
+ connected — whatever their track counts. `ambiguous_library` lists every
455
+ candidate with its track count, and `detail: "not_committed"`.
456
+
457
+ The count was never the right question. An earlier version refused only an
458
+ exact tie, reasoning that a USB drive and its copy tie precisely because one
459
+ is a copy of the other — measured 2026-09-01, both real libraries at 257. But
460
+ import one track on one side and the tie is gone, and the default quietly
461
+ takes the larger. A playlist written to the wrong drive is at least visible
462
+ there; a track's genre is not, and you are left believing the edit did not
463
+ work.
464
+
465
+ With a single library nothing changes: you never have to name it.
466
+
467
+ The refusal tells the assistant to **ask you** rather than choose. Otherwise
468
+ "pass `library`, here are the two" is an invitation to take the first one,
469
+ which puts the write back on an arbitrary disk and makes the refusal
470
+ pointless.
471
+
358
472
  Each library gets its own index and its own connection, opened the first time
359
473
  you ask that library something. Comparing two libraries against each other —
360
474
  *"what is on this drive but not that one?"* — is **not** something this server
@@ -373,20 +487,24 @@ created inside your `Engine Library` folder. The search index lives in
373
487
  Without `--allow-writes` the server has no tool that can write, and the
374
488
  paragraph above holds exactly as written: SQLite itself refuses.
375
489
 
376
- With the flag, four tools appear. `create_playlist` adds a new playlist and
490
+ With the flag, five tools appear. `create_playlist` adds a new playlist and
377
491
  nothing else. `add_tracks_to_playlist`, `remove_tracks_from_playlist` and
378
492
  `reorder_playlist` go further: with the flag, an **existing** playlist can
379
493
  now be changed, not only created — its tracks added to, removed from, or put
380
- in a different order. What each one touches is the named playlist's own
381
- entries, plus exactly two rows elsewhere: that playlist's own row, whose
382
- `lastEditTime` every edit stamps so Engine sees the change, and — for
383
- `create_playlist` only — the previous last playlist's link, made by Engine's
384
- own insert trigger. No other playlist is renamed, emptied or deleted, and no
385
- track, cue or beatgrid is touched by any of the four.
386
-
387
- Every edit returns `undo` — the exact tool call that reverses it, expressed
388
- against the positions the edit itself produced — and `undo_complete`, saying
389
- whether replaying it puts the playlist back exactly as it was. Replaying
494
+ in a different order. What these four playlist tools touch is the named
495
+ playlist's own entries, plus exactly two rows elsewhere: that playlist's own
496
+ row, whose `lastEditTime` every edit stamps so Engine sees the change, and —
497
+ for `create_playlist` only — the previous last playlist's link, made by
498
+ Engine's own insert trigger. No other playlist is renamed, emptied or deleted,
499
+ and no track, cue or beatgrid is touched by these four. `update_track_metadata`
500
+ changes genre, comment, label, year and rating on the tracks named — see its
501
+ section above — and nothing else: no playlist, cue, beatgrid, title, artist,
502
+ album, path or file is touched.
503
+
504
+ Every edit returns `undo` — the exact tool call that reverses it — and
505
+ `undo_complete`; for the playlist tools, `undo` is expressed against the
506
+ positions the edit itself produced, and `undo_complete` says whether
507
+ replaying it puts the playlist back exactly as it was. Replaying
390
508
  `undo` is the right way back from an edit; restoring `backup_path` is not,
391
509
  because it reverts the **whole library** to before this session's first
392
510
  write, discarding every play count, import, cue and beatgrid change Engine
@@ -418,9 +536,56 @@ and without `--allow-writes` not even this.
418
536
  The write takes SQLite's own write lock for the length of one transaction and
419
537
  does not wait for it: if something else — Engine DJ mid-save, a player — is
420
538
  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.
539
+ `library_busy` and nothing is changed.
540
+
541
+ **Quit Engine DJ before writing.** Having Engine open is not usually a lock
542
+ conflict, so the write itself will normally go through — but what Engine then
543
+ does with a change made underneath it has never been measured here. Every
544
+ acceptance check of a write was run with Engine closed. What *has* been
545
+ measured is that Engine does its own work on the library as it loads: it
546
+ renumbers playlist entries, and it copies playlist changes to another
547
+ connected library (see below). Quit, write, relaunch — Engine reads the
548
+ library on startup and shows the change.
549
+
550
+ Quit, not close. On macOS, closing Engine's window leaves the application
551
+ running: observed 2026-09-01 with the main process and seven
552
+ `OfflineAnalyzer` workers — which write to the database — still alive
553
+ afterwards. Use ⌘Q.
554
+
555
+ ### An undo covers one library
556
+
557
+ Every write result carries a `library` field — the `uuid` and `path` of the
558
+ library the write actually landed in. Two libraries connected at once is the
559
+ ordinary setup: a USB drive and its copy on the computer. This is where you
560
+ check which of them a write went to.
561
+
562
+ **`undo` reverses the edit in that one library, and only there.** Each undo
563
+ step names it — by path, in the step's own `library` argument — so replaying
564
+ a step verbatim goes back to the library the edit was made in, not to whatever
565
+ the default is at replay time. That matters because a USB drive and its copy
566
+ hold the same playlist ids and the same track ids: a replay that resolved the
567
+ default could land on the wrong disk, and its `expect_track_ids` would agree,
568
+ both sides having been edited the same way.
569
+
570
+ Engine DJ moves playlist changes between connected libraries by itself, so a
571
+ copy of your edit can still end up somewhere `undo` cannot reach.
572
+
573
+ Measured 2026-09-01. A track was added to a playlist in the library on the
574
+ computer. Engine DJ was then launched with the USB drive attached, and the
575
+ same playlist on the USB came back with the same track added — the copy
576
+ carrying the very `lastEditTime` this server's `INSERT` had written. The
577
+ `undo` was then run and reversed the edit on the computer. The USB kept it.
578
+
579
+ Nothing was damaged: both libraries stayed sound. But the two had diverged,
580
+ and the `undo` reported success, correctly, because within its own library it
581
+ did exactly what it promised.
582
+
583
+ So: if a second library is connected, look at the `library` field, and undo
584
+ against each library separately. Undoing before Engine DJ next runs avoids
585
+ the problem entirely.
586
+
587
+ Which library a change propagates to, and in which direction, is Engine's own
588
+ business — this project does not model it and will not guess at it.
424
589
 
425
590
  ### Restoring a snapshot
426
591
 
@@ -431,12 +596,19 @@ Engine DJ has written since is discarded along with the one edit you wanted
431
596
  gone. Reach for it only if the library itself is damaged — the case where a
432
597
  write comes back with `detail: "committed_unverified"`.
433
598
 
599
+ Snapshots live in `~/.engine-dj-mcp/backups/`, ten per library. Only a name
600
+ ending in `.db` is a snapshot. A file ending in `.partial-<number>` — with or
601
+ without `-journal` after it — is a copy still being written, or one whose
602
+ process died before it finished: **never restore one of those**. A copy is
603
+ renamed to its `.db` name only once it is complete, and an abandoned one is
604
+ cleared the next time that library is snapshotted.
605
+
434
606
  **To undo a playlist you created, delete it in Engine DJ.** Engine's own
435
607
  delete trigger repairs the playlist chain and cascades the entries away,
436
608
  which is exactly what removing it should do and is not something restoring
437
- a snapshot does better. **To undo an edit to an existing playlist, replay
438
- the `undo` the edit returned instead** — it names the precise
439
- `add_tracks_to_playlist`, `remove_tracks_from_playlist` or
609
+ a snapshot does better. **For the playlist tools, to undo an edit to an
610
+ existing playlist, replay the `undo` the edit returned instead** — it names
611
+ the precise `add_tracks_to_playlist`, `remove_tracks_from_playlist` or
440
612
  `reorder_playlist` call that puts the playlist back exactly as it was,
441
613
  without touching anything else Engine DJ has recorded since.
442
614
 
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", "stale_value", "track_not_editable"];
2
2
  export type ErrorCode = (typeof ERROR_CODES)[number];
3
3
  export interface EngineError {
4
4
  error: ErrorCode;
@@ -24,6 +24,20 @@ export interface EngineError {
24
24
  * taken.
25
25
  */
26
26
  backup_path?: string;
27
+ /**
28
+ * Set only on stale_value: every `expect` that no longer matched, capped at
29
+ * 20 entries (the message carries the total). Structured so a caller can
30
+ * tell the user precisely which tracks and fields changed instead of
31
+ * parsing prose -- not so it can re-read and retry: the track changed
32
+ * after `expect` was read, and overwriting that silently, without the
33
+ * user's consent, is exactly what stale_value refuses.
34
+ */
35
+ mismatches?: {
36
+ id: number;
37
+ field: string;
38
+ expected: string | number | null;
39
+ actual: string | number | null;
40
+ }[];
27
41
  }
28
42
  export declare function err(error: ErrorCode, message: string, extra?: Omit<EngineError, "error" | "message">): EngineError;
29
43
  /**
package/dist/errors.js CHANGED
@@ -33,6 +33,20 @@ 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",
42
+ // update_track_metadata (spec §7.2). stale_value: an `expect` no longer
43
+ // matches what is in the library. track_not_editable: this track, or one
44
+ // field of it, cannot be edited without harm -- an empty origin the Track
45
+ // trigger would rewrite, or a stored value this tool could not restore.
46
+ // Not unknown_track: the track exists, and telling a model it does not sends
47
+ // it back to search for a track it will find again.
48
+ "stale_value",
49
+ "track_not_editable",
36
50
  ];
37
51
  export function err(error, message, extra = {}) {
38
52
  return { error, message, ...extra };
@@ -34,6 +34,39 @@ 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 libraries a write must be told apart between: every supported one, as
39
+ * soon as there is more than one. Empty when there is nothing to choose --
40
+ * one supported library, or none -- so a DJ with a single library never has
41
+ * to name it.
42
+ *
43
+ * This used to ask a narrower question: which libraries *tied* for the
44
+ * default pick on track count. That was wrong, and measurably so. A USB drive
45
+ * and its copy tie only until one of them gains a track; at 258 against 257
46
+ * the tie is gone and `pickDefaultLibrary` silently takes the larger. For a
47
+ * playlist that is at least visible on the wrong drive. For a track's genre
48
+ * it is invisible, and the DJ is left believing the edit did not work.
49
+ *
50
+ * The count was never the question. Which physical disk changes is the
51
+ * caller's to say, and only reads -- which change nothing -- may be spared
52
+ * the question.
53
+ */
54
+ export declare function writeNeedsLibrary(libs: readonly LibraryInfo[]): LibraryInfo[];
55
+ /**
56
+ * The refusal for an ambiguous default on a write.
57
+ *
58
+ * Every candidate is named, with its track count, because the caller has to
59
+ * pick one and cannot do that from "it was ambiguous" -- the same reason
60
+ * get_playlist_tracks lists candidates for an ambiguous playlist name.
61
+ *
62
+ * The list goes in `message`, never in `detail`. This is only ever returned
63
+ * from a write tool, and on that path `detail` carries exactly
64
+ * `"not_committed"` or `"committed_unverified"` -- a client reads it to
65
+ * decide whether its library changed. Prose there would break that read for
66
+ * the one error whose answer is least in doubt: nothing was opened, let alone
67
+ * written.
68
+ */
69
+ export declare function ambiguousLibrary(tied: readonly LibraryInfo[]): EngineError;
37
70
  /**
38
71
  * Resolves a caller-supplied `library` value: uuid first, then filesystem
39
72
  * 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. A READ may always omit it. A WRITE " +
18
+ "may omit it only when a single supported library is connected: with two or more, a " +
19
+ "write refuses with ambiguous_library listing them, since the choice decides which " +
20
+ "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,48 @@ export function pickDefaultLibrary(libs) {
47
50
  }
48
51
  return best ?? libs[0] ?? null;
49
52
  }
53
+ /**
54
+ * The libraries a write must be told apart between: every supported one, as
55
+ * soon as there is more than one. Empty when there is nothing to choose --
56
+ * one supported library, or none -- so a DJ with a single library never has
57
+ * to name it.
58
+ *
59
+ * This used to ask a narrower question: which libraries *tied* for the
60
+ * default pick on track count. That was wrong, and measurably so. A USB drive
61
+ * and its copy tie only until one of them gains a track; at 258 against 257
62
+ * the tie is gone and `pickDefaultLibrary` silently takes the larger. For a
63
+ * playlist that is at least visible on the wrong drive. For a track's genre
64
+ * it is invisible, and the DJ is left believing the edit did not work.
65
+ *
66
+ * The count was never the question. Which physical disk changes is the
67
+ * caller's to say, and only reads -- which change nothing -- may be spared
68
+ * the question.
69
+ */
70
+ export function writeNeedsLibrary(libs) {
71
+ const supported = libs.filter((l) => l.supported);
72
+ return supported.length > 1 ? supported : [];
73
+ }
74
+ /**
75
+ * The refusal for an ambiguous default on a write.
76
+ *
77
+ * Every candidate is named, with its track count, because the caller has to
78
+ * pick one and cannot do that from "it was ambiguous" -- the same reason
79
+ * get_playlist_tracks lists candidates for an ambiguous playlist name.
80
+ *
81
+ * The list goes in `message`, never in `detail`. This is only ever returned
82
+ * from a write tool, and on that path `detail` carries exactly
83
+ * `"not_committed"` or `"committed_unverified"` -- a client reads it to
84
+ * decide whether its library changed. Prose there would break that read for
85
+ * the one error whose answer is least in doubt: nothing was opened, let alone
86
+ * written.
87
+ */
88
+ export function ambiguousLibrary(tied) {
89
+ const list = tied.map((l) => `${l.uuid} -- ${redactPath(l.path)} (${l.trackCount} tracks)`).join("; ");
90
+ return err("ambiguous_library", `More than one library is connected, so there is no default to write to: ${list}. ` +
91
+ `Nothing was written. ASK which one to write to, then retry with \`library\` set -- ` +
92
+ `do not choose for them. These are usually a USB drive and its copy on the computer, ` +
93
+ `and one of them may be the drive they perform from.`, { detail: "not_committed" });
94
+ }
50
95
  /**
51
96
  * Resolves a caller-supplied `library` value: uuid first, then filesystem
52
97
  * 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)));