Commit Graph
390 Commits
Author SHA1 Message Date
John O'Keefe 7c7e282e79 feat(dashboard): custom-section builder saves again — the flagged tree migration
Archaeology, prompted by the user asking when it broke: the builder's
save NEVER worked. The page was born (2026-02-19, a92bf99a) posting
auto_assign_rules as a JSON-encoded STRING into an endpoint that has
bound []Rule since its creation (2026-01-31, 6495cc2c), plus fields
(manual_book_ids, match_type, library_id, show_on_dashboard) the
endpoint never accepted. The preview button hit a real endpoint, so
the page looked alive while Save always failed at request binding.
Verified live before this change: the page's exact payload 400'd
"invalid request" against the unmodified server.

The fix adapts the builder's payload at write time — the legacy rules
vocabulary converts to a filter tree (RulesToFilterTree restored from
the retirement commit as the single write path, unit test restored
with it):

- match_type seeds the converted tree's root mode (any default).
- manual_book_ids attach to the created collection after create
  (unparsable ids skipped — the AddBooks leniency).
- show_on_dashboard persists (new create field).
- Converted trees validate under ParseFilterTree: an unhonorable rule
  is a 400 at write naming the problem, never a stored 500 at render.

Dashboard sections: smart user sections now evaluate their tree LIVE —
GetUserCollectionItems merges stored membership with the composed
engine's matches (deduped, library-scoped, capped at the section
limit). This exposed a REAL production wiring bug: every production
NewDashboardService call omitted the search service, so tree
evaluation through the dashboard could never have worked (the test
harness wired it correctly — the gap was production-only and latent,
because nothing in the dashboard evaluated trees before this
migration). The dashboard handler and both inline sites now receive
the search service, and evaluation failures log instead of silently
emptying a section.

Supersedes the same-day loud-400 stance for the builder's payload
(contract_pins migration entry); custom-section-builder.md documents
the save contract; the create_collection API doc gains the adapted
fields. New TestCustomSectionBuilderSave/BadRule pin the payload →
tree → dashboard-render path end to end. Full container suite green
(155s).
2026-10-31 19:40:43 -04:00
John O'Keefe a86bdac87e refactor(collections): retire the legacy rules surface — tree-only storage
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).
2026-10-31 19:06:48 -04:00
John O'Keefe ab98015190 feat(collections): RLS ownership enforcement at the DB layer
collections + collection_items join the RLS-governed tables, same
doctrine as the media_items family lens (2026-10-29). Policies route
through two PURE predicate functions (app_can_read_collection /
app_can_write_collection) that never query collections — no policy
recursion under FORCE RLS — so a future sharing model is one EXISTS
branch inside the functions, not a schema re-touch. Reads: owner or
admin; writes: owner, admin escape for SYSTEM collections only.

Why: the write path had no app-level ownership gate on Update/Delete/
AddBooks — any authenticated user could mutate another user's
collection by id. The DB is now the enforcement of record; existing
app-level gates (match-mode, reorder, view-settings) stay as
defense-in-depth.

Behavior changes (contract_pins 2026-10-31):
- cross-user GET detail: 200 -> 404 (row invisible)
- cross-user PUT update: silent success -> 404 (ErrNoRows mapping;
  service wrap fixed %v -> %w so errors.Is sees pgx.ErrNoRows)
- cross-user POST books: silent success -> 404
- cross-user DELETE: 204, now a true DB no-op
- anonymous sees nothing; admin persona unchanged

Bootstrap: registration seeds system collections BEFORE the JWT
exists — CreateDefaultCollectionsForUser declares the new user's
identity (rls.WithUser), the same fix mirrored in the test harness
(createDefaultCollectionsForUser owner-scoped; collection sweeps run
under the owner's identity).

Tests: new TestCollectionsRLS pins the cross-user matrix end-to-end
(404 detail/update/add/view-settings, 204 no-op delete, admin escape,
owner positive control, registration seeding under RLS). Harness
fixture writes to non-system collections now run as the row's OWNER
(scopeExecAs/ownerSeedCtx) — the admin write-escape covers system
rows only, and bare-ctx pool reads of collections see nothing.
Filter/playlist/audiobook/scan-state fixtures converted. Full
container suite green (159s).
2026-10-31 18:30:02 -04:00
John O'Keefe 744d33e13f fix(search): keep the empty-qf 400 at the wire
The empty-tree grammar relaxation (create-modal episodes seed) must not
leak into the search deep-link contract: qf={"mode":"all","children":[]}
as a QUERY would silently mean "match everything". The seed's meaning —
a rules-less collection matching nothing — lives in the stored tree and
its composers; the query entry point now rejects a rule-less tree
explicitly, preserving the pinned 400 that TestFilterQFValidationAndPrecedence
guards (the test caught the leak during this round's full-suite run).
2026-10-31 15:56:23 -04:00
John O'Keefe aa9bd950f9 fix(collections): gate the chain-order endpoint by ownership
PUT /api/collections/:id/view-settings writes the row; it now demands
UserOwnsCollection like its sibling config mutations (SetMatchMode,
ReorderItems) — otherwise any signed-in user could flip another
user's curated listening order. Discovered while documenting the
endpoint: the sibling pattern exists precisely because the main
PUT/DELETE routes lack it (their gap is noted for follow-up, not
expanded here).
2026-10-31 15:40:21 -04:00
John O'Keefe 536fb4794c fix(collections): the create modal's seed POST 400'd — echo cannot bind a form string into an any-typed field
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).
2026-10-31 13:52:40 -04:00
John O'Keefe cc0e9d7f91 feat(podcasts): the episodes authoring door + curated chain order
"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).
2026-10-31 13:39:24 -04:00
John O'Keefe c54430c254 fix(podcasts): code-review pass over the session's UI surfaces
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.
2026-10-30 19:38:39 -04:00
John O'Keefe 4f71eda6be feat(podcasts): the rules page retires — the collection builder IS the filter builder (item 3.4)
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.
2026-10-30 19:22:17 -04:00
John O'Keefe 6b2125a45a feat(podcasts): smart collections ride the unified filter engine — filter_tree column (item 3.2)
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).
2026-10-30 19:02:49 -04:00
John O'Keefe bc91ce68d4 test(pins): widen whitelist for the episode-collections JSON shapes (item 2 series)
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.
2026-10-30 17:25:22 -04:00
John O'Keefe abb7c84833 feat(podcasts): smart episode collections — ALL/ANY match modes + episode rule fields (item 2.4)
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).
2026-10-30 16:54:36 -04:00
John O'Keefe adb93cba9e feat(podcasts): episode collections — picker scope, hint rows, the chain (item 2.3)
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).
2026-10-30 16:18:29 -04:00
John O'Keefe f47734dd7d fix(podcasts): generic media delete refuses episode rows (invariant 2)
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.
2026-10-30 14:57:08 -04:00
John O'Keefe 7d5bbd1177 feat(admin): multi-select episode file deletion — checkboxes on downloaded rows
The plan's per-EPISODE bulk select for the admin view (§0 item 8
follow-up — trimmed in the first admin-view commit; user called it
back). Admins now tick checkboxes on DOWNLOADED episode rows and delete
their files in one request:

