diff --git a/internal/database/querier.go b/internal/database/querier.go index 65d1795..031e8f3 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -347,6 +347,10 @@ type Querier interface { ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error) + // 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. + ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error) @@ -366,6 +370,10 @@ type Querier interface { ListUsers(ctx context.Context) ([]ListUsersRow, error) // First consecutive scan that cannot find the file on disk. MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error + // 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. + MoveMediaItemFilePath(ctx context.Context, arg MoveMediaItemFilePathParams) error // Manual bulk purge from the library admin page. PurgeAllArchivedMediaItems(ctx context.Context) ([]PurgeAllArchivedMediaItemsRow, error) // Retention sweep at library-scan time: hard-delete archived items older diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 065d519..8e6d51b 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -3097,6 +3097,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 ($3::uuid IS NULL OR mi.library_id = $3::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 @@ -7884,6 +7885,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 ` @@ -8415,6 +8418,174 @@ func (q *Queries) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([] return items, nil } +const ListHiddenMediaItems = `-- name: ListHiddenMediaItems :many +SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, 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 +` + +type ListHiddenMediaItemsRow struct { + ID pgtype.UUID `db:"id" json:"id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + Isbn pgtype.Text `db:"isbn" json:"isbn"` + Description pgtype.Text `db:"description" json:"description"` + FilePath string `db:"file_path" json:"file_path"` + FileSize pgtype.Int8 `db:"file_size" json:"file_size"` + MimeType pgtype.Text `db:"mime_type" json:"mime_type"` + CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` + Series pgtype.Text `db:"series" json:"series"` + SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"` + Tags []string `db:"tags" json:"tags"` + Asin pgtype.Text `db:"asin" json:"asin"` + DatePublished pgtype.Date `db:"date_published" json:"date_published"` + Publisher pgtype.Text `db:"publisher" json:"publisher"` + Contributors []string `db:"contributors" json:"contributors"` + Language pgtype.Text `db:"language" json:"language"` + Edition pgtype.Text `db:"edition" json:"edition"` + PageCount pgtype.Int4 `db:"page_count" json:"page_count"` + Genre pgtype.Text `db:"genre" json:"genre"` + CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"` + GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"` + OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"` + GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"` + AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + ImportedAt pgtype.Timestamptz `db:"imported_at" json:"imported_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + FormatGroup string `db:"format_group" json:"format_group"` + FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"` + IsReflowable pgtype.Bool `db:"is_reflowable" json:"is_reflowable"` + HasFixedLayout pgtype.Bool `db:"has_fixed_layout" json:"has_fixed_layout"` + TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"` + ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"` + EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"` + RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` + KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` + KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"` + MangaType pgtype.Text `db:"manga_type" json:"manga_type"` + ReadingDirection pgtype.Text `db:"reading_direction" json:"reading_direction"` + SeriesCount pgtype.Int4 `db:"series_count" json:"series_count"` + Volume pgtype.Int4 `db:"volume" json:"volume"` + Imprint pgtype.Text `db:"imprint" json:"imprint"` + AgeRating pgtype.Text `db:"age_rating" json:"age_rating"` + WebUrl pgtype.Text `db:"web_url" json:"web_url"` + StoryArc pgtype.Text `db:"story_arc" json:"story_arc"` + IsBlackAndWhite pgtype.Bool `db:"is_black_and_white" json:"is_black_and_white"` + MetadataNotes pgtype.Text `db:"metadata_notes" json:"metadata_notes"` + CommunityRating pgtype.Float8 `db:"community_rating" json:"community_rating"` + AlternateInfo []byte `db:"alternate_info" json:"alternate_info"` + ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"` + Summary pgtype.Text `db:"summary" json:"summary"` + MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"` + MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"` + ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` + ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` + LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` + TagsSearch []string `db:"tags_search" json:"tags_search"` + ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` + OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` + HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` + LibraryName string `db:"library_name" json:"library_name"` + LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"` +} + +// 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. +func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error) { + rows, err := q.db.Query(ctx, ListHiddenMediaItems) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListHiddenMediaItemsRow{} + for rows.Next() { + var i ListHiddenMediaItemsRow + if err := rows.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.Genre, + &i.CopyrightYear, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.AddedByAdminID, + &i.CreatedAt, + &i.ImportedAt, + &i.UpdatedAt, + &i.FormatGroup, + &i.FormatMimetype, + &i.IsReflowable, + &i.HasFixedLayout, + &i.TotalCharacters, + &i.ChapterCount, + &i.EntitlementID, + &i.RevisionNumber, + &i.KoboContentID, + &i.KoboMetadata, + &i.MangaType, + &i.ReadingDirection, + &i.SeriesCount, + &i.Volume, + &i.Imprint, + &i.AgeRating, + &i.WebUrl, + &i.StoryArc, + &i.IsBlackAndWhite, + &i.MetadataNotes, + &i.CommunityRating, + &i.AlternateInfo, + &i.ScanInformation, + &i.Summary, + &i.MetadataOverrides, + &i.MissingScanCount, + &i.ArchivedAt, + &i.ChapterMetadata, + &i.LibraryTypeName, + &i.TagsSearch, + &i.ContributorsSearch, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + &i.LibraryName, + &i.LibraryTypeName_2, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListLibraries = `-- name: ListLibraries :many SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description FROM libraries l @@ -8470,6 +8641,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 ` @@ -8641,6 +8813,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 ` @@ -9156,6 +9329,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 = $1 ORDER BY CASE @@ -9805,6 +9979,26 @@ func (q *Queries) MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) erro return err } +const MoveMediaItemFilePath = `-- name: MoveMediaItemFilePath :exec +UPDATE media_items SET file_path = $2, file_size = $3, missing_scan_count = 0, + archived_at = NULL, updated_at = NOW() +WHERE id = $1 +` + +type MoveMediaItemFilePathParams struct { + ID pgtype.UUID `db:"id" json:"id"` + FilePath string `db:"file_path" json:"file_path"` + FileSize pgtype.Int8 `db:"file_size" json:"file_size"` +} + +// 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. +func (q *Queries) MoveMediaItemFilePath(ctx context.Context, arg MoveMediaItemFilePathParams) error { + _, err := q.db.Exec(ctx, MoveMediaItemFilePath, arg.ID, arg.FilePath, arg.FileSize) + return err +} + const PurgeAllArchivedMediaItems = `-- name: PurgeAllArchivedMediaItems :many DELETE FROM media_items WHERE archived_at IS NOT NULL @@ -10407,6 +10601,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 = $2 WHERE COALESCE(lv.is_visible, true) = true AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 AND mi.library_id = $3 AND word_similarity($1, COALESCE(mi.author, '')) > 0.3 AND mi.author IS NOT NULL @@ -10466,6 +10661,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 = $2 WHERE COALESCE(lv.is_visible, true) = true AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 AND mi.library_id = $3 AND word_similarity($1, COALESCE(mi.genre, '')) > 0.3 AND mi.genre IS NOT NULL @@ -10525,6 +10721,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 = $2 WHERE COALESCE(lv.is_visible, true) = true AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 AND mi.library_id = $3 AND word_similarity($1, COALESCE(mi.language, '')) > 0.3 AND mi.language IS NOT NULL @@ -10582,6 +10779,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 = $1 WHERE COALESCE(lv.is_visible, true) = true AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 AND ($2::uuid IS NULL OR mi.library_id = $2::uuid) AND ( mi.title ILIKE $3 OR @@ -10827,6 +11025,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 = $1 WHERE COALESCE(lv.is_visible, true) = true AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 AND ($2::uuid IS NULL OR mi.library_id = $2::uuid) -- Fuzzy author filter @@ -11112,6 +11311,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 = $2 WHERE COALESCE(lv.is_visible, true) = true AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 AND mi.library_id = $3 AND word_similarity($1, COALESCE(mi.series, '')) > 0.3 AND mi.series IS NOT NULL diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index a66fc86..66315db 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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