engine-dj-mcp 0.12.0 → 0.15.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 +127 -28
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +6 -0
- package/dist/library-select.d.ts +36 -0
- package/dist/library-select.js +53 -1
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +20 -0
- package/dist/semantics.js +5 -0
- package/dist/server.js +98 -9
- package/dist/store/backup.d.ts +19 -1
- package/dist/store/backup.js +72 -4
- package/dist/store/write.d.ts +17 -0
- package/dist/store/write.js +10 -4
- package/dist/tools/audit.d.ts +29 -1
- package/dist/tools/audit.js +82 -3
- package/dist/tools/playlists.js +2 -11
- package/dist/tools/search.d.ts +7 -0
- package/dist/tools/search.js +24 -7
- package/dist/tools/tracks.js +2 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,11 +54,15 @@ the configuration you are reading:
|
|
|
54
54
|
```json
|
|
55
55
|
{
|
|
56
56
|
"mcpServers": {
|
|
57
|
-
"engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp", "--allow-writes"] }
|
|
57
|
+
"engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp@0.15.0", "--allow-writes"] }
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
Pin the version in this one. Unpinned, `npx` fetches whatever is newest at
|
|
63
|
+
every launch, and this configuration gives that code write access to your
|
|
64
|
+
library. Pinned, a new release reaches it only when you change the number.
|
|
65
|
+
|
|
62
66
|
**Requirements:** Node.js 22.16 or newer (`node:sqlite` stopped needing a
|
|
63
67
|
flag in 22.13, but the pre-write snapshot uses its `backup()`, added in
|
|
64
68
|
22.16;
|
|
@@ -156,7 +160,7 @@ Positions are sample offsets; cue and loop items also carry seconds.
|
|
|
156
160
|
|
|
157
161
|
### `audit_library`
|
|
158
162
|
|
|
159
|
-
|
|
163
|
+
Eleven collection health checks. Returns a count and a small sample of ids per
|
|
160
164
|
check, never the full result set — a library with thousands of unanalysed
|
|
161
165
|
tracks should not fill an assistant's context.
|
|
162
166
|
|
|
@@ -169,11 +173,12 @@ tracks should not fill an assistant's context.
|
|
|
169
173
|
| `no_beatgrid` | Tracks with no beatgrid data |
|
|
170
174
|
| `missing_key` | Tracks with no key detected |
|
|
171
175
|
| `suspicious_bpm` | Analysed and tagged tempo disagree, or tempo is outside 60–200 |
|
|
172
|
-
| `duplicates` | Same artist and title,
|
|
176
|
+
| `duplicates` | Same artist and title, compared regardless of case in any script |
|
|
173
177
|
| `empty_metadata` | No artist or no title |
|
|
174
178
|
| `orphan_entries` | Playlist entries pointing at tracks not in this library — `get_playlist_tracks` shows where each one sits |
|
|
179
|
+
| `path_form_mismatch` | The file is on disk, but its name there — or a folder's on the way — is in a different Unicode form from the path Engine stored. macOS finds it anyway; Linux does not (measured on the kernel's exFAT driver), and Engine OS on a player is Linux, so these may fail to load on hardware. Differences in case alone are not counted: exFAT and Windows ignore case |
|
|
175
180
|
|
|
176
|
-
`checks` — omit it to run all
|
|
181
|
+
`checks` — omit it to run all eleven.
|
|
177
182
|
|
|
178
183
|
### `run_sql`
|
|
179
184
|
|
|
@@ -224,19 +229,14 @@ Each entry stores the track's origin identity — `(originDatabaseUuid,
|
|
|
224
229
|
originTrackId)`, the pair Engine matches on — not the local row id, so a
|
|
225
230
|
playlist built here reads the same way Engine's own does.
|
|
226
231
|
|
|
227
|
-
The result carries `playlist_id`, `tracks_added` and `backup_path`. **To undo
|
|
232
|
+
The result carries `playlist_id`, `tracks_added`, `library` and `backup_path`. **To undo
|
|
228
233
|
it, delete the playlist in Engine DJ**; `backup_path` is a whole-library
|
|
229
234
|
snapshot for the case where something went wrong at a lower level, not an
|
|
230
235
|
undo — see [Restoring a snapshot](#restoring-a-snapshot).
|
|
231
236
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
the
|
|
235
|
-
right then, `library_needs_recovery` if Engine DJ left an unrecovered
|
|
236
|
-
journal behind. Every error also carries `detail`: `not_committed` means the
|
|
237
|
-
library is exactly what it was, and `committed_unverified` — the rare one —
|
|
238
|
-
means the write may have landed but could not be verified afterwards, and is
|
|
239
|
-
the only case that hands back a `backup_path`.
|
|
237
|
+
Its own refusals: `playlist_exists` for a taken title, `unknown_track` for an
|
|
238
|
+
id this library does not have, `duplicate_track` for the same id twice — plus
|
|
239
|
+
the ones [every write tool shares](#refusals-every-write-tool-shares).
|
|
240
240
|
|
|
241
241
|
### `add_tracks_to_playlist`
|
|
242
242
|
|
|
@@ -258,15 +258,18 @@ way.
|
|
|
258
258
|
| `at` | Where the new tracks land, against the playlist's current 1-based positions (the same numbering `get_playlist_tracks` reports): `"start"`, `"end"` (the default), or `{ after_position: n }`. |
|
|
259
259
|
|
|
260
260
|
The result carries `playlist_id`, `tracks_added`, `positions` — where the
|
|
261
|
-
new tracks landed — `undo`, `undo_complete` (always `true` here)
|
|
262
|
-
`backup_path`. `undo` is the exact `remove_tracks_from_playlist` call that
|
|
261
|
+
new tracks landed — `undo`, `undo_complete` (always `true` here), `library`
|
|
262
|
+
and `backup_path`. `undo` is the exact `remove_tracks_from_playlist` call that
|
|
263
263
|
reverses this edit: the positions the tracks landed at, plus
|
|
264
264
|
`expect_track_ids` naming the tracks that landed there, so a playlist
|
|
265
265
|
something else changed in the meantime is refused rather than having the
|
|
266
266
|
wrong rows removed. Call it to undo rather than restoring `backup_path` —
|
|
267
|
-
see [Restoring a snapshot](#restoring-a-snapshot)
|
|
268
|
-
|
|
269
|
-
|
|
267
|
+
see [Restoring a snapshot](#restoring-a-snapshot), and
|
|
268
|
+
[An undo covers one library](#an-undo-covers-one-library) for what it does
|
|
269
|
+
not reach. Its own refusals: `playlist_not_found`, `playlist_chain_damaged`,
|
|
270
|
+
`invalid_position`, and `unknown_track` / `duplicate_track` as for
|
|
271
|
+
`create_playlist` — plus the ones
|
|
272
|
+
[every write tool shares](#refusals-every-write-tool-shares).
|
|
270
273
|
|
|
271
274
|
### `remove_tracks_from_playlist`
|
|
272
275
|
|
|
@@ -282,8 +285,8 @@ repaired.
|
|
|
282
285
|
| `expect_track_ids` | Optional, one entry per position: verifies each named position still holds the track expected before anything is removed, refusing the whole call otherwise. `null` means "this position should hold an entry whose track is missing", not "no expectation". |
|
|
283
286
|
|
|
284
287
|
The result carries `playlist_id`, `tracks_removed`, `removed` — each
|
|
285
|
-
position's `track_id`, `null` for a missing one — `undo`, `undo_complete
|
|
286
|
-
`backup_path`. `undo` is a **sequence** of `add_tracks_to_playlist` calls,
|
|
288
|
+
position's `track_id`, `null` for a missing one — `undo`, `undo_complete`,
|
|
289
|
+
`library` and `backup_path`. `undo` is a **sequence** of `add_tracks_to_playlist` calls,
|
|
287
290
|
one per removed track that can be restored. Run them in the order given,
|
|
288
291
|
never in parallel and never reversed — each step's target position is
|
|
289
292
|
computed against the list as it stands after the previous step has already
|
|
@@ -297,9 +300,10 @@ have, so no `add_tracks_to_playlist` call can put it back, and an
|
|
|
297
300
|
still restore everything else; the missing entries are recoverable only from
|
|
298
301
|
`backup_path`, which reverts the whole library.
|
|
299
302
|
|
|
300
|
-
|
|
303
|
+
Its own refusals: `playlist_not_found`, `playlist_chain_damaged`, and
|
|
301
304
|
`invalid_position` — for a repeated or out-of-range position, or one that
|
|
302
|
-
does not hold what `expect_track_ids` expected
|
|
305
|
+
does not hold what `expect_track_ids` expected — plus the ones
|
|
306
|
+
[every write tool shares](#refusals-every-write-tool-shares).
|
|
303
307
|
|
|
304
308
|
`playlist_chain_damaged` always means the same thing for all three edit
|
|
305
309
|
tools: the playlist's entry chain was already broken **before** the edit,
|
|
@@ -323,15 +327,43 @@ repaired.
|
|
|
323
327
|
| `order` | A full permutation of `1..n`, `n` being the playlist's current entry count. `order[i]` names the *current* 1-based position (from `get_playlist_tracks`) of the track that should end up at position `i + 1`. A partial "move x to y" instruction is not accepted — name every position, including ones that do not move. |
|
|
324
328
|
|
|
325
329
|
The result carries `playlist_id`, `undo`, `undo_complete` (always `true`
|
|
326
|
-
here) and `backup_path`. `undo` is the exact inverse permutation, as a single
|
|
327
|
-
`reorder_playlist` call.
|
|
330
|
+
here), `library` and `backup_path`. `undo` is the exact inverse permutation, as a single
|
|
331
|
+
`reorder_playlist` call. Its own refusals: `playlist_not_found`,
|
|
328
332
|
`playlist_chain_damaged`, and `invalid_position` if `order` is not a full
|
|
329
|
-
permutation of the playlist's current positions
|
|
333
|
+
permutation of the playlist's current positions — plus the ones
|
|
334
|
+
[every write tool shares](#refusals-every-write-tool-shares).
|
|
330
335
|
|
|
331
336
|
Reordering to the order a playlist is already in is accepted and rewrites no
|
|
332
337
|
entry: it still stamps the playlist's `lastEditTime`, and still costs this
|
|
333
338
|
session's snapshot if nothing had been written yet.
|
|
334
339
|
|
|
340
|
+
### Refusals every write tool shares
|
|
341
|
+
|
|
342
|
+
These come from what happens before the write itself — choosing the library,
|
|
343
|
+
bringing its index up to date, resolving the playlist — and from the write's
|
|
344
|
+
own checks.
|
|
345
|
+
|
|
346
|
+
| Code | Means | Nothing written? |
|
|
347
|
+
| --- | --- | --- |
|
|
348
|
+
| `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
|
+
| `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 |
|
|
351
|
+
| `unsupported_schema` | The library's version is outside what this server supports. | yes |
|
|
352
|
+
| `library_needs_recovery` | Engine DJ left an unrecovered journal. Launch Engine once. | yes |
|
|
353
|
+
| `library_busy` | Something holds a conflicting lock right now. Retry. | yes |
|
|
354
|
+
| `index_stale` | The index could not be built yet, typically because Engine holds a lock on a first run. Carries `retry_after_ms`. | yes |
|
|
355
|
+
| `query_timeout`, `query_process_crashed` | The lookup that resolves a playlist failed. Edit tools only. | yes |
|
|
356
|
+
| `library_unreadable` | The library could not be read; the snapshot taken before the first write could not be made (a full disk, or a Node older than 22.16); or a write's own read-back disagreed with what it wrote, and it was rolled back. | see `detail` |
|
|
357
|
+
|
|
358
|
+
**`detail` on these errors.** Once the write itself has started, `detail` is
|
|
359
|
+
exactly one of two strings, and a client can read it to decide whether the
|
|
360
|
+
library changed: `not_committed` — the library is what it was — or
|
|
361
|
+
`committed_unverified` — the rare one: the write may have landed but could not
|
|
362
|
+
be confirmed, and only this case hands back a `backup_path`. Refusals raised
|
|
363
|
+
*before* that point — every row above marked "yes" — never opened the library
|
|
364
|
+
for writing, whatever their `detail` says: it may be absent, `not_committed`,
|
|
365
|
+
or explanatory text such as the candidates an ambiguous `playlist_name` lists.
|
|
366
|
+
|
|
335
367
|
## Resources
|
|
336
368
|
|
|
337
369
|
- **`engine://schema`** — the field semantics an assistant needs before
|
|
@@ -355,6 +387,26 @@ tracks**. That matters: the local library Engine DJ creates on install is
|
|
|
355
387
|
scanned first and is often empty, so "the first one found" would hide the
|
|
356
388
|
drive you actually work from.
|
|
357
389
|
|
|
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.
|
|
404
|
+
|
|
405
|
+
The refusal tells the assistant to **ask you** rather than choose. Otherwise
|
|
406
|
+
"pass `library`, here are the two" is an invitation to take the first one,
|
|
407
|
+
which puts the write back on an arbitrary disk and makes the refusal
|
|
408
|
+
pointless.
|
|
409
|
+
|
|
358
410
|
Each library gets its own index and its own connection, opened the first time
|
|
359
411
|
you ask that library something. Comparing two libraries against each other —
|
|
360
412
|
*"what is on this drive but not that one?"* — is **not** something this server
|
|
@@ -418,9 +470,49 @@ and without `--allow-writes` not even this.
|
|
|
418
470
|
The write takes SQLite's own write lock for the length of one transaction and
|
|
419
471
|
does not wait for it: if something else — Engine DJ mid-save, a player — is
|
|
420
472
|
holding a conflicting lock at that moment, the write is refused with
|
|
421
|
-
`library_busy` and nothing is changed.
|
|
422
|
-
|
|
423
|
-
Engine
|
|
473
|
+
`library_busy` and nothing is changed.
|
|
474
|
+
|
|
475
|
+
**Quit Engine DJ before writing.** Having Engine open is not usually a lock
|
|
476
|
+
conflict, so the write itself will normally go through — but what Engine then
|
|
477
|
+
does with a change made underneath it has never been measured here. Every
|
|
478
|
+
acceptance check of a write was run with Engine closed. What *has* been
|
|
479
|
+
measured is that Engine does its own work on the library as it loads: it
|
|
480
|
+
renumbers playlist entries, and it copies playlist changes to another
|
|
481
|
+
connected library (see below). Quit, write, relaunch — Engine reads the
|
|
482
|
+
library on startup and shows the change.
|
|
483
|
+
|
|
484
|
+
Quit, not close. On macOS, closing Engine's window leaves the application
|
|
485
|
+
running: observed 2026-09-01 with the main process and seven
|
|
486
|
+
`OfflineAnalyzer` workers — which write to the database — still alive
|
|
487
|
+
afterwards. Use ⌘Q.
|
|
488
|
+
|
|
489
|
+
### An undo covers one library
|
|
490
|
+
|
|
491
|
+
Every write result carries a `library` field — the `uuid` and `path` of the
|
|
492
|
+
library the write actually landed in. Two libraries connected at once is the
|
|
493
|
+
ordinary setup: a USB drive and its copy on the computer. This is where you
|
|
494
|
+
check which of them a write went to.
|
|
495
|
+
|
|
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.
|
|
499
|
+
|
|
500
|
+
Measured 2026-09-01. A track was added to a playlist in the library on the
|
|
501
|
+
computer. Engine DJ was then launched with the USB drive attached, and the
|
|
502
|
+
same playlist on the USB came back with the same track added — the copy
|
|
503
|
+
carrying the very `lastEditTime` this server's `INSERT` had written. The
|
|
504
|
+
`undo` was then run and reversed the edit on the computer. The USB kept it.
|
|
505
|
+
|
|
506
|
+
Nothing was damaged: both libraries stayed sound. But the two had diverged,
|
|
507
|
+
and the `undo` reported success, correctly, because within its own library it
|
|
508
|
+
did exactly what it promised.
|
|
509
|
+
|
|
510
|
+
So: if a second library is connected, look at the `library` field, and undo
|
|
511
|
+
against each library separately. Undoing before Engine DJ next runs avoids
|
|
512
|
+
the problem entirely.
|
|
513
|
+
|
|
514
|
+
Which library a change propagates to, and in which direction, is Engine's own
|
|
515
|
+
business — this project does not model it and will not guess at it.
|
|
424
516
|
|
|
425
517
|
### Restoring a snapshot
|
|
426
518
|
|
|
@@ -431,6 +523,13 @@ Engine DJ has written since is discarded along with the one edit you wanted
|
|
|
431
523
|
gone. Reach for it only if the library itself is damaged — the case where a
|
|
432
524
|
write comes back with `detail: "committed_unverified"`.
|
|
433
525
|
|
|
526
|
+
Snapshots live in `~/.engine-dj-mcp/backups/`, ten per library. Only a name
|
|
527
|
+
ending in `.db` is a snapshot. A file ending in `.partial-<number>` — with or
|
|
528
|
+
without `-journal` after it — is a copy still being written, or one whose
|
|
529
|
+
process died before it finished: **never restore one of those**. A copy is
|
|
530
|
+
renamed to its `.db` name only once it is complete, and an abandoned one is
|
|
531
|
+
cleared the next time that library is snapshotted.
|
|
532
|
+
|
|
434
533
|
**To undo a playlist you created, delete it in Engine DJ.** Engine's own
|
|
435
534
|
delete trigger repairs the playlist chain and cascades the entries away,
|
|
436
535
|
which is exactly what removing it should do and is not something restoring
|
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"];
|
|
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"];
|
|
2
2
|
export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
3
3
|
export interface EngineError {
|
|
4
4
|
error: ErrorCode;
|
package/dist/errors.js
CHANGED
|
@@ -33,6 +33,12 @@ 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.
|
|
41
|
+
"ambiguous_library",
|
|
36
42
|
];
|
|
37
43
|
export function err(error, message, extra = {}) {
|
|
38
44
|
return { error, message, ...extra };
|
package/dist/library-select.d.ts
CHANGED
|
@@ -34,6 +34,42 @@ export declare const LibraryArg: z.ZodOptional<z.ZodString>;
|
|
|
34
34
|
* supported library" would otherwise collapse into.
|
|
35
35
|
*/
|
|
36
36
|
export declare function pickDefaultLibrary(libs: readonly LibraryInfo[]): LibraryInfo | null;
|
|
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.
|
|
41
|
+
*
|
|
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.
|
|
45
|
+
*
|
|
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.
|
|
56
|
+
*/
|
|
57
|
+
export declare function defaultLibraryTies(libs: readonly LibraryInfo[]): LibraryInfo[];
|
|
58
|
+
/**
|
|
59
|
+
* The refusal for an ambiguous default on a write.
|
|
60
|
+
*
|
|
61
|
+
* Every candidate is named, with its track count, because the caller has to
|
|
62
|
+
* pick one and cannot do that from "it was ambiguous" -- the same reason
|
|
63
|
+
* get_playlist_tracks lists candidates for an ambiguous playlist name.
|
|
64
|
+
*
|
|
65
|
+
* The list goes in `message`, never in `detail`. This is only ever returned
|
|
66
|
+
* from a write tool, and on that path `detail` carries exactly
|
|
67
|
+
* `"not_committed"` or `"committed_unverified"` -- a client reads it to
|
|
68
|
+
* decide whether its library changed. Prose there would break that read for
|
|
69
|
+
* the one error whose answer is least in doubt: nothing was opened, let alone
|
|
70
|
+
* written.
|
|
71
|
+
*/
|
|
72
|
+
export declare function ambiguousLibrary(tied: readonly LibraryInfo[]): EngineError;
|
|
37
73
|
/**
|
|
38
74
|
* Resolves a caller-supplied `library` value: uuid first, then filesystem
|
|
39
75
|
* path. Returns null when it matches neither -- the caller decides what
|
package/dist/library-select.js
CHANGED
|
@@ -14,7 +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."
|
|
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.";
|
|
18
21
|
export const LibraryArg = z.string().min(1).optional().describe(LIBRARY_ARG_DESCRIPTION);
|
|
19
22
|
/**
|
|
20
23
|
* The default when no `library` was given: the supported library with the
|
|
@@ -47,6 +50,55 @@ export function pickDefaultLibrary(libs) {
|
|
|
47
50
|
}
|
|
48
51
|
return best ?? libs[0] ?? null;
|
|
49
52
|
}
|
|
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.
|
|
57
|
+
*
|
|
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
|
+
*
|
|
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.
|
|
72
|
+
*/
|
|
73
|
+
export function defaultLibraryTies(libs) {
|
|
74
|
+
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 : [];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The refusal for an ambiguous default on a write.
|
|
83
|
+
*
|
|
84
|
+
* Every candidate is named, with its track count, because the caller has to
|
|
85
|
+
* pick one and cannot do that from "it was ambiguous" -- the same reason
|
|
86
|
+
* get_playlist_tracks lists candidates for an ambiguous playlist name.
|
|
87
|
+
*
|
|
88
|
+
* The list goes in `message`, never in `detail`. This is only ever returned
|
|
89
|
+
* from a write tool, and on that path `detail` carries exactly
|
|
90
|
+
* `"not_committed"` or `"committed_unverified"` -- a client reads it to
|
|
91
|
+
* decide whether its library changed. Prose there would break that read for
|
|
92
|
+
* the one error whose answer is least in doubt: nothing was opened, let alone
|
|
93
|
+
* written.
|
|
94
|
+
*/
|
|
95
|
+
export function ambiguousLibrary(tied) {
|
|
96
|
+
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" });
|
|
101
|
+
}
|
|
50
102
|
/**
|
|
51
103
|
* Resolves a caller-supplied `library` value: uuid first, then filesystem
|
|
52
104
|
* path. Returns null when it matches neither -- the caller decides what
|
package/dist/paths.d.ts
CHANGED
|
@@ -19,6 +19,17 @@ export declare function libraryCandidates(root: string): string[];
|
|
|
19
19
|
* shipped to a model provider, so the home prefix is folded to `~` by default.
|
|
20
20
|
*/
|
|
21
21
|
export declare function redactPath(p: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* redactPath for a URI. A uri carries its path after a scheme and usually
|
|
24
|
+
* percent-encoded -- the home directory shows up as `%2FUsers%2F<name>`, not
|
|
25
|
+
* as a leading `/Users/<name>` -- so redactPath's prefix check would never fire
|
|
26
|
+
* on one, and a "redacted" uri would leak exactly what redaction is there to
|
|
27
|
+
* hide. Folds every occurrence, raw or encoded, either case of hex digit since
|
|
28
|
+
* encoders differ. Only a whole path component: the home directory must be
|
|
29
|
+
* followed by a separator or the end, so a sibling account whose name merely
|
|
30
|
+
* starts the same is left alone.
|
|
31
|
+
*/
|
|
32
|
+
export declare function redactUri(u: string): string;
|
|
22
33
|
/**
|
|
23
34
|
* The inverse of redactPath, for values coming back *in*. Every library path
|
|
24
35
|
* this server reports has been through redactPath, so the most obvious way
|
package/dist/paths.js
CHANGED
|
@@ -34,6 +34,26 @@ export function redactPath(p) {
|
|
|
34
34
|
const home = homedir();
|
|
35
35
|
return p === home || p.startsWith(home + "/") ? "~" + p.slice(home.length) : p;
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* redactPath for a URI. A uri carries its path after a scheme and usually
|
|
39
|
+
* percent-encoded -- the home directory shows up as `%2FUsers%2F<name>`, not
|
|
40
|
+
* as a leading `/Users/<name>` -- so redactPath's prefix check would never fire
|
|
41
|
+
* on one, and a "redacted" uri would leak exactly what redaction is there to
|
|
42
|
+
* hide. Folds every occurrence, raw or encoded, either case of hex digit since
|
|
43
|
+
* encoders differ. Only a whole path component: the home directory must be
|
|
44
|
+
* followed by a separator or the end, so a sibling account whose name merely
|
|
45
|
+
* starts the same is left alone.
|
|
46
|
+
*/
|
|
47
|
+
export function redactUri(u) {
|
|
48
|
+
const home = homedir();
|
|
49
|
+
if (!home || home === "/")
|
|
50
|
+
return u;
|
|
51
|
+
const esc = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
52
|
+
const raw = new RegExp(`${esc(home)}(?=/|$)`, "g");
|
|
53
|
+
const encoded = esc(encodeURIComponent(home)).replace(/%([0-9A-F])([0-9A-F])/g, (_, a, b) => `%[${a}${a.toLowerCase()}][${b}${b.toLowerCase()}]`);
|
|
54
|
+
const enc = new RegExp(`${encoded}(?=%2[Ff]|$)`, "g");
|
|
55
|
+
return u.replace(raw, "~").replace(enc, "~");
|
|
56
|
+
}
|
|
37
57
|
/**
|
|
38
58
|
* The inverse of redactPath, for values coming back *in*. Every library path
|
|
39
59
|
* this server reports has been through redactPath, so the most obvious way
|
package/dist/semantics.js
CHANGED
|
@@ -85,6 +85,11 @@ export function keyDistance(a, b) {
|
|
|
85
85
|
export function registerFunctions(db, mdbPath) {
|
|
86
86
|
const opts = { deterministic: true };
|
|
87
87
|
db.function("camelot", opts, (key) => camelot(key === null ? null : Number(key)));
|
|
88
|
+
// A comparison key for text: one Unicode normalization form, lower-cased by
|
|
89
|
+
// Unicode rules. SQLite's own LOWER folds ASCII only -- LOWER('ЭЙФОРИЯ')
|
|
90
|
+
// comes back unchanged -- which made audit_library's duplicates find a
|
|
91
|
+
// capitalised Latin title and miss a capitalised Cyrillic one (#9).
|
|
92
|
+
db.function("fold", opts, (text) => text === null || text === undefined ? null : String(text).normalize("NFC").toLowerCase());
|
|
88
93
|
db.function("key_name", opts, (key) => keyName(key === null ? null : Number(key)));
|
|
89
94
|
db.function("tempo", opts, (a, b) => tempo(a === null ? null : Number(a), b === null ? null : Number(b)));
|
|
90
95
|
db.function("key_distance", opts, (a, b) => a === null || b === null ? null : keyDistance(String(a), String(b)));
|
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, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
|
|
8
|
+
import { LibraryArg, ambiguousLibrary, defaultLibraryTies, 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";
|
|
@@ -56,9 +56,34 @@ const PACKAGE_INFO = JSON.parse(readFileSync(new URL("../package.json", import.m
|
|
|
56
56
|
* this repeats the essentials in the description because some clients show a
|
|
57
57
|
* model the description and not the per-property schema documentation.
|
|
58
58
|
*/
|
|
59
|
+
/**
|
|
60
|
+
* Every write result names the library it landed in, and every undo is scoped
|
|
61
|
+
* to that one library. Engine DJ propagates a playlist change to another
|
|
62
|
+
* connected library by itself -- measured 2026-09-01: an edit made to the
|
|
63
|
+
* library on the computer appeared on the USB drive after Engine was next
|
|
64
|
+
* launched, the copy carrying the very timestamp this server's INSERT had
|
|
65
|
+
* written. An undo call cannot reach that copy, and reports success anyway,
|
|
66
|
+
* because within its own library it did exactly what it promised.
|
|
67
|
+
*
|
|
68
|
+
* Stated in the description, not just the README, because the caller who has
|
|
69
|
+
* to act on it is the model holding the undo.
|
|
70
|
+
*/
|
|
71
|
+
const UNDO_SCOPE_NOTE = "`undo` reverses this edit in ONE library: the one the result's `library` field names. " +
|
|
72
|
+
"Engine DJ copies playlist changes between connected libraries on its own, so launching it " +
|
|
73
|
+
"with a second library attached can leave a copy of this edit there -- and no undo call " +
|
|
74
|
+
"reaches that copy. With two libraries connected (a USB drive and its copy on the computer " +
|
|
75
|
+
"is the usual case), undo separately against each. ";
|
|
59
76
|
const LIBRARY_SELECTION_NOTE = "With more than one library connected, pass `library` (a uuid or path from list_libraries, " +
|
|
60
77
|
"either the ~/... form or the absolute one) to choose which one; the default is the " +
|
|
61
78
|
"supported library with the most tracks.";
|
|
79
|
+
/**
|
|
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.
|
|
83
|
+
*/
|
|
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.";
|
|
62
87
|
function reply(value) {
|
|
63
88
|
return {
|
|
64
89
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
@@ -222,6 +247,47 @@ export async function createServer(opts = {}) {
|
|
|
222
247
|
return state;
|
|
223
248
|
return fresh;
|
|
224
249
|
};
|
|
250
|
+
/**
|
|
251
|
+
* `acquire` for the write tools: identical, except that an omitted
|
|
252
|
+
* `library` must resolve to exactly one candidate.
|
|
253
|
+
*
|
|
254
|
+
* `pickDefaultLibrary` breaks a tie on root-scan order, which is
|
|
255
|
+
* deterministic and, for a read, fine -- libraries tie because one is a
|
|
256
|
+
* copy of the other, so either answer is very nearly the same answer, and
|
|
257
|
+
* making a read demand a `library` it does not care about would be noise.
|
|
258
|
+
*
|
|
259
|
+
* A write is not that. The choice decides which physical disk changes, and
|
|
260
|
+
* one of the two is the drive the DJ performs from; root-scan order is not
|
|
261
|
+
* a reason to pick it. Measured 2026-09-01: the computer's library and the
|
|
262
|
+
* USB drive both held 257 tracks, tied precisely because one was a copy of
|
|
263
|
+
* the other.
|
|
264
|
+
*
|
|
265
|
+
* Only the omitted case refuses. A caller who named a library gets it, tie
|
|
266
|
+
* or no tie -- the ambiguity being refused here is the server's, not theirs.
|
|
267
|
+
*
|
|
268
|
+
* Rescans first, because `knownList()` is a cache that deliberately keeps a
|
|
269
|
+
* library a later scan cannot see -- so a momentarily locked drive does not
|
|
270
|
+
* vanish from list_libraries. For a tie check that is wrong in the
|
|
271
|
+
* direction that bites: pull the USB drive and one library is left, but the
|
|
272
|
+
* cache still holds two, and the write is refused naming a drive that is no
|
|
273
|
+
* longer there. rescanLibraries() forgets a candidate whose path is gone,
|
|
274
|
+
* which is exactly the distinction wanted here, and it also lets a drive
|
|
275
|
+
* plugged in mid-session be seen at all.
|
|
276
|
+
*
|
|
277
|
+
* The cost is one filesystem probe per write, against a write that is about
|
|
278
|
+
* to copy the entire database for its pre-write snapshot. Reads are left
|
|
279
|
+
* alone: they run far more often and a stale pick between two copies is not
|
|
280
|
+
* worth a probe apiece.
|
|
281
|
+
*/
|
|
282
|
+
const acquireForWrite = async (requested) => {
|
|
283
|
+
if (requested === undefined) {
|
|
284
|
+
rescanLibraries();
|
|
285
|
+
const tied = defaultLibraryTies(knownList());
|
|
286
|
+
if (tied.length > 1)
|
|
287
|
+
return ambiguousLibrary(tied);
|
|
288
|
+
}
|
|
289
|
+
return acquire(requested);
|
|
290
|
+
};
|
|
225
291
|
/**
|
|
226
292
|
* Shared by the engine://libraries resource and the list_libraries tool so
|
|
227
293
|
* the two cannot drift in shape, while differing in exactly one respect:
|
|
@@ -371,6 +437,10 @@ export async function createServer(opts = {}) {
|
|
|
371
437
|
title: "Audit the collection",
|
|
372
438
|
description: `Run collection health checks. Available: ${AUDIT_CHECKS.join(", ")}. ` +
|
|
373
439
|
`missing_files resolves each track against the selected library's own folder. ` +
|
|
440
|
+
`path_form_mismatch finds files that are there, but under a name in a different Unicode ` +
|
|
441
|
+
`normalization form from the stored path -- macOS opens them anyway, Linux does not ` +
|
|
442
|
+
`(measured on its exFAT driver), and Engine OS on a player is Linux, so these may fail ` +
|
|
443
|
+
`to load on hardware while missing_files on a Mac reports nothing. ` +
|
|
374
444
|
`no_cues means "no hot cue is set" -- the quickCues blob is decoded for this, since ` +
|
|
375
445
|
`Engine writes one to every analysed track whether or not a pad is used -- while ` +
|
|
376
446
|
`no_beatgrid means the beatData blob is absent or empty. ` +
|
|
@@ -443,6 +513,10 @@ export async function createServer(opts = {}) {
|
|
|
443
513
|
"write of this session; it is a recovery route for a damaged library, NOT an undo. " +
|
|
444
514
|
"Restoring it reverts the entire library to that moment, discarding everything " +
|
|
445
515
|
"Engine DJ has written since (play counts, imports, cue and beatgrid edits). " +
|
|
516
|
+
"The result's `library` field names which library this went into. Engine DJ copies " +
|
|
517
|
+
"playlist changes between connected libraries on its own (measured for an edit to an " +
|
|
518
|
+
"existing playlist), so with a second library attached the new playlist may appear " +
|
|
519
|
+
"there too. " +
|
|
446
520
|
"No existing playlist is renamed, reordered, emptied or deleted, and no track, cue or " +
|
|
447
521
|
"beatgrid is touched. The one existing row that moves is the previous last playlist's " +
|
|
448
522
|
"link, and Engine's own insert trigger is what moves it. " +
|
|
@@ -453,11 +527,11 @@ export async function createServer(opts = {}) {
|
|
|
453
527
|
"library is unchanged and \"committed_unverified\" when the write may have gone " +
|
|
454
528
|
"through but could not be verified. track_ids may be empty (an empty playlist); a " +
|
|
455
529
|
"track id may appear at most once. " +
|
|
456
|
-
LIBRARY_SELECTION_NOTE,
|
|
530
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
|
|
457
531
|
inputSchema: { ...CreatePlaylistInput.shape, library: LibraryArg },
|
|
458
532
|
annotations: RW,
|
|
459
533
|
}, async (args) => {
|
|
460
|
-
const state = await
|
|
534
|
+
const state = await acquireForWrite(args.library);
|
|
461
535
|
if (isEngineError(state))
|
|
462
536
|
return reply(state);
|
|
463
537
|
return reply(await runCreatePlaylist(state.lib.path, state.lib.uuid, args, opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups")));
|
|
@@ -486,11 +560,12 @@ export async function createServer(opts = {}) {
|
|
|
486
560
|
"session's first write, discarding every play count, import, cue and beatgrid change " +
|
|
487
561
|
"Engine DJ has recorded since -- not just this one edit. backup_path is only a " +
|
|
488
562
|
"last-resort recovery route for a damaged library, never an undo. " +
|
|
489
|
-
|
|
563
|
+
UNDO_SCOPE_NOTE +
|
|
564
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
|
|
490
565
|
inputSchema: { ...AddTracksToPlaylistInput.shape, library: LibraryArg },
|
|
491
566
|
annotations: RW,
|
|
492
567
|
}, async (args) => {
|
|
493
|
-
const state = await
|
|
568
|
+
const state = await acquireForWrite(args.library);
|
|
494
569
|
if (isEngineError(state))
|
|
495
570
|
return reply(state);
|
|
496
571
|
return reply(await runAddTracksToPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
|
|
@@ -524,11 +599,12 @@ export async function createServer(opts = {}) {
|
|
|
524
599
|
"else. Preferred over restoring " +
|
|
525
600
|
"backup_path, which reverts the WHOLE library to before this session's first write, " +
|
|
526
601
|
"discarding everything Engine DJ has recorded since -- not just this edit. " +
|
|
527
|
-
|
|
602
|
+
UNDO_SCOPE_NOTE +
|
|
603
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
|
|
528
604
|
inputSchema: { ...RemoveTracksFromPlaylistInput.shape, library: LibraryArg },
|
|
529
605
|
annotations: RW_DESTRUCTIVE,
|
|
530
606
|
}, async (args) => {
|
|
531
|
-
const state = await
|
|
607
|
+
const state = await acquireForWrite(args.library);
|
|
532
608
|
if (isEngineError(state))
|
|
533
609
|
return reply(state);
|
|
534
610
|
return reply(await runRemoveTracksFromPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
|
|
@@ -550,11 +626,12 @@ export async function createServer(opts = {}) {
|
|
|
550
626
|
"backup_path, which reverts the " +
|
|
551
627
|
"WHOLE library to before this session's first write, discarding everything Engine DJ " +
|
|
552
628
|
"has recorded since -- not just this reorder. " +
|
|
553
|
-
|
|
629
|
+
UNDO_SCOPE_NOTE +
|
|
630
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
|
|
554
631
|
inputSchema: { ...ReorderPlaylistInput.shape, library: LibraryArg },
|
|
555
632
|
annotations: RW_DESTRUCTIVE,
|
|
556
633
|
}, async (args) => {
|
|
557
|
-
const state = await
|
|
634
|
+
const state = await acquireForWrite(args.library);
|
|
558
635
|
if (isEngineError(state))
|
|
559
636
|
return reply(state);
|
|
560
637
|
return reply(await runReorderPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
|
|
@@ -630,6 +707,18 @@ other.
|
|
|
630
707
|
- \`Track.path\` is relative to the \`Engine Library\` folder and usually
|
|
631
708
|
contains \`..\`. The SQL function \`abs_path(path)\` resolves it against
|
|
632
709
|
this library's location; the home prefix comes back folded to \`~\`.
|
|
710
|
+
- \`Track.streamingSource\`, \`uri\` and \`streamingFlags\` are reported to
|
|
711
|
+
decide whether Engine OS streams a track (from Dropbox) instead of reading
|
|
712
|
+
the file. Not measured here: on both reference libraries \`streamingSource\`
|
|
713
|
+
and \`uri\` are NULL on every track, and \`streamingFlags\` is 5 on about
|
|
714
|
+
half of them -- tracks that load and play -- so \`streamingFlags\` on its own
|
|
715
|
+
says nothing about whether a track will load. Selectable as the fields
|
|
716
|
+
\`streaming_source\`, \`streaming_flags\` and \`uri\`; \`uri\` is redacted like
|
|
717
|
+
\`path\`, including a home directory percent-encoded inside it.
|
|
718
|
+
- SQLite's \`LOWER()\` folds ASCII only: \`LOWER('ЭЙФОРИЯ')\` comes back
|
|
719
|
+
unchanged. To compare names regardless of case in any script, use
|
|
720
|
+
\`fold(text)\` -- one Unicode normalization form, lower-cased by Unicode
|
|
721
|
+
rules. It runs per row, like every function here.
|
|
633
722
|
- A track's natural key across drives is \`(originDatabaseUuid, originTrackId)\`.
|
|
634
723
|
- \`PerformanceData\`'s blob columns are binary and cannot be read with SQL.
|
|
635
724
|
Engine writes \`quickCues\`, \`loops\`, \`beatData\` and
|
package/dist/store/backup.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { backup } from "node:sqlite";
|
|
1
2
|
import { type EngineError } from "../errors.js";
|
|
2
3
|
/**
|
|
3
4
|
* The snapshots to delete, oldest first, from a directory listing.
|
|
@@ -23,4 +24,21 @@ import { type EngineError } from "../errors.js";
|
|
|
23
24
|
* predate the tagged scheme, so they predate anything written since.
|
|
24
25
|
*/
|
|
25
26
|
export declare function evictable(names: string[], uuid: string, tag: string): string[];
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Partial copies in this library's namespace whose writer is gone.
|
|
29
|
+
*
|
|
30
|
+
* A snapshot is copied to `<final name>.partial-<pid>` and renamed into place
|
|
31
|
+
* only once backup() has resolved (see snapshotLibrary). A process killed
|
|
32
|
+
* mid-copy never gets to clean up after itself, so its partial stays behind:
|
|
33
|
+
* harmless to rotation, which counts only `.db` names, but a full-size file
|
|
34
|
+
* nobody will ever finish.
|
|
35
|
+
*
|
|
36
|
+
* The pid is what separates such an orphan from another server's copy that is
|
|
37
|
+
* being written right now -- two servers can snapshot one library at once --
|
|
38
|
+
* so only a pid with no live process behind it is reclaimed. `prefix` keeps it
|
|
39
|
+
* to this library, exactly as rotation is kept to it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function abandonedPartials(names: string[], prefix: string, isAlive?: (pid: number) => boolean): string[];
|
|
42
|
+
export declare function snapshotLibrary(mdbPath: string, uuid: string, baseDir: string, deps?: {
|
|
43
|
+
backup?: typeof backup;
|
|
44
|
+
}): Promise<string | EngineError>;
|
package/dist/store/backup.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Engine Library folder -- the rule that no file is created there holds for
|
|
10
10
|
// writes exactly as it did for reads.
|
|
11
11
|
import { DatabaseSync, backup } from "node:sqlite";
|
|
12
|
-
import { mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
12
|
+
import { mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { err } from "../errors.js";
|
|
15
15
|
import { libraryTag } from "../paths.js";
|
|
@@ -74,7 +74,49 @@ export function evictable(names, uuid, tag) {
|
|
|
74
74
|
mine.sort((a, b) => (a.old !== b.old ? (a.old ? -1 : 1) : a.stamp < b.stamp ? -1 : a.stamp > b.stamp ? 1 : 0));
|
|
75
75
|
return mine.slice(0, Math.max(0, mine.length - KEEP)).map((x) => x.name);
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
/** Whether a process with this pid exists. EPERM means it does and is not ours to signal. */
|
|
78
|
+
function processAlive(pid) {
|
|
79
|
+
try {
|
|
80
|
+
process.kill(pid, 0);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
return e.code === "EPERM";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Partial copies in this library's namespace whose writer is gone.
|
|
89
|
+
*
|
|
90
|
+
* A snapshot is copied to `<final name>.partial-<pid>` and renamed into place
|
|
91
|
+
* only once backup() has resolved (see snapshotLibrary). A process killed
|
|
92
|
+
* mid-copy never gets to clean up after itself, so its partial stays behind:
|
|
93
|
+
* harmless to rotation, which counts only `.db` names, but a full-size file
|
|
94
|
+
* nobody will ever finish.
|
|
95
|
+
*
|
|
96
|
+
* The pid is what separates such an orphan from another server's copy that is
|
|
97
|
+
* being written right now -- two servers can snapshot one library at once --
|
|
98
|
+
* so only a pid with no live process behind it is reclaimed. `prefix` keeps it
|
|
99
|
+
* to this library, exactly as rotation is kept to it.
|
|
100
|
+
*/
|
|
101
|
+
export function abandonedPartials(names, prefix, isAlive = processAlive) {
|
|
102
|
+
// With or without `-journal`: SQLite keeps a rollback journal beside the
|
|
103
|
+
// copy while backup() runs (measured -- it exists mid-copy and is gone once
|
|
104
|
+
// backup() resolves), so a process killed partway leaves both behind. They
|
|
105
|
+
// share the pid, so a live copy's journal is spared along with the copy.
|
|
106
|
+
const partial = /\.db\.partial-(\d+)(-journal)?$/;
|
|
107
|
+
return names.filter((name) => {
|
|
108
|
+
if (!name.startsWith(prefix))
|
|
109
|
+
return false;
|
|
110
|
+
const m = partial.exec(name);
|
|
111
|
+
return m !== null && !isAlive(Number(m[1]));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
export async function snapshotLibrary(mdbPath, uuid, baseDir,
|
|
115
|
+
// Injectable so a test can make the copy fail after it has started writing
|
|
116
|
+
// -- the one failure that matters here, and one no real error reachable
|
|
117
|
+
// from a test produces (they all fail before the destination is touched).
|
|
118
|
+
deps = {}) {
|
|
119
|
+
const copy = deps.backup ?? backup;
|
|
78
120
|
// node:sqlite stopped needing a flag in 22.13, which is where this
|
|
79
121
|
// project's floor used to sit -- but backup() only arrived in 22.16. On
|
|
80
122
|
// 22.13 through 22.15 the read path works perfectly and this one throws
|
|
@@ -82,11 +124,12 @@ export async function snapshotLibrary(mdbPath, uuid, baseDir) {
|
|
|
82
124
|
// run against the declared floor. `engines` now says 22.16, and npm only
|
|
83
125
|
// enforces that under engine-strict, so the check is here too: a version
|
|
84
126
|
// number a user can act on beats a TypeError from inside a dependency.
|
|
85
|
-
if (typeof
|
|
127
|
+
if (typeof copy !== "function") {
|
|
86
128
|
return err("library_unreadable", `This Node cannot snapshot a library before writing to it: node:sqlite gained backup() in ` +
|
|
87
129
|
`22.16.0 and this is ${process.version}. Upgrade Node, or run without --allow-writes.`);
|
|
88
130
|
}
|
|
89
131
|
let src;
|
|
132
|
+
let partial;
|
|
90
133
|
try {
|
|
91
134
|
mkdirSync(baseDir, { recursive: true });
|
|
92
135
|
src = new DatabaseSync(mdbPath, { readOnly: true });
|
|
@@ -98,16 +141,41 @@ export async function snapshotLibrary(mdbPath, uuid, baseDir) {
|
|
|
98
141
|
// the same tag server.ts's sidecarBaseFor uses to keep two such
|
|
99
142
|
// libraries' indexes apart (see paths.ts).
|
|
100
143
|
const prefix = `${uuid}-${libraryTag(mdbPath)}-`;
|
|
144
|
+
// Before copying, not after: a copy that died because the disk filled up
|
|
145
|
+
// left its partial behind, and this is exactly when that space is wanted.
|
|
146
|
+
for (const dead of abandonedPartials(readdirSync(baseDir), prefix)) {
|
|
147
|
+
rmSync(join(baseDir, dead), { force: true });
|
|
148
|
+
}
|
|
149
|
+
// Copied under a name rotation does not count and nobody would restore,
|
|
150
|
+
// then renamed into place only once backup() has resolved. Written
|
|
151
|
+
// straight to the final name, a copy that died partway was an incomplete
|
|
152
|
+
// database indistinguishable from a good one: rotation took it for the
|
|
153
|
+
// newest and evicted a real snapshot for it, and "restore the latest
|
|
154
|
+
// backup" would have restored it (#3). rename() within one directory is
|
|
155
|
+
// atomic, so the final name only ever holds a finished copy.
|
|
101
156
|
const dest = join(baseDir, `${prefix}${stamp()}.db`);
|
|
102
|
-
|
|
157
|
+
partial = `${dest}.partial-${process.pid}`;
|
|
158
|
+
await copy(src, partial);
|
|
103
159
|
src.close();
|
|
104
160
|
src = undefined;
|
|
161
|
+
renameSync(partial, dest);
|
|
162
|
+
partial = undefined;
|
|
105
163
|
for (const stale of evictable(readdirSync(baseDir), uuid, libraryTag(mdbPath))) {
|
|
106
164
|
rmSync(join(baseDir, stale), { force: true });
|
|
107
165
|
}
|
|
108
166
|
return dest;
|
|
109
167
|
}
|
|
110
168
|
catch (e) {
|
|
169
|
+
// Safe to delete, which the destination itself never was: this name
|
|
170
|
+
// carries this process's pid and a stamp whose counter never repeats
|
|
171
|
+
// within a process, so whatever is there was created by this call. The
|
|
172
|
+
// earlier objection to cleaning up on failure -- deleting a file at a path
|
|
173
|
+
// we may not have created -- does not apply to a path no one else can
|
|
174
|
+
// produce.
|
|
175
|
+
if (partial) {
|
|
176
|
+
rmSync(partial, { force: true });
|
|
177
|
+
rmSync(`${partial}-journal`, { force: true });
|
|
178
|
+
}
|
|
111
179
|
return err("library_unreadable", `Could not snapshot ${mdbPath} before writing: ${String(e)}`);
|
|
112
180
|
}
|
|
113
181
|
finally {
|
package/dist/store/write.d.ts
CHANGED
|
@@ -4,8 +4,24 @@ export interface CreatePlaylistResult {
|
|
|
4
4
|
playlist_id: number;
|
|
5
5
|
title: string;
|
|
6
6
|
tracks_added: number;
|
|
7
|
+
library: LibraryRef;
|
|
7
8
|
backup_path: string;
|
|
8
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Which library a write actually landed in. Two connected libraries -- a USB
|
|
12
|
+
* drive and its copy on the computer -- is the ordinary setup, so "which one
|
|
13
|
+
* did that go to" is a question every write result has to answer on its own,
|
|
14
|
+
* without the caller re-deriving it from an argument it may not have passed.
|
|
15
|
+
*
|
|
16
|
+
* It is also the context `undo` needs: an undo reverses the edit in this
|
|
17
|
+
* library and cannot reach a copy Engine DJ has since propagated to another
|
|
18
|
+
* one (measured 2026-09-01, see README).
|
|
19
|
+
*/
|
|
20
|
+
export interface LibraryRef {
|
|
21
|
+
uuid: string;
|
|
22
|
+
/** The m.db path, in the `~/...` form list_libraries prints. */
|
|
23
|
+
path: string;
|
|
24
|
+
}
|
|
9
25
|
/**
|
|
10
26
|
* What editing an existing playlist's entries returns. One shape for
|
|
11
27
|
* add/remove/reorder alike -- each op leaves the fields it did not touch
|
|
@@ -37,6 +53,7 @@ export interface EditResult {
|
|
|
37
53
|
undo_complete: boolean;
|
|
38
54
|
/** Set only when `undo_complete` is false: which positions have no way back, and why. */
|
|
39
55
|
undo_note?: string;
|
|
56
|
+
library: LibraryRef;
|
|
40
57
|
backup_path: string;
|
|
41
58
|
}
|
|
42
59
|
/** One step of the tool call that would undo an edit, in the shape a client replays it. */
|
package/dist/store/write.js
CHANGED
|
@@ -14,6 +14,7 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
14
14
|
import { err, isEngineError, libraryNeedsRecovery } from "../errors.js";
|
|
15
15
|
import { snapshotLibrary } from "./backup.js";
|
|
16
16
|
import { hasHotJournal } from "./connections.js";
|
|
17
|
+
import { redactPath } from "../paths.js";
|
|
17
18
|
/**
|
|
18
19
|
* `detail` discriminator values for the EngineError this module returns.
|
|
19
20
|
* Stable across releases so a caller can decide "is the library still what
|
|
@@ -449,7 +450,10 @@ async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
|
|
|
449
450
|
const verifyErr = verifyAfterCommit(db, subject, backupPath);
|
|
450
451
|
if (verifyErr)
|
|
451
452
|
return verifyErr;
|
|
452
|
-
|
|
453
|
+
// Filled in here, alongside backup_path, for the same reason: it is the
|
|
454
|
+
// one place that knows the write succeeded, and doing it per-op would let
|
|
455
|
+
// a new op ship without it.
|
|
456
|
+
return { ...result, library: { uuid, path: redactPath(mdbPath) }, backup_path: backupPath };
|
|
453
457
|
}
|
|
454
458
|
catch (e) {
|
|
455
459
|
return classifyWriteFailure(e, commit, subject, mdbPath, backupPath, db, open);
|
|
@@ -1157,8 +1161,10 @@ export async function reorderPlaylist(mdbPath, uuid, input, opts) {
|
|
|
1157
1161
|
return withWriteTransaction(mdbPath, uuid, subject, opts, (db) => {
|
|
1158
1162
|
// Re-read: BEGIN IMMEDIATE is the first moment nothing else can change
|
|
1159
1163
|
// the chain, and gating on the pre-check's read alone would be trusting
|
|
1160
|
-
// one that could already be stale.
|
|
1161
|
-
|
|
1164
|
+
// one that could already be stale. Read once and kept: the links rewritten
|
|
1165
|
+
// below are exactly the ones this check passed, not a second read of them.
|
|
1166
|
+
const rows = readChain(db, listId);
|
|
1167
|
+
const gate = checkChain(rows);
|
|
1162
1168
|
if (!gate.ok) {
|
|
1163
1169
|
rollback(db);
|
|
1164
1170
|
return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
|
|
@@ -1183,7 +1189,7 @@ export async function reorderPlaylist(mdbPath, uuid, input, opts) {
|
|
|
1183
1189
|
// returns; each entry's new successor is the id that follows it there,
|
|
1184
1190
|
// or 0 for the new tail.
|
|
1185
1191
|
const newSeq = requestedOrder.map((p) => gate.order[p - 1]);
|
|
1186
|
-
const currentNext = new Map(
|
|
1192
|
+
const currentNext = new Map(rows.map((r) => [r.id, r.next]));
|
|
1187
1193
|
const link = db.prepare("UPDATE PlaylistEntity SET nextEntityId = ? WHERE id = ?");
|
|
1188
1194
|
for (let i = 0; i < newSeq.length; i++) {
|
|
1189
1195
|
const entryId = newSeq[i];
|
package/dist/tools/audit.d.ts
CHANGED
|
@@ -1,7 +1,35 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type EngineError } from "../errors.js";
|
|
3
3
|
import type { QueryProcess } from "../proc/query-client.js";
|
|
4
|
-
export declare const AUDIT_CHECKS: readonly ["missing_files", "unavailable", "unanalyzed", "no_cues", "no_beatgrid", "missing_key", "suspicious_bpm", "duplicates", "empty_metadata", "orphan_entries"];
|
|
4
|
+
export declare const AUDIT_CHECKS: readonly ["missing_files", "unavailable", "unanalyzed", "no_cues", "no_beatgrid", "missing_key", "suspicious_bpm", "duplicates", "empty_metadata", "orphan_entries", "path_form_mismatch"];
|
|
5
|
+
/**
|
|
6
|
+
* The checks that read the filesystem rather than the database: each needs
|
|
7
|
+
* the stored paths themselves, and each answers something no SQL can.
|
|
8
|
+
*/
|
|
9
|
+
export declare const FILESYSTEM_CHECKS: readonly ["missing_files", "path_form_mismatch"];
|
|
10
|
+
/**
|
|
11
|
+
* Whether a stored path resolves on disk only by ignoring Unicode
|
|
12
|
+
* normalization.
|
|
13
|
+
*
|
|
14
|
+
* Measured 2026-09-11 on Linux 6.17's in-kernel exFAT driver: a path in NFC
|
|
15
|
+
* does not find a file whose name was written in NFD, while a path that
|
|
16
|
+
* differs only in case does. macOS forgives both, which is why missing_files
|
|
17
|
+
* -- asking the host's own lookup -- reports nothing on a Mac for a file that
|
|
18
|
+
* Linux, and so plausibly Engine OS on a player, would not open. On the
|
|
19
|
+
* maintainer's USB drive that was 8 tracks: file and folder names macOS wrote
|
|
20
|
+
* in NFD behind the NFC paths Engine stored.
|
|
21
|
+
*
|
|
22
|
+
* Walks the path one component at a time against the real listings, so it
|
|
23
|
+
* answers the same on every host. A component matching exactly, or in case
|
|
24
|
+
* only, is fine -- exFAT and Windows ignore case, so reporting it would be
|
|
25
|
+
* noise. One found only when both sides are brought to NFC marks the path.
|
|
26
|
+
* One not found at all ends the walk unmarked: that file is missing, which is
|
|
27
|
+
* missing_files' business, not this check's.
|
|
28
|
+
*
|
|
29
|
+
* `listDir` is injected so the comparison can be exercised against an exact
|
|
30
|
+
* listing on any host; the audit passes a cached readdirSync.
|
|
31
|
+
*/
|
|
32
|
+
export declare function onlyFoundByIgnoringForm(absPath: string, listDir: (dir: string) => string[] | null): boolean;
|
|
5
33
|
export declare const AuditInput: z.ZodObject<{
|
|
6
34
|
checks: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
7
35
|
}, z.core.$strip>;
|
package/dist/tools/audit.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/tools/audit.ts
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join, parse, sep } from "node:path";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { err, isEngineError } from "../errors.js";
|
|
5
6
|
import { absTrackPath } from "../paths.js";
|
|
@@ -15,7 +16,56 @@ export const AUDIT_CHECKS = [
|
|
|
15
16
|
"duplicates",
|
|
16
17
|
"empty_metadata",
|
|
17
18
|
"orphan_entries",
|
|
19
|
+
"path_form_mismatch",
|
|
18
20
|
];
|
|
21
|
+
/**
|
|
22
|
+
* The checks that read the filesystem rather than the database: each needs
|
|
23
|
+
* the stored paths themselves, and each answers something no SQL can.
|
|
24
|
+
*/
|
|
25
|
+
export const FILESYSTEM_CHECKS = ["missing_files", "path_form_mismatch"];
|
|
26
|
+
/**
|
|
27
|
+
* Whether a stored path resolves on disk only by ignoring Unicode
|
|
28
|
+
* normalization.
|
|
29
|
+
*
|
|
30
|
+
* Measured 2026-09-11 on Linux 6.17's in-kernel exFAT driver: a path in NFC
|
|
31
|
+
* does not find a file whose name was written in NFD, while a path that
|
|
32
|
+
* differs only in case does. macOS forgives both, which is why missing_files
|
|
33
|
+
* -- asking the host's own lookup -- reports nothing on a Mac for a file that
|
|
34
|
+
* Linux, and so plausibly Engine OS on a player, would not open. On the
|
|
35
|
+
* maintainer's USB drive that was 8 tracks: file and folder names macOS wrote
|
|
36
|
+
* in NFD behind the NFC paths Engine stored.
|
|
37
|
+
*
|
|
38
|
+
* Walks the path one component at a time against the real listings, so it
|
|
39
|
+
* answers the same on every host. A component matching exactly, or in case
|
|
40
|
+
* only, is fine -- exFAT and Windows ignore case, so reporting it would be
|
|
41
|
+
* noise. One found only when both sides are brought to NFC marks the path.
|
|
42
|
+
* One not found at all ends the walk unmarked: that file is missing, which is
|
|
43
|
+
* missing_files' business, not this check's.
|
|
44
|
+
*
|
|
45
|
+
* `listDir` is injected so the comparison can be exercised against an exact
|
|
46
|
+
* listing on any host; the audit passes a cached readdirSync.
|
|
47
|
+
*/
|
|
48
|
+
export function onlyFoundByIgnoringForm(absPath, listDir) {
|
|
49
|
+
const { root } = parse(absPath);
|
|
50
|
+
let dir = root;
|
|
51
|
+
let formDiffered = false;
|
|
52
|
+
for (const want of absPath.slice(root.length).split(sep).filter(Boolean)) {
|
|
53
|
+
const names = listDir(dir);
|
|
54
|
+
if (!names)
|
|
55
|
+
return false;
|
|
56
|
+
const upper = want.toUpperCase();
|
|
57
|
+
let hit = names.find((n) => n === want) ?? names.find((n) => n.toUpperCase() === upper);
|
|
58
|
+
if (hit === undefined) {
|
|
59
|
+
const key = want.normalize("NFC").toUpperCase();
|
|
60
|
+
hit = names.find((n) => n.normalize("NFC").toUpperCase() === key);
|
|
61
|
+
if (hit === undefined)
|
|
62
|
+
return false;
|
|
63
|
+
formDiffered = true;
|
|
64
|
+
}
|
|
65
|
+
dir = join(dir, hit);
|
|
66
|
+
}
|
|
67
|
+
return formDiffered;
|
|
68
|
+
}
|
|
19
69
|
export const AuditInput = z.object({ checks: z.array(z.string()).optional() });
|
|
20
70
|
/**
|
|
21
71
|
* Counts plus a small sample, never rows: this result lands in an LLM's
|
|
@@ -79,8 +129,10 @@ const SQL_CHECKS = {
|
|
|
79
129
|
},
|
|
80
130
|
duplicates: {
|
|
81
131
|
id: "t.id",
|
|
82
|
-
|
|
83
|
-
|
|
132
|
+
// fold(), not LOWER(): LOWER is ASCII-only, so a Cyrillic title and the
|
|
133
|
+
// same title in capitals were not grouped (#9). See semantics.ts.
|
|
134
|
+
body: `FROM Track t WHERE fold(TRIM(t.artist)) || '|' || fold(TRIM(t.title)) IN (
|
|
135
|
+
SELECT fold(TRIM(artist)) || '|' || fold(TRIM(title)) FROM Track
|
|
84
136
|
WHERE artist IS NOT NULL AND title IS NOT NULL
|
|
85
137
|
GROUP BY 1 HAVING COUNT(*) > 1)`,
|
|
86
138
|
},
|
|
@@ -138,6 +190,33 @@ export async function auditLibrary(qp, mdbPath, raw) {
|
|
|
138
190
|
out.push({ name, count: missing.length, sample_ids: missing.slice(0, SAMPLE) });
|
|
139
191
|
continue;
|
|
140
192
|
}
|
|
193
|
+
if (name === "path_form_mismatch") {
|
|
194
|
+
// Only a path with a character outside printable ASCII can have a second
|
|
195
|
+
// normalization form, so only those cross the process boundary -- 22 of
|
|
196
|
+
// 257 on the reference library -- and each directory is listed once.
|
|
197
|
+
const res = await qp.run(`SELECT id, path FROM Track WHERE path GLOB '*[^ -~]*' ORDER BY id`);
|
|
198
|
+
if (isEngineError(res))
|
|
199
|
+
return res;
|
|
200
|
+
const listings = new Map();
|
|
201
|
+
const listDir = (d) => {
|
|
202
|
+
if (!listings.has(d)) {
|
|
203
|
+
try {
|
|
204
|
+
listings.set(d, readdirSync(d));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
listings.set(d, null);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return listings.get(d);
|
|
211
|
+
};
|
|
212
|
+
const marked = [];
|
|
213
|
+
for (const row of res.rows) {
|
|
214
|
+
if (onlyFoundByIgnoringForm(absTrackPath(mdbPath, String(row[1])), listDir))
|
|
215
|
+
marked.push(Number(row[0]));
|
|
216
|
+
}
|
|
217
|
+
out.push({ name, count: marked.length, sample_ids: marked.slice(0, SAMPLE) });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
141
220
|
const check = SQL_CHECKS[name];
|
|
142
221
|
const counted = await qp.run(`SELECT COUNT(*) AS c ${check.body}`);
|
|
143
222
|
if (isEngineError(counted))
|
package/dist/tools/playlists.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// src/tools/playlists.ts
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { err, isEngineError } from "../errors.js";
|
|
4
|
-
import { redactPath } from "../paths.js";
|
|
5
4
|
import { loadPlaylistEntries, loadPlaylistTree, resolvePlaylist, } from "../playlists.js";
|
|
6
|
-
import { DEFAULT_FIELDS, FIELD_SQL } from "./search.js";
|
|
5
|
+
import { DEFAULT_FIELDS, FIELD_SQL, presentField } from "./search.js";
|
|
7
6
|
/**
|
|
8
7
|
* Playlists returned in one call. Higher than any real library needs (the
|
|
9
8
|
* reference library has 16) but the result still goes into a model's
|
|
@@ -160,15 +159,7 @@ export async function getPlaylistTracks(qp, raw) {
|
|
|
160
159
|
return res;
|
|
161
160
|
const idx = Object.fromEntries(res.columns.map((c, i) => [c, i]));
|
|
162
161
|
for (const row of res.rows) {
|
|
163
|
-
const track = Object.fromEntries(fields.map((f) =>
|
|
164
|
-
const value = row[idx[f]];
|
|
165
|
-
return [
|
|
166
|
-
f,
|
|
167
|
-
input.redact_paths && f === "path" && typeof value === "string"
|
|
168
|
-
? redactPath(value)
|
|
169
|
-
: value,
|
|
170
|
-
];
|
|
171
|
-
}));
|
|
162
|
+
const track = Object.fromEntries(fields.map((f) => [f, presentField(f, row[idx[f]], input.redact_paths)]));
|
|
172
163
|
byKey.set(entryKey(String(row[idx.__uuid]), Number(row[idx.__origin])), track);
|
|
173
164
|
}
|
|
174
165
|
}
|
package/dist/tools/search.d.ts
CHANGED
|
@@ -10,6 +10,13 @@ export declare const DEFAULT_FIELDS: readonly ["id", "artist", "title", "bpm", "
|
|
|
10
10
|
* validating fields can never drift from this one.
|
|
11
11
|
*/
|
|
12
12
|
export declare const FIELD_SQL: Record<string, string>;
|
|
13
|
+
/**
|
|
14
|
+
* How one projected value is handed back. The single place a path-bearing
|
|
15
|
+
* field is redacted: search_tracks, get_tracks and get_playlist_tracks each
|
|
16
|
+
* carried their own copy of the `path` check, which is how a new path-bearing
|
|
17
|
+
* field ends up redacted in two of the three.
|
|
18
|
+
*/
|
|
19
|
+
export declare function presentField(field: string, value: unknown, redact: boolean): unknown;
|
|
13
20
|
export declare const SearchInput: z.ZodObject<{
|
|
14
21
|
q: z.ZodOptional<z.ZodString>;
|
|
15
22
|
bpm: z.ZodOptional<z.ZodObject<{
|
package/dist/tools/search.js
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { err, isEngineError } from "../errors.js";
|
|
5
5
|
import { camelotNeighbours } from "../semantics.js";
|
|
6
|
-
import { redactPath } from "../paths.js";
|
|
6
|
+
import { redactPath, redactUri } from "../paths.js";
|
|
7
7
|
import { ENTRY_TRACK_MATCH, resolvePlaylist } from "../playlists.js";
|
|
8
8
|
export const DEFAULT_FIELDS = ["id", "artist", "title", "bpm", "camelot", "rating"];
|
|
9
9
|
const MAX_LIMIT = 200;
|
|
@@ -47,7 +47,29 @@ export const FIELD_SQL = {
|
|
|
47
47
|
date_added: "t.dateAdded",
|
|
48
48
|
last_played: "t.timeLastPlayed",
|
|
49
49
|
is_analyzed: "t.isAnalyzed",
|
|
50
|
+
// Reported to decide whether Engine OS streams a track (from Dropbox)
|
|
51
|
+
// rather than reading it from disk -- not measured here: NULL, NULL and a
|
|
52
|
+
// mix of NULL/0/5 on both reference libraries, whose tracks all load. Opt-in
|
|
53
|
+
// only, for diagnosing a track that will not load (#8).
|
|
54
|
+
streaming_source: "t.streamingSource",
|
|
55
|
+
streaming_flags: "t.streamingFlags",
|
|
56
|
+
uri: "t.uri",
|
|
50
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* How one projected value is handed back. The single place a path-bearing
|
|
60
|
+
* field is redacted: search_tracks, get_tracks and get_playlist_tracks each
|
|
61
|
+
* carried their own copy of the `path` check, which is how a new path-bearing
|
|
62
|
+
* field ends up redacted in two of the three.
|
|
63
|
+
*/
|
|
64
|
+
export function presentField(field, value, redact) {
|
|
65
|
+
if (!redact || typeof value !== "string")
|
|
66
|
+
return value;
|
|
67
|
+
if (field === "path")
|
|
68
|
+
return redactPath(value);
|
|
69
|
+
if (field === "uri")
|
|
70
|
+
return redactUri(value);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
51
73
|
export const SearchInput = z.object({
|
|
52
74
|
q: z.string().optional(),
|
|
53
75
|
bpm: z
|
|
@@ -354,12 +376,7 @@ export async function searchTracks(qp, raw) {
|
|
|
354
376
|
if (isEngineError(res))
|
|
355
377
|
return res;
|
|
356
378
|
const idx = Object.fromEntries(res.columns.map((c, i) => [c, i]));
|
|
357
|
-
const tracks = res.rows.map((row) => Object.fromEntries(fields.map((f) =>
|
|
358
|
-
const value = row[idx[f]];
|
|
359
|
-
return [f, input.redact_paths && f === "path" && typeof value === "string"
|
|
360
|
-
? redactPath(value)
|
|
361
|
-
: value];
|
|
362
|
-
})));
|
|
379
|
+
const tracks = res.rows.map((row) => Object.fromEntries(fields.map((f) => [f, presentField(f, row[idx[f]], input.redact_paths)])));
|
|
363
380
|
let next_cursor;
|
|
364
381
|
if (res.rows.length === limit) {
|
|
365
382
|
const last = res.rows[res.rows.length - 1];
|
package/dist/tools/tracks.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// src/tools/tracks.ts
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { err, isEngineError } from "../errors.js";
|
|
4
|
-
import { DEFAULT_FIELDS, FIELD_SQL } from "./search.js";
|
|
5
|
-
import { redactPath } from "../paths.js";
|
|
4
|
+
import { DEFAULT_FIELDS, FIELD_SQL, presentField } from "./search.js";
|
|
6
5
|
export const GetTracksInput = z.object({
|
|
7
6
|
ids: z.array(z.number().int().positive()).min(1).max(200),
|
|
8
7
|
fields: z.array(z.string()).optional(),
|
|
@@ -38,10 +37,7 @@ export async function getTracks(qp, raw) {
|
|
|
38
37
|
const idx = Object.fromEntries(res.columns.map((c, i) => [c, i]));
|
|
39
38
|
const byId = new Map();
|
|
40
39
|
for (const row of res.rows) {
|
|
41
|
-
const track = Object.fromEntries(fields.map((f) =>
|
|
42
|
-
const value = row[idx[f]];
|
|
43
|
-
return [f, redact_paths && f === "path" && typeof value === "string" ? redactPath(value) : value];
|
|
44
|
-
}));
|
|
40
|
+
const track = Object.fromEntries(fields.map((f) => [f, presentField(f, row[idx[f]], redact_paths)]));
|
|
45
41
|
byId.set(Number(row[idx.__id]), track);
|
|
46
42
|
}
|
|
47
43
|
// Preserve the caller's ordering; missing ids are simply absent.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "engine-dj-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "MCP server for an Engine DJ library: search and audit it, read cues and beatgrids, and build playlists when you ask. Not affiliated with inMusic or Denon DJ.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|