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.
69 lines
2.3 KiB
Go
69 lines
2.3 KiB
Go
package router
|
|
|
|
// Podcast routes (plan §8). All under /api/podcasts with the JWT
|
|
// 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)
|
|
|
|
// :id-scoped (RLS answers 404 for feeds outside the caller's scope).
|
|
feeds := g.Group("/feeds/:id")
|
|
feeds.GET("", cfg.PodcastHandler.GetFeed)
|
|
feeds.GET("/sequence", cfg.PodcastHandler.GetFeedSequence)
|
|
feeds.PUT("/order", cfg.PodcastHandler.SetFeedOrder)
|
|
feeds.PUT("/subscription", cfg.PodcastHandler.UpdateSubscription)
|
|
feeds.DELETE("/subscription", cfg.PodcastHandler.DeleteSubscription)
|
|
feeds.POST("/refresh", cfg.PodcastHandler.RefreshFeed)
|
|
feeds.POST("/episodes/:eid/download", cfg.PodcastHandler.DownloadEpisodes)
|
|
feeds.DELETE("", cfg.PodcastHandler.DeleteFeed) // admin-only (handler checks role)
|
|
}
|