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