- Checkbox per row, rendered ONLY for admins + downloaded episodes
  (nothing to delete otherwise), via the existing podcastDetail Alpine
  component: selection ids/count survive the list's htmx swaps
  (reapplied on afterSwap — same pattern as the text filter) and clear
  after a successful bulk delete.
- Toolbar segment outside #episode-list (survives swaps): 'N selected'
  + 'Delete selected files' (hx-include gathers the CHECKED boxes);
  disabled-looking at zero selection; hx-confirm states the
  re-materialization contract. CSP-safe method bindings only
  (bulkSelectionLabel/Class) — no ternaries in Alpine expressions.
- POST /feeds/:id/episodes/delete-files (admin): repeated
  episode-file-select keys parsed via ParseForm directly (never bound —
  repeated-key wire is another echo-bind gotcha); each id validated to
  belong to THIS feed, so a forged foreign id is refused while the real
  selections apply; rows reset to pending, files removed.
- Tests: bulk of two real ids + one forged id → deleted:2, files gone,
  rows pending; non-admin 403; all prior single/feed-level pins hold.
2026-10-30 14:06:54 -04:00
John O'Keefe 0741e98ea5 feat(admin): all-podcasts view — disk-first maintenance for every feed
§0 item 8 (Phase-4 item 1 pulled forward, user directive: maintenance
tooling ships WITH the features it maintains):

- /admin/podcasts (AdminMiddleware): every feed from EVERY user via the
  RLS admin escape (first-admin identity, poller/fetcher contract) —
  feed + host, library, subscriber count/names, downloaded/total, DISK
  USAGE, last fetch. Sorted disk DESC ('what's eating the disk'),
  totals header (feeds · episodes · bytes on disk).
- Per-feed actions: Refresh · Delete files (rows → pending, disk freed;
  confirms spell out the re-materialization contract) · Remove feed
  (rows + files for everyone — the same admin delete as the detail
  page). All confirms ride the global confirm modal.
- New sqlc: ListAllPodcastFeedsAdmin (correlated aggregates),
  ResetPodcastEpisodeFileDeleted / ResetPodcastFeedFilesDeleted —
  downloaded rows reset to pending with download-derived facts cleared
  (audio_metadata, chapter_count, cover, size, mime); file_path KEPT
  (fetch-maintained layout — a re-download lands on the same path).
  file deletion via the existing DeleteFeedFiles file-removal pattern
  (episode files + .cover.jpg siblings).
- Poll upsert never touches download_state, so deleted files STAY
  deleted — a feed refresh re-lists metadata but re-materializes
  nothing (pinned by TestAdminDeleteEpisodeFiles).
- Per-episode admin delete-file: POST /feeds/:id/episodes/:eid/
  delete-file (admin) — one episode's file gone, row pending, siblings
  untouched; HX swaps the episode list. (Found while testing: the
  handler first read c.Param("id") — the FEED's uuid — instead of
  :eid; the 404 'episode not found' trail led straight to it.)
- Tests: admin sees other users' feeds + subscriber names; regular
  users 403 on page AND actions; single + feed-level deletion reset
  rows/clear facts/delete files; refresh leaves pending rows pending.
- Admin nav gains 'Podcasts' under Administration.

Wrong-gotcha note for future agents: parseUUID on c.Param('id') inside
a /feeds/:id/episodes/:eid route resolves the FEED, not the episode.
2026-10-30 13:54:37 -04:00
John O'Keefe 5c545534c2 feat(podcasts): Download All queues in the LISTENING order + add-modal order dropdown
Download All previously queued in feed-native order — 'getting the
last chapter first doesn't help me'. Now the queue follows the feed's
effective listening order (§0 item 5b): QueueFeedDownloadsAs reads the
CALLER's order override and runs episodes through services.OrderEpisodes
(same helper as the sequence/reader/page surfaces — the consolidation
from item 5a landed with the reverse-order commit). Queue tickets
(SEQUENCE-stamped, item 4) make that enqueue order the claim order, so
chapters land in the order you'll actually listen.

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

Test TestPodcastAddModalDownloadOrder: add with download_all+reverse →
the override row exists AND persisted tickets run position 3→1. Tickets
are asserted (not live queued rows) because the shared dev server's
worker legitimately races this test's queue — tickets survive every
state flip.
2026-10-30 10:26:00 -04:00
John O'Keefe 0f3c4a8cec feat(podcasts): concurrent download pool — fair share, tickets, admin tunables
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.
2026-10-30 10:12:43 -04:00
John O'Keefe e3675536aa feat(podcasts): reverse order — position DESC, date-agnostic; single order helper
The 'Oldest first' order sorts by PUB DATE, which breaks on Scribl's
migrated podiobooks feeds (messy/all-equal dates). New order value
'reverse' = feed position DESC with a date-DESC tiebreak — dates are
not involved, so nothing can break.

- services.OrderEpisodes: THE single order source of truth (§0 items
  3+5a). The four surfaces (sequence JSON, reader chain context, SSR
  page list) previously carried three near-identical handwritten
  comparators — all now route through one generic helper. The SSR page
  list gains a SortDate key (lexical 2006-01-02) so the pub-date
  tiebreak is actually chronological; the old comparator compared
  'Jan 2, 2006' display strings alphabetically (latent drift bug).
- Dropdown becomes four options: Feed order / Feed order (reversed) /
  Oldest first (pub date) / Newest first (pub date).
- Serial suggestion button renamed 'Reverse feed order' (sets reverse,
  not oldest) and its response out-of-band-swaps the order select
  (PodcastOrderSelectOOB — same mechanism as Download All's policy
  select), so the dropdown mirrors the server state immediately.
- Validation: oneof=native reverse oldest newest everywhere.
- Tests: reverse binds via the form wire; messy-dates sequence
  returns 3,2,1; existing reorder-safety stays green.
2026-10-30 09:15:03 -04:00
John O'Keefe 7c7c08748e fix(podcasts): download_all binds on every wire — FlexBool + flip log
Reproduced the §0 item 2 suspect: echo v5 bool-binds form values via
strconv.ParseBool, which accepts "true"/"1" but REJECTS the
browser-native <input type=checkbox> value "on" — and a bind failure
kills the WHOLE AddFeed request (400 "invalid request"), not just
the checkbox. The add modal posts urlencoded (htmx), so any
checkbox-serialization that emits "on" made Download All silently
unusable.

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

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

Integration test TestPodcastDownloadAllFormEncoding pins the urlencoded
wire (what htmx actually sends): true + on both flip the caller's
policy to 'all', an unchecked add leaves the default.
2026-10-30 09:06:41 -04:00
John O'Keefe 7ea84043fa fix(auth): logout actually signs out — clear the HttpOnly session cookie
Release / build-and-push (push) Successful in 3m9s
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).
2026-10-29 22:01:32 -04:00
John O'Keefe 30766020c8 feat(podcasts): real-time episode list — byte progress bars, direct swaps (issues #1/#3)
WHY: downloads needed manual refreshes to see progress; the 204 +
HX-Trigger event indirection missed its listener, and the worker
buffered whole enclosures so no progress existed to show.

