serato-dj-mcp 0.1.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/LICENSE +21 -0
- package/README.md +274 -0
- package/dist/apply/backup.d.ts +17 -0
- package/dist/apply/backup.js +98 -0
- package/dist/apply/manifest.d.ts +35 -0
- package/dist/apply/manifest.js +73 -0
- package/dist/apply/mutex.d.ts +21 -0
- package/dist/apply/mutex.js +64 -0
- package/dist/apply/root.d.ts +39 -0
- package/dist/apply/root.js +99 -0
- package/dist/apply/serato.d.ts +28 -0
- package/dist/apply/serato.js +102 -0
- package/dist/apply/sqlite.d.ts +2 -0
- package/dist/apply/sqlite.js +4 -0
- package/dist/apply/transaction.d.ts +34 -0
- package/dist/apply/transaction.js +330 -0
- package/dist/args.d.ts +25 -0
- package/dist/args.js +49 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +80 -0
- package/dist/discovery/index.d.ts +51 -0
- package/dist/discovery/index.js +152 -0
- package/dist/envelope.d.ts +56 -0
- package/dist/envelope.js +70 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +39 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +43 -0
- package/dist/paths.d.ts +20 -0
- package/dist/paths.js +42 -0
- package/dist/read/audit.d.ts +75 -0
- package/dist/read/audit.js +305 -0
- package/dist/read/crates.d.ts +36 -0
- package/dist/read/crates.js +88 -0
- package/dist/read/cursor.d.ts +52 -0
- package/dist/read/cursor.js +105 -0
- package/dist/read/fields.d.ts +36 -0
- package/dist/read/fields.js +133 -0
- package/dist/read/filters.d.ts +69 -0
- package/dist/read/filters.js +242 -0
- package/dist/read/key.d.ts +58 -0
- package/dist/read/key.js +135 -0
- package/dist/read/session.d.ts +34 -0
- package/dist/read/session.js +86 -0
- package/dist/read/sort.d.ts +31 -0
- package/dist/read/sort.js +131 -0
- package/dist/schema/index.d.ts +11 -0
- package/dist/schema/index.js +14 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +170 -0
- package/dist/snapshot/derive.d.ts +29 -0
- package/dist/snapshot/derive.js +80 -0
- package/dist/snapshot/index.d.ts +47 -0
- package/dist/snapshot/index.js +268 -0
- package/dist/stage/name.d.ts +26 -0
- package/dist/stage/name.js +50 -0
- package/dist/stage/store.d.ts +40 -0
- package/dist/stage/store.js +128 -0
- package/dist/tools/apply-changes.d.ts +39 -0
- package/dist/tools/apply-changes.js +173 -0
- package/dist/tools/audit-library.d.ts +30 -0
- package/dist/tools/audit-library.js +67 -0
- package/dist/tools/discard-changes.d.ts +21 -0
- package/dist/tools/discard-changes.js +57 -0
- package/dist/tools/get-crate-tracks.d.ts +40 -0
- package/dist/tools/get-crate-tracks.js +97 -0
- package/dist/tools/get-tracks.d.ts +27 -0
- package/dist/tools/get-tracks.js +53 -0
- package/dist/tools/list-crates.d.ts +35 -0
- package/dist/tools/list-crates.js +67 -0
- package/dist/tools/list-libraries.d.ts +58 -0
- package/dist/tools/list-libraries.js +124 -0
- package/dist/tools/preview-changes.d.ts +50 -0
- package/dist/tools/preview-changes.js +55 -0
- package/dist/tools/run-sql.d.ts +40 -0
- package/dist/tools/run-sql.js +212 -0
- package/dist/tools/search-tracks.d.ts +57 -0
- package/dist/tools/search-tracks.js +175 -0
- package/dist/tools/stage-crate.d.ts +52 -0
- package/dist/tools/stage-crate.js +255 -0
- package/dist/version.d.ts +17 -0
- package/dist/version.js +18 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikhail Chereshnev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# serato-dj-mcp
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Venut-Technologies/serato-dj-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
**Status: experimental.** Pre-1.0 and actively developed: a minor version may change
|
|
7
|
+
behaviour or break compatibility, a patch never does.
|
|
8
|
+
|
|
9
|
+
Talk to your Serato DJ library from an AI assistant. `serato-dj-mcp` is a local
|
|
10
|
+
[Model Context Protocol](https://modelcontextprotocol.io) server that lets Claude, or any other MCP
|
|
11
|
+
client, search your tracks by BPM, key and genre, look inside your crates, find gaps and
|
|
12
|
+
duplicates in the library, and — only when you switch it on — build new crates for you.
|
|
13
|
+
|
|
14
|
+
> [!IMPORTANT]
|
|
15
|
+
> **Not affiliated with, endorsed by, or supported by Serato.** Serato and Serato DJ are
|
|
16
|
+
> trademarks of their respective owner. This project reads a reverse-engineered database layout
|
|
17
|
+
> and can stop working after any Serato update.
|
|
18
|
+
|
|
19
|
+
## What you can ask
|
|
20
|
+
|
|
21
|
+
Once the server is connected, you talk to your assistant as usual:
|
|
22
|
+
|
|
23
|
+
- "Find tracks between 122 and 126 BPM in 8A or 9A that I added this year."
|
|
24
|
+
- "Which tracks in my library have no BPM or no key?"
|
|
25
|
+
- "Show me what's in my *Warm Up* crate, in order."
|
|
26
|
+
- "Audit my library: duplicates, missing files, tracks that aren't in any crate."
|
|
27
|
+
- "Give me tracks that mix harmonically out of 8A, around 124 BPM."
|
|
28
|
+
|
|
29
|
+
With writes enabled (`--allow-writes`):
|
|
30
|
+
|
|
31
|
+
- "Build a crate called *Friday Opening* from the twenty tracks you just found, and show me the
|
|
32
|
+
list before writing anything."
|
|
33
|
+
- "I've closed Serato — apply the staged crate."
|
|
34
|
+
|
|
35
|
+
The assistant does the searching; the server answers from your library and, when asked, writes
|
|
36
|
+
only what you approved.
|
|
37
|
+
|
|
38
|
+
## Compatibility
|
|
39
|
+
|
|
40
|
+
| | |
|
|
41
|
+
|---|---|
|
|
42
|
+
| **Serato DJ 4.x** | Supported. Developed and tested against Serato DJ Lite 4.0.9 (library schema 202). Other 4.x schema versions are read with a `schema_unknown` warning. Serato DJ Pro 4.x is expected to use the same library format but has not been tested. |
|
|
43
|
+
| **Serato DJ 3.x** | Detected and reported, not read (it keeps a binary `database V2` instead of SQLite). |
|
|
44
|
+
| **macOS** | Supported. This is where the project is developed, and CI runs on it. |
|
|
45
|
+
| **Windows** | Untested. The server has no Windows-specific handling: pass `--library` explicitly, because automatic discovery only knows the macOS layout, and expect macOS-style cache and state directories under your user folder. The "is Serato running" check uses `ps`, which Windows does not have, so `apply_changes` may refuse to write rather than guess. |
|
|
46
|
+
| **Linux** | Serato does not run on Linux; the test suite runs there in CI on synthetic fixtures. |
|
|
47
|
+
| **Node.js** | 22.16 or newer, because `backup()` from `node:sqlite` lands there. Not assumed: CI runs the whole suite on 22.16 and on 24, on macOS and on Ubuntu. |
|
|
48
|
+
|
|
49
|
+
Everything this server assumes about the Serato library is written down in
|
|
50
|
+
[docs/serato-4x-notes.md](docs/serato-4x-notes.md), with the measurement behind each claim.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
The package is **not published to npm yet**. Until the first release, run it from source:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
git clone https://github.com/Venut-Technologies/serato-dj-mcp.git
|
|
58
|
+
cd serato-dj-mcp
|
|
59
|
+
npm ci
|
|
60
|
+
npm run build
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Then point your MCP client at the built server. For Claude Desktop, in
|
|
64
|
+
`claude_desktop_config.json`:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"mcpServers": {
|
|
69
|
+
"serato": {
|
|
70
|
+
"command": "node",
|
|
71
|
+
"args": ["/absolute/path/to/serato-dj-mcp/dist/index.js"]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Claude Code: `claude mcp add serato -- node /absolute/path/to/serato-dj-mcp/dist/index.js`
|
|
78
|
+
|
|
79
|
+
Add `"--allow-writes"` to `args` only if you want crate writing (see below).
|
|
80
|
+
|
|
81
|
+
Once a release is on npm, the same configuration will work with
|
|
82
|
+
`"command": "npx", "args": ["-y", "serato-dj-mcp"]`.
|
|
83
|
+
|
|
84
|
+
This server uses `backup()` from `node:sqlite`, which was added in Node 22.16. `node:sqlite` is
|
|
85
|
+
an experimental Node API and prints a warning to stderr; that is expected and harmless, because
|
|
86
|
+
the MCP protocol travels over stdout.
|
|
87
|
+
|
|
88
|
+
## Read-only by default, writes on request
|
|
89
|
+
|
|
90
|
+
**By default the server never writes to Serato's files.** Every read goes through a snapshot
|
|
91
|
+
copy of the library database in `--cache-dir`, so a question from the assistant cannot change your
|
|
92
|
+
library, whether Serato is open or not.
|
|
93
|
+
|
|
94
|
+
Two flags widen that, and each registers extra tools only when it is given — a tool that does not
|
|
95
|
+
exist cannot be called by mistake:
|
|
96
|
+
|
|
97
|
+
- `--allow-raw-sql` adds `run_sql`: read-only `SELECT` against the snapshot, returning raw rows.
|
|
98
|
+
- `--allow-writes` adds `stage_crate`, `preview_changes`, `apply_changes` and `discard_changes`.
|
|
99
|
+
Writing is split in two: crates are *staged* first, which never touches the library, and are
|
|
100
|
+
*applied* only when you confirm and Serato is closed. Both databases are backed up before every
|
|
101
|
+
write. The details are in [Writing to the library](#writing-to-the-library).
|
|
102
|
+
|
|
103
|
+
## Tools
|
|
104
|
+
|
|
105
|
+
- `list_libraries` — the libraries this server can see, with version, schema
|
|
106
|
+
version and locations. Paths here are not redacted, so you can copy one into
|
|
107
|
+
`--library`.
|
|
108
|
+
- `search_tracks` — search by free text, BPM, key, genre, rating, date added, crate
|
|
109
|
+
membership and flags. Tonality is Camelot; a track whose key Serato itself could not
|
|
110
|
+
parse is still matched, and `key_source` says where the key came from. Paginated with an
|
|
111
|
+
opaque cursor; the default page is 25 tracks and nine fields.
|
|
112
|
+
- `get_tracks` — fetch tracks by the ids `search_tracks` returned. Unknown ids come back in
|
|
113
|
+
`missing` rather than being dropped.
|
|
114
|
+
- `list_crates` — the crates in the Serato Library space, with their display path and how many
|
|
115
|
+
distinct tracks each holds. Smart crates, space roots and Serato's other internal spaces
|
|
116
|
+
(such as the Prepare panel) are not listed.
|
|
117
|
+
- `get_crate_tracks` — the tracks of one crate, in the crate's own order. Only crates in the
|
|
118
|
+
Serato Library space can be given.
|
|
119
|
+
- `audit_library` — diagnose the library. Every check runs by default and reports a count plus
|
|
120
|
+
up to ten example track ids: tracks with no BPM, with no key at all, with a key Serato itself
|
|
121
|
+
cannot display, marked stale, in no crate, streaming-only, duplicated, and with broken paths.
|
|
122
|
+
`duplicates` reports groups instead of loose ids, because which track duplicates which is the
|
|
123
|
+
part you can act on. `broken_paths` reads Serato's own missing flag by default; pass
|
|
124
|
+
`check_filesystem: true` to also look on disk, which is opt-in because a stat against a
|
|
125
|
+
disconnected drive blocks for seconds. A drive that is not mounted is reported as such rather
|
|
126
|
+
than having all its tracks declared missing.
|
|
127
|
+
- `run_sql` — one read-only `SELECT` against a snapshot copy. Registered only
|
|
128
|
+
with `--allow-raw-sql`, because it returns raw rows with no path redaction.
|
|
129
|
+
|
|
130
|
+
With `--allow-writes`:
|
|
131
|
+
|
|
132
|
+
- `stage_crate` — stage a new crate from track ids. Nothing is written yet; the response lists
|
|
133
|
+
every staged track by title and artist, so check it.
|
|
134
|
+
- `preview_changes` — show what is staged, with `format: "detail"` down to each track.
|
|
135
|
+
- `apply_changes` — write everything staged, all or nothing. Refused while Serato is running.
|
|
136
|
+
- `discard_changes` — drop one staged crate, or all of them.
|
|
137
|
+
|
|
138
|
+
## Options
|
|
139
|
+
|
|
140
|
+
`--library <path>`, `--root <dir>` (repeatable), `--cache-dir <dir>`,
|
|
141
|
+
`--state-dir <dir>`, `--allow-raw-sql`, `--allow-writes`, `--help`,
|
|
142
|
+
`--version`. `SERATO_LIBRARY_PATH` is an alternative to `--library`;
|
|
143
|
+
the flag wins. An unknown option is an error, not a no-op.
|
|
144
|
+
|
|
145
|
+
## Writing to the library
|
|
146
|
+
|
|
147
|
+
Writes need `--allow-writes` and happen in two steps, because Serato must be closed while its
|
|
148
|
+
database is written and the model usually works while it is open. `stage_crate` can run at any
|
|
149
|
+
time; `apply_changes` refuses while Serato is running. Start Serato afterwards and the new crates
|
|
150
|
+
appear within a few seconds.
|
|
151
|
+
|
|
152
|
+
What a write does: it creates new crates at the top level of the Serato Library, in
|
|
153
|
+
`root.sqlite`, and nothing else. It never changes or deletes an existing crate, never edits a
|
|
154
|
+
track, never touches `master.sqlite`, `database V2` or the `Subcrates` folder — Serato regenerates
|
|
155
|
+
those itself.
|
|
156
|
+
|
|
157
|
+
Before every write both databases are backed up under
|
|
158
|
+
`<state-dir>/backups/<library-id>/<timestamp>/` (default state-dir:
|
|
159
|
+
`~/Library/Application Support/serato-dj-mcp`), and the last ten are kept. A backup is taken on
|
|
160
|
+
every `apply_changes` attempt that reaches the backup step, including attempts that are then
|
|
161
|
+
refused inside the transaction (a name conflict, for example) — so "the last ten" means the last
|
|
162
|
+
ten *attempts*, not ten successful writes, and the newest one may already contain the write you
|
|
163
|
+
are trying to undo.
|
|
164
|
+
|
|
165
|
+
**There is no undo tool.** To undo a specific write, first find the right backup: use the
|
|
166
|
+
`backup_paths` returned by that `apply_changes` call, or open
|
|
167
|
+
`<state-dir>/manifests/<library-id>.jsonl` and take the `backup_paths` of the line whose
|
|
168
|
+
`"commit_state"` is `"committed"`. `<library-id>` is the `uuid` reported by `list_libraries`. Then,
|
|
169
|
+
with that pair of paths in hand:
|
|
170
|
+
|
|
171
|
+
1. Quit Serato.
|
|
172
|
+
2. In the library folder, delete `root.sqlite-journal` if present, and delete
|
|
173
|
+
`master.sqlite-wal` and `master.sqlite-shm`.
|
|
174
|
+
3. Copy the backed-up `root.sqlite` and `master.sqlite` into the library folder, replacing the
|
|
175
|
+
current ones.
|
|
176
|
+
4. Delete `~/Music/_Serato_/Subcrates/<crate name>.crate` — Serato exported it after it synced
|
|
177
|
+
the crate, and copying the databases back does not remove it.
|
|
178
|
+
|
|
179
|
+
Restoring these files also rolls back anything Serato itself recorded in the library after that
|
|
180
|
+
backup was taken.
|
|
181
|
+
|
|
182
|
+
Nested crates are not supported: a crate created this way inside another crate is deleted by
|
|
183
|
+
Serato when it next syncs, so every crate goes to the top level.
|
|
184
|
+
|
|
185
|
+
## Privacy
|
|
186
|
+
|
|
187
|
+
- **Everything runs on your computer.** The server is a local process your MCP client starts. It
|
|
188
|
+
sends no telemetry, has no analytics, and makes no network requests. The only other program it
|
|
189
|
+
runs is `ps`, to check whether Serato is running before a write.
|
|
190
|
+
- **What it reads:** Serato's library databases, always read-only except for `apply_changes`;
|
|
191
|
+
with `audit_library`'s `check_filesystem: true`, the file metadata of your tracks on disk.
|
|
192
|
+
- **What it writes, and where:**
|
|
193
|
+
- `--cache-dir` (default `~/Library/Caches/serato-dj-mcp`) holds a snapshot copy of your library
|
|
194
|
+
database. Safe to delete at any time.
|
|
195
|
+
- `--state-dir` (default `~/Library/Application Support/serato-dj-mcp`) holds staged crates, a
|
|
196
|
+
manifest of every write, lock files, and backups of your library databases. Only used with
|
|
197
|
+
`--allow-writes`. Deleting it deletes those backups.
|
|
198
|
+
- With `--allow-writes`, `apply_changes` writes new crates into Serato's `root.sqlite`.
|
|
199
|
+
- **What leaves your computer is up to your MCP client.** Tool results — track titles, artists,
|
|
200
|
+
crate names, file paths — go to your assistant, and from there to whichever model provider the
|
|
201
|
+
client uses. Track paths under your home folder are shortened to `~`; `list_libraries`,
|
|
202
|
+
`run_sql` and the backup paths returned by `apply_changes` are full paths. Check your client's
|
|
203
|
+
data policy if that matters to you.
|
|
204
|
+
|
|
205
|
+
## Limitations
|
|
206
|
+
|
|
207
|
+
Read this before deciding what to trust.
|
|
208
|
+
|
|
209
|
+
- **Serato DJ 3.x is not supported.** It is recognised and reported as
|
|
210
|
+
`version: "3.x"`, but nothing reads it — it stores a binary `database V2`
|
|
211
|
+
rather than SQLite. No tool will return data from a 3.x library.
|
|
212
|
+
- **Reads go through a snapshot**, so an answer reflects the library as of the
|
|
213
|
+
last snapshot, not the current instant. A snapshot is reused for up to two
|
|
214
|
+
seconds, so while Serato is writing an answer can be that far behind. Only
|
|
215
|
+
the current snapshot of each library is kept in `--cache-dir`; older ones
|
|
216
|
+
are deleted as soon as a newer one is published.
|
|
217
|
+
- **Two audit checks rest on column semantics this project has not confirmed.** `stale` reads
|
|
218
|
+
`is_stale` and `streaming_only` reads `third_party_type`; both were zero on every track of the
|
|
219
|
+
reference library, so their counts are reported without any claim about what they mean.
|
|
220
|
+
- **`rating` and the streaming flag are passed through uninterpreted.** `rating` was NULL or 0 on
|
|
221
|
+
all 118 tracks of the reference library, so the top of the scale is unconfirmed. No meaning
|
|
222
|
+
beyond the raw column value is claimed for the streaming flag.
|
|
223
|
+
- **`analysis_flags` bit 2 is claimed, though the rest of the field is not.**
|
|
224
|
+
It is read as "Serato ran its own analysis" — not the same as "has a BPM",
|
|
225
|
+
since a BPM can come from the file's tags — and exposed as
|
|
226
|
+
`flags.analyzed`, which `search_tracks` can filter on. Measured 2026-09-06
|
|
227
|
+
on 118 tracks: 106 have bit 2 set, of which 104 have a BPM; twelve have it
|
|
228
|
+
clear — six sound effects and six tracks whose BPM came from tags rather
|
|
229
|
+
than Serato's own analysis.
|
|
230
|
+
- **Free-text search is not Serato's search.** Serato normalises text with a function only
|
|
231
|
+
its own process has, so `q` matches both the normalised columns and the raw ones and can
|
|
232
|
+
differ from what the application would find.
|
|
233
|
+
- **A page taken while Serato is writing can straddle two snapshots.** Pagination is keyset,
|
|
234
|
+
so it continues from the same position on the newer copy and says so in
|
|
235
|
+
`warnings: snapshot_advanced`; a few rows may be repeated or skipped at the seam.
|
|
236
|
+
- **Crate writing is new and experimental.** The write protocol was worked out against a live
|
|
237
|
+
Serato DJ Lite 4.0.9 library, but it has not been exercised across Serato updates, on large
|
|
238
|
+
libraries, or on libraries spread over external drives. Keep your own backups as well.
|
|
239
|
+
- **Writes are narrow on purpose.** `apply_changes` creates new top-level crates and nothing
|
|
240
|
+
else: no nested crates, no smart crates, no renaming, reordering or deleting crates, no edits
|
|
241
|
+
to tracks, cue points or other metadata.
|
|
242
|
+
- **A crate can only hold tracks from the library's own disk.** Streaming tracks, and tracks that
|
|
243
|
+
live in another drive's Serato store, are refused by `stage_crate`, naming each one.
|
|
244
|
+
- **Serato must be closed to apply, and restarted to see the result.** New crates show up in
|
|
245
|
+
Serato, and in this server's read tools, only after Serato has started and synced.
|
|
246
|
+
- **Staging reads Serato's live database.** `stage_crate` reads `root.sqlite` while Serato may be
|
|
247
|
+
running. Measured on 2026-09-16: three stagings of 50 tracks each, 8 to 41 ms apiece, with
|
|
248
|
+
nothing in Serato's own log for those seconds. That is evidence, not a guarantee — a busier
|
|
249
|
+
library, or a Serato in the middle of its own write, has not been tried.
|
|
250
|
+
- **There is no undo tool.** Undoing a write means restoring the backups by hand, as described
|
|
251
|
+
above.
|
|
252
|
+
|
|
253
|
+
## Principles
|
|
254
|
+
|
|
255
|
+
What this server guarantees about your library, and what it refuses to do, is stated in
|
|
256
|
+
[PRINCIPLES.md](PRINCIPLES.md) — each guarantee with the code that enforces it and the tests that
|
|
257
|
+
would fail if it stopped being true.
|
|
258
|
+
|
|
259
|
+
## Security
|
|
260
|
+
|
|
261
|
+
Please report vulnerabilities privately — see [SECURITY.md](SECURITY.md). Do not open a public
|
|
262
|
+
issue for them.
|
|
263
|
+
|
|
264
|
+
## Contributing
|
|
265
|
+
|
|
266
|
+
Issues and pull requests are welcome; start with [CONTRIBUTING.md](CONTRIBUTING.md). Changes are
|
|
267
|
+
recorded in [CHANGELOG.md](CHANGELOG.md).
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
[MIT](LICENSE). Maintained by [Venut Technologies](mailto:hello@venut.tech).
|
|
272
|
+
|
|
273
|
+
Serato and Serato DJ are trademarks of their respective owner. This project is independent and is
|
|
274
|
+
not affiliated with, endorsed by, or supported by Serato.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SeratoError } from "../errors.js";
|
|
2
|
+
/** Backups are kept, not merely cached -- the last ten per library. */
|
|
3
|
+
export declare const MAX_BACKUPS = 10;
|
|
4
|
+
export type BackupPaths = {
|
|
5
|
+
root: string;
|
|
6
|
+
master: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Backs up both databases before any write, fail-closed.
|
|
10
|
+
*
|
|
11
|
+
* root.sqlite is a plain file copy verified by hash: it runs in journal_mode
|
|
12
|
+
* DELETE with no sidecars, and apply refuses earlier if a root.sqlite-journal
|
|
13
|
+
* exists. master.sqlite goes through backup() on a read-only connection --
|
|
14
|
+
* never a file copy, which is not atomic against its -wal, and never a
|
|
15
|
+
* checkpoint of the live file.
|
|
16
|
+
*/
|
|
17
|
+
export declare function backupLibrary(libraryPath: string, stateDir: string, libraryId: string, now?: Date): Promise<BackupPaths | SeratoError>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { backup, DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { err } from "../errors.js";
|
|
6
|
+
/** Backups are kept, not merely cached -- the last ten per library. */
|
|
7
|
+
export const MAX_BACKUPS = 10;
|
|
8
|
+
const stamp = (d) => d
|
|
9
|
+
.toISOString()
|
|
10
|
+
.replace(/[-:]/g, "")
|
|
11
|
+
.replace("T", "-")
|
|
12
|
+
.replace(/\.(\d{3})Z$/, "-$1");
|
|
13
|
+
const sha256 = (path) => createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
14
|
+
const failed = (message) => err("write_failed_not_committed", message, { stage: "backup" });
|
|
15
|
+
/**
|
|
16
|
+
* Backs up both databases before any write, fail-closed.
|
|
17
|
+
*
|
|
18
|
+
* root.sqlite is a plain file copy verified by hash: it runs in journal_mode
|
|
19
|
+
* DELETE with no sidecars, and apply refuses earlier if a root.sqlite-journal
|
|
20
|
+
* exists. master.sqlite goes through backup() on a read-only connection --
|
|
21
|
+
* never a file copy, which is not atomic against its -wal, and never a
|
|
22
|
+
* checkpoint of the live file.
|
|
23
|
+
*/
|
|
24
|
+
export async function backupLibrary(libraryPath, stateDir, libraryId, now = new Date()) {
|
|
25
|
+
const libraryBackups = join(stateDir, "backups", libraryId);
|
|
26
|
+
const dir = join(libraryBackups, stamp(now));
|
|
27
|
+
const paths = { root: join(dir, "root.sqlite"), master: join(dir, "master.sqlite") };
|
|
28
|
+
try {
|
|
29
|
+
mkdirSync(dir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
return failed(`cannot create the backup directory: ${String(e)}`);
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const source = join(libraryPath, "root.sqlite");
|
|
36
|
+
copyFileSync(source, paths.root);
|
|
37
|
+
if (sha256(source) !== sha256(paths.root)) {
|
|
38
|
+
rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
return failed("the root.sqlite backup does not match the original byte for byte");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
return failed(`cannot back up root.sqlite: ${String(e)}`);
|
|
45
|
+
}
|
|
46
|
+
let src;
|
|
47
|
+
try {
|
|
48
|
+
src = new DatabaseSync(join(libraryPath, "master.sqlite"), { readOnly: true });
|
|
49
|
+
await backup(src, paths.master);
|
|
50
|
+
const copy = new DatabaseSync(paths.master, { readOnly: true });
|
|
51
|
+
try {
|
|
52
|
+
const { integrity_check } = copy.prepare("PRAGMA integrity_check").get();
|
|
53
|
+
if (integrity_check !== "ok")
|
|
54
|
+
throw new Error(`integrity_check returned ${integrity_check}`);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
copy.close();
|
|
58
|
+
}
|
|
59
|
+
// A read-only connection writes no frames, so any -wal/-shm beside the
|
|
60
|
+
// copy is only an artifact of the check above, not real content: dropped
|
|
61
|
+
// so the backup is exactly the two files a user can copy back.
|
|
62
|
+
rmSync(`${paths.master}-wal`, { force: true });
|
|
63
|
+
rmSync(`${paths.master}-shm`, { force: true });
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
rmSync(dir, { recursive: true, force: true });
|
|
67
|
+
return failed(`cannot back up master.sqlite: ${String(e)}`);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
src?.close();
|
|
71
|
+
}
|
|
72
|
+
// Retention never fails a backup that already succeeded: an old directory
|
|
73
|
+
// we cannot delete is a disk-space problem, not a reason to refuse a write.
|
|
74
|
+
//
|
|
75
|
+
// The directory names are UTC stamps sorted lexically, which assumes the
|
|
76
|
+
// clock moves forward. If it has moved back since an earlier backup, this
|
|
77
|
+
// call's own stamp can sort before some of the last ten and land in the
|
|
78
|
+
// "oldest" slice -- so this call's own directory is never a deletion
|
|
79
|
+
// candidate, no matter where it sorts.
|
|
80
|
+
const ownStamp = stamp(now);
|
|
81
|
+
try {
|
|
82
|
+
const all = readdirSync(libraryBackups).sort();
|
|
83
|
+
const deletable = all.filter((name) => name !== ownStamp);
|
|
84
|
+
const excess = Math.max(0, all.length - MAX_BACKUPS);
|
|
85
|
+
for (const old of deletable.slice(0, excess)) {
|
|
86
|
+
rmSync(join(libraryBackups, old), { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// see above
|
|
91
|
+
}
|
|
92
|
+
// Belt and braces: if the directory this call just wrote is gone regardless,
|
|
93
|
+
// a write must never proceed believing it has a backup it does not.
|
|
94
|
+
if (!existsSync(paths.root) || !existsSync(paths.master)) {
|
|
95
|
+
return failed("the new backup was removed during retention");
|
|
96
|
+
}
|
|
97
|
+
return paths;
|
|
98
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type SeratoError } from "../errors.js";
|
|
2
|
+
import type { BackupPaths } from "./backup.js";
|
|
3
|
+
export type ManifestCrate = {
|
|
4
|
+
staged_id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
track_count: number;
|
|
7
|
+
/** null until committed: the id exists only after the INSERT. */
|
|
8
|
+
container_id: number | null;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* One line per apply, with every crate it wrote: the manifest line matches
|
|
12
|
+
* the unit of atomicity, one transaction for the whole batch, not the crate
|
|
13
|
+
* count. An earlier per-crate design assumed every apply created exactly one
|
|
14
|
+
* crate, which need not hold.
|
|
15
|
+
*/
|
|
16
|
+
export type ManifestEntry = {
|
|
17
|
+
schema_version: 1;
|
|
18
|
+
op_id: string;
|
|
19
|
+
ts: string;
|
|
20
|
+
library_id: string;
|
|
21
|
+
crates: ManifestCrate[];
|
|
22
|
+
backup_paths: BackupPaths;
|
|
23
|
+
/** "aborted" is a state beyond just "intent" and "committed": a refusal
|
|
24
|
+
* found inside the transaction is known not to be committed, and leaving
|
|
25
|
+
* it as an intent would make it look like a crash. */
|
|
26
|
+
commit_state: "intent" | "committed" | "aborted";
|
|
27
|
+
abort_reason?: string;
|
|
28
|
+
};
|
|
29
|
+
export declare function manifestPath(stateDir: string, libraryId: string): string;
|
|
30
|
+
export declare function readManifest(stateDir: string, libraryId: string): ManifestEntry[];
|
|
31
|
+
/** Written BEFORE BEGIN, so a crash between BEGIN and COMMIT leaves evidence
|
|
32
|
+
* of what was being attempted. */
|
|
33
|
+
export declare function writeIntent(stateDir: string, entry: ManifestEntry): true | SeratoError;
|
|
34
|
+
export declare function markCommitted(stateDir: string, libraryId: string, opId: string, containerIds: Map<string, number>): true | SeratoError;
|
|
35
|
+
export declare function markAborted(stateDir: string, libraryId: string, opId: string, reason: string): true | SeratoError;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { err } from "../errors.js";
|
|
4
|
+
import { writeFileAtomic } from "../stage/store.js";
|
|
5
|
+
export function manifestPath(stateDir, libraryId) {
|
|
6
|
+
return join(stateDir, "manifests", `${libraryId}.jsonl`);
|
|
7
|
+
}
|
|
8
|
+
export function readManifest(stateDir, libraryId) {
|
|
9
|
+
const path = manifestPath(stateDir, libraryId);
|
|
10
|
+
if (!existsSync(path))
|
|
11
|
+
return [];
|
|
12
|
+
return readFileSync(path, "utf8")
|
|
13
|
+
.split("\n")
|
|
14
|
+
.filter((line) => line.trim() !== "")
|
|
15
|
+
.map((line) => JSON.parse(line));
|
|
16
|
+
}
|
|
17
|
+
function rewrite(stateDir, libraryId, entries) {
|
|
18
|
+
try {
|
|
19
|
+
// Whole-file rewrite through an atomic rename, not an append: an append
|
|
20
|
+
// torn by a crash leaves a half line that poisons every later read.
|
|
21
|
+
writeFileAtomic(manifestPath(stateDir, libraryId), `${entries.map((e) => JSON.stringify(e)).join("\n")}\n`);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
return err("write_failed_not_committed", `cannot write the manifest: ${String(e)}`, {
|
|
26
|
+
stage: "manifest",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Every change is read, modify, rewrite. readManifest throws on a damaged
|
|
32
|
+
* line, and change() can throw too on a line that parses but has the wrong
|
|
33
|
+
* shape (e.g. missing crates) -- both have to come back as a value here:
|
|
34
|
+
* markCommitted runs after COMMIT, and a throw there would fail a call whose
|
|
35
|
+
* write succeeded -- inviting a retry that the name-conflict check then
|
|
36
|
+
* refuses.
|
|
37
|
+
*/
|
|
38
|
+
function update(stateDir, libraryId, change) {
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = change(readManifest(stateDir, libraryId));
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
return err("write_failed_not_committed", `the manifest is unreadable or damaged: ${String(e)}`, {
|
|
45
|
+
stage: "manifest",
|
|
46
|
+
path: manifestPath(stateDir, libraryId),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return rewrite(stateDir, libraryId, entries);
|
|
50
|
+
}
|
|
51
|
+
/** Written BEFORE BEGIN, so a crash between BEGIN and COMMIT leaves evidence
|
|
52
|
+
* of what was being attempted. */
|
|
53
|
+
export function writeIntent(stateDir, entry) {
|
|
54
|
+
return update(stateDir, entry.library_id, (existing) => [
|
|
55
|
+
...existing,
|
|
56
|
+
{ ...entry, commit_state: "intent" },
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
export function markCommitted(stateDir, libraryId, opId, containerIds) {
|
|
60
|
+
return update(stateDir, libraryId, (entries) => entries.map((e) => e.op_id !== opId
|
|
61
|
+
? e
|
|
62
|
+
: {
|
|
63
|
+
...e,
|
|
64
|
+
commit_state: "committed",
|
|
65
|
+
crates: e.crates.map((c) => ({
|
|
66
|
+
...c,
|
|
67
|
+
container_id: containerIds.get(c.staged_id) ?? null,
|
|
68
|
+
})),
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
export function markAborted(stateDir, libraryId, opId, reason) {
|
|
72
|
+
return update(stateDir, libraryId, (entries) => entries.map((e) => e.op_id !== opId ? e : { ...e, commit_state: "aborted", abort_reason: reason }));
|
|
73
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type SeratoError } from "../errors.js";
|
|
2
|
+
/** Matches busy_timeout in the transaction: a caller told to retry after this
|
|
3
|
+
* long will find either the lock free or a fresh reason. */
|
|
4
|
+
export declare const WRITE_LOCK_RETRY_MS = 3000;
|
|
5
|
+
/**
|
|
6
|
+
* Exclusive per-library lock for the duration of apply_changes.
|
|
7
|
+
*
|
|
8
|
+
* The lock is SQLite's own: an EXCLUSIVE transaction on an empty database in
|
|
9
|
+
* the state directory, held open until release(). SQLite takes it with a POSIX
|
|
10
|
+
* advisory lock, and the kernel drops that lock the moment the holding process
|
|
11
|
+
* exits, however it exits. So there is no stale lock to detect and no takeover
|
|
12
|
+
* to race. The lock file with a pid in it that this replaced needed both, and
|
|
13
|
+
* review found that every takeover left a window: a live lock moved aside for
|
|
14
|
+
* an instant is a free path a third process can walk into.
|
|
15
|
+
*
|
|
16
|
+
* Within one process SQLite tracks locks per file across connections, so a
|
|
17
|
+
* second acquire from the same server is refused as well.
|
|
18
|
+
*/
|
|
19
|
+
export declare function acquireWriteLock(stateDir: string, libraryId: string): {
|
|
20
|
+
release(): void;
|
|
21
|
+
} | SeratoError;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { err } from "../errors.js";
|
|
5
|
+
import { isSqliteBusy } from "./sqlite.js";
|
|
6
|
+
/** Matches busy_timeout in the transaction: a caller told to retry after this
|
|
7
|
+
* long will find either the lock free or a fresh reason. */
|
|
8
|
+
export const WRITE_LOCK_RETRY_MS = 3000;
|
|
9
|
+
/**
|
|
10
|
+
* Exclusive per-library lock for the duration of apply_changes.
|
|
11
|
+
*
|
|
12
|
+
* The lock is SQLite's own: an EXCLUSIVE transaction on an empty database in
|
|
13
|
+
* the state directory, held open until release(). SQLite takes it with a POSIX
|
|
14
|
+
* advisory lock, and the kernel drops that lock the moment the holding process
|
|
15
|
+
* exits, however it exits. So there is no stale lock to detect and no takeover
|
|
16
|
+
* to race. The lock file with a pid in it that this replaced needed both, and
|
|
17
|
+
* review found that every takeover left a window: a live lock moved aside for
|
|
18
|
+
* an instant is a free path a third process can walk into.
|
|
19
|
+
*
|
|
20
|
+
* Within one process SQLite tracks locks per file across connections, so a
|
|
21
|
+
* second acquire from the same server is refused as well.
|
|
22
|
+
*/
|
|
23
|
+
export function acquireWriteLock(stateDir, libraryId) {
|
|
24
|
+
let db;
|
|
25
|
+
try {
|
|
26
|
+
const dir = join(stateDir, "locks");
|
|
27
|
+
mkdirSync(dir, { recursive: true });
|
|
28
|
+
db = new DatabaseSync(join(dir, `${libraryId}.lock.sqlite`));
|
|
29
|
+
db.exec("PRAGMA busy_timeout = 0");
|
|
30
|
+
db.exec("BEGIN EXCLUSIVE");
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
db?.close();
|
|
34
|
+
if (isSqliteBusy(e)) {
|
|
35
|
+
return err("busy", "another serato-dj-mcp call is changing this library's staged or written crates; retry shortly", { retry_after_ms: WRITE_LOCK_RETRY_MS });
|
|
36
|
+
}
|
|
37
|
+
return err("write_failed_not_committed", `cannot take the write lock: ${String(e)}`, {
|
|
38
|
+
stage: "lock",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const held = db;
|
|
42
|
+
let released = false;
|
|
43
|
+
return {
|
|
44
|
+
release() {
|
|
45
|
+
// Idempotent: a caller that releases in a finally after already
|
|
46
|
+
// releasing on an earlier path must not see close() throw "database is
|
|
47
|
+
// not open".
|
|
48
|
+
if (released)
|
|
49
|
+
return;
|
|
50
|
+
released = true;
|
|
51
|
+
// Closing ends the transaction and drops the lock; the ROLLBACK first
|
|
52
|
+
// only makes that explicit. Nothing was ever written to this database.
|
|
53
|
+
try {
|
|
54
|
+
held.exec("ROLLBACK");
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// already ended; close below still releases the lock
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
held.close();
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { type SeratoError } from "../errors.js";
|
|
3
|
+
export type Anchors = {
|
|
4
|
+
spaceId: number;
|
|
5
|
+
rootContainerId: number;
|
|
6
|
+
};
|
|
7
|
+
/** The tables root.sqlite must have for this protocol to write to it. */
|
|
8
|
+
export declare const REQUIRED_ROOT_TABLES: readonly string[];
|
|
9
|
+
export declare function checkRootSchema(root: DatabaseSync): null | SeratoError;
|
|
10
|
+
/**
|
|
11
|
+
* Both queries mirror how Serato itself resolves them. The space by name
|
|
12
|
+
* (space is UNIQUE on name COLLATE NOCASE, so 0 or 1 rows); the root
|
|
13
|
+
* container by parent_id = 0, never by name -- its name is generated as
|
|
14
|
+
* "<space> root", and UNIQUE (parent_id, name, type) does not stop a user
|
|
15
|
+
* crate of that name at another type.
|
|
16
|
+
*/
|
|
17
|
+
export declare function findAnchors(root: DatabaseSync): Anchors | SeratoError;
|
|
18
|
+
/**
|
|
19
|
+
* portable_id -> root.asset.id (unique index on portable_id COLLATE NOCASE,
|
|
20
|
+
* so this is one index probe per track) -> space_asset.id by (asset_id,
|
|
21
|
+
* space_id). A track without that space_asset row is unresolvable: this
|
|
22
|
+
* server's write path only ever inserts container and container_asset rows,
|
|
23
|
+
* never asset or space_asset ones.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveSpaceAssets(root: DatabaseSync, spaceId: number, portableIds: readonly string[]): {
|
|
26
|
+
resolved: Map<string, number>;
|
|
27
|
+
missing: string[];
|
|
28
|
+
};
|
|
29
|
+
/** Folds case the way container's UNIQUE(parent_id, name COLLATE NOCASE,
|
|
30
|
+
* type) does -- measured on a copy of a real library: 'sErAtO dEmO tRaCkS'
|
|
31
|
+
* collides with 'Serato Demo Tracks'. */
|
|
32
|
+
export declare function existingCrateId(root: DatabaseSync, rootContainerId: number, name: string): number | null;
|
|
33
|
+
/**
|
|
34
|
+
* Informational only, not a gate: apply re-validates what the crate depends
|
|
35
|
+
* on instead of refusing on this value, and reports a change as a warning.
|
|
36
|
+
* Reads serato.revision only -- never the master table, whose
|
|
37
|
+
* last_sync_secret does not fit a JavaScript number (measured 2026-09-14).
|
|
38
|
+
*/
|
|
39
|
+
export declare function rootGeneration(root: DatabaseSync, rootPath: string, libraryId: string): string;
|