engine-dj-mcp 0.15.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ // src/store/track-metadata.ts
2
+ //
3
+ // update_track_metadata against a real library. The decisions live in
4
+ // track-metadata-plan.ts; this module reads rows, applies a plan and verifies
5
+ // it. Order (spec §6): hot journal, input rules, read-only pre-check, then --
6
+ // only if something would change -- the shared write transaction, where every
7
+ // row is read and planned AGAIN under the lock and that second reading decides.
8
+ import { DatabaseSync } from "node:sqlite";
9
+ import { err, isEngineError, libraryNeedsRecovery } from "../errors.js";
10
+ import { redactPath } from "../paths.js";
11
+ import { hasHotJournal } from "./connections.js";
12
+ import { NOT_COMMITTED, mapWriteError, rollback, withWriteTransaction, } from "./write.js";
13
+ import { planUpdates, sameStored, validateUpdates, TEXT_FIELDS, } from "./track-metadata-plan.js";
14
+ const MAX_SAFE = Number.MAX_SAFE_INTEGER;
15
+ /**
16
+ * Types and ranges are settled in SQL, before a value reaches JS: node:sqlite
17
+ * throws on an INTEGER beyond 2^53, and a REAL rating or a TEXT year is not a
18
+ * value this tool could put back. Such a column comes back null and is listed
19
+ * in `inexpressible`. The empty-origin test is the fix-origin trigger's own
20
+ * WHEN, evaluated by SQLite, so it agrees with the trigger on TEXT '' (spec §3.3).
21
+ */
22
+ const READ_ROW = `SELECT id,
23
+ CASE WHEN typeof(genre) = 'text' THEN genre END AS genre, typeof(genre) AS genre_t,
24
+ CASE WHEN typeof(comment) = 'text' THEN comment END AS comment, typeof(comment) AS comment_t,
25
+ CASE WHEN typeof(label) = 'text' THEN label END AS label, typeof(label) AS label_t,
26
+ CASE WHEN typeof(year) = 'integer' AND year BETWEEN -${MAX_SAFE} AND ${MAX_SAFE} THEN year END AS year,
27
+ (typeof(year) = 'null' OR (typeof(year) = 'integer' AND year BETWEEN -${MAX_SAFE} AND ${MAX_SAFE})) AS year_ok,
28
+ CASE WHEN typeof(rating) = 'integer' AND rating BETWEEN 0 AND 255 THEN rating END AS rating,
29
+ (typeof(rating) = 'null' OR (typeof(rating) = 'integer' AND rating BETWEEN 0 AND 255)) AS rating_ok,
30
+ (IFNULL(originTrackId, 0) = 0 OR IFNULL(originDatabaseUuid, '') = '') AS origin_empty,
31
+ CAST(originDatabaseUuid AS TEXT) || '|' || CAST(originTrackId AS TEXT) AS origin
32
+ FROM Track WHERE id = ?`;
33
+ function readRows(db, ids) {
34
+ const stmt = db.prepare(READ_ROW);
35
+ const out = new Map();
36
+ for (const id of ids) {
37
+ const r = stmt.get(id);
38
+ if (!r)
39
+ continue;
40
+ const inexpressible = [];
41
+ for (const f of TEXT_FIELDS) {
42
+ if (r[`${f}_t`] !== "text" && r[`${f}_t`] !== "null")
43
+ inexpressible.push(f);
44
+ }
45
+ if (!r.year_ok)
46
+ inexpressible.push("year");
47
+ if (!r.rating_ok)
48
+ inexpressible.push("rating");
49
+ out.set(id, {
50
+ id,
51
+ genre: r.genre,
52
+ comment: r.comment,
53
+ label: r.label,
54
+ year: r.year,
55
+ rating: r.rating,
56
+ inexpressible,
57
+ originEmpty: r.origin_empty === 1,
58
+ origin: r.origin,
59
+ });
60
+ }
61
+ return out;
62
+ }
63
+ export async function updateTrackMetadata(mdbPath, uuid, input, opts) {
64
+ const { updates } = input;
65
+ const ids = updates.map((u) => u.id);
66
+ const subject = `${updates.length} track${updates.length === 1 ? "" : "s"}`;
67
+ // First, before anything opens the file: see createPlaylist in write.ts.
68
+ if (hasHotJournal(mdbPath))
69
+ return { ...libraryNeedsRecovery(), detail: NOT_COMMITTED };
70
+ const invalid = validateUpdates(updates);
71
+ if (invalid)
72
+ return invalid;
73
+ // Not the authority -- the transaction re-plans. This only spares a
74
+ // snapshot for a call that is going to refuse anyway, or has nothing to do.
75
+ let pre;
76
+ let precheck;
77
+ try {
78
+ precheck = new DatabaseSync(mdbPath, { readOnly: true });
79
+ pre = planUpdates(updates, readRows(precheck, ids));
80
+ }
81
+ catch (e) {
82
+ return mapWriteError(e, subject, mdbPath);
83
+ }
84
+ finally {
85
+ try {
86
+ precheck?.close();
87
+ }
88
+ catch {
89
+ /* never opened */
90
+ }
91
+ }
92
+ if (isEngineError(pre))
93
+ return pre;
94
+ if (pre.writes.length === 0) {
95
+ // Spec §5.5: no transaction, no snapshot, so no backup_path -- and
96
+ // withWriteTransaction is not here to fill in `library`.
97
+ return {
98
+ updated: 0,
99
+ unchanged: pre.unchanged.length,
100
+ changed: [],
101
+ undo: [],
102
+ undo_complete: true,
103
+ library: { uuid, path: redactPath(mdbPath) },
104
+ };
105
+ }
106
+ return withWriteTransaction(mdbPath, uuid, subject, opts, (db) => {
107
+ // Under BEGIN IMMEDIATE. Engine may have changed any of these rows while
108
+ // the snapshot was being copied; what is read here decides (spec §6.5).
109
+ const rows = readRows(db, ids);
110
+ const plan = planUpdates(updates, rows);
111
+ if (isEngineError(plan)) {
112
+ rollback(db);
113
+ return plan;
114
+ }
115
+ for (const w of plan.writes) {
116
+ // Column names come from the FieldName union, never from caller text.
117
+ const assignments = w.fields.map((f) => `${f} = ?`).join(", ");
118
+ // Set as Engine sets it on its own tag edits (spec §3.9, measured).
119
+ db.prepare(`UPDATE Track SET ${assignments}, isMetadataOfPackedTrackChanged = 1 WHERE id = ?`).run(...w.fields.map((f) => w.set[f] ?? null), w.id);
120
+ }
121
+ const after = readRows(db, plan.writes.map((w) => w.id));
122
+ for (const w of plan.writes) {
123
+ const got = after.get(w.id);
124
+ const landed = got !== undefined &&
125
+ got.origin === rows.get(w.id).origin &&
126
+ w.fields.every((f) => sameStored(f, got[f], w.set[f] ?? null));
127
+ if (!landed) {
128
+ rollback(db);
129
+ return err("library_unreadable", `Track ${w.id} did not read back as written; nothing was changed.`, {
130
+ detail: NOT_COMMITTED,
131
+ });
132
+ }
133
+ }
134
+ const result = {
135
+ updated: plan.writes.length,
136
+ unchanged: plan.unchanged.length,
137
+ changed: plan.writes.map((w) => ({ id: w.id, fields: w.fields })),
138
+ undo: plan.undo.length > 0 ? [{ tool: "update_track_metadata", arguments: { updates: plan.undo } }] : [],
139
+ undo_complete: true,
140
+ };
141
+ return result;
142
+ });
143
+ }
@@ -65,6 +65,22 @@ export interface OriginRef {
65
65
  uuid: string;
66
66
  trackId: number;
67
67
  }
