§0 item 8 (Phase-4 item 1 pulled forward, user directive: maintenance
tooling ships WITH the features it maintains):
- /admin/podcasts (AdminMiddleware): every feed from EVERY user via the
RLS admin escape (first-admin identity, poller/fetcher contract) —
feed + host, library, subscriber count/names, downloaded/total, DISK
USAGE, last fetch. Sorted disk DESC ('what's eating the disk'),
totals header (feeds · episodes · bytes on disk).
- Per-feed actions: Refresh · Delete files (rows → pending, disk freed;
confirms spell out the re-materialization contract) · Remove feed
(rows + files for everyone — the same admin delete as the detail
page). All confirms ride the global confirm modal.
- New sqlc: ListAllPodcastFeedsAdmin (correlated aggregates),
ResetPodcastEpisodeFileDeleted / ResetPodcastFeedFilesDeleted —
downloaded rows reset to pending with download-derived facts cleared
(audio_metadata, chapter_count, cover, size, mime); file_path KEPT
(fetch-maintained layout — a re-download lands on the same path).
file deletion via the existing DeleteFeedFiles file-removal pattern
(episode files + .cover.jpg siblings).
- Poll upsert never touches download_state, so deleted files STAY
deleted — a feed refresh re-lists metadata but re-materializes
nothing (pinned by TestAdminDeleteEpisodeFiles).
- Per-episode admin delete-file: POST /feeds/:id/episodes/:eid/
delete-file (admin) — one episode's file gone, row pending, siblings
untouched; HX swaps the episode list. (Found while testing: the
handler first read c.Param("id") — the FEED's uuid — instead of
:eid; the 404 'episode not found' trail led straight to it.)
- Tests: admin sees other users' feeds + subscriber names; regular
users 403 on page AND actions; single + feed-level deletion reset
rows/clear facts/delete files; refresh leaves pending rows pending.
- Admin nav gains 'Podcasts' under Administration.
Wrong-gotcha note for future agents: parseUUID on c.Param('id') inside
a /feeds/:id/episodes/:eid route resolves the FEED, not the episode.
83 lines
3.0 KiB
Go
83 lines
3.0 KiB
Go
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.
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/templates"
|
|
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
func registerPodcastRoutes(cfg *Config) {
|
|
g := cfg.Echo.Group("/api/podcasts", createJWTMiddleware(cfg))
|
|
|
|
// Episode-list renderer (issue #1): htmx actions answer with the
|
|
// refreshed partial directly — no event indirection to miss. The
|
|
// view assembly lives here (templates) while the action logic lives
|
|
// in the handler; this closure is the sanctioned seam.
|
|
cfg.PodcastHandler.SetEpisodeListRenderer(func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
feedUUID, err := parsePodcastUUID(feedID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := buildPodcastDetailData(c, cfg, user, feedUUID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var buf bytes.Buffer
|
|
render := templates.PodcastEpisodeList
|
|
if templates.HasActiveDownloads(data.Episodes) {
|
|
render = templates.PodcastEpisodeListPolling
|
|
}
|
|
if err := render(*data).Render(c.Request().Context(), &buf); err != nil {
|
|
return err
|
|
}
|
|
if includePolicySelect {
|
|
// Issue #3: out-of-band swap of the auto-download select —
|
|
// the dropdown reflects the flipped policy immediately.
|
|
if err := templates.PodcastAutoDownloadSelectOOB(data.Feed).Render(c.Request().Context(), &buf); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if includeOrderSelect {
|
|
// §0 item 3: out-of-band swap of the order select — the
|
|
// "Reverse feed order" button flips to reverse; the
|
|
// dropdown mirrors the server state immediately.
|
|
if err := templates.PodcastOrderSelectOOB(data.Feed.ID, data.Order).Render(c.Request().Context(), &buf); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
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)
|
|
|
|
// Admin maintenance (§0 item 8): file deletion frees the disk and
|
|
// resets rows to pending. Handler checks admin role.
|
|
feeds.POST("/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteFeedFiles))
|
|
feeds.POST("/episodes/:eid/delete-file", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFile))
|
|
}
|