- Worker streams enclosures to disk through a counting loop (64KiB
  chunks, scheme/redirect/size guards preserved) with an in-memory
  per-episode progress snapshot (received/total; total from
  Content-Length, seeded by the feed's declared length). Deliberately
  transient — the DB row state remains the durable truth.
- Episode-list renderer seam: the router injects a closure into
  PodcastHandler (handlers can't import templates — MediaDetail cycle)
  so htmx download/retry/Download All/refresh/order/policy actions
  answer with the REFRESHED partial directly (200, hx-target=
  #episode-list) — no events to miss. Non-HX callers keep JSON; tests
  without a renderer keep 204+trigger.
- Polling tightens to 2s while downloads are active; downloading rows
  render a percentage bar + MB counter (quote-free inline style per
  templ gotcha #8) or an indeterminate bar when the server sends no
  length.
- Issue #3: Download All (button AND the add-feed checkbox) flips the
  CALLER's subscription policy to 'all' — per-user, never global (D6:
  files shared, policies not) — and the auto-download select
  out-of-band-swaps so the dropdown shows what the user is getting.
  They can turn it down afterwards; the flip is honest communication,
  not coercion (user-approved 2026-10-29).

PINs: Download All with HX answers 200 + episode-list + oob select and
flips newest → all; add-feed download_all sets policy all.
2026-10-29 20:12:19 -04:00
John O'Keefe c753780817 fix(podcasts): order selector validation — bind the htmx form wire format (issue #2)
WHY: htmx sends non-GET requests urlencoded by default; the SetFeedOrder
request struct only carried a json tag, so the binder left Order empty
and 'required' fired — the exact error the order dropdown AND the
Serial-suggestion button produced (hx-vals JSON also arrives
form-encoded). One form tag fixes both controls.

Regression test drives the exact wire format: urlencoded PUT with
order=oldest/native → 200, empty/invalid → 400.
2026-10-29 19:45:46 -04:00
John O'Keefe 2235b00c59 feat(podcasts): web UI — /podcasts page, feed detail, download lifecycle (task 1.9)
Rendering ownership holds throughout: every piece of server data is
templ-rendered; htmx swaps partials; the only TS is the Add-Feed modal
toggle + error surfacing (pure client-local state, bundled via
web/src/podcasts.ts with automatic bundle-hash stamping).

Sidebar: /podcasts nav entry (D13, between Series and Collections),
gated by a ShowPodcastsNav flag resolved centrally in
getTemplateUserWithTheme (one indexed COUNT — same class of
per-request lookup as the live-user guard). New 'podcast' icon.

/podcasts: header + Add Feed modal (htmx post → HX-Redirect to the
feed page; inline error surfacing on failure), subscription-scoped
search + facet bar (host/tag/language — server-side GET filters, no
JS), grid cards with download counts / new-episodes / complete /
explicit badges. Empty states for no-library (admin creates it first)
and no-subscriptions.

Feed detail: hero (artwork, host/publisher, tags, fetch state), action
row — Download All, Refresh, ORDER SELECTOR (D5 override), serial
one-click oldest-first suggestion (never silent), auto-download policy
select (D6), Unsubscribe (files retained), admin Remove Feed — gap
banner (D8 count), and the EPISODE LIST with the full lifecycle inline
(D8/D19): pending greyed + Download button (every non-downloaded
episode has one), queued/downloading pulse, failed RED + reason text +
Retry (same endpoint). The list is a self-refreshing partial: re-fetch
on the podcast:changed HX-Trigger and polls every 4s ONLY while the
server says downloads are active (it renders the polling variant).

API endpoints grew HX awareness: 204 + HX-Trigger podcast:changed (or
HX-Redirect for add/unsubscribe/remove) — the sanctioned Go-handler
response channels; JSON callers keep JSON. AddFeed result exposes the
fetch-error text for the redirect.

Dates render in the requesting user's timezone (D17) via
utils.FormatTimeInTimezone; episodes play through the existing
/readers/{id} audio reader (episodes ARE audiobook items — no new
player code day one). CSP tripwire (templates/csp_source_test.go)
passes: no inline scripts, no on*= handlers, no hx-on/js: vals.
2026-10-29 16:17:55 -04:00
John O'Keefe 54d7d6e7a1 feat(podcasts): edit segregation — feed metadata is fetcher-owned (task 1.8)
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.
2026-10-29 16:05:03 -04:00
John O'Keefe 45b1152a15 feat(podcasts): API endpoints — add/list/detail, subscriptions, downloads, sequence, search (task 1.7)
The §8 surface, wired through main.go → router.Config →
/api/podcasts (JWT). Scoping is the engine's (D20): lookups outside the
caller's scope simply return no rows → 404; the only Go-level checks
are explicit-argument authorization (AddFeed's library +
allow_member_feeds gate) and the admin role on feed deletion.

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

Gap definition (countGaps): an un-downloaded episode with a downloaded
episode AFTER it in list order — the D8 'middle episodes missing'
banner condition.
2026-10-29 16:03:35 -04:00
John O'Keefe 9a148c2344 feat(podcasts): generic-surface exclusions — podcasts never render as books (task 1.3)
§9 / D3 / D13 / D15 / D16: podcasts-type libraries are excluded from
every generic book surface. Under RLS these are pure UX/type filters
(engine already hides other users' episodes); their job is preventing
episode grids in surfaces designed for books. The discriminator is the
denormalized media_items.library_type_name (IS DISTINCT FROM 'podcasts'
— NULL rows predate the column and stay visible).

SQL (queries.sql, marked '-- podcasts exclusion (plan §9)'):
ListMediaItems/ListMediaItemsSorted (All Books) · SearchMediaItems +
SearchMediaItemsUnified (generic search) · FacetCountsUnderFilters (all
14 arms) + the six Search*Values facet-suggestion queries ·
GetDistinctSeries + Count (series pages) · GetLibraryItems (collections
picker) · dashboard system sections (Continue Reading / Recently Added
/ Recently Read / Not Started) · GetPopularBooks + GetTopBooksByMinutes
(book rankings must not rank episodes).

Go: statistics summary counts episodes' sessions into time-spent (D15
keeps that) but skips them in BooksActive/BooksFinished — an episode is
not a book. OPDS filters podcasts libraries via one helper at all 7
library-walk sites (D16).

Templ: LibrarySwitcher skips podcasts entries and TotalMediaCount
excludes them — presentation-layer filter so every switcher consumer
(dashboard, collections, custom-section builder) is correct at once
(D13); podcasts surface via /podcasts only.
2026-10-29 15:44:48 -04:00
John O'Keefe c0e6993273 feat(podcasts): scanner exclusion — podcasts libraries are feed-managed (task 1.2)
WHY: podcast download roots hold fetcher-written episode files; the FS
walker must never discover them (invariant 4) — a walk would double-
ingest episodes as scanner items and fight the fetcher's sole-writer
rule (D9). Every scan/watch path funnels through MediaScanner.SetFolders
(StartScanner, ScanLibrary jobs incl. scoped single-folder scans,
watch mode), so the filter lives there once: folders resolving to a
podcasts-type library are dropped before registration, with a log line.

Belt and braces: ScanLibrary's per-folder scope refuses purpose='podcast'
folders explicitly (400) — the panel-level path that bypasses type
resolution. AddLibraryFolder now derives purpose from the library type
('podcast' for podcasts libraries, enforced single download root;
'scan' default elsewhere), so folder purpose and library type cannot
diverge through the API.
2026-10-29 15:38:45 -04:00
John O'Keefe d49a9e6ca2 fix(devices): device deletion detaches reading history instead of 500ing
Deleting any device whose stats rows still referenced it failed with
500: reading_history.device_id is a NO-ACTION FK by design (the rows
are real captured reading stats — Users reads them), so a blind DELETE
violated the constraint. Surfaced by the Android app's new logout
deregister call, but the admin Devices page Deletes hit the same stone.

Fix: DetachReadingHistoryFromDevice — NULL the device association on
the user's history rows before the device DELETE (single handler
pass). device_id is nullable; the stats survive, the device frees its
identifier. sqlc regenerated.
2026-10-29 11:57:20 -04:00
John O'Keefe 4d697f5fcf test(visibility): full suite green under RLS — sync paths, device-auth bridge, seed pool
Phase 5 (plan §7): the entire integration suite passes with the RLS
policies live. The suite was the triage enumerator the plan predicted:

- device_auth middleware: the THIRD identity bridge — KOReader/Kobo
  device-authenticated requests read media as the DEVICE OWNER
  (plan A9 called this path out; the flip was 404s on sync metadata).
- conflicts/analytics/progress handlers: detached
  context.Background() reads threaded to the request context (the
  requesting user's identity) — 12+ sites; resolve helpers take ctx.
- scanner: ScanFolders self-declares its configured admin identity
  (server maintenance, product-correct for any caller); generic job
  creation stamps the requesting admin.
- harness: setup.DBPool and the sync-test pool declare
  rls.SeedIdentity at SESSION scope via BeforeAcquire — the pool never
  serves a second identity, so no A3 bleed; setup.DB falls back to the
  seed identity (NewSeedDB) for ungoverned-context calls; filter tests
  use seedExec for raw governed-table statements.
- contract pin WIDENED (docs/contract_pins.md 2026-10-29): under RLS,
  item-scoped gates may answer 404 (row invisible — no existence leak)
  as well as 403 (Go-level checks on listings keep strict 403).
- TestWorker_JobStatusTracking — the pre-existing red from the Phase 0
  baseline — passes with the harness seed work; no product change.
2026-10-29 10:14:08 -04:00
John O'Keefe bb2cc26e9d feat(scanner): scanner entrypoints + generic jobs carry RLS identity; SeedDB harness fallback
The full suite enumerated the remaining triage class: tests drive the
scanner directly (ScanFolders(context.Background())) and read seeded
media via Queries with bare contexts — every governed read raised.

- MediaScanner.ScanFolders self-declares its configured admin
  (SetAdminID — set by main AND the test helpers): a scan is server
  maintenance, product-correct regardless of caller.
- ScopedDB gains a Fallback identity, exposed as NewSeedDB for the
  TEST HARNESS ONLY: setup.DB falls back to rls.SeedIdentity when the
  context carries none, so direct seeding/verification helpers work
  uniformly instead of per-site context threading. rls.SeedIdentity is
  the shared constant (no library_visibility rows => full visibility).
- generic job creation (jobs.go) stamps the requesting admin into the
  job Context, same as the scan endpoint.

The sync queue processor stays identity-free: sync_queue is not an
RLS-governed table.
2026-10-29 09:27:04 -04:00
John O'Keefe 385a5d9dba feat(scanner): background jobs declare acting identity for RLS
Phase 4 of the RLS plan: detached contexts (context.Background()) that
reach media_items now carry identities — with the policies live, a bare
context made governed reads raise (fresh connections: 42704 unrecognized
parameter; reused pool connections: 22P02 empty-string cast after the
LOCAL GUC reverted — the plan's A2 signature).

- scanner jobs (API-triggered, mtime-poll, initial scan): the job's
  Context now carries the acting admin's identity — the library owner,
  first-admin fallback (the job's UserID already encoded this trust
  decision; the Context just needed to carry it). performInitialScan
  uses the scanner's configured admin.
- websocket initial-sync read (GetUserMediaItemsForSync): runs as the
  connection's own user.
- hash backfill: runs as first admin (server self-heal over all media).

Verified on the dev stack: forced rescan of the 1199-file Audiobooks
library completed with 0 errors under RLS (previously every SHA lookup
and create raised); hash-backfill refusals gone. The sync queue
processor needs no identity — sync_queue is not an RLS-governed table.
Known remaining detached reads (conflicts/analytics handlers using
context.Background()) are HTTP-path triage, enumerated by the full
suite next.
2026-10-29 09:08:54 -04:00
John O'Keefe 9f39e2efd8 feat(settings): save feedback — success/error toasts, disabled buttons; fix scan-form raw-JSON swap
Release / build-and-push (push) Successful in 4m35s
None of the three save surfaces on the admin settings page gave usable
feedback: the Base URL form re-rendered into an identical-looking form
(its only indicator was the Go-source twin's banner — markup that
diverged from the templ original), the scan form was WORSE — its handler
responds JSON, so htmx's outerHTML swap replaced the whole scan card
with literal JSON text — and the tunable rows showed only a tiny
per-row label.

Client side (toasts are the sanctioned client-local island; no new
inline scripts, CSP unchanged):

- web/src/settings.ts: one delegated htmx:afterRequest/sendError
  listener keyed on a data-settings-save attribute stamped on each
  form. Success → green toast per surface ("Settings saved" / "Scan
  settings saved" / "Setting saved"); failure → error toast carrying
  the server's JSON error field, degrading to a status line for the
  tunable endpoint's HTML error body (markup must never reach a toast).
  Network-level failures (htmx:sendError) toast instead of silently
  re-enabling the button.
- htmx 2.x quirk handled: for FORM submissions, afterRequest's
  detail.elt is the event-dispatch ancestor, not the form — the
  requesting element lives on detail.requestConfig.elt (verified
  empirically; saveLabelFor reads the right one).
- hx-disabled-elt="this" on all save buttons: the click is
  acknowledged and double-clicks cannot double-save.

Server side:

- Scan form: hx-swap="none" (the inputs already hold the saved values;
  nothing needs re-rendering) — kills the JSON-over-the-card swap.
- The Base URL form's save response is a Go-source copy of the templ
  form (handlers cannot import templates: templates/utils.go imports
  handlers types). The twin now carries the functional attributes
  (data-settings-save, hx-disabled-elt) so feedback survives the swap,
  with a drift-warning comment; the double-save e2e pins this.

Dev-DB safety: every success save in the spec re-submits the value
already on the page (zero drift), and the spec restores
system_config.updated_by after itself — the save handler stamps the
requesting admin's id there, and that FK (no cascade) otherwise blocks
the integration harness's transient-user sweep (it broke
TestUpdateProfileAdminMode/TestDeleteUserConsolidated on this run's
first pass; pollution cleaned up by hand and now self-restoring).

Tests: web/src/settings.test.ts pins the form→label map and the
error-extraction contract (including the no-markup-in-toast rule);
e2e/settings-feedback.spec.ts drives all three surfaces through the
real admin UI including the error path and the twin-drift double-save.
2026-10-28 00:22:00 -04:00
John O'Keefe 96c1228805 feat(series): multi-field search, sort-direction toggle, apply placement
Toolbar feedback round: the series search box now matches whatever the
main search matches for a group — series NAME, any book's AUTHOR, and
any book's TITLE inside the series (WHERE EXISTS per group-row, so
counts stay true); the separate author input is gone from the toolbar
(the author= param stays live for the JSON API and deep links). The
sort control becomes a base-key select (Date added/Name/Author/Books/
Progress/Last read) plus a chevron direction toggle that flips and
resubmits; sort/dir compose through ResolveSeriesSort (full registry
keys keep working for back-compat, unknown base keys fall to recent).
The search/apply button moved next to the search box as requested, and
default URLs stay clean (sort/dir only emitted once the user chooses).
2026-10-27 19:05:23 -04:00
John O'Keefe eaf1b2c8dd feat(metadata): write-to-file — embedded EPUB OPF + Calibre sidecars
Phase 5a of the metadata-writer feature: the book editor gains a
"Write to file" action (admin) that persists the item's CURRENT
database metadata into the artifact on disk, so the library stays
portable to Calibre and other apps — the thing Save alone can't do.

Dispatch (services/metadata_writer.go): EPUB/KEPUB gets its embedded
OPF spliced in place (fresh dc:* block generated inside the ORIGINAL
metadata open tag; manifest/spine/guide untouched; every other zip
entry copied byte-identical in original order so mimetype stays first
and STORED; original dc:identifiers preserved — the package's
unique-identifier must keep resolving). Everything else takes a
Calibre-style sidecar: metadata.opf when the book is ALONE in its
directory (Calibre's own convention — round-trips through Calibre
too), <basename>.opf when siblings share the folder, so one book's
sidecar can never poison dir-mates' scans (the scanner now reads both,
basename first).

Safety contract: temp file + atomic rename — the original is never
truncated on failure; the generated OPF is unit-tested to ROUND-TRIP
through the scanner's own parser (write side and read side can never
disagree); metadata_overrides are deliberately kept (file and DB agree
after the write; overrides still guard against stale sidecars). The
endpoint re-extracts inline (RescanMediaItem) and reports rescan
failure separately — a failed rescan doesn't undo the write. SHA-256
changes are inherent and documented for users (Kobo/KOReader re-key).

UI: the metadata editor modal carries the file path as a data
attribute; the button confirms before mutating, labels the target
(embedded vs sidecar) and toasts the outcome. Integration tests pin
the 403, missing-file error, EPUB embed + post-write rescan title, the
lone-book metadata.opf case, and the shared-dir <basename>.opf case
with no dir-scoped litter.
2026-10-27 15:36:43 -04:00
John O'Keefe 61e5f47ec0 feat(search): viewer progress + completed badges; dropdown to templ
Search results now show where you are in a book: a completed check at
the 0.999 threshold (same cut as the filter status arms), a partial
progress bar along the cover while mid-read, nothing when you never
opened it. Rendered in BookCard (any grid whose adapter loads viewer
progress) and in the header-search dropdown rows.

The progress rides a LEFT JOIN reading_progress on the SAME user_id
param SearchMediaItemsUnified already binds for library visibility —
one requester param, no new wire inputs, row-neutral join (the table
is UNIQUE per media_item+user). SearchMediaItemsUnifiedRow and BookInfo
gain the keys additively (viewer_percentage null when unknown,
library_name empty on feeds that don't load it); contract_pins.md
carries the changelog entry and the pin tests widen in lockstep.

The header dropdown itself migrates off the innerHTML string renderer
in search.ts (a rendering-ownership violation: a templ twin existed via
the HX-Request branch). The input now hx-gets layout=dropdown and swaps
the new templ SearchResults partial — titles/authors highlighted
server-side (escape-first, safe by construction), rows with the same
progress overlays — while a slim Alpine component owns only
client-local state (min-length gating, open/close, keyboard selection,
htmx lifecycle). web/src/rendering-allowlist.txt drops the search.ts
baseline to zero accordingly.

Integration test pins the per-user join (fresh user sees 1.0/0.42/null
on the fixture; the shared admin sees null everywhere — read-only) and
the dropdown partial's overlays/count/row count.
2026-10-27 15:11:49 -04:00
John O'Keefe d1f9ad3b25 feat(series): sort and filter the series page
The /series page rendered its grid in one fixed order (newest first)
with no way to find a series beyond eyeballing cards. The page now has
a toolbar — series-name search, author filter, viewer-progress filter
and a 12-key sort select — wired as a plain GET form (the page's
existing model: the URL is the single source of truth, and the library
switcher now carries q/author/status/sort along on switch).

Extensibility is deliberate (user request): sorts live in a registry
(services.ResolveSeriesSort) mirrored by a CASE cascade in
GetDistinctSeries and a <select> in series.templ — a new sort is one
entry in each. The sort value is a BOUND PARAM compared inside the
cascade, never interpolated; unknown keys resolve to the default.
Filters: series-name matches in WHERE (the group key, so counts stay
true); author (BOOL_OR any-book match) and status (finished/reading/
unstarted vs the viewer's progress, same 0.999 threshold as the
filter_query status arms) in HAVING so COUNT(*) keeps reporting the
whole series. GetDistinctSeriesCount shares the JOIN + HAVING so
pagination totals match. The reading_progress join can't multiply rows
(the table is UNIQUE per media_item+user).

Cards gain the viewer's finished-count ("X of N read" + progress bar),
and the empty state distinguishes "no series at all" from "no matches —
clear filters". The JSON /api/series endpoint takes the same params and
gains read_count/last_read_at additively.

Integration test pins every sort key against a three-series fixture
with distinct created/author/count/progress answers, the filter matrix,
composition, unknown-sort fallback, card read-counts and the JSON
contract — all as the fresh regular user so the live reader's data is
untouched.
2026-10-27 14:53:45 -04:00
John O'Keefe c71ef4af85 fix(auth): show error feedback on failed htmx login/registration
Found while double-checking /login after the redirect fix: a wrong
password left the form completely silent. Two stacked causes, both
pre-dating the CSP work:

1. The two HX-Request branches for invalid credentials returned JSON,
   unlike every sibling error branch — there was no HTML partial to
   show at all. They now return a shared invalidCredentialsHTML div
   (the JSON shape is kept for non-htmx API callers).
2. htmx does not swap 4xx/5xx responses by default, so even the HTML
   error partials that DID exist (429 lockout, 500s, registration
   failures like "Email already exists") never landed in the target.
   auth_forms.ts now swaps them itself on htmx:beforeSwap when the
   error response is text/html — JSON errors stay ignored.

Verified live: /login success → /dashboard; wrong password shows
"Invalid email/username or password." and stays on /login; duplicate
email on /register shows "Email already exists". Gates: make test 9
pkgs, vitest 155, Playwright 20.
2026-10-27 14:05:36 -04:00
John O'Keefe 7c9e561e68 fix(auth): login/registration forms redirect again under hash CSP
The htmx login/registration partials answered success with an inline
<script> that stashed the auth token and set window.location — refused
by hash-mode script-src (no 'unsafe-inline'), so the user saw "Login
successful! Redirecting..." and nothing else (reported from the home-
page login form; /login was equally broken). The same pattern lived in
collections.go (delete → redirect) and devices.go (token regen →
reload, whose script tag was also unclosed).

Replacement, per the AGENTS.md CSP rules:
- auth.go login/registration: the partial now carries data-auth-token +
  data-redirect (redirect is path-only — open-redirect hygiene — and
  escaped); web/src/auth_forms.ts (new, armed from main.ts on every
  page) listens for htmx:afterSwap, stashes the token, and navigates.
- collections.go delete: data-redirect, same listener.
- devices.go token regen: htmx's native HX-Refresh response header.
- The dead localStorage 'user'/'refreshToken' writes are dropped — no
  reader anywhere in web/src; only 'token' is load-bearing (19 modules).

Tripwire extension: internal/middleware/csp_go_sources_test.go scans
non-test Go sources for literal <script> tags and native on*= attributes
(hand-written files only — templ codegen is owned by the .templ scan),
verified to fire on a reintroduced probe. AGENTS.md CSP section notes
the Go-emitted-partial vector.

Verified live: home-page login form → /dashboard with token stashed and
dashboard loading clean; /register form → /dashboard; collections and
devices share the same mechanisms (data-redirect / HX-Refresh). Gates:
make test (9 pkgs incl. both tripwires), vitest 155, Playwright 20,
tsc/vp clean.
2026-10-27 13:57:41 -04:00
John O'Keefe 0f1d0abbb0 feat(libraries): scope a library scan to one registered folder
POST /api/libraries/:id/scan accepts an optional {"folder_path": "..."}:
the scan then covers exactly that one registered folder's subtree instead
of the whole library. The path is validated against the library's
registered folders — anything else is a 400, never trusted. An omitted
body keeps today's full-library behavior, and coalescing (409 with the
active job) is unchanged.

