feat(podcasts): API endpoints — add/list/detail, subscriptions, downloads, sequence, search (task 1.7)
The §8 surface, wired through main.go → router.Config → /api/podcasts (JWT). Scoping is the engine's (D20): lookups outside the caller's scope simply return no rows → 404; the only Go-level checks are explicit-argument authorization (AddFeed's library + allow_member_feeds gate) and the admin role on feed deletion. Endpoints: POST/GET /feeds (add: fetch+parse+upsert+subscribe, optional download_all; list: subscriptions + episode counts + facet values computed in Go — feed counts are tens), GET /feeds/:id (detail with download states + gap count), PUT/DELETE /subscription (the ONLY user-writable podcast state, D9; unsubscribe retains files per D6), POST /refresh, POST /episodes/:eid/download (+ episode_ids[] bulk and eid='all' = Download All, exactly the non-downloaded set), PUT /order (D5 per-user override in the reader_settings KV — podcast_order:<feed_id>; 'native' deletes the row so default and explicit choice are indistinguishable), GET /sequence (effective order — override applied, native fallback — DOWNLOADED-only with the gap list; this payload powers web chain mode and, per §15, the future media3 queue), GET /search (subscription-scoped feed + episode search covering un-downloaded and historical rows, D14), DELETE /feeds/:id (admin; best-effort file removal, cascade deletes rows). Gap definition (countGaps): an un-downloaded episode with a downloaded episode AFTER it in list order — the D8 'middle episodes missing' banner condition.
This commit is contained in:
1 parent
a6723e6ac8
commit
45b1152a15
5 files changed
+772
No files matched your search
@@ -177,6 +177,12 @@ func main() {
|
|||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
|
|
||||||
|
// Podcast subsystem: fetcher service + sequential download worker +
|
||||||
|
// poller. One instance trio per server (per-feed locks dedupe).
|
||||||
|
podcastService := services.NewPodcastService(queries)
|
||||||
|
podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService)
|
||||||
|
podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker)
|
||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
// Set up validator
|
// Set up validator
|
||||||
@@ -237,6 +243,9 @@ func main() {
|
|||||||
CollectionHandler: collectionHandler,
|
CollectionHandler: collectionHandler,
|
||||||
FiltersHandler: filtersHandler,
|
FiltersHandler: filtersHandler,
|
||||||
DashboardHandler: dashboardHandler,
|
DashboardHandler: dashboardHandler,
|
||||||
|
PodcastHandler: podcastHandler,
|
||||||
|
PodcastService: podcastService,
|
||||||
|
PodcastWorker: podcastWorker,
|
||||||
DashboardService: dashboardService,
|
DashboardService: dashboardService,
|
||||||
SeriesHandler: seriesHandler,
|
SeriesHandler: seriesHandler,
|
||||||
OPDSHandler: opdsHandler,
|
OPDSHandler: opdsHandler,
|
||||||
|
|||||||
@@ -0,0 +1,505 @@
|
|||||||
|
// 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 (
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
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" validate:"required"`
|
||||||
|
URL string `json:"url" validate:"required"`
|
||||||
|
DownloadAll bool `json:"download_all"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdatePodcastSubscriptionRequest struct {
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
AutoDownload *string `json:"auto_download" validate:"omitempty,oneof=off all newest"`
|
||||||
|
AutoDownloadCount *int `json:"auto_download_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadEpisodesRequest struct {
|
||||||
|
EpisodeIDs []string `json:"episode_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
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" 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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]) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Podcast view types + mappers shared by the JSON API and the SSR
|
||||||
|
// partials (plan §8/§9). One shape per concept so the htmx partials and
|
||||||
|
// the API can never drift.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Feed is the API-facing feed shape (subscription fields attached when
|
||||||
|
// the caller has one).
|
||||||
|
type Feed struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
LibraryID string `json:"library_id"`
|
||||||
|
FeedUrl string `json:"feed_url"`
|
||||||
|
PodcastGuid string `json:"podcast_guid,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
Publisher string `json:"publisher,omitempty"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
ArtworkPath string `json:"artwork_path,omitempty"`
|
||||||
|
FeedType string `json:"feed_type,omitempty"`
|
||||||
|
Medium string `json:"medium,omitempty"`
|
||||||
|
IsComplete bool `json:"is_complete"`
|
||||||
|
LastFetchedAt string `json:"last_fetched_at,omitempty"`
|
||||||
|
|
||||||
|
// Subscription-owned (rendered when present).
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
AutoDownload string `json:"auto_download,omitempty"`
|
||||||
|
AutoDownloadCount int `json:"auto_download_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodcastEpisodeView is the episode-list row: media facts + the
|
||||||
|
// download lifecycle the list renders inline (D8/D19).
|
||||||
|
type PodcastEpisodeView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FeedID string `json:"feed_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Subtitle string `json:"subtitle,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Authors []string `json:"authors,omitempty"`
|
||||||
|
Hosts []string `json:"hosts,omitempty"`
|
||||||
|
Guests []string `json:"guests,omitempty"`
|
||||||
|
Date string `json:"date_published,omitempty"`
|
||||||
|
FeedPosition int `json:"feed_position"`
|
||||||
|
Season int `json:"season_number,omitempty"`
|
||||||
|
Episode int `json:"episode_number,omitempty"`
|
||||||
|
Duration int `json:"declared_duration_seconds,omitempty"`
|
||||||
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
|
CoverImage string `json:"cover_image_path,omitempty"`
|
||||||
|
EnclosureURL string `json:"enclosure_url,omitempty"`
|
||||||
|
|
||||||
|
// Download lifecycle.
|
||||||
|
DownloadState string `json:"download_state"`
|
||||||
|
DownloadError string `json:"download_error,omitempty"`
|
||||||
|
DownloadAttempts int `json:"download_attempts"`
|
||||||
|
Downloaded bool `json:"downloaded"`
|
||||||
|
FileSize int64 `json:"file_size,omitempty"`
|
||||||
|
|
||||||
|
// Progress (from the shared reading_progress machinery).
|
||||||
|
ProgressPercent float64 `json:"progress_percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// episodeView maps a media_items row (podcast episode) onto the view.
|
||||||
|
func episodeView(ep *database.MediaItems) PodcastEpisodeView {
|
||||||
|
v := PodcastEpisodeView{
|
||||||
|
ID: uuidString(ep.ID),
|
||||||
|
FeedID: uuidString(ep.SourceFeedID),
|
||||||
|
Title: ep.Title,
|
||||||
|
Subtitle: ep.Subtitle.String,
|
||||||
|
Description: ep.Description.String,
|
||||||
|
Authors: ep.Authors,
|
||||||
|
Hosts: ep.Hosts,
|
||||||
|
Guests: ep.Guests,
|
||||||
|
FeedPosition: int(ep.FeedPosition.Int32),
|
||||||
|
Season: int(ep.SeasonNumber.Int32),
|
||||||
|
Episode: int(ep.EpisodeNumber.Int32),
|
||||||
|
Duration: int(ep.DeclaredDurationSeconds.Int32),
|
||||||
|
MimeType: ep.MimeType.String,
|
||||||
|
CoverImage: ep.CoverImagePath.String,
|
||||||
|
EnclosureURL: ep.EnclosureUrl.String,
|
||||||
|
|
||||||
|
DownloadState: ep.DownloadState,
|
||||||
|
DownloadError: ep.DownloadError.String,
|
||||||
|
DownloadAttempts: int(ep.DownloadAttempts),
|
||||||
|
Downloaded: ep.DownloadState == "downloaded",
|
||||||
|
FileSize: ep.FileSize.Int64,
|
||||||
|
}
|
||||||
|
if ep.DatePublished.Valid {
|
||||||
|
v.Date = ep.DatePublished.Time.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
if !ep.FileSize.Valid {
|
||||||
|
v.FileSize = 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedWithSubscription merges the feed row + the caller's subscription
|
||||||
|
// (empty sub → bare feed).
|
||||||
|
func feedWithSubscription(feed *database.PodcastFeeds, sub database.PodcastSubscriptions, subFound bool) Feed {
|
||||||
|
f := Feed{
|
||||||
|
ID: uuidString(feed.ID),
|
||||||
|
LibraryID: uuidString(feed.LibraryID),
|
||||||
|
FeedUrl: feed.FeedUrl,
|
||||||
|
PodcastGuid: feed.PodcastGuid.String,
|
||||||
|
Title: feed.Title,
|
||||||
|
Description: feed.Description.String,
|
||||||
|
Host: feed.Host.String,
|
||||||
|
Publisher: feed.Publisher.String,
|
||||||
|
Language: feed.Language.String,
|
||||||
|
Tags: feed.Tags,
|
||||||
|
Explicit: feed.Explicit,
|
||||||
|
ArtworkPath: feed.ArtworkPath.String,
|
||||||
|
FeedType: feed.FeedType.String,
|
||||||
|
Medium: feed.Medium.String,
|
||||||
|
IsComplete: feed.IsComplete,
|
||||||
|
LastFetchedAt: timeString(feed.LastFetchedAt),
|
||||||
|
}
|
||||||
|
if subFound {
|
||||||
|
f.DisplayName = sub.DisplayName.String
|
||||||
|
f.AutoDownload = sub.AutoDownload
|
||||||
|
f.AutoDownloadCount = int(sub.AutoDownloadCount.Int32)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// countGaps counts un-downloaded episodes that have a downloaded
|
||||||
|
// episode AFTER them in list order — the D8 "middle episodes missing"
|
||||||
|
// definition that drives the gap banner.
|
||||||
|
func countGaps(eps []PodcastEpisodeView) int {
|
||||||
|
seenDownloaded := false
|
||||||
|
gaps := 0
|
||||||
|
for i := len(eps) - 1; i >= 0; i-- {
|
||||||
|
if eps[i].Downloaded {
|
||||||
|
seenDownloaded = true
|
||||||
|
} else if seenDownloaded {
|
||||||
|
gaps++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
func countDownloaded(eps []PodcastEpisodeView) int {
|
||||||
|
n := 0
|
||||||
|
for _, e := range eps {
|
||||||
|
if e.Downloaded {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// facetValuesFor derives the /podcasts facet bar values (D18) from the
|
||||||
|
// subscription list — feed counts are small; no facet SQL.
|
||||||
|
func facetValuesFor(feeds []podcastFeedSummary) map[string][]string {
|
||||||
|
hosts := map[string]bool{}
|
||||||
|
tags := map[string]bool{}
|
||||||
|
langs := map[string]bool{}
|
||||||
|
media := map[string]bool{}
|
||||||
|
for _, f := range feeds {
|
||||||
|
if f.Host != "" {
|
||||||
|
hosts[f.Host] = true
|
||||||
|
}
|
||||||
|
for _, t := range f.Tags {
|
||||||
|
tags[t] = true
|
||||||
|
}
|
||||||
|
if f.Language != "" {
|
||||||
|
langs[f.Language] = true
|
||||||
|
}
|
||||||
|
if f.Medium != "" {
|
||||||
|
media[f.Medium] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string][]string{
|
||||||
|
"hosts": sortedKeys(hosts),
|
||||||
|
"tags": sortedKeys(tags),
|
||||||
|
"language": sortedKeys(langs),
|
||||||
|
"medium": sortedKeys(media),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(set map[string]bool) []string {
|
||||||
|
out := make([]string, 0, len(set))
|
||||||
|
for k := range set {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// uuidString renders a pgtype.UUID (empty string when NULL).
|
||||||
|
func uuidString(id pgtype.UUID) string {
|
||||||
|
if !id.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return uuid.UUID(id.Bytes).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeString renders a timestamp (RFC3339; empty when NULL).
|
||||||
|
func timeString(t pgtype.Timestamptz) string {
|
||||||
|
if !t.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Time.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgtypeText (handlers-local) — service has its own.
|
||||||
|
func pgtypeText(s string) pgtype.Text {
|
||||||
|
if s == "" {
|
||||||
|
return pgtype.Text{}
|
||||||
|
}
|
||||||
|
return pgtype.Text{String: s, Valid: true}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
// Podcast routes (plan §8). All under /api/podcasts with the JWT
|
||||||
|
// middleware; scoping is RLS's job (D20) — handlers add no visibility
|
||||||
|
// plumbing.
|
||||||
|
|
||||||
|
func registerPodcastRoutes(cfg *Config) {
|
||||||
|
g := cfg.Echo.Group("/api/podcasts", createJWTMiddleware(cfg))
|
||||||
|
|
||||||
|
g.POST("/feeds", cfg.PodcastHandler.AddFeed)
|
||||||
|
g.GET("/feeds", cfg.PodcastHandler.ListFeeds)
|
||||||
|
g.GET("/search", cfg.PodcastHandler.Search)
|
||||||
|
|
||||||
|
// :id-scoped (RLS answers 404 for feeds outside the caller's scope).
|
||||||
|
feeds := g.Group("/feeds/:id")
|
||||||
|
feeds.GET("", cfg.PodcastHandler.GetFeed)
|
||||||
|
feeds.GET("/sequence", cfg.PodcastHandler.GetFeedSequence)
|
||||||
|
feeds.PUT("/order", cfg.PodcastHandler.SetFeedOrder)
|
||||||
|
feeds.PUT("/subscription", cfg.PodcastHandler.UpdateSubscription)
|
||||||
|
feeds.DELETE("/subscription", cfg.PodcastHandler.DeleteSubscription)
|
||||||
|
feeds.POST("/refresh", cfg.PodcastHandler.RefreshFeed)
|
||||||
|
feeds.POST("/episodes/:eid/download", cfg.PodcastHandler.DownloadEpisodes)
|
||||||
|
feeds.DELETE("", cfg.PodcastHandler.DeleteFeed) // admin-only (handler checks role)
|
||||||
|
}
|
||||||
@@ -80,6 +80,9 @@ type Config struct {
|
|||||||
SidecarHandler *handlers.SidecarHandler
|
SidecarHandler *handlers.SidecarHandler
|
||||||
ReaderHandler *handlers.ReaderHandler
|
ReaderHandler *handlers.ReaderHandler
|
||||||
LibraryService *services.LibraryService
|
LibraryService *services.LibraryService
|
||||||
|
PodcastHandler *handlers.PodcastHandler
|
||||||
|
PodcastService *services.PodcastService
|
||||||
|
PodcastWorker *services.PodcastDownloadWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBaseURL returns the configured base URL from the database, falling back to
|
// getBaseURL returns the configured base URL from the database, falling back to
|
||||||
@@ -344,6 +347,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
registerConflictRoutes(cfg)
|
registerConflictRoutes(cfg)
|
||||||
registerAnalyticsRoutes(cfg)
|
registerAnalyticsRoutes(cfg)
|
||||||
registerQueueRoutes(cfg)
|
registerQueueRoutes(cfg)
|
||||||
|
registerPodcastRoutes(cfg)
|
||||||
registerJobRoutes(cfg)
|
registerJobRoutes(cfg)
|
||||||
registerFiltersRoutes(cfg)
|
registerFiltersRoutes(cfg)
|
||||||
registerOPDSRoutes(cfg)
|
registerOPDSRoutes(cfg)
|
||||||
@@ -394,6 +398,14 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
// Start background tasks (queue processor and connection cleanup)
|
// Start background tasks (queue processor and connection cleanup)
|
||||||
scannerHandler.StartBackgroundTasks()
|
scannerHandler.StartBackgroundTasks()
|
||||||
|
|
||||||
|
// Podcast subsystem background loops: sequential download worker +
|
||||||
|
// feed poller (plan §7). The poller re-reads its interval setting
|
||||||
|
// every tick; both exit with the server context.
|
||||||
|
if cfg.PodcastWorker != nil && cfg.PodcastService != nil {
|
||||||
|
cfg.PodcastWorker.Start(context.Background())
|
||||||
|
services.NewPodcastPoller(cfg.Queries, cfg.PodcastService, cfg.PodcastWorker, cfg.Settings).Start(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
// Start watch mode for all libraries (after 2 second delay for DB)
|
// Start watch mode for all libraries (after 2 second delay for DB)
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|||||||
Reference in new issue
Block a user