engine-dj-mcp 0.10.0 → 0.11.1
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 +118 -12
- package/dist/discovery.js +3 -2
- package/dist/errors.d.ts +20 -1
- package/dist/errors.js +8 -0
- package/dist/index.js +8 -2
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +14 -0
- package/dist/playlists.d.ts +6 -4
- package/dist/playlists.js +6 -4
- package/dist/server.d.ts +15 -2
- package/dist/server.js +45 -7
- package/dist/store/backup.d.ts +2 -0
- package/dist/store/backup.js +82 -0
- package/dist/store/write.d.ts +37 -0
- package/dist/store/write.js +397 -0
- package/dist/tools/write-playlist.d.ts +11 -0
- package/dist/tools/write-playlist.js +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,8 @@ libraries — the one on your computer and the ones on your USB drives.
|
|
|
12
12
|
> inMusic or Denon DJ are used in this project.
|
|
13
13
|
|
|
14
14
|
Your library is opened **read-only at the operating-system level**. It is
|
|
15
|
-
never written to — see
|
|
15
|
+
never written to unless you start the server with `--allow-writes` — see
|
|
16
|
+
[Safety](#safety).
|
|
16
17
|
|
|
17
18
|
## What you can ask
|
|
18
19
|
|
|
@@ -25,6 +26,7 @@ Once connected, these are ordinary questions in chat:
|
|
|
25
26
|
- *"Anything around 128 in ACID Beach?"*
|
|
26
27
|
- *"What's broken in my collection — missing files, duplicates, bad tempos?"*
|
|
27
28
|
- *"Where are the cue points on this track, and what tempo did Engine analyse?"*
|
|
29
|
+
- *"Build me a playlist of everything in 5A from 140 BPM up."* (needs `--allow-writes`)
|
|
28
30
|
|
|
29
31
|
## Install
|
|
30
32
|
|
|
@@ -42,14 +44,28 @@ Claude Desktop — add to your configuration:
|
|
|
42
44
|
}
|
|
43
45
|
```
|
|
44
46
|
|
|
47
|
+
To let the assistant create playlists as well, add `--allow-writes` — a flag
|
|
48
|
+
in `args` rather than an environment variable precisely so it is visible in
|
|
49
|
+
the configuration you are reading:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"mcpServers": {
|
|
54
|
+
"engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp", "--allow-writes"] }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
45
59
|
**Requirements:** Node.js 22.13 or newer (for the unflagged `node:sqlite`;
|
|
46
60
|
there are no native dependencies), and an Engine DJ library at schema 3.0.0
|
|
47
61
|
through 3.0.2 — Engine DJ 4.5 and 5.x.
|
|
48
62
|
|
|
49
63
|
## Tools
|
|
50
64
|
|
|
51
|
-
Nine tools,
|
|
52
|
-
|
|
65
|
+
Nine read-only tools, and a tenth — `create_playlist` — that appears only
|
|
66
|
+
when you start the server with `--allow-writes`. Every tool that reads
|
|
67
|
+
library data also accepts an optional `library` argument — see
|
|
68
|
+
[Choosing a library](#choosing-a-library).
|
|
53
69
|
|
|
54
70
|
### `search_tracks`
|
|
55
71
|
|
|
@@ -181,6 +197,41 @@ No arguments.
|
|
|
181
197
|
Rebuilds the search index if the library changed. Normally unnecessary; the
|
|
182
198
|
server checks staleness itself before answering.
|
|
183
199
|
|
|
200
|
+
### `create_playlist`
|
|
201
|
+
|
|
202
|
+
The only tool that writes, and the only one that is not registered at all
|
|
203
|
+
unless the server was started with `--allow-writes`.
|
|
204
|
+
|
|
205
|
+
Creates one new top-level playlist from track ids — `track_ids` sets both
|
|
206
|
+
what is in it and the order it is in, so a list built by `search_tracks`
|
|
207
|
+
arrives in Engine DJ in the order the assistant chose. Nothing else changes:
|
|
208
|
+
no playlist is renamed, reordered, emptied or deleted, and no track, cue or
|
|
209
|
+
beatgrid is touched. The one existing row that moves is the previous last
|
|
210
|
+
playlist's link, and Engine's own insert trigger is what moves it.
|
|
211
|
+
|
|
212
|
+
| Argument | What it does |
|
|
213
|
+
| --- | --- |
|
|
214
|
+
| `title` | Name of the new playlist. Must not already be taken at the top level — Engine allows one name per folder. |
|
|
215
|
+
| `track_ids` | Ids from `search_tracks` or `get_tracks`, in playlist order. May be empty, for an empty playlist. A track may appear at most once, which is Engine's own rule. |
|
|
216
|
+
|
|
217
|
+
Each entry stores the track's origin identity — `(originDatabaseUuid,
|
|
218
|
+
originTrackId)`, the pair Engine matches on — not the local row id, so a
|
|
219
|
+
playlist built here reads the same way Engine's own does.
|
|
220
|
+
|
|
221
|
+
The result carries `playlist_id`, `tracks_added` and `backup_path`. **To undo
|
|
222
|
+
it, delete the playlist in Engine DJ**; `backup_path` is a whole-library
|
|
223
|
+
snapshot for the case where something went wrong at a lower level, not an
|
|
224
|
+
undo — see [Restoring a snapshot](#restoring-a-snapshot).
|
|
225
|
+
|
|
226
|
+
Refusals name themselves: `playlist_exists` for a taken title,
|
|
227
|
+
`unknown_track` for an id this library does not have, `duplicate_track` for
|
|
228
|
+
the same id twice, `library_busy` if something else holds a conflicting lock
|
|
229
|
+
right then, `library_needs_recovery` if Engine DJ left an unrecovered
|
|
230
|
+
journal behind. Every error also carries `detail`: `not_committed` means the
|
|
231
|
+
library is exactly what it was, and `committed_unverified` — the rare one —
|
|
232
|
+
means the write may have landed but could not be verified afterwards, and is
|
|
233
|
+
the only case that hands back a `backup_path`.
|
|
234
|
+
|
|
184
235
|
## Resources
|
|
185
236
|
|
|
186
237
|
- **`engine://schema`** — the field semantics an assistant needs before
|
|
@@ -213,16 +264,66 @@ does.
|
|
|
213
264
|
|
|
214
265
|
Your library is opened **read-only at the operating-system level**, not by
|
|
215
266
|
convention and not by a `PRAGMA` a query could turn back off. Writes are
|
|
216
|
-
refused by SQLite itself, and no file is ever
|
|
217
|
-
Library` folder. The search index lives in
|
|
267
|
+
refused by SQLite itself, and without `--allow-writes` no file is ever
|
|
268
|
+
created inside your `Engine Library` folder. The search index lives in
|
|
269
|
+
`~/.engine-dj-mcp/`.
|
|
270
|
+
|
|
271
|
+
### Writing
|
|
272
|
+
|
|
273
|
+
Without `--allow-writes` the server has no tool that can write, and the
|
|
274
|
+
paragraph above holds exactly as written: SQLite itself refuses.
|
|
275
|
+
|
|
276
|
+
With the flag, one tool appears — `create_playlist`. It adds a new playlist
|
|
277
|
+
and nothing else: no existing playlist is renamed, reordered, emptied or
|
|
278
|
+
deleted, and no track, cue or beatgrid is touched. The single change to an
|
|
279
|
+
existing row is the previous last playlist's link, made by Engine's own
|
|
280
|
+
trigger.
|
|
281
|
+
|
|
282
|
+
Before the first write of a session the database is snapshotted to
|
|
283
|
+
`~/.engine-dj-mcp/backups/`, and every write of that session returns its
|
|
284
|
+
path. Ten snapshots are kept per library — per library *file*, so a library
|
|
285
|
+
and its clone on another drive do not share the ten.
|
|
286
|
+
|
|
287
|
+
One file *is* created inside your `Engine Library` folder while a write is in
|
|
288
|
+
progress: SQLite's rollback journal, `m.db-journal`, next to `m.db`. It is
|
|
289
|
+
removed when the transaction commits, and it is what makes the write
|
|
290
|
+
all-or-nothing. If the process is killed mid-transaction the journal is left
|
|
291
|
+
behind, and both this server and Engine DJ then treat the library as needing
|
|
292
|
+
recovery — this server reports `library_needs_recovery` and refuses to touch
|
|
293
|
+
the library, including for reads, until you have launched Engine DJ once so
|
|
294
|
+
it can roll the journal back. Nothing else is ever written in that folder,
|
|
295
|
+
and without `--allow-writes` not even this.
|
|
296
|
+
|
|
297
|
+
The write takes SQLite's own write lock for the length of one transaction and
|
|
298
|
+
does not wait for it: if something else — Engine DJ mid-save, a player — is
|
|
299
|
+
holding a conflicting lock at that moment, the write is refused with
|
|
300
|
+
`library_busy` and nothing is changed. Merely having Engine DJ *open* is not
|
|
301
|
+
usually a conflict, and the write normally succeeds with Engine running;
|
|
302
|
+
Engine will show the new playlist after it next re-reads the library.
|
|
303
|
+
|
|
304
|
+
### Restoring a snapshot
|
|
305
|
+
|
|
306
|
+
`backup_path` is not an undo. It is a copy of the **whole** `m.db` from
|
|
307
|
+
before the session's first write, so putting it back reverts the entire
|
|
308
|
+
library to that moment: every play count, import, cue, beatgrid and rating
|
|
309
|
+
Engine DJ has written since is discarded along with the playlist you wanted
|
|
310
|
+
gone. Reach for it only if the library itself is damaged — the case where
|
|
311
|
+
`create_playlist` comes back with `detail: "committed_unverified"`.
|
|
312
|
+
|
|
313
|
+
**To undo a playlist, delete it in Engine DJ.** Engine's own delete trigger
|
|
314
|
+
repairs the playlist chain and cascades the entries away, which is exactly
|
|
315
|
+
what removing it should do and is not something restoring a snapshot does
|
|
316
|
+
better.
|
|
218
317
|
|
|
219
318
|
`run_sql` accepts arbitrary SQL, but only the first statement is ever
|
|
220
319
|
executed, and `VACUUM`, `ATTACH` and `DETACH` are rejected outright, so a
|
|
221
320
|
chained or exfiltrating statement cannot slip past the read-only connection.
|
|
222
321
|
|
|
223
322
|
If Engine DJ was closed uncleanly and left an unrecovered journal, this
|
|
224
|
-
server will not open the library
|
|
225
|
-
|
|
323
|
+
server will not open the library to "fix" it, with or without
|
|
324
|
+
`--allow-writes` — rolling a journal forward is a repair on someone else's
|
|
325
|
+
file, and `create_playlist` refuses such a library outright rather than
|
|
326
|
+
letting SQLite do it on the way in. It reports `library_needs_recovery` and
|
|
226
327
|
asks you to launch Engine DJ once so it can recover its own library.
|
|
227
328
|
|
|
228
329
|
## Limitations
|
|
@@ -266,8 +367,11 @@ changes. The track's **main cue** does not count towards it — Engine sets
|
|
|
266
367
|
that as a playback marker rather than the DJ placing it. `has_beatgrid` does
|
|
267
368
|
still test for the blob: `beatData` has no "written but empty" state.
|
|
268
369
|
|
|
269
|
-
**It
|
|
270
|
-
|
|
370
|
+
**It writes nothing but playlists, and only when you ask for it.** Without
|
|
371
|
+
`--allow-writes` the library is opened read-only at the OS level and there is
|
|
372
|
+
no tool that could write. With the flag, `create_playlist` adds playlists —
|
|
373
|
+
and that is the whole list. Not a cue, not a tag, not a rating, and not even
|
|
374
|
+
the recovery of a journal Engine DJ left behind.
|
|
271
375
|
|
|
272
376
|
**It does not read play history.** `Track.timeLastPlayed` answers "what have I
|
|
273
377
|
not played in six months?", but the separate Engine history database —
|
|
@@ -283,9 +387,11 @@ flag — a folder is simply a playlist that other playlists sit under — so
|
|
|
283
387
|
`is_folder` means "has child lists". A folder you have emptied is
|
|
284
388
|
indistinguishable from a playlist with no tracks.
|
|
285
389
|
|
|
286
|
-
**
|
|
287
|
-
|
|
288
|
-
|
|
390
|
+
**Playlists can be created, not edited.** With `--allow-writes` a new
|
|
391
|
+
playlist can be added; there is no reordering, renaming, deleting, or adding
|
|
392
|
+
a track to a playlist that already exists, and no set lists or suggested
|
|
393
|
+
transitions. It answers questions about the collection and writes down the
|
|
394
|
+
answer if you ask; the mixing is yours.
|
|
289
395
|
|
|
290
396
|
**Schema 3.0.0 through 3.0.2 only.** Older and newer libraries are listed with
|
|
291
397
|
their version and reported as unsupported rather than read on a guess.
|
package/dist/discovery.js
CHANGED
|
@@ -12,8 +12,9 @@ export function readLibraryInfo(mdbPath) {
|
|
|
12
12
|
}
|
|
13
13
|
if (hasHotJournal(mdbPath)) {
|
|
14
14
|
// Same check openQueryConnection makes before opening (store/connections.ts):
|
|
15
|
-
// recovering a hot journal needs a write,
|
|
16
|
-
//
|
|
15
|
+
// recovering a hot journal needs a write, and discovery never opens a
|
|
16
|
+
// library writably -- not to probe one, and not to heal one. Caught here
|
|
17
|
+
// first so the specific,
|
|
17
18
|
// actionable library_needs_recovery reaches the caller instead of the
|
|
18
19
|
// SELECT below failing with the raw "attempt to write a readonly
|
|
19
20
|
// database" and landing in the generic library_unreadable catch --
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
|
-
export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "library_unreadable", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery"];
|
|
1
|
+
export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "library_unreadable", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery", "playlist_exists", "unknown_track", "duplicate_track"];
|
|
2
2
|
export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
3
3
|
export interface EngineError {
|
|
4
4
|
error: ErrorCode;
|
|
5
5
|
message: string;
|
|
6
|
+
/**
|
|
7
|
+
* Free text almost everywhere (store/index-manager.ts passes a raw
|
|
8
|
+
* `e.message` through it), with one exception that is part of the tool
|
|
9
|
+
* contract: on an error from the write path (store/write.ts) this is
|
|
10
|
+
* always exactly `"not_committed"` -- the library is byte-for-byte what it
|
|
11
|
+
* was -- or `"committed_unverified"` -- the write may have landed and could
|
|
12
|
+
* not be confirmed, and `backup_path` below is then set. Those two strings
|
|
13
|
+
* are reserved on that path and must stay stable, because a client reads
|
|
14
|
+
* them to decide whether their library changed.
|
|
15
|
+
*/
|
|
6
16
|
detail?: string;
|
|
7
17
|
retry_after_ms?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Path to a pre-write snapshot the caller can restore from. Only ever set
|
|
20
|
+
* by the write path, and only on the errors a client cannot safely ignore:
|
|
21
|
+
* `detail === "committed_unverified"`. It is a whole-database snapshot, so
|
|
22
|
+
* restoring it is a recovery route for a damaged library and not an undo
|
|
23
|
+
* of one playlist -- it reverts everything Engine DJ wrote since it was
|
|
24
|
+
* taken.
|
|
25
|
+
*/
|
|
26
|
+
backup_path?: string;
|
|
8
27
|
}
|
|
9
28
|
export declare function err(error: ErrorCode, message: string, extra?: Omit<EngineError, "error" | "message">): EngineError;
|
|
10
29
|
/**
|
package/dist/errors.js
CHANGED
|
@@ -19,6 +19,14 @@ export const ERROR_CODES = [
|
|
|
19
19
|
"decode_failed",
|
|
20
20
|
"invalid_argument",
|
|
21
21
|
"library_needs_recovery",
|
|
22
|
+
// Write-path codes. There is no writes_not_enabled code: the tool is not
|
|
23
|
+
// registered at all without --allow-writes, and the MCP SDK's own
|
|
24
|
+
// dispatcher rejects a call to an unregistered tool name before any
|
|
25
|
+
// handler in this project runs, so this project never gets the chance to
|
|
26
|
+
// report that condition itself.
|
|
27
|
+
"playlist_exists",
|
|
28
|
+
"unknown_track",
|
|
29
|
+
"duplicate_track",
|
|
22
30
|
];
|
|
23
31
|
export function err(error, message, extra = {}) {
|
|
24
32
|
return { error, message, ...extra };
|
package/dist/index.js
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { createServer } from "./server.js";
|
|
5
5
|
async function main() {
|
|
6
|
-
|
|
6
|
+
// MCP clients configure a server as a command plus an args array, so a flag
|
|
7
|
+
// is visible in that configuration and greppable; an environment variable
|
|
8
|
+
// would not be. Writes are off unless this is present.
|
|
9
|
+
const allowWrites = process.argv.includes("--allow-writes");
|
|
10
|
+
// stderr is not the protocol channel, so this is safe for stdio transport,
|
|
11
|
+
// and it puts the mode in the client's log where a user can check it.
|
|
12
|
+
console.error(`engine-dj-mcp: writes ${allowWrites ? "ENABLED (--allow-writes)" : "disabled"}`);
|
|
13
|
+
const server = await createServer({ allowWrites });
|
|
7
14
|
await server.connect(new StdioServerTransport());
|
|
8
15
|
}
|
|
9
16
|
main().catch((e) => {
|
|
10
|
-
// stderr is not the protocol channel, so this is safe for stdio transport.
|
|
11
17
|
console.error("engine-dj-mcp failed to start:", e);
|
|
12
18
|
process.exit(1);
|
|
13
19
|
});
|
package/dist/paths.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
export declare function sidecarDir(uuid: string): string;
|
|
2
|
+
/**
|
|
3
|
+
* A short, stable tag for one library *file*, for use wherever a uuid alone
|
|
4
|
+
* would collide. A library copied onto a second drive carries the original's
|
|
5
|
+
* uuid -- an ordinary thing for a DJ to do -- so uuid is not unique across
|
|
6
|
+
* mounted volumes while the path of `m.db` always is.
|
|
7
|
+
*
|
|
8
|
+
* Shared by the sidecar layout (server.ts's sidecarBaseFor) and the backup
|
|
9
|
+
* filenames (store/backup.ts) so the two cannot drift into different ideas
|
|
10
|
+
* of which library they are talking about.
|
|
11
|
+
*/
|
|
12
|
+
export declare function libraryTag(mdbPath: string): string;
|
|
2
13
|
/** Engine stores Track.path relative to the `Engine Library` folder, usually with `..`. */
|
|
3
14
|
export declare function absTrackPath(mdbPath: string, relative: string): string;
|
|
4
15
|
/** Candidate locations of `m.db` beneath a filesystem root. */
|
package/dist/paths.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
3
|
import { join, dirname, resolve } from "node:path";
|
|
3
4
|
export function sidecarDir(uuid) {
|
|
4
5
|
return join(homedir(), ".engine-dj-mcp", uuid);
|
|
5
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* A short, stable tag for one library *file*, for use wherever a uuid alone
|
|
9
|
+
* would collide. A library copied onto a second drive carries the original's
|
|
10
|
+
* uuid -- an ordinary thing for a DJ to do -- so uuid is not unique across
|
|
11
|
+
* mounted volumes while the path of `m.db` always is.
|
|
12
|
+
*
|
|
13
|
+
* Shared by the sidecar layout (server.ts's sidecarBaseFor) and the backup
|
|
14
|
+
* filenames (store/backup.ts) so the two cannot drift into different ideas
|
|
15
|
+
* of which library they are talking about.
|
|
16
|
+
*/
|
|
17
|
+
export function libraryTag(mdbPath) {
|
|
18
|
+
return createHash("sha256").update(mdbPath).digest("hex").slice(0, 12);
|
|
19
|
+
}
|
|
6
20
|
/** Engine stores Track.path relative to the `Engine Library` folder, usually with `..`. */
|
|
7
21
|
export function absTrackPath(mdbPath, relative) {
|
|
8
22
|
const engineLibrary = dirname(dirname(mdbPath)); // .../Engine Library/Database2/m.db
|
package/dist/playlists.d.ts
CHANGED
|
@@ -22,10 +22,12 @@ import type { QueryProcess } from "./proc/query-client.js";
|
|
|
22
22
|
* never read by this project.
|
|
23
23
|
*
|
|
24
24
|
* Everything here therefore walks the chain — defensively. These are linked
|
|
25
|
-
* lists inside a file this server does not own and
|
|
26
|
-
* half-completed Engine write, a sync conflict or a
|
|
27
|
-
* backup can leave a cycle, a link to a row that is gone,
|
|
28
|
-
* disconnected runs.
|
|
25
|
+
* lists inside a file this server does not own, and which nothing in this
|
|
26
|
+
* module writes: a half-completed Engine write, a sync conflict or a
|
|
27
|
+
* partially restored backup can leave a cycle, a link to a row that is gone,
|
|
28
|
+
* or two disconnected runs. (Since 0.11.0 the server can append a playlist
|
|
29
|
+
* under --allow-writes — see src/store/write.ts — but only ever through
|
|
30
|
+
* Engine's own chain triggers, and never from here.) None of those may hang the walk, and none may come
|
|
29
31
|
* back as a silently short list that reads like a complete one.
|
|
30
32
|
*/
|
|
31
33
|
/**
|
package/dist/playlists.js
CHANGED
|
@@ -22,10 +22,12 @@ import { err, isEngineError } from "./errors.js";
|
|
|
22
22
|
* never read by this project.
|
|
23
23
|
*
|
|
24
24
|
* Everything here therefore walks the chain — defensively. These are linked
|
|
25
|
-
* lists inside a file this server does not own and
|
|
26
|
-
* half-completed Engine write, a sync conflict or a
|
|
27
|
-
* backup can leave a cycle, a link to a row that is gone,
|
|
28
|
-
* disconnected runs.
|
|
25
|
+
* lists inside a file this server does not own, and which nothing in this
|
|
26
|
+
* module writes: a half-completed Engine write, a sync conflict or a
|
|
27
|
+
* partially restored backup can leave a cycle, a link to a row that is gone,
|
|
28
|
+
* or two disconnected runs. (Since 0.11.0 the server can append a playlist
|
|
29
|
+
* under --allow-writes — see src/store/write.ts — but only ever through
|
|
30
|
+
* Engine's own chain triggers, and never from here.) None of those may hang the walk, and none may come
|
|
29
31
|
* back as a silently short list that reads like a complete one.
|
|
30
32
|
*/
|
|
31
33
|
/**
|
package/dist/server.d.ts
CHANGED
|
@@ -12,8 +12,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
12
12
|
* This walks the same candidate paths independently, purely to tell those
|
|
13
13
|
* two cases apart, so `ready()` below can report library_needs_recovery
|
|
14
14
|
* instead of the misleading library_not_found -- never to open the file:
|
|
15
|
-
* recovering a hot journal requires a write, and
|
|
16
|
-
* to
|
|
15
|
+
* recovering a hot journal requires a write, and nothing here opens a
|
|
16
|
+
* library writably to heal one. Not even create_playlist, which refuses a
|
|
17
|
+
* library in this state outright (store/write.ts) rather than letting
|
|
18
|
+
* SQLite roll the journal forward on its way in.
|
|
17
19
|
*/
|
|
18
20
|
export declare function findHotJournalCandidate(roots: string[]): string | null;
|
|
19
21
|
/**
|
|
@@ -29,4 +31,15 @@ export type EngineDjMcpServer = McpServer & {
|
|
|
29
31
|
export declare function createServer(opts?: {
|
|
30
32
|
roots?: string[];
|
|
31
33
|
sidecarBaseDir?: string;
|
|
34
|
+
allowWrites?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Where pre-write snapshots go. Defaults to ~/.engine-dj-mcp/backups.
|
|
37
|
+
*
|
|
38
|
+
* An option rather than a constant because a test that writes through
|
|
39
|
+
* this server would otherwise deposit a full copy of its throwaway
|
|
40
|
+
* fixture in the real home directory -- and under a fresh tag each run,
|
|
41
|
+
* since every fixture gets a new temp path, so rotation could never
|
|
42
|
+
* reclaim them and they accumulated without bound.
|
|
43
|
+
*/
|
|
44
|
+
backupBaseDir?: string;
|
|
32
45
|
}): Promise<EngineDjMcpServer>;
|
package/dist/server.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// src/server.ts
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
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";
|
|
@@ -17,8 +17,10 @@ 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
21
|
import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
|
|
21
22
|
const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
|
|
23
|
+
const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false };
|
|
22
24
|
/**
|
|
23
25
|
* name/version reported to every client on initialize. Read from
|
|
24
26
|
* package.json rather than typed here, so the two cannot re-diverge the way
|
|
@@ -63,8 +65,10 @@ function reply(value) {
|
|
|
63
65
|
* This walks the same candidate paths independently, purely to tell those
|
|
64
66
|
* two cases apart, so `ready()` below can report library_needs_recovery
|
|
65
67
|
* instead of the misleading library_not_found -- never to open the file:
|
|
66
|
-
* recovering a hot journal requires a write, and
|
|
67
|
-
* to
|
|
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.
|
|
68
72
|
*/
|
|
69
73
|
export function findHotJournalCandidate(roots) {
|
|
70
74
|
for (const root of roots) {
|
|
@@ -152,8 +156,7 @@ export async function createServer(opts = {}) {
|
|
|
152
156
|
const first = knownList().find((l) => l.uuid === lib.uuid);
|
|
153
157
|
if (!first || first.path === lib.path)
|
|
154
158
|
return opts.sidecarBaseDir;
|
|
155
|
-
|
|
156
|
-
return join(opts.sidecarBaseDir ?? sidecarDir(""), "duplicate-uuid", tag);
|
|
159
|
+
return join(opts.sidecarBaseDir ?? sidecarDir(""), "duplicate-uuid", libraryTag(lib.path));
|
|
157
160
|
};
|
|
158
161
|
/** Lazily creates -- and thereafter reuses -- one query child per library. */
|
|
159
162
|
const stateFor = (lib) => {
|
|
@@ -410,6 +413,40 @@ export async function createServer(opts = {}) {
|
|
|
410
413
|
return reply(lib);
|
|
411
414
|
return reply(await refreshIndex(stateFor(lib).mgr));
|
|
412
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, opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups")));
|
|
448
|
+
});
|
|
449
|
+
}
|
|
413
450
|
/**
|
|
414
451
|
* There was previously no way to shut this down at all: createServer
|
|
415
452
|
* forked a query child and handed back an McpServer whose close() knows
|
|
@@ -451,7 +488,8 @@ More than one library can be connected at once — the local one under
|
|
|
451
488
|
(\`search_tracks\`, \`get_tracks\`, \`get_playlists\`,
|
|
452
489
|
\`get_playlist_tracks\`, \`get_track_performance\`, \`audit_library\`,
|
|
453
490
|
\`run_sql\`, \`refresh_index\`) takes an optional
|
|
454
|
-
\`library\` argument naming one of them
|
|
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
|
|
455
493
|
\`path\`, in the \`~/...\` form \`list_libraries\` prints or the absolute
|
|
456
494
|
one. A value matching neither comes back as \`library_not_found\` listing
|
|
457
495
|
the libraries that are selectable.
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
// Snapshots this library owns: the tagged shape above, plus the untagged
|
|
54
|
+
// `${uuid}-${stamp}.db` an earlier version wrote. Without the second,
|
|
55
|
+
// those sat outside every namespace and were never reclaimed -- up to
|
|
56
|
+
// KEEP full copies of a library, kept forever, on any upgrading user.
|
|
57
|
+
// They predate the tag and therefore predate everything written since,
|
|
58
|
+
// which is why folding them into one window evicts them first. Two
|
|
59
|
+
// libraries sharing a uuid is precisely why the tag exists, and an
|
|
60
|
+
// untagged file cannot say which of them it came from -- ageing them out
|
|
61
|
+
// under whichever library writes next is the only thing left to do.
|
|
62
|
+
const legacy = new RegExp(`^${uuid}-\\d{4}-`);
|
|
63
|
+
const mine = readdirSync(baseDir)
|
|
64
|
+
.filter((f) => f.endsWith(".db") && (f.startsWith(prefix) || legacy.test(f)))
|
|
65
|
+
.sort();
|
|
66
|
+
for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) {
|
|
67
|
+
rmSync(join(baseDir, old), { force: true });
|
|
68
|
+
}
|
|
69
|
+
return dest;
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
return err("library_unreadable", `Could not snapshot ${mdbPath} before writing: ${String(e)}`);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
try {
|
|
76
|
+
src?.close();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* already closed */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -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>;
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// src/store/write.ts
|
|
2
|
+
//
|
|
3
|
+
// The only code in this project that writes to a user's Engine library, and
|
|
4
|
+
// it runs only when the server was started with --allow-writes.
|
|
5
|
+
//
|
|
6
|
+
// The read path is deliberately not reused. Queries run in a forked child
|
|
7
|
+
// whose connection is opened readOnly: true, and that guarantee is the
|
|
8
|
+
// product's core promise -- teaching it to write would dissolve it for reads
|
|
9
|
+
// as well. Writes therefore get their own short-lived connection here:
|
|
10
|
+
// validate read-only, snapshot, open, take the write lock, one transaction,
|
|
11
|
+
// verify, commit, check, close.
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { DatabaseSync } from "node:sqlite";
|
|
14
|
+
import { err, libraryNeedsRecovery } from "../errors.js";
|
|
15
|
+
import { snapshotLibrary } from "./backup.js";
|
|
16
|
+
import { hasHotJournal } from "./connections.js";
|
|
17
|
+
/**
|
|
18
|
+
* `detail` discriminator values for the EngineError this module returns.
|
|
19
|
+
* Stable across releases so a caller can decide "is the library still what
|
|
20
|
+
* it was" without parsing message prose. Everything before COMMIT --
|
|
21
|
+
* including validation that never reaches the database at all -- collapses
|
|
22
|
+
* to the same NOT_COMMITTED answer. COMMITTED_UNVERIFIED is produced by
|
|
23
|
+
* exactly two things, and both mean "the playlist may be on disk": the
|
|
24
|
+
* post-commit check reporting anything but "ok", and anything thrown from
|
|
25
|
+
* the COMMIT itself onwards. It is also the only case that carries
|
|
26
|
+
* backup_path, because it is the only case where restoring from a snapshot
|
|
27
|
+
* is ever the right next step.
|
|
28
|
+
*
|
|
29
|
+
* These two strings are part of the tool's contract; see src/errors.ts.
|
|
30
|
+
*/
|
|
31
|
+
const NOT_COMMITTED = "not_committed";
|
|
32
|
+
const COMMITTED_UNVERIFIED = "committed_unverified";
|
|
33
|
+
/**
|
|
34
|
+
* One snapshot per library per process, which is what "before the first write
|
|
35
|
+
* of a session" means in the spec (§6.1) and in the README.
|
|
36
|
+
*
|
|
37
|
+
* Keyed by backup directory, library path *and* uuid so a test (or a second
|
|
38
|
+
* configured backup root) cannot silently reuse a snapshot that lives
|
|
39
|
+
* somewhere else -- and so a different library that lands at the same path
|
|
40
|
+
* (a second USB stick sharing a volume label, an m.db replaced in place)
|
|
41
|
+
* cannot hit another library's cached entry and hand back its snapshot as
|
|
42
|
+
* this session's way back. The value is only ever a snapshot that actually
|
|
43
|
+
* landed on disk; a failed snapshot is not cached, so the next write tries
|
|
44
|
+
* again.
|
|
45
|
+
*/
|
|
46
|
+
const sessionSnapshots = new Map();
|
|
47
|
+
/** Test seam only: forget this process's snapshots so a test can start clean. */
|
|
48
|
+
export function resetSessionSnapshots() {
|
|
49
|
+
sessionSnapshots.clear();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The snapshot for this library, taken once per process.
|
|
53
|
+
*
|
|
54
|
+
* Called before the write connection is even opened (see createPlaylist),
|
|
55
|
+
* so it never holds SQLite's RESERVED lock and never blocks a write Engine
|
|
56
|
+
* DJ or a second concurrent call in this process is trying to make at the
|
|
57
|
+
* same moment. The hot-journal check and the read-only pre-check have
|
|
58
|
+
* already run by the time this is called, so a library needing recovery or
|
|
59
|
+
* failing the title/track validation never spends a snapshot slot; a
|
|
60
|
+
* library that turns out to be busy still does, once, and the memo above is
|
|
61
|
+
* what keeps a session that only ever hits library_busy at exactly one
|
|
62
|
+
* snapshot rather than one per retry.
|
|
63
|
+
*/
|
|
64
|
+
async function sessionSnapshot(mdbPath, uuid, backupDir) {
|
|
65
|
+
const key = `${backupDir}\u0000${mdbPath}\u0000${uuid}`;
|
|
66
|
+
// existsSync, not a bare Map hit: a user who cleared ~/.engine-dj-mcp/backups
|
|
67
|
+
// mid-session must get a real snapshot back, not a path to a deleted file.
|
|
68
|
+
const cached = sessionSnapshots.get(key);
|
|
69
|
+
if (cached && existsSync(cached))
|
|
70
|
+
return cached;
|
|
71
|
+
const fresh = await snapshotLibrary(mdbPath, uuid, backupDir);
|
|
72
|
+
if (typeof fresh === "string")
|
|
73
|
+
sessionSnapshots.set(key, fresh);
|
|
74
|
+
return fresh;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Engine stores a playlist entry's track as the pair the track was *born*
|
|
78
|
+
* with, not as a local row id. On both libraries measured, originTrackId
|
|
79
|
+
* happens to equal id -- which is exactly why this translation has to be
|
|
80
|
+
* explicit and tested against re-originated rows: the naive version is
|
|
81
|
+
* invisible in normal use and wrong on any library that has travelled.
|
|
82
|
+
*/
|
|
83
|
+
function resolveOrigins(db, trackIds) {
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
for (const id of trackIds) {
|
|
86
|
+
if (seen.has(id)) {
|
|
87
|
+
return err("duplicate_track", `Track ${id} appears more than once; Engine allows a track in a playlist only once.`, { detail: NOT_COMMITTED });
|
|
88
|
+
}
|
|
89
|
+
seen.add(id);
|
|
90
|
+
}
|
|
91
|
+
const stmt = db.prepare("SELECT originDatabaseUuid AS uuid, originTrackId AS trackId FROM Track WHERE id = ?");
|
|
92
|
+
const refs = [];
|
|
93
|
+
for (const id of trackIds) {
|
|
94
|
+
const row = stmt.get(id);
|
|
95
|
+
// == null, not a falsy check: originTrackId = 0 or originDatabaseUuid =
|
|
96
|
+
// "" are real values a track can legitimately carry, not "not found".
|
|
97
|
+
if (!row || row.uuid == null || row.trackId == null) {
|
|
98
|
+
return err("unknown_track", `No track with id ${id} in this library.`, { detail: NOT_COMMITTED });
|
|
99
|
+
}
|
|
100
|
+
refs.push({ uuid: row.uuid, trackId: row.trackId });
|
|
101
|
+
}
|
|
102
|
+
return refs;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Read the chain back starting from a row we know is the head, because we
|
|
106
|
+
* inserted it first. Re-deriving the head as "the row nothing points at"
|
|
107
|
+
* would be the same assumption the write just made, so it could not catch a
|
|
108
|
+
* write that made it wrongly.
|
|
109
|
+
*
|
|
110
|
+
* This, together with `sameOrder`, confirms that the *links* survived the
|
|
111
|
+
* round trip in the order given -- it does not independently confirm the
|
|
112
|
+
* *values* are correct. The comparison target is `refs`, the same array
|
|
113
|
+
* `resolveOrigins` produced and the write consumed, so a `resolveOrigins`
|
|
114
|
+
* that resolved every id wrongly (e.g. to the local row id instead of the
|
|
115
|
+
* origin pair) would write wrong values, read the same wrong values back,
|
|
116
|
+
* and pass this check. Catching that class of bug is what the
|
|
117
|
+
* re-originated-track test is for, not this readback.
|
|
118
|
+
*/
|
|
119
|
+
export function walkFrom(db, listId, headId) {
|
|
120
|
+
const rows = db
|
|
121
|
+
.prepare("SELECT id, trackId, databaseUuid, nextEntityId FROM PlaylistEntity WHERE listId = ?")
|
|
122
|
+
.all(listId);
|
|
123
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
124
|
+
const out = [];
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
let cur = byId.get(headId);
|
|
127
|
+
while (cur && !seen.has(cur.id)) {
|
|
128
|
+
seen.add(cur.id);
|
|
129
|
+
out.push({ uuid: cur.databaseUuid, trackId: cur.trackId });
|
|
130
|
+
cur = byId.get(cur.nextEntityId);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Roll back, swallowing a failure of the rollback itself.
|
|
136
|
+
*
|
|
137
|
+
* Every caller is already returning a specific error -- the chain did not read
|
|
138
|
+
* back, the library is busy -- and a ROLLBACK that throws on the way out would
|
|
139
|
+
* replace that reason with its own, telling the user about a failed rollback
|
|
140
|
+
* instead of what actually went wrong. Nothing is lost by ignoring it:
|
|
141
|
+
* db.close() in the finally block ends any transaction still open, and SQLite
|
|
142
|
+
* discards an uncommitted one on close.
|
|
143
|
+
*/
|
|
144
|
+
function rollback(db) {
|
|
145
|
+
try {
|
|
146
|
+
db.exec("ROLLBACK");
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* no transaction in progress, or the connection is already gone */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export function sameOrder(a, b) {
|
|
153
|
+
return a.length === b.length && a.every((x, i) => x.uuid === b[i].uuid && x.trackId === b[i].trackId);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Turns whatever node:sqlite throws into an EngineError. Shared between the
|
|
157
|
+
* read-only pre-check and the write transaction below it: both open a
|
|
158
|
+
* connection to the same file and can hit the same failure modes (the
|
|
159
|
+
* library gone missing mid-session, Engine holding the lock, a foreign or
|
|
160
|
+
* corrupt schema), and a caller whose promise is typed
|
|
161
|
+
* `Promise<CreatePlaylistResult | EngineError>` must never see one of them
|
|
162
|
+
* escape as a rejection instead.
|
|
163
|
+
*
|
|
164
|
+
* Every path that reaches this function is one where the library is
|
|
165
|
+
* unchanged: the transaction either never opened or is rolled back by the
|
|
166
|
+
* caller, and a failure at or after COMMIT is answered before this is ever
|
|
167
|
+
* called (see createPlaylist's catch). That is what lets the fallback below
|
|
168
|
+
* say "nothing was changed" without qualification -- it used to say
|
|
169
|
+
* `Writing "X" failed`, which reads as a half-write even when the failure was
|
|
170
|
+
* "file is not a database" and not one byte was attempted.
|
|
171
|
+
*/
|
|
172
|
+
function mapWriteError(e, title, mdbPath) {
|
|
173
|
+
const msg = e.message ?? String(e);
|
|
174
|
+
const isUniqueViolation = /UNIQUE constraint failed/i.test(msg);
|
|
175
|
+
// The constraint's *name* never appears in the message SQLite raises --
|
|
176
|
+
// only the column list does, e.g. "Playlist.title, Playlist.parentListId"
|
|
177
|
+
// -- so the two conditions are checked independently rather than as one
|
|
178
|
+
// pattern that happens to work only because title leads that index today.
|
|
179
|
+
if (isUniqueViolation && /\bPlaylist\.title\b/.test(msg)) {
|
|
180
|
+
return err("playlist_exists", `A playlist called "${title}" already exists in this library.`, {
|
|
181
|
+
detail: NOT_COMMITTED,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (isUniqueViolation && /\bPlaylistEntity\./.test(msg)) {
|
|
185
|
+
return err("duplicate_track", `A track in "${title}" collided with an existing playlist entry; Engine allows a track in a playlist only once.`, { detail: NOT_COMMITTED });
|
|
186
|
+
}
|
|
187
|
+
if (/SQLITE_BUSY|database is locked/i.test(msg)) {
|
|
188
|
+
return err("library_busy", "The library is locked by Engine DJ or a player. Close it and try again.", {
|
|
189
|
+
detail: NOT_COMMITTED,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (/readonly|attempt to write a readonly database/i.test(msg)) {
|
|
193
|
+
return err("library_unreadable", `The library at ${mdbPath} cannot be written to.`, { detail: NOT_COMMITTED });
|
|
194
|
+
}
|
|
195
|
+
// The volume can go away between discovery and this call -- a USB drive
|
|
196
|
+
// pulled mid-set is the live-performance version of this. node:sqlite's
|
|
197
|
+
// message for that is generic ("unable to open database file"), so this
|
|
198
|
+
// is matched by wording rather than an errno, the same tradeoff every
|
|
199
|
+
// other branch here makes.
|
|
200
|
+
if (/unable to open database file/i.test(msg)) {
|
|
201
|
+
return err("library_not_found", `No Engine library database at ${mdbPath}.`, { detail: NOT_COMMITTED });
|
|
202
|
+
}
|
|
203
|
+
return err("library_unreadable", `Could not write "${title}": ${msg}. Nothing was changed.`, {
|
|
204
|
+
detail: NOT_COMMITTED,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
export async function createPlaylist(mdbPath, uuid, input, opts) {
|
|
208
|
+
const title = input.title.trim();
|
|
209
|
+
if (!title)
|
|
210
|
+
return err("invalid_argument", "A playlist needs a non-empty title.", { detail: NOT_COMMITTED });
|
|
211
|
+
// A hot journal is a mandatory refusal reason (spec §6.2), and it has to be
|
|
212
|
+
// checked here rather than left to whatever opens the file first: SQLite
|
|
213
|
+
// refuses to open such a database *read-only* (rolling the journal forward
|
|
214
|
+
// is a write) with the raw "attempt to write a readonly database", which
|
|
215
|
+
// this module would otherwise map to library_unreadable -- the wrong code,
|
|
216
|
+
// and actively false, since the library can be written to perfectly well
|
|
217
|
+
// once Engine DJ has recovered it. Nor does the caller's acquire() cover
|
|
218
|
+
// it: IndexManager.ensureFresh reads the header change counter as raw
|
|
219
|
+
// bytes and returns "fresh" without opening the database at all, so a
|
|
220
|
+
// journal left behind after an earlier successful read reaches this
|
|
221
|
+
// function untouched.
|
|
222
|
+
if (hasHotJournal(mdbPath))
|
|
223
|
+
return { ...libraryNeedsRecovery(), detail: NOT_COMMITTED };
|
|
224
|
+
// Validate against a short-lived read-only connection before opening the
|
|
225
|
+
// library for writing at all. This pass only rules out the common case
|
|
226
|
+
// cheaply -- another writer can still create the same title (or, in
|
|
227
|
+
// principle, the same entry) between this check and the INSERT below, so
|
|
228
|
+
// the UNIQUE-constraint catch further down stays in place as the backstop
|
|
229
|
+
// for that race and must still report it correctly, not as a generic
|
|
230
|
+
// failure.
|
|
231
|
+
let refs;
|
|
232
|
+
{
|
|
233
|
+
// The constructor is inside the try, not just the statements after it:
|
|
234
|
+
// a missing file, an unmounted volume, or Engine holding the lock all
|
|
235
|
+
// fail right here, and this connection must report those exactly like
|
|
236
|
+
// the write connection below does rather than let them throw past
|
|
237
|
+
// createPlaylist's Promise<CreatePlaylistResult | EngineError> contract.
|
|
238
|
+
let precheck;
|
|
239
|
+
try {
|
|
240
|
+
precheck = new DatabaseSync(mdbPath, { readOnly: true });
|
|
241
|
+
const exists = precheck.prepare("SELECT 1 FROM Playlist WHERE title = ? AND parentListId = 0").get(title);
|
|
242
|
+
if (exists) {
|
|
243
|
+
return err("playlist_exists", `A playlist called "${title}" already exists in this library.`, {
|
|
244
|
+
detail: NOT_COMMITTED,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
refs = resolveOrigins(precheck, input.trackIds);
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
return mapWriteError(e, title, mdbPath);
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
try {
|
|
254
|
+
precheck?.close();
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
/* never opened, or already closed */
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!Array.isArray(refs))
|
|
262
|
+
return refs;
|
|
263
|
+
// Snapshot here, before the write connection is even opened, not after
|
|
264
|
+
// BEGIN IMMEDIATE. Taking it with RESERVED held meant a full-database copy
|
|
265
|
+
// -- tens of seconds on a multi-gigabyte USB library -- ran while every
|
|
266
|
+
// write Engine DJ attempted failed with SQLITE_BUSY, and a second
|
|
267
|
+
// concurrent create_playlist call in this process (the MCP SDK dispatches
|
|
268
|
+
// concurrently) was told the library was locked by Engine DJ when it was
|
|
269
|
+
// this server holding the lock. Snapshotting before BEGIN IMMEDIATE used
|
|
270
|
+
// to mean a call that turned out to be busy spent a slot on every retry --
|
|
271
|
+
// ten busy retries, ten full copies, evicting every genuine pre-write
|
|
272
|
+
// snapshot from backup.ts's KEEP window. The per-session memo
|
|
273
|
+
// (sessionSnapshot, above) is what makes moving it here safe: a session
|
|
274
|
+
// that only ever gets library_busy now leaves exactly one snapshot, not
|
|
275
|
+
// one per retry, so nothing is evicted. The hot-journal check and the
|
|
276
|
+
// read-only pre-check above still run first, so a call doomed by either of
|
|
277
|
+
// those still never copies anything.
|
|
278
|
+
const snapshot = await sessionSnapshot(mdbPath, uuid, opts.backupDir);
|
|
279
|
+
// snapshotLibrary sets no detail of its own (src/store/backup.ts); this is
|
|
280
|
+
// still a pre-commit failure, so the discriminator applies here too.
|
|
281
|
+
if (typeof snapshot !== "string")
|
|
282
|
+
return { ...snapshot, detail: NOT_COMMITTED };
|
|
283
|
+
const backupPath = snapshot;
|
|
284
|
+
let db;
|
|
285
|
+
let open = false;
|
|
286
|
+
/**
|
|
287
|
+
* "not yet" until COMMIT is reached; "maybe" for the moment COMMIT is in
|
|
288
|
+
* flight; "yes" once it returned. Anything thrown while this is not "not
|
|
289
|
+
* yet" may have left the playlist on disk -- COMMIT can fail at fsync with
|
|
290
|
+
* SQLITE_IOERR or SQLITE_FULL after the pages are already there, and the
|
|
291
|
+
* post-commit check below runs against a database that has definitely
|
|
292
|
+
* changed. Reporting those as not_committed (which is what a single catch
|
|
293
|
+
* calling mapWriteError did) inverts the one discriminator a client uses to
|
|
294
|
+
* decide whether their library still is what it was, and drops the snapshot
|
|
295
|
+
* path in exactly the case where it is the only way back.
|
|
296
|
+
*/
|
|
297
|
+
let commit = "not yet";
|
|
298
|
+
try {
|
|
299
|
+
db = new DatabaseSync(mdbPath);
|
|
300
|
+
open = true;
|
|
301
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
302
|
+
db.exec("BEGIN IMMEDIATE");
|
|
303
|
+
// nextListId = 0 appends: Engine's own insert triggers move the tail
|
|
304
|
+
// marker off the previous last row and point it at this one.
|
|
305
|
+
const ins = db
|
|
306
|
+
.prepare(`INSERT INTO Playlist (title, parentListId, isPersisted, nextListId, lastEditTime, isExplicitlyExported)
|
|
307
|
+
VALUES (?, 0, 1, 0, datetime('now'), 0)`)
|
|
308
|
+
.run(title);
|
|
309
|
+
const listId = Number(ins.lastInsertRowid);
|
|
310
|
+
// One row at a time, linked by the id the insert actually returned.
|
|
311
|
+
// A single INSERT ... SELECT ... ORDER BY would depend on SQLite
|
|
312
|
+
// assigning AUTOINCREMENT in sort order, which it does today and does not
|
|
313
|
+
// promise; the failure mode is a playlist with the right tracks in the
|
|
314
|
+
// wrong order, which looks like success.
|
|
315
|
+
const insEntity = db.prepare(`INSERT INTO PlaylistEntity (listId, trackId, databaseUuid, nextEntityId, membershipReference)
|
|
316
|
+
VALUES (?, ?, ?, 0, 0)`);
|
|
317
|
+
const link = db.prepare("UPDATE PlaylistEntity SET nextEntityId = ? WHERE id = ?");
|
|
318
|
+
const ids = [];
|
|
319
|
+
for (const ref of refs) {
|
|
320
|
+
ids.push(Number(insEntity.run(listId, ref.trackId, ref.uuid).lastInsertRowid));
|
|
321
|
+
}
|
|
322
|
+
for (let i = 0; i + 1 < ids.length; i++)
|
|
323
|
+
link.run(ids[i + 1], ids[i]);
|
|
324
|
+
// No foreign-key gate here, though spec §6.3 asks for one. PlaylistEntity
|
|
325
|
+
// carries exactly one foreign key -- listId -> Playlist(id) -- it is not
|
|
326
|
+
// DEFERRABLE, and this connection sets PRAGMA foreign_keys = ON, so a bad
|
|
327
|
+
// listId is refused by SQLite at the INSERT above and never reaches a
|
|
328
|
+
// check. The listId asked about would in any case be the one this
|
|
329
|
+
// transaction just inserted, which exists by construction. A gate whose
|
|
330
|
+
// condition cannot become true is not a safety net; it reads as one,
|
|
331
|
+
// which is worse than its absence. (`PRAGMA foreign_key_check(...)` is
|
|
332
|
+
// not the alternative: it reports every orphan in the table, so a
|
|
333
|
+
// PlaylistEntity row left behind by some earlier deleted playlist would
|
|
334
|
+
// fail every create_playlist call on that library forever, blaming this
|
|
335
|
+
// write for damage that predates it.)
|
|
336
|
+
if (ids.length > 0 && !sameOrder(walkFrom(db, listId, ids[0]), refs)) {
|
|
337
|
+
rollback(db);
|
|
338
|
+
return err("library_unreadable", `The entry chain for "${title}" did not read back as written; nothing was changed.`, { detail: NOT_COMMITTED });
|
|
339
|
+
}
|
|
340
|
+
// No check that exactly one Playlist row has nextListId = 0: the schema's
|
|
341
|
+
// own C_NEXT_LIST_ID_UNIQUE_FOR_PARENT constraint already permits at most
|
|
342
|
+
// one per parent, and this insert always uses nextListId = 0, so more
|
|
343
|
+
// than one tail is not a state this transaction can produce. Restating
|
|
344
|
+
// that as a runtime check would be a tautology, not a safety net.
|
|
345
|
+
commit = "maybe";
|
|
346
|
+
db.exec("COMMIT");
|
|
347
|
+
commit = "yes";
|
|
348
|
+
// quick_check, not integrity_check: both walk every page -- the
|
|
349
|
+
// difference is that integrity_check additionally cross-checks every
|
|
350
|
+
// index against its table's actual content, and that cross-check is
|
|
351
|
+
// what dominates the cost on a library with hundreds of thousands of
|
|
352
|
+
// tracks. quick_check skips only that verification and still catches
|
|
353
|
+
// the on-disk structural damage (a malformed b-tree page, say) that a
|
|
354
|
+
// check running right after a write exists to catch.
|
|
355
|
+
//
|
|
356
|
+
// check?.quick_check, not check.quick_check: the pragma is documented to
|
|
357
|
+
// return at least one row, but a `.get()` that came back undefined here
|
|
358
|
+
// would raise a TypeError *after* a successful commit, and that lands in
|
|
359
|
+
// the catch below as an error about a library that has in fact already
|
|
360
|
+
// changed. Reading it as "not ok" says the same true thing without
|
|
361
|
+
// depending on the throw being classified correctly.
|
|
362
|
+
const check = db.prepare("PRAGMA quick_check").get();
|
|
363
|
+
if (check?.quick_check !== "ok") {
|
|
364
|
+
return err("library_unreadable", `The database reports "${check?.quick_check ?? "no result"}" after writing "${title}". A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
|
|
365
|
+
}
|
|
366
|
+
return { playlist_id: listId, title, tracks_added: refs.length, backup_path: backupPath };
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
// A COMMIT that returned SQLITE_BUSY is the one in-flight failure SQLite
|
|
370
|
+
// defines precisely: the transaction stays open and nothing was written,
|
|
371
|
+
// so it is a plain retry, not an unverified write.
|
|
372
|
+
const busyOnCommit = commit === "maybe" && /SQLITE_BUSY|database is locked/i.test(e?.message ?? "");
|
|
373
|
+
if (commit === "not yet" || busyOnCommit) {
|
|
374
|
+
if (open && db)
|
|
375
|
+
rollback(db);
|
|
376
|
+
return mapWriteError(e, title, mdbPath);
|
|
377
|
+
}
|
|
378
|
+
// Past the point of no return. No ROLLBACK: after a successful COMMIT
|
|
379
|
+
// there is no transaction to roll back, and after a COMMIT that failed
|
|
380
|
+
// mid-flight there is no state we can reason about well enough to undo
|
|
381
|
+
// by hand -- db.close() in the finally block ends anything still open.
|
|
382
|
+
// The honest answer is that the write may have gone through, plus the
|
|
383
|
+
// path of the snapshot from before this session's first write, which is
|
|
384
|
+
// the only case where restoring one is ever the right next step.
|
|
385
|
+
const msg = e?.message ?? String(e);
|
|
386
|
+
return err("library_unreadable", `Writing "${title}" may have gone through: the library could not be verified afterwards (${msg}). ` +
|
|
387
|
+
`Check the library in Engine DJ. A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
try {
|
|
391
|
+
db?.close();
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
/* already closed */
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type CreatePlaylistResult } from "../store/write.js";
|
|
3
|
+
import { type EngineError } from "../errors.js";
|
|
4
|
+
export declare const CreatePlaylistInput: z.ZodObject<{
|
|
5
|
+
title: z.ZodString;
|
|
6
|
+
track_ids: z.ZodArray<z.ZodNumber>;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
export declare function runCreatePlaylist(mdbPath: string, uuid: string, args: {
|
|
9
|
+
title: string;
|
|
10
|
+
track_ids: number[];
|
|
11
|
+
}, backupDir: string): Promise<CreatePlaylistResult | EngineError>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/tools/write-playlist.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createPlaylist } from "../store/write.js";
|
|
4
|
+
export const CreatePlaylistInput = z.object({
|
|
5
|
+
title: z.string().min(1).describe("Name for the new playlist. Must not already exist in this library."),
|
|
6
|
+
track_ids: z
|
|
7
|
+
.array(z.number().int().positive())
|
|
8
|
+
.max(10_000)
|
|
9
|
+
.describe("Track ids from search_tracks, in the order they should appear in the playlist. May be empty."),
|
|
10
|
+
});
|
|
11
|
+
export async function runCreatePlaylist(mdbPath, uuid, args, backupDir) {
|
|
12
|
+
return createPlaylist(mdbPath, uuid, { title: args.title, trackIds: args.track_ids }, { backupDir });
|
|
13
|
+
}
|