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 CHANGED
@@ -1,5 +1,6 @@
1
1
  # engine-dj-mcp
2
2
 
3
+ [![CI](https://github.com/Venut-Labs/engine-dj-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Venut-Labs/engine-dj-mcp/actions/workflows/ci.yml)
3
4
  [![npm](https://img.shields.io/npm/v/engine-dj-mcp)](https://www.npmjs.com/package/engine-dj-mcp)
4
5
  [![licence](https://img.shields.io/npm/l/engine-dj-mcp)](./LICENSE)
5
6
 
@@ -58,16 +59,19 @@ the configuration you are reading:
58
59
  }
59
60
  ```
60
61
 
61
- **Requirements:** Node.js 22.13 or newer (for the unflagged `node:sqlite`;
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 a tenth — `create_playlist` — that appears only
68
- when you start the server with `--allow-writes`. Every tool that reads
69
- library data also accepts an optional `library` argument see
70
- [Choosing a library](#choosing-a-library).
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 only tool that writes, and the only one that is not registered at all
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, one tool appears `create_playlist`. It adds a new playlist
279
- and nothing else: no existing playlist is renamed, reordered, emptied or
280
- deleted, and no track, cue or beatgrid is touched. The single change to an
281
- existing row is the previous last playlist's link, made by Engine's own
282
- trigger.
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 playlist you wanted
312
- gone. Reach for it only if the library itself is damaged — the case where
313
- `create_playlist` comes back with `detail: "committed_unverified"`.
314
-
315
- **To undo a playlist, delete it in Engine DJ.** Engine's own delete trigger
316
- repairs the playlist chain and cascades the entries away, which is exactly
317
- what removing it should do and is not something restoring a snapshot does
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 `create_playlist` refuses such a library outright rather than
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, `create_playlist` adds playlists
375
- and that is the whole list. Not a cue, not a tag, not a rating, and not even
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
- **Playlists can be created, not edited.** With `--allow-writes` a new
393
- playlist can be added; there is no reordering, renaming, deleting, or adding
394
- a track to a playlist that already exists, and no set lists or suggested
395
- transitions. It answers questions about the collection and writes down the
396
- answer if you ask; the mixing is yours.
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 };
@@ -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
- * Deliberately reuses `invalid_argument` rather than adding an error code:
209
- * the taxonomy is closed (see errors.ts), and "the playlist you named is not
210
- * in this library" is a problem with the argument, reported the same way an
211
- * unknown field name is -- with the recognised values in `detail`.
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
- * Deliberately reuses `invalid_argument` rather than adding an error code:
365
- * the taxonomy is closed (see errors.ts), and "the playlist you named is not
366
- * in this library" is a problem with the argument, reported the same way an
367
- * unknown field name is -- with the recognised values in `detail`.
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 err("invalid_argument", `No playlist with ${names.id} ${sel.id} in this library`, {
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 err("invalid_argument", `No playlist named "${sel.name}" in this library`, {
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 or entry is ever modified; this only adds a new one. " +
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
@@ -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>;
@@ -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
- // Snapshots this library owns: the tagged shape above, plus the untagged
54
- // `${uuid}-${stamp}.db` an earlier version wrote. Without the second,
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
  }