engine-dj-mcp 0.15.0 → 0.17.1

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,6 +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
+ [![CI](https://github.com/Venut-Technologies/engine-dj-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Venut-Technologies/engine-dj-mcp/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/engine-dj-mcp)](https://www.npmjs.com/package/engine-dj-mcp)
5
5
  [![licence](https://img.shields.io/npm/l/engine-dj-mcp)](./LICENSE)
6
6
 
@@ -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.1", "--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 — or the uuid given is shared by copies on different drives. 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,33 @@ 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.
394
-
395
- **A read still chooses for itself.** Tied libraries hold the same tracks, so
396
- either answer is very nearly the same answer, and making you name a library
397
- you have no reason to care about would be noise.
398
-
399
- **A write refuses**, with `ambiguous_library` listing every candidate and its
400
- track count, and `detail: "not_committed"`. The choice decides which physical
401
- disk changes, and one of the two may be the drive you perform from; scan
402
- order is not a reason to pick it. Name a library and the write goes through,
403
- tie or not — the ambiguity being refused is the server's, not yours.
450
+ That rule is enough for a read, which changes nothing: with two libraries
451
+ connected a read picks one. Pass `library` when it matters 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"`.
456
+
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.
464
+
465
+ With a single library nothing changes: you never have to name it.
466
+
467
+ **Copies share a uuid.** Copy an `Engine Library` folder onto another drive —
468
+ a spare stick for a gig — and the copy keeps the original's uuid, so with both
469
+ connected one uuid names two libraries. A write naming that uuid is refused the
470
+ same way, with `ambiguous_library` listing both paths, rather than landing on
471
+ whichever drive was scanned first. Pass the path instead — it tells the copies
472
+ apart — and re-read from that path anything the write depends on, since a read
473
+ naming the uuid may have come from the other copy. Reads naming a shared uuid
474
+ are not refused: they answer from one of the copies. Before every write the
475
+ drives are scanned again, so a copy plugged in after the server started is
476
+ counted — as long as its library can be read.
404
477
 
405
478
  The refusal tells the assistant to **ask you** rather than choose. Otherwise
406
479
  "pass `library`, here are the two" is an invitation to take the first one,
@@ -425,20 +498,24 @@ created inside your `Engine Library` folder. The search index lives in
425
498
  Without `--allow-writes` the server has no tool that can write, and the
426
499
  paragraph above holds exactly as written: SQLite itself refuses.
427
500
 
428
- With the flag, four tools appear. `create_playlist` adds a new playlist and
501
+ With the flag, five tools appear. `create_playlist` adds a new playlist and
429
502
  nothing else. `add_tracks_to_playlist`, `remove_tracks_from_playlist` and
430
503
  `reorder_playlist` go further: with the flag, an **existing** playlist can
431
504
  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
505
+ in a different order. What these four playlist tools touch is the named
506
+ playlist's own entries, plus exactly two rows elsewhere: that playlist's own
507
+ row, whose `lastEditTime` every edit stamps so Engine sees the change, and —
508
+ for `create_playlist` only — the previous last playlist's link, made by
509
+ Engine's own insert trigger. No other playlist is renamed, emptied or deleted,
510
+ and no track, cue or beatgrid is touched by these four. `update_track_metadata`
511
+ changes genre, comment, label, year and rating on the tracks named — see its
512
+ section above — and nothing else: no playlist, cue, beatgrid, title, artist,
513
+ album, path or file is touched.
514
+
515
+ Every edit returns `undo` — the exact tool call that reverses it — and
516
+ `undo_complete`; for the playlist tools, `undo` is expressed against the
517
+ positions the edit itself produced, and `undo_complete` says whether
518
+ replaying it puts the playlist back exactly as it was. Replaying
442
519
  `undo` is the right way back from an edit; restoring `backup_path` is not,
443
520
  because it reverts the **whole library** to before this session's first
444
521
  write, discarding every play count, import, cue and beatgrid change Engine
@@ -493,9 +570,16 @@ library the write actually landed in. Two libraries connected at once is the
493
570
  ordinary setup: a USB drive and its copy on the computer. This is where you
494
571
  check which of them a write went to.
495
572
 
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.
573
+ **`undo` reverses the edit in that one library, and only there.** Each undo
574
+ step names it — by path, in the step's own `library` argument — so replaying
575
+ a step verbatim goes back to the library the edit was made in, not to whatever
576
+ the default is at replay time. That matters because a USB drive and its copy
577
+ hold the same playlist ids and the same track ids: a replay that resolved the
578
+ default could land on the wrong disk, and its `expect_track_ids` would agree,
579
+ both sides having been edited the same way.
580
+
581
+ Engine DJ moves playlist changes between connected libraries by itself, so a
582
+ copy of your edit can still end up somewhere `undo` cannot reach.
499
583
 
500
584
  Measured 2026-09-01. A track was added to a playlist in the library on the
501
585
  computer. Engine DJ was then launched with the USB drive attached, and the
@@ -533,9 +617,9 @@ cleared the next time that library is snapshotted.
533
617
  **To undo a playlist you created, delete it in Engine DJ.** Engine's own
534
618
  delete trigger repairs the playlist chain and cascades the entries away,
535
619
  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
620
+ a snapshot does better. **For the playlist tools, to undo an edit to an
621
+ existing playlist, replay the `undo` the edit returned instead** — it names
622
+ the precise `add_tracks_to_playlist`, `remove_tracks_from_playlist` or
539
623
  `reorder_playlist` call that puts the playlist back exactly as it was,
540
624
  without touching anything else Engine DJ has recorded since.
541
625
 
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
@@ -33,12 +33,21 @@ export const ERROR_CODES = [
33
33
  "playlist_chain_damaged",
34
34
  "playlist_not_found",
35
35
  "invalid_position",
36
- // No `library` was passed and the default rule names no single winner --
37
- // two supported libraries hold the same, highest track count. Its own code
38
- // rather than invalid_argument because the useful client response is
39
- // specific: ask which drive, then retry with `library` set. Only writes
40
- // raise it; see library-select.ts for why reads still choose.
36
+ // A write cannot tell which physical library to change: no `library` was
37
+ // passed and more than one supported library is connected, or the uuid
38
+ // passed is shared by copies on different drives. Its own code rather than
39
+ // invalid_argument because the useful client response is specific: ask
40
+ // which drive, then retry with its path. Only writes raise it; see
41
+ // library-select.ts for why reads still choose.
41
42
  "ambiguous_library",
43
+ // update_track_metadata (spec §7.2). stale_value: an `expect` no longer
44
+ // matches what is in the library. track_not_editable: this track, or one
45
+ // field of it, cannot be edited without harm -- an empty origin the Track
46
+ // trigger would rewrite, or a stored value this tool could not restore.
47
+ // Not unknown_track: the track exists, and telling a model it does not sends
48
+ // it back to search for a track it will find again.
49
+ "stale_value",
50
+ "track_not_editable",
42
51
  ];
43
52
  export function err(error, message, extra = {}) {
44
53
  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
  *
@@ -84,8 +81,29 @@ export declare function ambiguousLibrary(tied: readonly LibraryInfo[]): EngineEr
84
81
  *
85
82
  * A path match is exact on the m.db file, not a prefix: a value that merely
86
83
  * *contains* a library path must not select it.
84
+ *
85
+ * A uuid shared by several libraries resolves to the first of them in this
86
+ * server's list of known libraries -- root-scan order at startup, arrival
87
+ * order for a drive plugged in later, but no order a caller can rely on. That
88
+ * is tolerable for a read, which changes no disk, and never for a write: see
89
+ * namedWriteLibrary.
87
90
  */
88
91
  export declare function findLibrary(libs: readonly LibraryInfo[], requested: string): LibraryInfo | null;
92
+ /**
93
+ * Every library a `library` value names, in the order of `libs`.
94
+ * More than one only for a uuid: copying an Engine Library folder onto another
95
+ * drive copies its uuid with it, while each library's path is its own.
96
+ */
97
+ export declare function findLibraries(libs: readonly LibraryInfo[], requested: string): LibraryInfo[];
98
+ /**
99
+ * The one library a write names, or the refusal.
100
+ *
101
+ * findLibrary would hand back the first of two libraries sharing a uuid, and
102
+ * which is first is only discovery order -- the very thing ambiguousLibrary
103
+ * exists so that a write does not rest on. A named uuid is no better an answer
104
+ * than an omitted `library` when it names both a USB drive and its copy.
105
+ */
106
+ export declare function namedWriteLibrary(libs: readonly LibraryInfo[], requested: string): LibraryInfo | EngineError;
89
107
  /**
90
108
  * The error for a `library` value that matched nothing. It names what was
91
109
  * passed and lists what is actually selectable, because the two ways to get
@@ -14,10 +14,12 @@ 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. A library copied onto another drive " +
21
+ "keeps its uuid, so a uuid can name two connected libraries: a write naming such a uuid " +
22
+ "refuses with ambiguous_library as well. Pass the path to write to one of them.";
21
23
  export const LibraryArg = z.string().min(1).optional().describe(LIBRARY_ARG_DESCRIPTION);
22
24
  /**
23
25
  * The default when no `library` was given: the supported library with the
@@ -51,32 +53,25 @@ export function pickDefaultLibrary(libs) {
51
53
  return best ?? libs[0] ?? null;
52
54
  }
53
55
  /**
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.
56
+ * The libraries a write must be told apart between: every supported one, as
57
+ * soon as there is more than one. Empty when there is nothing to choose --
58
+ * one supported library, or none -- so a DJ with a single library never has
59
+ * to name it.
57
60
  *
58
- * A tie is not an exotic shape: it is what a USB drive and its copy on the
59
- * computer produce, and they tie *because* one is a copy of the other.
60
- * Measured 2026-09-01, both real libraries reported 257 tracks.
61
+ * This used to ask a narrower question: which libraries *tied* for the
62
+ * default pick on track count. That was wrong, and measurably so. A USB drive
63
+ * and its copy tie only until one of them gains a track; at 258 against 257
64
+ * the tie is gone and `pickDefaultLibrary` silently takes the larger. For a
65
+ * playlist that is at least visible on the wrong drive. For a track's genre
66
+ * it is invisible, and the DJ is left believing the edit did not work.
61
67
  *
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.
68
+ * The count was never the question. Which physical disk changes is the
69
+ * caller's to say, and only reads -- which change nothing -- may be spared
70
+ * the question.
72
71
  */
73
- export function defaultLibraryTies(libs) {
72
+ export function writeNeedsLibrary(libs) {
74
73
  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 : [];
74
+ return supported.length > 1 ? supported : [];
80
75
  }
81
76
  /**
82
77
  * The refusal for an ambiguous default on a write.
@@ -94,10 +89,13 @@ export function defaultLibraryTies(libs) {
94
89
  */
95
90
  export function ambiguousLibrary(tied) {
96
91
  const list = tied.map((l) => `${l.uuid} -- ${redactPath(l.path)} (${l.trackCount} tracks)`).join("; ");
97
- return err("ambiguous_library", `More than one library holds the most tracks, so there is no default to write to: ${list}. ` +
98
- `Nothing was written. ASK which one to write to, then retry with \`library\` set -- ` +
99
- `do not choose for them. These are usually a USB drive and its copy on the computer, ` +
100
- `and one of them may be the drive they perform from.`, { detail: "not_committed" });
92
+ return err("ambiguous_library", `More than one library is connected, so there is no default to write to: ${list}. ` +
93
+ `Nothing was written. ASK which one to write to, then retry with \`library\` set to that ` +
94
+ `library's path -- do not choose for them. A copy keeps its uuid, so a uuid may name more ` +
95
+ `than one of these. They are usually a USB drive and its copy on the computer, ` +
96
+ `and one of them may be the drive they perform from. A read without \`library\` may have ` +
97
+ `come from a different one of these, so re-read anything the write depends on (track and ` +
98
+ `playlist ids, positions, current values) from the chosen library first.`, { detail: "not_committed" });
101
99
  }
102
100
  /**
103
101
  * Resolves a caller-supplied `library` value: uuid first, then filesystem
@@ -113,19 +111,66 @@ export function ambiguousLibrary(tied) {
113
111
  *
114
112
  * A path match is exact on the m.db file, not a prefix: a value that merely
115
113
  * *contains* a library path must not select it.
114
+ *
115
+ * A uuid shared by several libraries resolves to the first of them in this
116
+ * server's list of known libraries -- root-scan order at startup, arrival
117
+ * order for a drive plugged in later, but no order a caller can rely on. That
118
+ * is tolerable for a read, which changes no disk, and never for a write: see
119
+ * namedWriteLibrary.
116
120
  */
117
121
  export function findLibrary(libs, requested) {
122
+ return findLibraries(libs, requested)[0] ?? null;
123
+ }
124
+ /**
125
+ * Every library a `library` value names, in the order of `libs`.
126
+ * More than one only for a uuid: copying an Engine Library folder onto another
127
+ * drive copies its uuid with it, while each library's path is its own.
128
+ */
129
+ export function findLibraries(libs, requested) {
118
130
  const wanted = requested.trim();
119
131
  if (!wanted)
120
- return null;
121
- const byUuid = libs.find((l) => l.uuid && l.uuid.toLowerCase() === wanted.toLowerCase());
122
- if (byUuid)
132
+ return [];
133
+ const byUuid = libs.filter((l) => l.uuid && l.uuid.toLowerCase() === wanted.toLowerCase());
134
+ if (byUuid.length > 0)
123
135
  return byUuid;
124
136
  // resolve() turns a relative value into something rooted at the process
125
137
  // cwd, which matches no library path -- exactly the intended outcome for
126
138
  // a value that is neither a uuid nor a real path.
127
139
  const wantedPath = resolve(expandHome(wanted));
128
- return libs.find((l) => resolve(l.path) === wantedPath) ?? null;
140
+ return libs.filter((l) => resolve(l.path) === wantedPath);
141
+ }
142
+ /**
143
+ * The one library a write names, or the refusal.
144
+ *
145
+ * findLibrary would hand back the first of two libraries sharing a uuid, and
146
+ * which is first is only discovery order -- the very thing ambiguousLibrary
147
+ * exists so that a write does not rest on. A named uuid is no better an answer
148
+ * than an omitted `library` when it names both a USB drive and its copy.
149
+ */
150
+ export function namedWriteLibrary(libs, requested) {
151
+ const matches = findLibraries(libs, requested);
152
+ if (matches.length > 1)
153
+ return sharedUuid(requested, matches);
154
+ if (matches[0])
155
+ return matches[0];
156
+ // The same refusal a read gets, with its list moved into `message`: on a
157
+ // write `detail` is reserved, for the reason given at ambiguousLibrary.
158
+ const miss = libraryNotFound(requested, libs);
159
+ return { ...miss, message: `${miss.message}. ${miss.detail}`, detail: "not_committed" };
160
+ }
161
+ /**
162
+ * The refusal for a uuid naming more than one library. Lists paths, since the
163
+ * uuid is the one thing the candidates do not differ in; `detail` stays
164
+ * exactly "not_committed" for the reason given at ambiguousLibrary.
165
+ */
166
+ function sharedUuid(requested, matches) {
167
+ const list = matches.map((l) => `${redactPath(l.path)} (${l.trackCount} tracks)`).join("; ");
168
+ return err("ambiguous_library", `"${requested.trim()}" names more than one connected library -- a library copied onto another ` +
169
+ `drive keeps its uuid: ${list}. Nothing was written. ASK which one to write to, then retry ` +
170
+ `with \`library\` set to that one's path -- do not choose for them. One of them may be the ` +
171
+ `drive they perform from. A read naming this uuid may have come from another of these copies, ` +
172
+ `so re-read anything the write depends on (track and playlist ids, positions, current values) ` +
173
+ `from that path first.`, { detail: "not_committed" });
129
174
  }
130
175
  /**
131
176
  * The error for a `library` value that matched nothing. It names what was