The LibraryPanel folder rows get a per-folder Scan button: pure htmx
attributes posting to the same scan endpoint, with hxValsFolderScan
emitting plain eval-free JSON for hx-vals alongside the existing
hxValsFolderDelete precedent. Progress surfaces through the existing
active-scan polling; no new client code.

Safety: the scanner's archive-reconciliation guard (previous commit)
skips partially-covered libraries, so a one-folder scan cannot mark the
library's other folders' items missing.
2026-10-27 13:22:41 -04:00
John O'Keefe 6f37be59fc feat(issues): retry a processing issue through the individual scanner
POST /api/libraries/:id/issues/:issueId/retry (admin, family-lens gated
via the existing gateIssue) re-runs the work that produced an open issue
— deliberately NOT a library-wide rescan. Two shapes mirror the two
ledger entry kinds:

- item-backed issues rescan their media item through the book-detail
  RescanMediaItem mechanism (self-resolving folders, multi-file
  audiobook routing, write-after-success guards) and then close the
  issue (RetryMediaItemIssue);
- row-less import_failed issues re-import the single file through the
  watcher's standalone processMediaFile path (RetryFailedImport: folders
  resolved RescanMediaItem-style so paths stay library-root-relative),
  whose create-path self-heal plus an explicit path resolve closes the
  issue even when the content was already imported by a prior attempt.

