feat(podcasts): generic-surface exclusions — podcasts never render as books (task 1.3)

§9 / D3 / D13 / D15 / D16: podcasts-type libraries are excluded from
every generic book surface. Under RLS these are pure UX/type filters
(engine already hides other users' episodes); their job is preventing
episode grids in surfaces designed for books. The discriminator is the
denormalized media_items.library_type_name (IS DISTINCT FROM 'podcasts'
— NULL rows predate the column and stay visible).

SQL (queries.sql, marked '-- podcasts exclusion (plan §9)'):
ListMediaItems/ListMediaItemsSorted (All Books) · SearchMediaItems +
SearchMediaItemsUnified (generic search) · FacetCountsUnderFilters (all
14 arms) + the six Search*Values facet-suggestion queries ·
GetDistinctSeries + Count (series pages) · GetLibraryItems (collections
picker) · dashboard system sections (Continue Reading / Recently Added
/ Recently Read / Not Started) · GetPopularBooks + GetTopBooksByMinutes
(book rankings must not rank episodes).

Go: statistics summary counts episodes' sessions into time-spent (D15
keeps that) but skips them in BooksActive/BooksFinished — an episode is
not a book. OPDS filters podcasts libraries via one helper at all 7
library-walk sites (D16).

Templ: LibrarySwitcher skips podcasts entries and TotalMediaCount
excludes them — presentation-layer filter so every switcher consumer
(dashboard, collections, custom-section builder) is correct at once
(D13); podcasts surface via /podcasts only.
This commit is contained in:
John O'Keefe committed 2026-10-29 15:44:48 -04:00
1 parent c0e6993273
commit 9a148c2344
8 files changed
+174 -47

No files matched your search

+25 -8
View File
@@ -1,5 +1,4 @@
package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/opds"
@@ -18,6 +17,24 @@ import (
"github.com/labstack/echo/v5"
)
// opdsVisibleLibraries is GetUserVisibleLibraries minus podcasts-type
// libraries (plan D16): feeds are not OPDS inventory day one — they
// surface via the /podcasts endpoints only.
func opdsVisibleLibraries(ctx context.Context, q *database.Queries, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) {
libs, err := q.GetUserVisibleLibraries(ctx, userID)
if err != nil {
return nil, err
}
out := make([]database.GetUserVisibleLibrariesRow, 0, len(libs))
for _, lib := range libs {
if lib.TypeName == services.LibraryTypePodcasts {
continue
}
out = append(out, lib)
}
return out, nil
}
type OPDSHandler struct {
db *database.Queries
libraryService *services.LibraryService
@@ -209,7 +226,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
// Get user's visible libraries
userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
}
@@ -318,7 +335,7 @@ func (h *OPDSHandler) serveAllBooksCatalog(c *echo.Context) error {
// Get user's visible libraries
userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
}
@@ -570,7 +587,7 @@ func (h *OPDSHandler) GetLibraryCatalog(c *echo.Context) error {
// The library must be visible to the device owner.
userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
}
@@ -677,7 +694,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
// Get user's visible libraries
userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
}
@@ -818,7 +835,7 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
// Get user's visible libraries
userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
}
@@ -974,7 +991,7 @@ func (h *OPDSHandler) GetCoverImage(c *echo.Context) error {
// Get user's visible libraries
userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
}
@@ -1115,7 +1132,7 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
// Get user's visible libraries
userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
}