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.
- package/README.md +216 -44
- package/dist/errors.d.ts +15 -1
- package/dist/errors.js +14 -0
- package/dist/library-select.d.ts +33 -0
- package/dist/library-select.js +46 -1
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +20 -0
- package/dist/semantics.js +5 -0
- package/dist/server.js +178 -9
- package/dist/store/backup.d.ts +19 -1
- package/dist/store/backup.js +72 -4
- package/dist/store/track-metadata-plan.d.ts +82 -0
- package/dist/store/track-metadata-plan.js +236 -0
- package/dist/store/track-metadata.d.ts +22 -0
- package/dist/store/track-metadata.js +143 -0
- package/dist/store/write.d.ts +100 -0
- package/dist/store/write.js +44 -10
- 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 +35 -9
- package/dist/tools/tracks.js +2 -6
- package/dist/tools/write-track-metadata.d.ts +26 -0
- package/dist/tools/write-track-metadata.js +42 -0
- package/package.json +1 -1
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;
|
|
@@ -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];
|
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.
|
|
@@ -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.
|
|
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",
|