Failures return the REAL underlying error so the UI can show why the
issue still exists; a vanished file is a 409 (ErrRetryFileMissing);
already-resolved issues reject re-retry.

Docs + bruno mirrors regenerated for the new route (the generator's seq
renumbering touches three unrelated bruno files as fallout; openapi
gains the path and the date bump), and the docs schema registry gains
the retry entry so TestDocsMatchRoutes passes.
2026-10-27 13:22:31 -04:00
John O'Keefe c645b8e207 feat(profile): keep_screen_awake user preference (default on)
New users column surfaced as an On/Off dropdown in the profile form.
The form and API carry "true"/"false" strings with the theme-style
""-guard, so JSON clients that omit the field can't clobber it;
anything else is rejected. GET/PUT /api/auth/profile expose
keep_screen_awake alongside the other profile fields, and the reader
route threads the preference into its template metadata.

Integration tests pin the schema default, both values, omission
semantics, and rejection — dedicated test user, never testuser.
2026-10-26 13:59:32 -04:00
John O'Keefe bdb9132b85 chore(auth): drop dead UpdateProfileRequest; rename shared struct
The combined profile handler (self-edit PUT /api/auth/profile and
admin PUT /api/auth/profile/:id) binds one struct for both paths —
and it's the admin-named one. Its twin UpdateProfileRequest had zero
references left. Delete it and rename AdminUpdateUserRequest to
ProfileUpdateRequest so the name matches who actually binds it; the
comments describing field semantics now stand on their own instead of
pointing at the deleted twin. Wire format (json/form tags) unchanged;
docs schema registration follows the rename.
2026-10-26 13:59:22 -04:00
John O'Keefe d41c3e4774 fix(ui): hide Comic/Manga editor section for non-comics media
The metadata editor's Comic/Manga section (Manga Type, Reading
Direction, Age Rating, Story Arc, Imprint, Scan Info, Metadata
Notes, B&W) rendered for every item — an audiobook showed a manga
dropdown, which is nonsense.

