feat(db): archive lifecycle queries, immediate-hide filters, move support

Storage behind the scan lifecycle fixes:

- MoveMediaItemFilePath: repoints a row (path, size) and clears archive
  state when identical content reappears at a new location.
- ListHiddenMediaItems: archived OR missing rows for the admin
  archived-items page; ListMediaItemsByLibraryIncludingArchived (prior
  commit) already fed the scanner.
- All user-facing listings now hide missing items immediately, not just
  archived ones: ListMediaItems, ListMediaItemsByLibrary,
  ListMediaItemsSorted, SearchMediaItems, SearchMediaItemsUnified, the
  next_books CTE, library media counts, and the five search autocomplete
  value queries gained AND mi.missing_scan_count = 0 next to the
  archived_at filter. Purge timing is unchanged (archived_at +
  ARCHIVE_RETENTION_DAYS), so the grace period keeps protecting data
  while the UI reflects removals on the first scan.
- Detail lookups by id/path/sha stay unfiltered on purpose.
This commit is contained in:
John O'Keefe
2026-09-13 11:59:41 -04:00
parent 841db91a29
commit 17216e9cc7
3 changed files with 239 additions and 0 deletions
+31
View File
@@ -142,6 +142,8 @@ SELECT l.id, COUNT(mi.id) as media_count
FROM libraries l
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
LEFT JOIN media_items mi ON mi.library_id = l.id AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.missing_scan_count = 0
WHERE COALESCE(lv.is_visible, true) = true
GROUP BY l.id;
@@ -161,6 +163,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
-- name: ListMediaItemsByLibrary :many
@@ -170,6 +173,7 @@ JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.library_id = $1
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.created_at DESC;
-- name: ListMediaItemsByLibraryIncludingArchived :many
@@ -186,6 +190,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
ORDER BY
CASE
@@ -309,6 +314,14 @@ WHERE id = $1;
UPDATE media_items SET missing_scan_count = missing_scan_count + 1, updated_at = NOW()
WHERE id = $1;
-- name: MoveMediaItemFilePath :exec
-- Content reappeared at a new location while the old path vanished: treat as
-- a move. Repoints the row (and refreshes file-level fields) so reading
-- history follows the book; archive/missing state is cleared separately.
UPDATE media_items SET file_path = $2, file_size = $3, missing_scan_count = 0,
archived_at = NULL, updated_at = NOW()
WHERE id = $1;
-- name: ArchiveMediaItem :exec
-- Second consecutive missing scan: hide the item from all browsing while
-- preserving reading history in case the file returns.
@@ -337,6 +350,17 @@ RETURNING id, title;
-- name: CountArchivedMediaItems :one
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
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.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;
-- name: DeleteMediaItem :exec
DELETE FROM media_items WHERE id = $1;
@@ -486,6 +510,7 @@ JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (
mi.title ILIKE sqlc.narg('search_pattern') OR
@@ -559,6 +584,7 @@ JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL
OR mi.library_id = sqlc.narg('library_id')::uuid)
-- Fuzzy author filter
@@ -693,6 +719,7 @@ JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
AND mi.author IS NOT NULL
@@ -711,6 +738,7 @@ JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
AND mi.genre IS NOT NULL
@@ -746,6 +774,7 @@ JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
AND mi.series IS NOT NULL
@@ -764,6 +793,7 @@ JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
AND mi.language IS NOT NULL
@@ -2512,6 +2542,7 @@ next_books AS (
FROM media_items mi
JOIN user_series_progress usp ON mi.series = usp.series
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
ORDER BY mi.series, mi.series_number ASC NULLS LAST