engine-dj-mcp 0.11.3 → 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.
@@ -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;
@@ -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];
@@ -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.
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
- import { type CreatePlaylistResult } from "../store/write.js";
2
+ import { type CreatePlaylistResult, type EditResult, type InsertAt } from "../store/write.js";
3
3
  import { type EngineError } from "../errors.js";
4
+ import type { QueryProcess } from "../proc/query-client.js";
4
5
  export declare const CreatePlaylistInput: z.ZodObject<{
5
6
  title: z.ZodString;
6
7
  track_ids: z.ZodArray<z.ZodNumber>;
@@ -9,3 +10,39 @@ export declare function runCreatePlaylist(mdbPath: string, uuid: string, args: {
9
10
  title: string;
10
11
  track_ids: number[];
11
12
  }, backupDir: string): Promise<CreatePlaylistResult | EngineError>;
13
+ export declare const AddTracksToPlaylistInput: z.ZodObject<{
14
+ playlist_id: z.ZodOptional<z.ZodNumber>;
15
+ playlist_name: z.ZodOptional<z.ZodString>;
16
+ track_ids: z.ZodArray<z.ZodNumber>;
17
+ at: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<"start">, z.ZodLiteral<"end">, z.ZodObject<{
18
+ after_position: z.ZodNumber;
19
+ }, z.core.$strip>]>>;
20
+ }, z.core.$strip>;
21
+ export declare function runAddTracksToPlaylist(qp: QueryProcess, mdbPath: string, uuid: string, args: {
22
+ playlist_id?: number;
23
+ playlist_name?: string;
24
+ track_ids: number[];
25
+ at: InsertAt;
26
+ }, backupDir: string): Promise<EditResult | EngineError>;
27
+ export declare const RemoveTracksFromPlaylistInput: z.ZodObject<{
28
+ playlist_id: z.ZodOptional<z.ZodNumber>;
29
+ playlist_name: z.ZodOptional<z.ZodString>;
30
+ positions: z.ZodArray<z.ZodNumber>;
31
+ expect_track_ids: z.ZodOptional<z.ZodArray<z.ZodNullable<z.ZodNumber>>>;
32
+ }, z.core.$strip>;
33
+ export declare function runRemoveTracksFromPlaylist(qp: QueryProcess, mdbPath: string, uuid: string, args: {
34
+ playlist_id?: number;
35
+ playlist_name?: string;
36
+ positions: number[];
37
+ expect_track_ids?: (number | null)[];
38
+ }, backupDir: string): Promise<EditResult | EngineError>;
39
+ export declare const ReorderPlaylistInput: z.ZodObject<{
40
+ playlist_id: z.ZodOptional<z.ZodNumber>;
41
+ playlist_name: z.ZodOptional<z.ZodString>;
42
+ order: z.ZodArray<z.ZodNumber>;
43
+ }, z.core.$strip>;
44
+ export declare function runReorderPlaylist(qp: QueryProcess, mdbPath: string, uuid: string, args: {
45
+ playlist_id?: number;
46
+ playlist_name?: string;
47
+ order: number[];
48
+ }, backupDir: string): Promise<EditResult | EngineError>;
@@ -1,6 +1,8 @@
1
1
  // src/tools/write-playlist.ts
2
2
  import { z } from "zod";
