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:
1 parent
ffc80f7ba7
commit
30766020c8
8 files changed
+545
-57
No files matched your search
@@ -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)")
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user