Files
bookhoard/internal
john-okeefe 9b171a0060 fix(scanner): prevent duplicate media item imports
A read-then-write race in processMediaFile allowed the same file to be
imported twice: two concurrent scan jobs (startup scan, fsnotify dirty-
directory scan, periodic backup poll, or a manual scan each run on
separate worker goroutines with separate MediaScanner instances) could
both SELECT 'not found' and both INSERT. There was no transaction, no
row lock, no unique constraint on (library_id, file_path), and no
ON CONFLICT clause, so nothing stopped the double insert. Observed in
production as two identical 'Head First SQL' rows created in the same
second (same sha256, size, path, library).

Database enforcement:
- schema.sql: add UNIQUE(library_id, file_path) constraint, guarded so
  re-runs don't error
- schema.sql: add self-healing migration that runs on every startup -
  dedup_media_items_by_path() collapses existing path-duplicates and
  reparent_media_item_children() moves all child rows (progress,
  highlights, bookmarks, notes, collections, formats, aliases, kobo
  entitlements, etc.) onto a survivor before deleting losers, so the
  constraint applies cleanly on already-duplicated servers without
  losing reading history. Survivor picks the row with the most user
  data, ties broken by lowest id
- CreateMediaItem: upsert via ON CONFLICT (library_id, file_path) DO
  UPDATE so concurrent inserts collapse to one row and return it
- CreateMediaItemFormat: upsert via ON CONFLICT (media_item_id,
  format_type), closing the same race on format rows

Application-level guards:
- media_scanner processMediaFile: after computing the file hash, check
  GetMediaItemBySHA256AndLibrary (new query) and treat the file as
  existing when identical content is already in the library under a
  different path (content dedup, library-scoped so multi-library
  setups still work)

Ops tooling:
- scripts/dedup_media_items.sql: standalone idempotent maintenance
  script with a dry-run report (path + content duplicate groups, child
  row counts) and transactional cleanup, for servers that prefer to
  dedup manually before upgrading

Verified against the live database: the duplicate pair was collapsed
(reading_progress preserved on the survivor), schema.sql re-runs are a
no-op, and the constraint is in place with 62 unique books remaining.
2026-08-14 08:18:36 -04:00
..