diff --git a/internal/database/querier.go b/internal/database/querier.go index 031e8f3..5b2575d 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -259,6 +259,12 @@ type Querier interface { GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error) // Per-item user-data counts, used when choosing which duplicate copy to keep GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtype.UUID) (GetMediaItemUsageCountsRow, error) + // 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). + GetMediaItemsBySHA256AndSameLibraryType(ctx context.Context, arg GetMediaItemsBySHA256AndSameLibraryTypeParams) ([]GetMediaItemsBySHA256AndSameLibraryTypeRow, error) GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error) // ============================================ // ANNOTATION SYNC QUERIES (notes) @@ -349,7 +355,10 @@ type Querier interface { 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. + // 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. ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error) @@ -374,6 +383,11 @@ type Querier interface { // 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 + // 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. + MoveMediaItemToLibrary(ctx context.Context, arg MoveMediaItemToLibraryParams) 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 8e6d51b..479f1b7 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -2623,6 +2623,8 @@ SELECT ci.id, ci.collection_id, ci.media_item_id, ci.added_at, ci.added_by_user_ 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 ` @@ -2676,6 +2678,8 @@ SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.fi INNER JOIN collection_items ci ON ci.media_item_id = mi.id WHERE ci.collection_id = $1 AND ($2::uuid IS NULL OR mi.library_id = $2::uuid) + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 ORDER BY ci.added_at DESC LIMIT $3 ` @@ -2982,6 +2986,8 @@ INNER JOIN ( ORDER BY media_item_id, last_read_at DESC ) rp ON rp.media_item_id = mi.id WHERE ($2::uuid IS NULL OR mi.library_id = $2::uuid) + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 ORDER BY rp.last_read_at DESC LIMIT $3 ` @@ -4378,6 +4384,8 @@ func (q *Queries) GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) const GetLibraryItems = `-- name: GetLibraryItems :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 FROM media_items mi WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 ORDER BY mi.created_at DESC ` @@ -5694,6 +5702,184 @@ func (q *Queries) GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtyp return i, err } +const GetMediaItemsBySHA256AndSameLibraryType = `-- name: GetMediaItemsBySHA256AndSameLibraryType :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, 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 = $1 +WHERE mi.file_sha256 = $2 + AND mi.library_id <> $1 + 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 +` + +type GetMediaItemsBySHA256AndSameLibraryTypeParams struct { + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` +} + +type GetMediaItemsBySHA256AndSameLibraryTypeRow 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"` + LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"` +} + +// 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). +func (q *Queries) GetMediaItemsBySHA256AndSameLibraryType(ctx context.Context, arg GetMediaItemsBySHA256AndSameLibraryTypeParams) ([]GetMediaItemsBySHA256AndSameLibraryTypeRow, error) { + rows, err := q.db.Query(ctx, GetMediaItemsBySHA256AndSameLibraryType, arg.LibraryID, arg.FileSha256) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetMediaItemsBySHA256AndSameLibraryTypeRow{} + for rows.Next() { + var i GetMediaItemsBySHA256AndSameLibraryTypeRow + 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.LibraryTypeName_2, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetMediaNote = `-- name: GetMediaNote :one SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at FROM media_notes WHERE id = $1 ` @@ -5909,6 +6095,8 @@ func (q *Queries) GetNextRetryTime(ctx context.Context) (interface{}, error) { const GetNotStartedItems = `-- name: GetNotStartedItems :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 FROM media_items mi WHERE ($1::uuid IS NULL OR mi.library_id = $1::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 @@ -6312,6 +6500,8 @@ func (q *Queries) GetReadingSpeed(ctx context.Context, arg GetReadingSpeedParams const GetRecentlyAddedItems = `-- name: GetRecentlyAddedItems :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 FROM media_items mi WHERE ($1::uuid IS NULL OR mi.library_id = $1::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 $2 ` @@ -6416,6 +6606,8 @@ INNER JOIN ( ORDER BY media_item_id, last_read_at DESC ) rp ON rp.media_item_id = mi.id WHERE ($2::uuid IS NULL OR mi.library_id = $2::uuid) + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 ORDER BY rp.last_read_at DESC LIMIT $3 ` @@ -8419,10 +8611,28 @@ func (q *Queries) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([] } 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 +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, + 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 ` @@ -8494,11 +8704,17 @@ type ListHiddenMediaItemsRow struct { 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"` + MatchID pgtype.UUID `db:"match_id" json:"match_id"` + MatchTitle string `db:"match_title" json:"match_title"` + MatchLibraryName pgtype.Text `db:"match_library_name" json:"match_library_name"` } // 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. +// 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. func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error) { rows, err := q.db.Query(ctx, ListHiddenMediaItems) if err != nil { @@ -8575,6 +8791,9 @@ func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaIt &i.HashConfidence, &i.LibraryName, &i.LibraryTypeName_2, + &i.MatchID, + &i.MatchTitle, + &i.MatchLibraryName, ); err != nil { return nil, err } @@ -9999,6 +10218,38 @@ func (q *Queries) MoveMediaItemFilePath(ctx context.Context, arg MoveMediaItemFi return err } +const MoveMediaItemToLibrary = `-- name: MoveMediaItemToLibrary :exec +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 +` + +type MoveMediaItemToLibraryParams struct { + ID pgtype.UUID `db:"id" json:"id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + FilePath string `db:"file_path" json:"file_path"` + FileSize pgtype.Int8 `db:"file_size" json:"file_size"` +} + +// 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. +func (q *Queries) MoveMediaItemToLibrary(ctx context.Context, arg MoveMediaItemToLibraryParams) error { + _, err := q.db.Exec(ctx, MoveMediaItemToLibrary, + arg.ID, + arg.LibraryID, + arg.FilePath, + arg.FileSize, + ) + return err +} + const PurgeAllArchivedMediaItems = `-- name: PurgeAllArchivedMediaItems :many DELETE FROM media_items WHERE archived_at IS NOT NULL diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 66315db..99e664f 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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