engine-dj-mcp 0.12.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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>;
@@ -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
- body: `FROM Track t WHERE LOWER(TRIM(t.artist)) || '|' || LOWER(TRIM(t.title)) IN (
83
- SELECT LOWER(TRIM(artist)) || '|' || LOWER(TRIM(title)) FROM Track
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))
@@ -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
  }
@@ -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<{
@@ -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;
@@ -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",
@@ -47,7 +53,29 @@ export const FIELD_SQL = {
47
53
  date_added: "t.dateAdded",
48
54
  last_played: "t.timeLastPlayed",
49
55
  is_analyzed: "t.isAnalyzed",
56
+ // Reported to decide whether Engine OS streams a track (from Dropbox)
57
+ // rather than reading it from disk -- not measured here: NULL, NULL and a
58
+ // mix of NULL/0/5 on both reference libraries, whose tracks all load. Opt-in
59
+ // only, for diagnosing a track that will not load (#8).
60
+ streaming_source: "t.streamingSource",
61
+ streaming_flags: "t.streamingFlags",
62
+ uri: "t.uri",
50
63
  };
64
+ /**
65
+ * How one projected value is handed back. The single place a path-bearing
66
+ * field is redacted: search_tracks, get_tracks and get_playlist_tracks each
67
+ * carried their own copy of the `path` check, which is how a new path-bearing
68
+ * field ends up redacted in two of the three.
69
+ */
70
+ export function presentField(field, value, redact) {
71
+ if (!redact || typeof value !== "string")
72
+ return value;
73
+ if (field === "path")
74
+ return redactPath(value);
75
+ if (field === "uri")
76
+ return redactUri(value);
77
+ return value;
78
+ }
51
79
  export const SearchInput = z.object({
52
80
  q: z.string().optional(),
53
81
  bpm: z
@@ -274,13 +302,16 @@ export async function searchTracks(qp, raw) {
274
302
  filterParams.push(input.key.mode === "minor" ? "%A" : "%B");
275
303
  }
276
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.
277
308
  if (input.rating?.min !== undefined) {
278
309
  filterWhere.push("t.rating >= ?");
279
- filterParams.push(input.rating.min);
310
+ filterParams.push(input.rating.min * 20);
280
311
  }
281
312
  if (input.rating?.max !== undefined) {
282
313
  filterWhere.push("t.rating <= ?");
283
- filterParams.push(input.rating.max);
314
+ filterParams.push(input.rating.max * 20);
284
315
  }
285
316
  if (input.played?.never)
286
317
  filterWhere.push("(t.timeLastPlayed IS NULL OR t.isPlayed = 0)");
@@ -354,12 +385,7 @@ export async function searchTracks(qp, raw) {
354
385
  if (isEngineError(res))
355
386
  return res;
356
387
  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
- })));
388
+ const tracks = res.rows.map((row) => Object.fromEntries(fields.map((f) => [f, presentField(f, row[idx[f]], input.redact_paths)])));
363
389
  let next_cursor;
364
390
  if (res.rows.length === limit) {
365
391
  const last = res.rows[res.rows.length - 1];
@@ -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.
@@ -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.12.0",
3
+ "version": "0.17.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",