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