feat(db): hash_conflicts table and backfill/conflict queries

Content duplicates (same library + file_sha256 at different paths,
e.g. the same book imported twice under two names on a preexisting
database) cannot be auto-collapsed the way path duplicates were:
keeping both copies may be intentional. Surface them for an explicit
admin decision instead.

Schema:
- new hash_conflicts table keyed (library_id, file_sha256) with a
  status/resolution lifecycle: 'pending' until an admin resolves via
  'keep_all' or 'kept:<uuid>' (which copy was kept after merging)
- resolution is VARCHAR(50) - 'kept:<uuid>' is 41 chars; include a
  widening ALTER for databases created with the initial 30-char width
- resolution/resolved_by/resolved_at record who decided what and when

Queries:
- ListMediaItemsMissingHash: items imported before hashing existed
  (file_sha256 IS NULL), ordered oldest-first for the backfill pass
- FindHashConflictGroups: the content-duplicate group detection
  (GROUP BY library_id, file_sha256 HAVING COUNT(*) > 1)
- ListMediaItemsBySHA256AndLibrary: full membership of one group
- CreateHashConflict: upsert with DO NOTHING so already-tracked groups
  are untouched - critical behavior: a group an admin resolved as
  'keep both' is never re-flagged by later sweeps
- ListPendingHashConflicts: admin listing with library name and live
  item counts (items may have been deleted since flagging)
- GetHashConflict / ResolveHashConflict: lifecycle
- GetMediaItemUsageCounts: per-item progress/highlight/bookmark/note/
  collection counts so the admin can make an informed keep choice
- ReparentMediaItemChildren: sqlc binding for the existing
  reparent_media_item_children() migration function, used to merge a
  losing copy's child rows into the kept copy
This commit is contained in:
2026-08-14 08:52:05 -04:00
parent 8599e5c250
commit 0c39e04e4a
5 changed files with 485 additions and 0 deletions
+60
View File
@@ -1712,6 +1712,66 @@ SELECT * FROM media_items WHERE file_sha256 = $1;
-- name: GetMediaItemBySHA256AndLibrary :one
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2;
-- List all media items sharing a SHA-256 hash within a library (hash conflict group)
-- name: ListMediaItemsBySHA256AndLibrary :many
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path;
-- List media items that have no stored SHA-256 (imported before hashing existed)
-- name: ListMediaItemsMissingHash :many
SELECT * FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at;
-- Find content-duplicate groups (same library + SHA-256, more than one row)
-- name: FindHashConflictGroups :many
SELECT library_id, file_sha256, COUNT(*) AS dup_count
FROM media_items
WHERE file_sha256 IS NOT NULL
GROUP BY library_id, file_sha256
HAVING COUNT(*) > 1;
-- Per-item user-data counts, used when choosing which duplicate copy to keep
-- name: GetMediaItemUsageCounts :one
SELECT
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = $1) AS progress_count,
(SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = $1) AS highlights_count,
(SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = $1) AS bookmarks_count,
(SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = $1) AS notes_count,
(SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = $1) AS collections_count;
-- HASH CONFLICTS QUERIES
-- Record a pending hash conflict (no-op if the group is already tracked, so
-- resolved groups stay resolved and are never re-flagged)
-- name: CreateHashConflict :exec
INSERT INTO hash_conflicts (library_id, file_sha256)
VALUES ($1, $2)
ON CONFLICT (library_id, file_sha256) DO NOTHING;
-- name: ListPendingHashConflicts :many
SELECT hc.id, hc.library_id, hc.file_sha256, hc.created_at,
l.name AS library_name,
COUNT(mi.id) AS item_count
FROM hash_conflicts hc
JOIN libraries l ON l.id = hc.library_id
LEFT JOIN media_items mi ON mi.library_id = hc.library_id AND mi.file_sha256 = hc.file_sha256
WHERE hc.status = 'pending'
GROUP BY hc.id, hc.library_id, hc.file_sha256, hc.created_at, l.name
ORDER BY hc.created_at;
-- name: GetHashConflict :one
SELECT * FROM hash_conflicts WHERE id = $1;
-- name: ResolveHashConflict :exec
UPDATE hash_conflicts
SET status = 'resolved',
resolution = $2,
resolved_by = $3,
resolved_at = NOW()
WHERE id = $1;
-- Re-parent all child rows of p_source onto p_target (defined in schema.sql)
-- name: ReparentMediaItemChildren :exec
SELECT reparent_media_item_children($1::uuid, $2::uuid);
-- Get media item by OPF identifier
-- name: GetMediaItemByOPFIdentifier :one
SELECT * FROM media_items WHERE opf_identifier = $1;