feat(admin): all-podcasts view — disk-first maintenance for every feed
§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.
This commit is contained in:
1 parent
b293741eb1
commit
0741e98ea5
12 files changed
+1191
-47
No files matched your search
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/rls"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
@@ -1507,8 +1508,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
||||
// backfill or rescan, resolved by keeping all copies or merging into one.
|
||||
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
@@ -1562,6 +1562,62 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}))
|
||||
|
||||
// Admin all-podcasts view (§0 item 8): every feed from every user with
|
||||
// maintenance aggregates (disk usage first). The aggregate read runs
|
||||
// under the FIRST-ADMIN identity — the RLS admin escape (invariant
|
||||
// 12, poller/fetcher contract) — so admins see feeds from libraries
|
||||
// hidden even from them.
|
||||
frontendProtected.GET("/admin/podcasts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
|
||||
adminUUID, err := cfg.Queries.GetFirstAdmin(context.Background())
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading podcasts", "podcasts_load_error")
|
||||
}
|
||||
// cfg.Queries is ScopedDB-backed: the context identity is all the
|
||||
// aggregate read needs.
|
||||
actx := rls.WithUser(c.Request().Context(), adminUUID)
|
||||
|
||||
rows, err := cfg.Queries.ListAllPodcastFeedsAdmin(actx)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading podcasts", "podcasts_load_error")
|
||||
}
|
||||
|
||||
feeds := make([]templates.AdminPodcastFeedData, 0, len(rows))
|
||||
var totalBytes int64
|
||||
totalDownloaded, totalEpisodes := 0, 0
|
||||
for _, r := range rows {
|
||||
lastFetch := "never"
|
||||
if r.LastFetchedAt.Valid {
|
||||
lastFetch = r.LastFetchedAt.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006 15:04")
|
||||
}
|
||||
feeds = append(feeds, templates.AdminPodcastFeedData{
|
||||
ID: uuid.UUID(r.ID.Bytes).String(),
|
||||
Title: r.Title,
|
||||
Host: r.Host.String,
|
||||
LibraryName: r.LibraryName,
|
||||
Subscribers: int(r.SubscriberCount),
|
||||
SubscriberNames: r.SubscriberNames,
|
||||
EpisodeTotal: int(r.EpisodeTotal),
|
||||
EpisodeDownloaded: int(r.EpisodeDownloaded),
|
||||
DiskBytes: r.DiskBytes,
|
||||
LastFetched: lastFetch,
|
||||
})
|
||||
totalBytes += r.DiskBytes
|
||||
totalEpisodes += int(r.EpisodeTotal)
|
||||
totalDownloaded += int(r.EpisodeDownloaded)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := templates.AdminPodcasts(user, feeds, len(feeds), totalDownloaded, totalEpisodes, totalBytes).Render(c.Request().Context(), &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}))
|
||||
|
||||
// Admin users page
|
||||
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
|
||||
Reference in new issue
Block a user