engine-dj-mcp 0.9.2 → 0.11.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.
@@ -6,13 +6,15 @@
6
6
  // The layouts below were derived from, and checked against, a real Engine DJ
7
7
  // library: 257 analysed tracks on a USB export plus 24 in the local history
8
8
  // database, 281 blobs of each kind. What each check proved is recorded next
9
- // to the decoder it justifies. `loops` is the one field whose *contents*
10
- // could not be checked see decodeLoops.
9
+ // to the decoder it justifies. `loops` was the last field to be checked: its
10
+ // contents needed a library with a loop actually saved in one, which arrived
11
+ // later than the rest — see decodeLoops.
11
12
  //
12
13
  // Framing: `quickCues`, `beatData` and `overviewWaveFormData` are Qt
13
14
  // qCompress frames (4-byte big-endian uncompressed length, then a raw zlib
14
15
  // stream); the declared length matched the inflated length for all 281 of
15
- // each. `loops` is NOT compressed and NOT framed — it is 192 raw bytes, and
16
+ // each. `loops` is NOT compressed and NOT framed — 192 raw bytes plus any
17
+ // label text its slots carry, and
16
18
  // running it through the qCompress path reads its little-endian int64 count
17
19
  // of 8 as a big-endian length of 134217728 and hands zlib bytes it rejects.
18
20
  //
@@ -35,10 +37,12 @@ import { qUncompress, Reader, DecodeError } from "./qcompress.js";
35
37
  * sample count. `status: "ok"` on a verified field is a claim about the
36
38
  * values, not merely about the parse.
37
39
  *
38
- * `"unverified"` still means what it always did: the bytes parsed without
39
- * contradicting the layout, and nothing more. It survives on `loops` because
40
- * no track in the 281 examined has a loop set, so while the slot structure is
41
- * pinned down, the meaning of a *populated* slot is untested.
40
+ * `"unverified"` means the bytes parsed without contradicting the layout, and
41
+ * nothing more. No field carries it today: `loops` was the last one, and it
42
+ * held the marker only for as long as no library available had a loop saved in
43
+ * it. One saved loop supplied the missing prediction see `decodeLoops` and
44
+ * the marker is kept here because it is what a future field decoded from an
45
+ * unpopulated example would deserve.
42
46
  *
43
47
  * The marker is about the *bytes*: which offset holds which field, and what
44
48
  * the numbers there mean. It is not a claim about every English word this
