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:
John O'Keefe committed 2026-10-29 16:03:35 -04:00
1 parent a6723e6ac8
commit 45b1152a15
5 files changed
+772

No files matched your search

+24
View File
@@ -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)
}
+12
View File
@@ -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)