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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user