Release / build-and-push (push) Successful in 2m48s
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.
120 lines
3.7 KiB
Go
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)
|
|
}
|