@@ -252,23 +256,27 @@ export function hasCueSet(buf) {
252
256
  * (23 bytes with an empty label)
253
257
  *
254
258
  * Evidence for the framing and the slot grid: 8 + 8×23 = 192, the size of
255
- * all 281 real blobs, every one of which parsed to exactly its last byte;
259
+ * every unpopulated real blob, each of which parsed to exactly its last byte;
256
260
  * the little-endian count reads as 8, and the little-endian -1.0 sentinel
257
261
  * (`000000000000f0bf`) appears at the offsets this layout predicts in all
258
262
  * 2248 slots. Reading the count big-endian gives 134217728, which is what
259
263
  * made every real track decode as `unsupported` before.
260
264
  *
261
- * This layout keeps `layout: "unverified"`. Not one of the 2248 slots is
262
- * populated this library has no saved loops so while the slot grid is
263
- * pinned down by 2248 sentinels, the six bytes after each slot's two doubles
264
- * are zero everywhere, and nothing here distinguishes start/end from
265
- * end/start, or fixes the order of the flag and colour bytes. A populated
266
- * loop is the one thing the available data cannot exercise, so it is not
267
- * claimed as verified.
265
+ * The sentinels pin the slot grid, but an empty slot cannot say which double
266
+ * is the start, what unit the doubles are in, or what the six trailing bytes
267
+ * mean so this layout carried `unverified` for as long as no library had a
268
+ * saved loop in it. The `saved-loop` golden fixture is that loop, and it
269
+ * settles all three at once. Its slot reads label "Loop 1", start 2129416.08
270
+ * and end 2203430.06, which at the 44100 Hz `beatData` declares for the track
271
+ * is 1.678321678 s — exactly four beats at the 143 BPM Engine analysed, to
272
+ * 3.6e-15 s. Nothing but the right field order, the right unit and the right
273
+ * endianness lands on a whole number of beats, so the layout is verified.
274
+ * The trailing bytes read `01 01` (start set, end set) and four colour bytes,
275
+ * which is the same tail `quickCues` slots carry.
268
276
  */
269
277
  export function decodeLoops(buf, sampleRate) {
270
278
  const rate = usableRate(sampleRate);
271
- return guard(buf, LAYOUT_UNVERIFIED, "raw", (r) => {
279
+ return guard(buf, LAYOUT_VERIFIED, "raw", (r) => {
272
280
  const slots = boundedCount(r, true, MAX_ITEMS, `loop slot count`);
273
281
  const items = [];
274
282
  for (let i = 0; i < slots; i++) {
package/dist/discovery.js CHANGED
@@ -12,8 +12,9 @@ export function readLibraryInfo(mdbPath) {
12
12
  }
13
13
  if (hasHotJournal(mdbPath)) {
14
14
  // Same check openQueryConnection makes before opening (store/connections.ts):
15
- // recovering a hot journal needs a write, which this project never
16
- // performs, even to probe a library. Caught here first so the specific,
15
+ // recovering a hot journal needs a write, and discovery never opens a
16
+ // library writably -- not to probe one, and not to heal one. Caught here
17
+ // first so the specific,
17
18
  // actionable library_needs_recovery reaches the caller instead of the
18
19
  // SELECT below failing with the raw "attempt to write a readonly
19
20
  // database" and landing in the generic library_unreadable catch --
package/dist/errors.d.ts CHANGED
@@ -1,10 +1,29 @@
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"];
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"];
2
2
  export type ErrorCode = (typeof ERROR_CODES)[number];
3
3
  export interface EngineError {
4
4
  error: ErrorCode;
5
5
  message: string;
6
+ /**
7
+ * Free text almost everywhere (store/index-manager.ts passes a raw
8
+ * `e.message` through it), with one exception that is part of the tool
9
+ * contract: on an error from the write path (store/write.ts) this is
10
+ * always exactly `"not_committed"` -- the library is byte-for-byte what it
11
+ * was -- or `"committed_unverified"` -- the write may have landed and could
12
+ * not be confirmed, and `backup_path` below is then set. Those two strings
13
+ * are reserved on that path and must stay stable, because a client reads
14
+ * them to decide whether their library changed.
15
+ */
6
16
  detail?: string;
7
17
  retry_after_ms?: number;
18
+ /**
19
+ * Path to a pre-write snapshot the caller can restore from. Only ever set
20
+ * by the write path, and only on the errors a client cannot safely ignore:
21
+ * `detail === "committed_unverified"`. It is a whole-database snapshot, so
22
+ * restoring it is a recovery route for a damaged library and not an undo
23
+ * of one playlist -- it reverts everything Engine DJ wrote since it was
24
+ * taken.
25
+ */
26
+ backup_path?: string;
8
27
  }
9
28
  export declare function err(error: ErrorCode, message: string, extra?: Omit<EngineError, "error" | "message">): EngineError;
10
29
  /**
package/dist/errors.js CHANGED
@@ -19,6 +19,14 @@ export const ERROR_CODES = [
19
19
  "decode_failed",
20
20
  "invalid_argument",
21
21
  "library_needs_recovery",
22
+ // Write-path codes. There is no writes_not_enabled code: the tool is not
23
+ // registered at all without --allow-writes, and the MCP SDK's own
24
+ // dispatcher rejects a call to an unregistered tool name before any
25
+ // handler in this project runs, so this project never gets the chance to
26
+ // report that condition itself.
27
+ "playlist_exists",
28
+ "unknown_track",
29
+ "duplicate_track",
22
30
  ];
23
31
  export function err(error, message, extra = {}) {
24
32
  return { error, message, ...extra };
package/dist/index.js CHANGED
@@ -3,11 +3,17 @@
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { createServer } from "./server.js";
5
5
  async function main() {
6
- const server = await createServer();
6
+ // MCP clients configure a server as a command plus an args array, so a flag
7
+ // is visible in that configuration and greppable; an environment variable
8
+ // would not be. Writes are off unless this is present.
9
+ const allowWrites = process.argv.includes("--allow-writes");
10
+ // stderr is not the protocol channel, so this is safe for stdio transport,
11
+ // and it puts the mode in the client's log where a user can check it.
12
+ console.error(`engine-dj-mcp: writes ${allowWrites ? "ENABLED (--allow-writes)" : "disabled"}`);
13
+ const server = await createServer({ allowWrites });
7
14
  await server.connect(new StdioServerTransport());
8
15
  }
9
16
  main().catch((e) => {
10
- // stderr is not the protocol channel, so this is safe for stdio transport.
11
17
  console.error("engine-dj-mcp failed to start:", e);
12
18
  process.exit(1);
13
19
  });
package/dist/paths.d.ts CHANGED
@@ -1,4 +1,15 @@
1
1
  export declare function sidecarDir(uuid: string): string;
2
+ /**
3
+ * A short, stable tag for one library *file*, for use wherever a uuid alone
4
+ * would collide. A library copied onto a second drive carries the original's
5
+ * uuid -- an ordinary thing for a DJ to do -- so uuid is not unique across
6
+ * mounted volumes while the path of `m.db` always is.
7
+ *
8
+ * Shared by the sidecar layout (server.ts's sidecarBaseFor) and the backup
9
+ * filenames (store/backup.ts) so the two cannot drift into different ideas
10
+ * of which library they are talking about.
11
+ */
12
+ export declare function libraryTag(mdbPath: string): string;
2
13
  /** Engine stores Track.path relative to the `Engine Library` folder, usually with `..`. */
3
14
  export declare function absTrackPath(mdbPath: string, relative: string): string;
4
15
  /** Candidate locations of `m.db` beneath a filesystem root. */
package/dist/paths.js CHANGED
@@ -1,8 +1,22 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { homedir } from "node:os";
2
3
  import { join, dirname, resolve } from "node:path";
3
4
  export function sidecarDir(uuid) {
4
5
  return join(homedir(), ".engine-dj-mcp", uuid);
5
6
  }
7
+ /**
8
+ * A short, stable tag for one library *file*, for use wherever a uuid alone
9
+ * would collide. A library copied onto a second drive carries the original's
10
+ * uuid -- an ordinary thing for a DJ to do -- so uuid is not unique across
11
+ * mounted volumes while the path of `m.db` always is.
12
+ *
13
+ * Shared by the sidecar layout (server.ts's sidecarBaseFor) and the backup
14
+ * filenames (store/backup.ts) so the two cannot drift into different ideas
15
+ * of which library they are talking about.
16
+ */
17
+ export function libraryTag(mdbPath) {
18
+ return createHash("sha256").update(mdbPath).digest("hex").slice(0, 12);
19
+ }
6
20
  /** Engine stores Track.path relative to the `Engine Library` folder, usually with `..`. */
7
21
  export function absTrackPath(mdbPath, relative) {
8
22
  const engineLibrary = dirname(dirname(mdbPath)); // .../Engine Library/Database2/m.db
@@ -0,0 +1,240 @@
1
+ import { type EngineError } from "./errors.js";
2
+ import type { QueryProcess } from "./proc/query-client.js";
3
+ /**
4
+ * Engine stores both playlist order and playlist-entry order as singly
5
+ * linked lists, not as a position column:
6
+ *
7
+ * Playlist.nextListId — the next sibling under the same parentListId
8
+ * PlaylistEntity.nextEntityId — the next entry in the same listId
9
+ *
10
+ * Both terminate at 0.
11
+ *
12
+ * The schema also ships a `PlaylistPath` view carrying a column literally
13
+ * called `position`, which is the obvious thing to reach for and is wrong.
14
+ * Its `OrderedList` CTE anchors on `WHERE nextListId = 0` and counts
15
+ * *upwards from the tail*, then orders by that count ascending — so its
16
+ * `position` runs backwards. Measured on the reference library (16
17
+ * playlists): walking the chain yields
18
+ * ACID Beach, KatyaBar, Electro 1, Old Fasion 1A 120-130, Playlist, ...
19
+ * which is what Engine DJ shows in its sidebar, while the view's position
20
+ * order is that list exactly reversed (asserted in tests/playlists.test.ts
21
+ * against the same shape). The chain is the source of truth; the view is
22
+ * never read by this project.
23
+ *
24
+ * Everything here therefore walks the chain — defensively. These are linked
25
+ * lists inside a file this server does not own and never writes: a
26
+ * half-completed Engine write, a sync conflict or a partially restored
27
+ * backup can leave a cycle, a link to a row that is gone, or two
28
+ * disconnected runs. None of those may hang the walk, and none may come
29
+ * back as a silently short list that reads like a complete one.
30
+ */
31
+ /**
32
+ * The predicate that connects one `PlaylistEntity` (`e`) to its `Track` (`t`).
33
+ *
34
+ * A playlist entry does not name a local row id. It carries
35
+ * `(databaseUuid, trackId)`, which identifies the track **in the library the
36
+ * entry was made in**, and `Track` preserves that same identity in
37
+ * `(originDatabaseUuid, originTrackId)` — a pair that need not equal, and
38
+ * usually does not equal, `Track.id`. Engine's own schema says as much: the
39
+ * pair carries `CONSTRAINT C_originDatabaseUuid_originTrackId UNIQUE`, making
40
+ * it the track's natural key across drives, and a pair of triggers
41
+ * (`trigger_after_insert_Track_fix_origin` and its update twin) stamps it
42
+ * from `Information.uuid` whenever a row arrives without one. Engine's
43
+ * application binary embeds the same join verbatim:
44
+ *
45
+ * SELECT COUNT(DISTINCT databaseUuid || trackId) FROM PlaylistEntity
46
+ * JOIN Track ON (originDatabaseUuid, originTrackId) = (databaseUuid, trackId)
47
+ *
48
+ * Joining on `e.trackId = t.id` instead reads naturally and is wrong in both
49
+ * directions. Measured on the reference USB library (257 tracks, 16
50
+ * playlists, 202 entries): the id join reports 105 of 202 entries as orphans
51
+ * where the pair join finds 0, because 178 entries name a *third* library
52
+ * (`33be3313-…`) that is neither of the two attached and only 91 of the 257
53
+ * tracks carry this database's own uuid — a healthy library reported as
54
+ * riddled with holes, and one 43-entry playlist reduced to a single playable
55
+ * track. In the other direction it silently answers with the *wrong track*
56
+ * whenever a foreign entry's `trackId` happens to collide with a local row
57
+ * id, and calls a genuinely missing entry present.
58
+ *
59
+ * Written once and shared, so the three places that ask the question cannot
60
+ * drift apart. It names the aliases `t` and `e`; every call site uses them.
61
+ */
62
+ export declare const ENTRY_TRACK_MATCH = "t.originDatabaseUuid = e.databaseUuid AND t.originTrackId = e.trackId";
63
+ /** One node of an Engine linked list. `next` is 0 at the end of the chain. */
64
+ export interface Linked {
65
+ id: number;
66
+ next: number;
67
+ }
68
+ export interface ChainOrder<T> {
69
+ order: T[];
70
+ warnings: string[];
71
+ }
72
+ /**
73
+ * Orders one linked-list group, and says so when it could not.
74
+ *
75
+ * The contract that matters: `order` always contains **every** input node,
76
+ * exactly once. A chain defect degrades the *order* and raises a warning; it
77
+ * never drops a node, because a caller cannot tell a truncated list from a
78
+ * short one, and `get_playlist_tracks` returning 30 of 43 entries with no
79
+ * complaint is a worse answer than returning 43 in a partly-guessed order
80
+ * with an explanation attached.
81
+ *
82
+ * Walk termination does not rely on a magic iteration cap: every step either
83
+ * marks one previously unseen node visited or stops, so the walk is bounded
84
+ * by the group size by construction. Anything the walk could not reach is
85
+ * appended in id order.
86
+ *
87
+ * `subject` prefixes the warnings, so a caller reading a whole tree can tell
88
+ * which playlist was malformed.
89
+ */
90
+ export declare function orderByChain<T extends Linked>(nodes: readonly T[], subject: string): ChainOrder<T>;
91
+ /** A `Playlist` row, as this module needs it. */
92
+ export interface PlaylistRow extends Linked {
93
+ title: string;
94
+ parent: number;
95
+ isPersisted: boolean;
96
+ entryCount: number;
97
+ missingCount: number;
98
+ }
99
+ /**
100
+ * One playlist as reported to a caller.
101
+ *
102
+ * `is_folder` is derived, not stored: the schema has no folder flag, and
103
+ * Engine's own folders are simply `Playlist` rows that other `Playlist` rows
104
+ * name as their `parentListId`. So this means "has at least one child list",
105
+ * which is exactly what Engine draws as a folder -- with one honest
106
+ * consequence: a folder a DJ has emptied is indistinguishable from a
107
+ * playlist with no tracks, and is reported as the latter.
108
+ *
109
+ * `track_count` is entries in this list itself, never a total rolled up from
110
+ * children, so it matches the number Engine shows beside the playlist.
111
+ * `missing_count` is how many of those entries name a track that is not in
112
+ * this library -- see `get_playlist_tracks` for why that is routine rather
113
+ * than a corruption.
114
+ */
115
+ export interface PlaylistItem {
116
+ id: number;
117
+ name: string;
118
+ /** Full path from the top of the tree, `/`-separated. Unique across the tree. */
119
+ path: string;
120
+ parent_id: number | null;
121
+ depth: number;
122
+ is_folder: boolean;
123
+ is_persisted: boolean;
124
+ track_count: number;
125
+ missing_count: number;
126
+ }
127
+ /**
128
+ * Flattens the playlist forest into Engine's own display order: each sibling
129
+ * group in `nextListId` chain order, each list immediately followed by its
130
+ * own children (pre-order), which is exactly what an expanded Engine sidebar
131
+ * shows top to bottom.
132
+ *
133
+ * Flat-plus-`depth`-plus-`path` rather than nested `children` arrays: the
134
+ * order Engine displays is then readable in a single pass down the array
135
+ * without reconstructing a traversal, the result truncates to a genuine
136
+ * prefix of the sidebar, and `path` gives every list a unique handle that a
137
+ * bare name does not (see `findPlaylistByName`).
138
+ */
139
+ export declare function buildPlaylistTree(rows: readonly PlaylistRow[]): {
140
+ items: PlaylistItem[];
141
+ warnings: string[];
142
+ };
143
+ export interface PlaylistTree {
144
+ items: PlaylistItem[];
145
+ warnings: string[];
146
+ /** Playlists in the library, even when more were found than were returned. */
147
+ total: number;
148
+ truncated: boolean;
149
+ }
150
+ /**
151
+ * Reads every playlist plus its entry counts, and orders the result.
152
+ *
153
+ * The counts come from one grouped pass over `PlaylistEntity` rather than a
154
+ * correlated subquery per playlist, and `missing_count` is computed in the
155
+ * same pass: "how many of these can I actually play" is not a rare question
156
+ * and must not cost a second round trip per list.
157
+ *
158
+ * `missing` is an EXISTS semi-join on the natural key (see
159
+ * ENTRY_TRACK_MATCH), not a LEFT JOIN to Track. Membership is the whole
160
+ * question, and a semi-join cannot inflate `n`: a joined row set would count
161
+ * one entry twice if two tracks ever answered to the same origin key, which
162
+ * Engine's UNIQUE constraint forbids but this server has no way to enforce
163
+ * on a file it does not own.
164
+ */
165
+ export declare function loadPlaylistTree(qp: QueryProcess): Promise<PlaylistTree | EngineError>;
166
+ /**
167
+ * Every list matching a caller-supplied name, most specific interpretation
168
+ * first.
169
+ *
170
+ * `Playlist` is unique on `(title, parentListId)` only, so a bare name is
171
+ * genuinely ambiguous the moment a DJ uses folders -- "House" under
172
+ * *Warmup* and "House" under *Peak* are two different playlists and neither
173
+ * is the obvious winner. A full path is unique by construction, so it is
174
+ * tried first and gives a caller a way to say precisely which one they mean;
175
+ * a bare title is tried next and may legitimately return several, which the
176
+ * caller turns into an error rather than an arbitrary pick.
177
+ *
178
+ * Case-insensitive matching is a fallback tier, not the primary rule: a
179
+ * library containing both "Peak" and "peak" as siblings is legal, and an
180
+ * exact match must win outright rather than being reported as ambiguous
181
+ * against its own differently-cased neighbour.
182
+ */
183
+ export declare function findPlaylistByName(items: readonly PlaylistItem[], name: string): PlaylistItem[];
184
+ export interface PlaylistSelector {
185
+ id?: number;
186
+ name?: string;
187
+ }
188
+ /** The argument names to quote back in an error, so each tool blames its own. */
189
+ export interface SelectorNames {
190
+ id: string;
191
+ name: string;
192
+ }
193
+ export interface ResolvedPlaylist {
194
+ playlist: PlaylistItem;
195
+ /** Chain warnings raised while building the tree this playlist came from. */
196
+ warnings: string[];
197
+ }
198
+ /**
199
+ * Turns "id or name" into one specific playlist, or an actionable error.
200
+ *
201
+ * Ambiguity is never resolved by picking: two playlists really can share a
202
+ * name, and quietly answering about the wrong one is the failure mode this
203
+ * whole function exists to prevent. The error names every candidate with its
204
+ * id and its full path, so the retry is a copy-paste rather than a guess.
205
+ *
206
+ * Deliberately reuses `invalid_argument` rather than adding an error code:
207
+ * the taxonomy is closed (see errors.ts), and "the playlist you named is not
208
+ * in this library" is a problem with the argument, reported the same way an
209
+ * unknown field name is -- with the recognised values in `detail`.
210
+ */
211
+ export declare function resolvePlaylist(qp: QueryProcess, sel: PlaylistSelector, names?: SelectorNames): Promise<ResolvedPlaylist | EngineError>;
212
+ /** One entry of a playlist, in playlist order. */
213
+ export interface OrderedEntry extends Linked {
214
+ /**
215
+ * `PlaylistEntity.trackId` — the track's `originTrackId` in the library
216
+ * `databaseUuid` names, **not** a local `Track.id`. The two halves only
217
+ * mean anything together (see ENTRY_TRACK_MATCH), and together they need
218
+ * not name a track this library holds.
219
+ */
220
+ trackId: number;
221
+ /** The library this entry was made in. Null only in a malformed row. */
222
+ databaseUuid: string | null;
223
+ /** 1-based position within the playlist. */
224
+ position: number;
225
+ }
226
+ export interface PlaylistEntries {
227
+ entries: OrderedEntry[];
228
+ warnings: string[];
229
+ }
230
+ /**
231
+ * Every entry of one playlist, in `nextEntityId` chain order.
232
+ *
233
+ * The whole playlist is read before a page is cut from it, because position
234
+ * *is* the ordering: there is no indexed column to seek on, so a page can
235
+ * only be taken from an already-ordered list. That is affordable -- the
236
+ * reference library's largest playlist is 43 entries and `PlaylistEntity` is
237
+ * three integers wide -- and it is bounded by MAX_ENTRIES rather than by
238
+ * hope.
239
+ */
240
+ export declare function loadPlaylistEntries(qp: QueryProcess, playlist: PlaylistItem): Promise<PlaylistEntries | EngineError>;