From 864f2cc6b9ed59da98279c0121fe6ac1f15318bc Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 8 May 2026 20:26:39 -0400 Subject: [PATCH] feat(series): add SQL queries for series browsing and continue-series Add five new sqlc queries to support the series browse page and continue-series dashboard collection: - GetDistinctSeries: list unique series with book counts, sorted by most recent entry, with pagination - GetDistinctSeriesCount: total distinct series count for pagination - GetSeriesCovers: fetch up to N cover image paths for a series, ordered by series_number - GetSeriesBooks: fetch all books in a series ordered by series_number - GetContinueSeriesItems: CTE-based query using DISTINCT ON to find the next unread book per series for a given user/library, sorted by most recent last_read_at --- internal/database/querier.go | 5 + internal/database/queries.sql.go | 372 ++++++++++++++++++++++++++ internal/database/queries/queries.sql | 53 ++++ 3 files changed, 430 insertions(+) diff --git a/internal/database/querier.go b/internal/database/querier.go index 0b41efd..95fe1c6 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -142,6 +142,7 @@ type Querier interface { GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.UUID) ([]Collections, error) // Smart section queries (for system collections) GetContinueReadingItems(ctx context.Context, arg GetContinueReadingItemsParams) ([]MediaItems, error) + GetContinueSeriesItems(ctx context.Context, arg GetContinueSeriesItemsParams) ([]GetContinueSeriesItemsRow, error) // ============================================ // CAROUSEL-STYLE DASHBOARD // ============================================ @@ -167,6 +168,8 @@ type Querier interface { // Get device shelf mappings GetDeviceShelfMappings(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceShelfMappingsRow, error) GetDictionaryEntry(ctx context.Context, word string) (DictionaryCache, error) + GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) + GetDistinctSeriesCount(ctx context.Context, libraryID pgtype.UUID) (int32, error) GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error) GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error) GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error) @@ -236,6 +239,8 @@ type Querier interface { GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error) GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error) GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error) + GetSeriesBooks(ctx context.Context, arg GetSeriesBooksParams) ([]MediaItems, error) + GetSeriesCovers(ctx context.Context, arg GetSeriesCoversParams) ([]GetSeriesCoversRow, error) GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error) GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error) GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index dd201e5..6cd0b59 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -2411,6 +2411,185 @@ func (q *Queries) GetContinueReadingItems(ctx context.Context, arg GetContinueRe return items, nil } +const GetContinueSeriesItems = `-- name: GetContinueSeriesItems :many +WITH user_series_progress AS ( + SELECT mi.series, + MAX(mi.series_number) as max_read_number, + MAX(rp.last_read_at) as last_read_at + FROM reading_progress rp + JOIN media_items mi ON mi.id = rp.media_item_id + WHERE rp.user_id = $2 + AND rp.percentage > 0 + AND mi.series IS NOT NULL AND mi.series != '' + AND mi.library_id = $1 + GROUP BY mi.series +), +next_books AS ( + SELECT DISTINCT ON (mi.series) 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.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.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, + usp.last_read_at + FROM media_items mi + JOIN user_series_progress usp ON mi.series = usp.series + WHERE mi.library_id = $1 + 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 +) +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, last_read_at FROM next_books +ORDER BY last_read_at DESC NULLS LAST +LIMIT $3 +` + +type GetContinueSeriesItemsParams struct { + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + Limit int32 `db:"limit" json:"limit"` +} + +type GetContinueSeriesItemsRow 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"` + 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"` + 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"` + LastReadAt interface{} `db:"last_read_at" json:"last_read_at"` +} + +func (q *Queries) GetContinueSeriesItems(ctx context.Context, arg GetContinueSeriesItemsParams) ([]GetContinueSeriesItemsRow, error) { + rows, err := q.db.Query(ctx, GetContinueSeriesItems, arg.LibraryID, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetContinueSeriesItemsRow{} + for rows.Next() { + var i GetContinueSeriesItemsRow + 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.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.ChapterMetadata, + &i.LibraryTypeName, + &i.TagsSearch, + &i.ContributorsSearch, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + &i.LastReadAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetDashboardPreferences = `-- name: GetDashboardPreferences :one SELECT id, user_id, library_id, hidden_collections, collection_order, items_per_section, created_at, updated_at FROM user_dashboard_preferences @@ -2830,6 +3009,68 @@ func (q *Queries) GetDictionaryEntry(ctx context.Context, word string) (Dictiona return i, err } +const GetDistinctSeries = `-- name: GetDistinctSeries :many +SELECT series, COUNT(*) as book_count, + MAX(series_count) as total_in_series, + MAX(created_at) as last_entry_at +FROM media_items +WHERE library_id = $1 AND series IS NOT NULL AND series != '' +GROUP BY series +ORDER BY MAX(created_at) DESC +LIMIT $2 OFFSET $3 +` + +type GetDistinctSeriesParams struct { + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +type GetDistinctSeriesRow struct { + Series pgtype.Text `db:"series" json:"series"` + BookCount int64 `db:"book_count" json:"book_count"` + TotalInSeries interface{} `db:"total_in_series" json:"total_in_series"` + LastEntryAt interface{} `db:"last_entry_at" json:"last_entry_at"` +} + +func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) { + rows, err := q.db.Query(ctx, GetDistinctSeries, arg.LibraryID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetDistinctSeriesRow{} + for rows.Next() { + var i GetDistinctSeriesRow + if err := rows.Scan( + &i.Series, + &i.BookCount, + &i.TotalInSeries, + &i.LastEntryAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetDistinctSeriesCount = `-- name: GetDistinctSeriesCount :one +SELECT COUNT(DISTINCT series)::int +FROM media_items +WHERE library_id = $1 AND series IS NOT NULL AND series != '' +` + +func (q *Queries) GetDistinctSeriesCount(ctx context.Context, libraryID pgtype.UUID) (int32, error) { + row := q.db.QueryRow(ctx, GetDistinctSeriesCount, libraryID) + var column_1 int32 + err := row.Scan(&column_1) + return column_1, err +} + const GetFailedSyncQueueItems = `-- name: GetFailedSyncQueueItems :many SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue WHERE status = 'failed' AND attempts < max_attempts @@ -5217,6 +5458,137 @@ func (q *Queries) GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams return items, nil } +const GetSeriesBooks = `-- name: GetSeriesBooks :many +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items +WHERE library_id = $1 AND series = $2 +ORDER BY series_number ASC NULLS LAST +` + +type GetSeriesBooksParams struct { + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Series pgtype.Text `db:"series" json:"series"` +} + +func (q *Queries) GetSeriesBooks(ctx context.Context, arg GetSeriesBooksParams) ([]MediaItems, error) { + rows, err := q.db.Query(ctx, GetSeriesBooks, arg.LibraryID, arg.Series) + if err != nil { + return nil, err + } + defer rows.Close() + items := []MediaItems{} + for rows.Next() { + var i MediaItems + 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.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.ChapterMetadata, + &i.LibraryTypeName, + &i.TagsSearch, + &i.ContributorsSearch, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetSeriesCovers = `-- name: GetSeriesCovers :many +SELECT cover_image_path, library_id +FROM media_items +WHERE library_id = $1 AND series = $2 AND cover_image_path IS NOT NULL AND cover_image_path != '' +ORDER BY series_number ASC NULLS LAST +LIMIT $3 +` + +type GetSeriesCoversParams struct { + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Series pgtype.Text `db:"series" json:"series"` + Limit int32 `db:"limit" json:"limit"` +} + +type GetSeriesCoversRow struct { + CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` +} + +func (q *Queries) GetSeriesCovers(ctx context.Context, arg GetSeriesCoversParams) ([]GetSeriesCoversRow, error) { + rows, err := q.db.Query(ctx, GetSeriesCovers, arg.LibraryID, arg.Series, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetSeriesCoversRow{} + for rows.Next() { + var i GetSeriesCoversRow + if err := rows.Scan(&i.CoverImagePath, &i.LibraryID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetStuckSyncQueueItems = `-- name: GetStuckSyncQueueItems :many SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue WHERE status = 'processing' AND created_at < NOW() - INTERVAL '1 hour' diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 7033466..28786b4 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -1905,6 +1905,59 @@ SELECT mi.* FROM media_items mi WHERE mi.library_id = $1 ORDER BY mi.created_at DESC; +-- name: GetDistinctSeries :many +SELECT series, COUNT(*) as book_count, + MAX(series_count) as total_in_series, + MAX(created_at) as last_entry_at +FROM media_items +WHERE library_id = $1 AND series IS NOT NULL AND series != '' +GROUP BY series +ORDER BY MAX(created_at) DESC +LIMIT $2 OFFSET $3; + +-- name: GetDistinctSeriesCount :one +SELECT COUNT(DISTINCT series)::int +FROM media_items +WHERE library_id = $1 AND series IS NOT NULL AND series != ''; + +-- name: GetSeriesCovers :many +SELECT cover_image_path, library_id +FROM media_items +WHERE library_id = $1 AND series = $2 AND cover_image_path IS NOT NULL AND cover_image_path != '' +ORDER BY series_number ASC NULLS LAST +LIMIT $3; + +-- name: GetSeriesBooks :many +SELECT * FROM media_items +WHERE library_id = $1 AND series = $2 +ORDER BY series_number ASC NULLS LAST; + +-- name: GetContinueSeriesItems :many +WITH user_series_progress AS ( + SELECT mi.series, + MAX(mi.series_number) as max_read_number, + MAX(rp.last_read_at) as last_read_at + FROM reading_progress rp + JOIN media_items mi ON mi.id = rp.media_item_id + WHERE rp.user_id = $2 + AND rp.percentage > 0 + AND mi.series IS NOT NULL AND mi.series != '' + AND mi.library_id = $1 + GROUP BY mi.series +), +next_books AS ( + SELECT DISTINCT ON (mi.series) mi.*, + usp.last_read_at + FROM media_items mi + JOIN user_series_progress usp ON mi.series = usp.series + WHERE mi.library_id = $1 + 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 +) +SELECT * FROM next_books +ORDER BY last_read_at DESC NULLS LAST +LIMIT $3; + -- name: GetSavedFilters :many SELECT * FROM saved_filters WHERE user_id = @user_id AND resource_type = @resource_type