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",
|
assert.Contains(t, body, "matching episode",
|
||||||
"the match count renders on the card")
|
"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)")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -24,6 +25,55 @@ type PodcastHandler struct {
|
|||||||
db *database.Queries
|
db *database.Queries
|
||||||
podcasts *services.PodcastService
|
podcasts *services.PodcastService
|
||||||
worker *services.PodcastDownloadWorker
|
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 {
|
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
|
// subscription stands; download-all failure surfaces via the
|
||||||
// episode list states.
|
// 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) {
|
if isHXRequest(c) {
|
||||||
// Post-redirect-get: land on the new feed's page. Fetch errors
|
// 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) {
|
if isHXRequest(c) {
|
||||||
// Policy changes feed the auto-download union on the next poll.
|
// Policy changes feed the auto-download union on the next poll.
|
||||||
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
|
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
|
||||||
return hxAccepted(c, "")
|
return h.hxEpisodeList(c, feedID.String(), true)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, updated)
|
return c.JSON(http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
@@ -291,7 +344,7 @@ func (h *PodcastHandler) RefreshFeed(c *echo.Context) error {
|
|||||||
return podcastErrorResponse(c, err)
|
return podcastErrorResponse(c, err)
|
||||||
}
|
}
|
||||||
if isHXRequest(c) {
|
if isHXRequest(c) {
|
||||||
return hxAccepted(c, "")
|
return h.hxEpisodeList(c, feedID.String(), false)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"})
|
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)
|
return podcastErrorResponse(c, err)
|
||||||
}
|
}
|
||||||
if isHXRequest(c) {
|
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})
|
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)
|
return podcastErrorResponse(c, err)
|
||||||
}
|
}
|
||||||
if isHXRequest(c) {
|
if isHXRequest(c) {
|
||||||
return hxAccepted(c, "")
|
return h.hxEpisodeList(c, feedID.String(), false)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"})
|
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) {
|
if isHXRequest(c) {
|
||||||
return hxAccepted(c, "")
|
return h.hxEpisodeList(c, feedID.String(), false)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"order": req.Order})
|
return c.JSON(http.StatusOK, map[string]string{"order": req.Order})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,53 @@ package router
|
|||||||
// middleware; scoping is RLS's job (D20) — handlers add no visibility
|
// middleware; scoping is RLS's job (D20) — handlers add no visibility
|
||||||
// plumbing.
|
// plumbing.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"bookhoard/templates"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
func registerPodcastRoutes(cfg *Config) {
|
func registerPodcastRoutes(cfg *Config) {
|
||||||
g := cfg.Echo.Group("/api/podcasts", createJWTMiddleware(cfg))
|
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.POST("/feeds", cfg.PodcastHandler.AddFeed)
|
||||||
g.GET("/feeds", cfg.PodcastHandler.ListFeeds)
|
g.GET("/feeds", cfg.PodcastHandler.ListFeeds)
|
||||||
g.GET("/search", cfg.PodcastHandler.Search)
|
g.GET("/search", cfg.PodcastHandler.Search)
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ func registerPodcastPages(cfg *Config) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
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 {
|
if err != nil {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
_ = templates.ErrorPage("Feed not found", "not_found").Render(c.Request().Context(), &buf)
|
_ = templates.ErrorPage("Feed not found", "not_found").Render(c.Request().Context(), &buf)
|
||||||
@@ -67,7 +71,11 @@ func registerPodcastPages(cfg *Config) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
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 {
|
if err != nil {
|
||||||
return c.NoContent(http.StatusNotFound)
|
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
|
// buildPodcastDetailData assembles the feed-detail page + episode list
|
||||||
// partial. RLS scopes the lookup (non-subscribers get sqlc.ErrNoRows →
|
// partial. RLS scopes the lookup (non-subscribers get sqlc.ErrNoRows →
|
||||||
// 404 semantics — no Go visibility plumbing, D20).
|
// 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)
|
actor := handlers.MustGetAuthenticatedUser(c)
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
feedID, err := parsePodcastUUID(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
feed, err := cfg.Queries.GetPodcastFeed(ctx, feedID)
|
feed, err := cfg.Queries.GetPodcastFeed(ctx, feedID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -261,6 +265,7 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User) (
|
|||||||
|
|
||||||
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, feedID)
|
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, feedID)
|
||||||
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, feedID)
|
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, feedID)
|
||||||
|
subscriberCount, _ := cfg.Queries.CountPodcastFeedSubscribers(ctx, feedID)
|
||||||
|
|
||||||
card := templates.PodcastFeedCard{
|
card := templates.PodcastFeedCard{
|
||||||
ID: uuid.UUID(feed.ID.Bytes).String(),
|
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())
|
order := podcastOrderForFeed(cfg, c, feedID.String())
|
||||||
for i := range eps {
|
for i := range eps {
|
||||||
ep := &eps[i]
|
ep := &eps[i]
|
||||||
p := progressByItem[uuid.UUID(ep.ID.Bytes).String()]
|
rowID := uuid.UUID(ep.ID.Bytes).String()
|
||||||
|
p := progressByItem[rowID]
|
||||||
dateDisplay := ""
|
dateDisplay := ""
|
||||||
if ep.DatePublished.Valid {
|
if ep.DatePublished.Valid {
|
||||||
dateDisplay = ep.DatePublished.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006")
|
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)),
|
Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)),
|
||||||
Size: templates.FormatEpisodeSize(ep.FileSize.Int64),
|
Size: templates.FormatEpisodeSize(ep.FileSize.Int64),
|
||||||
PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(),
|
PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(),
|
||||||
ProgressPct: p.Pct,
|
ListenPct: p.Pct,
|
||||||
Downloaded: ep.DownloadState == "downloaded",
|
Downloaded: ep.DownloadState == "downloaded",
|
||||||
DownloadState: ep.DownloadState,
|
DownloadState: ep.DownloadState,
|
||||||
DownloadError: ep.DownloadError.String,
|
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,
|
Feed: card,
|
||||||
Episodes: rows,
|
Episodes: rows,
|
||||||
GapCount: gaps,
|
GapCount: gaps,
|
||||||
Order: order,
|
Order: order,
|
||||||
IsAdmin: actor.Role == "admin",
|
IsAdmin: actor.Role == "admin",
|
||||||
IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook",
|
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).
|
// podcastOrderForFeed reads the caller's order override (D5).
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
@@ -48,13 +51,19 @@ type PodcastDownloadWorker struct {
|
|||||||
db *database.Queries
|
db *database.Queries
|
||||||
svc *PodcastService
|
svc *PodcastService
|
||||||
wake chan struct{}
|
wake chan struct{}
|
||||||
|
|
||||||
|
// Live byte-progress slots for in-flight downloads (issue #1).
|
||||||
|
// Keyed by episode UUID string; guarded by progMu.
|
||||||
|
progMu sync.Mutex
|
||||||
|
progress map[string]*episodeProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPodcastDownloadWorker(db *database.Queries, svc *PodcastService) *PodcastDownloadWorker {
|
func NewPodcastDownloadWorker(db *database.Queries, svc *PodcastService) *PodcastDownloadWorker {
|
||||||
return &PodcastDownloadWorker{
|
return &PodcastDownloadWorker{
|
||||||
db: db,
|
db: db,
|
||||||
svc: svc,
|
svc: svc,
|
||||||
wake: make(chan struct{}, 1),
|
wake: make(chan struct{}, 1),
|
||||||
|
progress: make(map[string]*episodeProgress),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,19 +296,20 @@ func (w *PodcastDownloadWorker) runPipeline(ctx context.Context, ep database.Med
|
|||||||
// Unprobeable stray: fall through and re-fetch over it.
|
// Unprobeable stray: fall through and re-fetch over it.
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- fetch to temp ----
|
// ---- fetch to temp (streamed with byte-progress tracking) ----
|
||||||
|
// Enclosures stream straight to disk through a counting loop — no
|
||||||
|
// whole-body buffering — so the episode list can render a real
|
||||||
|
// progress bar while the worker runs (issue #1).
|
||||||
tmpPath := finalPath + ".part-" + randomSuffix()
|
tmpPath := finalPath + ".part-" + randomSuffix()
|
||||||
cleanup := func() { _ = os.Remove(tmpPath) }
|
cleanup := func() { _ = os.Remove(tmpPath) }
|
||||||
|
|
||||||
res, err := guardFetch(ctx, ep.EnclosureUrl.String, "", "", maxEnclosureCapBytes)
|
received, total, fetchErr := w.streamEnclosure(ctx, ep, tmpPath)
|
||||||
if err != nil {
|
w.endProgress(ep.ID) // terminal either way; the row state is the truth
|
||||||
|
if fetchErr != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
return fmt.Errorf("download failed: %w", classifyFetchError(err))
|
return fetchErr
|
||||||
}
|
|
||||||
if err := os.WriteFile(tmpPath, res.Body, 0o644); err != nil {
|
|
||||||
cleanup()
|
|
||||||
return fmt.Errorf("write: %w", err)
|
|
||||||
}
|
}
|
||||||
|
_, _ = received, total
|
||||||
|
|
||||||
// ---- validation ----
|
// ---- validation ----
|
||||||
info, err := os.Stat(tmpPath)
|
info, err := os.Stat(tmpPath)
|
||||||
@@ -328,6 +338,139 @@ func (w *PodcastDownloadWorker) runPipeline(ctx context.Context, ep database.Med
|
|||||||
return w.markDownloaded(ctx, ep, &feed, finalPath, payload)
|
return w.markDownloaded(ctx, ep, &feed, finalPath, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- byte-progress tracking (issue #1) ----
|
||||||
|
// In-memory, deliberately NOT persisted: a downloading row renders a
|
||||||
|
// bar from this snapshot while the worker holds it; the DB row's state
|
||||||
|
// remains the durable truth (queued/downloading/downloaded).
|
||||||
|
|
||||||
|
type episodeProgress struct {
|
||||||
|
Received int64
|
||||||
|
Total int64 // 0 = unknown (no Content-Length, no declared length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// beginProgress registers a download slot (total 0 = unknown until the
|
||||||
|
// response's Content-Length arrives).
|
||||||
|
func (w *PodcastDownloadWorker) beginProgress(episodeID pgtype.UUID, total int64) {
|
||||||
|
w.progMu.Lock()
|
||||||
|
defer w.progMu.Unlock()
|
||||||
|
w.progress[episodeID.String()] = &episodeProgress{Total: total}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addProgress accumulates streamed bytes.
|
||||||
|
func (w *PodcastDownloadWorker) addProgress(episodeID pgtype.UUID, n int64) {
|
||||||
|
w.progMu.Lock()
|
||||||
|
defer w.progMu.Unlock()
|
||||||
|
if p, ok := w.progress[episodeID.String()]; ok {
|
||||||
|
p.Received += n
|
||||||
|
if p.Total == 0 {
|
||||||
|
// Total unknown at start; keep received-only (bar renders
|
||||||
|
// indeterminate).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setProgressTotal records the authoritative total once known.
|
||||||
|
func (w *PodcastDownloadWorker) setProgressTotal(episodeID pgtype.UUID, total int64) {
|
||||||
|
w.progMu.Lock()
|
||||||
|
defer w.progMu.Unlock()
|
||||||
|
if p, ok := w.progress[episodeID.String()]; ok && total > 0 {
|
||||||
|
p.Total = total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endProgress drops the slot (terminal state reached — the row itself
|
||||||
|
// now tells the story).
|
||||||
|
func (w *PodcastDownloadWorker) endProgress(episodeID pgtype.UUID) {
|
||||||
|
w.progMu.Lock()
|
||||||
|
defer w.progMu.Unlock()
|
||||||
|
delete(w.progress, episodeID.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// EpisodeProgress reports the live byte counts for a downloading
|
||||||
|
// episode (ok=false when nothing is in flight for it).
|
||||||
|
func (w *PodcastDownloadWorker) EpisodeProgress(episodeID pgtype.UUID) (received, total int64, ok bool) {
|
||||||
|
w.progMu.Lock()
|
||||||
|
defer w.progMu.Unlock()
|
||||||
|
p, ok := w.progress[episodeID.String()]
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return p.Received, p.Total, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamEnclosure performs the SSRF-guarded enclosure download straight
|
||||||
|
// to tmpPath while updating the progress slot. Returns bytes received
|
||||||
|
// and the authoritative total (0 when unknown). Scheme and size guards
|
||||||
|
// match guardFetch; redirects re-dial through the same client so the
|
||||||
|
// IP guard re-applies per hop.
|
||||||
|
func (w *PodcastDownloadWorker) streamEnclosure(ctx context.Context, ep database.MediaItems, tmpPath string) (int64, int64, error) {
|
||||||
|
u, err := url.Parse(ep.EnclosureUrl.String)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("download failed: invalid enclosure URL: %w", err)
|
||||||
|
}
|
||||||
|
if err := guardURLScheme(u); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.EnclosureUrl.String, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Bookhoard/1.0 (podcast fetcher; +https://github.com/bookhoard)")
|
||||||
|
|
||||||
|
// Declared total (feed's enclosure length) seeds the bar until the
|
||||||
|
// response's Content-Length replaces it.
|
||||||
|
declared := int64(0)
|
||||||
|
if ep.FileSize.Valid {
|
||||||
|
declared = ep.FileSize.Int64
|
||||||
|
}
|
||||||
|
w.beginProgress(ep.ID, declared)
|
||||||
|
|
||||||
|
resp, err := podcastHTTPClientFactory().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, declared, fmt.Errorf("download failed: %w", classifyFetchError(err))
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return 0, declared, fmt.Errorf("download failed: HTTP %d for enclosure", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := resp.ContentLength // -1 when unknown
|
||||||
|
if total < 0 {
|
||||||
|
total = declared
|
||||||
|
}
|
||||||
|
w.setProgressTotal(ep.ID, total)
|
||||||
|
|
||||||
|
out, err := os.Create(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, total, fmt.Errorf("write: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = out.Close() }()
|
||||||
|
|
||||||
|
buf := make([]byte, 64<<10)
|
||||||
|
var received int64
|
||||||
|
for {
|
||||||
|
n, readErr := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
received += int64(n)
|
||||||
|
if received > maxEnclosureCapBytes {
|
||||||
|
return received, total, fmt.Errorf("rejected: enclosure exceeds %d byte cap", maxEnclosureCapBytes)
|
||||||
|
}
|
||||||
|
w.addProgress(ep.ID, int64(n))
|
||||||
|
if _, werr := out.Write(buf[:n]); werr != nil {
|
||||||
|
return received, total, fmt.Errorf("write: %w", werr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if readErr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return received, total, fmt.Errorf("download failed: %w", readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return received, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
// markDownloaded is the SUCCESS-ONLY row write (invariant 7): the file
|
// markDownloaded is the SUCCESS-ONLY row write (invariant 7): the file
|
||||||
// is on disk and (when ffprobe is available) probed before this runs.
|
// is on disk and (when ffprobe is available) probed before this runs.
|
||||||
func (w *PodcastDownloadWorker) markDownloaded(ctx context.Context, ep database.MediaItems, feed *database.PodcastFeeds, absPath string, payload *AudioMetadataPayload) error {
|
func (w *PodcastDownloadWorker) markDownloaded(ctx context.Context, ep database.MediaItems, feed *database.PodcastFeeds, absPath string, payload *AudioMetadataPayload) error {
|
||||||
|
|||||||
+149
-25
@@ -8,6 +8,8 @@ package templates
|
|||||||
// AND polls every 4s ONLY while downloads are active (server decides —
|
// AND polls every 4s ONLY while downloads are active (server decides —
|
||||||
// the polling attributes render only then).
|
// the polling attributes render only then).
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
templ PodcastDetailPage(user User, data PodcastDetailData) {
|
templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -85,7 +87,8 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/episodes/all/download" }
|
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/episodes/all/download" }
|
||||||
hx-swap="none"
|
hx-target="#episode-list"
|
||||||
|
hx-swap="outerHTML"
|
||||||
title="Queue every episode that is not downloaded yet"
|
title="Queue every episode that is not downloaded yet"
|
||||||
>
|
>
|
||||||
@Icon("save", "h-4 w-4")
|
@Icon("save", "h-4 w-4")
|
||||||
@@ -94,7 +97,8 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
|||||||
<button
|
<button
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/refresh" }
|
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/refresh" }
|
||||||
hx-swap="none"
|
hx-target="#episode-list"
|
||||||
|
hx-swap="outerHTML"
|
||||||
title="Check the feed for new episodes now"
|
title="Check the feed for new episodes now"
|
||||||
>
|
>
|
||||||
@Icon("refresh", "h-4 w-4")
|
@Icon("refresh", "h-4 w-4")
|
||||||
@@ -108,7 +112,8 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
|||||||
name="order"
|
name="order"
|
||||||
class="input w-auto py-1.5 cursor-pointer"
|
class="input w-auto py-1.5 cursor-pointer"
|
||||||
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
||||||
hx-swap="none"
|
hx-target="#episode-list"
|
||||||
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<option value="native" selected={ data.Order == "native" }>Feed order</option>
|
<option value="native" selected={ data.Order == "native" }>Feed order</option>
|
||||||
<option value="oldest" selected={ data.Order == "oldest" }>Oldest first</option>
|
<option value="oldest" selected={ data.Order == "oldest" }>Oldest first</option>
|
||||||
@@ -121,7 +126,8 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
|||||||
class="btn btn-secondary text-sm"
|
class="btn btn-secondary text-sm"
|
||||||
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
||||||
hx-vals='{"order": "oldest"}'
|
hx-vals='{"order": "oldest"}'
|
||||||
hx-swap="none"
|
hx-target="#episode-list"
|
||||||
|
hx-swap="outerHTML"
|
||||||
title="Serial feed: publishers list newest first — listen oldest-first"
|
title="Serial feed: publishers list newest first — listen oldest-first"
|
||||||
>
|
>
|
||||||
<span>Serial? Listen oldest-first</span>
|
<span>Serial? Listen oldest-first</span>
|
||||||
@@ -130,17 +136,7 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
|||||||
|
|
||||||
<!-- Auto-download policy (D6) -->
|
<!-- Auto-download policy (D6) -->
|
||||||
<label class="text-sm ml-2" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label>
|
<label class="text-sm ml-2" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label>
|
||||||
<select
|
@PodcastAutoDownloadSelect(data.Feed)
|
||||||
id="podcast-autodl-select"
|
|
||||||
name="auto_download"
|
|
||||||
class="input w-auto py-1.5 cursor-pointer"
|
|
||||||
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
|
||||||
hx-swap="none"
|
|
||||||
>
|
|
||||||
<option value="off" selected={ data.Feed.AutoDownload == "off" || data.Feed.AutoDownload == "" }>Off</option>
|
|
||||||
<option value="all" selected={ data.Feed.AutoDownload == "all" }>All episodes</option>
|
|
||||||
<option value="newest" selected={ data.Feed.AutoDownload == "newest" }>Newest</option>
|
|
||||||
</select>
|
|
||||||
if data.Feed.AutoDownload == "newest" {
|
if data.Feed.AutoDownload == "newest" {
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -154,15 +150,33 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Unsubscribe: files retained (D6) -->
|
<!-- Unsubscribe (issues #4/#7): the sole subscriber who is
|
||||||
<button
|
an admin gets the three-choice modal (unsubscribe /
|
||||||
class="btn btn-secondary ml-auto"
|
remove feed / cancel); everyone else gets a plain
|
||||||
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
confirm — file retention is admin knowledge only. -->
|
||||||
hx-confirm="Unsubscribe from this feed? Episodes and files are kept."
|
if data.IsAdmin && data.SubscriberCount <= 1 {
|
||||||
hx-swap="none"
|
<button class="btn btn-secondary ml-auto" @click="unsubOpen = true">
|
||||||
>
|
<span>Unsubscribe</span>
|
||||||
<span>Unsubscribe</span>
|
</button>
|
||||||
</button>
|
} else if data.IsAdmin {
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary ml-auto"
|
||||||
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
||||||
|
hx-confirm="Unsubscribe from this feed? Episodes and files are kept."
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
|
<span>Unsubscribe</span>
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary ml-auto"
|
||||||
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
||||||
|
hx-confirm="Unsubscribe from this feed?"
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
|
<span>Unsubscribe</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
if data.IsAdmin {
|
if data.IsAdmin {
|
||||||
<button
|
<button
|
||||||
@@ -192,6 +206,7 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
|||||||
<!-- Episode list (self-refreshing htmx partial) -->
|
<!-- Episode list (self-refreshing htmx partial) -->
|
||||||
@PodcastEpisodeList(data)
|
@PodcastEpisodeList(data)
|
||||||
</main>
|
</main>
|
||||||
|
@PodcastUnsubscribeModal(data)
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
@@ -228,7 +243,7 @@ templ PodcastEpisodeListPolling(data PodcastDetailData) {
|
|||||||
<div
|
<div
|
||||||
id="episode-list"
|
id="episode-list"
|
||||||
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
|
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
|
||||||
hx-trigger="podcast:changed from:body, every 4s"
|
hx-trigger="podcast:changed from:body, every 2s"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
|
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
|
||||||
@@ -259,6 +274,23 @@ templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
|
|||||||
if ep.DownloadError != "" {
|
if ep.DownloadError != "" {
|
||||||
<p class="text-xs mt-0.5 line-clamp-2" style="color: var(--status-danger)">{ ep.DownloadError }</p>
|
<p class="text-xs mt-0.5 line-clamp-2" style="color: var(--status-danger)">{ ep.DownloadError }</p>
|
||||||
}
|
}
|
||||||
|
if ep.DownloadState == "downloading" && ep.ProgressKnown {
|
||||||
|
<div class="mt-1.5">
|
||||||
|
if ep.ProgressTotal > 0 {
|
||||||
|
<div class="h-1.5 w-full rounded-full overflow-hidden" style="background-color: var(--surface-secondary);">
|
||||||
|
<div class="h-full rounded-full transition-all" style={ progressWidth(ep.ProgressPct) + " background-color: var(--accent);" }></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">
|
||||||
|
{ FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal) }
|
||||||
|
</p>
|
||||||
|
} else {
|
||||||
|
<div class="h-1.5 w-full rounded-full overflow-hidden animate-pulse" style="background-color: var(--surface-secondary);">
|
||||||
|
<div class="h-full w-1/3 rounded-full" style="background-color: var(--accent);"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">{ FormatEpisodeSize(ep.ProgressReceived) }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
|
||||||
{ ep.Date }
|
{ ep.Date }
|
||||||
if ep.Duration != "" {
|
if ep.Duration != "" {
|
||||||
@@ -302,9 +334,101 @@ func downloadButtonLabel(state string) string {
|
|||||||
return "Download"
|
return "Download"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// progressWidth renders the bar width as a quote-free inline style
|
||||||
|
// fragment (templ gotcha #8: style attribute values must not carry
|
||||||
|
// quote characters).
|
||||||
|
func progressWidth(pct float64) string {
|
||||||
|
if pct < 1 {
|
||||||
|
pct = 1
|
||||||
|
}
|
||||||
|
if pct > 100 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("width: %.1f%%;", pct)
|
||||||
|
}
|
||||||
|
|
||||||
func downloadButtonTitle(state string) string {
|
func downloadButtonTitle(state string) string {
|
||||||
if state == "failed" {
|
if state == "failed" {
|
||||||
return "Retry the download"
|
return "Retry the download"
|
||||||
}
|
}
|
||||||
return "Download this episode"
|
return "Download this episode"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// PodcastAutoDownloadSelect is the policy dropdown (issue #3): extracted
|
||||||
|
// so the Download All response can out-of-band-swap it after flipping
|
||||||
|
// the caller's policy — the dropdown must reflect what the user gets.
|
||||||
|
templ PodcastAutoDownloadSelect(feed PodcastFeedCard) {
|
||||||
|
<select
|
||||||
|
id="podcast-autodl-select"
|
||||||
|
name="auto_download"
|
||||||
|
class="input w-auto py-1.5 cursor-pointer"
|
||||||
|
hx-put={ "/api/podcasts/feeds/" + feed.ID + "/subscription" }
|
||||||
|
hx-target="#episode-list"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<option value="off" selected={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
|
||||||
|
<option value="all" selected={ feed.AutoDownload == "all" }>All episodes</option>
|
||||||
|
<option value="newest" selected={ feed.AutoDownload == "newest" }>Newest</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodcastAutoDownloadSelectOOB is the out-of-band variant: same element
|
||||||
|
// id + hx-swap-oob so htmx replaces the action-row select in place.
|
||||||
|
templ PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) {
|
||||||
|
<select
|
||||||
|
id="podcast-autodl-select"
|
||||||
|
name="auto_download"
|
||||||
|
hx-swap-oob="true"
|
||||||
|
class="input w-auto py-1.5 cursor-pointer"
|
||||||
|
hx-put={ "/api/podcasts/feeds/" + feed.ID + "/subscription" }
|
||||||
|
hx-target="#episode-list"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<option value="off" selected={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
|
||||||
|
<option value="all" selected={ feed.AutoDownload == "all" }>All episodes</option>
|
||||||
|
<option value="newest" selected={ feed.AutoDownload == "newest" }>Newest</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodcastUnsubscribeModal is the last-subscriber admin choice (issue
|
||||||
|
// #7): unsubscribe (files kept — the D6 default), remove feed + files
|
||||||
|
// entirely, or cancel. Reachable only for admins (the button that opens
|
||||||
|
// it renders for admins alone).
|
||||||
|
templ PodcastUnsubscribeModal(data PodcastDetailData) {
|
||||||
|
<div
|
||||||
|
id="podcast-unsub-modal"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||||
|
style="background-color: var(--surface-overlay);"
|
||||||
|
x-show="unsubOpen"
|
||||||
|
x-cloak
|
||||||
|
@click.self="unsubOpen = false"
|
||||||
|
>
|
||||||
|
<div class="card w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||||
|
<div class="p-6 border-b" style="border-color: var(--border);">
|
||||||
|
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Unsubscribe from { data.Feed.DisplayTitle() }?</h2>
|
||||||
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">You are the only subscriber.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
||||||
|
hx-swap="none"
|
||||||
|
@click="unsubOpen = false"
|
||||||
|
>
|
||||||
|
<span>Unsubscribe — files kept</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
style="color: var(--status-danger);"
|
||||||
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID }
|
||||||
|
hx-swap="none"
|
||||||
|
@click="unsubOpen = false"
|
||||||
|
>
|
||||||
|
<span>Remove feed — delete episodes and files</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary w-full" @click="unsubOpen = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -91,10 +91,23 @@ type PodcastEpisodeRow struct {
|
|||||||
Size string // human MB/GB when known
|
Size string // human MB/GB when known
|
||||||
PlayURL string // reader URL (audio reader serves episodes like audiobooks)
|
PlayURL string // reader URL (audio reader serves episodes like audiobooks)
|
||||||
CoverImage string
|
CoverImage string
|
||||||
ProgressPct float64
|
|
||||||
Downloaded bool
|
Downloaded bool
|
||||||
DownloadState string // pending|queued|downloading|downloaded|failed
|
DownloadState string // pending|queued|downloading|downloaded|failed
|
||||||
DownloadError string
|
DownloadError string
|
||||||
|
|
||||||
|
// Playback progress (reading_progress; the row badge) — reuses the
|
||||||
|
// ProgressPct field below when the row is downloading? No: keep the
|
||||||
|
// two concerns separate (issue #1).
|
||||||
|
ListenPct float64
|
||||||
|
|
||||||
|
// Live byte progress for rows in the downloading state (issue #1):
|
||||||
|
// the worker's in-flight snapshot. ProgressKnown=false renders the
|
||||||
|
// indeterminate bar; ProgressPct (0-100) drives the width when the
|
||||||
|
// total is authoritative.
|
||||||
|
ProgressKnown bool
|
||||||
|
ProgressPct float64
|
||||||
|
ProgressReceived int64
|
||||||
|
ProgressTotal int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateBadge renders the canonical state label.
|
// StateBadge renders the canonical state label.
|
||||||
@@ -172,6 +185,10 @@ type PodcastDetailData struct {
|
|||||||
Order string // native | oldest | newest (the caller's override)
|
Order string // native | oldest | newest (the caller's override)
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
IsSerial bool // serial/audiobook feeds get the oldest-first suggestion (D5)
|
IsSerial bool // serial/audiobook feeds get the oldest-first suggestion (D5)
|
||||||
|
// SubscriberCount (issue #7): drives the last-subscriber
|
||||||
|
// unsubscribe flow — the sole subscriber who is an admin gets the
|
||||||
|
// three-choice modal (unsubscribe / remove feed / cancel).
|
||||||
|
SubscriberCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// podcastInt renders an int for templates.
|
// podcastInt renders an int for templates.
|
||||||
|
|||||||
+5
-4
@@ -18,10 +18,11 @@ Alpine.data("podcastsPage", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// The detail page's actions are pure htmx (hx-post/hx-put/hx-delete +
|
// The detail page's actions are pure htmx; Alpine hosts the x-data the
|
||||||
// HX-Trigger-driven list refresh); Alpine only hosts the x-data the
|
// body declares (modal visibility) so CSP-bound Alpine stays happy.
|
||||||
// body declares so CSP-bound Alpine stays happy.
|
Alpine.data("podcastDetail", () => ({
|
||||||
Alpine.data("podcastDetail", () => ({}));
|
unsubOpen: false,
|
||||||
|
}));
|
||||||
|
|
||||||
// Add-feed form: surface the server's error text inline (the JSON error
|
// Add-feed form: surface the server's error text inline (the JSON error
|
||||||
// body arrives on non-2xx; htmx swaps nothing). Success redirects via
|
// body arrives on non-2xx; htmx swaps nothing). Success redirects via
|
||||||
|
|||||||
Reference in new issue
Block a user