feat(podcasts): web UI — /podcasts page, feed detail, download lifecycle (task 1.9)

Rendering ownership holds throughout: every piece of server data is
templ-rendered; htmx swaps partials; the only TS is the Add-Feed modal
toggle + error surfacing (pure client-local state, bundled via
web/src/podcasts.ts with automatic bundle-hash stamping).

Sidebar: /podcasts nav entry (D13, between Series and Collections),
gated by a ShowPodcastsNav flag resolved centrally in
getTemplateUserWithTheme (one indexed COUNT — same class of
per-request lookup as the live-user guard). New 'podcast' icon.

/podcasts: header + Add Feed modal (htmx post → HX-Redirect to the
feed page; inline error surfacing on failure), subscription-scoped
search + facet bar (host/tag/language — server-side GET filters, no
JS), grid cards with download counts / new-episodes / complete /
explicit badges. Empty states for no-library (admin creates it first)
and no-subscriptions.

Feed detail: hero (artwork, host/publisher, tags, fetch state), action
row — Download All, Refresh, ORDER SELECTOR (D5 override), serial
one-click oldest-first suggestion (never silent), auto-download policy
select (D6), Unsubscribe (files retained), admin Remove Feed — gap
banner (D8 count), and the EPISODE LIST with the full lifecycle inline
(D8/D19): pending greyed + Download button (every non-downloaded
episode has one), queued/downloading pulse, failed RED + reason text +
Retry (same endpoint). The list is a self-refreshing partial: re-fetch
on the podcast:changed HX-Trigger and polls every 4s ONLY while the
server says downloads are active (it renders the polling variant).

API endpoints grew HX awareness: 204 + HX-Trigger podcast:changed (or
HX-Redirect for add/unsubscribe/remove) — the sanctioned Go-handler
response channels; JSON callers keep JSON. AddFeed result exposes the
fetch-error text for the redirect.

Dates render in the requesting user's timezone (D17) via
utils.FormatTimeInTimezone; episodes play through the existing
/readers/{id} audio reader (episodes ARE audiobook items — no new
player code day one). CSP tripwire (templates/csp_source_test.go)
passes: no inline scripts, no on*= handlers, no hx-on/js: vals.
This commit is contained in:
John O'Keefe committed 2026-10-29 16:17:55 -04:00
1 parent 54d7d6e7a1
commit 2235b00c59
19 files changed
+3540 -327

No files matched your search

