-- scripts/dedup_media_items.sql -- -- Detects and removes duplicate media_items, re-parenting all child rows -- (reading progress, highlights, collections, etc.) onto a single survivor -- before deleting the losers. -- -- Two kinds of duplicates are handled: -- 1. PATH duplicates — same (library_id, file_path), multiple rows. -- These block the UNIQUE(library_id, file_path) -- constraint added by the schema migration. -- 2. CONTENT duplicates — same file_sha256 within a library, different paths. -- Same file imported twice under two names. -- -- This script is IDEMPOTENT: re-running it is a no-op once the data is clean. -- It is safe to run against any Bookhoard database, before or after upgrading. -- -- Usage: -- psql -h -U postgres -d bookhoard -f scripts/dedup_media_items.sql -- -- The first section is a DRY-RUN report (SELECTs only, no writes). The cleanup -- runs inside an explicit transaction. Comment out the cleanup block to inspect -- first. -- ===================================================================== -- DRY RUN: report duplicates (no writes) -- ====================================================================== \echo '=== PATH duplicates (library_id + file_path) ===' SELECT library_id, file_path, COUNT(*) AS dupes, array_agg(id::text) AS media_item_ids, array_agg(COALESCE(file_sha256::text, 'NULL')) AS hashes FROM media_items GROUP BY library_id, file_path HAVING COUNT(*) > 1 ORDER BY COUNT(*) DESC; \echo '=== CONTENT duplicates (same file_sha256 within a library, different paths) ===' SELECT library_id, file_sha256::text AS hash, COUNT(*) AS dupes, array_agg(file_path) AS paths, array_agg(id::text) AS media_item_ids FROM media_items WHERE file_sha256 IS NOT NULL GROUP BY library_id, file_sha256 HAVING COUNT(*) > 1 ORDER BY COUNT(*) DESC; \echo '=== Child-row counts per duplicate candidate (helps confirm survivor choice) ===' SELECT mi.id, mi.library_id, mi.file_path, (SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = mi.id) AS progress, (SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = mi.id) AS highlights, (SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = mi.id) AS bookmarks, (SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = mi.id) AS notes, (SELECT COUNT(*) FROM reading_history rh WHERE rh.media_item_id = mi.id) AS history, (SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = mi.id) AS collections FROM media_items mi WHERE (mi.library_id, mi.file_path) IN ( SELECT library_id, file_path FROM media_items GROUP BY library_id, file_path HAVING COUNT(*) > 1 ) ORDER BY mi.library_id, mi.file_path, mi.id; -- ===================================================================== -- HELPER FUNCTIONS (also defined by schema.sql; CREATE OR REPLACE keeps them in sync) -- ====================================================================== -- Move every child row that points at p_source so it points at p_target, -- deleting any source rows that would violate a UNIQUE constraint on the -- target. Idempotent; no-op when p_target = p_source. CREATE OR REPLACE FUNCTION reparent_media_item_children(p_target UUID, p_source UUID) RETURNS void LANGUAGE plpgsql AS $$ BEGIN IF p_target IS NULL OR p_source IS NULL OR p_target = p_source THEN RETURN; END IF; -- reading_progress (UNIQUE media_item_id, user_id) DELETE FROM reading_progress WHERE media_item_id = p_source AND user_id IN (SELECT user_id FROM reading_progress WHERE media_item_id = p_target); UPDATE reading_progress SET media_item_id = p_target WHERE media_item_id = p_source; -- reading_speed (UNIQUE user_id, media_item_id) DELETE FROM reading_speed WHERE media_item_id = p_source AND user_id IN (SELECT user_id FROM reading_speed WHERE media_item_id = p_target); UPDATE reading_speed SET media_item_id = p_target WHERE media_item_id = p_source; -- media_ratings (UNIQUE media_item_id, user_id) DELETE FROM media_ratings WHERE media_item_id = p_source AND user_id IN (SELECT user_id FROM media_ratings WHERE media_item_id = p_target); UPDATE media_ratings SET media_item_id = p_target WHERE media_item_id = p_source; -- media_bookmarks (UNIQUE media_item_id, user_id, title) DELETE FROM media_bookmarks WHERE media_item_id = p_source AND (user_id, title) IN (SELECT user_id, title FROM media_bookmarks WHERE media_item_id = p_target); UPDATE media_bookmarks SET media_item_id = p_target WHERE media_item_id = p_source; -- media_item_formats (UNIQUE media_item_id, format_type) DELETE FROM media_item_formats WHERE media_item_id = p_source AND format_type IN (SELECT format_type FROM media_item_formats WHERE media_item_id = p_target); UPDATE media_item_formats SET media_item_id = p_target WHERE media_item_id = p_source; -- collection_items (UNIQUE collection_id, media_item_id) DELETE FROM collection_items WHERE media_item_id = p_source AND collection_id IN (SELECT collection_id FROM collection_items WHERE media_item_id = p_target); UPDATE collection_items SET media_item_id = p_target WHERE media_item_id = p_source; -- kobo_shelves (UNIQUE device_id, media_item_id) DELETE FROM kobo_shelves WHERE media_item_id = p_source AND device_id IN (SELECT device_id FROM kobo_shelves WHERE media_item_id = p_target); UPDATE kobo_shelves SET media_item_id = p_target WHERE media_item_id = p_source; -- panel_data (UNIQUE media_item_id, page_number) DELETE FROM panel_data WHERE media_item_id = p_source AND page_number IN (SELECT page_number FROM panel_data WHERE media_item_id = p_target); UPDATE panel_data SET media_item_id = p_target WHERE media_item_id = p_source; -- processing_issues (UNIQUE media_item_id, issue_type) DELETE FROM processing_issues WHERE media_item_id = p_source AND issue_type IN (SELECT issue_type FROM processing_issues WHERE media_item_id = p_target); UPDATE processing_issues SET media_item_id = p_target WHERE media_item_id = p_source; -- device_file_aliases (UNIQUE device_id, file_path) — paths may collide DELETE FROM device_file_aliases WHERE media_item_id = p_source AND (device_id, file_path) IN (SELECT device_id, file_path FROM device_file_aliases WHERE media_item_id = p_target); UPDATE device_file_aliases SET media_item_id = p_target WHERE media_item_id = p_source; -- Tables whose UNIQUE keys do not include media_item_id: plain re-parent. UPDATE device_catalogs SET media_item_id = p_target WHERE media_item_id = p_source; UPDATE kobo_entitlements SET media_item_id = p_target WHERE media_item_id = p_source; UPDATE media_highlights SET media_item_id = p_target WHERE media_item_id = p_source; UPDATE media_notes SET media_item_id = p_target WHERE media_item_id = p_source; UPDATE reading_history SET media_item_id = p_target WHERE media_item_id = p_source; UPDATE sync_conflicts SET media_item_id = p_target WHERE media_item_id = p_source; UPDATE sync_queue SET media_item_id = p_target WHERE media_item_id = p_source; END; $$; -- Collapse every (library_id, file_path) group into a single row. -- Survivor = the row with the most user data; ties broken by lowest id. CREATE OR REPLACE FUNCTION dedup_media_items_by_path() RETURNS void LANGUAGE plpgsql AS $$ DECLARE g RECORD; v_surv UUID; v_loser UUID; BEGIN FOR g IN SELECT library_id, file_path FROM media_items GROUP BY library_id, file_path HAVING COUNT(*) > 1 LOOP SELECT mi.id INTO v_surv FROM media_items mi WHERE mi.library_id = g.library_id AND mi.file_path = g.file_path ORDER BY ((SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = mi.id) + (SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = mi.id) + (SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = mi.id) + (SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = mi.id) + (SELECT COUNT(*) FROM reading_history rh WHERE rh.media_item_id = mi.id) + (SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = mi.id)) DESC, mi.id ASC LIMIT 1; FOR v_loser IN SELECT id FROM media_items WHERE library_id = g.library_id AND file_path = g.file_path AND id <> v_surv ORDER BY id LOOP PERFORM reparent_media_item_children(v_surv, v_loser); DELETE FROM media_items WHERE id = v_loser; END LOOP; END LOOP; END; $$; -- ====================================================================== -- CLEANUP: collapse path duplicates (required before the UNIQUE constraint) -- ====================================================================== \echo '=== Collapsing path duplicates ==='; BEGIN; SELECT dedup_media_items_by_path(); COMMIT; \echo '=== Done. Remaining PATH duplicates (should be empty): ==='; SELECT library_id, file_path, COUNT(*) AS dupes FROM media_items GROUP BY library_id, file_path HAVING COUNT(*) > 1; \echo 'NOTE: CONTENT duplicates (same hash, different paths) are NOT auto-deleted.' \echo ' They do not violate the UNIQUE constraint. Review the dry-run output' \echo ' above and merge them manually if desired.'