feat(admin): multi-select episode file deletion — checkboxes on downloaded rows

The plan's per-EPISODE bulk select for the admin view (§0 item 8
follow-up — trimmed in the first admin-view commit; user called it
back). Admins now tick checkboxes on DOWNLOADED episode rows and delete
their files in one request:

- Checkbox per row, rendered ONLY for admins + downloaded episodes
  (nothing to delete otherwise), via the existing podcastDetail Alpine
  component: selection ids/count survive the list's htmx swaps
  (reapplied on afterSwap — same pattern as the text filter) and clear
  after a successful bulk delete.
- Toolbar segment outside #episode-list (survives swaps): 'N selected'
  + 'Delete selected files' (hx-include gathers the CHECKED boxes);
  disabled-looking at zero selection; hx-confirm states the
  re-materialization contract. CSP-safe method bindings only
  (bulkSelectionLabel/Class) — no ternaries in Alpine expressions.
- POST /feeds/:id/episodes/delete-files (admin): repeated
  episode-file-select keys parsed via ParseForm directly (never bound —
  repeated-key wire is another echo-bind gotcha); each id validated to
  belong to THIS feed, so a forged foreign id is refused while the real
  selections apply; rows reset to pending, files removed.
- Tests: bulk of two real ids + one forged id → deleted:2, files gone,
  rows pending; non-admin 403; all prior single/feed-level pins hold.
This commit is contained in:
John O'Keefe committed 2026-10-30 14:06:54 -04:00
1 parent 0741e98ea5
commit 7d5bbd1177
6 files changed
+511 -240

No files matched your search