+398
View File
@@ -0,0 +1,398 @@
package router
// Podcast SSR pages (plan task 1.9): /podcasts (subscriptions grid +
// add-feed wizard) and /podcasts/:id (feed detail with the inline
// download lifecycle). Rendering ownership: ALL content is
// templ-rendered server data; htmx swaps partials; the sanctioned JS is
// the modal toggle + event delegation in web/src/podcasts.ts.
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/utils"
"bookhoard/templates"
"bytes"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
func registerPodcastPages(cfg *Config) {
pages := cfg.Echo.Group("", createJWTMiddleware(cfg))
pages.GET("/podcasts", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
data, err := buildPodcastsPageData(c, cfg)
if err != nil {
data = &templates.PodcastsPageData{ErrorMsg: "Could not load subscriptions"}
}
var buf bytes.Buffer
if err := templates.PodcastsPage(user, *data, "/podcasts").Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
pages.GET("/podcasts/:id", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
data, err := buildPodcastDetailData(c, cfg, user)
if err != nil {
var buf bytes.Buffer
_ = templates.ErrorPage("Feed not found", "not_found").Render(c.Request().Context(), &buf)
return c.HTML(http.StatusNotFound, buf.String())
}
var buf bytes.Buffer
if err := templates.PodcastDetailPage(user, *data).Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Episode list partial — the htmx refresh unit (poll while
// downloads are active; podcast:changed events swap it too).
pages.GET("/podcasts/:id/episodes", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
data, err := buildPodcastDetailData(c, cfg, user)
if err != nil {
return c.NoContent(http.StatusNotFound)
}
var buf bytes.Buffer
if templates.HasActiveDownloads(data.Episodes) {
err = templates.PodcastEpisodeListPolling(*data).Render(c.Request().Context(), &buf)
} else {
err = templates.PodcastEpisodeList(*data).Render(c.Request().Context(), &buf)
}
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
}
// buildPodcastsPageData assembles the /podcasts page: subscription
// cards, facet values, server-side filters (q/host/tag/language).
func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPageData, error) {
user := handlers.MustGetAuthenticatedUser(c)
ctx := c.Request().Context()
libs, err := cfg.Queries.GetPodcastsLibraryForUser(ctx, user.ID)
data := &templates.PodcastsPageData{HasLibrary: len(libs) > 0}
if err != nil || len(libs) == 0 {
return data, err
}
for _, lib := range libs {
data.Libraries = append(data.Libraries, templates.LibraryData{
ID: uuid.UUID(lib.ID.Bytes).String(),
Name: lib.Name,
TypeName: "podcasts",
})
}
feeds, err := cfg.Queries.ListMyPodcastFeeds(ctx, user.ID)
if err != nil {
return data, err
}
for i := range feeds {
row := &feeds[i]
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID)
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, row.ID)
data.Feeds = append(data.Feeds, templates.PodcastFeedCard{
ID: uuid.UUID(row.ID.Bytes).String(),
LibraryID: uuid.UUID(row.LibraryID.Bytes).String(),
Title: row.Title,
DisplayName: row.DisplayName.String,
Description: firstLine(row.Description.String, 220),
Host: row.Host.String,
Publisher: row.Publisher.String,
Language: row.Language.String,
Tags: row.Tags,
Explicit: row.Explicit,
ArtworkPath: row.ArtworkPath.String,
FeedType: row.FeedType.String,
Medium: row.Medium.String,
IsComplete: row.IsComplete,
FeedURL: row.FeedUrl,
LastFetched: pgTimeDisplay(row.LastFetchedAt, utils.PgTextString(user.Timezone)),
SubscribedAt: pgTimeDisplay(row.SubscribedAt, utils.PgTextString(user.Timezone)),
EpisodeTotal: int(total),
DownloadedN: int(downloaded),
UndownloadedN: int(total) - int(downloaded),
NewEpisodes: total-downloaded > 0,
AutoDownload: row.AutoDownload,
AutoDownloadCount: int(row.AutoDownloadCount.Int32),
Subscribed: true,
})
}
// Facet values from the unfiltered subscription list (D18).
allData := *data
data.Facets = facetValuesFromCards(allData.Feeds)
// Server-side filters.
q := strings.ToLower(strings.TrimSpace(c.QueryParam("q")))
host := c.QueryParam("host")
tag := c.QueryParam("tag")
lang := c.QueryParam("language")
filtered := data.Feeds[:0:0]
for _, f := range data.Feeds {
if q != "" {
hay := strings.ToLower(f.Title + " " + f.DisplayName + " " + f.Host + " " + f.Publisher + " " + f.Description)
if !strings.Contains(hay, q) {
continue
}
}
if host != "" && f.Host != host {
continue
}
if lang != "" && !strings.EqualFold(f.Language, lang) {
continue
}
if tag != "" {
found := false
for _, t := range f.Tags {
if strings.EqualFold(t, tag) {
found = true
break
}
}
if !found {
continue
}
}
filtered = append(filtered, f)
}
data.Feeds = filtered
data.Query = q
return data, nil
}
// buildPodcastDetailData assembles the feed-detail page + episode list
// partial. RLS scopes the lookup (non-subscribers get sqlc.ErrNoRows →
// 404 semantics — no Go visibility plumbing, D20).
func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User) (*templates.PodcastDetailData, error) {
actor := handlers.MustGetAuthenticatedUser(c)
ctx := c.Request().Context()
feedID, err := parsePodcastUUID(c.Param("id"))
if err != nil {
return nil, err
}
feed, err := cfg.Queries.GetPodcastFeed(ctx, feedID)
if err != nil {
return nil, err
}
sub, _ := cfg.Queries.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{
UserID: actor.ID, FeedID: feedID,
})
eps, err := cfg.Queries.ListPodcastEpisodes(ctx, feedID)
if err != nil {
return nil, err
}
progressRows, _ := cfg.Queries.GetPodcastEpisodeProgress(ctx, database.GetPodcastEpisodeProgressParams{
UserID: actor.ID, SourceFeedID: feedID,
})
progressByItem := make(map[string]struct {
Pct float64
LastRead pgtype.Timestamptz
}, len(progressRows))
for _, p := range progressRows {
progressByItem[uuid.UUID(p.MediaItemID.Bytes).String()] = struct {
Pct float64
LastRead pgtype.Timestamptz
}{p.Percentage.Float64, p.LastReadAt}
}
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, feedID)
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, feedID)
card := templates.PodcastFeedCard{
ID: uuid.UUID(feed.ID.Bytes).String(),
LibraryID: uuid.UUID(feed.LibraryID.Bytes).String(),
Title: feed.Title,
DisplayName: sub.DisplayName.String,
Description: feed.Description.String,
Host: feed.Host.String,
Publisher: feed.Publisher.String,
Language: feed.Language.String,
Tags: feed.Tags,
Explicit: feed.Explicit,
ArtworkPath: feed.ArtworkPath.String,
FeedType: feed.FeedType.String,
Medium: feed.Medium.String,
IsComplete: feed.IsComplete,
FeedURL: feed.FeedUrl,
LastFetched: pgTimeDisplay(feed.LastFetchedAt, user.Timezone),
EpisodeTotal: int(total),
DownloadedN: int(downloaded),
UndownloadedN: int(total) - int(downloaded),
NewEpisodes: total-downloaded > 0,
AutoDownload: sub.AutoDownload,
AutoDownloadCount: int(sub.AutoDownloadCount.Int32),
Subscribed: sub.ID.Valid,
}
rows := make([]templates.PodcastEpisodeRow, 0, len(eps))
gaps := 0
seenDownloadedFrom := false // walk reversed: gap = undownloaded with a downloaded LATER in order
for i := len(eps) - 1; i >= 0; i-- {
ep := &eps[i]
downloadedEp := ep.DownloadState == "downloaded"
if !downloadedEp && seenDownloadedFrom {
gaps++
}
if downloadedEp {
seenDownloadedFrom = true
}
}
// Forward pass for the actual rows (native order — the list keeps
// feed order; the ORDER selector drives the sequence endpoint, and
// the order control re-renders this list via the override applied
// below).
order := podcastOrderForFeed(cfg, c, feedID.String())
for i := range eps {
ep := &eps[i]
p := progressByItem[uuid.UUID(ep.ID.Bytes).String()]
dateDisplay := ""
if ep.DatePublished.Valid {
dateDisplay = ep.DatePublished.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006")
}
rows = append(rows, templates.PodcastEpisodeRow{
ID: uuid.UUID(ep.ID.Bytes).String(),
FeedID: uuid.UUID(ep.SourceFeedID.Bytes).String(),
Title: ep.Title,
Description: firstLine(ep.Description.String, 200),
Date: dateDisplay,
Position: int(ep.FeedPosition.Int32),
Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)),
Size: templates.FormatEpisodeSize(ep.FileSize.Int64),
PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(),
ProgressPct: p.Pct,
Downloaded: ep.DownloadState == "downloaded",
DownloadState: ep.DownloadState,
DownloadError: ep.DownloadError.String,
})
}
// Order override reshapes the LIST too (the sequence endpoint is the
// strict downloaded-only view; the list shows everything in the
// chosen order so pending episodes stay visible).
if order == "oldest" || order == "newest" {
sort.SliceStable(rows, func(a, b int) bool {
ka := rows[a].Date + itoaPad(rows[a].Position)
kb := rows[b].Date + itoaPad(rows[b].Position)
if order == "newest" {
return ka > kb
}
return ka < kb
})
}
return &templates.PodcastDetailData{
Feed: card,
Episodes: rows,
GapCount: gaps,
Order: order,
IsAdmin: actor.Role == "admin",
IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook",
}, nil
}
// podcastOrderForFeed reads the caller's order override (D5).
func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string {
user := handlers.MustGetAuthenticatedUser(c)
row, err := cfg.Queries.GetUserSettingByKey(c.Request().Context(), database.GetUserSettingByKeyParams{
UserID: user.ID,
SettingKey: "podcast_order:" + feedID,
})
if err != nil {
return "native"
}
var v struct {
Order string `json:"order"`
}
if err := json.Unmarshal(row, &v); err != nil {
return "native"
}
switch v.Order {
case "oldest", "newest":
return v.Order
}
return "native"
}
// facetValuesFromCards derives the filter bar values.
func facetValuesFromCards(feeds []templates.PodcastFeedCard) map[string][]string {
hosts, tags, langs := map[string]bool{}, map[string]bool{}, map[string]bool{}
for _, f := range feeds {
if f.Host != "" {
hosts[f.Host] = true
}
for _, t := range f.Tags {
tags[t] = true
}
if f.Language != "" {
langs[f.Language] = true
}
}
return map[string][]string{
"hosts": sortedSet(hosts),
"tags": sortedSet(tags),
"language": sortedSet(langs),
}
}
func sortedSet(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
func firstLine(s string, max int) string {
s = strings.TrimSpace(s)
if i := strings.IndexAny(s, "\n\r"); i > 0 {
s = s[:i]
}
if len(s) > max {
s = s[:max] + "…"
}
return s
}
func pgTimeDisplay(t pgtype.Timestamptz, tz string) string {
if !t.Valid {
return ""
}
return utils.FormatTimeInTimezone(t.Time, tz)
}
func itoaPad(n int) string {
return fmt.Sprintf("%06d", n)
}
// parsePodcastUUID parses an id path param (handlers.parseUUID is
// package-private).
func parsePodcastUUID(s string) (pgtype.UUID, error) {
id, err := uuid.Parse(strings.TrimSpace(s))
if err != nil {
return pgtype.UUID{}, err
}
return pgtype.UUID{Bytes: id, Valid: true}, nil
}