feat(scanner): force rescan = original scan; feat(filters): the manually-edited filter (faceted plan Phase 13)
Release / build-and-push (push) Successful in 3m7s
Release / build-and-push (push) Successful in 3m7s
Force rescan contract change (owner-directed): the editorial-keep skip
is retired. Force rescan now re-derives EVERY field on EVERY book from
all sources — multi-file books included (the folder/zip flows ran the
full chain on create and on manifest-flagged edits, but skipped the
sidecar apply under force). metadata_overrides is the only protection:
ApplyMetadataOverrides re-applies manual values inside the writer, so
manual edits survive; everything else re-derives.
- A force pass also STAMPS each re-derived book's manifest with the
current parser version (success-only — same truthfulness rule as
Phase 10) and closes its open processing issues (Phase 11 self-heal).
Force rescan and the ev-bump trickle now converge: either leaves the
library fully current; the deploy order is bump-first, then Rescan
All (documented in the plan and contract_pins).
- Single-file force rescan already ran the full chain — unchanged,
now pinned.
NEW filter field manually_edited ("Manually edited", boolean):
cardinality(mi.metadata_overrides) > 0 / = 0. Composed-engine (qf)
only — the builder's Book facts group gains it with is true/false;
no value menu, no suggestion surface; family lens inherited.
Tests: multifile force applies sidecar metadata + stamps + override
protection; single-file pin; the filter both directions scoped to the
test library; adjacent suites (Audiobook, Calibre, ExtractorVersion,
CrossLibraryMove, ProcessingIssues, FilterBuilder, SchemaIdempotency)
green. The extractor mechanism itself is untouched — the owner bumps
scan_extractor_version manually via the admin UI (plan Phase 13 §3).
This commit is contained in:
1 parent
9b5defcb31
commit
a42c6d4f75
7 files changed
+239
-6
No files matched your search
@@ -749,6 +749,7 @@ func (s *SearchService) ComposedSearch(ctx context.Context, params SearchParams)
|
||||
b.joinBlock() + `
|
||||
WHERE ` + whereSQL +
|
||||
composedSortOrder(sortBind, qBind, exactBind) + ` ` + limitBind + ` OFFSET ` + offsetBind
|
||||
fmt.Printf("[DEBUG COMPOSED] %s\n", sqlStr)
|
||||
|
||||
rows, err := s.raw.Query(ctx, sqlStr, b.args...)
|
||||
if err != nil {
|
||||
|
||||
@@ -249,6 +249,18 @@ var filterFields = []FilterField{
|
||||
{Key: "abridged", Label: "Abridged", Group: GroupBookFacts, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
Clause: clauseBoolFactory("mi.abridged"), Suggest: suggestNone},
|
||||
{Key: "manually_edited", Label: "Manually edited", Group: GroupBookFacts, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
// metadata_overrides is NOT NULL TEXT[] — '{}' means untouched.
|
||||
Clause: func(r FilterRule, b *filterBuilder) string {
|
||||
switch r.Op {
|
||||
case OpIsTrue:
|
||||
return "(cardinality(mi.metadata_overrides) > 0)"
|
||||
case OpIsFalse:
|
||||
return "(cardinality(mi.metadata_overrides) = 0)"
|
||||
}
|
||||
return "TRUE"
|
||||
}, Suggest: suggestNone},
|
||||
{Key: "cover", Label: "Cover", Group: GroupBookFacts, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseCover, Suggest: suggestNone},
|
||||
|
||||
@@ -1490,6 +1490,15 @@ func (s *MediaScanner) processExistingRow(ctx context.Context, existingItem data
|
||||
// Use UPDATE instead of DELETE+INSERT to preserve created_at
|
||||
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
|
||||
fmt.Printf("Warning: failed to update existing media item: %v\n", err)
|
||||
} else {
|
||||
// Force rescan = original-scan semantics (faceted plan
|
||||
// Phase 13): stamp the fresh manifest with the current
|
||||
// parser version and close open ledger issues — success-
|
||||
// only, same truthfulness rule as the bulk path.
|
||||
dir := bookSidecarDir(path)
|
||||
s.storeManifest(ctx, libraryID, &scanEntry{ID: existingItem.ID, FilePath: s.getRelativePath(path)},
|
||||
hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
|
||||
s.resolveIssuesForItem(ctx, existingItem.ID)
|
||||
}
|
||||
// Recompute hash identifiers too - a force rescan is the admin's
|
||||
// backfill tool and must refresh stale or missing hashes.
|
||||
@@ -1541,6 +1550,16 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
|
||||
}
|
||||
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
|
||||
fmt.Printf("Warning: failed to update existing media item: %v\n", err)
|
||||
} else {
|
||||
// Force rescan = original-scan semantics (faceted plan
|
||||
// Phase 13): the full extraction just re-derived this row,
|
||||
// so stamp the fresh manifest with the current parser
|
||||
// version and close its open ledger issues. Success-only —
|
||||
// a stamp over a failed update would claim parser work
|
||||
// that never ran.
|
||||
dir := bookSidecarDir(path)
|
||||
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
|
||||
s.resolveIssuesForItem(ctx, entry.ID)
|
||||
}
|
||||
// Recompute hash identifiers too - a force rescan is the admin's
|
||||
// backfill tool and must refresh stale or missing hashes.
|
||||
|
||||
@@ -951,10 +951,11 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
||||
// ordering): the generic update's format-group pass derives the
|
||||
// mime from the sentinel (none) and chapters from
|
||||
// metadata.ChapterCount (unset) — the focused payload write below
|
||||
// must win. Force rescan keeps the editorial-keep contract (the
|
||||
// per-book RescanMediaItem is the editorial tool), so it skips
|
||||
// the apply even when the ladder flagged a drift it caused.
|
||||
if manifestChanged && !s.forceRescan {
|
||||
// must win. Force rescan applies TOO (faceted plan Phase 13:
|
||||
// force rescan = original-scan semantics — the full chain
|
||||
// re-lands on every field, with metadata_overrides as the only
|
||||
// protection, honored inside the apply).
|
||||
if manifestChanged || s.forceRescan {
|
||||
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, sentinel, metadata); applyErr != nil {
|
||||
fmt.Printf("Warning: %v\n", applyErr)
|
||||
} else {
|
||||
@@ -1191,10 +1192,16 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
|
||||
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload)
|
||||
if changed || s.forceRescan || manifestChanged {
|
||||
// Editorial apply BEFORE the payload write — see the folder
|
||||
// processor for the format-group ordering rationale.
|
||||
if manifestChanged && !s.forceRescan {
|
||||
// processor for the format-group ordering rationale. Force
|
||||
// rescan applies too (Phase 13: original-scan semantics,
|
||||
// metadata_overrides honored inside the apply).
|
||||
if manifestChanged || s.forceRescan {
|
||||
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, zipPath, metadata); applyErr != nil {
|
||||
fmt.Printf("Warning: %v\n", applyErr)
|
||||
} else {
|
||||
// Ledger self-heal (Phase 11): the book's metadata
|
||||
// re-applied successfully — close its open issues.
|
||||
s.resolveIssuesForItem(ctx, existing.ID)
|
||||
}
|
||||
s.storeMultiFileManifest(ctx, libraryID, zipPath, zipPath, filepath.Dir(zipPath))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user