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

+76
View File
@@ -730,3 +730,79 @@ func TestPodcastPageSearchEpisodes(t *testing.T) {
assert.Contains(t, body, "matching episode",
"the match count renders on the card")
}
// ---- issue #3: Download All flips the CALLER's auto-download to all ----
func TestPodcastDownloadAllPolicyFlip(t *testing.T) {
setup := setupTestServer(t)
regularID := getRegularUserID(t, setup.DB)
lib, _ := createPodcastsLibrary(t, setup, "test Flip Podcasts")
mux := http.NewServeMux()
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("Flip Feed", map[string]string{"f-1": "Flip One"})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
libID := uuid.UUID(lib.ID.Bytes).String()
regularPG := pgtype.UUID{Bytes: regularID, Valid: true}
t.Run("add_feed_with_download_all_sets_policy_all", func(t *testing.T) {
resp, body := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds",
fmt.Sprintf(`{"library_id":%q,"url":%q,"download_all":true}`, libID, srv.URL+"/feed.xml"))
require.Equal(t, http.StatusCreated, resp, string(body))
sub, err := setup.DB.GetPodcastSubscription(rls.WithUser(context.Background(), regularPG), database.GetPodcastSubscriptionParams{
UserID: regularPG, FeedID: func() pgtype.UUID {
feeds, _ := setup.DB.ListPodcastFeedsForPolling(rls.WithUser(context.Background(), regularPG))
for _, f := range feeds {
if f.Title == "Flip Feed" {
return f.ID
}
}
return pgtype.UUID{}
}(),
})
require.NoError(t, err)
assert.Equal(t, "all", sub.AutoDownload, "PIN: the add-feed checkbox flips the caller's policy to all")
})
t.Run("download_all_button_flips_policy_and_returns_oob_select", func(t *testing.T) {
// Set the policy to newest first: the flip must override it.
feeds, _ := setup.DB.ListPodcastFeedsForPolling(rls.WithUser(context.Background(), regularPG))
var feedID pgtype.UUID
for _, f := range feeds {
if f.Title == "Flip Feed" {
feedID = f.ID
}
}
_, err := setup.DB.UpdatePodcastSubscription(rls.WithUser(context.Background(), regularPG), database.UpdatePodcastSubscriptionParams{
UserID: regularPG, FeedID: feedID,
DisplayName: pgtype.Text{}, AutoDownload: "newest",
AutoDownloadCount: pgtype.Int4{Int32: 3, Valid: true},
})
require.NoError(t, err)
feedIDStr := uuid.UUID(feedID.Bytes).String()
req, _ := http.NewRequest(http.MethodPost,
setup.Server.URL+"/api/podcasts/feeds/"+feedIDStr+"/episodes/all/download", nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
req.Header.Set("HX-Request", "true")
hxResp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = hxResp.Body.Close() }()
require.Equal(t, http.StatusOK, hxResp.StatusCode, "HX action answers with the refreshed partial")
var buf bytes.Buffer
_, _ = buf.ReadFrom(hxResp.Body)
body := buf.String()
assert.Contains(t, body, `id="episode-list"`, "direct swap: the episode list partial IS the response")
assert.Contains(t, body, `hx-swap-oob="true"`, "issue #3: the policy select rides along out-of-band")
sub, err := setup.DB.GetPodcastSubscription(rls.WithUser(context.Background(), regularPG), database.GetPodcastSubscriptionParams{
UserID: regularPG, FeedID: feedID,
})
require.NoError(t, err)
assert.Equal(t, "all", sub.AutoDownload, "PIN: Download All flips newest → all (dynamic + visible)")
})
}