feat(admin): startup hash backfill and hash-conflict resolution API
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.
This commit is contained in:
2026-08-14 08:53:01 -04:00
parent 8004cb81a5
commit 03cb4c7869
5 changed files with 454 additions and 0 deletions
+16
View File
@@ -47,6 +47,7 @@ type Config struct {
MediaHandler *handlers.MediaHandler
MatchingHandler *handlers.MatchingHandler
ProcessingIssuesHandler *handlers.ProcessingIssuesHandler
HashConflictsHandler *handlers.HashConflictsHandler
KOReaderHandler *handlers.KOReaderHandler
WSHandler *handlers.WSHandler
ConflictHandler *handlers.ConflictHandler
@@ -294,6 +295,17 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
}
}()
// One-time hash backfill: compute and store SHA-256 for media items
// imported before hashing existed, then flag any content-duplicate groups
// for admin review on the Hash Conflicts page. Runs independently of
// auto-scan (it is a one-shot self-heal, not a recurring scan) and is a
// no-op once every item is hashed. Delayed so it does not compete with
// startup scans for disk I/O.
go func() {
time.Sleep(30 * time.Second)
services.NewHashBackfillService(cfg.Queries).Run(context.Background())
}()
// Register progress routes with actual handler
registerProgressRoutes(cfg, scannerHandler)
@@ -301,5 +313,9 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
admin := protected.Group("", handlers.AdminMiddleware)
registerScannerRoutes(admin, scannerHandler)
// Hash conflict routes (admin only)
admin.GET("/api/admin/hash-conflicts", cfg.HashConflictsHandler.ListHashConflicts)
admin.POST("/api/admin/hash-conflicts/:id/resolve", cfg.HashConflictsHandler.ResolveHashConflict)
return scannerHandler
}