engine-dj-mcp 0.11.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -28
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +6 -0
- package/dist/playlists.d.ts +19 -5
- package/dist/playlists.js +32 -14
- package/dist/server.js +115 -2
- package/dist/store/backup.d.ts +24 -0
- package/dist/store/backup.js +54 -15
- package/dist/store/write.d.ts +169 -0
- package/dist/store/write.js +930 -101
- package/dist/tools/write-playlist.d.ts +38 -1
- package/dist/tools/write-playlist.js +103 -1
- package/package.json +2 -2
package/dist/store/write.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// verify, commit, check, close.
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
13
13
|
import { DatabaseSync } from "node:sqlite";
|
|
14
|
-
import { err, libraryNeedsRecovery } from "../errors.js";
|
|
14
|
+
import { err, isEngineError, libraryNeedsRecovery } from "../errors.js";
|
|
15
15
|
import { snapshotLibrary } from "./backup.js";
|
|
16
16
|
import { hasHotJournal } from "./connections.js";
|
|
17
17
|
/**
|
|
@@ -51,7 +51,8 @@ export function resetSessionSnapshots() {
|
|
|
51
51
|
/**
|
|
52
52
|
* The snapshot for this library, taken once per process.
|
|
53
53
|
*
|
|
54
|
-
* Called before the write connection is even opened (see
|
|
54
|
+
* Called before the write connection is even opened (see
|
|
55
|
+
* withWriteTransaction, which every write op in this module goes through),
|
|
55
56
|
* so it never holds SQLite's RESERVED lock and never blocks a write Engine
|
|
56
57
|
* DJ or a second concurrent call in this process is trying to make at the
|
|
57
58
|
* same moment. The hot-journal check and the read-only pre-check have
|
|
@@ -101,6 +102,21 @@ function resolveOrigins(db, trackIds) {
|
|
|
101
102
|
}
|
|
102
103
|
return refs;
|
|
103
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* One origin pair as a Map key, joined on NUL because a databaseUuid is free
|
|
107
|
+
* text: any printable separator is a character some uuid could itself
|
|
108
|
+
* contain, and a key collision here would refuse a track as duplicate_track
|
|
109
|
+
* when it is not in the playlist at all. `uuid` must never be null: template
|
|
110
|
+
* coercion turns `null` into the four characters "null", which would then
|
|
111
|
+
* collide with an entry whose databaseUuid genuinely is that literal
|
|
112
|
+
* string. A resolved track's uuid is never null -- resolveOrigins already
|
|
113
|
+
* refused one that is -- so the only null this module ever sees is a
|
|
114
|
+
* PlaylistEntity row's own `databaseUuid`, which the caller below skips
|
|
115
|
+
* rather than passing in here.
|
|
116
|
+
*/
|
|
117
|
+
function pairKey(uuid, trackId) {
|
|
118
|
+
return `${uuid}\u0000${trackId}`;
|
|
119
|
+
}
|
|
104
120
|
/**
|
|
105
121
|
* Read the chain back starting from a row we know is the head, because we
|
|
106
122
|
* inserted it first. Re-deriving the head as "the row nothing points at"
|
|
@@ -131,6 +147,114 @@ export function walkFrom(db, listId, headId) {
|
|
|
131
147
|
}
|
|
132
148
|
return out;
|
|
133
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* One playlist's `PlaylistEntity` rows, in the shape `checkChain` wants: an
|
|
152
|
+
* entry id and the id it links to (0 for "links to nothing").
|
|
153
|
+
*
|
|
154
|
+
* Shared by every op that edits an *existing* playlist's entries --
|
|
155
|
+
* createPlaylist never calls this, because it builds a chain from nothing
|
|
156
|
+
* rather than reading one back.
|
|
157
|
+
*/
|
|
158
|
+
export function readChain(db, listId) {
|
|
159
|
+
return db
|
|
160
|
+
.prepare("SELECT id, nextEntityId AS next FROM PlaylistEntity WHERE listId = ?")
|
|
161
|
+
.all(listId);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Whether one playlist's entry chain is sound enough to edit.
|
|
165
|
+
*
|
|
166
|
+
* Deliberately not `orderByChain` from src/playlists.ts. That function's
|
|
167
|
+
* contract is that it returns every node it was given, degrading a damaged
|
|
168
|
+
* chain to a warning, because a reader that silently returned 30 of 43
|
|
169
|
+
* entries would be worse than one that guesses an order and says so. An edit
|
|
170
|
+
* needs the opposite: a yes or no.
|
|
171
|
+
*
|
|
172
|
+
* Four conditions, and each is needed because different breakages trip
|
|
173
|
+
* different ones. Checking only that the walk covered every row is the trap:
|
|
174
|
+
* a list severed into two runs has two heads, and walking from both covers
|
|
175
|
+
* everything -- measured, on a chain broken on purpose, as "5 of 5" while the
|
|
176
|
+
* walk from the real head reached 2. Checking coverage without also checking
|
|
177
|
+
* that the walk *ended* is a second, subtler version of the same trap: a row
|
|
178
|
+
* whose next points back into an already-linked interior row (two
|
|
179
|
+
* predecessors, no row pointing at 0) can visit every row and still never
|
|
180
|
+
* terminate -- the walk stops only because it revisits a row it has already
|
|
181
|
+
* seen, not because it reached the end.
|
|
182
|
+
*/
|
|
183
|
+
export function checkChain(rows) {
|
|
184
|
+
if (rows.length === 0)
|
|
185
|
+
return { ok: true, order: [] };
|
|
186
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
187
|
+
for (const r of rows) {
|
|
188
|
+
if (r.next !== 0 && !byId.has(r.next)) {
|
|
189
|
+
return { ok: false, reason: `entry ${r.id} links to ${r.next}, which is not in this playlist`, order: [] };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// A self-link (next === id) still counts as something pointing at that row
|
|
193
|
+
// -- unlike orderByChain, which excludes it so the row remains visible as
|
|
194
|
+
// its own one-node run. Here that would instead make the row look like a
|
|
195
|
+
// *second* head next to the real one, turning a one-row cycle in the
|
|
196
|
+
// middle of an otherwise sound chain into a false "two heads" instead of
|
|
197
|
+
// the coverage miss it actually is.
|
|
198
|
+
const targets = new Set(rows.map((r) => r.next));
|
|
199
|
+
const heads = rows.filter((r) => !targets.has(r.id));
|
|
200
|
+
if (heads.length !== 1) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
reason: `expected exactly one head (an entry nothing points at); found ${heads.length}`,
|
|
204
|
+
order: [],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const order = [];
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
let cur = heads[0];
|
|
210
|
+
// Whether the walk stopped by reaching a row whose next is 0, as opposed
|
|
211
|
+
// to stopping because it looped back onto a row already visited. Coverage
|
|
212
|
+
// alone cannot tell these apart: a converging chain (row 3 pointing back
|
|
213
|
+
// into row 2, an already-visited interior row) walks every row before it
|
|
214
|
+
// repeats one, so `order.length === rows.length` is true for it too. This
|
|
215
|
+
// flag is what distinguishes ending from merely running out of new rows to
|
|
216
|
+
// visit.
|
|
217
|
+
let clean = false;
|
|
218
|
+
while (cur && !seen.has(cur.id)) {
|
|
219
|
+
seen.add(cur.id);
|
|
220
|
+
order.push(cur.id);
|
|
221
|
+
if (cur.next === 0) {
|
|
222
|
+
clean = true;
|
|
223
|
+
cur = undefined;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
cur = byId.get(cur.next);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Checked before coverage, and independently of it: a converging chain can
|
|
230
|
+
// cover every row while still having no tail, so coverage passing must not
|
|
231
|
+
// stand in for this.
|
|
232
|
+
if (!clean) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
reason: "the chain does not end -- it loops back into an entry already visited instead of reaching a terminating entry",
|
|
236
|
+
order: [],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (order.length !== rows.length) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
reason: `the chain reaches ${order.length} of ${rows.length} entries`,
|
|
243
|
+
order: [],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
return { ok: true, order };
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* `checkChain(readChain(db, listId))` in one call. Every point in an edit
|
|
250
|
+
* that needs to know whether a playlist's entry chain is currently sound --
|
|
251
|
+
* the pre-check, the transaction right after BEGIN IMMEDIATE, and the
|
|
252
|
+
* post-write readback -- asks the same question of the same two functions,
|
|
253
|
+
* so it asks it through the same name.
|
|
254
|
+
*/
|
|
255
|
+
function gateChain(db, listId) {
|
|
256
|
+
return checkChain(readChain(db, listId));
|
|
257
|
+
}
|
|
134
258
|
/**
|
|
135
259
|
* Roll back, swallowing a failure of the rollback itself.
|
|
136
260
|
*
|
|
@@ -153,23 +277,27 @@ export function sameOrder(a, b) {
|
|
|
153
277
|
return a.length === b.length && a.every((x, i) => x.uuid === b[i].uuid && x.trackId === b[i].trackId);
|
|
154
278
|
}
|
|
155
279
|
/**
|
|
156
|
-
* Turns whatever node:sqlite throws into an EngineError. Shared
|
|
157
|
-
* read-only pre-check and the write transaction below
|
|
158
|
-
* connection to the same file and can hit the same failure
|
|
159
|
-
* library gone missing mid-session, Engine holding the lock, a
|
|
160
|
-
* corrupt schema), and a caller whose promise is typed
|
|
161
|
-
* `Promise
|
|
162
|
-
*
|
|
280
|
+
* Turns whatever node:sqlite throws into an EngineError. Shared by every op
|
|
281
|
+
* in this module -- the read-only pre-check and the write transaction below
|
|
282
|
+
* it both open a connection to the same file and can hit the same failure
|
|
283
|
+
* modes (the library gone missing mid-session, Engine holding the lock, a
|
|
284
|
+
* foreign or corrupt schema), and a caller whose promise is typed
|
|
285
|
+
* `Promise<... | EngineError>` must never see one of them escape as a
|
|
286
|
+
* rejection instead.
|
|
287
|
+
*
|
|
288
|
+
* `subject` is whatever this write is named for in its own messages -- a new
|
|
289
|
+
* playlist's title for createPlaylist, `playlist ${listId}` for an op that
|
|
290
|
+
* edits one that already exists.
|
|
163
291
|
*
|
|
164
292
|
* Every path that reaches this function is one where the library is
|
|
165
293
|
* unchanged: the transaction either never opened or is rolled back by the
|
|
166
294
|
* caller, and a failure at or after COMMIT is answered before this is ever
|
|
167
|
-
* called (see
|
|
168
|
-
* say "nothing was changed" without qualification -- it used to say
|
|
295
|
+
* called (see classifyWriteFailure, below). That is what lets the fallback
|
|
296
|
+
* below say "nothing was changed" without qualification -- it used to say
|
|
169
297
|
* `Writing "X" failed`, which reads as a half-write even when the failure was
|
|
170
298
|
* "file is not a database" and not one byte was attempted.
|
|
171
299
|
*/
|
|
172
|
-
function mapWriteError(e,
|
|
300
|
+
function mapWriteError(e, subject, mdbPath) {
|
|
173
301
|
const msg = e.message ?? String(e);
|
|
174
302
|
const isUniqueViolation = /UNIQUE constraint failed/i.test(msg);
|
|
175
303
|
// The constraint's *name* never appears in the message SQLite raises --
|
|
@@ -177,12 +305,12 @@ function mapWriteError(e, title, mdbPath) {
|
|
|
177
305
|
// -- so the two conditions are checked independently rather than as one
|
|
178
306
|
// pattern that happens to work only because title leads that index today.
|
|
179
307
|
if (isUniqueViolation && /\bPlaylist\.title\b/.test(msg)) {
|
|
180
|
-
return err("playlist_exists", `A playlist called "${
|
|
308
|
+
return err("playlist_exists", `A playlist called "${subject}" already exists in this library.`, {
|
|
181
309
|
detail: NOT_COMMITTED,
|
|
182
310
|
});
|
|
183
311
|
}
|
|
184
312
|
if (isUniqueViolation && /\bPlaylistEntity\./.test(msg)) {
|
|
185
|
-
return err("duplicate_track", `A track in "${
|
|
313
|
+
return err("duplicate_track", `A track in "${subject}" collided with an existing playlist entry; Engine allows a track in a playlist only once.`, { detail: NOT_COMMITTED });
|
|
186
314
|
}
|
|
187
315
|
if (/SQLITE_BUSY|database is locked/i.test(msg)) {
|
|
188
316
|
return err("library_busy", "The library is locked by Engine DJ or a player. Close it and try again.", {
|
|
@@ -200,10 +328,141 @@ function mapWriteError(e, title, mdbPath) {
|
|
|
200
328
|
if (/unable to open database file/i.test(msg)) {
|
|
201
329
|
return err("library_not_found", `No Engine library database at ${mdbPath}.`, { detail: NOT_COMMITTED });
|
|
202
330
|
}
|
|
203
|
-
return err("library_unreadable", `Could not write "${
|
|
331
|
+
return err("library_unreadable", `Could not write "${subject}": ${msg}. Nothing was changed.`, {
|
|
204
332
|
detail: NOT_COMMITTED,
|
|
205
333
|
});
|
|
206
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* The check every write op in this module runs immediately after its own
|
|
337
|
+
* COMMIT, and the COMMITTED_UNVERIFIED error it produces when the database
|
|
338
|
+
* does not come back "ok". Shared because this step is identical for every
|
|
339
|
+
* op here -- only what ran before COMMIT differs.
|
|
340
|
+
*
|
|
341
|
+
* quick_check, not integrity_check: both walk every page -- the difference
|
|
342
|
+
* is that integrity_check additionally cross-checks every index against its
|
|
343
|
+
* table's actual content, and that cross-check is what dominates the cost on
|
|
344
|
+
* a library with hundreds of thousands of tracks. quick_check skips only
|
|
345
|
+
* that verification and still catches the on-disk structural damage (a
|
|
346
|
+
* malformed b-tree page, say) that a check running right after a write
|
|
347
|
+
* exists to catch.
|
|
348
|
+
*
|
|
349
|
+
* check?.quick_check, not check.quick_check: the pragma is documented to
|
|
350
|
+
* return at least one row, but a `.get()` that came back undefined here
|
|
351
|
+
* would raise a TypeError *after* a successful commit, and that lands in
|
|
352
|
+
* whatever catch block called this as an error about a library that has in
|
|
353
|
+
* fact already changed. Reading it as "not ok" says the same true thing
|
|
354
|
+
* without depending on the throw being classified correctly.
|
|
355
|
+
*/
|
|
356
|
+
function verifyAfterCommit(db, subject, backupPath) {
|
|
357
|
+
const check = db.prepare("PRAGMA quick_check").get();
|
|
358
|
+
if (check?.quick_check === "ok")
|
|
359
|
+
return undefined;
|
|
360
|
+
return err("library_unreadable", `The database reports "${check?.quick_check ?? "no result"}" after writing "${subject}". A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Classifies whatever the write transaction threw, using how far `commit`
|
|
364
|
+
* had gotten when it did. Shared across every op in this module: the
|
|
365
|
+
* three-way split below -- never reached COMMIT, SQLITE_BUSY on COMMIT
|
|
366
|
+
* itself, or past the point of no return -- does not depend on what the
|
|
367
|
+
* transaction was doing before it threw.
|
|
368
|
+
*
|
|
369
|
+
* A COMMIT that returned SQLITE_BUSY is the one in-flight failure SQLite
|
|
370
|
+
* defines precisely: the transaction stays open and nothing was written, so
|
|
371
|
+
* it is a plain retry, not an unverified write. Everything else that throws
|
|
372
|
+
* once COMMIT has started is past the point of no return: no ROLLBACK,
|
|
373
|
+
* because after a successful COMMIT there is no transaction left to roll
|
|
374
|
+
* back, and after a COMMIT that failed mid-flight there is no state this
|
|
375
|
+
* code can reason about well enough to undo by hand (the caller's `finally`
|
|
376
|
+
* block's db.close() ends anything still open). The honest answer there is
|
|
377
|
+
* that the write may have gone through, plus the path of the snapshot from
|
|
378
|
+
* before this session's first write, which is the only case where restoring
|
|
379
|
+
* one is ever the right next step.
|
|
380
|
+
*/
|
|
381
|
+
function classifyWriteFailure(e, commit, subject, mdbPath, backupPath, db, open) {
|
|
382
|
+
const busyOnCommit = commit === "maybe" && /SQLITE_BUSY|database is locked/i.test(e?.message ?? "");
|
|
383
|
+
if (commit === "not yet" || busyOnCommit) {
|
|
384
|
+
if (open && db)
|
|
385
|
+
rollback(db);
|
|
386
|
+
return mapWriteError(e, subject, mdbPath);
|
|
387
|
+
}
|
|
388
|
+
const msg = e?.message ?? String(e);
|
|
389
|
+
return err("library_unreadable", `Writing "${subject}" may have gone through: the library could not be verified afterwards (${msg}). ` +
|
|
390
|
+
`Check the library in Engine DJ. A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Owns everything past the read-only pre-check that every write op in this
|
|
394
|
+
* module does identically: snapshot, open, `PRAGMA foreign_keys = ON`,
|
|
395
|
+
* `BEGIN IMMEDIATE`, commit, verify, and classify whatever went wrong.
|
|
396
|
+
* `body` does the one thing that differs between ops -- the INSERTs and
|
|
397
|
+
* UPDATEs specific to what this write is -- against the open `db` it is
|
|
398
|
+
* handed, and returns either the result to hand back (minus `backup_path`,
|
|
399
|
+
* which this function fills in once it knows COMMIT succeeded) or an
|
|
400
|
+
* `EngineError`. A body that has already written something rolls back before
|
|
401
|
+
* returning that error, but this function rolls back on that branch too
|
|
402
|
+
* rather than relying on it: `rollback` is a no-op when there is no
|
|
403
|
+
* transaction left to undo, and a body that forgot would otherwise leave the
|
|
404
|
+
* open transaction to `db.close()` -- correct today, and only by accident.
|
|
405
|
+
*
|
|
406
|
+
* `isEngineError`, not a second return channel, is what tells `body`'s
|
|
407
|
+
* success value apart from its failure one: this module already exports
|
|
408
|
+
* that check for callers, so reusing it here means a body never has to wrap
|
|
409
|
+
* its result to disambiguate the two.
|
|
410
|
+
*/
|
|
411
|
+
async function withWriteTransaction(mdbPath, uuid, subject, opts, body) {
|
|
412
|
+
// Snapshot here, before the write connection is even opened, not after
|
|
413
|
+
// BEGIN IMMEDIATE. Taking it with RESERVED held meant a full-database copy
|
|
414
|
+
// -- tens of seconds on a multi-gigabyte USB library -- ran while every
|
|
415
|
+
// write Engine DJ attempted failed with SQLITE_BUSY, and a second
|
|
416
|
+
// concurrent call in this process (the MCP SDK dispatches concurrently)
|
|
417
|
+
// was told the library was locked by Engine DJ when it was this server
|
|
418
|
+
// holding the lock. Snapshotting before BEGIN IMMEDIATE used to mean a
|
|
419
|
+
// call that turned out to be busy spent a slot on every retry -- ten busy
|
|
420
|
+
// retries, ten full copies, evicting every genuine pre-write snapshot from
|
|
421
|
+
// backup.ts's KEEP window. The per-session memo (sessionSnapshot, above)
|
|
422
|
+
// is what makes moving it here safe: a session that only ever gets
|
|
423
|
+
// library_busy now leaves exactly one snapshot, not one per retry, so
|
|
424
|
+
// nothing is evicted. The hot-journal check and the read-only pre-check
|
|
425
|
+
// that ran before this was ever called still run first, so a call doomed
|
|
426
|
+
// by either of those still never copies anything.
|
|
427
|
+
const snapshot = await sessionSnapshot(mdbPath, uuid, opts.backupDir);
|
|
428
|
+
// snapshotLibrary sets no detail of its own (src/store/backup.ts); this is
|
|
429
|
+
// still a pre-commit failure, so the discriminator applies here too.
|
|
430
|
+
if (typeof snapshot !== "string")
|
|
431
|
+
return { ...snapshot, detail: NOT_COMMITTED };
|
|
432
|
+
const backupPath = snapshot;
|
|
433
|
+
let db;
|
|
434
|
+
let open = false;
|
|
435
|
+
let commit = "not yet";
|
|
436
|
+
try {
|
|
437
|
+
db = new DatabaseSync(mdbPath);
|
|
438
|
+
open = true;
|
|
439
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
440
|
+
db.exec("BEGIN IMMEDIATE");
|
|
441
|
+
const result = body(db);
|
|
442
|
+
if (isEngineError(result)) {
|
|
443
|
+
rollback(db);
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
446
|
+
commit = "maybe";
|
|
447
|
+
db.exec("COMMIT");
|
|
448
|
+
commit = "yes";
|
|
449
|
+
const verifyErr = verifyAfterCommit(db, subject, backupPath);
|
|
450
|
+
if (verifyErr)
|
|
451
|
+
return verifyErr;
|
|
452
|
+
return { ...result, backup_path: backupPath };
|
|
453
|
+
}
|
|
454
|
+
catch (e) {
|
|
455
|
+
return classifyWriteFailure(e, commit, subject, mdbPath, backupPath, db, open);
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
try {
|
|
459
|
+
db?.close();
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
/* already closed */
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
207
466
|
export async function createPlaylist(mdbPath, uuid, input, opts) {
|
|
208
467
|
const title = input.title.trim();
|
|
209
468
|
if (!title)
|
|
@@ -260,46 +519,8 @@ export async function createPlaylist(mdbPath, uuid, input, opts) {
|
|
|
260
519
|
}
|
|
261
520
|
if (!Array.isArray(refs))
|
|
262
521
|
return refs;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// -- tens of seconds on a multi-gigabyte USB library -- ran while every
|
|
266
|
-
// write Engine DJ attempted failed with SQLITE_BUSY, and a second
|
|
267
|
-
// concurrent create_playlist call in this process (the MCP SDK dispatches
|
|
268
|
-
// concurrently) was told the library was locked by Engine DJ when it was
|
|
269
|
-
// this server holding the lock. Snapshotting before BEGIN IMMEDIATE used
|
|
270
|
-
// to mean a call that turned out to be busy spent a slot on every retry --
|
|
271
|
-
// ten busy retries, ten full copies, evicting every genuine pre-write
|
|
272
|
-
// snapshot from backup.ts's KEEP window. The per-session memo
|
|
273
|
-
// (sessionSnapshot, above) is what makes moving it here safe: a session
|
|
274
|
-
// that only ever gets library_busy now leaves exactly one snapshot, not
|
|
275
|
-
// one per retry, so nothing is evicted. The hot-journal check and the
|
|
276
|
-
// read-only pre-check above still run first, so a call doomed by either of
|
|
277
|
-
// those still never copies anything.
|
|
278
|
-
const snapshot = await sessionSnapshot(mdbPath, uuid, opts.backupDir);
|
|
279
|
-
// snapshotLibrary sets no detail of its own (src/store/backup.ts); this is
|
|
280
|
-
// still a pre-commit failure, so the discriminator applies here too.
|
|
281
|
-
if (typeof snapshot !== "string")
|
|
282
|
-
return { ...snapshot, detail: NOT_COMMITTED };
|
|
283
|
-
const backupPath = snapshot;
|
|
284
|
-
let db;
|
|
285
|
-
let open = false;
|
|
286
|
-
/**
|
|
287
|
-
* "not yet" until COMMIT is reached; "maybe" for the moment COMMIT is in
|
|
288
|
-
* flight; "yes" once it returned. Anything thrown while this is not "not
|
|
289
|
-
* yet" may have left the playlist on disk -- COMMIT can fail at fsync with
|
|
290
|
-
* SQLITE_IOERR or SQLITE_FULL after the pages are already there, and the
|
|
291
|
-
* post-commit check below runs against a database that has definitely
|
|
292
|
-
* changed. Reporting those as not_committed (which is what a single catch
|
|
293
|
-
* calling mapWriteError did) inverts the one discriminator a client uses to
|
|
294
|
-
* decide whether their library still is what it was, and drops the snapshot
|
|
295
|
-
* path in exactly the case where it is the only way back.
|
|
296
|
-
*/
|
|
297
|
-
let commit = "not yet";
|
|
298
|
-
try {
|
|
299
|
-
db = new DatabaseSync(mdbPath);
|
|
300
|
-
open = true;
|
|
301
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
302
|
-
db.exec("BEGIN IMMEDIATE");
|
|
522
|
+
const trackRefs = refs;
|
|
523
|
+
return withWriteTransaction(mdbPath, uuid, title, opts, (db) => {
|
|
303
524
|
// nextListId = 0 appends: Engine's own insert triggers move the tail
|
|
304
525
|
// marker off the previous last row and point it at this one.
|
|
305
526
|
const ins = db
|
|
@@ -316,7 +537,7 @@ export async function createPlaylist(mdbPath, uuid, input, opts) {
|
|
|
316
537
|
VALUES (?, ?, ?, 0, 0)`);
|
|
317
538
|
const link = db.prepare("UPDATE PlaylistEntity SET nextEntityId = ? WHERE id = ?");
|
|
318
539
|
const ids = [];
|
|
319
|
-
for (const ref of
|
|
540
|
+
for (const ref of trackRefs) {
|
|
320
541
|
ids.push(Number(insEntity.run(listId, ref.trackId, ref.uuid).lastInsertRowid));
|
|
321
542
|
}
|
|
322
543
|
for (let i = 0; i + 1 < ids.length; i++)
|
|
@@ -333,7 +554,7 @@ export async function createPlaylist(mdbPath, uuid, input, opts) {
|
|
|
333
554
|
// PlaylistEntity row left behind by some earlier deleted playlist would
|
|
334
555
|
// fail every create_playlist call on that library forever, blaming this
|
|
335
556
|
// write for damage that predates it.)
|
|
336
|
-
if (ids.length > 0 && !sameOrder(walkFrom(db, listId, ids[0]),
|
|
557
|
+
if (ids.length > 0 && !sameOrder(walkFrom(db, listId, ids[0]), trackRefs)) {
|
|
337
558
|
rollback(db);
|
|
338
559
|
return err("library_unreadable", `The entry chain for "${title}" did not read back as written; nothing was changed.`, { detail: NOT_COMMITTED });
|
|
339
560
|
}
|
|
@@ -342,56 +563,664 @@ export async function createPlaylist(mdbPath, uuid, input, opts) {
|
|
|
342
563
|
// one per parent, and this insert always uses nextListId = 0, so more
|
|
343
564
|
// than one tail is not a state this transaction can produce. Restating
|
|
344
565
|
// that as a runtime check would be a tautology, not a safety net.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
566
|
+
return { playlist_id: listId, title, tracks_added: trackRefs.length };
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Resolves `at` against a chain's *current* order into a 0-based insert
|
|
571
|
+
* index, or `invalid_position` if `after_position` names a position that
|
|
572
|
+
* order does not have.
|
|
573
|
+
*
|
|
574
|
+
* Called twice by addTracksToPlaylist, against two different reads of the
|
|
575
|
+
* same chain, deliberately: once in the pre-check, to fail fast, and again
|
|
576
|
+
* inside the transaction against the chain BEGIN IMMEDIATE just locked. The
|
|
577
|
+
* second call is the one that matters -- the pre-check's `order` can be
|
|
578
|
+
* stale by the time the lock is held, and reusing its answer instead of
|
|
579
|
+
* recomputing this one would mean a playlist that shrank in between turns a
|
|
580
|
+
* clean invalid_position refusal into `gate.order[insertAt - 1]` reading
|
|
581
|
+
* past the end of the array.
|
|
582
|
+
*/
|
|
583
|
+
function resolveInsertAt(order, at, listId) {
|
|
584
|
+
if (at === "start")
|
|
585
|
+
return 0;
|
|
586
|
+
if (at === "end")
|
|
587
|
+
return order.length;
|
|
588
|
+
const p = at.after_position;
|
|
589
|
+
if (!Number.isInteger(p) || p < 1 || p > order.length) {
|
|
590
|
+
return err("invalid_position", `Playlist ${listId} has ${order.length} entries; after_position must be between 1 and ${order.length}.`, { detail: NOT_COMMITTED });
|
|
591
|
+
}
|
|
592
|
+
return p;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Adds one or more tracks to an existing playlist, at the start, the end, or
|
|
596
|
+
* after a named position in its current order.
|
|
597
|
+
*
|
|
598
|
+
* Shares its skeleton with createPlaylist: a read-only pre-check first (cheap
|
|
599
|
+
* enough to rule out the common failure modes without ever opening the
|
|
600
|
+
* library for writing), then withWriteTransaction. Where createPlaylist
|
|
601
|
+
* builds a chain from nothing, this extends one that already exists, so it
|
|
602
|
+
* also has to confirm that chain is sound before it touches it -- twice. The
|
|
603
|
+
* pre-check gates it once, both to fail fast (and skip the snapshot) for a
|
|
604
|
+
* playlist that cannot be edited at all, and because validating `at` needs
|
|
605
|
+
* to know how many entries the playlist currently has. The transaction gates
|
|
606
|
+
* it again after BEGIN IMMEDIATE, because that lock is the first moment
|
|
607
|
+
* nothing else can change the chain -- gating on the pre-check's read alone,
|
|
608
|
+
* or reusing the insert position it computed, would be trusting one that
|
|
609
|
+
* could already be stale.
|
|
610
|
+
*/
|
|
611
|
+
export async function addTracksToPlaylist(mdbPath, uuid, input, opts) {
|
|
612
|
+
const { listId, trackIds, at } = input;
|
|
613
|
+
// Not a playlist title -- there isn't one here -- but the same role: what
|
|
614
|
+
// this write is named for in mapWriteError/verifyAfterCommit/
|
|
615
|
+
// classifyWriteFailure's shared messages.
|
|
616
|
+
const subject = `playlist ${listId}`;
|
|
617
|
+
if (trackIds.length === 0) {
|
|
618
|
+
return err("invalid_argument", "Name at least one track to add.", { detail: NOT_COMMITTED });
|
|
619
|
+
}
|
|
620
|
+
// See createPlaylist for why this has to be checked before anything else
|
|
621
|
+
// even tries to open the file.
|
|
622
|
+
if (hasHotJournal(mdbPath))
|
|
623
|
+
return { ...libraryNeedsRecovery(), detail: NOT_COMMITTED };
|
|
624
|
+
let refs;
|
|
625
|
+
{
|
|
626
|
+
let precheck;
|
|
627
|
+
try {
|
|
628
|
+
precheck = new DatabaseSync(mdbPath, { readOnly: true });
|
|
629
|
+
const exists = precheck.prepare("SELECT 1 FROM Playlist WHERE id = ?").get(listId);
|
|
630
|
+
if (!exists) {
|
|
631
|
+
return err("playlist_not_found", `No playlist with id ${listId} in this library.`, {
|
|
632
|
+
detail: NOT_COMMITTED,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
const gate = gateChain(precheck, listId);
|
|
636
|
+
if (!gate.ok) {
|
|
637
|
+
return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
|
|
638
|
+
detail: NOT_COMMITTED,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
refs = resolveOrigins(precheck, trackIds);
|
|
642
|
+
if (!Array.isArray(refs))
|
|
643
|
+
return refs;
|
|
644
|
+
// Compared as pairs, in the same direction the write goes: each
|
|
645
|
+
// existing entry's stored (databaseUuid, trackId) against the pairs
|
|
646
|
+
// the requested tracks resolve to -- which is exactly what
|
|
647
|
+
// UNIQUE (listId, databaseUuid, trackId) will compare when the INSERT
|
|
648
|
+
// runs. Its value is failing fast: before the snapshot is copied and
|
|
649
|
+
// before a write transaction opens, with a message naming the track,
|
|
650
|
+
// rather than a UNIQUE-constraint violation surfacing from inside a
|
|
651
|
+
// transaction and getting mapped back to the same code.
|
|
652
|
+
//
|
|
653
|
+
// The reverse direction -- resolving each entry back to a local track
|
|
654
|
+
// and asking whether the request names it -- looks equivalent and is
|
|
655
|
+
// strictly weaker, on two counts. First, it is not what gets compared:
|
|
656
|
+
// UNIQUE (listId, databaseUuid, trackId) compares the pair itself, so
|
|
657
|
+
// the pair form is exactly that comparison run early, while the
|
|
658
|
+
// reverse form goes through Track and answers a related but different
|
|
659
|
+
// question. Second, where two Track rows share one origin pair the
|
|
660
|
+
// reverse form has to pick one and can miss the other -- but Engine's
|
|
661
|
+
// own C_originDatabaseUuid_originTrackId UNIQUE constraint (see the
|
|
662
|
+
// ENTRY_TRACK_MATCH comment in playlists.ts) forbids a real library
|
|
663
|
+
// from ever holding that state; it is reachable at all only in this
|
|
664
|
+
// project's own fixture, whose generated schema omits that constraint
|
|
665
|
+
// (see tests/playlist-edit.test.ts). The pair form also drops one
|
|
666
|
+
// unindexed Track scan per existing entry from a path that runs before
|
|
667
|
+
// every add.
|
|
668
|
+
//
|
|
669
|
+
// Neither form can catch more than the constraint itself: an entry
|
|
670
|
+
// whose stored pair no longer names any local track -- what a library
|
|
671
|
+
// looks like right after a re-origination moved a track's origin on
|
|
672
|
+
// without updating the entries naming its old one -- is not a
|
|
673
|
+
// duplicate of anything, because nothing in the database still says
|
|
674
|
+
// that entry and the requested track are the same one. That is a
|
|
675
|
+
// property of the data, not a gap in the check.
|
|
676
|
+
const wanted = new Map();
|
|
677
|
+
for (let i = 0; i < refs.length; i++)
|
|
678
|
+
wanted.set(pairKey(refs[i].uuid, refs[i].trackId), trackIds[i]);
|
|
679
|
+
const existing = precheck
|
|
680
|
+
.prepare("SELECT trackId, databaseUuid FROM PlaylistEntity WHERE listId = ?")
|
|
681
|
+
.all(listId);
|
|
682
|
+
for (const e of existing) {
|
|
683
|
+
// A null databaseUuid names a malformed entry (see OrderedEntry in
|
|
684
|
+
// playlists.ts), never a real origin pair -- no resolved track can
|
|
685
|
+
// match it, since resolveOrigins already refuses a null uuid. Keying
|
|
686
|
+
// it would collide with pairKey's own null-coercion trap; skipping
|
|
687
|
+
// it is what the old `= NULL` comparison did for free, since that
|
|
688
|
+
// matches no row.
|
|
689
|
+
if (e.databaseUuid === null)
|
|
690
|
+
continue;
|
|
691
|
+
const clash = wanted.get(pairKey(e.databaseUuid, e.trackId));
|
|
692
|
+
if (clash !== undefined) {
|
|
693
|
+
return err("duplicate_track", `Track ${clash} is already in playlist ${listId}; Engine allows a track in a playlist only once.`, { detail: NOT_COMMITTED });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// Discarded once it has done its job: only the pass/fail matters here
|
|
697
|
+
// (see resolveInsertAt's own comment for why the number itself is not
|
|
698
|
+
// carried across the lock).
|
|
699
|
+
const insertAt = resolveInsertAt(gate.order, at, listId);
|
|
700
|
+
if (isEngineError(insertAt))
|
|
701
|
+
return insertAt;
|
|
702
|
+
}
|
|
703
|
+
catch (e) {
|
|
704
|
+
return mapWriteError(e, subject, mdbPath);
|
|
705
|
+
}
|
|
706
|
+
finally {
|
|
707
|
+
try {
|
|
708
|
+
precheck?.close();
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
/* never opened, or already closed */
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const trackRefs = refs;
|
|
716
|
+
return withWriteTransaction(mdbPath, uuid, subject, opts, (db) => {
|
|
717
|
+
// The chain read in the pre-check is re-read here: BEGIN IMMEDIATE is
|
|
718
|
+
// the first moment nothing else can change it, and gating on a chain
|
|
719
|
+
// read before the lock would be gating on a stale one.
|
|
720
|
+
const gate = gateChain(db, listId);
|
|
721
|
+
if (!gate.ok) {
|
|
722
|
+
rollback(db);
|
|
723
|
+
return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
|
|
724
|
+
detail: NOT_COMMITTED,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
// Re-resolved against this read of the chain, not the pre-check's: see
|
|
728
|
+
// resolveInsertAt's comment.
|
|
729
|
+
const insertAt = resolveInsertAt(gate.order, at, listId);
|
|
730
|
+
if (isEngineError(insertAt)) {
|
|
731
|
+
rollback(db);
|
|
732
|
+
return insertAt;
|
|
733
|
+
}
|
|
734
|
+
// What the final chain should read back as: the existing entries' track
|
|
735
|
+
// identities, with the requested tracks spliced in at the position this
|
|
736
|
+
// call resolved to. Built before the writes below, from a walk keyed by
|
|
737
|
+
// id rather than by row count, so the readback check afterwards does not
|
|
738
|
+
// itself depend on how the writes below number their new rows.
|
|
739
|
+
const existingRefs = gate.order.length > 0 ? walkFrom(db, listId, gate.order[0]) : [];
|
|
740
|
+
const expected = [...existingRefs.slice(0, insertAt), ...trackRefs, ...existingRefs.slice(insertAt)];
|
|
741
|
+
// Insert one row at a time, linking by the id each insert actually
|
|
742
|
+
// returned -- the same reason createPlaylist does: SQLite assigning
|
|
743
|
+
// AUTOINCREMENT in ORDER BY order is optimizer behaviour, not a promise.
|
|
744
|
+
const insEntity = db.prepare(`INSERT INTO PlaylistEntity (listId, trackId, databaseUuid, nextEntityId, membershipReference)
|
|
745
|
+
VALUES (?, ?, ?, 0, 0)`);
|
|
746
|
+
const link = db.prepare("UPDATE PlaylistEntity SET nextEntityId = ? WHERE id = ?");
|
|
747
|
+
const ids = trackRefs.map((ref) => Number(insEntity.run(listId, ref.trackId, ref.uuid).lastInsertRowid));
|
|
748
|
+
for (let i = 0; i + 1 < ids.length; i++)
|
|
749
|
+
link.run(ids[i + 1], ids[i]);
|
|
750
|
+
if (ids.length > 0) {
|
|
751
|
+
if (insertAt === 0) {
|
|
752
|
+
// Inserting at the start needs no other row touched, because nothing
|
|
753
|
+
// links to a head: the new run just becomes the head, ending in
|
|
754
|
+
// whatever was the old one (0 if the playlist was empty).
|
|
755
|
+
const oldHead = gate.order.length > 0 ? gate.order[0] : 0;
|
|
756
|
+
link.run(oldHead, ids[ids.length - 1]);
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
// Otherwise the new run is spliced in after gate.order[insertAt - 1]:
|
|
760
|
+
// its link is repointed at the first new row, and the last new row
|
|
761
|
+
// takes the link the predecessor had (0 if it was the tail).
|
|
762
|
+
const predId = gate.order[insertAt - 1];
|
|
763
|
+
const predNext = insertAt < gate.order.length ? gate.order[insertAt] : 0;
|
|
764
|
+
link.run(ids[0], predId);
|
|
765
|
+
link.run(predNext, ids[ids.length - 1]);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// Re-gated, not just re-walked: sameOrder alone confirms the *values*
|
|
769
|
+
// survived the round trip in order, but a wrong implementation could in
|
|
770
|
+
// principle produce a chain that still walks to the right values from
|
|
771
|
+
// this head (e.g. a converging link elsewhere) while failing checkChain.
|
|
772
|
+
// Both are cheap; there is no reason to trust only one of them here.
|
|
773
|
+
const newHeadId = insertAt === 0 ? (ids[0] ?? gate.order[0] ?? 0) : gate.order[0];
|
|
774
|
+
const finalGate = gateChain(db, listId);
|
|
775
|
+
if (!finalGate.ok || !sameOrder(walkFrom(db, listId, newHeadId), expected)) {
|
|
776
|
+
rollback(db);
|
|
777
|
+
return err("library_unreadable", `The entry chain for playlist ${listId} did not read back as written; nothing was changed.`, { detail: NOT_COMMITTED });
|
|
778
|
+
}
|
|
779
|
+
// No trigger maintains this: measured, changing PlaylistEntity leaves the
|
|
780
|
+
// parent Playlist row untouched. datetime('now'), not strftime('%s') --
|
|
781
|
+
// that is the Track convention, and Playlist.lastEditTime is TEXT.
|
|
782
|
+
db.prepare("UPDATE Playlist SET lastEditTime = datetime('now') WHERE id = ?").run(listId);
|
|
783
|
+
const positions = ids.map((_, i) => insertAt + 1 + i);
|
|
784
|
+
// expect_track_ids, not bare positions: this is the one moment the
|
|
785
|
+
// server knows exactly which tracks landed at those positions, and a
|
|
786
|
+
// playlist that changed between this edit and the undo would otherwise
|
|
787
|
+
// have the undo remove whatever now sits there -- silently, and with no
|
|
788
|
+
// way for the caller to notice. The ids are the caller's own local track
|
|
789
|
+
// ids, which is what remove_tracks_from_playlist compares against (it
|
|
790
|
+
// resolves each entry's stored origin pair back to a local track the
|
|
791
|
+
// same way). Stale list, refused undo; unchanged list, the undo runs.
|
|
792
|
+
return {
|
|
793
|
+
playlist_id: listId,
|
|
794
|
+
tracks_added: trackRefs.length,
|
|
795
|
+
positions,
|
|
796
|
+
undo: [
|
|
797
|
+
{
|
|
798
|
+
tool: "remove_tracks_from_playlist",
|
|
799
|
+
arguments: { playlist_id: listId, positions, expect_track_ids: trackIds },
|
|
800
|
+
},
|
|
801
|
+
],
|
|
802
|
+
undo_complete: true,
|
|
803
|
+
};
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* The local `Track.id` that carries a given origin pair, or null if none
|
|
808
|
+
* does -- the reverse of resolveOrigins, going from a PlaylistEntity row's
|
|
809
|
+
* stored (databaseUuid, trackId) back to the local row a caller's
|
|
810
|
+
* expectTrackIds and the response's `removed[].track_id` are expressed in.
|
|
811
|
+
*
|
|
812
|
+
* Used only by resolveRemoval, which genuinely needs that direction: it has
|
|
813
|
+
* an entry and must say which local track it holds. addTracksToPlaylist's
|
|
814
|
+
* duplicate check used to go through here too and no longer does -- it
|
|
815
|
+
* compares origin pairs directly, which is both stronger and cheaper; see
|
|
816
|
+
* the comment on that check. Where two Track rows share one origin pair this
|
|
817
|
+
* function answers with whichever row the query returns first, which is
|
|
818
|
+
* exactly why the duplicate check must not be built on it.
|
|
819
|
+
*/
|
|
820
|
+
function resolveLocalTrackId(db, uuid, trackId) {
|
|
821
|
+
const row = db
|
|
822
|
+
.prepare("SELECT id FROM Track WHERE originDatabaseUuid = ? AND originTrackId = ?")
|
|
823
|
+
.get(uuid, trackId);
|
|
824
|
+
return row ? row.id : null;
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Validates `positions` against a chain's current order -- in range, no
|
|
828
|
+
* repeats -- and, when `expectTrackIds` is given, that each named position
|
|
829
|
+
* still holds the track a caller who read the list earlier believed it did
|
|
830
|
+
* (`null` there means "resolves to no local track", not "no expectation" --
|
|
831
|
+
* see resolveLocalTrackId, above -- so a non-null value at that slot is a
|
|
832
|
+
* mismatch, same as a wrong id would be at any other slot).
|
|
833
|
+
* Resolves each surviving position to the entry id to delete and the
|
|
834
|
+
* *local* track id it currently holds, translated from the stored origin
|
|
835
|
+
* pair the same way addTracksToPlaylist's duplicate-track check does (null
|
|
836
|
+
* if that pair no longer resolves to any local track).
|
|
837
|
+
*
|
|
838
|
+
* Called twice by removeTracksFromPlaylist, against two different reads of
|
|
839
|
+
* the same chain, for the same reason resolveInsertAt is: once in the
|
|
840
|
+
* pre-check, to fail fast, and again inside the transaction against the
|
|
841
|
+
* chain BEGIN IMMEDIATE just locked -- the pre-check's read can be stale by
|
|
842
|
+
* the time the lock is held.
|
|
843
|
+
*/
|
|
844
|
+
function resolveRemoval(db, listId, order, positions, expectTrackIds) {
|
|
845
|
+
const seen = new Set();
|
|
846
|
+
for (const p of positions) {
|
|
847
|
+
if (!Number.isInteger(p) || p < 1 || p > order.length) {
|
|
848
|
+
return err("invalid_position", `Playlist ${listId} has ${order.length} entries; each position must be between 1 and ${order.length}.`, { detail: NOT_COMMITTED });
|
|
849
|
+
}
|
|
850
|
+
if (seen.has(p)) {
|
|
851
|
+
return err("invalid_position", `Position ${p} is named more than once.`, { detail: NOT_COMMITTED });
|
|
852
|
+
}
|
|
853
|
+
seen.add(p);
|
|
854
|
+
}
|
|
855
|
+
if (expectTrackIds && expectTrackIds.length !== positions.length) {
|
|
856
|
+
return err("invalid_position", `expectTrackIds has ${expectTrackIds.length} entries but positions has ${positions.length}.`, { detail: NOT_COMMITTED });
|
|
857
|
+
}
|
|
858
|
+
const entryAt = db.prepare("SELECT trackId, databaseUuid FROM PlaylistEntity WHERE id = ?");
|
|
859
|
+
const plan = [];
|
|
860
|
+
for (let i = 0; i < positions.length; i++) {
|
|
861
|
+
const position = positions[i];
|
|
862
|
+
const entryId = order[position - 1];
|
|
863
|
+
const row = entryAt.get(entryId);
|
|
864
|
+
const trackId = resolveLocalTrackId(db, row.databaseUuid, row.trackId);
|
|
865
|
+
if (expectTrackIds && expectTrackIds[i] !== trackId) {
|
|
866
|
+
return err("invalid_position", `Position ${position} in playlist ${listId} does not hold track ${expectTrackIds[i]}; refusing to remove the wrong track.`, { detail: NOT_COMMITTED });
|
|
867
|
+
}
|
|
868
|
+
plan.push({ position, entryId, trackId });
|
|
869
|
+
}
|
|
870
|
+
return plan;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Removes one or more tracks from an existing playlist by their current
|
|
874
|
+
* position.
|
|
875
|
+
*
|
|
876
|
+
* `trigger_before_delete_PlaylistEntity` (see gen-library.ts's copy of it,
|
|
877
|
+
* taken verbatim from a real 3.0.2 library) relinks each deleted row's
|
|
878
|
+
* predecessor onto its successor as SQLite processes the delete -- verified
|
|
879
|
+
* for a row removed at the head, the middle and the tail, and, by
|
|
880
|
+
* construction of the trigger itself, for a batch that removes several
|
|
881
|
+
* rows, adjacent or not, in one statement. So this function does no chain
|
|
882
|
+
* maintenance of its own; writing any would just be fighting Engine's own
|
|
883
|
+
* trigger. What it does own is checking that the trigger's job actually
|
|
884
|
+
* landed: its `WHEN OLD.trackId > 0` means a row with trackId <= 0 is
|
|
885
|
+
* deleted *without* relinking, leaving its predecessor pointing at a row
|
|
886
|
+
* that is now gone. No real library measured has such a row, but the
|
|
887
|
+
* post-delete check below is the only thing that would ever notice one --
|
|
888
|
+
* and it checks the surviving order against what was expected, not only
|
|
889
|
+
* that some sound chain is left, because "sound" and "right" are different
|
|
890
|
+
* questions and only the second one is what the caller asked for.
|
|
891
|
+
*
|
|
892
|
+
* Shares createPlaylist/addTracksToPlaylist's skeleton: a read-only
|
|
893
|
+
* pre-check first, then withWriteTransaction.
|
|
894
|
+
*/
|
|
895
|
+
export async function removeTracksFromPlaylist(mdbPath, uuid, input, opts) {
|
|
896
|
+
const { listId, positions, expectTrackIds } = input;
|
|
897
|
+
const subject = `playlist ${listId}`;
|
|
898
|
+
if (positions.length === 0) {
|
|
899
|
+
return err("invalid_argument", "Name at least one position to remove.", { detail: NOT_COMMITTED });
|
|
900
|
+
}
|
|
901
|
+
// See createPlaylist for why this has to be checked before anything else
|
|
902
|
+
// even tries to open the file.
|
|
903
|
+
if (hasHotJournal(mdbPath))
|
|
904
|
+
return { ...libraryNeedsRecovery(), detail: NOT_COMMITTED };
|
|
905
|
+
{
|
|
906
|
+
let precheck;
|
|
907
|
+
try {
|
|
908
|
+
precheck = new DatabaseSync(mdbPath, { readOnly: true });
|
|
909
|
+
const exists = precheck.prepare("SELECT 1 FROM Playlist WHERE id = ?").get(listId);
|
|
910
|
+
if (!exists) {
|
|
911
|
+
return err("playlist_not_found", `No playlist with id ${listId} in this library.`, {
|
|
912
|
+
detail: NOT_COMMITTED,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
const gate = gateChain(precheck, listId);
|
|
916
|
+
if (!gate.ok) {
|
|
917
|
+
return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
|
|
918
|
+
detail: NOT_COMMITTED,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
const plan = resolveRemoval(precheck, listId, gate.order, positions, expectTrackIds);
|
|
922
|
+
if (isEngineError(plan))
|
|
923
|
+
return plan;
|
|
924
|
+
}
|
|
925
|
+
catch (e) {
|
|
926
|
+
return mapWriteError(e, subject, mdbPath);
|
|
927
|
+
}
|
|
928
|
+
finally {
|
|
929
|
+
try {
|
|
930
|
+
precheck?.close();
|
|
931
|
+
}
|
|
932
|
+
catch {
|
|
933
|
+
/* never opened, or already closed */
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return withWriteTransaction(mdbPath, uuid, subject, opts, (db) => {
|
|
938
|
+
// Re-read: BEGIN IMMEDIATE is the first moment nothing else can change
|
|
939
|
+
// the chain, and gating on the pre-check's read alone would be trusting
|
|
940
|
+
// one that could already be stale.
|
|
941
|
+
const gate = gateChain(db, listId);
|
|
942
|
+
if (!gate.ok) {
|
|
943
|
+
rollback(db);
|
|
944
|
+
return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
|
|
945
|
+
detail: NOT_COMMITTED,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
// Re-resolved against this read of the chain, not the pre-check's: see
|
|
949
|
+
// resolveRemoval's comment.
|
|
950
|
+
const plan = resolveRemoval(db, listId, gate.order, positions, expectTrackIds);
|
|
951
|
+
if (isEngineError(plan)) {
|
|
952
|
+
rollback(db);
|
|
953
|
+
return plan;
|
|
954
|
+
}
|
|
955
|
+
// What the chain must read back as once the deletes have landed: the
|
|
956
|
+
// entries this call did not name, in their original order. Built here,
|
|
957
|
+
// before the DELETE, from a walk of the chain BEGIN IMMEDIATE locked --
|
|
958
|
+
// the same reason addTracksToPlaylist and reorderPlaylist build theirs
|
|
959
|
+
// up front: a readback check derived from the writes it is meant to
|
|
960
|
+
// verify checks nothing.
|
|
961
|
+
const removedIndexes = new Set(plan.map((p) => p.position - 1));
|
|
962
|
+
const originalRefs = gate.order.length > 0 ? walkFrom(db, listId, gate.order[0]) : [];
|
|
963
|
+
const expected = originalRefs.filter((_, i) => !removedIndexes.has(i));
|
|
964
|
+
const survivors = gate.order.filter((_, i) => !removedIndexes.has(i));
|
|
965
|
+
// One statement, no chain maintenance -- see this function's own
|
|
966
|
+
// comment for why the trigger is trusted to relink around every row
|
|
967
|
+
// this deletes, including a batch of several at once.
|
|
968
|
+
const placeholders = plan.map(() => "?").join(", ");
|
|
969
|
+
db.prepare(`DELETE FROM PlaylistEntity WHERE id IN (${placeholders})`).run(...plan.map((p) => p.entryId));
|
|
970
|
+
// Gate *and* order, the same pairing add and reorder use, and spec §6
|
|
971
|
+
// asks for here specifically. The gate catches what the trigger's WHEN
|
|
972
|
+
// clause does not cover: a deleted row with trackId <= 0 leaves its
|
|
973
|
+
// predecessor pointing at a row that no longer exists. sameOrder catches
|
|
974
|
+
// the class the gate structurally cannot -- a chain that is still one
|
|
975
|
+
// sound run but holds the wrong entries, or the wrong number of them,
|
|
976
|
+
// which is what a delete that took the wrong row (or a trigger this code
|
|
977
|
+
// does not know about) leaves behind.
|
|
355
978
|
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
979
|
+
// library_unreadable, not playlist_chain_damaged, and deliberately the
|
|
980
|
+
// same code add and reorder use for their own post-check: the two mean
|
|
981
|
+
// different things to a client. playlist_chain_damaged means the chain
|
|
982
|
+
// was already broken before this edit and the edit refused to touch it;
|
|
983
|
+
// this one means the edit's own verification disagreed with what it
|
|
984
|
+
// wrote, so the transaction was rolled back. Both leave the library
|
|
985
|
+
// unchanged (detail: not_committed); only the second says the library
|
|
986
|
+
// did something this code cannot account for.
|
|
987
|
+
const newHeadId = survivors.length > 0 ? survivors[0] : 0;
|
|
988
|
+
const finalGate = gateChain(db, listId);
|
|
989
|
+
if (!finalGate.ok || !sameOrder(walkFrom(db, listId, newHeadId), expected)) {
|
|
990
|
+
rollback(db);
|
|
991
|
+
return err("library_unreadable", `The entry chain for playlist ${listId} did not read back as written; nothing was changed.`, { detail: NOT_COMMITTED });
|
|
992
|
+
}
|
|
993
|
+
// No trigger maintains this: measured, changing PlaylistEntity leaves the
|
|
994
|
+
// parent Playlist row untouched. datetime('now'), not strftime('%s') --
|
|
995
|
+
// that is the Track convention, and Playlist.lastEditTime is TEXT.
|
|
996
|
+
db.prepare("UPDATE Playlist SET lastEditTime = datetime('now') WHERE id = ?").run(listId);
|
|
997
|
+
// Reported, and undone, in ascending position order rather than the
|
|
998
|
+
// order the caller named them in -- the undo below depends on it.
|
|
999
|
+
const removed = plan
|
|
1000
|
+
.slice()
|
|
1001
|
+
.sort((a, b) => a.position - b.position)
|
|
1002
|
+
.map((p) => ({ position: p.position, track_id: p.trackId }));
|
|
1003
|
+
// An entry whose stored origin pair matches no track in this library has
|
|
1004
|
+
// no track id to hand back, so no add_tracks_to_playlist call can
|
|
1005
|
+
// restore it: the DELETE above destroyed the only place that pair was
|
|
1006
|
+
// written down. Emitting a step for it anyway -- `track_ids: [null]` --
|
|
1007
|
+
// produced an undo its own tool's schema rejects, and the entry was gone
|
|
1008
|
+
// regardless. So no step is emitted for such a position, and the result
|
|
1009
|
+
// says so rather than implying a way back it does not have.
|
|
1010
|
+
const restorable = removed.filter((r) => r.track_id !== null);
|
|
1011
|
+
const lost = removed.filter((r) => r.track_id === null).map((r) => r.position);
|
|
1012
|
+
// Undo restores in that same ascending order, each step expressed
|
|
1013
|
+
// against the list as it will be *after* the previous step has run.
|
|
1014
|
+
// Restoring in ascending order means that immediately before the row
|
|
1015
|
+
// originally at position p is restored, every row originally before p
|
|
1016
|
+
// that *can* come back is present again -- either it was never removed,
|
|
1017
|
+
// or, being an earlier and already-restored entry, it is back in its
|
|
1018
|
+
// exact original spot -- and nothing originally at or after p has been
|
|
1019
|
+
// restored yet. So the number of rows preceding that slot at that moment
|
|
1020
|
+
// is p - 1 minus the rows originally before p that were removed and
|
|
1021
|
+
// cannot be restored. With nothing lost that is just `position - 1`
|
|
1022
|
+
// computed against the original position: removing positions 1 and 3
|
|
1023
|
+
// from a three-entry list restores 1 first (at "start") and then 3 (at
|
|
1024
|
+
// after_position: 2), reproducing the original order. Restoring 3 first
|
|
1025
|
+
// would compute that same after_position: 2 against a list from which 1
|
|
1026
|
+
// is *also* still missing -- a single surviving entry -- which is
|
|
1027
|
+
// already wrong (there is no position 2 to be after yet); ascending
|
|
1028
|
+
// order is what keeps every step's target position valid, not just
|
|
1029
|
+
// correct. Subtracting the lost rows is the same argument applied to a
|
|
1030
|
+
// list that will never get them back: a step that still counted them
|
|
1031
|
+
// would name a position the list does not reach, and be refused.
|
|
1032
|
+
const undo = restorable.map((r) => {
|
|
1033
|
+
const before = r.position - 1 - lost.filter((p) => p < r.position).length;
|
|
1034
|
+
return {
|
|
1035
|
+
tool: "add_tracks_to_playlist",
|
|
1036
|
+
arguments: {
|
|
1037
|
+
playlist_id: listId,
|
|
1038
|
+
track_ids: [r.track_id],
|
|
1039
|
+
at: before === 0 ? "start" : { after_position: before },
|
|
1040
|
+
},
|
|
1041
|
+
};
|
|
1042
|
+
});
|
|
1043
|
+
return {
|
|
1044
|
+
playlist_id: listId,
|
|
1045
|
+
tracks_removed: removed.length,
|
|
1046
|
+
removed,
|
|
1047
|
+
undo,
|
|
1048
|
+
undo_complete: lost.length === 0,
|
|
1049
|
+
...(lost.length === 0
|
|
1050
|
+
? {}
|
|
1051
|
+
: {
|
|
1052
|
+
undo_note: `Position${lost.length > 1 ? "s" : ""} ${lost.join(", ")} held an entry whose stored ` +
|
|
1053
|
+
`origin pair names no track in this library, so there is no track id to add back and ` +
|
|
1054
|
+
`no undo step can restore it. The other steps put everything else back; the only way ` +
|
|
1055
|
+
`back for ${lost.length > 1 ? "those entries" : "that entry"} is the snapshot at backup_path, ` +
|
|
1056
|
+
`which reverts the whole library.`,
|
|
1057
|
+
}),
|
|
1058
|
+
};
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Validates `order` against a chain's current length: it must be a
|
|
1063
|
+
* permutation of `1..n` for `n = currentLength`, not a partial "move X to Y"
|
|
1064
|
+
* instruction. That is deliberate -- a full permutation is the only shape
|
|
1065
|
+
* that can catch a wrong length, a repeat, a zero, a negative or an
|
|
1066
|
+
* out-of-range value in one pass; a move instruction cannot name any of
|
|
1067
|
+
* those at all. Length is checked first and independently of range, so a
|
|
1068
|
+
* too-short or too-long `order` is reported as such rather than as an
|
|
1069
|
+
* in-range element failing to cover the tail.
|
|
1070
|
+
*
|
|
1071
|
+
* Called twice by reorderPlaylist, against two different reads of the same
|
|
1072
|
+
* chain, for the same reason resolveInsertAt and resolveRemoval are: once in
|
|
1073
|
+
* the pre-check, to fail fast, and again inside the transaction against the
|
|
1074
|
+
* chain BEGIN IMMEDIATE just locked -- the pre-check's read can be stale by
|
|
1075
|
+
* the time the lock is held.
|
|
1076
|
+
*/
|
|
1077
|
+
function validatePermutation(order, currentLength, listId) {
|
|
1078
|
+
if (order.length !== currentLength) {
|
|
1079
|
+
return err("invalid_position", `Playlist ${listId} has ${currentLength} entries; order must name exactly that many positions.`, { detail: NOT_COMMITTED });
|
|
367
1080
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const busyOnCommit = commit === "maybe" && /SQLITE_BUSY|database is locked/i.test(e?.message ?? "");
|
|
373
|
-
if (commit === "not yet" || busyOnCommit) {
|
|
374
|
-
if (open && db)
|
|
375
|
-
rollback(db);
|
|
376
|
-
return mapWriteError(e, title, mdbPath);
|
|
1081
|
+
const seen = new Set();
|
|
1082
|
+
for (const p of order) {
|
|
1083
|
+
if (!Number.isInteger(p) || p < 1 || p > currentLength) {
|
|
1084
|
+
return err("invalid_position", `order must be a permutation of 1..${currentLength}; ${p} is out of range.`, { detail: NOT_COMMITTED });
|
|
377
1085
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
// The honest answer is that the write may have gone through, plus the
|
|
383
|
-
// path of the snapshot from before this session's first write, which is
|
|
384
|
-
// the only case where restoring one is ever the right next step.
|
|
385
|
-
const msg = e?.message ?? String(e);
|
|
386
|
-
return err("library_unreadable", `Writing "${title}" may have gone through: the library could not be verified afterwards (${msg}). ` +
|
|
387
|
-
`Check the library in Engine DJ. A snapshot from before this session's first write is at ${backupPath}.`, { detail: COMMITTED_UNVERIFIED, backup_path: backupPath });
|
|
1086
|
+
if (seen.has(p)) {
|
|
1087
|
+
return err("invalid_position", `order names position ${p} more than once.`, { detail: NOT_COMMITTED });
|
|
1088
|
+
}
|
|
1089
|
+
seen.add(p);
|
|
388
1090
|
}
|
|
389
|
-
|
|
1091
|
+
return undefined;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Reorders an existing playlist's entries to a caller-given permutation of
|
|
1095
|
+
* its current order.
|
|
1096
|
+
*
|
|
1097
|
+
* Unlike add/remove, this rewrites links only -- no INSERT, no DELETE -- so
|
|
1098
|
+
* none of Engine's PlaylistEntity triggers fire and there is no trigger
|
|
1099
|
+
* behaviour to trust or verify here, only the links this function writes
|
|
1100
|
+
* itself. `order[i]` names the *current* 1-based position of the track that
|
|
1101
|
+
* should end up at position `i + 1` (see validatePermutation for why the
|
|
1102
|
+
* spec takes a full permutation rather than a move instruction). Only the
|
|
1103
|
+
* entries whose successor actually changes get an UPDATE -- measured on a
|
|
1104
|
+
* real library, moving an entry from the middle to the front took exactly
|
|
1105
|
+
* two updates, and the identity permutation writes no PlaylistEntity row at
|
|
1106
|
+
* all. It is still not a no-op: like every other op here it stamps
|
|
1107
|
+
* `Playlist.lastEditTime`, and the session's snapshot is copied before the
|
|
1108
|
+
* body ever runs, so the identity case costs a timestamp and (once per
|
|
1109
|
+
* session) a snapshot. Left that way deliberately -- "did this permutation
|
|
1110
|
+
* change anything" can only be answered honestly after BEGIN IMMEDIATE, by
|
|
1111
|
+
* which point the snapshot is already taken, and an edit that reports
|
|
1112
|
+
* success without touching lastEditTime would be the one op whose result
|
|
1113
|
+
* Engine cannot see.
|
|
1114
|
+
*
|
|
1115
|
+
* Shares createPlaylist/addTracksToPlaylist/removeTracksFromPlaylist's
|
|
1116
|
+
* skeleton: a read-only pre-check first, then withWriteTransaction.
|
|
1117
|
+
*/
|
|
1118
|
+
export async function reorderPlaylist(mdbPath, uuid, input, opts) {
|
|
1119
|
+
const { listId, order: requestedOrder } = input;
|
|
1120
|
+
const subject = `playlist ${listId}`;
|
|
1121
|
+
// See createPlaylist for why this has to be checked before anything else
|
|
1122
|
+
// even tries to open the file.
|
|
1123
|
+
if (hasHotJournal(mdbPath))
|
|
1124
|
+
return { ...libraryNeedsRecovery(), detail: NOT_COMMITTED };
|
|
1125
|
+
{
|
|
1126
|
+
let precheck;
|
|
390
1127
|
try {
|
|
391
|
-
|
|
1128
|
+
precheck = new DatabaseSync(mdbPath, { readOnly: true });
|
|
1129
|
+
const exists = precheck.prepare("SELECT 1 FROM Playlist WHERE id = ?").get(listId);
|
|
1130
|
+
if (!exists) {
|
|
1131
|
+
return err("playlist_not_found", `No playlist with id ${listId} in this library.`, {
|
|
1132
|
+
detail: NOT_COMMITTED,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
const gate = gateChain(precheck, listId);
|
|
1136
|
+
if (!gate.ok) {
|
|
1137
|
+
return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
|
|
1138
|
+
detail: NOT_COMMITTED,
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
const invalid = validatePermutation(requestedOrder, gate.order.length, listId);
|
|
1142
|
+
if (invalid)
|
|
1143
|
+
return invalid;
|
|
392
1144
|
}
|
|
393
|
-
catch {
|
|
394
|
-
|
|
1145
|
+
catch (e) {
|
|
1146
|
+
return mapWriteError(e, subject, mdbPath);
|
|
1147
|
+
}
|
|
1148
|
+
finally {
|
|
1149
|
+
try {
|
|
1150
|
+
precheck?.close();
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
/* never opened, or already closed */
|
|
1154
|
+
}
|
|
395
1155
|
}
|
|
396
1156
|
}
|
|
1157
|
+
return withWriteTransaction(mdbPath, uuid, subject, opts, (db) => {
|
|
1158
|
+
// Re-read: BEGIN IMMEDIATE is the first moment nothing else can change
|
|
1159
|
+
// the chain, and gating on the pre-check's read alone would be trusting
|
|
1160
|
+
// one that could already be stale.
|
|
1161
|
+
const gate = gateChain(db, listId);
|
|
1162
|
+
if (!gate.ok) {
|
|
1163
|
+
rollback(db);
|
|
1164
|
+
return err("playlist_chain_damaged", `Playlist ${listId}: ${gate.reason}. Nothing was changed.`, {
|
|
1165
|
+
detail: NOT_COMMITTED,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
// Re-validated against this read of the chain, not the pre-check's: see
|
|
1169
|
+
// validatePermutation's comment.
|
|
1170
|
+
const invalid = validatePermutation(requestedOrder, gate.order.length, listId);
|
|
1171
|
+
if (invalid) {
|
|
1172
|
+
rollback(db);
|
|
1173
|
+
return invalid;
|
|
1174
|
+
}
|
|
1175
|
+
// The entry-identity chain the final order must read back as, built from
|
|
1176
|
+
// this walk -- taken *before* any UPDATE below -- rather than re-queried
|
|
1177
|
+
// afterwards, the same reason addTracksToPlaylist builds `expected` up
|
|
1178
|
+
// front: the readback check must not depend on the writes it is meant to
|
|
1179
|
+
// verify.
|
|
1180
|
+
const originalRefs = gate.order.length > 0 ? walkFrom(db, listId, gate.order[0]) : [];
|
|
1181
|
+
const expected = requestedOrder.map((p) => originalRefs[p - 1]);
|
|
1182
|
+
// newSeq[i] is the entry id that must sit at position i + 1 once this
|
|
1183
|
+
// returns; each entry's new successor is the id that follows it there,
|
|
1184
|
+
// or 0 for the new tail.
|
|
1185
|
+
const newSeq = requestedOrder.map((p) => gate.order[p - 1]);
|
|
1186
|
+
const currentNext = new Map(readChain(db, listId).map((r) => [r.id, r.next]));
|
|
1187
|
+
const link = db.prepare("UPDATE PlaylistEntity SET nextEntityId = ? WHERE id = ?");
|
|
1188
|
+
for (let i = 0; i < newSeq.length; i++) {
|
|
1189
|
+
const entryId = newSeq[i];
|
|
1190
|
+
const newNext = i + 1 < newSeq.length ? newSeq[i + 1] : 0;
|
|
1191
|
+
// Only entries whose link actually changes are written -- this avoids
|
|
1192
|
+
// n redundant PlaylistEntity writes for an identity permutation. It
|
|
1193
|
+
// does not make the call itself a no-op: lastEditTime is still stamped
|
|
1194
|
+
// below, and the session's snapshot was already copied before this ran
|
|
1195
|
+
// (see the doc-comment above).
|
|
1196
|
+
if (currentNext.get(entryId) !== newNext)
|
|
1197
|
+
link.run(newNext, entryId);
|
|
1198
|
+
}
|
|
1199
|
+
// Re-gated, not just re-walked: see addTracksToPlaylist's comment on the
|
|
1200
|
+
// same pairing. gateChain confirms the structure is still one sound
|
|
1201
|
+
// chain; sameOrder confirms the values landed in the requested order.
|
|
1202
|
+
const newHeadId = newSeq.length > 0 ? newSeq[0] : 0;
|
|
1203
|
+
const finalGate = gateChain(db, listId);
|
|
1204
|
+
if (!finalGate.ok || (newSeq.length > 0 && !sameOrder(walkFrom(db, listId, newHeadId), expected))) {
|
|
1205
|
+
rollback(db);
|
|
1206
|
+
return err("library_unreadable", `The entry chain for playlist ${listId} did not read back as written; nothing was changed.`, { detail: NOT_COMMITTED });
|
|
1207
|
+
}
|
|
1208
|
+
// No trigger maintains this: measured, changing PlaylistEntity leaves the
|
|
1209
|
+
// parent Playlist row untouched. datetime('now'), not strftime('%s') --
|
|
1210
|
+
// that is the Track convention, and Playlist.lastEditTime is TEXT.
|
|
1211
|
+
db.prepare("UPDATE Playlist SET lastEditTime = datetime('now') WHERE id = ?").run(listId);
|
|
1212
|
+
// The inverse permutation: if order[i] = p, then inverse[p - 1] = i + 1.
|
|
1213
|
+
// Applying it undoes this call exactly, because reorderPlaylist's own
|
|
1214
|
+
// effect is just "relabel positions by this permutation" -- composing a
|
|
1215
|
+
// permutation with its inverse is the identity.
|
|
1216
|
+
const inverse = new Array(requestedOrder.length);
|
|
1217
|
+
for (let i = 0; i < requestedOrder.length; i++) {
|
|
1218
|
+
inverse[requestedOrder[i] - 1] = i + 1;
|
|
1219
|
+
}
|
|
1220
|
+
return {
|
|
1221
|
+
playlist_id: listId,
|
|
1222
|
+
undo: [{ tool: "reorder_playlist", arguments: { playlist_id: listId, order: inverse } }],
|
|
1223
|
+
undo_complete: true,
|
|
1224
|
+
};
|
|
1225
|
+
});
|
|
397
1226
|
}
|