feat(admin): startup hash backfill and hash-conflict resolution API
Release / build-and-push (push) Successful in 2m48s
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:
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -944,6 +945,67 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}))
|
||||
|
||||
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
||||
// backfill or rescan, resolved by keeping all copies or merging into one.
|
||||
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
|
||||
pending, err := cfg.Queries.ListPendingHashConflicts(c.Request().Context())
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading hash conflicts", "conflicts_load_error")
|
||||
}
|
||||
|
||||
conflicts := make([]templates.HashConflictData, 0, len(pending))
|
||||
for _, p := range pending {
|
||||
conflict := templates.HashConflictData{
|
||||
ID: uuid.UUID(p.ID.Bytes).String(),
|
||||
LibraryName: p.LibraryName,
|
||||
SHA256: p.FileSha256,
|
||||
SHAShort: p.FileSha256[:16] + "…",
|
||||
CreatedAt: p.CreatedAt.Time.Format("Jan 2, 2006"),
|
||||
Items: []templates.HashConflictItemData{},
|
||||
}
|
||||
|
||||
items, err := cfg.Queries.ListMediaItemsBySHA256AndLibrary(c.Request().Context(), database.ListMediaItemsBySHA256AndLibraryParams{
|
||||
FileSha256: pgtype.Text{String: p.FileSha256, Valid: true},
|
||||
LibraryID: p.LibraryID,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, mi := range items {
|
||||
counts, err := cfg.Queries.GetMediaItemUsageCounts(c.Request().Context(), mi.ID)
|
||||
if err != nil {
|
||||
counts = database.GetMediaItemUsageCountsRow{}
|
||||
}
|
||||
totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount
|
||||
conflict.Items = append(conflict.Items, templates.HashConflictItemData{
|
||||
ID: uuid.UUID(mi.ID.Bytes).String(),
|
||||
Title: mi.Title,
|
||||
Author: mi.Author.String,
|
||||
FilePath: mi.FilePath,
|
||||
FileSize: mi.FileSize.Int64,
|
||||
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
|
||||
counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount),
|
||||
HasReadingData: totalData > 0,
|
||||
})
|
||||
}
|
||||
|
||||
conflicts = append(conflicts, conflict)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.AdminHashConflicts(user, conflicts).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}))
|
||||
|
||||
// Admin users page
|
||||
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
|
||||
Reference in New Issue
Block a user