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.
- package/README.md +263 -43
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +12 -0
- package/dist/library-select.d.ts +36 -0
- package/dist/library-select.js +53 -1
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +20 -0
- package/dist/playlists.d.ts +19 -5
- package/dist/playlists.js +32 -14
- package/dist/semantics.js +5 -0
- package/dist/server.js +207 -5
- package/dist/store/backup.d.ts +19 -1
- package/dist/store/backup.js +72 -4
- package/dist/store/write.d.ts +186 -0
- package/dist/store/write.js +936 -101
- 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 +24 -7
- package/dist/tools/tracks.js +2 -6
- package/dist/tools/write-playlist.d.ts +38 -1
- package/dist/tools/write-playlist.js +103 -1
- 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;
|
|
@@ -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];
|
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.
|
|
@@ -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.
|
|
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",
|