Reverses the Phase 4 scope cut: for EXISTING folder/zip audiobooks a
sidecar edit was invisible to everything, including force rescan —
updateAudioBookItem writes only scan-derived fields, and these formats
often have no useful embedded tags, so the sidecar is the only source.
- Change fingerprint gains the manifest compare in both processors:
changed := size differs || track payload differs || manifest drifted.
The ladder runs against the bulk-loaded map entry (stat pairs only,
zero extra queries); NULL stored manifest backfills without
re-extraction (deploy-day rule, same as single-file).
- Manifest-changed applies the once-extracted book metadata to the
editorial columns (updateMediaItemWithMetadata, overrides protected)
and re-runs the playlist hook. The apply runs BEFORE the focused
payload write: the generic update's format-group pass derives mime
from the sentinel (none) and chapters from metadata.ChapterCount
(unset) — the payload write must win (rescanMultiFileAudioBook's
ordering). Track-only changes keep the existing editorial-keep
update; force rescan keeps it too.
- Manifests seeded at multifile create (folder names derive from the
sentinel — the exact representative the extractor reads — via
detectSidecarChangeWithRepresentative, so manifest and extractor can
never disagree; a directory-basename cover is ignored by the
extractor and therefore not tracked).
- Zip parity fixes: the fast path now lifts archive state like the
folder path always did (a returned zip book stayed archived
forever), and the change branch handles the unchanged-archived case.
- Single-file sidecar change path: extract-failure guard — the old
non-nil empty &MediaMetadata{} fallback bypassed the update path's
filename-title guard (preExtracted == nil only) and would blank the
title on a failed extraction; the row is now left untouched and the
manifest refreshed. Empty-success extraction (sidecar removed from a
tag-less book) now passes nil so the create-equivalent chain (tags →
filename) re-derives instead of blanking.
- Multifile changes now count in the scan summary (changedItems);
they were previously uncounted.
Tests: sentinel-name derivation unit pin; folder sidecar edit
(title + collection sync + fresh manifest + steady-state skip),
track-only change keeps editorial, zip sidecar edit + archive
restore, sidecar removal falls back to filename (the wipe-guard's
testable sibling). Full container suite: green except the documented
pre-existing TestAdminCreateUser red.
177 lines
6.2 KiB
Go
177 lines
6.2 KiB
Go
package services
|
|
|
|
// Unit tests for the sidecar manifest ladder (faceted plan Part 2 §3):
|
|
// stat-pair fast compare, hash-based repoint reconciliation, and the
|
|
// change verdicts. Real files only where the hash compare needs them.
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDecodeSidecarManifest(t *testing.T) {
|
|
if decodeSidecarManifest(nil) != nil {
|
|
t.Error("NULL manifest decodes to nil (backfill territory)")
|
|
}
|
|
if decodeSidecarManifest([]byte("{not json")) != nil {
|
|
t.Error("corrupt manifest decodes to nil (self-healing backfill)")
|
|
}
|
|
m := decodeSidecarManifest([]byte(`{"v":1,"entries":[{"n":"metadata.opf","s":10,"m":100,"h":"abc"}]}`))
|
|
if m == nil || len(m.Entries) != 1 || m.Entries[0].Sha != "abc" {
|
|
t.Errorf("valid manifest round-trip failed: %+v", m)
|
|
}
|
|
}
|
|
|
|
// TestStatSidecarManifestSentinelNames pins the Phase 6 name derivation
|
|
// for folder books: the manifest must stat exactly the names the
|
|
// extractor consumes through the sentinel (metadata sidecars + the
|
|
// standard cover set) — never the directory's own basename (the
|
|
// extractor's basename-pattern covers resolve against the sentinel's
|
|
// basename, which generates no candidates).
|
|
func TestStatSidecarManifestSentinelNames(t *testing.T) {
|
|
dir := t.TempDir()
|
|
write := func(name string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("metadata.json")
|
|
write("cover.jpg")
|
|
write(filepath.Base(dir) + ".jpg") // extractor never reads this
|
|
sentinel := filepath.Join(dir, ".audiobook-root")
|
|
|
|
m := statSidecarManifest(sentinel, dir)
|
|
if m == nil {
|
|
t.Fatal("manifest must stat, not nil")
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, e := range m.Entries {
|
|
seen[e.Name] = true
|
|
}
|
|
if !seen["metadata.json"] || !seen["cover.jpg"] {
|
|
t.Errorf("extractor-consumed sidecars missing from manifest: %v", seen)
|
|
}
|
|
if seen[filepath.Base(dir)+".jpg"] {
|
|
t.Errorf("directory-basename cover must not be tracked (extractor ignores it): %v", seen)
|
|
}
|
|
for name := range seen {
|
|
if name == ".audiobook-root.jpg" {
|
|
t.Errorf("sentinel-basename candidates must not appear (they cannot exist): %v", seen)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReconcileSidecarManifest(t *testing.T) {
|
|
dir := t.TempDir()
|
|
write := func(name, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// The book: one hashed sidecar + one size-only cover.
|
|
write("metadata.opf", `<opf>original content</opf>`)
|
|
originalSha, err := sha256File(filepath.Join(dir, "metadata.opf"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
write("cover.jpg", "coverbytes")
|
|
// stored is seeded exactly the way production backfill does: a real
|
|
// stat pass + hash fill. Hand-built fake mtimes would drift on the
|
|
// first compare (real files' mtimes never equal a constant).
|
|
stored := hashSidecarManifest("book.epub", dir, statSidecarManifest(filepath.Join(dir, "book.epub"), dir))
|
|
|
|
stat := func() *sidecarManifest {
|
|
m := statSidecarManifest(filepath.Join(dir, "book.epub"), dir)
|
|
if m == nil || len(m.Entries) < 2 {
|
|
t.Fatalf("stat manifest should find both sidecars, got %+v", m)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Stat pairs identical → unchanged, zero reads, nothing to store.
|
|
fresh, changed := reconcileSidecarManifest(dir, stored, stat())
|
|
if changed || fresh != nil {
|
|
t.Errorf("identical stats must be unchanged, got changed=%v fresh=%v", changed, fresh)
|
|
}
|
|
|
|
// Repoint: mtime drifts, content identical → reconciled fresh
|
|
// manifest with the refreshed stat pair and the KEPT hash, no change.
|
|
if err := os.Chtimes(filepath.Join(dir, "metadata.opf"), time.Now(), time.Now().Add(time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fresh, changed = reconcileSidecarManifest(dir, stored, stat())
|
|
if changed {
|
|
t.Error("mtime drift with identical content is repoint reconciliation, not a change")
|
|
}
|
|
if fresh == nil || len(fresh.Entries) != 2 {
|
|
t.Fatalf("repoint must produce a refreshed manifest, got %+v", fresh)
|
|
}
|
|
if fresh.Entries[0].Sha != originalSha {
|
|
t.Error("repointed entry keeps its stored hash")
|
|
}
|
|
if fresh.Entries[0].MTime == stored.Entries[0].MTime {
|
|
t.Error("repointed entry refreshes its stat pair")
|
|
}
|
|
|
|
// Same-size edit: drift + hash differs → changed.
|
|
write("metadata.opf", `<opf>mutated XYY content</opf>`) // +1 byte, and hash differs
|
|
_, changed = reconcileSidecarManifest(dir, stored, stat())
|
|
if !changed {
|
|
t.Error("content drift must be a change")
|
|
}
|
|
|
|
// Same-LENGTH edit (the size-only detection blind spot) → changed.
|
|
write("metadata.opf", `<opf>original Xontent</opf>`)
|
|
_, changed = reconcileSidecarManifest(dir, stored, stat())
|
|
if !changed {
|
|
t.Error("same-size content drift must be a change (hash compare)")
|
|
}
|
|
|
|
// New sidecar appears → changed.
|
|
write("metadata.json", `{"title":"X"}`)
|
|
_, changed = reconcileSidecarManifest(dir, stored, stat())
|
|
if !changed {
|
|
t.Error("an appearing sidecar must be a change")
|
|
}
|
|
|
|
// Sidecar vanishes AND everything else returns to the stored state
|
|
// (opf restored; its mtime drifts but the hash proves equality) →
|
|
// the manifest reconciles, not a change.
|
|
if err := os.Remove(filepath.Join(dir, "metadata.json")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
write("metadata.opf", `<opf>original content</opf>`)
|
|
fresh, changed = reconcileSidecarManifest(dir, stored, stat())
|
|
if changed {
|
|
t.Errorf("returning to the stored state is not a change: %+v", fresh)
|
|
}
|
|
if fresh == nil || len(fresh.Entries) != 2 {
|
|
t.Errorf("reconciled manifest covers both sidecars: %+v", fresh)
|
|
}
|
|
}
|
|
|
|
// An unhashable drift (a stored entry with no baseline hash — legacy
|
|
// or partially-written manifest) is a change: the manifest cannot prove
|
|
// content equality for it.
|
|
func TestReconcileUnhashableDrift(t *testing.T) {
|
|
dir := t.TempDir()
|
|
stored := &sidecarManifest{Version: 1, Entries: []manifestSidecar{
|
|
{Name: "cover.jpg", Size: 10, MTime: 1000},
|
|
}}
|
|
computed := &sidecarManifest{Version: 1, Entries: []manifestSidecar{
|
|
{Name: "cover.jpg", Size: 11, MTime: 1001},
|
|
}}
|
|
fresh, changed := reconcileSidecarManifest(dir, stored, computed)
|
|
if !changed {
|
|
t.Error("stat drift without a stored baseline hash must be a change")
|
|
}
|
|
if fresh != nil {
|
|
t.Error("a change never returns a fresh manifest (caller builds one)")
|
|
}
|
|
}
|