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)
}