engine-dj-mcp 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -33
- package/dist/errors.d.ts +15 -1
- package/dist/errors.js +8 -0
- package/dist/library-select.d.ts +14 -17
- package/dist/library-select.js +20 -27
- package/dist/server.js +95 -15
- package/dist/store/track-metadata-plan.d.ts +82 -0
- package/dist/store/track-metadata-plan.js +236 -0
- package/dist/store/track-metadata.d.ts +22 -0
- package/dist/store/track-metadata.js +143 -0
- package/dist/store/write.d.ts +83 -0
- package/dist/store/write.js +35 -7
- package/dist/tools/search.js +11 -2
- package/dist/tools/write-track-metadata.d.ts +26 -0
- package/dist/tools/write-track-metadata.js +42 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ the configuration you are reading:
|
|
|
54
54
|
```json
|
|
55
55
|
{
|
|
56
56
|
"mcpServers": {
|
|
57
|
-
"engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp@0.
|
|
57
|
+
"engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp@0.17.0", "--allow-writes"] }
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
```
|
|
@@ -87,7 +87,7 @@ tempo, key, rating, when a track was added and when it was last played.
|
|
|
87
87
|
| `q` | Full text over title, artist, album, genre, comment and label. Diacritics are folded, so `bjork` matches `Björk` — Engine's own search does not. |
|
|
88
88
|
| `bpm` | `{ min, max }` or `{ around, tolerance_pct }`. Resolved tempo, so an analysed BPM wins over the tag. |
|
|
89
89
|
| `key` | `{ camelot: [...] }` for exact keys, `{ compatible_with: "8A" }` for harmonic neighbours, `{ mode: "minor" }` for a whole side of the wheel. |
|
|
90
|
-
| `rating` | `{ min, max }`, 0–5. |
|
|
90
|
+
| `rating` | `{ min, max }`, in stars, 0–5. Engine stores 0, 20, 40, 60, 80, 100; the filter converts, so `{ min: 4 }` means four stars and up. The `rating` field hands back the stored number, and `rating_stars` the same thing in stars. |
|
|
91
91
|
| `played` | `{ never: true }`, or `{ before, after }` taking an ISO date or a relative form like `-6 months`. |
|
|
92
92
|
| `added` | `{ before, after }`, same date forms. |
|
|
93
93
|
| `flags` | `analyzed`, `available`, `has_cues`, `has_beatgrid`. `has_cues` means a hot cue is genuinely set — see [Limitations](#limitations). |
|
|
@@ -210,7 +210,7 @@ server checks staleness itself before answering.
|
|
|
210
210
|
|
|
211
211
|
### `create_playlist`
|
|
212
212
|
|
|
213
|
-
The first of the
|
|
213
|
+
The first of the five tools that write, none of which is registered at all
|
|
214
214
|
unless the server was started with `--allow-writes`.
|
|
215
215
|
|
|
216
216
|
Creates one new top-level playlist from track ids — `track_ids` sets both
|
|
@@ -337,6 +337,66 @@ Reordering to the order a playlist is already in is accepted and rewrites no
|
|
|
337
337
|
entry: it still stamps the playlist's `lastEditTime`, and still costs this
|
|
338
338
|
session's snapshot if nothing had been written yet.
|
|
339
339
|
|
|
340
|
+
### `update_track_metadata`
|
|
341
|
+
|
|
342
|
+
Changes genre, comment, label, year or rating on tracks — the values Engine
|
|
343
|
+
DJ shows in its columns. It writes to Engine's database, **not to the audio files' tags**. Engine itself writes a comment into the file when you edit it
|
|
344
|
+
there, but not a genre or a rating, so other software reading the tags will
|
|
345
|
+
not see these edits either way.
|
|
346
|
+
|
|
347
|
+
| Argument | |
|
|
348
|
+
| --- | --- |
|
|
349
|
+
| `updates` | Up to 200 entries, each `{ id, genre?, comment?, label?, year?, rating_stars? }`. Only the named fields change. `""` clears a text field; `year: 0` means unknown, as Engine stores it; `rating_stars` is 0–5. |
|
|
350
|
+
| `library` | Required when more than one library is connected — see [Choosing a library](#choosing-a-library). |
|
|
351
|
+
|
|
352
|
+
A track that already holds the requested values is not written, so repeating
|
|
353
|
+
a call changes nothing; it is counted in `unchanged`. The result carries
|
|
354
|
+
`updated`, `unchanged`, `changed` — which fields changed on which tracks —
|
|
355
|
+
`undo`, `undo_complete` (always `true`), `library`, and `backup_path` whenever
|
|
356
|
+
the write transaction ran — which can include `updated: 0`, if the tracks had
|
|
357
|
+
already changed to the requested values by the time the write lock was taken.
|
|
358
|
+
|
|
359
|
+
**Undo.** `undo` is one `update_track_metadata` call that restores the
|
|
360
|
+
previous values of exactly the fields that changed, and names the library.
|
|
361
|
+
It restores values, not `lastEditTime`: Engine's own trigger stamps every
|
|
362
|
+
edit, the undo included. Each entry carries `expect` set to what this call
|
|
363
|
+
wrote, so an undo replayed after someone edited the track again is refused as
|
|
364
|
+
`stale_value` instead of overwriting that edit. `rating_raw` and `expect`
|
|
365
|
+
exist for this; an ordinary edit needs neither.
|
|
366
|
+
|
|
367
|
+
Keep the undo from the first response. Repeating a call that already went
|
|
368
|
+
through finds nothing to change and returns an empty undo. For work spread
|
|
369
|
+
over several calls, replay the undos in reverse order.
|
|
370
|
+
|
|
371
|
+
Its own refusals: `unknown_track`; `track_not_editable` — a track whose origin
|
|
372
|
+
is empty (Engine's trigger rewrites an empty origin on any update, which would
|
|
373
|
+
detach it from playlist entries on other drives), or a field holding a value
|
|
374
|
+
this tool could not put back, such as a rating outside 0–255; `stale_value` —
|
|
375
|
+
the track changed after the values in `expect` were read (up to 20 mismatches
|
|
376
|
+
come back in a structured `mismatches` field, with the total count in the
|
|
377
|
+
prose message); and `invalid_argument`. On `stale_value`, tell the user which
|
|
378
|
+
tracks and fields changed — do not rebuild `expect` from a fresh read to force
|
|
379
|
+
the write without the user's consent, or it silently overwrites the edit the
|
|
380
|
+
DJ made since. Plus the ones
|
|
381
|
+
[every write tool shares](#refusals-every-write-tool-shares), except
|
|
382
|
+
`index_stale` and the query errors: this tool addresses tracks by id and never
|
|
383
|
+
touches the search index.
|
|
384
|
+
|
|
385
|
+
**Searching right after an edit.** Genre, comment and label are in the search
|
|
386
|
+
index, which is rebuilt on the next read. While Engine DJ holds the library
|
|
387
|
+
open it cannot be rebuilt, so a search can keep showing the old values, and
|
|
388
|
+
`refresh_index` cannot help until Engine lets go. The edit itself is in the
|
|
389
|
+
database.
|
|
390
|
+
|
|
391
|
+
**Smart playlists.** A smart playlist whose rules match on genre changes what
|
|
392
|
+
it contains when a genre is renamed, though none of its own rows were touched.
|
|
393
|
+
|
|
394
|
+
**Two connected libraries.** Do not assume a tag edit propagates the way a
|
|
395
|
+
playlist edit does (see [An undo covers one library](#an-undo-covers-one-library)):
|
|
396
|
+
measured once, a tag edit made on the USB library was not copied to the
|
|
397
|
+
computer's library on a fresh Engine DJ launch. The other direction has not
|
|
398
|
+
been measured for tags. Edited tracks are marked for sync (`isMetadataOfPackedTrackChanged`) the same way Engine DJ marks its own tag edits — measured 2026-09-15. That an explicit sync to a drive then carries the edit is what the flag appears to be for, but it has not been measured.
|
|
399
|
+
|
|
340
400
|
### Refusals every write tool shares
|
|
341
401
|
|
|
342
402
|
These come from what happens before the write itself — choosing the library,
|
|
@@ -347,7 +407,7 @@ own checks.
|
|
|
347
407
|
| --- | --- | --- |
|
|
348
408
|
| `invalid_argument` | The arguments do not make sense — both `playlist_id` and `playlist_name`, an empty list where one is required, or a `playlist_name` that matches several playlists (every candidate is listed). | yes |
|
|
349
409
|
| `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
|
|
410
|
+
| `ambiguous_library` | No `library` given, and more than one supported library is connected. Lists them — see [Choosing a library](#choosing-a-library). | yes |
|
|
351
411
|
| `unsupported_schema` | The library's version is outside what this server supports. | yes |
|
|
352
412
|
| `library_needs_recovery` | Engine DJ left an unrecovered journal. Launch Engine once. | yes |
|
|
353
413
|
| `library_busy` | Something holds a conflicting lock right now. Retry. | yes |
|
|
@@ -387,20 +447,22 @@ tracks**. That matters: the local library Engine DJ creates on install is
|
|
|
387
447
|
scanned first and is often empty, so "the first one found" would hide the
|
|
388
448
|
drive you actually work from.
|
|
389
449
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
450
|
+
That rule is enough for a read, which changes nothing: with two libraries
|
|
451
|
+
connected a read picks one, and the `library` field in the result says which.
|
|
452
|
+
|
|
453
|
+
**A write refuses instead**, as soon as more than one supported library is
|
|
454
|
+
connected — whatever their track counts. `ambiguous_library` lists every
|
|
455
|
+
candidate with its track count, and `detail: "not_committed"`.
|
|
394
456
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
457
|
+
The count was never the right question. An earlier version refused only an
|
|
458
|
+
exact tie, reasoning that a USB drive and its copy tie precisely because one
|
|
459
|
+
is a copy of the other — measured 2026-09-01, both real libraries at 257. But
|
|
460
|
+
import one track on one side and the tie is gone, and the default quietly
|
|
461
|
+
takes the larger. A playlist written to the wrong drive is at least visible
|
|
462
|
+
there; a track's genre is not, and you are left believing the edit did not
|
|
463
|
+
work.
|
|
398
464
|
|
|
399
|
-
|
|
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.
|
|
465
|
+
With a single library nothing changes: you never have to name it.
|
|
404
466
|
|
|
405
467
|
The refusal tells the assistant to **ask you** rather than choose. Otherwise
|
|
406
468
|
"pass `library`, here are the two" is an invitation to take the first one,
|
|
@@ -425,20 +487,24 @@ created inside your `Engine Library` folder. The search index lives in
|
|
|
425
487
|
Without `--allow-writes` the server has no tool that can write, and the
|
|
426
488
|
paragraph above holds exactly as written: SQLite itself refuses.
|
|
427
489
|
|
|
428
|
-
With the flag,
|
|
490
|
+
With the flag, five tools appear. `create_playlist` adds a new playlist and
|
|
429
491
|
nothing else. `add_tracks_to_playlist`, `remove_tracks_from_playlist` and
|
|
430
492
|
`reorder_playlist` go further: with the flag, an **existing** playlist can
|
|
431
493
|
now be changed, not only created — its tracks added to, removed from, or put
|
|
432
|
-
in a different order. What
|
|
433
|
-
entries, plus exactly two rows elsewhere: that playlist's own
|
|
434
|
-
`lastEditTime` every edit stamps so Engine sees the change, and —
|
|
435
|
-
`create_playlist` only — the previous last playlist's link, made by
|
|
436
|
-
own insert trigger. No other playlist is renamed, emptied or deleted,
|
|
437
|
-
track, cue or beatgrid is touched by
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
494
|
+
in a different order. What these four playlist tools touch is the named
|
|
495
|
+
playlist's own entries, plus exactly two rows elsewhere: that playlist's own
|
|
496
|
+
row, whose `lastEditTime` every edit stamps so Engine sees the change, and —
|
|
497
|
+
for `create_playlist` only — the previous last playlist's link, made by
|
|
498
|
+
Engine's own insert trigger. No other playlist is renamed, emptied or deleted,
|
|
499
|
+
and no track, cue or beatgrid is touched by these four. `update_track_metadata`
|
|
500
|
+
changes genre, comment, label, year and rating on the tracks named — see its
|
|
501
|
+
section above — and nothing else: no playlist, cue, beatgrid, title, artist,
|
|
502
|
+
album, path or file is touched.
|
|
503
|
+
|
|
504
|
+
Every edit returns `undo` — the exact tool call that reverses it — and
|
|
505
|
+
`undo_complete`; for the playlist tools, `undo` is expressed against the
|
|
506
|
+
positions the edit itself produced, and `undo_complete` says whether
|
|
507
|
+
replaying it puts the playlist back exactly as it was. Replaying
|
|
442
508
|
`undo` is the right way back from an edit; restoring `backup_path` is not,
|
|
443
509
|
because it reverts the **whole library** to before this session's first
|
|
444
510
|
write, discarding every play count, import, cue and beatgrid change Engine
|
|
@@ -493,9 +559,16 @@ library the write actually landed in. Two libraries connected at once is the
|
|
|
493
559
|
ordinary setup: a USB drive and its copy on the computer. This is where you
|
|
494
560
|
check which of them a write went to.
|
|
495
561
|
|
|
496
|
-
**`undo` reverses the edit in that one library, and only there.**
|
|
497
|
-
|
|
498
|
-
|
|
562
|
+
**`undo` reverses the edit in that one library, and only there.** Each undo
|
|
563
|
+
step names it — by path, in the step's own `library` argument — so replaying
|
|
564
|
+
a step verbatim goes back to the library the edit was made in, not to whatever
|
|
565
|
+
the default is at replay time. That matters because a USB drive and its copy
|
|
566
|
+
hold the same playlist ids and the same track ids: a replay that resolved the
|
|
567
|
+
default could land on the wrong disk, and its `expect_track_ids` would agree,
|
|
568
|
+
both sides having been edited the same way.
|
|
569
|
+
|
|
570
|
+
Engine DJ moves playlist changes between connected libraries by itself, so a
|
|
571
|
+
copy of your edit can still end up somewhere `undo` cannot reach.
|
|
499
572
|
|
|
500
573
|
Measured 2026-09-01. A track was added to a playlist in the library on the
|
|
501
574
|
computer. Engine DJ was then launched with the USB drive attached, and the
|
|
@@ -533,9 +606,9 @@ cleared the next time that library is snapshotted.
|
|
|
533
606
|
**To undo a playlist you created, delete it in Engine DJ.** Engine's own
|
|
534
607
|
delete trigger repairs the playlist chain and cascades the entries away,
|
|
535
608
|
which is exactly what removing it should do and is not something restoring
|
|
536
|
-
a snapshot does better. **
|
|
537
|
-
the `undo` the edit returned instead** — it names
|
|
538
|
-
`add_tracks_to_playlist`, `remove_tracks_from_playlist` or
|
|
609
|
+
a snapshot does better. **For the playlist tools, to undo an edit to an
|
|
610
|
+
existing playlist, replay the `undo` the edit returned instead** — it names
|
|
611
|
+
the precise `add_tracks_to_playlist`, `remove_tracks_from_playlist` or
|
|
539
612
|
`reorder_playlist` call that puts the playlist back exactly as it was,
|
|
540
613
|
without touching anything else Engine DJ has recorded since.
|
|
541
614
|
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "library_unreadable", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery", "playlist_exists", "unknown_track", "duplicate_track", "playlist_chain_damaged", "playlist_not_found", "invalid_position", "ambiguous_library"];
|
|
1
|
+
export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "library_unreadable", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery", "playlist_exists", "unknown_track", "duplicate_track", "playlist_chain_damaged", "playlist_not_found", "invalid_position", "ambiguous_library", "stale_value", "track_not_editable"];
|
|
2
2
|
export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
3
3
|
export interface EngineError {
|
|
4
4
|
error: ErrorCode;
|
|
@@ -24,6 +24,20 @@ export interface EngineError {
|
|
|
24
24
|
* taken.
|
|
25
25
|
*/
|
|
26
26
|
backup_path?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Set only on stale_value: every `expect` that no longer matched, capped at
|
|
29
|
+
* 20 entries (the message carries the total). Structured so a caller can
|
|
30
|
+
* tell the user precisely which tracks and fields changed instead of
|
|
31
|
+
* parsing prose -- not so it can re-read and retry: the track changed
|
|
32
|
+
* after `expect` was read, and overwriting that silently, without the
|
|
33
|
+
* user's consent, is exactly what stale_value refuses.
|
|
34
|
+
*/
|
|
35
|
+
mismatches?: {
|
|
36
|
+
id: number;
|
|
37
|
+
field: string;
|
|
38
|
+
expected: string | number | null;
|
|
39
|
+
actual: string | number | null;
|
|
40
|
+
}[];
|
|
27
41
|
}
|
|
28
42
|
export declare function err(error: ErrorCode, message: string, extra?: Omit<EngineError, "error" | "message">): EngineError;
|
|
29
43
|
/**
|
package/dist/errors.js
CHANGED
|
@@ -39,6 +39,14 @@ export const ERROR_CODES = [
|
|
|
39
39
|
// specific: ask which drive, then retry with `library` set. Only writes
|
|
40
40
|
// raise it; see library-select.ts for why reads still choose.
|
|
41
41
|
"ambiguous_library",
|
|
42
|
+
// update_track_metadata (spec §7.2). stale_value: an `expect` no longer
|
|
43
|
+
// matches what is in the library. track_not_editable: this track, or one
|
|
44
|
+
// field of it, cannot be edited without harm -- an empty origin the Track
|
|
45
|
+
// trigger would rewrite, or a stored value this tool could not restore.
|
|
46
|
+
// Not unknown_track: the track exists, and telling a model it does not sends
|
|
47
|
+
// it back to search for a track it will find again.
|
|
48
|
+
"stale_value",
|
|
49
|
+
"track_not_editable",
|
|
42
50
|
];
|
|
43
51
|
export function err(error, message, extra = {}) {
|
|
44
52
|
return { error, message, ...extra };
|
package/dist/library-select.d.ts
CHANGED
|
@@ -35,26 +35,23 @@ export declare const LibraryArg: z.ZodOptional<z.ZodString>;
|
|
|
35
35
|
*/
|
|
36
36
|
export declare function pickDefaultLibrary(libs: readonly LibraryInfo[]): LibraryInfo | null;
|
|
37
37
|
/**
|
|
38
|
-
* The
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* The libraries a write must be told apart between: every supported one, as
|
|
39
|
+
* soon as there is more than one. Empty when there is nothing to choose --
|
|
40
|
+
* one supported library, or none -- so a DJ with a single library never has
|
|
41
|
+
* to name it.
|
|
41
42
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
43
|
+
* This used to ask a narrower question: which libraries *tied* for the
|
|
44
|
+
* default pick on track count. That was wrong, and measurably so. A USB drive
|
|
45
|
+
* and its copy tie only until one of them gains a track; at 258 against 257
|
|
46
|
+
* the tie is gone and `pickDefaultLibrary` silently takes the larger. For a
|
|
47
|
+
* playlist that is at least visible on the wrong drive. For a track's genre
|
|
48
|
+
* it is invisible, and the DJ is left believing the edit did not work.
|
|
45
49
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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.
|
|
50
|
+
* The count was never the question. Which physical disk changes is the
|
|
51
|
+
* caller's to say, and only reads -- which change nothing -- may be spared
|
|
52
|
+
* the question.
|
|
56
53
|
*/
|
|
57
|
-
export declare function
|
|
54
|
+
export declare function writeNeedsLibrary(libs: readonly LibraryInfo[]): LibraryInfo[];
|
|
58
55
|
/**
|
|
59
56
|
* The refusal for an ambiguous default on a write.
|
|
60
57
|
*
|
package/dist/library-select.js
CHANGED
|
@@ -14,10 +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.
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
17
|
+
"the supported library holding the most tracks. A READ may always omit it. A WRITE " +
|
|
18
|
+
"may omit it only when a single supported library is connected: with two or more, a " +
|
|
19
|
+
"write refuses with ambiguous_library listing them, since the choice decides which " +
|
|
20
|
+
"disk changes; ask the user which, then pass it here.";
|
|
21
21
|
export const LibraryArg = z.string().min(1).optional().describe(LIBRARY_ARG_DESCRIPTION);
|
|
22
22
|
/**
|
|
23
23
|
* The default when no `library` was given: the supported library with the
|
|
@@ -51,32 +51,25 @@ export function pickDefaultLibrary(libs) {
|
|
|
51
51
|
return best ?? libs[0] ?? null;
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
|
-
* The
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* The libraries a write must be told apart between: every supported one, as
|
|
55
|
+
* soon as there is more than one. Empty when there is nothing to choose --
|
|
56
|
+
* one supported library, or none -- so a DJ with a single library never has
|
|
57
|
+
* to name it.
|
|
57
58
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
59
|
+
* This used to ask a narrower question: which libraries *tied* for the
|
|
60
|
+
* default pick on track count. That was wrong, and measurably so. A USB drive
|
|
61
|
+
* and its copy tie only until one of them gains a track; at 258 against 257
|
|
62
|
+
* the tie is gone and `pickDefaultLibrary` silently takes the larger. For a
|
|
63
|
+
* playlist that is at least visible on the wrong drive. For a track's genre
|
|
64
|
+
* it is invisible, and the DJ is left believing the edit did not work.
|
|
61
65
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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.
|
|
66
|
+
* The count was never the question. Which physical disk changes is the
|
|
67
|
+
* caller's to say, and only reads -- which change nothing -- may be spared
|
|
68
|
+
* the question.
|
|
72
69
|
*/
|
|
73
|
-
export function
|
|
70
|
+
export function writeNeedsLibrary(libs) {
|
|
74
71
|
const supported = libs.filter((l) => l.supported);
|
|
75
|
-
|
|
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 : [];
|
|
72
|
+
return supported.length > 1 ? supported : [];
|
|
80
73
|
}
|
|
81
74
|
/**
|
|
82
75
|
* The refusal for an ambiguous default on a write.
|
|
@@ -94,7 +87,7 @@ export function defaultLibraryTies(libs) {
|
|
|
94
87
|
*/
|
|
95
88
|
export function ambiguousLibrary(tied) {
|
|
96
89
|
const list = tied.map((l) => `${l.uuid} -- ${redactPath(l.path)} (${l.trackCount} tracks)`).join("; ");
|
|
97
|
-
return err("ambiguous_library", `More than one library
|
|
90
|
+
return err("ambiguous_library", `More than one library is connected, so there is no default to write to: ${list}. ` +
|
|
98
91
|
`Nothing was written. ASK which one to write to, then retry with \`library\` set -- ` +
|
|
99
92
|
`do not choose for them. These are usually a USB drive and its copy on the computer, ` +
|
|
100
93
|
`and one of them may be the drive they perform from.`, { detail: "not_committed" });
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
6
|
import { discoverLibraries, defaultRoots, probeLibraries } from "./discovery.js";
|
|
7
7
|
import { libraryCandidates, libraryTag, sidecarDir } from "./paths.js";
|
|
8
|
-
import { LibraryArg, ambiguousLibrary,
|
|
8
|
+
import { LibraryArg, ambiguousLibrary, writeNeedsLibrary, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
|
|
9
9
|
import { hasHotJournal } from "./store/connections.js";
|
|
10
10
|
import { QueryProcess } from "./proc/query-client.js";
|
|
11
11
|
import { IndexManager } from "./store/index-manager.js";
|
|
@@ -18,6 +18,7 @@ import { runSql, RunSqlInput } from "./tools/sql.js";
|
|
|
18
18
|
import { listLibraries } from "./tools/libraries.js";
|
|
19
19
|
import { refreshIndex } from "./tools/refresh.js";
|
|
20
20
|
import { CreatePlaylistInput, runCreatePlaylist, AddTracksToPlaylistInput, runAddTracksToPlaylist, RemoveTracksFromPlaylistInput, runRemoveTracksFromPlaylist, ReorderPlaylistInput, runReorderPlaylist, } from "./tools/write-playlist.js";
|
|
21
|
+
import { UpdateTrackMetadataInput, runUpdateTrackMetadata } from "./tools/write-track-metadata.js";
|
|
21
22
|
import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
|
|
22
23
|
const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
|
|
23
24
|
/**
|
|
@@ -35,6 +36,12 @@ const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false
|
|
|
35
36
|
* additive told a client it need not ask before calling.
|
|
36
37
|
*/
|
|
37
38
|
const RW_DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false };
|
|
39
|
+
/**
|
|
40
|
+
* A write that overwrites or clears what is there -- so destructive -- but
|
|
41
|
+
* that a repeat of the same call leaves alone: a track already holding the
|
|
42
|
+
* requested values is not written again. update_track_metadata.
|
|
43
|
+
*/
|
|
44
|
+
const RW_OVERWRITE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true };
|
|
38
45
|
/**
|
|
39
46
|
* name/version reported to every client on initialize. Read from
|
|
40
47
|
* package.json rather than typed here, so the two cannot re-diverge the way
|
|
@@ -77,13 +84,27 @@ const LIBRARY_SELECTION_NOTE = "With more than one library connected, pass `libr
|
|
|
77
84
|
"either the ~/... form or the absolute one) to choose which one; the default is the " +
|
|
78
85
|
"supported library with the most tracks.";
|
|
79
86
|
/**
|
|
80
|
-
* Appended to the write tools only.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
87
|
+
* Appended to the write tools only. More than one library is a refusal there
|
|
88
|
+
* and a free choice on the read side, so the shared note above cannot carry it
|
|
89
|
+
* without being wrong for one of the two.
|
|
90
|
+
*/
|
|
91
|
+
const WRITE_LIBRARY_NOTE = " With two or more supported libraries connected, this tool refuses with ambiguous_library " +
|
|
92
|
+
"rather than picking one, and lists them; nothing is written. That is so whatever their track " +
|
|
93
|
+
"counts are -- the count never said which disk should change. Ask the user which one, then " +
|
|
94
|
+
"retry with `library` set; do not pick for them, since one of them may be the drive they " +
|
|
95
|
+
"perform from.";
|
|
96
|
+
/**
|
|
97
|
+
* Not UNDO_SCOPE_NOTE: that one says Engine copies *playlist* changes between
|
|
98
|
+
* libraries, which was measured. For track tags, a fresh Engine launch was
|
|
99
|
+
* measured NOT copying a tag edit made on the USB library to the computer's
|
|
100
|
+
* library (spec §3.9); the other direction has not been measured for tags,
|
|
101
|
+
* so repeating the playlist claim here would state a guess as fact.
|
|
83
102
|
*/
|
|
84
|
-
const
|
|
85
|
-
"
|
|
86
|
-
"
|
|
103
|
+
const TRACK_UNDO_NOTE = "`undo` reverses this edit in ONE library: the one the result's `library` field names, and each " +
|
|
104
|
+
"undo step carries it. It restores the values of the fields that changed; it does not restore " +
|
|
105
|
+
"lastEditTime, which Engine DJ's own trigger sets on every edit. Keep the undo from the first " +
|
|
106
|
+
"response: repeating a call that already succeeded finds nothing to change and returns an empty " +
|
|
107
|
+
"undo. For work spread over several calls, replay their undos in REVERSE order. ";
|
|
87
108
|
function reply(value) {
|
|
88
109
|
return {
|
|
89
110
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
@@ -279,13 +300,25 @@ export async function createServer(opts = {}) {
|
|
|
279
300
|
* alone: they run far more often and a stale pick between two copies is not
|
|
280
301
|
* worth a probe apiece.
|
|
281
302
|
*/
|
|
282
|
-
|
|
303
|
+
/**
|
|
304
|
+
* The library a write may land in, without touching its search index. A
|
|
305
|
+
* tool that addresses tracks by id needs no index, and building one right
|
|
306
|
+
* before a write only makes it stale the moment the write commits.
|
|
307
|
+
* acquireForWrite adds the index for the tools that resolve playlists.
|
|
308
|
+
*/
|
|
309
|
+
const selectForWrite = (requested) => {
|
|
283
310
|
if (requested === undefined) {
|
|
284
311
|
rescanLibraries();
|
|
285
|
-
const
|
|
286
|
-
if (
|
|
287
|
-
return ambiguousLibrary(
|
|
312
|
+
const choices = writeNeedsLibrary(knownList());
|
|
313
|
+
if (choices.length > 0)
|
|
314
|
+
return ambiguousLibrary(choices);
|
|
288
315
|
}
|
|
316
|
+
return selectLibrary(requested);
|
|
317
|
+
};
|
|
318
|
+
const acquireForWrite = async (requested) => {
|
|
319
|
+
const lib = selectForWrite(requested);
|
|
320
|
+
if (isEngineError(lib))
|
|
321
|
+
return lib;
|
|
289
322
|
return acquire(requested);
|
|
290
323
|
};
|
|
291
324
|
/**
|
|
@@ -527,7 +560,7 @@ export async function createServer(opts = {}) {
|
|
|
527
560
|
"library is unchanged and \"committed_unverified\" when the write may have gone " +
|
|
528
561
|
"through but could not be verified. track_ids may be empty (an empty playlist); a " +
|
|
529
562
|
"track id may appear at most once. " +
|
|
530
|
-
LIBRARY_SELECTION_NOTE +
|
|
563
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
|
|
531
564
|
inputSchema: { ...CreatePlaylistInput.shape, library: LibraryArg },
|
|
532
565
|
annotations: RW,
|
|
533
566
|
}, async (args) => {
|
|
@@ -561,7 +594,7 @@ export async function createServer(opts = {}) {
|
|
|
561
594
|
"Engine DJ has recorded since -- not just this one edit. backup_path is only a " +
|
|
562
595
|
"last-resort recovery route for a damaged library, never an undo. " +
|
|
563
596
|
UNDO_SCOPE_NOTE +
|
|
564
|
-
LIBRARY_SELECTION_NOTE +
|
|
597
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
|
|
565
598
|
inputSchema: { ...AddTracksToPlaylistInput.shape, library: LibraryArg },
|
|
566
599
|
annotations: RW,
|
|
567
600
|
}, async (args) => {
|
|
@@ -600,7 +633,7 @@ export async function createServer(opts = {}) {
|
|
|
600
633
|
"backup_path, which reverts the WHOLE library to before this session's first write, " +
|
|
601
634
|
"discarding everything Engine DJ has recorded since -- not just this edit. " +
|
|
602
635
|
UNDO_SCOPE_NOTE +
|
|
603
|
-
LIBRARY_SELECTION_NOTE +
|
|
636
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
|
|
604
637
|
inputSchema: { ...RemoveTracksFromPlaylistInput.shape, library: LibraryArg },
|
|
605
638
|
annotations: RW_DESTRUCTIVE,
|
|
606
639
|
}, async (args) => {
|
|
@@ -627,7 +660,7 @@ export async function createServer(opts = {}) {
|
|
|
627
660
|
"WHOLE library to before this session's first write, discarding everything Engine DJ " +
|
|
628
661
|
"has recorded since -- not just this reorder. " +
|
|
629
662
|
UNDO_SCOPE_NOTE +
|
|
630
|
-
LIBRARY_SELECTION_NOTE +
|
|
663
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
|
|
631
664
|
inputSchema: { ...ReorderPlaylistInput.shape, library: LibraryArg },
|
|
632
665
|
annotations: RW_DESTRUCTIVE,
|
|
633
666
|
}, async (args) => {
|
|
@@ -636,6 +669,41 @@ export async function createServer(opts = {}) {
|
|
|
636
669
|
return reply(state);
|
|
637
670
|
return reply(await runReorderPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
|
|
638
671
|
});
|
|
672
|
+
server.registerTool("update_track_metadata", {
|
|
673
|
+
title: "Edit track tags",
|
|
674
|
+
description: "Change genre, comment, label, year or rating on tracks in this Engine DJ library -- the " +
|
|
675
|
+
"values Engine shows in its columns. This WRITES to the library's database, not to the audio " +
|
|
676
|
+
"files' tags. Each entry names a track by id (from search_tracks or get_tracks) and only the " +
|
|
677
|
+
'fields to change; "" clears a text field; rating_stars is 0-5 (Engine stores 0-100). Up to ' +
|
|
678
|
+
"200 tracks per call, all or nothing. A track already holding the requested values is left " +
|
|
679
|
+
"alone and counted in `unchanged`; `changed` lists which fields changed on which tracks. " +
|
|
680
|
+
"Each refusal names every offending track of its kind -- invalid_argument, unknown_track, " +
|
|
681
|
+
"track_not_editable (the track cannot be edited without harm -- say so to the user and leave it; " +
|
|
682
|
+
"do not search for it again), stale_value -- and kinds are reported one at a time, in that order: " +
|
|
683
|
+
"fix the one reported first and retry to see the next, if any -- except stale_value, which is not " +
|
|
684
|
+
"something to just retry: see below. stale_value means the track " +
|
|
685
|
+
"changed after the values in `expect` were read; tell the user which tracks and fields changed. " +
|
|
686
|
+
"Do NOT rebuild `expect` from a fresh read to force the write -- that would silently overwrite " +
|
|
687
|
+
"an edit the DJ made since, without their consent. " +
|
|
688
|
+
"Search results may keep showing the old values while Engine DJ holds the library open; " +
|
|
689
|
+
"refresh_index cannot help until Engine lets go. " +
|
|
690
|
+
TRACK_UNDO_NOTE +
|
|
691
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
|
|
692
|
+
inputSchema: { ...UpdateTrackMetadataInput.shape, library: LibraryArg },
|
|
693
|
+
annotations: RW_OVERWRITE,
|
|
694
|
+
}, async (args) => {
|
|
695
|
+
const lib = selectForWrite(args.library);
|
|
696
|
+
if (isEngineError(lib))
|
|
697
|
+
return reply(lib);
|
|
698
|
+
// ensureFresh is what refuses an unsupported schema for every other
|
|
699
|
+
// tool; this path skips it, so it must refuse here.
|
|
700
|
+
if (!lib.supported) {
|
|
701
|
+
return reply(err("unsupported_schema", `Schema ${lib.schema.join(".")} is not supported`, {
|
|
702
|
+
detail: "Supported versions are 3.0.0, 3.0.1 and 3.0.2",
|
|
703
|
+
}));
|
|
704
|
+
}
|
|
705
|
+
return reply(await runUpdateTrackMetadata(lib.path, lib.uuid, args, backupDirFor()));
|
|
706
|
+
});
|
|
639
707
|
}
|
|
640
708
|
/**
|
|
641
709
|
* There was previously no way to shut this down at all: createServer
|
|
@@ -704,6 +772,11 @@ other.
|
|
|
704
772
|
\`bpmAnalyzed\` to within 0.68, and Engine's own interface displays 102 for
|
|
705
773
|
the track stored as 102). \`side.track_derived.tempo\` holds the resolved
|
|
706
774
|
value and is indexed.
|
|
775
|
+
- \`Track.rating\` is 0, 20, 40, 60, 80 or 100 -- one step per star, measured
|
|
776
|
+
against Engine's own display. The \`rating\` field returns it as stored and
|
|
777
|
+
\`rating_stars\` returns 0..5; the \`rating\` **filter** takes stars. A value
|
|
778
|
+
from other software (ID3's POPM is 0..255) is kept exactly in \`rating\` and
|
|
779
|
+
rounded to the nearest star in \`rating_stars\`.
|
|
707
780
|
- \`Track.path\` is relative to the \`Engine Library\` folder and usually
|
|
708
781
|
contains \`..\`. The SQL function \`abs_path(path)\` resolves it against
|
|
709
782
|
this library's location; the home prefix comes back folded to \`~\`.
|
|
@@ -719,6 +792,13 @@ other.
|
|
|
719
792
|
unchanged. To compare names regardless of case in any script, use
|
|
720
793
|
\`fold(text)\` -- one Unicode normalization form, lower-cased by Unicode
|
|
721
794
|
rules. It runs per row, like every function here.
|
|
795
|
+
- \`Track\` carries two Engine triggers. \`trigger_after_update_only_Track_timestamp\`
|
|
796
|
+
sets \`lastEditTime\` (epoch seconds) whenever genre, comment, label, year,
|
|
797
|
+
rating or a dozen other columns are updated -- even to the same value.
|
|
798
|
+
\`trigger_after_update_Track_fix_origin\` fires on ANY update and rewrites an
|
|
799
|
+
empty \`(originDatabaseUuid, originTrackId)\` to this library's uuid and the
|
|
800
|
+
track's own id; in SQLite \`'' = 0\` is false, so a TEXT '' originTrackId does
|
|
801
|
+
not count as empty.
|
|
722
802
|
- A track's natural key across drives is \`(originDatabaseUuid, originTrackId)\`.
|
|
723
803
|
- \`PerformanceData\`'s blob columns are binary and cannot be read with SQL.
|
|
724
804
|
Engine writes \`quickCues\`, \`loops\`, \`beatData\` and
|