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

+55 -6
View File
@@ -33,21 +33,39 @@ func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worke
// ---- request/response shapes ----
type AddPodcastFeedRequest struct {
LibraryID string `json:"library_id" validate:"required"`
URL string `json:"url" validate:"required"`
DownloadAll bool `json:"download_all"`
LibraryID string `json:"library_id" form:"library_id" validate:"required"`
URL string `json:"url" form:"url" validate:"required"`
DownloadAll bool `json:"download_all" form:"download_all"`
}
type UpdatePodcastSubscriptionRequest struct {
DisplayName *string `json:"display_name"`
AutoDownload *string `json:"auto_download" validate:"omitempty,oneof=off all newest"`
AutoDownloadCount *int `json:"auto_download_count"`
DisplayName *string `json:"display_name" form:"display_name"`
AutoDownload *string `json:"auto_download" form:"auto_download" validate:"omitempty,oneof=off all newest"`
AutoDownloadCount *int `json:"auto_download_count" form:"auto_download_count"`
}
type DownloadEpisodesRequest struct {
EpisodeIDs []string `json:"episode_ids"`
}
// hxAccepted answers an htmx request with 204 + the podcast:changed
// trigger — the episode-list partial re-fetches itself (its hx-trigger
// listens for the event; polling covers download progress). JSON
// callers keep the JSON body. HX-Redirect/HX-Trigger headers are the
// sanctioned Go-handler response channels (CSP discipline).
func hxAccepted(c *echo.Context, redirect string) error {
if redirect != "" {
c.Response().Header().Set("HX-Redirect", redirect)
return c.NoContent(http.StatusNoContent)
}
c.Response().Header().Set("HX-Trigger", "podcast:changed")
return c.NoContent(http.StatusNoContent)
}
func isHXRequest(c *echo.Context) bool {
return c.Request().Header.Get("HX-Request") == "true"
}
// podcastErrorResponse maps service errors (incl. PodcastError statuses)
// onto HTTP responses.
func podcastErrorResponse(c *echo.Context, err error) error {
@@ -94,6 +112,14 @@ func (h *PodcastHandler) AddFeed(c *echo.Context) error {
// episode list states.
}
}
if isHXRequest(c) {
// Post-redirect-get: land on the new feed's page. Fetch errors
// (subscription stands) surface via the flash query param.
if res.FeedError() != "" {
return hxAccepted(c, "/podcasts/"+uuidString(res.Feed.ID)+"?fetch_error=1")
}
return hxAccepted(c, "/podcasts/"+uuidString(res.Feed.ID))
}
return c.JSON(http.StatusCreated, res)
}
@@ -223,6 +249,11 @@ func (h *PodcastHandler) UpdateSubscription(c *echo.Context) error {
if err != nil {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
// Policy changes feed the auto-download union on the next poll.
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
return hxAccepted(c, "")
}
return c.JSON(http.StatusOK, updated)
}
@@ -242,6 +273,9 @@ func (h *PodcastHandler) DeleteSubscription(c *echo.Context) error {
if rows == 0 {
return c.JSON(http.StatusNotFound, map[string]string{"error": "not subscribed"})
}
if isHXRequest(c) {
return hxAccepted(c, "/podcasts")
}
return c.JSON(http.StatusOK, map[string]string{"message": "unsubscribed (files retained)"})
}
@@ -256,6 +290,9 @@ func (h *PodcastHandler) RefreshFeed(c *echo.Context) error {
if err := h.podcasts.RefreshFeed(c.Request().Context(), feedID); err != nil {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
return hxAccepted(c, "")
}
return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"})
}
@@ -301,6 +338,9 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
if err != nil {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
return hxAccepted(c, "")
}
return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued})
}
epID, err := parseUUID(eidRaw)
@@ -310,6 +350,9 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
if err := h.worker.QueueEpisode(ctx, epID, feedID); err != nil {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
return hxAccepted(c, "")
}
return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"})
}
@@ -367,6 +410,9 @@ func (h *PodcastHandler) DeleteFeed(c *echo.Context) error {
} else if rows == 0 {
return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"})
}
if isHXRequest(c) {
return hxAccepted(c, "/podcasts")
}
return c.JSON(http.StatusOK, map[string]string{"message": "feed removed"})
}
@@ -438,6 +484,9 @@ func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
return podcastErrorResponse(c, err)
}
}
if isHXRequest(c) {
return hxAccepted(c, "")
}
return c.JSON(http.StatusOK, map[string]string{"order": req.Order})
}