engine-dj-mcp 0.9.1 → 0.10.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 +259 -133
- package/dist/blobs/index.d.ts +30 -12
- package/dist/blobs/index.js +38 -17
- package/dist/discovery.js +22 -4
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +11 -0
- package/dist/playlists.d.ts +240 -0
- package/dist/playlists.js +447 -0
- package/dist/server.d.ts +4 -3
- package/dist/server.js +115 -20
- package/dist/store/index-manager.d.ts +12 -0
- package/dist/store/index-manager.js +15 -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/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { QueryProcess } from "./proc/query-client.js";
|
|
|
11
11
|
import { IndexManager } from "./store/index-manager.js";
|
|
12
12
|
import { searchTracks, SearchInput } from "./tools/search.js";
|
|
13
13
|
import { getTracks, GetTracksInput } from "./tools/tracks.js";
|
|
14
|
+
import { getPlaylists, GetPlaylistsInput, getPlaylistTracks, GetPlaylistTracksInput, } from "./tools/playlists.js";
|
|
14
15
|
import { getTrackPerformance, PerformanceInput } from "./tools/performance.js";
|
|
15
16
|
import { auditLibrary, AuditInput, AUDIT_CHECKS } from "./tools/audit.js";
|
|
16
17
|
import { runSql, RunSqlInput } from "./tools/sql.js";
|
|
@@ -54,9 +55,10 @@ function reply(value) {
|
|
|
54
55
|
* design (a permissions error on one candidate must not blank out every
|
|
55
56
|
* other one). That means a hot journal on the *only* library on this
|
|
56
57
|
* machine looks identical to no library existing at all -- both come back
|
|
57
|
-
* as an empty list, verified:
|
|
58
|
-
*
|
|
59
|
-
*
|
|
58
|
+
* as an empty list, verified: readLibraryInfo (discovery.ts) does report a
|
|
59
|
+
* hot journal precisely, as library_needs_recovery, but discoverLibraries()
|
|
60
|
+
* still drops it along with every other unreadable candidate, by that same
|
|
61
|
+
* design.
|
|
60
62
|
*
|
|
61
63
|
* This walks the same candidate paths independently, purely to tell those
|
|
62
64
|
* two cases apart, so `ready()` below can report library_needs_recovery
|
|
@@ -220,18 +222,30 @@ export async function createServer(opts = {}) {
|
|
|
220
222
|
* null, not as "generation zero". A library nobody has queried yet has no
|
|
221
223
|
* IndexManager at all and reports null for the same reason: listing the
|
|
222
224
|
* libraries must not fork a query child per drive to fill in a number.
|
|
225
|
+
*
|
|
226
|
+
* Reads each known IndexManager's generation via peekGeneration(), not
|
|
227
|
+
* ensureFresh(): ensureFresh() also rebuilds when the library has
|
|
228
|
+
* changed, which is exactly right for a tool that is about to query the
|
|
229
|
+
* index and exactly wrong here. list_libraries is what a user reaches
|
|
230
|
+
* for when something looks broken, and list_libraries re-scans on every
|
|
231
|
+
* call (see below) -- so making it pay for a first, or renewed, index
|
|
232
|
+
* build on a big or currently-locked library would make the one
|
|
233
|
+
* diagnostic tool that must stay fast the one most likely to block.
|
|
234
|
+
* peekGeneration() only reads the sidecar already on disk, so this stays
|
|
235
|
+
* honest (a real, current generation number, never a fabricated one) and
|
|
236
|
+
* never forces work list_libraries does not itself need to answer.
|
|
223
237
|
*/
|
|
224
|
-
const libraryReport =
|
|
238
|
+
const libraryReport = (discovered) => {
|
|
225
239
|
const generations = new Map();
|
|
226
240
|
for (const state of states.values()) {
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
generations.set(state.lib.uuid,
|
|
241
|
+
const generation = state.mgr.peekGeneration();
|
|
242
|
+
if (generation > 0)
|
|
243
|
+
generations.set(state.lib.uuid, generation);
|
|
230
244
|
}
|
|
231
245
|
return listLibraries(generations, discovered);
|
|
232
246
|
};
|
|
233
247
|
server.registerResource("schema", "engine://schema", { title: "Engine DJ schema and semantics", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, text: SCHEMA_NOTE }] }));
|
|
234
|
-
server.registerResource("libraries", "engine://libraries", { title: "Discovered Engine DJ libraries", mimeType: "application/json" }, async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(
|
|
248
|
+
server.registerResource("libraries", "engine://libraries", { title: "Discovered Engine DJ libraries", mimeType: "application/json" }, async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(libraryReport(libs), null, 2) }] }));
|
|
235
249
|
server.registerTool("search_tracks", {
|
|
236
250
|
title: "Search tracks",
|
|
237
251
|
description: "Search the Engine DJ library by text, tempo, key, rating, play history and analysis " +
|
|
@@ -241,6 +255,8 @@ export async function createServer(opts = {}) {
|
|
|
241
255
|
"flags.has_cues means a hot cue is actually set (the blob is decoded when the index " +
|
|
242
256
|
"is built), not merely that Engine analysed the track; flags.has_beatgrid means a " +
|
|
243
257
|
"beatData blob is present. " +
|
|
258
|
+
"playlist: {id} or {name} narrows the search to one playlist -- results still come " +
|
|
259
|
+
"back by relevance or id, not in playlist order; use get_playlist_tracks for that. " +
|
|
244
260
|
LIBRARY_SELECTION_NOTE,
|
|
245
261
|
inputSchema: { ...SearchInput.shape, library: LibraryArg },
|
|
246
262
|
annotations: RO,
|
|
@@ -262,19 +278,64 @@ export async function createServer(opts = {}) {
|
|
|
262
278
|
return reply(state);
|
|
263
279
|
return reply(await getTracks(state.qp, args));
|
|
264
280
|
});
|
|
281
|
+
server.registerTool("get_playlists", {
|
|
282
|
+
title: "List playlists",
|
|
283
|
+
description: "The library's playlist tree, in the order Engine DJ displays it -- taken from the " +
|
|
284
|
+
"Playlist.nextListId chain, which is where that order actually lives (the PlaylistPath " +
|
|
285
|
+
"view's `position` column runs the other way). " +
|
|
286
|
+
"Flat and in pre-order, so reading top to bottom is exactly the sidebar: `depth` and " +
|
|
287
|
+
"`path` carry the nesting, `parent_id` names the folder. " +
|
|
288
|
+
"is_folder means the list has child lists (Engine has no folder flag; a folder is a " +
|
|
289
|
+
"playlist other playlists sit under), so an emptied folder reads as an empty playlist. " +
|
|
290
|
+
"track_count is entries in that list alone, never rolled up from children, and " +
|
|
291
|
+
"missing_count is how many of them name a track that is not in this library. " +
|
|
292
|
+
"`warnings` appears when a link chain is broken or cyclic; nothing is ever dropped " +
|
|
293
|
+
"from the list because of one. " +
|
|
294
|
+
LIBRARY_SELECTION_NOTE,
|
|
295
|
+
inputSchema: { ...GetPlaylistsInput.shape, library: LibraryArg },
|
|
296
|
+
annotations: RO,
|
|
297
|
+
}, async (args) => {
|
|
298
|
+
const state = await acquire(args.library);
|
|
299
|
+
if (isEngineError(state))
|
|
300
|
+
return reply(state);
|
|
301
|
+
return reply(await getPlaylists(state.qp, args));
|
|
302
|
+
});
|
|
303
|
+
server.registerTool("get_playlist_tracks", {
|
|
304
|
+
title: "Get the tracks in a playlist",
|
|
305
|
+
description: "The tracks of one playlist, in playlist order -- from the PlaylistEntity.nextEntityId " +
|
|
306
|
+
"chain, not from row ids, so a track dragged up the list comes back where the DJ put it. " +
|
|
307
|
+
"Name the playlist with playlist_id, or with playlist_name (exactly one of the two). A " +
|
|
308
|
+
"name that matches several playlists is refused with every candidate's id and full path " +
|
|
309
|
+
"rather than picked between -- names are unique only within a folder, so pass the full " +
|
|
310
|
+
"`path` from get_playlists to disambiguate. " +
|
|
311
|
+
"Each row carries `position`, its 1-based place in the playlist. An entry whose track is " +
|
|
312
|
+
"not in this library comes back as { position, entry_id, track_id, missing: true } and " +
|
|
313
|
+
"keeps its slot, so entry_count still matches the playlist's own length; missing_count " +
|
|
314
|
+
"says how many of those there are. That is ordinary, not corruption -- entries outlive " +
|
|
315
|
+
"their tracks and arrive from other drives (see audit_library's orphan_entries). " +
|
|
316
|
+
"Same fields, limit and cursor conventions as search_tracks. " +
|
|
317
|
+
LIBRARY_SELECTION_NOTE,
|
|
318
|
+
inputSchema: { ...GetPlaylistTracksInput.shape, library: LibraryArg },
|
|
319
|
+
annotations: RO,
|
|
320
|
+
}, async (args) => {
|
|
321
|
+
const state = await acquire(args.library);
|
|
322
|
+
if (isEngineError(state))
|
|
323
|
+
return reply(state);
|
|
324
|
+
return reply(await getPlaylistTracks(state.qp, args));
|
|
325
|
+
});
|
|
265
326
|
server.registerTool("get_track_performance", {
|
|
266
327
|
title: "Get cues, loops and beatgrid",
|
|
267
328
|
description: "Decode PerformanceData for one track: hot cues, the main cue, saved loops, the " +
|
|
268
329
|
"beatgrid and a coarse waveform profile. Each field carries its own decode status " +
|
|
269
330
|
"and its own layout marker. " +
|
|
270
|
-
"layout: \"verified\" (
|
|
331
|
+
"layout: \"verified\" (every field) means the binary layout was " +
|
|
271
332
|
"confirmed against a real Engine DJ library -- cue positions land inside the track, " +
|
|
272
|
-
"the beatgrid's implied tempo matches the analysed BPM,
|
|
273
|
-
"point spacing multiplies back out to the track's sample count
|
|
274
|
-
"
|
|
275
|
-
"
|
|
276
|
-
"
|
|
277
|
-
"
|
|
333
|
+
"the beatgrid's implied tempo matches the analysed BPM, the waveform's declared " +
|
|
334
|
+
"point spacing multiplies back out to the track's sample count, and a saved loop " +
|
|
335
|
+
"spans a whole number of beats at that same analysed BPM -- so status: \"ok\" " +
|
|
336
|
+
"is a claim about the values, not just about the parse. " +
|
|
337
|
+
"layout: \"unverified\" would mean only that the bytes parsed; no field returns it " +
|
|
338
|
+
"today. " +
|
|
278
339
|
"Positions are sample offsets; sample_rate at the top level converts them to " +
|
|
279
340
|
"seconds, and cue/loop items carry the seconds already. Only hot-cue and loop slots " +
|
|
280
341
|
"that hold something are listed -- slots is how many the track has in total, so " +
|
|
@@ -335,7 +396,7 @@ export async function createServer(opts = {}) {
|
|
|
335
396
|
"library; without it they use the supported library holding the most tracks.",
|
|
336
397
|
inputSchema: {},
|
|
337
398
|
annotations: RO,
|
|
338
|
-
}, async () => reply(
|
|
399
|
+
}, async () => reply(libraryReport(rescanLibraries())));
|
|
339
400
|
server.registerTool("refresh_index", {
|
|
340
401
|
title: "Refresh the search index",
|
|
341
402
|
description: "Rebuild the search index if the library has changed. " + LIBRARY_SELECTION_NOTE,
|
|
@@ -387,8 +448,9 @@ Tables live in \`m.db\` (attached as \`main\`); the search index lives in \`side
|
|
|
387
448
|
More than one library can be connected at once — the local one under
|
|
388
449
|
\`~/Music\` and one per USB drive. \`list_libraries\` reports each with a
|
|
389
450
|
\`uuid\` and a \`path\`, and every tool that reads library data
|
|
390
|
-
(\`search_tracks\`, \`get_tracks\`, \`
|
|
391
|
-
\`
|
|
451
|
+
(\`search_tracks\`, \`get_tracks\`, \`get_playlists\`,
|
|
452
|
+
\`get_playlist_tracks\`, \`get_track_performance\`, \`audit_library\`,
|
|
453
|
+
\`run_sql\`, \`refresh_index\`) takes an optional
|
|
392
454
|
\`library\` argument naming one of them: either the \`uuid\` or the
|
|
393
455
|
\`path\`, in the \`~/...\` form \`list_libraries\` prints or the absolute
|
|
394
456
|
one. A value matching neither comes back as \`library_not_found\` listing
|
|
@@ -417,8 +479,6 @@ other.
|
|
|
417
479
|
- \`Track.path\` is relative to the \`Engine Library\` folder and usually
|
|
418
480
|
contains \`..\`. The SQL function \`abs_path(path)\` resolves it against
|
|
419
481
|
this library's location; the home prefix comes back folded to \`~\`.
|
|
420
|
-
- Playlists are singly linked lists: order lives in \`Playlist.nextListId\`
|
|
421
|
-
and \`PlaylistEntity.nextEntityId\`, not in any position column.
|
|
422
482
|
- A track's natural key across drives is \`(originDatabaseUuid, originTrackId)\`.
|
|
423
483
|
- \`PerformanceData\`'s blob columns are binary and cannot be read with SQL.
|
|
424
484
|
Engine writes \`quickCues\`, \`loops\`, \`beatData\` and
|
|
@@ -428,6 +488,41 @@ other.
|
|
|
428
488
|
\`get_track_performance\` decodes one track's blobs; for the whole library,
|
|
429
489
|
\`side.track_derived.has_cues\` below holds the decoded answer.
|
|
430
490
|
|
|
491
|
+
## Playlists
|
|
492
|
+
Order is a **singly linked list**, in both directions of the structure, and
|
|
493
|
+
there is no position column anywhere:
|
|
494
|
+
- \`Playlist.nextListId\` orders sibling lists within one \`parentListId\`.
|
|
495
|
+
- \`PlaylistEntity.nextEntityId\` orders the entries within one \`listId\`.
|
|
496
|
+
Both chains terminate at \`0\`.
|
|
497
|
+
|
|
498
|
+
The schema ships a \`PlaylistPath\` view with a column named \`position\`.
|
|
499
|
+
**Do not use it for display order.** Its \`OrderedList\` CTE anchors on
|
|
500
|
+
\`WHERE nextListId = 0\` and counts upwards from the *tail*, so ordering by
|
|
501
|
+
it yields the sidebar reversed — measured on a real library of 16 playlists,
|
|
502
|
+
\`PlaylistPath\` order is exactly the reverse of the chain, and the chain is
|
|
503
|
+
what Engine DJ draws. \`get_playlists\` and \`get_playlist_tracks\` walk the
|
|
504
|
+
chains; write the same recursive walk if you go via \`run_sql\`, and never
|
|
505
|
+
\`ORDER BY id\` — ids happen to agree with the chain until the first time
|
|
506
|
+
someone drags a track up a playlist.
|
|
507
|
+
|
|
508
|
+
Nesting is \`Playlist.parentListId\` (\`0\` at the top level). There is no
|
|
509
|
+
folder flag: an Engine folder is just a \`Playlist\` row that other rows
|
|
510
|
+
name as their parent. \`Playlist.isPersisted\` marks a list saved to the
|
|
511
|
+
device rather than a transient one; both values occur on lists Engine
|
|
512
|
+
displays, so nothing is filtered on it. Titles are unique only within a
|
|
513
|
+
parent (\`UNIQUE (title, parentListId)\`), so a bare name can match several
|
|
514
|
+
playlists — the full \`path\` \`get_playlists\` reports is unique.
|
|
515
|
+
|
|
516
|
+
\`PlaylistEntity.trackId\` may name a track that is not in this library:
|
|
517
|
+
entries outlive their tracks and travel between drives (\`databaseUuid\`
|
|
518
|
+
records which library an entry came from). On the reference library 105 of
|
|
519
|
+
202 entries are such holes. \`audit_library\`'s \`orphan_entries\` counts
|
|
520
|
+
them; \`get_playlist_tracks\` keeps them in place, flagged \`missing\`.
|
|
521
|
+
|
|
522
|
+
Rule-based **smartlists** live in a separate \`Smartlist\` table, keyed by
|
|
523
|
+
uuid with its own \`nextListUuid\` ordering and a JSON \`rules\` column.
|
|
524
|
+
Nothing here reads it — a smartlist is not reported by \`get_playlists\`.
|
|
525
|
+
|
|
431
526
|
## SQL functions
|
|
432
527
|
Registered on the query connection, all deterministic:
|
|
433
528
|
\`camelot(key)\`, \`key_name(key)\`, \`tempo(bpmAnalyzed, bpm)\`,
|
|
@@ -25,5 +25,17 @@ export declare class IndexManager {
|
|
|
25
25
|
constructor(lib: LibraryInfo, qp: QueryProcess, baseDir?: string);
|
|
26
26
|
get generation(): number;
|
|
27
27
|
get path(): string;
|
|
28
|
+
/**
|
|
29
|
+
* The generation of whatever index currently exists on disk, read
|
|
30
|
+
* directly from the sidecar's own index_meta table -- never built,
|
|
31
|
+
* rebuilt or attached. This is the cheap half of ensureFresh() (the part
|
|
32
|
+
* that does not open the main library at all), used by list_libraries so
|
|
33
|
+
* that asking "what libraries do I have" is never the call that pays for
|
|
34
|
+
* a first (or renewed) index build: that cost belongs to whichever tool
|
|
35
|
+
* call actually needs a fresh index, through ensureFresh(). Returns 0,
|
|
36
|
+
* the same "not a real generation" value ensureFresh() reports before
|
|
37
|
+
* ever building one, when no usable sidecar exists yet.
|
|
38
|
+
*/
|
|
39
|
+
peekGeneration(): number;
|
|
28
40
|
ensureFresh(): Promise<FreshResult | EngineError>;
|
|
29
41
|
}
|
|
@@ -33,6 +33,21 @@ export class IndexManager {
|
|
|
33
33
|
get path() {
|
|
34
34
|
return join(this.baseDir, this.lib.uuid, "index.db");
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The generation of whatever index currently exists on disk, read
|
|
38
|
+
* directly from the sidecar's own index_meta table -- never built,
|
|
39
|
+
* rebuilt or attached. This is the cheap half of ensureFresh() (the part
|
|
40
|
+
* that does not open the main library at all), used by list_libraries so
|
|
41
|
+
* that asking "what libraries do I have" is never the call that pays for
|
|
42
|
+
* a first (or renewed) index build: that cost belongs to whichever tool
|
|
43
|
+
* call actually needs a fresh index, through ensureFresh(). Returns 0,
|
|
44
|
+
* the same "not a real generation" value ensureFresh() reports before
|
|
45
|
+
* ever building one, when no usable sidecar exists yet.
|
|
46
|
+
*/
|
|
47
|
+
peekGeneration() {
|
|
48
|
+
this.#storedCounter();
|
|
49
|
+
return this.#generation;
|
|
50
|
+
}
|
|
36
51
|
/**
|
|
37
52
|
* The change counter the sidecar on disk was built from, or null when
|
|
38
53
|
* there is no usable sidecar — which forces a rebuild.
|
package/dist/tools/audit.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { err, isEngineError } from "../errors.js";
|
|
5
5
|
import { absTrackPath } from "../paths.js";
|
|
6
|
+
import { ENTRY_TRACK_MATCH } from "../playlists.js";
|
|
6
7
|
export const AUDIT_CHECKS = [
|
|
7
8
|
"missing_files",
|
|
8
9
|
"unavailable",
|
|
@@ -83,9 +84,19 @@ const SQL_CHECKS = {
|
|
|
83
84
|
WHERE artist IS NOT NULL AND title IS NOT NULL
|
|
84
85
|
GROUP BY 1 HAVING COUNT(*) > 1)`,
|
|
85
86
|
},
|
|
87
|
+
// "No track answers to this entry" — asked on the natural key
|
|
88
|
+
// `(databaseUuid, trackId)` -> `(originDatabaseUuid, originTrackId)`, never
|
|
89
|
+
// on `Track.id`. ENTRY_TRACK_MATCH carries the measurement: on the
|
|
90
|
+
// reference library the id join reported 105 orphans of 202 entries on a
|
|
91
|
+
// library that has none, which is this check's whole user-visible failure
|
|
92
|
+
// mode — a DJ told their playlists are full of holes.
|
|
93
|
+
//
|
|
94
|
+
// NOT EXISTS rather than a LEFT JOIN so the count is entries, not matched
|
|
95
|
+
// pairs, whatever the file on disk happens to contain.
|
|
86
96
|
orphan_entries: {
|
|
87
97
|
id: "e.id",
|
|
88
|
-
body: `FROM PlaylistEntity e
|
|
98
|
+
body: `FROM PlaylistEntity e
|
|
99
|
+
WHERE NOT EXISTS (SELECT 1 FROM Track t WHERE ${ENTRY_TRACK_MATCH})`,
|
|
89
100
|
},
|
|
90
101
|
};
|
|
91
102
|
export async function auditLibrary(qp, mdbPath, raw) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type EngineError } from "../errors.js";
|
|
3
|
+
import { type PlaylistItem } from "../playlists.js";
|
|
4
|
+
import type { QueryProcess } from "../proc/query-client.js";
|
|
5
|
+
export declare const GetPlaylistsInput: z.ZodObject<{
|
|
6
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
export type GetPlaylistsInput = z.input<typeof GetPlaylistsInput>;
|
|
9
|
+
export interface GetPlaylistsResult {
|
|
10
|
+
playlists: PlaylistItem[];
|
|
11
|
+
total: number;
|
|
12
|
+
truncated: boolean;
|
|
13
|
+
warnings?: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The library's playlist tree, in the order Engine DJ displays it.
|
|
17
|
+
*
|
|
18
|
+
* Flat, in pre-order, with `depth` and `path` carrying the nesting -- see
|
|
19
|
+
* buildPlaylistTree for why that beats nested `children` arrays here.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getPlaylists(qp: QueryProcess, raw: GetPlaylistsInput): Promise<GetPlaylistsResult | EngineError>;
|
|
22
|
+
export declare const GetPlaylistTracksInput: z.ZodObject<{
|
|
23
|
+
playlist_id: z.ZodOptional<z.ZodNumber>;
|
|
24
|
+
playlist_name: z.ZodOptional<z.ZodString>;
|
|
25
|
+
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
26
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
27
|
+
cursor: z.ZodOptional<z.ZodString>;
|
|
28
|
+
redact_paths: z.ZodDefault<z.ZodBoolean>;
|
|
29
|
+
}, z.core.$strip>;
|
|
30
|
+
export type GetPlaylistTracksInput = z.input<typeof GetPlaylistTracksInput>;
|
|
31
|
+
/**
|
|
32
|
+
* A row of a playlist. Either a track (the requested `fields`, plus its
|
|
33
|
+
* position) or a hole where a track used to be.
|
|
34
|
+
*
|
|
35
|
+
* A hole is a real, ordinary state, not corruption: `PlaylistEntity` rows
|
|
36
|
+
* survive their track, which is why `audit_library` has an `orphan_entries`
|
|
37
|
+
* check at all. What a hole is *not* is an entry that merely came from
|
|
38
|
+
* another drive — those resolve perfectly well through the natural key (see
|
|
39
|
+
* ENTRY_TRACK_MATCH), and on the reference USB library all 202 entries do,
|
|
40
|
+
* including the 178 stamped with a third library's uuid.
|
|
41
|
+
*
|
|
42
|
+
* Genuine holes are kept in the list rather than filtered out, at their real
|
|
43
|
+
* positions, precisely so `tracks.length` still equals the playlist's own
|
|
44
|
+
* length and position 12 is still the twelfth thing the DJ sees in Engine.
|
|
45
|
+
* Dropping them would make a 43-entry playlist silently return fewer rows
|
|
46
|
+
* and look like a paging bug.
|
|
47
|
+
*/
|
|
48
|
+
export type PlaylistTrackRow = Record<string, unknown>;
|
|
49
|
+
export interface GetPlaylistTracksResult {
|
|
50
|
+
playlist: PlaylistItem;
|
|
51
|
+
tracks: PlaylistTrackRow[];
|
|
52
|
+
/** Entries in the whole playlist, not in this page. */
|
|
53
|
+
entry_count: number;
|
|
54
|
+
/** Entries in the whole playlist whose track is not in this library. */
|
|
55
|
+
missing_count: number;
|
|
56
|
+
next_cursor?: string;
|
|
57
|
+
warnings?: string[];
|
|
58
|
+
}
|
|
59
|
+
export declare function getPlaylistTracks(qp: QueryProcess, raw: GetPlaylistTracksInput): Promise<GetPlaylistTracksResult | EngineError>;
|
|
@@ -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
|
};
|