feat(data): duplicate-content queries — archive filters, cross-library moves, archived-twin matching

Three related query groups in queries.sql (sqlc regenerated; no generated
signatures changed, so no caller edits were needed):

1. Dashboard/collection archive filters. The smart-section and collection
   queries returned archived and missing items (visible only as broken
   covers once their files vanished). Added the codebase-standard
   "AND archived_at IS NULL AND missing_scan_count = 0" predicate to:
   GetContinueReadingItems, GetRecentlyAddedItems, GetRecentlyReadItems,
   GetNotStartedItems, GetCollectionItemsForDashboard, GetLibraryItems.
   Matches the existing convention in ListMediaItems, SearchMediaItems,
   the library counts, and GetContinueSeriesItems.

2. Cross-library SHA move detection support.
   - GetMediaItemsBySHA256AndSameLibraryType: finds identical content
     (same file_sha256) registered in a DIFFERENT library of the SAME
     library type, ordered archived-first, most-missing, oldest. Type
     scoping keeps ebooks from merging into manga/comics libraries.
   - MoveMediaItemToLibrary: repoints an existing row to the new library
     (library_id + recomputed file_path + file_size) and clears
     archive/missing state. The row keeps its ID, so reading progress,
     annotations, collections, and kobo shelves follow the book
     automatically; the new set_library_type_name_on_library_change
     trigger refreshes the denormalized type column.

3. Archived-twin matching. ListHiddenMediaItems (admin archived-items
   page) now LEFT JOIN LATERALs the best active same-SHA twin per hidden
   row — preferring same library type, then same library, then most
   reading progress, then oldest — exposing match_id / match_title /
   match_library_name so the UI can offer merging the archived row's
   reading data into its active duplicate. COALESCE on match_title keeps
   the no-match case NULL-safe.
This commit is contained in:
John O'Keefe
2026-09-21 18:08:18 -04:00
parent 1002cbca93
commit a4dbe79d0b
3 changed files with 337 additions and 5 deletions
+69 -2
View File
@@ -328,6 +328,20 @@ WHERE id = $1;
UPDATE media_items SET archived_at = NOW(), updated_at = NOW()
WHERE id = $1;
-- name: MoveMediaItemToLibrary :exec
-- Cross-library move: identical content reappeared in a different library of
-- the same type while the old file vanished. Repoints the row so reading
-- history, annotations and collections follow the book; archive/missing
-- state is cleared. library_type_name is refreshed by trigger.
UPDATE media_items
SET library_id = $2,
file_path = $3,
file_size = $4,
archived_at = NULL,
missing_scan_count = 0,
updated_at = NOW()
WHERE id = $1;
-- name: ClearMediaItemArchive :exec
-- File is back on disk (by path or content hash): restore visibility and
-- reset the missing-scan counter. No-op for items that were never archived.
@@ -353,11 +367,32 @@ SELECT COUNT(*) FROM media_items WHERE archived_at IS NOT NULL;
-- name: ListHiddenMediaItems :many
-- Items hidden from libraries: either missing from disk (waiting on the
-- second scan that archives them) or already archived and pending purge.
-- Feeds the admin archived-items page.
SELECT mi.*, l.name as library_name, lt.name as library_type_name
-- Feeds the admin archived-items page. match_* columns identify an active
-- item with identical content (same SHA-256) so the UI can offer merging the
-- archived row's reading data into its active twin instead of waiting for a
-- file that will never return.
SELECT mi.*, l.name as library_name, lt.name as library_type_name,
twin.id AS match_id, COALESCE(twin.title, '') AS match_title,
twin_library.name AS match_library_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN LATERAL (
SELECT t.id, t.title, t.library_id
FROM media_items t
JOIN libraries tl ON t.library_id = tl.id
JOIN library_types tlt ON tl.library_type_id = tlt.id
WHERE t.file_sha256 = mi.file_sha256
AND t.id <> mi.id
AND t.archived_at IS NULL
AND t.missing_scan_count = 0
ORDER BY (tlt.id = lt.id) DESC,
(tl.id = l.id) DESC,
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = t.id) DESC,
t.created_at ASC
LIMIT 1
) twin ON true
LEFT JOIN libraries twin_library ON twin_library.id = twin.library_id
WHERE mi.archived_at IS NOT NULL OR mi.missing_scan_count > 0
ORDER BY mi.archived_at NULLS LAST, mi.updated_at DESC;
@@ -1898,6 +1933,24 @@ SELECT * FROM media_items WHERE file_sha256 = $1;
-- name: GetMediaItemBySHA256AndLibrary :one
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2;
-- name: GetMediaItemsBySHA256AndSameLibraryType :many
-- Cross-library move detection: identical content registered in a different
-- library of the same type (ebooks stay with ebooks, comics with comics).
-- Candidates may be active copies (deliberate duplicates - leave alone) or
-- hidden/archived rows whose file vanished (move candidates - the scanner
-- verifies disk state before repointing).
SELECT mi.*, lt.name AS library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
JOIN libraries target_lib ON target_lib.id = sqlc.arg('library_id')
WHERE mi.file_sha256 = sqlc.arg('file_sha256')
AND mi.library_id <> sqlc.arg('library_id')
AND lt.id = target_lib.library_type_id
ORDER BY (mi.archived_at IS NOT NULL) DESC,
mi.missing_scan_count DESC,
mi.created_at ASC;
-- 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;
@@ -2143,6 +2196,8 @@ SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id
FROM collection_items ci
JOIN media_items mi ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY ci.added_at DESC;
-- Get collections for book
@@ -2449,12 +2504,16 @@ INNER JOIN (
ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY rp.last_read_at DESC
LIMIT sqlc.narg('limit');
-- name: GetRecentlyAddedItems :many
SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
LIMIT sqlc.narg('limit');
@@ -2468,12 +2527,16 @@ INNER JOIN (
ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY rp.last_read_at DESC
LIMIT sqlc.narg('limit');
-- name: GetNotStartedItems :many
SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND NOT EXISTS (
SELECT 1 FROM reading_progress rp
WHERE rp.media_item_id = mi.id
@@ -2488,12 +2551,16 @@ SELECT mi.*, ci.excluded FROM media_items mi
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
WHERE ci.collection_id = sqlc.narg('collection_id')
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY ci.added_at DESC
LIMIT sqlc.narg('limit');
-- name: GetLibraryItems :many
SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.created_at DESC;
-- name: GetDistinctSeries :many