Compare commits

..
115 Commits
Author SHA1 Message Date
John O'Keefe fbdd4ab9f9 feat(audio-player): Resume Rewind select in the settings popover
Release / build-and-push (push) Successful in 4m43s
The player window could only change Skip and Speed; resume rewind
lived exclusively on the profile page. The user-facing settings now
match Speed's precedent — changeable from the player, saved to the
account, applied immediately.

The select mirrors the default-speed row (CSP-safe @change bound to
one Alpine method; templ emits the option list from the shared
ResumeRewindOptions allow-list and the :selected expression against
the live resumeRewind field). Applying updates the running player's
value first — the next open/unpause uses it with no reload — then
PUTs audio_resume_rewind_seconds to the profile endpoint as the
string wire shape the server binds. normalizeResumeRewind gates the
select's value through 0/5/10 before both steps.
2026-11-01 21:29:19 -05:00
John O'Keefe ef827a3a05 test(api): pin the resume-rewind contract
Release / build-and-push (push) Successful in 3m2s
TestAudioResumeRewind drives the whole surface against the shared dev
DB with the fresh per-test regular user (the live admin is never
touched; the fixture book lives in a test-named library that cascades
away): default 0, profile PUT "5" rides the chapters payload,
non-allow-listed values 400 without side effects, explicit "0"
resets everywhere, and an omitted JSON key leaves the column
untouched.

Changelog entry in docs/contract_pins.md (additive chapters key +
the profile field's string-shaped PUT, keep_screen_awake precedent)
and the get_profile.md example gains the preference keys.

Debug note for the next agent: the first runs here 401'd with
'Ghost session rejected' — NOT a harness or auth bug. The tests never
execute schema.sql; the shared dev DB gets its schema from the app
container's boot. The rebuilt tests image ran the new GetUser
(selecting audio_resume_rewind_seconds) against a DB whose schema
came from the old app image — every GetUser errored, and the
ghost-session guard turned that into 401s. Applying the schema.sql
ALTER to the dev DB (what the next app rebuild does anyway) turned
the suite green. Until make rebuild-app runs, the running app
container serves the old binary.
2026-11-01 19:38:08 -05:00
John O'Keefe 7b7ce39826 feat(audio-player): apply the resume-rewind setting on open and unpause
The chapters payload now carries resume_rewind_seconds (server-side
per-user setting); the player applies it at two events, each rewinding
once from where playback currently sits — never stacked:

- open-resume: restorePosition starts the rewind early from the saved
  spot (percentage and chapter+offset branches, clamped at the book
  start). Deep links (?loc=) stay exempt — an explicit jump is not a
  resume.
- unpause: a genuine pause→play steps back again (the re-orientation
  gap). Programmatic plays — post-restore autoplay and track-switch
  autoplays — set a suppress flag: their play event is the session
  start the open-rewind already positioned, not an unpause.

Math lives in audio-rewind.ts (pure, unit-tested) after the
audio-scrubber pattern; payload values gate through the 0/5/10
allow-list so stale or foreign servers degrade to exact resume.
2026-11-01 17:21:35 -05:00
John O'Keefe 9cf9e1d305 feat(api): expose and validate the audio resume rewind setting
GET/PUT /api/auth/profile now carries audio_resume_rewind_seconds
(always-present on the response: 0 is a real value, exact resume; the
request shape is the keep_screen_awake string precedent so the form
select can send an explicit 0=Off while JSON clients that omit it
leave the column untouched — a plain int with an omitempty contract
could not express 'set to off'). Strict allow-list {0,5,10}: the UI
offers exactly three choices and the API refuses everything else.

The audiobook chapters endpoint embeds resume_rewind_seconds in its
existing payload — it already loads the user row for playback speed,
so both players (web + Android, phone UI and the Android Auto service)
get the value with the fetch they perform anyway; zero extra round
trips.

Profile page gains the Resume Rewind select (Off / 5 s before /
10 s before) next to Default Playback Speed.
2026-11-01 17:19:58 -05:00
John O'Keefe daffe499eb feat(users): audio_resume_rewind_seconds column
Per-user resume rewind for the audiobook/podcast player: how many
seconds (0/5/10) before the saved position playback starts, both on
open-resume and again on every genuine unpause. Motivated by the car
startup case — exact resume loses the listener's place, and the
PlaybackRequestResolver path (phone UI, Android Auto, media buttons)
all funnel through one start position.

0 = exact resume (default), so the column is inert until the profile
page sets it. Follows the default_audio_speed / keep_screen_awake
precedent: idempotent ALTER in schema.sql (re-runs on every boot),
explicit GetUser SELECT, dedicated :exec update. Handler-side strict
allow-list {0,5,10} lands with the API slice.
2026-11-01 17:16:35 -05:00
John O'Keefe 0d613ba0e3 fix(docs): folder-index pages expand the sidebar and highlight — same rule for every page
The filter manual is the docs tree's first FOLDER-index page: it serves
at /docs/user/filters while its nav entry carries /index
(/docs/user/filters/index). The sidebar's two checks — group expansion
(containsURL) and active-link styling (docsNavClass) — compared URLs
EXACTLY, so the folder URL matched neither: no submenu expansion, no
highlight (2026-11-01 user report: "the left sidebar should open to
the submenu and highlight the page").

Fix is structural, not per-page: both checks normalize folder-index
URLs (a trailing /index is the same page as its directory), so EVERY
folder-index page — current and future — expands and highlights like
a file page. No per-page registration exists anywhere in the docs
system to redo: the sidebar derives from the docs tree, and this was
the one URL shape its equality rule missed.

Pins: navigation (section + group expand for the folder URL, group
present) and docsNavClass (folder URL highlights the Overview item,
normalization symmetric, file pages keep their highlight, no
cross-highlighting).
2026-11-01 16:34:25 -05:00
John O'Keefe a4dc03984c feat(docs): "?" help affordance on every filter mount + docs loader fixes
The filter manual (1ed1602e) was reachable only by knowing /docs
already existed — nothing in the UI pointed at it (the parked manual
plan's open decision). Every filter-builder mount now carries a "?"
in its header linking to the guide (opens in a new tab so drawer and
editor state survive): the All Books drawer and the collection rules
page — the two mounts sharing FilterBuilderRows. The docs home
indexes the manual (guide, cookbook, generated field reference).

Two docs-loader fixes surfaced by pinning this:
- Folder URLs resolve to their index page: /docs/user/filters now
  serves user/filters/index.md (the manual is a folder: guide +
  cookbook + generated reference); previously only root /docs had
  index handling.
- The docs folder path is resolved by walking upward (bounded):
  `go test` runs test binaries from the PACKAGE directory, so the
  relative "docs" path resolved to a folder that didn't exist there
  and every /docs route 404'd in tests while working in prod —
  found by the new pin, fixed so tests exercise the docs routes
  exactly as prod serves them.

Pins: TestFilterHelpAffordance (folder URL serves the guide; drawer
and rules pages carry the link). internal/docs suite + full suite
green (239s).
2026-11-01 16:22:35 -05:00
John O'Keefe 1ed1602e70 docs(filters): user manual — guide, cookbook, generated field reference
The filter manual (docs/user/filters/), folded in from the parked plan
now that the UI matches the backend (group ALL/ANY header toggle,
2344f568):

- index.md — the guide: the three homes of filters (drawer/saved
  filters, smart collections, dashboard sections), the books/episodes
  scope door, rule anatomy, value picking (chips with x, live counts
  under other rules), GROUPS via the new header toggle (+ the row
  chips as the fine-tuning tool), the live match count, today's
  defaults (reversed listening order, family-lens scoping,
  un-downloaded episodes), saving and shareable views.
- cookbook.md — seven exact-clicks recipes (born-reversed briefing,
  OR-across-authors, big-unread-fantasy, downloaded-unplayed,
  new-arrivals, dashboard shelf, shareable views). Every claim
  cross-checked against the generated reference — which caught the
  one wrong operator label in my first draft ("within last" vs the
  real "within the last"), the drift protection working as designed.
- fields.md — GENERATED from FilterFieldsPayload by
  TestFieldReferenceDocIsCurrent: the drift test fails the build if
  the registry ever changes without regenerating the docs (user
  decision: docs forced to match working code; FILTER_DOCS_REGEN=1
  regenerates).
- searching-and-filtering.md cross-links to the folder.
2026-11-01 15:46:34 -05:00
John O'Keefe 2344f5686a feat(filter-builder): the group header is the ALL/ANY switch
The group header ("MATCH"/"GROUP") was a dead label while the engine
honored group modes all along — buildGroupClause parenthesizes nested
groups and joins each by its own mode, and validation accepted
all/any from day one. The only thing missing was the UI: + Group
birthed ALL groups and no control anywhere could flip one, so the
user's target structures (GROUP(GROUP(... or ...) ...) — 3-level
mixed-mode nesting) were constructible only through the invisible
row-level chips, and standalone ANY-groups not at all (2026-11-01
user report: "MATCH cannot be clicked").

- The header is now the control: MATCH ALL ⇄ MATCH ANY (root — a
  synthetic header row, since the root was never projected and its
  mode was invisible too), GROUP ALL ⇄ GROUP ANY (nested). ANY is
  accented; aria-pressed carries the state. Labels are path-based
  (root reads Match, explicit groups read Group) instead of
  depth-based, which previously mislabeled top-level groups.
- The row-level chips are untouched — they mix AND/OR inside a group
  (A AND (B OR C)), which a global mode toggle cannot express; the
  header is the discoverable entry point, the chips the refinement.
- Dropdown audit (the "would it bug out" check): suggestTree is
  mode-generic — root-ANY drops group siblings, sibling ANY-groups
  survive into the constraint with their mode intact, empty groups
  never emit (no 400s) — now pinned. Deep-nesting Go pin: a 3-level
  mixed-mode tree evaluated through composed search + the suggestion
  endpoint (200, never the empty-group 400). 192 web tests green.
2026-11-01 15:43:52 -05:00
John O'Keefe 5fb80f82ed test(podcasts): the briefing acceptance plays the born-reversed order
Release / build-and-push (push) Successful in 3m32s
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).
2026-11-01 13:55:49 -05:00
John O'Keefe 57bc0e9dd4 feat(collections): the rules-Apply path seeds the reversed default too
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.
2026-11-01 13:44:23 -05:00
John O'Keefe 96a40f7d88 feat(filter): label endpoint names id values for client-held trees
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.
2026-11-01 13:40:25 -05:00
John O'Keefe a20741ccc9 feat(filter-builder): multi-pick values render as removable chips
The id-kind value cells (feed/library/collection/added_by) rendered
their picks as one comma-joined string inside a 9rem-wide input — you
saw the first label and nothing else, and reading 3-5 picks meant
scrolling inside a tiny box (worst on mobile, user request 2026-11-01).

Id-kind cells now render a chipbox: each picked value is a chip (label
truncated with a hover title, removable via an X button with a finger-
sized tap target), the box flex-wraps and grows vertically instead of
clipping, and the inline input stays at the end for typing/searching —
including the Backspace-on-empty-removes-last-chip affordance. Text/
number/date cells keep today's single input; the value menu is
unchanged (it never offers an already-picked value, so the chip X is
the faster removal path, not a new one).

Presentation only: the stored wire is still one comma-joined value per
rule; matching, menus, apply, and label resolution are untouched. Pins
moved from valueCellText (now text-cells-only) to the chip model
(idChipsFor/idDraftFor) and cover removal order, last-chip removal, and
Backspace; 187 web tests green.
2026-11-01 13:33:34 -05:00
John O'Keefe d56031f334 feat(collections): new podcast smart collections are BORN reversed
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.
2026-11-01 11:53:24 -05:00
John O'Keefe 0e29eff7b7 feat(podcasts): unset listening order defaults to reversed (audiobook-first)
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.
2026-11-01 11:46:14 -05:00
John O'Keefe 65f257f377 fix(filter-builder): id labels resolve at render — refresh keeps names
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.
2026-11-01 10:44:48 -05:00
John O'Keefe c448fa438f fix(filter-builder): one operator name per field (Option A); drawer ids resolve on refresh
The is/in question, settled per user decision: they were exact
synonyms (feed's clause compiles is/is_not and in/not_in to the same
ANY), so the vocabulary collapses instead of being explained.

- Registry: feed's offered ops are in/isn't-in (+has/missing);
  legacyFieldOps keeps is/is_not ACCEPTED at parse forever — every
  stored tree and saved filter from before the collapse parses and
  evaluates unchanged. Library keeps is/isn't (its only names);
  collection's genuinely distinct operators are untouched.
- Client: id-kind cells are uniform — every operator on an id field
  accepts a set, so all id cells say "pick one or more" and accumulate
  picks. The per-operator placeholder/pick branching is gone (it also
  never fired: the registry types is as Values:"many").
- The drawer-refresh id fix rides along: hydrateFromForm now calls
  resolveIdLabels, so a page refresh with an active saved filter
  resolves stored ids to names in the bookshelf drawer (the collection
  rules page already did at init).

Pins: feed payload omits the synonyms while legacy feed is/is_not
trees parse (Go unit); uniform accumulate + placeholder (vitest);
menu-open regression and hydrate-resolution pins kept. Web 183/183;
services, filter + episode-tree + search/collections contract slices
green (full suite skipped per user — vocabulary + UI-layer change,
engine paths pinned by the targeted slices).
2026-11-01 01:16:20 -04:00
John O'Keefe 81e54f89b2 fix(collections): the order select re-orders the PAGE — visible order = play order
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.
2026-11-01 00:49:04 -04:00
John O'Keefe 178b1f30c5 fix(filter-builder): menu opens to the whole list again; arity-aware cells
Regression from the id-label resolution (09f8f087): opening an id-kind
value menu carried the STORED ids as the typed filter, and the new
id-exact-match narrowed the menu to exactly the already-picked rows —
nothing else was visible, so "can't add more than one" (and clicking
the lone visible row unpicked it). The ids now ride as the typed
lookup ONLY in quiet mode (label resolution); a real menu open browses
(blank) or filters by the typed NAME draft.

The multi-value affordance is now ARITY-AWARE, per user request: the
op decides how many values the cell takes, and both the hint and the
pick behavior follow it — "in" says "pick one or more" and toggles
ids into the comma list; "is" says "pick one" and REPLACES the value.
Changing the operator updates the hint in place.

Pins: menu-open after a pick must not carry the stored ids (the
regression), one-arity pick replaces instead of toggling, and the
placeholder follows the op's arity. 38 filter-builder vitest cases;
web suite 183/183; container filter + episode-tree slices green.
2026-11-01 00:30:41 -04:00
John O'Keefe cce488b76f fix(filter-builder): multi-pick id values matched nothing; hint the multi boxes
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.
2026-11-01 00:20:31 -04:00
John O'Keefe 09f8f0875f fix(filter-builder): id labels survive Apply — stored ids resolve to names
The library cell reverted to the raw uuid after Apply: the apply flow
reloads the page, the SSR-preloaded tree re-ingests, and idLabels —
populated only by suggestion PICKS — starts empty, so valueCellText
falls back to the id. Same on any fresh load of a stored id-rule.

Two halves:

- Server: the id-source suggest query's typed filter (library/
  collection/added_by/feed) now also exact-matches a comma-separated
  ID list alongside the name ILIKE — so a suggest call with the stored
  ids returns their labeled rows. No new endpoint; the menu and the
  resolver share one surface.

- Client: fetchSuggestions gains a QUIET mode (fills idLabels, touches
  no menu state), id rows fall back to their stored ids as the typed
  lookup when no draft is present, and init() calls resolveIdLabels()
  after tree ingest — one quiet fetch per id-kind rule resolves every
  stored id to its name from the first paint after reload.

Pins: the Go side (suggest with a library's UUID returns the row with
the library NAME as display), the client side (resolveIdLabels fills
labels, the cell renders the name, no menu opens). 35 filter-builder
vitest cases green; container filter slice green.
2026-11-01 00:11:57 -04:00
John O'Keefe ed42111df1 fix(filter-builder): OR-aware suggest constraints (user correction)
The previous fix was too blunt: under OR it sent NO constraint tree at
all, so "library = ebooks" stopped narrowing the author menu the
moment an OR-connected author rule appeared. The user spec is subtler:

  library = ebooks AND (author = Lewis Carroll OR author = ?)
  → the menu shows EBOOK authors, with Lewis Carroll OFF the list.

suggestTree now walks root → edited rule and builds one conjunctive
chain:

- ALL-group siblings stay (AND narrows: the ebook scope persists);
- ANY-group siblings drop — they are independent alternatives under
  OR and must not narrow;
- EXCEPT a same-field ANY sibling, whose picked values NEGATE into
  the chain (contains → not_contains, is → is_not, in → not_in): the
  picked value leaves the menu, everything else in scope stays;
- an ALREADY-NEGATED sibling picks nothing to take off, so it drops
  rather than flipping positive (a positive flip would narrow);
- a field without a negation twin falls back to dropping;
- incomplete rules never constrain (not filters yet), and the edited
  rule itself never constrains its own menu.

Pins: the user's exact tree (ebook scope kept + Lewis Carroll negated
off), the root-OR variant (different-field sibling drops, same-field
negates), the twinless fallback, plus the existing AND/OR cases —
34 filter-builder vitest cases green, container filter slice green
(TestFilterSuggestionsUnderRules unaffected: the server contract is
unchanged, only which qf the client composes).
2026-10-31 23:48:04 -04:00
John O'Keefe c52b514036 chore: formatting sweep — vp fmt on drifted web files, templ regen
Pre-existing format drift in seven web/src files (blank-line and
indent normalization by vp fmt) plus templ generate normalization
across the generated _templ.go files; both swept up while committing
the filter-builder fix. No behavior changes.
2026-10-31 23:27:21 -04:00
John O'Keefe 28efaf3fe9 fix(filter-builder): id cells showed raw ids; OR mode starved the value menu
Two user-reported builder defects:

1. Id-kind value cells (library/collection/feed/added_by) rendered the
   stored UUIDs in the input box. The menu offered names (s.display)
   and picking stored the id correctly — but the cell bound
   rule.values.join(',') raw. The cell now renders through
   valueCellText: idLabels give the NAMES; typing drafts a name filter
   in idDrafts (the stored values stay ids — the clause matches ids —
   and are updated only by picking). Text/number/days cells are
   unchanged.

2. The value menu ignored the connective: fetchSuggestions always sent
   the sibling rules as the qf constraint, so with "Author contains
   Lewis Carroll" present, flipping to OR still starved the author
   menu down to Lewis Carroll. Under OR the siblings are independent
   alternatives — a new rule's candidates must not be narrowed by
   them. pathUnderAll now checks the edited rule's group path: under
   AND all the way up, siblings constrain as before (helpful
   narrowing); under any OR on the path, NO tree rides and the menu
   browses the whole scope.

Server contract unchanged — the suggest endpoint already treats a
missing qf as an unconstrained browse; the fix is client-side
mode-awareness. Pins: 6 new vitest cases (AND sends siblings, OR sends
none, id label display, draft typing never touches stored ids, pick
toggles + relabels, text passthrough).
2026-10-31 23:27:21 -04:00
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 aaf57a66a2 fix(ui): cookie-authed users' writes silently stranded behind bearer gates
THE CLASS: the /api JWT middleware's TokenLookup is
"cookie:token,header:Authorization:Bearer" — the token cookie always
rides same-origin fetches, so a signed-in user never needs the
localStorage bearer. But a dozen call sites gated their fetch on
`localStorage.getItem("token")` and early-returned when absent. The
global 401 interceptor CLEARS that localStorage entry on any session
rotation/blip — after which every gated write silently did nothing:
theme changes reverted on reload (the reported "can no longer change
themes", on top of the interpolation bug d5dbaa48 fixed), chain-order
selects, collection deletion, Add-to-collection submits, filter
save/load/delete, conflict actions, linking actions, stats charts,
tag suggestions, device management, admin scanner actions — all dead
for perfectly authenticated cookie users.

Fix: storage.ts exports authHeaders() (bearer when present, cookie
carries otherwise) and every gate/header site now uses it — theme.ts,
collections.ts (htmx auth, doRemoveBook/doBulkRemove, setChainOrder),
bookPicker submit, filter_builder suggestions + match count,
collection-rules apply hook, bookshelf save/load/delete filter,
conflicts x4, linking x3, admin scanner actions + polls (requireToken
removed — its "Session expired" toast fired for cookie users too),
dashboard preferences/sections, custom-section-builder, header
scanner poll, stats x3, tag suggestions, profile delete-account
(which also wrongly redirected cookie users to /login), and
device-management x4. Reads keep their graceful-empty behavior; the
bearers still ride along when present.

Pinned: the knob-matrix theme spec now REMOVES localStorage.token and
asserts the theme PUT still fires (cookie-only) and the class flips —
the exact strand that started this.

Found by noticing the panel browser's Nord click switched the class
but produced no PUT: the persistence half was gated on a token the
401 interceptor had cleared.
2026-10-31 17:02:44 -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 d6619cd0e1 test(ui): the knob matrix — every control the round-4 defects lived behind
Six specs driving the user-reported controls the way a human does,
encoding the sweep rules COVERAGE.md now states: menus/modals assert
VISIBILITY (never DOM text — hidden menus' text is why the 2026-10-30
panel sweep certified dead controls), geometry asserts the z-ladder
via elementFromPoint and viewport containment via boundingBox, every
click must produce an observable outcome (PUT fired, class flipped,
chip text swapped) and zero new console problems — the silent
CSP-throw family (theme buttons, connective chip, invisible op menu)
now fails a test instead of surviving to a user's mouse.

Covers: theme picker (interpolation regression from eb57b720),
pencil-glyph vs card-body contract, builder field menu / op menu
(full non-empty vocabulary) / connective both directions, the
Smart→Episodes create-modal seed, the picker overlay's ladder and
height, and the chain-order select's persistence round-trip.

Fixtures self-create/self-delete under test-prefixed names; localStorage
token + selectedLibrary=__all__ are seeded because the API login
establishes only the cookie; run the folder with --workers=1 (the login
route rate-limits and all suites share testuser — a parallel worker's
login revokes another's mid-run).
2026-10-31 15:40:21 -04:00
John O'Keefe 8aa66a30fc docs(api): sync mirrors with the retired rules CRUD and the chain-order endpoint
Three mirrors had drifted:

- The rules-endpoint retirement (4f71eda6) deleted the routes but not
  their mirrors: removed docs/developer/api/collections/
  {add_auto_assign_rule,remove_auto_assign_rule,test_rule,update_rule}
  .md + their bruno twins, and pruned /api/collections/test-rules,
  /{id}/rules, /{id}/rules/{ruleId} from the generated openapi spec
  (hand-edited — regenerating with -update would wipe hand-authored
  bruno suites that predate the docs mirrors).
- New endpoint PUT /api/collections/:id/view-settings gets a doc page
  + schema-registry entry + bruno mirror (repo convention: the route
  and its docs land together).
- 54 pre-existing TestDocsMatchRoutes failures: the media-item
  endpoints emit the 16 episode/download fields (source_feed_id,
  feed_position, download_state, enclosure_url, hosts/guests, …) and
  libraries emit purpose/allow_member_feeds, but the doc examples
  were never extended — the package only runs on the host, so the
  container suite never caught it. Examples now carry every key the
  handlers emit; internal/docs is fully green.
2026-10-31 15:40:21 -04:00
John O'Keefe 4410a994ef fix(ui): silence the two benign cancelled-transition rejections
Round-4 console hygiene: fast navigation rejects with "AbortError:
Transition was skipped" (the browser's MPA view transitions —
@view-transition navigation:auto — when a navigation interrupts a
running crossfade) and Alpine's x-transition hide rejects with
{isFromCancelledTransition: true} when a re-show supersedes an
out-phase. Both are races the user cannot lose anything in, but they
print console errors on every quick click — and the knob matrix's
zero-console-error tripwire (the thing that catches silent CSP death)
depends on a clean console. A narrow global gate swallows exactly
these two shapes; every other rejection still reports.
2026-10-31 15:40:21 -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 79b47c9680 fix(collections): Close/Cancel removed the hidden confirm dialog, not the modal
closeCollectionModal picked document.querySelector(".fixed.inset-0") —
the FIRST overlay in DOM order, which is the sitewide #confirm-modal
that the header renders (hidden, display:none, but present). The real
create/edit modal stayed open: every Close and Cancel click on the
collection modals did nothing. Caught by the knob-matrix spec — the
pencil-glyph case clicked Close and the Edit modal persisted.

The buttons now pass $el and the overlay OWNING the button is closed
(closest(".fixed.inset-0")), falling back to #modal-container's child
for non-Alpine callers. Both the collection create/edit modal and the
restore-system modal take the argument.
2026-10-31 15:40:21 -04:00
John O'Keefe 21142cae5f chore(router): remove the CHAINDBG probe — the curated-order path is browser-verified
The debug print served its purpose: the stale-binary native-order render
was an artifact of an incomplete rebuild, not code. Fresh container
resolves view_settings.chain_order and the chain plays in the curated
order (newest-first verified on a 23-episode feed with a date tie at the
tail, handled by the position tie-break).
2026-10-31 14:08:54 -04:00
John O'Keefe fd716a7dab fix(filters): the episodes-scope vocabulary buried the episode fields under book-only metadata
The scope door's first live test showed the episodes editor leading its
field menu with People (Author/Narrator/Contributor) and Publishing —
book-only fields that can never match an episode row — while the fields
the scope exists for (Feed, Downloaded, Played) sat below the fold.
Root cause: the registry's book fields carry no Scopes restriction
(nil = all scopes), a leftover from before the episode fields existed.

35 unambiguous book-only fields (people, publishing metadata, topics,
series/comics, identifiers, cover, reading-specific statuses) are now
tagged Scopes:books. Title, description, published dates, language,
file facts, explicit, the my-reading group (listening progress is
real), and collection stay dual-scope. Parse continues to accept the
hidden fields everywhere (their composed clauses simply match nothing
on episode rows) — this governs the AUTHORING surface, not the
grammar.

TestFilterParseValidation's empty-root rejection updated to the new
contract (empty root legal — the seed; nested empty groups still
invalid), and TestEpisodesScopeHidesBookOnlyFields pins both scopes'
vocabulary.
2026-10-31 13:58:42 -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 d5dbaa48ea fix(ui): user-test round 4 — five browser-reported control defects
Every one of these was silent at the code level and loud in a real
browser; each fix carries its own tripwire (render pin, unit pin, or
CSP-safe rewrite) so the class cannot quietly return.

1. Theme picker dead since the CSP migration (eb57b720): the Appearance
   panel's theme buttons emitted `@click="selectTheme(opt.Name)"` as
   LITERAL text (templ gotcha #1 — quoted attribute values are never
   interpolated; `opt` is a Go loop var), so Alpine threw "Undefined
   variable: opt" on every click and no PUT /api/auth/theme ever fired.
   The wood-paneling sibling always used the correct expression form,
   which is why the panel half-worked. Fixed to the interpolated form;
   TestThemePickerHandlersAreInterpolated pins that real theme names
   reach the HTML and the raw identifier never does.

2. Filter-builder op dropdown "does nothing": the field ingest
   hardcoded `ops: []` into every field's meta while only mirroring the
   ops into the global label registry — opsFor() returned [] for EVERY
   field, so the op menu rendered zero items: an invisible menu, no
   console error (user tried author/publisher/title/subtitle, all
   equally dead). FieldMeta.ops is now the wire's op objects, opsFor()
   is a standalone exported helper, and opsForField unit tests pin the
   round-trip. Verified live: the field menu opens/closes while the op
   menu stayed shut, both with identical wiring — the difference was
   the data, not the wiring.

3. AND/OR connective chip dead: the chip carried an `if` STATEMENT in
   the Alpine attribute (`@click="if (row.chipClickable) ..."`) which
   the CSP build's evaluator rejects as a statement — the whole
   expression silently never ran. Moved the clickability check into a
   toggleConnective(row) component method; the attribute is now a plain
   method call.

4. Pencil vs card navigation race: navigateToCollection guarded with
   `event.target instanceof HTMLElement`, which is FALSE for the
   pencil glyph's SVGElement — a click landing on the glyph sailed past
   the guard and navigated while the htmx edit-modal request still
   fired (the reported "modal flashes, then the builder page"). The
   guard now uses target.closest("button") (works on SVGElement) and
   takes $event explicitly instead of the deprecated window.event.
   .icon-btn is already a 36px square, so no hit-area change needed.

5. Add Items modal under the sidebar + taller than the window: the
   picker overlay sat at z-50 UNDER the app-sidebar's z-60 (its left
   edge rendered beneath the nav), and the card had no height bound —
   header + tabs + filters + grid + footer overflow short windows with
   the footer unreachable (fixed overlay, no page scroll). The picker
   and create-modal overlays move to z-[65], both confirm dialogs to
   z-[70], and the picker card is now a flex column capped at
   max-h-[85vh] whose grids flex-fill (they grow on tall windows,
   shrink on short ones). The ladder is documented at .app-sidebar in
   input.css: sidebar 60 / overlays 65 / confirm 70 / toasts 9999.

Found by clicking every control the way a user does (panel clicks +
computed-style reads + screenshots) after the previous sweep trusted
DOM-text snapshots, which show hidden menus' text and cannot see
occlusion, stacking, or geometry at all.
2026-10-31 13:14:05 -04:00
John O'Keefe 8aec80cbb7 docs(ui): record the 2026-10-30 browser sweep against coverage notes
Marks the sweep-candidate list as executed (every knob driven in a real
browser), preserves the four defect classes the sweep fixed so the next
agent does not re-learn them the hard way, and pins the panel-vs-
Playwright limitation (value assignment without change events) that any
future automated spec of the htmx-triggered controls must work around.
2026-10-30 20:51:26 -04:00
John O'Keefe 94985adb5e fix(collections): browser-verification pass over the unified builder/editor
The real-browser sweep (the gotcha classes' native habitat — CSP refusals
fail SILENTLY at build/test time) found four defects, all in the item
3.3/3.4 surfaces:

- Shared builder rows used Alpine attribute expressions that the CSP
  parser rejects outright: backtick template literals in :style, the
  ?. optional chain in :placeholder/x-if, and an if-STATEMENT in a
  @click.outside. Rewritten as string concat plus two component methods
  (fieldIsDays, dismissSuggestions). Until this fix every drawer open
  threw a wall of "CSP Parser Error" console noise and the days-value
  affordance never rendered.
- "2 bookss match": the count noun is interpolated into "<n> <noun>s
  match", so callers must pass the SINGULAR ("book"/"item").
- The rules editor page received its fields/tree payloads from the
  route as already-JSON-encoded strings and wrapped them in JSONArg
  AGAIN — double-encoded, so JSON.parse yields a string and the editor
  loaded with NO vocabulary and NO prefill. The route now emits raw
  JSON into the data attributes (single encode).
- Apply was silently inert: the hook store round-trip used
  window.Alpine?.store (optional-chained, no-op when absent) and the
  Cancel/Apply block sat outside the builder island scope for part of
  the session. Both fixed via the imported Alpine instance and moving
  the buttons inside the island; Apply now PUTs the tree end-to-end
  (verified live: prefill → edit → apply → 201 → reload → membership).

Browser-verified clean (no fixes needed): picker scope tabs + episode
grid (?scope=episodes door), selection store across swaps, Add Selected
episode membership, library-switcher partial contract (episodes visible
under All Libraries, correctly filtered out under a book library — and
now never OFFERED as a switcher option), episode cards + reorder arrows
persisting position, collection-chain player (Ep N/M, prev/next carry
?queue=collection:, order control absent in collection mode and present
+ persistent in feed mode both in player and feed page), and the
empty-tree Apply guard (400 — a static collection can never be
promoted by accident).

Notes for the next reader of these lines: the OpenChamber panel's
browser.select sets select.value without dispatching change, so htmx
change-triggers must be exercised by real events (Playwright) rather
than the panel; and one 500 was observed on the player's leave-page
progress PUT — a request whose context the navigating browser cancels
mid-write, pre-existing and benign (the immediately following save
returned 200).
2026-10-30 20:50:55 -04:00
John O'Keefe 7bf5697c71 fix(podcasts): library switcher leaked literal "continue" and the podcasts option
The D13 podcast exclusion (9a148c23) was written as a templ
`if !IsBookLibrary(...) { continue }` — but templ markup control flow
has NO continue STATEMENT: codegen emitted the word as a TEXT node
(visible junk between options on every page's library subbar) and
rendered the podcasts option anyway, defeating the exclusion. The real
browser sweep caught what every Go/API test structurally cannot:
generated markup that renders wrong yet compiles clean.

Rewritten as an inverted guard (the idiomatic templ skip). New render
pin TestLibrarySwitcherExcludesPodcasts fails on both halves of the
original leak — the literal text AND the presence of the podcasts
option — so this codegen gotcha class stays tripped-wired.
2026-10-30 20:50:38 -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
&#34; 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 94bd69d22d feat(filters): save current filter as smart collection — the bridge (item 3.5)
The bookshelf's save modal grows a second action: "Save as Collection"
POSTs the current (completed, scope-stamped) tree to
/api/collections — the same validated write path every tree
collection uses — and lands you on the new collection's page. The qf
engine then drives it everywhere: the shelf's live shelf-count, the
collections page, the replay chain for episode trees. The legacy
"Save Filter" (bookmark the wire params) stays as the sibling action.

This is the plan's bridge: a book search graduated into a live shelf
with one click, authored in the ONE filter system.
2026-10-30 19:25:02 -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 eb68672390 feat(filters): one vocabulary, server-rendered — the TS mirror retires (item 3.3)
"One filter system to maintain" now holds at the presentation layer
too: the field/op list the builder renders comes from
services.FilterFieldsPayload(scope) — the Go registry's wire face,
scope-filtered and group-ordered — rendered into the bookshelf mount
as a data attribute (templates.JSONArg), ingested once by the Alpine
component. The registry carries operator presentation (labels,
negation, arity) in FilterOpWire alongside the grammar; per-field
value-cell semantics (valueKind) derive from the existing Kind.

The hardcoded TS mirror (OPS, op(), F(), TEXT_OPS/NUM_OPS/DATE_OPS,
FIELD_GROUPS, its known drift surface) is deleted; filter_builder.ts
now ingests the wire (ingestFilterFields — malformed payloads leave
empty menus rather than client-invented rows), and the field menu's
episode fields are HIDDEN on the All-Books page for free (the books
scope payload excludes them — the plan's "hide podcasts from the
rules on the all books page" is now structural, not enforced by a
second hand-kept list).

Unit tests ingest a minimal wire fixture (the payload shape is the
contract, not the mirror); vitest 167 green, tsc + vp clean.
2026-10-30 19:07:45 -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 56871b65ed feat(filters): scopes + episode fields — the engine becomes the single query system (item 3.1)
The qf tree gains an optional top-level `scope` ("books" default |
"episodes" — absent = books, so every existing saved tree and legacy
client stays valid). The row head is scope-aware AND this closes a
real leak: the composed engine's head had NO podcasts exclusion, so
episodes already surfaced on All-Books qf queries (plan §9 violation —
the sqlc flat head excluded them, the composed head didn't). Books
scope now says library_type_name <> 'podcasts'; episodes scope is
source = 'podcast_episode'.

The registry fields declare their scopes (the plan's "reusable
template with conditionals" = scope-driven rendering in 3.3): episode
fields declare exactly the episodes scope and the books scope REJECTS
them at parse (the "hide podcasts from the rules on the all books
page" rule enforced server-side); books fields inside episodes trees
are allowed and honestly no-op on NULLs.

New fields (the item-2.4 rule vocabulary expressed in SQL):
- feed — KindIdentifier over podcast_feeds (RLS-governed suggestion
  source and match; unsubscribed feeds match nothing, by the engine)
- downloaded — is_true/is_false on download_state = 'downloaded'
- played — THE canonical finished predicate (viewer progress >= 0.999,
  the hide_finished definition shared with item 2.4), reading_progress
  join per viewer
- published_date — clauseDateFactory like added_at, within_days
  default op (the published_within rule's SQL form)

Engine hygiene: the stray ComposedSearch SQL debug printf is gone.

Pinned by TestFilterScopeValidation (unit: scope grammar, books×
episode-field 400s, legacy no-scope trees) and
TestFilterScopesAndEpisodeFields (integration: books head excludes
podcasts including the 404-no-results contract, episodes head is
episodes only, feed-in/downloaded/played/published-date evaluate in
SQL against a subscribed-feed fixture with a played viewer).
2026-10-30 18:19:55 -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 962f9089e0 chore(db): querier interface picks up the UpdateCollectionViewSettings comment — generated side of cc56b9ef 2026-10-30 17:12:29 -04:00
John O'Keefe 69773ec307 feat(podcasts): smart collections play as a sequence — the Daily Fiction acceptance (item 2.5)
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.
2026-10-30 17:12:07 -04:00
John O'Keefe cc56b9ef2a fix(collections): UpdateCollectionViewSettings wrote a nonexistent updated_at column
The query said `SET view_settings = $2, updated_at = NOW()` but the
collections table has never had an updated_at column — every call
failed with 42703. The one caller (media_scanner's series-import
provenance write) discards the error, so the failure was silent:
imported-from-series view settings never persisted. Surfaced while
writing the sibling UpdateCollectionMatchMode query for item 2.4 (the
item-2.4 commit message prematurely claimed this fix — this is that
fix, for real).
2026-10-30 16:55:16 -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 f68fb19afb test(ui): COVERAGE.md — episode-collection sweep candidates (item 2.3) 2026-10-30 16:18:47 -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 a90e7debfd feat(podcasts): queue context source + player order control — item 2.2
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 &#34; attribute escaping).
2026-10-30 15:52:14 -04:00
John O'Keefe 7bf0b1257d feat(podcasts): widen reader_settings key ceiling — item 2.1 foundation
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.
2026-10-30 15:29:34 -04:00
John O'Keefe f7ba4d24d6 style(web): main sidebar drops 16rem to 14rem (user review)
Release / build-and-push (push) Successful in 3m38s
The app-shell sidebar read as too wide against the content area.
--sidebar-w is the single source of truth for the shell geometry, so
one line moves all three consumers together: the .app-sidebar width,
the body:has(.app-sidebar) content offset, and the topbar's left
inset on desktop. No templ or JS change needed — nothing else pinned
16rem.

style.css is the regenerated tailwind build (build:css:prod), diff
limited to the one var; committed with its source per the generated-
artifacts rule.
2026-10-30 15:09:44 -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 5fee8bfb4a fix(admin): bulk delete swaps the episode list — Play → Download without reload
The bulk 'Delete selected files' button posted with hx-swap='none',
which makes htmx DISCARD the response — and the response is the
refreshed episode list partial. Rows (Play → Download, badges) stayed
stale until a manual refresh. Target the swap at #episode-list /
outerHTML like the Download All button; the afterSwap listeners then
reapply the (now-empty) selection and any active filter.
2026-10-30 14:13:39 -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 b293741eb1 feat(reader): podcast episode prev/next on the transport row's outer buttons
User design (round 3): one set of big buttons does the big navigation.
The outer prev/next step buttons were chapter-only even on podcasts,
while episode nav hid as small icons in the top-left cluster. Now:

- Podcasts (sequence context present): the outer buttons navigate
  EPISODES via goToPrev/goToNextEpisode (progress pushed before the
  hop); disabled ghost buttons at the first/last episode keep the row
  shape. Embedded-chapter jumps stay on the Chapter pill's list.
- Books: unchanged chapter stepping.
- Keyboard (h/l shortcuts) and hardware media keys (MediaTrackPrev/
  Next) funnel through stepChapter, which is now sequence-aware —
  headset next-track = next episode on podcasts.
- The top-left episode icons are gone; the 'Ep N/M' counter stays.
- Help-modal labels say 'chapter, or episode on podcasts'.

Same icons both modes (extracted audioStepPrev/NextIcon partials);
books are untouched — no sequence context, byte-identical behavior.
2026-10-30 12:46:36 -04:00
John O'Keefe ace9e57d27 fix(ui): hx-confirm interception actually worked — htmx 2 detail shape
User report: Remove Feed still used the browser dialog. Headless-proven
root cause (tests/ui/confirm_probe.spec.ts): the htmx:confirm handler
read evt.detail.element, which htmx 2 does not provide (its detail is
{ elt, question, issueRequest, ... }) — the handler returned at
'if (!elt) return' BEFORE preventDefault, so every hx-confirm sitewide
fell through to htmx's native confirm. The acbcc61a migration only ever
worked for direct confirmDialog() callers; hx-confirm interception was
dead on arrival against htmx 2 and no test watched the JS side.

Fix: read detail.question (htmx's own closest-attribute resolution of
hx-confirm); return without preventing default when absent. Probe
spec pins: click Remove Feed → #confirm-modal opens with the message,
cancel hides it, no dialog event fires.

Companion fixes in the same user-test round (one commit, one defect
family — templ boolean attributes + confirm-modal placement):
- Add-modal Download order never revealed: #podcast-download-all was
  nested in a wrapper div, so ':checked ~ .podcast-add-order' had no
  sibling relationship. Input, label, and order row are now siblings.
- Order/auto-download dropdowns always DISPLAYED the last option
  ('Newest first'/'Newest') whatever was saved: templ selected={ bool }
  emits selected="false" — presence selects, so every option was
  selected and the browser honored the last. Swept to selected?={...}
  across podcast_detail (14), book_detail_modals (10, incl. two
  always-checked checkboxes), collection_modal (2, same). Saves were
  correct all along; display only.
- Global confirm modal moved from SidebarSignIn (signed-out only!) into
  Header — signed-in pages had no #confirm-modal, so confirmDialog
  degraded to window.confirm even with a working interceptor.

Facet bar scale (user question): values now carry feed counts, sort
frequency-first, cap at 6 chips per group with a native <details>
'+N more' expander.

Probes: tests/ui/confirm_probe.spec.ts + add_order_probe.spec.ts
(live-server, BH_FEED-gated) — both pass against the dev instance.
2026-10-30 12:24:16 -04:00
John O'Keefe 2fd0828c06 fix(podcasts+ui): user-test round 3 — five browser-reported defects
1. Add-modal 'Download order' never revealed: the CSS sibling selector
   (#podcast-download-all:checked ~ .podcast-add-order) can't fire —
   the checkbox was nested inside a wrapper div, so the select row was
   no SIBLING of the input. Restructured: input, label, and order row
   are now siblings under one flex-wrap wrapper.

2. Order + auto-download dropdowns always displayed the LAST option
   ('Newest first'/'Newest') regardless of the saved value: templ's
   selected={ boolExpr } compiles to selected="false" — and the mere
   PRESENCE of the attribute selects the option, so every option was
   selected and the browser honored the last. Sweep to the boolean-
   attribute form selected?={...} (omits on false) across
   podcast_detail (14), book_detail_modals (10: manga type/reading
   direction selects + abridged/explicit checkboxes — same defect,
   always-checked), collection_modal (2: hide-finished/show-up-next
   always-checked). Values were SAVING correctly all along; this was
   pure display. audio_reader/collections :selected/:checked are
   Alpine bindings — unaffected.

3. Remove Feed (and every hx-confirm) still showed the browser dialog
   for signed-in users: the global confirm modal rendered at the end
   of SidebarSignIn — signed-IN pages have no SidebarSignIn, so no
   #confirm-modal existed and confirmDialog() degraded to
   window.confirm. Moved the modal into Header proper (rendered on
   every page, after </aside>).

4. Facet bar at scale: values now carry feed COUNTS, sort by frequency
   DESC then alphabetically, cap at 6 chips per group flat, and the
   rest collapse into a native <details> '+N more' expander — hundreds
   of hosts/tags degrade gracefully instead of a wall of chips.

Verified live on the dev instance (order select reflects the saved
override, override row persists, reveal fires, modal renders on
signed-in pages). Podcast + logout + media-pin suites green; vitest
167 green.
2026-10-30 12:03:44 -04:00
John O'Keefe 15f41e6fd4 test(pins): whitelist download_queue_pos on raw media rows (additive widening)
The contract pin caught the §0 item 4 column surfacing on media-list
raw sqlc rows — its job. Widened additively per doctrine: rawRowKeys +
docs/contract_pins.md changelog entry. NULL for scan rows and
never-queued episodes; nothing removed or renamed.
2026-10-30 10:40:34 -04:00
John O'Keefe 0da8e0cdf9 test(settings): cover the new float setting type in the defaults audit
TestSettingDefaults enumerates registry types; 'float' hit the unknown-
type branch. Added float validation (default parses, within [min,max])
mirroring the int case.
2026-10-30 10:31:02 -04:00
John O'Keefe b574df346a chore(podcasts): drop the dead QueueFeedDownloads wrapper
All callers moved to QueueFeedDownloadsAs in the item-5 commit; the
no-override shim had no remaining callers.
2026-10-30 10:29:45 -04:00
John O'Keefe f0f1eb6e8f feat(podcasts): hide played/downloaded toggle + episode text filter
Pocket Casts steal pulled forward from Phase 4 (§0 item 6): the episode
list gains a client-side 'Filter episodes…' box and a 'Hide
played/downloaded' toggle. Pure presentation — rows carry templ-rendered
data-* attributes (downloaded, listen-pct, search text); the filter
toggles DOM visibility with the 99.5% finishedThreshold. The toolbar
lives OUTSIDE #episode-list so its state survives the list's polling
swaps, and an htmx:afterSwap listener re-applies the active filter to
freshly swapped rows. No query change, no server round trip, no new JS
rendering surface (Alpine component method, CSP-safe).
2026-10-30 10:28:31 -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 cdf81f6cf4 fix(podcasts): page-load polls during downloads; indeterminate bar slides
A fresh detail-page load while downloads were active rendered the
NON-polling episode container (PollingEpisodeList was only reachable
via the /episodes partial route after Refresh), so the page froze
until the user refreshed manually. PodcastDetailPage now picks the
variant the same way the route does — HasActiveDownloads selects the
every-2s poll container.

Also: the indeterminate progress bar (feeds without Content-Length,
e.g. Scribl) used animate-pulse, which fades in place and reads as
frozen; replaced with a sliding shimmer keyframe
(.podcast-progress-indeterminate, 33% sliver, translating -100% ->
300%) in input.css. The live MB counter stays.

§0 item 1.
2026-10-30 08:53:35 -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 253d8d0e8e feat(podcasts+ops): Apple launcher-scheme feeds + visible build stamp
Release / build-and-push (push) Successful in 3m29s
Apple scheme normalization (follow-up to the Scribl discussion): Add
Feed accepts the launcher spellings podcast pages ship next to plain
RSS — itpc://, podcasts://, feed:// — and rewrites them to the https
feed URL they wrap (scheme match case-insensitive). http stays and the
guard still rejects it. Unit-pinned.

Build stamp (stale-container drift guard): the 2026-10-29 podcast
session lost an hour to a container running a build that predated the
feature. version.Commit rides ldflags from a GIT_COMMIT build arg; the
dev compose passes git rev-parse --short HEAD via the Makefile; the
app logs 'Bookhoard <ver> (commit <hash>)' at boot and /api/health
reports it — the UI and the repo can no longer silently diverge.
2026-10-29 20:12:39 -04:00
John O'Keefe acbcc61ae4 feat(ui): global confirm modal — browser dialogs retired sitewide (issues #5/#4/#7)
ONE styled modal answers every hx-confirm in the app: a body-level
htmx:confirm interceptor (web/src/confirm.ts, canonical htmx pattern)
preventDefaults and shows the dialog, issuing the request on Confirm.
Nine hx-confirm call sites upgrade without touching them. The modal
lives in the header partial so every page has it; confirmDialog() is
exported for JS call sites and degrades to the native dialog if the
header is absent (bare layouts).

Migrated the five window.confirm sites (admin.ts ×3, book-detail.ts ×2)
to the same promise API.

Podcast feed detail (issues #4/#7): the unsubscribe copy is
role-aware — non-admins get 'Unsubscribe from this feed?' with silent
retention (file state is admin knowledge); an admin who is the LAST
subscriber gets a three-choice modal instead: Unsubscribe (files kept,
the D6 default — sliding-window feeds make purge unrecoverable) /
Remove feed + files / Cancel. Admins with co-subscribers keep the
files-note confirm.
2026-10-29 20:12:30 -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 ffc80f7ba7 fix(podcasts): page search hits episode titles/descriptions (issue #8)
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.
2026-10-29 19:49:39 -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 8d9fb0046c refactor(podcasts): drop the dead downloadRoot param on upsertEpisode
FilePath is library-relative (the pinned layout resolves at download
time via the library folders); the parameter never carried a value.
2026-10-29 18:22:09 -04:00
John O'Keefe 5c10bbb90e test(podcasts): reorder-safety pin — progress survives position renumbering (task 2.6)
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.
2026-10-29 18:17:51 -04:00
John O'Keefe 49b0b6eeb9 test(podcasts): integration suite + RLS auditor + contract widening (task 1.10)
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.
2026-10-29 18:09:35 -04:00
John O'Keefe 28420d56ed feat(podcasts): chain playback + Continue section — sequence mode in the web player (phase 2 core)
D7 chain-by-default, delivered with minimal player surgery:

- ReaderMetadata gains PodcastSequence: the neighboring DOWNLOADED
  episodes in the caller's effective order (D5 override applied,
  feed-native fallback; computed in the reader route for
  source='podcast_episode' items — RLS already scoped the reads; gaps
  never enter the chain, invariant 6). ?single=1 escapes chaining (the
  'play single episode' affordance).
- Audio reader renders the sequence into body[data-json]
  (AudioReaderConfigJSON — books produce the identical shape minus the
  sequence key, so the reader path is unchanged for them) and an
  'Ep N/M' indicator + prev/next episode controls in the header.
- audio.ts: the last-track 'ended' handler chains into the next
  episode's reader page (final progress push precedes navigation);
  back button exits episodes to their feed page. Books are untouched —
  no sequence context, zero behavior change. Day-one chaining is
  page-navigation based (progress persists; position restores).

Continue aggregation (2.5): GetPodcastContinueForUser (in-progress
episodes, most-recent-first, ≥95% excluded) feeds a Continue section
on /podcasts — sorted most recently listened, cards show 'Ep N · title'
— ahead of All Feeds. Cards prefer the continue line over the
description.
2026-10-29 16:25:44 -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 a6723e6ac8 feat(podcasts): feed poller + podcast_poll_interval_seconds setting (task 1.6)
One ticker re-reading its interval from the settings registry each
cycle (live tunable — the cache stays honest because writes go through
the settings API path, AGENTS.md gotcha #2). Per tick: list feeds
(first-admin identity, invariant 12), skip is_complete feeds (back
off; explicit refresh still works), poll each (conditional GET via
stored validators), and apply the subscriber auto-download policy
union after each successful fetch so new episodes queue without
waiting a tick.

Schema/registry: podcast_poll_interval_seconds (default 3600s, floor
300s, category podcasts) — seeded with ON CONFLICT DO NOTHING per the
idempotency rule.
2026-10-29 16:03:22 -04:00
John O'Keefe 4288a86b2d feat(podcasts): download worker — state machine, idempotency, policy union (task 1.5)
One sequential worker (day one) over a DB-backed queue
(download_state='queued' IS the worklist — the episode list always
renders truth, D8/D19). State machine: pending → queued → downloading
→ downloaded | failed (+ download_error + download_attempts).

Idempotency: downloaded re-request is a no-op; a file already on disk
that probes cleanly re-marks the row without re-fetching
(crash-between-move-and-update reconciliation). Retry: manual path
resets attempts (human override), auto policies re-queue up to
maxAutoDownloadAttempts=5 then stay failed until a human retries.

Pipeline: enclosure fetch (same SSRF/size guards as the fetcher) →
.part temp → declared-size pre-check → MIME/size validation (empty /
over-cap / content-sniff refuses non-audio) → ffprobe reconcile +
embedded-cover extraction (scanner machinery reuse: runFFProbe,
extractEmbeddedAudioCover, AudioMetadataPayload) → atomic rename →
SUCCESS-ONLY row update (invariant 7: a failure never clears existing
episode data).

Policy union (D6): subscriber policies per feed — 'all' → everything
missing; 'newest N' → the N tail positions; 'off' contributes nothing;
auto-retry budget respected at queue time. DeleteFeedFiles for the
admin-only removal path (episode files + cover siblings + artwork).
2026-10-29 16:03:22 -04:00
John O'Keefe 85a9873a75 feat(podcasts): RSS fetcher — parse, SSRF guards, upsert-without-delete (task 1.4)
The fetcher is the SOLE writer of feed/episode metadata (D9). Three
files, no new deps (stdlib encoding/xml + net/http):

podcast_rss.go — RSS 2.0 + iTunes + Podcasting-2.0 structs. iTunes
fields are namespace-qualified in tags (local names collide with core
RSS: title/subtitle/author/summary/explicit/image/duration);
podcast:* fields match on local name (collision-free, tolerant of both
declared namespace URIs). Description/author fallback chains per §5;
itunes:duration parses seconds/HH:MM:SS/MM:SS/prose; pubDate accepts
the RFC-822/1123 spellings feeds actually emit.

podcast_http.go — the security boundary for user-supplied URLs:
https-only on every hop (CheckRedirect), private/loopback/link-local/
multicast IP ranges blocked at DIAL time (Dialer.Control sees the
RESOLVED address — DNS rebinding into 127.0.0.1 is refused at the
socket), 10-redirect cap, hard body caps (25MiB feeds / 20MiB artwork /
1GiB enclosures), no proxy.

podcast_service.go — fetch → parse → upsert. Episodes upsert
metadata-only keyed on (source_feed_id, episode_guid); rows for items
that vanish from the XML are NEVER deleted (D14 search covers
historical episodes). itunes:new-feed-url + permanent HTTP redirects
rewrite feed_url in place (row/subscriptions/episodes stay put);
podcast:guid dedupe reuses the canonical row instead of forking.
Artwork downloads feed-level under <feed-slug>/artwork.<ext>.
AddFeed authorizes ONLY on explicit arguments (library visible +
allow_member_feeds for non-admins — the D20 scoping model); detached
work runs under the first-admin identity via adminCtx (invariant 12).
Feed layout: <download-root>/<feed-slug>/<pos:04d>-<slug>.<ext> so
ServeFile/Range streaming resolve unchanged.
2026-10-29 16:03:22 -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 0b7ad0f376 feat(podcasts): schema, RLS policy extension, sqlc queries — feed core foundations
Podcast integration task 1.1 (plan: podcast-integration.md §6/§8, D20).

WHY: podcasts are a new first-class media domain — RSS feeds users
subscribe to personally, with episodes as media_items rows so progress,
stats and the audio player work unchanged. Visibility is per-user
(strict: only your subscriptions), which after the RLS migration
(2026-10-29) belongs in the POLICIES, not Go helpers.

Schema (idempotent — schema.sql re-executes every boot):
- 'podcasts' library type seeded (ON CONFLICT DO NOTHING, ARRAY[]::TEXT[]
  cast required — bare ARRAY[] 42802s under ON_ERROR_STOP)
- podcast_feeds: per-library feed entity (UNIQUE(library_id, feed_url)),
  full RSS/iTunes/Podcasting-2.0 metadata inventory, conditional-GET
  validators, is_complete poll backoff
- podcast_subscriptions: the ONLY user-writable podcast state (display
  name, auto-download policy); deliberately UNGOVERNED — it IS the read
  policy's lookup table
- media_items episode columns: source/source_feed_id/episode_guid/
  feed_position/season/episode/enclosure_url/declared_duration/
  transcript_url/chapters_url/hosts/guests + download state machine
  (pending|queued|downloading|downloaded|failed + error + attempts),
  all partial-indexed so scan rows never touch them
- libraries.allow_member_feeds (first per-library knob); library_folders
  .purpose ('podcast' download roots, scanner-skipped)

RLS (inside the labeled policy block):
- p_media_items_visible gains the podcast-episode subscription predicate
  + admin escape (source IS DISTINCT FROM 'podcast_episode' guard keeps
  scan rows on the cheap path); unset identity still fails loud
- podcast_feeds governed identically: library-visibility join AND
  subscriber-or-admin SELECT; permissive write stand-ins (mandatory at
  ENABLE time; fetcher/worker declare first-admin, add-feed upsert uses
  ON CONFLICT DO UPDATE RETURNING so creators never need SELECT on
  foreign rows)
- Verified against dev DB: migration identity sees nothing, admin sees
  all, member sees feeds/episodes only post-subscribe (probed in a
  rolled-back tx — no rows persisted)

sqlc: 30 new queries (feed upsert/CRUD, subscriptions, episode
upsert-with-write-after-success-guard, download state transitions,
poller worklist, subscription-scoped search) — no visibility clauses
anywhere, per the D20 scoping model.
2026-10-29 15:36:11 -04:00
John O'Keefe 4ab4f9fa90 docs(readme): reconcile quickstart block post-inline-bootstrap
Release / build-and-push (push) Successful in 3m6s
The 12:21 sync (cf73eec2) made the README block a byte-copy of
docker-compose.yml as it stood before the inline db-init landed;
reconcile to the current file: initdb.d mount dropped, inline
hBcescaped bootstrap, PGPORT in db-init env (DB_PORT is a variable in
every connection path). Block verified byte-identical to
docker-compose.yml; no secrets in either version (placeholders only).
2026-10-29 13:12:36 -04:00
John O'Keefe 304ea9599f feat(deploy): inline bootstrap into db-init — compose is the single source; DB_PORT everywhere
z_rls_role.sh is gone. The db-init service carries the bootstrap SQL
inline (the script's docker-exec/local-socket mode was its only reason
to exist as a file, and the no-clone deployment path could not use it:
docker bind-mounts of missing host paths auto-create empty DIRECTORIES,
so a compose-only install got '/z_rls_role.sh: no such file').

The inline form surfaced two silent bugs the script had masked:
- psql defaulted its DATABASE to the username (POSTGRES_DB is not a
  libpq variable) — the ownership loop ran against 'postgres' and
  no-opped on existing volumes. Fixed with PGDATABASE: bookhoard.
- PGPORT was never propagated: with a custom DB_PORT, db-init dialed
  5432 while Postgres listened on the custom port. Fixed with
  PGPORT: ${DB_PORT:-5432} — DB_PORT is now a variable in every
  connection path (db listener, host mappings, app, db-init).

Also removed the db service's initdb.d mount: the app executes the full
schema as the runtime-role owner on every boot incl. first (pg_trgm is
a trusted extension; the db owner can create it), so the bootstrap SQL
was its only content.

Verified: dev two-boot against the existing 40-table volume (owners
converged, 0 off-owner tables/functions), custom-port throwaway
(DB_PORT=15432: db-init + app both on the custom port, healthy),
default-port throwaway (fresh volume, healthy), full Go suite green.
Manual repair path: docker compose run --rm db-init.
2026-10-29 13:06:16 -04:00
John O'Keefe cf73eec2e4 docs(readme): sync quickstart compose block to docker-compose.yml verbatim
The 'copy this wholesale' docker-compose.yml block in the README had
drifted from the real make-of-record file: it hardcoded 5432/8765
(missing the DB_PORT/SERVER_PORT configurability from de8f71b2), lost
the loopback-only db publishing (b03ed9a2), carried its own hand-tailored
db-init service with inline SQL instead of the z_rls_role.sh script
version shipped in 313268a7, and still contained the vestigial
./uploads mount on the db service (dropped in the previous commit).

Replace the whole yaml block with a byte-for-byte copy of
docker-compose.yml so the quickstart can no longer drift — the
documented optional envs, loopback bind, db-init script path, and
env_file wiring all now match what actually ships. Verified by diffing
the fenced block against the file.
2026-10-29 12:21:07 -04:00
John O'Keefe ab4bddc784 fix(compose): drop vestigial uploads mount from db service
The db service mounted ./uploads:/app/uploads, which Postgres has never
used and nothing in the repo relies on: no SQL COPY TO/pg_dump path, no
doc/test reference to an uploads path inside the db container (the only
/app/uploads mentions in TEST_DATA.md are the app/test containers), and
the backup scan is app-side.

Forensics: the mount was introduced by accident in 6ebe9749 ("Simplify
docker-compose healthcheck configuration"), which incidentally REPLACED
the db service's volumes with the app service's uploads mount. The
follow-up 91456d11 ("restore essential database configuration")
restored postgres_data and the schema-init mount but nobody noticed the
stray uploads mount riding along, and it has survived every compose
refactor since.

The tests service in docker-compose.dev.yml keeps its own ./uploads
mount — that is the app-like test runner (TEST_UPLOAD_PATH=/app/uploads)
and is legitimate. The dev db inherits its volumes from the base file,
so it is fixed automatically.
2026-10-29 12:21:03 -04:00
John O'Keefe de26544d9d docs(readme): prod install without a repo clone — copyable compose
The Installation section assumed 'git clone the repo'. Self-hosted
operators only need three files. The install is now: mkdir + copy this
compose block + .env (three openssl secrets) + uploads dir + pull/up.

The compose is fully self-contained: the runtime-role bootstrap is
inlined into db-init's command (same SQL as
database/schema/z_rls_role.sh — keep in sync) — no ./database/schema
bind mount, because the app itself executes the schema as the runtime
role (owner) on first boot; with the role as DATABASE owner the trusted
pg_trgm extension needs no superuser.

Verified end to end in a throwaway project: fresh volume, pasted block,
.env with generated secrets, db-init converged role+ownership, app ran
the full schema and came up healthy — no repo checkout anywhere.

Compose-quoting lessons encoded (the reason for odd dollars): compose
interpolates '532016' to '$' before the container sees it, so the plpgsql
532016 tags in the inline SQL are written 532016532016, and PGUSER must be set
explicitly (POSTGRES_USER is not a libpq variable).
2026-10-29 12:05:56 -04:00
John O'Keefe efbe312f16 docs(agents): family-lens enforcement moves to RLS — one source
Approves and lands the doctrine rewrite from the verified rollout:
RLS on media_items IS the enforcement (policy joins library_visibility
on app.user_id, declared per transaction by ScopedDB); new
media-listing queries and endpoints need no visibility plumbing.
Documents the three identity bridge sites, background-job identities,
the FORCE/owner role and db-init provisioning, per-boot schema
idempotency, and the two auditors (TestRlsInvariant + tests/ui).
Item-scoped gates may answer 404 as well as 403 (contract_pins
2026-10-29). History records the convention-era leak the dashboard
section queries carried.
2026-10-29 12:01:37 -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 313268a7ae feat(deploy): db-init one-shot service — paste-and-go bootstrap for the runtime role
The role split previously required a one-time manual script run on
existing volumes (initdb.d only fires on fresh ones). This folds the
bootstrap into the compose file the way split-account deployments
standardly do it: a one-shot db-init service runs z_rls_role.sh
(idempotent, network mode via PGHOST=db + PGPASSWORD from DBPASS) on
every 'compose up', and the app gates on
db-init: condition: service_completed_successfully.

- Zero manual steps for fresh AND existing deployments: update compose,
  add DBSRVPASS to .env, 'compose pull && up -d'.
- Security posture improves: the superuser password is now held ONLY by
  the init service — the app container connects exclusively as the
  non-superuser runtime role.
- z_rls_role.sh gains dual connection modes (local socket for docker
  exec, network for the init service) with the DBPASS requirement
  enforced only in network mode.
- Verified on the dev stack: init runs and exits clean, app gates on it
  and comes up healthy, second 'up' re-converges idempotently, invariant
  test passes through the new dependency chain.

The initdb.d mount stays as-is (harmless double coverage on fresh
volumes; both paths are idempotent).
2026-10-29 11:17:42 -04:00
John O'Keefe 1457dc7289 docs(env): DBSRVPASS in .env.example — the runtime role password
.env.example is the template prod/new deployments copy; it was missing
the runtime role password the app now requires (DATABASE_USER
bookhoard_srv). Notes distinguish it from the bootstrap DBPASS and
point existing-volume operators at the one-time z_rls_role.sh run.
2026-10-29 11:02:41 -04:00
John O'Keefe f579696633 test(ui): Playwright RLS sweep — real-browser proof, both personas
Phase 5.5 (plan §7.5) first increment: the SSR/HTMX frontend is swept
in a real browser (official Playwright image, no host browsers) for
BOTH personas — testuser (admin) and rls-phone (restricted; Audiobooks
hidden from them via the visibility API).

Green checks: auth rejection + session establishment; admin dashboard
renders sections incl. the hidden-from-others library; restricted
dashboard renders real sections (Recently Added present — not a
vacuous landing-page pass) with ZERO hidden-library traces; search and
media payloads leak nothing for the restricted persona.

Harness lessons encoded: / is a landing page (dashboard lives at
/dashboard); the login endpoint rate-limits rapid attempts so sessions
are established once per persona and reused via storageState — a sweep
must not look like a brute-force run; getByText visibility assertions
are wrong for <option> text (library switcher) — body-textContent
assertions instead.

COVERAGE.md tracks the full 14-area sweep per the plan; the remaining
areas (reader, collections CRUD, admin matrix, upload roundtrip) are
enumerated and pending their sweep runs.
2026-10-29 10:25:08 -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 922b92a8b6 feat(db): RLS library-visibility enforcement — invariant GREEN
The dashboard leak is closed at the database engine. The black-box
invariant (restricted user + hidden library => absent from every
media-returning surface) went RED->GREEN with this change set; clause-
protected surfaces never regressed, proving enforcement came from the
policies, not the test.

Phase 3 of plan ~/.opencode/plan/server-rls-library-visibility.md:
- schema.sql: idempotent RLS block — ENABLE + FORCE on media_items,
  SELECT policy mirroring COALESCE(is_visible,true) exactly, permissive
  per-command write stand-ins (FOR ALL would OR into SELECT).
- internal/rls + internal/database/scoped.go: ScopedDB declares the
  acting user per statement (LOCAL GUC). DESIGN CORRECTION during
  execution: the decorator never refuses — the ENGINE is the fail-loud
  layer (policies raise on governed tables; auth lookups and settings,
  ungoverned, simply work). A decorator refusal would have been a new
  convention gate — plan B thinking — and broke login (chicken-and-egg:
  credentials are checked before any identity exists).
- database/schema.go: the migration tx declares a synthetic identity —
  schema.sql's dedup DO block SELECTs media_items, which raises once
  policies are live (plan A11). Caught by the two-boot gate: boot 1
  created the policies after the block ran; boot 2 raised.
- router.go: RLS bridge at BOTH c.Set("user") sites (JWT + device
  auth) — echo-local c.Set never reaches the stdlib ctx the DB layer
  receives.
- search: the search service's raw DBTX path receives the ScopedDB
  itself, never the bare pool.
- harness: the server under test is wired exactly like main.go; the
  seed handle is also ScopedDB-wired but carries seedCtx() — a
  synthetic identity with no visibility rows — because sqlc :one
  inserts use RETURNING, which evaluates the SELECT policy even on
  INSERT. Regex UUID-scraping in the invariant replaced with a
  structured JSON decode.
- invariant test: RED -> GREEN across every surface incl. View All;
  403 pins and admin-scoping pins hold.
2026-10-29 08:56:05 -04:00
John O'Keefe 8c3d919aa0 wip(rls): engine-side fail-loud + harness seed identity [pin: work interrupted]
Design correction discovered by the auth chicken-and-egg: the
decorator-level refusal was re-created a convention gate (plan B
thinking). ScopedDB now NEVER refuses — it declares the GUC when the
context carries an identity and declares nothing otherwise; the ENGINE
is the fail-loud layer (policies raise on governed tables, ungoverned
tables like auth lookups simply work). rls.WithInternal removed as a
dead concept.

- search service's raw DBTX path now receives the ScopedDB itself
  (main.go + harness) — a bare pool bypassed the GUC.
- database/schema.go: migration tx declares a synthetic identity
  (schema.sql's dedup DO block SELECTs media_items; with policies
  live, boot 2 raised 'unrecognized configuration parameter' — A11).
- harness: TestServerSetup.DB is a raw-pool SEED handle (write
  policies permissive, other seeded tables ungoverned); the server
  under test keeps the scoped wiring. 33 CreateMediaItem sites use
  seedCtx() — a seed identity with no visibility rows — because
  RETURNING requires SELECT visibility of the new row.
- invariant test: regex UUID-scraping replaced with a structured JSON
  decode of the sections payload.

INTERRUPTION NOTE: an edit to this same effect (seedCtx) was applied,
confirmed by the tool, and subsequently vanished from disk while an
'opencode serve' process (PID 84551) is alive on the machine. This
commit pins the tree against further loss; implementation is PAUSED
pending the user killing that process.
2026-10-29 08:44:12 -04:00
John O'Keefe 004d4197a4 fix(test): rls fixture — reuse declared err variable 2026-10-29 08:26:10 -04:00
John O'Keefe f4b581872f wip(rls): ScopedDB + rls package + schema policies + echo bridge [pin compiling state]
Phase 3 of the RLS plan, pinned mid-flight because a parallel writer
was actively editing cmd/server/tests/rls_visibility_test.go (A14):
the file flip-flopped between two implementations and gained a compile
error after the previous commit. This snapshot makes the whole tree
compile; functional verification (two-boot gate, invariant green,
full suite) lands in the next commit(s).

- internal/rls: typed ctx keys — user identity (bridged from echo by
  the router) and an INTERNAL marker whose semantics grant nothing:
  no GUC is declared, so policy'd tables still raise at the engine;
  only RLS-ungoverned tables (system_settings, users counts) work for
  user-less startup paths (settings registry Load, CountAdmins).
- internal/database/scoped.go: DBTX decorator — per-statement tx,
  commit-on-Close (:many) / commit-on-Scan (:one), error paths always
  terminate the tx, refusal before SQL when no identity and not
  internal.
- database/schema/schema.sql: idempotent policy block (DROP IF EXISTS
  + CREATE — schema.sql re-runs every boot): ENABLE + FORCE, read
  policy exactly mirroring COALESCE(is_visible,true), permissive
  per-command write stand-ins (FOR ALL would OR into SELECT).
- router.go: RLS bridge at BOTH c.Set("user") sites (JWT + device
  auth) — echo-local storage never reaches the stdlib ctx.
- main.go + test harness: database.New over ScopedDB at both wiring
  sites; startup reads use rls.WithInternal.
2026-10-29 08:11:20 -04:00
John O'Keefe a19d7a4cbf test(visibility): fixture fixes from first live runs
Three harness corrections found while executing the invariant test:
- library type is 'ebooks' (plural) in library_types, not 'ebook'
- cleanup ordering: t.Cleanup is LIFO — register the pool Close BEFORE
  the fixture's library deletes, or the deletes run against a closed
  pool and fail silently into ignored errors (leaving RLSMARKER rows
  behind; found because a second run saw the first run's 'deleted'
  books still leaking through Recently Added)
- collection detail is addressed by the section's collection_id (the
  UUID in view_all_url), not the section name
2026-10-29 08:02:34 -04:00
John O'Keefe cb258b6d20 feat(db): dedicated non-superuser runtime role for RLS
The app connected as 'postgres' — the bootstrap SUPERUSER — which
bypasses row-level security unconditionally (FORCE RLS does not apply
to superusers), making any future policy a silent no-op. This is the
prerequisite commit for the RLS visibility work (plan
~/.opencode/plan/server-rls-library-visibility.md REV 2, §4.0).

What changed:
- database/schema/z_rls_role.sh: creates the bookhoard_srv role
  (LOGIN, non-superuser, non-bypassrls) and transfers ownership of
  every public table/sequence/function + schema + database to it.
  Ownership (not mere grants) because executeSchema re-runs the whole
  schema.sql on EVERY boot and needs owner DDL. Idempotent, catalog-
  only — no user data is read or written.
- docker-compose.yml app service + docker-compose.dev.yml tests
  service: DATABASE_USER postgres -> bookhoard_srv, DATABASE_PASSWORD
  DBPASS -> DBSRVPASS (new .env key, generated locally).
- POSTGRES_USER: postgres stays as the container bootstrap account;
  on FRESH volumes z_rls_role.sh runs automatically via
  docker-entrypoint-initdb.d AFTER schema.sql (alphabetical: z > s).

Verified on the dev stack: role has rolsuper=f, rolbypassrls=f, owns
all 40 tables and every public function; single boot as bookhoard_srv
executed schema.sql cleanly (CREATE EXTENSION IF NOT EXISTS pg_trgm
skips fine as non-superuser when the extension exists); health +
authenticated smoke pass. Behavior otherwise unchanged — no policies
yet.
2026-10-29 08:02:34 -04:00
John O'Keefe 3dcb794032 test(visibility): black-box invariant — hidden library absent from every surface
Adds the permanent auditor for per-user library visibility: a restricted
user with a hidden library sees none of its items on any media-returning
surface (media list, search, dashboard sections, series, facets, OPDS
device catalog, collection detail, scoped/detail access).

RED now, on exactly the surfaces the audit predicted: the dashboard
section queries (GetNotStartedItems / GetRecentlyAddedItems /
GetCollectionItemsForDashboard et al.) never carried the
visible_library_ids clause, and the collection-detail endpoint (mobile
View All) exposes the full section query results. Clause-protected
surfaces (media list, search, series) pass, proving the test itself
isn't a false positive.

Implementation-agnostic on purpose: it asserts the user-level invariant
through the public API, so it stays valid when RLS replaces the
per-query clauses and catches any future regression regardless of how
the code is written. Also pins: hidden library_id=403 (never silently
passed), item detail denial, and that the hide is USER-scoped (admin
still sees the hidden library).

Test-lesson encoded: cleanup ordering is LIFO — the pool Close must be
registered BEFORE fixture cleanups, or library deletes run against a
closed pool and fail silently into ignored errors.
2026-10-29 07:59:05 -04:00
John O'Keefe 2b122f2481 feat(web): densify all-books and series grids
Release / build-and-push (push) Successful in 3m14s
Book cards on the library (all-books) page were oversized at full
window width, and both the all-books and series grids showed only
2 columns on phones, making covers unnecessarily large on mobile.

Shift the all-books column ladder up one at every breakpoint
(2/4/5/6 -> 3/5/6/7) so the responsive reflow the grid is liked for
is preserved while every window size gets smaller cards; the series
grid gains a column at mobile only (2 -> 3), leaving md/lg untouched
per request. Dashboard carousel sizing (w-36/w-40) and the series
stacked-covers component are deliberately unchanged.

templ codegen and the tailwind build output commit with the source
change that produced them (repo convention).
2026-10-28 10:12:20 -04:00
John O'Keefe f1e4b2a603 fix(admin): un-dead the archived page's purge and delete buttons
Release / build-and-push (push) Successful in 3m15s
Two independent dead-ends, confirmed on prod's /admin/archived
(2026-10): Purge All rendered but clicking it did nothing at all, and
per-row Delete never even showed its confirm dialog.

Purge All is Alpine-bound (@click="purgeArchivedItems()"), but this
page's <body> lacked x-data="admin" — the only admin page without it.
Alpine evaluates directives only inside an x-data tree, so the
attribute was inert: the admin component (and its method, registered
in admin.ts) never instantiated here. The body now carries
x-data="admin", matching admin.templ and admin_library.templ.

Per-item Delete was a second, subtler dead-end: admin.ts's API helpers
guard with `if (!token) return;` BEFORE the confirm dialog, and the
reporting session had no localStorage token — SSR pages run on the
cookie session, so the web UI works while every Bearer-
authenticated admin action (purge, delete, restore, merge, scan-all,
cancel-scan) silently no-ops with zero feedback. Those guards now go
through requireToken, which toasts "Session expired — sign out and
back in, then retry" when the token is absent. The two passive
page-load probes (loadWatchStatus, checkActiveScans) deliberately keep
the silent return: a toast there would fire on every visit.

Pinned by TestAdminArchivedPageHasAlpineScope: renders the page and
asserts the body scope and the action buttons coexist in one render.

After deploy: hard-refresh prod, click Purge All (confirm appears);
if the token toast fires on any admin action, log out and back in once
to stash a fresh API token.
2026-10-28 09:37:14 -04:00
249 changed files with 28101 additions and 5533 deletions

No files matched your search

+9 -1
View File
@@ -6,10 +6,18 @@
# Generate with: openssl rand -hex 32
JWT_SECRET=your-secure-jwt-secret-key-here
# PostgreSQL database password
# PostgreSQL database password (the bootstrap superuser account; kept
# for container initialization — the app itself connects as bookhoard_srv)
# Generate with: openssl rand -hex 16
DBPASS=your-secure-database-password-here
# Runtime database role password (bookhoard_srv — the non-superuser
# account the app connects as; subject to RLS). Required for the app to
# start. Consumed by the one-shot db-init service on every startup
# (idempotent — creates/refreshes the role and its privileges).
# Generate with: openssl rand -hex 16
DBSRVPASS=your-secure-runtime-role-password-here
# Networking: change a port if it conflicts on your host
# Postgres port, host + container (e.g. another local DB already uses 5432)
# DB_PORT=15432
+1
View File
@@ -87,3 +87,4 @@ session-*.md
# see docs/contributing/development.md → Media fixtures)
internal/services/testdata/
test-results
.openchamber/
+52 -21
View File
@@ -106,30 +106,61 @@ inside quoted attribute values (gotcha #1 below), so dynamic
## Library visibility (family-lens rule) — ALWAYS apply
Per-user library visibility (`library_visibility` table, default-ALLOW:
every user sees every library unless an admin hides one) must be
enforced on **every endpoint that returns media metadata or content**.
It is NOT automatic — enforcement is per-handler.
every user sees every library unless an admin hides one) is enforced by
**Postgres RLS** — one source of truth: the labeled policy block in
`database/schema/schema.sql` + the runtime role provisioned by the
`db-init` compose service.
- **New endpoints that list/filter media or series** MUST filter by the
requesting user's visible-library set, and MUST reject an explicit
`library_id` outside that set with 403 (never silently pass).
- Helpers (do not hand-roll):
- `handlers`: `(*MediaHandler).visibleLibrarySet` /
`(*SeriesHandler).visibleLibrarySet` — membership map + id slice.
- `router`: `visibleLibraryIDsFor` — same for SSR routes.
- `resolveLibrary` (helpers.go) only honors requested library ids
inside the visible set.
- SQL pattern: `AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR
library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))` — see
`ListMediaItems`, `GetDistinctSeries`, `SearchMediaItems`.
- Content endpoints (file bytes, page images) additionally gate per item
like `ReaderHandler.ensureItemVisible` / the download endpoint.
- Tests must pin visibility: hidden library → 403 on the endpoint, and
absent from unscoped aggregates.
- **The engine is the enforcement.** Every `media_items` read is
filtered by the policy, which joins `library_visibility` on the
per-transaction GUC `app.user_id`. New media-listing queries need
NO visibility clause; new endpoints need NO visibility plumbing.
- **The app declares identity; it never computes visibility.** All
sqlc statements run through `ScopedDB`
(`internal/database/scoped.go`), which declares `app.user_id` from
the stdlib request context. Three bridge sites put it there:
`router.go` JWT, `router.go` cookie/device, and
`middleware/device_auth.go` (KOReader/Kobo sync devices). A media
read on a detached `context.Background()` RAISES — thread the
request context or the acting identity (see conflicts.go and
analytics.go for the pattern).
- **Background jobs declare the acting identity** before detached
work: scan jobs carry the library owner / first-admin fallback
(the job Context carries it — `ScanFolders` self-declares the
configured admin), the hash backfill runs as first admin. The sync
queue processor needs nothing (`sync_queue` is not RLS-governed).
- **NEVER**: session-scoped `app.user_id` (pooled-connection identity
bleed); a bypass flag; connecting the app as the bootstrap
superuser (superusers bypass RLS unconditionally — `DATABASE_USER`
stays `bookhoard_srv`; the `db-init` compose service provisions the
role on every startup).
- **schema.sql re-runs on EVERY boot** (executeSchema) — everything
added to it must be idempotent (`DROP .. IF EXISTS` + `CREATE`).
The migration tx declares a synthetic identity
(`schemaMigrationIdentity`) because the schema's own dedup block
reads `media_items`.
- **Auditors**: `TestRlsInvariant` (API black-box — add new
media-returning surfaces to it; loosening requires a
docs/contract_pins.md entry) and the Playwright sweep (`tests/ui`,
new pages/controls go into `tests/ui/COVERAGE.md`). Item-scoped
gates may answer 404 as well as 403 (row invisible vs Go-level
check — contract_pins 2026-10-29).
- **Collections RLS (2026-10-31)**: `collections` and
`collection_items` are governed too — predicates
`app_can_read_collection` / `app_can_write_collection`, PURE over
their arguments (never query `collections`, so no policy recursion
under FORCE RLS). Reads: owner-or-admin; writes: owner, admin escape
for SYSTEM collections only. Future sharing = one EXISTS branch
inside the predicate functions, no policy migration. Registration
seeding declares the new user's identity (`rls.WithUser` in
`CreateDefaultCollectionsForUser` — the JWT does not exist yet);
test-harness writes to non-system collections must run as the row's
OWNER (`scopeExecAs`/`ownerSeedCtx`, not the admin handle).
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
leaked hidden libraries to any authenticated account — series endpoints
were name-only across ALL libraries. Fixed; do not regress.
leaked hidden libraries; **2026-10-29 enforcement moved to RLS** — the
dashboard section queries had silently never carried the clauses,
and the invariant test went red → green and stays as the auditor.
## Tests
+3 -1
View File
@@ -41,12 +41,14 @@ RUN npm run build:ts
# Declared after `COPY . .` so a version bump only invalidates this layer.
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
ARG APP_VERSION=dev
# Short commit hash of the built source (stale-container drift guard).
ARG GIT_COMMIT=none
# Build Go binary (cached unless Go files or generated code changes)
RUN --mount=type=cache,target=/root/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION}" \
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION} -X bookhoard/internal/version.Commit=${GIT_COMMIT}" \
-o main ./cmd/server
# Materialize the Go module cache into an image layer so the test-runner
+4
View File
@@ -11,6 +11,10 @@ endif
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
# Build stamp (stale-container drift guard): the rebuilt image carries
# the source commit; the app logs it at boot and /api/health reports it.
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
# Dev compose stack: base prod file merged with the dev override (local build + tests).
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
+146 -141
View File
@@ -21,160 +21,165 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
- **Podman** (recommended) or Docker
- **5 minutes** of your time
### Installation
### Installation (no source checkout needed)
Create a directory on the Docker host. You only need the compose file,
an `.env`, and an `uploads` folder — everything else rides the published
image.
```bash
# 1. Clone the repository
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
cd bookhoard
# 2. Set up environment
cp .env.example .env
# Generate secure passwords (no special characters):
# JWT_SECRET: openssl rand -hex 32
# DBPASS: openssl rand -hex 16
# Edit .env with your generated values
# 3. Pull images and start the server
docker compose pull
docker compose up -d
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
# 4. Open your browser
open http://localhost:8765
mkdir bookhoard && cd bookhoard
mkdir uploads
```
The first user to register automatically becomes an admin.
**`docker-compose.yml`** — copy this wholesale:
---
```yaml
services:
# PostgreSQL Database
db:
image: postgres:15-alpine
container_name: bookhoard_db
environment:
POSTGRES_DB: bookhoard
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DBPASS}
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
PGPORT: ${DB_PORT:-5432}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
env_file:
- .env
## 📖 Key Features
# One-shot bootstrap: creates the bookhoard_srv runtime role and
# transfers object ownership to it (RLS prerequisite). Idempotent —
# safe on every startup; the app waits for its success. Passwords
# accept inline values or .env, like the rest of this file.
# NOTE: plpgsql $$ tags are written $$$$ — compose eats one dollar
# per pair before the container sees the SQL.
db-init:
image: postgres:15-alpine
container_name: bookhoard_db_init
environment:
PGHOST: db
PGPORT: ${DB_PORT:-5432}
PGUSER: postgres
PGDATABASE: bookhoard
PGPASSWORD: ${DBPASS}
DBSRVPASS: ${DBSRVPASS}
entrypoint: ["bash", "-c"]
command:
- |
set -e
: "${DBSRVPASS:?DBSRVPASS required in .env}"
psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL'
DO $$$$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN
CREATE ROLE bookhoard_srv LOGIN;
END IF;
END $$$$;
ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass';
DO $$$$
DECLARE r RECORD;
BEGIN
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename);
END LOOP;
FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename);
END LOOP;
FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP
EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn);
END LOOP;
END $$$$;
ALTER SCHEMA public OWNER TO bookhoard_srv;
ALTER DATABASE bookhoard OWNER TO bookhoard_srv;
EOSQL
depends_on:
db:
condition: service_healthy
restart: "no"
### Universal Cross-Platform Sync
# Bookhoard Application
# In production this image is pulled from the Gitea container registry.
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
app:
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
container_name: bookhoard
restart: unless-stopped
environment:
# Database Configuration
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
# via FORCE). POSTGRES_USER above is the bootstrap account ONLY —
# the app must NEVER connect as it (superusers bypass RLS).
DATABASE_HOST: db
DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_USER: bookhoard_srv
DATABASE_PASSWORD: ${DBSRVPASS}
DATABASE_NAME: bookhoard
- **Real-Time Progress**: Turn a page on your e-reader, see it in your browser
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly
- **Offline Queue**: Changes sync when you reconnect, priority-processed
- **Conflict Resolution**: Smart handling when same book read on multiple devices
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi
- **Format Conversion**: On-the-fly EPUB→KEPUB conversion (for upcoming native Kobo support)
# Application Configuration
JWT_SECRET: ${JWT_SECRET}
SERVER_PORT: ${SERVER_PORT:-8765}
# IMPORTANT: Device sync requires full URL with protocol
# Local: http://localhost:8765
# Local network: http://192.168.1.X:8765
# Domain: https://bookhoard.example.com
BASE_URL: ${BASE_URL:-http://localhost:8765}
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
COOKIE_SECURE: ${COOKIE_SECURE:-false}
### Media Management
# Rate Limiting Configuration
TEST_MODE: ${TEST_MODE:-false}
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
REQUESTS_PER_MINUTE: ${REQUESTS_PER_MINUTE:-10}
- **Calibre Integration**: Automatic metadata import from Calibre `metadata.opf` sidecar files
- **Smart Search**: Partial matching with fuzzy search fallback for typos
- **Advanced Filtering**: Filter by author, series, genre, language, year, cover images
- **Dynamic Sorting**: By title, author, date added, published date, page count, series
- **Rich Metadata**: Title, author, series, publisher, ISBN, language, edition, tags
- **5-Star Ratings**: Half-star precision (1-10 scale)
- **Notes & Highlights**: Color-coded annotations with linked notes
- **Usage Analytics**: Reading statistics, device usage, popular books
# Conversion Service Configuration
BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
### Smart Collections
# Library Maintenance
# Days an archived item (file missing from disk for 2+ scans) is kept,
# with its reading history, before library scans purge it for good.
# Set 0 to keep archived items until purged manually on the library admin page.
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}
- **Auto-Assign Rules**: Automatically add books based on genre, author, series, tags, language, publisher, year
- **Device Shelf Mappings**: Map collections to device shelves (used by native Kobo sync, coming soon)
- **Test Before Creating**: Preview which books match your rules
# System timezone (fallback for server-side time operations)
TZ: ${TZ:-UTC}
ports:
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
depends_on:
db:
condition: service_healthy
db-init:
condition: service_completed_successfully
volumes:
- ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
### Library Organization
- **Multi-Library Support**: Ebooks, Comics, and Manga with type-specific file formats
- **Multiple Folders**: Add multiple scanning folders per library
- **Visibility Control**: Admins control which libraries each user can see
- **Background Scanning**: Auto-scan with per-user frequency settings
- **Watch Mode**: Real-time file system monitoring for instant updates
### Security
- **JWT Authentication**: Short-lived access tokens (1 hour) with refresh tokens (7 days)
- **Strong Passwords**: Complexity requirements enforced (8+ chars, uppercase, lowercase, number, special)
- **Account Lockout**: 5 failed attempts = 15-minute lockout
- **Rate Limiting**: 10 requests/minute on auth endpoints
- **Input Validation**: Comprehensive validation on all inputs
- **No Passwords on Devices**: Web-based device approval with QR codes
---
## 📚 Documentation
### For Users & Self-Hosters
- **[docs/user/calibre-integration.md](docs/user/calibre-integration.md)** - Calibre library integration
- **[docs/user/sync-guide.md](docs/user/sync-guide.md)** - Understanding and using universal sync
- **[docs/user/devices/koreader-setup.md](docs/user/devices/koreader-setup.md)** - KOReader configuration
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration (coming soon)
- **[docs/user/user-guide.md](docs/user/user-guide.md)** - General user guide
- **[docs/user/admin-guide.md](docs/user/admin-guide.md)** - Admin features and configuration
- **[docs/user/settings-guide.md](docs/user/settings-guide.md)** - Settings and preferences
### For Developers
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
- **[docs/developer/android-app.md](docs/developer/android-app.md)** - Android app design & roadmap
- **[docs/contributing/development.md](docs/contributing/development.md)** - Development workflow
---
## 🎯 Supported Devices
| Platform | Sync | OPDS | Status |
| ---------------- | ---- | ---- | ------------------------------------------------------------- |
| **Web Browser** | ✅ | ✅ | Full support |
| **KOReader** | ✅ | ✅ | Runs on Kindle, Kobo, PocketBook hardware |
| **Kobo Devices** | 🚧 | 🚧 | Native Kobo sync coming soon (use KOReader on Kobo today) |
| **Mobile Apps** | 🚧 | 🚧 | Native Android app in design ([docs](docs/developer/android-app.md)); iOS later |
---
## 💾 Network Storage (NAS / NFS / SMB)
Libraries on network mounts are supported for everyday use — serving,
reading, progress, and annotation sync all work over the mount — with a
few behaviors worth knowing:
- **Outage hysteresis.** When the mount drops, files miss one scan
(marked missing, hidden) then a second (archived, still recoverable).
When the mount returns — by path or by identical content elsewhere —
everything is restored automatically: annotations, progress, and
covers included.
- **Retention purge.** Archived items older than `ARCHIVE_RETENTION_DAYS`
(default **90**, `0` = never auto-purge) are deleted for good by
library scans, annotations included. A NAS offline longer than the
retention window loses those books' metadata on return.
- **Deleting while the storage is down is refused.** Deleting a book
cascades its annotations, so deletes are rejected with a clear error
while the item's library storage is unreachable. Archive the item
instead, or retry when the mount is back.
- **Watch mode does not fire on network mounts** (inotify doesn't work
over NFS/SMB). The periodic scan is the change detector.
- **Latency** — folder reachability checks are cached ~30s; large
libraries on slow mounts will still feel slower than local disk.
---
## 🛠 Tech Stack
- **Backend**: Go 1.25+ with Echo framework
- **Database**: PostgreSQL 15+ with pgx v5
- **Frontend**: HTMX + Tailwind CSS + Templ
- **Auth**: JWT tokens with bcrypt password hashing
- **Container**: Podman (Docker compatible)
---
## 🧪 Testing
```bash
# Run all tests
make test-all
# Run integration tests (with test mode)
make test-integration
# Run Bruno OpenCollection YAML API tests
npm install -g @usebruno/cli
bruno run
# Named Volumes
volumes:
postgres_data:
bookhoard_conversion_cache:
```
---
@@ -1,41 +0,0 @@
info:
name: Add Auto-Assign Rule
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/rules'
auth: inherit
body:
type: json
data: |-
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
"priority": 1,
"enabled": true
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 201", function() {
expect(res.status).to.eql(201);
});
docs:
content: |-
# Add Auto-Assign Rule
`POST /api/collections/:id/rules` — auth: jwt.
Canonical reference: docs/developer/api/collections/add_auto_assign_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
-1
View File
@@ -16,7 +16,6 @@ http:
"color": "#00FF00",
"icon": "📖",
"collection_type": "static",
"auto_assign_rules": [],
"view_settings": { "kobo": { "view_mode": "grid" } }
}
settings:
@@ -1,31 +0,0 @@
info:
name: Remove Auto-Assign Rule
type: http
seq: 13
http:
method: DELETE
url: '{{base_url}}/api/collections/{{collection_id}}/rules/{{rule_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Remove Auto-Assign Rule
`DELETE /api/collections/:id/rules/:ruleId` — auth: jwt.
Canonical reference: docs/developer/api/collections/remove_auto_assign_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+29
View File
@@ -0,0 +1,29 @@
info:
name: Set Collection Chain Order
type: http
seq: 13
http:
method: PUT
url: '{{base_url}}/api/collections/{{collection_id}}/view-settings'
auth: inherit
body:
type: json
data: |-
{
"chain_order": "newest"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Set Collection Chain Order
`PUT /api/collections/:id/view-settings` — auth: jwt.
Canonical reference: docs/developer/api/collections/set_chain_order.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
-48
View File
@@ -1,48 +0,0 @@
info:
name: Test Rule
type: http
seq: 15
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: |-
{
"rules": [
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction"
},
{
"field": "author",
"operator": "contains",
"value": "Asimov"
}
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Test Rule
`POST /api/collections/test-rules` — auth: jwt.
Canonical reference: docs/developer/api/collections/test_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
-1
View File
@@ -15,7 +15,6 @@ http:
"description": "Updated description",
"color": "#33CCFF",
"icon": "📚",
"auto_assign_rules": [],
"view_settings": { "hide_finished": true },
"hide_finished": ["true"],
"show_up_next": ["true"]
-29
View File
@@ -1,29 +0,0 @@
info:
name: Update Collection Rule
type: http
seq: 17
http:
method: PUT
url: '{{base_url}}/api/collections/{{collection_id}}/rules/{{rule_id}}'
auth: inherit
body:
type: json
data: |-
{
"enabled": false
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Update Collection Rule
`PUT /api/collections/:id/rules/:ruleId` — auth: jwt.
Canonical reference: docs/developer/api/collections/update_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+10
View File
@@ -60,3 +60,13 @@ variables:
name: book_sha256
- secret: true
name: book_uuid
- secret: true
name: podcasts_library_id
- name: podcast_feed_url
value: https://example.com/feed.xml
- secret: true
name: podcast_feed_id
- secret: true
name: podcast_episode_id
- name: podcast_search_query
value: episode
+30
View File
@@ -0,0 +1,30 @@
info:
name: Add Podcast Feed
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/podcasts/feeds'
auth: inherit
body:
type: json
data: |-
{
"library_id": "{{podcasts_library_id}}",
"url": "{{podcast_feed_url}}",
"download_all": false
}
settings:
encodeUrl: true
timeout: 30000
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 201", function() {
expect(res.status).to.eql(201);
});
+17
View File
@@ -0,0 +1,17 @@
info:
name: Delete Feed (admin; ?files=false keeps files)
type: http
seq: 12
http:
method: DELETE
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}'
auth: inherit
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
+17
View File
@@ -0,0 +1,17 @@
info:
name: Download All (every non-downloaded episode)
type: http
seq: 8
http:
method: POST
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/episodes/all/download'
auth: inherit
runtime:
scripts:
- type: tests
code: |-
test("status is 202", function() {
expect(res.status).to.eql(202);
});
+17
View File
@@ -0,0 +1,17 @@
info:
name: Download / Retry Episode (idempotent state machine)
type: http
seq: 7
http:
method: POST
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/episodes/{{podcast_episode_id}}/download'
auth: inherit
runtime:
scripts:
- type: tests
code: |-
test("status is 202", function() {
expect(res.status).to.eql(202);
});
+17
View File
@@ -0,0 +1,17 @@
info:
name: Feed Detail (episodes + download states)
type: http
seq: 3
http:
method: GET
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}'
auth: inherit
runtime:
scripts:
- type: tests
code: |-
test("status is 200 (404 would be scoping/RLS)", function() {
expect(res.status).to.eql(200);
});
+17
View File
@@ -0,0 +1,17 @@
info:
name: List My Podcast Feeds
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/podcasts/feeds'
auth: inherit
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
+17
View File
@@ -0,0 +1,17 @@
info:
name: Manual Refresh (poll one feed)
type: http
seq: 6
http:
method: POST
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/refresh'
auth: inherit
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
+17
View File
@@ -0,0 +1,17 @@
info:
name: Effective Sequence (override applied, downloaded-only, gaps)
type: http
seq: 9
http:
method: GET
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/sequence'
auth: inherit
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
+23
View File
@@ -0,0 +1,23 @@
info:
name: Set Order Override (native | oldest | newest)
type: http
seq: 10
http:
method: PUT
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/order'
auth: inherit
body:
type: json
data: |-
{
"order": "oldest"
}
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
+17
View File
@@ -0,0 +1,17 @@
info:
name: Subscription-scoped Search (covers un-downloaded + historical)
type: http
seq: 11
http:
method: GET
url: '{{base_url}}/api/podcasts/search?q={{podcast_search_query}}'
auth: inherit
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
+17
View File
@@ -0,0 +1,17 @@
info:
name: Unsubscribe (files retained)
type: http
seq: 5
http:
method: DELETE
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/subscription'
auth: inherit
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
+25
View File
@@ -0,0 +1,25 @@
info:
name: Update Subscription (display name + auto-download)
type: http
seq: 4
http:
method: PUT
url: '{{base_url}}/api/podcasts/feeds/{{podcast_feed_id}}/subscription'
auth: inherit
body:
type: json
data: |-
{
"display_name": "My Serialized Feed",
"auto_download": "newest",
"auto_download_count": 3
}
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
+30 -5
View File
@@ -10,6 +10,7 @@ import (
"bookhoard/internal/router"
"bookhoard/internal/services"
"bookhoard/internal/sync"
"bookhoard/internal/version"
"bookhoard/templates"
"context"
"log"
@@ -33,6 +34,7 @@ func (cv *CustomValidator) Validate(i interface{}) error {
}
func main() {
log.Printf("Bookhoard %s (commit %s) starting", version.Version, version.Commit)
cfg := config.LoadConfig()
dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
@@ -41,7 +43,12 @@ func main() {
}
defer dbPool.Close()
queries := database.New(dbPool)
// The search service's raw DBTX path must ALSO carry the RLS
// identity — hand it the ScopedDB itself (a DBTX), never the bare
// pool, or search queries bypass the per-user GUC and raise under
// the policies.
scopedDB := database.NewScopedDB(dbPool)
queries := database.New(scopedDB)
// Initialize database schema
log.Println("🔧 Ensuring database schema is initialized...")
@@ -53,6 +60,9 @@ func main() {
// Load tunable settings from the DB into the registry. All values fall back
// to compiled defaults if a row is missing, so this never blocks startup.
// (Startup has no end-user identity; system_settings is not RLS-
// governed, and ScopedDB declares nothing when no identity exists —
// the engine only raises on governed tables.)
registry := database.NewSettingsRegistry(queries)
if err := registry.Load(ctx); err != nil {
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
@@ -160,15 +170,27 @@ func main() {
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
opdsHandler.SetSettings(registry)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
// One composed-engine service shared by search AND the collection
// subsystem (item 3.2): tree-driven smart collections evaluate
// through the unified engine.
collectionSearchService := services.NewSearchService(queries, scopedDB)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService)
dashboardService := services.NewDashboardService(queries, collectionSearchService)
dashboardHandler := handlers.NewDashboardHandler(queries, collectionSearchService)
seriesHandler := handlers.NewSeriesHandler(queries)
filtersHandler := handlers.NewFiltersHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService)
mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
jobsHandler := handlers.NewJobsHandler(queries, worker)
// Podcast subsystem: fetcher service + concurrent download worker
// (§0 item 4: pool, fair share, admin tunables) + poller. One
// instance trio per server (per-feed locks dedupe).
podcastService := services.NewPodcastService(queries)
podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService)
podcastWorker.SetSettings(registry)
podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker)
e := echo.New()
// Set up validator
@@ -229,6 +251,9 @@ func main() {
CollectionHandler: collectionHandler,
FiltersHandler: filtersHandler,
DashboardHandler: dashboardHandler,
PodcastHandler: podcastHandler,
PodcastService: podcastService,
PodcastWorker: podcastWorker,
DashboardService: dashboardService,
SeriesHandler: seriesHandler,
OPDSHandler: opdsHandler,
+10 -3
View File
@@ -289,7 +289,12 @@ func TestAudiobookTrackEndpoints(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s must be gated", path)
// PIN WIDENED for RLS (docs/contract_pins.md 2026-10-29): the
// handler's own Go-level gate still answers 403, but under
// engine-level visibility the hidden row is simply ABSENT —
// 404 is equally gated (no existence leak). Both accepted.
require.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound,
"%s must be gated (403 or 404), got %d", path, resp.StatusCode)
}
// Restore visibility so harness cleanup ordering never sees a
@@ -654,12 +659,14 @@ func TestAudiobookFolderSidecarEditDetected(t *testing.T) {
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Titlz","series":["Sidecar Saga (Reading Order) #1"]}`), 0o644))
scanTestFolder(t, setup, admin, folder)
var collCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
// Owner-scoped read: collections RLS (2026-10-31) hides rows from
// the pool session's SeedIdentity.
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
).Scan(&collCount))
require.Equal(t, 1, collCount, "reading-order sidecar edit creates the collection for an existing book")
scanTestFolder(t, setup, admin, folder)
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
).Scan(&collCount))
require.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
+1 -4
View File
@@ -2,7 +2,6 @@ package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -367,8 +366,6 @@ func TestPreviewCollection(t *testing.T) {
}
func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID string, count int) []string {
ctx := context.Background()
libUUID, err := uuid.Parse(libraryID)
require.NoError(t, err, "Should parse library ID")
@@ -379,7 +376,7 @@ func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID s
for i := 0; i < count; i++ {
genre := genres[i%len(genres)]
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Preview Test Book " + string(rune('A'+i)),
Authors: []string{"Test Author " + string(rune('A'+i))},
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"bytes"
"testing"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Collections RLS (2026-10-31): ownership enforcement moved to the DB
// layer (p_collections_* + parent-checked collection_items policies in
// schema.sql, predicates app_can_read/write_collection). The API surface
// answers invisibility with 404 — including the write endpoints that
// had NO app-level gate (Update/AddBooks, the flagged gap). Admin keeps
// the escape; system collections stay per-user; registration still
// seeds them (the seeding identity fix in
// CreateDefaultCollectionsForUser is regression-pinned here).
func TestCollectionsRLS(t *testing.T) {
setup := setupTestServer(t)
ownerToken := setup.RegularToken // user A: the harness regular user
adminToken := setup.Token
marker := fmt.Sprintf("rls%d", time.Now().UnixNano()%100000)
// User B: a second regular user via registration. The register
// endpoint is rate-limited — exactly one call. The response's
// access_token is B's identity for every probe below.
regEmail := fmt.Sprintf("%s@tests.bookhoard.internal", marker)
regBody := fmt.Sprintf(
`{"email":%q,"username":%q,"password":"Test@Pass123!"}`,
regEmail, "rlsb"+marker)
regResp, err := http.Post(setup.Server.URL+"/api/auth/register",
"application/json", bytes.NewReader([]byte(regBody)))
require.NoError(t, err)
require.Equal(t, http.StatusCreated, regResp.StatusCode,
"registration must succeed — including the system-collection seeding that now runs under the new user's RLS identity")
var reg struct {
Token string `json:"access_token"`
}
require.NoError(t, json.NewDecoder(regResp.Body).Decode(&reg))
require.NotEmpty(t, reg.Token)
otherToken := reg.Token
_ = regResp.Body.Close()
// B's registration seeded B's OWN system collections — visible in
// B's list (this is the registration-seeding identity fix, live).
code, body := podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), "Continue Reading",
"registration seeded the new user's system collections under RLS")
// A creates the target collection.
code, body = podcastAuthed(t, setup, ownerToken, "POST", "/api/collections",
`{"name":"test RLS Owner ` + marker + `"}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, ownerToken, "DELETE", "/api/collections/"+coll.ID, "")
})
// ── B (non-owner) probes A's collection: invisible = 404 ──
code, _ = podcastAuthed(t, setup, otherToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusNotFound, code, "detail-by-id is RLS-invisible cross-user")
code, body = podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "")
require.Equal(t, http.StatusOK, code)
assert.NotContains(t, string(body), "test RLS Owner "+marker,
"the list query stays user-scoped")
code, body = podcastAuthed(t, setup, otherToken, "PUT", "/api/collections/"+coll.ID,
`{"name":"renamed by B"}`)
require.Equal(t, http.StatusNotFound, code,
"update of an RLS-invisible row answers 404 (was: ungated success): %s", string(body))
code, body = podcastAuthed(t, setup, otherToken, "POST", "/api/collections/"+coll.ID+"/books",
`{"book_ids":[]}`)
require.Equal(t, http.StatusNotFound, code,
"AddBooks on an RLS-invisible collection answers 404 (the flagged ungated-write gap, closed at the DB)")
code, body = podcastAuthed(t, setup, otherToken, "PUT",
"/api/collections/"+coll.ID+"/view-settings", `{"chain_order":"newest"}`)
require.Equal(t, http.StatusNotFound, code,
"chain-order write on an invisible collection answers 404")
code, _ = podcastAuthed(t, setup, otherToken, "DELETE", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusNoContent, code,
"DELETE touches no rows under RLS (silent no-op — no destructive action)")
// The no-op delete lied about nothing: A still owns the collection.
code, body = podcastAuthed(t, setup, ownerToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code, "owner retains the collection after B's failed delete")
// ── admin escape: sees everything, as with media ──
code, _ = podcastAuthed(t, setup, adminToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code, "admin persona sees collections (policy escape)")
// ── B's ownership works: B renames B's OWN system collection ──
// (positive control — B is not locked out of what B owns)
code, body = podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "")
require.Equal(t, http.StatusOK, code)
var list struct {
Collections []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"collections"`
}
require.NoError(t, json.Unmarshal(body, &list))
var bSystem string
for _, c := range list.Collections {
if c.Name == "Continue Reading" {
bSystem = c.ID
break
}
}
require.NotEmpty(t, bSystem, "B owns the seeded system collections")
code, body = podcastAuthed(t, setup, otherToken, "PUT", "/api/collections/"+bSystem,
`{"name":"Continue Reading"}`)
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, code,
"owner mutation of an OWN collection still works: %s", string(body))
}
+96 -141
View File
@@ -56,36 +56,6 @@ func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionI
return result
}
func smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response {
t.Helper()
payload := map[string]interface{}{
"field": field,
"operator": operator,
"value": value,
}
if enabled != nil {
payload["enabled"] = *enabled
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return resp
}
func smartDecodeRuleID(t *testing.T, resp *http.Response) string {
t.Helper()
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
var result map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
rule := result["rule"].(map[string]interface{})
return rule["id"].(string)
}
// Data helpers -----------------------------------------------------------
func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) {
t.Helper()
ctx := context.Background()
@@ -108,7 +78,7 @@ func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uui
})
for title, genre := range titlesByGenre {
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: title,
Authors: []string{"Smart Rule Author"},
@@ -145,81 +115,83 @@ func TestSmartCollections(t *testing.T) {
setup := setupTestServer(t)
adminID := getTestUserID(t, setup.DB)
t.Run("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) {
t.Run("TreeCollectionLiveMembership", func(t *testing.T) {
// The unified engine (item 3.2): a collection created WITH a
// books-scope tree evaluates LIVE — stored links are ignored,
// only tree matches render.
_, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart SF Book One": "Science Fiction",
"Smart SF Book Two": "Science Fiction",
"Smart FANT Book": "Fantasy",
})
_ = bookIDs
// Rule evaluation ignores stored links: a static collection's own
// added item must NOT show up once the collection is smart.
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static")
// Add one hand-picked book (a Fantasy one that the smart rule
// will not match — so it must vanish from the render).
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}})
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
addReq.Header.Set("Content-Type", "application/json")
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
addResp, err := http.DefaultClient.Do(addReq)
tree := map[string]interface{}{
"scope": "books", "mode": "all",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
}
body, _ := json.Marshal(map[string]interface{}{
"name": "Test Smart Tree " + uuid.NewString()[:8],
"filter_tree": tree,
})
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
_ = addResp.Body.Close()
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
assert.Equal(t, false, staticDetail["is_smart"])
assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link")
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var created struct {
ID string `json:"id"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
_ = resp.Body.Close()
t.Cleanup(func() {
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+created.ID, nil)
del.Header.Set("Authorization", "Bearer "+setup.Token)
_, _ = http.DefaultClient.Do(del)
})
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type")
assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only")
memberTitles := smartLibraryTitleSet(t, smartDetail)
detail := smartGetCollection(t, setup, setup.Token, created.ID)
assert.Equal(t, true, detail["is_smart"], "tree presence derives the type")
memberTitles := smartLibraryTitleSet(t, detail)
assert.True(t, memberTitles["Smart SF Book One"])
assert.True(t, memberTitles["Smart SF Book Two"])
assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)")
})
t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) {
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart Diss Book": "Science Fiction",
})
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart")
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
ruleID := smartDecodeRuleID(t, resp)
assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"])
// Disable: membership drops to zero at once.
falseFlag := false
dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag})
dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody))
dReq.Header.Set("Content-Type", "application/json")
dReq.Header.Set("Authorization", "Bearer "+setup.Token)
dResp, err := http.DefaultClient.Do(dReq)
require.NoError(t, err)
require.Equal(t, http.StatusOK, dResp.StatusCode)
_ = dResp.Body.Close()
assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match")
assert.False(t, memberTitles["Smart FANT Book"], "the tree's AND governs membership live")
})
t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) {
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart Guard Book": "Science Fiction",
})
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart")
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
tree := map[string]interface{}{
"scope": "books", "mode": "all",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
}
body, _ := json.Marshal(map[string]interface{}{
"name": "Test Smart Guard " + uuid.NewString()[:8],
"filter_tree": tree,
})
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var created struct {
ID string `json:"id"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
_ = resp.Body.Close()
collID := created.ID
t.Cleanup(func() {
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil)
del.Header.Set("Authorization", "Bearer "+setup.Token)
_, _ = http.DefaultClient.Do(del)
})
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
@@ -238,53 +210,10 @@ func TestSmartCollections(t *testing.T) {
_ = removeResp.Body.Close()
})
t.Run("RemoveLastRuleRevertsToStatic", func(t *testing.T) {
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart Back Book": "Science Fiction",
})
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart")
resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
ruleID := smartDecodeRuleID(t, resp)
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
assert.Equal(t, true, smartDetail["is_smart"])
delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil)
delReq.Header.Set("Authorization", "Bearer "+setup.Token)
delResp, err := http.DefaultClient.Do(delReq)
require.NoError(t, err)
require.Equal(t, http.StatusOK, delResp.StatusCode)
_ = delResp.Body.Close()
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static")
// Membership mutations work again on the reverted static.
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
addReq.Header.Set("Content-Type", "application/json")
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
addResp, err := http.DefaultClient.Do(addReq)
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, addResp.StatusCode)
_ = addResp.Body.Close()
})
t.Run("RuleValidationAndSystemCollections", func(t *testing.T) {
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static")
resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
_ = resp.Body.Close()
resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
_ = resp.Body.Close()
// System collections may never carry rules.
colls, err := setup.DB.GetCollectionsByUser(context.Background(), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
t.Run("SystemCollectionsBlockedFromTheEditor", func(t *testing.T) {
// System collections may never carry rules/trees — the editor
// route refuses them (item 3.4: the page IS the editor).
colls, err := setup.DB.GetCollectionsByUser(ownerSeedCtx(pgtype.UUID{Bytes: [16]byte(adminID), Valid: true}), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
require.NoError(t, err)
var systemID string
for _, c := range colls {
@@ -295,20 +224,46 @@ func TestSmartCollections(t *testing.T) {
}
require.NotEmpty(t, systemID, "admin user should have system collections")
sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil)
require.Equal(t, http.StatusBadRequest, sysResp.StatusCode)
_ = sysResp.Body.Close()
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+systemID+"/rules", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
"system collections never render the smart editor")
})
t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) {
_, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart List Book": "Science Fiction",
})
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart")
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
_ = resp.Body.Close()
tree := map[string]interface{}{
"scope": "books", "mode": "all",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
}
body, _ := json.Marshal(map[string]interface{}{
"name": "Test Smart List " + uuid.NewString()[:8],
"filter_tree": tree,
})
req2, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", "Bearer "+setup.Token)
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp2.StatusCode)
var created struct {
ID string `json:"id"`
}
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&created))
_ = resp2.Body.Close()
collID := created.ID
t.Cleanup(func() {
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil)
del.Header.Set("Authorization", "Bearer "+setup.Token)
_, _ = http.DefaultClient.Do(del)
})
// Default list (the Android app's read): smart collections hidden.
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil)
@@ -113,7 +113,7 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
params.AlternateInfo = []byte(v)
}
mediaItem, err := setup.DB.CreateMediaItem(ctx, params)
mediaItem, err := setup.DB.CreateMediaItem(seedCtx(), params)
require.NoError(t, err, "Should create media item")
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
@@ -596,7 +596,7 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
Isbn: pgtype.Text{String: "9781234567890", Valid: true},
}
mediaItem, err := setup.DB.CreateMediaItem(ctx, params)
mediaItem, err := setup.DB.CreateMediaItem(seedCtx(), params)
require.NoError(t, err, "Should create ebook")
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
@@ -740,7 +740,7 @@ func TestComicMetadataDisplay_AllFieldsTogether(t *testing.T) {
// Create a media item with ALL metadata fields populated
alternateInfoStr := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
var mediaItem database.MediaItems
mediaItem, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
mediaItem, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "Complete Metadata Comic",
FilePath: "/test/complete.cbz",
+7 -7
View File
@@ -25,7 +25,7 @@ func TestComicMetadataExtraction(t *testing.T) {
require.NoError(t, err, "Should parse library UUID")
// Insert test media item with full comic metadata
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "Test Manga",
FilePath: "/test/manga.cbz",
@@ -64,7 +64,7 @@ func TestComicMetadataExtraction(t *testing.T) {
parsedLibraryID, err := uuid.Parse(libraryID)
require.NoError(t, err, "Should parse library UUID")
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "Test Comic",
FilePath: "/test/comic.cbz",
@@ -94,7 +94,7 @@ func TestComicMetadataExtraction(t *testing.T) {
parsedLibraryID, err := uuid.Parse(libraryID)
require.NoError(t, err, "Should parse library UUID")
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "Minimal Comic",
FilePath: "/test/minimal.cbz",
@@ -136,7 +136,7 @@ func TestReadingDirectionAPI(t *testing.T) {
}
for _, tc := range testCases {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: tc.title,
FilePath: "/test/" + tc.title + ".cbz",
@@ -205,7 +205,7 @@ func TestUniversalMetadataFields(t *testing.T) {
require.NoError(t, err, "Should parse ebook library UUID")
t.Run("Comic with universal fields", func(t *testing.T) {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedComicLibraryID, Valid: true},
Title: "Comic with Universal Metadata",
FilePath: "/test/comic.cbz",
@@ -231,7 +231,7 @@ func TestUniversalMetadataFields(t *testing.T) {
})
t.Run("Ebook with universal fields", func(t *testing.T) {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedEbookLibraryID, Valid: true},
Title: "Ebook with Universal Metadata",
FilePath: "/test/book.epub",
@@ -270,7 +270,7 @@ func TestComicSpecificFields(t *testing.T) {
t.Run("Alternate series info as JSONB", func(t *testing.T) {
alternateInfo := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "X-Men with Alternate Series",
FilePath: "/test/xmen.cbz",
+24 -5
View File
@@ -120,7 +120,7 @@ func createContractFixture(t *testing.T, setup *TestServerSetup) contractFixture
seriesName := fmt.Sprintf("Test Contract Series %d", time.Now().UnixNano())
itemTitle := fmt.Sprintf("Test Contract Item %d", time.Now().UnixNano())
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: itemTitle,
Authors: []string{"Test Contract Author"},
@@ -459,6 +459,8 @@ var bookInfoKeys = []string{
"media_item_id", "title", "author", "cover_image_path",
"has_conflict", "format_group", "badge",
"viewer_percentage", "library_name",
// Episode-collections extras (2.3 widening, every surface emits):
"is_episode", "feed_title", "download_state", "hidden",
}
func collectionURL(setup *TestServerSetup, id string) string {
@@ -498,8 +500,9 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
// payload; docs/contract_pins.md changelog).
envelopeKeys := []string{
"id", "user_id", "name", "description", "color", "icon",
"auto_assign_rules", "view_settings", "books", "book_count",
"view_settings", "books", "book_count",
"is_smart", "created_at", "up_next",
"filter_tree", // item-3.2 widening (docs/contract_pins.md)
}
t.Run("user collection with library_id (badged branch)", func(t *testing.T) {
@@ -562,11 +565,14 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
assertArrayItemShape(t, "collection books[] (no library)", books, bookInfoKeys, bookInfoKeys)
})
t.Run("smart collection (rules branch)", func(t *testing.T) {
t.Run("smart collection (tree branch)", func(t *testing.T) {
resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{
"name": fmt.Sprintf("Test Contract Smart %d", time.Now().UnixNano()),
"auto_assign_rules": []map[string]interface{}{
{"id": "rule-1", "field": "genre", "operator": "equals", "value": "Science Fiction", "priority": 5},
"filter_tree": map[string]interface{}{
"scope": "books", "mode": "any",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
},
}, setup.Token)
defer resp.Body.Close()
@@ -752,6 +758,19 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
"tags_search", "contributors_search", "narrators_search", "file_sha256",
"opf_identifier", "opf_uuid", "hash_confidence", "library_name",
"library_type_name_2",
// Podcast episode columns (2026-10-29 additive widening — plan
// podcast-integration.md tasks 1.1/1.10): columns on
// media_items, NULL for scan rows. See docs/contract_pins.md
// changelog.
"source", "source_feed_id", "episode_guid", "feed_position",
"season_number", "episode_number", "enclosure_url",
"declared_duration_seconds", "transcript_url", "chapters_url",
"hosts", "guests", "download_state", "download_error",
"download_attempts",
// §0 item 4 (2026-10-30 additive widening): the download pool's
// queue ticket — SEQUENCE-stamped at enqueue, NULL for scan rows
// and never-queued episodes. See docs/contract_pins.md changelog.
"download_queue_pos",
}
resolvedKeys := []string{
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
@@ -0,0 +1,157 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"bookhoard/internal/database"
)
// The custom-section builder's save, end to end (2026-10-31 tree
// migration). The page posts the legacy rules vocabulary — rules as a
// JSON-encoded ARRAY inside a STRING (it has done so since the page was
// written, 2026-02-19; the save never once bound successfully before
// the migration) — plus match_type, hand-picked book ids, and the
// dashboard pin. The server converts the rules to a filter tree at
// write time, attaches the manual picks, and the dashboard section
// renders stored membership + the tree's live matches, deduped.
func TestCustomSectionBuilderSave(t *testing.T) {
setup := setupTestServer(t)
adminToken := setup.Token
regToken := setup.RegularToken
marker := fmt.Sprintf("csb%d", time.Now().UnixNano()%100000)
libID := createTestLibraryWithFolder(t, setup.Server, adminToken, "test CSB Library "+marker, true)
libUUID, err := uuid.Parse(libID)
require.NoError(t, err)
// Two items the title rule matches, one it doesn't. Alpha is ALSO
// hand-picked — the section must render it once, not twice.
makeItem := func(title string) uuid.UUID {
id, cerr := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: title,
Authors: []string{"CSB Author"},
FilePath: fmt.Sprintf("/tmp/csb/%s-%s.epub", marker, strings.ReplaceAll(title, " ", "-")),
FileSize: pgtype.Int8{Int64: 128, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, cerr)
out, perr := uuid.FromBytes(id.ID.Bytes[:])
require.NoError(t, perr)
return out
}
alpha := makeItem("CSBRULE Alpha")
_ = makeItem("CSBRULE Beta")
manual := makeItem("CSB Manual Pick")
require.NotZero(t, alpha)
// The EXACT payload shape the builder page posts.
payload := fmt.Sprintf(`{
"library_id": %q,
"name": "test CSB Section %s",
"icon": "📚",
"description": "built by the custom-section builder",
"show_on_dashboard": true,
"auto_assign_rules": %[3]s,
"manual_book_ids": [%q, %q],
"match_type": "all"
}`, libID, marker,
`"[{\"field\":\"title\",\"operator\":\"contains\",\"value\":\"CSBRULE\"}]"`,
alpha.String(), manual.String())
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", payload)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal([]byte(body), &coll))
collID := coll.ID
require.NotEmpty(t, collID)
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+collID, "")
})
// The converted tree round-trips: root mode from match_type, the
// rule as a qf child, and the collection IS smart with no
// collection_type sent.
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
var detail struct {
IsSmart bool `json:"is_smart"`
FilterTree struct {
Mode string `json:"mode"`
Children []struct {
Field string `json:"field"`
Op string `json:"op"`
Values []string `json:"values"`
} `json:"children"`
} `json:"filter_tree"`
}
require.NoError(t, json.Unmarshal([]byte(body), &detail))
assert.True(t, detail.IsSmart, "converted rules make the collection smart")
assert.Equal(t, "all", detail.FilterTree.Mode, "match_type seeds the tree's root mode")
require.Len(t, detail.FilterTree.Children, 1)
assert.Equal(t, "title", detail.FilterTree.Children[0].Field)
assert.Equal(t, []string{"CSBRULE"}, detail.FilterTree.Children[0].Values)
// The dashboard section renders stored membership + live matches,
// deduped: Alpha (rule + manual), Beta (rule), Manual (manual).
code, body = podcastAuthed(t, setup, regToken, "GET",
"/api/dashboard/sections?library_id="+libID, "")
require.Equal(t, http.StatusOK, code, "%s", string(body))
var dash struct {
Sections []struct {
Title string `json:"title"`
CollectionID string `json:"collection_id"`
Items []struct {
Title string `json:"title"`
} `json:"items"`
} `json:"sections"`
}
require.NoError(t, json.Unmarshal([]byte(body), &dash))
var section *struct {
Title string `json:"title"`
CollectionID string `json:"collection_id"`
Items []struct {
Title string `json:"title"`
} `json:"items"`
}
for i := range dash.Sections {
if dash.Sections[i].CollectionID == collID {
section = &dash.Sections[i]
break
}
}
require.NotNil(t, section, "the custom section renders on the dashboard: %s", string(body))
titles := map[string]int{}
for _, it := range section.Items {
titles[it.Title]++
}
assert.Equal(t, 1, titles["CSBRULE Alpha"], "rule-matched AND hand-picked renders exactly once")
assert.Equal(t, 1, titles["CSBRULE Beta"], "rule match rides the tree")
assert.Equal(t, 1, titles["CSB Manual Pick"], "manual picks are attached")
assert.Len(t, section.Items, 3, "nothing else leaks into the section")
}
// TestCustomSectionBuilderBadRule pins the adapter's rejection path: a
// rule the engine cannot honor is a 400 that names the field, never a
// stored 500 at render time.
func TestCustomSectionBuilderBadRule(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test CSB Bad `+fmt.Sprint(time.Now().UnixNano())+`","auto_assign_rules":"[{\"field\":\"not_a_field\",\"operator\":\"contains\",\"value\":\"x\"}]"}`)
assert.Equal(t, http.StatusBadRequest, code, "%s", string(body))
assert.Contains(t, string(body), "section rule")
}
+6 -7
View File
@@ -89,7 +89,7 @@ func TestFacetRegistryParamsFilter(t *testing.T) {
if mutate != nil {
mutate(&p)
}
_, err := setup.DB.CreateMediaItem(ctx, p)
_, err := setup.DB.CreateMediaItem(seedCtx(), p)
require.NoError(t, err, "create %s", title)
}
@@ -194,7 +194,7 @@ func TestFacetsEndpoint(t *testing.T) {
// Two items share the genre; one carries the language + narrator.
for _, title := range []string{"Facet List One", "Facet List Two"} {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID,
Title: title,
Authors: []string{"Facet Listing Author"},
@@ -206,7 +206,7 @@ func TestFacetsEndpoint(t *testing.T) {
})
require.NoError(t, err)
}
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Facet List Three",
Authors: []string{"Facet Listing Author"},
@@ -318,7 +318,6 @@ func mergeParams(base, extra url.Values) url.Values {
// "Baen Books") cross-match at 0.3+ (session gotcha).
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
t.Helper()
ctx := context.Background()
books := []struct {
title string
genre string
@@ -329,7 +328,7 @@ func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UU
{prefix + " Gamma", "history", "Pinecrest"},
}
for _, b := range books {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libraryID,
Title: b.title,
Authors: []string{prefix + " Author"},
@@ -426,7 +425,7 @@ func TestFacetCountsUnderFilters(t *testing.T) {
facetFixtureBooks(t, setup, library.ID, "Counts")
// A book in the HIDDEN library: distinctive values that must never
// surface for the regular user.
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: hiddenLib.ID,
Title: "Counts Hidden Book",
Authors: []string{"Counts Secret Author"},
@@ -559,7 +558,7 @@ func TestFieldValuesAllLibraries(t *testing.T) {
id pgtype.UUID
name string
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: lib.id,
Title: fmt.Sprintf("Field Values %s Book", lib.name),
Authors: []string{"Verdana Scribe"},
+232 -35
View File
@@ -156,7 +156,7 @@ func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, n
}
books[i].TagsSearch = lowered
}
_, err := setup.DB.CreateMediaItem(ctx, books[i])
_, err := setup.DB.CreateMediaItem(seedCtx(), books[i])
require.NoError(t, err, "create %s", books[i].Title)
}
return libUUID, library.ID
@@ -267,11 +267,9 @@ func TestFilterComposedSemantics(t *testing.T) {
assert.Len(t, got, 4, "mime_type is")
// Series full membership: the alternate_info series matches too.
ctx := context.Background()
_, err := setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
WHERE title = $1`, "Sem Fen")
require.NoError(t, err)
got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
got = titles(qfAll(qfRule("series", "is", "Quill Chronicles")))
@@ -297,6 +295,115 @@ func TestFilterComposedSemantics(t *testing.T) {
assert.Equal(t, []string{"Sem Tide"}, got, "published_year at_least")
}
// TestFilterScopesAndEpisodeFields pins item 3.1: the books scope
// excludes podcasts (the leak the composed head used to have), the
// episodes scope is episodes only, episode fields evaluate in SQL
// (feed-in, downloaded, played, published-date-within), and episode
// fields are 400s in books trees.
func TestFilterScopesAndEpisodeFields(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
regUUID := getRegularUserID(t, setup.DB)
builderFixtureLib(t, setup, regUUID, "Scope")
// Podcasts library + a subscribed feed with three downloaded
// episodes: two fresh/unplayed, one played (~finished) — the
// played-rule fixture.
feedLib, _ := createPodcastsLibrary(t, setup, "test Scope Podcasts")
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: feedLib.ID,
FeedUrl: "https://feed.invalid/scope-probe.xml",
Title: "Scope Probe Feed",
})
require.NoError(t, err)
epFresh := upsertTestEpisode(t, setup, feed, "scope-fresh", "Scope Fresh Episode", 1)
epPlayed := upsertTestEpisode(t, setup, feed, "scope-played", "Scope Played Episode", 2)
upsertTestEpisode(t, setup, feed, "scope-pending", "Scope Pending Episode", 3)
subscribeRaw(t, setup, regUUID, feed.ID)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE WHERE id IN ($1, $2)`, epFresh.ID, epPlayed.ID)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
INSERT INTO reading_progress (media_item_id, user_id, percentage)
VALUES ($1, $2, 1.0) ON CONFLICT (media_item_id, user_id)
DO UPDATE SET percentage = 1.0`, epPlayed.ID, regUUID)
feedID := uuid.UUID(feed.ID.Bytes).String()
qfScoped := func(scope string, children ...map[string]interface{}) url.Values {
b, _ := json.Marshal(map[string]interface{}{"scope": scope, "mode": "all", "children": children})
return url.Values{"qf": {string(b)}}
}
const booksScope = "books"
const episodesScope = "episodes"
search := func(p url.Values) []string {
rows := p
status, body := qfSearch(t, setup, setup.RegularToken, rows)
// 404 is the endpoint's pinned no-results contract — an empty set.
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status, "qf must parse: %v", rows)
return qfTitles(t, body)
}
t.Run("books_scope_excludes_podcasts", func(t *testing.T) {
// An exact-ish title hits BOTH a book fixture ("Scope Tide") and
// the episode fixture if the head leaks; books scope must return
// the book, never the episodes.
got := search(qfScoped(booksScope, qfRule("title", "is", "Scope Tide")))
assert.Equal(t, []string{"Scope Tide"}, got, "books scope returns the book")
got = search(qfScoped(booksScope, qfRule("title", "is", "Scope Fresh Episode")))
assert.Empty(t, got,
"books scope must never return podcast episodes (the composed-head leak is CLOSED)")
})
t.Run("episodes_scope_is_episodes_only", func(t *testing.T) {
got := search(qfScoped(episodesScope, qfRule("title", "is", "Scope Fresh Episode")))
assert.Equal(t, []string{"Scope Fresh Episode"}, got,
"episodes scope: only episodes — books drop")
})
t.Run("episode_rules_evaluate_in_sql", func(t *testing.T) {
got := search(qfScoped(episodesScope,
qfRule("feed", "in", feedID)))
assert.ElementsMatch(t, []string{"Scope Fresh Episode", "Scope Played Episode", "Scope Pending Episode"},
got, "feed-in matches every episode of the feed (download state is a separate rule)")
got = search(qfScoped(episodesScope,
qfRule("downloaded", "is_true")))
assert.ElementsMatch(t, []string{"Scope Fresh Episode", "Scope Played Episode"},
got, "downloaded:true drops the pending episode")
got = search(qfScoped(episodesScope,
qfRule("played", "is_false"),
qfRule("downloaded", "is_true")))
assert.ElementsMatch(t, []string{"Scope Fresh Episode"}, got,
"played:false + downloaded = the unplayed playable episode (viewer progress)")
got = search(qfScoped(episodesScope,
qfRule("played", "is_true")))
assert.ElementsMatch(t, []string{"Scope Played Episode"}, got,
"played:true matches the finished episode")
got = search(qfScoped(episodesScope,
qfRule("published_date", "within_days", "7"),
qfRule("downloaded", "is_true")))
assert.ElementsMatch(t, []string{"Scope Fresh Episode", "Scope Played Episode"},
got, "published-within includes the dated episodes")
})
t.Run("episode_fields_are_400_in_books_scope", func(t *testing.T) {
p := qfScoped(booksScope, qfRule("played", "is_true"))
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode,
"episode-only fields are rejected in books trees at parse time")
})
}
func TestFilterUserRelativeFields(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
@@ -327,43 +434,39 @@ func TestFilterUserRelativeFields(t *testing.T) {
// Reader A: reading Tide (50%), finished Stone, rated Tide 8,
// reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
// Stone, time on Wick (2h), collection holding Tide.
_, err := setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,0.5, now()), ($3,$4,1.0, now())`,
tideID, aID, stoneID, aID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
tideID, bID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`,
aID, wickID, aID, tideID)
require.NoError(t, err)
var collID pgtype.UUID
err = setup.DBPool.QueryRow(ctx,
// Seed as the OWNER (collections RLS, 2026-10-31): the insert
// predicate wants actor == owner, and the item/cleanup writes want
// the same identity — the admin write-escape covers SYSTEM rows only.
err := database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(aID),
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
_, err = setup.DBPool.Exec(ctx,
t.Cleanup(func() {
scopeExecAs(t, setup, aID, `DELETE FROM collections WHERE id = $1`, collID)
})
scopeExecAs(t, setup, aID,
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
require.NoError(t, err)
titlesFor := func(token string, qf string) []string {
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
@@ -444,8 +547,7 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
libUUID, _ := builderFixtureLib(t, setup, userID, "Sug")
// Add a second distinct author+genre pair (trigram-disjoint).
ctx := context.Background()
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Sug Dune",
Authors: []string{"Zephyrine Hale"},
@@ -548,6 +650,16 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
}
assert.True(t, found, "the visible library is offered")
// ID LOOKUP (2026-11-01): typing a stored ID resolves its label —
// the builder's post-Apply reload path resolves stored ids to names
// through this same call.
status, results = suggest("library", libUUID.String(), "")
require.Equal(t, http.StatusOK, status)
require.NotEmpty(t, results, "the id lookup returns its row")
assert.Equal(t, libUUID.String(), results[0]["value"])
disp, _ := results[0]["display"].(string)
assert.Contains(t, disp, "Test Builder", "the id resolves to the library NAME")
// BROWSE the library field UNDER rules (the blank-typed click): the
// id-source query must not choke on the unused typed parameter and
// must list exactly the library holding the surviving books.
@@ -572,18 +684,22 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
// offered with the surviving count; zero-match containers never are.
userA := getRegularUserID(t, setup.DB)
var collID pgtype.UUID
err = setup.DBPool.QueryRow(ctx,
// Seed as the OWNER (collections RLS, 2026-10-31) — same identity
// rule as the first seeding site in this file.
ownerID := pgtype.UUID{Bytes: [16]byte(userA), Valid: true}
err = database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(ownerID),
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
ownerID,
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
t.Cleanup(func() {
scopeExecAs(t, setup, ownerID, `DELETE FROM collections WHERE id = $1`, collID)
})
var tideID pgtype.UUID
err = setup.DBPool.QueryRow(ctx, `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
err = setup.DBPool.QueryRow(context.Background(), `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
scopeExecAs(t, setup, ownerID,
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
require.NoError(t, err)
status, results = suggest("collection", " ", "")
require.Equal(t, http.StatusOK, status)
@@ -632,7 +748,7 @@ func TestFilterFamilyLens(t *testing.T) {
id pgtype.UUID
title string
}{{visibleID, "Lens Vis Book"}, {hiddenID, "Lens Hid Book"}} {
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: lib.id, Title: lib.title,
Authors: []string{"Lens Secret Author"},
FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()),
@@ -641,10 +757,9 @@ func TestFilterFamilyLens(t *testing.T) {
})
require.NoError(t, err)
}
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
require.NoError(t, err)
search := func(params url.Values) (int, []map[string]interface{}) {
return qfSearch(t, setup, setup.RegularToken, params)
@@ -788,3 +903,85 @@ func TestFilterFlatEquivalence(t *testing.T) {
require.Equal(t, statusFlat, statusTree, "same result status")
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows))
}
// ---- deep group nesting (2026-11-01 group ALL/ANY header toggle): ----
// the UI can now produce 3+-level mixed-mode trees; pin the engine's
// parenthesized evaluation for the user's target structure
// GROUP(GROUP(Field and (Field or Field)) or GROUP(Field and Field and
// (Field or Field)) and GROUP(Field)) — plus a suggestion request
// carrying a nested-any constraint (the mode-aware menu under real
// nesting) — must be 200, never the empty-group 400.
func TestFilterDeepGroupNesting(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
userID := getRegularUserID(t, setup.DB)
libUUID, _ := builderFixtureLib(t, setup, userID, "Deep")
titles := func(qf string) []string {
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
status, rows := qfSearch(t, setup, setup.RegularToken, p)
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
return qfTitles(t, rows)
}
// root ALL[
// ANY[ ALL[fantasy, ANY[>=250p, abridged]] , ALL[Dar, >=500p] ],
// author not_contains Quill
// ]
// => Tide (fantasy + 300p), Wick (Dar + 700p); Fen dies at the root
// AND (Quill); Stone fails branch A (history) and branch B (Voss).
deep := map[string]interface{}{
"mode": "all",
"children": []interface{}{
map[string]interface{}{
"mode": "any",
"children": []interface{}{
map[string]interface{}{
"mode": "all",
"children": []interface{}{
qfRule("genre", "is", "fantasy"),
map[string]interface{}{
"mode": "any",
"children": []interface{}{
qfRule("page_count", "at_least", "250"),
qfRule("abridged", "is_true"),
},
},
},
},
map[string]interface{}{
"mode": "all",
"children": []interface{}{
qfRule("author", "contains", "Dar"),
qfRule("page_count", "at_least", "500"),
},
},
},
},
qfRule("author", "not_contains", "Quill"),
},
}
deepJSON, merr := json.Marshal(deep)
require.NoError(t, merr)
got := titles(string(deepJSON))
assert.ElementsMatch(t, []string{"Deep Tide", "Deep Wick"}, got,
"3-level mixed-mode nesting evaluates parenthesized")
// The same shape riding the SUGGEST endpoint (the mode-aware menu's
// constraint) — nested any must be a 200, never the empty-group 400.
p := url.Values{
"library_id": {libUUID.String()},
"author": {""},
"limit": {"20"},
"qf": {string(deepJSON)},
}
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusOK, resp.StatusCode,
"a nested-any constraint rides the suggestion endpoint cleanly: %s", p.Encode())
}
+2 -2
View File
@@ -85,7 +85,7 @@ func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, l
}
}
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
item, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Ownership Suite Item",
Authors: []string{"Suite Author"},
@@ -269,7 +269,7 @@ func (s *OwnershipAndAdminTestSuite) TestDelete_StorageDownRefused() {
})
require.NoError(s.T(), err)
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
item, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Offline Storage Item",
FilePath: "offline.epub",
+1 -1
View File
@@ -134,7 +134,7 @@ func TestManuallyEditedFilter(t *testing.T) {
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
createBook := func(path string) pgtype.UUID {
created, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
created, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libID,
Title: path,
Authors: []string{},
+13 -6
View File
@@ -13,6 +13,8 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"bookhoard/internal/database"
)
// A2c end-to-end: an ABS sidecar whose series array mixes a real series and
@@ -67,13 +69,14 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
require.NotEmpty(t, foundPath)
// (b) The playlist collection exists with provenance, dashboard pin,
// and the item at reading position 7.
// and the item at reading position 7. Owner-scoped read: collections
// RLS (2026-10-31) hides the row from the pool session's SeedIdentity.
adminUUIDStr := adminID.String()
var collID uuid.UUID
var collName, collDesc string
var showOnDashboard bool
var viewSettingsRaw []byte
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
`SELECT id, name, description, show_on_dashboard, view_settings
FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
adminID).Scan(&collID, &collName, &collDesc, &showOnDashboard, &viewSettingsRaw))
@@ -85,7 +88,9 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
var position *int32
var itemCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
// Owner-scoped reads: collections RLS (2026-10-31) — items ride the
// parent's readability, so the pool session's SeedIdentity sees none.
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
`SELECT ci.position, COUNT(*) OVER () FROM collection_items ci
WHERE ci.collection_id = $1`, collID).Scan(&position, &itemCount))
require.Equal(t, 1, itemCount)
@@ -96,11 +101,11 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
// (c) Rescan: no duplicate collection, no duplicate membership.
scanTestFolder(t, setup, adminID, folder)
var collectionCount, membershipCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
`SELECT COUNT(*) FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
adminID).Scan(&collectionCount))
require.Equal(t, 1, collectionCount)
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
`SELECT COUNT(*) FROM collection_items WHERE collection_id = $1`, collID).Scan(&membershipCount))
require.Equal(t, 1, membershipCount)
@@ -131,7 +136,9 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
// (e) hide_finished + Up Next + completion marker.
// Flip the collection's flag on, mark the single item finished for the
// admin, and the dashboard section becomes Complete with no items.
_, err = setup.DBPool.Exec(context.Background(),
// Owner-or-admin UPDATE policy: run as ADMIN (collections RLS,
// 2026-10-31).
adminScopeExec(t, setup, adminCtxValue(t, setup),
`UPDATE collections SET view_settings = view_settings || '{"hide_finished": true}'::jsonb WHERE id = $1`, collID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(context.Background(),
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -48,7 +48,7 @@ func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) (
require.NoError(t, err, "Should create library")
// 4. Create media item
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
mediaItem, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Test Book for Queue",
Authors: []string{"Test Author"},
+123
View File
@@ -0,0 +1,123 @@
package main
// Resume rewind (per-user server setting): seconds before the saved
// position the audiobook/podcast player starts (0 = exact resume).
// Contract: the profile endpoint owns reads/writes with a strict
// 0/5/10 allow-list (the request shape is the keep_screen_awake string
// precedent — the form select must be able to send an explicit Off),
// and the chapters payload carries the value so every client (web +
// Android, incl. the Auto service) gets it with its existing fetch.
// All writes ride the fresh per-test regular user — the shared admin
// account is never touched (AGENTS doctrine). Fixture book lives in a
// test-named library that cascades away with the library delete.
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestAudioResumeRewind(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Resume Rewind %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// 664 Hz tone: not the shared helper's 440 or the speed test's 552,
// so the scanner's content dedup never skips this book.
writeTestTrackTone(t, filepath.Join(folder, "Rewind Book", "01 one.mp3"), 664)
scanTestFolder(t, setup, admin, folder)
var bookPath string
for _, item := range listLibraryAudioItems(t, setup, libID) {
if strings.Contains(filepath.ToSlash(item.FilePath), "Rewind Book") && bookPath == "" {
bookPath = item.FilePath
}
}
require.NotEmpty(t, bookPath, "Rewind Book item must exist")
book, found := findAudioItemByPath(t, setup, libID, bookPath)
require.True(t, found)
bookID := uuid.UUID(book.ID.Bytes)
client := &http.Client{}
token := setup.RegularToken
authed := func(method, url, body string) (*http.Response, func()) {
var req *http.Request
if body == "" {
req, _ = http.NewRequest(method, url, nil)
} else {
req, _ = http.NewRequest(method, url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
return resp, func() { _ = resp.Body.Close() }
}
getPayload := func(itemID uuid.UUID) (rewind int) {
resp, closer := authed("GET", setup.Server.URL+"/api/media-items/"+itemID.String()+"/chapters", "")
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var data struct {
ResumeRewindSeconds int `json:"resume_rewind_seconds"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
return data.ResumeRewindSeconds
}
getProfileRewind := func() int {
resp, closer := authed("GET", setup.Server.URL+"/api/auth/profile", "")
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var profile struct {
AudioResumeRewind int `json:"audio_resume_rewind_seconds"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
return profile.AudioResumeRewind
}
t.Run("defaults to off (exact resume)", func(t *testing.T) {
require.Equal(t, 0, getProfileRewind())
require.Equal(t, 0, getPayload(bookID))
})
t.Run("profile PUT selects 5 and rides the chapters payload", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"audio_resume_rewind_seconds":"5"}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 5, getProfileRewind())
require.Equal(t, 5, getPayload(bookID))
})
t.Run("non-allow-listed values are rejected and change nothing", func(t *testing.T) {
for _, body := range []string{`{"audio_resume_rewind_seconds":"7"}`, `{"audio_resume_rewind_seconds":"-5"}`, `{"audio_resume_rewind_seconds":"abc"}`} {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", body)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
closer()
}
require.Equal(t, 5, getProfileRewind())
})
t.Run("explicit off resets to 0 everywhere", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"audio_resume_rewind_seconds":"0"}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 0, getProfileRewind())
require.Equal(t, 0, getPayload(bookID))
})
t.Run("JSON clients that omit the key leave it untouched", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"timezone":"UTC"}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 0, getProfileRewind())
})
}
+283
View File
@@ -0,0 +1,283 @@
package main
// rls_visibility_test.go — the PERMANENT black-box auditor for per-user
// library visibility (the "family lens").
//
// The one sentence it asserts, in the user's language:
//
// A user with a library hidden sees NOTHING from that library, on
// ANY media-returning surface — while an admin still sees it (the
// hide is per-user, never global).
//
// Implementation-agnostic on purpose: it speaks only HTTP + response
// bodies, so it stays valid regardless of HOW enforcement is done
// (per-query visible_library_ids clauses today, Postgres RLS in the
// RLS rollout, anything tomorrow). It is the auditor that makes a
// forgotten surface a failing test instead of a silent leak.
//
// Red/green history: RED at introduction on the dashboard-section and
// collection-detail (mobile View All) surfaces, whose queries never
// carried the visible_library_ids clause; GREEN once enforcement
// covers every surface (plan:
// ~/.opencode/plan/server-rls-library-visibility.md).
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"bookhoard/internal/database"
)
// uuidInBody finds candidate collection ids in a dashboard-sections
// body so the View-All detail endpoint can be probed without coupling
// the test to unrelated ids in the body.
func TestRlsInvariant(t *testing.T) {
setup := setupTestServer(t)
ctx := context.Background()
adminToken := setup.Token
regularToken := setup.RegularToken
regularID := getRegularUserID(t, setup.DB)
// Unique markers: one for the hidden library's whole subtree
// (library name + both book titles — a single string that must
// appear NOWHERE), one for the visible control book.
hiddenMarker := fmt.Sprintf("RLSHIDE%s", uuid.New().String()[:8])
controlMarker := fmt.Sprintf("RLSCTRL%s", uuid.New().String()[:8])
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(t, err, "ebooks library type should exist")
createLib := func(name string) database.Libraries {
lib, cerr := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: name,
Description: pgtype.Text{String: "rls invariant test library", Valid: true},
LibraryTypeID: libType.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
})
require.NoError(t, cerr, "create library %q", name)
// Deleting the library cascades its media_items AND its
// library_visibility rows (FK ON DELETE CASCADE) — one cleanup
// covers the whole subtree.
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
return lib
}
libH := createLib("RLS Test Hidden Library " + hiddenMarker)
libV := createLib("RLS Test Visible Library " + controlMarker)
createBook := func(libraryID pgtype.UUID, title string) {
_, cerr := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libraryID,
Title: title,
Authors: []string{"RLS Invariant Author"},
FilePath: fmt.Sprintf("/tmp/rls-invariant/%s-%s.epub", title, uuid.New().String()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, cerr, "create book %q", title)
}
createBook(libH.ID, hiddenMarker+" Hidden Alpha")
createBook(libH.ID, hiddenMarker+" Hidden Beta")
createBook(libV.ID, controlMarker+" Visible Gamma")
// Hide libH from the REGULAR user — the same row the admin
// visibility UI writes (harness idiom: SetLibraryVisibility).
_, err = setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
LibraryID: libH.ID,
IsVisible: false,
})
require.NoError(t, err, "hide libH from regular user")
// OPDS device owned by the REGULAR user (OPDS identity = device owner).
opdsToken := "dev_" + uuid.New().String()
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
DeviceName: "RLS Invariant OPDS Device",
DeviceType: "koreader",
DeviceIdentifier: "rls-invariant-" + uuid.New().String()[:8],
AuthToken: opdsToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 60, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "create OPDS device for regular user")
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, device.ID) })
rlsGet := func(token, path string) (int, string) {
req, _ := http.NewRequest(http.MethodGet, setup.Server.URL+path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err, "GET %s", path)
defer func() { _ = resp.Body.Close() }()
var sb strings.Builder
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
sb.Write(buf[:n])
}
if err != nil {
break
}
}
return resp.StatusCode, sb.String()
}
// Surfaces that render media TITLES (books appear by name).
titleSurfaces := []struct {
name string
path string
auth string
}{
{"media-items list", "/api/media-items?limit=500", regularToken},
{"dashboard sections", "/api/dashboard/sections?limit=500", regularToken},
{"opds catalog", "/opds/devices/" + device.ID.String() + "/catalog", opdsToken},
}
// Surfaces that return media-adjacent data (counts, series names,
// aggregates) — hidden-subtree leakage would show up as marker
// series names / inventory rows, not book titles.
aggregateSurfaces := []struct {
name string
path string
auth string
}{
{"collections list", "/api/collections", regularToken},
{"series list", "/api/series", regularToken},
{"stats summary", "/api/stats/summary", regularToken},
{"stats library-usage", "/api/stats/library-usage", regularToken},
}
t.Run("restricted_user_hidden_library_absent_everywhere", func(t *testing.T) {
for _, s := range titleSurfaces {
t.Run(s.name, func(t *testing.T) {
code, body := rlsGet(s.auth, s.path)
require.Equal(t, http.StatusOK, code, "surface %s must be authorized (a 401 here is a harness bug, not a pass)", s.path)
// THE INVARIANT: nothing from the hidden subtree —
// neither its books nor the library itself.
assert.NotContains(t, body, hiddenMarker,
"LEAK: hidden library content surfaced on %s", s.path)
// Control: the visible library's book IS present —
// guards against false greens from empty/anonymous
// responses.
if s.name != "opds catalog" { // root catalog may nest books behind library subsections
assert.Contains(t, body, controlMarker,
"control book missing from %s — response is not listing media (false-green guard)", s.path)
}
})
}
for _, s := range aggregateSurfaces {
t.Run(s.name, func(t *testing.T) {
code, body := rlsGet(s.auth, s.path)
require.Equal(t, http.StatusOK, code, "surface %s must be authorized", s.path)
assert.NotContains(t, body, hiddenMarker,
"LEAK: hidden library content surfaced on %s", s.path)
})
}
})
t.Run("restricted_user_dashboard_scoped_to_hidden_library", func(t *testing.T) {
code, body := rlsGet(regularToken, "/api/dashboard/sections?library_id="+uuid.UUID(libH.ID.Bytes).String())
assert.NotContains(t, body, hiddenMarker,
"LEAK: dashboard scoped to the hidden library leaked its content (status %d)", code)
})
t.Run("restricted_user_collection_detail_view_all", func(t *testing.T) {
// The mobile View All path: GET /api/collections/{id} for every
// collection the dashboard references (system sections included).
// Structured decode — probe exactly the collection ids the
// sections payload declares (its own contract field), not
// whatever UUIDs happen to appear in the body.
_, sectionsBody := rlsGet(regularToken, "/api/dashboard/sections?limit=500")
var sections struct {
Sections []struct {
CollectionID string `json:"collection_id"`
} `json:"sections"`
}
require.NoError(t, json.Unmarshal([]byte(sectionsBody), &sections),
"dashboard sections should decode; body: %s", sectionsBody)
seen := map[string]bool{}
for _, sec := range sections.Sections {
id := sec.CollectionID
if id == "" || seen[id] {
continue
}
seen[id] = true
code, body := rlsGet(regularToken, "/api/collections/"+id)
if code != http.StatusOK {
continue // not visible to this user — absence trivially holds
}
assert.NotContains(t, body, hiddenMarker,
"LEAK: collection detail %s (View All surface) returned hidden-library content", id)
}
})
t.Run("restricted_user_explicit_hidden_library_scoped_requests_rejected", func(t *testing.T) {
// Pinned contract (AGENTS.md family-lens rule): naming a hidden
// library explicitly is a 403, never a silent pass.
libID := uuid.UUID(libH.ID.Bytes).String()
for _, path := range []string{
"/api/media-items?library_id=" + libID,
"/api/series?library_id=" + libID,
"/api/series/books?library_id=" + libID + "&name=RLS",
} {
code, body := rlsGet(regularToken, path)
assert.Equal(t, http.StatusForbidden, code,
"scoped request %s must be 403 for a hidden library (got %d)", path, code)
assert.NotContains(t, body, hiddenMarker)
}
})
t.Run("admin_still_sees_hidden_library_user_scoped_not_global", func(t *testing.T) {
// The hide is PER-USER: an admin (no hidden rows) must still see
// everything. This catches "fixing" the leak by hiding the
// library globally.
for _, s := range []struct{ name, path string }{
{"media-items list", "/api/media-items?limit=500"},
{"dashboard sections", "/api/dashboard/sections?limit=500"},
} {
t.Run(s.name, func(t *testing.T) {
code, body := rlsGet(adminToken, s.path)
require.Equal(t, http.StatusOK, code)
assert.Contains(t, body, hiddenMarker,
"admin must still see the hidden library on %s (hide is user-scoped)", s.path)
})
}
t.Run("opds catalog", func(t *testing.T) {
// OPDS identity = device owner; an ADMIN-owned device sees all.
adminDevToken := "dev_" + uuid.New().String()
adminID := getTestUserID(t, setup.DB)
adminDevice, derr := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: [16]byte(adminID), Valid: true},
DeviceName: "RLS Invariant Admin OPDS Device",
DeviceType: "koreader",
DeviceIdentifier: "rls-invariant-admin-" + uuid.New().String()[:8],
AuthToken: adminDevToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 60, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, derr, "create OPDS device for admin")
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, adminDevice.ID) })
code, body := rlsGet(adminDevToken, "/opds/devices/"+adminDevice.ID.String()+"/catalog")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, body, hiddenMarker,
"admin's OPDS catalog must still expose the hidden library (user-scoped hide)")
})
})
}
+4 -3
View File
@@ -78,7 +78,7 @@ func TestScanStateBulkLoad(t *testing.T) {
require.NoError(t, os.WriteFile(apiFile, []byte("api made book content"), 0644))
apiStat, err := os.Stat(apiFile)
require.NoError(t, err)
created, err := setup.DB.CreateMediaItem(context.Background(), database.CreateMediaItemParams{
created, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libID,
Title: "API Made Book",
Authors: []string{"API Author"},
@@ -207,12 +207,13 @@ func TestScanManifestLifecycle(t *testing.T) {
require.NoError(t, os.WriteFile(opfPath, []byte(`{"title":"Manifesu Book","series":["Lifecycle Saga (Reading Order) #1"]}`), 0644))
scanTestFolder(t, setup, admin, folder)
var collCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
// Owner-scoped read: collections RLS (2026-10-31).
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
).Scan(&collCount))
assert.Equal(t, 1, collCount, "reading-order sidecar added to an existing book creates its collection")
scanTestFolder(t, setup, admin, folder)
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
).Scan(&collCount))
assert.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
+4 -6
View File
@@ -253,7 +253,7 @@ func TestReadingStatsSummary(t *testing.T) {
media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0])))
require.NoError(t, err, "reuse seeded media item")
} else {
media, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true},
Title: title,
FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()),
@@ -448,7 +448,7 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) {
libB := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibB %d", time.Now().UnixNano()), "ebooks"))
seed := func(lib uuid.UUID, title string, daysAgo int, minutes int32) {
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title,
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
@@ -526,7 +526,6 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) {
func TestLibraryUsageStats(t *testing.T) {
setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks"))
@@ -541,7 +540,7 @@ func TestLibraryUsageStats(t *testing.T) {
if durationSeconds > 0 {
audioMeta = []byte(fmt.Sprintf(`{"duration_seconds":%d,"chapters":[]}`, durationSeconds))
}
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title,
Authors: []string{author},
@@ -689,7 +688,6 @@ func TestLibraryUsageStats(t *testing.T) {
func TestLibraryUsageStats_Visibility(t *testing.T) {
setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks"))
@@ -697,7 +695,7 @@ func TestLibraryUsageStats_Visibility(t *testing.T) {
fmt.Sprintf("Test Usage Hidden %d", time.Now().UnixNano()), "ebooks"))
seed := func(lib uuid.UUID, title string, size int64) {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title,
FilePath: fmt.Sprintf("/tmp/usage-vis-%s-%d.bin", title, time.Now().UnixNano()),
+9 -3
View File
@@ -3,6 +3,7 @@ package main
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/rls"
"context"
"net/http"
"net/http/httptest"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
@@ -26,11 +28,16 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
dbConfig, err := pgxpool.ParseConfig(dbURL)
require.NoError(t, err, "Failed to parse database URL")
dbConfig.MaxConns = 1
// Harness-only session identity (plan §5): see setupTestServer.
dbConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
_, err := conn.Exec(ctx, `SELECT set_config('app.user_id', $1, false)`, rls.SeedIdentity.String())
return err == nil
}
dbPool, err := pgxpool.NewWithConfig(ctx, dbConfig)
require.NoError(t, err, "Failed to connect to test database")
db := database.New(dbPool)
db := database.New(database.NewSeedDB(dbPool))
t.Cleanup(func() {
// Scoped wipes ONLY: reading_history now carries real captured
// sessions for the shared dev accounts, so a table-wide DELETE
@@ -90,9 +97,8 @@ func createSyncTestDevice(t *testing.T, db *database.Queries, userID pgtype.UUID
}
func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UUID) pgtype.UUID {
ctx := context.Background()
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libraryID,
Title: "Sync Test Book",
Authors: []string{"Test Author"},
+103 -11
View File
@@ -6,6 +6,7 @@ import (
"bookhoard/internal/handlers"
"bookhoard/internal/middleware"
ratelimit "bookhoard/internal/middleware"
"bookhoard/internal/rls"
"bookhoard/internal/router"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
@@ -317,6 +318,53 @@ func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
}
}
// seedCtx is the context harness-level media seeding runs under. It
// carries rls.SeedIdentity — a valid UUID with NO library_visibility
// rows, so RLS grants it full visibility (needed because sqlc :one
// inserts use RETURNING, which requires SELECT visibility of the new
// row). setup.DB additionally falls back to this identity for calls
// that pass a bare context. Product code never uses this; requests
// carry real personas.
func seedCtx() context.Context {
return rls.WithUser(context.Background(), rls.SeedIdentity)
}
// ownerSeedCtx returns a context carrying a fixture user's identity,
// for seeding rows OWNED by that user. Collections RLS (2026-10-31):
// the write predicates want actor == owner for non-system rows (the
// admin write-escape covers SYSTEM collections only), and reads under
// the pool session's SeedIdentity see nothing.
func ownerSeedCtx(userID pgtype.UUID) context.Context {
return rls.WithUser(context.Background(), userID)
}
// scopeExecAs is adminScopeExec's generic form: raw SQL under ANY
// declared identity. Use it (with the row's OWNER) for fixture writes
// to non-system collections — see ownerSeedCtx.
func scopeExecAs(t *testing.T, setup *TestServerSetup, userID pgtype.UUID, sql string, args ...any) {
adminScopeExec(t, setup, userID, sql, args...)
}
// seedExec runs a raw statement against the pool under the seed
// identity. The bare pool bypasses ScopedDB (nothing reads the ctx for
// the GUC), so governed tables need the identity declared manually
// inside a transaction.
func seedExec(t *testing.T, pool *pgxpool.Pool, sql string, args ...any) {
t.Helper()
ctx := rls.WithUser(context.Background(), rls.SeedIdentity)
tx, err := pool.Begin(ctx)
require.NoError(t, err)
if _, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, rls.SeedIdentity.String()); err != nil {
_ = tx.Rollback(ctx)
require.NoError(t, err, "declare seed identity")
}
if _, err := tx.Exec(ctx, sql, args...); err != nil {
_ = tx.Rollback(ctx)
require.NoError(t, err, "seed exec")
}
require.NoError(t, tx.Commit(ctx))
}
// uuidToPGType converts uuid.UUID to pgtype.UUID
func uuidToPGType(u uuid.UUID) pgtype.UUID {
return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
@@ -324,7 +372,12 @@ func uuidToPGType(u uuid.UUID) pgtype.UUID {
// createDefaultCollectionsForUser creates the 4 default system collections for a user
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
ctx := context.Background()
// Owner-scoped seeding: the collections RLS policies (2026-10-31)
// need an acting identity, and the harness seeds before any JWT —
// the same bootstrap fix as CreateDefaultCollectionsForUser in
// internal/handlers/auth.go. Owner == actor, so SELECT sees the
// rows, the INSERT passes, and the heal-UPDATE passes.
ctx := rls.WithUser(context.Background(), userID)
// Canonical Title-case names — the same list the registration seed
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
// A stale kebab-case copy lived here once and duplicated every
@@ -491,10 +544,29 @@ func setupTestServer(t *testing.T) *TestServerSetup {
dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL())
require.NoError(t, err, "Failed to parse database URL")
dbConfig.MaxConns = 1
// Harness-only session identity (plan §5): every connection in this
// pool runs as rls.SeedIdentity at SESSION scope, so raw-pool
// statements in tests (DBPool.Exec/QueryRow against governed tables)
// pass the read policy. No bleed risk: this pool never serves a
// second identity — A3 applies to the server's request pool.
dbConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
_, err := conn.Exec(ctx, `SELECT set_config('app.user_id', $1, false)`, rls.SeedIdentity.String())
return err == nil
}
dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig)
require.NoError(t, err, "Failed to connect to test database")
queries := database.New(dbPool)
// ScopedDB: the test server must behave EXACTLY like the production
// wiring (main.go) — every statement through Queries declares the
// acting user for RLS, including the search service's raw DBTX path.
// TestServerSetup.DB below is the SEED handle — also ScopedDB-wired
// (required: sqlc :one inserts use RETURNING, which evaluates the
// SELECT policy even on INSERT) — but its callers pass seedCtx(), a
// synthetic identity with no visibility rows, so seeding sees and
// writes everything. Real personas only exist at the HTTP layer.
scopedDB := database.NewScopedDB(dbPool)
queries := database.New(scopedDB)
seedDB := database.New(database.NewSeedDB(dbPool))
// Wire the tunable settings registry exactly like main.go: Load
// populates the cache (missing rows fall back to compiled defaults)
@@ -545,20 +617,31 @@ func setupTestServer(t *testing.T) *TestServerSetup {
worker := services.NewWorker(3, connManager)
services.WorkerInstance = worker
jobsHandler := handlers.NewJobsHandler(queries, worker)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
// One composed-engine service for search AND the collection
// subsystem (item 3.2 — mirrors main.go).
collectionSearchService := services.NewSearchService(queries, scopedDB)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService)
filtersHandler := handlers.NewFiltersHandler(queries)
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
dashboardService := services.NewDashboardService(queries, collectionSearchService)
dashboardHandler := handlers.NewDashboardHandler(queries, collectionSearchService)
statisticsService := services.NewStatisticsService(queries)
statsHandler := handlers.NewStatsHandler(statisticsService)
seriesHandler := handlers.NewSeriesHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService, worker)
mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService, worker)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
// Create conversion service for OPDS
conversionService := services.NewConversionService(queries, getCachePath())
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
// Podcast subsystem (mirrors main.go wiring): fetcher service +
// download worker + handler. The poller is NOT started in tests
// (no background ticks against the shared dev DB); the worker loop
// only drains when a test queues work.
podcastService := services.NewPodcastService(queries)
podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService)
podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker)
// Create Echo instance
e := echo.New()
@@ -621,6 +704,9 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
LibraryService: libraryService,
PodcastHandler: podcastHandler,
PodcastService: podcastService,
PodcastWorker: podcastWorker,
}
router.RegisterRoutes(routerConfig)
@@ -707,11 +793,15 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// run's creates die on UNIQUE(user_id, name) — the "collections
// 500-panic" rot. System collections are skipped (permanent rows).
// Same convention as the library pass: names containing "test".
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
// Swept under the OWNER's identity: collections RLS (2026-10-31)
// makes unscoped reads return nothing, which would silently turn
// this cleanup into a no-op.
adminScope := rls.WithUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
adminColls, cerr := queries.GetCollectionsByUser(adminScope, pgtype.UUID{Bytes: adminUUID, Valid: true})
if cerr == nil {
for _, coll := range adminColls {
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
queries.DeleteCollection(ctx, coll.ID)
queries.DeleteCollection(adminScope, coll.ID)
}
}
}
@@ -756,7 +846,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// Create TestServerSetup struct with all resources
setup := &TestServerSetup{
Server: ts,
DB: queries,
DB: seedDB, // harness seeding/cleanup handle (see comment above)
DBPool: dbPool,
Config: cfg,
ConnManager: connManager,
@@ -785,11 +875,13 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// Same pass for test-created COLLECTIONS (the "collections
// 500-panic" rot: they lingered forever and poisoned later runs).
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
// Owner-scoped for the same RLS reason as the setup sweep.
adminScope := rls.WithUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
adminColls, cerr := queries.GetCollectionsByUser(adminScope, pgtype.UUID{Bytes: adminUUID, Valid: true})
if cerr == nil {
for _, coll := range adminColls {
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
queries.DeleteCollection(ctx, coll.ID)
queries.DeleteCollection(adminScope, coll.ID)
}
}
}
+424 -5
View File
@@ -18,7 +18,8 @@ INSERT INTO library_types (name, description, allowed_extensions) VALUES
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif']),
('audiobooks', 'Audiobook files; m4b first, breadth formats registered from day one', ARRAY['.m4b', '.m4a', '.mp3', '.flac'])
('audiobooks', 'Audiobook files; m4b first, breadth formats registered from day one', ARRAY['.m4b', '.m4a', '.mp3', '.flac']),
('podcasts', 'RSS podcast/feed libraries: feeds subscribe-and-download; the scanner never walks these folders', ARRAY[]::TEXT[])
ON CONFLICT (name) DO NOTHING;
-- Create users table
@@ -47,6 +48,14 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEF
-- mid-session. On by default; the profile page's On/Off dropdown owns it.
ALTER TABLE users ADD COLUMN IF NOT EXISTS keep_screen_awake BOOLEAN NOT NULL DEFAULT TRUE;
-- Audio resume rewind: seconds before the saved position the
-- audiobook/podcast player starts (on open-resume AND again on every
-- genuine unpause — the re-orientation gap, never stacked). Strict
-- allow-list 0/5/10 enforced handler-side; 0 = exact resume. The
-- profile page's dropdown owns it; the chapters payload carries it to
-- every client (web + Android, incl. the car path).
ALTER TABLE users ADD COLUMN IF NOT EXISTS audio_resume_rewind_seconds INT NOT NULL DEFAULT 0;
-- Create system_settings table
CREATE TABLE IF NOT EXISTS system_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -99,7 +108,11 @@ INSERT INTO system_settings (setting_key, setting_value, description, setting_ty
('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance')
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance'),
-- podcasts
('podcast_poll_interval_seconds', '3600', 'How often to poll podcast feeds for new episodes (seconds; complete feeds back off)', 'int', '300', '604800', FALSE, 'podcasts'),
('podcast_download_concurrency', '4', 'Podcast episode downloads in flight server-wide (the connection is the limit; fair-share spreads them across feeds)', 'int', '1', '16', FALSE, 'podcasts'),
('podcast_download_speed_mbps', '0', 'Shared bandwidth cap for podcast downloads in megabits/second (0 = unlimited)', 'float', '0', '10000', FALSE, 'podcasts')
ON CONFLICT (setting_key) DO NOTHING;
-- Create refresh_tokens table
@@ -1336,7 +1349,9 @@ CREATE TABLE IF NOT EXISTS collections (
description TEXT,
color VARCHAR(7),
icon VARCHAR(50),
auto_assign_rules JSONB,
-- The unified filter tree (plan item 3.2): when present the qf
-- engine drives membership; see FilterTree. NULL = static.
filter_tree JSONB,
view_settings JSONB,
show_on_dashboard BOOLEAN DEFAULT false,
query_type TEXT DEFAULT 'filter',
@@ -1352,12 +1367,23 @@ CREATE TABLE IF NOT EXISTS collections (
-- the row id (and everything referencing it) never churn.
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Legacy rules-array columns RETIRED (2026-10-31): the unified filter
-- tree is the only smart-membership storage. No production row ever
-- carried a rules array or a non-default match mode (verified), so the
-- columns drop clean; the DROPs are the idempotent heal for databases
-- created before this change.
ALTER TABLE collections DROP COLUMN IF EXISTS auto_assign_rules;
ALTER TABLE collections DROP COLUMN IF EXISTS rule_match_mode;
-- Smart collections in the UNIFIED filter engine (plan item 3.2): the
-- qf tree (same DSL as saved filters, See FilterTree). NULL = static.
ALTER TABLE collections ADD COLUMN IF NOT EXISTS filter_tree JSONB;
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
-- Index for dashboard queries
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
WHERE show_on_dashboard = true;
-- Create user_dashboard_preferences table
CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1623,12 +1649,19 @@ CREATE INDEX IF NOT EXISTS idx_dictionary_word ON dictionary_cache(word);
CREATE TABLE IF NOT EXISTS reader_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
setting_key VARCHAR(50) NOT NULL,
setting_key VARCHAR(120) NOT NULL,
setting_value JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, setting_key)
);
-- Key-width headroom (podcast plan §10 R5, item 2.1): the composite
-- `podcast_order:<uuid>` key sits at exactly 50 chars, and future
-- per-feed/per-collection preference keys must not be forced to
-- payload-stuff by a column width. Widening a varchar is metadata-only
-- (no table rewrite); idempotent on every boot.
ALTER TABLE reader_settings ALTER COLUMN setting_key TYPE VARCHAR(120);
CREATE INDEX IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id);
-- PDF bookmarks (custom user bookmarks)
@@ -1877,3 +1910,389 @@ CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);
-- Widen for databases created before the resolution format settled (no-op otherwise)
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
-- ============================================================
-- PODCASTS / RSS FEEDS
-- Plan: ~/.opencode/plan/podcast-integration.md (§6).
-- Feeds are per-library entities (UNIQUE(library_id, feed_url)); users
-- subscribe personally (podcast_subscriptions). Episodes are
-- media_items rows with source='podcast_episode' — per-episode
-- progress/stats for free — grouped by source_feed_id. Visibility is
-- RLS-enforced (see the policy block at the end of this file): an
-- episode/feed row is SELECT-visible only to subscribers of the feed
-- and to admins. The fetcher/download worker is the SOLE writer of
-- feed/episode metadata; users own only their subscription row and the
-- reader_settings order override. ALL DDL IDEMPOTENT: schema.sql
-- re-executes on EVERY boot.
-- ============================================================
-- First per-library boolean knob (plain column, not KV): when false,
-- only admins may add feeds to the library (members keep subscribe
-- rights to existing feeds).
ALTER TABLE libraries ADD COLUMN IF NOT EXISTS allow_member_feeds BOOLEAN NOT NULL DEFAULT TRUE;
-- Folder purpose: 'scan' (default; the FS walker's root) vs 'podcast'
-- (a podcasts library's download root — scanner skips it wholesale,
-- belt-and-braces on top of the library-type skip).
ALTER TABLE library_folders ADD COLUMN IF NOT EXISTS purpose VARCHAR(20) NOT NULL DEFAULT 'scan';
CREATE TABLE IF NOT EXISTS podcast_feeds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
feed_url TEXT NOT NULL,
-- podcast:guid — the feed's canonical identity; dedupes across URL
-- migrations (fetcher honors itunes:new-feed-url + redirects).
podcast_guid TEXT,
title VARCHAR(500) NOT NULL,
description TEXT,
-- Channel itunes:author (semantically the host) and itunes:owner.
host VARCHAR(500),
publisher VARCHAR(500),
owner_name VARCHAR(500),
owner_email VARCHAR(500),
language VARCHAR(50),
tags TEXT[] DEFAULT '{}',
explicit BOOLEAN NOT NULL DEFAULT FALSE,
artwork_path VARCHAR(500),
-- itunes:type: 'serial' | 'episodic'
feed_type VARCHAR(20),
-- podcast:medium: 'audiobook' | 'podcast' | 'music' | ...
medium VARCHAR(20),
-- itunes:complete: poller backs off once the publisher ends the feed.
is_complete BOOLEAN NOT NULL DEFAULT FALSE,
-- Conditional-GET validators (opaque; echoed verbatim).
etag VARCHAR(500),
last_modified VARCHAR(500),
last_fetched_at TIMESTAMPTZ,
last_build_date TIMESTAMPTZ,
created_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(library_id, feed_url)
);
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_library ON podcast_feeds(library_id);
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_guid ON podcast_feeds(podcast_guid);
-- User-owned subscription row: the ONLY podcast state a user writes.
-- display_name NULL renders as the feed title. auto_download:
-- 'off' | 'all' | 'newest' (+ auto_download_count for newest N).
CREATE TABLE IF NOT EXISTS podcast_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
feed_id UUID NOT NULL REFERENCES podcast_feeds(id) ON DELETE CASCADE,
display_name VARCHAR(500),
auto_download VARCHAR(20) NOT NULL DEFAULT 'off',
auto_download_count INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, feed_id)
);
CREATE INDEX IF NOT EXISTS idx_podcast_subs_user ON podcast_subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_podcast_subs_feed ON podcast_subscriptions(feed_id);
-- Episode columns (NULL for scan-sourced items). See the plan §5 for
-- the full RSS/iTunes/Podcasting-2.0 field mapping — existing columns
-- (title, subtitle, description, authors, explicit, date_published,
-- cover_image_path, file_size, mime_type, language, genre/tags) carry
-- the shared fields; only genuinely podcast-only storage lives here.
ALTER TABLE media_items
ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'scan',
ADD COLUMN IF NOT EXISTS source_feed_id UUID REFERENCES podcast_feeds(id) ON DELETE CASCADE,
-- Stable reorder-proof addressing key (invariant 3): progress
-- anchors on guid, never ordinal.
ADD COLUMN IF NOT EXISTS episode_guid TEXT,
-- Native order as of the latest fetch (renumbered per fetch).
ADD COLUMN IF NOT EXISTS feed_position INTEGER,
ADD COLUMN IF NOT EXISTS season_number INTEGER,
ADD COLUMN IF NOT EXISTS episode_number INTEGER,
ADD COLUMN IF NOT EXISTS enclosure_url TEXT,
-- itunes:duration claim (often HH:MM:SS or prose); display until
-- ffprobe reconciles after download.
ADD COLUMN IF NOT EXISTS declared_duration_seconds INTEGER,
ADD COLUMN IF NOT EXISTS transcript_url TEXT,
ADD COLUMN IF NOT EXISTS chapters_url TEXT,
ADD COLUMN IF NOT EXISTS hosts TEXT[] DEFAULT '{}',
ADD COLUMN IF NOT EXISTS guests TEXT[] DEFAULT '{}',
-- Download state machine: pending | queued | downloading |
-- downloaded | failed (+ reason text + attempt count). The episode
-- list renders this inline (D8/D19) — never Processing Issues.
ADD COLUMN IF NOT EXISTS download_state VARCHAR(20) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS download_error TEXT,
ADD COLUMN IF NOT EXISTS download_attempts INT NOT NULL DEFAULT 0,
-- Monotonic queue ticket (Postgres SEQUENCE nextval at enqueue
-- time): the download pool claims in ticket order, so the enqueue
-- loop's ordering IS the download order (effective listening order
-- — §0 items 4/5). NULL for rows never queued.
ADD COLUMN IF NOT EXISTS download_queue_pos BIGINT;
-- Queue ticket source: atomic across concurrent enqueues (per-feed
-- locks serialize within a feed; the sequence covers across feeds).
CREATE SEQUENCE IF NOT EXISTS podcast_queue_seq;
-- Identity + ordering + worker-worklist indexes (all partial: scan
-- rows never touch these columns).
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_items_feed_guid
ON media_items(source_feed_id, episode_guid) WHERE source_feed_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_media_items_feed_pos
ON media_items(source_feed_id, feed_position) WHERE source_feed_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_media_items_feed_undownloaded
ON media_items(source_feed_id) WHERE source_feed_id IS NOT NULL AND download_state <> 'downloaded';
CREATE INDEX IF NOT EXISTS idx_media_items_queue
ON media_items(download_queue_pos) WHERE source = 'podcast_episode' AND download_state = 'queued';
-- ============================================================
-- RLS: PER-USER LIBRARY VISIBILITY (ONE SOURCE OF TRUTH)
-- Plan: ~/.opencode/plan/server-rls-library-visibility.md REV 2.
-- The policy joins library_visibility DIRECTLY — the same table the
-- visibility UI writes. The app declares the acting user per
-- transaction via the LOCAL GUC app.user_id (ScopedDB, set_config
-- is_local=true). Unset identity FAILS LOUD, never fails open:
-- - GUC never set in the session -> current_setting() raises
-- ("unrecognized configuration parameter")
-- - GUC set-then-transaction-ended -> reverts to '' -> the ::uuid
-- cast raises
-- - NULL GUC (unreachable via ScopedDB) -> IS NOT NULL guard ->
-- zero rows (fail-closed)
-- The runtime role bookhoard_srv OWNS these tables and is subject to
-- the policies via FORCE (z_rls_role.sh). Never connect the app as
-- the bootstrap superuser — superusers bypass RLS unconditionally.
-- IDEMPOTENT: schema.sql re-executes on EVERY app boot
-- (database.Initialize -> executeSchema); DROP .. IF EXISTS + CREATE
-- matches the repo's trigger idiom. DO NOT make this block
-- non-idempotent — the second boot would brick the app.
-- ============================================================
DROP POLICY IF EXISTS p_media_items_visible ON media_items;
DROP POLICY IF EXISTS p_media_items_insert ON media_items;
DROP POLICY IF EXISTS p_media_items_update ON media_items;
DROP POLICY IF EXISTS p_media_items_delete ON media_items;
ALTER TABLE media_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE media_items FORCE ROW LEVEL SECURITY;
-- READ policy: EXACTLY the app's COALESCE(lv.is_visible, true)
-- semantics — row absent -> visible; row true -> visible; row false
-- -> hidden. No new index needed: library_visibility's
-- UNIQUE(user_id, library_id) constraint already indexes the join.
-- PODCAST EPISODES (plan D20): source='podcast_episode' rows carry the
-- ADDITIONAL subscription predicate — visible only to subscribers of
-- source_feed_id, or to admins (admins manage feeds regardless of
-- subscription; the escape also satisfies background jobs, which
-- declare a first-admin identity). Scan rows pass unchanged via the
-- IS DISTINCT FROM guard — the predicate costs them one NULL compare.
-- Unset identity FAILS LOUD, never fails open (see the block header).
CREATE POLICY p_media_items_visible ON media_items
FOR SELECT
USING (
current_setting('app.user_id') IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM library_visibility lv
WHERE lv.user_id = current_setting('app.user_id')::uuid
AND lv.library_id = media_items.library_id
AND lv.is_visible = false
)
AND (
media_items.source IS DISTINCT FROM 'podcast_episode'
OR EXISTS (
SELECT 1 FROM podcast_subscriptions s
WHERE s.user_id = current_setting('app.user_id')::uuid
AND s.feed_id = media_items.source_feed_id
)
OR EXISTS (
SELECT 1 FROM users u
WHERE u.id = current_setting('app.user_id')::uuid
AND u.role = 'admin'
)
)
);
-- WRITE policies: PERMISSIVE STAND-INS for the deferred scoped write
-- lens. MANDATORY at ENABLE time — with RLS enabled and no applicable
-- policy, INSERT fails (42501) and UPDATE/DELETE silently affect zero
-- rows, bricking the media scanner. Per-command split is deliberate:
-- a single FOR ALL policy with USING (true) would OR into SELECT and
-- disable the read policy above. Writes still need SELECT visibility
-- of the target rows (the read policy governs that), which internal
-- identities satisfy (they have no hidden rows).
-- ON CONFLICT upserts (scanner: (library_id, file_path)) need the
-- conflicting row SELECT-visible to the acting identity — holds for
-- every internal identity by construction.
CREATE POLICY p_media_items_insert ON media_items
FOR INSERT WITH CHECK (true);
CREATE POLICY p_media_items_update ON media_items
FOR UPDATE USING (true) WITH CHECK (true);
CREATE POLICY p_media_items_delete ON media_items
FOR DELETE USING (true);
-- Other tables: collection_items reaches media only via INNER
-- JOIN media_items (the join drops filtered rows). collections
-- metadata (book_count) is a separate counts question — plan A13.
-- PODCAST FEEDS (plan D20): governed the same way. SELECT-visible to
-- subscribers of the feed (their subscription IS the grant — this is
-- the predicate's lookup table, podcast_subscriptions itself stays
-- UNGOVERNED) plus the library-visibility join (a hidden library
-- hides its feeds too), and to admins. WRITE policies are permissive
-- stand-ins for the same reason as media_items above: mandatory at
-- ENABLE time, and the fetcher/worker (first-admin identity) plus the
-- add-feed upsert (ON CONFLICT DO UPDATE needs no separate SELECT)
-- cover every legitimate writer.
DROP POLICY IF EXISTS p_podcast_feeds_visible ON podcast_feeds;
DROP POLICY IF EXISTS p_podcast_feeds_insert ON podcast_feeds;
DROP POLICY IF EXISTS p_podcast_feeds_update ON podcast_feeds;
DROP POLICY IF EXISTS p_podcast_feeds_delete ON podcast_feeds;
ALTER TABLE podcast_feeds ENABLE ROW LEVEL SECURITY;
ALTER TABLE podcast_feeds FORCE ROW LEVEL SECURITY;
CREATE POLICY p_podcast_feeds_visible ON podcast_feeds
FOR SELECT
USING (
current_setting('app.user_id') IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM library_visibility lv
WHERE lv.user_id = current_setting('app.user_id')::uuid
AND lv.library_id = podcast_feeds.library_id
AND lv.is_visible = false
)
AND (
EXISTS (
SELECT 1 FROM podcast_subscriptions s
WHERE s.user_id = current_setting('app.user_id')::uuid
AND s.feed_id = podcast_feeds.id
)
OR EXISTS (
SELECT 1 FROM users u
WHERE u.id = current_setting('app.user_id')::uuid
AND u.role = 'admin'
)
)
);
CREATE POLICY p_podcast_feeds_insert ON podcast_feeds
FOR INSERT WITH CHECK (true);
CREATE POLICY p_podcast_feeds_update ON podcast_feeds
FOR UPDATE USING (true) WITH CHECK (true);
CREATE POLICY p_podcast_feeds_delete ON podcast_feeds
FOR DELETE USING (true);
-- podcast_subscriptions: UNGOVERNED, deliberately — it IS the read
-- policy's lookup table, and a user must always see their own
-- subscription rows (subscribe -> visibility is a single INSERT).
-- ============================================================
-- ── collections + collection_items (2026-10-31): ownership at the DB ──
-- layer, same doctrine as media_items/podcast_feeds above. The
-- predicates live in FUNCTIONS (not inline) so a future sharing model
-- edits exactly one place: add the share-table EXISTS branch inside
-- app_can_read_collection / app_can_write_collection. The functions are
-- PURE over their arguments — they never query collections — so the
-- policies cannot recurse, FORCE RLS safe. Reads are authenticated-
-- allow (household-readable: any signed-in identity, SeedIdentity
-- harness included); WRITES are owner-or-admin — that is the
-- enforcement of record for Update/Delete/AddBooks, which have no
-- app-level gate.
-- ===========================================================
CREATE OR REPLACE FUNCTION app_is_admin() RETURNS BOOLEAN
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
SELECT EXISTS (
SELECT 1 FROM users u
WHERE u.id = NULLIF(current_setting('app.user_id', true), '')::uuid
AND u.role = 'admin'
);
$$;
CREATE OR REPLACE FUNCTION app_can_read_collection(
p_collection_id UUID, p_owner UUID
) RETURNS BOOLEAN
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
SELECT NULLIF(current_setting('app.user_id', true), '') IS NOT NULL
AND (
p_owner = current_setting('app.user_id', true)::uuid
OR app_is_admin()
-- FUTURE SHARING slot: OR EXISTS (
-- SELECT 1 FROM collection_shares cs
-- WHERE cs.collection_id = p_collection_id
-- AND cs.shared_with_user_id = current_setting('app.user_id', true)::uuid)
);
$$;
CREATE OR REPLACE FUNCTION app_can_write_collection(
p_collection_id UUID, p_owner UUID, p_system BOOLEAN
) RETURNS BOOLEAN
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
SELECT NULLIF(current_setting('app.user_id', true), '') IS NOT NULL
AND (
p_owner = current_setting('app.user_id', true)::uuid
OR (COALESCE(p_system, false) AND app_is_admin())
-- FUTURE SHARING slot: OR EXISTS (
-- SELECT 1 FROM collection_shares cs
-- WHERE cs.collection_id = p_collection_id
-- AND cs.shared_with_user_id = current_setting('app.user_id', true)::uuid
-- AND cs.permission IN ('edit'))
);
$$;
ALTER TABLE collections ENABLE ROW LEVEL SECURITY;
ALTER TABLE collections FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_collections_select ON collections;
CREATE POLICY p_collections_select ON collections
FOR SELECT USING (app_can_read_collection(id, user_id));
DROP POLICY IF EXISTS p_collections_insert ON collections;
CREATE POLICY p_collections_insert ON collections
FOR INSERT WITH CHECK (
user_id = NULLIF(current_setting('app.user_id', true), '')::uuid
OR app_is_admin()
);
DROP POLICY IF EXISTS p_collections_update ON collections;
CREATE POLICY p_collections_update ON collections
FOR UPDATE
USING (app_can_write_collection(id, user_id, is_system_collection))
WITH CHECK (app_can_write_collection(id, user_id, is_system_collection));
DROP POLICY IF EXISTS p_collections_delete ON collections;
CREATE POLICY p_collections_delete ON collections
FOR DELETE USING (app_can_write_collection(id, user_id, is_system_collection));
ALTER TABLE collection_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE collection_items FORCE ROW LEVEL SECURITY;
-- Items ride their PARENT's accessibility: the collections SELECT
-- policy auto-applies inside these EXISTS subqueries.
DROP POLICY IF EXISTS p_collection_items_select ON collection_items;
CREATE POLICY p_collection_items_select ON collection_items
FOR SELECT USING (EXISTS (
SELECT 1 FROM collections c
WHERE c.id = collection_items.collection_id
));
DROP POLICY IF EXISTS p_collection_items_insert ON collection_items;
CREATE POLICY p_collection_items_insert ON collection_items
FOR INSERT WITH CHECK (EXISTS (
SELECT 1 FROM collections c
WHERE c.id = collection_items.collection_id
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
));
DROP POLICY IF EXISTS p_collection_items_update ON collection_items;
CREATE POLICY p_collection_items_update ON collection_items
FOR UPDATE
USING (EXISTS (
SELECT 1 FROM collections c
WHERE c.id = collection_items.collection_id
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
))
WITH CHECK (EXISTS (
SELECT 1 FROM collections c
WHERE c.id = collection_items.collection_id
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
));
DROP POLICY IF EXISTS p_collection_items_delete ON collection_items;
CREATE POLICY p_collection_items_delete ON collection_items
FOR DELETE USING (EXISTS (
SELECT 1 FROM collections c
WHERE c.id = collection_items.collection_id
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
));
+6 -2
View File
@@ -12,6 +12,8 @@ services:
build:
context: .
dockerfile: ./Dockerfile
args:
GIT_COMMIT: ${GIT_COMMIT:-none}
# Integration Tests - runs against containerized app and db (dev only)
tests:
@@ -22,10 +24,12 @@ services:
container_name: bookhoard_tests
environment:
# Database Configuration
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
# via FORCE) — same role the app service uses.
DATABASE_HOST: db
DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_USER: postgres
DATABASE_PASSWORD: ${DBPASS}
DATABASE_USER: bookhoard_srv
DATABASE_PASSWORD: ${DBSRVPASS}
DATABASE_NAME: bookhoard
COOKIE_SECURE: false
+59 -5
View File
@@ -11,9 +11,6 @@ services:
PGPORT: ${DB_PORT:-5432}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/schema:/docker-entrypoint-initdb.d
# Make other volumes as needed
- ./uploads:/app/uploads
ports:
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
@@ -26,6 +23,58 @@ services:
env_file:
- .env
# One-shot bootstrap: creates the bookhoard_srv runtime role and
# transfers object ownership to it (RLS prerequisite). Idempotent —
# safe on every startup; the app waits for its success. Passwords
# accept inline values or .env, like the rest of this file.
# NOTE: plpgsql $$ tags are written $$$$ — compose eats one dollar
# per pair before the container sees the SQL.
db-init:
image: postgres:15-alpine
container_name: bookhoard_db_init
environment:
PGHOST: db
PGPORT: ${DB_PORT:-5432}
PGUSER: postgres
PGDATABASE: bookhoard
PGPASSWORD: ${DBPASS}
DBSRVPASS: ${DBSRVPASS}
entrypoint: ["bash", "-c"]
command:
- |
set -e
: "${DBSRVPASS:?DBSRVPASS required in .env}"
psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL'
DO $$$$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN
CREATE ROLE bookhoard_srv LOGIN;
END IF;
END $$$$;
ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass';
DO $$$$
DECLARE r RECORD;
BEGIN
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename);
END LOOP;
FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename);
END LOOP;
FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP
EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn);
END LOOP;
END $$$$;
ALTER SCHEMA public OWNER TO bookhoard_srv;
ALTER DATABASE bookhoard OWNER TO bookhoard_srv;
EOSQL
depends_on:
db:
condition: service_healthy
restart: "no"
# Bookhoard Application
# In production this image is pulled from the Gitea container registry.
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
@@ -35,10 +84,13 @@ services:
restart: unless-stopped
environment:
# Database Configuration
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
# via FORCE). POSTGRES_USER above is the bootstrap account ONLY —
# the app must NEVER connect as it (superusers bypass RLS).
DATABASE_HOST: db
DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_USER: postgres
DATABASE_PASSWORD: ${DBPASS}
DATABASE_USER: bookhoard_srv
DATABASE_PASSWORD: ${DBSRVPASS}
DATABASE_NAME: bookhoard
# Application Configuration
@@ -75,6 +127,8 @@ services:
depends_on:
db:
condition: service_healthy
db-init:
condition: service_completed_successfully
volumes:
- ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub
+305
View File
@@ -432,3 +432,308 @@ library_name`.
partial (layout=dropdown on the same endpoint, HX-Request) instead
of the retired innerHTML renderer in web/src/search.ts — same
endpoint, same auth, content server-rendered.
## 2026-10-29 — RLS visibility: item-scoped gates may answer 404 as well as 403
With library visibility enforced by Postgres RLS (see
database/schema/schema.sql), a hidden library's rows are INVISIBLE to
the engine — item-scoped endpoints (chapters, tracks, download, detail)
can no longer run their Go-level 403 gate because the row never arrives.
Both answers are gated: 403 (Go-level explicit-library checks remain on
listing endpoints) and 404 (row-level invisibility — no existence leak).
Pins asserting 403 on item-scoped content endpoints widen to accept
{403, 404}. Listing/scoped endpoints keep the strict 403 contract.
## 2026-10-29 — Podcast episode columns join media_items (additive widening)
The podcast integration (plan podcast-integration.md tasks 1.1/1.10) adds
fifteen nullable/no-null-default columns to `media_items` — they surface in
raw sqlc rows ONLY (`library-scoped list emits raw sqlc rows`), never in the
resolved unscoped map:
`source` ('scan' default), `source_feed_id`, `episode_guid`, `feed_position`,
`season_number`, `episode_number`, `enclosure_url`,
`declared_duration_seconds`, `transcript_url`, `chapters_url`, `hosts`,
`guests`, `download_state`, `download_error`, `download_attempts`.
Scan-sourced rows carry the inert defaults ('scan' / NULLs / 'pending');
raw-row consumers that filter `source='scan'` (scanner paths) are unaffected.
Nothing removed, nothing renamed.
## 2026-10-30 — download_queue_pos joins media_items (additive widening)
§0 item 4 of podcast-integration.md: the download pool's monotonic
queue ticket. `download_queue_pos BIGINT` (NULL for scan rows and
never-queued episodes) is stamped from the `podcast_queue_seq` SEQUENCE
at enqueue time so claim order = enqueue order (the effective listening
order). Raw-row pins (TestContractPin_MediaListBothBranches →
rawRowKeys) whitelist the key. Nothing removed, nothing renamed.
## 2026-10-30 — episode collections (additive widening, plan item 2/2.3/2.4)
Two shapes widen, both ADDITIVE — nothing removed, nothing renamed:
- **Collections envelope** (`GET /api/collections/:id` → envelopeKeys)
gains `rule_match_mode` (smart-collection match semantics, 'any'
legacy default | 'all'). Static collections emit the default value.
- **BookInfo** (every card surface → bookInfoKeys) gains four STABLE
keys — `is_episode`, `feed_title`, `download_state`, `hidden` — the
episode-collections extras. Adapters that predate episode awareness
emit their zero values (`false`/`""`), matching the
viewer_percentage precedent (nil-representable, key always present).
## 2026-10-30 — collections envelope gains filter_tree (additive widening, item 3.2)
`GET /api/collections/:id` adds `filter_tree` to the envelope: the
unified filter tree when the smart collection carries one (item 3.2 —
tree-driven membership), `null` for legacy/static collections. The
tree is the same qf DSL as saved filters (`{scope?, mode, children}`).
Nothing removed, nothing renamed.
## 2026-10-30 — rules endpoints RETIRED (filter-system unification, item 3.4)
The per-rule CRUD endpoints and the preview endpoint are DELETED — the
unified filter tree (item 3.2) is the only smart-membership authoring
surface, and the rules PAGE is now the filter builder in the
collection's scope:
- `POST /api/collections/:id/rules`, `PUT/DELETE
/api/collections/:id/rules/:ruleId` — gone (legacy rule arrays still
EVALUATE until the collection is rewritten; conversions run at the
builder's first Apply via RulesToFilterTree).
- `POST /api/collections/test-rules` — gone (the builder's live count
via /api/media-items/filter-count replaces the preview).
- `POST /api/collections/preview` STAYS (the dashboard
custom-section-builder still consumes it — flagged for its own
migration, not silently broken).
- Legacy compatibility KEPT: `auto_assign_rules` still round-trips in
the collections envelope; `PUT /api/collections/:id/match-mode`
still serves rules-array collections.
Test-side deletions follow the asymmetry rule: subtests exercising the
deleted endpoints are gone; their surviving SEMANTICS are pinned in
their tree form (TestSmartCollections rewritten on trees,
TestEpisodeCollectionsFilterTree, TestPodcastSmartCollections).
## 2026-10-31 — collections RLS: cross-user detail/writes tighten to 404/no-op
`collections` and `collection_items` joined the RLS-governed tables
(policies `p_collections_*` / `p_collection_items_*`, predicates
`app_can_read_collection` / `app_can_write_collection` — the same
doctrine as the media_items family lens, 2026-10-29). Contract
changes:
- `GET /api/collections/:id` for another user's collection: was 200
(by-id readability), now 404 (row invisible).
- `PUT /api/collections/:id` cross-user: was ungated success (no
app-level ownership check), now 404 — the RLS update touches no
rows, and the handler maps ErrNoRows to 404.
- `POST /api/collections/:id/books` cross-user: was ungated, now 404
(rejectSmartMutation's GetCollectionData is RLS-invisible).
- `DELETE /api/collections/:id` cross-user: still 204, but now a true
DB-level no-op (zero rows deleted) instead of an app-level free hit.
- `PUT /api/collections/:id/view-settings` cross-user: 404 (the owner
gate stays as defense-in-depth; the DB is enforcement of record).
- `PUT .../match-mode` and `.../items/order` keep their app-level 403
gates; the DB now backs them.
- Reads: owner or admin (policy escape); non-owner non-admin users see
nothing, anonymous included. Future sharing rides the predicate
functions — one EXISTS branch in app_can_read/write_collection, no
policy migration.
Test-side: TestCollectionsRLS pins the cross-user matrix end-to-end;
fixture writes to non-system collections run as the row's OWNER
(scopeExecAs/ownerSeedCtx) because the admin write-escape covers
system collections only.
## 2026-10-31 — legacy collection rules surface RETIRED (tree-only storage)
The pre-tree rules machinery is gone — no production row ever carried a
rules array or a non-default match mode (verified in the dev DB), the
web never reads the envelope keys, and the Android app consumes none of
it. Deletions:
- `PUT /api/collections/:id/match-mode` — endpoint retired (it had no
doc page). The tree's root `mode` owns any|all semantics now.
- Envelope: `auto_assign_rules` and `rule_match_mode` are REMOVED from
the create/list/detail/update payloads. This entry retracts the
2026-10-30 pins that listed them. `collection_type` and `is_smart`
stay.
- Request: `auto_assign_rules`/`rule_match_mode` are no longer accepted
on create/update. A create carrying `auto_assign_rules` answers 400
naming the retirement — the custom-section builder's legacy save
(already failing to bind, verified live) fails loudly instead of
silently creating a hollow static section.
- Storage: the `collections.auto_assign_rules` and
`collections.rule_match_mode` columns are DROPPED (idempotent
`DROP COLUMN IF EXISTS` in schema.sql).
- Service: the rules CRUD layer (AddRule/SetRuleEnabled/RemoveRule +
validateRule + the UpdateCollectionRulesJSON query),
ParseRules/EnabledRules/IsSmartCollection, RulesToFilterTree (+ the
SSR preload branch), and the legacy branch of EvaluateSmartMembership
are deleted. IsCollectionSmart = tree presence.
KEPT (live consumers): `POST /api/collections/preview` still accepts
`rules` and evaluates them in memory (the custom-section builder's
preview button); `include_auto` on the list stays (the Android app
sends it). The builder's SAVE remains the flagged §12 tree migration.
## 2026-10-31 — custom-section builder save: the flagged tree migration lands
Supersedes the same-day retirement entry's loud-400 stance for the
builder's payload. `POST /api/collections` now ACCEPTS the builder's
shape and adapts it at write time:
- `auto_assign_rules` (JSON-encoded array — string or bare) converts to
a filter tree via the restored `RulesToFilterTree`; converted trees
validate under `ParseFilterTree`, so an unhonorable rule is a 400 at
write naming the problem.
- `match_type` seeds the 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).
- `library_id` remains selection context only (not stored).
Dashboard sections: smart user sections evaluate their tree LIVE —
`GetUserCollectionItems` merges stored membership with the composed
engine's matches (deduped, library-scoped, capped at the section
limit). This exposed and fixed a real wiring bug: every production
`NewDashboardService` call omitted the search service, so tree
evaluation through the dashboard could never work (the test harness
wired it correctly — the gap was production-only). The dashboard
handler and both inline sites now receive it, and evaluation failures
log instead of silently emptying a section.
## 2026-11-01 — feed operator vocabulary collapsed (Option A): one name per semantic
The feed field's menu offered `is`/`isn't` alongside `in`/`isn't in` —
exact synonyms (the clause compiles both pairs to the same `ANY`). The
MENUS now offer `in`/`isn't in` (+ has/missing) only. This is an
offered-vocabulary narrowing, not a parse change:
- Stored trees and saved filters carrying `feed is X` / `feed is_not X`
PARSE AND EVALUATE FOREVER (`legacyFieldOps` accepts the synonyms at
validation — the stored wire is never invalidated).
- The fields payload (the client's operator menus) no longer carries
the duplicates.
- Client id-cells are uniform: every operator on an id-valued field
accepts a set, so all id cells say "pick one or more" and accumulate
picks — no per-operator placeholder/pick distinction to explain.
## 2026-11-01 — id labels resolve at render (SSR embed): refresh keeps names
Pages that render a filter tree embed the tree's id→name map at render
time (`data-id-labels` on the builder mount) — the collection editor
(stored tree) and the bookshelf (URL `qf`). Before this, refresh showed
raw uuids: only ids persist in trees; the client's id→label cache dies
with the page, and the quiet suggest fetch reused the MEDIA-CONSTRAINED
facet, so an id whose media doesn't match the tree's sibling rules got
no row and no label (live repro: `feed in [A,B,C] AND title contains
Chapter` labeled one feed of three — two feeds had no "Chapter"
episodes).
- `SearchService.FilterTreeLabels` reads the ENTITY tables (no media
join, no tree constraint): podcast_feeds and collections are
RLS-governed through the scoped handle (invisible ids resolve to
nothing); libraries mirror the facet's visibility join; added_by is a
plain username lookup. A zero-episode feed resolves its title —
pinned (TestRulesPageEmbedsIdLabels), as is the hidden-library
non-resolution.
- The bookshelf drawer also HYDRATES at component init now (no
data-tree mount → read the form): the parent's x-init
(initBookshelf) runs BEFORE the nested builder component exists
(Alpine initializes parents first), so its builderData() call was
null and a URL-qf refresh left the drawer with an empty seeded rule
while the grid stayed filtered. Init-order contract pinned in
filter_builder.test.ts.
- The quiet suggest fetch remains only as a fallback for trees the SSR
never saw (client-side apply flows); embedded labels make it a no-op
on refresh.
## 2026-11-01 — unset podcast order defaults to reversed (audiobook-first)
The per-feed listening order (the D5 `podcast_order:<feedID>` KV shown
in the Order dropdown) resolved its UNSET state to "native" — feed
document order, which runs newest-first for classic RSS, so an
audiobook feed played its LAST chapter first. The unset default is now
`reverse` at every resolution site of that one setting: the podcast
page list + dropdown (`podcastOrderForFeed`), the player's feed chain
(reader.go), and the download worker (`orderOverrideFor`).
- Stored overrides are untouched: anyone who picked an order keeps it
(native remains a valid, savable choice).
- No ordering semantics changed — `OrderEpisodes` and the four-option
vocabulary are as they were; only the unset fallback flipped.
- Pinned: the player sequence config carries `"order":"reverse"` and
feed position 1 plays LAST for an unset feed; the podcast page's
dropdown renders `reverse` selected (TestPodcastPlayerOrderControl,
replacing the former native pin).
## 2026-11-01 — new podcast smart collections are BORN reversed
`POST /api/collections` with an EPISODES-scope `filter_tree` seeds
`view_settings.chain_order = "reverse"` on the new row (merge semantics:
an explicit client `chain_order` is never clobbered). The dropdown's
default selection and the chain/page behavior come from the STORED
value — no resolution-logic changes; books-scope smart collections and
statics get nothing seeded; existing collections are untouched.
Pinned (TestSmartCollectionBornReversed): episodes seed, explicit-keep,
books-scope no-seed, static no-seed; TestEpisodeCollectionsFilterTree's
born-reversed chain/page pins replace the former native expectations
(which changelog here per the expectation-change rule). One misplaced
pin from 81e54f89 was corrected while flipping: the API envelope's
match list is NOT chain-ordered and never was — order pins live on the
SSR page and the reader chain.
## 2026-11-01 — label endpoint replaces the suggest-facet fallback
`GET /api/filter/labels?field=X&ids=a,b,c` names id-kind filter values
from their source tables (entity rows only — no media join, no tree
constraint; visibility/RLS ride the request identity). Non-id fields
are a 400; ids truncate at the same 100 cap the SSR embed uses.
The client's label resolution (`resolveIdLabels`/`resolveLabelsAsync`)
now uses ONLY this endpoint. The former fallback — the quiet suggest
fetch through the MEDIA-CONSTRAINED facet — is deleted: it labeled only
the ids whose media matched the tree's sibling rules (the live feed
repro), which mattered exactly where the SSR embed can't reach:
applying a SAVED filter from a cold bookshelf. The apply flow now
resolves through the endpoint and rebuilds the chips bar when labels
land. Pinned (TestFilterLabelsEndpoint): a zero-episode feed resolves
its title, comma-joined ids resolve together, an unsubscribed feed id
stays unnamed (RLS), non-id fields are 400.
## 2026-11-01 — the rules-Apply path seeds the reversed default too
PUT /api/collections/:id carrying an EPISODES-scope filter_tree seeds
`chain_order = "reverse"` when neither the request nor the stored
view_settings names one — the creation seed's twin: a static collection
becoming smart lands with the same default a born-smart one gets.
Explicit values (client-sent or stored — e.g. a deliberate native)
are never clobbered. Pinned (TestSmartCollectionBornReversed):
apply-seeds-when-unordered, respects stored native, respects client
native.
TestPodcastSmartCollections' acceptance pin updated with the same flip:
the briefing acceptance now starts from the page's FIRST card (feed
position 5 — the born-reversed default's book order) and chains into
feed position 1. The acceptance meaning is unchanged: play from what
you see, chained back-to-back within the live briefing set.
## 2026-11-01 — audio resume rewind setting
- `GET /api/media-items/:id/chapters` gained one ADDITIVE response key:
`resume_rewind_seconds` (int, the requesting user's setting; 0 for
anonymous). Existing keys unchanged. The web player and the Android
app both gate the value through the 0/5/10 allow-list, so servers
without the key (absent → 0) degrade to exact resume.
- `GET /api/auth/profile` now always carries
`audio_resume_rewind_seconds` (int, never omitted — 0 is a real
value, exact resume). `PUT /api/auth/profile` accepts the key as the
keep_screen_awake-style STRING ("0"/"5"/"10") so the profile form's
select can send an explicit Off; JSON clients that omit it leave the
column untouched, and anything outside {0,5,10} is 400.
Pinned (TestAudioResumeRewind): default 0, PUT "5" rides the
chapters payload, non-allow-listed rejections change nothing,
explicit "0" resets everywhere, omitted key leaves it untouched.
@@ -1,89 +0,0 @@
---
method: POST
path: /api/collections/:id/rules
auth: jwt
area: collections
---
<!-- source: internal/handlers/collections.go:877 CollectionHandler.AddRule -->
# Add Auto-Assign Rule
Add an automatic book assignment rule to a collection.
**Endpoint**: `POST /api/collections/{id}/rules`
**Auth**: Required
**Content-Type**: `application/json`
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | --------------- |
| id | string (UUID) | Yes | Collection UUID |
## Request Body
| Field | Type | Required | Description |
| -------- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| field | string | Yes | Field to match on (genre, author, series, language, publisher, copyright_year, tags) |
| operator | string | Yes | Comparison operator (equals, not_equals, contains, not_contains, starts_with, ends_with, greater_than, less_than) |
| value | string/number | Yes | Value to compare against |
| priority | integer | No | Rule priority (1 = highest, default: 1) |
| enabled | boolean | No | Whether rule is active (default: true) |
### Supported Fields
| Field | Type | Description |
| -------------- | ------ | ------------------------------------- |
| genre | string | Book genre |
| author | string | Book author |
| series | string | Book series name |
| language | string | Book language |
| publisher | string | Publisher name |
| copyright_year | number | Publication year (numeric comparison) |
| tags | string | Book tags |
### Supported Operators
| Operator | Type | Description |
| ------------ | ------ | ------------------------------------- |
| equals | all | Exact match |
| not_equals | all | Not equal |
| contains | string | Contains substring (case-insensitive) |
| not_contains | string | Does not contain |
| starts_with | string | Starts with (case-insensitive) |
| ends_with | string | Ends with (case-insensitive) |
| greater_than | number | Greater than |
| less_than | number | Less than |
### Example Request
```json example-request
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
"priority": 1,
"enabled": true
}
```
## Response (201 Created)
```json example-response
{
"collection_id": null,
"is_smart": null,
"rule": null
}
```
## Error Responses
| Code | Description |
| ---- | ----------------------------------- |
| 400 | Invalid request (validation failed) |
| 401 | Authentication required |
| 404 | Collection not found |
| 500 | Internal server error |
## Try It Out
@@ -23,7 +23,6 @@ Creating a collection whose name already exists for the user returns **409**.
"color": "#00FF00",
"icon": "📖",
"collection_type": "static",
"auto_assign_rules": [],
"view_settings": { "kobo": { "view_mode": "grid" } }
}
```
@@ -39,7 +38,6 @@ Creating a collection whose name already exists for the user returns **409**.
"color": "#00FF00",
"icon": "📖",
"collection_type": "static",
"auto_assign_rules": null,
"view_settings": { "kobo": { "view_mode": "grid" } },
"book_count": 0,
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
@@ -57,20 +55,13 @@ RFC3339. Treat it as display text; do not parse it as an ISO timestamp.
| description | string | no | |
| color | string | no | hex color, e.g. `"#FF5733"` |
| icon | string | no | emoji |
| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by non-empty `auto_assign_rules` |
| auto_assign_rules | array | no | rule objects, see below |
| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by a `filter_tree` |
| view_settings | object | no | per-device display preferences |
### Auto-assign rule object
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| id | string | no | rule id |
| field | string | yes | one of `genre`, `series`, `author`, `language`, `publisher`, `copyright_year`, `tags` |
| operator | string | yes | one of `equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `ends_with`, `greater_than`, `less_than` |
| value | string | yes | value to compare against |
| priority | number | no | 1-10; higher values take precedence |
| enabled | boolean | no | absent = enabled (rules written before this field existed keep matching) |
| filter_tree | object | no | the unified filter tree (qf DSL); presence makes the collection smart |
| auto_assign_rules | string \| array | no | custom-section-builder payload — converted to `filter_tree` at write (see custom-section-builder doc) |
| match_type | string | no | builder payload: `any` (default) or `all` — seeds the converted tree's root mode |
| manual_book_ids | string\[\] | no | builder payload: attached to the collection after create |
| show_on_dashboard | boolean | no | pins the collection as a dashboard section |
## Errors
@@ -27,7 +27,6 @@ GET /api/collections/550e8400-e29b-41d4-a716-446655440000
"description": "Books I want to read soon",
"color": "#00FF00",
"icon": "📖",
"auto_assign_rules": null,
"view_settings": { "kobo": { "view_mode": "grid" } },
"books": [],
"book_count": 0,
@@ -57,8 +56,8 @@ GET /api/collections/550e8400-e29b-41d4-a716-446655440000
| description | string | |
| color | string | |
| icon | string | |
| auto_assign_rules | array \| null | |
| view_settings | object | |
| filter_tree | object \| null | the unified filter tree (qf DSL); null = static |
| books | array | book cards in this collection |
| book_count | number | length of `books` |
| is_smart | boolean | |
@@ -29,7 +29,6 @@ GET /api/collections?include_auto=true&sort_by=name
"description": "Books I want to read soon",
"color": "#00FF00",
"icon": "📖",
"auto_assign_rules": null,
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC",
"book_count": 12,
"is_smart": false
@@ -65,7 +64,6 @@ GET /api/collections?include_auto=true&sort_by=name
| description | string | |
| color | string | |
| icon | string | |
| auto_assign_rules | array \| null | raw rules JSON; non-null implies smart |
| created_at | string | Go `time.String()` format |
| book_count | number | live count; respects `library_id` for smart collections |
| is_smart | boolean | |
@@ -1,46 +0,0 @@
---
method: DELETE
path: /api/collections/:id/rules/:ruleId
auth: jwt
area: collections
---
<!-- source: internal/handlers/collections.go:960 CollectionHandler.RemoveRule -->
# Remove Auto-Assign Rule
Remove an automatic book assignment rule from a collection.
**Endpoint**: `DELETE /api/collections/{collectionId}/rules/{ruleId}`
**Auth**: Required
**Content-Type**: `application/json`
## Path Parameters
| Parameter | Type | Required | Description |
| ------------ | ------------- | -------- | --------------- |
| collectionId | string (UUID) | Yes | Collection UUID |
| ruleId | string (UUID) | Yes | Rule UUID |
## Response (204 No Content)
Rule deleted successfully. No response body.
```json example-response
{
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
"is_smart": true
}
```
`is_smart` reflects the collection state AFTER removal — removing the
last rule converts a smart collection back to static.
## Error Responses
| Code | Description |
| ---- | ---------------------------- |
| 401 | Authentication required |
| 404 | Collection or rule not found |
| 500 | Internal server error |
## Try It Out
@@ -0,0 +1,48 @@
---
method: PUT
path: /api/collections/:id/view-settings
auth: jwt
area: collections
---
<!-- source: internal/handlers/collections.go CollectionHandler.SetCollectionChainOrder -->
# Set Collection Chain Order
Sets the curated listening order for the collection's episode sequence
(plan §10 R2): the chain producer (`buildSmartCollectionSequenceContext`)
plays `Play as Sequence` in this order. The key merges into the
collection's `view_settings` JSONB — playlist toggles that share the
document survive the write. Owner-only.
## Example request
```json example-request
{
"chain_order": "newest"
}
```
## Example response
```json example-response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"chain_order": "newest",
"view_settings": { "chain_order": "newest" }
}
```
## Fields
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| chain_order | string | Yes | One of `native`, `newest`, `oldest`, `reverse`; `native`/empty removes the stored key |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid collection id"}` | malformed id |
| 400 | `{"error": "chain_order must be one of: native, newest, oldest, reverse"}` | unknown order |
| 404 | `{"error": "collection not found"}` | unknown id |
| 401 / 403 | — | missing JWT / not the owner |
@@ -1,79 +0,0 @@
---
method: POST
path: /api/collections/test-rules
auth: jwt
area: collections
---
<!-- source: internal/handlers/collections.go:996 CollectionHandler.TestRules -->
# Test Rule
Test which books would match given rules without saving.
**Endpoint**: `POST /api/collections/test-rules`
**Auth**: Required
**Content-Type**: `application/json`
## Request Body
| Field | Type | Required | Description |
| ----- | ----- | -------- | ----------------------------- |
| rules | array | Yes | Array of rule objects to test |
### Rule Object
| Field | Type | Required | Description |
| -------- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| field | string | Yes | Field to match on (genre, author, series, language, publisher, copyright_year, tags) |
| operator | string | Yes | Comparison operator (equals, not_equals, contains, not_contains, starts_with, ends_with, greater_than, less_than) |
| value | string/number | Yes | Value to compare against |
### Example Request
```json example-request
{
"rules": [
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction"
},
{
"field": "author",
"operator": "contains",
"value": "Asimov"
}
]
}
```
## Response (200 OK)
```json example-response
{
"matches": [
{
"media_item_id": "660e8400-e29b-41d4-a716-446655440000",
"title": "Foundation",
"author": "Isaac Asimov",
"cover_image_path": "/covers/foundation.jpg",
"match_reason": "Matched rule: genre equals Science Fiction"
}
],
"total": 42
}
```
## Use Case
Test rules before creating a collection to verify correct book matching. This endpoint shows which books would be added without actually modifying any collections.
## Error Responses
| Code | Description |
| ---- | ----------------------------------- |
| 400 | Invalid request (validation failed) |
| 401 | Authentication required |
| 500 | Internal server error |
## Try It Out
@@ -24,7 +24,6 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
"description": "Updated description",
"color": "#33CCFF",
"icon": "📚",
"auto_assign_rules": [],
"view_settings": { "hide_finished": true },
"hide_finished": ["true"],
"show_up_next": ["true"]
@@ -41,7 +40,6 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
"description": "Updated description",
"color": "#33CCFF",
"icon": "📚",
"auto_assign_rules": null,
"view_settings": { "hide_finished": true, "show_up_next": true },
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
}
@@ -57,8 +55,8 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
| description | string | no | |
| color | string | no | |
| icon | string | no | |
| auto_assign_rules | array | no | replaces the whole rule set (rule object shape: see Create Collection) |
| view_settings | object | no | merged into stored settings |
| filter_tree | object | no | replaces the stored filter tree (omitting it keeps the stored one) |
| hide_finished | array | no | single-element string array `["true"]`/`["false"]`; merged into view_settings |
| show_up_next | array | no | same shape as `hide_finished` |
@@ -1,45 +0,0 @@
---
method: PUT
path: /api/collections/:id/rules/:ruleId
auth: jwt
area: collections
---
<!-- source: internal/handlers/collections.go:922 CollectionHandler.UpdateRule (SetRuleEnabled) -->
# Update Collection Rule
Despite the route name, this endpoint **toggles a rule's enabled state only**.
It does not update the rule's field/operator/value — rules are replaced wholesale
via [Create Collection](create_collection.md) (`auto_assign_rules`) or added via
the rules endpoint.
## Example request
```json example-request
{
"enabled": false
}
```
## Example response
```json example-response
{
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
"is_smart": true
}
```
## Request fields
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| enabled | boolean | yes | toggles the rule on/off |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid collection id"}` / `{"error": "rule id required"}` / `{"error": "invalid request"}` | malformed |
| 404 | `{"error": "rule not found"}` | no such rule |
| 500 | `{"error": "..."}` | unexpected server error |
@@ -68,6 +68,15 @@ Creates a new custom collection with filter rules and/or manual book selection.
**Request Body:**
**Save contract (2026-10-31 tree migration):** the rules array travels
as a JSON-encoded string and the server converts it to a filter tree at
write time — `match_type` seeds the tree's root mode (`any` default),
`manual_book_ids` attach to the created collection, and
`show_on_dashboard` persists. `library_id` is selection context only
(sections scope to the dashboard's active library at render). Invalid
rules are a 400 at write naming the problem; the section renders stored
picks + the tree's live matches, deduped.
```json
{
"name": "My Custom Section",
@@ -41,7 +41,8 @@ Add a folder to an existing library (Admin only).
"id": "uuid",
"library_id": "uuid",
"folder_path": "/path/to/comics",
"created_at": "2026-01-31T10:00:00Z"
"created_at": "2026-01-31T10:00:00Z",
"purpose": "scan"
}
```
@@ -42,7 +42,8 @@ Create a new library (Admin only).
"created_at": "2026-01-31T10:00:00Z",
"created_by_admin_id": null,
"library_type_id": null,
"updated_at": null
"updated_at": null,
"allow_member_feeds": false
}
```
+2 -1
View File
@@ -43,7 +43,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
"created_at": null,
"created_by_admin_id": null,
"library_type_id": null,
"updated_at": null
"updated_at": null,
"allow_member_feeds": false
}
```
@@ -18,7 +18,8 @@ Lists the filesystem folders configured for a library.
"id": "aa0e8400-e29b-41d4-a716-446655440000",
"library_id": "990e8400-e29b-41d4-a716-446655440000",
"folder_path": "/books/manga",
"created_at": "2026-01-01T00:00:00Z"
"created_at": "2026-01-01T00:00:00Z",
"purpose": "scan"
}
]
```
@@ -42,7 +42,8 @@ A top-level JSON **array** of library rows:
"updated_at": "2026-01-31T10:00:00Z",
"type_name": "ebooks",
"type_description": "Ebook libraries",
"is_visible": true
"is_visible": true,
"allow_member_feeds": false
}
]
```
@@ -46,7 +46,8 @@ Update a library's information.
"created_at": "2026-02-08T10:00:00Z",
"updated_at": "2026-02-08T11:00:00Z",
"created_by_admin_id": null,
"description": null
"description": null,
"allow_member_feeds": false
}
```
@@ -164,7 +164,23 @@ Tags and contributors are automatically normalized:
"summary": null,
"total_characters": null,
"volume": null,
"web_url": null
"web_url": null,
"source": null,
"source_feed_id": null,
"feed_position": null,
"episode_number": null,
"season_number": null,
"episode_guid": null,
"enclosure_url": null,
"chapters_url": null,
"transcript_url": null,
"hosts": null,
"guests": null,
"declared_duration_seconds": null,
"download_state": null,
"download_attempts": null,
"download_queue_pos": null,
"download_error": null
}
```
@@ -231,7 +231,23 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
"total_characters": null,
"updated_at": null,
"volume": null,
"web_url": null
"web_url": null,
"source": null,
"source_feed_id": null,
"feed_position": null,
"episode_number": null,
"season_number": null,
"episode_guid": null,
"enclosure_url": null,
"chapters_url": null,
"transcript_url": null,
"hosts": null,
"guests": null,
"declared_duration_seconds": null,
"download_state": null,
"download_attempts": null,
"download_queue_pos": null,
"download_error": null
}
]
```
@@ -128,7 +128,23 @@ Update media item metadata (Admin only).
"tags_search": null,
"total_characters": null,
"volume": null,
"web_url": null
"web_url": null,
"source": null,
"source_feed_id": null,
"feed_position": null,
"episode_number": null,
"season_number": null,
"episode_guid": null,
"enclosure_url": null,
"chapters_url": null,
"transcript_url": null,
"hosts": null,
"guests": null,
"declared_duration_seconds": null,
"download_state": null,
"download_attempts": null,
"download_queue_pos": null,
"download_error": null
}
```
+2
View File
@@ -36,6 +36,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
"first_name": "John",
"last_name": "Doe",
"role": "user",
"default_audio_speed": 1.5,
"audio_resume_rewind_seconds": 5,
"keep_screen_awake": true
}
```
+4 -61
View File
@@ -229,7 +229,6 @@ paths:
"color": "#00FF00",
"icon": "📖",
"collection_type": "static",
"auto_assign_rules": [],
"view_settings": { "kobo": { "view_mode": "grid" } }
}
responses:
@@ -277,33 +276,6 @@ paths:
responses:
'200':
description: Success
"/api/collections/test-rules":
post:
tags: [collections]
summary: "Test Rule"
security:
- bearerAuth: []
requestBody:
content:
application/json:
example: |-
{
"rules": [
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction"
},
{
"field": "author",
"operator": "contains",
"value": "Asimov"
}
]
}
responses:
'200':
description: OK
"/api/collections/{id}":
delete:
tags: [collections]
@@ -335,7 +307,6 @@ paths:
"description": "Updated description",
"color": "#33CCFF",
"icon": "📚",
"auto_assign_rules": [],
"view_settings": { "hide_finished": true },
"hide_finished": ["true"],
"show_up_next": ["true"]
@@ -409,38 +380,10 @@ paths:
responses:
'200':
description: Success
"/api/collections/{id}/rules":
post:
tags: [collections]
summary: "Add Auto-Assign Rule"
security:
- bearerAuth: []
requestBody:
content:
application/json:
example: |-
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
"priority": 1,
"enabled": true
}
responses:
'201':
description: Created
"/api/collections/{id}/rules/{ruleId}":
delete:
tags: [collections]
summary: "Remove Auto-Assign Rule"
security:
- bearerAuth: []
responses:
'204':
description: No Content
"/api/collections/{id}/view-settings":
put:
tags: [collections]
summary: "Update Collection Rule"
summary: "Set Collection Chain Order"
security:
- bearerAuth: []
requestBody:
@@ -448,11 +391,11 @@ paths:
application/json:
example: |-
{
"enabled": false
"chain_order": "newest"
}
responses:
'200':
description: Success
description: OK
"/api/conflicts":
get:
tags: [conflicts]
+5
View File
@@ -17,6 +17,11 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
- **Sync Configuration**
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
- **Filters**
- [Filters — the complete guide](user/filters/index.md) - rules, groups (ALL/ANY), saved filters, smart collections
- [Filter cookbook](user/filters/cookbook.md) - ready-made recipes, exact clicks
- [Every filter field](user/filters/fields.md) - the generated, always-current reference
- **Frontend & Settings** (Coming Soon)
- Frontend Guide
- User Areas Guide
+78
View File
@@ -0,0 +1,78 @@
# Filter cookbook
Real questions, exact clicks. Every recipe uses the same builder described in
[the guide](index.md) — same fields, same rules, wherever filters live.
Recipes marked **collection** become smart collections that stay current forever; the rest are
drawer filters you can save with a name.
---
## "New chapters of 7th Son, automatically" — collection
1. **Collections** → open (or create) a collection → **Rules**.
2. The scope is already *Episodes*. Add a rule: **Feed** → **in** → pick
*7th Son: Book One* (it becomes a chip — hit the × if you pick wrong).
3. Add a second rule: **Title** → **contains** → `Chapter`.
4. The header should read **MATCH ALL** (it does by default — every rule must match).
5. **Apply to Collection**. Done: new chapters appear on their own, and the listening order
starts at **Feed order (reversed)** — chapter 1 first.
---
## "Lewis Carroll or George Orwell" — drawer
1. **Filters** on the All Books page. Add: **Author** → **contains** → `Carroll`.
2. Add a second rule: **Author** → **contains** → `Orwell`.
3. Click the group header **MATCH ALL** once — it flips to **MATCH ANY** (or click the `AND`
chip between the two rules, same effect).
4. **Apply Filters**. Either author is enough.
*Flip it back to ALL and you'd get only books they both wrote — which is none, and the counter
will tell you so before you apply.*
---
## "Long fantasy books I haven't started" — drawer
1. **Genre** → **is** → `fantasy`.
2. **Page count** → **at least** → `400`.
3. **My reading** → **Reading status** → **is** → *Unfinished* (or your app's wording — the
status list is the fixed dropdown on that field).
4. **MATCH ALL** stays on: all three must hold.
5. Apply — then **Save** it as *"Big unread fantasy"* and it's in the Load menu forever.
---
## "Downloaded podcast episodes I haven't played" — collection
1. A collection with an Episodes scope (see the first recipe — drop the Title rule if you want
whole feeds).
2. **Downloaded** → **is true**.
3. **Played** → **is false**.
4. **MATCH ALL** on. **Apply to Collection**.
---
## "Added to my library this month" — drawer
1. **Date added** → **within the last** → `30` (the days box — the field's page in
[Every filter field](fields.md) has its exact questions).
2. Optionally **MATCH ALL** with **Library** → **in** → your audiobooks library.
3. Save as *"New arrivals"*.
---
## "A dashboard shelf that builds itself" — dashboard
1. Create a collection with the rules above (or open one).
2. On the dashboard, add a section pointed at that collection.
3. The shelf re-fills itself as your library changes — no maintenance.
---
## Sharing a filtered view
Apply any filter, then copy the address bar. The filter rides in the URL — send it to another
user of your server and they open the same narrowed view (they see it through their own
libraries, not yours).
+755
View File
@@ -0,0 +1,755 @@
<!-- GENERATED by TestFieldReferenceDocIsCurrent (internal/services/filter_docs_test.go)
from the filter registry — the same source the real menus render. DO NOT EDIT by hand:
regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent -->
# Every filter field
This is the complete list of everything the filters can ask about, generated straight from the
same source the app's menus render — it cannot drift from what you see in the UI.
Filters come in two worlds:
- **Books** — rules about books and your reading of them.
- **Episodes** — rules about podcast episodes (feeds, downloaded, played).
Every rule is **a field + a question + your values**. The "Ask" column is the question;
the "Your values" column is what you provide. The **default** question is what a new rule
starts with.
## Books
### Book facts
**Title** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Subtitle** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Description** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Summary** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Language** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Page count** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Chapter count** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Total characters** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Community rating** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Date added** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**Date imported** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**Web URL** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Metadata notes** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**ISBN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick |
| isn't | matches anything but this |
| has | has a value *(default)* |
| missing | is empty or missing |
**ASIN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick |
| isn't | matches anything but this |
| has | has a value *(default)* |
| missing | is empty or missing |
**File size** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**File availability** — Choose from a fixed list of options.
| Ask | Means |
| --- | --- |
| is | matches exactly *(default)* |
| isn't | matches anything but this |
**Explicit** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Abridged** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Manually edited** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Cover** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Added by** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick |
| has | has a value *(default)* |
| missing | is empty or missing |
### Comics & manga
**Series** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Series number** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Series count** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Volume** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Format group** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
**Reflowable** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Fixed layout** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Black & white** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Manga type** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
| is empty | — |
**Reading direction** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
| is empty | — |
### Library
**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
### My reading
**Reading status** — Choose from a fixed list of options.
| Ask | Means |
| --- | --- |
| is | matches exactly *(default)* |
**Progress percent** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Last read** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**My rating** — Type a number.
| Ask | Means |
| --- | --- |
| is | matches exactly |
| at least | is at least this *(default)* |
| at most | is at most this |
| unrated | — |
**Has review** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Notes** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
| within the last | — |
**Highlights** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
| within the last | — |
**Note text** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is empty | — |
**Highlight text** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is empty | — |
**Time spent (minutes)** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
**Pages read** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| in | is one of the ones you pick (any one is enough) *(default)* |
| isn't in | is none of the ones you pick |
| in any | is in at least one of the collections you pick |
| in none | — |
### People
**Author** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Narrator** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Contributor** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
### Publishing
**Publisher** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Imprint** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Edition** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Published year** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Published date** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
### Topics
**Genre** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Tags** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Story arc** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Age rating** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
| is empty | — |
## Podcast episodes
### Book facts
**Title** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Description** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Language** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Chapter count** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Date added** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**Date imported** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**File size** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**File availability** — Choose from a fixed list of options.
| Ask | Means |
| --- | --- |
| is | matches exactly *(default)* |
| isn't | matches anything but this |
**Explicit** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
### Comics & manga
**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
### Episodes
**Feed** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| in | is one of the ones you pick (any one is enough) *(default)* |
| isn't in | is none of the ones you pick |
| has | has a value |
| missing | is empty or missing |
**Downloaded** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Played** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on |
| is false | is switched off *(default)* |
### Library
**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
### My reading
**Progress percent** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Last read** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**My rating** — Type a number.
| Ask | Means |
| --- | --- |
| is | matches exactly |
| at least | is at least this *(default)* |
| at most | is at most this |
| unrated | — |
**Has review** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Notes** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
| within the last | — |
**Highlights** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
| within the last | — |
**Note text** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is empty | — |
**Highlight text** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is empty | — |
**Time spent (minutes)** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| in | is one of the ones you pick (any one is enough) *(default)* |
| isn't in | is none of the ones you pick |
| in any | is in at least one of the collections you pick |
| in none | — |
### Publishing
**Published year** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Published date** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
---
*Anything here that doesn't match what the app actually shows is a bug — this page is generated
from the app's own menus and a test fails if they ever disagree.*
+91
View File
@@ -0,0 +1,91 @@
# Filters — the complete guide
Filters answer one question: *of everything in my library, show me the ones that match.*
You'll meet the same filter system in three places:
| Place | What it's for |
| --- | --- |
| **The Filters drawer** (All Books / Podcasts pages) | A quick, temporary narrowing of what you see. Save it if you want to keep it. |
| **Smart collections** (a collection's **Rules** page) | A filter that *lives* — the collection's contents update automatically forever. |
| **Dashboard sections** | The same rules, feeding a shelf on your home screen. |
One system, one set of skills, three payoffs.
## Two worlds: Books and Episodes
Rules about **books** (authors, series, your reading progress) and rules about **podcast episodes**
(feeds, downloaded, played) are two different worlds. The field menus only show the fields that
make sense where you are — you'll never see a podcast field on the All Books page. The complete
list of every field is in [Every filter field](fields.md).
## Anatomy of a rule
Every rule is **a field + a question + your values**:
> **Author** *contains* `asc`
> **Feed** *in* `7th Son: Book One`
> **Page count** *at least* `400`
The field is *what* you're asking about. The question (contains, at least, is empty…) is *how*.
Your values are *what counts as a match*. The complete questions per field are in
[Every filter field](fields.md).
## Picking values
Two kinds of value boxes:
- **Dropdown boxes** (Feed, Library, Collection, Added by): start typing and real names appear,
with live counts of how much matches. Click to pick — each pick becomes a **chip** with a little
**×** to remove it. Pick as many as you like; *any one of them* matching is enough.
- **Typed boxes** (Author, Title, Page count…): just type. Several words means "contains these
words"; number boxes take numbers; date boxes take a number of days.
The dropdown's counts are **live under your other rules**: if you've already narrowed to
*Title contains Chapter*, the feed menu shows how many Chapter-titled episodes each of your feeds
has. Rules already picked disappear from their own menu (removing a chip brings them back).
## Groups: ALL and ANY
When you have several rules, the group header — **MATCH ALL** or **MATCH ANY** — decides how they
combine:
- **MATCH ALL** — *every* rule must match. (The default, and what you want almost always.)
- **MATCH ANY** — *at least one* rule matching is enough. This is how you ask for
"Lewis Carroll **or** George Orwell".
**Click the header to switch it.** It says what it does, and clicking flips it.
`+ group` adds a nested group with its own header (**GROUP ALL / GROUP ANY**), so bigger shapes
build naturally:
> Match ALL: Author *contains* `Carroll` **AND** GROUP ANY (Title contains `Alice` **OR** Title
> contains `Wonderland`)
For fine-tuning inside an ALL group, the small **AND / OR** chip between two rules mixes them —
click `AND` and it becomes `OR`, joining those neighbors together. (The header switch and these
chips are two different tools: the header sets the group's overall combine; a chip makes just two
neighboring rules special.)
## The live count
While you build, the counter at the bottom — *N books match* — runs your rules against the real
library. It's the exact number you'll get when you apply.
## Defaults worth knowing
- **Podcast listening order**: podcast pages and new podcast collections start at
**Feed order (reversed)** — book order, chapter 1 first. Change it per feed or collection from
the order dropdown; your choice sticks.
- **Hidden libraries** (family lens) never match — a filter can't accidentally surface a library
that's hidden from you.
- **Episodes that aren't downloaded** stay out of playback chains (they still appear in lists).
## Keeping a filter
- **Save** (drawer): name it; it appears in the **Load** menu from then on.
- **Apply to Collection** (rules page): the filter becomes the smart collection — live forever.
- Filtered views are **shareable**: the URL carries the filter, so a bookmark or sent link opens
the same view.
+9
View File
@@ -35,3 +35,12 @@ You can combine tags with other filters:
- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction"
- Best matching tags appear first in results
- Autocomplete shows existing tags as you type with relevance scores
## The complete filter guide
The filters described above are one system that also powers smart collections and dashboard
sections — groups with ALL/ANY, saved filters, and every field listed. The full manual lives in
[this folder](filters/index.md): the [guide](filters/index.md), the
[cookbook](filters/cookbook.md) of ready-made recipes, and the generated
[field reference](filters/fields.md).
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.31.0
package database
+58 -2
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.31.0
package database
@@ -34,7 +34,7 @@ type Collections struct {
Description pgtype.Text `db:"description" json:"description"`
Color pgtype.Text `db:"color" json:"color"`
Icon pgtype.Text `db:"icon" json:"icon"`
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
ViewSettings []byte `db:"view_settings" json:"view_settings"`
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
QueryType pgtype.Text `db:"query_type" json:"query_type"`
@@ -150,6 +150,7 @@ type Libraries struct {
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
}
type LibraryFolders struct {
@@ -157,6 +158,7 @@ type LibraryFolders struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
FolderPath string `db:"folder_path" json:"folder_path"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Purpose string `db:"purpose" json:"purpose"`
}
type LibraryTypes struct {
@@ -338,6 +340,22 @@ type MediaItems struct {
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
Source string `db:"source" json:"source"`
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
Hosts []string `db:"hosts" json:"hosts"`
Guests []string `db:"guests" json:"guests"`
DownloadState string `db:"download_state" json:"download_state"`
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
}
type MediaNotes struct {
@@ -405,6 +423,43 @@ type PanelData struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type PodcastFeeds struct {
ID pgtype.UUID `db:"id" json:"id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
FeedUrl string `db:"feed_url" json:"feed_url"`
PodcastGuid pgtype.Text `db:"podcast_guid" json:"podcast_guid"`
Title string `db:"title" json:"title"`
Description pgtype.Text `db:"description" json:"description"`
Host pgtype.Text `db:"host" json:"host"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
OwnerName pgtype.Text `db:"owner_name" json:"owner_name"`
OwnerEmail pgtype.Text `db:"owner_email" json:"owner_email"`
Language pgtype.Text `db:"language" json:"language"`
Tags []string `db:"tags" json:"tags"`
Explicit bool `db:"explicit" json:"explicit"`
ArtworkPath pgtype.Text `db:"artwork_path" json:"artwork_path"`
FeedType pgtype.Text `db:"feed_type" json:"feed_type"`
Medium pgtype.Text `db:"medium" json:"medium"`
IsComplete bool `db:"is_complete" json:"is_complete"`
Etag pgtype.Text `db:"etag" json:"etag"`
LastModified pgtype.Text `db:"last_modified" json:"last_modified"`
LastFetchedAt pgtype.Timestamptz `db:"last_fetched_at" json:"last_fetched_at"`
LastBuildDate pgtype.Timestamptz `db:"last_build_date" json:"last_build_date"`
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type PodcastSubscriptions struct {
ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
FeedID pgtype.UUID `db:"feed_id" json:"feed_id"`
DisplayName pgtype.Text `db:"display_name" json:"display_name"`
AutoDownload string `db:"auto_download" json:"auto_download"`
AutoDownloadCount pgtype.Int4 `db:"auto_download_count" json:"auto_download_count"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
}
// Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans.
type ProcessingIssues struct {
ID pgtype.UUID `db:"id" json:"id"`
@@ -599,4 +654,5 @@ type Users struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
AudioResumeRewindSeconds int32 `db:"audio_resume_rewind_seconds" json:"audio_resume_rewind_seconds"`
}
+190 -10
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.31.0
package database
@@ -22,6 +22,8 @@ type Querier interface {
// ============================================
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
// Library Folders queries
// purpose: 'scan' (FS-walker root, default) | 'podcast' (download root;
// scanner-skipped, written by the podcast download worker instead).
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
// Second consecutive missing scan: hide the item from all browsing while
// preserving reading history in case the file returns.
@@ -30,6 +32,11 @@ type Querier interface {
BulkUpdateFormatGroups(ctx context.Context) error
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
// Atomic claim (§0 item 4): converts queued -> downloading in ONE
// statement. Two claimants racing on the same row: exactly one UPDATE
// matches download_state='queued'; the loser gets no rows (ErrNoRows).
// This is the SKIP-LOCKED guarantee without a second round-trip.
ClaimQueuedPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error)
// Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
@@ -53,11 +60,19 @@ type Querier interface {
// collections page); same live filter as GetCollectionItems, plus the
// future-hybrid excluded exclusion and an optional library scope.
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
CountDownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
// metadata.opf may only be written when NO other non-archived item
// shares the directory; otherwise the sidecar goes to
// <basename>.opf so one book's metadata can never poison dir-mates.
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error)
CountPodcastEpisodesForFeed(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
CountPodcastFeedSubscribers(ctx context.Context, feedID pgtype.UUID) (int64, error)
// Sidebar/page gate (D13): >=1 podcasts-type library the user can see
// (COALESCE(is_visible,true) semantics — mirrors GetUserVisibleLibraries).
CountPodcastLibrariesForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
CountQueuedEpisodes(ctx context.Context) (int64, error)
CountUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
// Count unlinked books for a device (ignored rows stay hidden)
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
// Banner count: unresolved, non-ignored queue rows across the caller's own
@@ -116,6 +131,22 @@ type Querier interface {
// ============================================
CreateOrUpdateKoboEntitlement(ctx context.Context, arg CreateOrUpdateKoboEntitlementParams) (KoboEntitlements, error)
// ============================================================================
// PODCASTS / RSS FEEDS (plan ~/.opencode/plan/podcast-integration.md §6/§8)
// Visibility is RLS-enforced (schema policy block) — NO query here carries
// visibility clauses. Feed reads run under the acting user's identity
// (subscriber-or-admin policy); the fetcher/worker run as first-admin.
// The fetcher/download worker is the SOLE writer of feed/episode metadata;
// users write only podcast_subscriptions rows.
// ============================================================================
// Add-feed upsert keyed on the per-library UNIQUE(library_id, feed_url):
// a second subscriber adding the same URL attaches to the existing row
// instead of erroring. DO UPDATE (not DO NOTHING) so the RETURNING works
// even when the conflicting row is invisible to the actor under RLS
// (non-admin creators cannot SELECT other users' feeds). Metadata columns
// are filled by the fetcher right after; this only establishes identity.
CreatePodcastFeed(ctx context.Context, arg CreatePodcastFeedParams) (PodcastFeeds, error)
CreatePodcastLibraryFolder(ctx context.Context, arg CreatePodcastLibraryFolderParams) (LibraryFolders, error)
// ============================================================================
// PROCESSING ISSUES QUERIES
// ============================================================================
// Widened upsert (faceted plan Phase 11): media_item_id and file_path
@@ -160,6 +191,11 @@ type Querier interface {
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error
// Admin-only removal (handler checks role); episodes cascade via
// media_items.source_feed_id ON DELETE CASCADE.
DeletePodcastFeed(ctx context.Context, id pgtype.UUID) (int64, error)
// Unsubscribe NEVER deletes feed/episode/file state (D6).
DeletePodcastSubscription(ctx context.Context, arg DeletePodcastSubscriptionParams) (int64, error)
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
@@ -170,7 +206,14 @@ type Querier interface {
// Delete unlinked book
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
DeleteUser(ctx context.Context, id pgtype.UUID) error
DeleteUserSettingByKey(ctx context.Context, arg DeleteUserSettingByKeyParams) error
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
// Device deletion (deregister on logout): detach the device's reading
// history instead of deleting it — these rows are real captured stats
// (the Users section reads them); reading_history.device_id is NO ACTION
// so a blind device delete 500s once any history anchors to the device.
// device_id is nullable: NULL keeps the session, loses the association.
DetachReadingHistoryFromDevice(ctx context.Context, deviceID pgtype.UUID) error
// Device existence probe for capture: the web/app progress paths
// historically stuff the user id into DeviceID (it only fed a broadcast
// label before stats); a capture row must only reference a REAL device.
@@ -219,11 +262,25 @@ type Querier interface {
// rename can't spawn duplicates on the next scan.
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error)
// The collection queue's downloaded episodes in collection order
// (plan item 2.3 "Play collection as sequence"): position-first, then
// added_at. INNER JOIN — the chain steps only through episodes the
// requester can see (RLS drops unsubscribed feeds) and that are
// downloaded (invariant 6: un-downloaded episodes are never tracked).
GetCollectionEpisodesOrdered(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionEpisodesOrderedRow, error)
// Get collection items
// A2c: position-first ordering (playlist reading order); un-positioned
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
// which walked a playlist backwards.
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
// Episode-collections detail (plan item 2.3): the collection's full
// membership for the SSR detail page, with per-row visibility. LEFT
// JOIN + RLS: rows whose media is invisible to the requester
// (unsubscribed episode — or a family-lens hidden book) survive with
// NULL media columns and visible=false, so the page can render an
// "unavailable" hint instead of silently vanishing them. Rows visible
// but archived/missing-scan keep the old INNER-JOIN behavior (dropped).
GetCollectionItemsDetailed(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsDetailedRow, error)
// The excluded filter lives here (was: callers filtered ci.excluded in
// Go over a 69-field row + a 45-field converter to MediaItems; embed
// returns the real row so neither exists).
@@ -415,6 +472,25 @@ type Querier interface {
// Get OPDS tokens by device
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error)
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error)
// Continue aggregation (plan 2.5): the caller's IN-PROGRESS episodes
// (0 < pct < finishedThreshold) across ALL subscribed feeds, most
// recent first. Go groups per feed — the first row per feed IS the
// continue target.
GetPodcastContinueForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastContinueForUserRow, error)
GetPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error)
// Per-user progress for a feed's episodes (row badges + the Phase-2
// continue fields). reading_progress is UNIQUE per (media_item, user).
GetPodcastEpisodeProgress(ctx context.Context, arg GetPodcastEpisodeProgressParams) ([]GetPodcastEpisodeProgressRow, error)
GetPodcastFeed(ctx context.Context, id pgtype.UUID) (PodcastFeeds, error)
// URL-migration dedupe: same canonical guid within the library reuses
// the existing row.
GetPodcastFeedByGuid(ctx context.Context, arg GetPodcastFeedByGuidParams) (PodcastFeeds, error)
// The download root (purpose='podcast') of a podcasts library.
GetPodcastLibraryFolder(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error)
GetPodcastSubscription(ctx context.Context, arg GetPodcastSubscriptionParams) (PodcastSubscriptions, error)
// The podcasts library/ies visible to the user (add-feed target list;
// a self-hosted instance typically has exactly one).
GetPodcastsLibraryForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastsLibraryForUserRow, error)
// Get most popular books for a user
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
// Single-issue read for the resolve/delete endpoints' family-lens gate.
@@ -515,14 +591,14 @@ type Querier interface {
// END instant (the push time; NULL falls back to created_at) — the same
// value the capture stamps every write with.
GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error)
// ============================================================================
// GENERIC reader_settings KV (podcast order override, plan D5: key
// podcast_order:<feed_id>). The pre-existing reader_settings queries are
// hard-keyed to 'reader_settings'; these two are the generic form.
// ============================================================================
GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error)
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
// membership comes from the candidate book set instead of collection_items.
// The set is scoped to the requesting user's visible libraries (family
// lens enforced at the source) and live items only — the same filters the
// static collection paths apply.
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
// Soft-dismiss one row (ownership is checked by the caller against the
// device's user_id before calling this).
@@ -534,6 +610,12 @@ type Querier interface {
// Link unlinked book to media item
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
// EVERY user with its maintenance aggregates. Runs under the first-
// admin identity (invariant 12 — same contract as the poller). Sorted
// by disk usage DESC: the maintenance question is "what's eating the
// disk", so the hogs surface first.
ListAllPodcastFeedsAdmin(ctx context.Context) ([]ListAllPodcastFeedsAdminRow, error)
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
// ============================================
@@ -546,6 +628,9 @@ type Querier interface {
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
// Download-worker policy union (D6): most aggressive policy wins for
// what arrives. Runs under an admin identity (background job).
ListFeedSubscribers(ctx context.Context, feedID pgtype.UUID) ([]ListFeedSubscribersRow, error)
// Items hidden from libraries: either missing from disk (waiting on the
// second scan that archives them) or already archived and pending purge.
// Feeds the admin archived-items page. match_* columns identify an active
@@ -557,6 +642,9 @@ type Querier interface {
// collapsible "Ignored" section so a misclick is recoverable.
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
// Podcasts exclusion (plan §9): generic book surfaces never list
// podcast episodes (library_type_name is the denormalized, trigger/
// fetcher-maintained discriminator; NULL rows predate it and stay).
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
@@ -565,18 +653,35 @@ type Querier interface {
// List media items that have no stored SHA-256 (imported before hashing existed)
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error)
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
// The podcasts page: the acting user's subscriptions with feed metadata
// and the user-owned subscription fields. Facet values derive in Go.
ListMyPodcastFeeds(ctx context.Context, userID pgtype.UUID) ([]ListMyPodcastFeedsRow, error)
ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
// DeviceID NULL = every device's pending items (the queue processor's
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
// silently disabled the offline flush until 2026-10-17).
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
// Feed detail / sequence source. RLS scopes to subscriber-or-admin;
// ordering: native position, then pub date, then guid (stable across
// re-fetches for historical rows whose position went stale).
ListPodcastEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) ([]MediaItems, error)
// Poller worklist (admin identity): every feed with its validators and
// the back-off flag (is_complete feeds poll rarely, never disappear).
ListPodcastFeedsForPolling(ctx context.Context) ([]PodcastFeeds, error)
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
// row — an inner join would silently drop the ledger's newest kind.
// Item-side projections COALESCE to keep them non-nullable strings;
// the display file path falls back to the issue's own for row-less rows.
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
// Fair-share candidate scan (§0 item 4): the download pool claims in
// TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the
// enqueue loop's ordering IS the download order, item 5). The worker
// groups these per feed and prefers the least-recently-served feed.
ListQueuedPodcastEpisodes(ctx context.Context) ([]ListQueuedPodcastEpisodesRow, error)
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// Download All + policy union worklist.
ListUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) ([]MediaItems, error)
// List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
// Page list: unresolved queue rows for the caller's own devices (ignored
@@ -585,6 +690,17 @@ type Querier interface {
ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk.
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
// SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
// GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
// concurrency; ClaimQueuedPodcastEpisode does both atomically.
// SUCCESS-only write (invariant 7): the file is on disk and probed.
// ffprobe facts (real duration via audio_metadata, precise size/mime)
// reconcile the feed's declared values.
MarkPodcastEpisodeDownloaded(ctx context.Context, arg MarkPodcastEpisodeDownloadedParams) (MediaItems, error)
// Failure/rejection write: state + reason + whether a human already
// intervened (manual_retry resets the auto-retry budget). Existing
// metadata/file facts are never cleared.
MarkPodcastEpisodeFailed(ctx context.Context, arg MarkPodcastEpisodeFailedParams) error
// Content reappeared at a new location while the old path vanished: treat as
// a move. Repoints the row (and refreshes file-level fields) so reading
// history follows the book; archive/missing state is cleared separately.
@@ -619,6 +735,17 @@ type Querier interface {
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
// Admin file deletion (§0 item 8): ONE downloaded episode's disk file
// was removed — reset the row to pending and clear the download-derived
// facts. file_path is deliberately KEPT: it is the fetch-maintained
// layout (written at fetch time, not download time), so a re-download
// lands on the same path. The poll upsert never touches download_state,
// so a deleted file stays deleted (invariant 2: polling never deletes;
// admins delete knowingly).
ResetPodcastEpisodeFileDeleted(ctx context.Context, id pgtype.UUID) error
// Same reset for EVERY downloaded episode of a feed (per-feed
// "Delete files" action on the admin view).
ResetPodcastFeedFilesDeleted(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
@@ -643,19 +770,44 @@ type Querier interface {
RevokeOpdsToken(ctx context.Context, token string) error
RevokeRefreshToken(ctx context.Context, token pgtype.UUID) error
SearchAuthorValues(ctx context.Context, arg SearchAuthorValuesParams) ([]SearchAuthorValuesRow, error)
// Episode picker scope (plan item 2.3): YOUR subscriptions' episodes.
// The podcasts exclusion in SearchMediaItemsUnified stays (books
// surfaces never list podcasts — plan §9); THIS query is the episode
// picker's dedicated scope. Visibility is the engine's job (D20): the
// media_items policy drops episodes from unsubscribed feeds and the
// podcast_feeds join is governed the same way — non-subscribers see
// nothing here without any Go-side clause.
SearchEpisodesForPicker(ctx context.Context, q_ string) ([]SearchEpisodesForPickerRow, error)
SearchGenreValues(ctx context.Context, arg SearchGenreValuesParams) ([]SearchGenreValuesRow, error)
SearchLanguageValues(ctx context.Context, arg SearchLanguageValuesParams) ([]SearchLanguageValuesRow, error)
// Search Media Items queries
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error)
SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error)
// Subscription-scoped episode search (D14): title + description cover
// un-downloaded AND historical rows (fetcher persists metadata-only).
// RLS already restricts to the caller's subscriptions — the explicit
// source filter keeps scan items out of podcast search.
SearchMyPodcastEpisodes(ctx context.Context, searchQuery string) ([]MediaItems, error)
// Feed-title AND subscription-display-name search (D14) + the facet
// fields the podcasts page filters on.
SearchMyPodcastFeeds(ctx context.Context, arg SearchMyPodcastFeedsParams) ([]SearchMyPodcastFeedsRow, error)
SearchNarratorValues(ctx context.Context, arg SearchNarratorValuesParams) ([]SearchNarratorValuesRow, error)
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error)
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
// Library Visibility queries
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
// Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET
// (§0 items 4/5) so claim order = enqueue order (the effective
// listening order the enqueue loop applied). Attempts increment at
// enqueue so a crashed worker still counts the try.
SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error)
// Manual retry (same endpoint as download): reset to pending so the
// worker re-claims it; the reason text clears with the fresh attempt.
SetPodcastEpisodePending(ctx context.Context, id pgtype.UUID) error
// Set system config
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
SubscribePodcastFeed(ctx context.Context, arg SubscribePodcastFeedParams) (PodcastSubscriptions, error)
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error
@@ -668,11 +820,12 @@ type Querier interface {
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
// Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
// of the JSONB without touching metadata stale clients did not send).
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
// A2c §2.4: provenance + playlist flags on an existing collection without
// touching the rest of the row.
// (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
// collections has no updated_at column — the scanner's series-import
// call has been failing silently, its error discarded. Fixed by
// dropping the phantom column write.)
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
@@ -720,6 +873,20 @@ type Querier interface {
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
UpdatePodcastFeedArtwork(ctx context.Context, arg UpdatePodcastFeedArtworkParams) error
// Post-fetch bookkeeping: validators for conditional GETs, the fetch
// timestamp, and the feed's own last-build marker.
UpdatePodcastFeedFetch(ctx context.Context, arg UpdatePodcastFeedFetchParams) (PodcastFeeds, error)
// Fetcher-only full metadata refresh (parse result). podcast_guid is
// set once a feed declares it; new_feed_url handling is a dedicated
// query below.
UpdatePodcastFeedMetadata(ctx context.Context, arg UpdatePodcastFeedMetadataParams) (PodcastFeeds, error)
// itunes:new-feed-url (or a permanent HTTP redirect chain): the feed has
// MOVED. Rewrite feed_url so future polls hit the new location; the row
// (and every subscription/episode) stays put.
UpdatePodcastFeedRedirect(ctx context.Context, arg UpdatePodcastFeedRedirectParams) error
// User-owned fields only (D9): display name + auto-download policy.
UpdatePodcastSubscription(ctx context.Context, arg UpdatePodcastSubscriptionParams) (PodcastSubscriptions, error)
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
UpdateReadingSpeed(ctx context.Context, arg UpdateReadingSpeedParams) (ReadingSpeed, error)
UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error)
@@ -727,6 +894,7 @@ type Querier interface {
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
// Update universal progress
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
UpdateUserAudioResumeRewind(ctx context.Context, arg UpdateUserAudioResumeRewindParams) error
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
@@ -745,11 +913,23 @@ type Querier interface {
// content identical), and after a manifest-gated re-extract.
UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) error
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
// ============================================================================
// Podcast episodes — media_items rows with source='podcast_episode'
// ============================================================================
// Fetcher-only upsert keyed on the partial unique index
// (source_feed_id, episode_guid). DO UPDATE refreshes FETCH-OWNED
// metadata only. Write-after-success guard (AGENTS.md gotcha #7):
// download state/error/attempts are NEVER touched here, and the
// downloaded file's facts (path/size/mime/cover) survive renumbering —
// only un-downloaded rows follow the re-derived file_path.
// authors_search mirrors authors (trigram search column).
UpsertPodcastEpisode(ctx context.Context, arg UpsertPodcastEpisodeParams) (MediaItems, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
// Ingest: insert or refresh the queue row for a device file that could not
// be identified. One row per (device, content_id); re-syncs bump last_seen.
UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error)
UpsertUserSettingByKey(ctx context.Context, arg UpsertUserSettingByKeyParams) error
}
var _ Querier = (*Queries)(nil)
File diff suppressed because it is too large. Load diff
+595 -25
View File
@@ -30,6 +30,7 @@ SELECT
u.timezone,
u.default_audio_speed,
u.keep_screen_awake,
u.audio_resume_rewind_seconds,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1;
@@ -97,7 +98,9 @@ DELETE FROM libraries WHERE id = $1;
-- Library Folders queries
-- name: AddLibraryFolder :one
INSERT INTO library_folders (library_id, folder_path) VALUES ($1, $2) RETURNING *;
-- purpose: 'scan' (FS-walker root, default) | 'podcast' (download root;
-- scanner-skipped, written by the podcast download worker instead).
INSERT INTO library_folders (library_id, folder_path, purpose) VALUES ($1, $2, $3) RETURNING *;
-- name: GetLibraryFolders :many
SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at;
@@ -178,12 +181,16 @@ SELECT * FROM media_items WHERE library_id = $1 AND file_path = $2;
SELECT * FROM media_items WHERE id = $1;
-- name: ListMediaItems :many
-- Podcasts exclusion (plan §9): generic book surfaces never list
-- podcast episodes (library_type_name is the denormalized, trigger/
-- fetcher-maintained discriminator; NULL rows predate it and stay).
SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (mi.library_type_name IS DISTINCT FROM 'podcasts')
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
@@ -212,6 +219,7 @@ JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.library_id = sqlc.narg('library_id')
ORDER BY
CASE
@@ -519,6 +527,9 @@ UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserKeepScreenAwake :exec
UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserAudioResumeRewind :exec
UPDATE users SET audio_resume_rewind_seconds = $2, updated_at = NOW() WHERE id = $1;
-- Per-book playback speed preference (player plan §8).
-- name: GetAudioPlaybackSpeed :one
@@ -658,6 +669,7 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.na
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (
mi.title ILIKE sqlc.narg('search_pattern') OR
@@ -748,6 +760,7 @@ LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND (sqlc.narg('library_id')::uuid IS NULL
OR mi.library_id = sqlc.narg('library_id')::uuid)
-- Fuzzy author filter — per-element against the authors[] array (A2c
@@ -923,6 +936,26 @@ ORDER BY
mi.title ASC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: SearchEpisodesForPicker :many
-- Episode picker scope (plan item 2.3): YOUR subscriptions' episodes.
-- The podcasts exclusion in SearchMediaItemsUnified stays (books
-- surfaces never list podcasts — plan §9); THIS query is the episode
-- picker's dedicated scope. Visibility is the engine's job (D20): the
-- media_items policy drops episodes from unsubscribed feeds and the
-- podcast_feeds join is governed the same way — non-subscribers see
-- nothing here without any Go-side clause.
SELECT mi.id, mi.title, mi.author, mi.cover_image_path, mi.library_id,
mi.format_group, mi.download_state,
pf.title AS feed_title
FROM media_items mi
JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
WHERE mi.source = 'podcast_episode'
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.arg('q')::text = '' OR mi.title ILIKE '%' || sqlc.arg('q')::text || '%')
ORDER BY pf.title ASC, mi.feed_position ASC NULLS LAST
LIMIT 60;
-- name: SearchAuthorValues :many
SELECT
mi.author as value,
@@ -932,6 +965,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -951,6 +985,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -971,6 +1006,7 @@ JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
CROSS JOIN LATERAL unnest(mi.tags_search) AS tag
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND tag IS NOT NULL
AND word_similarity(sqlc.narg('search_query'), tag) > 0.3
@@ -988,6 +1024,7 @@ JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
CROSS JOIN LATERAL unnest(mi.narrators) AS narrator
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND narrator IS NOT NULL
AND word_similarity(sqlc.narg('search_query'), narrator) > 0.3
@@ -1004,6 +1041,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1023,6 +1061,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1061,6 +1100,7 @@ JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
CROSS JOIN LATERAL unnest(mi.authors) AS a
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1129,6 +1169,7 @@ JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
CROSS JOIN LATERAL unnest(mi.publishers) AS p
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1196,6 +1237,7 @@ JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
CROSS JOIN LATERAL unnest(mi.narrators) AS n
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1265,6 +1307,7 @@ JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
CROSS JOIN LATERAL unnest(mi.tags) AS t
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1333,6 +1376,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1404,6 +1448,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1475,6 +1520,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1546,6 +1592,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1613,6 +1660,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1680,6 +1728,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1747,6 +1796,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1818,6 +1868,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1889,6 +1940,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1960,6 +2012,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -2604,6 +2657,7 @@ SELECT
FROM media_items mi
JOIN reading_history rh ON rh.media_item_id = mi.id
WHERE rh.user_id = $1
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
GROUP BY mi.id, mi.title, mi.author
ORDER BY read_count DESC
LIMIT $2;
@@ -2666,6 +2720,7 @@ SELECT
FROM reading_history rh
JOIN media_items mi ON mi.id = rh.media_item_id
WHERE rh.user_id = sqlc.arg('user_id')
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
@@ -2809,6 +2864,14 @@ SET
updated_at = NOW()
WHERE id = $1;
-- name: DetachReadingHistoryFromDevice :exec
-- Device deletion (deregister on logout): detach the device's reading
-- history instead of deleting it — these rows are real captured stats
-- (the Users section reads them); reading_history.device_id is NO ACTION
-- so a blind device delete 500s once any history anchors to the device.
-- device_id is nullable: NULL keeps the session, loses the association.
UPDATE reading_history SET device_id = NULL WHERE device_id = $1;
-- name: DeleteDevice :exec
DELETE FROM devices WHERE id = $1;
@@ -3430,14 +3493,18 @@ DELETE FROM device_file_aliases WHERE id = $1;
-- Create collection
-- name: CreateCollection :one
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
INSERT INTO collections (user_id, name, description, color, icon, view_settings, show_on_dashboard, filter_tree)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- A2c §2.4: provenance + playlist flags on an existing collection without
-- touching the rest of the row.
-- name: UpdateCollectionViewSettings :exec
UPDATE collections SET view_settings = $2, updated_at = NOW() WHERE id = $1;
-- (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
-- collections has no updated_at column — the scanner's series-import
-- call has been failing silently, its error discarded. Fixed by
-- dropping the phantom column write.)
UPDATE collections SET view_settings = $2 WHERE id = $1;
-- Get collection
-- name: GetCollection :one
@@ -3465,16 +3532,11 @@ SET
description = $3,
color = $4,
icon = $5,
auto_assign_rules = $6,
view_settings = $7
view_settings = $6,
filter_tree = $7
WHERE id = $1
RETURNING *;
-- Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
-- of the JSONB without touching metadata stale clients did not send).
-- name: UpdateCollectionRulesJSON :one
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING *;
-- Delete collection
-- name: DeleteCollection :exec
DELETE FROM collections WHERE id = $1;
@@ -3537,6 +3599,42 @@ WHERE ci.collection_id = $1
AND mi.missing_scan_count = 0
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
-- name: GetCollectionItemsDetailed :many
-- Episode-collections detail (plan item 2.3): the collection's full
-- membership for the SSR detail page, with per-row visibility. LEFT
-- JOIN + RLS: rows whose media is invisible to the requester
-- (unsubscribed episode — or a family-lens hidden book) survive with
-- NULL media columns and visible=false, so the page can render an
-- "unavailable" hint instead of silently vanishing them. Rows visible
-- but archived/missing-scan keep the old INNER-JOIN behavior (dropped).
SELECT ci.media_item_id, ci.position, ci.added_at,
(mi.id IS NOT NULL)::bool AS visible,
mi.title, mi.author, mi.cover_image_path, mi.format_group,
mi.source, mi.download_state,
pf.title AS feed_title
FROM collection_items ci
LEFT JOIN media_items mi ON ci.media_item_id = mi.id
LEFT JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
WHERE ci.collection_id = $1
AND (mi.id IS NULL OR (mi.archived_at IS NULL AND mi.missing_scan_count = 0))
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
-- name: GetCollectionEpisodesOrdered :many
-- The collection queue's downloaded episodes in collection order
-- (plan item 2.3 "Play collection as sequence"): position-first, then
-- added_at. INNER JOIN — the chain steps only through episodes the
-- requester can see (RLS drops unsubscribed feeds) and that are
-- downloaded (invariant 6: un-downloaded episodes are never tracked).
SELECT mi.id, mi.title, mi.feed_position, mi.date_published
FROM collection_items ci
JOIN media_items mi ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1
AND mi.source = 'podcast_episode'
AND mi.download_state = 'downloaded'
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
-- Get collections for book
-- name: GetCollectionsForBook :many
SELECT c.*
@@ -3928,8 +4026,8 @@ RETURNING *;
DELETE FROM collection_items WHERE collection_id = $1;
-- name: CreateSystemCollection :one
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
RETURNING *;
-- name: ResetSystemCollectionMetadata :exec
@@ -3954,6 +4052,7 @@ INNER JOIN (
ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY rp.last_read_at DESC
@@ -3962,6 +4061,7 @@ LIMIT sqlc.narg('limit');
-- name: GetRecentlyAddedItems :many
SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
@@ -3977,6 +4077,7 @@ INNER JOIN (
ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY rp.last_read_at DESC
@@ -3985,6 +4086,7 @@ LIMIT sqlc.narg('limit');
-- name: GetNotStartedItems :many
SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND NOT EXISTS (
@@ -4041,19 +4143,6 @@ LIMIT sqlc.narg('limit');
-- name: CountCollectionItems :one
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
-- Smart ("dynamic") collections: rules are evaluated LIVE at render, so
-- membership comes from the candidate book set instead of collection_items.
-- The set is scoped to the requesting user's visible libraries (family
-- lens enforced at the source) and live items only — the same filters the
-- static collection paths apply.
-- name: GetVisibleLiveMediaForRules :many
SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.title ASC;
-- Live item count for a STATIC collection (rule-free cards on the
-- collections page); same live filter as GetCollectionItems, plus the
-- future-hybrid excluded exclusion and an optional library scope.
@@ -4071,6 +4160,7 @@ SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: collections picker)
ORDER BY mi.created_at DESC;
-- name: GetDistinctSeries :many
@@ -4103,6 +4193,7 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND (library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: series pages)
AND series IS NOT NULL AND series != ''
-- Multi-field search (series page): the query matches the series
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
@@ -4163,6 +4254,7 @@ SELECT COUNT(*)::int FROM (
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND (library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: series pages)
AND series IS NOT NULL AND series != ''
AND (sqlc.narg('series_query')::text IS NULL OR
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
@@ -4582,3 +4674,481 @@ SET file_size = $2,
updated_at = NOW()
WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence;
-- ============================================================================
-- PODCASTS / RSS FEEDS (plan ~/.opencode/plan/podcast-integration.md §6/§8)
-- Visibility is RLS-enforced (schema policy block) — NO query here carries
-- visibility clauses. Feed reads run under the acting user's identity
-- (subscriber-or-admin policy); the fetcher/worker run as first-admin.
-- The fetcher/download worker is the SOLE writer of feed/episode metadata;
-- users write only podcast_subscriptions rows.
-- ============================================================================
-- name: CreatePodcastFeed :one
-- Add-feed upsert keyed on the per-library UNIQUE(library_id, feed_url):
-- a second subscriber adding the same URL attaches to the existing row
-- instead of erroring. DO UPDATE (not DO NOTHING) so the RETURNING works
-- even when the conflicting row is invisible to the actor under RLS
-- (non-admin creators cannot SELECT other users' feeds). Metadata columns
-- are filled by the fetcher right after; this only establishes identity.
INSERT INTO podcast_feeds (library_id, feed_url, title, created_by_admin_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (library_id, feed_url) DO UPDATE SET updated_at = NOW()
RETURNING *;
-- name: GetPodcastFeed :one
SELECT * FROM podcast_feeds WHERE id = $1;
-- name: ListMyPodcastFeeds :many
-- The podcasts page: the acting user's subscriptions with feed metadata
-- and the user-owned subscription fields. Facet values derive in Go.
SELECT f.*,
s.display_name,
s.auto_download,
s.auto_download_count,
s.created_at AS subscribed_at
FROM podcast_subscriptions s
JOIN podcast_feeds f ON f.id = s.feed_id
WHERE s.user_id = $1
ORDER BY COALESCE(s.display_name, f.title) ASC;
-- name: GetPodcastSubscription :one
SELECT * FROM podcast_subscriptions WHERE user_id = $1 AND feed_id = $2;
-- name: SubscribePodcastFeed :one
INSERT INTO podcast_subscriptions (user_id, feed_id)
VALUES ($1, $2)
ON CONFLICT (user_id, feed_id) DO NOTHING
RETURNING *;
-- name: UpdatePodcastSubscription :one
-- User-owned fields only (D9): display name + auto-download policy.
UPDATE podcast_subscriptions
SET display_name = $3,
auto_download = $4,
auto_download_count = $5
WHERE user_id = $1 AND feed_id = $2
RETURNING *;
-- name: DeletePodcastSubscription :execrows
-- Unsubscribe NEVER deletes feed/episode/file state (D6).
DELETE FROM podcast_subscriptions WHERE user_id = $1 AND feed_id = $2;
-- name: ListFeedSubscribers :many
-- Download-worker policy union (D6): most aggressive policy wins for
-- what arrives. Runs under an admin identity (background job).
SELECT user_id, auto_download, auto_download_count
FROM podcast_subscriptions
WHERE feed_id = $1;
-- name: CountPodcastFeedSubscribers :one
SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1;
-- name: ListAllPodcastFeedsAdmin :many
-- Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
-- EVERY user with its maintenance aggregates. Runs under the first-
-- admin identity (invariant 12 — same contract as the poller). Sorted
-- by disk usage DESC: the maintenance question is "what's eating the
-- disk", so the hogs surface first.
SELECT f.id, f.title, f.host, f.last_fetched_at, f.library_id,
l.name AS library_name,
(SELECT COUNT(*) FROM media_items m
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode') AS episode_total,
(SELECT COUNT(*) FROM media_items m
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
AND m.download_state = 'downloaded') AS episode_downloaded,
(SELECT COALESCE(SUM(m.file_size), 0)::BIGINT FROM media_items m
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
AND m.download_state = 'downloaded') AS disk_bytes,
(SELECT COUNT(*) FROM podcast_subscriptions s
WHERE s.feed_id = f.id) AS subscriber_count,
(SELECT COALESCE(string_agg(u.username, ', ' ORDER BY u.username), '')::TEXT
FROM podcast_subscriptions s
JOIN users u ON u.id = s.user_id
WHERE s.feed_id = f.id) AS subscriber_names
FROM podcast_feeds f
JOIN libraries l ON l.id = f.library_id
ORDER BY 9 DESC, f.title ASC;
-- name: ResetPodcastEpisodeFileDeleted :exec
-- Admin file deletion (§0 item 8): ONE downloaded episode's disk file
-- was removed — reset the row to pending and clear the download-derived
-- facts. file_path is deliberately KEPT: it is the fetch-maintained
-- layout (written at fetch time, not download time), so a re-download
-- lands on the same path. The poll upsert never touches download_state,
-- so a deleted file stays deleted (invariant 2: polling never deletes;
-- admins delete knowingly).
UPDATE media_items
SET download_state = 'pending',
download_error = NULL,
download_attempts = 0,
file_size = NULL,
mime_type = NULL,
cover_image_path = NULL,
audio_metadata = NULL,
chapter_count = NULL,
updated_at = NOW()
WHERE id = $1 AND source = 'podcast_episode' AND download_state = 'downloaded';
-- name: ResetPodcastFeedFilesDeleted :execrows
-- Same reset for EVERY downloaded episode of a feed (per-feed
-- "Delete files" action on the admin view).
UPDATE media_items
SET download_state = 'pending',
download_error = NULL,
download_attempts = 0,
file_size = NULL,
mime_type = NULL,
cover_image_path = NULL,
audio_metadata = NULL,
chapter_count = NULL,
updated_at = NOW()
WHERE source_feed_id = $1 AND source = 'podcast_episode'
AND download_state = 'downloaded';
-- name: ListPodcastFeedsForPolling :many
-- Poller worklist (admin identity): every feed with its validators and
-- the back-off flag (is_complete feeds poll rarely, never disappear).
SELECT * FROM podcast_feeds ORDER BY last_fetched_at ASC NULLS FIRST;
-- name: UpdatePodcastFeedFetch :one
-- Post-fetch bookkeeping: validators for conditional GETs, the fetch
-- timestamp, and the feed's own last-build marker.
UPDATE podcast_feeds
SET etag = $2,
last_modified = $3,
last_fetched_at = NOW(),
last_build_date = $4,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: UpdatePodcastFeedMetadata :one
-- Fetcher-only full metadata refresh (parse result). podcast_guid is
-- set once a feed declares it; new_feed_url handling is a dedicated
-- query below.
UPDATE podcast_feeds
SET title = $2,
description = $3,
host = $4,
publisher = $5,
owner_name = $6,
owner_email = $7,
language = $8,
tags = $9,
explicit = $10,
feed_type = $11,
medium = $12,
is_complete = $13,
podcast_guid = COALESCE($14, podcast_guid),
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: UpdatePodcastFeedArtwork :exec
UPDATE podcast_feeds SET artwork_path = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdatePodcastFeedRedirect :exec
-- itunes:new-feed-url (or a permanent HTTP redirect chain): the feed has
-- MOVED. Rewrite feed_url so future polls hit the new location; the row
-- (and every subscription/episode) stays put.
UPDATE podcast_feeds
SET feed_url = $2,
updated_at = NOW()
WHERE id = $1;
-- name: GetPodcastFeedByGuid :one
-- URL-migration dedupe: same canonical guid within the library reuses
-- the existing row.
SELECT * FROM podcast_feeds
WHERE library_id = $1 AND podcast_guid = $2
LIMIT 1;
-- name: DeletePodcastFeed :execrows
-- Admin-only removal (handler checks role); episodes cascade via
-- media_items.source_feed_id ON DELETE CASCADE.
DELETE FROM podcast_feeds WHERE id = $1;
-- name: CountPodcastLibrariesForUser :one
-- Sidebar/page gate (D13): >=1 podcasts-type library the user can see
-- (COALESCE(is_visible,true) semantics — mirrors GetUserVisibleLibraries).
SELECT COUNT(*)
FROM libraries l
JOIN library_types lt ON lt.id = l.library_type_id AND lt.name = 'podcasts'
LEFT JOIN library_visibility lv ON lv.library_id = l.id AND lv.user_id = $1
WHERE COALESCE(lv.is_visible, true) = true;
-- name: GetPodcastsLibraryForUser :many
-- The podcasts library/ies visible to the user (add-feed target list;
-- a self-hosted instance typically has exactly one).
SELECT l.id, l.name
FROM libraries l
JOIN library_types lt ON lt.id = l.library_type_id AND lt.name = 'podcasts'
LEFT JOIN library_visibility lv ON lv.library_id = l.id AND lv.user_id = $1
WHERE COALESCE(lv.is_visible, true) = true
ORDER BY l.created_at ASC;
-- name: GetPodcastLibraryFolder :many
-- The download root (purpose='podcast') of a podcasts library.
SELECT lf.*
FROM library_folders lf
WHERE lf.library_id = $1 AND lf.purpose = 'podcast';
-- name: CreatePodcastLibraryFolder :one
INSERT INTO library_folders (library_id, folder_path, purpose)
VALUES ($1, $2, 'podcast')
RETURNING *;
-- ============================================================================
-- Podcast episodes — media_items rows with source='podcast_episode'
-- ============================================================================
-- name: UpsertPodcastEpisode :one
-- Fetcher-only upsert keyed on the partial unique index
-- (source_feed_id, episode_guid). DO UPDATE refreshes FETCH-OWNED
-- metadata only. Write-after-success guard (AGENTS.md gotcha #7):
-- download state/error/attempts are NEVER touched here, and the
-- downloaded file's facts (path/size/mime/cover) survive renumbering —
-- only un-downloaded rows follow the re-derived file_path.
-- authors_search mirrors authors (trigram search column).
INSERT INTO media_items (
library_id, title, subtitle, description, authors, authors_search,
file_path, file_size, mime_type, cover_image_path, explicit,
date_published, language, tags, genre,
format_group, format_mimetype, is_reflowable, library_type_name,
source, source_feed_id, episode_guid, feed_position,
season_number, episode_number, enclosure_url,
declared_duration_seconds, transcript_url, chapters_url,
hosts, guests
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13, $14, $15,
'audiobook', $9, FALSE, 'podcasts',
'podcast_episode', $16, $17, $18,
$19, $20, $21,
$22, $23, $24,
$25, $26
)
ON CONFLICT (source_feed_id, episode_guid) WHERE source_feed_id IS NOT NULL DO UPDATE SET
title = EXCLUDED.title,
subtitle = EXCLUDED.subtitle,
description = EXCLUDED.description,
authors = EXCLUDED.authors,
authors_search = EXCLUDED.authors_search,
explicit = EXCLUDED.explicit,
date_published = EXCLUDED.date_published,
language = EXCLUDED.language,
tags = EXCLUDED.tags,
genre = EXCLUDED.genre,
season_number = EXCLUDED.season_number,
episode_number = EXCLUDED.episode_number,
enclosure_url = EXCLUDED.enclosure_url,
declared_duration_seconds = EXCLUDED.declared_duration_seconds,
transcript_url = EXCLUDED.transcript_url,
chapters_url = EXCLUDED.chapters_url,
hosts = EXCLUDED.hosts,
guests = EXCLUDED.guests,
feed_position = EXCLUDED.feed_position,
cover_image_path = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.cover_image_path ELSE EXCLUDED.cover_image_path END,
file_path = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.file_path ELSE EXCLUDED.file_path END,
file_size = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.file_size ELSE EXCLUDED.file_size END,
mime_type = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.mime_type ELSE EXCLUDED.mime_type END,
updated_at = NOW()
RETURNING *;
-- name: ListPodcastEpisodes :many
-- Feed detail / sequence source. RLS scopes to subscriber-or-admin;
-- ordering: native position, then pub date, then guid (stable across
-- re-fetches for historical rows whose position went stale).
SELECT * FROM media_items
WHERE source_feed_id = $1 AND source = 'podcast_episode'
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST, episode_guid ASC;
-- name: GetPodcastEpisode :one
SELECT * FROM media_items
WHERE id = $1 AND source = 'podcast_episode';
-- name: SetPodcastEpisodeDownloadQueued :execrows
-- Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET
-- (§0 items 4/5) so claim order = enqueue order (the effective
-- listening order the enqueue loop applied). Attempts increment at
-- enqueue so a crashed worker still counts the try.
UPDATE media_items
SET download_state = 'queued',
download_queue_pos = nextval('podcast_queue_seq'),
download_error = NULL,
download_attempts = download_attempts + 1,
updated_at = NOW()
WHERE id = $1
AND source = 'podcast_episode'
AND download_state IN ('pending', 'failed');
-- SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
-- GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
-- concurrency; ClaimQueuedPodcastEpisode does both atomically.
-- name: MarkPodcastEpisodeDownloaded :one
-- SUCCESS-only write (invariant 7): the file is on disk and probed.
-- ffprobe facts (real duration via audio_metadata, precise size/mime)
-- reconcile the feed's declared values.
UPDATE media_items
SET download_state = 'downloaded',
download_error = NULL,
file_path = $2,
file_size = $3,
mime_type = $4,
cover_image_path = COALESCE($5, cover_image_path),
audio_metadata = $6,
chapter_count = $7,
updated_at = NOW()
WHERE id = $1 AND source = 'podcast_episode'
RETURNING *;
-- name: MarkPodcastEpisodeFailed :exec
-- Failure/rejection write: state + reason + whether a human already
-- intervened (manual_retry resets the auto-retry budget). Existing
-- metadata/file facts are never cleared.
UPDATE media_items
SET download_state = 'failed',
download_error = $2,
download_attempts = CASE WHEN $3::boolean THEN 0 ELSE download_attempts END,
updated_at = NOW()
WHERE id = $1 AND source = 'podcast_episode';
-- name: SetPodcastEpisodePending :exec
-- Manual retry (same endpoint as download): reset to pending so the
-- worker re-claims it; the reason text clears with the fresh attempt.
UPDATE media_items
SET download_state = 'pending',
download_error = NULL,
download_attempts = 0,
updated_at = NOW()
WHERE id = $1 AND source = 'podcast_episode';
-- name: ListUndownloadedEpisodes :many
-- Download All + policy union worklist.
SELECT * FROM media_items
WHERE source_feed_id = $1
AND source = 'podcast_episode'
AND download_state <> 'downloaded'
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST, episode_guid ASC;
-- name: CountUndownloadedEpisodes :one
SELECT COUNT(*) FROM media_items
WHERE source_feed_id = $1
AND source = 'podcast_episode'
AND download_state <> 'downloaded';
-- name: CountDownloadedEpisodes :one
SELECT COUNT(*) FROM media_items
WHERE source_feed_id = $1
AND source = 'podcast_episode'
AND download_state = 'downloaded';
-- name: SearchMyPodcastEpisodes :many
-- Subscription-scoped episode search (D14): title + description cover
-- un-downloaded AND historical rows (fetcher persists metadata-only).
-- RLS already restricts to the caller's subscriptions — the explicit
-- source filter keeps scan items out of podcast search.
SELECT * FROM media_items
WHERE source = 'podcast_episode'
AND (sqlc.arg('search_query')::text = '' OR title ILIKE '%' || sqlc.arg('search_query')::text || '%' OR description ILIKE '%' || sqlc.arg('search_query')::text || '%')
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST
LIMIT 50;
-- name: SearchMyPodcastFeeds :many
-- Feed-title AND subscription-display-name search (D14) + the facet
-- fields the podcasts page filters on.
SELECT f.*,
s.display_name,
s.auto_download,
s.auto_download_count,
s.created_at AS subscribed_at
FROM podcast_subscriptions s
JOIN podcast_feeds f ON f.id = s.feed_id
WHERE s.user_id = sqlc.arg('user_id')
AND (sqlc.arg('search_query')::text = '' OR f.title ILIKE '%' || sqlc.arg('search_query')::text || '%'
OR COALESCE(s.display_name, '') ILIKE '%' || sqlc.arg('search_query')::text || '%'
OR COALESCE(f.host, '') ILIKE '%' || sqlc.arg('search_query')::text || '%'
OR COALESCE(f.publisher, '') ILIKE '%' || sqlc.arg('search_query')::text || '%')
ORDER BY COALESCE(s.display_name, f.title) ASC;
-- name: ListQueuedPodcastEpisodes :many
-- Fair-share candidate scan (§0 item 4): the download pool claims in
-- TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the
-- enqueue loop's ordering IS the download order, item 5). The worker
-- groups these per feed and prefers the least-recently-served feed.
SELECT id, source_feed_id FROM media_items
WHERE source = 'podcast_episode' AND download_state = 'queued'
ORDER BY download_queue_pos ASC NULLS LAST;
-- name: ClaimQueuedPodcastEpisode :one
-- Atomic claim (§0 item 4): converts queued -> downloading in ONE
-- statement. Two claimants racing on the same row: exactly one UPDATE
-- matches download_state='queued'; the loser gets no rows (ErrNoRows).
-- This is the SKIP-LOCKED guarantee without a second round-trip.
UPDATE media_items
SET download_state = 'downloading', updated_at = NOW()
WHERE id = $1
AND source = 'podcast_episode'
AND download_state = 'queued'
RETURNING media_items.*;
-- name: CountQueuedEpisodes :one
SELECT COUNT(*) FROM media_items
WHERE source = 'podcast_episode' AND download_state IN ('queued', 'downloading');
-- name: CountPodcastEpisodesForFeed :one
SELECT COUNT(*) FROM media_items
WHERE source_feed_id = $1 AND source = 'podcast_episode';
-- ============================================================================
-- GENERIC reader_settings KV (podcast order override, plan D5: key
-- podcast_order:<feed_id>). The pre-existing reader_settings queries are
-- hard-keyed to 'reader_settings'; these two are the generic form.
-- ============================================================================
-- name: GetUserSettingByKey :one
SELECT setting_value FROM reader_settings
WHERE user_id = $1 AND setting_key = $2;
-- name: UpsertUserSettingByKey :exec
INSERT INTO reader_settings (user_id, setting_key, setting_value)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, setting_key)
DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW();
-- name: DeleteUserSettingByKey :exec
DELETE FROM reader_settings WHERE user_id = $1 AND setting_key = $2;
-- name: GetPodcastEpisodeProgress :many
-- Per-user progress for a feed's episodes (row badges + the Phase-2
-- continue fields). reading_progress is UNIQUE per (media_item, user).
SELECT rp.media_item_id,
rp.percentage,
rp.last_read_at
FROM reading_progress rp
JOIN media_items mi ON mi.id = rp.media_item_id
WHERE rp.user_id = $1
AND mi.source_feed_id = $2
AND mi.source = 'podcast_episode';
-- name: GetPodcastContinueForUser :many
-- Continue aggregation (plan 2.5): the caller's IN-PROGRESS episodes
-- (0 < pct < finishedThreshold) across ALL subscribed feeds, most
-- recent first. Go groups per feed — the first row per feed IS the
-- continue target.
SELECT mi.source_feed_id,
mi.id,
mi.title,
mi.feed_position,
rp.percentage,
rp.last_read_at
FROM reading_progress rp
JOIN media_items mi ON mi.id = rp.media_item_id
WHERE rp.user_id = $1
AND mi.source = 'podcast_episode'
AND rp.percentage > 0
AND rp.percentage < 0.995
ORDER BY rp.last_read_at DESC;
+16
View File
@@ -178,6 +178,16 @@ func verifyFunctions(ctx context.Context, db *pgxpool.Pool, expectedFunctions []
return nil
}
// schemaMigrationIdentity is the synthetic identity the schema
// migration transaction declares for RLS (plan appendix A11). The
// schema's dedup DO block SELECTs/DELETEs media_items, and with the
// visibility policies live (FORCE applies to the owner) that SELECT
// needs app.user_id set even in this maintenance tx. The identity has
// NO library_visibility rows, so the policy grants it full visibility
// — appropriate for the server's own schema pass, and unreachable by
// requests (this tx only ever runs at startup, on the raw pool).
const schemaMigrationIdentity = "00000000-0000-0000-0000-000000000001"
// executeSchema runs the entire schema.sql in a single transaction
func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
tx, err := db.Begin(ctx)
@@ -186,6 +196,12 @@ func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
}
defer tx.Rollback(ctx)
// Declare the migration identity BEFORE the file executes (A11):
// policy'd tables are queried by schema.sql itself on re-runs.
if _, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, schemaMigrationIdentity); err != nil {
return fmt.Errorf("schema execution failed (declare migration identity): %w", err)
}
_, err = tx.Exec(ctx, SchemaFile)
if err != nil {
log.Printf("HINT: Run manually to debug: psql -h localhost -U postgres -d bookhoard -f database/schema/schema.sql")
+163
View File
@@ -0,0 +1,163 @@
// ScopedDB wraps a pgx pool so every sqlc statement runs inside a
// transaction that declares the acting user via the LOCAL
// `app.user_id` GUC — the one thing the RLS policies
// (database/schema/schema.sql) need to filter media rows per user.
//
// Fail-loud lives in the ENGINE, not here: a context with no identity
// simply declares nothing, and any RLS-governed statement (media_items)
// then raises on the unset GUC — while ungoverned tables (auth lookups,
// settings, sessions) keep working, as they have no policy. That is the
// plan-C property: the database is the one door everything crosses; the
// decorator is only the identity declarer.
//
// SEMANTICS PRESERVED: every sqlc call was already a single
// auto-committed statement; one transaction per call keeps per-call
// atomicity identical. pgx v5 rows are bound to their transaction, so
// the transaction ends when the ROWS end: commit-on-Close for :many
// results, commit-on-Scan for :one results, plain commit for Exec.
//
// Rollout plan: ~/.opencode/plan/server-rls-library-visibility.md.
package database
import (
"context"
"fmt"
"log"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"bookhoard/internal/rls"
)
// Compile-pin: ScopedDB is a drop-in DBTX for database.New.
var _ DBTX = (*ScopedDB)(nil)
// ScopedDB implements DBTX over a pool, declaring the acting user for
// the RLS policies on every statement. Fallback (test harness ONLY —
// see NewSeedDB) supplies an identity when the context carries none.
type ScopedDB struct {
Pool *pgxpool.Pool
Fallback *pgtype.UUID
}
// NewScopedDB is the constructor used at the single Queries
// construction site (cmd/server/main.go).
func NewScopedDB(pool *pgxpool.Pool) *ScopedDB {
return &ScopedDB{Pool: pool}
}
// NewSeedDB returns a ScopedDB for TEST-HARNESS seeding/verification:
// when the context carries no identity it falls back to
// rls.SeedIdentity, so direct media reads/writes from helpers work
// without impersonating a real user. Never use in product wiring.
func NewSeedDB(pool *pgxpool.Pool) *ScopedDB {
seed := rls.SeedIdentity
return &ScopedDB{Pool: pool, Fallback: &seed}
}
const zeroUUID = "00000000-0000-0000-0000-000000000000"
// begin opens the per-statement transaction. If the context carries a
// user identity, the GUC is declared (LOCAL); if not, NOTHING is
// declared and the ENGINE decides: RLS-governed tables (media_items)
// raise on the unset GUC — fail-loud at the database, the layer plan C
// exists to enforce at — while RLS-ungoverned tables (users, settings,
// sessions, auth lookups pre-bridge) simply work, as they have no
// policy to evaluate. The decorator therefore never refuses: refusing
// here would re-create a convention gate in code, one more door future
// paths could forget — the engine is the one door everything crosses.
func (s *ScopedDB) begin(ctx context.Context) (pgx.Tx, error) {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return nil, err
}
uid, ok := rls.FromContext(ctx)
if (!ok || !uid.Valid || uid.String() == zeroUUID) && s.Fallback != nil {
uid, ok = *s.Fallback, true
}
if ok && uid.Valid && uid.String() != zeroUUID {
if err := rls.DeclareUser(ctx, tx, uid); err != nil {
_ = tx.Rollback(ctx)
return nil, fmt.Errorf("rls: declare user: %w", err)
}
}
return tx, nil
}
func (s *ScopedDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
tx, err := s.begin(ctx)
if err != nil {
return pgconn.CommandTag{}, err
}
// Rollback after a successful Commit is a no-op (ErrTxClosed ignored):
// this defer only acts on the error paths.
defer func() { _ = tx.Rollback(ctx) }()
tag, err := tx.Exec(ctx, sql, args...)
if err != nil {
return pgconn.CommandTag{}, err
}
return tag, tx.Commit(ctx)
}
func (s *ScopedDB) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
tx, err := s.begin(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, sql, args...)
if err != nil {
_ = tx.Rollback(ctx)
return nil, err
}
return &scopedRows{Rows: rows, tx: tx, ctx: ctx}, nil
}
// scopedRows commits the transaction when the rows close — committing
// earlier would kill the rows mid-read. sqlc's :many path fully
// consumes rows then Close()s. pgx v5 Rows.Close returns nothing, so a
// commit error is logged, not propagated (a commit failure on a
// read-only transaction is log-worthy only).
type scopedRows struct {
pgx.Rows
tx pgx.Tx
ctx context.Context
}
func (r *scopedRows) Close() {
r.Rows.Close()
if err := r.tx.Commit(r.ctx); err != nil {
log.Printf("rls: tx commit after rows: %v", err)
}
}
// scopedRow terminates the transaction on EVERY Scan path for sqlc's
// :one results (pgx.Row is just { Scan(dest ...) error }).
type scopedRow struct {
row pgx.Row
tx pgx.Tx
ctx context.Context
}
func (r scopedRow) Scan(dest ...any) error {
err := r.row.Scan(dest...)
if err == nil {
return r.tx.Commit(r.ctx)
}
_ = r.tx.Rollback(r.ctx) // best effort; the tx ends either way
return err
}
type errRow struct{ err error }
func (r errRow) Scan(_ ...any) error { return r.err }
func (s *ScopedDB) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
tx, err := s.begin(ctx)
if err != nil {
return errRow{err}
}
return scopedRow{row: tx.QueryRow(ctx, sql, args...), tx: tx, ctx: ctx}
}
+42
View File
@@ -25,6 +25,7 @@ import (
// SettingType enumerates the value types stored in system_settings.setting_type.
const (
SettingTypeInt = "int"
SettingTypeFloat = "float"
SettingTypeBool = "bool"
SettingTypeString = "string"
SettingTypeStringList = "string_list"
@@ -82,6 +83,9 @@ var SettingDefaults = []SettingDefault{
{Key: "sync_queue_batch_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "Maximum items processed per sync queue flush"},
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
{Key: "podcast_poll_interval_seconds", Value: "3600", Type: SettingTypeInt, Min: "300", Max: "604800", Category: "podcasts", Group: "Podcasts", Description: "How often to poll podcast feeds for new episodes (seconds; complete feeds back off)"},
{Key: "podcast_download_concurrency", Value: "4", Type: SettingTypeInt, Min: "1", Max: "16", Category: "podcasts", Group: "Podcasts", Description: "Podcast episode downloads in flight server-wide (the connection is the limit; fair-share spreads them across feeds)"},
{Key: "podcast_download_speed_mbps", Value: "0", Type: SettingTypeFloat, Min: "0", Max: "10000", Category: "podcasts", Group: "Podcasts", Description: "Shared bandwidth cap for podcast downloads in megabits/second (0 = unlimited)"},
}
// defaultBy indexes SettingDefaults by key for O(1) lookup.
@@ -172,6 +176,26 @@ func (r *SettingsRegistry) getInt(key string) int {
return n
}
func (r *SettingsRegistry) getFloat(key string) float64 {
d := defaultBy[key]
v := r.raw(key)
f, err := strconv.ParseFloat(v, 64)
if err != nil {
f, _ = strconv.ParseFloat(d.Value, 64)
}
if d.Min != "" {
if mn, err := strconv.ParseFloat(d.Min, 64); err == nil && f < mn {
f = mn
}
}
if d.Max != "" {
if mx, err := strconv.ParseFloat(d.Max, 64); err == nil && f > mx {
f = mx
}
}
return f
}
func (r *SettingsRegistry) getBool(key string) bool {
v := r.raw(key)
b, err := strconv.ParseBool(v)
@@ -188,6 +212,24 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
}
// PodcastPollInterval is how often the poller checks feeds, as a
// duration (live setting; the poller re-reads it every cycle — writes
// go through the settings API path, AGENTS.md gotcha #2).
func (r *SettingsRegistry) PodcastPollInterval() time.Duration {
return time.Duration(r.getInt("podcast_poll_interval_seconds")) * time.Second
}
// PodcastDownloadConcurrency: episode downloads in flight server-wide
// (the connection is the limit; fair-share spreads them across feeds).
func (r *SettingsRegistry) PodcastDownloadConcurrency() int {
return r.getInt("podcast_download_concurrency")
}
// PodcastDownloadSpeedMbps: shared bandwidth cap (0 = unlimited).
func (r *SettingsRegistry) PodcastDownloadSpeedMbps() float64 {
return r.getFloat("podcast_download_speed_mbps")
}
// AutoScanEnabled reports whether auto-scanning is on.
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
@@ -36,6 +36,21 @@ func TestSettingDefaults(t *testing.T) {
if _, err := strconv.ParseBool(d.Value); err != nil {
t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value)
}
case SettingTypeFloat:
f, err := strconv.ParseFloat(d.Value, 64)
if err != nil {
t.Errorf("float setting %s default %q is not a float", d.Key, d.Value)
}
if d.Min != "" {
if mn, err := strconv.ParseFloat(d.Min, 64); err == nil && f < mn {
t.Errorf("float setting %s default %q below min %q", d.Key, d.Value, d.Min)
}
}
if d.Max != "" {
if mx, err := strconv.ParseFloat(d.Max, 64); err == nil && f > mx {
t.Errorf("float setting %s default %q above max %q", d.Key, d.Value, d.Max)
}
}
case SettingTypeString:
if d.Value == "" {
t.Errorf("string setting %s has empty default", d.Key)
+31 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"bookhoard/templates"
@@ -24,7 +25,23 @@ type DocsHandler struct {
docsFS fs.FS
}
// resolveDocsPath — the docs folder lives at the repo root, but `go test`
// runs test binaries from the PACKAGE directory (/cmd/server/tests), so a
// plain relative "docs" resolves to a folder that doesn't exist there and
// every /docs route 404s in tests (2026-11-01). Walk upward, bounded,
// until the folder is real; prod (/app) matches on the first try.
func resolveDocsPath(p string) string {
for i := 0; i < 6; i++ {
if fi, err := os.Stat(filepath.Join(p, "index.md")); err == nil && !fi.IsDir() {
return p
}
p = filepath.Join("..", p)
}
return "docs"
}
func NewDocsHandler(docsPath string) *DocsHandler {
docsPath = resolveDocsPath(docsPath)
// Create goldmark renderer with syntax highlighting
md := goldmark.New(
goldmark.WithExtensions(
@@ -74,11 +91,24 @@ func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error)
}
}
// Read the markdown file
// Read the markdown file. Folder URLs resolve to their index page:
// /docs/user/filters → user/filters/index.md (2026-11-01 — the
// filter manual is a folder: guide + cookbook + generated field
// reference).
content, err := fs.ReadFile(h.docsFS, docPath)
if err != nil {
if !strings.HasSuffix(docPath, "index.md") {
alt := strings.TrimSuffix(docPath, ".md") + "/index.md"
if altContent, altErr := fs.ReadFile(h.docsFS, alt); altErr == nil {
docPath = alt
content = altContent
} else {
return nil, fmt.Errorf("failed to read document: %w", err)
}
} else {
return nil, fmt.Errorf("failed to read document: %w", err)
}
}
// Convert markdown to HTML
var buf bytes.Buffer
+15 -2
View File
@@ -321,10 +321,23 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
return nav
}
// containsURL reports whether currentPath is one of the given doc URLs.
// normalizeDocURL — folder-index pages SERVE at their directory URL
// (/docs/user/filters) while their nav entry carries the /index suffix
// (/docs/user/filters/index): same page, two spellings. Both sidebar
// checks (expansion + highlight) compare normalized forms, so folder
// pages expand and highlight like file pages — no per-page special
// cases (2026-11-01, the filter manual's folder).
func normalizeDocURL(u string) string {
u = strings.TrimRight(u, "/")
return strings.TrimSuffix(u, "/index")
}
// containsURL reports whether currentPath is one of the given doc URLs
// (folder-index URLs normalized — see normalizeDocURL).
func containsURL(currentPath string, urls []string) bool {
current := normalizeDocURL(currentPath)
for _, u := range urls {
if currentPath == u {
if current == normalizeDocURL(u) {
return true
}
}
+40
View File
@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"bookhoard/templates"
)
func TestPlaceDoc(t *testing.T) {
@@ -205,3 +207,41 @@ func TestBuildNavigationNothingCurrent(t *testing.T) {
t.Errorf("expected zero sections open when nothing matches (docs home / 404 renders fully closed), got %d", open)
}
}
// TestBuildNavigationFolderIndex (2026-11-01): folder-index pages serve
// at their directory URL (/docs/user/filters) while their nav entries
// carry /index (/docs/user/filters/index) — the sidebar must EXPAND the
// containing section/group and HIGHLIGHT the index item for the folder
// URL, exactly like a file page. The filter manual's folder is the
// first page of this shape.
func TestBuildNavigationFolderIndex(t *testing.T) {
// The REAL docs tree: the Filters group only exists because the
// filter manual (guide/cookbook/fields) lives in user/filters/.
handler := NewDocsHandler("../../docs")
nav := handler.BuildNavigation("/docs/user/filters")
var userGuide *templates.NavSection
for i := range nav.Sections {
if nav.Sections[i].Title == "User Guide" {
userGuide = &nav.Sections[i]
}
}
if userGuide == nil {
t.Fatal("User Guide section missing")
}
if userGuide.Collapsed {
t.Error("User Guide section must expand for /docs/user/filters")
}
var filters *templates.NavGroup
for i := range userGuide.Groups {
if userGuide.Groups[i].Title == "Filters" {
filters = &userGuide.Groups[i]
}
}
if filters == nil {
t.Fatal("Filters group missing from User Guide")
}
if filters.Collapsed {
t.Error("Filters group must expand for its folder URL")
}
}
+8 -25
View File
@@ -49,7 +49,7 @@ var Schemas = map[string]Schema{
Response: json.RawMessage(`{
"id": "", "user_id": "", "name": "", "description": "",
"color": "", "icon": "", "collection_type": "",
"auto_assign_rules": null, "view_settings": {},
"view_settings": {},
"book_count": 0, "created_at": ""
}`),
},
@@ -62,7 +62,7 @@ var Schemas = map[string]Schema{
"GET /api/collections/:id": {
Response: json.RawMessage(`{
"id": "", "user_id": "", "name": "", "description": "",
"color": "", "icon": "", "auto_assign_rules": null,
"color": "", "icon": "",
"view_settings": {}, "books": [], "book_count": 0,
"is_smart": false, "up_next": [], "created_at": ""
}`),
@@ -71,7 +71,7 @@ var Schemas = map[string]Schema{
Request: handlers.UpdateCollectionRequest{},
Response: json.RawMessage(`{
"id": "", "user_id": "", "name": "", "description": "",
"color": "", "icon": "", "auto_assign_rules": null,
"color": "", "icon": "",
"view_settings": {}, "created_at": ""
}`),
},
@@ -601,15 +601,14 @@ var Schemas = map[string]Schema{
Request: json.RawMessage(`{"item_ids": []}`),
Response: json.RawMessage(`{"status": "reordered"}`),
},
"PUT /api/collections/:id/rules/:ruleId": {
// Handler is SetRuleEnabled: toggle only, request {enabled required},
// response {collection_id, is_smart}.
Request: json.RawMessage(`{"enabled": false}`),
"PUT /api/collections/:id/view-settings": {
// Merges chain_order into the collection's view_settings JSONB
// (playlist toggles share the doc and are preserved).
Request: json.RawMessage(`{"chain_order": "newest"}`),
Response: json.RawMessage(`{
"collection_id": "", "is_smart": false
"id": "", "chain_order": "", "view_settings": {}
}`),
},
// --- Beam batch 7: scanner ---
"POST /api/scanner/scan": {
Request: handlers.ScanLibraryRequest{},
@@ -835,22 +834,6 @@ var Schemas = map[string]Schema{
"DELETE /api/devices/:id/file-aliases/:aliasId": {
Response: json.RawMessage(`{"message": ""}`),
},
"POST /api/collections/:id/rules": {
// First rule converts a static collection to smart.
Request: handlers.AddRuleRequest{},
Response: json.RawMessage(`{
"collection_id": "", "rule": {}, "is_smart": false
}`),
},
"DELETE /api/collections/:id/rules/:ruleId": {
Response: json.RawMessage(`{
"collection_id": "", "is_smart": false
}`),
},
"POST /api/collections/test-rules": {
Request: handlers.TestRulesRequest{},
Response: json.RawMessage(`{"matches": [], "total": 0}`),
},
"POST /api/collections/:id/books": {
// Bulk add: {operations: [{collection_id, book_ids:[...]}]}.
Request: json.RawMessage(`{
+3 -4
View File
@@ -3,7 +3,6 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"errors"
"fmt"
"net/http"
@@ -102,7 +101,7 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
// "today") never appear.
endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond)
ctx := context.Background()
ctx := c.Request().Context()
history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{
UserID: user.ID,
@@ -220,7 +219,7 @@ func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserRead
func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
user := c.Get("user").(database.Users)
ctx := context.Background()
ctx := c.Request().Context()
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
@@ -268,7 +267,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
limitInt = parsedLimit
}
ctx := context.Background()
ctx := c.Request().Context()
books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{
UserID: user.ID,
+35
View File
@@ -6,6 +6,7 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/middleware"
"bookhoard/internal/rls"
"bookhoard/internal/setupstatus"
"context"
"errors"
@@ -15,6 +16,7 @@ import (
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
@@ -125,6 +127,10 @@ type UserProfile struct {
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
// Whether the web reader/player should hold a Screen Wake Lock.
KeepScreenAwake bool `json:"keep_screen_awake"`
// Resume rewind: seconds before the saved position the audio
// player starts (open-resume and every genuine unpause). 0 is a
// real value (exact resume) — never omitted.
AudioResumeRewind int `json:"audio_resume_rewind_seconds"`
}
type ProfileUpdateRequest struct {
@@ -141,6 +147,11 @@ type ProfileUpdateRequest struct {
// Per the SPEEDS list bounds; the profile select always sends it,
// JSON clients omit it (0 = untouched).
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
// Resume rewind seconds, as the form select sends it ("0"/"5"/"10").
// The string shape (keep_screen_awake precedent) lets the form
// select an explicit 0=Off while JSON clients that omit it decode
// as "" and leave the column untouched.
AudioResumeRewind string `json:"audio_resume_rewind_seconds,omitempty" form:"audio_resume_rewind_seconds" validate:"omitempty,oneof=0 5 10"`
}
// Register handles POST /api/auth/register
@@ -550,6 +561,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
Theme: user.Theme.String,
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
KeepScreenAwake: user.KeepScreenAwake,
AudioResumeRewind: int(user.AudioResumeRewindSeconds),
})
}
@@ -714,6 +726,24 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
}
}
// Resume-rewind preference. The form select always sends it
// ("0"/"5"/"10"); JSON clients that omit it decode as "" and leave
// the column untouched. Strict allow-list — the three choices the
// UI offers are the only values the API accepts.
if req.AudioResumeRewind != "" {
rewind, err := strconv.Atoi(req.AudioResumeRewind)
if err != nil || (rewind != 0 && rewind != 5 && rewind != 10) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "audio_resume_rewind_seconds must be 0, 5, or 10"})
}
err = h.db.UpdateUserAudioResumeRewind(c.Request().Context(), database.UpdateUserAudioResumeRewindParams{
ID: targetUserUUID,
AudioResumeRewindSeconds: int32(rewind),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Keep-screen-awake preference. The form dropdown always sends it
// ("true"/"false"); JSON clients that omit it decode as "" and leave
// the column untouched.
@@ -1145,6 +1175,11 @@ func (h *AuthHandler) generateJWT(userID string) (string, error) {
}
func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userID pgtype.UUID) error {
// Registration runs BEFORE the JWT exists — the request context
// carries no identity, and the collections RLS insert policy
// (2026-10-31) would reject the five system-collection seeds.
// Declare the new user as the actor: owner == actor, policy passes.
ctx = rls.WithUser(ctx, userID)
defaultCollections := []struct {
Name string
Description string
+49 -2
View File
@@ -43,6 +43,21 @@ type BookInfo struct {
// it empty rather than pay a join).
ViewerPercentage *float64 `json:"viewer_percentage"`
LibraryName string `json:"library_name"`
// Episode-collections extras (plan item 2.3): populated only by the
// collection-detail adapter. IsEpisode renders the episode-row
// variant in the grid; FeedTitle disambiguates same-named episodes;
// DownloadState gates the play affordance. Hidden=true means the
// LEFT JOIN missed under RLS (unsubscribed episode, or a
// family-lens hidden book) — the row renders as an "unavailable"
// hint instead of silently vanishing. Adapters that don't load
// visibility leave Hidden false (cards stay fully rendered). The
// four keys are NOT omitempty: every BookInfo surface emits the
// same stable key set (the viewer_percentage precedent —
// contract-pin widening, docs/contract_pins.md 2026-10-30).
IsEpisode bool `json:"is_episode"`
FeedTitle string `json:"feed_title"`
DownloadState string `json:"download_state"`
Hidden bool `json:"hidden"`
}
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
@@ -151,8 +166,7 @@ func BookCardsFromCollectionDashRows(rows []database.GetCollectionItemsForDashbo
ID: uuid.UUID(mi.ID.Bytes),
Title: mi.Title,
Author: utils.PgTextString(mi.Author),
LibraryID: mi.LibraryID,
CoverPath: mi.CoverImagePath,
LibraryID: mi.LibraryID, CoverPath: mi.CoverImagePath,
FormatGroup: mi.FormatGroup,
LibraryType: libTypes[uuid.UUID(mi.LibraryID.Bytes).String()],
}))
@@ -180,6 +194,39 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
return cards
}
// BookCardsFromCollectionDetailed builds cards from the
// GetCollectionItemsDetailed rows (plan item 2.3): the collection's
// full membership with per-row visibility. RLS-invisible rows
// (unsubscribed episode, family-lens hidden book) become Hidden cards
// — the grid renders them as "unavailable" hint rows; episode rows
// carry feed title + download state for the play affordance. Unknown
// library types resolve no badge; hidden rows need none anyway.
func BookCardsFromCollectionDetailed(rows []database.GetCollectionItemsDetailedRow, libTypes map[string]string) []BookInfo {
cards := make([]BookInfo, 0, len(rows))
for _, r := range rows {
if !r.Visible {
cards = append(cards, BookInfo{
MediaItemID: uuid.UUID(r.MediaItemID.Bytes).String(),
Hidden: true,
})
continue
}
isEpisode := r.Source.String == "podcast_episode"
card := buildBookCard(cardSource{
ID: uuid.UUID(r.MediaItemID.Bytes),
Title: utils.PgTextString(r.Title),
Author: utils.PgTextString(r.Author),
CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup.String,
})
card.IsEpisode = isEpisode
card.FeedTitle = r.FeedTitle.String
card.DownloadState = r.DownloadState.String
cards = append(cards, card)
}
return cards
}
// BookCardsFromUnified builds cards from search-unified rows. Those
// rows carry the library type name as a SQL column, so this adapter
// needs no LibraryTypeMap query at all — and the viewer's own
+314 -288
View File
@@ -9,7 +9,6 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
@@ -26,10 +25,10 @@ type CollectionHandler struct {
connManager *wsync.ConnectionManager
}
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager) *CollectionHandler {
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager, search ...*services.SearchService) *CollectionHandler {
return &CollectionHandler{
db: db,
collectionService: services.NewCollectionService(db),
collectionService: services.NewCollectionService(db, search...),
libraryService: libraryService,
connManager: connManager,
}
@@ -40,17 +39,113 @@ type CreateCollectionRequest struct {
Description string `form:"description" json:"description"`
Color string `form:"color" json:"color"`
Icon string `form:"icon" json:"icon"`
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based)
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart"
// Custom-section builder payload (adapted 2026-10-31): the page
// posts the legacy rules vocabulary — rules as a JSON-encoded array
// (a string), a match_type select, hand-picked book ids, and the
// dashboard pin. The server converts rules → filter tree at write
// time and attaches the manual picks; storage stays tree-only.
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
MatchType string `json:"match_type"`
ManualBookIDs []string `json:"manual_book_ids"`
LibraryID string `json:"library_id"` // selection context only — not stored
ShowOnDashboard bool `json:"show_on_dashboard"`
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
// The unified filter tree (item 3.2): when present the collection's
// smart membership runs through the qf engine. INVALID trees are a
// 400 (ParseFilterTree at the handler); a tree supersedes
// auto_assign_rules. Persisted as-is (any). RawMessage for
// round-trip safety. JSON bodies bind FilterTree; FORM bodies
// (the create modal's urlencoded hx-post) bind FilterTreeForm —
// echo's binder cannot set an any-typed field from a form string
// (the create-modal seed POST 400'd "invalid request" on it,
// 2026-10-31).
FilterTree any `json:"filter_tree"`
// FilterTreeForm mirrors filter_tree for application/x-www-form-
// urlencoded submissions: the raw JSON as a string, validated by
// the same parseRequestFilterTree path.
FilterTreeForm string `form:"filter_tree"`
}
// parseBuilderRules decodes the custom-section builder's rules payload:
// the page posts JSON.stringify(rules) — a JSON string CONTAINING the
// array (it has done so since the page was written, 2026-02-19) — while
// JSON clients may post the array directly. Null/empty = no rules.
func parseBuilderRules(raw json.RawMessage) ([]services.Rule, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var rules []services.Rule
if raw[0] == '"' {
var encoded string
if err := json.Unmarshal(raw, &encoded); err != nil {
return nil, err
}
if strings.TrimSpace(encoded) == "" {
return nil, nil
}
return rules, json.Unmarshal([]byte(encoded), &rules)
}
return rules, json.Unmarshal(raw, &rules)
}
// treeFilterJSON returns the request's filter tree as JSON storage
// bytes (nil when none): object payloads marshal; strings (a legacy
// double-encoded client, the custom-section builder's known bug class)
// are written verbatim after object-attempt.
func treeFilterJSON(payload any) ([]byte, error) {
switch v := payload.(type) {
case nil:
return nil, nil
case map[string]interface{}:
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
return b, nil
case string:
return []byte(v), nil
default:
return nil, fmt.Errorf("filter_tree must be an object")
}
}
// parseRequestFilterTree validates the request's tree against the
// engine grammar (ParseFilterTree) so an invalid tree is a 400, never
// a stored 500 at render time.
func parseRequestFilterTree(payload any) ([]byte, error) {
b, err := treeFilterJSON(payload)
if err != nil || b == nil {
return b, err
}
if _, err := services.ParseFilterTree(string(b)); err != nil {
return nil, fmt.Errorf("invalid filter_tree: %w", err)
}
return b, nil
}
// resolveTreePayload — the tree payload from either transport: JSON
// bodies carry the object in FilterTree, form bodies (the create
// modal's urlencoded hx-post) the raw JSON string in FilterTreeForm.
func resolveTreePayload(jsonTree any, formTree string) any {
if formTree != "" {
return formTree
}
return jsonTree
}
type UpdateCollectionRequest struct {
Name string `form:"name" json:"name" validate:"required"`
// The unified filter tree (item 3.2): when present the collection's
// smart membership runs through the qf engine. INVALID trees are a
// 400 (ParseFilterTree at the handler). JSON bodies bind FilterTree; form bodies bind
// FilterTreeForm (echo's binder cannot set an any-typed field from
// a form string — see CreateCollectionRequest).
FilterTree any `json:"filter_tree"`
FilterTreeForm string `form:"filter_tree"`
Description string `form:"description" json:"description"`
Color string `form:"color" json:"color"`
Icon string `form:"icon" json:"icon"`
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
// A2c §3.3: playlist toggles — merged into view_settings server-side
// (nil = untouched). []string because the form posts a hidden "false"
@@ -94,7 +189,6 @@ type CollectionSummary struct {
Description string `json:"description"`
Color string `json:"color"`
Icon string `json:"icon"`
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
CreatedAt string `json:"created_at"`
BookCount int `json:"book_count"`
IsSmart bool `json:"is_smart"`
@@ -138,6 +232,55 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Custom-section builder adapter: convert the posted rules to a
// filter tree at WRITE time (the save-fix — the page's payload had
// never once bound successfully before this).
builderRules, rulesErr := parseBuilderRules(req.AutoAssignRules)
if rulesErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "auto_assign_rules: " + rulesErr.Error()})
}
matchMode := req.MatchType
if matchMode != services.RuleMatchAll && matchMode != services.RuleMatchAny {
matchMode = services.RuleMatchAny
}
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
if treeErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
}
if treeJSON == nil && len(builderRules) > 0 {
tree, cerr := services.RulesToFilterTree(builderRules, matchMode)
if cerr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + cerr.Error()})
}
b, merr := json.Marshal(tree)
if merr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": merr.Error()})
}
// The strict-parse guarantee holds for converted trees too: an
// invalid tree is a 400 at write, never a stored 500 at render.
if _, verr := services.ParseFilterTree(string(b)); verr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + verr.Error()})
}
treeJSON = b
}
// New podcast smart collections are BORN with the reversed listening
// default (2026-11-01 user request): seed chain_order into
// view_settings at creation — the dropdown shows "Feed order
// (reversed)" selected and the chain/page machinery reads the stored
// value exactly as if picked. Episodes-scope trees only (books have
// no episode chain); merge semantics never clobber a value the
// request itself sent; existing collections are untouched.
if treeJSON != nil {
if t, perr := services.ParseFilterTree(string(treeJSON)); perr == nil &&
t.FilterScope() == services.ScopeEpisodes {
if req.ViewSettings == nil {
req.ViewSettings = map[string]interface{}{}
}
if _, exists := req.ViewSettings["chain_order"]; !exists {
req.ViewSettings["chain_order"] = "reverse"
}
}
}
collection, err := h.collectionService.CreateCollection(
c.Request().Context(),
userUUID,
@@ -145,8 +288,9 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
req.Description,
req.Color,
req.Icon,
req.AutoAssignRules,
req.ViewSettings,
req.ShowOnDashboard,
treeJSON,
)
if err != nil {
if errors.Is(err, services.ErrCollectionNameTaken) {
@@ -155,8 +299,19 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Manual picks: a section = rules + hand-picked books. Attach after
// create; unparsable ids are skipped (same leniency as AddBooks).
for _, bookIDStr := range req.ManualBookIDs {
bookID, perr := uuid.Parse(bookIDStr)
if perr != nil {
continue
}
_ = h.collectionService.AddBookToCollection(c.Request().Context(),
uuid.UUID(collection.ID.Bytes), bookID, userUUID)
}
bookCount := int32(0)
isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0
isSmart := req.CollectionType == "smart" || treeJSON != nil
response := map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
@@ -165,7 +320,6 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
"color": utils.PgTextString(collection.Color),
"icon": utils.PgTextString(collection.Icon),
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
"auto_assign_rules": collection.AutoAssignRules,
"view_settings": collection.ViewSettings,
"book_count": bookCount,
"created_at": collection.CreatedAt.Time.String(),
@@ -197,7 +351,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
response := make([]CollectionSummary, 0, len(collections))
for _, col := range collections {
isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules)
isSmart := services.IsCollectionSmart(col)
if !includeAuto && isSmart {
// Legacy default for API clients (the Android app pre-dates
// smart collections): rule-driven collections stay opt-in so
@@ -213,12 +367,12 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
}
}
if isSmart {
rules := h.collectionService.ParseRules(col.AutoAssignRules)
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
if visErr == nil {
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), col, services.SmartEvalOptions{
LibraryID: libUUID,
VisibleIDs: visibleIDs,
ViewerUserID: MustGetAuthenticatedUser(c).ID,
})
if evalErr == nil {
bookCount = len(matches)
@@ -240,7 +394,6 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
Description: utils.PgTextString(col.Description),
Color: utils.PgTextString(col.Color),
Icon: utils.PgTextString(col.Icon),
AutoAssignRules: col.AutoAssignRules,
CreatedAt: col.CreatedAt.Time.String(),
BookCount: bookCount,
IsSmart: isSmart,
@@ -286,17 +439,17 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
isSmart := h.collectionService.IsSmartCollection(collection.AutoAssignRules)
isSmart := services.IsCollectionSmart(collection)
libTypes := LibraryTypeMap(c.Request().Context(), h.db)
if isSmart {
rules := h.collectionService.ParseRules(collection.AutoAssignRules)
visibleIDs, err := h.userVisibleLibraryIDs(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), collection, services.SmartEvalOptions{
LibraryID: libUUID,
VisibleIDs: visibleIDs,
ViewerUserID: MustGetAuthenticatedUser(c).ID,
})
if evalErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
@@ -305,7 +458,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
dashboardSvc := services.NewDashboardService(h.db)
dashboardSvc := services.NewDashboardService(h.db, h.collectionService.Search())
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if secErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
@@ -409,8 +562,8 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
"description": utils.PgTextString(collection.Description),
"color": utils.PgTextString(collection.Color),
"icon": utils.PgTextString(collection.Icon),
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": viewSettings,
"filter_tree": treeEnvelope(collection.FilterTree),
"books": bookList,
"book_count": len(bookList),
"is_smart": isSmart,
@@ -450,6 +603,31 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
req.ViewSettings["show_up_next"] = *v
}
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
if treeErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
}
// Applying a tree to a collection with NO curated listening order
// seeds the reversed default (2026-11-01 — the creation seed's twin):
// a static collection becoming smart lands with the same default a
// born-smart one gets. Triple-guarded: the client's explicit
// chain_order wins, a STORED chain_order is never clobbered, and
// only episodes-scope trees seed (books have no episode chain).
if treeJSON != nil && (req.ViewSettings == nil || req.ViewSettings["chain_order"] == nil) {
if t, perr := services.ParseFilterTree(string(treeJSON)); perr == nil &&
t.FilterScope() == services.ScopeEpisodes {
if existing, gerr := h.collectionService.GetCollection(c.Request().Context(), collectionID); gerr == nil {
var stored map[string]interface{}
_ = json.Unmarshal(existing.ViewSettings, &stored)
if stored == nil || stored["chain_order"] == nil {
if req.ViewSettings == nil {
req.ViewSettings = map[string]interface{}{}
}
req.ViewSettings["chain_order"] = "reverse"
}
}
}
}
collection, err := h.collectionService.UpdateCollection(
c.Request().Context(),
collectionID,
@@ -457,10 +635,16 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
req.Description,
req.Color,
req.Icon,
req.AutoAssignRules,
req.ViewSettings,
treeJSON,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Collections RLS: another user's collection is INVISIBLE —
// the update touched no rows. Answer 404, not a 500 that
// reads like a bug (2026-10-31 collections RLS).
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
@@ -471,7 +655,6 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
"description": utils.PgTextString(collection.Description),
"color": utils.PgTextString(collection.Color),
"icon": utils.PgTextString(collection.Icon),
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": json.RawMessage(collection.ViewSettings),
"created_at": collection.CreatedAt.Time.String(),
}
@@ -484,6 +667,99 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
return c.JSON(http.StatusCreated, response)
}
// ValidChainOrders — the collection chain's listening-order vocabulary
// (plan §10 R2: a collection's playback order is CURATED on the
// collection, not a player-side knob). Mirrors OrderEpisodes' orders;
// "" means native (the pre-chain-order default).
var ValidChainOrders = map[string]bool{"": true, "native": true, "newest": true, "oldest": true, "reverse": true}
// CollectionChainOrder resolves a collection row's curated chain order
// from its view_settings JSONB — "" (native) when absent/unknown. The
// episode chain producer routes OrderEpisodes through this; the
// view-settings PUT writes the key (SetCollectionChainOrder).
func CollectionChainOrder(viewSettings []byte) string {
if len(viewSettings) == 0 {
return ""
}
var vs map[string]interface{}
if json.Unmarshal(viewSettings, &vs) != nil {
return ""
}
if s, ok := vs["chain_order"].(string); ok && ValidChainOrders[s] {
return s
}
return ""
}
// SetCollectionChainOrder — PUT /api/collections/:id/view-settings with
// {"chain_order": "..."}: merges the key into the collection's stored
// view_settings (the playlist toggles share that JSONB — a blind write
// would clobber them). The episode chain producer
// (buildSmartCollectionSequenceContext) honors it.
func (h *CollectionHandler) SetCollectionChainOrder(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
var req struct {
ChainOrder string `json:"chain_order"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if !ValidChainOrders[req.ChainOrder] {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "chain_order must be one of: native, newest, oldest, reverse"})
}
collection, err := h.collectionService.GetCollection(c.Request().Context(), collectionID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
// Same owner gate as the sibling config mutation (ReorderItems) —
// my endpoint writes the row, so it must not let a non-owner flip
// someone else's chain order.
if u, ok := c.Get("user").(database.Users); ok {
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(u.ID.Bytes))
if err != nil || !owns {
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can change it"})
}
} else {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "no user in context"})
}
settings := map[string]interface{}{}
if len(collection.ViewSettings) > 0 {
if uerr := json.Unmarshal(collection.ViewSettings, &settings); uerr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "corrupt view_settings"})
}
}
if req.ChainOrder == "" || req.ChainOrder == "native" {
// Both spellings mean the default — drop the key entirely so the
// stored doc stays clean.
delete(settings, "chain_order")
} else {
settings["chain_order"] = req.ChainOrder
}
blob, err := json.Marshal(settings)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if err := h.db.UpdateCollectionViewSettings(c.Request().Context(), database.UpdateCollectionViewSettingsParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
ViewSettings: blob,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"chain_order": req.ChainOrder,
"view_settings": settings,
})
}
func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
@@ -851,278 +1127,25 @@ func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uu
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
}
if h.collectionService.IsSmartCollection(collection.AutoAssignRules) {
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand")
if services.IsCollectionSmart(collection) {
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are tree-based — membership is managed by the collection's filter tree, not by hand")
}
return nil
}
type AddRuleRequest struct {
Field string `json:"field" validate:"required"`
Operator string `json:"operator" validate:"required"`
Value string `json:"value" validate:"required"`
Enabled *bool `json:"enabled"`
Priority int `json:"priority"`
// treeEnvelope renders the stored filter tree for the envelope: nil
// (static collections) serializes as null, trees as the object.
func treeEnvelope(t []byte) any {
if len(t) == 0 {
return nil
}
var v interface{}
if json.Unmarshal(t, &v) == nil {
return v
}
return nil
}
// AddRule handles POST /api/collections/:id/rules — appends one rule to
// the ruleset. The first rule converts a static collection to smart.
func (h *CollectionHandler) AddRule(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
collection, err := h.GetCollectionData(c, collectionID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
if collection.IsSystemCollection.Bool {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "system collections cannot have rules"})
}
var req AddRuleRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
updated, rule, err := h.collectionService.AddRule(c.Request().Context(), collectionID, services.Rule{
Field: req.Field,
Operator: req.Operator,
Value: req.Value,
Enabled: req.Enabled,
Priority: req.Priority,
})
if err != nil {
var ruleErr *services.RuleValidationError
if errors.As(err, &ruleErr) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
"rule": rule,
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
})
}
// UpdateRule handles PUT /api/collections/:id/rules/:ruleId — currently
// the enabled toggle; the payload may only carry {"enabled": bool}.
func (h *CollectionHandler) UpdateRule(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
ruleID := c.Param("ruleId")
if ruleID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
}
var req struct {
Enabled *bool `json:"enabled" validate:"required"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
updated, err := h.collectionService.SetRuleEnabled(c.Request().Context(), collectionID, ruleID, *req.Enabled)
if err != nil {
if strings.Contains(err.Error(), "rule not found") {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
})
}
// RemoveRule handles DELETE /api/collections/:id/rules/:ruleId.
func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
ruleID := c.Param("ruleId")
if ruleID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
}
updated, err := h.collectionService.RemoveRule(c.Request().Context(), collectionID, ruleID)
if err != nil {
if strings.Contains(err.Error(), "rule not found") {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
})
}
type TestRulesRequest struct {
Rules []map[string]interface{} `json:"rules" validate:"required"`
}
type BookMatch struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
MatchReason string `json:"match_reason"`
}
func (h *CollectionHandler) TestRules(c *echo.Context) error {
var req TestRulesRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Candidates mirror what smart collections actually evaluate against:
// the requester's visible libraries and live items only — the preview
// must not promise matches the live render will never produce.
visibleIDs, err := h.userVisibleLibraryIDs(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
var visibleArg []pgtype.UUID
if len(visibleIDs) > 0 {
visibleArg = visibleIDs
}
mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{
LibraryID: pgtype.UUID{},
VisibleLibraryIds: visibleArg,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
}
var matches []BookMatch
for _, item := range mediaItems {
matchReason := h.checkRulesAgainstBook(item, req.Rules)
if matchReason != "" {
author := ""
if item.Author.Valid {
author = item.Author.String
}
matches = append(matches, BookMatch{
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
Title: item.Title,
Author: author,
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
MatchReason: matchReason,
})
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"matches": matches,
"total": len(matches),
})
}
func (h *CollectionHandler) checkRulesAgainstBook(item database.MediaItems, rules []map[string]interface{}) string {
for _, rule := range rules {
field, _ := rule["field"].(string)
operator, _ := rule["operator"].(string)
value, _ := rule["value"].(string)
if h.evaluateRule(item, field, operator, value) {
return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value)
}
}
return ""
}
func (h *CollectionHandler) evaluateRule(item database.MediaItems, field, operator, value string) bool {
var itemValue string
switch field {
case "genre":
if item.Genre.Valid {
itemValue = item.Genre.String
}
case "series":
if item.Series.Valid {
itemValue = item.Series.String
}
case "author":
if item.Author.Valid {
itemValue = item.Author.String
}
case "language":
if item.Language.Valid {
itemValue = item.Language.String
}
case "publisher":
if item.Publisher.Valid {
itemValue = item.Publisher.String
}
case "copyright_year":
if item.CopyrightYear.Valid {
itemValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
}
case "tags":
if len(item.Tags) > 0 {
itemValue = strings.Join(item.Tags, ", ")
}
}
return h.compareValues(itemValue, operator, value)
}
func (h *CollectionHandler) compareValues(itemValue, operator, ruleValue string) bool {
switch operator {
case "equals":
return itemValue == ruleValue
case "not_equals":
return itemValue != ruleValue
case "contains":
return strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "not_contains":
return !strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "starts_with":
return strings.HasPrefix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "ends_with":
return strings.HasSuffix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "greater_than":
itemNum, err1 := strconv.Atoi(itemValue)
ruleNum, err2 := strconv.Atoi(ruleValue)
if err1 != nil || err2 != nil {
return false
}
return itemNum > ruleNum
case "less_than":
itemNum, err1 := strconv.Atoi(itemValue)
ruleNum, err2 := strconv.Atoi(ruleValue)
if err1 != nil || err2 != nil {
return false
}
return itemNum < ruleNum
}
return false
}
// Bulk Operations
// POST /api/collections/bulk-add-books
// Bulk add books to multiple collections
func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
@@ -1268,9 +1291,12 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
}
// Book-scope preview (the rules page remains books-first until the
// filter-unification pass); no viewer progress loaded here, so
// played/unplayed rules preview at the no-progress default.
var matchedItems []database.MediaItems
for _, item := range allItems {
evaluations := h.collectionService.EvaluateRules(item, req.Rules)
evaluations := h.collectionService.EvaluateRules(item, 0, req.Rules)
for _, eval := range evaluations {
if eval.Matches {
matchedItems = append(matchedItems, item)
@@ -0,0 +1,30 @@
package handlers
import "testing"
// The collection chain's curated order (plan §10 R2): the resolver
// reads view_settings.chain_order, tolerates absent/corrupt docs, and
// refuses unknown orders (they mean native).
func TestCollectionChainOrder(t *testing.T) {
cases := []struct {
name string
vs string
want string
}{
{"absent doc", "", ""},
{"empty object", `{}`, ""},
{"reverse", `{"chain_order":"reverse"}`, "reverse"},
{"newest", `{"chain_order":"newest"}`, "newest"},
{"merged with toggles", `{"hide_finished":true,"chain_order":"oldest"}`, "oldest"},
{"unknown order means native", `{"chain_order":"shuffle"}`, ""},
{"non-string means native", `{"chain_order":3}`, ""},
{"corrupt doc means native", `not json`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := CollectionChainOrder([]byte(tc.vs)); got != tc.want {
t.Fatalf("CollectionChainOrder(%s) = %q, want %q", tc.vs, got, tc.want)
}
})
}
}
-415
View File
@@ -1,415 +0,0 @@
package handlers
import (
"testing"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
// Test collection rule matching with various operators
func TestEvaluateRule_Equals_StringMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
assert.True(t, result)
}
func TestEvaluateRule_Equals_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Fantasy", Valid: true},
}
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
assert.False(t, result)
}
func TestEvaluateRule_NotEquals_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Fantasy", Valid: true},
}
result := handler.evaluateRule(item, "genre", "not_equals", "Science Fiction")
assert.True(t, result)
}
func TestEvaluateRule_NotEquals_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
result := handler.evaluateRule(item, "genre", "not_equals", "Science Fiction")
assert.False(t, result)
}
func TestEvaluateRule_Contains_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
}
result := handler.evaluateRule(item, "author", "contains", "asimov")
assert.True(t, result)
}
func TestEvaluateRule_Contains_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
}
result := handler.evaluateRule(item, "author", "contains", "clarke")
assert.False(t, result)
}
func TestEvaluateRule_NotContains_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
}
result := handler.evaluateRule(item, "author", "not_contains", "clarke")
assert.True(t, result)
}
func TestEvaluateRule_StartsWith_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Series: pgtype.Text{String: "Foundation", Valid: true},
}
result := handler.evaluateRule(item, "series", "starts_with", "Foun")
assert.True(t, result)
}
func TestEvaluateRule_StartsWith_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Series: pgtype.Text{String: "Foundation", Valid: true},
}
result := handler.evaluateRule(item, "series", "starts_with", "Robots")
assert.False(t, result)
}
func TestEvaluateRule_EndsWith_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Series: pgtype.Text{String: "Foundation", Valid: true},
}
result := handler.evaluateRule(item, "series", "ends_with", "tion")
assert.True(t, result)
}
func TestEvaluateRule_GreaterThan_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
}
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
assert.True(t, result)
}
func TestEvaluateRule_GreaterThan_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 1999, Valid: true},
}
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
assert.False(t, result)
}
func TestEvaluateRule_LessThan_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 1999, Valid: true},
}
result := handler.evaluateRule(item, "copyright_year", "less_than", "2000")
assert.True(t, result)
}
func TestEvaluateRule_LessThan_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
}
result := handler.evaluateRule(item, "copyright_year", "less_than", "2000")
assert.False(t, result)
}
func TestEvaluateRule_NullField(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{Valid: false}, // NULL
}
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
assert.False(t, result, "Should return false for NULL fields")
}
func TestCompareValues_CaseInsensitive(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("Science Fiction", "contains", "science fiction")
assert.True(t, result, "Contains should be case-insensitive")
result = handler.compareValues("Science Fiction", "contains", "SCIENCE FICTION")
assert.True(t, result)
}
func TestCompareValues_EmptyString(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("", "contains", "test")
assert.False(t, result, "Empty string should not contain anything")
result = handler.compareValues("test", "contains", "")
assert.True(t, result, "Non-empty string contains empty string")
}
func TestCompareValues_NumericEdgeCases(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("0", "greater_than", "-1")
assert.True(t, result)
result = handler.compareValues("9999", "less_than", "10000")
assert.True(t, result)
}
// Test rule checking with multiple rules
func TestCheckRulesAgainstBook_MatchesFirstRule(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
rules := []map[string]interface{}{
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
},
{
"field": "author",
"operator": "contains",
"value": "Asimov",
},
}
reason := handler.checkRulesAgainstBook(item, rules)
assert.NotEmpty(t, reason, "Should match at least one rule")
assert.Contains(t, reason, "genre")
}
func TestCheckRulesAgainstBook_MatchesSecondRule(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Fantasy", Valid: true},
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
}
rules := []map[string]interface{}{
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
},
{
"field": "author",
"operator": "contains",
"value": "Asimov",
},
}
reason := handler.checkRulesAgainstBook(item, rules)
assert.NotEmpty(t, reason, "Should match second rule")
assert.Contains(t, reason, "author")
}
func TestCheckRulesAgainstBook_NoMatches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Mystery", Valid: true},
Author: pgtype.Text{String: "Agatha Christie", Valid: true},
CopyrightYear: pgtype.Int4{Int32: 1950, Valid: true},
}
rules := []map[string]interface{}{
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
},
{
"field": "author",
"operator": "contains",
"value": "Asimov",
},
}
reason := handler.checkRulesAgainstBook(item, rules)
assert.Empty(t, reason, "Should not match any rules")
}
func TestCheckRulesAgainstBook_EmptyRules(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
rules := []map[string]interface{}{}
reason := handler.checkRulesAgainstBook(item, rules)
assert.Empty(t, reason, "Empty rules should return empty reason")
}
// Test complex rule scenarios
func TestComplexRule_MultipleConditions(t *testing.T) {
handler := &CollectionHandler{}
// Book: "Foundation" by Isaac Asimov, Sci-Fi, 1951
item := database.MediaItems{
Title: "Foundation",
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
CopyrightYear: pgtype.Int4{Int32: 1951, Valid: true},
Series: pgtype.Text{String: "Foundation", Valid: true},
}
tests := []struct {
name string
field string
operator string
value string
expected bool
}{
{
name: "Match genre exactly",
field: "genre",
operator: "equals",
value: "Science Fiction",
expected: true,
},
{
name: "Match author substring",
field: "author",
operator: "contains",
value: "asimov",
expected: true,
},
{
name: "Match year greater than",
field: "copyright_year",
operator: "greater_than",
value: "1950",
expected: true,
},
{
name: "Match year less than",
field: "copyright_year",
operator: "less_than",
value: "2000",
expected: true,
},
{
name: "Match series starts with",
field: "series",
operator: "starts_with",
value: "Found",
expected: true,
},
{
name: "Match series ends with",
field: "series",
operator: "ends_with",
value: "tion",
expected: true,
},
{
name: "No match for genre",
field: "genre",
operator: "equals",
value: "Fantasy",
expected: false,
},
{
name: "No match for author",
field: "author",
operator: "contains",
value: "Tolkien",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := handler.evaluateRule(item, tt.field, tt.operator, tt.value)
assert.Equal(t, tt.expected, result)
})
}
}
// Test edge cases and error handling
func TestEvaluateRule_InvalidOperator(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
result := handler.evaluateRule(item, "genre", "invalid_operator", "Science Fiction")
assert.False(t, result, "Invalid operator should return false")
}
func TestEvaluateRule_NonExistentField(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
result := handler.evaluateRule(item, "nonexistent_field", "equals", "value")
assert.False(t, result, "Non-existent field should return false")
}
func TestCompareValues_InvalidOperator(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("test", "invalid_op", "value")
assert.False(t, result, "Invalid comparison operator should return false")
}
func TestCompareValues_NumericConversion(t *testing.T) {
handler := &CollectionHandler{}
// Valid numeric strings
result := handler.compareValues("100", "greater_than", "99")
assert.True(t, result)
result = handler.compareValues("99", "less_than", "100")
assert.True(t, result)
// Invalid numeric strings (should return false)
result = handler.compareValues("abc", "greater_than", "100")
assert.False(t, result, "Non-numeric string should not compare as greater than")
result = handler.compareValues("100", "greater_than", "xyz")
assert.False(t, result, "Non-numeric value should not allow comparison")
}
-83
View File
@@ -3,95 +3,12 @@ package handlers
import (
"testing"
"bookhoard/internal/database"
"bookhoard/internal/utils"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
func TestCompareValues_Equals(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("Science Fiction", "equals", "Science Fiction")
assert.True(t, result)
result = handler.compareValues("Science Fiction", "equals", "Fantasy")
assert.False(t, result)
}
func TestCompareValues_Contains(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("Isaac Asimov", "contains", "asimov")
assert.True(t, result)
result = handler.compareValues("Isaac Asimov", "contains", "clarke")
assert.False(t, result)
}
func TestCompareValues_GreaterThan(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("2021", "greater_than", "2000")
assert.True(t, result)
result = handler.compareValues("1999", "greater_than", "2000")
assert.False(t, result)
}
func TestCompareValues_NotEquals(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("Science Fiction", "not_equals", "Fantasy")
assert.True(t, result)
result = handler.compareValues("Science Fiction", "not_equals", "Science Fiction")
assert.False(t, result)
}
func TestEvaluateRule_Genre(t *testing.T) {
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
handler := &CollectionHandler{}
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
assert.True(t, result)
result = handler.evaluateRule(item, "genre", "equals", "Fantasy")
assert.False(t, result)
}
func TestEvaluateRule_Author(t *testing.T) {
item := database.MediaItems{
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
}
handler := &CollectionHandler{}
result := handler.evaluateRule(item, "author", "contains", "asimov")
assert.True(t, result)
result = handler.evaluateRule(item, "author", "contains", "clarke")
assert.False(t, result)
}
func TestEvaluateRule_CopyrightYear(t *testing.T) {
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
}
handler := &CollectionHandler{}
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
assert.True(t, result)
result = handler.evaluateRule(item, "copyright_year", "less_than", "2000")
assert.False(t, result)
}
func TestTextToString(t *testing.T) {
tests := []struct {
name string
+19 -20
View File
@@ -80,7 +80,7 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes
status = "unresolved"
}
ctx := context.Background()
ctx := c.Request().Context()
var conflicts interface{}
var err error
@@ -175,7 +175,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
@@ -187,7 +187,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
mediaItem, err := h.db.GetMediaItem(context.Background(), conflict.MediaItemID)
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), conflict.MediaItemID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media item")
}
@@ -223,7 +223,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
@@ -263,13 +263,13 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
}
if conflict.ConflictType == "progress" {
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
if err := h.applyProgressResolution(c.Request().Context(), conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
appliedTo["progress"] = true
}
}
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
if err := h.applyAnnotationResolution(c.Request().Context(), conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
appliedTo["annotations"] = true
}
}
@@ -283,7 +283,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
resolutionDataJSON, _ := json.Marshal(resolutionData)
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
_, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
ID: conflictUUID,
ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true},
ResolutionData: resolutionDataJSON,
@@ -321,7 +321,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
// the service's LWW seed instead of being NULLed — that is the fix.
// chapter_progress keeps its stored value instead of being overwritten
// with the book-level percentage.
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
func (h *ConflictHandler) applyProgressResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
req := wsync.SaveProgressRequest{
MediaItemID: mediaItemID,
UserID: userID,
@@ -361,12 +361,11 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
req.TotalPages = &tp
}
_, err := h.progressSvc.SaveProgress(context.Background(), req)
_, err := h.progressSvc.SaveProgress(ctx, req)
return err
}
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
ctx := context.Background()
func (h *ConflictHandler) applyAnnotationResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
dedupKey, _ := winnerData["dedup_key"].(string)
if dedupKey == "" {
@@ -530,7 +529,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
@@ -542,7 +541,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
if err := h.db.DeleteSyncConflict(context.Background(), conflictUUID); err != nil {
if err := h.db.DeleteSyncConflict(c.Request().Context(), conflictUUID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to delete conflict")
}
@@ -552,7 +551,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
user := c.Get("user").(database.Users)
conflicts, err := h.db.ListConflictsByUser(context.Background(), user.ID)
conflicts, err := h.db.ListConflictsByUser(c.Request().Context(), user.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
}
@@ -560,7 +559,7 @@ func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
deleted := 0
for _, conflict := range conflicts {
if conflict.ResolutionStatus.String == "user_resolved" || conflict.ResolutionStatus.String == "bulk_resolved" {
if err := h.db.DeleteSyncConflict(context.Background(), conflict.ID); err == nil {
if err := h.db.DeleteSyncConflict(c.Request().Context(), conflict.ID); err == nil {
deleted++
}
}
@@ -620,7 +619,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
@@ -702,7 +701,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
continue
}
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
if err := h.applyProgressResolution(c.Request().Context(), conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
@@ -719,7 +718,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
}
resolutionDataJSON, _ := json.Marshal(resolutionData)
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
_, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
ID: conflictUUID,
ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true},
ResolutionData: resolutionDataJSON,
@@ -817,7 +816,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
@@ -838,7 +837,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
continue
}
if err := h.db.DeleteSyncConflict(context.Background(), conflictUUID); err != nil {
if err := h.db.DeleteSyncConflict(c.Request().Context(), conflictUUID); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
+2 -2
View File
@@ -18,10 +18,10 @@ type DashboardHandler struct {
dashboardService *services.DashboardService
}
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
func NewDashboardHandler(db *database.Queries, search ...*services.SearchService) *DashboardHandler {
return &DashboardHandler{
db: db,
dashboardService: services.NewDashboardService(db),
dashboardService: services.NewDashboardService(db, search...),
}
}
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff. Show more