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
+29
View File
@@ -1533,3 +1533,32 @@ BEGIN
ADD CONSTRAINT media_items_library_id_file_path_key UNIQUE (library_id, file_path);
END IF;
END $$;
-- ============================================
--: HASH CONFLICTS
-- ============================================
-- Records content-duplicate groups discovered during hash backfill or rescan:
-- two or more media_items in the same library share a file_sha256 but live at
-- different file paths (e.g. the same book imported twice under two names on
-- a preexisting database). Unlike path duplicates these cannot be auto-collapsed
-- (keeping both copies may be intentional), so each group is surfaced on the
-- admin Hash Conflicts page for the user to resolve:
-- keep_all - both copies are intentional; just stop flagging
-- kept:<uuid> - merge every other copy's child rows into the kept item
-- (via reparent_media_item_children) and delete the losers
CREATE TABLE IF NOT EXISTS hash_conflicts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
file_sha256 CHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','resolved')),
resolution VARCHAR(50), -- 'keep_all' or 'kept:<media_item_uuid>' (41 chars)
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
UNIQUE(library_id, file_sha256)
);
CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);
-- Widen for databases created before the resolution format settled (no-op otherwise)
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);