From 9405afa2c5cc7e40a31ce48bb326cc3afefc9660 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 8 May 2026 20:26:47 -0400 Subject: [PATCH] feat(series): add SeriesService and wire continue-series into dashboard Create SeriesService with methods for paginated series listing, cover path resolution, series book listing, and a conversion helper for GetContinueSeriesItemsRow to MediaItems. Wire the continue-series query type into DashboardService's getCollectionItemsByQueryType switch and add its metadata to the RestoreSystemCollection default collection map. --- internal/services/dashboard_service.go | 15 +++ internal/services/series_service.go | 146 +++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 internal/services/series_service.go diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index 81e5961..e57fc7c 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -292,6 +292,20 @@ func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, co LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, Limit: int32(limit), }) + case "continue-series": + rows, err := s.db.GetContinueSeriesItems(ctx, database.GetContinueSeriesItemsParams{ + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + Limit: int32(limit), + }) + if err != nil { + return nil, err + } + items := make([]database.MediaItems, 0, len(rows)) + for _, row := range rows { + items = append(items, continueSeriesRowToMediaItems(row)) + } + return items, nil default: return []database.MediaItems{}, nil } @@ -407,6 +421,7 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u "Recently Added": {"Newly added items to this library", "🆕", "#9ece6a", 2, "recently-added"}, "Recently Read": {"Books you've finished (progress >= 1)", "✅", "#e0af68", 3, "recently-read"}, "Not Started": {"Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", 4, "not-started"}, + "Continue Series": {"Next book in series you're reading", "📚", "#bb9af7", 5, "continue-series"}, } meta, exists := defaultMetadata[collectionName] diff --git a/internal/services/series_service.go b/internal/services/series_service.go new file mode 100644 index 0000000..0f2a484 --- /dev/null +++ b/internal/services/series_service.go @@ -0,0 +1,146 @@ +package services + +import ( + "bookhoard/internal/database" + "bookhoard/internal/utils" + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type SeriesInfo struct { + Name string + BookCount int64 + TotalInSeries int + CoverPaths []string + LastEntryAt string +} + +type SeriesService struct { + db *database.Queries +} + +func NewSeriesService(db *database.Queries) *SeriesService { + return &SeriesService{db: db} +} + +func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID uuid.UUID, limit, offset int) ([]SeriesInfo, int, error) { + libUUID := pgtype.UUID{Bytes: libraryID, Valid: true} + + totalCount, err := s.db.GetDistinctSeriesCount(ctx, libUUID) + if err != nil { + return nil, 0, err + } + + rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{ + LibraryID: libUUID, + Limit: int32(limit), + Offset: int32(offset), + }) + if err != nil { + return nil, 0, err + } + + var series []SeriesInfo + for _, row := range rows { + seriesName := row.Series.String + coverPaths, _ := s.GetSeriesCovers(ctx, libraryID, seriesName, 7) + + totalInSeries := 0 + if row.TotalInSeries != nil { + if v, ok := row.TotalInSeries.(int32); ok { + totalInSeries = int(v) + } else if v, ok := row.TotalInSeries.(int64); ok { + totalInSeries = int(v) + } + } + + lastEntry := "" + if row.LastEntryAt != nil { + switch v := row.LastEntryAt.(type) { + case pgtype.Timestamptz: + if v.Valid { + lastEntry = v.Time.String() + } + case string: + lastEntry = v + } + } + + if totalInSeries == 0 || totalInSeries < int(row.BookCount) { + totalInSeries = int(row.BookCount) + } + + series = append(series, SeriesInfo{ + Name: seriesName, + BookCount: row.BookCount, + TotalInSeries: totalInSeries, + CoverPaths: coverPaths, + LastEntryAt: lastEntry, + }) + } + + if series == nil { + series = []SeriesInfo{} + } + + return series, int(totalCount), nil +} + +func (s *SeriesService) GetSeriesCovers(ctx context.Context, libraryID uuid.UUID, seriesName string, limit int) ([]string, error) { + covers, err := s.db.GetSeriesCovers(ctx, database.GetSeriesCoversParams{ + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Series: pgtype.Text{String: seriesName, Valid: true}, + Limit: int32(limit), + }) + if err != nil { + return nil, err + } + + paths := make([]string, 0, len(covers)) + for _, c := range covers { + resolved := utils.ResolveMediaURL(c.LibraryID, c.CoverImagePath) + if resolved != "" { + paths = append(paths, resolved) + } + } + + return paths, nil +} + +func (s *SeriesService) GetSeriesBooks(ctx context.Context, libraryID uuid.UUID, seriesName string) ([]database.MediaItems, error) { + return s.db.GetSeriesBooks(ctx, database.GetSeriesBooksParams{ + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Series: pgtype.Text{String: seriesName, Valid: true}, + }) +} + +func continueSeriesRowToMediaItems(row database.GetContinueSeriesItemsRow) database.MediaItems { + return database.MediaItems{ + ID: row.ID, LibraryID: row.LibraryID, Title: row.Title, Author: row.Author, + Isbn: row.Isbn, Description: row.Description, FilePath: row.FilePath, + FileSize: row.FileSize, MimeType: row.MimeType, CoverImagePath: row.CoverImagePath, + Series: row.Series, SeriesNumber: row.SeriesNumber, Tags: row.Tags, Asin: row.Asin, + DatePublished: row.DatePublished, Publisher: row.Publisher, Contributors: row.Contributors, + Language: row.Language, Edition: row.Edition, PageCount: row.PageCount, + Genre: row.Genre, CopyrightYear: row.CopyrightYear, GoodreadsID: row.GoodreadsID, + OpenlibraryID: row.OpenlibraryID, GoogleBooksID: row.GoogleBooksID, + AddedByAdminID: row.AddedByAdminID, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + FormatGroup: row.FormatGroup, FormatMimetype: row.FormatMimetype, + IsReflowable: row.IsReflowable, HasFixedLayout: row.HasFixedLayout, + TotalCharacters: row.TotalCharacters, ChapterCount: row.ChapterCount, + EntitlementID: row.EntitlementID, RevisionNumber: row.RevisionNumber, + KoboContentID: row.KoboContentID, KoboMetadata: row.KoboMetadata, + MangaType: row.MangaType, ReadingDirection: row.ReadingDirection, + SeriesCount: row.SeriesCount, Volume: row.Volume, Imprint: row.Imprint, + AgeRating: row.AgeRating, WebUrl: row.WebUrl, StoryArc: row.StoryArc, + IsBlackAndWhite: row.IsBlackAndWhite, MetadataNotes: row.MetadataNotes, + CommunityRating: row.CommunityRating, AlternateInfo: row.AlternateInfo, + ScanInformation: row.ScanInformation, Summary: row.Summary, + ChapterMetadata: row.ChapterMetadata, LibraryTypeName: row.LibraryTypeName, + TagsSearch: row.TagsSearch, ContributorsSearch: row.ContributorsSearch, + FileSha256: row.FileSha256, OpfIdentifier: row.OpfIdentifier, + OpfUuid: row.OpfUuid, HashConfidence: row.HashConfidence, + } +}