+40
View File
@@ -1399,6 +1399,46 @@ func TestAdminDeleteEpisodeFiles(t *testing.T) {
assert.Equal(t, "pending", after2.DownloadState) assert.Equal(t, "pending", after2.DownloadState)
assert.NoFileExists(t, pathFor(after2), "feed-level deletion clears the remaining file") assert.NoFileExists(t, pathFor(after2), "feed-level deletion clears the remaining file")
// --- bulk multi-select delete-files (§0 item 8 follow-up) ---
// Re-download both, then delete them in ONE request with a forged
// foreign id mixed in: only the matching-feed ids are applied.
for _, ep := range []database.MediaItems{ep1, ep2} {
require.NoError(t, setup.DB.SetPodcastEpisodePending(adminCtx, ep.ID))
_, 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 re-downloaded for the bulk test")
foreignID := uuid.New().String()
bulkForm := url.Values{}
bulkForm["episode-file-select"] = []string{uuid.UUID(ep1.ID.Bytes).String(), foreignID, uuid.UUID(ep2.ID.Bytes).String()}
code, rbody = podcastAuthedForm(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/delete-files", bulkForm)
require.Equal(t, http.StatusOK, code, string(rbody))
assert.Contains(t, string(rbody), `"deleted":2`, "the forged foreign id is refused, the real two applied")
assert.NoFileExists(t, pathFor(ep1))
assert.NoFileExists(t, pathFor(ep2))
afterBulk, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
for _, ep := range afterBulk {
assert.Equal(t, "pending", ep.DownloadState, "bulk deletion resets every selected row (%s)", ep.Title)
}
// Non-admins cannot bulk-delete either.
code, _ = podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/delete-files", "")
assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins cannot bulk-delete files")
// --- refresh must NOT re-materialize deleted files (invariant 2): // --- refresh must NOT re-materialize deleted files (invariant 2):
// the poll upsert rewrites metadata but never touches download_state. // the poll upsert rewrites metadata but never touches download_state.
code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "") code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "")
+52
View File
@@ -120,6 +120,58 @@ func (h *PodcastHandler) AdminDeleteEpisodeFile(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "file deleted"}) return c.JSON(http.StatusOK, map[string]string{"message": "file deleted"})
} }
// AdminDeleteEpisodeFilesBulk POST /api/podcasts/feeds/:id/episodes/delete-files —
// admin-only (§0 item 8): multi-select file deletion. Body carries the
// CHECKED boxes' values (form-encoded, repeated episode-file-select
// keys — hx-include gathers them; parsed directly, never bound: the
// repeated-key wire is another echo-bind gotcha). Each id is validated
// to belong to THIS feed; rows reset to pending, files removed.
func (h *PodcastHandler) AdminDeleteEpisodeFilesBulk(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
}
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
if err := c.Request().ParseForm(); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
rawIDs := c.Request().PostForm["episode-file-select"]
if len(rawIDs) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no episodes selected"})
}
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
deleted := 0
for _, raw := range rawIDs {
episodeID, perr := parseUUID(raw)
if perr != nil {
continue
}
// The checkbox list only renders this feed's rows, but the wire
// is user-controlled: a forged id for a foreign feed's episode
// is refused (feed-match guard), not silently applied.
ep, gerr := h.db.GetPodcastEpisode(actx, episodeID)
if gerr != nil || ep.SourceFeedID != feedID {
continue
}
if derr := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); derr == nil {
deleted++
}
}
log.Printf("[PODCAST] admin: bulk-deleted files for %d/%d episode(s) of feed %s (user %s)",
deleted, len(rawIDs), feedID, user.Username)
if isHXRequest(c) {
return h.hxEpisodeList(c, feedID.String(), false, false)
}
return c.JSON(http.StatusOK, map[string]interface{}{"deleted": deleted})
}
// flipSubscriptionToAll implements issue #3: Download All (button or // flipSubscriptionToAll implements issue #3: Download All (button or
// add-feed checkbox) sets the CALLER's subscription policy to 'all' — // add-feed checkbox) sets the CALLER's subscription policy to 'all' —
// per-user, never global (files are shared D6, policies are not). The // per-user, never global (files are shared D6, policies are not). The
+1
View File
@@ -78,5 +78,6 @@ func registerPodcastRoutes(cfg *Config) {
// Admin maintenance (§0 item 8): file deletion frees the disk and // Admin maintenance (§0 item 8): file deletion frees the disk and
// resets rows to pending. Handler checks admin role. // resets rows to pending. Handler checks admin role.
feeds.POST("/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteFeedFiles)) feeds.POST("/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteFeedFiles))
feeds.POST("/episodes/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFilesBulk))
feeds.POST("/episodes/:eid/delete-file", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFile)) feeds.POST("/episodes/:eid/delete-file", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFile))
} }
+48 -3
View File
@@ -240,6 +240,31 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
Hide played/downloaded Hide played/downloaded
</label> </label>
<span class="text-xs hidden" x-ref="filterEmptyNote" style="color: var(--text-secondary)">No episodes match the filter.</span> <span class="text-xs hidden" x-ref="filterEmptyNote" style="color: var(--text-secondary)">No episodes match the filter.</span>
<!-- Admin bulk file deletion (§0 item 8 follow-up): the
transport for the per-row checkboxes inside the episode
list. Lives OUTSIDE #episode-list so the button survives
the list's htmx swaps; hx-include gathers the CHECKED
boxes into the post body. State (count/ids) lives in the
podcastDetail component — CSP-safe method bindings only. -->
if data.IsAdmin && data.Feed.DownloadedN > 0 {
<div class="ml-auto flex items-center gap-2">
<span class="text-xs tabular-nums" style="color: var(--text-secondary)" x-text="bulkSelectionLabel()"></span>
<button
id="episode-bulk-delete"
type="button"
class="btn btn-secondary text-xs"
:class="bulkSelectionClass()"
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/episodes/delete-files" }
hx-include="input[name='episode-file-select']:checked"
hx-swap="none"
hx-confirm="Delete the selected episode files? Rows reset to pending (a feed refresh will not re-materialize them); episodes re-download on demand."
style="color: var(--status-warning, #d97706);"
>
Delete selected files
</button>
</div>
}
</div> </div>
<!-- Episode list (self-refreshing htmx partial). Pick the <!-- Episode list (self-refreshing htmx partial). Pick the
@@ -277,7 +302,7 @@ templ PodcastEpisodeList(data PodcastDetailData) {
} else { } else {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
for _, ep := range data.Episodes { for _, ep := range data.Episodes {
@PodcastEpisodeRowView(ep) @PodcastEpisodeRowView(ep, data.IsAdmin)
} }
</div> </div>
} }
@@ -297,13 +322,13 @@ templ PodcastEpisodeListPolling(data PodcastDetailData) {
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2> <h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
for _, ep := range data.Episodes { for _, ep := range data.Episodes {
@PodcastEpisodeRowView(ep) @PodcastEpisodeRowView(ep, data.IsAdmin)
} }
</div> </div>
</div> </div>
} }
templ PodcastEpisodeRowView(ep PodcastEpisodeRow) { templ PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) {
<div <div
class="card p-3 flex items-center gap-3" class="card p-3 flex items-center gap-3"
data-episode-row="true" data-episode-row="true"
@@ -311,6 +336,26 @@ templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
data-listen-pct={ podcastPct(ep.ListenPct) } data-listen-pct={ podcastPct(ep.ListenPct) }
data-search-text={ strings.ToLower(ep.Title + " " + ep.Description) } data-search-text={ strings.ToLower(ep.Title + " " + ep.Description) }
> >
<!-- Admin bulk-select (§0 item 8 follow-up): multi-select
downloaded episodes for file deletion. Rendered ONLY for
admins + DOWNLOADED rows — a checkbox on a file-less episode
would be an invitation to confusion. State survives the
list's htmx swaps via the podcastDetail component
(reapplied on afterSwap). -->
if isAdmin {
if ep.Downloaded {
<input
type="checkbox"
name="episode-file-select"
value={ ep.ID }
class="cursor-pointer shrink-0"
aria-label={ "Select " + ep.Title + " for file deletion" }
@change="toggleBulkSelection($event.target)"
/>
} else {
<span class="w-4 shrink-0"></span>
}
}
<span class="text-xs font-mono w-10 shrink-0 text-right" style="color: var(--text-secondary)"> <span class="text-xs font-mono w-10 shrink-0 text-right" style="color: var(--text-secondary)">
{ podcastInt(ep.Position) } { podcastInt(ep.Position) }
</span> </span>
File diff suppressed because it is too large. Load diff
+69 -2
View File
@@ -24,6 +24,63 @@ Alpine.data("podcastDetail", () => ({
unsubOpen: false, unsubOpen: false,
filterListenerInstalled: false, filterListenerInstalled: false,
// Admin bulk file selection (§0 item 8 follow-up): ids of CHECKED
// episode rows. Survives the list's htmx swaps (rows re-render fresh
// and unchecked → reapplied on afterSwap); cleared after a successful
// bulk delete. Plain array — deliberately NOT reactive state beyond
// bulkCount, which the toolbar binds.
bulkIds: [] as string[],
bulkCount: 0,
bulkSelectionLabel(): string {
return this.bulkCount === 0 ? "" : this.bulkCount + " selected";
},
bulkSelectionClass(): string {
return this.bulkCount === 0 ? "opacity-40 pointer-events-none" : "";
},
toggleBulkSelection(target: HTMLInputElement): void {
const id = target.value;
if (!id) return;
if (target.checked) {
if (!this.bulkIds.includes(id)) this.bulkIds.push(id);
} else {
this.bulkIds = this.bulkIds.filter((v) => v !== id);
}
this.bulkCount = this.bulkIds.length;
},
// After each swap the list re-renders unchecked; restore ticks for
// ids still selected. Ids whose rows vanished (deleted / state
// flipped) silently drop out.
reapplyBulkSelection(): void {
if (this.bulkIds.length === 0) return;
document
.querySelectorAll<HTMLInputElement>("input[name='episode-file-select']")
.forEach((box) => {
box.checked = this.bulkIds.includes(box.value);
});
// Drop ids whose checkboxes no longer exist.
const present = new Set(
Array.from(
document.querySelectorAll<HTMLInputElement>("input[name='episode-file-select']"),
).map((b) => b.value),
);
this.bulkIds = this.bulkIds.filter((id) => present.has(id));
this.bulkCount = this.bulkIds.length;
},
clearBulkSelection(): void {
this.bulkIds = [];
this.bulkCount = 0;
document
.querySelectorAll<HTMLInputElement>("input[name='episode-file-select']:checked")
.forEach((box) => {
box.checked = false;
});
},
// Episode-list filters (§0 item 6, 2026-10-30): hide played/ // Episode-list filters (§0 item 6, 2026-10-30): hide played/
// downloaded rows and/or substring-filter titles+descriptions. Pure // downloaded rows and/or substring-filter titles+descriptions. Pure
// presentation — the DOM IS the data (rows carry data-* attributes // presentation — the DOM IS the data (rows carry data-* attributes
@@ -53,14 +110,24 @@ Alpine.data("podcastDetail", () => ({
}, },
// Re-apply after every htmx swap of the list (polling + action // Re-apply after every htmx swap of the list (polling + action
// responses swap #episode-list wholesale; fresh rows must honor the // responses swap #episode-list wholesale): fresh rows must honor the
// active filter). Installed once per page. // active filter AND restored bulk-selection ticks; a successful bulk
// delete clears the selection. Installed once per page.
initPodcastFilterPersistence(): void { initPodcastFilterPersistence(): void {
if (this.filterListenerInstalled) return; if (this.filterListenerInstalled) return;
this.filterListenerInstalled = true; this.filterListenerInstalled = true;
document.addEventListener("htmx:afterSwap", () => { document.addEventListener("htmx:afterSwap", () => {
this.reapplyBulkSelection();
this.applyEpisodeFilter(); this.applyEpisodeFilter();
}); });
// Successful bulk delete → the list swaps fresh; drop the state.
document.addEventListener("htmx:afterRequest", (evt) => {
const custom = evt as CustomEvent<{ successful: boolean; elt: HTMLElement }>;
const elt = custom.detail.elt as HTMLElement | undefined;
if (custom.detail.successful && elt && elt.id === "episode-bulk-delete") {
this.clearBulkSelection();
}
});
}, },
})); }));