TestContractPin_CollectionsJSON tripped on the additive keys the episode-collections work introduced — the tripwire worked exactly as designed (additive keys must be whitelisted here AND in docs/contract_pins.md first). - Collections envelope gains rule_match_mode (item 2.4): the smart-collection match semantics on GET /api/collections/:id. - BookInfo gains four STABLE keys (item 2.3): is_episode, feed_title, download_state, hidden — dropped omitempty so every card surface emits the same key set (the viewer_percentage precedent; adapters that predate episode awareness emit zero values). docs/contract_pins.md changelog entry written. Nothing removed, nothing renamed.
257 lines
9.8 KiB
Go
257 lines
9.8 KiB
Go
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 nine
|
|
// 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"`
|
|
// Viewer-scoped extras (2026-10-27 search-progress batch): nil
|
|
// percentage = the requesting user never opened the book, so cards
|
|
// render no progress bar and no completed badge. Adapters that
|
|
// don't load viewer progress leave it nil; the search-unified
|
|
// adapter is the only populated feed today. LibraryName rides the
|
|
// same deal (unified rows carry it as a column; other feeds leave
|
|
// it empty rather than pay a join).
|
|
ViewerPercentage *float64 `json:"viewer_percentage"`
|
|
LibraryName string `json:"library_name"`
|
|
// Episode-collections extras (plan item 2.3): populated only by the
|
|
// collection-detail adapter. IsEpisode renders the episode-row
|
|
// variant in the grid; FeedTitle disambiguates same-named episodes;
|
|
// DownloadState gates the play affordance. Hidden=true means the
|
|
// LEFT JOIN missed under RLS (unsubscribed episode, or a
|
|
// family-lens hidden book) — the row renders as an "unavailable"
|
|
// hint instead of silently vanishing. Adapters that don't load
|
|
// visibility leave Hidden false (cards stay fully rendered). The
|
|
// four keys are NOT omitempty: every BookInfo surface emits the
|
|
// same stable key set (the viewer_percentage precedent —
|
|
// contract-pin widening, docs/contract_pins.md 2026-10-30).
|
|
IsEpisode bool `json:"is_episode"`
|
|
FeedTitle string `json:"feed_title"`
|
|
DownloadState string `json:"download_state"`
|
|
Hidden bool `json:"hidden"`
|
|
}
|
|
|
|
// 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),
|
|
ViewerPercentage: src.ViewerProgress,
|
|
LibraryName: src.LibraryName,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// Viewer-scoped extras: nil progress = no data loaded for the
|
|
// requesting user (see BookInfo.ViewerPercentage).
|
|
ViewerProgress *float64
|
|
LibraryName 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: utils.PgTextString(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 query
|
|
// embeds the media row and filters excluded rows in SQL, so the adapter
|
|
// reads through the embed.
|
|
func BookCardsFromCollectionDashRows(rows []database.GetCollectionItemsForDashboardRow, libTypes map[string]string) []BookInfo {
|
|
cards := make([]BookInfo, 0, len(rows))
|
|
for _, r := range rows {
|
|
mi := r.MediaItems
|
|
cards = append(cards, buildBookCard(cardSource{
|
|
ID: uuid.UUID(mi.ID.Bytes),
|
|
Title: mi.Title,
|
|
Author: utils.PgTextString(mi.Author),
|
|
LibraryID: mi.LibraryID, CoverPath: mi.CoverImagePath,
|
|
FormatGroup: mi.FormatGroup,
|
|
LibraryType: libTypes[uuid.UUID(mi.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: utils.PgTextString(r.Author),
|
|
LibraryID: r.LibraryID,
|
|
CoverPath: r.CoverImagePath,
|
|
FormatGroup: r.FormatGroup,
|
|
LibraryType: libTypes[uuid.UUID(r.LibraryID.Bytes).String()],
|
|
}))
|
|
}
|
|
return cards
|
|
}
|
|
|
|
// BookCardsFromCollectionDetailed builds cards from the
|
|
// GetCollectionItemsDetailed rows (plan item 2.3): the collection's
|
|
// full membership with per-row visibility. RLS-invisible rows
|
|
// (unsubscribed episode, family-lens hidden book) become Hidden cards
|
|
// — the grid renders them as "unavailable" hint rows; episode rows
|
|
// carry feed title + download state for the play affordance. Unknown
|
|
// library types resolve no badge; hidden rows need none anyway.
|
|
func BookCardsFromCollectionDetailed(rows []database.GetCollectionItemsDetailedRow, libTypes map[string]string) []BookInfo {
|
|
cards := make([]BookInfo, 0, len(rows))
|
|
for _, r := range rows {
|
|
if !r.Visible {
|
|
cards = append(cards, BookInfo{
|
|
MediaItemID: uuid.UUID(r.MediaItemID.Bytes).String(),
|
|
Hidden: true,
|
|
})
|
|
continue
|
|
}
|
|
isEpisode := r.Source.String == "podcast_episode"
|
|
card := buildBookCard(cardSource{
|
|
ID: uuid.UUID(r.MediaItemID.Bytes),
|
|
Title: utils.PgTextString(r.Title),
|
|
Author: utils.PgTextString(r.Author),
|
|
CoverPath: r.CoverImagePath,
|
|
FormatGroup: r.FormatGroup.String,
|
|
})
|
|
card.IsEpisode = isEpisode
|
|
card.FeedTitle = r.FeedTitle.String
|
|
card.DownloadState = r.DownloadState.String
|
|
cards = append(cards, card)
|
|
}
|
|
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 — and the viewer's own
|
|
// progress (the 2026-10-27 search-progress batch), which no other
|
|
// feed loads.
|
|
func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo {
|
|
cards := make([]BookInfo, 0, len(rows))
|
|
for _, r := range rows {
|
|
var viewer *float64
|
|
if r.ViewerPercentage.Valid {
|
|
pct := r.ViewerPercentage.Float64
|
|
viewer = &pct
|
|
}
|
|
cards = append(cards, buildBookCard(cardSource{
|
|
ID: uuid.UUID(r.ID.Bytes),
|
|
Title: r.Title,
|
|
Author: utils.PgTextString(r.Author),
|
|
LibraryID: r.LibraryID,
|
|
CoverPath: r.CoverImagePath,
|
|
FormatGroup: r.FormatGroup,
|
|
LibraryType: r.LibraryTypeName,
|
|
ViewerProgress: viewer,
|
|
LibraryName: r.LibraryName,
|
|
}))
|
|
}
|
|
return cards
|
|
}
|