MediaDetail now carries LibraryTypeName (resolved via
GetLibraryType in the /media/:uuid handler) and a
ShowComicMetadata() gate: comics and manga libraries always show the
section; other media only when the scanner recorded comic-style
values (fixed-layout manga-style EPUBs get those scanner defaults
deliberately — when present they stay editable).
2026-10-25 12:08:26 -04:00
John O'Keefe a716b386a1 fix: wire security headers, graceful shutdown, and settings invalidation
Lifecycle (internal/app + main.go):
- App.OnShutdown hooks run after the HTTP server drains, in
  registration order: Worker.Shutdown (background jobs now drain
  instead of dying on SIGTERM), DeviceAuthMiddleware.Stop (releases
  the rate-limiter ticker), Handler.CloseScanner (fsnotify watcher
  and scan log files)

Security headers:
- SecurityHeadersMiddleware (survivor of middleware/security.go,
  renamed security_headers.go) is now on the echo stack: nosniff,
  frame-deny, HSTS, CSP (unsafe-inline required by the sanctioned
  theme flash guard + Alpine), referrer and permissions policy

Scanner settings cache:
- settings saves (UpdateScanSettings + ApplySetting for the three
  scan keys) now invalidate MediaScanner's 30s settings cache via
  SystemSettingsHandler.SetScanSettingsChanged, so changes apply on
  the next poll instead of after the TTL
