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