Files
bookhoard/internal/router/search.go
T
John O'Keefe adb93cba9e feat(podcasts): episode collections — picker scope, hint rows, the chain (item 2.3)
D11 lifts: podcast episodes (media_items rows) join collections, and
"Play collection as sequence" feeds the player from the collection's
order. No new tables — collection_items already carries ordered
membership (item 2.1 audit).

PICKER: the collection modal gains a Books/Episodes tab pair. The
episodes tab hits /api/media-items/search?scope=episodes — a dedicated
picker query (SearchEpisodesForPicker) whose visibility is entirely
RLS: the media_items subscription predicate drops unsubscribed feeds'
episodes, and the podcast_feeds join is governed the same way. The
books scope keeps its podcasts exclusion (plan §9) — this scope is the
only door in. Pending episodes are pickable (plan ahead; the detail
page shows them as not downloaded). Selection rides the existing
bookPicker store — ids never collide across scopes, and AddBooks works
unchanged on episode ids.

DETAIL: the All-Libraries membership view switches to
GetCollectionItemsDetailed — LEFT JOIN + per-row visibility. An
unsubscribed episode (or family-lens hidden book) survives as a
Hidden card rendered as an "Unavailable — remove entry" hint row
instead of silently vanishing; visible episodes render feed title,
download state, and a Play link. The library-scoped view keeps its
existing dashboard-query behavior (the switcher's contract is
untouched for books).

CHAIN: ?queue=collection:<id> on any reader URL makes
buildPodcastSequenceContext produce the COLLECTION context (§10 R2
same shape, source=collection): downloaded episodes in collection
order (positions are content — R1; no feed-order override), prev/next
URLs carrying the queue param so the chain survives hops, and
backToDetail landing on the collection page. Ownership gates the chain
— foreign/deleted ids fall back to the feed queue; the order control
stays hidden (2.2's source gate). The header's "Play as Sequence"
button starts from the first downloaded episode in list order.

Pinned by TestPodcastEpisodeCollections (7 subtests): RLS-scoped
picker + family-lens auditor case (subscribed episode in a hidden
library must not reach the picker), books-scope exclusion intact,
hint rows, chain carrying the queue param, start-from-first, foreign
fallback, and reorder driving the chain (the R4 primitive works on
episode ids unchanged).
2026-10-30 16:18:29 -04:00

128 lines
4.9 KiB
Go

package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/templates"
"bytes"
"log"
"net/http"
"github.com/labstack/echo/v5"
)
func registerSearchRoutes(cfg *Config) {
e := cfg.Echo
// JWT middleware for protected routes
jwtMiddleware := createJWTMiddleware(cfg)
protected := e.Group("/api", jwtMiddleware)
// Search endpoint - returns JSON or HTML based on HX-Request header
protected.GET("/media-items/search", func(c *echo.Context) error {
// Check if HTMX request (expects HTML)
if c.Request().Header.Get("HX-Request") == "true" {
return handleSearchHTML(c, cfg)
}
// Otherwise return JSON (API clients)
return cfg.MediaHandler.SearchMediaItems(c)
})
// Facet-value listings for the filter panel (faceted-navigation
// Batch C): /api/media-items/facets?facet=X&library_id=...
protected.GET("/media-items/facets", cfg.MediaHandler.GetFacets)
// Rule-builder live count (faceted plan Phase 9): the composed
// tree's true match count (COUNT(*)), rules + library scope.
protected.GET("/media-items/filter-count", cfg.MediaHandler.FilterCount)
protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks)
}
// handleSearchHTML renders HTML response for HTMX requests
func handleSearchHTML(c *echo.Context, cfg *Config) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.String(http.StatusUnauthorized, "Unauthorized")
}
// Episode picker scope (plan item 2.3): ?scope=episodes&show_checkbox=true
// swaps the book cards for YOUR subscriptions' episodes — visibility
// is the RLS policy's job (D20), the Go side adds nothing. The books
// branch keeps its podcasts exclusion; this is the only door in.
if c.QueryParam("scope") == "episodes" && c.QueryParam("show_checkbox") == "true" {
eps, err := cfg.Queries.SearchEpisodesForPicker(c.Request().Context(), c.QueryParam("q"))
if err != nil {
log.Printf("Episode picker search error: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
}
var buf bytes.Buffer
if err := templates.EpisodePickerGrid(eps).Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}
// Extract filters via the SHARED parser (handlers.ParseMediaSearchParams)
// — the HTMX grid partial and the JSON endpoint can never disagree on
// which facet params exist or how they parse. (This branch previously
// dropped publisher_filter and narrator_filter entirely — the shared
// parser fixes that too.)
params := handlers.ParseMediaSearchParams(c)
params.UserID = user.ID
// Filter rule builder: a malformed qf is a 400, never a silently
// ignored token.
if params.FilterParseErr != "" {
return c.HTML(http.StatusBadRequest, `<div style="color: red;">Invalid filter</div>`)
}
limit := params.Limit
offset := params.Offset
libraryID := c.QueryParam("library_id")
// Family-lens gate: an explicit library_id outside the requester's
// visible set is a 403 (same rule as the JSON branch).
if params.LibraryID.Valid {
_, ids, err := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
if err != nil {
log.Printf("VisibleLibrarySet failed: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
}
visible := false
for _, id := range ids {
if id == params.LibraryID {
visible = true
break
}
}
if !visible {
return c.HTML(http.StatusForbidden, `<div style="color: red;">Library not visible</div>`)
}
}
// Execute search using handler method
results, totalCount, err := cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
if err != nil {
log.Printf("Search error: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
}
// Convert to BookInfo
bookInfoList := handlers.BookCardsFromUnified(results)
// Stamp active conflict flags so cards route the play action correctly
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
// Render using BooksGrid template (or BookPickerGrid for collection picker)
var buf bytes.Buffer
switch {
case c.QueryParam("show_checkbox") == "true":
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
case c.QueryParam("layout") == "dropdown":
// Header-search dropdown: compact templ rows (rendering
// ownership) carrying the viewer's progress/completed overlay.
// Replaces the old innerHTML string renderer in search.ts.
err = templates.SearchResults(bookInfoList, c.QueryParam("q")).Render(c.Request().Context(), &buf)
default:
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
}
if err != nil {
log.Printf("Template render error: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
}
return c.HTML(http.StatusOK, buf.String())
}