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

+172
View File
@@ -1240,3 +1240,175 @@ func TestPodcastAddModalDownloadOrder(t *testing.T) {
assert.Equal(t, int32(3), byID[tickets[0].ID], "PIN: reverse download order — position 3 claims first")
assert.Equal(t, int32(1), byID[tickets[2].ID])
}
// ---- §0 item 8: admin all-podcasts view + file maintenance ----
func TestAdminPodcastsView(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test AdminView Podcasts")
mux := http.NewServeMux()
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("AdminView Feed", map[string]string{"av-1": "AdminView One"})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
// Subscribe the REGULAR user: the admin view must show their feed +
// their name even though the library was created by/for the test.
form := url.Values{}
form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
form.Set("url", srv.URL+"/feed.xml")
resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form)
require.Equal(t, http.StatusCreated, resp, string(body))
// Admin sees the page with the regular user's feed + their name.
code, page := podcastAuthed(t, setup, setup.Token, "GET", "/admin/podcasts", "")
require.Equal(t, http.StatusOK, code, string(page))
assert.Contains(t, string(page), "AdminView Feed",
"PIN: the admin view lists feeds the admin does not subscribe to (RLS admin escape)")
assert.Contains(t, string(page), "All Podcasts")
regularName := "testregularuser"
assert.Contains(t, string(page), regularName, "subscriber names render")
// Regular users are locked out of the page AND the maintenance actions.
code, _ = podcastAuthed(t, setup, setup.RegularToken, "GET", "/admin/podcasts", "")
assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins never see the all-podcasts view")
feeds, _ := setup.DB.ListPodcastFeedsForPolling(adminSeedCtx(t, setup))
var feedIDStr string
for _, f := range feeds {
if f.Title == "AdminView Feed" {
feedIDStr = uuid.UUID(f.ID.Bytes).String()
}
}
require.NotEmpty(t, feedIDStr)
code, _ = podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds/"+feedIDStr+"/delete-files", "")
assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins cannot delete files")
}
func TestAdminDeleteEpisodeFiles(t *testing.T) {
setup := setupTestServer(t)
if _, err := exec.LookPath("ffmpeg"); err != nil {
if _, err := os.Stat("/usr/bin/ffmpeg"); err != nil {
t.Skip("ffmpeg unavailable: worker pipeline test needs runtime-generated audio")
}
}
lib, root := createPodcastsLibrary(t, setup, "test AdminFiles Podcasts")
mp3Path := filepath.Join(t.TempDir(), "tone.mp3")
gen := exec.Command("ffmpeg", "-y", "-v", "error", "-f", "lavfi",
"-i", "sine=frequency=440:duration=1", "-b:a", "32k", mp3Path)
out, err := gen.CombinedOutput()
require.NoError(t, err, "ffmpeg generate: %s", string(out))
audio, err := os.ReadFile(mp3Path)
require.NoError(t, err)
mux := http.NewServeMux()
mux.HandleFunc("/audio/", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write(audio)
})
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("AdminFiles Feed", map[string]string{
"af-1": "AdminFiles One", "af-2": "AdminFiles Two",
})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
form := url.Values{}
form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
form.Set("url", srv.URL+"/feed.xml")
resp, body := podcastAuthedForm(t, setup, setup.Token, "POST", "/api/podcasts/feeds", form)
require.Equal(t, http.StatusCreated, resp, string(body))
adminCtx := adminSeedCtx(t, setup)
feeds, err := setup.DB.ListPodcastFeedsForPolling(adminCtx)
require.NoError(t, err)
var feedRow database.PodcastFeeds
for _, f := range feeds {
if f.Title == "AdminFiles Feed" {
feedRow = f
}
}
require.NotZero(t, feedRow.ID, "feed must exist")
feedIDStr := uuid.UUID(feedRow.ID.Bytes).String()
// Point enclosures at the fixture server, THEN queue + drain
// immediately: queueing after re-pointing keeps the dev server's
// worker (which can't reach the fixture URL and would fail the
// rows) out of the race.
adminScope := database.NewScopedDB(setup.DBPool)
_, err = adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)),
`UPDATE media_items SET enclosure_url = $2 WHERE source_feed_id = $1`,
feedRow.ID, srv.URL+"/audio/ep.mp3")
require.NoError(t, err)
worker := services.NewPodcastDownloadWorker(setup.DB, services.NewPodcastService(setup.DB))
eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
for _, ep := range eps {
_, err := setup.DB.SetPodcastEpisodeDownloadQueued(adminCtx, ep.ID)
require.NoError(t, err)
}
worker.DrainNow(context.Background())
require.Eventually(t, func() bool {
eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
if err != nil || len(eps) != 2 {
return false
}
for _, ep := range eps {
if ep.DownloadState != "downloaded" {
return false
}
}
return true
}, 30*time.Second, 250*time.Millisecond, "both episodes downloaded")
eps, err = setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
ep1, ep2 := eps[0], eps[1]
pathFor := func(ep database.MediaItems) string { return filepath.Join(root, ep.FilePath) }
require.FileExists(t, pathFor(ep1))
require.FileExists(t, pathFor(ep2))
// --- single-episode delete-file (admin) ---
ep1Str := uuid.UUID(ep1.ID.Bytes).String()
code, rbody := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/"+ep1Str+"/delete-file", "")
require.Equal(t, http.StatusOK, code, string(rbody))
after1, err := setup.DB.GetPodcastEpisode(adminCtx, ep1.ID)
require.NoError(t, err)
assert.Equal(t, "pending", after1.DownloadState, "PIN: deleted-file episode resets to pending")
assert.False(t, after1.FileSize.Valid, "file facts cleared")
assert.Nil(t, after1.AudioMetadata, "audio_metadata cleared (it described the deleted file)")
after2, err := setup.DB.GetPodcastEpisode(adminCtx, ep2.ID)
require.NoError(t, err)
assert.Equal(t, "downloaded", after2.DownloadState, "the sibling episode is untouched")
assert.NoFileExists(t, pathFor(after1), "disk file removed")
assert.FileExists(t, pathFor(after2), "sibling file kept")
// --- per-feed delete-files (admin) ---
code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/delete-files", "")
require.Equal(t, http.StatusOK, code, string(rbody))
assert.Contains(t, string(rbody), "reset_episodes")
after2, err = setup.DB.GetPodcastEpisode(adminCtx, ep2.ID)
require.NoError(t, err)
assert.Equal(t, "pending", after2.DownloadState)
assert.NoFileExists(t, pathFor(after2), "feed-level deletion clears the remaining file")
// --- refresh must NOT re-materialize deleted files (invariant 2):
// the poll upsert rewrites metadata but never touches download_state.
code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "")
require.Equal(t, http.StatusOK, code, string(rbody))
refreshed, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
require.Len(t, refreshed, 2)
for _, ep := range refreshed {
assert.Equal(t, "pending", ep.DownloadState,
"PIN: a feed refresh does not resurrect deleted downloads (%s)", ep.Title)
assert.NoFileExists(t, pathFor(ep), "no file re-materialized without an explicit download")
}
}