- MediaScanner.Close closes the scan logger, fixing a file-descriptor
  leak on every per-item rescan (fresh scanners never closed logs)

Client fix:
- custom-section-builder's add/remove-selection buttons used inline
  onclick attributes referencing module-scoped functions, which are
  not global in a bundled ES module: the buttons threw ReferenceError.
  Functions restored and exposed on window via declare global

Also fixes a pre-existing vet error (saveCoverImage copied
echo.Context by value, mutex included) and drops the per-key
SettingsCache.InvalidateKey variant left callerless by the
full-flush invalidation wiring.
2026-10-25 11:06:26 -04:00
John O'Keefe 8631aa00bf refactor: remove dead code across server, templ pages, and client modules
Deadcode-verified removals (RTA reachability from main, cross-checked
with -test and textual xrefs):

Go server:
- whole files: middleware/transaction.go, middleware/error_handler.go,
  middleware/password_validator.go (live registration machinery kept),
  handlers/sync.go, sync/offline.go
- unrouted endpoints: auth UpdateUsername/UpdateEmail, timezone
  settings handler, OPDS RegisterOPDS, Kobo LibrarySync handler,
  ReaderHandler.ShowReader, JSON-data twins on collections/library
- duplicate book-matching handlers (book_matching.go Handler twins,
  matching.go MatchingHandler twins), superseded helpers
  (router.parseUUID, handlers.ValidateDeviceToken, normalizePath,
  userExists, generateLockID)

