package main // Extractor-version (ev) mechanism, faceted plan Phase 10: parser fixes // (7e4c97f6, genre model v2) must reach rows ingested under an older // parser without a force rescan. The manifest's ev stamp is the // per-item provenance; scan_extractor_version is the current parser's // claim; a mismatch re-extracts on a later scan, bounded per scan. // // State machine pinned here, driven through REAL scans over a temp // library (the corruption probe makes re-extraction observable: the // only writer that restores the sidecar values is a re-extraction): // // create → stamped ev=1 (current parser, truthful) // equal → no re-extract (corruption persists) // wiped → backfill stores ev UNSET, never stamps, never re-extracts // bump → re-extract ONCE (corruption healed, ev=2) // equal → stable (second corruption persists) import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os" "path/filepath" "strconv" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/require" ) // storedManifestEv reads the "ev" stamp from an item's stored manifest // (0 = absent — the backfill's v1-or-unknown marker). func storedManifestEv(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID) int { t.Helper() row, err := setup.DB.GetMediaScanState(context.Background(), itemID) require.NoError(t, err, "scan-state row must exist — every book needs an ev home") var m map[string]any require.NoError(t, json.Unmarshal(row.Manifest, &m)) ev, _ := m["ev"].(float64) return int(ev) } // corruptItemColumn scribbles a scan-owned column on a TEST-CREATED item // so a later re-extraction is observable (the sidecar rewrites it back). // The row is test data cascade-deleted with the library — never real rows. func corruptItemColumn(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID, column, value string) { t.Helper() tag, err := setup.DBPool.Exec(context.Background(), fmt.Sprintf("UPDATE media_items SET %s = $1 WHERE id = $2", column), value, itemID) require.NoError(t, err) require.Equal(t, int64(1), tag.RowsAffected(), "corruption must hit exactly the test item") } func setExtractorVersion(t *testing.T, setup *TestServerSetup, v int) { t.Helper() body, _ := json.Marshal(map[string]string{"key": "scan_extractor_version", "value": strconv.Itoa(v)}) req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/system/settings", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+setup.Token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() require.Equal(t, http.StatusOK, resp.StatusCode, "settings API is the only sanctioned write path") } func TestExtractorVersionReextract(t *testing.T) { setup := setupTestServer(t) t.Cleanup(func() { _ = setup.Close() }) ctx := context.Background() admin, _ := moveTestAdminID(t, setup) libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test EV Library %d", time.Now().UnixNano()), "ebooks") t.Cleanup(func() { deleteLibraries(t, setup, libID) }) // metadata.opf sidecar: the re-extraction's provenance source. bookDir := filepath.Join(folder, "EV Probe Author", "EV Probe Book") require.NoError(t, os.MkdirAll(bookDir, 0o755)) opf := ` EV Probe Book EV Probe Author ` require.NoError(t, os.WriteFile(filepath.Join(bookDir, "metadata.opf"), []byte(opf), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(bookDir, "EV Probe Book.epub"), []byte("dummy epub content"), 0o644)) scanTestFolder(t, setup, admin, folder) item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub")) require.True(t, found, "sidecar book must import") // Create stamps the current parser version (truthful). require.Equal(t, 1, storedManifestEv(t, setup, item.ID), "create must stamp the default ev=1") // Equality → skip: corruption persists (nothing re-extracts at ev=1). // NOTE: `author` is trigger-owned (sync_author_publisher_display // rewrites it from the authors array on every UPDATE) — corrupt a // trigger-free column the re-extraction rewrites: title. corruptItemColumn(t, setup, item.ID, "title", "ZZZ corrupted") scanTestFolder(t, setup, admin, folder) item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub")) require.True(t, found) require.Equal(t, "ZZZ corrupted", item.Title, "equal ev must NOT re-extract (corruption persists)") require.Equal(t, 1, storedManifestEv(t, setup, item.ID)) // Wipe the scan state (test-owned row) to simulate a pre-release // book: backfill re-fingerprints WITHOUT re-extraction and WITHOUT // stamping — the row never claims a version it can't prove. _, err := setup.DBPool.Exec(ctx, "DELETE FROM media_scan_state WHERE media_item_id = $1", item.ID) require.NoError(t, err) scanTestFolder(t, setup, admin, folder) require.Equal(t, 0, storedManifestEv(t, setup, item.ID), "backfill must store ev UNSET (v1-or-unknown)") item, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub")) require.Equal(t, "ZZZ corrupted", item.Title, "backfill must NOT re-extract") // Bump via the SETTINGS API; the absent ev rides v1 → mismatch → // re-extract once: the corruption heals and the fresh manifest // carries ev=2. setExtractorVersion(t, setup, 2) t.Cleanup(func() { setExtractorVersion(t, setup, 1) }) scanTestFolder(t, setup, admin, folder) item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub")) require.True(t, found) require.Equal(t, "EV Probe Book", item.Title, "ev mismatch must re-extract (sidecar value restored)") require.Equal(t, 2, storedManifestEv(t, setup, item.ID), "post-extraction store stamps the new ev") // Then stable: the second corruption persists at ev=2 == setting 2. corruptItemColumn(t, setup, item.ID, "title", "ZZZ corrupted again") scanTestFolder(t, setup, admin, folder) item, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub")) require.Equal(t, "ZZZ corrupted again", item.Title, "equal ev must NOT re-extract again (stable)") require.Equal(t, 2, storedManifestEv(t, setup, item.ID)) } func TestExtractorVersionMultifile(t *testing.T) { setup := setupTestServer(t) t.Cleanup(func() { _ = setup.Close() }) admin, _ := moveTestAdminID(t, setup) libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test EV Audio %d", time.Now().UnixNano())) t.Cleanup(func() { deleteLibraries(t, setup, libID) }) // A canonical folder book, sidecar-less: the Phase 6 seed gives it // an entries-less manifest — now also its ev home. writeTestTrack(t, filepath.Join(folder, "EV Audio Author", "EV Audio Title", "01 First.mp3")) writeTestTrack(t, filepath.Join(folder, "EV Audio Author", "EV Audio Title", "02 Second.mp3")) scanTestFolder(t, setup, admin, folder) book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title")) require.True(t, found, "folder book must import") require.Equal(t, "EV Audio Title", book.Title) require.Equal(t, 1, storedManifestEv(t, setup, book.ID), "multifile create must stamp the default ev=1") // Equality → skip: the multifile ladder's unchanged gate stays shut. corruptItemColumn(t, setup, book.ID, "title", "ZZZ corrupted") scanTestFolder(t, setup, admin, folder) book, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title")) require.Equal(t, "ZZZ corrupted", book.Title, "equal ev must NOT re-apply (corruption persists)") require.Equal(t, 1, storedManifestEv(t, setup, book.ID)) // Bump → the ev gate fires through multifileSidecarChanged → the // editorial apply re-derives the title from the folder basename. setExtractorVersion(t, setup, 2) t.Cleanup(func() { setExtractorVersion(t, setup, 1) }) scanTestFolder(t, setup, admin, folder) book, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title")) require.True(t, found) require.Equal(t, "EV Audio Title", book.Title, "ev mismatch must re-extract the folder book") require.Equal(t, 2, storedManifestEv(t, setup, book.ID), "change-path store stamps the new ev") // Then stable. corruptItemColumn(t, setup, book.ID, "title", "ZZZ corrupted again") scanTestFolder(t, setup, admin, folder) book, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title")) require.Equal(t, "ZZZ corrupted again", book.Title, "equal ev must NOT re-apply again (stable)") require.Equal(t, 2, storedManifestEv(t, setup, book.ID)) }