3
- import { createPlaylist } from "../store/write.js";
3
+ import { createPlaylist, addTracksToPlaylist, removeTracksFromPlaylist, reorderPlaylist, } from "../store/write.js";
4
+ import { resolvePlaylist } from "../playlists.js";
5
+ import { isEngineError } from "../errors.js";
4
6
  export const CreatePlaylistInput = z.object({
5
7
  title: z.string().min(1).describe("Name for the new playlist. Must not already exist in this library."),
6
8
  track_ids: z
@@ -11,3 +13,103 @@ export const CreatePlaylistInput = z.object({
11
13
  export async function runCreatePlaylist(mdbPath, uuid, args, backupDir) {
12
14
  return createPlaylist(mdbPath, uuid, { title: args.title, trackIds: args.track_ids }, { backupDir });
13
15
  }
16
+ /** playlist_id/playlist_name, shared verbatim by every edit tool's schema below. */
17
+ const PlaylistSelectorShape = {
18
+ playlist_id: z
19
+ .number()
20
+ .int()
21
+ .positive()
22
+ .optional()
23
+ .describe("The playlist's id, from get_playlists or get_playlist_tracks. Exactly one of playlist_id/playlist_name is required."),
24
+ playlist_name: z
25
+ .string()
26
+ .min(1)
27
+ .optional()
28
+ .describe("The playlist's name or full path (from get_playlists). Names are unique only within a " +
29
+ "folder; a name matching more than one playlist is refused with every candidate listed, " +
30
+ "not guessed at. Exactly one of playlist_id/playlist_name is required."),
31
+ };
32
+ const InsertAtInput = z.union([
33
+ z.literal("start"),
34
+ z.literal("end"),
35
+ z.object({
36
+ after_position: z
37
+ .number()
38
+ .int()
39
+ .positive()
40
+ .describe("1-based position, from get_playlist_tracks, to insert immediately after."),
41
+ }),
42
+ ]);
43
+ export const AddTracksToPlaylistInput = z.object({
44
+ ...PlaylistSelectorShape,
45
+ track_ids: z
46
+ .array(z.number().int().positive())
47
+ .max(10_000)
48
+ .describe("Track ids from search_tracks or get_tracks, in the order they should appear. A track " +
49
+ "already in the playlist is refused as duplicate_track."),
50
+ at: InsertAtInput.default("end").describe("Where the new tracks land, against the playlist's current 1-based positions (the same " +
51
+ "numbering get_playlist_tracks reports): \"start\", \"end\" (the default), or " +
52
+ "{ after_position: n }."),
53
+ });
54
+ /**
55
+ * Turns `playlist_id`/`playlist_name` into a concrete `listId`, the same way
56
+ * `get_playlist_tracks` does (see resolvePlaylist in ../playlists.ts), so
57
+ * every edit tool refuses an ambiguous name identically rather than each
58
+ * growing its own resolution logic.
59
+ *
60
+ * `notFound: "playlist_not_found"` is what makes that code reachable at all.
61
+ * The store functions return it for an id that gets as far as them, and the
62
+ * README documents it as a refusal of all three edit tools -- but resolution
63
+ * runs first and used to answer every miss with `invalid_argument`, so no
64
+ * caller could ever see the documented code. It is also the more accurate of
65
+ * the two: the argument is well-formed, the playlist simply is not there.
66
+ * Ambiguity still comes back as `invalid_argument`, because a name matching
67
+ * three playlists genuinely is a problem with the argument.
68
+ */
69
+ async function resolveListId(qp, args) {
70
+ const resolved = await resolvePlaylist(qp, { id: args.playlist_id, name: args.playlist_name }, { id: "playlist_id", name: "playlist_name", notFound: "playlist_not_found" });
71
+ if (isEngineError(resolved))
72
+ return resolved;
73
+ return resolved.playlist.id;
74
+ }
75
+ export async function runAddTracksToPlaylist(qp, mdbPath, uuid, args, backupDir) {
76
+ const listId = await resolveListId(qp, args);
77
+ if (isEngineError(listId))
78
+ return listId;
79
+ return addTracksToPlaylist(mdbPath, uuid, { listId, trackIds: args.track_ids, at: args.at }, { backupDir });
80
+ }
81
+ export const RemoveTracksFromPlaylistInput = z.object({
82
+ ...PlaylistSelectorShape,
83
+ positions: z
84
+ .array(z.number().int().positive())
85
+ .max(50_000)
86
+ .describe("1-based positions to remove, from get_playlist_tracks against this playlist right now. " +
87
+ "Includes entries whose track is missing from the library (missing: true)."),
88
+ expect_track_ids: z
89
+ .array(z.number().int().positive().nullable())
90
+ .optional()
91
+ .describe("Optional, one entry per position: verifies each named position still holds the track " +
92
+ "expected before anything is removed. null means the position should hold an entry " +
93
+ "whose track is missing from the library, not \"no expectation\"."),
94
+ });
95
+ export async function runRemoveTracksFromPlaylist(qp, mdbPath, uuid, args, backupDir) {
96
+ const listId = await resolveListId(qp, args);
97
+ if (isEngineError(listId))
98
+ return listId;
99
+ return removeTracksFromPlaylist(mdbPath, uuid, { listId, positions: args.positions, expectTrackIds: args.expect_track_ids }, { backupDir });
100
+ }
101
+ export const ReorderPlaylistInput = z.object({
102
+ ...PlaylistSelectorShape,
103
+ order: z
104
+ .array(z.number().int().positive())
105
+ .max(50_000)
106
+ .describe("A full permutation of 1..n, n being the playlist's current entry count. order[i] names " +
107
+ "the CURRENT 1-based position (from get_playlist_tracks) of the track that should end " +
108
+ "up at position i + 1. Every position must be named, even ones that do not move."),
109
+ });
110
+ export async function runReorderPlaylist(qp, mdbPath, uuid, args, backupDir) {
111
+ const listId = await resolveListId(qp, args);
112
+ if (isEngineError(listId))
113
+ return listId;
114
+ return reorderPlaylist(mdbPath, uuid, { listId, order: args.order }, { backupDir });
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engine-dj-mcp",
3
- "version": "0.11.3",
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",