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 +216 -44
- package/dist/errors.d.ts +15 -1
- package/dist/errors.js +14 -0
- package/dist/library-select.d.ts +33 -0
- package/dist/library-select.js +46 -1
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +20 -0
- package/dist/semantics.js +5 -0
- package/dist/server.js +178 -9
- package/dist/store/backup.d.ts +19 -1
- package/dist/store/backup.js +72 -4
- package/dist/store/track-metadata-plan.d.ts +82 -0
- package/dist/store/track-metadata-plan.js +236 -0
- package/dist/store/track-metadata.d.ts +22 -0
- package/dist/store/track-metadata.js +143 -0
- package/dist/store/write.d.ts +100 -0
- package/dist/store/write.js +44 -10
- package/dist/tools/audit.d.ts +29 -1
- package/dist/tools/audit.js +82 -3
- package/dist/tools/playlists.js +2 -11
- package/dist/tools/search.d.ts +7 -0
- package/dist/tools/search.js +35 -9
- package/dist/tools/tracks.js +2 -6
- package/dist/tools/write-track-metadata.d.ts +26 -0
- package/dist/tools/write-track-metadata.js +42 -0
- package/package.json +1 -1
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
the
|
|
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)
|
|
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)
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
|
381
|
-
entries, plus exactly two rows elsewhere: that playlist's own
|
|
382
|
-
`lastEditTime` every edit stamps so Engine sees the change, and —
|
|
383
|
-
`create_playlist` only — the previous last playlist's link, made by
|
|
384
|
-
own insert trigger. No other playlist is renamed, emptied or deleted,
|
|
385
|
-
track, cue or beatgrid is touched by
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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.
|
|
422
|
-
|
|
423
|
-
Engine
|
|
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. **
|
|
438
|
-
the `undo` the edit returned instead** — it names
|
|
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 };
|
package/dist/library-select.d.ts
CHANGED
|
@@ -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
|
package/dist/library-select.js
CHANGED
|
@@ -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)));
|