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:
1 parent
0741e98ea5
commit
7d5bbd1177
6 files changed
+511
-240
No files matched your search
@@ -1399,6 +1399,46 @@ func TestAdminDeleteEpisodeFiles(t *testing.T) {
|
||||
assert.Equal(t, "pending", after2.DownloadState)
|
||||
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):
|
||||
// the poll upsert rewrites metadata but never touches download_state.
|
||||
code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "")
|
||||
|
||||
@@ -120,6 +120,58 @@ func (h *PodcastHandler) AdminDeleteEpisodeFile(c *echo.Context) error {
|
||||
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
|
||||
// add-feed checkbox) sets the CALLER's subscription policy to 'all' —
|
||||
// per-user, never global (files are shared D6, policies are not). The
|
||||
|
||||
@@ -78,5 +78,6 @@ func registerPodcastRoutes(cfg *Config) {
|
||||
// Admin maintenance (§0 item 8): file deletion frees the disk and
|
||||
// resets rows to pending. Handler checks admin role.
|
||||
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))
|
||||
}
|
||||
@@ -240,6 +240,31 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
Hide played/downloaded
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<!-- Episode list (self-refreshing htmx partial). Pick the
|
||||
@@ -277,7 +302,7 @@ templ PodcastEpisodeList(data PodcastDetailData) {
|
||||
} else {
|
||||
<div class="flex flex-col gap-2">
|
||||
for _, ep := range data.Episodes {
|
||||
@PodcastEpisodeRowView(ep)
|
||||
@PodcastEpisodeRowView(ep, data.IsAdmin)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -297,13 +322,13 @@ templ PodcastEpisodeListPolling(data PodcastDetailData) {
|
||||
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
|
||||
<div class="flex flex-col gap-2">
|
||||
for _, ep := range data.Episodes {
|
||||
@PodcastEpisodeRowView(ep)
|
||||
@PodcastEpisodeRowView(ep, data.IsAdmin)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
|
||||
templ PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) {
|
||||
<div
|
||||
class="card p-3 flex items-center gap-3"
|
||||
data-episode-row="true"
|
||||
@@ -311,6 +336,26 @@ templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
|
||||
data-listen-pct={ podcastPct(ep.ListenPct) }
|
||||
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)">
|
||||
{ podcastInt(ep.Position) }
|
||||
</span>
|
||||
|
||||
+301
-235
File diff suppressed because it is too large.
Load diff
+69
-2
@@ -24,6 +24,63 @@ Alpine.data("podcastDetail", () => ({
|
||||
unsubOpen: 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/
|
||||
// downloaded rows and/or substring-filter titles+descriptions. Pure
|
||||
// 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
|
||||
// responses swap #episode-list wholesale; fresh rows must honor the
|
||||
// active filter). Installed once per page.
|
||||
// responses swap #episode-list wholesale): fresh rows must honor the
|
||||
// active filter AND restored bulk-selection ticks; a successful bulk
|
||||
// delete clears the selection. Installed once per page.
|
||||
initPodcastFilterPersistence(): void {
|
||||
if (this.filterListenerInstalled) return;
|
||||
this.filterListenerInstalled = true;
|
||||
document.addEventListener("htmx:afterSwap", () => {
|
||||
this.reapplyBulkSelection();
|
||||
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();
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user