Commit Graph
9 Commits
Author SHA1 Message Date
John O'Keefe 7d5bbd1177 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.
2026-10-30 14:06:54 -04:00
John O'Keefe 0741e98ea5 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.
2026-10-30 13:54:37 -04:00
John O'Keefe 5c545534c2 feat(podcasts): Download All queues in the LISTENING order + add-modal order dropdown
Download All previously queued in feed-native order — 'getting the
last chapter first doesn't help me'. Now the queue follows the feed's
effective listening order (§0 item 5b): QueueFeedDownloadsAs reads the
CALLER's order override and runs episodes through services.OrderEpisodes
(same helper as the sequence/reader/page surfaces — the consolidation
from item 5a landed with the reverse-order commit). Queue tickets
(SEQUENCE-stamped, item 4) make that enqueue order the claim order, so
chapters land in the order you'll actually listen.

Add modal (§0 item 5c): with 'Download all episodes now' checked, a
'Download order' select appears (pure-CSS :checked sibling reveal — no
JS island, CSP-clean). Its choice is the feed's INITIAL order override
(reader_settings upsert; 'feed' = no row) AND the queue order for the
initial batch. Per-book by design: the override IS the download order.

Test TestPodcastAddModalDownloadOrder: add with download_all+reverse →
the override row exists AND persisted tickets run position 3→1. Tickets
are asserted (not live queued rows) because the shared dev server's
worker legitimately races this test's queue — tickets survive every
state flip.
2026-10-30 10:26:00 -04:00
John O'Keefe e3675536aa feat(podcasts): reverse order — position DESC, date-agnostic; single order helper
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.
2026-10-30 09:15:03 -04:00
John O'Keefe 7c7c08748e fix(podcasts): download_all binds on every wire — FlexBool + flip log
Reproduced the §0 item 2 suspect: echo v5 bool-binds form values via
strconv.ParseBool, which accepts "true"/"1" but REJECTS the
browser-native <input type=checkbox> value "on" — and a bind failure
kills the WHOLE AddFeed request (400 "invalid request"), not just
the checkbox. The add modal posts urlencoded (htmx), so any
checkbox-serialization that emits "on" made Download All silently
unusable.

Fix: FlexBool (encoding.TextUnmarshaler-style UnmarshalParam) binds
download_all permissively on every wire — "true"/"on"/"yes"/"checked"
=> true, explicit false/0/off => false, ABSENCE (unchecked box on any
wire) => false; JSON bool binding is native and unaffected. Tolerates
the exact wires htmx and browsers emit.

AddFeed's subscription flip now logs "[PODCAST] flip ... <feed> —
user" so the live flip is provable in app logs (user suspected it
never fired).

Integration test TestPodcastDownloadAllFormEncoding pins the urlencoded
wire (what htmx actually sends): true + on both flip the caller's
policy to 'all', an unchecked add leaves the default.
2026-10-30 09:06:41 -04:00
John O'Keefe 30766020c8 feat(podcasts): real-time episode list — byte progress bars, direct swaps (issues #1/#3)
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.
2026-10-29 20:12:19 -04:00
John O'Keefe c753780817 fix(podcasts): order selector validation — bind the htmx form wire format (issue #2)
WHY: htmx sends non-GET requests urlencoded by default; the SetFeedOrder
request struct only carried a json tag, so the binder left Order empty
and 'required' fired — the exact error the order dropdown AND the
Serial-suggestion button produced (hx-vals JSON also arrives
form-encoded). One form tag fixes both controls.

Regression test drives the exact wire format: urlencoded PUT with
order=oldest/native → 200, empty/invalid → 400.
2026-10-29 19:45:46 -04:00
John O'Keefe 2235b00c59 feat(podcasts): web UI — /podcasts page, feed detail, download lifecycle (task 1.9)
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.
2026-10-29 16:17:55 -04:00
John O'Keefe 45b1152a15 feat(podcasts): API endpoints — add/list/detail, subscriptions, downloads, sequence, search (task 1.7)
The §8 surface, wired through main.go → router.Config →
/api/podcasts (JWT). Scoping is the engine's (D20): lookups outside the
caller's scope simply return no rows → 404; the only Go-level checks
are explicit-argument authorization (AddFeed's library +
allow_member_feeds gate) and the admin role on feed deletion.

Endpoints: POST/GET /feeds (add: fetch+parse+upsert+subscribe, optional
download_all; list: subscriptions + episode counts + facet values
computed in Go — feed counts are tens), GET /feeds/:id (detail with
download states + gap count), PUT/DELETE /subscription (the ONLY
user-writable podcast state, D9; unsubscribe retains files per D6),
POST /refresh, POST /episodes/:eid/download (+ episode_ids[] bulk and
eid='all' = Download All, exactly the non-downloaded set),
PUT /order (D5 per-user override in the reader_settings KV —
podcast_order:<feed_id>; 'native' deletes the row so default and
explicit choice are indistinguishable), GET /sequence (effective order
— override applied, native fallback — DOWNLOADED-only with the gap
list; this payload powers web chain mode and, per §15, the future
media3 queue), GET /search (subscription-scoped feed + episode search
covering un-downloaded and historical rows, D14), DELETE /feeds/:id
(admin; best-effort file removal, cascade deletes rows).

Gap definition (countGaps): an un-downloaded episode with a downloaded
episode AFTER it in list order — the D8 'middle episodes missing'
banner condition.
2026-10-29 16:03:35 -04:00