package services import ( "bookhoard/internal/database" "context" "log" "time" "github.com/jackc/pgx/v5/pgtype" ) // HashBackfillService is a one-time self-heal pass that computes and stores the // SHA-256 for media items imported before hashing existed (file_sha256 IS // NULL). It runs once shortly after startup, independently of auto-scan, and // also performs a final conflict sweep that flags any content-duplicate groups // (same library + SHA-256 at different paths) on the admin Hash Conflicts page. // // The sweep runs after the per-item pass because during the pass only one side // of a preexisting duplicate pair may be hashed at a time - the group only // becomes visible once every item has its hash. type HashBackfillService struct { db *database.Queries libSvc *LibraryService } // NewHashBackfillService creates a backfill service. func NewHashBackfillService(db *database.Queries) *HashBackfillService { return &HashBackfillService{db: db, libSvc: NewLibraryService(db)} } // Run performs the backfill pass followed by the conflict sweep. It logs // progress and never returns an error - failures on individual items are // skipped so one unreadable file cannot block the rest. func (s *HashBackfillService) Run(ctx context.Context) { items, err := s.db.ListMediaItemsMissingHash(ctx) if err != nil { log.Printf("[HASH-BACKFILL] failed to list items missing hash: %v", err) return } if len(items) == 0 { log.Printf("[HASH-BACKFILL] all media items already hashed, nothing to do") s.sweepConflicts(ctx) return } log.Printf("[HASH-BACKFILL] computing SHA-256 for %d unhashed media items", len(items)) started := time.Now() hashed, failed := 0, 0 for _, item := range items { if ctx.Err() != nil { log.Printf("[HASH-BACKFILL] cancelled after %d items", hashed) return } path, err := s.libSvc.ResolveMediaPath(ctx, item.LibraryID, item.FilePath) if err != nil { log.Printf("[HASH-BACKFILL] could not resolve path for %q: %v", item.FilePath, err) failed++ continue } sha, err := computeFileSHA256(path) if err != nil { log.Printf("[HASH-BACKFILL] could not hash %q: %v", path, err) failed++ continue } _, err = s.db.UpdateMediaItemIdentifiers(ctx, database.UpdateMediaItemIdentifiersParams{ ID: item.ID, FileSha256: pgtype.Text{String: sha, Valid: true}, HashConfidence: pgtype.Text{String: "sha256_full", Valid: true}, }) if err != nil { log.Printf("[HASH-BACKFILL] could not store hash for %q: %v", item.FilePath, err) failed++ continue } hashed++ if hashed%25 == 0 { log.Printf("[HASH-BACKFILL] progress: %d/%d hashed", hashed, len(items)) } } log.Printf("[HASH-BACKFILL] done in %s: %d hashed, %d failed (of %d)", time.Since(started).Round(time.Second), hashed, failed, len(items)) s.sweepConflicts(ctx) } // sweepConflicts flags every content-duplicate group (same library + SHA-256, // more than one item) as a pending hash conflict. The upsert is a no-op for // groups that are already tracked or resolved, so admins who chose "keep both" // are never re-prompted. func (s *HashBackfillService) sweepConflicts(ctx context.Context) { groups, err := s.db.FindHashConflictGroups(ctx) if err != nil { log.Printf("[HASH-BACKFILL] conflict sweep failed: %v", err) return } if len(groups) == 0 { return } flagged := 0 for _, g := range groups { if err := s.db.CreateHashConflict(ctx, database.CreateHashConflictParams{ LibraryID: g.LibraryID, FileSha256: g.FileSha256.String, }); err != nil { log.Printf("[HASH-BACKFILL] could not record conflict group: %v", err) continue } flagged++ } log.Printf("[HASH-BACKFILL] flagged %d content-duplicate group(s) for admin review", flagged) }