package handlers import ( "context" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "bookhoard/internal/database" "bookhoard/internal/utils" ) // BookInfo is THE canonical wire+template card shape: every surface that // lists a book card as JSON (collections detail, series, preview, // dashboard sections, search) or renders a templ card (BookCard in // dashboard.templ, BooksGrid, BrowseDetail) carries exactly these seven // keys and no others — pinned by docs/contract_pins.md. // // H2 doctrine: this struct is constructed in EXACTLY ONE place — // buildBookCard below, via the per-feed adapters. Direct BookInfo // literals outside this file are a bug, not a style choice: before the // builder, ten sites hand-rolled subsets of these fields, which is how // badges vanished from the bookshelf and has_conflict stayed stamped // false-forever on collections/series JSON. type BookInfo struct { MediaItemID string `json:"media_item_id"` Title string `json:"title"` Author string `json:"author"` CoverImagePath string `json:"cover_image_path"` HasConflict bool `json:"has_conflict"` // Media-type identifier for card badges/hover icons (A2c) FormatGroup string `json:"format_group"` // Resolved cover-badge glyph: "audiobook" | "comic" | "manga" | "". // comic vs manga rides the LIBRARY type (format_group only knows // comic_archive — manga_type is unreliable across sources). Badge string `json:"badge"` } // LibraryTypeMap builds libraryID→library-type-name for badge resolution // (one small query per request; libraries are few). // // Call it ONCE per request and pass the map into the card adapters. // Calling it inside a per-item loop was the old way — it re-ran // ListLibraries for every card on the page. func LibraryTypeMap(ctx context.Context, db *database.Queries) map[string]string { m := map[string]string{} libs, err := db.ListLibraries(ctx) if err != nil { return m } for _, l := range libs { if l.ID.Valid { m[uuid.UUID(l.ID.Bytes).String()] = l.TypeName } } return m } // MediaBadgeKind resolves the badge glyph per media type from the row's // format group and its library's type name. The library type is // authoritative for the comic/manga split: manga volumes ship as // fixed_layout scans (image PDFs) just as often as comic_archive, and // manga_type is unreliable across sources (unknown/NULL on real rows), // so anything in a manga library reads manga, anything in a comics // library reads comic, and a stray comic_archive elsewhere still does. func MediaBadgeKind(formatGroup, libraryTypeName string) string { switch { case formatGroup == "audiobook": return "audiobook" case libraryTypeName == "manga": return "manga" case formatGroup == "comic_archive" || libraryTypeName == "comics": return "comic" } return "" } // buildBookCard is the single construction site for BookInfo. Feed // adapters below normalize the sqlc row shapes into cardSource. func buildBookCard(src cardSource) BookInfo { return BookInfo{ MediaItemID: src.ID.String(), Title: src.Title, Author: src.Author, CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath), FormatGroup: src.FormatGroup, Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType), } } // HasConflict is deliberately absent here: conflict stamping is a // per-user query (MarkActiveConflicts), done AFTER the cards are built // by the surfaces that carry it. Builders stay user-free. // cardSource is the neutral input to the builder core: whatever fields // every card feed row provably has. type cardSource struct { ID uuid.UUID Title string Author string LibraryID pgtype.UUID CoverPath pgtype.Text FormatGroup string LibraryType string } // BookCardsFromMediaItems builds cards from full media rows // (dashboard sections, smart-collection matches, series/tag books, // preview matches). libTypes must come from ONE LibraryTypeMap call // per request, not per row. func BookCardsFromMediaItems(rows []database.MediaItems, libTypes map[string]string) []BookInfo { cards := make([]BookInfo, 0, len(rows)) for _, r := range rows { cards = append(cards, buildBookCard(cardSource{ ID: uuid.UUID(r.ID.Bytes), Title: r.Title, Author: textToString(r.Author), LibraryID: r.LibraryID, CoverPath: r.CoverImagePath, FormatGroup: r.FormatGroup, LibraryType: libTypes[uuid.UUID(r.LibraryID.Bytes).String()], })) } return cards } // BookCardsFromCollectionDashRows builds cards from the // collection-items join (GetCollectionItemsForDashboard). The row's ID // IS the media item id. The Excluded filter stays at call sites — only // the two collection-detail surfaces exclude tombstones. func BookCardsFromCollectionDashRows(rows []database.GetCollectionItemsForDashboardRow, libTypes map[string]string) []BookInfo { cards := make([]BookInfo, 0, len(rows)) for _, r := range rows { cards = append(cards, buildBookCard(cardSource{ ID: uuid.UUID(r.ID.Bytes), Title: r.Title, Author: textToString(r.Author), LibraryID: r.LibraryID, CoverPath: r.CoverImagePath, FormatGroup: r.FormatGroup, LibraryType: libTypes[uuid.UUID(r.LibraryID.Bytes).String()], })) } return cards } // BookCardsFromCollectionItemRows builds cards from GetCollectionItems // rows, whose ID is the collection-item id and whose MediaItemID is the // book — the SSR all-libraries collection branch and the JSON // no-library branch both feed from this shape. func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libTypes map[string]string) []BookInfo { cards := make([]BookInfo, 0, len(rows)) for _, r := range rows { cards = append(cards, buildBookCard(cardSource{ ID: uuid.UUID(r.MediaItemID.Bytes), Title: r.Title, Author: textToString(r.Author), LibraryID: r.LibraryID, CoverPath: r.CoverImagePath, FormatGroup: r.FormatGroup, LibraryType: libTypes[uuid.UUID(r.LibraryID.Bytes).String()], })) } return cards } // BookCardsFromUnified builds cards from search-unified rows. Those // rows carry the library type name as a SQL column, so this adapter // needs no LibraryTypeMap query at all. func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo { cards := make([]BookInfo, 0, len(rows)) for _, r := range rows { cards = append(cards, buildBookCard(cardSource{ ID: uuid.UUID(r.ID.Bytes), Title: r.Title, Author: textToString(r.Author), LibraryID: r.LibraryID, CoverPath: r.CoverImagePath, FormatGroup: r.FormatGroup, LibraryType: r.LibraryTypeName, })) } return cards }