Files
bookhoard/internal/handlers/podcast.go
T
John O'Keefe 7d5bbd1177 feat(admin): multi-select episode file deletion — checkboxes on downloaded rows
The plan's per-EPISODE bulk select for the admin view (§0 item 8
follow-up — trimmed in the first admin-view commit; user called it
back). Admins now tick checkboxes on DOWNLOADED episode rows and delete
their files in one request:

- Checkbox per row, rendered ONLY for admins + downloaded episodes
  (nothing to delete otherwise), via the existing podcastDetail Alpine
  component: selection ids/count survive the list's htmx swaps
  (reapplied on afterSwap — same pattern as the text filter) and clear
  after a successful bulk delete.
- Toolbar segment outside #episode-list (survives swaps): 'N selected'
  + 'Delete selected files' (hx-include gathers the CHECKED boxes);
  disabled-looking at zero selection; hx-confirm states the
  re-materialization contract. CSP-safe method bindings only
  (bulkSelectionLabel/Class) — no ternaries in Alpine expressions.
- POST /feeds/:id/episodes/delete-files (admin): repeated
  episode-file-select keys parsed via ParseForm directly (never bound —
  repeated-key wire is another echo-bind gotcha); each id validated to
  belong to THIS feed, so a forged foreign id is refused while the real
  selections apply; rows reset to pending, files removed.
- Tests: bulk of two real ids + one forged id → deleted:2, files gone,
  rows pending; non-admin 403; all prior single/feed-level pins hold.
2026-10-30 14:06:54 -04:00

759 lines
29 KiB
Go

