engine-dj-mcp 0.9.2 → 0.11.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 +366 -134
- package/dist/blobs/index.d.ts +18 -12
- package/dist/blobs/index.js +24 -16
- package/dist/discovery.js +3 -2
- package/dist/errors.d.ts +20 -1
- package/dist/errors.js +8 -0
- package/dist/index.js +8 -2
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +14 -0
- package/dist/playlists.d.ts +240 -0
- package/dist/playlists.js +447 -0
- package/dist/server.d.ts +5 -2
- package/dist/server.js +138 -18
- package/dist/store/backup.d.ts +2 -0
- package/dist/store/backup.js +72 -0
- package/dist/store/write.d.ts +37 -0
- package/dist/store/write.js +397 -0
- package/dist/tools/audit.js +12 -1
- package/dist/tools/playlists.d.ts +59 -0
- package/dist/tools/playlists.js +206 -0
- package/dist/tools/search.d.ts +10 -0
- package/dist/tools/search.js +58 -0
- package/dist/tools/write-playlist.d.ts +11 -0
- package/dist/tools/write-playlist.js +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// src/tools/playlists.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { err, isEngineError } from "../errors.js";
|
|
4
|
+
import { redactPath } from "../paths.js";
|
|
5
|
+
import { loadPlaylistEntries, loadPlaylistTree, resolvePlaylist, } from "../playlists.js";
|
|
6
|
+
import { DEFAULT_FIELDS, FIELD_SQL } from "./search.js";
|
|
7
|
+
/**
|
|
8
|
+
* Playlists returned in one call. Higher than any real library needs (the
|
|
9
|
+
* reference library has 16) but the result still goes into a model's
|
|
10
|
+
* context, so it is a cap rather than "everything".
|
|
11
|
+
*/
|
|
12
|
+
const MAX_PLAYLIST_PAGE = 1000;
|
|
13
|
+
/** Matches search_tracks: the largest page of tracks any tool will return. */
|
|
14
|
+
const MAX_TRACK_LIMIT = 200;
|
|
15
|
+
export const GetPlaylistsInput = z.object({
|
|
16
|
+
limit: z.number().int().positive().default(200),
|
|
17
|
+
});
|
|
18
|
+
/**
|
|
19
|
+
* The library's playlist tree, in the order Engine DJ displays it.
|
|
20
|
+
*
|
|
21
|
+
* Flat, in pre-order, with `depth` and `path` carrying the nesting -- see
|
|
22
|
+
* buildPlaylistTree for why that beats nested `children` arrays here.
|
|
23
|
+
*/
|
|
24
|
+
export async function getPlaylists(qp, raw) {
|
|
25
|
+
const parsed = GetPlaylistsInput.safeParse(raw);
|
|
26
|
+
if (!parsed.success)
|
|
27
|
+
return err("invalid_argument", "limit must be a positive integer");
|
|
28
|
+
const limit = Math.min(parsed.data.limit, MAX_PLAYLIST_PAGE);
|
|
29
|
+
const tree = await loadPlaylistTree(qp);
|
|
30
|
+
if (isEngineError(tree))
|
|
31
|
+
return tree;
|
|
32
|
+
const playlists = tree.items.slice(0, limit);
|
|
33
|
+
// Two independent reasons the answer can be short: more playlists exist
|
|
34
|
+
// than this page holds, and more exist than loadPlaylistTree would read at
|
|
35
|
+
// all. Both mean "this is not the whole tree", so both set the same flag.
|
|
36
|
+
const truncated = tree.truncated || playlists.length < tree.items.length;
|
|
37
|
+
return {
|
|
38
|
+
playlists,
|
|
39
|
+
total: tree.total,
|
|
40
|
+
truncated,
|
|
41
|
+
...(tree.warnings.length ? { warnings: tree.warnings } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export const GetPlaylistTracksInput = z.object({
|
|
45
|
+
playlist_id: z.number().int().positive().optional(),
|
|
46
|
+
playlist_name: z.string().min(1).optional(),
|
|
47
|
+
fields: z.array(z.string()).optional(),
|
|
48
|
+
limit: z.number().int().positive().default(25),
|
|
49
|
+
cursor: z.string().optional(),
|
|
50
|
+
redact_paths: z.boolean().default(true),
|
|
51
|
+
});
|
|
52
|
+
/**
|
|
53
|
+
* A cursor here is just "resume at position N of playlist P".
|
|
54
|
+
*
|
|
55
|
+
* It carries the playlist id so a cursor from one playlist cannot page
|
|
56
|
+
* through another: unlike search_tracks, whose cursor encodes a keyset that
|
|
57
|
+
* could be silently misapplied to a different filter set, position N means
|
|
58
|
+
* something in every playlist, so the wrong-playlist mistake would page
|
|
59
|
+
* perfectly happily through the wrong list.
|
|
60
|
+
*/
|
|
61
|
+
function encodeCursor(playlistId, position) {
|
|
62
|
+
return Buffer.from(JSON.stringify([playlistId, position])).toString("base64url");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A track's natural key, flattened for use as a Map key.
|
|
66
|
+
*
|
|
67
|
+
* `\u0000` cannot occur inside an Engine uuid, so no two distinct pairs
|
|
68
|
+
* flatten to the same string — which a plain `uuid + trackId` concatenation
|
|
69
|
+
* could not promise.
|
|
70
|
+
*/
|
|
71
|
+
function entryKey(databaseUuid, trackId) {
|
|
72
|
+
return `${databaseUuid}\u0000${trackId}`;
|
|
73
|
+
}
|
|
74
|
+
function decodeCursor(cursor) {
|
|
75
|
+
try {
|
|
76
|
+
const v = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
77
|
+
if (!Array.isArray(v) || v.length !== 2)
|
|
78
|
+
return null;
|
|
79
|
+
const [listId, position] = v;
|
|
80
|
+
if (typeof listId !== "number" || typeof position !== "number")
|
|
81
|
+
return null;
|
|
82
|
+
if (!Number.isInteger(listId) || !Number.isInteger(position) || position < 1)
|
|
83
|
+
return null;
|
|
84
|
+
return [listId, position];
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function getPlaylistTracks(qp, raw) {
|
|
91
|
+
const parsed = GetPlaylistTracksInput.safeParse(raw);
|
|
92
|
+
if (!parsed.success) {
|
|
93
|
+
return err("invalid_argument", "Invalid arguments for get_playlist_tracks", {
|
|
94
|
+
detail: parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const input = parsed.data;
|
|
98
|
+
// Same allowlist and same two failure messages as search_tracks and
|
|
99
|
+
// get_tracks: a field name becomes SQL text, so it is checked against
|
|
100
|
+
// FIELD_SQL rather than interpolated, and an empty list is a named error
|
|
101
|
+
// instead of a raw SQLite syntax error.
|
|
102
|
+
const requestedFields = input.fields ?? [...DEFAULT_FIELDS];
|
|
103
|
+
if (requestedFields.length === 0)
|
|
104
|
+
return err("invalid_argument", "No fields requested");
|
|
105
|
+
const unknownFields = requestedFields.filter((f) => !(f in FIELD_SQL));
|
|
106
|
+
if (unknownFields.length) {
|
|
107
|
+
return err("invalid_argument", `Unknown field(s): ${unknownFields.join(", ")}`, {
|
|
108
|
+
detail: `Recognised fields: ${Object.keys(FIELD_SQL).join(", ")}`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const fields = requestedFields;
|
|
112
|
+
const resolved = await resolvePlaylist(qp, { id: input.playlist_id, name: input.playlist_name });
|
|
113
|
+
if (isEngineError(resolved))
|
|
114
|
+
return resolved;
|
|
115
|
+
const playlist = resolved.playlist;
|
|
116
|
+
let from = 1;
|
|
117
|
+
if (input.cursor) {
|
|
118
|
+
const cur = decodeCursor(input.cursor);
|
|
119
|
+
if (!cur)
|
|
120
|
+
return err("invalid_argument", "Malformed cursor");
|
|
121
|
+
if (cur[0] !== playlist.id) {
|
|
122
|
+
return err("invalid_argument", "This cursor belongs to a different playlist. Page with the playlist it came from, " +
|
|
123
|
+
"or start again without a cursor.");
|
|
124
|
+
}
|
|
125
|
+
from = cur[1];
|
|
126
|
+
}
|
|
127
|
+
const ordered = await loadPlaylistEntries(qp, playlist);
|
|
128
|
+
if (isEngineError(ordered))
|
|
129
|
+
return ordered;
|
|
130
|
+
const limit = Math.min(input.limit, MAX_TRACK_LIMIT);
|
|
131
|
+
const page = ordered.entries.slice(from - 1, from - 1 + limit);
|
|
132
|
+
// One lookup for the page, then reordered in memory -- SQL has no ordering
|
|
133
|
+
// to offer here, since playlist order lives in a linked list and not in
|
|
134
|
+
// any column that could appear in ORDER BY.
|
|
135
|
+
//
|
|
136
|
+
// Looked up by the natural key (see ENTRY_TRACK_MATCH), never by Track.id.
|
|
137
|
+
// An entry's `trackId` is a row id in *its own* library, so on a drive that
|
|
138
|
+
// has travelled -- which is the ordinary case -- resolving it as a local id
|
|
139
|
+
// finds nothing for most entries and, where a foreign id happens to collide
|
|
140
|
+
// with a local one, confidently returns an entirely different track.
|
|
141
|
+
//
|
|
142
|
+
// An entry with no databaseUuid is skipped rather than bound: SQL equality
|
|
143
|
+
// never matches NULL, so it is a hole by construction and a bind slot spent
|
|
144
|
+
// on it could only find the wrong row.
|
|
145
|
+
const wanted = new Map();
|
|
146
|
+
for (const e of page) {
|
|
147
|
+
if (e.trackId > 0 && e.databaseUuid !== null) {
|
|
148
|
+
wanted.set(entryKey(e.databaseUuid, e.trackId), { uuid: e.databaseUuid, trackId: e.trackId });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const byKey = new Map();
|
|
152
|
+
if (wanted.size) {
|
|
153
|
+
const keys = [...wanted.values()];
|
|
154
|
+
const select = fields.map((f) => `${FIELD_SQL[f]} AS "${f}"`).join(", ");
|
|
155
|
+
const res = await qp.run(`SELECT ${select}, t.originDatabaseUuid AS __uuid, t.originTrackId AS __origin
|
|
156
|
+
FROM main.Track t JOIN side.track_derived d ON d.track_id = t.id
|
|
157
|
+
WHERE (t.originDatabaseUuid, t.originTrackId)
|
|
158
|
+
IN (VALUES ${keys.map(() => "(?,?)").join(",")})`, keys.flatMap((k) => [k.uuid, k.trackId]));
|
|
159
|
+
if (isEngineError(res))
|
|
160
|
+
return res;
|
|
161
|
+
const idx = Object.fromEntries(res.columns.map((c, i) => [c, i]));
|
|
162
|
+
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
|
+
}));
|
|
172
|
+
byKey.set(entryKey(String(row[idx.__uuid]), Number(row[idx.__origin])), track);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const tracks = page.map((entry) => {
|
|
176
|
+
const track = entry.databaseUuid === null ? undefined : byKey.get(entryKey(entry.databaseUuid, entry.trackId));
|
|
177
|
+
return track
|
|
178
|
+
? { position: entry.position, ...track }
|
|
179
|
+
: {
|
|
180
|
+
position: entry.position,
|
|
181
|
+
entry_id: entry.id,
|
|
182
|
+
// Both halves of the key, because neither is meaningful alone:
|
|
183
|
+
// track_id is a row id in the library database_uuid names, and the
|
|
184
|
+
// same number means a different track in every other library.
|
|
185
|
+
track_id: entry.trackId,
|
|
186
|
+
database_uuid: entry.databaseUuid,
|
|
187
|
+
// Named, not implied by absent fields: a model must be able to tell
|
|
188
|
+
// "this slot has no track in this library" from "this track has no
|
|
189
|
+
// artist tag".
|
|
190
|
+
missing: true,
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
const last = page[page.length - 1];
|
|
194
|
+
const next_cursor = last && last.position < ordered.entries.length
|
|
195
|
+
? encodeCursor(playlist.id, last.position + 1)
|
|
196
|
+
: undefined;
|
|
197
|
+
const warnings = [...resolved.warnings, ...ordered.warnings];
|
|
198
|
+
return {
|
|
199
|
+
playlist,
|
|
200
|
+
tracks,
|
|
201
|
+
entry_count: ordered.entries.length,
|
|
202
|
+
missing_count: playlist.missing_count,
|
|
203
|
+
...(next_cursor ? { next_cursor } : {}),
|
|
204
|
+
...(warnings.length ? { warnings } : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
package/dist/tools/search.d.ts
CHANGED
|
@@ -30,6 +30,10 @@ export declare const SearchInput: z.ZodObject<{
|
|
|
30
30
|
min: z.ZodOptional<z.ZodNumber>;
|
|
31
31
|
max: z.ZodOptional<z.ZodNumber>;
|
|
32
32
|
}, z.core.$strip>>;
|
|
33
|
+
playlist: z.ZodOptional<z.ZodObject<{
|
|
34
|
+
id: z.ZodOptional<z.ZodNumber>;
|
|
35
|
+
name: z.ZodOptional<z.ZodString>;
|
|
36
|
+
}, z.core.$strip>>;
|
|
33
37
|
played: z.ZodOptional<z.ZodObject<{
|
|
34
38
|
never: z.ZodOptional<z.ZodBoolean>;
|
|
35
39
|
before: z.ZodOptional<z.ZodString>;
|
|
@@ -57,4 +61,10 @@ export declare function searchTracks(qp: QueryProcess, raw: SearchInput): Promis
|
|
|
57
61
|
total?: number;
|
|
58
62
|
total_capped?: boolean;
|
|
59
63
|
next_cursor?: string;
|
|
64
|
+
/** Echoed only when the playlist filter was used: which list it resolved to. */
|
|
65
|
+
playlist?: {
|
|
66
|
+
id: number;
|
|
67
|
+
name: string;
|
|
68
|
+
path: string;
|
|
69
|
+
};
|
|
60
70
|
} | EngineError>;
|
package/dist/tools/search.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { err, isEngineError } from "../errors.js";
|
|
5
5
|
import { camelotNeighbours } from "../semantics.js";
|
|
6
6
|
import { redactPath } from "../paths.js";
|
|
7
|
+
import { ENTRY_TRACK_MATCH, resolvePlaylist } from "../playlists.js";
|
|
7
8
|
export const DEFAULT_FIELDS = ["id", "artist", "title", "bpm", "camelot", "rating"];
|
|
8
9
|
const MAX_LIMIT = 200;
|
|
9
10
|
/**
|
|
@@ -65,6 +66,29 @@ export const SearchInput = z.object({
|
|
|
65
66
|
})
|
|
66
67
|
.optional(),
|
|
67
68
|
rating: z.object({ min: z.number().optional(), max: z.number().optional() }).optional(),
|
|
69
|
+
/**
|
|
70
|
+
* Restricts the search to one playlist.
|
|
71
|
+
*
|
|
72
|
+
* An object, like every other filter here (`bpm`, `key`, `rating`,
|
|
73
|
+
* `played`, `added`, `flags`), rather than a flat `playlist_id` /
|
|
74
|
+
* `playlist_name` pair as get_playlist_tracks uses: this schema's shape is
|
|
75
|
+
* "one named object per dimension you can narrow on", each grouping its own
|
|
76
|
+
* alternative ways of expressing that dimension -- exactly what id-or-name
|
|
77
|
+
* is. Two more top-level keys would put playlist selection at a different
|
|
78
|
+
* altitude from every other filter and leave no room to grow (an
|
|
79
|
+
* `exclude` sits naturally inside this object; `playlist_exclude` does
|
|
80
|
+
* not). get_playlist_tracks is flat for the opposite reason: there the
|
|
81
|
+
* playlist is the subject of the call, not one filter among eight.
|
|
82
|
+
*
|
|
83
|
+
* Ordering is unaffected -- results still come back by relevance or id, not
|
|
84
|
+
* in playlist order. get_playlist_tracks is the tool that preserves order.
|
|
85
|
+
*/
|
|
86
|
+
playlist: z
|
|
87
|
+
.object({
|
|
88
|
+
id: z.number().int().positive().optional(),
|
|
89
|
+
name: z.string().min(1).optional(),
|
|
90
|
+
})
|
|
91
|
+
.optional(),
|
|
68
92
|
played: z
|
|
69
93
|
.object({
|
|
70
94
|
never: z.boolean().optional(),
|
|
@@ -183,6 +207,39 @@ export async function searchTracks(qp, raw) {
|
|
|
183
207
|
filterWhere.push("f.fts_track MATCH ?");
|
|
184
208
|
filterParams.push(sanitizeFtsQuery(input.q));
|
|
185
209
|
}
|
|
210
|
+
// Resolved to an id before the SQL is built, so an unknown or ambiguous
|
|
211
|
+
// name comes back as the same actionable error get_playlist_tracks gives
|
|
212
|
+
// (naming every candidate), rather than as an empty result set that reads
|
|
213
|
+
// like "you own nothing at 128 BPM in that playlist".
|
|
214
|
+
//
|
|
215
|
+
// The subquery is a semi-join on PlaylistEntity, not a join: a track
|
|
216
|
+
// appears once in a playlist (UNIQUE (listId, databaseUuid, trackId)), but
|
|
217
|
+
// an entry may point at a track that is gone, and joining would then be
|
|
218
|
+
// one more way for row counts to drift. Membership is the whole question
|
|
219
|
+
// here.
|
|
220
|
+
//
|
|
221
|
+
// It matches on the natural key (see ENTRY_TRACK_MATCH), not on
|
|
222
|
+
// `t.id = e.trackId`: an entry made on another drive carries that drive's
|
|
223
|
+
// track id, so the id form both drops the playlist's real members and
|
|
224
|
+
// admits unrelated local tracks whose row id happens to collide with a
|
|
225
|
+
// foreign one.
|
|
226
|
+
let resolvedPlaylist;
|
|
227
|
+
if (input.playlist) {
|
|
228
|
+
const resolved = await resolvePlaylist(qp, input.playlist, {
|
|
229
|
+
id: "playlist.id",
|
|
230
|
+
name: "playlist.name",
|
|
231
|
+
});
|
|
232
|
+
if (isEngineError(resolved))
|
|
233
|
+
return resolved;
|
|
234
|
+
resolvedPlaylist = {
|
|
235
|
+
id: resolved.playlist.id,
|
|
236
|
+
name: resolved.playlist.name,
|
|
237
|
+
path: resolved.playlist.path,
|
|
238
|
+
};
|
|
239
|
+
filterWhere.push(`EXISTS (SELECT 1 FROM main.PlaylistEntity e
|
|
240
|
+
WHERE e.listId = ? AND ${ENTRY_TRACK_MATCH})`);
|
|
241
|
+
filterParams.push(resolved.playlist.id);
|
|
242
|
+
}
|
|
186
243
|
if (input.bpm) {
|
|
187
244
|
// Key and tempo filters go through the indexed side.track_derived
|
|
188
245
|
// columns (d.tempo / d.camelot below), never through the camelot() or
|
|
@@ -322,6 +379,7 @@ export async function searchTracks(qp, raw) {
|
|
|
322
379
|
}
|
|
323
380
|
return {
|
|
324
381
|
tracks,
|
|
382
|
+
...(resolvedPlaylist ? { playlist: resolvedPlaylist } : {}),
|
|
325
383
|
...(total !== undefined ? { total, total_capped } : {}),
|
|
326
384
|
...(next_cursor ? { next_cursor } : {}),
|
|
327
385
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type CreatePlaylistResult } from "../store/write.js";
|
|
3
|
+
import { type EngineError } from "../errors.js";
|
|
4
|
+
export declare const CreatePlaylistInput: z.ZodObject<{
|
|
5
|
+
title: z.ZodString;
|
|
6
|
+
track_ids: z.ZodArray<z.ZodNumber>;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
export declare function runCreatePlaylist(mdbPath: string, uuid: string, args: {
|
|
9
|
+
title: string;
|
|
10
|
+
track_ids: number[];
|
|
11
|
+
}, backupDir: string): Promise<CreatePlaylistResult | EngineError>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/tools/write-playlist.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createPlaylist } from "../store/write.js";
|
|
4
|
+
export const CreatePlaylistInput = z.object({
|
|
5
|
+
title: z.string().min(1).describe("Name for the new playlist. Must not already exist in this library."),
|
|
6
|
+
track_ids: z
|
|
7
|
+
.array(z.number().int().positive())
|
|
8
|
+
.max(10_000)
|
|
9
|
+
.describe("Track ids from search_tracks, in the order they should appear in the playlist. May be empty."),
|
|
10
|
+
});
|
|
11
|
+
export async function runCreatePlaylist(mdbPath, uuid, args, backupDir) {
|
|
12
|
+
return createPlaylist(mdbPath, uuid, { title: args.title, trackIds: args.track_ids }, { backupDir });
|
|
13
|
+
}
|