From 30766020c8a844169ebf1e354544ade7764abb65 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Oct 2026 20:12:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(podcasts):=20real-time=20episode=20list=20?= =?UTF-8?q?=E2=80=94=20byte=20progress=20bars,=20direct=20swaps=20(issues?= =?UTF-8?q?=20#1/#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/server/tests/podcast_test.go | 76 ++++++++++++ internal/handlers/podcast.go | 66 ++++++++++- internal/router/podcast.go | 44 +++++++ internal/router/podcast_page.go | 49 ++++++-- internal/services/podcast_worker.go | 165 ++++++++++++++++++++++++-- templates/podcast_detail.templ | 174 ++++++++++++++++++++++++---- templates/podcast_types.go | 19 ++- web/src/podcasts.ts | 9 +- 8 files changed, 545 insertions(+), 57 deletions(-) diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index bd4f9654..54e5ca7f 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -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)") + }) +} diff --git a/internal/handlers/podcast.go b/internal/handlers/podcast.go index 94f1cbd2..b83614d5 100644 --- a/internal/handlers/podcast.go +++ b/internal/handlers/podcast.go @@ -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}) } diff --git a/internal/router/podcast.go b/internal/router/podcast.go index c47a4b13..792bd9bf 100644 --- a/internal/router/podcast.go +++ b/internal/router/podcast.go @@ -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) diff --git a/internal/router/podcast_page.go b/internal/router/podcast_page.go index 7a69350a..7ac8ed61 100644 --- a/internal/router/podcast_page.go +++ b/internal/router/podcast_page.go @@ -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). diff --git a/internal/services/podcast_worker.go b/internal/services/podcast_worker.go index d8b41b8a..d6e4cc8e 100644 --- a/internal/services/podcast_worker.go +++ b/internal/services/podcast_worker.go @@ -31,7 +31,10 @@ import ( "log" "os" "path/filepath" + "net/http" + "net/url" "strings" + "sync" "time" "bookhoard/internal/database" @@ -48,13 +51,19 @@ type PodcastDownloadWorker struct { db *database.Queries svc *PodcastService 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 { return &PodcastDownloadWorker{ - db: db, - svc: svc, - wake: make(chan struct{}, 1), + db: db, + svc: svc, + 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. } - // ---- 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() cleanup := func() { _ = os.Remove(tmpPath) } - res, err := guardFetch(ctx, ep.EnclosureUrl.String, "", "", maxEnclosureCapBytes) - if err != nil { + received, total, fetchErr := w.streamEnclosure(ctx, ep, tmpPath) + w.endProgress(ep.ID) // terminal either way; the row state is the truth + if fetchErr != nil { cleanup() - return fmt.Errorf("download failed: %w", classifyFetchError(err)) - } - if err := os.WriteFile(tmpPath, res.Body, 0o644); err != nil { - cleanup() - return fmt.Errorf("write: %w", err) + return fetchErr } + _, _ = received, total // ---- validation ---- 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) } +// ---- 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 // 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 { diff --git a/templates/podcast_detail.templ b/templates/podcast_detail.templ index 96c43c8a..77e3b97d 100644 --- a/templates/podcast_detail.templ +++ b/templates/podcast_detail.templ @@ -8,6 +8,8 @@ package templates // AND polls every 4s ONLY while downloads are active (server decides — // the polling attributes render only then). +import "fmt" + templ PodcastDetailPage(user User, data PodcastDetailData) { @@ -85,7 +87,8 @@ templ PodcastDetailPage(user User, data PodcastDetailData) { + + if data.IsAdmin && data.SubscriberCount <= 1 { + + } else if data.IsAdmin { + + } else { + + } if data.IsAdmin { + + + + + +} diff --git a/templates/podcast_types.go b/templates/podcast_types.go index 1a31d769..e677a561 100644 --- a/templates/podcast_types.go +++ b/templates/podcast_types.go @@ -91,10 +91,23 @@ type PodcastEpisodeRow struct { Size string // human MB/GB when known PlayURL string // reader URL (audio reader serves episodes like audiobooks) CoverImage string - ProgressPct float64 Downloaded bool DownloadState string // pending|queued|downloading|downloaded|failed 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. @@ -172,6 +185,10 @@ type PodcastDetailData struct { Order string // native | oldest | newest (the caller's override) IsAdmin bool 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. diff --git a/web/src/podcasts.ts b/web/src/podcasts.ts index 1e6d39d9..00486efd 100644 --- a/web/src/podcasts.ts +++ b/web/src/podcasts.ts @@ -18,10 +18,11 @@ Alpine.data("podcastsPage", () => ({ }, })); -// The detail page's actions are pure htmx (hx-post/hx-put/hx-delete + -// HX-Trigger-driven list refresh); Alpine only hosts the x-data the -// body declares so CSP-bound Alpine stays happy. -Alpine.data("podcastDetail", () => ({})); +// The detail page's actions are pure htmx; Alpine hosts the x-data the +// body declares (modal visibility) so CSP-bound Alpine stays happy. +Alpine.data("podcastDetail", () => ({ + unsubOpen: false, +})); // Add-feed form: surface the server's error text inline (the JSON error // body arrives on non-2xx; htmx swaps nothing). Success redirects via