engine-dj-mcp 0.15.0 → 0.17.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 +120 -36
- package/dist/errors.d.ts +15 -1
- package/dist/errors.js +14 -5
- package/dist/library-select.d.ts +35 -17
- package/dist/library-select.js +79 -34
- package/dist/server.js +136 -38
- package/dist/store/track-metadata-plan.d.ts +82 -0
- package/dist/store/track-metadata-plan.js +236 -0
- package/dist/store/track-metadata.d.ts +22 -0
- package/dist/store/track-metadata.js +143 -0
- package/dist/store/write.d.ts +83 -0
- package/dist/store/write.js +35 -7
- package/dist/tools/search.js +11 -2
- package/dist/tools/write-track-metadata.d.ts +26 -0
- package/dist/tools/write-track-metadata.js +42 -0
- package/package.json +4 -4
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
6
|
import { discoverLibraries, defaultRoots, probeLibraries } from "./discovery.js";
|
|
7
7
|
import { libraryCandidates, libraryTag, sidecarDir } from "./paths.js";
|
|
8
|
-
import { LibraryArg, ambiguousLibrary,
|
|
8
|
+
import { LibraryArg, ambiguousLibrary, writeNeedsLibrary, findLibrary, namedWriteLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
|
|
9
9
|
import { hasHotJournal } from "./store/connections.js";
|
|
10
10
|
import { QueryProcess } from "./proc/query-client.js";
|
|
11
11
|
import { IndexManager } from "./store/index-manager.js";
|
|
@@ -18,6 +18,7 @@ import { runSql, RunSqlInput } from "./tools/sql.js";
|
|
|
18
18
|
import { listLibraries } from "./tools/libraries.js";
|
|
19
19
|
import { refreshIndex } from "./tools/refresh.js";
|
|
20
20
|
import { CreatePlaylistInput, runCreatePlaylist, AddTracksToPlaylistInput, runAddTracksToPlaylist, RemoveTracksFromPlaylistInput, runRemoveTracksFromPlaylist, ReorderPlaylistInput, runReorderPlaylist, } from "./tools/write-playlist.js";
|
|
21
|
+
import { UpdateTrackMetadataInput, runUpdateTrackMetadata } from "./tools/write-track-metadata.js";
|
|
21
22
|
import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
|
|
22
23
|
const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
|
|
23
24
|
/**
|
|
@@ -35,6 +36,12 @@ const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false
|
|
|
35
36
|
* additive told a client it need not ask before calling.
|
|
36
37
|
*/
|
|
37
38
|
const RW_DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false };
|
|
39
|
+
/**
|
|
40
|
+
* A write that overwrites or clears what is there -- so destructive -- but
|
|
41
|
+
* that a repeat of the same call leaves alone: a track already holding the
|
|
42
|
+
* requested values is not written again. update_track_metadata.
|
|
43
|
+
*/
|
|
44
|
+
const RW_OVERWRITE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true };
|
|
38
45
|
/**
|
|
39
46
|
* name/version reported to every client on initialize. Read from
|
|
40
47
|
* package.json rather than typed here, so the two cannot re-diverge the way
|
|
@@ -77,13 +84,28 @@ const LIBRARY_SELECTION_NOTE = "With more than one library connected, pass `libr
|
|
|
77
84
|
"either the ~/... form or the absolute one) to choose which one; the default is the " +
|
|
78
85
|
"supported library with the most tracks.";
|
|
79
86
|
/**
|
|
80
|
-
* Appended to the write tools only.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
87
|
+
* Appended to the write tools only. More than one library is a refusal there
|
|
88
|
+
* and a free choice on the read side, so the shared note above cannot carry it
|
|
89
|
+
* without being wrong for one of the two.
|
|
90
|
+
*/
|
|
91
|
+
const WRITE_LIBRARY_NOTE = " With two or more supported libraries connected, this tool refuses with ambiguous_library " +
|
|
92
|
+
"rather than picking one, and lists them; nothing is written. That is so whatever their track " +
|
|
93
|
+
"counts are -- the count never said which disk should change. Ask the user which one, then " +
|
|
94
|
+
"retry with `library` set; do not pick for them, since one of them may be the drive they " +
|
|
95
|
+
"perform from. A library copied onto another drive keeps its uuid, so naming a uuid that " +
|
|
96
|
+
"two connected libraries share is refused the same way -- pass the path.";
|
|
97
|
+
/**
|
|
98
|
+
* Not UNDO_SCOPE_NOTE: that one says Engine copies *playlist* changes between
|
|
99
|
+
* libraries, which was measured. For track tags, a fresh Engine launch was
|
|
100
|
+
* measured NOT copying a tag edit made on the USB library to the computer's
|
|
101
|
+
* library (spec §3.9); the other direction has not been measured for tags,
|
|
102
|
+
* so repeating the playlist claim here would state a guess as fact.
|
|
83
103
|
*/
|
|
84
|
-
const
|
|
85
|
-
"
|
|
86
|
-
"
|
|
104
|
+
const TRACK_UNDO_NOTE = "`undo` reverses this edit in ONE library: the one the result's `library` field names, and each " +
|
|
105
|
+
"undo step carries it. It restores the values of the fields that changed; it does not restore " +
|
|
106
|
+
"lastEditTime, which Engine DJ's own trigger sets on every edit. Keep the undo from the first " +
|
|
107
|
+
"response: repeating a call that already succeeded finds nothing to change and returns an empty " +
|
|
108
|
+
"undo. For work spread over several calls, replay their undos in REVERSE order. ";
|
|
87
109
|
function reply(value) {
|
|
88
110
|
return {
|
|
89
111
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
@@ -225,6 +247,13 @@ export async function createServer(opts = {}) {
|
|
|
225
247
|
rescanLibraries();
|
|
226
248
|
return findLibrary(knownList(), requested) ?? libraryNotFound(requested, knownList());
|
|
227
249
|
};
|
|
250
|
+
/** Resolves `library` the read way -- see selectLibrary -- then prepares it. */
|
|
251
|
+
const acquire = async (requested) => {
|
|
252
|
+
const lib = selectLibrary(requested);
|
|
253
|
+
if (isEngineError(lib))
|
|
254
|
+
return lib;
|
|
255
|
+
return prepare(lib);
|
|
256
|
+
};
|
|
228
257
|
/**
|
|
229
258
|
* `index_stale` is swallowed only when an index is genuinely attached:
|
|
230
259
|
* "the previous index is still in use" is a reason to answer anyway, but
|
|
@@ -235,10 +264,7 @@ export async function createServer(opts = {}) {
|
|
|
235
264
|
* string "no such table: side.track_derived", instead of `index_stale`
|
|
236
265
|
* with a `retry_after_ms` the model can act on.
|
|
237
266
|
*/
|
|
238
|
-
const
|
|
239
|
-
const lib = selectLibrary(requested);
|
|
240
|
-
if (isEngineError(lib))
|
|
241
|
-
return lib;
|
|
267
|
+
const prepare = async (lib) => {
|
|
242
268
|
const state = stateFor(lib);
|
|
243
269
|
const fresh = await state.mgr.ensureFresh();
|
|
244
270
|
if (!isEngineError(fresh))
|
|
@@ -248,8 +274,13 @@ export async function createServer(opts = {}) {
|
|
|
248
274
|
return fresh;
|
|
249
275
|
};
|
|
250
276
|
/**
|
|
251
|
-
*
|
|
252
|
-
*
|
|
277
|
+
* The library a write may land in, without touching its search index. A
|
|
278
|
+
* tool that addresses tracks by id needs no index, and building one right
|
|
279
|
+
* before a write only makes it stale the moment the write commits.
|
|
280
|
+
* acquireForWrite adds the index for the tools that resolve playlists.
|
|
281
|
+
*
|
|
282
|
+
* Selection differs from a read's in that it must name exactly one
|
|
283
|
+
* physical library, whether `library` was omitted or given.
|
|
253
284
|
*
|
|
254
285
|
* `pickDefaultLibrary` breaks a tie on root-scan order, which is
|
|
255
286
|
* deterministic and, for a read, fine -- libraries tie because one is a
|
|
@@ -262,31 +293,51 @@ export async function createServer(opts = {}) {
|
|
|
262
293
|
* USB drive both held 257 tracks, tied precisely because one was a copy of
|
|
263
294
|
* the other.
|
|
264
295
|
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
296
|
+
* A named library is taken as named -- unless the name is a uuid two
|
|
297
|
+
* connected libraries share. Copying an Engine Library folder onto another
|
|
298
|
+
* drive copies its uuid, and resolving that uuid to the first match would
|
|
299
|
+
* be the same order-of-discovery pick the omitted case refuses, reached by
|
|
300
|
+
* a caller who had no way to know it was ambiguous. namedWriteLibrary refuses it and asks
|
|
301
|
+
* for the path, which tells the copies apart.
|
|
302
|
+
*
|
|
303
|
+
* Rescans first, in both cases, because `knownList()` is a cache that
|
|
304
|
+
* deliberately keeps a library a later scan cannot see -- so a momentarily
|
|
305
|
+
* locked drive does not vanish from list_libraries. For a tie check that is
|
|
306
|
+
* wrong in the direction that bites: pull the USB drive and one library is
|
|
307
|
+
* left, but the cache still holds two, and the write is refused naming a
|
|
308
|
+
* drive that is no longer there. rescanLibraries() forgets a candidate
|
|
309
|
+
* whose path is gone, which is exactly the distinction wanted here, and it
|
|
310
|
+
* also lets a drive plugged in mid-session be seen at all -- including a
|
|
311
|
+
* copy that makes a named uuid ambiguous.
|
|
267
312
|
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
* direction that bites: pull the USB drive and one library is left, but the
|
|
272
|
-
* cache still holds two, and the write is refused naming a drive that is no
|
|
273
|
-
* longer there. rescanLibraries() forgets a candidate whose path is gone,
|
|
274
|
-
* which is exactly the distinction wanted here, and it also lets a drive
|
|
275
|
-
* plugged in mid-session be seen at all.
|
|
313
|
+
* A copy that has never once been readable since startup -- a hot journal,
|
|
314
|
+
* no permission -- is still not counted: its uuid was never read, so there
|
|
315
|
+
* is nothing to match. rescanLibraries keeps only libraries it has read.
|
|
276
316
|
*
|
|
277
|
-
* The cost is one
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
317
|
+
* The cost is one discovery scan per write: every candidate library under
|
|
318
|
+
* the roots is opened read-only and its header and track count read, the
|
|
319
|
+
* same scan list_libraries runs. That is small against a write that is
|
|
320
|
+
* about to copy the entire database for its pre-write snapshot. Reads are
|
|
321
|
+
* left alone: they run far more often and a stale pick between two copies
|
|
322
|
+
* is not worth a scan apiece.
|
|
281
323
|
*/
|
|
324
|
+
const selectForWrite = (requested) => {
|
|
325
|
+
rescanLibraries();
|
|
326
|
+
if (requested !== undefined)
|
|
327
|
+
return namedWriteLibrary(knownList(), requested);
|
|
328
|
+
const choices = writeNeedsLibrary(knownList());
|
|
329
|
+
if (choices.length > 0)
|
|
330
|
+
return ambiguousLibrary(choices);
|
|
331
|
+
return selectLibrary();
|
|
332
|
+
};
|
|
282
333
|
const acquireForWrite = async (requested) => {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return
|
|
334
|
+
const lib = selectForWrite(requested);
|
|
335
|
+
if (isEngineError(lib))
|
|
336
|
+
return lib;
|
|
337
|
+
// The library just resolved, not `requested` again by the read rules:
|
|
338
|
+
// today both give the same answer, but only this one was checked for a
|
|
339
|
+
// uuid shared between copies.
|
|
340
|
+
return prepare(lib);
|
|
290
341
|
};
|
|
291
342
|
/**
|
|
292
343
|
* Shared by the engine://libraries resource and the list_libraries tool so
|
|
@@ -527,7 +578,7 @@ export async function createServer(opts = {}) {
|
|
|
527
578
|
"library is unchanged and \"committed_unverified\" when the write may have gone " +
|
|
528
579
|
"through but could not be verified. track_ids may be empty (an empty playlist); a " +
|
|
529
580
|
"track id may appear at most once. " +
|
|
530
|
-
LIBRARY_SELECTION_NOTE +
|
|
581
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
|
|
531
582
|
inputSchema: { ...CreatePlaylistInput.shape, library: LibraryArg },
|
|
532
583
|
annotations: RW,
|
|
533
584
|
}, async (args) => {
|
|
@@ -561,7 +612,7 @@ export async function createServer(opts = {}) {
|
|
|
561
612
|
"Engine DJ has recorded since -- not just this one edit. backup_path is only a " +
|
|
562
613
|
"last-resort recovery route for a damaged library, never an undo. " +
|
|
563
614
|
UNDO_SCOPE_NOTE +
|
|
564
|
-
LIBRARY_SELECTION_NOTE +
|
|
615
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
|
|
565
616
|
inputSchema: { ...AddTracksToPlaylistInput.shape, library: LibraryArg },
|
|
566
617
|
annotations: RW,
|
|
567
618
|
}, async (args) => {
|
|
@@ -600,7 +651,7 @@ export async function createServer(opts = {}) {
|
|
|
600
651
|
"backup_path, which reverts the WHOLE library to before this session's first write, " +
|
|
601
652
|
"discarding everything Engine DJ has recorded since -- not just this edit. " +
|
|
602
653
|
UNDO_SCOPE_NOTE +
|
|
603
|
-
LIBRARY_SELECTION_NOTE +
|
|
654
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
|
|
604
655
|
inputSchema: { ...RemoveTracksFromPlaylistInput.shape, library: LibraryArg },
|
|
605
656
|
annotations: RW_DESTRUCTIVE,
|
|
606
657
|
}, async (args) => {
|
|
@@ -627,7 +678,7 @@ export async function createServer(opts = {}) {
|
|
|
627
678
|
"WHOLE library to before this session's first write, discarding everything Engine DJ " +
|
|
628
679
|
"has recorded since -- not just this reorder. " +
|
|
629
680
|
UNDO_SCOPE_NOTE +
|
|
630
|
-
LIBRARY_SELECTION_NOTE +
|
|
681
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
|
|
631
682
|
inputSchema: { ...ReorderPlaylistInput.shape, library: LibraryArg },
|
|
632
683
|
annotations: RW_DESTRUCTIVE,
|
|
633
684
|
}, async (args) => {
|
|
@@ -636,6 +687,41 @@ export async function createServer(opts = {}) {
|
|
|
636
687
|
return reply(state);
|
|
637
688
|
return reply(await runReorderPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
|
|
638
689
|
});
|
|
690
|
+
server.registerTool("update_track_metadata", {
|
|
691
|
+
title: "Edit track tags",
|
|
692
|
+
description: "Change genre, comment, label, year or rating on tracks in this Engine DJ library -- the " +
|
|
693
|
+
"values Engine shows in its columns. This WRITES to the library's database, not to the audio " +
|
|
694
|
+
"files' tags. Each entry names a track by id (from search_tracks or get_tracks) and only the " +
|
|
695
|
+
'fields to change; "" clears a text field; rating_stars is 0-5 (Engine stores 0-100). Up to ' +
|
|
696
|
+
"200 tracks per call, all or nothing. A track already holding the requested values is left " +
|
|
697
|
+
"alone and counted in `unchanged`; `changed` lists which fields changed on which tracks. " +
|
|
698
|
+
"Each refusal names every offending track of its kind -- invalid_argument, unknown_track, " +
|
|
699
|
+
"track_not_editable (the track cannot be edited without harm -- say so to the user and leave it; " +
|
|
700
|
+
"do not search for it again), stale_value -- and kinds are reported one at a time, in that order: " +
|
|
701
|
+
"fix the one reported first and retry to see the next, if any -- except stale_value, which is not " +
|
|
702
|
+
"something to just retry: see below. stale_value means the track " +
|
|
703
|
+
"changed after the values in `expect` were read; tell the user which tracks and fields changed. " +
|
|
704
|
+
"Do NOT rebuild `expect` from a fresh read to force the write -- that would silently overwrite " +
|
|
705
|
+
"an edit the DJ made since, without their consent. " +
|
|
706
|
+
"Search results may keep showing the old values while Engine DJ holds the library open; " +
|
|
707
|
+
"refresh_index cannot help until Engine lets go. " +
|
|
708
|
+
TRACK_UNDO_NOTE +
|
|
709
|
+
LIBRARY_SELECTION_NOTE + WRITE_LIBRARY_NOTE,
|
|
710
|
+
inputSchema: { ...UpdateTrackMetadataInput.shape, library: LibraryArg },
|
|
711
|
+
annotations: RW_OVERWRITE,
|
|
712
|
+
}, async (args) => {
|
|
713
|
+
const lib = selectForWrite(args.library);
|
|
714
|
+
if (isEngineError(lib))
|
|
715
|
+
return reply(lib);
|
|
716
|
+
// ensureFresh is what refuses an unsupported schema for every other
|
|
717
|
+
// tool; this path skips it, so it must refuse here.
|
|
718
|
+
if (!lib.supported) {
|
|
719
|
+
return reply(err("unsupported_schema", `Schema ${lib.schema.join(".")} is not supported`, {
|
|
720
|
+
detail: "Supported versions are 3.0.0, 3.0.1 and 3.0.2",
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
return reply(await runUpdateTrackMetadata(lib.path, lib.uuid, args, backupDirFor()));
|
|
724
|
+
});
|
|
639
725
|
}
|
|
640
726
|
/**
|
|
641
727
|
* There was previously no way to shut this down at all: createServer
|
|
@@ -704,6 +790,11 @@ other.
|
|
|
704
790
|
\`bpmAnalyzed\` to within 0.68, and Engine's own interface displays 102 for
|
|
705
791
|
the track stored as 102). \`side.track_derived.tempo\` holds the resolved
|
|
706
792
|
value and is indexed.
|
|
793
|
+
- \`Track.rating\` is 0, 20, 40, 60, 80 or 100 -- one step per star, measured
|
|
794
|
+
against Engine's own display. The \`rating\` field returns it as stored and
|
|
795
|
+
\`rating_stars\` returns 0..5; the \`rating\` **filter** takes stars. A value
|
|
796
|
+
from other software (ID3's POPM is 0..255) is kept exactly in \`rating\` and
|
|
797
|
+
rounded to the nearest star in \`rating_stars\`.
|
|
707
798
|
- \`Track.path\` is relative to the \`Engine Library\` folder and usually
|
|
708
799
|
contains \`..\`. The SQL function \`abs_path(path)\` resolves it against
|
|
709
800
|
this library's location; the home prefix comes back folded to \`~\`.
|
|
@@ -719,6 +810,13 @@ other.
|
|
|
719
810
|
unchanged. To compare names regardless of case in any script, use
|
|
720
811
|
\`fold(text)\` -- one Unicode normalization form, lower-cased by Unicode
|
|
721
812
|
rules. It runs per row, like every function here.
|
|
813
|
+
- \`Track\` carries two Engine triggers. \`trigger_after_update_only_Track_timestamp\`
|
|
814
|
+
sets \`lastEditTime\` (epoch seconds) whenever genre, comment, label, year,
|
|
815
|
+
rating or a dozen other columns are updated -- even to the same value.
|
|
816
|
+
\`trigger_after_update_Track_fix_origin\` fires on ANY update and rewrites an
|
|
817
|
+
empty \`(originDatabaseUuid, originTrackId)\` to this library's uuid and the
|
|
818
|
+
track's own id; in SQLite \`'' = 0\` is false, so a TEXT '' originTrackId does
|
|
819
|
+
not count as empty.
|
|
722
820
|
- A track's natural key across drives is \`(originDatabaseUuid, originTrackId)\`.
|
|
723
821
|
- \`PerformanceData\`'s blob columns are binary and cannot be read with SQL.
|
|
724
822
|
Engine writes \`quickCues\`, \`loops\`, \`beatData\` and
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type EngineError } from "../errors.js";
|
|
2
|
+
export type FieldName = "genre" | "comment" | "label" | "year" | "rating";
|
|
3
|
+
export declare const TEXT_FIELDS: readonly ["genre", "comment", "label"];
|
|
4
|
+
export type TextField = (typeof TEXT_FIELDS)[number];
|
|
5
|
+
export interface TrackExpect {
|
|
6
|
+
genre?: string;
|
|
7
|
+
comment?: string;
|
|
8
|
+
label?: string;
|
|
9
|
+
year?: number;
|
|
10
|
+
rating_raw?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface TrackUpdate {
|
|
13
|
+
id: number;
|
|
14
|
+
genre?: string;
|
|
15
|
+
comment?: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
year?: number;
|
|
18
|
+
rating_stars?: number;
|
|
19
|
+
rating_raw?: number;
|
|
20
|
+
expect?: TrackExpect;
|
|
21
|
+
}
|
|
22
|
+
export declare const MAX_UPDATES = 200;
|
|
23
|
+
export declare const MAX_TEXT = 1000;
|
|
24
|
+
/** The columns an update writes, in a fixed order. Both rating inputs write `rating`. */
|
|
25
|
+
export declare function writtenFields(u: TrackUpdate): FieldName[];
|
|
26
|
+
/**
|
|
27
|
+
* A refusal names every offender, not the first (spec §7.3): one vanished
|
|
28
|
+
* track must not cost two hundred round trips. Capped so the message stays
|
|
29
|
+
* readable.
|
|
30
|
+
*/
|
|
31
|
+
export declare function listProblems(problems: string[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* Rules that need no database. Ranges check NEW values only: a field written
|
|
34
|
+
* together with `expect` on the same field is a restore -- typically a replayed
|
|
35
|
+
* undo -- and must be able to put back whatever was there, however odd
|
|
36
|
+
* (spec §5.3). rating_raw is the exception that proves the rule: it exists only
|
|
37
|
+
* for restoring, so it always needs expect.rating_raw, and its 0-255 bound (the
|
|
38
|
+
* ID3 scale) always applies.
|
|
39
|
+
*/
|
|
40
|
+
export declare function validateUpdates(updates: TrackUpdate[]): EngineError | undefined;
|
|
41
|
+
export type StoredValue = string | number | null;
|
|
42
|
+
/** One track as read from the library, already classified (see src/store/track-metadata.ts). */
|
|
43
|
+
export interface CurrentRow {
|
|
44
|
+
id: number;
|
|
45
|
+
genre: string | null;
|
|
46
|
+
comment: string | null;
|
|
47
|
+
label: string | null;
|
|
48
|
+
year: number | null;
|
|
49
|
+
rating: number | null;
|
|
50
|
+
/** Columns whose stored value this tool could not put back (spec §5.3). */
|
|
51
|
+
inexpressible: FieldName[];
|
|
52
|
+
/** The fix-origin trigger's own WHEN, evaluated by SQLite (spec §3.3). */
|
|
53
|
+
originEmpty: boolean;
|
|
54
|
+
}
|
|
55
|
+
export interface RowWrite {
|
|
56
|
+
id: number;
|
|
57
|
+
set: Partial<Record<FieldName, StoredValue>>;
|
|
58
|
+
fields: FieldName[];
|
|
59
|
+
}
|
|
60
|
+
export interface Plan {
|
|
61
|
+
writes: RowWrite[];
|
|
62
|
+
unchanged: number[];
|
|
63
|
+
undo: TrackUpdate[];
|
|
64
|
+
}
|
|
65
|
+
/** The value a field will hold once written. `""` is NULL; stars are Engine's 0-100. */
|
|
66
|
+
export declare function targetOf(u: TrackUpdate, f: FieldName): StoredValue;
|
|
67
|
+
/**
|
|
68
|
+
* Spec §5.1: NULL and '' are one empty for text, NULL and 0 for numbers.
|
|
69
|
+
* Otherwise exact -- no Unicode folding, because hiding a real difference is
|
|
70
|
+
* worse than reporting a confusing one (describeValue spells those out).
|
|
71
|
+
* Done in JS: in SQL, NULL = '' is neither true nor false.
|
|
72
|
+
*/
|
|
73
|
+
export declare function sameStored(f: FieldName, a: StoredValue, b: StoredValue): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Decide, for rows already read, what each update writes. Order matters and
|
|
76
|
+
* follows spec §5.2 and §6: a row already at its target is unchanged before
|
|
77
|
+
* anything else is asked of it -- expect included, since expect guards writes
|
|
78
|
+
* and this row is not written. Refusals are collected across all rows and
|
|
79
|
+
* reported by class: unknown ids first, then tracks that cannot be edited,
|
|
80
|
+
* then stale expectations.
|
|
81
|
+
*/
|
|
82
|
+
export declare function planUpdates(updates: TrackUpdate[], rows: ReadonlyMap<number, CurrentRow>): Plan | EngineError;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// src/store/track-metadata-plan.ts
|
|
2
|
+
//
|
|
3
|
+
// The pure half of update_track_metadata (spec docs/superpowers/specs/
|
|
4
|
+
// 2026-09-14-track-metadata-design.md). No database access, so every rule can
|
|
5
|
+
// be tested against an exact row.
|
|
6
|
+
import { err } from "../errors.js";
|
|
7
|
+
import { NOT_COMMITTED } from "./write.js";
|
|
8
|
+
export const TEXT_FIELDS = ["genre", "comment", "label"];
|
|
9
|
+
export const MAX_UPDATES = 200;
|
|
10
|
+
export const MAX_TEXT = 1000;
|
|
11
|
+
const LIST_LIMIT = 20;
|
|
12
|
+
/** The columns an update writes, in a fixed order. Both rating inputs write `rating`. */
|
|
13
|
+
export function writtenFields(u) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const f of TEXT_FIELDS)
|
|
16
|
+
if (u[f] !== undefined)
|
|
17
|
+
out.push(f);
|
|
18
|
+
if (u.year !== undefined)
|
|
19
|
+
out.push("year");
|
|
20
|
+
if (u.rating_stars !== undefined || u.rating_raw !== undefined)
|
|
21
|
+
out.push("rating");
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A refusal names every offender, not the first (spec §7.3): one vanished
|
|
26
|
+
* track must not cost two hundred round trips. Capped so the message stays
|
|
27
|
+
* readable.
|
|
28
|
+
*/
|
|
29
|
+
export function listProblems(problems) {
|
|
30
|
+
const shown = problems.slice(0, LIST_LIMIT).join("; ");
|
|
31
|
+
const rest = problems.length - LIST_LIMIT;
|
|
32
|
+
return rest > 0 ? `${shown}; and ${rest} more` : shown;
|
|
33
|
+
}
|
|
34
|
+
const isWhole = (n, lo, hi) => Number.isInteger(n) && n >= lo && n <= hi;
|
|
35
|
+
/**
|
|
36
|
+
* Rules that need no database. Ranges check NEW values only: a field written
|
|
37
|
+
* together with `expect` on the same field is a restore -- typically a replayed
|
|
38
|
+
* undo -- and must be able to put back whatever was there, however odd
|
|
39
|
+
* (spec §5.3). rating_raw is the exception that proves the rule: it exists only
|
|
40
|
+
* for restoring, so it always needs expect.rating_raw, and its 0-255 bound (the
|
|
41
|
+
* ID3 scale) always applies.
|
|
42
|
+
*/
|
|
43
|
+
export function validateUpdates(updates) {
|
|
44
|
+
if (updates.length === 0) {
|
|
45
|
+
return err("invalid_argument", "updates is empty; name at least one track to change. Nothing was written.", {
|
|
46
|
+
detail: NOT_COMMITTED,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const problems = [];
|
|
50
|
+
if (updates.length > MAX_UPDATES)
|
|
51
|
+
problems.push(`${updates.length} updates, more than the ${MAX_UPDATES} allowed per call`);
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const repeated = new Set();
|
|
54
|
+
for (const u of updates) {
|
|
55
|
+
if (seen.has(u.id))
|
|
56
|
+
repeated.add(u.id);
|
|
57
|
+
seen.add(u.id);
|
|
58
|
+
}
|
|
59
|
+
if (repeated.size > 0) {
|
|
60
|
+
problems.push(`track id${repeated.size > 1 ? "s" : ""} ${[...repeated].join(", ")} named more than once`);
|
|
61
|
+
}
|
|
62
|
+
for (const u of updates) {
|
|
63
|
+
const at = `track ${u.id}`;
|
|
64
|
+
const fields = writtenFields(u);
|
|
65
|
+
const expect = u.expect ?? {};
|
|
66
|
+
if (fields.length === 0)
|
|
67
|
+
problems.push(`${at}: no field to change`);
|
|
68
|
+
if (u.rating_stars !== undefined && u.rating_raw !== undefined) {
|
|
69
|
+
problems.push(`${at}: rating_stars and rating_raw together`);
|
|
70
|
+
}
|
|
71
|
+
if (u.rating_raw !== undefined && expect.rating_raw === undefined) {
|
|
72
|
+
problems.push(`${at}: rating_raw restores an exact value and needs expect.rating_raw; to set a rating use rating_stars`);
|
|
73
|
+
}
|
|
74
|
+
for (const key of Object.keys(expect)) {
|
|
75
|
+
if (expect[key] === undefined)
|
|
76
|
+
continue;
|
|
77
|
+
const writes = key === "rating_raw" ? fields.includes("rating") : fields.includes(key);
|
|
78
|
+
if (!writes)
|
|
79
|
+
problems.push(`${at}: expect.${key} names a field this update does not change`);
|
|
80
|
+
}
|
|
81
|
+
if (u.rating_stars !== undefined && !isWhole(u.rating_stars, 0, 5)) {
|
|
82
|
+
problems.push(`${at}: rating_stars must be a whole number 0-5`);
|
|
83
|
+
}
|
|
84
|
+
if (u.rating_raw !== undefined && !isWhole(u.rating_raw, 0, 255)) {
|
|
85
|
+
problems.push(`${at}: rating_raw must be a whole number 0-255`);
|
|
86
|
+
}
|
|
87
|
+
if (u.year !== undefined) {
|
|
88
|
+
if (!Number.isInteger(u.year))
|
|
89
|
+
problems.push(`${at}: year must be a whole number`);
|
|
90
|
+
else if (expect.year === undefined && u.year !== 0 && !isWhole(u.year, 1000, 2200)) {
|
|
91
|
+
problems.push(`${at}: year must be 0 (unknown) or 1000-2200`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const f of TEXT_FIELDS) {
|
|
95
|
+
const v = u[f];
|
|
96
|
+
// A lone surrogate passes every other check here, reaches SQLite, and
|
|
97
|
+
// comes back mangled -- caught downstream as library_unreadable
|
|
98
|
+
// "did not read back as written" instead of the invalid_argument this
|
|
99
|
+
// is. Checked on both the new value and its expect counterpart, and
|
|
100
|
+
// regardless of whether this is a restore: MAX_TEXT is waived for a
|
|
101
|
+
// restore (spec §5.3), but a value that is not valid Unicode text at
|
|
102
|
+
// all is never something this tool could have written or read back.
|
|
103
|
+
if (v !== undefined && !v.isWellFormed())
|
|
104
|
+
problems.push(`${at}: ${f} is not valid Unicode text`);
|
|
105
|
+
const ev = expect[f];
|
|
106
|
+
if (ev !== undefined && !ev.isWellFormed())
|
|
107
|
+
problems.push(`${at}: expect.${f} is not valid Unicode text`);
|
|
108
|
+
if (v !== undefined && expect[f] === undefined && v.length > MAX_TEXT) {
|
|
109
|
+
problems.push(`${at}: ${f} is longer than ${MAX_TEXT} characters`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (problems.length === 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
return err("invalid_argument", `${problems.length} problem${problems.length > 1 ? "s" : ""} with updates, nothing was written: ${listProblems(problems)}`, { detail: NOT_COMMITTED });
|
|
116
|
+
}
|
|
117
|
+
/** The value a field will hold once written. `""` is NULL; stars are Engine's 0-100. */
|
|
118
|
+
export function targetOf(u, f) {
|
|
119
|
+
if (f === "rating")
|
|
120
|
+
return u.rating_raw !== undefined ? u.rating_raw : u.rating_stars * 20;
|
|
121
|
+
if (f === "year")
|
|
122
|
+
return u.year;
|
|
123
|
+
const v = u[f];
|
|
124
|
+
return v === "" ? null : v;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Spec §5.1: NULL and '' are one empty for text, NULL and 0 for numbers.
|
|
128
|
+
* Otherwise exact -- no Unicode folding, because hiding a real difference is
|
|
129
|
+
* worse than reporting a confusing one (describeValue spells those out).
|
|
130
|
+
* Done in JS: in SQL, NULL = '' is neither true nor false.
|
|
131
|
+
*/
|
|
132
|
+
export function sameStored(f, a, b) {
|
|
133
|
+
if (f === "year" || f === "rating")
|
|
134
|
+
return (a ?? 0) === (b ?? 0);
|
|
135
|
+
return (a ?? "") === (b ?? "");
|
|
136
|
+
}
|
|
137
|
+
const codePoints = (s) => [...s].map((c) => "U+" + c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")).join(" ");
|
|
138
|
+
function describeMismatch(m) {
|
|
139
|
+
const show = (v) => (v === null ? "empty" : JSON.stringify(v));
|
|
140
|
+
let line = `track ${m.id} ${m.field}: expected ${show(m.expected)}, found ${show(m.actual)}`;
|
|
141
|
+
if (typeof m.expected === "string" && typeof m.actual === "string" &&
|
|
142
|
+
m.expected !== m.actual && m.expected.normalize("NFC") === m.actual.normalize("NFC")) {
|
|
143
|
+
line += ` (same text in a different Unicode form: expected ${codePoints(m.expected)}, found ${codePoints(m.actual)})`;
|
|
144
|
+
}
|
|
145
|
+
return line;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Decide, for rows already read, what each update writes. Order matters and
|
|
149
|
+
* follows spec §5.2 and §6: a row already at its target is unchanged before
|
|
150
|
+
* anything else is asked of it -- expect included, since expect guards writes
|
|
151
|
+
* and this row is not written. Refusals are collected across all rows and
|
|
152
|
+
* reported by class: unknown ids first, then tracks that cannot be edited,
|
|
153
|
+
* then stale expectations.
|
|
154
|
+
*/
|
|
155
|
+
export function planUpdates(updates, rows) {
|
|
156
|
+
const plan = { writes: [], unchanged: [], undo: [] };
|
|
157
|
+
const unknown = [];
|
|
158
|
+
const notEditable = [];
|
|
159
|
+
const mismatches = [];
|
|
160
|
+
for (const u of updates) {
|
|
161
|
+
const current = rows.get(u.id);
|
|
162
|
+
if (!current) {
|
|
163
|
+
unknown.push(u.id);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// An inexpressible field is read as null, so comparing it would call a stored
|
|
167
|
+
// 999 "already 0 stars". It always counts as different, which routes it to
|
|
168
|
+
// the track_not_editable refusal below instead of a false "unchanged".
|
|
169
|
+
const differs = writtenFields(u).filter((f) => current.inexpressible.includes(f) || !sameStored(f, current[f], targetOf(u, f)));
|
|
170
|
+
if (differs.length === 0) {
|
|
171
|
+
plan.unchanged.push(u.id);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (current.originEmpty) {
|
|
175
|
+
notEditable.push(`track ${u.id}: its origin is empty, and Engine's own trigger rewrites an empty origin on any ` +
|
|
176
|
+
`update, which would detach it from playlist entries on other drives; edit it in Engine DJ instead`);
|
|
177
|
+
}
|
|
178
|
+
for (const f of differs) {
|
|
179
|
+
if (current.inexpressible.includes(f)) {
|
|
180
|
+
notEditable.push(`track ${u.id}: ${f} holds a stored value this tool could not put back`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const expect = u.expect ?? {};
|
|
184
|
+
for (const key of Object.keys(expect)) {
|
|
185
|
+
const expected = expect[key];
|
|
186
|
+
if (expected === undefined)
|
|
187
|
+
continue;
|
|
188
|
+
const field = key === "rating_raw" ? "rating" : key;
|
|
189
|
+
// Spec §5.2 (field-level): expect guards writes, so an expect on a
|
|
190
|
+
// field this row will not write is skipped. Otherwise undoing a
|
|
191
|
+
// "genre + comment" edit would refuse the whole row because genre was
|
|
192
|
+
// already put back by hand, though comment still needs restoring.
|
|
193
|
+
if (!differs.includes(field))
|
|
194
|
+
continue;
|
|
195
|
+
const want = field === "year" || field === "rating" ? expected : expected || null;
|
|
196
|
+
if (!sameStored(field, current[field], want)) {
|
|
197
|
+
mismatches.push({ id: u.id, field: key, expected, actual: current[field] });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const write = { id: u.id, set: {}, fields: differs };
|
|
201
|
+
const back = { id: u.id, expect: {} };
|
|
202
|
+
for (const f of differs) {
|
|
203
|
+
const now = targetOf(u, f);
|
|
204
|
+
write.set[f] = now;
|
|
205
|
+
const was = current[f];
|
|
206
|
+
if (f === "rating") {
|
|
207
|
+
back.rating_raw = was ?? 0;
|
|
208
|
+
back.expect.rating_raw = now ?? 0;
|
|
209
|
+
}
|
|
210
|
+
else if (f === "year") {
|
|
211
|
+
back.year = was ?? 0;
|
|
212
|
+
back.expect.year = now ?? 0;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
back[f] = was ?? "";
|
|
216
|
+
back.expect[f] = now ?? "";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
plan.writes.push(write);
|
|
220
|
+
plan.undo.push(back);
|
|
221
|
+
}
|
|
222
|
+
if (unknown.length > 0) {
|
|
223
|
+
return err("unknown_track", `${unknown.length} track id${unknown.length > 1 ? "s are" : " is"} not in this library, nothing was written: ` +
|
|
224
|
+
listProblems(unknown.map(String)), { detail: NOT_COMMITTED });
|
|
225
|
+
}
|
|
226
|
+
if (notEditable.length > 0) {
|
|
227
|
+
return err("track_not_editable", `${notEditable.length} edit${notEditable.length > 1 ? "s" : ""} cannot be made, nothing was written: ${listProblems(notEditable)}`, { detail: NOT_COMMITTED });
|
|
228
|
+
}
|
|
229
|
+
if (mismatches.length > 0) {
|
|
230
|
+
return err("stale_value", `${mismatches.length} expected value${mismatches.length > 1 ? "s" : ""} no longer match, nothing was written. ` +
|
|
231
|
+
`The track changed after the values in expect were read -- tell the user which tracks and fields changed. ` +
|
|
232
|
+
`Do NOT rebuild expect from a fresh read to force the write without the user's consent: ` +
|
|
233
|
+
`${listProblems(mismatches.map(describeMismatch))}`, { detail: NOT_COMMITTED, mismatches: mismatches.slice(0, LIST_LIMIT) });
|
|
234
|
+
}
|
|
235
|
+
return plan;
|
|
236
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type EngineError } from "../errors.js";
|
|
2
|
+
import { type LibraryRef, type UndoStep } from "./write.js";
|
|
3
|
+
import { type FieldName, type TrackUpdate } from "./track-metadata-plan.js";
|
|
4
|
+
export interface TrackMetadataResult {
|
|
5
|
+
updated: number;
|
|
6
|
+
unchanged: number;
|
|
7
|
+
changed: {
|
|
8
|
+
id: number;
|
|
9
|
+
fields: FieldName[];
|
|
10
|
+
}[];
|
|
11
|
+
undo: UndoStep[];
|
|
12
|
+
undo_complete: true;
|
|
13
|
+
}
|
|
14
|
+
export declare function updateTrackMetadata(mdbPath: string, uuid: string, input: {
|
|
15
|
+
updates: TrackUpdate[];
|
|
16
|
+
}, opts: {
|
|
17
|
+
backupDir: string;
|
|
18
|
+
beforeLock?: () => void;
|
|
19
|
+
}): Promise<(TrackMetadataResult & {
|
|
20
|
+
library: LibraryRef;
|
|
21
|
+
backup_path?: string;
|
|
22
|
+
}) | EngineError>;
|