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:
John O'Keefe committed 2026-10-30 13:54:37 -04:00
1 parent b293741eb1
commit 0741e98ea5
12 files changed
+1191 -47

No files matched your search

+17
View File
@@ -602,6 +602,12 @@ type Querier interface {
// Link unlinked book to media item
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
// EVERY user with its maintenance aggregates. Runs under the first-
// admin identity (invariant 12 — same contract as the poller). Sorted
// by disk usage DESC: the maintenance question is "what's eating the
// disk", so the hogs surface first.
ListAllPodcastFeedsAdmin(ctx context.Context) ([]ListAllPodcastFeedsAdminRow, error)
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
// ============================================
@@ -721,6 +727,17 @@ type Querier interface {
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
// Admin file deletion (§0 item 8): ONE downloaded episode's disk file
// was removed — reset the row to pending and clear the download-derived
// facts. file_path is deliberately KEPT: it is the fetch-maintained
// layout (written at fetch time, not download time), so a re-download
// lands on the same path. The poll upsert never touches download_state,
// so a deleted file stays deleted (invariant 2: polling never deletes;
// admins delete knowingly).
ResetPodcastEpisodeFileDeleted(ctx context.Context, id pgtype.UUID) error
// Same reset for EVERY downloaded episode of a feed (per-feed
// "Delete files" action on the admin view).
ResetPodcastFeedFilesDeleted(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
+124
View File
@@ -12564,6 +12564,79 @@ func (q *Queries) ListAllConflictsByUserAndStatus(ctx context.Context, arg ListA
return items, nil
}
const ListAllPodcastFeedsAdmin = `-- name: ListAllPodcastFeedsAdmin :many
SELECT f.id, f.title, f.host, f.last_fetched_at, f.library_id,
l.name AS library_name,
(SELECT COUNT(*) FROM media_items m
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode') AS episode_total,
(SELECT COUNT(*) FROM media_items m
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
AND m.download_state = 'downloaded') AS episode_downloaded,
(SELECT COALESCE(SUM(m.file_size), 0)::BIGINT FROM media_items m
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
AND m.download_state = 'downloaded') AS disk_bytes,
(SELECT COUNT(*) FROM podcast_subscriptions s
WHERE s.feed_id = f.id) AS subscriber_count,
(SELECT COALESCE(string_agg(u.username, ', ' ORDER BY u.username), '')::TEXT
FROM podcast_subscriptions s
JOIN users u ON u.id = s.user_id
WHERE s.feed_id = f.id) AS subscriber_names
FROM podcast_feeds f
JOIN libraries l ON l.id = f.library_id
ORDER BY 9 DESC, f.title ASC
`
type ListAllPodcastFeedsAdminRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Host pgtype.Text `db:"host" json:"host"`
LastFetchedAt pgtype.Timestamptz `db:"last_fetched_at" json:"last_fetched_at"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
LibraryName string `db:"library_name" json:"library_name"`
EpisodeTotal int64 `db:"episode_total" json:"episode_total"`
EpisodeDownloaded int64 `db:"episode_downloaded" json:"episode_downloaded"`
DiskBytes int64 `db:"disk_bytes" json:"disk_bytes"`
SubscriberCount int64 `db:"subscriber_count" json:"subscriber_count"`
SubscriberNames string `db:"subscriber_names" json:"subscriber_names"`
}
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
// EVERY user with its maintenance aggregates. Runs under the first-
// admin identity (invariant 12 — same contract as the poller). Sorted
// by disk usage DESC: the maintenance question is "what's eating the
// disk", so the hogs surface first.
func (q *Queries) ListAllPodcastFeedsAdmin(ctx context.Context) ([]ListAllPodcastFeedsAdminRow, error) {
rows, err := q.db.Query(ctx, ListAllPodcastFeedsAdmin)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAllPodcastFeedsAdminRow{}
for rows.Next() {
var i ListAllPodcastFeedsAdminRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Host,
&i.LastFetchedAt,
&i.LibraryID,
&i.LibraryName,
&i.EpisodeTotal,
&i.EpisodeDownloaded,
&i.DiskBytes,
&i.SubscriberCount,
&i.SubscriberNames,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListAllSyncQueueItems = `-- name: ListAllSyncQueueItems :many
SELECT
sq.id, sq.device_id, sq.media_item_id, sq.sync_type, sq.sync_data, sq.priority, sq.attempts, sq.max_attempts, sq.status, sq.error_message, sq.created_at, sq.processed_at,
@@ -15924,6 +15997,57 @@ func (q *Queries) ReparentMediaItemChildren(ctx context.Context, arg ReparentMed
return err
}
const ResetPodcastEpisodeFileDeleted = `-- name: ResetPodcastEpisodeFileDeleted :exec
UPDATE media_items
SET download_state = 'pending',
download_error = NULL,
download_attempts = 0,
file_size = NULL,
mime_type = NULL,
cover_image_path = NULL,
audio_metadata = NULL,
chapter_count = NULL,
updated_at = NOW()
WHERE id = $1 AND source = 'podcast_episode' AND download_state = 'downloaded'
`
// Admin file deletion (§0 item 8): ONE downloaded episode's disk file
// was removed — reset the row to pending and clear the download-derived
// facts. file_path is deliberately KEPT: it is the fetch-maintained
// layout (written at fetch time, not download time), so a re-download
// lands on the same path. The poll upsert never touches download_state,
// so a deleted file stays deleted (invariant 2: polling never deletes;
// admins delete knowingly).
func (q *Queries) ResetPodcastEpisodeFileDeleted(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, ResetPodcastEpisodeFileDeleted, id)
return err
}
const ResetPodcastFeedFilesDeleted = `-- name: ResetPodcastFeedFilesDeleted :execrows
UPDATE media_items
SET download_state = 'pending',
download_error = NULL,
download_attempts = 0,
file_size = NULL,
mime_type = NULL,
cover_image_path = NULL,
audio_metadata = NULL,
chapter_count = NULL,
updated_at = NOW()
WHERE source_feed_id = $1 AND source = 'podcast_episode'
AND download_state = 'downloaded'
`
// Same reset for EVERY downloaded episode of a feed (per-feed
// "Delete files" action on the admin view).
func (q *Queries) ResetPodcastFeedFilesDeleted(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, ResetPodcastFeedFilesDeleted, sourceFeedID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const ResetSystemCollectionMetadata = `-- name: ResetSystemCollectionMetadata :exec
UPDATE collections
SET description = $3,
+62
View File
@@ -4698,6 +4698,68 @@ WHERE feed_id = $1;
-- name: CountPodcastFeedSubscribers :one
SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1;
-- name: ListAllPodcastFeedsAdmin :many
-- Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
-- EVERY user with its maintenance aggregates. Runs under the first-
-- admin identity (invariant 12 — same contract as the poller). Sorted
-- by disk usage DESC: the maintenance question is "what's eating the
-- disk", so the hogs surface first.
SELECT f.id, f.title, f.host, f.last_fetched_at, f.library_id,
l.name AS library_name,
(SELECT COUNT(*) FROM media_items m
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode') AS episode_total,
(SELECT COUNT(*) FROM media_items m
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
AND m.download_state = 'downloaded') AS episode_downloaded,
(SELECT COALESCE(SUM(m.file_size), 0)::BIGINT FROM media_items m
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
AND m.download_state = 'downloaded') AS disk_bytes,
(SELECT COUNT(*) FROM podcast_subscriptions s
WHERE s.feed_id = f.id) AS subscriber_count,
(SELECT COALESCE(string_agg(u.username, ', ' ORDER BY u.username), '')::TEXT
FROM podcast_subscriptions s
JOIN users u ON u.id = s.user_id
WHERE s.feed_id = f.id) AS subscriber_names
FROM podcast_feeds f
JOIN libraries l ON l.id = f.library_id
ORDER BY 9 DESC, f.title ASC;
-- name: ResetPodcastEpisodeFileDeleted :exec
-- Admin file deletion (§0 item 8): ONE downloaded episode's disk file
-- was removed — reset the row to pending and clear the download-derived
-- facts. file_path is deliberately KEPT: it is the fetch-maintained
-- layout (written at fetch time, not download time), so a re-download
-- lands on the same path. The poll upsert never touches download_state,
-- so a deleted file stays deleted (invariant 2: polling never deletes;
-- admins delete knowingly).
UPDATE media_items
SET download_state = 'pending',
download_error = NULL,
download_attempts = 0,
file_size = NULL,
mime_type = NULL,
cover_image_path = NULL,
audio_metadata = NULL,
chapter_count = NULL,
updated_at = NOW()
WHERE id = $1 AND source = 'podcast_episode' AND download_state = 'downloaded';
-- name: ResetPodcastFeedFilesDeleted :execrows
-- Same reset for EVERY downloaded episode of a feed (per-feed
-- "Delete files" action on the admin view).
UPDATE media_items
SET download_state = 'pending',
download_error = NULL,
download_attempts = 0,
file_size = NULL,
mime_type = NULL,
cover_image_path = NULL,
audio_metadata = NULL,
chapter_count = NULL,
updated_at = NOW()
WHERE source_feed_id = $1 AND source = 'podcast_episode'
AND download_state = 'downloaded';
-- name: ListPodcastFeedsForPolling :many
-- Poller worklist (admin identity): every feed with its validators and
-- the back-off flag (is_complete feeds poll rarely, never disappear).
+62
View File
@@ -58,6 +58,68 @@ func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePo
return hxAccepted(c, "")
}
// AdminDeleteFeedFiles POST /api/podcasts/feeds/:id/delete-files —
// admin-only (§0 item 8): delete every downloaded episode file of the
// feed; rows reset to pending. HX answers HX-Refresh (the admin table
// re-renders with fresh aggregates).
func (h *PodcastHandler) AdminDeleteFeedFiles(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"})
}
// media_items writes run under the first-admin identity (invariant
// 12 — same contract as the fetcher/worker).
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
reset, err := h.podcasts.AdminDeleteFeedFiles(actx, feedID)
if err != nil {
return podcastErrorResponse(c, err)
}
log.Printf("[PODCAST] admin: deleted files for %d episode(s) of feed %s (user %s)", reset, feedID, user.Username)
if isHXRequest(c) {
c.Response().Header().Set("HX-Refresh", "true")
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
}
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
}
// AdminDeleteEpisodeFile POST /api/podcasts/episodes/:id/delete-file —
// admin-only (§0 item 8): ONE episode's file deleted, row reset to
// pending. HX answers with the refreshed episode list.
func (h *PodcastHandler) AdminDeleteEpisodeFile(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
}
episodeID, err := parseUUID(c.Param("eid"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid episode id"})
}
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
if err := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); err != nil {
return podcastErrorResponse(c, err)
}
log.Printf("[PODCAST] admin: deleted file of episode %s (user %s)", episodeID, user.Username)
if isHXRequest(c) {
// Re-render the episode list: the row needs its fresh state. The
// episode's feed id comes from the row we just reset.
if ep, err := h.db.GetPodcastEpisode(actx, episodeID); err == nil && ep.SourceFeedID.Valid {
return h.hxEpisodeList(c, ep.SourceFeedID.String(), false, false)
}
return hxAccepted(c, "")
}
return c.JSON(http.StatusOK, map[string]string{"message": "file deleted"})
}
// flipSubscriptionToAll implements issue #3: Download All (button or
// add-feed checkbox) sets the CALLER's subscription policy to 'all' —
// per-user, never global (files are shared D6, policies are not). The
+58 -2
View File
@@ -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)
+6
View File
@@ -8,6 +8,7 @@ import (
"bytes"
"net/http"
"bookhoard/internal/handlers"
"bookhoard/templates"
"github.com/labstack/echo/v5"
@@ -73,4 +74,9 @@ func registerPodcastRoutes(cfg *Config) {
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))
}
+60
View File
@@ -542,6 +542,66 @@ func (s *PodcastService) DeleteFeedFiles(ctx context.Context, feed *database.Pod
_ = os.Remove(filepath.Join(root, feedSlug(feed.Title, feed.ID)))
}
// AdminDeleteEpisodeFile (§0 item 8): ONE downloaded episode's file is
// removed from disk and its row reset to pending (facts cleared,
// file_path kept — fetch-maintained layout). Runs under the first-admin
// identity (caller supplies the admin ctx — invariant 12). The poll
// upsert never touches download_state, so the file stays deleted.
func (s *PodcastService) AdminDeleteEpisodeFile(ctx context.Context, episodeID pgtype.UUID) error {
ep, err := s.db.GetPodcastEpisode(ctx, episodeID)
if err != nil {
return errNotFound("episode not found")
}
if !ep.SourceFeedID.Valid {
return fmt.Errorf("episode has no feed reference")
}
if ep.DownloadState != "downloaded" {
return nil // nothing on disk; idempotent no-op
}
feed, err := s.db.GetPodcastFeed(ctx, ep.SourceFeedID)
if err != nil {
return fmt.Errorf("feed lookup: %w", errNotFound("feed not found"))
}
root, err := s.downloadRootAbs(ctx, &feed)
if err != nil {
return err
}
if ep.FilePath != "" {
_ = os.Remove(filepath.Join(root, ep.FilePath))
base := filepath.Join(root, strings.TrimSuffix(ep.FilePath, filepath.Ext(ep.FilePath)))
_ = os.Remove(base + ".cover.jpg")
}
return s.db.ResetPodcastEpisodeFileDeleted(ctx, episodeID)
}
// AdminDeleteFeedFiles (§0 item 8): every DOWNLOADED episode of the
// feed loses its disk file; rows reset to pending. Returns how many
// rows were reset. Poll upsert never touches download_state → a
// refresh re-lists metadata but does NOT re-materialize files.
func (s *PodcastService) AdminDeleteFeedFiles(ctx context.Context, feedID pgtype.UUID) (int64, error) {
feed, err := s.db.GetPodcastFeed(ctx, feedID)
if err != nil {
return 0, errNotFound("feed not found")
}
eps, err := s.db.ListPodcastEpisodes(ctx, feedID)
if err != nil {
return 0, err
}
root, err := s.downloadRootAbs(ctx, &feed)
if err != nil {
return 0, err
}
for _, ep := range eps {
if ep.DownloadState != "downloaded" || ep.FilePath == "" {
continue
}
_ = os.Remove(filepath.Join(root, ep.FilePath))
base := filepath.Join(root, strings.TrimSuffix(ep.FilePath, filepath.Ext(ep.FilePath)))
_ = os.Remove(base + ".cover.jpg")
}
return s.db.ResetPodcastFeedFilesDeleted(ctx, feedID)
}
// downloadRootAbs resolves the podcasts library's single download root
// (purpose='podcast') as an absolute filesystem path.
func (s *PodcastService) downloadRootAbs(ctx context.Context, feed *database.PodcastFeeds) (string, error) {