Files
bookhoard/internal/services/hash_backfill.go
T
john-okeefe 03cb4c7869
Release / build-and-push (push) Successful in 2m48s
feat(admin): startup hash backfill and hash-conflict resolution API
Complete the SHA-256 lifecycle for preexisting databases: items
imported before hashing existed get hashed automatically, and any
content duplicates discovered in the process land on the new admin
Hash Conflicts page for an explicit keep/merge decision.

HashBackfillService (runs once 30s after startup, independent of
auto-scan):
- hashes every media_items row where file_sha256 IS NULL, resolving
  each path through LibraryService; per-item failures are logged and
  skipped so one unreadable file cannot block the pass
- no-op once everything is hashed (logged and skipped)
- finishes with a conflict sweep flagging every content-duplicate
  group via FindHashConflictGroups + CreateHashConflict; the sweep
  runs after the per-item pass because a preexisting pair only
  becomes detectable once both sides have their hash

API (admin-only):
- GET /api/admin/hash-conflicts - pending groups with member items
  and usage counts
- POST /api/admin/hash-conflicts/:id/resolve - action=keep_all, or
  action=keep with keep_uuid: validates the uuid belongs to the
  group, re-parents every other copy's child rows onto the kept item
  (reparent_media_item_children), deletes the losers, and records
  the resolution + resolving admin; accepts form or JSON bodies and
  returns the htmx resolved fragment

Page route /admin/hash-conflicts (admin-only) renders the template
with hydrated conflict data; HashConflictsHandler wired into the
router Config and constructed in main.

Verified end-to-end against the live database: duplicate detection,
pending listing, keep_all resolution, merge path (re-parent +
delete), and - critically - a resolved group is not re-flagged by a
later sweep (upsert no-op). Database restored afterward.
2026-08-14 08:53:01 -04:00

120 lines
3.7 KiB
Go

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)
}