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/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, defaultLibraryTies, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
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. A tie in the default rule is a refusal
81
- * there and a free choice on the read side, so the shared note above cannot
82
- * carry it without being wrong for one of the two.
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 WRITE_LIBRARY_TIE_NOTE = " If two supported libraries hold the same most tracks -- what a USB drive and its copy on " +
85
- "the computer produce -- this tool refuses with ambiguous_library rather than picking one, " +
86
- "and lists both; nothing is written. Ask the user which one, then retry with `library` set -- do not pick for them, since one of the two may be the drive they perform from.";
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 acquire = async (requested) => {
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
- * `acquire` for the write tools: identical, except that an omitted
252
- * `library` must resolve to exactly one candidate.
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
- * Only the omitted case refuses. A caller who named a library gets it, tie
266
- * or no tie -- the ambiguity being refused here is the server's, not theirs.
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
- * Rescans first, because `knownList()` is a cache that deliberately keeps a
269
- * library a later scan cannot see -- so a momentarily locked drive does not
270
- * vanish from list_libraries. For a tie check that is wrong in the
271
- * direction that bites: pull the USB drive and one library is left, but the
272
- * cache still holds two, and the write is refused naming a drive that is no
273
- * longer there. rescanLibraries() forgets a candidate whose path is gone,
274
- * which is exactly the distinction wanted here, and it also lets a drive
275
- * plugged in mid-session be seen at all.
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 filesystem probe per write, against a write that is about
278
- * to copy the entire database for its pre-write snapshot. Reads are left
279
- * alone: they run far more often and a stale pick between two copies is not
280
- * worth a probe apiece.
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
- if (requested === undefined) {
284
- rescanLibraries();
285
- const tied = defaultLibraryTies(knownList());
286
- if (tied.length > 1)
287
- return ambiguousLibrary(tied);
288
- }
289
- return acquire(requested);
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 + WRITE_LIBRARY_TIE_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 + WRITE_LIBRARY_TIE_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 + WRITE_LIBRARY_TIE_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 + WRITE_LIBRARY_TIE_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>;