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.
1. Add-modal 'Download order' never revealed: the CSS sibling selector
(#podcast-download-all:checked ~ .podcast-add-order) can't fire —
the checkbox was nested inside a wrapper div, so the select row was
no SIBLING of the input. Restructured: input, label, and order row
are now siblings under one flex-wrap wrapper.
2. Order + auto-download dropdowns always displayed the LAST option
('Newest first'/'Newest') regardless of the saved value: templ's
selected={ boolExpr } compiles to selected="false" — and the mere
PRESENCE of the attribute selects the option, so every option was
selected and the browser honored the last. Sweep to the boolean-
attribute form selected?={...} (omits on false) across
podcast_detail (14), book_detail_modals (10: manga type/reading
direction selects + abridged/explicit checkboxes — same defect,
always-checked), collection_modal (2: hide-finished/show-up-next
always-checked). Values were SAVING correctly all along; this was
pure display. audio_reader/collections :selected/:checked are
Alpine bindings — unaffected.
3. Remove Feed (and every hx-confirm) still showed the browser dialog
for signed-in users: the global confirm modal rendered at the end
of SidebarSignIn — signed-IN pages have no SidebarSignIn, so no
#confirm-modal existed and confirmDialog() degraded to
window.confirm. Moved the modal into Header proper (rendered on
every page, after </aside>).
4. Facet bar at scale: values now carry feed COUNTS, sort by frequency
DESC then alphabetically, cap at 6 chips per group flat, and the
rest collapse into a native <details> '+N more' expander — hundreds
of hosts/tags degrade gracefully instead of a wall of chips.
Verified live on the dev instance (order select reflects the saved
override, override row persists, reveal fires, modal renders on
signed-in pages). Podcast + logout + media-pin suites green; vitest
167 green.
Pocket Casts steal pulled forward from Phase 4 (§0 item 6): the episode
list gains a client-side 'Filter episodes…' box and a 'Hide
played/downloaded' toggle. Pure presentation — rows carry templ-rendered
data-* attributes (downloaded, listen-pct, search text); the filter
toggles DOM visibility with the 99.5% finishedThreshold. The toolbar
lives OUTSIDE #episode-list so its state survives the list's polling
swaps, and an htmx:afterSwap listener re-applies the active filter to
freshly swapped rows. No query change, no server round trip, no new JS
rendering surface (Alpine component method, CSP-safe).
The 'Oldest first' order sorts by PUB DATE, which breaks on Scribl's
migrated podiobooks feeds (messy/all-equal dates). New order value
'reverse' = feed position DESC with a date-DESC tiebreak — dates are
not involved, so nothing can break.
- services.OrderEpisodes: THE single order source of truth (§0 items
3+5a). The four surfaces (sequence JSON, reader chain context, SSR
page list) previously carried three near-identical handwritten
comparators — all now route through one generic helper. The SSR page
list gains a SortDate key (lexical 2006-01-02) so the pub-date
tiebreak is actually chronological; the old comparator compared
'Jan 2, 2006' display strings alphabetically (latent drift bug).
- Dropdown becomes four options: Feed order / Feed order (reversed) /
Oldest first (pub date) / Newest first (pub date).
- Serial suggestion button renamed 'Reverse feed order' (sets reverse,
not oldest) and its response out-of-band-swaps the order select
(PodcastOrderSelectOOB — same mechanism as Download All's policy
select), so the dropdown mirrors the server state immediately.
- Validation: oneof=native reverse oldest newest everywhere.
- Tests: reverse binds via the form wire; messy-dates sequence
returns 3,2,1; existing reorder-safety stays green.
A fresh detail-page load while downloads were active rendered the
NON-polling episode container (PollingEpisodeList was only reachable
via the /episodes partial route after Refresh), so the page froze
until the user refreshed manually. PodcastDetailPage now picks the
variant the same way the route does — HasActiveDownloads selects the
every-2s poll container.
Also: the indeterminate progress bar (feeds without Content-Length,
e.g. Scribl) used animate-pulse, which fades in place and reads as
frozen; replaced with a sliding shimmer keyframe
(.podcast-progress-indeterminate, 33% sliver, translating -100% ->
300%) in input.css. The live MB counter stays.
§0 item 1.
WHY: downloads needed manual refreshes to see progress; the 204 +
HX-Trigger event indirection missed its listener, and the worker
buffered whole enclosures so no progress existed to show.
- Worker streams enclosures to disk through a counting loop (64KiB
chunks, scheme/redirect/size guards preserved) with an in-memory
per-episode progress snapshot (received/total; total from
Content-Length, seeded by the feed's declared length). Deliberately
transient — the DB row state remains the durable truth.
- Episode-list renderer seam: the router injects a closure into
PodcastHandler (handlers can't import templates — MediaDetail cycle)
so htmx download/retry/Download All/refresh/order/policy actions
answer with the REFRESHED partial directly (200, hx-target=
#episode-list) — no events to miss. Non-HX callers keep JSON; tests
without a renderer keep 204+trigger.
- Polling tightens to 2s while downloads are active; downloading rows
render a percentage bar + MB counter (quote-free inline style per
templ gotcha #8) or an indeterminate bar when the server sends no
length.
- Issue #3: Download All (button AND the add-feed checkbox) flips the
CALLER's subscription policy to 'all' — per-user, never global (D6:
files shared, policies not) — and the auto-download select
out-of-band-swaps so the dropdown shows what the user is getting.
They can turn it down afterwards; the flip is honest communication,
not coercion (user-approved 2026-10-29).
PINs: Download All with HX answers 200 + episode-list + oob select and
flips newest → all; add-feed download_all sets policy all.
D7 chain-by-default, delivered with minimal player surgery:
- ReaderMetadata gains PodcastSequence: the neighboring DOWNLOADED
episodes in the caller's effective order (D5 override applied,
feed-native fallback; computed in the reader route for
source='podcast_episode' items — RLS already scoped the reads; gaps
never enter the chain, invariant 6). ?single=1 escapes chaining (the
'play single episode' affordance).
- Audio reader renders the sequence into body[data-json]
(AudioReaderConfigJSON — books produce the identical shape minus the
sequence key, so the reader path is unchanged for them) and an
'Ep N/M' indicator + prev/next episode controls in the header.
- audio.ts: the last-track 'ended' handler chains into the next
episode's reader page (final progress push precedes navigation);
back button exits episodes to their feed page. Books are untouched —
no sequence context, zero behavior change. Day-one chaining is
page-navigation based (progress persists; position restores).
Continue aggregation (2.5): GetPodcastContinueForUser (in-progress
episodes, most-recent-first, ≥95% excluded) feeds a Continue section
on /podcasts — sorted most recently listened, cards show 'Ep N · title'
— ahead of All Feeds. Cards prefer the continue line over the
description.
Rendering ownership holds throughout: every piece of server data is
templ-rendered; htmx swaps partials; the only TS is the Add-Feed modal
toggle + error surfacing (pure client-local state, bundled via
web/src/podcasts.ts with automatic bundle-hash stamping).
Sidebar: /podcasts nav entry (D13, between Series and Collections),
gated by a ShowPodcastsNav flag resolved centrally in
getTemplateUserWithTheme (one indexed COUNT — same class of
per-request lookup as the live-user guard). New 'podcast' icon.
/podcasts: header + Add Feed modal (htmx post → HX-Redirect to the
feed page; inline error surfacing on failure), subscription-scoped
search + facet bar (host/tag/language — server-side GET filters, no
JS), grid cards with download counts / new-episodes / complete /
explicit badges. Empty states for no-library (admin creates it first)
and no-subscriptions.
Feed detail: hero (artwork, host/publisher, tags, fetch state), action
row — Download All, Refresh, ORDER SELECTOR (D5 override), serial
one-click oldest-first suggestion (never silent), auto-download policy
select (D6), Unsubscribe (files retained), admin Remove Feed — gap
banner (D8 count), and the EPISODE LIST with the full lifecycle inline
(D8/D19): pending greyed + Download button (every non-downloaded
episode has one), queued/downloading pulse, failed RED + reason text +
Retry (same endpoint). The list is a self-refreshing partial: re-fetch
on the podcast:changed HX-Trigger and polls every 4s ONLY while the
server says downloads are active (it renders the polling variant).
API endpoints grew HX awareness: 204 + HX-Trigger podcast:changed (or
HX-Redirect for add/unsubscribe/remove) — the sanctioned Go-handler
response channels; JSON callers keep JSON. AddFeed result exposes the
fetch-error text for the redirect.
Dates render in the requesting user's timezone (D17) via
utils.FormatTimeInTimezone; episodes play through the existing
/readers/{id} audio reader (episodes ARE audiobook items — no new
player code day one). CSP tripwire (templates/csp_source_test.go)
passes: no inline scripts, no on*= handlers, no hx-on/js: vals.