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
@@ -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})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user