diff --git a/cmd/server/main.go b/cmd/server/main.go index 9a316f48..269ec2bb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -177,6 +177,12 @@ func main() { matchingHandler := handlers.NewMatchingHandler(queries, connManager) 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() // Set up validator @@ -237,6 +243,9 @@ func main() { CollectionHandler: collectionHandler, FiltersHandler: filtersHandler, DashboardHandler: dashboardHandler, + PodcastHandler: podcastHandler, + PodcastService: podcastService, + PodcastWorker: podcastWorker, DashboardService: dashboardService, SeriesHandler: seriesHandler, OPDSHandler: opdsHandler, diff --git a/internal/handlers/podcast.go b/internal/handlers/podcast.go new file mode 100644 index 00000000..0c1e71ed --- /dev/null +++ b/internal/handlers/podcast.go @@ -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]) }) +} diff --git a/internal/handlers/podcast_views.go b/internal/handlers/podcast_views.go new file mode 100644 index 00000000..e5505ec1 --- /dev/null +++ b/internal/handlers/podcast_views.go @@ -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} +} diff --git a/internal/router/podcast.go b/internal/router/podcast.go new file mode 100644 index 00000000..c47a4b13 --- /dev/null +++ b/internal/router/podcast.go @@ -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) +} diff --git a/internal/router/router.go b/internal/router/router.go index 695fe34b..de27ce9e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -80,6 +80,9 @@ type Config struct { SidecarHandler *handlers.SidecarHandler ReaderHandler *handlers.ReaderHandler LibraryService *services.LibraryService + PodcastHandler *handlers.PodcastHandler + PodcastService *services.PodcastService + PodcastWorker *services.PodcastDownloadWorker } // getBaseURL returns the configured base URL from the database, falling back to @@ -344,6 +347,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler { registerConflictRoutes(cfg) registerAnalyticsRoutes(cfg) registerQueueRoutes(cfg) + registerPodcastRoutes(cfg) registerJobRoutes(cfg) registerFiltersRoutes(cfg) registerOPDSRoutes(cfg) @@ -394,6 +398,14 @@ func RegisterRoutes(cfg *Config) *handlers.Handler { // Start background tasks (queue processor and connection cleanup) 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) go func() { time.Sleep(2 * time.Second)