engine-dj-mcp 0.11.3 → 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 +263 -43
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +12 -0
- package/dist/library-select.d.ts +36 -0
- package/dist/library-select.js +53 -1
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +20 -0
- package/dist/playlists.d.ts +19 -5
- package/dist/playlists.js +32 -14
- package/dist/semantics.js +5 -0
- package/dist/server.js +207 -5
- package/dist/store/backup.d.ts +19 -1
- package/dist/store/backup.js +72 -4
- package/dist/store/write.d.ts +186 -0
- package/dist/store/write.js +936 -101
- 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 +24 -7
- package/dist/tools/tracks.js +2 -6
- package/dist/tools/write-playlist.d.ts +38 -1
- package/dist/tools/write-playlist.js +103 -1
- 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.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;
|
|
@@ -67,10 +71,11 @@ through 3.0.2 — Engine DJ 4.5 and 5.x.
|
|
|
67
71
|
|
|
68
72
|
## Tools
|
|
69
73
|
|
|
70
|
-
Nine read-only tools, and
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
Nine read-only tools, and four that write — `create_playlist`,
|
|
75
|
+
`add_tracks_to_playlist`, `remove_tracks_from_playlist` and
|
|
76
|
+
`reorder_playlist` — that appear only when you start the server with
|
|
77
|
+
`--allow-writes`. Every tool that reads library data also accepts an
|
|
78
|
+
optional `library` argument — see [Choosing a library](#choosing-a-library).
|
|
74
79
|
|
|
75
80
|
### `search_tracks`
|
|
76
81
|
|
|
@@ -155,7 +160,7 @@ Positions are sample offsets; cue and loop items also carry seconds.
|
|
|
155
160
|
|
|
156
161
|
### `audit_library`
|
|
157
162
|
|
|
158
|
-
|
|
163
|
+
Eleven collection health checks. Returns a count and a small sample of ids per
|
|
159
164
|
check, never the full result set — a library with thousands of unanalysed
|
|
160
165
|
tracks should not fill an assistant's context.
|
|
161
166
|
|
|
@@ -168,11 +173,12 @@ tracks should not fill an assistant's context.
|
|
|
168
173
|
| `no_beatgrid` | Tracks with no beatgrid data |
|
|
169
174
|
| `missing_key` | Tracks with no key detected |
|
|
170
175
|
| `suspicious_bpm` | Analysed and tagged tempo disagree, or tempo is outside 60–200 |
|
|
171
|
-
| `duplicates` | Same artist and title,
|
|
176
|
+
| `duplicates` | Same artist and title, compared regardless of case in any script |
|
|
172
177
|
| `empty_metadata` | No artist or no title |
|
|
173
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 |
|
|
174
180
|
|
|
175
|
-
`checks` — omit it to run all
|
|
181
|
+
`checks` — omit it to run all eleven.
|
|
176
182
|
|
|
177
183
|
### `run_sql`
|
|
178
184
|
|
|
@@ -204,7 +210,7 @@ server checks staleness itself before answering.
|
|
|
204
210
|
|
|
205
211
|
### `create_playlist`
|
|
206
212
|
|
|
207
|
-
The
|
|
213
|
+
The first of the four tools that write, none of which is registered at all
|
|
208
214
|
unless the server was started with `--allow-writes`.
|
|
209
215
|
|
|
210
216
|
Creates one new top-level playlist from track ids — `track_ids` sets both
|
|
@@ -223,19 +229,140 @@ Each entry stores the track's origin identity — `(originDatabaseUuid,
|
|
|
223
229
|
originTrackId)`, the pair Engine matches on — not the local row id, so a
|
|
224
230
|
playlist built here reads the same way Engine's own does.
|
|
225
231
|
|
|
226
|
-
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
|
|
227
233
|
it, delete the playlist in Engine DJ**; `backup_path` is a whole-library
|
|
228
234
|
snapshot for the case where something went wrong at a lower level, not an
|
|
229
235
|
undo — see [Restoring a snapshot](#restoring-a-snapshot).
|
|
230
236
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
the
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
|
|
241
|
+
### `add_tracks_to_playlist`
|
|
242
|
+
|
|
243
|
+
Adds one or more tracks to an **existing** playlist — this edits that
|
|
244
|
+
playlist's contents, it does not create a new one (`create_playlist` does
|
|
245
|
+
that). If the playlist's entry chain is already damaged, the write is
|
|
246
|
+
refused outright rather than repaired, and nothing is added.
|
|
247
|
+
|
|
248
|
+
A playlist that is a **folder** (`is_folder: true` — it has child lists) is
|
|
249
|
+
edited like any other: Engine has no separate folder type, a folder can hold
|
|
250
|
+
entries of its own, and all three edit tools add to, remove from and reorder
|
|
251
|
+
those entries without complaint. The lists inside it are untouched either
|
|
252
|
+
way.
|
|
253
|
+
|
|
254
|
+
| Argument | What it does |
|
|
255
|
+
| --- | --- |
|
|
256
|
+
| `playlist_id` / `playlist_name` | Exactly one of the two, resolved the same way `get_playlist_tracks` does: a name matching more than one playlist is refused with every candidate's id and full path listed, not guessed at. |
|
|
257
|
+
| `track_ids` | Ids from `search_tracks` or `get_tracks`, in the order they should appear. A track already in the playlist is refused as `duplicate_track` — Engine allows a track in a playlist only once. |
|
|
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
|
+
|
|
260
|
+
The result carries `playlist_id`, `tracks_added`, `positions` — where the
|
|
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
|
+
reverses this edit: the positions the tracks landed at, plus
|
|
264
|
+
`expect_track_ids` naming the tracks that landed there, so a playlist
|
|
265
|
+
something else changed in the meantime is refused rather than having the
|
|
266
|
+
wrong rows removed. Call it to undo rather than restoring `backup_path` —
|
|
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).
|
|
273
|
+
|
|
274
|
+
### `remove_tracks_from_playlist`
|
|
275
|
+
|
|
276
|
+
Removes one or more tracks from an **existing** playlist by position — this
|
|
277
|
+
edits that playlist's contents; it never touches any other playlist. If the
|
|
278
|
+
entry chain is already damaged, the write is refused outright rather than
|
|
279
|
+
repaired.
|
|
280
|
+
|
|
281
|
+
| Argument | What it does |
|
|
282
|
+
| --- | --- |
|
|
283
|
+
| `playlist_id` / `playlist_name` | Exactly one of the two, resolved the same way `get_playlist_tracks` does. |
|
|
284
|
+
| `positions` | 1-based positions `get_playlist_tracks` reports for this playlist right now. Includes entries whose track is missing from the library (`missing: true`) — removing one is a legitimate way to clean up a hole, and the one removal `undo` cannot reverse (see below). |
|
|
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". |
|
|
286
|
+
|
|
287
|
+
The result carries `playlist_id`, `tracks_removed`, `removed` — each
|
|
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,
|
|
290
|
+
one per removed track that can be restored. Run them in the order given,
|
|
291
|
+
never in parallel and never reversed — each step's target position is
|
|
292
|
+
computed against the list as it stands after the previous step has already
|
|
293
|
+
run, so firing them out of order puts tracks back in the wrong places.
|
|
294
|
+
Preferred over restoring `backup_path` for the same reason as above.
|
|
295
|
+
|
|
296
|
+
`undo_complete` is `false` when the removal included an entry whose track is
|
|
297
|
+
missing from the library: that entry named a track this library does not
|
|
298
|
+
have, so no `add_tracks_to_playlist` call can put it back, and an
|
|
299
|
+
`undo_note` names those positions. The steps that are returned still run and
|
|
300
|
+
still restore everything else; the missing entries are recoverable only from
|
|
301
|
+
`backup_path`, which reverts the whole library.
|
|
302
|
+
|
|
303
|
+
Its own refusals: `playlist_not_found`, `playlist_chain_damaged`, and
|
|
304
|
+
`invalid_position` — for a repeated or out-of-range position, or one that
|
|
305
|
+
does not hold what `expect_track_ids` expected — plus the ones
|
|
306
|
+
[every write tool shares](#refusals-every-write-tool-shares).
|
|
307
|
+
|
|
308
|
+
`playlist_chain_damaged` always means the same thing for all three edit
|
|
309
|
+
tools: the playlist's entry chain was already broken **before** the edit,
|
|
310
|
+
which is why the edit refused to touch it. If instead the check each edit
|
|
311
|
+
runs on its own work disagrees — the chain did not read back as it was
|
|
312
|
+
written — the transaction is rolled back and that comes back as
|
|
313
|
+
`library_unreadable`, with `detail: "not_committed"`. Both leave the library
|
|
314
|
+
exactly as it was; only the second one is this server saying it does not
|
|
315
|
+
understand what the library just did.
|
|
316
|
+
|
|
317
|
+
### `reorder_playlist`
|
|
318
|
+
|
|
319
|
+
Reorders an **existing** playlist's tracks — this changes the order of that
|
|
320
|
+
playlist's existing entries; it adds nothing and removes nothing. If the
|
|
321
|
+
entry chain is already damaged, the write is refused outright rather than
|
|
322
|
+
repaired.
|
|
323
|
+
|
|
324
|
+
| Argument | What it does |
|
|
325
|
+
| --- | --- |
|
|
326
|
+
| `playlist_id` / `playlist_name` | Exactly one of the two, resolved the same way `get_playlist_tracks` does. |
|
|
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. |
|
|
328
|
+
|
|
329
|
+
The result carries `playlist_id`, `undo`, `undo_complete` (always `true`
|
|
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`,
|
|
332
|
+
`playlist_chain_damaged`, and `invalid_position` if `order` is not a full
|
|
333
|
+
permutation of the playlist's current positions — plus the ones
|
|
334
|
+
[every write tool shares](#refusals-every-write-tool-shares).
|
|
335
|
+
|
|
336
|
+
Reordering to the order a playlist is already in is accepted and rewrites no
|
|
337
|
+
entry: it still stamps the playlist's `lastEditTime`, and still costs this
|
|
338
|
+
session's snapshot if nothing had been written yet.
|
|
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.
|
|
239
366
|
|
|
240
367
|
## Resources
|
|
241
368
|
|
|
@@ -260,6 +387,26 @@ tracks**. That matters: the local library Engine DJ creates on install is
|
|
|
260
387
|
scanned first and is often empty, so "the first one found" would hide the
|
|
261
388
|
drive you actually work from.
|
|
262
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
|
+
|
|
263
410
|
Each library gets its own index and its own connection, opened the first time
|
|
264
411
|
you ask that library something. Comparing two libraries against each other —
|
|
265
412
|
*"what is on this drive but not that one?"* — is **not** something this server
|
|
@@ -278,11 +425,32 @@ created inside your `Engine Library` folder. The search index lives in
|
|
|
278
425
|
Without `--allow-writes` the server has no tool that can write, and the
|
|
279
426
|
paragraph above holds exactly as written: SQLite itself refuses.
|
|
280
427
|
|
|
281
|
-
With the flag,
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
428
|
+
With the flag, four tools appear. `create_playlist` adds a new playlist and
|
|
429
|
+
nothing else. `add_tracks_to_playlist`, `remove_tracks_from_playlist` and
|
|
430
|
+
`reorder_playlist` go further: with the flag, an **existing** playlist can
|
|
431
|
+
now be changed, not only created — its tracks added to, removed from, or put
|
|
432
|
+
in a different order. What each one touches is the named playlist's own
|
|
433
|
+
entries, plus exactly two rows elsewhere: that playlist's own row, whose
|
|
434
|
+
`lastEditTime` every edit stamps so Engine sees the change, and — for
|
|
435
|
+
`create_playlist` only — the previous last playlist's link, made by Engine's
|
|
436
|
+
own insert trigger. No other playlist is renamed, emptied or deleted, and no
|
|
437
|
+
track, cue or beatgrid is touched by any of the four.
|
|
438
|
+
|
|
439
|
+
Every edit returns `undo` — the exact tool call that reverses it, expressed
|
|
440
|
+
against the positions the edit itself produced — and `undo_complete`, saying
|
|
441
|
+
whether replaying it puts the playlist back exactly as it was. Replaying
|
|
442
|
+
`undo` is the right way back from an edit; restoring `backup_path` is not,
|
|
443
|
+
because it reverts the **whole library** to before this session's first
|
|
444
|
+
write, discarding every play count, import, cue and beatgrid change Engine
|
|
445
|
+
DJ has recorded since, along with the one edit you actually wanted undone.
|
|
446
|
+
See [Restoring a snapshot](#restoring-a-snapshot).
|
|
447
|
+
|
|
448
|
+
There is exactly one edit `undo` cannot reverse, and it says so rather than
|
|
449
|
+
pretending otherwise: removing an entry whose track is missing from the
|
|
450
|
+
library (`missing: true`). Such an entry names a track this library does not
|
|
451
|
+
have, so there is no track id to add back — the result comes back with
|
|
452
|
+
`undo_complete: false` and an `undo_note` naming those positions, and the
|
|
453
|
+
steps it does return still restore everything else.
|
|
286
454
|
|
|
287
455
|
Before the first write of a session the database is snapshotted to
|
|
288
456
|
`~/.engine-dj-mcp/backups/`, and every write of that session returns its
|
|
@@ -302,23 +470,74 @@ and without `--allow-writes` not even this.
|
|
|
302
470
|
The write takes SQLite's own write lock for the length of one transaction and
|
|
303
471
|
does not wait for it: if something else — Engine DJ mid-save, a player — is
|
|
304
472
|
holding a conflicting lock at that moment, the write is refused with
|
|
305
|
-
`library_busy` and nothing is changed.
|
|
306
|
-
|
|
307
|
-
Engine
|
|
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.
|
|
308
516
|
|
|
309
517
|
### Restoring a snapshot
|
|
310
518
|
|
|
311
519
|
`backup_path` is not an undo. It is a copy of the **whole** `m.db` from
|
|
312
520
|
before the session's first write, so putting it back reverts the entire
|
|
313
521
|
library to that moment: every play count, import, cue, beatgrid and rating
|
|
314
|
-
Engine DJ has written since is discarded along with the
|
|
315
|
-
gone. Reach for it only if the library itself is damaged — the case where
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
522
|
+
Engine DJ has written since is discarded along with the one edit you wanted
|
|
523
|
+
gone. Reach for it only if the library itself is damaged — the case where a
|
|
524
|
+
write comes back with `detail: "committed_unverified"`.
|
|
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
|
+
|
|
533
|
+
**To undo a playlist you created, delete it in Engine DJ.** Engine's own
|
|
534
|
+
delete trigger repairs the playlist chain and cascades the entries away,
|
|
535
|
+
which is exactly what removing it should do and is not something restoring
|
|
536
|
+
a snapshot does better. **To undo an edit to an existing playlist, replay
|
|
537
|
+
the `undo` the edit returned instead** — it names the precise
|
|
538
|
+
`add_tracks_to_playlist`, `remove_tracks_from_playlist` or
|
|
539
|
+
`reorder_playlist` call that puts the playlist back exactly as it was,
|
|
540
|
+
without touching anything else Engine DJ has recorded since.
|
|
322
541
|
|
|
323
542
|
`run_sql` accepts arbitrary SQL, but only the first statement is ever
|
|
324
543
|
executed, and `VACUUM`, `ATTACH` and `DETACH` are rejected outright, so a
|
|
@@ -327,7 +546,7 @@ chained or exfiltrating statement cannot slip past the read-only connection.
|
|
|
327
546
|
If Engine DJ was closed uncleanly and left an unrecovered journal, this
|
|
328
547
|
server will not open the library to "fix" it, with or without
|
|
329
548
|
`--allow-writes` — rolling a journal forward is a repair on someone else's
|
|
330
|
-
file, and
|
|
549
|
+
file, and every write tool refuses such a library outright rather than
|
|
331
550
|
letting SQLite do it on the way in. It reports `library_needs_recovery` and
|
|
332
551
|
asks you to launch Engine DJ once so it can recover its own library.
|
|
333
552
|
|
|
@@ -374,9 +593,9 @@ still test for the blob: `beatData` has no "written but empty" state.
|
|
|
374
593
|
|
|
375
594
|
**It writes nothing but playlists, and only when you ask for it.** Without
|
|
376
595
|
`--allow-writes` the library is opened read-only at the OS level and there is
|
|
377
|
-
no tool that could write. With the flag,
|
|
378
|
-
and that is the whole list. Not a cue, not a tag, not a
|
|
379
|
-
the recovery of a journal Engine DJ left behind.
|
|
596
|
+
no tool that could write. With the flag, the four write tools add, edit and
|
|
597
|
+
reorder playlists — and that is the whole list. Not a cue, not a tag, not a
|
|
598
|
+
rating, and not even the recovery of a journal Engine DJ left behind.
|
|
380
599
|
|
|
381
600
|
**It does not read play history.** `Track.timeLastPlayed` answers "what have I
|
|
382
601
|
not played in six months?", but the separate Engine history database —
|
|
@@ -392,11 +611,12 @@ flag — a folder is simply a playlist that other playlists sit under — so
|
|
|
392
611
|
`is_folder` means "has child lists". A folder you have emptied is
|
|
393
612
|
indistinguishable from a playlist with no tracks.
|
|
394
613
|
|
|
395
|
-
**
|
|
396
|
-
playlist can be
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
614
|
+
**A playlist's tracks can be edited; the playlist itself cannot.** With
|
|
615
|
+
`--allow-writes` a new playlist can be created, and an existing one can have
|
|
616
|
+
tracks added, removed or reordered — but not renamed, deleted, moved between
|
|
617
|
+
folders, or turned into a folder itself, and there are no set lists or
|
|
618
|
+
suggested transitions. It answers questions about the collection and writes
|
|
619
|
+
down the answer if you ask; the mixing is yours.
|
|
400
620
|
|
|
401
621
|
**Schema 3.0.0 through 3.0.2 only.** Older and newer libraries are listed with
|
|
402
622
|
their version and reported as unsupported rather than read on a guess.
|
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"];
|
|
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
|
@@ -27,6 +27,18 @@ export const ERROR_CODES = [
|
|
|
27
27
|
"playlist_exists",
|
|
28
28
|
"unknown_track",
|
|
29
29
|
"duplicate_track",
|
|
30
|
+
// Editing an existing playlist. playlist_chain_damaged is the one that
|
|
31
|
+
// matters: a chain with a cycle or a severed link cannot be edited without
|
|
32
|
+
// making it worse, and the edit would not notice.
|
|
33
|
+
"playlist_chain_damaged",
|
|
34
|
+
"playlist_not_found",
|
|
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",
|
|
30
42
|
];
|
|
31
43
|
export function err(error, message, extra = {}) {
|
|
32
44
|
return { error, message, ...extra };
|
package/dist/library-select.d.ts
CHANGED
|
@@ -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
|
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. 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/playlists.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type EngineError } from "./errors.js";
|
|
1
|
+
import { type EngineError, type ErrorCode } from "./errors.js";
|
|
2
2
|
import type { QueryProcess } from "./proc/query-client.js";
|
|
3
3
|
/**
|
|
4
4
|
* Engine stores both playlist order and playlist-entry order as singly
|
|
@@ -191,6 +191,16 @@ export interface PlaylistSelector {
|
|
|
191
191
|
export interface SelectorNames {
|
|
192
192
|
id: string;
|
|
193
193
|
name: string;
|
|
194
|
+
/**
|
|
195
|
+
* The code for "this library has no such playlist", when the caller has a
|
|
196
|
+
* more accurate one than the default `invalid_argument`. The write tools
|
|
197
|
+
* pass `playlist_not_found`, which their own store functions already
|
|
198
|
+
* return for an id that reaches them -- without this, that code was
|
|
199
|
+
* unreachable through MCP, because resolution runs first and every miss
|
|
200
|
+
* came back as invalid_argument. Ambiguity is never reported through this:
|
|
201
|
+
* a name matching several playlists really is a problem with the argument.
|
|
202
|
+
*/
|
|
203
|
+
notFound?: ErrorCode;
|
|
194
204
|
}
|
|
195
205
|
export interface ResolvedPlaylist {
|
|
196
206
|
playlist: PlaylistItem;
|
|
@@ -205,10 +215,14 @@ export interface ResolvedPlaylist {
|
|
|
205
215
|
* whole function exists to prevent. The error names every candidate with its
|
|
206
216
|
* id and its full path, so the retry is a copy-paste rather than a guess.
|
|
207
217
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* in this library" is a problem with the argument, reported the
|
|
211
|
-
* unknown field name is -- with the recognised values in
|
|
218
|
+
* Reuses `invalid_argument` by default rather than adding an error code: the
|
|
219
|
+
* taxonomy is closed (see errors.ts), and for a reader "the playlist you
|
|
220
|
+
* named is not in this library" is a problem with the argument, reported the
|
|
221
|
+
* same way an unknown field name is -- with the recognised values in
|
|
222
|
+
* `detail`. A caller that already owns a more accurate code for that one
|
|
223
|
+
* case passes it as `names.notFound`; the write tools do, so their documented
|
|
224
|
+
* `playlist_not_found` is what a client actually sees. Ambiguity is never
|
|
225
|
+
* reported through it.
|
|
212
226
|
*/
|
|
213
227
|
export declare function resolvePlaylist(qp: QueryProcess, sel: PlaylistSelector, names?: SelectorNames): Promise<ResolvedPlaylist | EngineError>;
|
|
214
228
|
/** One entry of a playlist, in playlist order. */
|