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

+61 -5
View File
@@ -7,6 +7,7 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"sort"
@@ -24,6 +25,55 @@ type PodcastHandler struct {
db *database.Queries
podcasts *services.PodcastService
worker *services.PodcastDownloadWorker
// episodeListRenderer renders the refreshed episode-list partial
// for htmx action responses (download/refresh/order/policy).
// Injected by the router at wiring time — the view assembly lives
// with the other templ view code (handlers must not import
// templates: templates imports handlers for MediaDetail). When nil
// (tests, API-only), htmx callers fall back to JSON + HX-Trigger.
episodeListRenderer EpisodeListRenderer
}
// EpisodeListRenderer renders the post-action episode list for an htmx
// request. includePolicySelect additionally out-of-band-swaps the
// auto-download select (Download All flips the caller's policy — the
// dropdown must reflect it immediately, issue #3).
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect bool) error
// SetEpisodeListRenderer wires the renderer (router-only; product wiring).
func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
h.episodeListRenderer = r
}
// hxEpisodeList answers an htmx action with the refreshed partial when
// a renderer is wired; otherwise falls back to 204 + HX-Trigger.
func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePolicySelect bool) error {
if h.episodeListRenderer != nil {
return h.episodeListRenderer(c, feedID, includePolicySelect)
}
return hxAccepted(c, "")
}
// flipSubscriptionToAll implements issue #3: Download All (button or
// add-feed checkbox) sets the CALLER's subscription policy to 'all' —
// per-user, never global (files are shared D6, policies are not). The
// dropdown reflects it via the renderer's oob swap; the user can turn
// it down afterwards.
func (h *PodcastHandler) flipSubscriptionToAll(ctx context.Context, userID, feedID pgtype.UUID) {
sub, err := h.db.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{
UserID: userID, FeedID: feedID,
})
if err != nil || sub.AutoDownload == "all" {
return // not subscribed (admin pass-through) or already all
}
_, _ = h.db.UpdatePodcastSubscription(ctx, database.UpdatePodcastSubscriptionParams{
UserID: userID,
FeedID: feedID,
DisplayName: sub.DisplayName,
AutoDownload: "all",
AutoDownloadCount: pgtype.Int4{}, // all needs no count
})
}
func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worker *services.PodcastDownloadWorker) *PodcastHandler {
@@ -111,6 +161,9 @@ func (h *PodcastHandler) AddFeed(c *echo.Context) error {
// subscription stands; download-all failure surfaces via the
// episode list states.
}
// Issue #3: the checkbox means "everything, now AND going
// forward" — reflect it in the caller's subscription policy.
h.flipSubscriptionToAll(c.Request().Context(), user.ID, res.Feed.ID)
}
if isHXRequest(c) {
// Post-redirect-get: land on the new feed's page. Fetch errors
@@ -252,7 +305,7 @@ func (h *PodcastHandler) UpdateSubscription(c *echo.Context) error {
if isHXRequest(c) {
// Policy changes feed the auto-download union on the next poll.
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
return hxAccepted(c, "")
return h.hxEpisodeList(c, feedID.String(), true)
}
return c.JSON(http.StatusOK, updated)
}
@@ -291,7 +344,7 @@ func (h *PodcastHandler) RefreshFeed(c *echo.Context) error {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
return hxAccepted(c, "")
return h.hxEpisodeList(c, feedID.String(), false)
}
return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"})
}
@@ -339,7 +392,10 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
return hxAccepted(c, "")
// Download All flips the caller's policy to all (issue #3)
// — the oob select keeps the dropdown honest.
h.flipSubscriptionToAll(ctx, user.ID, feedID)
return h.hxEpisodeList(c, feedID.String(), true)
}
return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued})
}
@@ -351,7 +407,7 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
return hxAccepted(c, "")
return h.hxEpisodeList(c, feedID.String(), false)
}
return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"})
}
@@ -485,7 +541,7 @@ func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
}
}
if isHXRequest(c) {
return hxAccepted(c, "")
return h.hxEpisodeList(c, feedID.String(), false)
}
return c.JSON(http.StatusOK, map[string]string{"order": req.Order})
}
+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)
+38 -11
View File
@@ -47,7 +47,11 @@ func registerPodcastPages(cfg *Config) {
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
data, err := buildPodcastDetailData(c, cfg, user)
fid, ferr := parsePodcastUUID(c.Param("id"))
if ferr != nil {
return c.NoContent(http.StatusNotFound)
}
data, err := buildPodcastDetailData(c, cfg, user, fid)
if err != nil {
var buf bytes.Buffer
_ = templates.ErrorPage("Feed not found", "not_found").Render(c.Request().Context(), &buf)
@@ -67,7 +71,11 @@ func registerPodcastPages(cfg *Config) {
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
data, err := buildPodcastDetailData(c, cfg, user)
fid, ferr := parsePodcastUUID(c.Param("id"))
if ferr != nil {
return c.NoContent(http.StatusNotFound)
}
data, err := buildPodcastDetailData(c, cfg, user, fid)
if err != nil {
return c.NoContent(http.StatusNotFound)
}
@@ -226,14 +234,10 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
// 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) {
func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, feedID pgtype.UUID) (*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
@@ -261,6 +265,7 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User) (
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, feedID)
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, feedID)
subscriberCount, _ := cfg.Queries.CountPodcastFeedSubscribers(ctx, feedID)
card := templates.PodcastFeedCard{
ID: uuid.UUID(feed.ID.Bytes).String(),
@@ -308,7 +313,8 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User) (
order := podcastOrderForFeed(cfg, c, feedID.String())
for i := range eps {
ep := &eps[i]
p := progressByItem[uuid.UUID(ep.ID.Bytes).String()]
rowID := uuid.UUID(ep.ID.Bytes).String()
p := progressByItem[rowID]
dateDisplay := ""
if ep.DatePublished.Valid {
dateDisplay = ep.DatePublished.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006")
@@ -323,7 +329,7 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User) (
Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)),
Size: templates.FormatEpisodeSize(ep.FileSize.Int64),
PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(),
ProgressPct: p.Pct,
ListenPct: p.Pct,
Downloaded: ep.DownloadState == "downloaded",
DownloadState: ep.DownloadState,
DownloadError: ep.DownloadError.String,
@@ -343,14 +349,35 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User) (
})
}
return &templates.PodcastDetailData{
detail := &templates.PodcastDetailData{
Feed: card,
Episodes: rows,
GapCount: gaps,
Order: order,
IsAdmin: actor.Role == "admin",
IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook",
}, nil
SubscriberCount: int(subscriberCount),
}
// Live byte progress for in-flight downloads (issue #1): read the
// worker's snapshot; rows not actively downloading render states.
if cfg.PodcastWorker != nil {
for i := range detail.Episodes {
row := &detail.Episodes[i]
if row.DownloadState != "downloading" {
continue
}
if recv, tot, ok := cfg.PodcastWorker.EpisodeProgress(pgtype.UUID{Bytes: uuid.MustParse(row.ID), Valid: true}); ok {
row.ProgressKnown = true
row.ProgressReceived = recv
row.ProgressTotal = tot
if tot > 0 {
row.ProgressPct = float64(recv) / float64(tot) * 100
}
}
}
}
return detail, nil
}
// podcastOrderForFeed reads the caller's order override (D5).
+154 -11
View File
@@ -31,7 +31,10 @@ import (
"log"
"os"
"path/filepath"
"net/http"
"net/url"
"strings"
"sync"
"time"
"bookhoard/internal/database"
@@ -48,13 +51,19 @@ type PodcastDownloadWorker struct {
db *database.Queries
svc *PodcastService
wake chan struct{}
// Live byte-progress slots for in-flight downloads (issue #1).
// Keyed by episode UUID string; guarded by progMu.
progMu sync.Mutex
progress map[string]*episodeProgress
}
func NewPodcastDownloadWorker(db *database.Queries, svc *PodcastService) *PodcastDownloadWorker {
return &PodcastDownloadWorker{
db: db,
svc: svc,
wake: make(chan struct{}, 1),
db: db,
svc: svc,
wake: make(chan struct{}, 1),
progress: make(map[string]*episodeProgress),
}
}
@@ -287,19 +296,20 @@ func (w *PodcastDownloadWorker) runPipeline(ctx context.Context, ep database.Med
// Unprobeable stray: fall through and re-fetch over it.
}
// ---- fetch to temp ----
// ---- fetch to temp (streamed with byte-progress tracking) ----
// Enclosures stream straight to disk through a counting loop — no
// whole-body buffering — so the episode list can render a real
// progress bar while the worker runs (issue #1).
tmpPath := finalPath + ".part-" + randomSuffix()
cleanup := func() { _ = os.Remove(tmpPath) }
res, err := guardFetch(ctx, ep.EnclosureUrl.String, "", "", maxEnclosureCapBytes)
if err != nil {
received, total, fetchErr := w.streamEnclosure(ctx, ep, tmpPath)
w.endProgress(ep.ID) // terminal either way; the row state is the truth
if fetchErr != nil {
cleanup()
return fmt.Errorf("download failed: %w", classifyFetchError(err))
}
if err := os.WriteFile(tmpPath, res.Body, 0o644); err != nil {
cleanup()
return fmt.Errorf("write: %w", err)
return fetchErr
}
_, _ = received, total
// ---- validation ----
info, err := os.Stat(tmpPath)
@@ -328,6 +338,139 @@ func (w *PodcastDownloadWorker) runPipeline(ctx context.Context, ep database.Med
return w.markDownloaded(ctx, ep, &feed, finalPath, payload)
}
// ---- byte-progress tracking (issue #1) ----
// In-memory, deliberately NOT persisted: a downloading row renders a
// bar from this snapshot while the worker holds it; the DB row's state
// remains the durable truth (queued/downloading/downloaded).
type episodeProgress struct {
Received int64
Total int64 // 0 = unknown (no Content-Length, no declared length)
}
// beginProgress registers a download slot (total 0 = unknown until the
// response's Content-Length arrives).
func (w *PodcastDownloadWorker) beginProgress(episodeID pgtype.UUID, total int64) {
w.progMu.Lock()
defer w.progMu.Unlock()
w.progress[episodeID.String()] = &episodeProgress{Total: total}
}
// addProgress accumulates streamed bytes.
func (w *PodcastDownloadWorker) addProgress(episodeID pgtype.UUID, n int64) {
w.progMu.Lock()
defer w.progMu.Unlock()
if p, ok := w.progress[episodeID.String()]; ok {
p.Received += n
if p.Total == 0 {
// Total unknown at start; keep received-only (bar renders
// indeterminate).
}
}
}
// setProgressTotal records the authoritative total once known.
func (w *PodcastDownloadWorker) setProgressTotal(episodeID pgtype.UUID, total int64) {
w.progMu.Lock()
defer w.progMu.Unlock()
if p, ok := w.progress[episodeID.String()]; ok && total > 0 {
p.Total = total
}
}
// endProgress drops the slot (terminal state reached — the row itself
// now tells the story).
func (w *PodcastDownloadWorker) endProgress(episodeID pgtype.UUID) {
w.progMu.Lock()
defer w.progMu.Unlock()
delete(w.progress, episodeID.String())
}
// EpisodeProgress reports the live byte counts for a downloading
// episode (ok=false when nothing is in flight for it).
func (w *PodcastDownloadWorker) EpisodeProgress(episodeID pgtype.UUID) (received, total int64, ok bool) {
w.progMu.Lock()
defer w.progMu.Unlock()
p, ok := w.progress[episodeID.String()]
if !ok {
return 0, 0, false
}
return p.Received, p.Total, true
}
// streamEnclosure performs the SSRF-guarded enclosure download straight
// to tmpPath while updating the progress slot. Returns bytes received
// and the authoritative total (0 when unknown). Scheme and size guards
// match guardFetch; redirects re-dial through the same client so the
// IP guard re-applies per hop.
func (w *PodcastDownloadWorker) streamEnclosure(ctx context.Context, ep database.MediaItems, tmpPath string) (int64, int64, error) {
u, err := url.Parse(ep.EnclosureUrl.String)
if err != nil {
return 0, 0, fmt.Errorf("download failed: invalid enclosure URL: %w", err)
}
if err := guardURLScheme(u); err != nil {
return 0, 0, fmt.Errorf("download failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.EnclosureUrl.String, nil)
if err != nil {
return 0, 0, fmt.Errorf("download failed: %w", err)
}
req.Header.Set("User-Agent", "Bookhoard/1.0 (podcast fetcher; +https://github.com/bookhoard)")
// Declared total (feed's enclosure length) seeds the bar until the
// response's Content-Length replaces it.
declared := int64(0)
if ep.FileSize.Valid {
declared = ep.FileSize.Int64
}
w.beginProgress(ep.ID, declared)
resp, err := podcastHTTPClientFactory().Do(req)
if err != nil {
return 0, declared, fmt.Errorf("download failed: %w", classifyFetchError(err))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return 0, declared, fmt.Errorf("download failed: HTTP %d for enclosure", resp.StatusCode)
}
total := resp.ContentLength // -1 when unknown
if total < 0 {
total = declared
}
w.setProgressTotal(ep.ID, total)
out, err := os.Create(tmpPath)
if err != nil {
return 0, total, fmt.Errorf("write: %w", err)
}
defer func() { _ = out.Close() }()
buf := make([]byte, 64<<10)
var received int64
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
received += int64(n)
if received > maxEnclosureCapBytes {
return received, total, fmt.Errorf("rejected: enclosure exceeds %d byte cap", maxEnclosureCapBytes)
}
w.addProgress(ep.ID, int64(n))
if _, werr := out.Write(buf[:n]); werr != nil {
return received, total, fmt.Errorf("write: %w", werr)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return received, total, fmt.Errorf("download failed: %w", readErr)
}
}
return received, total, nil
}
// markDownloaded is the SUCCESS-ONLY row write (invariant 7): the file
// is on disk and (when ffprobe is available) probed before this runs.
func (w *PodcastDownloadWorker) markDownloaded(ctx context.Context, ep database.MediaItems, feed *database.PodcastFeeds, absPath string, payload *AudioMetadataPayload) error {