The §0-item-2 acceptance pin predates the chain-order system and
assumed the native default: it started the chain at feed position 1
and expected a forward hop. With the born-reversed default (d56031f3),
the page's first card is feed position 5 and the chain runs 5 → 1 —
the acceptance's MEANING is unchanged (play from the first card you
see, chained back-to-back within the live briefing set); the fixture
card it starts from flipped accordingly. Changelogged.
This was the only failure in the full-suite validation run (154s,
green) — the first whole-suite pass across today's four units
(chips, label endpoint, apply-path seed, born-reversed acceptance).
The creation seed (d56031f3) covered born-smart collections; a STATIC
collection that becomes smart through the rules editor's Apply landed
without any curated order — its dropdown stayed "Feed order" while
every other podcast smart collection opened reversed. Same decision,
same guardrails (2026-11-01 user approved closing this gap): a PUT
carrying an episodes-scope tree seeds chain_order=reverse only when
neither the request NOR the stored view_settings names one — a
deliberate native (client-sent or saved) is never clobbered, and
books-scope trees seed nothing.
Pins: apply-seeds-when-unordered, respects-stored-native,
respects-client-native (TestSmartCollectionBornReversed now covers
both seed paths); changelogged in contract_pins.
The SSR embed (65f257f3) covers trees the server renders; one path
still fell through to the flawed fallback — applying a SAVED filter
from a cold bookshelf. That page was rendered before any filter
existed (nothing embedded), and the fallback reused the suggest facet,
whose rows are MEDIA-CONSTRAINED: "feed in [A,B,C] AND title contains
Chapter" labeled only the feeds with a matching episode (the exact
live repro from the original bug report).
- GET /api/filter/labels?field=X&ids=a,b,c resolves names straight
from the entity tables via the shared ResolveFieldLabels core
(extracted from FilterTreeLabels): no media join, no tree
constraint. RLS/visibility ride the request identity — a
zero-episode subscribed feed resolves its title (pinned; the facet
path structurally could not), an unsubscribed feed id stays unnamed
(pinned), non-id fields are 400 (pinned), ids cap at 100.
- Client: resolveIdLabels/resolveLabelsAsync use ONLY the endpoint;
the quiet suggest-fetch mode is deleted (its only caller was the
flawed fallback). The saved-filter apply flow resolves through the
endpoint and rebuilds the chips bar when labels land — chips render
raw ids only while the round trip is in flight, never stuck.
- 188 web tests green; Go endpoint pins + filter/episode slices green;
contract_pins documents the endpoint and the fallback removal.
Per user request, same shape as 0e29eff7 — a default flip, no internal
logic changes: POST /api/collections with an episodes-scope filter_tree
now seeds view_settings.chain_order = "reverse" on the new row, so the
collection lands with "Feed order (reversed)" selected in the detail
header's dropdown and the chain/page order follows the stored value as
if picked. Books-scope smart collections and statics get nothing
seeded; an explicit client chain_order is never clobbered; existing
collections are untouched (the seed exists only at creation).
Pins: TestSmartCollectionBornReversed (episodes seeded, explicit keep,
books-scope and static no-seed); TestEpisodeCollectionsFilterTree's
native expectations flip to the born-reversed reality (changelogged).
One misplaced pin from 81e54f89 corrected: the API envelope's match
list is not chain-ordered and never was — order pins live on the SSR
page and reader chain.
The per-feed listening order — the Order dropdown on the podcast page
and player — resolved its UNSET state to "native": feed document order,
which runs newest-first for classic RSS. This platform is audiobook-
first, so native started every book at its LAST chapter (verified on
live data: 7th Son position 1 = Bonus Episode #2, positions descending
through the chapters).
The unset fallback flips to "reverse" at every resolution site of that
ONE setting — the podcast page's list + dropdown (podcastOrderForFeed),
the player's feed chain (reader.go), and the download queue
(orderOverrideFor) — so the dropdown shows reversed selected, the page
renders in that order, and play/download follow. Stored overrides are
untouched (native stays a valid savable choice); no ordering semantics
changed; collections are untouched.
Pins: the flipped native expectation is changelogged in contract_pins;
new pins cover the sequence config carrying reverse, feed position 1
playing last, and the page dropdown rendering reverse selected for an
unset feed.
Refresh showed raw uuids in id-kind value cells (feed/library/
collection/added_by). Two defects, one story:
1. Only ids persist in filter trees; the client's id→label cache dies
with the page. The quiet re-resolution fetch reused the suggest
facet, whose rows are MEDIA-CONSTRAINED (INNER JOIN media_items +
the tree's sibling rules as a constraint) — so an id whose media
doesn't match got no row and no label. Live repro: "feed in [A,B,C]
AND title contains Chapter" labeled one feed of three (live-request
evidence: 200 with total:1). A label is a property of the entity
row, not of what currently matches.
Fix: pages that render a tree resolve labels SERVER-SIDE at render
and embed the map on the mount (data-id-labels) — the collection
editor (stored tree) and the bookshelf (URL qf). New
SearchService.FilterTreeLabels reads the entity tables with no
media join and no tree constraint; scoping rides the request
identity through the scoped handle (podcast_feeds/collections RLS —
an invisible id resolves to nothing; pinned), libraries mirror the
facet's visibility join. A feed with ZERO episodes resolves its
title — pinned; that case was structurally impossible before.
2. The bookshelf drawer never hydrated at page load at all: the only
load-time hydrate calls live in the PARENT's x-init
(initBookshelf), and Alpine initializes parents before nested
components — builderData() was null there, so hydrateFromForm
no-oped and a URL-qf refresh left the drawer with an empty seeded
rule while the grid stayed filtered. The drawer now hydrates in its
OWN init when the mount carries no data-tree; the init-order
contract is pinned in vitest (185/185).
The quiet suggest fetch remains only as a fallback for trees the SSR
never saw; embedded labels make it a no-op on refresh. Interactive
menus are untouched (their constrained, count-bearing behavior is
correct for choosing).
Pins: TestRulesPageEmbedsIdLabels (zero-episode feed labels at render,
bookshelf qf library label, hidden library NOT labeled);
filter/episode-tree/search-contract/RLS/playlist container slices
green; contract_pins entry documents the embed contract.
The detail header's listening-order select persisted fine and the
reader chain honored it, but the page's card list never applied
chain_order (it kept the composed query's title order) — so flipping
the select changed nothing visible, and Play as Sequence launches from
the FIRST CARD ON THE PAGE, making the played start (and the perceived
order) disagree with the curated setting (2026-11-01 user report on
the smart podcast page).
- Smart episode collections: the page's match list re-orders through
OrderEpisodeMedia (the same OrderEpisodes comparator the sequence
producer uses — §10 R2's single source of order truth) before card
building. Books-scope matches keep title order.
- Static episode playlists: the sequence now honors the curated order
too — native keeps the manual position order (SQL), reverse flips
it, oldest/newest re-key on publication date with feed position as
tiebreak. Previously statics ignored chain_order entirely while the
select rendered on their pages.
Pins: native page order (feed positions) and the reverse flip
re-ordering the SSR cards, asserted in the curated-order subtest
alongside the existing reader-chain pins. Podcast/playlist/audiobook/
RLS/dashboard/contract slices green.
Comma-joined multi-picks matched ZERO: clauseFeed/clauseLibrary/
clauseCollection parsed each rule value as ONE uuid, but the builder's
pick loop stores several picks as a single comma-joined value
(["id1,id2"]) — uuid.Parse failed on the joined string, the id list
came back empty, and "feeds in [A, B]" matched no episodes (one pick
worked, which is why the briefing recipe survived). parseUUIDValues
now splits every value on commas (trimming) before parsing, accepting
both the builder's joined shape and hand-written separate elements —
same leniency the suggest-side id lookup already has.
The multi-value cells now say so: id-kind value inputs get the
placeholder "pick one or more" (the only boxes on the page that accept
several values; every other cell replaces its value).
Pins: comma_joined_multi_feed_matches_both — a tree collection built
with the exact comma-joined wire the client sends, asserting BOTH
feeds' episodes match. Filter-builder vitest 35/35, container filter +
episode-tree slices green.
The pre-tree rules machinery is deleted: no row ever carried a rules
array or a non-default match mode (verified in the dev DB), the web
never read the envelope keys, and the Android app consumes none of it.
Dead code gets no documentation.
Removed:
- PUT /api/collections/:id/match-mode — endpoint, route, and the
UpdateCollectionMatchMode query. The tree's root mode owns any|all.
- auto_assign_rules / rule_match_mode from every envelope
(create/list/detail/update), from the create/update request shapes,
and from the schema: both columns DROPPED (schema.sql carries
idempotent DROP COLUMN IF EXISTS heals for existing DBs; sqlc
regenerated).
- The rules CRUD service layer (AddRule/SetRuleEnabled/RemoveRule +
validateRule + UpdateCollectionRulesJSON), ParseRules /
EnabledRules / IsSmartCollection, RulesToFilterTree (+ the SSR
preload branch), the legacy branch of EvaluateSmartMembership, and
SmartEvalOptions.MatchMode. IsCollectionSmart = tree presence;
create-path isSmart = collection_type=smart OR a filter_tree.
Kept (live consumers): POST /api/collections/preview still evaluates
rules payloads in memory (the custom-section builder's preview), and
include_auto stays on the list (the Android app sends it).
The custom-section builder's SAVE was already broken — its
stringified auto_assign_rules payload failed request binding (probed
live: 400 invalid request). Create now rejects that payload loudly
with a 400 naming the retirement, instead of silently creating a
hollow static section; custom-section-builder.md carries the flag.
The builder's tree migration remains the flagged follow-up.
Test-side (asymmetry rule — deleted behavior, deleted tests):
TestPodcastSmartCollections rewritten onto trees (mode lives in the
tree root), match-mode subtests and tree_wins_over_legacy_rules
deleted, TestRulesToFilterTree deleted, contract-pin envelope keys
retracted per the contract_pins 2026-10-31 retirement entry. Mirrors
hand-synced: openapi.yaml, bruno create/update, docs/api/*.md,
docs/schema.go, api.d.ts. Full container suite green (155s).
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).
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.
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).
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 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 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 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.
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.
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).
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.