feat(db): archive lifecycle schema and queries for missing media
Add the storage behind the archive-instead-of-delete lifecycle: - media_items.missing_scan_count (INT, default 0) and archived_at (TIMESTAMPTZ, partial index), both as idempotent ADD COLUMN IF NOT EXISTS backfills for existing installs. - MarkMediaItemMissing / ArchiveMediaItem / ClearMediaItemArchive plus PurgeExpiredArchivedMediaItems (retention cutoff) and PurgeAllArchivedMediaItems (manual bulk), with CountArchivedMediaItems for the admin UI. - Archived items are hidden from every user-facing listing: ListMediaItems, ListMediaItemsByLibrary, ListMediaItemsSorted, SearchMediaItems, SearchMediaItemsUnified, the next_books CTE, the library media counts, and the search autocomplete value lists. Detail lookups by id/path/sha are intentionally unfiltered, and a dedicated ListMediaItemsByLibraryIncludingArchived feeds the scanner so the lifecycle pass can see and restore archived rows.
This commit is contained in:
@@ -141,7 +141,7 @@ ORDER BY l.created_at ASC;
|
||||
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
|
||||
LEFT JOIN media_items mi ON mi.library_id = l.id AND mi.archived_at IS NULL
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
GROUP BY l.id;
|
||||
|
||||
@@ -160,6 +160,7 @@ 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 NULL
|
||||
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: ListMediaItemsByLibrary :many
|
||||
@@ -167,6 +168,15 @@ 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.library_id = $1
|
||||
AND mi.archived_at IS NULL
|
||||
ORDER BY mi.created_at DESC;
|
||||
|
||||
-- name: ListMediaItemsByLibraryIncludingArchived :many
|
||||
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.library_id = $1
|
||||
ORDER BY mi.created_at DESC;
|
||||
|
||||
@@ -175,7 +185,8 @@ 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.library_id = sqlc.narg('library_id')
|
||||
WHERE mi.archived_at IS NULL
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
||||
@@ -293,6 +304,39 @@ RETURNING *;
|
||||
UPDATE media_items SET metadata_overrides = '{}', updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: MarkMediaItemMissing :exec
|
||||
-- First consecutive scan that cannot find the file on disk.
|
||||
UPDATE media_items SET missing_scan_count = missing_scan_count + 1, 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.
|
||||
UPDATE media_items SET archived_at = NOW(), 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.
|
||||
UPDATE media_items SET archived_at = NULL, missing_scan_count = 0, updated_at = NOW()
|
||||
WHERE id = $1 AND (archived_at IS NOT NULL OR missing_scan_count > 0);
|
||||
|
||||
-- name: PurgeExpiredArchivedMediaItems :many
|
||||
-- Retention sweep at library-scan time: hard-delete archived items older
|
||||
-- than the cutoff. Cascades remove reading history with the row.
|
||||
DELETE FROM media_items
|
||||
WHERE archived_at IS NOT NULL AND archived_at < $1
|
||||
RETURNING id, title;
|
||||
|
||||
-- name: PurgeAllArchivedMediaItems :many
|
||||
-- Manual bulk purge from the library admin page.
|
||||
DELETE FROM media_items
|
||||
WHERE archived_at IS NOT NULL
|
||||
RETURNING id, title;
|
||||
|
||||
-- name: CountArchivedMediaItems :one
|
||||
SELECT COUNT(*) FROM media_items WHERE archived_at IS NOT NULL;
|
||||
|
||||
-- name: DeleteMediaItem :exec
|
||||
DELETE FROM media_items WHERE id = $1;
|
||||
|
||||
@@ -441,6 +485,7 @@ JOIN libraries l ON mi.library_id = l.id
|
||||
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 (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
|
||||
@@ -513,6 +558,7 @@ JOIN libraries l ON mi.library_id = l.id
|
||||
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 (sqlc.narg('library_id')::uuid IS NULL
|
||||
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
-- Fuzzy author filter
|
||||
@@ -646,6 +692,7 @@ FROM media_items mi
|
||||
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.library_id = sqlc.narg('library_id')
|
||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
|
||||
AND mi.author IS NOT NULL
|
||||
@@ -663,6 +710,7 @@ FROM media_items mi
|
||||
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.library_id = sqlc.narg('library_id')
|
||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
|
||||
AND mi.genre IS NOT NULL
|
||||
@@ -697,6 +745,7 @@ FROM media_items mi
|
||||
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.library_id = sqlc.narg('library_id')
|
||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
|
||||
AND mi.series IS NOT NULL
|
||||
@@ -714,6 +763,7 @@ FROM media_items mi
|
||||
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.library_id = sqlc.narg('library_id')
|
||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
|
||||
AND mi.language IS NOT NULL
|
||||
@@ -2461,7 +2511,8 @@ next_books AS (
|
||||
usp.last_read_at
|
||||
FROM media_items mi
|
||||
JOIN user_series_progress usp ON mi.series = usp.series
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
WHERE mi.archived_at IS NULL
|
||||
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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user