// 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"
"log"
"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);
// includeOrderSelect likewise rides along for the order endpoint (the
// "Reverse feed order" button flips to reverse — the dropdown must
// mirror the server state, §0 item 3).
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect 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, includeOrderSelect bool) error {
if h.episodeListRenderer != nil {
return h.episodeListRenderer(c, feedID, includePolicySelect, includeOrderSelect)
}
return hxAccepted(c, "")
}
// AdminDeleteFeedFiles POST /api/podcasts/feeds/:id/delete-files —
// admin-only (§0 item 8): delete every downloaded episode file of the
// feed; rows reset to pending. HX answers HX-Refresh (the admin table
// re-renders with fresh aggregates).
func (h *PodcastHandler) AdminDeleteFeedFiles(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"})
}
// media_items writes run under the first-admin identity (invariant
// 12 — same contract as the fetcher/worker).
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
reset, err := h.podcasts.AdminDeleteFeedFiles(actx, feedID)
if err != nil {
return podcastErrorResponse(c, err)
}
log.Printf("[PODCAST] admin: deleted files for %d episode(s) of feed %s (user %s)", reset, feedID, user.Username)
if isHXRequest(c) {
c.Response().Header().Set("HX-Refresh", "true")
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
}
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
}
// AdminDeleteEpisodeFile POST /api/podcasts/episodes/:id/delete-file —
// admin-only (§0 item 8): ONE episode's file deleted, row reset to
// pending. HX answers with the refreshed episode list.
func (h *PodcastHandler) AdminDeleteEpisodeFile(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
}
episodeID, err := parseUUID(c.Param("eid"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid episode id"})
}
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
if err := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); err != nil {
return podcastErrorResponse(c, err)
}
log.Printf("[PODCAST] admin: deleted file of episode %s (user %s)", episodeID, user.Username)
if isHXRequest(c) {
// Re-render the episode list: the row needs its fresh state. The
// episode's feed id comes from the row we just reset.
if ep, err := h.db.GetPodcastEpisode(actx, episodeID); err == nil && ep.SourceFeedID.Valid {
return h.hxEpisodeList(c, ep.SourceFeedID.String(), false, false)
}
return hxAccepted(c, "")
}
return c.JSON(http.StatusOK, map[string]string{"message": "file deleted"})
}
// AdminDeleteEpisodeFilesBulk POST /api/podcasts/feeds/:id/episodes/delete-files —
// admin-only (§0 item 8): multi-select file deletion. Body carries the
// CHECKED boxes' values (form-encoded, repeated episode-file-select
// keys — hx-include gathers them; parsed directly, never bound: the
// repeated-key wire is another echo-bind gotcha). Each id is validated
// to belong to THIS feed; rows reset to pending, files removed.
func (h *PodcastHandler) AdminDeleteEpisodeFilesBulk(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"})
}
if err := c.Request().ParseForm(); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
rawIDs := c.Request().PostForm["episode-file-select"]
if len(rawIDs) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no episodes selected"})
}
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
deleted := 0
for _, raw := range rawIDs {
episodeID, perr := parseUUID(raw)
if perr != nil {
continue
}
// The checkbox list only renders this feed's rows, but the wire
// is user-controlled: a forged id for a foreign feed's episode
// is refused (feed-match guard), not silently applied.
ep, gerr := h.db.GetPodcastEpisode(actx, episodeID)
if gerr != nil || ep.SourceFeedID != feedID {
continue
}
if derr := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); derr == nil {
deleted++
}
}
log.Printf("[PODCAST] admin: bulk-deleted files for %d/%d episode(s) of feed %s (user %s)",
deleted, len(rawIDs), feedID, user.Username)
if isHXRequest(c) {
return h.hxEpisodeList(c, feedID.String(), false, false)
}
return c.JSON(http.StatusOK, map[string]interface{}{"deleted": deleted})
}
// 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
})
log.Printf("[PODCAST] flip: user %s → policy 'all' for feed %s (Download All / add checkbox)", userID, feedID)
}
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 FlexBool `json:"download_all" form:"download_all"`
// DownloadOrder: the initial listening order for THIS feed, applied
// only with DownloadAll (§0 item 5c) — it both seeds the queue in
// listening order and sets the caller's order override. ""/"feed" =
// no override row.
DownloadOrder string `json:"download_order,omitempty" form:"download_order"`
}
// FlexBool binds checkbox-style booleans on every wire. echo bool-binds
// via strconv.ParseBool, which accepts "true"/"1" but REJECTS the
// browser-native checkbox value "on" — and a bind failure is fatal for
// the WHOLE request (the add modal would 400 "invalid request" on any
// form variant that posts "on"). §0 item 2 (2026-10-30).
//
// A checkbox only contributes its field when CHECKED, so for form
// posts presence really is the signal — JSON's native bool binding
// ("false"→false) is unaffected. Absence on either wire = false.
type FlexBool bool
func (b *FlexBool) UnmarshalParam(v string) error {
switch strings.ToLower(strings.TrimSpace(v)) {
case "false", "0", "off", "n", "no":
*b = false
default:
*b = true // "true", "on", "yes", "checked", "1" — or any truthy variant
}
return nil
}
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 {
// §0 item 5c: the add modal's order dropdown is the feed's
// INITIAL listening order — it IS the download order (the queue
// seeds in that order below). 'feed'/'native' = no override row.
order := "native"
switch req.DownloadOrder {
case "reverse", "oldest", "newest":
order = req.DownloadOrder
payload, _ := json.Marshal(map[string]string{"order": order})
if err := h.db.UpsertUserSettingByKey(c.Request().Context(), database.UpsertUserSettingByKeyParams{
UserID: user.ID, SettingKey: podcastOrderSettingKey(res.Feed.ID.String()), SettingValue: payload,
}); err != nil {
// subscription + queue stand; the dropdown just won't
// preselect it (surfaced on the next order set).
_ = err
}
}
if _, err := h.worker.QueueFeedDownloadsAs(c.Request().Context(), user.ID, 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, false)
}
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, 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).
// Queues in the CALLER's listening order (§0 item 5b).
queued, err := h.worker.QueueFeedDownloadsAs(ctx, user.ID, 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, false)
}
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, 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) | reverse (position DESC,
// no dates) | oldest | newest.
func podcastOrderValid(o string) bool {
return services.ValidPodcastEpisodeOrder(o)
}
// 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 reverse 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, true)
}
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]))
}
// Effective listening order (order consolidation §0 item 5a).
services.OrderEpisodes(views, order,
func(v PodcastEpisodeView) int32 { return int32(v.FeedPosition) },
func(v PodcastEpisodeView) string { return v.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,
})
}
// 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]) })
}