feat(scanner): archive-at-two-scans lifecycle for files missing from disk

Replace the silent hard-delete orphan cleanup (which logged only through
ScannerLogger file logs and whose failure paths left rows undetected)
with an archive lifecycle that preserves reading history:

- A file missing in one scan is marked (missing_scan_count = 1); missing
  in a second consecutive scan archives it (archived_at, hidden from
  browsing, progress/notes/highlights survive). Every branch logs to
  stdout with an [ARCHIVE] prefix so skips are always visible.
- When a file reappears - same path, or identical content at a new path
  via the SHA-256 dedup match - the archived state clears automatically
  and the item returns with its history intact.
- Archived rows older than ARCHIVE_RETENTION_DAYS are hard-purged at
  scan time (cascading deletes); 0 disables auto-purge for manual-only
  management. Retention is read from the environment in NewMediaScanner.
This commit is contained in:
John O'Keefe
2026-09-12 17:50:15 -04:00
parent e6aceae0da
commit 14445a7c3f
2 changed files with 105 additions and 47 deletions
+11
View File
@@ -84,3 +84,14 @@ func getEnvInt(key string, defaultValue int) int {
} }
return defaultValue return defaultValue
} }
// ArchiveRetentionDays returns how many days a media item stays archived
// (file missing from disk for two consecutive scans) before library scans
// purge it for good. Reading progress, notes, and highlights survive the
// archive window and are restored if the file returns; the purge deletes
// them along with the row.
// Configure via ARCHIVE_RETENTION_DAYS (default 90); 0 keeps archived items
// until an admin purges them manually from the library admin page.
func ArchiveRetentionDays() int {
return getEnvInt("ARCHIVE_RETENTION_DAYS", 90)
}
+94 -47
View File
@@ -5,6 +5,7 @@ package services
import ( import (
"archive/tar" "archive/tar"
"archive/zip" "archive/zip"
"bookhoard/internal/config"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"bytes" "bytes"
@@ -105,22 +106,23 @@ type FormatInfo struct {
// MediaScanner scans library folders for media files (ebooks, comics, manga) // MediaScanner scans library folders for media files (ebooks, comics, manga)
type MediaScanner struct { type MediaScanner struct {
db *database.Queries db *database.Queries
watcher *fsnotify.Watcher watcher *fsnotify.Watcher
folders []string folders []string
adminID pgtype.UUID adminID pgtype.UUID
defaultLibraryID pgtype.UUID defaultLibraryID pgtype.UUID
libraryTypes map[string][]string libraryTypes map[string][]string
forceRescan bool forceRescan bool
logger *ScannerLogger archiveRetentionDays int
dirtyDirs map[string]time.Time logger *ScannerLogger
dirtyDirsMu sync.RWMutex dirtyDirs map[string]time.Time
fileStability map[string]*atomic.Bool dirtyDirsMu sync.RWMutex
fileStabilityMu sync.RWMutex fileStability map[string]*atomic.Bool
scanMutex sync.Mutex fileStabilityMu sync.RWMutex
scanInProgress atomic.Bool scanMutex sync.Mutex
watching atomic.Bool scanInProgress atomic.Bool
settingsCache *SettingsCache watching atomic.Bool
settingsCache *SettingsCache
totalFiles int totalFiles int
newItems int newItems int
@@ -157,18 +159,19 @@ type CalibreOPFMetadata struct {
// never closed. // never closed.
func NewMediaScanner(db *database.Queries) *MediaScanner { func NewMediaScanner(db *database.Queries) *MediaScanner {
return &MediaScanner{ return &MediaScanner{
db: db, db: db,
watcher: nil, watcher: nil,
settingsCache: NewSettingsCache(30 * time.Second), archiveRetentionDays: config.ArchiveRetentionDays(),
dirtyDirs: make(map[string]time.Time), settingsCache: NewSettingsCache(30 * time.Second),
fileStability: make(map[string]*atomic.Bool), dirtyDirs: make(map[string]time.Time),
watching: atomic.Bool{}, fileStability: make(map[string]*atomic.Bool),
scanInProgress: atomic.Bool{}, watching: atomic.Bool{},
folders: []string{}, scanInProgress: atomic.Bool{},
adminID: pgtype.UUID{}, folders: []string{},
defaultLibraryID: pgtype.UUID{Valid: false}, adminID: pgtype.UUID{},
libraryTypes: make(map[string][]string), defaultLibraryID: pgtype.UUID{Valid: false},
logger: NewScannerLogger(), libraryTypes: make(map[string][]string),
logger: NewScannerLogger(),
} }
} }
@@ -476,17 +479,23 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
fmt.Printf("Scan completed: %d total files scanned, %d media files found, %d new items, %d errors\n", fmt.Printf("Scan completed: %d total files scanned, %d media files found, %d new items, %d errors\n",
processedFiles, mediaFiles, s.newItems, s.errors) processedFiles, mediaFiles, s.newItems, s.errors)
// Clean up: Find media items in DB that no longer exist on filesystem // Archive lifecycle pass. Items whose files vanished from disk are
// archived after two consecutive missing scans (reading history kept,
// item hidden), and purged for good once archived older than the
// retention window (ARCHIVE_RETENTION_DAYS; 0 = manual purge only).
// Every branch logs - the previous hard-delete cleanup failed silently
// and left orphaned rows undetected.
for _, folder := range s.folders { for _, folder := range s.folders {
lib, err := s.db.GetLibraryByFolder(ctx, folder) lib, err := s.db.GetLibraryByFolder(ctx, folder)
if err != nil { if err != nil {
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping archive pass: %v\n", folder, err)
continue continue
} }
libraryID := lib.LibraryID libraryID := lib.LibraryID
dbItems, err := s.db.ListMediaItemsByLibrary(ctx, libraryID) dbItems, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID)
if err != nil { if err != nil {
fmt.Printf("Warning: failed to get library items for cleanup: %v\n", err) fmt.Printf("[ARCHIVE] Warning: failed to get library items for archive pass: %v\n", err)
continue continue
} }
@@ -501,28 +510,46 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
} }
return nil return nil
}); err != nil { }); err != nil {
fmt.Printf("[RESCAN-CLEANUP] Warning: failed to walk directory %s, skipping orphan cleanup: %v\n", folder, err) fmt.Printf("[ARCHIVE] Warning: failed to walk directory %s, skipping archive pass: %v\n", folder, err)
continue // Skip to next folder to avoid false deletions continue // Skip to next folder to avoid false archivals
} }
// Delete items whose files no longer exist - with safety logging
for _, item := range dbItems { for _, item := range dbItems {
filePath := item.FilePath if item.FilePath == "" || scannedPaths[item.FilePath] {
if filePath != "" && !scannedPaths[filePath] { continue // File present; unarchive is handled in processMediaFile
msg := fmt.Sprintf("[RESCAN-CLEANUP] Orphaned media item found: ID=%s, Title=%s, Path=%s", }
item.ID, item.Title, filePath)
s.logger.LogDelete(msg)
delMsg := fmt.Sprintf("[RESCAN-CLEANUP] Deleting orphaned item '%s' (file no longer exists at %s)", if item.ArchivedAt.Valid {
item.Title, filePath) // Still missing and already archived: purge once past the
s.logger.LogDelete(delMsg) // retention window (0 = keep until manual purge).
if s.archiveRetentionDays > 0 && time.Now().AddDate(0, 0, -s.archiveRetentionDays).After(item.ArchivedAt.Time) {
if err := s.db.DeleteMediaItem(ctx, item.ID); err != nil {
fmt.Printf("[ARCHIVE] Error: failed to purge archived item %s: %v\n", item.Title, err)
s.logger.LogError(fmt.Sprintf("[ARCHIVE] ERROR: failed to purge archived item '%s': %v", item.Title, err))
} else {
fmt.Printf("[ARCHIVE] Purged archived item '%s' (retention %d days): %s\n", item.Title, s.archiveRetentionDays, item.FilePath)
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Purged archived item '%s' after retention window (file missing at %s)", item.Title, item.FilePath))
}
}
continue
}
if err := s.db.DeleteMediaItem(ctx, item.ID); err != nil { // Missing but not yet archived.
errMsg := fmt.Sprintf("[RESCAN-CLEANUP] ERROR: failed to delete orphaned item %s: %v", item.Title, err) if item.MissingScanCount >= 1 {
s.logger.LogDelete(errMsg) // Second consecutive missing scan: archive it.
s.logger.LogError(errMsg) if err := s.db.ArchiveMediaItem(ctx, item.ID); err != nil {
fmt.Printf("[ARCHIVE] Error: failed to archive item %s: %v\n", item.Title, err)
s.logger.LogError(fmt.Sprintf("[ARCHIVE] ERROR: failed to archive item '%s': %v", item.Title, err))
} else { } else {
s.logger.LogDelete(fmt.Sprintf("[RESCAN-CLEANUP] SUCCESS: deleted orphaned item '%s'", item.Title)) fmt.Printf("[ARCHIVE] Archived item '%s' (missing from disk for %d scans): %s\n", item.Title, item.MissingScanCount+1, item.FilePath)
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Archived item '%s' (file missing from disk at %s)", item.Title, item.FilePath))
}
} else {
// First missing scan: mark, archive on the next one.
if err := s.db.MarkMediaItemMissing(ctx, item.ID); err != nil {
fmt.Printf("[ARCHIVE] Warning: failed to mark item missing %s: %v\n", item.Title, err)
} else {
fmt.Printf("[ARCHIVE] Item missing from disk (1/2 scans before archiving): %s\n", item.FilePath)
} }
} }
} }
@@ -693,6 +720,17 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
if err == nil { if err == nil {
fmt.Printf("Media item already exists in database: %s (size: %d vs %d)\n", path, existingItem.FileSize.Int64, info.Size()) fmt.Printf("Media item already exists in database: %s (size: %d vs %d)\n", path, existingItem.FileSize.Int64, info.Size())
// The file is back on disk: lift any archive/missing state so the
// item reappears in libraries and future missing scans start fresh.
if existingItem.ArchivedAt.Valid || existingItem.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existingItem.ID); err != nil {
fmt.Printf("Warning: failed to unarchive media item %s: %v\n", existingItem.FilePath, err)
} else if existingItem.ArchivedAt.Valid {
fmt.Printf("[ARCHIVE] Restored from archive, file is back: %s\n", existingItem.FilePath)
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Restored archived item '%s' (file returned at %s)", existingItem.Title, existingItem.FilePath))
}
}
// If force rescan is enabled, always re-process // If force rescan is enabled, always re-process
if s.forceRescan { if s.forceRescan {
fmt.Printf("Force rescan enabled, updating existing media item: %s\n", path) fmt.Printf("Force rescan enabled, updating existing media item: %s\n", path)
@@ -757,6 +795,15 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
if err == nil && existingByHash.ID.Valid { if err == nil && existingByHash.ID.Valid {
fmt.Printf("Media item with same SHA-256 already exists in library (path %q), skipping duplicate: %s\n", fmt.Printf("Media item with same SHA-256 already exists in library (path %q), skipping duplicate: %s\n",
existingByHash.FilePath, path) existingByHash.FilePath, path)
// Content returned (possibly at a new path): restore archived rows.
if existingByHash.ArchivedAt.Valid || existingByHash.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existingByHash.ID); err != nil {
fmt.Printf("Warning: failed to unarchive media item %s: %v\n", existingByHash.FilePath, err)
} else if existingByHash.ArchivedAt.Valid {
fmt.Printf("[ARCHIVE] Restored from archive, identical content found at %s\n", path)
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Restored archived item '%s' (identical content found at %s)", existingByHash.Title, path))
}
}
if s.forceRescan { if s.forceRescan {
_ = s.updateMediaItem(ctx, existingByHash, path) _ = s.updateMediaItem(ctx, existingByHash, path)
} }