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/playlists.js
CHANGED
|
@@ -353,6 +353,28 @@ function describe(items) {
|
|
|
353
353
|
? `${shown}; and ${items.length - NAMED_IN_ERROR} more (call get_playlists to see them all)`
|
|
354
354
|
: shown;
|
|
355
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* "No such playlist", for both branches resolvePlaylist can reach it from.
|
|
358
|
+
*
|
|
359
|
+
* The default `invalid_argument` code has no write-path contract on
|
|
360
|
+
* `detail` (see errors.ts), so the candidate listing lives there, exactly as
|
|
361
|
+
* before. `playlist_not_found` is different: a write tool's caller reads
|
|
362
|
+
* `detail` to learn whether the library changed (see errors.ts), and every
|
|
363
|
+
* other `playlist_not_found` -- raised inside store/write.ts once an id
|
|
364
|
+
* reaches it -- carries `detail: "not_committed"`. Nothing was attempted
|
|
365
|
+
* here either, so this must match, which leaves no room in `detail` for the
|
|
366
|
+
* candidate listing; it moves into `message` instead, so a human still sees
|
|
367
|
+
* it.
|
|
368
|
+
*/
|
|
369
|
+
function notFoundError(names, reason, items) {
|
|
370
|
+
const candidates = items.length
|
|
371
|
+
? `Playlists (id -- path): ${describe(items)}`
|
|
372
|
+
: "This library has no playlists.";
|
|
373
|
+
if (names.notFound) {
|
|
374
|
+
return err(names.notFound, `${reason}. ${candidates}`, { detail: "not_committed" });
|
|
375
|
+
}
|
|
376
|
+
return err("invalid_argument", reason, { detail: candidates });
|
|
377
|
+
}
|
|
356
378
|
/**
|
|
357
379
|
* Turns "id or name" into one specific playlist, or an actionable error.
|
|
358
380
|
*
|
|
@@ -361,10 +383,14 @@ function describe(items) {
|
|
|
361
383
|
* whole function exists to prevent. The error names every candidate with its
|
|
362
384
|
* id and its full path, so the retry is a copy-paste rather than a guess.
|
|
363
385
|
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
* in this library" is a problem with the argument, reported the
|
|
367
|
-
* unknown field name is -- with the recognised values in
|
|
386
|
+
* Reuses `invalid_argument` by default rather than adding an error code: the
|
|
387
|
+
* taxonomy is closed (see errors.ts), and for a reader "the playlist you
|
|
388
|
+
* named is not in this library" is a problem with the argument, reported the
|
|
389
|
+
* same way an unknown field name is -- with the recognised values in
|
|
390
|
+
* `detail`. A caller that already owns a more accurate code for that one
|
|
391
|
+
* case passes it as `names.notFound`; the write tools do, so their documented
|
|
392
|
+
* `playlist_not_found` is what a client actually sees. Ambiguity is never
|
|
393
|
+
* reported through it.
|
|
368
394
|
*/
|
|
369
395
|
export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name: "playlist_name" }) {
|
|
370
396
|
const hasId = sel.id !== undefined && sel.id !== null;
|
|
@@ -385,11 +411,7 @@ export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name
|
|
|
385
411
|
if (hasId) {
|
|
386
412
|
const found = tree.items.find((i) => i.id === sel.id);
|
|
387
413
|
if (!found) {
|
|
388
|
-
return
|
|
389
|
-
detail: tree.items.length
|
|
390
|
-
? `Playlists (id -- path): ${describe(tree.items)}`
|
|
391
|
-
: "This library has no playlists.",
|
|
392
|
-
});
|
|
414
|
+
return notFoundError(names, `No playlist with ${names.id} ${sel.id} in this library`, tree.items);
|
|
393
415
|
}
|
|
394
416
|
return { playlist: found, warnings: tree.warnings };
|
|
395
417
|
}
|
|
@@ -397,11 +419,7 @@ export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name
|
|
|
397
419
|
if (matches.length === 1)
|
|
398
420
|
return { playlist: matches[0], warnings: tree.warnings };
|
|
399
421
|
if (matches.length === 0) {
|
|
400
|
-
return
|
|
401
|
-
detail: tree.items.length
|
|
402
|
-
? `Playlists (id -- path): ${describe(tree.items)}`
|
|
403
|
-
: "This library has no playlists.",
|
|
404
|
-
});
|
|
422
|
+
return notFoundError(names, `No playlist named "${sel.name}" in this library`, tree.items);
|
|
405
423
|
}
|
|
406
424
|
return err("invalid_argument", `"${sel.name}" names ${matches.length} playlists in this library`, {
|
|
407
425
|
detail: `Playlist names are unique only within a folder. Pass ${names.id}, or pass the full ` +
|
package/dist/semantics.js
CHANGED
|
@@ -85,6 +85,11 @@ export function keyDistance(a, b) {
|
|
|
85
85
|
export function registerFunctions(db, mdbPath) {
|
|
86
86
|
const opts = { deterministic: true };
|
|
87
87
|
db.function("camelot", opts, (key) => camelot(key === null ? null : Number(key)));
|
|
88
|
+
// A comparison key for text: one Unicode normalization form, lower-cased by
|
|
89
|
+
// Unicode rules. SQLite's own LOWER folds ASCII only -- LOWER('ЭЙФОРИЯ')
|
|
90
|
+
// comes back unchanged -- which made audit_library's duplicates find a
|
|
91
|
+
// capitalised Latin title and miss a capitalised Cyrillic one (#9).
|
|
92
|
+
db.function("fold", opts, (text) => text === null || text === undefined ? null : String(text).normalize("NFC").toLowerCase());
|
|
88
93
|
db.function("key_name", opts, (key) => keyName(key === null ? null : Number(key)));
|
|
89
94
|
db.function("tempo", opts, (a, b) => tempo(a === null ? null : Number(a), b === null ? null : Number(b)));
|
|
90
95
|
db.function("key_distance", opts, (a, b) => a === null || b === null ? null : keyDistance(String(a), String(b)));
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@ 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
7
|
import { libraryCandidates, libraryTag, sidecarDir } from "./paths.js";
|
|
8
|
-
import { LibraryArg, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
|
|
8
|
+
import { LibraryArg, ambiguousLibrary, defaultLibraryTies, 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";
|
|
@@ -17,10 +17,24 @@ import { auditLibrary, AuditInput, AUDIT_CHECKS } from "./tools/audit.js";
|
|
|
17
17
|
import { runSql, RunSqlInput } from "./tools/sql.js";
|
|
18
18
|
import { listLibraries } from "./tools/libraries.js";
|
|
19
19
|
import { refreshIndex } from "./tools/refresh.js";
|
|
20
|
-
import { CreatePlaylistInput, runCreatePlaylist } from "./tools/write-playlist.js";
|
|
20
|
+
import { CreatePlaylistInput, runCreatePlaylist, AddTracksToPlaylistInput, runAddTracksToPlaylist, RemoveTracksFromPlaylistInput, runRemoveTracksFromPlaylist, ReorderPlaylistInput, runReorderPlaylist, } from "./tools/write-playlist.js";
|
|
21
21
|
import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
|
|
22
22
|
const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
|
|
23
|
+
/**
|
|
24
|
+
* A write that only ever *adds*. `destructiveHint: false` is a claim with a
|
|
25
|
+
* defined meaning in MCP -- "this tool performs only additive updates" -- and
|
|
26
|
+
* clients use it to decide whether to confirm with the user first. True of
|
|
27
|
+
* create_playlist (a new playlist, nothing else touched) and of
|
|
28
|
+
* add_tracks_to_playlist (new entries, existing ones left where they are).
|
|
29
|
+
*/
|
|
23
30
|
const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false };
|
|
31
|
+
/**
|
|
32
|
+
* A write that can destroy or reorganise what is already there:
|
|
33
|
+
* remove_tracks_from_playlist deletes entries, reorder_playlist rewrites the
|
|
34
|
+
* order of a list a DJ may be playing from live. Advertising either as
|
|
35
|
+
* additive told a client it need not ask before calling.
|
|
36
|
+
*/
|
|
37
|
+
const RW_DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false };
|
|
24
38
|
/**
|
|
25
39
|
* name/version reported to every client on initialize. Read from
|
|
26
40
|
* package.json rather than typed here, so the two cannot re-diverge the way
|
|
@@ -42,9 +56,34 @@ const PACKAGE_INFO = JSON.parse(readFileSync(new URL("../package.json", import.m
|
|
|
42
56
|
* this repeats the essentials in the description because some clients show a
|
|
43
57
|
* model the description and not the per-property schema documentation.
|
|
44
58
|
*/
|
|
59
|
+
/**
|
|
60
|
+
* Every write result names the library it landed in, and every undo is scoped
|
|
61
|
+
* to that one library. Engine DJ propagates a playlist change to another
|
|
62
|
+
* connected library by itself -- measured 2026-09-01: an edit made to the
|
|
63
|
+
* library on the computer appeared on the USB drive after Engine was next
|
|
64
|
+
* launched, the copy carrying the very timestamp this server's INSERT had
|
|
65
|
+
* written. An undo call cannot reach that copy, and reports success anyway,
|
|
66
|
+
* because within its own library it did exactly what it promised.
|
|
67
|
+
*
|
|
68
|
+
* Stated in the description, not just the README, because the caller who has
|
|
69
|
+
* to act on it is the model holding the undo.
|
|
70
|
+
*/
|
|
71
|
+
const UNDO_SCOPE_NOTE = "`undo` reverses this edit in ONE library: the one the result's `library` field names. " +
|
|
72
|
+
"Engine DJ copies playlist changes between connected libraries on its own, so launching it " +
|
|
73
|
+
"with a second library attached can leave a copy of this edit there -- and no undo call " +
|
|
74
|
+
"reaches that copy. With two libraries connected (a USB drive and its copy on the computer " +
|
|
75
|
+
"is the usual case), undo separately against each. ";
|
|
45
76
|
const LIBRARY_SELECTION_NOTE = "With more than one library connected, pass `library` (a uuid or path from list_libraries, " +
|
|
46
77
|
"either the ~/... form or the absolute one) to choose which one; the default is the " +
|
|
47
78
|
"supported library with the most tracks.";
|
|
79
|
+
/**
|
|
80
|
+
* Appended to the write tools only. A tie in the default rule is a refusal
|
|
81
|
+
* there and a free choice on the read side, so the shared note above cannot
|
|
82
|
+
* carry it without being wrong for one of the two.
|
|
83
|
+
*/
|
|
84
|
+
const WRITE_LIBRARY_TIE_NOTE = " If two supported libraries hold the same most tracks -- what a USB drive and its copy on " +
|
|
85
|
+
"the computer produce -- this tool refuses with ambiguous_library rather than picking one, " +
|
|
86
|
+
"and lists both; nothing is written. Ask the user which one, then retry with `library` set -- do not pick for them, since one of the two may be the drive they perform from.";
|
|
48
87
|
function reply(value) {
|
|
49
88
|
return {
|
|
50
89
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
@@ -208,6 +247,47 @@ export async function createServer(opts = {}) {
|
|
|
208
247
|
return state;
|
|
209
248
|
return fresh;
|
|
210
249
|
};
|
|
250
|
+
/**
|
|
251
|
+
* `acquire` for the write tools: identical, except that an omitted
|
|
252
|
+
* `library` must resolve to exactly one candidate.
|
|
253
|
+
*
|
|
254
|
+
* `pickDefaultLibrary` breaks a tie on root-scan order, which is
|
|
255
|
+
* deterministic and, for a read, fine -- libraries tie because one is a
|
|
256
|
+
* copy of the other, so either answer is very nearly the same answer, and
|
|
257
|
+
* making a read demand a `library` it does not care about would be noise.
|
|
258
|
+
*
|
|
259
|
+
* A write is not that. The choice decides which physical disk changes, and
|
|
260
|
+
* one of the two is the drive the DJ performs from; root-scan order is not
|
|
261
|
+
* a reason to pick it. Measured 2026-09-01: the computer's library and the
|
|
262
|
+
* USB drive both held 257 tracks, tied precisely because one was a copy of
|
|
263
|
+
* the other.
|
|
264
|
+
*
|
|
265
|
+
* Only the omitted case refuses. A caller who named a library gets it, tie
|
|
266
|
+
* or no tie -- the ambiguity being refused here is the server's, not theirs.
|
|
267
|
+
*
|
|
268
|
+
* Rescans first, because `knownList()` is a cache that deliberately keeps a
|
|
269
|
+
* library a later scan cannot see -- so a momentarily locked drive does not
|
|
270
|
+
* vanish from list_libraries. For a tie check that is wrong in the
|
|
271
|
+
* direction that bites: pull the USB drive and one library is left, but the
|
|
272
|
+
* cache still holds two, and the write is refused naming a drive that is no
|
|
273
|
+
* longer there. rescanLibraries() forgets a candidate whose path is gone,
|
|
274
|
+
* which is exactly the distinction wanted here, and it also lets a drive
|
|
275
|
+
* plugged in mid-session be seen at all.
|
|
276
|
+
*
|
|
277
|
+
* The cost is one filesystem probe per write, against a write that is about
|
|
278
|
+
* to copy the entire database for its pre-write snapshot. Reads are left
|
|
279
|
+
* alone: they run far more often and a stale pick between two copies is not
|
|
280
|
+
* worth a probe apiece.
|
|
281
|
+
*/
|
|
282
|
+
const acquireForWrite = async (requested) => {
|
|
283
|
+
if (requested === undefined) {
|
|
284
|
+
rescanLibraries();
|
|
285
|
+
const tied = defaultLibraryTies(knownList());
|
|
286
|
+
if (tied.length > 1)
|
|
287
|
+
return ambiguousLibrary(tied);
|
|
288
|
+
}
|
|
289
|
+
return acquire(requested);
|
|
290
|
+
};
|
|
211
291
|
/**
|
|
212
292
|
* Shared by the engine://libraries resource and the list_libraries tool so
|
|
213
293
|
* the two cannot drift in shape, while differing in exactly one respect:
|
|
@@ -357,6 +437,10 @@ export async function createServer(opts = {}) {
|
|
|
357
437
|
title: "Audit the collection",
|
|
358
438
|
description: `Run collection health checks. Available: ${AUDIT_CHECKS.join(", ")}. ` +
|
|
359
439
|
`missing_files resolves each track against the selected library's own folder. ` +
|
|
440
|
+
`path_form_mismatch finds files that are there, but under a name in a different Unicode ` +
|
|
441
|
+
`normalization form from the stored path -- macOS opens them anyway, Linux does not ` +
|
|
442
|
+
`(measured on its exFAT driver), and Engine OS on a player is Linux, so these may fail ` +
|
|
443
|
+
`to load on hardware while missing_files on a Mac reports nothing. ` +
|
|
360
444
|
`no_cues means "no hot cue is set" -- the quickCues blob is decoded for this, since ` +
|
|
361
445
|
`Engine writes one to every analysed track whether or not a pad is used -- while ` +
|
|
362
446
|
`no_beatgrid means the beatData blob is absent or empty. ` +
|
|
@@ -429,7 +513,13 @@ export async function createServer(opts = {}) {
|
|
|
429
513
|
"write of this session; it is a recovery route for a damaged library, NOT an undo. " +
|
|
430
514
|
"Restoring it reverts the entire library to that moment, discarding everything " +
|
|
431
515
|
"Engine DJ has written since (play counts, imports, cue and beatgrid edits). " +
|
|
432
|
-
"
|
|
516
|
+
"The result's `library` field names which library this went into. Engine DJ copies " +
|
|
517
|
+
"playlist changes between connected libraries on its own (measured for an edit to an " +
|
|
518
|
+
"existing playlist), so with a second library attached the new playlist may appear " +
|
|
519
|
+
"there too. " +
|
|
520
|
+
"No existing playlist is renamed, reordered, emptied or deleted, and no track, cue or " +
|
|
521
|
+
"beatgrid is touched. The one existing row that moves is the previous last playlist's " +
|
|
522
|
+
"link, and Engine's own insert trigger is what moves it. " +
|
|
433
523
|
"Fails with playlist_exists if a top-level playlist already has that title, and " +
|
|
434
524
|
"with library_busy if Engine DJ or a player is holding a conflicting lock on the " +
|
|
435
525
|
"library right then -- nothing is written in that case, so retry rather than " +
|
|
@@ -437,15 +527,115 @@ export async function createServer(opts = {}) {
|
|
|
437
527
|
"library is unchanged and \"committed_unverified\" when the write may have gone " +
|
|
438
528
|
"through but could not be verified. track_ids may be empty (an empty playlist); a " +
|
|
439
529
|
"track id may appear at most once. " +
|
|
440
|
-
LIBRARY_SELECTION_NOTE,
|
|
530
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
|
|
441
531
|
inputSchema: { ...CreatePlaylistInput.shape, library: LibraryArg },
|
|
442
532
|
annotations: RW,
|
|
443
533
|
}, async (args) => {
|
|
444
|
-
const state = await
|
|
534
|
+
const state = await acquireForWrite(args.library);
|
|
445
535
|
if (isEngineError(state))
|
|
446
536
|
return reply(state);
|
|
447
537
|
return reply(await runCreatePlaylist(state.lib.path, state.lib.uuid, args, opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups")));
|
|
448
538
|
});
|
|
539
|
+
const backupDirFor = () => opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups");
|
|
540
|
+
server.registerTool("add_tracks_to_playlist", {
|
|
541
|
+
title: "Add tracks to a playlist",
|
|
542
|
+
description: "Add one or more tracks to an EXISTING playlist -- this edits that playlist's " +
|
|
543
|
+
"contents, it does NOT create a new one (use create_playlist for that). Name the " +
|
|
544
|
+
"playlist with playlist_id or playlist_name, exactly one of the two, resolved the " +
|
|
545
|
+
"same way get_playlist_tracks does: a name matching more than one playlist in this " +
|
|
546
|
+
"library is refused, listing every candidate's id and full path, rather than guessed " +
|
|
547
|
+
"at. track_ids are ids from search_tracks or get_tracks; a track already in the " +
|
|
548
|
+
"playlist is refused as duplicate_track, since Engine allows a track in a playlist " +
|
|
549
|
+
"only once. at chooses where the new tracks land, using the playlist's current " +
|
|
550
|
+
"1-based positions (the same numbering get_playlist_tracks reports): \"start\", " +
|
|
551
|
+
"\"end\" (the default), or { after_position: n }. If this playlist's entry chain is " +
|
|
552
|
+
"already damaged, the write is refused outright rather than repaired -- nothing is " +
|
|
553
|
+
"added, and the error names what is broken. " +
|
|
554
|
+
"On success, the result's `undo` is the exact remove_tracks_from_playlist call that " +
|
|
555
|
+
"reverses this edit -- the positions the new tracks landed at, plus expect_track_ids " +
|
|
556
|
+
"naming the tracks that landed there, so a list someone changed in the meantime is " +
|
|
557
|
+
"refused rather than having the wrong rows removed -- and `undo_complete` is true. " +
|
|
558
|
+
"Call it to undo " +
|
|
559
|
+
"rather than restoring backup_path, which reverts the WHOLE library to before this " +
|
|
560
|
+
"session's first write, discarding every play count, import, cue and beatgrid change " +
|
|
561
|
+
"Engine DJ has recorded since -- not just this one edit. backup_path is only a " +
|
|
562
|
+
"last-resort recovery route for a damaged library, never an undo. " +
|
|
563
|
+
UNDO_SCOPE_NOTE +
|
|
564
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
|
|
565
|
+
inputSchema: { ...AddTracksToPlaylistInput.shape, library: LibraryArg },
|
|
566
|
+
annotations: RW,
|
|
567
|
+
}, async (args) => {
|
|
568
|
+
const state = await acquireForWrite(args.library);
|
|
569
|
+
if (isEngineError(state))
|
|
570
|
+
return reply(state);
|
|
571
|
+
return reply(await runAddTracksToPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
|
|
572
|
+
});
|
|
573
|
+
server.registerTool("remove_tracks_from_playlist", {
|
|
574
|
+
title: "Remove tracks from a playlist",
|
|
575
|
+
description: "Remove one or more tracks from an EXISTING playlist by position -- this edits that " +
|
|
576
|
+
"playlist's contents; it never touches any other playlist. Name the playlist with " +
|
|
577
|
+
"playlist_id or playlist_name, exactly one of the two, resolved the same way " +
|
|
578
|
+
"get_playlist_tracks does (an ambiguous name is refused with every candidate listed, " +
|
|
579
|
+
"not guessed at). positions are the 1-based positions get_playlist_tracks reports " +
|
|
580
|
+
"for THIS playlist right now -- they include entries whose track is missing from the " +
|
|
581
|
+
"library (get_playlist_tracks marks those missing: true), and removing one of those " +
|
|
582
|
+
"is a legitimate way to clean up a hole -- but it is the one removal that cannot be " +
|
|
583
|
+
"undone: such an entry names no track id, so no add_tracks_to_playlist call can put " +
|
|
584
|
+
"it back, and the result says so with undo_complete: false plus an undo_note naming " +
|
|
585
|
+
"those positions. expect_track_ids is optional and, when " +
|
|
586
|
+
"given, must have one entry per position: it verifies each named position still " +
|
|
587
|
+
"holds the track expected before anything is removed, refusing the whole call " +
|
|
588
|
+
"otherwise; null there means \"this position should hold an entry whose track is " +
|
|
589
|
+
"missing\", not \"no expectation\". If this playlist's entry chain is already " +
|
|
590
|
+
"damaged, the write is refused outright rather than repaired. " +
|
|
591
|
+
"On success, the result's `undo` is a SEQUENCE of add_tracks_to_playlist calls, one " +
|
|
592
|
+
"per removed track THAT CAN BE RESTORED -- run them IN THE ORDER GIVEN, never in " +
|
|
593
|
+
"parallel and never " +
|
|
594
|
+
"reversed: each step's target position is computed against the list as it stands " +
|
|
595
|
+
"after the previous step has already run, so firing them out of order or " +
|
|
596
|
+
"concurrently puts tracks back in the wrong places. Check `undo_complete`: false " +
|
|
597
|
+
"means one or more removed entries had no track to name and are gone for good -- " +
|
|
598
|
+
"`undo_note` says which positions, and the remaining steps still restore everything " +
|
|
599
|
+
"else. Preferred over restoring " +
|
|
600
|
+
"backup_path, which reverts the WHOLE library to before this session's first write, " +
|
|
601
|
+
"discarding everything Engine DJ has recorded since -- not just this edit. " +
|
|
602
|
+
UNDO_SCOPE_NOTE +
|
|
603
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
|
|
604
|
+
inputSchema: { ...RemoveTracksFromPlaylistInput.shape, library: LibraryArg },
|
|
605
|
+
annotations: RW_DESTRUCTIVE,
|
|
606
|
+
}, async (args) => {
|
|
607
|
+
const state = await acquireForWrite(args.library);
|
|
608
|
+
if (isEngineError(state))
|
|
609
|
+
return reply(state);
|
|
610
|
+
return reply(await runRemoveTracksFromPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
|
|
611
|
+
});
|
|
612
|
+
server.registerTool("reorder_playlist", {
|
|
613
|
+
title: "Reorder a playlist",
|
|
614
|
+
description: "Reorder an EXISTING playlist's tracks -- this changes the order of that playlist's " +
|
|
615
|
+
"existing entries; it adds nothing and removes nothing. Name the playlist with " +
|
|
616
|
+
"playlist_id or playlist_name, exactly one of the two, resolved the same way " +
|
|
617
|
+
"get_playlist_tracks does (an ambiguous name is refused with every candidate listed, " +
|
|
618
|
+
"not guessed at). order must be a full permutation of 1..n, n being the playlist's " +
|
|
619
|
+
"current entry count: order[i] names the CURRENT 1-based position (from " +
|
|
620
|
+
"get_playlist_tracks) of the track that should end up at position i + 1. A partial " +
|
|
621
|
+
"'move x to y' instruction is not accepted -- name every position, including ones " +
|
|
622
|
+
"that do not move. If this playlist's entry chain is already damaged, the write is " +
|
|
623
|
+
"refused outright rather than repaired. " +
|
|
624
|
+
"On success, the result's `undo` is the exact inverse permutation, as a single " +
|
|
625
|
+
"reorder_playlist call, and `undo_complete` is true; prefer it over restoring " +
|
|
626
|
+
"backup_path, which reverts the " +
|
|
627
|
+
"WHOLE library to before this session's first write, discarding everything Engine DJ " +
|
|
628
|
+
"has recorded since -- not just this reorder. " +
|
|
629
|
+
UNDO_SCOPE_NOTE +
|
|
630
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_TIE_NOTE,
|
|
631
|
+
inputSchema: { ...ReorderPlaylistInput.shape, library: LibraryArg },
|
|
632
|
+
annotations: RW_DESTRUCTIVE,
|
|
633
|
+
}, async (args) => {
|
|
634
|
+
const state = await acquireForWrite(args.library);
|
|
635
|
+
if (isEngineError(state))
|
|
636
|
+
return reply(state);
|
|
637
|
+
return reply(await runReorderPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
|
|
638
|
+
});
|
|
449
639
|
}
|
|
450
640
|
/**
|
|
451
641
|
* There was previously no way to shut this down at all: createServer
|
|
@@ -517,6 +707,18 @@ other.
|
|
|
517
707
|
- \`Track.path\` is relative to the \`Engine Library\` folder and usually
|
|
518
708
|
contains \`..\`. The SQL function \`abs_path(path)\` resolves it against
|
|
519
709
|
this library's location; the home prefix comes back folded to \`~\`.
|
|
710
|
+
- \`Track.streamingSource\`, \`uri\` and \`streamingFlags\` are reported to
|
|
711
|
+
decide whether Engine OS streams a track (from Dropbox) instead of reading
|
|
712
|
+
the file. Not measured here: on both reference libraries \`streamingSource\`
|
|
713
|
+
and \`uri\` are NULL on every track, and \`streamingFlags\` is 5 on about
|
|
714
|
+
half of them -- tracks that load and play -- so \`streamingFlags\` on its own
|
|
715
|
+
says nothing about whether a track will load. Selectable as the fields
|
|
716
|
+
\`streaming_source\`, \`streaming_flags\` and \`uri\`; \`uri\` is redacted like
|
|
717
|
+
\`path\`, including a home directory percent-encoded inside it.
|
|
718
|
+
- SQLite's \`LOWER()\` folds ASCII only: \`LOWER('ЭЙФОРИЯ')\` comes back
|
|
719
|
+
unchanged. To compare names regardless of case in any script, use
|
|
720
|
+
\`fold(text)\` -- one Unicode normalization form, lower-cased by Unicode
|
|
721
|
+
rules. It runs per row, like every function here.
|
|
520
722
|
- A track's natural key across drives is \`(originDatabaseUuid, originTrackId)\`.
|
|
521
723
|
- \`PerformanceData\`'s blob columns are binary and cannot be read with SQL.
|
|
522
724
|
Engine writes \`quickCues\`, \`loops\`, \`beatData\` and
|
package/dist/store/backup.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { backup } from "node:sqlite";
|
|
1
2
|
import { type EngineError } from "../errors.js";
|
|
2
3
|
/**
|
|
3
4
|
* The snapshots to delete, oldest first, from a directory listing.
|
|
@@ -23,4 +24,21 @@ import { type EngineError } from "../errors.js";
|
|
|
23
24
|
* predate the tagged scheme, so they predate anything written since.
|
|
24
25
|
*/
|
|
25
26
|
export declare function evictable(names: string[], uuid: string, tag: string): string[];
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Partial copies in this library's namespace whose writer is gone.
|
|
29
|
+
*
|
|
30
|
+
* A snapshot is copied to `<final name>.partial-<pid>` and renamed into place
|
|
31
|
+
* only once backup() has resolved (see snapshotLibrary). A process killed
|
|
32
|
+
* mid-copy never gets to clean up after itself, so its partial stays behind:
|
|
33
|
+
* harmless to rotation, which counts only `.db` names, but a full-size file
|
|
34
|
+
* nobody will ever finish.
|
|
35
|
+
*
|
|
36
|
+
* The pid is what separates such an orphan from another server's copy that is
|
|
37
|
+
* being written right now -- two servers can snapshot one library at once --
|
|
38
|
+
* so only a pid with no live process behind it is reclaimed. `prefix` keeps it
|
|
39
|
+
* to this library, exactly as rotation is kept to it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function abandonedPartials(names: string[], prefix: string, isAlive?: (pid: number) => boolean): string[];
|
|
42
|
+
export declare function snapshotLibrary(mdbPath: string, uuid: string, baseDir: string, deps?: {
|
|
43
|
+
backup?: typeof backup;
|
|
44
|
+
}): Promise<string | EngineError>;
|
package/dist/store/backup.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Engine Library folder -- the rule that no file is created there holds for
|
|
10
10
|
// writes exactly as it did for reads.
|
|
11
11
|
import { DatabaseSync, backup } from "node:sqlite";
|
|
12
|
-
import { mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
12
|
+
import { mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { err } from "../errors.js";
|
|
15
15
|
import { libraryTag } from "../paths.js";
|
|
@@ -74,7 +74,49 @@ export function evictable(names, uuid, tag) {
|
|
|
74
74
|
mine.sort((a, b) => (a.old !== b.old ? (a.old ? -1 : 1) : a.stamp < b.stamp ? -1 : a.stamp > b.stamp ? 1 : 0));
|
|
75
75
|
return mine.slice(0, Math.max(0, mine.length - KEEP)).map((x) => x.name);
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
/** Whether a process with this pid exists. EPERM means it does and is not ours to signal. */
|
|
78
|
+
function processAlive(pid) {
|
|
79
|
+
try {
|
|
80
|
+
process.kill(pid, 0);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
return e.code === "EPERM";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Partial copies in this library's namespace whose writer is gone.
|
|
89
|
+
*
|
|
90
|
+
* A snapshot is copied to `<final name>.partial-<pid>` and renamed into place
|
|
91
|
+
* only once backup() has resolved (see snapshotLibrary). A process killed
|
|
92
|
+
* mid-copy never gets to clean up after itself, so its partial stays behind:
|
|
93
|
+
* harmless to rotation, which counts only `.db` names, but a full-size file
|
|
94
|
+
* nobody will ever finish.
|
|
95
|
+
*
|
|
96
|
+
* The pid is what separates such an orphan from another server's copy that is
|
|
97
|
+
* being written right now -- two servers can snapshot one library at once --
|
|
98
|
+
* so only a pid with no live process behind it is reclaimed. `prefix` keeps it
|
|
99
|
+
* to this library, exactly as rotation is kept to it.
|
|
100
|
+
*/
|
|
101
|
+
export function abandonedPartials(names, prefix, isAlive = processAlive) {
|
|
102
|
+
// With or without `-journal`: SQLite keeps a rollback journal beside the
|
|
103
|
+
// copy while backup() runs (measured -- it exists mid-copy and is gone once
|
|
104
|
+
// backup() resolves), so a process killed partway leaves both behind. They
|
|
105
|
+
// share the pid, so a live copy's journal is spared along with the copy.
|
|
106
|
+
const partial = /\.db\.partial-(\d+)(-journal)?$/;
|
|
107
|
+
return names.filter((name) => {
|
|
108
|
+
if (!name.startsWith(prefix))
|
|
109
|
+
return false;
|
|
110
|
+
const m = partial.exec(name);
|
|
111
|
+
return m !== null && !isAlive(Number(m[1]));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
export async function snapshotLibrary(mdbPath, uuid, baseDir,
|
|
115
|
+
// Injectable so a test can make the copy fail after it has started writing
|
|
116
|
+
// -- the one failure that matters here, and one no real error reachable
|
|
117
|
+
// from a test produces (they all fail before the destination is touched).
|
|
118
|
+
deps = {}) {
|
|
119
|
+
const copy = deps.backup ?? backup;
|
|
78
120
|
// node:sqlite stopped needing a flag in 22.13, which is where this
|
|
79
121
|
// project's floor used to sit -- but backup() only arrived in 22.16. On
|
|
80
122
|
// 22.13 through 22.15 the read path works perfectly and this one throws
|
|
@@ -82,11 +124,12 @@ export async function snapshotLibrary(mdbPath, uuid, baseDir) {
|
|
|
82
124
|
// run against the declared floor. `engines` now says 22.16, and npm only
|
|
83
125
|
// enforces that under engine-strict, so the check is here too: a version
|
|
84
126
|
// number a user can act on beats a TypeError from inside a dependency.
|
|
85
|
-
if (typeof
|
|
127
|
+
if (typeof copy !== "function") {
|
|
86
128
|
return err("library_unreadable", `This Node cannot snapshot a library before writing to it: node:sqlite gained backup() in ` +
|
|
87
129
|
`22.16.0 and this is ${process.version}. Upgrade Node, or run without --allow-writes.`);
|
|
88
130
|
}
|
|
89
131
|
let src;
|
|
132
|
+
let partial;
|
|
90
133
|
try {
|
|
91
134
|
mkdirSync(baseDir, { recursive: true });
|
|
92
135
|
src = new DatabaseSync(mdbPath, { readOnly: true });
|
|
@@ -98,16 +141,41 @@ export async function snapshotLibrary(mdbPath, uuid, baseDir) {
|
|
|
98
141
|
// the same tag server.ts's sidecarBaseFor uses to keep two such
|
|
99
142
|
// libraries' indexes apart (see paths.ts).
|
|
100
143
|
const prefix = `${uuid}-${libraryTag(mdbPath)}-`;
|
|
144
|
+
// Before copying, not after: a copy that died because the disk filled up
|
|
145
|
+
// left its partial behind, and this is exactly when that space is wanted.
|
|
146
|
+
for (const dead of abandonedPartials(readdirSync(baseDir), prefix)) {
|
|
147
|
+
rmSync(join(baseDir, dead), { force: true });
|
|
148
|
+
}
|
|
149
|
+
// Copied under a name rotation does not count and nobody would restore,
|
|
150
|
+
// then renamed into place only once backup() has resolved. Written
|
|
151
|
+
// straight to the final name, a copy that died partway was an incomplete
|
|
152
|
+
// database indistinguishable from a good one: rotation took it for the
|
|
153
|
+
// newest and evicted a real snapshot for it, and "restore the latest
|
|
154
|
+
// backup" would have restored it (#3). rename() within one directory is
|
|
155
|
+
// atomic, so the final name only ever holds a finished copy.
|
|
101
156
|
const dest = join(baseDir, `${prefix}${stamp()}.db`);
|
|
102
|
-
|
|
157
|
+
partial = `${dest}.partial-${process.pid}`;
|
|
158
|
+
await copy(src, partial);
|
|
103
159
|
src.close();
|
|
104
160
|
src = undefined;
|
|
161
|
+
renameSync(partial, dest);
|
|
162
|
+
partial = undefined;
|
|
105
163
|
for (const stale of evictable(readdirSync(baseDir), uuid, libraryTag(mdbPath))) {
|
|
106
164
|
rmSync(join(baseDir, stale), { force: true });
|
|
107
165
|
}
|
|
108
166
|
return dest;
|
|
109
167
|
}
|
|
110
168
|
catch (e) {
|
|
169
|
+
// Safe to delete, which the destination itself never was: this name
|
|
170
|
+
// carries this process's pid and a stamp whose counter never repeats
|
|
171
|
+
// within a process, so whatever is there was created by this call. The
|
|
172
|
+
// earlier objection to cleaning up on failure -- deleting a file at a path
|
|
173
|
+
// we may not have created -- does not apply to a path no one else can
|
|
174
|
+
// produce.
|
|
175
|
+
if (partial) {
|
|
176
|
+
rmSync(partial, { force: true });
|
|
177
|
+
rmSync(`${partial}-journal`, { force: true });
|
|
178
|
+
}
|
|
111
179
|
return err("library_unreadable", `Could not snapshot ${mdbPath} before writing: ${String(e)}`);
|
|
112
180
|
}
|
|
113
181
|
finally {
|