68
+ /**
69
+ * `detail` discriminator values for the EngineError this module returns.
70
+ * Stable across releases so a caller can decide "is the library still what
71
+ * it was" without parsing message prose. Everything before COMMIT --
72
+ * including validation that never reaches the database at all -- collapses
73
+ * to the same NOT_COMMITTED answer. COMMITTED_UNVERIFIED is produced by
74
+ * exactly two things, and both mean "the playlist may be on disk": the
75
+ * post-commit check reporting anything but "ok", and anything thrown from
76
+ * the COMMIT itself onwards. It is also the only case that carries
77
+ * backup_path, because it is the only case where restoring from a snapshot
78
+ * is ever the right next step.
79
+ *
80
+ * These two strings are part of the tool's contract; see src/errors.ts.
81
+ */
82
+ /** Exported for src/store/track-metadata*.ts; the string is part of the tool contract. */
83
+ export declare const NOT_COMMITTED = "not_committed";
68
84
  /** Test seam only: forget this process's snapshots so a test can start clean. */
69
85
  export declare function resetSessionSnapshots(): void;
70
86
  /**
@@ -125,7 +141,74 @@ export declare function checkChain(rows: {
125
141
  id: number;
126
142
  next: number;
127
143
  }[]): ChainCheck;
144
+ /**
145
+ * Roll back, swallowing a failure of the rollback itself.
146
+ *
147
+ * Every caller is already returning a specific error -- the chain did not read
148
+ * back, the library is busy -- and a ROLLBACK that throws on the way out would
149
+ * replace that reason with its own, telling the user about a failed rollback
150
+ * instead of what actually went wrong. Nothing is lost by ignoring it:
151
+ * db.close() in the finally block ends any transaction still open, and SQLite
152
+ * discards an uncommitted one on close.
153
+ */
154
+ /** Exported for src/store/track-metadata.ts. */
155
+ export declare function rollback(db: DatabaseSync): void;
128
156
  export declare function sameOrder(a: OriginRef[], b: OriginRef[]): boolean;
