feat(scanner): multifile folder/zip sidecar detection (faceted plan Phase 6)
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.
This commit is contained in:
1 parent
5245f292ca
commit
2c5ad014c0
5 files changed
+444
-17
No files matched your search
@@ -1498,12 +1498,36 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("sidecar change re-read failed: %v", err)
|
||||
}
|
||||
// Extract once and pass it in, so the playlist sync below sees
|
||||
// the sidecar's ordering entries (metadata_overrides stay
|
||||
// protected inside the update path either way).
|
||||
// Extract once; the result also carries the sidecar's playlist
|
||||
// entries for the sync below.
|
||||
metadata, extractErr := s.extractMetadata(path)
|
||||
dir := bookSidecarDir(path)
|
||||
if extractErr != nil || metadata == nil {
|
||||
metadata = &MediaMetadata{}
|
||||
// Extract-failure guard (faceted plan Phase 6 audit find):
|
||||
// UpdateMediaItem writes editorial columns unconditionally,
|
||||
// and preExtracted != nil disables the update path's
|
||||
// filename-title guard — a non-nil empty fallback would blank
|
||||
// the row. Keep the row untouched and refresh the manifest so
|
||||
// the failure isn't re-detected every scan; Phase 9's ledger
|
||||
// records the processing issue. No extractor error path is
|
||||
// reachable today (mergeMetadata never fails) — this is the
|
||||
// regression net for the class of bug the guard exists for.
|
||||
fmt.Printf("Warning: sidecar re-extraction failed for %s: %v — row left unchanged\n", path, extractErr)
|
||||
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
|
||||
return false, nil
|
||||
}
|
||||
// Playlist entries ride the FIRST extraction either way — capture
|
||||
// them before the reroute below.
|
||||
playlistSeries := metadata.PlaylistSeries
|
||||
if metadata.Title == "" {
|
||||
// Empty-success extraction (a tag-less book whose sidecar
|
||||
// vanished): preExtracted != nil would skip the update path's
|
||||
// filename-title guard AND the audio tag re-derivation —
|
||||
// blanking the title. Passing nil delegates to the function's
|
||||
// own create-equivalent chain instead (sidecars → folder
|
||||
// structure → tags → filename), mirroring the create path's
|
||||
// last rung.
|
||||
metadata = nil
|
||||
}
|
||||
if err := s.updateMediaItemWithMetadata(ctx, existingItem, path, metadata); err != nil {
|
||||
fmt.Printf("Warning: failed to update media item after sidecar change %s: %v\n", path, err)
|
||||
@@ -1511,10 +1535,9 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
|
||||
// Playlist sync on the change path (plan Correction 2): a
|
||||
// "(Reading Order)" entry added to an EXISTING book's sidecar
|
||||
// still creates its collection — add-if-missing is idempotent.
|
||||
s.syncPlaylistCollectionsAfterCreate(ctx, entry.ID, metadata.PlaylistSeries)
|
||||
s.syncPlaylistCollectionsAfterCreate(ctx, entry.ID, playlistSeries)
|
||||
// Fresh full manifest: the next scan compares against TODAY's
|
||||
// sidecars, not the stale pre-edit fingerprint.
|
||||
dir := bookSidecarDir(path)
|
||||
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
|
||||
s.changedItems++
|
||||
return false, nil
|
||||
|
||||
@@ -840,7 +840,25 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
||||
}
|
||||
|
||||
existing, existingErr := s.getMediaItemByFilePath(ctx, rootRel, libraryID)
|
||||
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(tracks)) {
|
||||
|
||||
// The representative drives the sidecar/folder-structure checks; they
|
||||
// all look at Dir(representative), so a sentinel INSIDE the root
|
||||
// resolves covers and metadata.json living there (including disc-
|
||||
// layout roots where track zero sits deeper).
|
||||
sentinel := filepath.Join(rootDir, ".audiobook-root")
|
||||
|
||||
// Manifest-gated sidecar detection (faceted plan Phase 6): a
|
||||
// size-stable folder book is changed by EDITING its sidecars —
|
||||
// including on scans where the track fingerprint says "unchanged".
|
||||
// The ladder is stat-only in the steady state; a detected edit falls
|
||||
// through to the full probe below so the once-extracted metadata can
|
||||
// feed the editorial apply.
|
||||
manifestChanged := false
|
||||
if existingErr == nil && !s.forceRescan {
|
||||
manifestChanged = s.multifileSidecarChanged(ctx, libraryID, rootDir, sentinel, rootDir)
|
||||
}
|
||||
|
||||
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(tracks)) && !manifestChanged {
|
||||
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
||||
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to unarchive folder book %s: %v\n", rootRel, err)
|
||||
@@ -888,11 +906,9 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
||||
}
|
||||
orderTracksByTags(probed)
|
||||
|
||||
// The representative path drives the sidecar/folder-structure checks;
|
||||
// they all look at Dir(representative), so a sentinel INSIDE the root
|
||||
// resolves covers and metadata.json living there (including disc-
|
||||
// layout roots where track zero sits deeper).
|
||||
sentinel := filepath.Join(rootDir, ".audiobook-root")
|
||||
// The sentinel is defined above, next to the manifest check that
|
||||
// shares it; the probe pass reuses it as the extraction
|
||||
// representative.
|
||||
metadata := s.extractAudioBookMetadata(sentinel, rootFolder, firstReport, tracks[0], filepath.Base(rootDir))
|
||||
payload := buildMultiFileAudioPayload("folder", metadata.AudioMeta, probed)
|
||||
metadata.AudioMeta = payload
|
||||
@@ -915,10 +931,34 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
||||
switch {
|
||||
case err == nil:
|
||||
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload)
|
||||
if changed || s.forceRescan {
|
||||
if changed || s.forceRescan || manifestChanged {
|
||||
// Editorial apply BEFORE the payload write (rescanMultiFileAudioBook's
|
||||
// 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 {
|
||||
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, sentinel, metadata); applyErr != nil {
|
||||
fmt.Printf("Warning: %v\n", applyErr)
|
||||
}
|
||||
// Fresh full manifest: the next scan compares against
|
||||
// TODAY's sidecars, not the stale pre-edit fingerprint.
|
||||
s.storeMultiFileManifest(ctx, libraryID, rootDir, sentinel, rootDir)
|
||||
}
|
||||
if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
|
||||
return err
|
||||
}
|
||||
s.changedItems++
|
||||
// The book is demonstrably back and fresh: lift any archive
|
||||
// state the missing-root scans set (the same lift the
|
||||
// unchanged path applies).
|
||||
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
||||
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to unarchive folder book %s: %v\n", rootRel, err)
|
||||
}
|
||||
}
|
||||
} else if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
||||
// Unchanged content that was archived while the root was gone.
|
||||
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
||||
@@ -936,6 +976,10 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
||||
fmt.Printf("Warning: failed to set format group for folder book %s: %v\n", rootRel, err)
|
||||
}
|
||||
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
|
||||
// Seed the sidecar manifest at create (Phase 6): names derive
|
||||
// from the sentinel — the exact representative the extractor
|
||||
// reads — so the manifest can never disagree with extraction.
|
||||
s.seedMultiFileManifest(ctx, libraryID, created.ID, rootRel, sentinel, rootDir, totalSize)
|
||||
fmt.Printf("[AUDIO] Created folder book '%s' (%d tracks) at %s\n", metadata.Title, len(tracks), rootRel)
|
||||
s.newItems++
|
||||
default:
|
||||
@@ -964,6 +1008,63 @@ func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing databas
|
||||
return nil
|
||||
}
|
||||
|
||||
// multifileSidecarChanged runs the manifest ladder for a folder/zip book
|
||||
// (faceted plan Phase 6): a size-stable multi-file book is changed by
|
||||
// EDITING its sidecars — the track-count/size fingerprint never saw it,
|
||||
// not even on force rescan. The bulk-map entry is keyed by the item's
|
||||
// file_path, but sidecar NAMES derive from the representative the
|
||||
// extractor reads (the sentinel inside a folder root, the zip itself).
|
||||
// NULL stored manifest → backfill only (deploy-day rule); no bulk entry
|
||||
// (nil map, brand-new item) → not detected this pass.
|
||||
func (s *MediaScanner) multifileSidecarChanged(ctx context.Context, libraryID pgtype.UUID, itemPath, representative, dir string) bool {
|
||||
entry, ok := s.scanStateLookup(libraryID, itemPath)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return s.detectSidecarChangeWithRepresentative(ctx, entry, libraryID, representative, dir)
|
||||
}
|
||||
|
||||
// applyMultiFileSidecarEdit lands the once-extracted book metadata
|
||||
// (sidecars → folder structure → first-track tags → fallback title) on
|
||||
// the editorial columns of an EXISTING folder/zip book — exactly what
|
||||
// rescanMultiFileAudioBook applies; metadata_overrides stay protected
|
||||
// inside the update. The caller runs the focused payload write AFTER
|
||||
// this: the generic update's format-group pass derives the mime type
|
||||
// from the sentinel/zip path (none/audio-container) and the chapter
|
||||
// count from metadata.ChapterCount (unset on the book chain), so the
|
||||
// payload write must win. The playlist hook re-runs because a
|
||||
// "(Reading Order)" entry added to an EXISTING book's sidecar still
|
||||
// creates its collection (add-if-missing is idempotent).
|
||||
func (s *MediaScanner) applyMultiFileSidecarEdit(ctx context.Context, existing database.MediaItems, representative string, metadata *MediaMetadata) error {
|
||||
if err := s.updateMediaItemWithMetadata(ctx, existing, representative, metadata); err != nil {
|
||||
return fmt.Errorf("failed to apply sidecar metadata to %s: %w", existing.FilePath, err)
|
||||
}
|
||||
s.syncPlaylistCollectionsAfterCreate(ctx, existing.ID, metadata.PlaylistSeries)
|
||||
fmt.Printf("[AUDIO] Sidecar metadata applied to '%s'\n", existing.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedMultiFileManifest inserts the new book's scan-state entry and
|
||||
// hashes its sidecars into the initial manifest — the multifile twin of
|
||||
// the single-file create tail: the next scan compares instead of
|
||||
// backfilling, so an edit is detected on its first post-seed scan.
|
||||
func (s *MediaScanner) seedMultiFileManifest(ctx context.Context, libraryID pgtype.UUID, itemID pgtype.UUID, relPath, representative, dir string, totalSize int64) {
|
||||
newEntry := &scanEntry{ID: itemID, FilePath: relPath, FileSize: totalSize}
|
||||
s.scanStateUpsert(libraryID, newEntry)
|
||||
if computed := statSidecarManifest(representative, dir); len(computed.Entries) > 0 {
|
||||
s.storeManifest(ctx, libraryID, newEntry, hashSidecarManifest(representative, dir, computed))
|
||||
}
|
||||
}
|
||||
|
||||
// storeMultiFileManifest refreshes the book's full manifest after a
|
||||
// change-path apply: the next scan compares against TODAY's sidecars,
|
||||
// not the stale pre-edit fingerprint.
|
||||
func (s *MediaScanner) storeMultiFileManifest(ctx context.Context, libraryID pgtype.UUID, itemPath, representative, dir string) {
|
||||
if entry, ok := s.scanStateLookup(libraryID, itemPath); ok {
|
||||
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(representative, dir, statSidecarManifest(representative, dir)))
|
||||
}
|
||||
}
|
||||
|
||||
// processZipAudioBook ingests a zip archive of audio tracks as ONE book.
|
||||
// The zip is the item's file (downloadable like any single file);
|
||||
// audio_metadata carries layout "zip" and per-entry track paths the
|
||||
@@ -990,7 +1091,26 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
|
||||
}
|
||||
|
||||
existing, existingErr := s.getMediaItemByFilePath(ctx, zipRel, libraryID)
|
||||
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(entries)) {
|
||||
|
||||
// Manifest-gated sidecar detection (faceted plan Phase 6) — the zip
|
||||
// itself is both the map key and the representative: its sidecars are
|
||||
// the siblings the extractor reads (metadata.json next to the zip,
|
||||
// "<zip-basename>.<ext>" covers).
|
||||
manifestChanged := false
|
||||
if existingErr == nil && !s.forceRescan {
|
||||
manifestChanged = s.multifileSidecarChanged(ctx, libraryID, zipPath, zipPath, filepath.Dir(zipPath))
|
||||
}
|
||||
|
||||
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(entries)) && !manifestChanged {
|
||||
// Archive-restore parity with the folder path (Phase 6 fix): a
|
||||
// zip book whose file returned must not stay archived forever.
|
||||
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
||||
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
|
||||
} else {
|
||||
fmt.Printf("[AUDIO] Restored zip book '%s' (file is back)\n", existing.Title)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1045,10 +1165,32 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
|
||||
switch {
|
||||
case err == nil:
|
||||
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload)
|
||||
if changed || s.forceRescan {
|
||||
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 {
|
||||
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, zipPath, metadata); applyErr != nil {
|
||||
fmt.Printf("Warning: %v\n", applyErr)
|
||||
}
|
||||
s.storeMultiFileManifest(ctx, libraryID, zipPath, zipPath, filepath.Dir(zipPath))
|
||||
}
|
||||
if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
|
||||
return err
|
||||
}
|
||||
s.changedItems++
|
||||
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
||||
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
|
||||
}
|
||||
}
|
||||
} else if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
||||
// Parity with the folder change branch: content unchanged but
|
||||
// the row was archived while the zip was gone.
|
||||
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
|
||||
} else {
|
||||
fmt.Printf("[AUDIO] Restored zip book '%s' (file is back)\n", existing.Title)
|
||||
}
|
||||
}
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
created, err := s.createAudioBookItem(ctx, libraryID, metadata, zipRel, totalSize, firstMime, modTime)
|
||||
@@ -1059,6 +1201,7 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
|
||||
fmt.Printf("Warning: failed to set format group for zip book %s: %v\n", zipRel, err)
|
||||
}
|
||||
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
|
||||
s.seedMultiFileManifest(ctx, libraryID, created.ID, zipRel, zipPath, filepath.Dir(zipPath), totalSize)
|
||||
fmt.Printf("[AUDIO] Created zip book '%s' (%d tracks) at %s\n", metadata.Title, len(entries), zipRel)
|
||||
s.newItems++
|
||||
default:
|
||||
|
||||
@@ -228,7 +228,18 @@ func (s *MediaScanner) storeManifest(ctx context.Context, libraryID pgtype.UUID,
|
||||
// manifest after a change-path update.
|
||||
func (s *MediaScanner) detectSidecarChange(ctx context.Context, entry *scanEntry, libraryID pgtype.UUID, path string) bool {
|
||||
dir := bookSidecarDir(path)
|
||||
computed := statSidecarManifest(path, dir)
|
||||
return s.detectSidecarChangeWithRepresentative(ctx, entry, libraryID, path, dir)
|
||||
}
|
||||
|
||||
// detectSidecarChangeWithRepresentative is detectSidecarChange with the
|
||||
// sidecar-name derivation decoupled from the map key. Multi-file books
|
||||
// (faceted plan Phase 6) are keyed by the item's file_path (the folder
|
||||
// root / zip), but the extractor reads a folder book's sidecars through
|
||||
// the sentinel INSIDE the root — whose basename yields no "<base>.<ext>"
|
||||
// cover candidates — so the manifest must stat the representative's name
|
||||
// list to never disagree with what extraction consumes.
|
||||
func (s *MediaScanner) detectSidecarChangeWithRepresentative(ctx context.Context, entry *scanEntry, libraryID pgtype.UUID, representative, dir string) bool {
|
||||
computed := statSidecarManifest(representative, dir)
|
||||
stored := decodeSidecarManifest(entry.Manifest)
|
||||
|
||||
if len(computed.Entries) == 0 {
|
||||
@@ -240,7 +251,7 @@ func (s *MediaScanner) detectSidecarChange(ctx context.Context, entry *scanEntry
|
||||
|
||||
if stored == nil {
|
||||
// Deploy-day backfill: hash + store WITHOUT re-extraction.
|
||||
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, computed))
|
||||
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(representative, dir, computed))
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,46 @@ func TestDecodeSidecarManifest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in new issue
Block a user