feat(podcasts): real-time episode list — byte progress bars, direct swaps (issues #1/#3)

WHY: downloads needed manual refreshes to see progress; the 204 +
HX-Trigger event indirection missed its listener, and the worker
buffered whole enclosures so no progress existed to show.

- Worker streams enclosures to disk through a counting loop (64KiB
  chunks, scheme/redirect/size guards preserved) with an in-memory
  per-episode progress snapshot (received/total; total from
  Content-Length, seeded by the feed's declared length). Deliberately
  transient — the DB row state remains the durable truth.
- Episode-list renderer seam: the router injects a closure into
  PodcastHandler (handlers can't import templates — MediaDetail cycle)
  so htmx download/retry/Download All/refresh/order/policy actions
  answer with the REFRESHED partial directly (200, hx-target=
  #episode-list) — no events to miss. Non-HX callers keep JSON; tests
  without a renderer keep 204+trigger.
- Polling tightens to 2s while downloads are active; downloading rows
  render a percentage bar + MB counter (quote-free inline style per
  templ gotcha #8) or an indeterminate bar when the server sends no
  length.
- Issue #3: Download All (button AND the add-feed checkbox) flips the
  CALLER's subscription policy to 'all' — per-user, never global (D6:
  files shared, policies not) — and the auto-download select
  out-of-band-swaps so the dropdown shows what the user is getting.
  They can turn it down afterwards; the flip is honest communication,
  not coercion (user-approved 2026-10-29).

PINs: Download All with HX answers 200 + episode-list + oob select and
flips newest → all; add-feed download_all sets policy all.
This commit is contained in:
John O'Keefe committed 2026-10-29 20:12:19 -04:00
1 parent ffc80f7ba7
commit 30766020c8
8 files changed
+545 -57

No files matched your search

+44
View File
@@ -4,9 +4,53 @@ package router
// middleware; scoping is RLS's job (D20) — handlers add no visibility
// plumbing.
import (
"bytes"
"net/http"
"bookhoard/templates"
"github.com/labstack/echo/v5"
)
func registerPodcastRoutes(cfg *Config) {
g := cfg.Echo.Group("/api/podcasts", createJWTMiddleware(cfg))
// Episode-list renderer (issue #1): htmx actions answer with the
// refreshed partial directly — no event indirection to miss. The
// view assembly lives here (templates) while the action logic lives
// in the handler; this closure is the sanctioned seam.
cfg.PodcastHandler.SetEpisodeListRenderer(func(c *echo.Context, feedID string, includePolicySelect bool) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return err
}
feedUUID, err := parsePodcastUUID(feedID)
if err != nil {
return err
}
data, err := buildPodcastDetailData(c, cfg, user, feedUUID)
if err != nil {
return err
}
var buf bytes.Buffer
render := templates.PodcastEpisodeList
if templates.HasActiveDownloads(data.Episodes) {
render = templates.PodcastEpisodeListPolling
}
if err := render(*data).Render(c.Request().Context(), &buf); err != nil {
return err
}
if includePolicySelect {
// Issue #3: out-of-band swap of the auto-download select —
// the dropdown reflects the flipped policy immediately.
if err := templates.PodcastAutoDownloadSelectOOB(data.Feed).Render(c.Request().Context(), &buf); err != nil {
return err
}
}
return c.HTML(http.StatusOK, buf.String())
})
g.POST("/feeds", cfg.PodcastHandler.AddFeed)
g.GET("/feeds", cfg.PodcastHandler.ListFeeds)
g.GET("/search", cfg.PodcastHandler.Search)