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 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.15.0", "--allow-writes"] }
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 four tools that write, none of which is registered at all
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 two libraries tie for the default. Lists both — see [Choosing a library](#choosing-a-library). | yes |
410
+ | `ambiguous_library` | No `library` given, and more than one supported library is connected. Lists them — see [Choosing a library](#choosing-a-library). | yes |
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
- When two supported libraries hold the *same* highest number of tracks, that
391
- rule names no winner — and a tie is the ordinary case, not an exotic one: a
392
- USB drive and its copy on the computer tie precisely because one is a copy of
393
- the other. Measured 2026-09-01, both real libraries reported 257 tracks.
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
- **A read still chooses for itself.** Tied libraries hold the same tracks, so
396
- either answer is very nearly the same answer, and making you name a library
397
- you have no reason to care about would be noise.
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
- **A write refuses**, with `ambiguous_library` listing every candidate and its
400
- track count, and `detail: "not_committed"`. The choice decides which physical
401
- disk changes, and one of the two may be the drive you perform from; scan
402
- order is not a reason to pick it. Name a library and the write goes through,
403
- tie or not — the ambiguity being refused is the server's, not yours.
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, four tools appear. `create_playlist` adds a new playlist and
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 each one touches is the named playlist's own
433
- entries, plus exactly two rows elsewhere: that playlist's own row, whose
434
- `lastEditTime` every edit stamps so Engine sees the change, and — for
435
- `create_playlist` only — the previous last playlist's link, made by Engine's
436
- own insert trigger. No other playlist is renamed, emptied or deleted, and no
437
- track, cue or beatgrid is touched by any of the four.
438
-
439
- Every edit returns `undo` — the exact tool call that reverses it, expressed
440
- against the positions the edit itself produced — and `undo_complete`, saying
441
- whether replaying it puts the playlist back exactly as it was. Replaying
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.** Engine DJ
497
- moves playlist changes between connected libraries by itself, so a copy of
498
- your edit can end up somewhere `undo` cannot reach.
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. **To undo an edit to an existing playlist, replay
537
- the `undo` the edit returned instead** — it names the precise
538
- `add_tracks_to_playlist`, `remove_tracks_from_playlist` or
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 };
@@ -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 supported libraries tied for the default pick -- more than one holding
39
- * the same, highest track count. Empty when the default is unambiguous, which
40
- * includes the single-library case every DJ starts from.
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
- * A tie is not an exotic shape: it is what a USB drive and its copy on the
43
- * computer produce, and they tie *because* one is a copy of the other.
44
- * Measured 2026-09-01, both real libraries reported 257 tracks.
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
- * For a read, either answer is very nearly the same answer -- the two are
47
- * copies -- so `pickDefaultLibrary` keeps choosing, and a read never has to
48
- * name a library it does not care about. For a write the choice decides which
49
- * physical disk changes, and one of them is the drive the DJ performs from.
50
- * Hence: reads choose, writes refuse. Callers wanting the strict behaviour
51
- * check this first.
52
- *
53
- * Skips unsupported libraries for the same reason `pickDefaultLibrary` does:
54
- * one can never be chosen while a supported library exists, so it is not a
55
- * competing candidate and must not make a write refuse.
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 defaultLibraryTies(libs: readonly LibraryInfo[]): LibraryInfo[];
54
+ export declare function writeNeedsLibrary(libs: readonly LibraryInfo[]): LibraryInfo[];
58
55
  /**
59
56
  * The refusal for an ambiguous default on a write.
60
57
  *
@@ -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. If two supported libraries hold " +
18
- "the same most tracks -- what a USB drive and its copy on the computer produce -- " +
19
- "a read still picks one, but a WRITE refuses with ambiguous_library listing both, " +
20
- "since the choice decides which disk changes; ask the user which, then pass it here.";
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 supported libraries tied for the default pick -- more than one holding
55
- * the same, highest track count. Empty when the default is unambiguous, which
56
- * includes the single-library case every DJ starts from.
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
- * A tie is not an exotic shape: it is what a USB drive and its copy on the
59
- * computer produce, and they tie *because* one is a copy of the other.
60
- * Measured 2026-09-01, both real libraries reported 257 tracks.
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
- * For a read, either answer is very nearly the same answer -- the two are
63
- * copies -- so `pickDefaultLibrary` keeps choosing, and a read never has to
64
- * name a library it does not care about. For a write the choice decides which
65
- * physical disk changes, and one of them is the drive the DJ performs from.
66
- * Hence: reads choose, writes refuse. Callers wanting the strict behaviour
67
- * check this first.
68
- *
69
- * Skips unsupported libraries for the same reason `pickDefaultLibrary` does:
70
- * one can never be chosen while a supported library exists, so it is not a
71
- * competing candidate and must not make a write refuse.
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 defaultLibraryTies(libs) {
70
+ export function writeNeedsLibrary(libs) {
74
71
  const supported = libs.filter((l) => l.supported);
75
- if (supported.length < 2)
76
- return [];
77
- const best = Math.max(...supported.map((l) => l.trackCount ?? -1));
78
- const tied = supported.filter((l) => (l.trackCount ?? -1) === best);
79
- return tied.length > 1 ? tied : [];
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 holds the most tracks, so there is no default to write to: ${list}. ` +
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, defaultLibraryTies, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
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. A tie in the default rule is a refusal
81
- * there and a free choice on the read side, so the shared note above cannot
82
- * carry it without being wrong for one of the two.
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 WRITE_LIBRARY_TIE_NOTE = " If two supported libraries hold the same most tracks -- what a USB drive and its copy on " +
85
- "the computer produce -- this tool refuses with ambiguous_library rather than picking one, " +
86
- "and lists both; nothing is written. Ask the user which one, then retry with `library` set -- do not pick for them, since one of the two may be the drive they perform from.";
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
- const acquireForWrite = async (requested) => {
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 tied = defaultLibraryTies(knownList());
286
- if (tied.length > 1)
287
- return ambiguousLibrary(tied);
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 + WRITE_LIBRARY_TIE_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 + WRITE_LIBRARY_TIE_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 + WRITE_LIBRARY_TIE_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 + WRITE_LIBRARY_TIE_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