engine-dj-mcp 0.11.2 → 0.12.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 +152 -28
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +6 -0
- package/dist/playlists.d.ts +19 -5
- package/dist/playlists.js +32 -14
- package/dist/server.js +115 -2
- package/dist/store/backup.d.ts +24 -0
- package/dist/store/backup.js +54 -15
- package/dist/store/write.d.ts +169 -0
- package/dist/store/write.js +930 -101
- package/dist/tools/write-playlist.d.ts +38 -1
- package/dist/tools/write-playlist.js +103 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# engine-dj-mcp
|
|
2
2
|
|
|
3
|
+
[](https://github.com/Venut-Labs/engine-dj-mcp/actions/workflows/ci.yml)
|
|
3
4
|
[](https://www.npmjs.com/package/engine-dj-mcp)
|
|
4
5
|
[](./LICENSE)
|
|
5
6
|
|
|
@@ -58,16 +59,19 @@ the configuration you are reading:
|
|
|
58
59
|
}
|
|
59
60
|
```
|
|
60
61
|
|
|
61
|
-
**Requirements:** Node.js 22.
|
|
62
|
+
**Requirements:** Node.js 22.16 or newer (`node:sqlite` stopped needing a
|
|
63
|
+
flag in 22.13, but the pre-write snapshot uses its `backup()`, added in
|
|
64
|
+
22.16;
|
|
62
65
|
there are no native dependencies), and an Engine DJ library at schema 3.0.0
|
|
63
66
|
through 3.0.2 — Engine DJ 4.5 and 5.x.
|
|
64
67
|
|
|
65
68
|
## Tools
|
|
66
69
|
|
|
67
|
-
Nine read-only tools, and
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
Nine read-only tools, and four that write — `create_playlist`,
|
|
71
|
+
`add_tracks_to_playlist`, `remove_tracks_from_playlist` and
|
|
72
|
+
`reorder_playlist` — that appear only when you start the server with
|
|
73
|
+
`--allow-writes`. Every tool that reads library data also accepts an
|
|
74
|
+
optional `library` argument — see [Choosing a library](#choosing-a-library).
|
|
71
75
|
|
|
72
76
|
### `search_tracks`
|
|
73
77
|
|
|
@@ -201,7 +205,7 @@ server checks staleness itself before answering.
|
|
|
201
205
|
|
|
202
206
|
### `create_playlist`
|
|
203
207
|
|
|
204
|
-
The
|
|
208
|
+
The first of the four tools that write, none of which is registered at all
|
|
205
209
|
unless the server was started with `--allow-writes`.
|
|
206
210
|
|
|
207
211
|
Creates one new top-level playlist from track ids — `track_ids` sets both
|
|
@@ -234,6 +238,100 @@ library is exactly what it was, and `committed_unverified` — the rare one —
|
|
|
234
238
|
means the write may have landed but could not be verified afterwards, and is
|
|
235
239
|
the only case that hands back a `backup_path`.
|
|
236
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) and
|
|
262
|
+
`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). Refusals add
|
|
268
|
+
`playlist_not_found`, `playlist_chain_damaged` and `invalid_position` to
|
|
269
|
+
`create_playlist`'s own list; `detail` works the same way.
|
|
270
|
+
|
|
271
|
+
### `remove_tracks_from_playlist`
|
|
272
|
+
|
|
273
|
+
Removes one or more tracks from an **existing** playlist by position — this
|
|
274
|
+
edits that playlist's contents; it never touches any other playlist. If the
|
|
275
|
+
entry chain is already damaged, the write is refused outright rather than
|
|
276
|
+
repaired.
|
|
277
|
+
|
|
278
|
+
| Argument | What it does |
|
|
279
|
+
| --- | --- |
|
|
280
|
+
| `playlist_id` / `playlist_name` | Exactly one of the two, resolved the same way `get_playlist_tracks` does. |
|
|
281
|
+
| `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). |
|
|
282
|
+
| `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
|
+
|
|
284
|
+
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,
|
|
287
|
+
one per removed track that can be restored. Run them in the order given,
|
|
288
|
+
never in parallel and never reversed — each step's target position is
|
|
289
|
+
computed against the list as it stands after the previous step has already
|
|
290
|
+
run, so firing them out of order puts tracks back in the wrong places.
|
|
291
|
+
Preferred over restoring `backup_path` for the same reason as above.
|
|
292
|
+
|
|
293
|
+
`undo_complete` is `false` when the removal included an entry whose track is
|
|
294
|
+
missing from the library: that entry named a track this library does not
|
|
295
|
+
have, so no `add_tracks_to_playlist` call can put it back, and an
|
|
296
|
+
`undo_note` names those positions. The steps that are returned still run and
|
|
297
|
+
still restore everything else; the missing entries are recoverable only from
|
|
298
|
+
`backup_path`, which reverts the whole library.
|
|
299
|
+
|
|
300
|
+
Refusals: `playlist_not_found`, `playlist_chain_damaged`, and
|
|
301
|
+
`invalid_position` — for a repeated or out-of-range position, or one that
|
|
302
|
+
does not hold what `expect_track_ids` expected.
|
|
303
|
+
|
|
304
|
+
`playlist_chain_damaged` always means the same thing for all three edit
|
|
305
|
+
tools: the playlist's entry chain was already broken **before** the edit,
|
|
306
|
+
which is why the edit refused to touch it. If instead the check each edit
|
|
307
|
+
runs on its own work disagrees — the chain did not read back as it was
|
|
308
|
+
written — the transaction is rolled back and that comes back as
|
|
309
|
+
`library_unreadable`, with `detail: "not_committed"`. Both leave the library
|
|
310
|
+
exactly as it was; only the second one is this server saying it does not
|
|
311
|
+
understand what the library just did.
|
|
312
|
+
|
|
313
|
+
### `reorder_playlist`
|
|
314
|
+
|
|
315
|
+
Reorders an **existing** playlist's tracks — this changes the order of that
|
|
316
|
+
playlist's existing entries; it adds nothing and removes nothing. If the
|
|
317
|
+
entry chain is already damaged, the write is refused outright rather than
|
|
318
|
+
repaired.
|
|
319
|
+
|
|
320
|
+
| Argument | What it does |
|
|
321
|
+
| --- | --- |
|
|
322
|
+
| `playlist_id` / `playlist_name` | Exactly one of the two, resolved the same way `get_playlist_tracks` does. |
|
|
323
|
+
| `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
|
+
|
|
325
|
+
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`,
|
|
328
|
+
`playlist_chain_damaged`, and `invalid_position` if `order` is not a full
|
|
329
|
+
permutation of the playlist's current positions.
|
|
330
|
+
|
|
331
|
+
Reordering to the order a playlist is already in is accepted and rewrites no
|
|
332
|
+
entry: it still stamps the playlist's `lastEditTime`, and still costs this
|
|
333
|
+
session's snapshot if nothing had been written yet.
|
|
334
|
+
|
|
237
335
|
## Resources
|
|
238
336
|
|
|
239
337
|
- **`engine://schema`** — the field semantics an assistant needs before
|
|
@@ -275,11 +373,32 @@ created inside your `Engine Library` folder. The search index lives in
|
|
|
275
373
|
Without `--allow-writes` the server has no tool that can write, and the
|
|
276
374
|
paragraph above holds exactly as written: SQLite itself refuses.
|
|
277
375
|
|
|
278
|
-
With the flag,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
376
|
+
With the flag, four tools appear. `create_playlist` adds a new playlist and
|
|
377
|
+
nothing else. `add_tracks_to_playlist`, `remove_tracks_from_playlist` and
|
|
378
|
+
`reorder_playlist` go further: with the flag, an **existing** playlist can
|
|
379
|
+
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
|
|
390
|
+
`undo` is the right way back from an edit; restoring `backup_path` is not,
|
|
391
|
+
because it reverts the **whole library** to before this session's first
|
|
392
|
+
write, discarding every play count, import, cue and beatgrid change Engine
|
|
393
|
+
DJ has recorded since, along with the one edit you actually wanted undone.
|
|
394
|
+
See [Restoring a snapshot](#restoring-a-snapshot).
|
|
395
|
+
|
|
396
|
+
There is exactly one edit `undo` cannot reverse, and it says so rather than
|
|
397
|
+
pretending otherwise: removing an entry whose track is missing from the
|
|
398
|
+
library (`missing: true`). Such an entry names a track this library does not
|
|
399
|
+
have, so there is no track id to add back — the result comes back with
|
|
400
|
+
`undo_complete: false` and an `undo_note` naming those positions, and the
|
|
401
|
+
steps it does return still restore everything else.
|
|
283
402
|
|
|
284
403
|
Before the first write of a session the database is snapshotted to
|
|
285
404
|
`~/.engine-dj-mcp/backups/`, and every write of that session returns its
|
|
@@ -308,14 +427,18 @@ Engine will show the new playlist after it next re-reads the library.
|
|
|
308
427
|
`backup_path` is not an undo. It is a copy of the **whole** `m.db` from
|
|
309
428
|
before the session's first write, so putting it back reverts the entire
|
|
310
429
|
library to that moment: every play count, import, cue, beatgrid and rating
|
|
311
|
-
Engine DJ has written since is discarded along with the
|
|
312
|
-
gone. Reach for it only if the library itself is damaged — the case where
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
**To undo a playlist, delete it in Engine DJ.** Engine's own
|
|
316
|
-
repairs the playlist chain and cascades the entries away,
|
|
317
|
-
what removing it should do and is not something restoring
|
|
318
|
-
better.
|
|
430
|
+
Engine DJ has written since is discarded along with the one edit you wanted
|
|
431
|
+
gone. Reach for it only if the library itself is damaged — the case where a
|
|
432
|
+
write comes back with `detail: "committed_unverified"`.
|
|
433
|
+
|
|
434
|
+
**To undo a playlist you created, delete it in Engine DJ.** Engine's own
|
|
435
|
+
delete trigger repairs the playlist chain and cascades the entries away,
|
|
436
|
+
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
|
|
440
|
+
`reorder_playlist` call that puts the playlist back exactly as it was,
|
|
441
|
+
without touching anything else Engine DJ has recorded since.
|
|
319
442
|
|
|
320
443
|
`run_sql` accepts arbitrary SQL, but only the first statement is ever
|
|
321
444
|
executed, and `VACUUM`, `ATTACH` and `DETACH` are rejected outright, so a
|
|
@@ -324,7 +447,7 @@ chained or exfiltrating statement cannot slip past the read-only connection.
|
|
|
324
447
|
If Engine DJ was closed uncleanly and left an unrecovered journal, this
|
|
325
448
|
server will not open the library to "fix" it, with or without
|
|
326
449
|
`--allow-writes` — rolling a journal forward is a repair on someone else's
|
|
327
|
-
file, and
|
|
450
|
+
file, and every write tool refuses such a library outright rather than
|
|
328
451
|
letting SQLite do it on the way in. It reports `library_needs_recovery` and
|
|
329
452
|
asks you to launch Engine DJ once so it can recover its own library.
|
|
330
453
|
|
|
@@ -371,9 +494,9 @@ still test for the blob: `beatData` has no "written but empty" state.
|
|
|
371
494
|
|
|
372
495
|
**It writes nothing but playlists, and only when you ask for it.** Without
|
|
373
496
|
`--allow-writes` the library is opened read-only at the OS level and there is
|
|
374
|
-
no tool that could write. With the flag,
|
|
375
|
-
and that is the whole list. Not a cue, not a tag, not a
|
|
376
|
-
the recovery of a journal Engine DJ left behind.
|
|
497
|
+
no tool that could write. With the flag, the four write tools add, edit and
|
|
498
|
+
reorder playlists — and that is the whole list. Not a cue, not a tag, not a
|
|
499
|
+
rating, and not even the recovery of a journal Engine DJ left behind.
|
|
377
500
|
|
|
378
501
|
**It does not read play history.** `Track.timeLastPlayed` answers "what have I
|
|
379
502
|
not played in six months?", but the separate Engine history database —
|
|
@@ -389,11 +512,12 @@ flag — a folder is simply a playlist that other playlists sit under — so
|
|
|
389
512
|
`is_folder` means "has child lists". A folder you have emptied is
|
|
390
513
|
indistinguishable from a playlist with no tracks.
|
|
391
514
|
|
|
392
|
-
**
|
|
393
|
-
playlist can be
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
515
|
+
**A playlist's tracks can be edited; the playlist itself cannot.** With
|
|
516
|
+
`--allow-writes` a new playlist can be created, and an existing one can have
|
|
517
|
+
tracks added, removed or reordered — but not renamed, deleted, moved between
|
|
518
|
+
folders, or turned into a folder itself, and there are no set lists or
|
|
519
|
+
suggested transitions. It answers questions about the collection and writes
|
|
520
|
+
down the answer if you ask; the mixing is yours.
|
|
397
521
|
|
|
398
522
|
**Schema 3.0.0 through 3.0.2 only.** Older and newer libraries are listed with
|
|
399
523
|
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"];
|
|
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,12 @@ 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",
|
|
30
36
|
];
|
|
31
37
|
export function err(error, message, extra = {}) {
|
|
32
38
|
return { error, message, ...extra };
|
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. */
|
package/dist/playlists.js
CHANGED
|
@@ -353,6 +353,28 @@ function describe(items) {
|
|
|
353
353
|
? `${shown}; and ${items.length - NAMED_IN_ERROR} more (call get_playlists to see them all)`
|
|
354
354
|
: shown;
|
|
355
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* "No such playlist", for both branches resolvePlaylist can reach it from.
|
|
358
|
+
*
|
|
359
|
+
* The default `invalid_argument` code has no write-path contract on
|
|
360
|
+
* `detail` (see errors.ts), so the candidate listing lives there, exactly as
|
|
361
|
+
* before. `playlist_not_found` is different: a write tool's caller reads
|
|
362
|
+
* `detail` to learn whether the library changed (see errors.ts), and every
|
|
363
|
+
* other `playlist_not_found` -- raised inside store/write.ts once an id
|
|
364
|
+
* reaches it -- carries `detail: "not_committed"`. Nothing was attempted
|
|
365
|
+
* here either, so this must match, which leaves no room in `detail` for the
|
|
366
|
+
* candidate listing; it moves into `message` instead, so a human still sees
|
|
367
|
+
* it.
|
|
368
|
+
*/
|
|
369
|
+
function notFoundError(names, reason, items) {
|
|
370
|
+
const candidates = items.length
|
|
371
|
+
? `Playlists (id -- path): ${describe(items)}`
|
|
372
|
+
: "This library has no playlists.";
|
|
373
|
+
if (names.notFound) {
|
|
374
|
+
return err(names.notFound, `${reason}. ${candidates}`, { detail: "not_committed" });
|
|
375
|
+
}
|
|
376
|
+
return err("invalid_argument", reason, { detail: candidates });
|
|
377
|
+
}
|
|
356
378
|
/**
|
|
357
379
|
* Turns "id or name" into one specific playlist, or an actionable error.
|
|
358
380
|
*
|
|
@@ -361,10 +383,14 @@ function describe(items) {
|
|
|
361
383
|
* whole function exists to prevent. The error names every candidate with its
|
|
362
384
|
* id and its full path, so the retry is a copy-paste rather than a guess.
|
|
363
385
|
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
* in this library" is a problem with the argument, reported the
|
|
367
|
-
* unknown field name is -- with the recognised values in
|
|
386
|
+
* Reuses `invalid_argument` by default rather than adding an error code: the
|
|
387
|
+
* taxonomy is closed (see errors.ts), and for a reader "the playlist you
|
|
388
|
+
* named is not in this library" is a problem with the argument, reported the
|
|
389
|
+
* same way an unknown field name is -- with the recognised values in
|
|
390
|
+
* `detail`. A caller that already owns a more accurate code for that one
|
|
391
|
+
* case passes it as `names.notFound`; the write tools do, so their documented
|
|
392
|
+
* `playlist_not_found` is what a client actually sees. Ambiguity is never
|
|
393
|
+
* reported through it.
|
|
368
394
|
*/
|
|
369
395
|
export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name: "playlist_name" }) {
|
|
370
396
|
const hasId = sel.id !== undefined && sel.id !== null;
|
|
@@ -385,11 +411,7 @@ export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name
|
|
|
385
411
|
if (hasId) {
|
|
386
412
|
const found = tree.items.find((i) => i.id === sel.id);
|
|
387
413
|
if (!found) {
|
|
388
|
-
return
|
|
389
|
-
detail: tree.items.length
|
|
390
|
-
? `Playlists (id -- path): ${describe(tree.items)}`
|
|
391
|
-
: "This library has no playlists.",
|
|
392
|
-
});
|
|
414
|
+
return notFoundError(names, `No playlist with ${names.id} ${sel.id} in this library`, tree.items);
|
|
393
415
|
}
|
|
394
416
|
return { playlist: found, warnings: tree.warnings };
|
|
395
417
|
}
|
|
@@ -397,11 +419,7 @@ export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name
|
|
|
397
419
|
if (matches.length === 1)
|
|
398
420
|
return { playlist: matches[0], warnings: tree.warnings };
|
|
399
421
|
if (matches.length === 0) {
|
|
400
|
-
return
|
|
401
|
-
detail: tree.items.length
|
|
402
|
-
? `Playlists (id -- path): ${describe(tree.items)}`
|
|
403
|
-
: "This library has no playlists.",
|
|
404
|
-
});
|
|
422
|
+
return notFoundError(names, `No playlist named "${sel.name}" in this library`, tree.items);
|
|
405
423
|
}
|
|
406
424
|
return err("invalid_argument", `"${sel.name}" names ${matches.length} playlists in this library`, {
|
|
407
425
|
detail: `Playlist names are unique only within a folder. Pass ${names.id}, or pass the full ` +
|
package/dist/server.js
CHANGED
|
@@ -17,10 +17,24 @@ import { auditLibrary, AuditInput, AUDIT_CHECKS } from "./tools/audit.js";
|
|
|
17
17
|
import { runSql, RunSqlInput } from "./tools/sql.js";
|
|
18
18
|
import { listLibraries } from "./tools/libraries.js";
|
|
19
19
|
import { refreshIndex } from "./tools/refresh.js";
|
|
20
|
-
import { CreatePlaylistInput, runCreatePlaylist } from "./tools/write-playlist.js";
|
|
20
|
+
import { CreatePlaylistInput, runCreatePlaylist, AddTracksToPlaylistInput, runAddTracksToPlaylist, RemoveTracksFromPlaylistInput, runRemoveTracksFromPlaylist, ReorderPlaylistInput, runReorderPlaylist, } from "./tools/write-playlist.js";
|
|
21
21
|
import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
|
|
22
22
|
const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
|
|
23
|
+
/**
|
|
24
|
+
* A write that only ever *adds*. `destructiveHint: false` is a claim with a
|
|
25
|
+
* defined meaning in MCP -- "this tool performs only additive updates" -- and
|
|
26
|
+
* clients use it to decide whether to confirm with the user first. True of
|
|
27
|
+
* create_playlist (a new playlist, nothing else touched) and of
|
|
28
|
+
* add_tracks_to_playlist (new entries, existing ones left where they are).
|
|
29
|
+
*/
|
|
23
30
|
const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false };
|
|
31
|
+
/**
|
|
32
|
+
* A write that can destroy or reorganise what is already there:
|
|
33
|
+
* remove_tracks_from_playlist deletes entries, reorder_playlist rewrites the
|
|
34
|
+
* order of a list a DJ may be playing from live. Advertising either as
|
|
35
|
+
* additive told a client it need not ask before calling.
|
|
36
|
+
*/
|
|
37
|
+
const RW_DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false };
|
|
24
38
|
/**
|
|
25
39
|
* name/version reported to every client on initialize. Read from
|
|
26
40
|
* package.json rather than typed here, so the two cannot re-diverge the way
|
|
@@ -429,7 +443,9 @@ export async function createServer(opts = {}) {
|
|
|
429
443
|
"write of this session; it is a recovery route for a damaged library, NOT an undo. " +
|
|
430
444
|
"Restoring it reverts the entire library to that moment, discarding everything " +
|
|
431
445
|
"Engine DJ has written since (play counts, imports, cue and beatgrid edits). " +
|
|
432
|
-
"No existing playlist
|
|
446
|
+
"No existing playlist is renamed, reordered, emptied or deleted, and no track, cue or " +
|
|
447
|
+
"beatgrid is touched. The one existing row that moves is the previous last playlist's " +
|
|
448
|
+
"link, and Engine's own insert trigger is what moves it. " +
|
|
433
449
|
"Fails with playlist_exists if a top-level playlist already has that title, and " +
|
|
434
450
|
"with library_busy if Engine DJ or a player is holding a conflicting lock on the " +
|
|
435
451
|
"library right then -- nothing is written in that case, so retry rather than " +
|
|
@@ -446,6 +462,103 @@ export async function createServer(opts = {}) {
|
|
|
446
462
|
return reply(state);
|
|
447
463
|
return reply(await runCreatePlaylist(state.lib.path, state.lib.uuid, args, opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups")));
|
|
448
464
|
});
|
|
465
|
+
const backupDirFor = () => opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups");
|
|
466
|
+
server.registerTool("add_tracks_to_playlist", {
|
|
467
|
+
title: "Add tracks to a playlist",
|
|
468
|
+
description: "Add one or more tracks to an EXISTING playlist -- this edits that playlist's " +
|
|
469
|
+
"contents, it does NOT create a new one (use create_playlist for that). Name the " +
|
|
470
|
+
"playlist with playlist_id or playlist_name, exactly one of the two, resolved the " +
|
|
471
|
+
"same way get_playlist_tracks does: a name matching more than one playlist in this " +
|
|
472
|
+
"library is refused, listing every candidate's id and full path, rather than guessed " +
|
|
473
|
+
"at. track_ids are ids from search_tracks or get_tracks; a track already in the " +
|
|
474
|
+
"playlist is refused as duplicate_track, since Engine allows a track in a playlist " +
|
|
475
|
+
"only once. at chooses where the new tracks land, using the playlist's current " +
|
|
476
|
+
"1-based positions (the same numbering get_playlist_tracks reports): \"start\", " +
|
|
477
|
+
"\"end\" (the default), or { after_position: n }. If this playlist's entry chain is " +
|
|
478
|
+
"already damaged, the write is refused outright rather than repaired -- nothing is " +
|
|
479
|
+
"added, and the error names what is broken. " +
|
|
480
|
+
"On success, the result's `undo` is the exact remove_tracks_from_playlist call that " +
|
|
481
|
+
"reverses this edit -- the positions the new tracks landed at, plus expect_track_ids " +
|
|
482
|
+
"naming the tracks that landed there, so a list someone changed in the meantime is " +
|
|
483
|
+
"refused rather than having the wrong rows removed -- and `undo_complete` is true. " +
|
|
484
|
+
"Call it to undo " +
|
|
485
|
+
"rather than restoring backup_path, which reverts the WHOLE library to before this " +
|
|
486
|
+
"session's first write, discarding every play count, import, cue and beatgrid change " +
|
|
487
|
+
"Engine DJ has recorded since -- not just this one edit. backup_path is only a " +
|
|
488
|
+
"last-resort recovery route for a damaged library, never an undo. " +
|
|
489
|
+
LIBRARY_SELECTION_NOTE,
|
|
490
|
+
inputSchema: { ...AddTracksToPlaylistInput.shape, library: LibraryArg },
|
|
491
|
+
annotations: RW,
|
|
492
|
+
}, async (args) => {
|
|
493
|
+
const state = await acquire(args.library);
|
|
494
|
+
if (isEngineError(state))
|
|
495
|
+
return reply(state);
|
|
496
|
+
return reply(await runAddTracksToPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
|
|
497
|
+
});
|
|
498
|
+
server.registerTool("remove_tracks_from_playlist", {
|
|
499
|
+
title: "Remove tracks from a playlist",
|
|
500
|
+
description: "Remove one or more tracks from an EXISTING playlist by position -- this edits that " +
|
|
501
|
+
"playlist's contents; it never touches any other playlist. Name the playlist with " +
|
|
502
|
+
"playlist_id or playlist_name, exactly one of the two, resolved the same way " +
|
|
503
|
+
"get_playlist_tracks does (an ambiguous name is refused with every candidate listed, " +
|
|
504
|
+
"not guessed at). positions are the 1-based positions get_playlist_tracks reports " +
|
|
505
|
+
"for THIS playlist right now -- they include entries whose track is missing from the " +
|
|
506
|
+
"library (get_playlist_tracks marks those missing: true), and removing one of those " +
|
|
507
|
+
"is a legitimate way to clean up a hole -- but it is the one removal that cannot be " +
|
|
508
|
+
"undone: such an entry names no track id, so no add_tracks_to_playlist call can put " +
|
|
509
|
+
"it back, and the result says so with undo_complete: false plus an undo_note naming " +
|
|
510
|
+
"those positions. expect_track_ids is optional and, when " +
|
|
511
|
+
"given, must have one entry per position: it verifies each named position still " +
|
|
512
|
+
"holds the track expected before anything is removed, refusing the whole call " +
|
|
513
|
+
"otherwise; null there means \"this position should hold an entry whose track is " +
|
|
514
|
+
"missing\", not \"no expectation\". If this playlist's entry chain is already " +
|
|
515
|
+
"damaged, the write is refused outright rather than repaired. " +
|
|
516
|
+
"On success, the result's `undo` is a SEQUENCE of add_tracks_to_playlist calls, one " +
|
|
517
|
+
"per removed track THAT CAN BE RESTORED -- run them IN THE ORDER GIVEN, never in " +
|
|
518
|
+
"parallel and never " +
|
|
519
|
+
"reversed: each step's target position is computed against the list as it stands " +
|
|
520
|
+
"after the previous step has already run, so firing them out of order or " +
|
|
521
|
+
"concurrently puts tracks back in the wrong places. Check `undo_complete`: false " +
|
|
522
|
+
"means one or more removed entries had no track to name and are gone for good -- " +
|
|
523
|
+
"`undo_note` says which positions, and the remaining steps still restore everything " +
|
|
524
|
+
"else. Preferred over restoring " +
|
|
525
|
+
"backup_path, which reverts the WHOLE library to before this session's first write, " +
|
|
526
|
+
"discarding everything Engine DJ has recorded since -- not just this edit. " +
|
|
527
|
+
LIBRARY_SELECTION_NOTE,
|
|
528
|
+
inputSchema: { ...RemoveTracksFromPlaylistInput.shape, library: LibraryArg },
|
|
529
|
+
annotations: RW_DESTRUCTIVE,
|
|
530
|
+
}, async (args) => {
|
|
531
|
+
const state = await acquire(args.library);
|
|
532
|
+
if (isEngineError(state))
|
|
533
|
+
return reply(state);
|
|
534
|
+
return reply(await runRemoveTracksFromPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
|
|
535
|
+
});
|
|
536
|
+
server.registerTool("reorder_playlist", {
|
|
537
|
+
title: "Reorder a playlist",
|
|
538
|
+
description: "Reorder an EXISTING playlist's tracks -- this changes the order of that playlist's " +
|
|
539
|
+
"existing entries; it adds nothing and removes nothing. Name the playlist with " +
|
|
540
|
+
"playlist_id or playlist_name, exactly one of the two, resolved the same way " +
|
|
541
|
+
"get_playlist_tracks does (an ambiguous name is refused with every candidate listed, " +
|
|
542
|
+
"not guessed at). order must be a full permutation of 1..n, n being the playlist's " +
|
|
543
|
+
"current entry count: order[i] names the CURRENT 1-based position (from " +
|
|
544
|
+
"get_playlist_tracks) of the track that should end up at position i + 1. A partial " +
|
|
545
|
+
"'move x to y' instruction is not accepted -- name every position, including ones " +
|
|
546
|
+
"that do not move. If this playlist's entry chain is already damaged, the write is " +
|
|
547
|
+
"refused outright rather than repaired. " +
|
|
548
|
+
"On success, the result's `undo` is the exact inverse permutation, as a single " +
|
|
549
|
+
"reorder_playlist call, and `undo_complete` is true; prefer it over restoring " +
|
|
550
|
+
"backup_path, which reverts the " +
|
|
551
|
+
"WHOLE library to before this session's first write, discarding everything Engine DJ " +
|
|
552
|
+
"has recorded since -- not just this reorder. " +
|
|
553
|
+
LIBRARY_SELECTION_NOTE,
|
|
554
|
+
inputSchema: { ...ReorderPlaylistInput.shape, library: LibraryArg },
|
|
555
|
+
annotations: RW_DESTRUCTIVE,
|
|
556
|
+
}, async (args) => {
|
|
557
|
+
const state = await acquire(args.library);
|
|
558
|
+
if (isEngineError(state))
|
|
559
|
+
return reply(state);
|
|
560
|
+
return reply(await runReorderPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
|
|
561
|
+
});
|
|
449
562
|
}
|
|
450
563
|
/**
|
|
451
564
|
* There was previously no way to shut this down at all: createServer
|
package/dist/store/backup.d.ts
CHANGED
|
@@ -1,2 +1,26 @@
|
|
|
1
1
|
import { type EngineError } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* The snapshots to delete, oldest first, from a directory listing.
|
|
4
|
+
*
|
|
5
|
+
* Two name shapes live here: the tagged `${uuid}-${tag}-${stamp}.db` written
|
|
6
|
+
* now, and the untagged `${uuid}-${stamp}.db` an earlier version wrote.
|
|
7
|
+
* Without the second, those sat outside every namespace and were never
|
|
8
|
+
* reclaimed -- up to KEEP full copies of a library, kept forever, on any
|
|
9
|
+
* upgrading user. Two libraries sharing a uuid is precisely why the tag
|
|
10
|
+
* exists, and an untagged file cannot say which of them it came from, so
|
|
11
|
+
* ageing them out under whichever library writes next is the only thing left
|
|
12
|
+
* to do with them.
|
|
13
|
+
*
|
|
14
|
+
* Ordering is by the stamp alone, never by the whole filename. Sorting the
|
|
15
|
+
* names as text compares a tag against a year at the same offset, and a tag
|
|
16
|
+
* is hex: `1e269292c523` sorts *before* `2026-...`, so a library whose path
|
|
17
|
+
* happens to hash to a tag starting 0 or 1 had its newest snapshot land at
|
|
18
|
+
* the head of the list and be deleted -- the file the caller had just been
|
|
19
|
+
* handed as its way back. Measured: it passed locally under the tag
|
|
20
|
+
* `cf11e2d00f88` and failed in CI under `1e269292c523`, same code.
|
|
21
|
+
*
|
|
22
|
+
* Untagged files sort before every tagged one regardless of stamp: they
|
|
23
|
+
* predate the tagged scheme, so they predate anything written since.
|
|
24
|
+
*/
|
|
25
|
+
export declare function evictable(names: string[], uuid: string, tag: string): string[];
|
|
2
26
|
export declare function snapshotLibrary(mdbPath: string, uuid: string, baseDir: string): Promise<string | EngineError>;
|
package/dist/store/backup.js
CHANGED
|
@@ -33,7 +33,59 @@ function stamp() {
|
|
|
33
33
|
const iso = new Date().toISOString().replace(/[:.]/g, "-");
|
|
34
34
|
return `${iso}-${String(++counter).padStart(10, "0")}`;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The snapshots to delete, oldest first, from a directory listing.
|
|
38
|
+
*
|
|
39
|
+
* Two name shapes live here: the tagged `${uuid}-${tag}-${stamp}.db` written
|
|
40
|
+
* now, and the untagged `${uuid}-${stamp}.db` an earlier version wrote.
|
|
41
|
+
* Without the second, those sat outside every namespace and were never
|
|
42
|
+
* reclaimed -- up to KEEP full copies of a library, kept forever, on any
|
|
43
|
+
* upgrading user. Two libraries sharing a uuid is precisely why the tag
|
|
44
|
+
* exists, and an untagged file cannot say which of them it came from, so
|
|
45
|
+
* ageing them out under whichever library writes next is the only thing left
|
|
46
|
+
* to do with them.
|
|
47
|
+
*
|
|
48
|
+
* Ordering is by the stamp alone, never by the whole filename. Sorting the
|
|
49
|
+
* names as text compares a tag against a year at the same offset, and a tag
|
|
50
|
+
* is hex: `1e269292c523` sorts *before* `2026-...`, so a library whose path
|
|
51
|
+
* happens to hash to a tag starting 0 or 1 had its newest snapshot land at
|
|
52
|
+
* the head of the list and be deleted -- the file the caller had just been
|
|
53
|
+
* handed as its way back. Measured: it passed locally under the tag
|
|
54
|
+
* `cf11e2d00f88` and failed in CI under `1e269292c523`, same code.
|
|
55
|
+
*
|
|
56
|
+
* Untagged files sort before every tagged one regardless of stamp: they
|
|
57
|
+
* predate the tagged scheme, so they predate anything written since.
|
|
58
|
+
*/
|
|
59
|
+
export function evictable(names, uuid, tag) {
|
|
60
|
+
const tagged = `${uuid}-${tag}-`;
|
|
61
|
+
const legacy = new RegExp(`^${uuid}-(\\d{4}-.*)\\.db$`);
|
|
62
|
+
const mine = [];
|
|
63
|
+
for (const name of names) {
|
|
64
|
+
if (!name.endsWith(".db"))
|
|
65
|
+
continue;
|
|
66
|
+
if (name.startsWith(tagged)) {
|
|
67
|
+
mine.push({ name, old: false, stamp: name.slice(tagged.length, -3) });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const m = legacy.exec(name);
|
|
71
|
+
if (m)
|
|
72
|
+
mine.push({ name, old: true, stamp: m[1] });
|
|
73
|
+
}
|
|
74
|
+
mine.sort((a, b) => (a.old !== b.old ? (a.old ? -1 : 1) : a.stamp < b.stamp ? -1 : a.stamp > b.stamp ? 1 : 0));
|
|
75
|
+
return mine.slice(0, Math.max(0, mine.length - KEEP)).map((x) => x.name);
|
|
76
|
+
}
|
|
36
77
|
export async function snapshotLibrary(mdbPath, uuid, baseDir) {
|
|
78
|
+
// node:sqlite stopped needing a flag in 22.13, which is where this
|
|
79
|
+
// project's floor used to sit -- but backup() only arrived in 22.16. On
|
|
80
|
+
// 22.13 through 22.15 the read path works perfectly and this one throws
|
|
81
|
+
// "backup is not a function", which is what CI reported on its very first
|
|
82
|
+
// run against the declared floor. `engines` now says 22.16, and npm only
|
|
83
|
+
// enforces that under engine-strict, so the check is here too: a version
|
|
84
|
+
// number a user can act on beats a TypeError from inside a dependency.
|
|
85
|
+
if (typeof backup !== "function") {
|
|
86
|
+
return err("library_unreadable", `This Node cannot snapshot a library before writing to it: node:sqlite gained backup() in ` +
|
|
87
|
+
`22.16.0 and this is ${process.version}. Upgrade Node, or run without --allow-writes.`);
|
|
88
|
+
}
|
|
37
89
|
let src;
|
|
38
90
|
try {
|
|
39
91
|
mkdirSync(baseDir, { recursive: true });
|
|
@@ -50,21 +102,8 @@ export async function snapshotLibrary(mdbPath, uuid, baseDir) {
|
|
|
50
102
|
await backup(src, dest);
|
|
51
103
|
src.close();
|
|
52
104
|
src = undefined;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// those sat outside every namespace and were never reclaimed -- up to
|
|
56
|
-
// KEEP full copies of a library, kept forever, on any upgrading user.
|
|
57
|
-
// They predate the tag and therefore predate everything written since,
|
|
58
|
-
// which is why folding them into one window evicts them first. Two
|
|
59
|
-
// libraries sharing a uuid is precisely why the tag exists, and an
|
|
60
|
-
// untagged file cannot say which of them it came from -- ageing them out
|
|
61
|
-
// under whichever library writes next is the only thing left to do.
|
|
62
|
-
const legacy = new RegExp(`^${uuid}-\\d{4}-`);
|
|
63
|
-
const mine = readdirSync(baseDir)
|
|
64
|
-
.filter((f) => f.endsWith(".db") && (f.startsWith(prefix) || legacy.test(f)))
|
|
65
|
-
.sort();
|
|
66
|
-
for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) {
|
|
67
|
-
rmSync(join(baseDir, old), { force: true });
|
|
105
|
+
for (const stale of evictable(readdirSync(baseDir), uuid, libraryTag(mdbPath))) {
|
|
106
|
+
rmSync(join(baseDir, stale), { force: true });
|
|
68
107
|
}
|
|
69
108
|
return dest;
|
|
70
109
|
}
|