Templ/client:
- never-routed pages: api_explorer.templ, queue.templ and their
  client modules api-explorer.ts, api-explorer-docs.ts, queue.ts
- fully orphaned wrapper modules events.ts, dom.ts
- broken clearSyncQueue (called a nonexistent endpoint) and the
  hidden, never-populated sync-queue section on the devices page
- 166+ unused exports demoted to private (knip-verified); ~85 truly
  dead declarations removed via tsc noUnusedLocals fixpoint
- stale 'interactive API Explorer' claims dropped from docs

The docs drift-gate package (internal/docs), templ-bound modules
(dashboard, stats, header, index, login, profile*, bookshelf,
collections, toast-error), sqlc-generated code, and web/static are
intentionally untouched.
2026-10-25 11:06:08 -04:00
John O'Keefe 8733e83f1f style: apply gofmt across Go sources
Repo-wide gofmt sweep: const-block alignment, doc-comment spacing,
statement-after-brace fix in media.go, trailing blank lines. No
semantic changes — whitespace-insensitive diff is empty for every
file in this commit.
2026-10-25 11:05:37 -04:00
John O'Keefe c4b62af213 docs(devices): Work Order 3 batch 1 — 6 missing device endpoints + gate entries
New frontmatter'd docs with handler-compatible examples:
- devices/get_device.md (DeviceInfo shape)
- devices/regenerate_device_token.md (token + sync_urls)
- devices/get_device_mappings.md (wrapper + DeviceMappingSummary)
- devices/update_device_mapping.md (UpdateDeviceMappingRequest)
- devices/download_sidecar_config.md (sidecar config JSON file)
- devices/link_book.md (LinkBookRequest; one UNVERIFIED marker on required-tag)

Schema registry: 6 new entries; DeviceMappingSummary hoisted to package
scope. Gate passes (the UNVERIFIED marker is logged, not a failure).

Excludes /api/devices-page (htmx page route, per ground rules).
2026-10-24 14:46:22 -04:00
John O'Keefe cc893f758e feat(docs): drift gate — docs frontmatter validated against real routes
Every API doc that declares frontmatter (method/path/auth/area) is now
checked by a unit test against three sources of truth:

1. Route registration: internal/router/*.go is parsed the way echo resolves
   it (group prefixes concatenate; scanner.go's parameterized groups seeded).
   Doc paths that no longer exist fail. (This is the check that would have
   caught the /api/api/admin/hash-conflicts split.)
2. Auth: frontmatter auth label must match the middleware chain
   (none|jwt|jwt+admin|device-token|optional-jwt).
3. Shapes: internal/docs/schema.go registry — example-request decoded with
   unknown fields DISALLOWED (typo guard), required-by-validate fields must
   appear (staleness guard), example-response top-level key set compared
   both directions against the marshaled handler type (or a RawMessage pin
   for hand-built map responses).

Five collections docs are the pilots with real handler shapes; notable
corrections they pin: update returns 201 (not 200), list excludes smart
collections unless include_auto=true, rule priority is 1-10 higher-wins
(docs claimed 1=highest), created_at is Go time.String() not RFC3339,
hide_finished/show_up_next toggles documented. CollectionResponse hoisted
out of GetCollections/GetBookCollections as CollectionSummary /
BookCollectionSummary so the registry can pin real types.

The gate fails on missing registry entries by design — that is the
maintenance contract. Known pre-existing (not this commit): vet copy-lock
warnings in media.go.
2026-10-24 14:41:02 -04:00
John O'Keefe 3576a4415a feat(audio-player): per-book playback speed + user default (plan §8)
Speed becomes a per-USER-per-BOOK preference that syncs across
devices, with a user-level default for books not yet played:
- new audio_playback_speed table (mirrors reading_speed's shape; a
  preference, NOT a measurement and NOT a reading_progress column) +
  users.default_audio_speed, schema in place
- chapters endpoint resolves per-book row → user default → 1.0 and
  ships playback_speed + default_playback_speed (zero extra round
  trips; default powers the dropdown marker)
- PUT /api/media-items/:id/audio-speed upserts (range-checked; the
  client SPEEDS list stays the real gate; library visibility gates
  writes)
- the user default rides the profile path like theme/timezone:
  profile form gains a 'Default Playback Speed' select (server-side
  SpeedOptions mirrors the web SPEEDS list), GET /profile carries it
- web: restorePosition applies the resolved server speed (localStorage
  audio_speed demotes to a first-paint cache); setSpeed PUTs
  fire-and-forget with a failure toast; the Speed dropdown gains a
  '(default)' row marker and a 'Make N× my default' action with a
  success toast

ALSO fixes a pre-existing profile-page bug surfaced by the §8 form
verification: the password section's current_password input was
required INSIDE the shared form, so HTML5 validation blocked every
Save Changes submission (dead since the shell migration). The
password flow keeps its own gates (disabled-button checkers + server
400).

Integration test TestAudioPlaybackSpeed pins the full contract (unset
→ 1x, upsert resolution, range rejection, default fallback for
row-less books, default updates re-satisfying unset books). Verified
live: 9 Playwright checks.
2026-10-22 15:01:41 -04:00