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/dist/server.js CHANGED
@@ -1,23 +1,26 @@
1
1
  // src/server.ts
2
2
  import { existsSync, readFileSync } from "node:fs";
3
- import { createHash } from "node:crypto";
3
+ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
6
  import { discoverLibraries, defaultRoots, probeLibraries } from "./discovery.js";
7
- import { libraryCandidates, sidecarDir } from "./paths.js";
7
+ import { libraryCandidates, libraryTag, sidecarDir } from "./paths.js";
8
8
  import { LibraryArg, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
9
9
  import { hasHotJournal } from "./store/connections.js";
10
10
  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";
17
18
  import { listLibraries } from "./tools/libraries.js";
18
19
  import { refreshIndex } from "./tools/refresh.js";
20
+ import { CreatePlaylistInput, runCreatePlaylist } from "./tools/write-playlist.js";
19
21
  import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
20
22
  const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
23
+ const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false };
21
24
  /**
22
25
  * name/version reported to every client on initialize. Read from
23
26
  * package.json rather than typed here, so the two cannot re-diverge the way
@@ -62,8 +65,10 @@ function reply(value) {
62
65
  * This walks the same candidate paths independently, purely to tell those
63
66
  * two cases apart, so `ready()` below can report library_needs_recovery
64
67
  * instead of the misleading library_not_found -- never to open the file:
65
- * recovering a hot journal requires a write, and this project never writes
66
- * to the user's library, even to heal it.
68
+ * recovering a hot journal requires a write, and nothing here opens a
69
+ * library writably to heal one. Not even create_playlist, which refuses a
70
+ * library in this state outright (store/write.ts) rather than letting
71
+ * SQLite roll the journal forward on its way in.
67
72
  */
