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