// Podcast handlers (plan §8): JSON API over the RLS-governed podcast // surfaces. Handlers parse requests and call services; visibility is // the engine's job (D20) — Go checks only explicit-argument // authorization (target-library rules, admin role) and subscription // lookups (podcast_subscriptions is ungoverned BY DESIGN: it IS the // read policy's lookup table). package handlers import ( "context" "encoding/json" "fmt" "sort" "bookhoard/internal/database" "bookhoard/internal/services" "net/http" "strings" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) 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 { return &PodcastHandler{db: db, podcasts: svc, worker: worker} } // ---- request/response shapes ---- type AddPodcastFeedRequest struct { LibraryID string `json:"library_id" form:"library_id" validate:"required"` URL string `json:"url" form:"url" validate:"required"` DownloadAll bool `json:"download_all" form:"download_all"` } type UpdatePodcastSubscriptionRequest struct { DisplayName *string `json:"display_name" form:"display_name"` AutoDownload *string `json:"auto_download" form:"auto_download" validate:"omitempty,oneof=off all newest"` AutoDownloadCount *int `json:"auto_download_count" form:"auto_download_count"` } type DownloadEpisodesRequest struct { EpisodeIDs []string `json:"episode_ids"` } // hxAccepted answers an htmx request with 204 + the podcast:changed // trigger — the episode-list partial re-fetches itself (its hx-trigger // listens for the event; polling covers download progress). JSON // callers keep the JSON body. HX-Redirect/HX-Trigger headers are the // sanctioned Go-handler response channels (CSP discipline). func hxAccepted(c *echo.Context, redirect string) error { if redirect != "" { c.Response().Header().Set("HX-Redirect", redirect) return c.NoContent(http.StatusNoContent) } c.Response().Header().Set("HX-Trigger", "podcast:changed") return c.NoContent(http.StatusNoContent) } func isHXRequest(c *echo.Context) bool { return c.Request().Header.Get("HX-Request") == "true" } // podcastErrorResponse maps service errors (incl. PodcastError statuses) // onto HTTP responses. func podcastErrorResponse(c *echo.Context, err error) error { if pe, ok := err.(*services.PodcastError); ok { return c.JSON(pe.Status, map[string]string{"error": pe.Message}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } // podcastFeedSummaries is the list payload: subscription + feed metadata // + the facet values the /podcasts page filters on (computed here — // feed counts are tens, not thousands; no facet SQL needed). type podcastFeedSummary struct { Feed SubscribedAt string `json:"subscribed_at"` EpisodeTotal int `json:"episode_total"` DownloadedN int `json:"episode_downloaded"` UndownloadedN int `json:"episode_undownloaded"` NewEpisodes bool `json:"new_episodes"` } // AddFeed POST /api/podcasts/feeds func (h *PodcastHandler) AddFeed(c *echo.Context) error { user := MustGetAuthenticatedUser(c) var req AddPodcastFeedRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } libraryID, err := parseUUID(req.LibraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"}) } res, err := h.podcasts.AddFeed(c.Request().Context(), user.ID, user.Role == "admin", libraryID, req.URL) if err != nil { return podcastErrorResponse(c, err) } if req.DownloadAll && res.Feed != nil { if _, err := h.worker.QueueFeedDownloads(c.Request().Context(), res.Feed.ID); err != nil { // 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 // (subscription stands) surface via the flash query param. if res.FeedError() != "" { return hxAccepted(c, "/podcasts/"+uuidString(res.Feed.ID)+"?fetch_error=1") } return hxAccepted(c, "/podcasts/"+uuidString(res.Feed.ID)) } return c.JSON(http.StatusCreated, res) } // ListFeeds GET /api/podcasts/feeds — the caller's subscriptions. func (h *PodcastHandler) ListFeeds(c *echo.Context) error { user := MustGetAuthenticatedUser(c) ctx := c.Request().Context() feeds, err := h.db.ListMyPodcastFeeds(ctx, user.ID) if err != nil { return podcastErrorResponse(c, err) } out := make([]podcastFeedSummary, 0, len(feeds)) for i := range feeds { row := &feeds[i] total, _ := h.db.CountPodcastEpisodesForFeed(ctx, row.ID) downloaded, _ := h.db.CountDownloadedEpisodes(ctx, row.ID) undownloaded, _ := h.db.CountUndownloadedEpisodes(ctx, row.ID) out = append(out, podcastFeedSummary{ Feed: Feed{ ID: uuidString(row.ID), LibraryID: uuidString(row.LibraryID), FeedUrl: row.FeedUrl, PodcastGuid: row.PodcastGuid.String, Title: row.Title, Description: row.Description.String, Host: row.Host.String, Publisher: row.Publisher.String, Language: row.Language.String, Tags: row.Tags, Explicit: row.Explicit, ArtworkPath: row.ArtworkPath.String, FeedType: row.FeedType.String, Medium: row.Medium.String, IsComplete: row.IsComplete, LastFetchedAt: timeString(row.LastFetchedAt), }, SubscribedAt: timeString(row.SubscribedAt), EpisodeTotal: int(total), DownloadedN: int(downloaded), UndownloadedN: int(undownloaded), // "New episodes" (D18): the feed released something the user // has not pulled — undownloaded rows exist beyond the last // downloaded position. NewEpisodes: undownloaded > 0, }) } return c.JSON(http.StatusOK, map[string]interface{}{ "feeds": out, "facets": facetValuesFor(out), }) } // GetFeed GET /api/podcasts/feeds/:id — detail: metadata, episodes with // download state, gap summary. RLS answers 404 for non-subscribers // (existence leak) — no Go-level visibility plumbing. func (h *PodcastHandler) GetFeed(c *echo.Context) error { user := MustGetAuthenticatedUser(c) feedID, err := parseUUID(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"}) } ctx := c.Request().Context() feed, err := h.db.GetPodcastFeed(ctx, feedID) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"}) } sub, subFound := h.db.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{UserID: user.ID, FeedID: feedID}) eps, err := h.db.ListPodcastEpisodes(ctx, feedID) if err != nil { return podcastErrorResponse(c, err) } episodeViews := make([]PodcastEpisodeView, 0, len(eps)) for i := range eps { episodeViews = append(episodeViews, episodeView(&eps[i])) } return c.JSON(http.StatusOK, map[string]interface{}{ "feed": feedWithSubscription(&feed, sub, subFound == nil), "episodes": episodeViews, "gap_count": countGaps(episodeViews), "undownloaded": len(episodeViews) - countDownloaded(episodeViews), "has_library_access": true, // RLS already gated the row; kept for client convenience }) } // UpdateSubscription PUT /api/podcasts/feeds/:id/subscription — the // only user-writable podcast state (D9): display name + auto-download. func (h *PodcastHandler) UpdateSubscription(c *echo.Context) error { user := MustGetAuthenticatedUser(c) feedID, err := parseUUID(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"}) } var req UpdatePodcastSubscriptionRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } ctx := c.Request().Context() sub, err := h.db.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{UserID: user.ID, FeedID: feedID}) if err != nil { // Not subscribed (or feed invisible): 404 semantics. return c.JSON(http.StatusNotFound, map[string]string{"error": "not subscribed"}) } displayName := sub.DisplayName if req.DisplayName != nil { displayName = pgtypeText(*req.DisplayName) } autoDownload := sub.AutoDownload if req.AutoDownload != nil { autoDownload = *req.AutoDownload } count := sub.AutoDownloadCount if req.AutoDownloadCount != nil { if *req.AutoDownloadCount < 0 || *req.AutoDownloadCount > 100 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "auto_download_count out of range"}) } count = pgtype.Int4{Int32: int32(*req.AutoDownloadCount), Valid: *req.AutoDownloadCount > 0} } if autoDownload == "newest" && !count.Valid { count = pgtype.Int4{Int32: 3, Valid: true} // sane default for newest-N } updated, err := h.db.UpdatePodcastSubscription(ctx, database.UpdatePodcastSubscriptionParams{ UserID: user.ID, FeedID: feedID, DisplayName: displayName, AutoDownload: autoDownload, AutoDownloadCount: count, }) if err != nil { return podcastErrorResponse(c, err) } if isHXRequest(c) { // Policy changes feed the auto-download union on the next poll. _, _ = h.worker.ApplyAutoPolicies(ctx, feedID) return h.hxEpisodeList(c, feedID.String(), true) } return c.JSON(http.StatusOK, updated) } // DeleteSubscription DELETE /api/podcasts/feeds/:id/subscription — // unsubscribe NEVER deletes files/episodes (D6; last unsubscribe // retains them so re-subscribing must not re-download). func (h *PodcastHandler) DeleteSubscription(c *echo.Context) error { user := MustGetAuthenticatedUser(c) feedID, err := parseUUID(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"}) } rows, err := h.db.DeletePodcastSubscription(c.Request().Context(), database.DeletePodcastSubscriptionParams{UserID: user.ID, FeedID: feedID}) if err != nil { return podcastErrorResponse(c, err) } if rows == 0 { return c.JSON(http.StatusNotFound, map[string]string{"error": "not subscribed"}) } if isHXRequest(c) { return hxAccepted(c, "/podcasts") } return c.JSON(http.StatusOK, map[string]string{"message": "unsubscribed (files retained)"}) } // RefreshFeed POST /api/podcasts/feeds/:id/refresh — manual poll. func (h *PodcastHandler) RefreshFeed(c *echo.Context) error { user := MustGetAuthenticatedUser(c) _ = user feedID, err := parseUUID(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"}) } if err := h.podcasts.RefreshFeed(c.Request().Context(), feedID); err != nil { return podcastErrorResponse(c, err) } if isHXRequest(c) { return h.hxEpisodeList(c, feedID.String(), false) } return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"}) } // DownloadEpisodes POST /api/podcasts/feeds/:id/episodes/:eid/download // (+ bulk via episode_ids[]; Download All = all non-downloaded). Retry // is the same endpoint (idempotent state machine, plan §7). func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error { user := MustGetAuthenticatedUser(c) _ = user feedID, err := parseUUID(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"}) } ctx := c.Request().Context() // The feed must be in the caller's scope; RLS answers 404. if _, err := h.db.GetPodcastFeed(ctx, feedID); err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"}) } // Bulk form? var bulk DownloadEpisodesRequest _ = c.Bind(&bulk) if len(bulk.EpisodeIDs) > 0 { queued := 0 for _, raw := range bulk.EpisodeIDs { epID, err := parseUUID(raw) if err != nil { continue } if err := h.worker.QueueEpisode(ctx, epID, feedID); err == nil { queued++ } } return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued}) } // Single-episode path (:eid). eidRaw := c.Param("eid") if eidRaw == "all" { // Download All affordance: exactly the non-downloaded set (pin). queued, err := h.worker.QueueFeedDownloads(ctx, feedID) if err != nil { return podcastErrorResponse(c, err) } if isHXRequest(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}) } epID, err := parseUUID(eidRaw) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid episode id"}) } if err := h.worker.QueueEpisode(ctx, epID, feedID); err != nil { return podcastErrorResponse(c, err) } if isHXRequest(c) { return h.hxEpisodeList(c, feedID.String(), false) } return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"}) } // Search GET /api/podcasts/search?q= — subscription-scoped feed + // episode search (D14): covers un-downloaded and historical episodes // (rows are never deleted when a feed shrinks). func (h *PodcastHandler) Search(c *echo.Context) error { user := MustGetAuthenticatedUser(c) q := strings.TrimSpace(c.QueryParam("q")) ctx := c.Request().Context() feeds, err := h.db.SearchMyPodcastFeeds(ctx, database.SearchMyPodcastFeedsParams{UserID: user.ID, SearchQuery: q}) if err != nil { return podcastErrorResponse(c, err) } eps, err := h.db.SearchMyPodcastEpisodes(ctx, q) if err != nil { return podcastErrorResponse(c, err) } episodeViews := make([]PodcastEpisodeView, 0, len(eps)) for i := range eps { episodeViews = append(episodeViews, episodeView(&eps[i])) } return c.JSON(http.StatusOK, map[string]interface{}{"feeds": feeds, "episodes": episodeViews}) } // DeleteFeed DELETE /api/podcasts/feeds/:id — admin-only removal; // optional ?files=false keeps the episode files on disk. func (h *PodcastHandler) DeleteFeed(c *echo.Context) error { user := MustGetAuthenticatedUser(c) if user.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"}) } feedID, err := parseUUID(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"}) } ctx := c.Request().Context() if _, err := h.db.GetPodcastFeed(ctx, feedID); err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"}) } keepFiles := c.QueryParam("files") == "false" if !keepFiles { // File deletion is best-effort before the row cascade; episodes' // file_path values are relative to the download root. if eps, err := h.db.ListPodcastEpisodes(ctx, feedID); err == nil { if feed, err := h.db.GetPodcastFeed(ctx, feedID); err == nil { h.podcasts.DeleteFeedFiles(ctx, &feed, eps) } } } if rows, err := h.db.DeletePodcastFeed(ctx, feedID); err != nil { return podcastErrorResponse(c, err) } else if rows == 0 { return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"}) } if isHXRequest(c) { return hxAccepted(c, "/podcasts") } return c.JSON(http.StatusOK, map[string]string{"message": "feed removed"}) } // ---- Sequence (plan §8 / Phase 2: D5 order override, D7 chaining) ---- // podcastOrderSettingKey is the per-user, per-feed order override key // in the reader_settings KV (D5). func podcastOrderSettingKey(feedID string) string { return "podcast_order:" + feedID } // podcastOrderValid: native (feed position) | oldest | newest. func podcastOrderValid(o string) bool { return o == "native" || o == "oldest" || o == "newest" } // GetFeedOrder reads the caller's effective order for a feed // (default: native — feed-native order is the D5 default). func (h *PodcastHandler) GetFeedOrder(c *echo.Context, feedID string) string { user := MustGetAuthenticatedUser(c) row, err := h.db.GetUserSettingByKey(c.Request().Context(), database.GetUserSettingByKeyParams{ UserID: user.ID, SettingKey: podcastOrderSettingKey(feedID), }) if err != nil { return "native" } var v struct { Order string `json:"order"` } if err := json.Unmarshal(row, &v); err != nil || !podcastOrderValid(v.Order) { return "native" } return v.Order } // SetFeedOrder PUT /api/podcasts/feeds/:id/order — per-user override. // Deleting the override (order=native) removes the KV row so the // default and the explicit choice are indistinguishable. func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error { user := MustGetAuthenticatedUser(c) feedID, err := parseUUID(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"}) } var req struct { Order string `json:"order" form:"order" validate:"required,oneof=native oldest newest"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } ctx := c.Request().Context() if _, err := h.db.GetPodcastFeed(ctx, feedID); err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"}) } key := podcastOrderSettingKey(feedID.String()) if req.Order == "native" { _ = h.db.DeleteUserSettingByKey(ctx, database.DeleteUserSettingByKeyParams{ UserID: user.ID, SettingKey: key, }) } else { payload, _ := json.Marshal(map[string]string{"order": req.Order}) if err := h.db.UpsertUserSettingByKey(ctx, database.UpsertUserSettingByKeyParams{ UserID: user.ID, SettingKey: key, SettingValue: payload, }); err != nil { return podcastErrorResponse(c, err) } } if isHXRequest(c) { return h.hxEpisodeList(c, feedID.String(), false) } return c.JSON(http.StatusOK, map[string]string{"order": req.Order}) } // GetFeedSequence GET /api/podcasts/feeds/:id/sequence — the effective // listening order (override applied, feed-native fallback), DOWNLOADED // episodes only, plus the gap list (D7/D8). This payload powers both // the web player's chain mode and (later, §15) the media3 queue. func (h *PodcastHandler) GetFeedSequence(c *echo.Context) error { feedID, err := parseUUID(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"}) } ctx := c.Request().Context() order := h.GetFeedOrder(c, feedID.String()) eps, err := h.db.ListPodcastEpisodes(ctx, feedID) if err != nil { return podcastErrorResponse(c, err) } views := make([]PodcastEpisodeView, 0, len(eps)) for i := range eps { views = append(views, episodeView(&eps[i])) } sortSlice(views, func(a, b PodcastEpisodeView) bool { switch order { case "oldest": return episodeSortKey(a) < episodeSortKey(b) case "newest": return episodeSortKey(a) > episodeSortKey(b) default: // native: feed position (fetch-maintained) if a.FeedPosition != b.FeedPosition { return a.FeedPosition < b.FeedPosition } return a.Date < b.Date } }) sequence := make([]PodcastEpisodeView, 0, len(views)) gaps := make([]PodcastEpisodeView, 0) for _, v := range views { if v.Downloaded { sequence = append(sequence, v) } else { gaps = append(gaps, v) } } return c.JSON(http.StatusOK, map[string]interface{}{ "feed_id": feedID.String(), "order": order, "episodes": sequence, "gaps": gaps, }) } // episodeSortKey: chronological key for oldest/newest (date, then // native position as the same-day tiebreaker). func episodeSortKey(v PodcastEpisodeView) string { return v.Date + fmt.Sprintf("%06d", v.FeedPosition) } // sortSlice: sort.Slice with a controller-friendly wrapper. func sortSlice[T any](s []T, less func(a, b T) bool) { sort.Slice(s, func(i, j int) bool { return less(s[i], s[j]) }) }