68
73
  export function findHotJournalCandidate(roots) {
69
74
  for (const root of roots) {
@@ -151,8 +156,7 @@ export async function createServer(opts = {}) {
151
156
  const first = knownList().find((l) => l.uuid === lib.uuid);
152
157
  if (!first || first.path === lib.path)
153
158
  return opts.sidecarBaseDir;
154
- const tag = createHash("sha256").update(lib.path).digest("hex").slice(0, 12);
155
- return join(opts.sidecarBaseDir ?? sidecarDir(""), "duplicate-uuid", tag);
159
+ return join(opts.sidecarBaseDir ?? sidecarDir(""), "duplicate-uuid", libraryTag(lib.path));
156
160
  };
157
161
  /** Lazily creates -- and thereafter reuses -- one query child per library. */
158
162
  const stateFor = (lib) => {
@@ -254,6 +258,8 @@ export async function createServer(opts = {}) {
254
258
  "flags.has_cues means a hot cue is actually set (the blob is decoded when the index " +
255
259
  "is built), not merely that Engine analysed the track; flags.has_beatgrid means a " +
256
260
  "beatData blob is present. " +
261
+ "playlist: {id} or {name} narrows the search to one playlist -- results still come " +
262
+ "back by relevance or id, not in playlist order; use get_playlist_tracks for that. " +
257
263
  LIBRARY_SELECTION_NOTE,
258
264
  inputSchema: { ...SearchInput.shape, library: LibraryArg },
259
265
  annotations: RO,
@@ -275,19 +281,64 @@ export async function createServer(opts = {}) {
275
281
  return reply(state);
276
282
  return reply(await getTracks(state.qp, args));
277
283
  });
284
+ server.registerTool("get_playlists", {
285
+ title: "List playlists",
286
+ description: "The library's playlist tree, in the order Engine DJ displays it -- taken from the " +
287
+ "Playlist.nextListId chain, which is where that order actually lives (the PlaylistPath " +
288
+ "view's `position` column runs the other way). " +
289
+ "Flat and in pre-order, so reading top to bottom is exactly the sidebar: `depth` and " +
290
+ "`path` carry the nesting, `parent_id` names the folder. " +
291
+ "is_folder means the list has child lists (Engine has no folder flag; a folder is a " +
292
+ "playlist other playlists sit under), so an emptied folder reads as an empty playlist. " +
293
+ "track_count is entries in that list alone, never rolled up from children, and " +
294
+ "missing_count is how many of them name a track that is not in this library. " +
295
+ "`warnings` appears when a link chain is broken or cyclic; nothing is ever dropped " +
296
+ "from the list because of one. " +
297
+ LIBRARY_SELECTION_NOTE,
298
+ inputSchema: { ...GetPlaylistsInput.shape, library: LibraryArg },
299
+ annotations: RO,
300
+ }, async (args) => {
301
+ const state = await acquire(args.library);
302
+ if (isEngineError(state))
303
+ return reply(state);
304
+ return reply(await getPlaylists(state.qp, args));
305
+ });
306
+ server.registerTool("get_playlist_tracks", {
307
+ title: "Get the tracks in a playlist",
308
+ description: "The tracks of one playlist, in playlist order -- from the PlaylistEntity.nextEntityId " +
309
+ "chain, not from row ids, so a track dragged up the list comes back where the DJ put it. " +
310
+ "Name the playlist with playlist_id, or with playlist_name (exactly one of the two). A " +
311
+ "name that matches several playlists is refused with every candidate's id and full path " +
312
+ "rather than picked between -- names are unique only within a folder, so pass the full " +
313
+ "`path` from get_playlists to disambiguate. " +
314
+ "Each row carries `position`, its 1-based place in the playlist. An entry whose track is " +
315
+ "not in this library comes back as { position, entry_id, track_id, missing: true } and " +
316
+ "keeps its slot, so entry_count still matches the playlist's own length; missing_count " +
317
+ "says how many of those there are. That is ordinary, not corruption -- entries outlive " +
318
+ "their tracks and arrive from other drives (see audit_library's orphan_entries). " +
319
+ "Same fields, limit and cursor conventions as search_tracks. " +
320
+ LIBRARY_SELECTION_NOTE,
321
+ inputSchema: { ...GetPlaylistTracksInput.shape, library: LibraryArg },
322
+ annotations: RO,
323
+ }, async (args) => {
324
+ const state = await acquire(args.library);
325
+ if (isEngineError(state))
326
+ return reply(state);
327
+ return reply(await getPlaylistTracks(state.qp, args));
328
+ });
278
329
  server.registerTool("get_track_performance", {
279
330
  title: "Get cues, loops and beatgrid",
280
331
  description: "Decode PerformanceData for one track: hot cues, the main cue, saved loops, the " +
281
332
  "beatgrid and a coarse waveform profile. Each field carries its own decode status " +
282
333
  "and its own layout marker. " +
283
- "layout: \"verified\" (cues, beatgrid, waveform_summary) means the binary layout was " +
334
+ "layout: \"verified\" (every field) means the binary layout was " +
284
335
  "confirmed against a real Engine DJ library -- cue positions land inside the track, " +
285
- "the beatgrid's implied tempo matches the analysed BPM, and the waveform's declared " +
286
- "point spacing multiplies back out to the track's sample count -- so status: \"ok\" " +
287
- "there is a claim about the values, not just about the parse. " +
288
- "layout: \"unverified\" (loops) still means only that the bytes parsed: the loop slot " +
289
- "structure is known, but no library was available with a loop actually saved, so " +
290
- "loop bounds must not be reported to a user as fact. " +
336
+ "the beatgrid's implied tempo matches the analysed BPM, the waveform's declared " +
337
+ "point spacing multiplies back out to the track's sample count, and a saved loop " +
338
+ "spans a whole number of beats at that same analysed BPM -- so status: \"ok\" " +
339
+ "is a claim about the values, not just about the parse. " +
340
+ "layout: \"unverified\" would mean only that the bytes parsed; no field returns it " +
341
+ "today. " +
291
342
  "Positions are sample offsets; sample_rate at the top level converts them to " +
292
343
  "seconds, and cue/loop items carry the seconds already. Only hot-cue and loop slots " +
293
344
  "that hold something are listed -- slots is how many the track has in total, so " +
@@ -362,6 +413,40 @@ export async function createServer(opts = {}) {
362
413
  return reply(lib);
363
414
  return reply(await refreshIndex(stateFor(lib).mgr));
364
415
  });
416
+ // Registered only under --allow-writes. A client that never enables it sees
417
+ // exactly the read-only server it saw before this feature existed, which is
418
+ // what keeps the README's promise true by default.
419
+ if (opts.allowWrites) {
420
+ server.registerTool("create_playlist", {
421
+ title: "Create a playlist",
422
+ description: "Create a new playlist in this Engine DJ library from track ids returned by " +
423
+ "search_tracks or get_tracks -- track_ids sets both membership and order. " +
424
+ "Unlike every other tool here, this WRITES to the library, so do not call it " +
425
+ "speculatively: only call it once you actually intend to add the playlist. " +
426
+ "To undo it, delete the playlist in Engine DJ -- Engine's own delete trigger and " +
427
+ "cascade remove the playlist and its entries cleanly. " +
428
+ "backup_path in the result names a whole-database snapshot taken before the first " +
429
+ "write of this session; it is a recovery route for a damaged library, NOT an undo. " +
430
+ "Restoring it reverts the entire library to that moment, discarding everything " +
431
+ "Engine DJ has written since (play counts, imports, cue and beatgrid edits). " +
432
+ "No existing playlist or entry is ever modified; this only adds a new one. " +
433
+ "Fails with playlist_exists if a top-level playlist already has that title, and " +
434
+ "with library_busy if Engine DJ or a player is holding a conflicting lock on the " +
435
+ "library right then -- nothing is written in that case, so retry rather than " +
436
+ "treating it as permanent. On any error, `detail` is \"not_committed\" when the " +
437
+ "library is unchanged and \"committed_unverified\" when the write may have gone " +
438
+ "through but could not be verified. track_ids may be empty (an empty playlist); a " +
439
+ "track id may appear at most once. " +
440
+ LIBRARY_SELECTION_NOTE,
441
+ inputSchema: { ...CreatePlaylistInput.shape, library: LibraryArg },
442
+ annotations: RW,
443
+ }, async (args) => {
444
+ const state = await acquire(args.library);
445
+ if (isEngineError(state))
446
+ return reply(state);
447
+ return reply(await runCreatePlaylist(state.lib.path, state.lib.uuid, args, join(homedir(), ".engine-dj-mcp", "backups")));
448
+ });
449
+ }
365
450
  /**
366
451
  * There was previously no way to shut this down at all: createServer
367
452
  * forked a query child and handed back an McpServer whose close() knows
@@ -400,9 +485,11 @@ Tables live in \`m.db\` (attached as \`main\`); the search index lives in \`side
400
485
  More than one library can be connected at once — the local one under
401
486
  \`~/Music\` and one per USB drive. \`list_libraries\` reports each with a
402
487
  \`uuid\` and a \`path\`, and every tool that reads library data
403
- (\`search_tracks\`, \`get_tracks\`, \`get_track_performance\`,
404
- \`audit_library\`, \`run_sql\`, \`refresh_index\`) takes an optional
405
- \`library\` argument naming one of them: either the \`uuid\` or the
488
+ (\`search_tracks\`, \`get_tracks\`, \`get_playlists\`,
489
+ \`get_playlist_tracks\`, \`get_track_performance\`, \`audit_library\`,
490
+ \`run_sql\`, \`refresh_index\`) takes an optional
491
+ \`library\` argument naming one of them — as does \`create_playlist\`, when
492
+ the server was started with \`--allow-writes\` — either the \`uuid\` or the
406
493
  \`path\`, in the \`~/...\` form \`list_libraries\` prints or the absolute
407
494
  one. A value matching neither comes back as \`library_not_found\` listing
408
495
  the libraries that are selectable.
@@ -430,8 +517,6 @@ other.
430
517
  - \`Track.path\` is relative to the \`Engine Library\` folder and usually
431
518
  contains \`..\`. The SQL function \`abs_path(path)\` resolves it against
432
519
  this library's location; the home prefix comes back folded to \`~\`.
433
- - Playlists are singly linked lists: order lives in \`Playlist.nextListId\`
434
- and \`PlaylistEntity.nextEntityId\`, not in any position column.
435
520
  - A track's natural key across drives is \`(originDatabaseUuid, originTrackId)\`.
436
521
  - \`PerformanceData\`'s blob columns are binary and cannot be read with SQL.
437
522
  Engine writes \`quickCues\`, \`loops\`, \`beatData\` and
@@ -441,6 +526,41 @@ other.
441
526
  \`get_track_performance\` decodes one track's blobs; for the whole library,
442
527
  \`side.track_derived.has_cues\` below holds the decoded answer.
443
528
 
529
+ ## Playlists
530
+ Order is a **singly linked list**, in both directions of the structure, and
531
+ there is no position column anywhere:
532
+ - \`Playlist.nextListId\` orders sibling lists within one \`parentListId\`.
533
+ - \`PlaylistEntity.nextEntityId\` orders the entries within one \`listId\`.
534
+ Both chains terminate at \`0\`.
535
+
536
+ The schema ships a \`PlaylistPath\` view with a column named \`position\`.
537
+ **Do not use it for display order.** Its \`OrderedList\` CTE anchors on
538
+ \`WHERE nextListId = 0\` and counts upwards from the *tail*, so ordering by
539
+ it yields the sidebar reversed — measured on a real library of 16 playlists,
540
+ \`PlaylistPath\` order is exactly the reverse of the chain, and the chain is
541
+ what Engine DJ draws. \`get_playlists\` and \`get_playlist_tracks\` walk the
542
+ chains; write the same recursive walk if you go via \`run_sql\`, and never
543
+ \`ORDER BY id\` — ids happen to agree with the chain until the first time
544
+ someone drags a track up a playlist.
545
+
546
+ Nesting is \`Playlist.parentListId\` (\`0\` at the top level). There is no
547
+ folder flag: an Engine folder is just a \`Playlist\` row that other rows
548
+ name as their parent. \`Playlist.isPersisted\` marks a list saved to the
549
+ device rather than a transient one; both values occur on lists Engine
550
+ displays, so nothing is filtered on it. Titles are unique only within a
551
+ parent (\`UNIQUE (title, parentListId)\`), so a bare name can match several
552
+ playlists — the full \`path\` \`get_playlists\` reports is unique.
553
+
554
+ \`PlaylistEntity.trackId\` may name a track that is not in this library:
555
+ entries outlive their tracks and travel between drives (\`databaseUuid\`
556
+ records which library an entry came from). On the reference library 105 of
557
+ 202 entries are such holes. \`audit_library\`'s \`orphan_entries\` counts
558
+ them; \`get_playlist_tracks\` keeps them in place, flagged \`missing\`.
559
+
560
+ Rule-based **smartlists** live in a separate \`Smartlist\` table, keyed by
561
+ uuid with its own \`nextListUuid\` ordering and a JSON \`rules\` column.
562
+ Nothing here reads it — a smartlist is not reported by \`get_playlists\`.
563
+
444
564
  ## SQL functions
445
565
  Registered on the query connection, all deterministic:
446
566
  \`camelot(key)\`, \`key_name(key)\`, \`tempo(bpmAnalyzed, bpm)\`,
@@ -0,0 +1,2 @@
1
+ import { type EngineError } from "../errors.js";
2
+ export declare function snapshotLibrary(mdbPath: string, uuid: string, baseDir: string): Promise<string | EngineError>;
@@ -0,0 +1,72 @@
1
+ // src/store/backup.ts
2
+ //
3
+ // A snapshot taken before the first write of a session, so a user who does
4
+ // not like the result has a known way back. It is taken through SQLite's own
5
+ // backup API rather than a file copy: that is correct even while Engine DJ
6
+ // holds the database open, which a cp is not.
7
+ //
8
+ // Snapshots live under ~/.engine-dj-mcp/backups/ and never inside the user's
9
+ // Engine Library folder -- the rule that no file is created there holds for
10
+ // writes exactly as it did for reads.
11
+ import { DatabaseSync, backup } from "node:sqlite";
12
+ import { mkdirSync, readdirSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { err } from "../errors.js";
15
+ import { libraryTag } from "../paths.js";
16
+ /** How many snapshots to keep per library before deleting the oldest. */
17
+ const KEEP = 10;
18
+ /**
19
+ * Monotonic counter to ensure unique, sortable filenames even in tight loops.
20
+ * The ISO string alone provides only millisecond precision, so rapid-fire calls
21
+ * in the same millisecond would collide. The counter serves as an infallible
22
+ * tiebreaker: calls within the same millisecond are ordered by counter value,
23
+ * and calls across milliseconds are already separated by the ISO string. This
24
+ * ordering is essential: rotation immediately deletes the text-sorted oldest,
25
+ * so an inverted sort would delete the snapshot we just handed back.
26
+ */
27
+ let counter = 0;
28
+ /**
29
+ * A filename-safe, sortable stamp. Sorting the directory listing as text
30
+ * therefore orders snapshots by age, which is what rotation relies on.
31
+ */
32
+ function stamp() {
33
+ const iso = new Date().toISOString().replace(/[:.]/g, "-");
34
+ return `${iso}-${String(++counter).padStart(10, "0")}`;
35
+ }
36
+ export async function snapshotLibrary(mdbPath, uuid, baseDir) {
37
+ let src;
38
+ try {
39
+ mkdirSync(baseDir, { recursive: true });
40
+ src = new DatabaseSync(mdbPath, { readOnly: true });
41
+ // uuid *and* a hash of the file's own path. Keyed on uuid alone, a
42
+ // library and its clone on a second drive -- same uuid, different drive,
43
+ // an ordinary thing for a DJ to have -- shared one namespace and one
44
+ // KEEP-slot window: writes to either evicted the other's snapshots, and
45
+ // a returned backup_path did not say which drive it came from. This is
46
+ // the same tag server.ts's sidecarBaseFor uses to keep two such
47
+ // libraries' indexes apart (see paths.ts).
48
+ const prefix = `${uuid}-${libraryTag(mdbPath)}-`;
49
+ const dest = join(baseDir, `${prefix}${stamp()}.db`);
50
+ await backup(src, dest);
51
+ src.close();
52
+ src = undefined;
53
+ const mine = readdirSync(baseDir)
54
+ .filter((f) => f.startsWith(prefix) && f.endsWith(".db"))
55
+ .sort();
56
+ for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) {
57
+ rmSync(join(baseDir, old), { force: true });
58
+ }
59
+ return dest;
60
+ }
61
+ catch (e) {
62
+ return err("library_unreadable", `Could not snapshot ${mdbPath} before writing: ${String(e)}`);
63
+ }
64
+ finally {
65
+ try {
66
+ src?.close();
67
+ }
68
+ catch {
69
+ /* already closed */
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,37 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { type EngineError } from "../errors.js";
3
+ export interface CreatePlaylistResult {
4
+ playlist_id: number;
5
+ title: string;
6
+ tracks_added: number;
7
+ backup_path: string;
8
+ }
9
+ export interface OriginRef {
10
+ uuid: string;
11
+ trackId: number;
12
+ }
13
+ /** Test seam only: forget this process's snapshots so a test can start clean. */
14
+ export declare function resetSessionSnapshots(): void;
15
+ /**
16
+ * Read the chain back starting from a row we know is the head, because we
17
+ * inserted it first. Re-deriving the head as "the row nothing points at"
18
+ * would be the same assumption the write just made, so it could not catch a
19
+ * write that made it wrongly.
20
+ *
21
+ * This, together with `sameOrder`, confirms that the *links* survived the
22
+ * round trip in the order given -- it does not independently confirm the
23
+ * *values* are correct. The comparison target is `refs`, the same array
24
+ * `resolveOrigins` produced and the write consumed, so a `resolveOrigins`
25
+ * that resolved every id wrongly (e.g. to the local row id instead of the
26
+ * origin pair) would write wrong values, read the same wrong values back,
27
+ * and pass this check. Catching that class of bug is what the
28
+ * re-originated-track test is for, not this readback.
29
+ */
30
+ export declare function walkFrom(db: DatabaseSync, listId: number, headId: number): OriginRef[];
31
+ export declare function sameOrder(a: OriginRef[], b: OriginRef[]): boolean;
32
+ export declare function createPlaylist(mdbPath: string, uuid: string, input: {
33
+ title: string;
34
+ trackIds: number[];
35
+ }, opts: {
36
+ backupDir: string;
37
+ }): Promise<CreatePlaylistResult | EngineError>;