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})
}