157
+ /**
158
+ * Turns whatever node:sqlite throws into an EngineError. Shared by every op
159
+ * in this module -- the read-only pre-check and the write transaction below
160
+ * it both open a connection to the same file and can hit the same failure
161
+ * modes (the library gone missing mid-session, Engine holding the lock, a
162
+ * foreign or corrupt schema), and a caller whose promise is typed
163
+ * `Promise<... | EngineError>` must never see one of them escape as a
164
+ * rejection instead.
165
+ *
166
+ * `subject` is whatever this write is named for in its own messages -- a new
167
+ * playlist's title for createPlaylist, `playlist ${listId}` for an op that
168
+ * edits one that already exists.
169
+ *
170
+ * Every path that reaches this function is one where the library is
171
+ * unchanged: the transaction either never opened or is rolled back by the
172
+ * caller, and a failure at or after COMMIT is answered before this is ever
173
+ * called (see classifyWriteFailure, below). That is what lets the fallback
174
+ * below say "nothing was changed" without qualification -- it used to say
175
+ * `Writing "X" failed`, which reads as a half-write even when the failure was
176
+ * "file is not a database" and not one byte was attempted.
177
+ */
178
+ /** Exported for src/store/track-metadata.ts. */
179
+ export declare function mapWriteError(e: unknown, subject: string, mdbPath: string): EngineError;
180
+ /**
181
+ * Owns everything past the read-only pre-check that every write op in this
182
+ * module does identically: snapshot, open, `PRAGMA foreign_keys = ON`,
183
+ * `BEGIN IMMEDIATE`, commit, verify, and classify whatever went wrong.
184
+ * `body` does the one thing that differs between ops -- the INSERTs and
185
+ * UPDATEs specific to what this write is -- against the open `db` it is
186
+ * handed, and returns either the result to hand back (minus `backup_path`,
187
+ * which this function fills in once it knows COMMIT succeeded) or an
188
+ * `EngineError`. A body that has already written something rolls back before
189
+ * returning that error, but this function rolls back on that branch too
190
+ * rather than relying on it: `rollback` is a no-op when there is no
191
+ * transaction left to undo, and a body that forgot would otherwise leave the
192
+ * open transaction to `db.close()` -- correct today, and only by accident.
193
+ *
194
+ * `isEngineError`, not a second return channel, is what tells `body`'s
195
+ * success value apart from its failure one: this module already exports
196
+ * that check for callers, so reusing it here means a body never has to wrap
197
+ * its result to disambiguate the two.
198
+ */
199
+ /** Exported for src/store/track-metadata.ts. */
200
+ export declare function withWriteTransaction<T extends object>(mdbPath: string, uuid: string, subject: string, opts: {
201
+ backupDir: string;
202
+ /**
203
+ * Test seam only. Runs after the snapshot and before the write connection
204
+ * opens -- the window in which Engine DJ can still change a row this call
205
+ * already pre-checked. No production caller passes it.
206
+ */
207
+ beforeLock?: () => void;
208
+ }, body: (db: DatabaseSync) => T | EngineError): Promise<(T & {
209
+ library: LibraryRef;
210
+ backup_path: string;
211
+ }) | EngineError>;
129
212
  export declare function createPlaylist(mdbPath: string, uuid: string, input: {
130
213
  title: string;
131
214
  trackIds: number[];
@@ -1,7 +1,8 @@
1
1
  // src/store/write.ts
2
2
  //
3
- // The only code in this project that writes to a user's Engine library, and
4
- // it runs only when the server was started with --allow-writes.
3
+ // Writes to a user's Engine library, together with src/store/track-metadata.ts
4
+ // (which reuses withWriteTransaction, mapWriteError and rollback from here).
5
+ // Both run only when the server was started with --allow-writes.
5
6
  //
6
7
  // The read path is deliberately not reused. Queries run in a forked child
7
8
  // whose connection is opened readOnly: true, and that guarantee is the
@@ -29,7 +30,8 @@ import { redactPath } from "../paths.js";
29
30
  *
30
31
  * These two strings are part of the tool's contract; see src/errors.ts.
31
32
  */
32
- const NOT_COMMITTED = "not_committed";
33
+ /** Exported for src/store/track-metadata*.ts; the string is part of the tool contract. */
34
+ export const NOT_COMMITTED = "not_committed";
33
35
  const COMMITTED_UNVERIFIED = "committed_unverified";
34
36
  /**
35
37
  * One snapshot per library per process, which is what "before the first write
@@ -266,7 +268,8 @@ function gateChain(db, listId) {
266
268
  * db.close() in the finally block ends any transaction still open, and SQLite
267
269
  * discards an uncommitted one on close.
268
270
  */
269
- function rollback(db) {
271
+ /** Exported for src/store/track-metadata.ts. */
272
+ export function rollback(db) {
270
273
  try {
271
274
  db.exec("ROLLBACK");
272
275
  }
@@ -298,7 +301,8 @@ export function sameOrder(a, b) {
298
301
  * `Writing "X" failed`, which reads as a half-write even when the failure was
299
302
  * "file is not a database" and not one byte was attempted.
300
303
  */
301
- function mapWriteError(e, subject, mdbPath) {
304
+ /** Exported for src/store/track-metadata.ts. */
305
+ export function mapWriteError(e, subject, mdbPath) {
302
306
  const msg = e.message ?? String(e);
303
307
  const isUniqueViolation = /UNIQUE constraint failed/i.test(msg);
304
308
  // The constraint's *name* never appears in the message SQLite raises --
@@ -409,7 +413,8 @@ function classifyWriteFailure(e, commit, subject, mdbPath, backupPath, db, open)
409
413
  * that check for callers, so reusing it here means a body never has to wrap
410
414
  * its result to disambiguate the two.
411
415
  */
412
- async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
416
+ /** Exported for src/store/track-metadata.ts. */
417
+ export async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
413
418
  // Snapshot here, before the write connection is even opened, not after
414
419
  // BEGIN IMMEDIATE. Taking it with RESERVED held meant a full-database copy
415
420
  // -- tens of seconds on a multi-gigabyte USB library -- ran while every
@@ -431,6 +436,7 @@ async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
431
436
  if (typeof snapshot !== "string")
432
437
  return { ...snapshot, detail: NOT_COMMITTED };
433
438
  const backupPath = snapshot;
439
+ opts.beforeLock?.();
434
440
  let db;
435
441
  let open = false;
436
442
  let commit = "not yet";
@@ -453,7 +459,29 @@ async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
453
459
  // Filled in here, alongside backup_path, for the same reason: it is the
454
460
  // one place that knows the write succeeded, and doing it per-op would let
455
461
  // a new op ship without it.
456
- return { ...result, library: { uuid, path: redactPath(mdbPath) }, backup_path: backupPath };
462
+ const library = { uuid, path: redactPath(mdbPath) };
463
+ // Every undo step carries the library too, and for a sharper reason than
464
+ // symmetry: a caller replaying an undo sends `undo.arguments` and nothing
465
+ // else, so without it the replay resolves whatever library is the default
466
+ // at replay time. A USB drive and its copy hold the same playlist ids and
467
+ // the same track ids, so such a replay lands on the wrong disk and
468
+ // expect_track_ids agrees -- both sides having been edited the same way.
469
+ //
470
+ // By path, not by uuid: a library and its clone on a second drive share a
471
+ // uuid (see store/backup.ts, which tags snapshots by path for exactly
472
+ // that), and a uuid could not say which of the two to undo in. A path
473
+ // names one. If that drive has since moved, the replay fails loudly with
474
+ // library_not_found rather than quietly editing the other copy.
475
+ //
476
+ // Injected here rather than in each op so a new op cannot ship without it.
477
+ const withUndo = result;
478
+ if (Array.isArray(withUndo.undo)) {
479
+ withUndo.undo = withUndo.undo.map((step) => ({
480
+ ...step,
481
+ arguments: { library: library.path, ...step.arguments },
482
+ }));
483
+ }
484
+ return { ...result, library, backup_path: backupPath };
457
485
  }
458
486
  catch (e) {
459
487
  return classifyWriteFailure(e, commit, subject, mdbPath, backupPath, db, open);
@@ -32,6 +32,12 @@ export const FIELD_SQL = {
32
32
  label: "t.label",
33
33
  year: "t.year",
34
34
  rating: "t.rating",
35
+ // Engine stores 0, 20, 40, 60, 80, 100 -- measured: four stars set in Engine
36
+ // came back as 80. `rating` hands that back as it is; `rating_stars` is the
37
+ // same thing in the units a person uses, so a caller never has to know the
38
+ // factor. A value no Engine writes (a third-party tagger's 55) rounds to the
39
+ // nearest star here and stays exact in `rating`.
40
+ rating_stars: "CAST(ROUND(COALESCE(t.rating, 0) / 20.0) AS INTEGER)",
35
41
  length: "t.length",
36
42
  path: "t.path",
37
43
  filename: "t.filename",
@@ -296,13 +302,16 @@ export async function searchTracks(qp, raw) {
296
302
  filterParams.push(input.key.mode === "minor" ? "%A" : "%B");
297
303
  }
298
304
  }
305
+ // In stars, which is what README has always documented and what a person
306
+ // means. Compared against the raw column, `min: 4` matched 20, 40, 60, 80
307
+ // and 100 -- every rated track -- and `max: 3` matched only unrated ones.
299
308
  if (input.rating?.min !== undefined) {
300
309
  filterWhere.push("t.rating >= ?");
301
- filterParams.push(input.rating.min);
310
+ filterParams.push(input.rating.min * 20);
302
311
  }
303
312
  if (input.rating?.max !== undefined) {
304
313
  filterWhere.push("t.rating <= ?");
305
- filterParams.push(input.rating.max);
314
+ filterParams.push(input.rating.max * 20);
306
315
  }
307
316
  if (input.played?.never)
308
317
  filterWhere.push("(t.timeLastPlayed IS NULL OR t.isPlayed = 0)");
@@ -0,0 +1,26 @@
1
+ import { z } from "zod";
2
+ import { type TrackUpdate } from "../store/track-metadata-plan.js";
3
+ export declare const UpdateTrackMetadataInput: z.ZodObject<{
4
+ updates: z.ZodArray<z.ZodObject<{
5
+ id: z.ZodNumber;
6
+ genre: z.ZodOptional<z.ZodString>;
7
+ comment: z.ZodOptional<z.ZodString>;
8
+ label: z.ZodOptional<z.ZodString>;
9
+ year: z.ZodOptional<z.ZodNumber>;
10
+ rating_stars: z.ZodOptional<z.ZodNumber>;
11
+ rating_raw: z.ZodOptional<z.ZodNumber>;
12
+ expect: z.ZodOptional<z.ZodObject<{
13
+ genre: z.ZodOptional<z.ZodString>;
14
+ comment: z.ZodOptional<z.ZodString>;
15
+ label: z.ZodOptional<z.ZodString>;
16
+ year: z.ZodOptional<z.ZodNumber>;
17
+ rating_raw: z.ZodOptional<z.ZodNumber>;
18
+ }, z.core.$strict>>;
19
+ }, z.core.$strict>>;
20
+ }, z.core.$strip>;
21
+ export declare function runUpdateTrackMetadata(mdbPath: string, uuid: string, args: {
22
+ updates: TrackUpdate[];
23
+ }, backupDir: string): Promise<import("../errors.js").EngineError | (import("../store/track-metadata.js").TrackMetadataResult & {
24
+ library: import("../store/write.js").LibraryRef;
25
+ backup_path?: string;
26
+ })>;
@@ -0,0 +1,42 @@
1
+ // src/tools/write-track-metadata.ts
2
+ import { z } from "zod";
3
+ import { updateTrackMetadata } from "../store/track-metadata.js";
4
+ import { MAX_UPDATES } from "../store/track-metadata-plan.js";
5
+ /**
6
+ * Types and the per-call cap only (spec §7.1). Every other rule -- ranges,
7
+ * which depend on whether `expect` is present, and the rating_raw restore
8
+ * rule -- lives in the store, so it comes back as a structured
9
+ * invalid_argument instead of a bare SDK validation message. `.strict()` makes
10
+ * a misnamed field such as `rating` an error rather than silently ignored.
11
+ */
12
+ const Expect = z
13
+ .object({
14
+ genre: z.string().optional(),
15
+ comment: z.string().optional(),
16
+ label: z.string().optional(),
17
+ year: z.number().int().optional(),
18
+ rating_raw: z.number().int().optional(),
19
+ })
20
+ .strict();
21
+ export const UpdateTrackMetadataInput = z.object({
22
+ updates: z
23
+ .array(z
24
+ .object({
25
+ id: z.number().int().positive(),
26
+ genre: z.string().optional(),
27
+ comment: z.string().optional(),
28
+ label: z.string().optional(),
29
+ year: z.number().int().optional(),
30
+ rating_stars: z.number().int().optional(),
31
+ rating_raw: z.number().int().optional(),
32
+ expect: Expect.optional(),
33
+ })
34
+ .strict())
35
+ .max(MAX_UPDATES)
36
+ .describe("One entry per track: its id from search_tracks or get_tracks, and only the fields to change. " +
37
+ '"" clears a text field. rating_stars is 0-5. rating_raw and expect exist for replaying an undo; ' +
38
+ "you do not need them for an ordinary edit."),
39
+ });
40
+ export async function runUpdateTrackMetadata(mdbPath, uuid, args, backupDir) {
41
+ return updateTrackMetadata(mdbPath, uuid, { updates: args.updates }, { backupDir });
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engine-dj-mcp",
3
- "version": "0.15.0",
3
+ "version": "0.17.1",
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",
@@ -19,11 +19,11 @@
19
19
  "author": "Mikhail Chereshnev <venuttv@gmail.com>",
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "git+https://github.com/Venut-Labs/engine-dj-mcp.git"
22
+ "url": "git+https://github.com/Venut-Technologies/engine-dj-mcp.git"
23
23
  },
24
- "homepage": "https://github.com/Venut-Labs/engine-dj-mcp#readme",
24
+ "homepage": "https://github.com/Venut-Technologies/engine-dj-mcp#readme",
25
25
  "bugs": {
26
- "url": "https://github.com/Venut-Labs/engine-dj-mcp/issues"
26
+ "url": "https://github.com/Venut-Technologies/engine-dj-mcp/issues"
27
27
  },
28
28
  "type": "module",
29
29
  "engines": {