The first real-browser create of an Episodes-scope collection failed
with "invalid request": the modal's hx-post submits
application/x-www-form-urlencoded, and the form value for filter_tree
had to bind into CreateCollectionRequest.FilterTree (any) — echo's
form binder errors on any-typed fields instead of setting the string,
so every form carrying a tree 400'd before validation even ran. JSON
bodies (the save-filter bridge, the editor's Apply) were unaffected,
which is why no API-level or Go test caught it: only a browser posting
the real form shape exercised the transport.
Fix: the form variant binds a dedicated string field
(FilterTreeForm, form:"filter_tree") on both request structs while
FilterTree keeps the JSON tag; resolveTreePayload picks whichever
transport arrived and both ride the same parseRequestFilterTree
validation. Pinned by TestEpisodeCollectionsFormSeedCreate (form-encoded
seed → 201 → scope round-trips).
"Newest episodes from my selected podcasts" is now buildable in the UI,
end to end — the user could not find ANY podcast-related filter surface
because none existed: the New Collection modal could only create
book-scope smart collections, and the engine's episodes scope (item 3.1)
had no authoring door.
The scope door:
- The create modal's Smart card gains a Rules-scope segmented control
(📚 Books / 🎧 Podcast episodes); the copy no longer claims smart
means "match books by rules". Choosing Episodes seeds an EMPTY
episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the
collection is born episode-scoped: the editor opens with the episode
vocabulary (feed/downloaded/played/published date) and membership
runs the episode engine from the first rule.
- ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay
invalid) — the seed's legal shape. The root arrives at depth 1 by
call convention, hence the depth>1 exemption.
- The composers define the empty-tree semantics: zero children matches
NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring
EvaluateSmartCollection's zero-enabled-rules convention. Without the
guard the composed query degrades to the bare scope head and a fresh
collection would swallow every episode in scope — pinned by
TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0)
and TestEmptyRootTreeSeedsEpisodesScope.
The curated chain order (plan §10 R2, user need: the briefing plays
NEWEST-first):
- view_settings.chain_order ("native"/"newest"/"oldest"/"reverse")
stored on the collection — curated content, not a player-side knob.
- PUT /api/collections/:id/view-settings merges the key into the
stored JSONB (a blind write would clobber the playlist toggles).
- buildSmartCollectionSequenceContext routes OrderEpisodes through
handlers.CollectionChainOrder; the detail header's Play-as-Sequence
gains the order select next to it (PlayCollectionButton), which
PUTs and reloads. Pinned by chain_follows_the_curated_order
(reverse flips the chain indexes and hops).
Caught by the new test, a latent clobber fixed: UpdateCollection
treated a NIL view_settings payload as "write NULL" — any edit that
carried no settings dropped every stored key (chain_order, playlist
toggles from API clients, imported provenance). Nil now means
untouched, the same convention filterTree already had.
Composer guards also cover ComposedCount, so the builder's live count
reads the same semantics. Tests: collectionChainOrder unit matrix,
empty-root parse unit pin, two integration pins (seed matches nothing;
curated order drives the chain and survives whole-map edits).
Every one of these was silent at the code level and loud in a real
browser; each fix carries its own tripwire (render pin, unit pin, or
CSP-safe rewrite) so the class cannot quietly return.
1. Theme picker dead since the CSP migration (eb57b720): the Appearance
panel's theme buttons emitted `@click="selectTheme(opt.Name)"` as
LITERAL text (templ gotcha #1 — quoted attribute values are never
interpolated; `opt` is a Go loop var), so Alpine threw "Undefined
variable: opt" on every click and no PUT /api/auth/theme ever fired.
The wood-paneling sibling always used the correct expression form,
which is why the panel half-worked. Fixed to the interpolated form;
TestThemePickerHandlersAreInterpolated pins that real theme names
reach the HTML and the raw identifier never does.
2. Filter-builder op dropdown "does nothing": the field ingest
hardcoded `ops: []` into every field's meta while only mirroring the
ops into the global label registry — opsFor() returned [] for EVERY
field, so the op menu rendered zero items: an invisible menu, no
console error (user tried author/publisher/title/subtitle, all
equally dead). FieldMeta.ops is now the wire's op objects, opsFor()
is a standalone exported helper, and opsForField unit tests pin the
round-trip. Verified live: the field menu opens/closes while the op
menu stayed shut, both with identical wiring — the difference was
the data, not the wiring.
3. AND/OR connective chip dead: the chip carried an `if` STATEMENT in
the Alpine attribute (`@click="if (row.chipClickable) ..."`) which
the CSP build's evaluator rejects as a statement — the whole
expression silently never ran. Moved the clickability check into a
toggleConnective(row) component method; the attribute is now a plain
method call.
4. Pencil vs card navigation race: navigateToCollection guarded with
`event.target instanceof HTMLElement`, which is FALSE for the
pencil glyph's SVGElement — a click landing on the glyph sailed past
the guard and navigated while the htmx edit-modal request still
fired (the reported "modal flashes, then the builder page"). The
guard now uses target.closest("button") (works on SVGElement) and
takes $event explicitly instead of the deprecated window.event.
.icon-btn is already a 36px square, so no hit-area change needed.
5. Add Items modal under the sidebar + taller than the window: the
picker overlay sat at z-50 UNDER the app-sidebar's z-60 (its left
edge rendered beneath the nav), and the card had no height bound —
header + tabs + filters + grid + footer overflow short windows with
the footer unreachable (fixed overlay, no page scroll). The picker
and create-modal overlays move to z-[65], both confirm dialogs to
z-[70], and the picker card is now a flex column capped at
max-h-[85vh] whose grids flex-fill (they grow on tall windows,
shrink on short ones). The ladder is documented at .app-sidebar in
input.css: sidebar 60 / overlays 65 / confirm 70 / toasts 9999.
Found by clicking every control the way a user does (panel clicks +
computed-style reads + screenshots) after the previous sweep trusted
DOM-text snapshots, which show hidden menus' text and cannot see
occlusion, stacking, or geometry at all.
Marks the sweep-candidate list as executed (every knob driven in a real
browser), preserves the four defect classes the sweep fixed so the next
agent does not re-learn them the hard way, and pins the panel-vs-
Playwright limitation (value assignment without change events) that any
future automated spec of the htmx-triggered controls must work around.
The real-browser sweep (the gotcha classes' native habitat — CSP refusals
fail SILENTLY at build/test time) found four defects, all in the item
3.3/3.4 surfaces:
- Shared builder rows used Alpine attribute expressions that the CSP
parser rejects outright: backtick template literals in :style, the
?. optional chain in :placeholder/x-if, and an if-STATEMENT in a
@click.outside. Rewritten as string concat plus two component methods
(fieldIsDays, dismissSuggestions). Until this fix every drawer open
threw a wall of "CSP Parser Error" console noise and the days-value
affordance never rendered.
- "2 bookss match": the count noun is interpolated into "<n> <noun>s
match", so callers must pass the SINGULAR ("book"/"item").
- The rules editor page received its fields/tree payloads from the
route as already-JSON-encoded strings and wrapped them in JSONArg
AGAIN — double-encoded, so JSON.parse yields a string and the editor
loaded with NO vocabulary and NO prefill. The route now emits raw
JSON into the data attributes (single encode).
- Apply was silently inert: the hook store round-trip used
window.Alpine?.store (optional-chained, no-op when absent) and the
Cancel/Apply block sat outside the builder island scope for part of
the session. Both fixed via the imported Alpine instance and moving
the buttons inside the island; Apply now PUTs the tree end-to-end
(verified live: prefill → edit → apply → 201 → reload → membership).
Browser-verified clean (no fixes needed): picker scope tabs + episode
grid (?scope=episodes door), selection store across swaps, Add Selected
episode membership, library-switcher partial contract (episodes visible
under All Libraries, correctly filtered out under a book library — and
now never OFFERED as a switcher option), episode cards + reorder arrows
persisting position, collection-chain player (Ep N/M, prev/next carry
?queue=collection:, order control absent in collection mode and present
+ persistent in feed mode both in player and feed page), and the
empty-tree Apply guard (400 — a static collection can never be
promoted by accident).
Notes for the next reader of these lines: the OpenChamber panel's
browser.select sets select.value without dispatching change, so htmx
change-triggers must be exercised by real events (Playwright) rather
than the panel; and one 500 was observed on the player's leave-page
progress PUT — a request whose context the navigating browser cancels
mid-write, pre-existing and benign (the immediately following save
returned 200).
The D13 podcast exclusion (9a148c23) was written as a templ
`if !IsBookLibrary(...) { continue }` — but templ markup control flow
has NO continue STATEMENT: codegen emitted the word as a TEXT node
(visible junk between options on every page's library subbar) and
rendered the podcasts option anyway, defeating the exclusion. The real
browser sweep caught what every Go/API test structurally cannot:
generated markup that renders wrong yet compiles clean.
Rewritten as an inverted guard (the idiomatic templ skip). New render
pin TestLibrarySwitcherExcludesPodcasts fails on both halves of the
original leak — the literal text AND the presence of the podcasts
option — so this codegen gotcha class stays tripped-wired.
Findings from the systematic gotcha-class review before the browser
pass:
- The collection smart editor's Apply derived the collection NAME from
document.title — which parses to "Smart Collection", RENAMING the
collection on every apply. The server now renders the real name
(data-collection-name) and the write path refuses to proceed without
it.
- TestRenderingPartialsRender pinned the pre-episode-collections
empty-state copy ("No books in this collection yet.") — updated to
the shipped label ("Nothing in this collection yet."; collections
hold episodes now).
- The deleted handler-local rule comparators (evaluateRule/
compareValues — removed in item 3.4's single-evaluator refactor)
left behind two unit-test files testing the duplicates; deleted.
Their semantics remain covered at the service level
(TestEvaluateRules_* over FieldMatches/EvaluateRules) and
TestTextToString (which actually pins utils.PgTextString) stays.
Everything else reviewed clean against the gotcha classes: templ
whole-expression attrs, ?={ boolean attrs, JSONArg quoting in
x-init/data attrs, Alpine CSP store paths, htmx form-encoding, the
" assert escaping, and the aria-label selector contract on the
player's reload listener.
The bookshelf's save modal grows a second action: "Save as Collection"
POSTs the current (completed, scope-stamped) tree to
/api/collections — the same validated write path every tree
collection uses — and lands you on the new collection's page. The qf
engine then drives it everywhere: the shelf's live shelf-count, the
collections page, the replay chain for episode trees. The legacy
"Save Filter" (bookmark the wire params) stays as the sibling action.
This is the plan's bridge: a book search graduated into a live shelf
with one click, authored in the ONE filter system.
The smart-collection editor at /collections/:id/rules now hosts the
SAME builder island as All-Books (FilterBuilderRows — the rows markup
extracted into one shared templ both mounts render; the shared
filterBuilderExtra component). One vocabulary, one engine, one row UI;
the collection differences are exactly three: the payload is the
collection's scope (episode trees get the episode fields — books
trees hide them), Apply writes the tree to the collection (PUT
filter_tree through the validated write path), and a legacy rules
array preloads as its CONVERTED tree (RulesToFilterTree rendered
server-side) so the upgrade lands at the first Apply — read-compat
never mutates data silently.
Retired with the page: the per-rule CRUD endpoints and test-rules
(their handler bodies, routes, api-docs rows, and the dead collections.ts
island functions — the file's LIVE halves, the collection modal/navigate/
grid-refresh, stay; the dead rules half went). match-mode endpoint and
preview survive (legacy channel + the dashboard custom-section-builder,
flagged for its own migration). Contract-pin changelog documents the
deletions per the asymmetry rule.
The builder component gains collection mounts: scopeKey (the row
universe for counts/apply), data-tree prefill, treeJSON (the apply
wire with scope riding the top level), and an apply hook (the page
owns the write; All-Books never sets one). ensureReadyRule defaults to
the scope's first field instead of hardcoding author.
Tests migrated onto trees: TestSmartCollections' live-membership /
reject-mutations / include-auto / system-collection subtests now drive
TREE collections; the deleted-endpoint subtests die with them.
"One filter system to maintain" now holds at the presentation layer
too: the field/op list the builder renders comes from
services.FilterFieldsPayload(scope) — the Go registry's wire face,
scope-filtered and group-ordered — rendered into the bookshelf mount
as a data attribute (templates.JSONArg), ingested once by the Alpine
component. The registry carries operator presentation (labels,
negation, arity) in FilterOpWire alongside the grammar; per-field
value-cell semantics (valueKind) derive from the existing Kind.
The hardcoded TS mirror (OPS, op(), F(), TEXT_OPS/NUM_OPS/DATE_OPS,
FIELD_GROUPS, its known drift surface) is deleted; filter_builder.ts
now ingests the wire (ingestFilterFields — malformed payloads leave
empty menus rather than client-invented rows), and the field menu's
episode fields are HIDDEN on the All-Books page for free (the books
scope payload excludes them — the plan's "hide podcasts from the
rules on the all books page" is now structural, not enforced by a
second hand-kept list).
Unit tests ingest a minimal wire fixture (the payload shape is the
contract, not the mirror); vitest 167 green, tsc + vp clean.
collections gains filter_tree JSONB (idempotent in-place; same qf DSL
as saved filters — the scope key included). IsCollectionSmart is the
ONE detection (tree OR legacy rules), and EvaluateSmartMembership is
the ONE evaluator: tree-bearing collections run the composed engine
via ComposedLiveMedia (full media rows, so the collection surfaces
keep their episode-card stamping, feed positions for the replay
chain, and the family lens rides the mandatory head), while legacy
rule arrays keep per-item FieldMatches until rewritten — one call
surface, two storage epochs. All smart surfaces (detail JSON, counts,
detail page, reader chain) dispatch through it; the search service is
injected into the collection subsystem (variadic — main.go builds one
shared instance; the test harness mirrors it).
Write path: CreateCollection/UpdateCollection accept the tree,
validated against the engine grammar at the HANDLER (an invalid tree
is a 400, never a stored render-time 500) and exposed in the envelope
(pin widened in TestContractPin_CollectionsJSON + changelog). Update
semantics: an edit form that doesn't send a tree keeps the stored one.
RulesToFilterTree converts legacy rules + match mode at upgrade time:
episode rules map mechanically (feed→in-list, played/downloaded→
booleans, published_within→published_date+within_days), tree root
mode absorbs rule_match_mode, episode rules force the episodes scope,
and legacy starts_with/ends_with fall back to contains (documented).
Read-compat by design: nothing is migrated at boot — legacy arrays
evaluate exactly as before until the collection is rewritten.
Pinned by TestRulesToFilterTree (unit conversion) +
TestEpisodeCollectionsFilterTree (a tree collection evaluates through
the engine, the chain follows the tree's live membership, invalid
trees 400 at write, the tree wins when legacy rules coexist, and an
edit without a tree keeps it).
The qf tree gains an optional top-level `scope` ("books" default |
"episodes" — absent = books, so every existing saved tree and legacy
client stays valid). The row head is scope-aware AND this closes a
real leak: the composed engine's head had NO podcasts exclusion, so
episodes already surfaced on All-Books qf queries (plan §9 violation —
the sqlc flat head excluded them, the composed head didn't). Books
scope now says library_type_name <> 'podcasts'; episodes scope is
source = 'podcast_episode'.
The registry fields declare their scopes (the plan's "reusable
template with conditionals" = scope-driven rendering in 3.3): episode
fields declare exactly the episodes scope and the books scope REJECTS
them at parse (the "hide podcasts from the rules on the all books
page" rule enforced server-side); books fields inside episodes trees
are allowed and honestly no-op on NULLs.
New fields (the item-2.4 rule vocabulary expressed in SQL):
- feed — KindIdentifier over podcast_feeds (RLS-governed suggestion
source and match; unsubscribed feeds match nothing, by the engine)
- downloaded — is_true/is_false on download_state = 'downloaded'
- played — THE canonical finished predicate (viewer progress >= 0.999,
the hide_finished definition shared with item 2.4), reading_progress
join per viewer
- published_date — clauseDateFactory like added_at, within_days
default op (the published_within rule's SQL form)
Engine hygiene: the stray ComposedSearch SQL debug printf is gone.
Pinned by TestFilterScopeValidation (unit: scope grammar, books×
episode-field 400s, legacy no-scope trees) and
TestFilterScopesAndEpisodeFields (integration: books head excludes
podcasts including the 404-no-results contract, episodes head is
episodes only, feed-in/downloaded/played/published-date evaluate in
SQL against a subscribed-feed fixture with a played viewer).
TestContractPin_CollectionsJSON tripped on the additive keys the
episode-collections work introduced — the tripwire worked exactly as
designed (additive keys must be whitelisted here AND in
docs/contract_pins.md first).
- Collections envelope gains rule_match_mode (item 2.4): the
smart-collection match semantics on GET /api/collections/:id.
- BookInfo gains four STABLE keys (item 2.3): is_episode,
feed_title, download_state, hidden — dropped omitempty so every
card surface emits the same key set (the viewer_percentage
precedent; adapters that predate episode awareness emit zero
values).
docs/contract_pins.md changelog entry written. Nothing removed,
nothing renamed.
ACCEPTANCE (plan §0 item 2.5): "Daily Fiction" = feed ∈ {A} ∧ unplayed
∧ <7d matches exactly today's episodes, and PLAY = today's episodes
back-to-back. Two pieces were missing after item 2.4:
- The chain builder only read static membership (collection_items) — a
smart collection has none. buildSmartCollectionSequenceContext now
evaluates the rules LIVE for the requesting user (mode from the
collection, family lens via the visible set) and chains the
downloaded episode matches in NATIVE feed order — the collection's
computed order is content (R1: never written back; OrderEpisodes is
the order chokepoint per R3). A match that aged out of the briefing
(played since, published beyond the window) falls back to the feed
queue instead of breaking the page.
- The smart-detail branch rendered match cards as books: episode
matches are now flagged (IsEpisode + download state) so they carry
the play affordance and the header's Play-as-Sequence button;
feed titles stay empty on smart cards for now (batch lookup is
polish, not acceptance).
Pinned by TestPodcastSmartCollections' acceptance subtests: the
briefing starts at index 1/2 and chains into the second with the
queue param on both hops; the order control stays hidden (computed
order); a stale match falls back to the feed queue honestly.
The query said `SET view_settings = $2, updated_at = NOW()` but the
collections table has never had an updated_at column — every call
failed with 42703. The one caller (media_scanner's series-import
provenance write) discards the error, so the failure was silent:
imported-from-series view settings never persisted. Surfaced while
writing the sibling UpdateCollectionMatchMode query for item 2.4 (the
item-2.4 commit message prematurely claimed this fix — this is that
fix, for real).
The daily briefing needs AND: today's rules engine was OR-only. The
match semantics become a per-collection column
(collections.rule_match_mode, 'any' legacy default | 'all'), flipped on
its own endpoint (PUT /api/collections/:id/match-mode, owner-only —
nothing else on the row changes) and surfaced in the rules page as an
ANY/ALL toggle with matching copy.
Episode rule fields join the vocabulary (plan item 2.4): feed (the
multi-select rides the new `in` operator — comma-separated feed ids),
played (the canonical finished predicate — viewer progress ≥ 0.999,
same as hide_finished), downloaded, published_within (N days), plus
title/description contains. The rules candidate query now carries the
viewer's progress (LEFT JOIN reading_progress) so played/unplayed is
per-user, and TestRules/preview speak the SAME evaluator: the handler's
duplicated field switch is deleted and delegates to
CollectionService.FieldMatches — one vocabulary, one semantics.
Legitimate semantics note: in ANY mode `played:false` matches unplayed
BOOKS too (everything with no progress row) — correct per-rule
behavior, and exactly why the briefing mode exists.
Backward compatibility: every legacy row defaults to 'any' (the old
OR), UpdateCollection keeps the stored mode when the form doesn't send
one, and the evaluator treats absent data honestly (no date → no
match; no progress → unplayed).
Pinned by TestPodcastSmartCollections: the AND-mode briefing
(feed∈{A} ∧ unplayed ∧ <7d = exactly the new episode), OR-mode
widening, owner-only flips, the preview honoring the mode, and
downloaded/contains rules. Also fixes a latent bug the new query
surfaced: UpdateCollectionViewSettings referenced a nonexistent
updated_at column (never exercised until now — the new sibling query
was written without it).
D11 lifts: podcast episodes (media_items rows) join collections, and
"Play collection as sequence" feeds the player from the collection's
order. No new tables — collection_items already carries ordered
membership (item 2.1 audit).
PICKER: the collection modal gains a Books/Episodes tab pair. The
episodes tab hits /api/media-items/search?scope=episodes — a dedicated
picker query (SearchEpisodesForPicker) whose visibility is entirely
RLS: the media_items subscription predicate drops unsubscribed feeds'
episodes, and the podcast_feeds join is governed the same way. The
books scope keeps its podcasts exclusion (plan §9) — this scope is the
only door in. Pending episodes are pickable (plan ahead; the detail
page shows them as not downloaded). Selection rides the existing
bookPicker store — ids never collide across scopes, and AddBooks works
unchanged on episode ids.
DETAIL: the All-Libraries membership view switches to
GetCollectionItemsDetailed — LEFT JOIN + per-row visibility. An
unsubscribed episode (or family-lens hidden book) survives as a
Hidden card rendered as an "Unavailable — remove entry" hint row
instead of silently vanishing; visible episodes render feed title,
download state, and a Play link. The library-scoped view keeps its
existing dashboard-query behavior (the switcher's contract is
untouched for books).
CHAIN: ?queue=collection:<id> on any reader URL makes
buildPodcastSequenceContext produce the COLLECTION context (§10 R2
same shape, source=collection): downloaded episodes in collection
order (positions are content — R1; no feed-order override), prev/next
URLs carrying the queue param so the chain survives hops, and
backToDetail landing on the collection page. Ownership gates the chain
— foreign/deleted ids fall back to the feed queue; the order control
stays hidden (2.2's source gate). The header's "Play as Sequence"
button starts from the first downloaded episode in list order.
Pinned by TestPodcastEpisodeCollections (7 subtests): RLS-scoped
picker + family-lens auditor case (subscribed episode in a hidden
library must not reach the picker), books-scope exclusion intact,
hint rows, chain carrying the queue param, start-from-first, foreign
fallback, and reorder driving the chain (the R4 primitive works on
episode ids unchanged).
Foundation rule R2 (§10): the queue context is the only queue, and
every producer emits the same shape. PodcastSequenceContext gains
source (feed|collection), feed_id, and order; the feed producer sets
them now — the collection producer (2.3) slots into the same shape
without the player ever special-casing beyond showing/hiding controls.
The player header gains the FEED-mode order control next to the
"Ep N/M" counter: a dropdown (native/reverse/oldest/newest) plus a
one-click reverse button. Both PUT the existing per-user feed order
override — the SAME state the feed page writes, so flipping it in the
player updates the feed page's dropdown too. The control renders only
when source=feed: a collection queue's order is curated content
(R1), never a listening preference. Behavior is hx-put +
hx-swap="none" (response discarded) with a module-level
htmx:afterRequest listener in audio.ts reloading the page — the
server re-renders prev/next/index in the new order; templ decides
what it looks like, JS only triggers the round trip (CSP-clean, no
inline handlers).
Pinned by TestPodcastPlayerOrderControl: the config JSON carries
source/feed_id/order/episode_index; the control renders feed-mode;
an order override re-renders with the dropdown reflecting it (data-
json pins must match templ's " attribute escaping).
reader_settings.setting_key was VARCHAR(50), and the composite
podcast_order:<uuid> key already sits at exactly 50 chars — zero
headroom. The deferred queue-editor idea (plan §0 item 2b) and any
future per-feed/per-collection preference key would have been forced
to payload-stuff inside the order row by a column width. Composite
prefix:<uuid> keys are the norm now (§10 R5), so the ceiling widens
to VARCHAR(120): idempotent in-place ALTER (varchar widening is
metadata-only, no table rewrite), re-runs harmlessly on every boot
per the schema convention.
No new episode-membership tables: collection_items already carries
ordered playlist membership (position, A2c) against media_items,
which podcast episodes ARE — episodes join collections through the
existing table.
Pinned by TestReaderSettingsCompositeKeyHeadroom: a 57-char composite
key (the podcast_custom_order:<uuid> shape) upserts and reads back.
Under the old width this fails; sqlc codegen is byte-identical.
The app-shell sidebar read as too wide against the content area.
--sidebar-w is the single source of truth for the shell geometry, so
one line moves all three consumers together: the .app-sidebar width,
the body:has(.app-sidebar) content offset, and the topbar's left
inset on desktop. No templ or JS change needed — nothing else pinned
16rem.
style.css is the regenerated tailwind build (build:css:prod), diff
limited to the one var; committed with its source per the generated-
artifacts rule.
The admin endpoint DELETE /api/media-items/:id predated podcasts and
would delete any media row — including podcast episodes — if hit
directly. Invariant 2 (absolute form, user decision 2026-10-30) says
episode rows are removed ONLY by whole-feed removal: "the feed should
match the XML until the entire feed is gone." The plan audit found
this endpoint was the one remaining surface that could violate it:
the poller, unsubscribe, and the admin file-deletion endpoints are
files-only, and the hash-conflict merge can't see episodes (they
never hash).
The guard sits right after the GetMediaItem read and BEFORE the
storage-down check, so a refused episode never touches storage
resolution. Same segregation pattern as the generic edit path
(D9/D10, UpdateMediaItem).
Pinned by TestDeleteMediaItemPodcastGuard: admin DELETE of an episode
→ 409 with the row surviving; a book delete through the same endpoint
→ 204 control.
The bulk 'Delete selected files' button posted with hx-swap='none',
which makes htmx DISCARD the response — and the response is the
refreshed episode list partial. Rows (Play → Download, badges) stayed
stale until a manual refresh. Target the swap at #episode-list /
outerHTML like the Download All button; the afterSwap listeners then
reapply the (now-empty) selection and any active filter.
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.
§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.
User design (round 3): one set of big buttons does the big navigation.
The outer prev/next step buttons were chapter-only even on podcasts,
while episode nav hid as small icons in the top-left cluster. Now:
- Podcasts (sequence context present): the outer buttons navigate
EPISODES via goToPrev/goToNextEpisode (progress pushed before the
hop); disabled ghost buttons at the first/last episode keep the row
shape. Embedded-chapter jumps stay on the Chapter pill's list.
- Books: unchanged chapter stepping.
- Keyboard (h/l shortcuts) and hardware media keys (MediaTrackPrev/
Next) funnel through stepChapter, which is now sequence-aware —
headset next-track = next episode on podcasts.
- The top-left episode icons are gone; the 'Ep N/M' counter stays.
- Help-modal labels say 'chapter, or episode on podcasts'.
Same icons both modes (extracted audioStepPrev/NextIcon partials);
books are untouched — no sequence context, byte-identical behavior.
User report: Remove Feed still used the browser dialog. Headless-proven
root cause (tests/ui/confirm_probe.spec.ts): the htmx:confirm handler
read evt.detail.element, which htmx 2 does not provide (its detail is
{ elt, question, issueRequest, ... }) — the handler returned at
'if (!elt) return' BEFORE preventDefault, so every hx-confirm sitewide
fell through to htmx's native confirm. The acbcc61a migration only ever
worked for direct confirmDialog() callers; hx-confirm interception was
dead on arrival against htmx 2 and no test watched the JS side.
Fix: read detail.question (htmx's own closest-attribute resolution of
hx-confirm); return without preventing default when absent. Probe
spec pins: click Remove Feed → #confirm-modal opens with the message,
cancel hides it, no dialog event fires.
Companion fixes in the same user-test round (one commit, one defect
family — templ boolean attributes + confirm-modal placement):
- Add-modal Download order never revealed: #podcast-download-all was
nested in a wrapper div, so ':checked ~ .podcast-add-order' had no
sibling relationship. Input, label, and order row are now siblings.
- Order/auto-download dropdowns always DISPLAYED the last option
('Newest first'/'Newest') whatever was saved: templ selected={ bool }
emits selected="false" — presence selects, so every option was
selected and the browser honored the last. Swept to selected?={...}
across podcast_detail (14), book_detail_modals (10, incl. two
always-checked checkboxes), collection_modal (2, same). Saves were
correct all along; display only.
- Global confirm modal moved from SidebarSignIn (signed-out only!) into
Header — signed-in pages had no #confirm-modal, so confirmDialog
degraded to window.confirm even with a working interceptor.
Facet bar scale (user question): values now carry feed counts, sort
frequency-first, cap at 6 chips per group with a native <details>
'+N more' expander.
Probes: tests/ui/confirm_probe.spec.ts + add_order_probe.spec.ts
(live-server, BH_FEED-gated) — both pass against the dev instance.
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.
The contract pin caught the §0 item 4 column surfacing on media-list
raw sqlc rows — its job. Widened additively per doctrine: rawRowKeys +
docs/contract_pins.md changelog entry. NULL for scan rows and
never-queued episodes; nothing removed or renamed.
TestSettingDefaults enumerates registry types; 'float' hit the unknown-
type branch. Added float validation (default parses, within [min,max])
mirroring the int case.
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).
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.
Replaces the sequential worker with a download-manager-style pool
(§0 item 4). Model: the user's connection is the limit; semantics are
fair-share ACROSS feeds, not goroutine-per-book (a global FIFO would
make Book B wait behind Book A's 100 files).
- Atomic claim: GetNextQueuedEpisode + SetPodcastEpisodeDownloading
was a read-then-write race under concurrency; ClaimQueuedPodcastEpisode
converts queued→downloading in ONE guarded statement (two claimants,
one winner — the loser gets ErrNoRows). SetPodcastEpisodeDownloading
retired.
- Queue tickets: download_queue_pos BIGINT fed by a Postgres SEQUENCE
stamped at enqueue — the enqueue loop's ordering IS the claim order
(the foundation §0 item 5 builds on; previously all rows in a batch
shared one updated_at, so within-feed order was arbitrary).
- Pool: up to podcast_download_concurrency (default 4, live range 1–16)
claim-loop goroutines per drain round; the setting re-reads every
round so admin changes take effect without restart.
- Fair share: claim prefers the feed least-recently-served
(pickFeedToServe; never-served first), earliest ticket within a feed —
books proceed in parallel while chapters land in order (start
listening while the rest download).
- Bandwidth: shared token bucket across all workers
(podcast_download_speed_mbps, default 0 = unlimited); take() reserves
bandwidth and callers sleep outside the lock.
- Settings: new float setting type end to end (registry defaults +
clamping, save validation, admin UI number-with-step input) + the two
schema seeds; worker wires the registry via SetSettings (tests run on
registered defaults).
- Tests: unit coverage for the bucket reservation math + fair-share
pick; integration TestPodcastDownloadPool pins ticket order = enqueue
order, claim exclusivity, and a full 3-episode pool drain.
Dev-DB note: the column/sequence/settings were applied live (idempotent
DDL, same statements the schema runs at next boot) since the running
container predates them.
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.
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.
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.
Logout wiped localStorage but never cleared the token cookie (HttpOnly —
only a server response can) and never called the logout API, leaving the
refresh token live for 30 days and every cookie-authenticated SSR page
accessible: the fake-signout bug. Reproduced on prod and dev; unrelated
to RLS (it sits behind authentication) and long-standing.
- /api/auth/logout now ALWAYS clears the session cookie (Set-Cookie
empty + Max-Age=-1, HttpOnly/Path/SameSite mirroring login) — even a
malformed body must sign the browser out; refresh-token revocation
unchanged.
- The header logout button (and the rules-page copy) now POST to the
API with the stored refresh token BEFORE clearing local state and
redirecting — best effort: local cleanup never blocks on API failure.
- toast 401 handler fixed: it cleared 'refreshToken' but storage uses
'refresh_token' — the real key.
- Regression test TestLogoutClearsSession: real login → logout →
response deletes the cookie; logged-out refresh token can't mint
access tokens; body-less logout still clears.
Also carries regenerated artifacts from the confirm-modal and
episode-list work (header_templ/podcast_detail_templ/style.css).
Apple scheme normalization (follow-up to the Scribl discussion): Add
Feed accepts the launcher spellings podcast pages ship next to plain
RSS — itpc://, podcasts://, feed:// — and rewrites them to the https
feed URL they wrap (scheme match case-insensitive). http stays and the
guard still rejects it. Unit-pinned.
Build stamp (stale-container drift guard): the 2026-10-29 podcast
session lost an hour to a container running a build that predated the
feature. version.Commit rides ldflags from a GIT_COMMIT build arg; the
dev compose passes git rev-parse --short HEAD via the Makefile; the
app logs 'Bookhoard <ver> (commit <hash>)' at boot and /api/health
reports it — the UI and the repo can no longer silently diverge.
ONE styled modal answers every hx-confirm in the app: a body-level
htmx:confirm interceptor (web/src/confirm.ts, canonical htmx pattern)
preventDefaults and shows the dialog, issuing the request on Confirm.
Nine hx-confirm call sites upgrade without touching them. The modal
lives in the header partial so every page has it; confirmDialog() is
exported for JS call sites and degrades to the native dialog if the
header is absent (bare layouts).
Migrated the five window.confirm sites (admin.ts ×3, book-detail.ts ×2)
to the same promise API.
Podcast feed detail (issues #4/#7): the unsubscribe copy is
role-aware — non-admins get 'Unsubscribe from this feed?' with silent
retention (file state is admin knowledge); an admin who is the LAST
subscriber gets a three-choice modal instead: Unsubscribe (files kept,
the D6 default — sliding-window feeds make purge unrecoverable) /
Remove feed + files / Cancel. Admins with co-subscribers keep the
files-note confirm.
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.
WHY: the /podcasts search box filtered feed CARDS only (title, display
name, host, publisher, feed description). D14's episode search existed
only as the /api/podcasts/search endpoint and was never wired into the
page — searching 'Quarter' (present only in episode titles) cleared the
grid while 'Trader' (feed metadata) survived.
The page query now consults SearchMyPodcastEpisodes (subscription-scoped
via RLS — the caller's identity) when a query is present; a feed
surfaces when its OWN metadata OR any of its episodes matches, with an
'N matching episode(s)' badge on the card.
PIN: SSR test with a needle that exists only in an episode title — the
feed must surface with the match count.
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.
Invariant 3: progress anchors on the episode row (guid identity +
chapter/offset), never the ordinal. Renumbering feed_position (the
fetcher's per-fetch renumber case) leaves the progress row untouched
and the sequence membership stable — downloaded-only with no gaps.
cmd/server/tests/podcast_test.go — six suites, the shared-dev-DB
doctrine (test*-named libraries, cascading cleanup, no binary
fixtures):
- TestPodcastRLSSurfaces: THE AUDITOR (invariant 13) — non-subscriber
404 on feed detail + SSR page (existence leak), episodes absent from
generic media reads, admin escape, subscriber visibility, then the
hidden-library flip hiding an already-subscribed feed (the feed
policy's library-visibility join). Seeding runs as the FIRST ADMIN —
the seed identity has neither subscription nor users row, so podcast
INSERT..RETURNING (SELECT policy under FORCE) 42501s for it;
production pods run as exactly that admin (invariant 12). All direct
row probes read through the admin scope for the same reason.
- TestPodcastAddFeedFlow: member add + subscribe + metadata-only
upsert; duplicate-URL dedupe (no second row); allow_member_feeds
gate (member 403 / admin 201 / subscribe-still-allowed).
- TestPodcastShrunkenFeedRetention: feed purges an episode from the
XML, both rows persist (upsert-without-delete pin, D14).
- TestPodcastEditSegregation: generic edit 400 + write-to-file refusal
for episodes; control book edit unaffected.
- TestPodcastDownloadWorker (ffmpeg-generated tone at runtime): file
lands at <download-root>/<feed-slug>/NNNN-…, audio_metadata probed;
re-download of a downloaded episode never re-fetches (idempotency
pin, enclosure-hit counted); dead enclosure → failed + persisted
reason + metadata intact. Fetch/SSRF tests swap the client via
SetPodcastHTTPClientForTests (the production client refuses
loopback httptest servers by design — the real client gets its own
negative test in services).
- TestPodcastScannerExclusion: SetFolders on a podcasts library root
yields zero scanner folders (invariant 4 pin) — MediaScanner
.FolderCount() accessor added.
Unit: internal/services/podcast_rss_test.go — namespace-aware parsing
fixture (three XML namespaces, fallback chains, hosts/guests),
duration spellings, RFC-822 variants, explicit variants, slug/path
layout, enclosure-extension selection, stripHTML, and the SSRF matrix
(private/loopback/link-local/multicast/mapped-v6 blocked; a loopback
httptest server PROVES the production client refuses it).
suites green: full integration run 161s / 0 failures; vitest 12 files
163 tests.
Contract widening: TestContractPin_MediaListBothBranches' raw-row
allowlist gains the 15 podcast episode columns (additive; documented in
docs/contract_pins.md — inert defaults on scan rows). Bruno: bruno/
podcasts/ mirrors the §8 endpoints (12 requests) + Bookhoard env vars.
tests/ui/COVERAGE.md rows 18-23: podcasts pages/lifecycle/RLS/chain —
Playwright sweep pending.
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.
D9/D10: users never edit podcast episode metadata. Three gates:
- Generic edit endpoint (UpdateMediaItem) rejects source=
'podcast_episode' items with 400 — the next fetch refresh would
clobber any edit anyway, and the fetcher is the sole metadata writer.
- Write-to-file (MetadataWriter.WriteMetadataToFile) refuses podcast
episodes for the same reason (tag write-back for downloaded episodes
is a named Phase-4 item needing its own iTunes-tag mapping).
- The book-detail page hides the admin Edit affordance for
podcasts-type items (direct-URL visits), and the metadata-editor
modal's field list has no podcast fields by construction (fixed
templ field list) — episodes render read-only via /podcasts
surfaces.