Compare commits

..
85 Commits
Author SHA1 Message Date
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
John O'Keefe 795225f097 fix(scanner): reconcile archive pass per library, not per folder
Release / build-and-push (push) Successful in 3m5s
The archive pass looped over the scan's folders and, inside each
iteration, compared the ENTIRE library's item list against that one
folder's walk. With two registered folders every item living under the
other folder was flagged "missing (1/2)" on every reconciling scan —
prod's audiobook library (the only multi-folder one) showed 743/743
flagged — and nothing reached the two-consecutive-miss threshold only
because each scan's walk clears missing_scan_count before the pass
re-flags it. With three or more folders the defect stops bouncing: a
later iteration sees an earlier iteration's count-1 marks on items its
own walk lacks and archives them mid-pass — one registered folder away
from archiving a third of a library per scan. Single-folder libraries
were unaffected, which is why comics/ebooks/manga showed zero flags.

Folder-book presence was suspected alongside it (audioBookRoots read
during the comparison), but the map is written for every >=2-track
folder-book root on every walk (processAudioBookFile, since 6216bd9b);
the flagged folder-books were victims of the same cross-folder
comparison. The roots attribution did carry the same unchecked
strings.HasPrefix boundary bug as the rest of the scanner (8th site,
variable named `root`, missed by the earlier sweep); it now uses
folderContains.

The pass now groups folders by owning library and, per library,
compares ListMediaItemsByLibraryIncludingArchived against the UNION of
all that library's folders' walks. The coverage (partial-scope) guard,
the walk-failure skip, and the mark/archive/purge item loop are
unchanged.

Regression test (archive_multifolder_test.go) builds a two-folder
audiobooks library — single-file book in one folder, folder-of-tracks
book in the other — and scans twice: pre-fix both items are flagged
missing on both scans (verified by running the test against the old
code), post-fix neither is ever flagged or archived.
2026-10-28 01:30:41 -04:00
John O'Keefe 9f39e2efd8 feat(settings): save feedback — success/error toasts, disabled buttons; fix scan-form raw-JSON swap
Release / build-and-push (push) Successful in 4m35s
None of the three save surfaces on the admin settings page gave usable
feedback: the Base URL form re-rendered into an identical-looking form
(its only indicator was the Go-source twin's banner — markup that
diverged from the templ original), the scan form was WORSE — its handler
responds JSON, so htmx's outerHTML swap replaced the whole scan card
with literal JSON text — and the tunable rows showed only a tiny
per-row label.

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

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

Server side:

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

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

Tests: web/src/settings.test.ts pins the form→label map and the
error-extraction contract (including the no-markup-in-toast rule);
e2e/settings-feedback.spec.ts drives all three surfaces through the
real admin UI including the error path and the twin-drift double-save.
2026-10-28 00:22:00 -04:00
John O'Keefe 7b9e8e4fc2 fix(scanner): match library folders only at directory boundaries
A bare string-prefix test (strings.CutPrefix / strings.HasPrefix) let a
registered folder whose path was a string prefix of a sibling claim the
sibling's files: "/app/audiobooks" matched "/app/audiobookssd/Brian
Jacques/...", so getRelativePath sliced the remnant "sd/" into every
stored relative path for that library (prod scan log: "[AUDIO] Created
folder book 'Taggerung' (41 tracks) at sd/Brian Jacques/Redwall/Book 14").
Scanner lookups built their keys with the same function
(getMediaItemByFilePath, scanStateLookup), so writes and lookups were
consistently poisoned and no scan ever corrected them; everything
outside the scanner resolves paths by joining back to the roots
(ResolveMediaPath), so cover/track URLs 404ed for files sitting one
directory away.

The same unchecked HasPrefix pattern also drove five folder-membership
sites, so a sibling-prefix pair misattributed more than paths:
owning-library resolution (processMediaFile, maybeProcessAudioBook),
per-library-type extension allowlisting (isScannableFile), and
folder-structure metadata extraction on both create and update paths —
a file could be scanned under the wrong library's rules entirely.

folderContains(folder, path) is now the single boundary-aware
primitive: path must equal the folder or continue with a
filepath.Separator (after Clean). All seven sites go through it, so no
folder name — however similar to another — can interfere with any
other's files again, in any order of registration; trailing slashes on
registered folders are normalized. Registration order still decides for
genuinely overlapping (nested) registered roots: pre-existing behavior,
deliberately unchanged.

Regression tests (media_scanner_path_test.go, pure unit — no DB) pin the
prod trap (audiobooks/audiobookssd), order-independence, trailing
slashes, mid-word siblings, and the helper itself.

Repair of already-poisoned rows is deliberately separate and safe only
under this fix: a rescan repoints them via the SHA-256 move path
(MoveMediaItemFilePath) once lookups stop producing poisoned keys.
2026-10-27 23:44:41 -04:00
John O'Keefe 4c43caa639 fix(search): dropdown dead on htmx-less pages; Alpine-side debounce; auto bundle stamps
The "top search bar doesn't work" report had TWO stacked causes, found
by reproducing with real keypress-by-keypress typing instead of
Playwright's fill() (which fires a single input event and masked both):

1. htmx.min.js was included PER PAGE. The header's live search is
   htmx-driven on every page, but five pages rendering @Header —
   series, admin, admin hash-conflicts, admin processing-issues,
   browse-detail — never loaded it: the input's hx-* attributes were
   inert (htmx-internal-data absent), so typing produced no request at
   all. The include now lives in the header partial itself (classic
   script, before the module bundle); the sixteen per-page copies on
   @Header pages are removed (double-processing guard). Non-header
   pages (login/register/setup/reader/index) keep their own.

2. htmx's delay: modifier on a PROGRAMMATICALLY dispatched custom event
   (htmx.trigger) loses the request entirely under rapid successive
   triggers — every re-fire canceled the pending timer and the last
   never issued, so fast typing (the normal kind) never opened the
   dropdown while one-shot fill() worked. The debounce moved into the
   Alpine component (client-local state, where it belongs); htmx now
   executes the bare `search` event immediately.

Also made bundle staleness structurally impossible: each bundle's
script stamp is a content hash computed at startup (templates/
bundles.go), so a rebuild that changes bundle bytes changes its cache
key with no manual ?v=N bump — the manual stamp was how the first
dropdown fix stayed invisible for a deploy. The reader bundle (never
stamped at all) is covered too. HTML pages additionally send
Cache-Control: no-cache (they are dynamic), so a stale page can no
longer reference a stale stamp; /api, /static and /uploads keep their
native caching, pinned by a middleware test.
2026-10-27 23:33:52 -04:00
John O'Keefe fd9292e6f2 fix(series): Date Added respects the direction toggle; bump bundle stamp
Two follow-ups from live testing:

- "recent" is BOTH a base toolbar key and a full registry key (where it
  means desc). ResolveSeriesSort's full-key passthrough ran first, so
  sort=recent&dir=asc silently resolved back to desc — the direction
  chevron flipped but Date Added could never sort ascending. "recent"
  now composes with the direction before the passthrough, unknown bases
  fall to the default entirely (a hostile dir can't ride an unknown
  sort), and the composition table is unit-pinned plus an HTTP-level
  row in the series filter test.

- The header search "still broken" report was a stale-cache artifact:
  the fixed code shipped but main.js kept its ?v=7 stamp, so browsers
  with a cached copy never refetched (fresh-profile e2e passed while
  the user's browser didn't — heuristically cached module). Stamp bumped
  to ?v=8 in header + docs; the e2e suite re-verified against the
  rebuilt server.
2026-10-27 22:55:11 -04:00
John O'Keefe ffbcc53083 test(e2e): drive write-to-file per format in the real UI; fix sidecar perms
Per the owner's testing bar: a Playwright spec registers an admin,
seeds one fixture per write-capable type (EPUB, PDF, CBZ, MP3, FLAC,
M4A, M4B — generated at runtime, no binary media in git), drives the
REAL metadata-editor flow (Edit → change title → in-app dialog →
Save & write) for each, and then reads every artifact back
INDEPENDENTLY of the app — EPUB/CBZ/sidecar via python zip+XML parse,
audio via ffprobe — asserting the written title landed in the FILE and
the old one is gone.

Writing the e2e exposed a real bug: atomicWrite left server-written
sidecars at 0600 root (os.CreateTemp's default), so host-side tools
sharing the volume — Calibre reading metadata.opf — were locked out.
The temp file is now chmod'd 0644 before the rename, matching the
scanner's cover-file convention; the e2e's host-side reads prove it.
2026-10-27 22:26:00 -04:00
John O'Keefe fb1645c90c fix(metadata): m4b text-track fallback; in-app confirm; save-first write
Three defects from real-world use:

- The m4b rewrite assumed every stream survives -map 0 -c copy; files
  carrying text-ish tracks (the movenc 'text' fourcc) fail the ipod
  muxer with "Tag text incompatible with output codec id" — exactly the
  error a write attempt hit in the field. The rewrite now attempts full
  fidelity first, then retries mapping audio + attached pictures only
  (-dn -sn): chapters live in the container header (-map_chapters 0)
  so they survive; every attempt still ffprobe-validates before the
  atomic rename and the original is never touched on total failure.

- The browser confirm() is replaced by an in-app dialog
  (WriteToFileConfirmDialog, Alpine + templ — no native dialogs).

- "Write to file" now SAVES the pending editor edits first (persisted
  through the same PUT the Save button uses), then writes the file
  from those DB values, and closes the editor + refreshes the page
  exactly like Save — previously the endpoint wrote the LAST-SAVED DB
  values, so edits sitting in the modal never reached the file, and
  the page didn't visibly update.

Test coverage per the owner's bar: EVERY single-file container (MP3,
FLAC, M4A, M4B) gets its own runtime-generated fixture, gets written,
and the FILE's metadata is then verified independently — ffprobe reads
the rewritten container and every mapped key is asserted per format,
including the subtitle round-trip split (MP3/FLAC yes, M4A/B no) — and
the EPUB/CBZ/sidecar assertions now PARSE the written XML (dc titles/
creators, ComicInfo fields, calibre meta) instead of substring
matching. A smart-genre unit test also pins the mixed-blob contract: a
recognized shelf wins the genre column even when demoted format words
ride along in the same blob.
2026-10-27 19:05:23 -04:00
John O'Keefe 96c1228805 feat(series): multi-field search, sort-direction toggle, apply placement
Toolbar feedback round: the series search box now matches whatever the
main search matches for a group — series NAME, any book's AUTHOR, and
any book's TITLE inside the series (WHERE EXISTS per group-row, so
counts stay true); the separate author input is gone from the toolbar
(the author= param stays live for the JSON API and deep links). The
sort control becomes a base-key select (Date added/Name/Author/Books/
Progress/Last read) plus a chevron direction toggle that flips and
resubmits; sort/dir compose through ResolveSeriesSort (full registry
keys keep working for back-compat, unknown base keys fall to recent).
The search/apply button moved next to the search box as requested, and
default URLs stay clean (sort/dir only emitted once the user chooses).
2026-10-27 19:05:23 -04:00
John O'Keefe 14285ff314 fix(search): register the headerSearch Alpine component (dropdown regression)
The dropdown never opened on typing: the search-dropdown migration left
./search un-imported after dropping the initializeSearch call from
header.ts, so the bundler tree-shook the module away and the
x-data="headerSearch" scope never registered — every handler binding
(input/focus/keys) silently died. This is a client-wiring defect that
HTTP-level integration tests structurally cannot see; a Playwright e2e
spec (e2e/search-scrubber.spec.ts) now drives the real bundle: sub-2-char
input stays closed, 2+ chars opens the swapped templ dropdown.
2026-10-27 19:04:59 -04:00
John O'Keefe e5d4744977 fix(audio): Shift+grab opens the precision rail anchored at the playhead
The first grab implementation degraded Shift+grab to a quarter-speed
relative crawl — losing the ±5-minute rail the user expects when they
grab the handle with Shift held. The rail is back: Shift+grab anchors
railWindow at the CURRENT position (not the nearest chapter line, and
never teleports the playhead on pointerdown); plain grab stays relative
1:1. GRAB_FINE_SCALE is deleted as dead code, and the dialog guide
documents the restored gesture.
2026-10-27 19:04:59 -04:00
John O'Keefe b462c7fb44 test(scanner): pin the demoted-genre contract the genre fix intended
TestAudiobookTagFallbacks still asserted the verbatim "Audiobook" genre
column, but 885d6140 demoted format words to tags-only — and that
commit's message explicitly says the pinned expectation was updated to
the demoted contract. The code change landed; the test edit it
describes never did, so the full suite has been red since 13:21 today
on a test my changes never touched.

Pin the new contract: the genre column stays empty for demoted format
words and the raw candidate ("Audiobook") lands in the tags array —
exactly the tags-only behavior the fix's message documents. Full
integration suite green after this.
2026-10-27 16:24:07 -04:00
John O'Keefe 59eddc1386 feat(metadata): audio tag rewrite via ffmpeg + CBZ ComicInfo injection
Phases 5b and 5c of the metadata writer.

5b — single-file audio (MP3/FLAC/M4A/M4B): the container's tags are
rewritten with ffmpeg -c copy (no re-encode; -map 0 keeps cover art,
-map_chapters 0 keeps chapters). The key set is exactly what the
scanner's applyAudioTagFallbacks reads back — title+album, subtitle,
artist/album_artist, composer (narrators, Audible's convention),
grouping + iTunes series-part, date, genre — so a write + rescan
round-trips. Output is ffprobe-VALIDATED (duration within 1s/0.5% of
the source) before the atomic rename; a lossy container rewrite never
replaces a good book. Fields the format can't carry per our own read
chain are deliberately not written, and fields_written reports only
what really round-trips (subtitle rides MP3/FLAC — this image's mp4
muxer drops subtitle keys, discovered empirically and pinned by test).

5c — CBZ: ComicInfo.xml injected (or replaced) with the same atomic zip
rewrite as the EPUB path, extended with an append-if-missing mode
since a comic may not carry one yet. Field map mirrors the scanner's
comic block: Title/Series/Number/Count/Volume, Writer (authors),
Summary, Publisher, Year/Month/Day, LanguageISO, Web, Genre+Tags;
fractional series numbers are omitted rather than truncated to a lie.
CBR/CB7 stay on the sidecar path (RAR/7z can't be rewritten in place).

Integration tests generate media at test runtime with ffmpeg (m4b) and
in-memory zips (cbz) — no binary fixtures — pinning the tag round-trip
through ffprobe, post-write rescan titles, the ComicInfo injection and
the validation-gate behavior.
2026-10-27 16:04:34 -04:00
John O'Keefe eaf1b2c8dd feat(metadata): write-to-file — embedded EPUB OPF + Calibre sidecars
Phase 5a of the metadata-writer feature: the book editor gains a
"Write to file" action (admin) that persists the item's CURRENT
database metadata into the artifact on disk, so the library stays
portable to Calibre and other apps — the thing Save alone can't do.

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

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

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

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

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

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

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

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

Integration test pins every sort key against a three-series fixture
with distinct created/author/count/progress answers, the filter matrix,
composition, unknown-sort fallback, card read-counts and the JSON
contract — all as the fresh regular user so the live reader's data is
untouched.
2026-10-27 14:53:45 -04:00
John O'Keefe 79f4d668f1 fix(audio): grabbing the playhead thumb no longer teleports it
A pointerdown on the scrub bar unconditionally mapped the click X to a
position, so grabbing the handle itself made the playhead jump — worst
with Shift, whose branch force-snapped to the nearest chapter line
before the drag even started. The user's complaint: the handle click
flowed through to the track-click behavior behind it.

The bar now hit-tests the thumb first (GRAB_SLOP_PX, a pixel test like
the chapter magnetism — a seconds threshold goes sub-pixel on long
books). A grab enters a new "grab" drag mode: the position stays where
it is until the pointer moves, then advances RELATIVELY via the new
applyGrab helper (incremental like the touch cursor, fixed scale — the
mouse is always on the bar, so Shift = quarter-speed fine crawl instead
of a pull-away zoom).

Track clicks keep today's behavior exactly: click = jump with chapter
magnetism, Shift+click = nearest chapter line, Shift+drag = precision
rail, touch = incremental pull-away. MOUSE_GUIDE gains the two grab
rows so the dialog documents the gesture; new pure helpers are unit-
tested alongside the existing scrub math.
2026-10-27 14:21:13 -04:00
John O'Keefe c71ef4af85 fix(auth): show error feedback on failed htmx login/registration
Found while double-checking /login after the redirect fix: a wrong
password left the form completely silent. Two stacked causes, both
pre-dating the CSP work:

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

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

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

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

Verified live: home-page login form → /dashboard with token stashed and
dashboard loading clean; /register form → /dashboard; collections and
devices share the same mechanisms (data-redirect / HX-Refresh). Gates:
make test (9 pkgs incl. both tripwires), vitest 155, Playwright 20,
tsc/vp clean.
2026-10-27 13:57:41 -04:00
John O'Keefe b7595c3011 docs(agents): commit each completed unit of work as it lands
Release / build-and-push (push) Successful in 3m20s
Standing rule: commit per related edit with detailed conventional-commit
messages as each project section finishes, instead of batching a whole
session into one giant commit. Committing is local bookkeeping; pushing
stays a separate, explicit decision. Ordering guidance added so stacked
trees compile (service before handler, guard before dependent feature).
2026-10-27 13:39:02 -04:00
John O'Keefe 6156ff6154 fix(docs): record keep_screen_awake in the get_profile example
TestDocsMatchRoutes flagged the example response as missing a key the
handler emits — drift introduced by c645b8e2 (the wake-lock preference),
surfaced while regenerating the API mirrors for the retry endpoint.
2026-10-27 13:23:09 -04:00
John O'Keefe 69d53e92a7 test(integration): cover retry, folder-path validation, and the archive guard
- TestRetryProcessingIssueRowless: a row-less import_failed entry retries
  to a real import and resolves; a missing file retries to 409 with the
  real reason and stays open; an item-backed issue retries through the
  rescan mechanism and resolves.
- TestScanLibraryFolderPathValidation: an unregistered folder_path is a
  400; a registered one scopes and enqueues (202).
- TestScanSingleFolderSkipsArchiveReconciliation: scanning one folder of
  a two-folder library leaves the other folder's healthy item at
  missing_scan_count 0 / unarchived — pinning the partial-scope guard
  against the false-archive regression it exists to prevent.
2026-10-27 13:23:02 -04:00
John O'Keefe 248bb7816d feat(issues): Processing Issues page retry UI with a sequential client queue
Per issue card: a Retry button — error-severity issues previously had no
action at all (only warning/info got Dismiss). Right of the Errors stat
card: Retry all, which builds a queue of every issue currently on the
page and runs them strictly one at a time through the per-issue retry
endpoint. The browser is the queue — resource use is bounded to a single
book in flight, and no batch endpoint exists to maintain.

While the queue runs, the button shows an n/m counter and cards tint via
client-local state classes (the sanctioned island shape; the cards
themselves stay templ-rendered). After the queue drains, the page swaps
in the new /admin/libraries/:id/issues/content templ partial — the
unlinked-books htmx.ajax pattern with a reload fallback — so resolved
issues drop off server-side while failures remain listed, with a
summary toast and per-item error toasts carrying the real failure
reasons.

CSP discipline holds throughout: Alpine @click component methods, no
inline handlers, no js:-prefixed hx-vals.
2026-10-27 13:22:54 -04:00
John O'Keefe 0f1d0abbb0 feat(libraries): scope a library scan to one registered folder
POST /api/libraries/:id/scan accepts an optional {"folder_path": "..."}:
the scan then covers exactly that one registered folder's subtree instead
of the whole library. The path is validated against the library's
registered folders — anything else is a 400, never trusted. An omitted
body keeps today's full-library behavior, and coalescing (409 with the
active job) is unchanged.

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

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

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

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

Docs + bruno mirrors regenerated for the new route (the generator's seq
renumbering touches three unrelated bruno files as fallout; openapi
gains the path and the date bump), and the docs schema registry gains
the retry entry so TestDocsMatchRoutes passes.
2026-10-27 13:22:31 -04:00
John O'Keefe fb117635f8 feat(scanner): ledger failed metadata updates; guard partial-scope archive passes
Two scanner-integrity changes supporting the Processing Issues retry work:

- The size-change and sidecar-change update paths only printf'd their
  failures, so a book whose metadata update kept failing (the Crusader
  genre-22001 loop) degraded silently on every scan with nothing on the
  Processing Issues page. Both paths now record an item-backed
  metadata_extraction_failed issue — the same type and shape the
  sidecar-extraction guard already used — and resolveIssuesForItem
  clears it when an update finally succeeds.

- The archive reconciliation pass compares every library item against
  what one scan saw on disk. The new per-folder scan scope runs a scan
  covering a single folder, which would mark every item under the
  library's OTHER folders missing and archive them on a later pass.
  The pass now skips a library whose registered folders the scan only
  partially covered (GetLibraryFolders vs the scan's folder set),
  logging the skip.
2026-10-27 13:21:48 -04:00
John O'Keefe 885d614092 fix(scanner): route the audio-tag genre through the smart-genre pipeline
applyAudioTagFallbacks captured the ffprobe genre atom verbatim into
media_items.genre — no splitting, no shelf resolution, no cap. That raw
capture is what fed 100+-char Audible category blobs into the column
(the 2026-10 Crusader import/update failures).

The ABS sidecar path already has the correct doctrine (ab2a5568): a known
lexicon shelf wins the genre column, everything else is tags-only. Give
the tag fallback the same treatment — splitGenreCandidates +
pickSmartGenre for the column, split candidates appended to tags
(NormalizeTags dedups). Demoted genres like "Audiobook" stop masquerading
as a shelf, and an unknown blob can no longer manufacture an overflowing
value at all.

Fixtures pin the new behavior: the real 111-char Crusader blob stays out
of the column entirely and lands as six separate tags; a shelf-carrying
blob still wins the column; the previously-pinned verbatim "Audiobook"
expectation is updated to the demoted (tags-only) contract.
2026-10-27 13:21:39 -04:00
John O'Keefe d42c78f58c fix(schema): widen media_items.genre to TEXT
The genre column was VARCHAR(100), and the audio tag fallback stores the
file's raw genre atom verbatim. Libation writes Audible's category ladder
as one comma-joined string ("Politicians, Presidents & Heads of State,
United States, Russia, Communism & Socialism, International Relations" —
111 chars), so any such book failed its import or metadata update with
SQLSTATE 22001 (value too long), taking the whole row down with it.

VARCHAR -> TEXT is a metadata-only change in Postgres and safe to re-run;
the in-place ALTER follows the media_highlights position-widening
precedent so existing databases converge on restart (schema.sql runs on
every boot).
2026-10-27 13:21:29 -04:00
217 changed files with 26900 additions and 3833 deletions

No files matched your search

+9 -1
View File
@@ -6,10 +6,18 @@
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
JWT_SECRET=your-secure-jwt-secret-key-here 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 # Generate with: openssl rand -hex 16
DBPASS=your-secure-database-password-here 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 # Networking: change a port if it conflicts on your host
# Postgres port, host + container (e.g. another local DB already uses 5432) # Postgres port, host + container (e.g. another local DB already uses 5432)
# DB_PORT=15432 # DB_PORT=15432
+72 -22
View File
@@ -4,6 +4,32 @@ Standing rules for any agent or developer working in this repo.
Project-wide context lives in the sibling repos' AGENTS.md files Project-wide context lives in the sibling repos' AGENTS.md files
(../bookhoard-app/AGENTS.md is the Android client's). (../bookhoard-app/AGENTS.md is the Android client's).
## Commit as each unit of work completes — ALWAYS apply
Commit when a logically complete piece of the project is done — a fix,
a feature slice, a schema change, a docs/test batch — before moving on
to the next piece. Do not hold a whole session's work for one giant
end-of-session commit, and never push uncommitted work aside until the
tree is clean.
- **One commit per related edit.** If the changed files serve more than
one purpose, they are separate commits; only share a commit when you
are sure every file in it is the same related edit. Generated
artifacts (templ codegen, sqlc output, vite/tailwind build output,
bruno/openapi mirrors) commit with the source change that produced
them.
- **Detailed messages**, in the repo's conventional-commit style
(`fix(scanner): …`, `feat(issues): …`) with a body that says WHY the
change exists, what behavior changed, and any precedent it follows —
not just what the diff shows. The body is how the next agent (and
git history) arbitrates "test stale vs code changed".
- **Ordering**: stack commits so each tree compiles where practical
(a service before the handler that calls it, a guard before the
feature that relies on it).
- **Committing ≠ pushing.** Commits are local bookkeeping and land as
work completes; pushing (once, after the last commit) is the author's
call. Never push half-finished units just because they were committed.
## Rendering ownership (templ vs TypeScript) — ALWAYS apply ## Rendering ownership (templ vs TypeScript) — ALWAYS apply
The point of templ + htmx + Alpine is: the server renders the UI, and The point of templ + htmx + Alpine is: the server renders the UI, and
@@ -54,7 +80,11 @@ refuses, **silently at build/test time**, two authoring patterns:
guard means recomputing the hash (the failing test prints it); adding guard means recomputing the hash (the failing test prints it); adding
a *second* inline script means hashing it too and extending a *second* inline script means hashing it too and extending
`cspSanctionedInlineScripts` in `templates/csp_source_test.go`. `cspSanctionedInlineScripts` in `templates/csp_source_test.go`.
Everything else goes in a bundled `web/src` module. Everything else goes in a bundled `web/src` module. This includes
partials emitted from Go handler strings — those use data attributes
+ `web/src/auth_forms.ts` (post-swap redirect/stash) or htmx's
`HX-Redirect`/`HX-Refresh` headers; `internal/middleware`'s
Go-source scan is the tripwire for that vector.
- **No native `on*=` handler attributes** (`onclick=`, `onerror=`, - **No native `on*=` handler attributes** (`onclick=`, `onerror=`,
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline `onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
handlers never go through the hash path; hash-mode CSP refuses them handlers never go through the hash path; hash-mode CSP refuses them
@@ -76,30 +106,50 @@ inside quoted attribute values (gotcha #1 below), so dynamic
## Library visibility (family-lens rule) — ALWAYS apply ## Library visibility (family-lens rule) — ALWAYS apply
Per-user library visibility (`library_visibility` table, default-ALLOW: Per-user library visibility (`library_visibility` table, default-ALLOW:
every user sees every library unless an admin hides one) must be every user sees every library unless an admin hides one) is enforced by
enforced on **every endpoint that returns media metadata or content**. **Postgres RLS** — one source of truth: the labeled policy block in
It is NOT automatic — enforcement is per-handler. `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 - **The engine is the enforcement.** Every `media_items` read is
requesting user's visible-library set, and MUST reject an explicit filtered by the policy, which joins `library_visibility` on the
`library_id` outside that set with 403 (never silently pass). per-transaction GUC `app.user_id`. New media-listing queries need
- Helpers (do not hand-roll): NO visibility clause; new endpoints need NO visibility plumbing.
- `handlers`: `(*MediaHandler).visibleLibrarySet` / - **The app declares identity; it never computes visibility.** All
`(*SeriesHandler).visibleLibrarySet` — membership map + id slice. sqlc statements run through `ScopedDB`
- `router`: `visibleLibraryIDsFor` — same for SSR routes. (`internal/database/scoped.go`), which declares `app.user_id` from
- `resolveLibrary` (helpers.go) only honors requested library ids the stdlib request context. Three bridge sites put it there:
inside the visible set. `router.go` JWT, `router.go` cookie/device, and
- SQL pattern: `AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR `middleware/device_auth.go` (KOReader/Kobo sync devices). A media
library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))` — see read on a detached `context.Background()` RAISES — thread the
`ListMediaItems`, `GetDistinctSeries`, `SearchMediaItems`. request context or the acting identity (see conflicts.go and
- Content endpoints (file bytes, page images) additionally gate per item analytics.go for the pattern).
like `ReaderHandler.ensureItemVisible` / the download endpoint. - **Background jobs declare the acting identity** before detached
- Tests must pin visibility: hidden library → 403 on the endpoint, and work: scan jobs carry the library owner / first-admin fallback
absent from unscoped aggregates. (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).
History: 2026-10-13 (`431fec0`) the series/media listing surfaces History: 2026-10-13 (`431fec0`) the series/media listing surfaces
leaked hidden libraries to any authenticated account — series endpoints leaked hidden libraries; **2026-10-29 enforcement moved to RLS** — the
were name-only across ALL libraries. Fixed; do not regress. dashboard section queries had silently never carried the clauses,
and the invariant test went red → green and stays as the auditor.
## Tests ## 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. # Declared after `COPY . .` so a version bump only invalidates this layer.
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5. # The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
ARG APP_VERSION=dev 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) # Build Go binary (cached unless Go files or generated code changes)
RUN --mount=type=cache,target=/root/go/pkg/mod \ RUN --mount=type=cache,target=/root/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \ 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 -o main ./cmd/server
# Materialize the Go module cache into an image layer so the test-runner # 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 # Override with: CONTAINER_RUNTIME=podman make rebuild-app
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null) 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). # 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. # 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 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 - **Podman** (recommended) or Docker
- **5 minutes** of your time - **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 ```bash
# 1. Clone the repository mkdir bookhoard && cd bookhoard
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git mkdir uploads
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
``` ```
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 # Application Configuration
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly JWT_SECRET: ${JWT_SECRET}
- **Offline Queue**: Changes sync when you reconnect, priority-processed SERVER_PORT: ${SERVER_PORT:-8765}
- **Conflict Resolution**: Smart handling when same book read on multiple devices # IMPORTANT: Device sync requires full URL with protocol
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID # Local: http://localhost:8765
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi # Local network: http://192.168.1.X:8765
- **Format Conversion**: On-the-fly EPUB→KEPUB conversion (for upcoming native Kobo support) # 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 # Conversion Service Configuration
- **Smart Search**: Partial matching with fuzzy search fallback for typos BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
- **Advanced Filtering**: Filter by author, series, genre, language, year, cover images BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
- **Dynamic Sorting**: By title, author, date added, published date, page count, series BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
- **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
### 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 # System timezone (fallback for server-side time operations)
- **Device Shelf Mappings**: Map collections to device shelves (used by native Kobo sync, coming soon) TZ: ${TZ:-UTC}
- **Test Before Creating**: Preview which books match your rules 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 # Named Volumes
volumes:
- **Multi-Library Support**: Ebooks, Comics, and Manga with type-specific file formats postgres_data:
- **Multiple Folders**: Add multiple scanning folders per library bookhoard_conversion_cache:
- **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
``` ```
--- ---
+10
View File
@@ -60,3 +60,13 @@ variables:
name: book_sha256 name: book_sha256
- secret: true - secret: true
name: book_uuid 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
@@ -0,0 +1,23 @@
info:
name: Retry Processing Issue
type: http
seq: 18
http:
method: POST
url: '{{base_url}}/api/libraries/{{ebook_library_id}}/issues/{{issueid}}/retry'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Retry Processing Issue
`POST /api/libraries/:id/issues/:issueId/retry` — auth: jwt+admin.
Canonical reference: docs/developer/api/libraries/retry_processing_issue.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 -1
View File
@@ -1,7 +1,7 @@
info: info:
name: Scan Library name: Scan Library
type: http type: http
seq: 18 seq: 19
http: http:
method: POST method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info: info:
name: Set Library Visibility name: Set Library Visibility
type: http type: http
seq: 19 seq: 20
http: http:
method: POST method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info: info:
name: Update Library name: Update Library
type: http type: http
seq: 20 seq: 21
http: http:
method: PUT method: PUT
+1 -1
View File
@@ -1,7 +1,7 @@
info: info:
name: Update Scan Settings name: Update Scan Settings
type: http type: http
seq: 21 seq: 22
http: http:
method: PUT method: PUT
+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);
});
+23 -2
View File
@@ -10,6 +10,7 @@ import (
"bookhoard/internal/router" "bookhoard/internal/router"
"bookhoard/internal/services" "bookhoard/internal/services"
"bookhoard/internal/sync" "bookhoard/internal/sync"
"bookhoard/internal/version"
"bookhoard/templates" "bookhoard/templates"
"context" "context"
"log" "log"
@@ -33,6 +34,7 @@ func (cv *CustomValidator) Validate(i interface{}) error {
} }
func main() { func main() {
log.Printf("Bookhoard %s (commit %s) starting", version.Version, version.Commit)
cfg := config.LoadConfig() cfg := config.LoadConfig()
dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL()) dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
@@ -41,7 +43,12 @@ func main() {
} }
defer dbPool.Close() 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 // Initialize database schema
log.Println("🔧 Ensuring database schema is initialized...") 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 // 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. // 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) registry := database.NewSettingsRegistry(queries)
if err := registry.Load(ctx); err != nil { if err := registry.Load(ctx); err != nil {
log.Printf("⚠️ Could not load system settings (using defaults): %v", err) log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
@@ -165,10 +175,18 @@ func main() {
dashboardHandler := handlers.NewDashboardHandler(queries) dashboardHandler := handlers.NewDashboardHandler(queries)
seriesHandler := handlers.NewSeriesHandler(queries) seriesHandler := handlers.NewSeriesHandler(queries)
filtersHandler := handlers.NewFiltersHandler(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) matchingHandler := handlers.NewMatchingHandler(queries, connManager)
jobsHandler := handlers.NewJobsHandler(queries, worker) 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() e := echo.New()
// Set up validator // Set up validator
@@ -229,6 +247,9 @@ func main() {
CollectionHandler: collectionHandler, CollectionHandler: collectionHandler,
FiltersHandler: filtersHandler, FiltersHandler: filtersHandler,
DashboardHandler: dashboardHandler, DashboardHandler: dashboardHandler,
PodcastHandler: podcastHandler,
PodcastService: podcastService,
PodcastWorker: podcastWorker,
DashboardService: dashboardService, DashboardService: dashboardService,
SeriesHandler: seriesHandler, SeriesHandler: seriesHandler,
OPDSHandler: opdsHandler, OPDSHandler: opdsHandler,
@@ -0,0 +1,87 @@
package main
// Regression tests for the archive reconciliation pass: a multi-folder
// library must be reconciled against the UNION of its folders' walks, not
// once per folder. The old per-folder loop listed every library item
// against each single folder's walk, so with two registered folders every
// item living under the other folder was flagged "missing (1/2)" on every
// scan (prod: 743/743 audiobooks stuck at count 1 — unarchivable only
// because each scan's walk kept clearing them), and with three or more
// folders the crossed marks would have reached the archive threshold
// mid-pass and archived real content. Folder-book roots (directory
// file_path) must also count as present across folder boundaries — the
// file walk never emits a directory.
import (
"context"
"fmt"
"path/filepath"
"testing"
"time"
"bookhoard/internal/database"
"bookhoard/internal/services"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// scanTestFolders runs a synchronous scan over several folders — the
// multi-folder shape the archive pass must get right (scanTestFolder
// covers the single-folder case).
func scanTestFolders(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folders []string) {
t.Helper()
scanner := services.NewMediaScanner(setup.DB)
defer func() { _ = scanner.Close() }()
scanner.SetAdminID(adminID)
require.NoError(t, scanner.SetFolders(folders, false))
require.NoError(t, scanner.ScanFolders(context.Background()))
}
func TestArchivePassMultiFolderLibraryKeepsEverythingPresent(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
libID, folderA := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Archive MultiFolder %d", time.Now().UnixNano()), "audiobooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) }) // after setup.Close defer (LIFO): pool still open
// Second registered folder in the SAME library — the shape the
// per-folder reconciliation got wrong.
folderB := t.TempDir()
_, err := setup.DB.AddLibraryFolder(context.Background(), database.AddLibraryFolderParams{
LibraryID: libID,
FolderPath: folderB,
})
require.NoError(t, err)
admin, _ := moveTestAdminID(t, setup)
// Folder A holds a lone single-file book; folder B holds a
// folder-of-tracks book whose file_path is a DIRECTORY.
writeTestTrack(t, filepath.Join(folderA, "Author A", "Lone Book", "lonely.mp3"))
writeTestTrack(t, filepath.Join(folderB, "Author B", "Folder Book", "01 First.mp3"))
writeTestTrack(t, filepath.Join(folderB, "Author B", "Folder Book", "02 Second.mp3"))
// Two consecutive scans: the first proves no flags are raised, the
// second proves the flag/walk bounce (clear → re-flag) is gone too.
for scanNo := 1; scanNo <= 2; scanNo++ {
scanTestFolders(t, setup, admin, []string{folderA, folderB})
items := listLibraryAudioItems(t, setup, libID)
var singleFile, folderBook bool
for _, item := range items {
assert.False(t, item.ArchivedAt.Valid, "scan %d: item %q must not be archived", scanNo, item.FilePath)
assert.Equal(t, int32(0), item.MissingScanCount, "scan %d: item %q must not be flagged missing", scanNo, item.FilePath)
switch item.FilePath {
case "Author A/Lone Book/lonely.mp3":
singleFile = true
case "Author B/Folder Book":
folderBook = true
}
}
require.True(t, singleFile, "scan %d: single-file book must exist", scanNo)
require.True(t, folderBook, "scan %d: folder book must exist at its directory path", scanNo)
require.Len(t, items, 2, "scan %d: exactly the two books, no per-track strays", scanNo)
}
}
+13 -2
View File
@@ -25,6 +25,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -288,7 +289,12 @@ func TestAudiobookTrackEndpoints(t *testing.T) {
resp, err := client.Do(req) resp, err := client.Do(req)
require.NoError(t, err) require.NoError(t, err)
_ = resp.Body.Close() _ = 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 // Restore visibility so harness cleanup ordering never sees a
@@ -411,7 +417,12 @@ func TestAudiobookTagFallbacks(t *testing.T) {
require.Len(t, item.Narrators, 1, "composer tag fills narrators (Audible convention)") require.Len(t, item.Narrators, 1, "composer tag fills narrators (Audible convention)")
require.Equal(t, "Tag Narrator", item.Narrators[0]) require.Equal(t, "Tag Narrator", item.Narrators[0])
require.Equal(t, "Tag Series", item.Series.String, "grouping tag fills series") require.Equal(t, "Tag Series", item.Series.String, "grouping tag fills series")
require.Equal(t, "Audiobook", item.Genre.String, "genre tag fills genre") // 885d6140 demoted format words ("Audiobook") to tags-only: the
// genre column stays empty and the raw candidate lands in tags.
// (The commit message declares this test's update; the edit itself
// never landed — pinned to the demoted contract now.)
require.Empty(t, item.Genre.String, "demoted format words never win the genre column")
assert.Contains(t, item.Tags, "Audiobook", "the demoted candidate lands as a tag")
require.False(t, item.DatePublished.Time.IsZero(), "date tag fills publish date") require.False(t, item.DatePublished.Time.IsZero(), "date tag fills publish date")
require.Equal(t, 2020, item.DatePublished.Time.Year()) require.Equal(t, 2020, item.DatePublished.Time.Year())
// ASIN/abridged/subtitle arrive only through Audible freeform atoms, // ASIN/abridged/subtitle arrive only through Audible freeform atoms,
+1 -4
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "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 { func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID string, count int) []string {
ctx := context.Background()
libUUID, err := uuid.Parse(libraryID) libUUID, err := uuid.Parse(libraryID)
require.NoError(t, err, "Should parse library ID") 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++ { for i := 0; i < count; i++ {
genre := genres[i%len(genres)] 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}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Preview Test Book " + string(rune('A'+i)), Title: "Preview Test Book " + string(rune('A'+i)),
Authors: []string{"Test Author " + string(rune('A'+i))}, Authors: []string{"Test Author " + string(rune('A'+i))},
+1 -1
View File
@@ -108,7 +108,7 @@ func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uui
}) })
for title, genre := range titlesByGenre { 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}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: title, Title: title,
Authors: []string{"Smart Rule Author"}, Authors: []string{"Smart Rule Author"},
@@ -113,7 +113,7 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
params.AlternateInfo = []byte(v) 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") require.NoError(t, err, "Should create media item")
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16]) 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}, 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") require.NoError(t, err, "Should create ebook")
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16]) 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 // Create a media item with ALL metadata fields populated
alternateInfoStr := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}` alternateInfoStr := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
var mediaItem database.MediaItems 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}, LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "Complete Metadata Comic", Title: "Complete Metadata Comic",
FilePath: "/test/complete.cbz", 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") require.NoError(t, err, "Should parse library UUID")
// Insert test media item with full comic metadata // 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}, LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "Test Manga", Title: "Test Manga",
FilePath: "/test/manga.cbz", FilePath: "/test/manga.cbz",
@@ -64,7 +64,7 @@ func TestComicMetadataExtraction(t *testing.T) {
parsedLibraryID, err := uuid.Parse(libraryID) parsedLibraryID, err := uuid.Parse(libraryID)
require.NoError(t, err, "Should parse library UUID") 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}, LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "Test Comic", Title: "Test Comic",
FilePath: "/test/comic.cbz", FilePath: "/test/comic.cbz",
@@ -94,7 +94,7 @@ func TestComicMetadataExtraction(t *testing.T) {
parsedLibraryID, err := uuid.Parse(libraryID) parsedLibraryID, err := uuid.Parse(libraryID)
require.NoError(t, err, "Should parse library UUID") 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}, LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "Minimal Comic", Title: "Minimal Comic",
FilePath: "/test/minimal.cbz", FilePath: "/test/minimal.cbz",
@@ -136,7 +136,7 @@ func TestReadingDirectionAPI(t *testing.T) {
} }
for _, tc := range testCases { 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}, LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: tc.title, Title: tc.title,
FilePath: "/test/" + tc.title + ".cbz", FilePath: "/test/" + tc.title + ".cbz",
@@ -205,7 +205,7 @@ func TestUniversalMetadataFields(t *testing.T) {
require.NoError(t, err, "Should parse ebook library UUID") require.NoError(t, err, "Should parse ebook library UUID")
t.Run("Comic with universal fields", func(t *testing.T) { 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}, LibraryID: pgtype.UUID{Bytes: parsedComicLibraryID, Valid: true},
Title: "Comic with Universal Metadata", Title: "Comic with Universal Metadata",
FilePath: "/test/comic.cbz", FilePath: "/test/comic.cbz",
@@ -231,7 +231,7 @@ func TestUniversalMetadataFields(t *testing.T) {
}) })
t.Run("Ebook with universal fields", func(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}, LibraryID: pgtype.UUID{Bytes: parsedEbookLibraryID, Valid: true},
Title: "Ebook with Universal Metadata", Title: "Ebook with Universal Metadata",
FilePath: "/test/book.epub", FilePath: "/test/book.epub",
@@ -270,7 +270,7 @@ func TestComicSpecificFields(t *testing.T) {
t.Run("Alternate series info as JSONB", func(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}` 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}, LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "X-Men with Alternate Series", Title: "X-Men with Alternate Series",
FilePath: "/test/xmen.cbz", FilePath: "/test/xmen.cbz",
+24 -1
View File
@@ -120,7 +120,7 @@ func createContractFixture(t *testing.T, setup *TestServerSetup) contractFixture
seriesName := fmt.Sprintf("Test Contract Series %d", time.Now().UnixNano()) seriesName := fmt.Sprintf("Test Contract Series %d", time.Now().UnixNano())
itemTitle := fmt.Sprintf("Test Contract Item %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}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: itemTitle, Title: itemTitle,
Authors: []string{"Test Contract Author"}, Authors: []string{"Test Contract Author"},
@@ -452,9 +452,13 @@ func TestContractPin_KoboSyncFromServer(t *testing.T) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// bookInfoKeys is the BookInfo JSON every card surface emits. // bookInfoKeys is the BookInfo JSON every card surface emits.
// 2026-10-27 (search-progress batch): + viewer_percentage (nil = no
// viewer data; only the search-unified adapter populates it today)
// and + library_name (same deal). docs/contract_pins.md changelog.
var bookInfoKeys = []string{ var bookInfoKeys = []string{
"media_item_id", "title", "author", "cover_image_path", "media_item_id", "title", "author", "cover_image_path",
"has_conflict", "format_group", "badge", "has_conflict", "format_group", "badge",
"viewer_percentage", "library_name",
} }
func collectionURL(setup *TestServerSetup, id string) string { func collectionURL(setup *TestServerSetup, id string) string {
@@ -667,6 +671,12 @@ func TestContractPin_SearchJSON(t *testing.T) {
// (docs/contract_pins.md changelog). // (docs/contract_pins.md changelog).
"subtitle", "explicit", "authors", "authors_search", "subtitle", "explicit", "authors", "authors_search",
"publishers", "publishers_search", "narrators_search", "publishers", "publishers_search", "narrators_search",
// 2026-10-27 (search-progress batch): + viewer_percentage —
// the requester's reading_progress.percentage (LEFT JOIN on
// the same user_id param the visibility join binds), null
// when they never opened the book (docs/contract_pins.md
// changelog).
"viewer_percentage",
} }
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID) url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
@@ -742,6 +752,19 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
"tags_search", "contributors_search", "narrators_search", "file_sha256", "tags_search", "contributors_search", "narrators_search", "file_sha256",
"opf_identifier", "opf_uuid", "hash_confidence", "library_name", "opf_identifier", "opf_uuid", "hash_confidence", "library_name",
"library_type_name_2", "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{ resolvedKeys := []string{
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description", "id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
+6 -7
View File
@@ -89,7 +89,7 @@ func TestFacetRegistryParamsFilter(t *testing.T) {
if mutate != nil { if mutate != nil {
mutate(&p) mutate(&p)
} }
_, err := setup.DB.CreateMediaItem(ctx, p) _, err := setup.DB.CreateMediaItem(seedCtx(), p)
require.NoError(t, err, "create %s", title) 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. // Two items share the genre; one carries the language + narrator.
for _, title := range []string{"Facet List One", "Facet List Two"} { 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, LibraryID: library.ID,
Title: title, Title: title,
Authors: []string{"Facet Listing Author"}, Authors: []string{"Facet Listing Author"},
@@ -206,7 +206,7 @@ func TestFacetsEndpoint(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
} }
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ _, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID, LibraryID: library.ID,
Title: "Facet List Three", Title: "Facet List Three",
Authors: []string{"Facet Listing Author"}, 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). // "Baen Books") cross-match at 0.3+ (session gotcha).
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) { func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
t.Helper() t.Helper()
ctx := context.Background()
books := []struct { books := []struct {
title string title string
genre string genre string
@@ -329,7 +328,7 @@ func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UU
{prefix + " Gamma", "history", "Pinecrest"}, {prefix + " Gamma", "history", "Pinecrest"},
} }
for _, b := range books { for _, b := range books {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ _, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libraryID, LibraryID: libraryID,
Title: b.title, Title: b.title,
Authors: []string{prefix + " Author"}, Authors: []string{prefix + " Author"},
@@ -426,7 +425,7 @@ func TestFacetCountsUnderFilters(t *testing.T) {
facetFixtureBooks(t, setup, library.ID, "Counts") facetFixtureBooks(t, setup, library.ID, "Counts")
// A book in the HIDDEN library: distinctive values that must never // A book in the HIDDEN library: distinctive values that must never
// surface for the regular user. // surface for the regular user.
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ _, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: hiddenLib.ID, LibraryID: hiddenLib.ID,
Title: "Counts Hidden Book", Title: "Counts Hidden Book",
Authors: []string{"Counts Secret Author"}, Authors: []string{"Counts Secret Author"},
@@ -559,7 +558,7 @@ func TestFieldValuesAllLibraries(t *testing.T) {
id pgtype.UUID id pgtype.UUID
name string name string
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} { }{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ _, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: lib.id, LibraryID: lib.id,
Title: fmt.Sprintf("Field Values %s Book", lib.name), Title: fmt.Sprintf("Field Values %s Book", lib.name),
Authors: []string{"Verdana Scribe"}, Authors: []string{"Verdana Scribe"},
+18 -31
View File
@@ -156,7 +156,7 @@ func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, n
} }
books[i].TagsSearch = lowered 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) require.NoError(t, err, "create %s", books[i].Title)
} }
return libUUID, library.ID return libUUID, library.ID
@@ -267,11 +267,9 @@ func TestFilterComposedSemantics(t *testing.T) {
assert.Len(t, got, 4, "mime_type is") assert.Len(t, got, 4, "mime_type is")
// Series full membership: the alternate_info series matches too. // Series full membership: the alternate_info series matches too.
ctx := context.Background() seedExec(t, setup.DBPool,
_, err := setup.DBPool.Exec(ctx,
`UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb `UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
WHERE title = $1`, "Sem Fen") WHERE title = $1`, "Sem Fen")
require.NoError(t, err)
got = titles(qfAll(qfRule("series", "contains", "Quill Chron"))) got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership") assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
got = titles(qfAll(qfRule("series", "is", "Quill Chronicles"))) got = titles(qfAll(qfRule("series", "is", "Quill Chronicles")))
@@ -327,43 +325,34 @@ func TestFilterUserRelativeFields(t *testing.T) {
// Reader A: reading Tide (50%), finished Stone, rated Tide 8, // Reader A: reading Tide (50%), finished Stone, rated Tide 8,
// reviewed Fen, noted on Tide ("the catharsis scene"), highlighted // reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
// Stone, time on Wick (2h), collection holding Tide. // 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())`, `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) tideID, aID, stoneID, aID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`, `INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
tideID, bID) tideID, bID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID) `INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID) `INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID) `INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID) `INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID) `INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`, `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) aID, wickID, aID, tideID)
require.NoError(t, err)
var collID pgtype.UUID var collID pgtype.UUID
err = setup.DBPool.QueryRow(ctx, err := setup.DBPool.QueryRow(context.Background(),
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`, `INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID) aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) }) t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
_, err = setup.DBPool.Exec(ctx, seedExec(t, setup.DBPool,
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID) `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 { titlesFor := func(token string, qf string) []string {
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}} p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
@@ -445,7 +434,7 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
// Add a second distinct author+genre pair (trigram-disjoint). // Add a second distinct author+genre pair (trigram-disjoint).
ctx := context.Background() ctx := context.Background()
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ _, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Sug Dune", Title: "Sug Dune",
Authors: []string{"Zephyrine Hale"}, Authors: []string{"Zephyrine Hale"},
@@ -572,18 +561,17 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
// offered with the surviving count; zero-match containers never are. // offered with the surviving count; zero-match containers never are.
userA := getRegularUserID(t, setup.DB) userA := getRegularUserID(t, setup.DB)
var collID pgtype.UUID var collID pgtype.UUID
err = setup.DBPool.QueryRow(ctx, err = setup.DBPool.QueryRow(context.Background(),
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`, `INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
pgtype.UUID{Bytes: [16]byte(userA), Valid: true}, pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID) fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) }) t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
var tideID pgtype.UUID 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) require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx, seedExec(t, setup.DBPool,
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID) `INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
require.NoError(t, err)
status, results = suggest("collection", " ", "") status, results = suggest("collection", " ", "")
require.Equal(t, http.StatusOK, status) require.Equal(t, http.StatusOK, status)
@@ -632,7 +620,7 @@ func TestFilterFamilyLens(t *testing.T) {
id pgtype.UUID id pgtype.UUID
title string title string
}{{visibleID, "Lens Vis Book"}, {hiddenID, "Lens Hid Book"}} { }{{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, LibraryID: lib.id, Title: lib.title,
Authors: []string{"Lens Secret Author"}, Authors: []string{"Lens Secret Author"},
FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()), FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()),
@@ -641,10 +629,9 @@ func TestFilterFamilyLens(t *testing.T) {
}) })
require.NoError(t, err) 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)`, `INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true}) hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
require.NoError(t, err)
search := func(params url.Values) (int, []map[string]interface{}) { search := func(params url.Values) (int, []map[string]interface{}) {
return qfSearch(t, setup, setup.RegularToken, params) return qfSearch(t, setup, setup.RegularToken, params)
+444
View File
@@ -0,0 +1,444 @@
package main
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMetadataWriteToFile pins the write-to-file endpoint (2026-10-27):
// POST /api/media-items/:id/write-metadata (admin only) persists the
// item's CURRENT database metadata into the artifact — embedded OPF for
// EPUBs, a Calibre-style sidecar for formats that can't carry metadata
// — then re-extracts. Sidecar naming: metadata.opf when the item is the
// only book in its directory, <basename>.opf otherwise (one book's
// sidecar must never poison dir-mates).
//
// Fixture files live under /app/uploads (the container's library
// folder) and are removed on cleanup; the test library delete
// cascades the items.
func TestMetadataWriteToFile(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
ctx := context.Background()
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Metadata Write Library", true)
uploadsDir := "/app/uploads"
insertItem := func(relPath, title string) string {
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, $2, $3, 'ebook', 'application/epub+zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
libID, title, relPath,
).Scan(&itemID)
require.NoError(t, err)
return itemID
}
writeEPUB := func(absPath, title string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(absPath), 0o755))
var buf bytes.Buffer
w := zip.NewWriter(&buf)
mimetype, _ := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
_, _ = mimetype.Write([]byte("application/epub+zip"))
container, _ := w.Create("META-INF/container.xml")
_, _ = container.Write([]byte(`<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`))
opf, _ := w.Create("OEBPS/content.opf")
_, _ = opf.Write([]byte(fmt.Sprintf(`<?xml version="1.0"?><package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title id="t1">%s</dc:title><dc:creator>Original Author</dc:creator><dc:identifier id="pub-id">urn:uuid:abcd</dc:identifier></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="c1"/></spine></package>`, title)))
ch, _ := w.Create("OEBPS/c1.xhtml")
_, _ = ch.Write([]byte("<html><body>x</body></html>"))
require.NoError(t, w.Close())
require.NoError(t, os.WriteFile(absPath, buf.Bytes(), 0o644))
}
callWrite := func(token, itemID string) (int, map[string]interface{}) {
req, _ := http.NewRequest("POST", fmt.Sprintf("%s/api/media-items/%s/write-metadata", setup.Server.URL, itemID), nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
var body map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
return resp.StatusCode, body
}
// Edit the item's metadata the way the editor does (PUT), then ask
// the endpoint to persist it.
editItem := func(itemID string, fields map[string]interface{}) {
body, _ := json.Marshal(fields)
req, _ := http.NewRequest("PUT", fmt.Sprintf("%s/api/media-items/%s", setup.Server.URL, itemID), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "PUT should succeed")
}
t.Run("non-admin is forbidden", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/forbidden/book.epub", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
writeEPUB(abs, "Forbidden Book")
itemID := insertItem(rel, "Forbidden Book")
code, _ := callWrite(setup.RegularToken, itemID)
assert.Equal(t, http.StatusForbidden, code)
})
t.Run("missing file errors without sidecar litter", func(t *testing.T) {
itemID := insertItem(fmt.Sprintf("test-metadata-write-%d/ghost/book.epub", os.Getpid()), "Ghost Book")
code, body := callWrite(setup.Token, itemID)
assert.Equal(t, http.StatusInternalServerError, code)
assert.Contains(t, body["error"], "missing on disk")
})
t.Run("EPUB metadata is embedded and rescan keeps it", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/solo/book.epub", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
writeEPUB(abs, "Original EPUB Title")
itemID := insertItem(rel, "Original EPUB Title")
editItem(itemID, map[string]interface{}{
"title": "Embedded New Title",
"authors": []string{"Embedded Author"},
"series": "Embedded Series",
"series_number": 2,
})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "embedded-opf", body["metadata_target"])
// The zip on disk now carries the new values — verified by
// PARSING the rewritten OPF (not string matching), with the
// ORIGINAL unique-identifier surviving the splice.
reader, err := zip.OpenReader(abs)
require.NoError(t, err)
var opfContent []byte
for _, f := range reader.File {
if f.Name == "OEBPS/content.opf" {
rc, err := f.Open()
require.NoError(t, err)
opfContent, err = io.ReadAll(rc)
require.NoError(t, rc.Close())
require.NoError(t, err)
}
}
reader.Close()
var opf struct {
Metadata struct {
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
Metas []struct {
Name string `xml:"name,attr"`
Content string `xml:"content,attr"`
} `xml:"meta"`
} `xml:"metadata"`
}
require.NoError(t, xml.Unmarshal(opfContent, &opf))
require.NotEmpty(t, opf.Metadata.Titles, "dc:title present in written OPF")
assert.Equal(t, "Embedded New Title", opf.Metadata.Titles[0])
assert.NotContains(t, opf.Metadata.Titles, "Original EPUB Title", "old title replaced")
require.NotEmpty(t, opf.Metadata.Creators)
var seriesMeta string
for _, m := range opf.Metadata.Metas {
if m.Name == "calibre:series" {
seriesMeta = m.Content
}
}
assert.Equal(t, "Embedded Series", seriesMeta)
assert.Contains(t, string(opfContent), "urn:uuid:abcd", "original identifier preserved for unique-identifier")
// The inline rescan re-extracted from the file: the DB title
// still reads the written value.
var title string
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
assert.Equal(t, "Embedded New Title", title)
})
t.Run("single-book dir gets a Calibre-style metadata.opf sidecar", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/alone/book.pdf", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
require.NoError(t, os.WriteFile(abs, []byte("%PDF-1.4 fake"), 0o644))
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Sidecar Book', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
libID, rel,
).Scan(&itemID)
require.NoError(t, err)
editItem(itemID, map[string]interface{}{"title": "Sidecar New Title"})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "sidecar-opf", body["metadata_target"])
sidecar := filepath.Join(filepath.Dir(abs), "metadata.opf")
data, err := os.ReadFile(sidecar)
require.NoError(t, err, "metadata.opf must exist next to a lone book")
// Parse the sidecar like the scanner does: title must be present
// as a dc:title ELEMENT, not just a substring.
var opf struct {
Metadata struct {
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
} `xml:"metadata"`
}
require.NoError(t, xml.Unmarshal(data, &opf))
require.NotEmpty(t, opf.Metadata.Titles)
assert.Equal(t, "Sidecar New Title", opf.Metadata.Titles[0])
})
t.Run("shared dir falls back to <basename>.opf", func(t *testing.T) {
// PDFs can't carry metadata, so they take the sidecar path —
// and with two books in one directory, the sidecar must be
// per-file or A's write would poison B's next scan.
dir := fmt.Sprintf("test-metadata-write-%d/shared", os.Getpid())
relA, relB := dir+"/a.pdf", dir+"/b.pdf"
absA, absB := filepath.Join(uploadsDir, relA), filepath.Join(uploadsDir, relB)
require.NoError(t, os.MkdirAll(filepath.Dir(absA), 0o755))
require.NoError(t, os.WriteFile(absA, []byte("%PDF-1.4 fake a"), 0o644))
require.NoError(t, os.WriteFile(absB, []byte("%PDF-1.4 fake b"), 0o644))
var itemA string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Book A', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
libID, relA,
).Scan(&itemA)
require.NoError(t, err)
var itemB string
err = setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Book B', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
libID, relB,
).Scan(&itemB)
require.NoError(t, err)
editItem(itemA, map[string]interface{}{"title": "Book A Rewritten"})
code, body := callWrite(setup.Token, itemA)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "sidecar-opf", body["metadata_target"])
// a.opf carries A's metadata; NO dir-scoped metadata.opf
// may appear (it would poison B's next scan), and B gets no
// sidecar from A's write. The name strips the extension,
// matching the scanner's {basename}.jpg cover convention.
data, err := os.ReadFile(filepath.Join(uploadsDir, dir, "a.opf"))
require.NoError(t, err, "shared dir must use <basename>.opf")
assert.Contains(t, string(data), "Book A Rewritten")
_, err = os.Stat(filepath.Join(uploadsDir, dir, "metadata.opf"))
assert.True(t, os.IsNotExist(err), "no dir-scoped sidecar over a multi-book folder")
_, err = os.Stat(filepath.Join(uploadsDir, dir, "b.opf"))
assert.True(t, os.IsNotExist(err), "B gets no sidecar from A's write")
_ = itemB
})
t.Run("audio tags rewrite round-trips for every container", func(t *testing.T) {
// Each single-file container gets its own generated fixture
// (ffmpeg at test runtime — no binary media in git), its own
// write, and an INDEPENDENT verification pass: ffprobe the
// rewritten file and assert every mapped key landed. This is the
// "check the file's metadata yourself" contract per format:
//
// album/artist/composer/grouping/date/genre → all four
// subtitle → mp3 + flac only
// (the mp4 muxer
// drops it)
cases := []struct {
ext string
audioCodec string
wantSubtitle bool
}{
{ext: ".mp3", audioCodec: "libmp3lame", wantSubtitle: true},
{ext: ".flac", audioCodec: "flac", wantSubtitle: true},
{ext: ".m4a", audioCodec: "aac", wantSubtitle: false},
{ext: ".m4b", audioCodec: "aac", wantSubtitle: false},
}
for _, tc := range cases {
t.Run(tc.ext, func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/audio-%s/book%s", os.Getpid(), strings.TrimPrefix(tc.ext, "."), tc.ext)
abs := filepath.Join(uploadsDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
gen := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-t", "2", "-c:a", tc.audioCodec,
"-metadata", "title=Generated Fixture",
"-metadata", "album=Generated Fixture",
abs)
if out, err := gen.CombinedOutput(); err != nil {
t.Skipf("fixture generation failed for %s (%v): %s", tc.ext, err, string(out))
}
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Generated Fixture', $2, 'audiobook', $3, 100, NOW(), NOW(), NOW()) RETURNING id`,
libID, rel, "audio/"+strings.TrimPrefix(tc.ext, "."),
).Scan(&itemID)
require.NoError(t, err)
// Follow the real editor flow: PUT first (records
// metadata_overrides, so the inline rescan keeps the
// edited values instead of folder-structure derivations).
editItem(itemID, map[string]interface{}{
"title": "Audio New Title",
"subtitle": "Audio Sub",
"authors": []string{"Audio Author"},
"narrators": []string{"Narrator Person"},
"series": "Audio Series",
"series_number": 1.5,
"date_published": "1953-04-16",
"tags": []string{"Science"},
})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code, "%s write", tc.ext)
assert.Equal(t, "audio-tags", body["metadata_target"], tc.ext)
// ---- verify the FILE's metadata independently ----
probe := func() map[string]string {
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
"format_tags", "-of", "json", abs).Output()
require.NoError(t, err)
var parsed struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
}
require.NoError(t, json.Unmarshal(out, &parsed))
return parsed.Format.Tags
}
tags := probe()
assert.Equal(t, "Audio New Title", tags["album"], "%s: the scanner's album rung", tc.ext)
assert.Equal(t, "Audio New Title", tags["title"], tc.ext)
assert.Equal(t, "Audio Author", tags["artist"], tc.ext)
assert.Equal(t, "Narrator Person", tags["composer"], "%s: narrators ride composer", tc.ext)
assert.Equal(t, "Audio Series", tags["grouping"], tc.ext)
assert.Equal(t, "Science", tags["genre"], tc.ext)
assert.Equal(t, "1953-04-16", tags["date"], tc.ext)
assert.NotContains(t, tags["album"], "Generated Fixture", "%s: old tag replaced", tc.ext)
if tc.wantSubtitle {
assert.Equal(t, "Audio Sub", tags["subtitle"], "%s: subtitle round-trips", tc.ext)
} else {
assert.Empty(t, tags["subtitle"], "%s: subtitle must not be written where it can't round-trip", tc.ext)
writeMap := body["write"].(map[string]interface{})
assert.NotContains(t, writeMap["fields_written"], "subtitle", "%s: honest fields_written", tc.ext)
}
// Still valid, same-length audio after the rewrite.
durOut, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
"format=duration", "-of", "default=noprint_wrappers=1:nokey=1", abs).Output()
require.NoError(t, err)
dur, err := strconv.ParseFloat(strings.TrimSpace(string(durOut)), 64)
require.NoError(t, err)
assert.Greater(t, dur, 1.5, "%s: audio survived the rewrite", tc.ext)
// The inline rescan re-read the tags: the DB title stays
// the written one.
var title string
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
assert.Equal(t, "Audio New Title", title, tc.ext)
})
}
})
t.Run("CBZ gets ComicInfo.xml injected and rescan keeps it", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/comic/book.cbz", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
// Minimal archive: one "page" file (a text stub — cover
// extraction failing is tolerated by the scanner) and no
// ComicInfo.xml yet.
var buf bytes.Buffer
w := zip.NewWriter(&buf)
page, _ := w.Create("page_0001.jpg")
_, _ = page.Write([]byte("not-a-real-jpeg"))
require.NoError(t, w.Close())
require.NoError(t, os.WriteFile(abs, buf.Bytes(), 0o644))
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Generated Comic', $2, 'comic_archive', 'application/zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
libID, rel,
).Scan(&itemID)
require.NoError(t, err)
editItem(itemID, map[string]interface{}{
"title": "Comic New Title",
"series": "Comic Series",
"series_number": 4,
"authors": []string{"Comic Writer"},
"tags": []string{"Superhero"},
})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "comicinfo", body["metadata_target"])
// ---- verify the FILE's ComicInfo.xml by parsing it ----
reader, err := zip.OpenReader(abs)
require.NoError(t, err)
var infoData []byte
for _, f := range reader.File {
if f.Name == "ComicInfo.xml" {
rc, err := f.Open()
require.NoError(t, err)
infoData, err = io.ReadAll(rc)
require.NoError(t, rc.Close())
require.NoError(t, err)
}
}
reader.Close()
require.NotEmpty(t, infoData, "ComicInfo.xml must exist inside the CBZ")
var info struct {
Title string `xml:"Title"`
Series string `xml:"Series"`
Number int `xml:"Number"`
Writer string `xml:"Writer"`
}
require.NoError(t, xml.Unmarshal(infoData, &info))
assert.Equal(t, "Comic New Title", info.Title)
assert.Equal(t, "Comic Series", info.Series)
assert.Equal(t, 4, info.Number)
assert.Equal(t, "Comic Writer", info.Writer)
// The inline rescan reads the ComicInfo back: the DB title and
// series stay the written values.
var title, series string
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT title, series FROM media_items WHERE id = $1`, itemID).Scan(&title, &series))
assert.Equal(t, "Comic New Title", title)
assert.Equal(t, "Comic Series", series)
})
t.Cleanup(func() {
_ = os.RemoveAll(filepath.Join(uploadsDir, fmt.Sprintf("test-metadata-write-%d", os.Getpid())))
})
}
+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, LibraryID: library.ID,
Title: "Ownership Suite Item", Title: "Ownership Suite Item",
Authors: []string{"Suite Author"}, Authors: []string{"Suite Author"},
@@ -269,7 +269,7 @@ func (s *OwnershipAndAdminTestSuite) TestDelete_StorageDownRefused() {
}) })
require.NoError(s.T(), err) require.NoError(s.T(), err)
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{ item, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID, LibraryID: library.ID,
Title: "Offline Storage Item", Title: "Offline Storage Item",
FilePath: "offline.epub", FilePath: "offline.epub",
+1 -1
View File
@@ -134,7 +134,7 @@ func TestManuallyEditedFilter(t *testing.T) {
t.Cleanup(func() { deleteLibraries(t, setup, libID) }) t.Cleanup(func() { deleteLibraries(t, setup, libID) })
createBook := func(path string) pgtype.UUID { createBook := func(path string) pgtype.UUID {
created, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ created, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libID, LibraryID: libID,
Title: path, Title: path,
Authors: []string{}, Authors: []string{},
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,213 @@
package main
// Retry + scoped-scan integration tests (2026-10 Processing Issues work).
//
// - Per-issue retry re-imports a row-less import_failed ledger entry
// through the individual scanner (NOT a library scan) and closes it.
// - A missing file retries to 409 with the real reason.
// - An item-backed issue retries through the book-detail rescan
// mechanism and closes.
// - POST /api/libraries/:id/scan with folder_path must be a registered
// folder (validated, never trusted).
// - A single-folder scan must NOT run archive reconciliation for the
// library's other folders — without that guard the scoped walk would
// mark every item under the unscanned folders missing and archive them.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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"
)
func retryIssueViaAPI(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, issueID uuid.UUID) *httptest.ResponseRecorder {
t.Helper()
libUUID, err := uuid.FromBytes(libraryID.Bytes[:])
require.NoError(t, err)
req := httptest.NewRequest("POST", "/api/libraries/"+libUUID.String()+"/issues/"+issueID.String()+"/retry", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
return rec
}
func openIssuesFor(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID) []database.ListProcessingIssuesByLibraryRow {
t.Helper()
rows, err := setup.DB.ListProcessingIssuesByLibrary(context.Background(), libraryID)
require.NoError(t, err)
return rows
}
func seedRowlessIssue(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, relPath string) {
t.Helper()
_, err := setup.DBPool.Exec(context.Background(),
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
VALUES (NULL, $1, 'import_failed', 'seeded failure for retry test', 'error', $2)`,
libraryID, relPath)
require.NoError(t, err)
}
func TestRetryProcessingIssueRowless(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
admin, _ := moveTestAdminID(t, setup)
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Retry Rowless %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// A real file whose import "failed" earlier (the ledger row exists,
// the media_items row does not) — exactly the production shape the
// retry button targets.
rel := "Author/retrybook.epub"
require.NoError(t, os.MkdirAll(filepath.Join(folder, "Author"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(folder, rel), []byte("dummy epub content"), 0o644))
seedRowlessIssue(t, setup, libID, filepath.ToSlash(rel))
issues := openIssuesFor(t, setup, libID)
require.Len(t, issues, 1)
issueID, err := uuid.FromBytes(issues[0].ID.Bytes[:])
require.NoError(t, err)
rec := retryIssueViaAPI(t, setup, libID, issueID)
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
// The file imported through the individual scanner...
var count int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT count(*) FROM media_items WHERE library_id = $1 AND file_path = $2`,
libID, filepath.ToSlash(rel)).Scan(&count))
require.Equal(t, 1, count, "retry must import the file")
// ...and the ledger closed (import self-heal + explicit path resolve).
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT count(*) FROM processing_issues WHERE id = $1 AND resolved AND resolved_at IS NOT NULL`,
issues[0].ID).Scan(&count))
require.Equal(t, 1, count, "successful retry must resolve the issue")
require.Len(t, openIssuesFor(t, setup, libID), 0)
// Missing file: retry answers 409 with the real reason, issue stays.
seedRowlessIssue(t, setup, libID, "Author/vanished.epub")
issues = openIssuesFor(t, setup, libID)
require.Len(t, issues, 1)
vanishedID, err := uuid.FromBytes(issues[0].ID.Bytes[:])
require.NoError(t, err)
rec = retryIssueViaAPI(t, setup, libID, vanishedID)
require.Equal(t, http.StatusConflict, rec.Code)
assert.Contains(t, rec.Body.String(), "not found on disk")
// Item-backed issue: retry runs the book-detail rescan mechanism.
itemRow, err := setup.DB.GetMediaItemByLibraryAndPath(context.Background(),
database.GetMediaItemByLibraryAndPathParams{LibraryID: libID, FilePath: filepath.ToSlash(rel)})
require.NoError(t, err)
_, err = setup.DBPool.Exec(context.Background(),
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity)
VALUES ($1, $2, 'metadata_extraction_failed', 'seeded item issue for retry test', 'warning')`,
itemRow.ID, libID)
require.NoError(t, err)
// vanished.epub's issue is still open — pick the item-backed one.
var itemIssueID uuid.UUID
for _, open := range openIssuesFor(t, setup, libID) {
if open.MediaItemID.Valid {
itemIssueID, err = uuid.FromBytes(open.ID.Bytes[:])
require.NoError(t, err)
break
}
}
require.NotEqual(t, uuid.Nil, itemIssueID, "item-backed fixture")
rec = retryIssueViaAPI(t, setup, libID, itemIssueID)
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
var resolvedCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT count(*) FROM processing_issues WHERE id = $1 AND resolved`,
itemIssueID).Scan(&resolvedCount))
require.Equal(t, 1, resolvedCount, "successful item retry must resolve the issue")
_ = admin // scanner attribution is implicit via the library owner
}
func TestScanLibraryFolderPathValidation(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Scan Validate %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
post := func(folderPath string) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]string{"folder_path": folderPath})
req := httptest.NewRequest("POST", "/api/libraries/"+libID.String()+"/scan", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
return rec
}
// An unregistered path is rejected, never scanned.
rec := post("/tmp/definitely-not-registered")
require.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "not one of this library's registered folders")
// A registered folder scopes the scan and enqueues normally.
rec = post(folder)
require.Equal(t, http.StatusAccepted, rec.Code, "body: %s", rec.Body.String())
}
func TestScanSingleFolderSkipsArchiveReconciliation(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folderA := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Archive Guard %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// Second registered folder: the scoped scan below covers only this one.
folderB := t.TempDir()
_, err := setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: libID,
FolderPath: folderB,
})
require.NoError(t, err)
// A healthy item under folder A.
rel := "Author/archguard.epub"
require.NoError(t, os.MkdirAll(filepath.Join(folderA, "Author"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(folderA, rel), []byte("dummy epub content"), 0o644))
scanTestFolder(t, setup, admin, folderA)
item, err := setup.DB.GetMediaItemByLibraryAndPath(ctx,
database.GetMediaItemByLibraryAndPathParams{LibraryID: libID, FilePath: filepath.ToSlash(rel)})
require.NoError(t, err)
require.False(t, item.ArchivedAt.Valid)
require.Equal(t, int32(0), item.MissingScanCount)
// Scan ONLY folder B. Without the partial-scope guard the archive
// pass would see nothing from folder A and mark this item missing
// (1/2 toward archival) — the exact false-archive the guard exists
// to prevent.
scanTestFolder(t, setup, admin, folderB)
after, err := setup.DB.GetMediaItem(ctx, item.ID)
require.NoError(t, err)
require.False(t, after.ArchivedAt.Valid, "scoped scan must not archive items under unscanned folders")
require.Equal(t, int32(0), after.MissingScanCount, "scoped scan must not mark unscanned-folder items missing")
// Control: the item's own folder scans clean as before.
scanTestFolder(t, setup, admin, folderA)
after, err = setup.DB.GetMediaItem(ctx, item.ID)
require.NoError(t, err)
require.False(t, after.ArchivedAt.Valid)
require.Equal(t, int32(0), after.MissingScanCount)
}
+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") require.NoError(t, err, "Should create library")
// 4. Create media item // 4. Create media item
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{ mediaItem, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID, LibraryID: library.ID,
Title: "Test Book for Queue", Title: "Test Book for Queue",
Authors: []string{"Test Author"}, Authors: []string{"Test Author"},
+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)")
})
})
}
+1 -1
View File
@@ -78,7 +78,7 @@ func TestScanStateBulkLoad(t *testing.T) {
require.NoError(t, os.WriteFile(apiFile, []byte("api made book content"), 0644)) require.NoError(t, os.WriteFile(apiFile, []byte("api made book content"), 0644))
apiStat, err := os.Stat(apiFile) apiStat, err := os.Stat(apiFile)
require.NoError(t, err) require.NoError(t, err)
created, err := setup.DB.CreateMediaItem(context.Background(), database.CreateMediaItemParams{ created, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libID, LibraryID: libID,
Title: "API Made Book", Title: "API Made Book",
Authors: []string{"API Author"}, Authors: []string{"API Author"},
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSearchViewerProgress pins the search-progress batch (2026-10-27):
// the unified search row carries the REQUESTING user's reading progress
// (viewer_percentage) via a LEFT JOIN on the same user_id param the
// visibility join binds, the JSON endpoint serializes it, and the
// layout=dropdown HX branch renders templ rows with the completed check
// / partial bar. Fixture: three books in a test library; the FRESH
// regular user finished one (1.0), is mid-read on another (0.42) and
// never opened the third (NULL). The shared admin's search response is
// read but never written — their progress stays untouched.
func TestSearchViewerProgress(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
ctx := context.Background()
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Search Progress Library", false)
viewerID := getRegularUserID(t, setup.DB)
books := []struct {
title string
percentage string // "" = no progress row at all
}{
{title: "Progress Probe Alpha", percentage: "1.0"},
{title: "Progress Probe Beta", percentage: "0.42"},
{title: "Progress Probe Gamma", percentage: ""},
}
itemIDs := map[string]string{}
t.Cleanup(func() {
_, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
})
for _, b := range books {
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, created_at, imported_at, updated_at)
VALUES ($1, $2, $3, '2026-02-01 00:00:00+00', '2026-02-01 00:00:00+00', '2026-02-01 00:00:00+00') RETURNING id`,
libID, b.title, fmt.Sprintf("/tmp/test-search-progress/%s.epub", b.title),
).Scan(&itemID)
require.NoError(t, err)
itemIDs[b.title] = itemID
if b.percentage != "" {
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
VALUES ($1, $2, $3::float, '2026-02-02 00:00:00+00')`,
itemID, viewerID, b.percentage,
)
require.NoError(t, err)
}
}
t.Run("JSON rows carry the viewer's own percentage", func(t *testing.T) {
rows := searchJSON(t, setup, setup.RegularToken, "Progress Probe", libID)
require.Len(t, rows, 3)
byTitle := map[string]map[string]interface{}{}
for _, row := range rows {
byTitle[row["title"].(string)] = row
}
assert.Equal(t, float64(1), byTitle["Progress Probe Alpha"]["viewer_percentage"])
assert.InDelta(t, 0.42, byTitle["Progress Probe Beta"]["viewer_percentage"], 0.0001)
require.Nil(t, byTitle["Progress Probe Gamma"]["viewer_percentage"], "never-opened book must be null")
// Per-user join: the shared admin (no progress on any fixture
// book) sees null across the board — the join binds the
// REQUESTER, not a global.
adminRows := searchJSON(t, setup, setup.Token, "Progress Probe", libID)
require.Len(t, adminRows, 3)
for _, row := range adminRows {
assert.Nil(t, row["viewer_percentage"], "admin must see null for %v", row["title"])
}
})
t.Run("dropdown partial renders progress overlays", func(t *testing.T) {
req, err := http.NewRequest("GET",
setup.Server.URL+"/api/media-items/search?layout=dropdown&q=Progress+Probe&library_id="+libID, nil)
require.NoError(t, err)
req.Header.Set("Cookie", "token="+setup.RegularToken)
req.Header.Set("HX-Request", "true")
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
bodyBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
body := string(bodyBytes)
assert.Contains(t, body, "3 results for", "count label")
// Completed book: the check overlay + title (completed ≥ 0.999).
assert.Contains(t, body, "Completed", "completed overlay")
// Partial book: the bar at 42%.
assert.Contains(t, body, "width: 42.0%", "partial progress bar")
// Server-rendered rows, one per hit.
assert.Equal(t, 3, strings.Count(body, `class="search-result-item`), "one row per result")
})
}
// searchJSON runs a search as the given token and decodes the row array
// (library-scoped: the unified search is fuzzy, so an unscoped q would
// sweep in live dev rows).
func searchJSON(t *testing.T, setup *TestServerSetup, token, query, libraryID string) []map[string]interface{} {
t.Helper()
req, err := http.NewRequest("GET",
fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(query), libraryID), nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var rows []map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
return rows
}
+257
View File
@@ -0,0 +1,257 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSeriesSortAndFilter pins the series page's sort/filter contract
// (2026-10-27 feature): the /series SSR page and the /api/series JSON
// endpoint share one sort registry (services.ResolveSeriesSort) and one
// SQL CASE cascade (GetDistinctSeries). The fixture gives three series
// DISTINCT answers per sort key so ordering bugs can't hide:
//
// created author books viewer progress
// Probe Zebra 01-03 Zeta Zed 2 1 of 2 read (01-09) → reading
// Probe Mango 01-02 Alpha One 3 3 of 3 read (01-10) → finished
// Probe Apple 01-01 Midway Two 1 none → unstarted
//
// All viewer progress belongs to the FRESH regular user
// (getRegularUserID) and cascades away with the test library — the
// shared admin's reading data is never touched.
func TestSeriesSortAndFilter(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
ctx := context.Background()
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Series Filter Library", false)
viewerID := getRegularUserID(t, setup.DB)
type seriesFixture struct {
name string
author string
createdAt string
books []struct {
title string
percentage string
lastReadAt string
}
}
fixtures := []seriesFixture{
{
name: "Probe Zebra Series", author: "Zeta Zed", createdAt: "2026-01-03 12:00:00+00",
books: []struct {
title string
percentage string
lastReadAt string
}{
{title: "Zebra Book 1", percentage: "1.0", lastReadAt: "2026-01-09 08:00:00+00"},
{title: "Zebra Book 2", percentage: "0.5", lastReadAt: "2026-01-09 08:00:00+00"},
},
},
{
name: "Probe Mango Series", author: "Alpha One", createdAt: "2026-01-02 12:00:00+00",
books: []struct {
title string
percentage string
lastReadAt string
}{
{title: "Mango Book 1", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
{title: "Mango Book 2", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
{title: "Mango Book 3", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
},
},
{
name: "Probe Apple Series", author: "Midway Two", createdAt: "2026-01-01 12:00:00+00",
books: []struct {
title string
percentage string
lastReadAt string
}{
{title: "Apple Book 1", percentage: "0", lastReadAt: ""},
},
},
}
stripComments := regexp.MustCompile(`<!--.*?-->`)
// Card headings only: the empty-state panels use different h3
// classes ("text-xl font-semibold mb-2"), and "No Series Match"
// must not read as a rendered series.
nameRegex := regexp.MustCompile(`<h3 class="font-semibold text-sm line-clamp-2"[^>]*>([^<]+)</h3>`)
renderedNames := func(query string) []string {
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+query, setup.RegularToken)
if code != 200 {
t.Logf("series request %q -> %d; body head: %.500s", query, code, body)
}
require.Equal(t, 200, code)
var names []string
for _, m := range nameRegex.FindAllStringSubmatch(stripComments.ReplaceAllString(body, ""), -1) {
names = append(names, strings.TrimSpace(m[1]))
}
return names
}
t.Cleanup(func() {
_, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
})
for _, fx := range fixtures {
for _, b := range fx.books {
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, series, authors, created_at, imported_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $6, $6) RETURNING id`,
libID, b.title, fmt.Sprintf("/tmp/test-series-filter/%s.epub", b.title), fx.name, []string{fx.author}, fx.createdAt,
).Scan(&itemID)
require.NoError(t, err)
if b.percentage != "0" && b.percentage != "" {
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
VALUES ($1, $2, $3::float, $4::timestamptz)`,
itemID, viewerID, b.percentage, b.lastReadAt,
)
require.NoError(t, err)
}
}
}
t.Run("default sort is recently added", func(t *testing.T) {
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames(""))
})
t.Run("every sort key orders the grid", func(t *testing.T) {
cases := []struct {
sort string
want []string
}{
{"sort=recent", []string{"Zebra", "Mango", "Apple"}},
{"sort=recent_asc", []string{"Apple", "Mango", "Zebra"}},
{"sort=name_asc", []string{"Apple", "Mango", "Zebra"}},
{"sort=name_desc", []string{"Zebra", "Mango", "Apple"}},
{"sort=author_asc", []string{"Mango", "Apple", "Zebra"}},
{"sort=author_desc", []string{"Zebra", "Apple", "Mango"}},
{"sort=books_desc", []string{"Mango", "Zebra", "Apple"}},
{"sort=books_asc", []string{"Apple", "Zebra", "Mango"}},
{"sort=progress_desc", []string{"Mango", "Zebra", "Apple"}},
{"sort=progress_asc", []string{"Apple", "Zebra", "Mango"}},
{"sort=last_read_desc", []string{"Mango", "Zebra", "Apple"}},
{"sort=last_read_asc", []string{"Zebra", "Mango", "Apple"}},
}
for _, tc := range cases {
got := renderedNames("&" + tc.sort)
require.Len(t, got, 3, tc.sort)
for i, want := range tc.want {
assert.Contains(t, got[i], want, "%s: position %d", tc.sort, i)
}
}
})
t.Run("unknown sort falls back to the default", func(t *testing.T) {
// Unknown keys resolve to "recent" in the registry; the SQL never
// interpolates the value (bound-param CASE cascade), so a hostile
// sort value is inert by construction.
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames("&sort=bogus-key"))
})
t.Run("base sort + direction toggle composes registry keys", func(t *testing.T) {
// The toolbar sends base keys + dir; defaults apply when dir is
// absent, and both directions of every base key are reachable.
cases := []struct {
query string
want []string
}{
{"sort=name", []string{"Apple", "Mango", "Zebra"}}, // name asc default
{"sort=name&dir=desc", []string{"Zebra", "Mango", "Apple"}}, // toggle flips
{"sort=books&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // books desc default, flipped
{"sort=progress&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // progress desc default, flipped
{"sort=author", []string{"Mango", "Apple", "Zebra"}}, // author asc default
// THE toggle regression (2026-10-27): "recent" is both a base
// key and a registry key — dir=asc used to be swallowed.
{"sort=recent&dir=asc", []string{"Apple", "Mango", "Zebra"}},
}
for _, tc := range cases {
got := renderedNames("&" + tc.query)
require.Len(t, got, 3, tc.query)
for i, want := range tc.want {
assert.Contains(t, got[i], want, "%s: position %d", tc.query, i)
}
}
})
t.Run("name query filters", func(t *testing.T) {
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=Mango"))
// ILIKE is case-insensitive and substring-shaped.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=mango"))
})
t.Run("multi-field query matches authors and book titles", func(t *testing.T) {
// Author of Mango is "Alpha One" — the query no longer needs the
// separate author box.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=alpha+one"))
// "Zebra Book 2" is a book TITLE inside the Zebra series: the
// series must surface for a title match too.
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=zebra+book+2"))
// Nothing matches anywhere.
assert.Empty(t, renderedNames("&q=nobody-nowhere"))
})
t.Run("author param (API back-compat) filters on any book's author", func(t *testing.T) {
// The toolbar no longer renders a separate author box (the
// multi-field q covers authors), but the param stays live for
// the JSON API and deep links.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&author=alpha"))
assert.Empty(t, renderedNames("&author=nobody"))
})
t.Run("status filter matches the viewer's progress", func(t *testing.T) {
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&status=finished"), "all books finished")
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&status=reading"), "some book mid-read")
assert.Equal(t, []string{"Probe Apple Series"}, renderedNames("&status=unstarted"), "no progress anywhere")
})
t.Run("filters compose", func(t *testing.T) {
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=probe&status=reading"))
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=probe&status=finished&sort=progress_desc"))
})
t.Run("cards carry the viewer's read count", func(t *testing.T) {
_, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.RegularToken)
assert.Contains(t, body, "3 of 3 read")
assert.Contains(t, body, "1 of 2 read")
assert.NotContains(t, body, "of 1 read", "unstarted series must not render a progress line")
})
t.Run("JSON API shares the sort/filter contract", func(t *testing.T) {
req, err := http.NewRequest("GET", setup.Server.URL+"/api/series?library_id="+libID+"&sort=author_asc&status=finished", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
var payload struct {
Series []struct {
Name string `json:"name"`
ReadCount int64 `json:"read_count"`
} `json:"series"`
Total int `json:"total"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
require.Len(t, payload.Series, 1)
assert.Equal(t, "Probe Mango Series", payload.Series[0].Name)
assert.Equal(t, int64(3), payload.Series[0].ReadCount)
assert.Equal(t, 1, payload.Total)
})
}
+4 -6
View File
@@ -253,7 +253,7 @@ func TestReadingStatsSummary(t *testing.T) {
media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0]))) media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0])))
require.NoError(t, err, "reuse seeded media item") require.NoError(t, err, "reuse seeded media item")
} else { } 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}, LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true},
Title: title, Title: title,
FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()), 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")) 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) { 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}, LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title, Title: title,
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()), 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) { func TestLibraryUsageStats(t *testing.T) {
setup := setupTestServer(t) setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB) userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks")) fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks"))
@@ -541,7 +540,7 @@ func TestLibraryUsageStats(t *testing.T) {
if durationSeconds > 0 { if durationSeconds > 0 {
audioMeta = []byte(fmt.Sprintf(`{"duration_seconds":%d,"chapters":[]}`, durationSeconds)) 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}, LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title, Title: title,
Authors: []string{author}, Authors: []string{author},
@@ -689,7 +688,6 @@ func TestLibraryUsageStats(t *testing.T) {
func TestLibraryUsageStats_Visibility(t *testing.T) { func TestLibraryUsageStats_Visibility(t *testing.T) {
setup := setupTestServer(t) setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB) userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks")) 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")) fmt.Sprintf("Test Usage Hidden %d", time.Now().UnixNano()), "ebooks"))
seed := func(lib uuid.UUID, title string, size int64) { 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}, LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title, Title: title,
FilePath: fmt.Sprintf("/tmp/usage-vis-%s-%d.bin", title, time.Now().UnixNano()), FilePath: fmt.Sprintf("/tmp/usage-vis-%s-%d.bin", title, time.Now().UnixNano()),
+9 -3
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"bookhoard/internal/config" "bookhoard/internal/config"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/rls"
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -10,6 +11,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -26,11 +28,16 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
dbConfig, err := pgxpool.ParseConfig(dbURL) dbConfig, err := pgxpool.ParseConfig(dbURL)
require.NoError(t, err, "Failed to parse database URL") require.NoError(t, err, "Failed to parse database URL")
dbConfig.MaxConns = 1 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) dbPool, err := pgxpool.NewWithConfig(ctx, dbConfig)
require.NoError(t, err, "Failed to connect to test database") require.NoError(t, err, "Failed to connect to test database")
db := database.New(dbPool) db := database.New(database.NewSeedDB(dbPool))
t.Cleanup(func() { t.Cleanup(func() {
// Scoped wipes ONLY: reading_history now carries real captured // Scoped wipes ONLY: reading_history now carries real captured
// sessions for the shared dev accounts, so a table-wide DELETE // 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 { 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, LibraryID: libraryID,
Title: "Sync Test Book", Title: "Sync Test Book",
Authors: []string{"Test Author"}, Authors: []string{"Test Author"},
+65 -3
View File
@@ -6,6 +6,7 @@ import (
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"bookhoard/internal/middleware" "bookhoard/internal/middleware"
ratelimit "bookhoard/internal/middleware" ratelimit "bookhoard/internal/middleware"
"bookhoard/internal/rls"
"bookhoard/internal/router" "bookhoard/internal/router"
"bookhoard/internal/services" "bookhoard/internal/services"
wsync "bookhoard/internal/sync" wsync "bookhoard/internal/sync"
@@ -317,6 +318,37 @@ 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)
}
// 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 // uuidToPGType converts uuid.UUID to pgtype.UUID
func uuidToPGType(u uuid.UUID) pgtype.UUID { func uuidToPGType(u uuid.UUID) pgtype.UUID {
return pgtype.UUID{Bytes: [16]byte(u), Valid: true} return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
@@ -491,10 +523,29 @@ func setupTestServer(t *testing.T) *TestServerSetup {
dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL()) dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL())
require.NoError(t, err, "Failed to parse database URL") require.NoError(t, err, "Failed to parse database URL")
dbConfig.MaxConns = 1 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) dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig)
require.NoError(t, err, "Failed to connect to test database") 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 // Wire the tunable settings registry exactly like main.go: Load
// populates the cache (missing rows fall back to compiled defaults) // populates the cache (missing rows fall back to compiled defaults)
@@ -552,13 +603,21 @@ func setupTestServer(t *testing.T) *TestServerSetup {
statisticsService := services.NewStatisticsService(queries) statisticsService := services.NewStatisticsService(queries)
statsHandler := handlers.NewStatsHandler(statisticsService) statsHandler := handlers.NewStatsHandler(statisticsService)
seriesHandler := handlers.NewSeriesHandler(queries) 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) matchingHandler := handlers.NewMatchingHandler(queries, connManager)
// Create conversion service for OPDS // Create conversion service for OPDS
conversionService := services.NewConversionService(queries, getCachePath()) conversionService := services.NewConversionService(queries, getCachePath())
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService) 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 // Create Echo instance
e := echo.New() e := echo.New()
@@ -621,6 +680,9 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// hands the kobo/kepub CFI converter a typed-nil (kobo markup // hands the kobo/kepub CFI converter a typed-nil (kobo markup
// panic-recovered as 500s in TestProgressWeb_KoboIntegration). // panic-recovered as 500s in TestProgressWeb_KoboIntegration).
LibraryService: libraryService, LibraryService: libraryService,
PodcastHandler: podcastHandler,
PodcastService: podcastService,
PodcastWorker: podcastWorker,
} }
router.RegisterRoutes(routerConfig) router.RegisterRoutes(routerConfig)
@@ -756,7 +818,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// Create TestServerSetup struct with all resources // Create TestServerSetup struct with all resources
setup := &TestServerSetup{ setup := &TestServerSetup{
Server: ts, Server: ts,
DB: queries, DB: seedDB, // harness seeding/cleanup handle (see comment above)
DBPool: dbPool, DBPool: dbPool,
Config: cfg, Config: cfg,
ConnManager: connManager, ConnManager: connManager,
+288 -3
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']), ('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']), ('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']), ('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; ON CONFLICT (name) DO NOTHING;
-- Create users table -- Create users table
@@ -99,7 +100,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_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'), ('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_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; ON CONFLICT (setting_key) DO NOTHING;
-- Create refresh_tokens table -- Create refresh_tokens table
@@ -174,7 +179,7 @@ CREATE TABLE IF NOT EXISTS media_items (
language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1) language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1)
edition VARCHAR(100), -- Edition information edition VARCHAR(100), -- Edition information
page_count INTEGER, -- Total page count page_count INTEGER, -- Total page count
genre VARCHAR(100), -- Genre classification genre TEXT, -- Genre classification (smart-genre shelf OR raw tag free text — Audible category strings exceed 100 chars)
copyright_year INTEGER, -- Copyright/publication year copyright_year INTEGER, -- Copyright/publication year
goodreads_id VARCHAR(50), -- Goodreads identifier goodreads_id VARCHAR(50), -- Goodreads identifier
openlibrary_id VARCHAR(50), -- Open Library identifier openlibrary_id VARCHAR(50), -- Open Library identifier
@@ -279,6 +284,14 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[]; ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[]; ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
-- Widen genre for existing databases: the audio tag fallback stores the
-- raw file genre atom (Audible category strings exceed 100 chars —
-- e.g. "Politicians, Presidents & Heads of State, United States, ..."),
-- and VARCHAR(100) rejected the whole import/update at the database
-- layer (SQLSTATE 22001). VARCHAR -> TEXT is a metadata-only change,
-- safe to re-run (media_highlights position widening precedent).
ALTER TABLE media_items ALTER COLUMN genre TYPE TEXT;
-- Audiobook editorial facts (A2a): narrators are user-editable first-class -- Audiobook editorial facts (A2a): narrators are user-editable first-class
-- (search/filter/badge surface); abridged rides along; audio_metadata keeps -- (search/filter/badge surface); abridged rides along; audio_metadata keeps
-- the scan-derived tech record (duration/codec/chapters). -- the scan-derived tech record (duration/codec/chapters).
@@ -1869,3 +1882,275 @@ 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) -- Widen for databases created before the resolution format settled (no-op otherwise)
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50); 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).
-- ============================================================
+6 -2
View File
@@ -12,6 +12,8 @@ services:
build: build:
context: . context: .
dockerfile: ./Dockerfile dockerfile: ./Dockerfile
args:
GIT_COMMIT: ${GIT_COMMIT:-none}
# Integration Tests - runs against containerized app and db (dev only) # Integration Tests - runs against containerized app and db (dev only)
tests: tests:
@@ -22,10 +24,12 @@ services:
container_name: bookhoard_tests container_name: bookhoard_tests
environment: environment:
# Database Configuration # Database Configuration
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
# via FORCE) — same role the app service uses.
DATABASE_HOST: db DATABASE_HOST: db
DATABASE_PORT: ${DB_PORT:-5432} DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_USER: postgres DATABASE_USER: bookhoard_srv
DATABASE_PASSWORD: ${DBPASS} DATABASE_PASSWORD: ${DBSRVPASS}
DATABASE_NAME: bookhoard DATABASE_NAME: bookhoard
COOKIE_SECURE: false COOKIE_SECURE: false
+59 -5
View File
@@ -11,9 +11,6 @@ services:
PGPORT: ${DB_PORT:-5432} PGPORT: ${DB_PORT:-5432}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./database/schema:/docker-entrypoint-initdb.d
# Make other volumes as needed
- ./uploads:/app/uploads
ports: ports:
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}" - "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}" - "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
@@ -26,6 +23,58 @@ services:
env_file: env_file:
- .env - .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 # Bookhoard Application
# In production this image is pulled from the Gitea container registry. # 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"). # Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
@@ -35,10 +84,13 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
# Database Configuration # 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_HOST: db
DATABASE_PORT: ${DB_PORT:-5432} DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_USER: postgres DATABASE_USER: bookhoard_srv
DATABASE_PASSWORD: ${DBPASS} DATABASE_PASSWORD: ${DBSRVPASS}
DATABASE_NAME: bookhoard DATABASE_NAME: bookhoard
# Application Configuration # Application Configuration
@@ -75,6 +127,8 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
db-init:
condition: service_completed_successfully
volumes: volumes:
- ./uploads:/app/uploads - ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub - bookhoard_conversion_cache:/app/cache/kepub
+56 -2
View File
@@ -37,14 +37,15 @@ These are the response SHAPES (JSON key sets, not values) frozen by the
| `CollectionsJSON` | `GET /api/collections/:id` (all 4 branches) | envelope `id, user_id, name, description, color, icon, auto_assign_rules, view_settings, books[], book_count, is_smart, created_at`; books = `BookInfo` | | `CollectionsJSON` | `GET /api/collections/:id` (all 4 branches) | envelope `id, user_id, name, description, color, icon, auto_assign_rules, view_settings, books[], book_count, is_smart, created_at`; books = `BookInfo` |
| | `POST /api/collections/preview` | bare `{items: BookInfo[]}` | | | `POST /api/collections/preview` | bare `{items: BookInfo[]}` |
| `SeriesJSON` | `GET /api/series/books?name=&library_id=` | `{name, library_id, books: BookInfo[], total}` | | `SeriesJSON` | `GET /api/series/books?name=&library_id=` | `{name, library_id, books: BookInfo[], total}` |
| `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys) | | `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys + viewer_percentage) |
| `DashboardSections` | `GET /api/dashboard/sections` | `{sections[]: {id, collection_id, is_system, title, description, icon, items: BookInfo[], view_all_url, priority}}` | | `DashboardSections` | `GET /api/dashboard/sections` | `{sections[]: {id, collection_id, is_system, title, description, icon, items: BookInfo[], view_all_url, priority}}` |
| `MediaListBothBranches` | `GET /api/media-items` (unscoped) | `{data: [38-key resolved map]}` (intentionally divergent from the scoped branch until the row-shape collapse batch) | | `MediaListBothBranches` | `GET /api/media-items` (unscoped) | `{data: [38-key resolved map]}` (intentionally divergent from the scoped branch until the row-shape collapse batch) |
| | `GET /api/media-items?library_id=&sort=` | `{data: [raw ListMediaItemsSortedRow, 70 sqlc keys]}` | | | `GET /api/media-items?library_id=&sort=` | `{data: [raw ListMediaItemsSortedRow, 70 sqlc keys]}` |
| `SSRPages` | `/bookshelf`, `/collections`, `/collections/:id`, `/series`, `/series/detail`, `/tags/detail` | 200 + stable title/substring markers | | `SSRPages` | `/bookshelf`, `/collections`, `/collections/:id`, `/series`, `/series/detail`, `/tags/detail` | 200 + stable title/substring markers |
`BookInfo` (every card surface): `media_item_id, title, author, `BookInfo` (every card surface): `media_item_id, title, author,
cover_image_path, has_conflict, format_group, badge`. cover_image_path, has_conflict, format_group, badge, viewer_percentage,
library_name`.
## Changelog (additive keys per batch) ## Changelog (additive keys per batch)
@@ -414,3 +415,56 @@ cover_image_path, has_conflict, format_group, badge`.
BEFORE Rescan All — force rescan stamps books with the current BEFORE Rescan All — force rescan stamps books with the current
setting, so bump-first + force = one pass of work; force-first + setting, so bump-first + force = one pass of work; force-first +
bump = the trickle re-derives everything again (wasted, not harmful). bump = the trickle re-derives everything again (wasted, not harmful).
## 2026-10-27 — Search-progress batch: viewer progress on cards
- **`SearchJSON`**: `SearchMediaItemsUnifiedRow` + `viewer_percentage`
(float 0..1, NULL when the requester never opened the book). The
query LEFT JOINs `reading_progress` on the SAME `user_id` param the
library-visibility join already binds — one requester param, no new
wire inputs; the join is row-neutral (UNIQUE(media_item, user)).
- **`BookInfo`** (CollectionsJSON / SeriesJSON / preview /
DashboardSections / search cards): + `viewer_percentage` (JSON null
when nil) and + `library_name` (empty on feeds that don't load it —
only the search-unified adapter populates both today). Adapters
other than BookCardsFromUnified keep emitting null/""; templ cards
render the progress bar/completed check only when populated.
- The header-search dropdown now renders the templ `SearchResults`
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.
@@ -0,0 +1,41 @@
---
method: POST
path: /api/libraries/:id/issues/:issueId/retry
auth: jwt+admin
area: libraries
---
<!-- source: internal/handlers/processing_issues.go RetryProcessingIssue -->
# Retry Processing Issue
Re-runs the work that produced an open issue through the individual
scanner — deliberately NOT a library-wide rescan. Two shapes:
- **Item-backed issues** (media item exists): the item is rescanned via
the same mechanism as the book-detail Rescan action — self-resolving
folders, multi-file audiobook routing, write-after-success guards.
- **Row-less `import_failed` issues** (no media item): the single file
is re-imported through the scanner's standalone per-file path.
On success the issue is closed. On failure the response carries the REAL
error (e.g. the underlying SQL failure), so the UI can show why the issue
still exists. The issue's library must be in the requester's visible set.
## Example response
```json example-response
{
"success": true,
"message": "File imported; issue resolved"
}
```
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "Invalid issue ID"}` | malformed path param |
| 403 | — | issue's library not in requester's visible set |
| 404 | `{"error": "Issue not found"}` | no such issue |
| 409 | `{"error": "Issue is already resolved"}` / `{"error": "media file not found on disk: …"}` | nothing to retry, or the file vanished |
| 500 | `{"error": "…"}` | the retried work failed — body carries the underlying error |
+9 -2
View File
@@ -8,10 +8,17 @@ area: libraries
# Scan Library # Scan Library
Enqueues a full scan of one library's configured folders. The library id comes Enqueues a scan of one library's configured folders. The library id comes
from the path — no request body is needed (the generic `POST /api/scanner/scan` from the path — no request body is needed (the generic `POST /api/scanner/scan`
variant is the one that takes `folder_paths`). variant is the one that takes `folder_paths`).
**Per-folder scope**: an optional body of `{"folder_path": "…"}` restricts the
scan to ONE of the library's registered folders (the per-folder Scan buttons
in the library panel). The path must be a registered folder — anything else is
a **400**. A scoped scan never runs archive reconciliation for the library's
other folders, so items outside the scanned folder cannot be marked missing
by it.
Scans are **coalesced**: if a scan for this library is already pending or Scans are **coalesced**: if a scan for this library is already pending or
running you get **409** with the active job id instead of a second scan. running you get **409** with the active job id instead of a second scan.
@@ -29,7 +36,7 @@ running you get **409** with the active job id instead of a second scan.
| Status | Body | When | | Status | Body | When |
| ------ | ---- | ---- | | ------ | ---- | ---- |
| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` | malformed id / empty library | | 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` / `{"error": "folder_path is not one of this library's registered folders"}` | malformed id / empty library / unregistered folder scope |
| 404 | `{"error": "library not found or has no folders"}` | unknown library | | 404 | `{"error": "library not found or has no folders"}` | unknown library |
| 409 | `{"error": "a scan for this library is already pending or running", "active_job_id": "..."}` | scan coalescing | | 409 | `{"error": "a scan for this library is already pending or running", "active_job_id": "..."}` | scan coalescing |
| 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure | | 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure |
+2 -1
View File
@@ -35,7 +35,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
"username": "john", "username": "john",
"first_name": "John", "first_name": "John",
"last_name": "Doe", "last_name": "Doe",
"role": "user" "role": "user",
"keep_screen_awake": true
} }
``` ```
+10 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Bookhoard API title: Bookhoard API
version: '2026.10.24' version: '2026.10.27'
description: | description: |
Generated from the canonical API docs (docs/developer/api/**) by Generated from the canonical API docs (docs/developer/api/**) by
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update). internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
@@ -1141,6 +1141,15 @@ paths:
responses: responses:
'200': '200':
description: Success description: Success
"/api/libraries/{id}/issues/{issueId}/retry":
post:
tags: [libraries]
summary: "Retry Processing Issue"
security:
- bearerAuth: []
responses:
'200':
description: Success
"/api/libraries/{id}/issues/{issueId}/{mediaItemId}/resolve": "/api/libraries/{id}/issues/{issueId}/{mediaItemId}/resolve":
post: post:
tags: [libraries] tags: [libraries]
+29
View File
@@ -298,6 +298,35 @@ A: Calibre Web uses the same `metadata.opf` files, so yes - Bookhoard can scan a
A: Bookhoard has its own sorting logic and doesn't import Calibre sort fields. This allows for consistent sorting across all books. A: Bookhoard has its own sorting logic and doesn't import Calibre sort fields. This allows for consistent sorting across all books.
## Writing metadata back to files (admin)
The book page's metadata editor has a **Write to file** action alongside
Save. Save updates Bookhoard's database only; Write to file persists the
currently saved values into the artifact on disk, then re-extracts so
everything stays in sync:
- **EPUB/KEPUB** — the OPF embedded in the book is rewritten in place
(cover, chapters and reading position are untouched). The file's
checksum changes, so device syncs (Kobo, KOReader) re-key the book on
their next sync.
- **Single-file audio (MP3/FLAC/M4A/M4B)** — the container's own tags
are rewritten (audio streams copied untouched, chapters and cover art
preserved). Only fields the format can carry are written: title,
subtitle (MP3/FLAC), authors, narrators (composer tag — Audible's
convention), series, date and genre.
- **CBZ** — a ComicInfo.xml is written inside the archive (injected or
replaced). CBR/CB7 archives can't be rewritten in place, so they take
the sidecar path.
- **Everything else** (PDF, folder books, CBR/CB7) — a Calibre-style
sidecar is written next to the file:
`metadata.opf` when the book is alone in its folder (Calibre's own
convention, so Calibre reads it too), or `<filename>.opf` when several
books share a folder, so one book's sidecar can never leak onto its
neighbours.
The write mirrors exactly what the scanner reads back, and the original
file is never touched if anything fails (temp file + atomic rename).
## Resources ## Resources
- [Calibre Website](https://calibre-ebook.com/) - [Calibre Website](https://calibre-ebook.com/)
+194
View File
@@ -0,0 +1,194 @@
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
const BASE = "http://localhost:8765";
const STAMP = Date.now();
const EMAIL = `e2e-search-${STAMP}@example.com`;
const USERNAME = `e2e-search-${STAMP}`;
const PASSWORD = "E2e-Playwright-2026!";
let token = "";
const consoleFailures: string[] = [];
function watchPage(page: Page): void {
page.on("pageerror", (err) => {
consoleFailures.push(`pageerror: ${err.message}`);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleFailures.push(`console.error: ${msg.text()}`);
}
});
}
function expectNoClientErrors(): void {
const bad = consoleFailures.filter((f) =>
/(is not defined|is not a function|headerSearch|alpine)/i.test(f),
);
expect(bad, `uncaught client errors:\n${bad.join("\n")}`).toEqual([]);
}
async function apiLogin(request: APIRequestContext): Promise<string> {
const res = await request.post("/api/auth/login", {
data: { login: EMAIL, password: PASSWORD },
});
expect(res.status(), "login should succeed").toBe(200);
const body = await res.json();
return body.access_token as string;
}
test.describe.configure({ mode: "serial" });
test.describe("Header search + audio scrubber (client wiring)", () => {
let page: Page;
test.beforeAll(async ({ browser, request }) => {
const reg = await request.post("/api/auth/register", {
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
});
expect([201, 200, 409]).toContain(reg.status());
token = await apiLogin(request);
expect(token).toBeTruthy();
page = await browser.newPage();
watchPage(page);
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
});
test.afterAll(async () => {
expectNoClientErrors();
await page?.close();
});
test("header search dropdown opens on typing (regression: tree-shaken module)", async () => {
// Drive the header on TWO pages: the bundle is shared, but this
// catches any page-scoped wiring surprise.
for (const pagePath of ["/bookshelf", "/series"]) {
await page.goto(pagePath);
const input = page.locator("#header-search");
await expect(input).toBeVisible();
// The regression this pins: search.ts (which registers the
// headerSearch Alpine component) was tree-shaken from the bundle,
// so typing did nothing — no request, no dropdown. Type with REAL
// key events (not fill) and assert the request carries the query.
await input.click();
await input.pressSequentially("algo", { delay: 90 });
const requestPromise = page.waitForRequest(
(req) =>
req.url().includes("/api/media-items/search") &&
req.url().includes("q=algo") &&
(req.headers()["hx-request"] === "true" || req.headers()["hx-request"] === "true"),
{ timeout: 10_000 },
);
const req = await requestPromise;
expect(req.url()).toContain("layout=dropdown");
const results = page.locator("#search-results");
await expect(results).toBeVisible({ timeout: 10_000 });
await expect(results.locator(".search-result-item").first()).toBeVisible({
timeout: 10_000,
});
// Escape closes the dropdown (keyboard contract).
await page.keyboard.press("Escape");
await expect(results).toBeHidden();
}
});
test("audio scrubber: grabbing the handle does not teleport (plain and Shift)", async ({ request }) => {
// Find an audiobook in the shared dev library (plain media list,
// then filter client-side — the search endpoint's facet behavior
// with empty q isn't a listing API).
const res = await request.get("/api/media-items", {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(200);
const body = (await res.json()) as { data?: Array<{ id: string; format_group?: string }> };
const rows = (body.data ?? []).filter((r) => r.format_group === "audiobook");
test.skip(rows.length === 0, "no audiobooks in the dev library");
const bookId = rows[0].id;
await page.goto(`/readers/${bookId}`);
const scrubArea = page.locator("#scrub-area");
await expect(scrubArea).toBeVisible();
// The fresh user has never played this book — park the playhead at
// 30% via the progress API so the grab tests run mid-book.
const put = await request.put(`/api/media-items/${bookId}/progress`, {
headers: { Authorization: `Bearer ${token}` },
data: { percentage: 0.3, device_id: "e2e-scrubber" },
});
expect([200, 204]).toContain(put.status());
await page.reload();
await expect(scrubArea).toBeVisible();
const box = await scrubArea.boundingBox();
test.skip(!box, "scrub bar not laid out");
const y = box!.y + box!.height / 2;
// Where is the thumb right now? The native range input mirrors the
// playhead; read its aria-valuenow + max to compute the thumb X.
const thumbX = await page.evaluate(() => {
const input = document.getElementById("audio-scrub") as HTMLInputElement;
const area = document.getElementById("scrub-area")!;
const rect = area.getBoundingClientRect();
const value = Number(input.value || 0);
const max = Number(input.max || 1);
return rect.left + (value / max) * rect.width;
});
const readScrubValue = () =>
page.evaluate(() => Number((document.getElementById("audio-scrub") as HTMLInputElement).value));
const before = await readScrubValue();
expect(before).toBeGreaterThan(0);
// --- plain grab: pointerdown ON the thumb must not move it ---
await page.mouse.move(thumbX, y);
await page.mouse.down();
await page.waitForTimeout(150);
const atGrab = await readScrubValue();
expect(Math.abs(atGrab - before)).toBeLessThan(
Math.max(1, before * 0.0005),
"grabbing the handle must not jump the playhead",
);
// Drag left by 60px: the handle follows RELATIVELY.
await page.mouse.move(thumbX - 60, y, { steps: 5 });
await page.waitForTimeout(100);
const afterDrag = await readScrubValue();
expect(afterDrag).toBeLessThan(atGrab, "dragging left scrubs backward");
await page.mouse.up();
await page.waitForTimeout(400); // seek commits
// --- Shift+grab: the precision rail appears, no teleport ---
const railBefore = await readScrubValue();
// Re-locate the thumb after the commit.
const thumbX2 = await page.evaluate(() => {
const input = document.getElementById("audio-scrub") as HTMLInputElement;
const area = document.getElementById("scrub-area")!;
const rect = area.getBoundingClientRect();
const value = Number(input.value || 0);
const max = Number(input.max || 1);
return rect.left + (value / max) * rect.width;
});
await page.keyboard.down("Shift");
await page.mouse.move(thumbX2, y);
await page.mouse.down();
await page.waitForTimeout(150);
const railAtGrab = await readScrubValue();
expect(Math.abs(railAtGrab - railBefore)).toBeLessThan(
Math.max(1, railBefore * 0.0005),
"Shift+grab must not teleport either",
);
// The rail is the feedback for Shift+grab — it must be visible.
await expect(page.locator('div[x-show="railVisible"]')).toBeVisible();
await page.keyboard.up("Shift");
await page.mouse.up();
});
});
+160
View File
@@ -0,0 +1,160 @@
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
import { execSync } from "child_process";
const BASE = "http://localhost:8765";
const STAMP = Date.now();
const EMAIL = `e2e-setfb-${STAMP}@example.com`;
const USERNAME = `e2e-setfb-${STAMP}`;
const PASSWORD = "E2e-Playwright-2026!";
// Settings-page save feedback: every save surface must acknowledge the
// click (toast) and the scan form must NOT swap the handler's JSON body
// over its card (the pre-toast behavior — raw JSON replaced the form).
//
// Dev-state safety: every success save re-submits the value ALREADY in
// the input (read from the page first), so nothing in the shared dev DB
// changes; the error-path request is rejected server-side before any
// write.
let token = "";
let page = null as Page | null;
// system_config.updated_by audit restore: the config save handler stamps
// the requesting admin's id onto system_config rows. The harness cleanup
// deletes transient example.com users, and this FK (no cascade) would
// block the delete and fail unrelated integration tests. Capture before
// any save, restore after.
let e2eUserID = "";
let origUpdatedBy = "";
function psql(sql: string): string {
const flat = sql.replace(/\s+/g, " ");
return execSync(
`docker exec bookhoard_db psql -U postgres -d bookhoard -tAc ${JSON.stringify(flat)}`,
)
.toString()
.trim();
}
async function apiLogin(request: APIRequestContext): Promise<string> {
const res = await request.post("/api/auth/login", {
data: { login: EMAIL, password: PASSWORD },
});
expect(res.status(), "login should succeed").toBe(200);
return (await res.json()).access_token as string;
}
test.describe.configure({ mode: "serial" });
test.describe("Settings save feedback (toasts + disabled buttons)", () => {
test.beforeAll(async ({ browser, request }) => {
// Fresh user, promoted to admin in the dev DB (the harness cleanup
// sweeps transient example.com users afterwards).
const reg = await request.post("/api/auth/register", {
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
});
expect([201, 200, 409]).toContain(reg.status());
psql(`UPDATE users SET role='admin' WHERE email='${EMAIL}'`);
token = await apiLogin(request);
expect(token).toBeTruthy();
e2eUserID = psql(`SELECT id FROM users WHERE email='${EMAIL}'`);
origUpdatedBy = psql(`SELECT COALESCE(updated_by::text, '') FROM system_config WHERE key='base_url'`);
page = await browser.newPage();
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
await page.goto("/admin/settings");
await expect(page.locator("h1")).toHaveText("System Settings");
});
test.afterAll(async () => {
await page?.close();
// Hand system_config.updated_by back to whoever held it (or NULL),
// so the transient user can be swept.
if (e2eUserID) {
const restore = origUpdatedBy
? `'${origUpdatedBy}'::uuid`
: "NULL";
psql(`UPDATE system_config SET updated_by = ${restore} WHERE updated_by = '${e2eUserID}'::uuid`);
}
});
test("Base URL save shows a success toast — twice (twin-form drift pin)", async () => {
const input = page.locator("#settings-form input[name='base_url']");
await expect(input).toBeVisible();
// First save goes through the templ form…
await page.locator("#settings-form button[type='submit']").first().click();
await expect(page.getByText("Settings saved", { exact: true })).toBeVisible({
timeout: 10_000,
});
// …the second through the Go-source twin the handler swaps in after
// the first save. If the twin ever drops data-settings-save (the
// handlers→templates import cycle is why the twin exists), THIS is
// the assertion that fails.
await page.waitForTimeout(300); // let the swap settle
await expect(page.locator("#settings-form")).toBeVisible();
await page.locator("#settings-form button[type='submit']").first().click();
await expect(page.getByText("Settings saved", { exact: true })).toBeVisible({
timeout: 10_000,
});
});
test("Scan settings save shows a toast and never swaps raw JSON over the card", async () => {
const section = page.locator("#scan-settings-section");
await expect(section).toBeVisible();
await section.locator("button[type='submit']").click();
await expect(page.getByText("Scan settings saved", { exact: true })).toBeVisible({
timeout: 10_000,
});
// The regression: the scan handler responds JSON; with the old
// hx-swap="outerHTML" the card was replaced by the literal JSON text.
await expect(section).toBeVisible();
await expect(section.locator("form")).toBeVisible();
const body = await page.locator("body").innerText();
expect(body).not.toContain("scan settings updated successfully");
});
test("Tunable row save shows a toast and the per-row status span", async () => {
const row = page.locator("form[data-settings-save='tunable']").first();
await expect(row).toBeVisible();
const key = await row.locator("input[name='key']").getAttribute("value");
expect(key).toBeTruthy();
await row.locator("button[type='submit']").click();
await expect(page.getByText("Setting saved", { exact: true })).toBeVisible({
timeout: 10_000,
});
// Server-driven per-row status (kept alongside the toast; it carries
// the "restart required" nuance).
await expect(page.locator(`#status-${key}`)).toContainText("Saved", {
timeout: 10_000,
});
});
test("Invalid value shows an error toast and the form survives", async () => {
// Inject a genuinely invalid option and select it: setting a select
// to a value with no matching option silently serializes as NO field
// at all (no selected option → nothing submitted), which would save
// fine instead of failing. With a real option present, the server
// rejects with 400 {"error":"invalid timezone"} before any write.
await page.evaluate(() => {
const select = document.getElementById("default_timezone") as HTMLSelectElement;
const opt = document.createElement("option");
opt.value = "Not/AZone";
opt.textContent = "E2E invalid";
select.appendChild(opt);
select.value = "Not/AZone";
});
await page.locator("#settings-form button[type='submit']").first().click();
await expect(page.getByText("Save failed: invalid timezone")).toBeVisible({
timeout: 10_000,
});
// htmx does not swap non-2xx bodies: the form is intact.
await expect(page.locator("#settings-form")).toBeVisible();
await expect(page.locator("#settings-form input[name='base_url']")).toBeVisible();
});
});
+259
View File
@@ -0,0 +1,259 @@
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
import { execSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
const REPO = process.cwd(); // uploads/ here is bind-mounted at /app/uploads
const BASE = "http://localhost:8765";
const STAMP = Date.now();
const EMAIL = `e2e-meta-${STAMP}@example.com`;
const USERNAME = `e2e-meta-${STAMP}`;
const PASSWORD = "E2e-Playwright-2026!";
// Host-side upload dir (bind-mounted into the app container).
const UPLOADS_HOST = path.join(REPO, "uploads", `e2e-meta-${STAMP}`);
const UPLOADS_CONT = `/app/uploads/e2e-meta-${STAMP}`;
interface FormatSeed {
ext: string;
group: string;
mime: string;
id?: string;
}
// Every write-capable file type, through the REAL admin UI, verified by
// reading each artifact back independently afterwards (this test IS the
// "choose each file type, then check the file's metadata yourself"
// contract):
//
// epub → embedded OPF splice; verify by parsing the OPF XML
// pdf → Calibre metadata.opf sidecar (lone book); parse XML
// cbz → ComicInfo.xml injected; parse XML
// mp3/flac/m4a/m4b → ffprobe the rewritten tags
const FORMATS: FormatSeed[] = [
{ ext: "epub", group: "ebook", mime: "application/epub+zip" },
{ ext: "pdf", group: "ebook", mime: "application/pdf" },
{ ext: "cbz", group: "comic_archive", mime: "application/zip" },
{ ext: "mp3", group: "audiobook", mime: "audio/mpeg" },
{ ext: "flac", group: "audiobook", mime: "audio/flac" },
{ ext: "m4a", group: "audiobook", mime: "audio/mp4" },
{ ext: "m4b", group: "audiobook", mime: "audio/mp4" },
];
let token = "";
let libraryID = "";
function psql(sql: string): string {
// Collapse whitespace (JSON.stringify would bind newlines as literal
// "\n" through the shell and break the SQL).
const flat = sql.replace(/\s+/g, " ");
return execSync(
`docker exec bookhoard_db psql -U postgres -d bookhoard -tAc ${JSON.stringify(flat)}`,
)
.toString()
.trim();
}
function run(cmd: string): string {
return execSync(cmd, { encoding: "utf8" }).trim();
}
function writePython(script: string): void {
run(`python3 - <<'PYEOF'\n${script}\nPYEOF`);
}
function writeEPUB(dest: string, title: string): void {
const script = `import zipfile, shutil
arc = ${JSON.stringify(dest)}
tmp = arc + ".tmp"
with zipfile.ZipFile(tmp, "w") as z:
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip", compress_type=zipfile.ZIP_STORED)
z.writestr("META-INF/container.xml", '<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>')
z.writestr("OEBPS/content.opf", '<?xml version="1.0"?><package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title id="t1">' + ${JSON.stringify(title)} + '</dc:title><dc:identifier id="pub-id">urn:uuid:e2e</dc:identifier></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="c1"/></spine></package>')
z.writestr("OEBPS/c1.xhtml", "<html><body>chapter</body></html>")
shutil.move(tmp, arc)`;
writePython(script);
}
function writeCBZ(dest: string): void {
writePython(`import zipfile, shutil
arc = ${JSON.stringify(dest)}
tmp = arc + ".tmp"
with zipfile.ZipFile(tmp, "w") as z:
z.writestr("page_0001.jpg", "not-a-real-jpeg-but-fine")
shutil.move(tmp, arc)`);
}
/** Independent artifact readers — no app code involved. The python is
* written to a temp file (never -c through the shell: paths with
* quotes/newlines are unreadable that way). */
function pythonRead(script: string, absPath: string): string {
const scriptPath = path.join(os.tmpdir(), `e2e-meta-read-${STAMP}.py`);
fs.writeFileSync(scriptPath, script);
return run(`python3 ${JSON.stringify(scriptPath)} ${JSON.stringify(absPath)}`);
}
function artifactTitle(ext: string, absPath: string): string {
switch (ext) {
case "epub":
return pythonRead(`import zipfile, sys, xml.etree.ElementTree as ET
with zipfile.ZipFile(sys.argv[1]) as z:
root = ET.fromstring(z.read('OEBPS/content.opf'))
ns = {'dc': 'http://purl.org/dc/elements/1.1/'}
titles = [t.text or '' for t in root.findall('.//dc:title', ns)]
print(titles[0] if titles else '')`, absPath);
case "cbz":
return pythonRead(`import zipfile, sys, xml.etree.ElementTree as ET
with zipfile.ZipFile(sys.argv[1]) as z:
root = ET.fromstring(z.read('ComicInfo.xml'))
print(root.findtext('Title') or '')`, absPath);
case "pdf":
// one-book dir → the Calibre-style sidecar carries the metadata
return pythonRead(`import os, sys, xml.etree.ElementTree as ET
sidecar = os.path.join(os.path.dirname(sys.argv[1]), 'metadata.opf')
root = ET.parse(sidecar).getroot()
ns = {'dc': 'http://purl.org/dc/elements/1.1/'}
print(root.findtext('.//dc:title', default='', namespaces=ns))`, absPath);
default:
// audio containers: ffprobe the rewritten album tag
return run(
`ffprobe -v error -show_entries format_tags=album -of default=noprint_wrappers=1:nokey=1 ${JSON.stringify(absPath)}`,
);
}
}
async function apiLogin(request: APIRequestContext): Promise<string> {
const res = await request.post("/api/auth/login", {
data: { login: EMAIL, password: PASSWORD },
});
expect(res.status(), "login should succeed").toBe(200);
return (await res.json()).access_token as string;
}
test.describe.configure({ mode: "serial" });
test.describe("Write-to-file per format (real admin UI + artifact verification)", () => {
let page: Page;
test.beforeAll(async ({ browser, request }) => {
fs.mkdirSync(UPLOADS_HOST, { recursive: true });
// Fresh user, promoted to admin in the dev DB (the harness cleanup
// sweeps transient example.com users afterwards).
const reg = await request.post("/api/auth/register", {
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
});
expect([201, 200, 409]).toContain(reg.status());
psql(`UPDATE users SET role='admin' WHERE email='${EMAIL}'`);
token = await apiLogin(request);
expect(token).toBeTruthy();
// Test library with a folder at our upload dir.
const libRes = await request.post("/api/libraries", {
headers: { Authorization: `Bearer ${token}` },
data: { name: `E2E Meta Write ${STAMP}`, type: "ebooks" },
});
expect(libRes.status()).toBe(201);
libraryID = ((await libRes.json()) as { id: string }).id;
const foldRes = await request.post(`/api/libraries/${libraryID}/folders`, {
headers: { Authorization: `Bearer ${token}` },
data: { folder_path: UPLOADS_CONT },
});
expect(foldRes.status()).toBe(201);
// Seed one fixture per format: file on disk + media_items row.
for (const f of FORMATS) {
const rel = `${f.group}/${f.ext}/e2e-fixture.${f.ext}`;
const abs = path.join(UPLOADS_HOST, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
if (f.ext === "epub") writeEPUB(abs, "E2E Original Title");
else if (f.ext === "cbz") writeCBZ(abs);
else if (f.ext === "pdf") fs.writeFileSync(abs, "%PDF-1.4 e2e stub\n");
else {
const codec = f.ext === "mp3" ? "libmp3lame" : f.ext === "flac" ? "flac" : "aac";
run(
`ffmpeg -hide_banner -loglevel error -y -f lavfi -i anullsrc=r=44100:cl=mono -t 2 -c:a ${codec} -metadata title='E2E Original Title' -metadata album='E2E Original Title' ${JSON.stringify(abs)}`,
);
}
f.id = psql(
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ('${libraryID}', 'E2E Original Title', '${rel}', '${f.group}', '${f.mime}', 100, NOW(), NOW(), NOW())
RETURNING id`,
);
// -tAc prints the id row first, then psql's command tag
// ("INSERT 0 1") last; take the first non-empty line.
f.id = f.id.split("\n").find((l) => l.trim())!.trim();
expect(f.id).toMatch(/^[0-9a-f-]{36}$/i);
}
page = await browser.newPage();
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
});
test.afterAll(async () => {
await page?.close();
// Best-effort cleanup: the library delete cascades the items; the
// psql fallback + rm sweep anything the API couldn't.
try {
await fetch(`${BASE}/api/libraries/${libraryID}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
} catch {
/* best effort */
}
fs.rmSync(UPLOADS_HOST, { recursive: true, force: true });
try {
psql(`DELETE FROM libraries WHERE id='${libraryID}'`);
} catch {
/* already gone */
}
});
for (const f of FORMATS) {
test(`writes metadata into ${f.ext} and the file content verifies`, async () => {
const newTitle = `E2E WRITTEN ${f.ext.toUpperCase()}`;
const abs = path.join(UPLOADS_HOST, f.group, f.ext, `e2e-fixture.${f.ext}`);
expect(f.id).toBeTruthy();
// Real admin UI flow: book page → Edit → change title → the
// in-app dialog → Save & write.
await page.goto(`/media/${f.id}`);
await page.click('button:has-text("Edit")');
const modal = page.locator("#metadata-editor-modal");
await expect(modal).toBeVisible();
await page.fill('#metadata-editor-modal input[name="title"]', newTitle);
await page.click('#metadata-editor-modal button:has-text("Write to file")');
const dialog = page.locator("#write-confirm-dialog");
await expect(dialog).toBeVisible();
await dialog.locator('button:has-text("Save & write")').click();
// Wait for the request + inline rescan + page reload to settle.
await page.waitForTimeout(2500);
// Then verify the FILE's metadata — independently of the app
// (read the artifact back with python/ffprobe, not via the app).
let seen = "";
let lastErr = "";
for (let i = 0; i < 20; i++) {
try {
seen = artifactTitle(f.ext, abs);
} catch (e) {
lastErr = String(e);
seen = "";
}
if (seen.includes(newTitle)) break;
await page.waitForTimeout(500);
}
if (!seen.includes(newTitle)) {
console.error(`[${f.ext}] artifact read failed: ${lastErr}`);
}
expect(seen, `${f.ext}: the FILE must carry the written metadata`).toContain(newTitle);
expect(seen, `${f.ext}: the old title must be replaced`).not.toContain("E2E Original Title");
});
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.31.0
package database package database
+70 -15
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.31.0
package database package database
@@ -150,6 +150,7 @@ type Libraries struct {
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"` CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
} }
type LibraryFolders struct { type LibraryFolders struct {
@@ -157,6 +158,7 @@ type LibraryFolders struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
FolderPath string `db:"folder_path" json:"folder_path"` FolderPath string `db:"folder_path" json:"folder_path"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Purpose string `db:"purpose" json:"purpose"`
} }
type LibraryTypes struct { type LibraryTypes struct {
@@ -324,20 +326,36 @@ type MediaItems struct {
// Scan information from ComicInfo.xml (scanner group, resolution, etc.) // Scan information from ComicInfo.xml (scanner group, resolution, etc.)
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"` ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
// Summary from ComicInfo.xml (may be merged with description from Calibre) // Summary from ComicInfo.xml (may be merged with description from Calibre)
Summary pgtype.Text `db:"summary" json:"summary"` Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"` MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"` MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"` AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"` TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"` NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` 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 { type MediaNotes struct {
@@ -405,6 +423,43 @@ type PanelData struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` 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. // 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 { type ProcessingIssues struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
+185 -6
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.31.0
package database package database
@@ -22,6 +22,8 @@ type Querier interface {
// ============================================ // ============================================
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error) AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
// Library Folders queries // 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) AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
// Second consecutive missing scan: hide the item from all browsing while // Second consecutive missing scan: hide the item from all browsing while
// preserving reading history in case the file returns. // preserving reading history in case the file returns.
@@ -30,6 +32,11 @@ type Querier interface {
BulkUpdateFormatGroups(ctx context.Context) error BulkUpdateFormatGroups(ctx context.Context) error
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error) BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, 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 // Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error CleanupExpiredOpdsTokens(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
@@ -53,6 +60,19 @@ type Querier interface {
// collections page); same live filter as GetCollectionItems, plus the // collections page); same live filter as GetCollectionItems, plus the
// future-hybrid excluded exclusion and an optional library scope. // future-hybrid excluded exclusion and an optional library scope.
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error) 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) // Count unlinked books for a device (ignored rows stay hidden)
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
// Banner count: unresolved, non-ignored queue rows across the caller's own // Banner count: unresolved, non-ignored queue rows across the caller's own
@@ -111,6 +131,22 @@ type Querier interface {
// ============================================ // ============================================
CreateOrUpdateKoboEntitlement(ctx context.Context, arg CreateOrUpdateKoboEntitlementParams) (KoboEntitlements, error) 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 // PROCESSING ISSUES QUERIES
// ============================================================================ // ============================================================================
// Widened upsert (faceted plan Phase 11): media_item_id and file_path // Widened upsert (faceted plan Phase 11): media_item_id and file_path
@@ -155,6 +191,11 @@ type Querier interface {
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) 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) DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error) DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
@@ -165,7 +206,14 @@ type Querier interface {
// Delete unlinked book // Delete unlinked book
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
DeleteUser(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 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 // Device existence probe for capture: the web/app progress paths
// historically stuff the user id into DeviceID (it only fed a broadcast // historically stuff the user id into DeviceID (it only fed a broadcast
// label before stats); a capture row must only reference a REAL device. // label before stats); a capture row must only reference a REAL device.
@@ -275,12 +323,24 @@ type Querier interface {
// Series are grouped PER LIBRARY: the same series name in two libraries // Series are grouped PER LIBRARY: the same series name in two libraries
// (novels vs manga adaptations, LN splits) stays two entries. Visible // (novels vs manga adaptations, LN splits) stays two entries. Visible
// filter enforces per-user library visibility at the source. // filter enforces per-user library visibility at the source.
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one //
// transaction share a single NOW() timestamp, leaving many groups tied on // Series-page sort/filter contract (extensible registry in
// MAX(created_at). Postgres does not guarantee tie order between // services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
// executions, so page boundaries could repeat or drop series. The GROUP BY // it filters in WHERE (the group key — counts stay true); AuthorQuery
// keys make the sort total and deterministic. // and Status filter via HAVING so COUNT(*) keeps reporting the whole
// series. ViewerUserId scopes the per-user progress aggregates (the rp
// join can't multiply rows: reading_progress is UNIQUE per
// media_item+user). Sort is a BOUND PARAM compared inside the CASE
// cascade — never interpolated — and the GROUP BY tiebreaker stays
// REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied
// on MAX(created_at), and Postgres does not guarantee tie order between
// executions, so page boundaries could repeat or drop series.
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
// Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
// HAVING so the pagination total matches what the page query returns
// under the same filters (Sort is a page-ORDER concern, not a set
// membership one, so it is absent here by design).
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error) GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error) GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error) GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
@@ -398,6 +458,25 @@ type Querier interface {
// Get OPDS tokens by device // Get OPDS tokens by device
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error) GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error)
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, 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 // Get most popular books for a user
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error) GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
// Single-issue read for the resolve/delete endpoints' family-lens gate. // Single-issue read for the resolve/delete endpoints' family-lens gate.
@@ -498,6 +577,12 @@ type Querier interface {
// END instant (the push time; NULL falls back to created_at) — the same // END instant (the push time; NULL falls back to created_at) — the same
// value the capture stamps every write with. // value the capture stamps every write with.
GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error) 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) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so // Smart ("dynamic") collections: rules are evaluated LIVE at render, so
@@ -517,6 +602,12 @@ type Querier interface {
// Link unlinked book to media item // Link unlinked book to media item
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, 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) ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error) ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
// ============================================ // ============================================
@@ -529,6 +620,9 @@ type Querier interface {
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error) ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]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 // Items hidden from libraries: either missing from disk (waiting on the
// second scan that archives them) or already archived and pending purge. // second scan that archives them) or already archived and pending purge.
// Feeds the admin archived-items page. match_* columns identify an active // Feeds the admin archived-items page. match_* columns identify an active
@@ -540,6 +634,9 @@ type Querier interface {
// collapsible "Ignored" section so a misclick is recoverable. // collapsible "Ignored" section so a misclick is recoverable.
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error) ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
ListLibraries(ctx context.Context) ([]ListLibrariesRow, 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) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error) ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
@@ -548,18 +645,35 @@ type Querier interface {
// List media items that have no stored SHA-256 (imported before hashing existed) // List media items that have no stored SHA-256 (imported before hashing existed)
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error)
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, 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) ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
// DeviceID NULL = every device's pending items (the queue processor's // DeviceID NULL = every device's pending items (the queue processor's
// enumerator passes NULL; a bare `device_id = NULL` never matches, which // enumerator passes NULL; a bare `device_id = NULL` never matches, which
// silently disabled the offline flush until 2026-10-17). // silently disabled the offline flush until 2026-10-17).
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error) 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 // LEFT JOIN (Phase 11): row-less import failures have NO media_items
// row — an inner join would silently drop the ledger's newest kind. // row — an inner join would silently drop the ledger's newest kind.
// Item-side projections COALESCE to keep them non-nullable strings; // 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. // the display file path falls back to the issue's own for row-less rows.
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error) 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) ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, 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 // List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
// Page list: unresolved queue rows for the caller's own devices (ignored // Page list: unresolved queue rows for the caller's own devices (ignored
@@ -568,6 +682,17 @@ type Querier interface {
ListUsers(ctx context.Context) ([]ListUsersRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk. // First consecutive scan that cannot find the file on disk.
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error 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 // 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 // a move. Repoints the row (and refreshes file-level fields) so reading
// history follows the book; archive/missing state is cleared separately. // history follows the book; archive/missing state is cleared separately.
@@ -602,6 +727,17 @@ type Querier interface {
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
// Re-parent all child rows of p_source onto p_target (defined in schema.sql) // Re-parent all child rows of p_source onto p_target (defined in schema.sql)
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error 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 ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
// Ledger self-heal (faceted plan Phase 11): a scan that processes the // Ledger self-heal (faceted plan Phase 11): a scan that processes the
@@ -631,14 +767,31 @@ type Querier interface {
// Search Media Items queries // Search Media Items queries
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error) SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error)
SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, 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) SearchNarratorValues(ctx context.Context, arg SearchNarratorValuesParams) ([]SearchNarratorValuesRow, error)
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error) SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error) SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error)
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error) SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
// Library Visibility queries // Library Visibility queries
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error) 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 // Set system config
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error) SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
SubscribePodcastFeed(ctx context.Context, arg SubscribePodcastFeedParams) (PodcastSubscriptions, error)
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error
@@ -703,6 +856,20 @@ type Querier interface {
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error) UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error) UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) 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) UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
UpdateReadingSpeed(ctx context.Context, arg UpdateReadingSpeedParams) (ReadingSpeed, error) UpdateReadingSpeed(ctx context.Context, arg UpdateReadingSpeedParams) (ReadingSpeed, error)
UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error) UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error)
@@ -728,11 +895,23 @@ type Querier interface {
// content identical), and after a manifest-gated re-extract. // content identical), and after a manifest-gated re-extract.
UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) error UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) error
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, 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) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
// Ingest: insert or refresh the queue row for a device file that could not // 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. // be identified. One row per (device, content_id); re-syncs bump last_seen.
UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error)
UpsertUserSettingByKey(ctx context.Context, arg UpsertUserSettingByKeyParams) error
} }
var _ Querier = (*Queries)(nil) var _ Querier = (*Queries)(nil)
File diff suppressed because it is too large. Load diff
+638 -14
View File
@@ -97,7 +97,9 @@ DELETE FROM libraries WHERE id = $1;
-- Library Folders queries -- Library Folders queries
-- name: AddLibraryFolder :one -- 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 -- name: GetLibraryFolders :many
SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at; SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at;
@@ -178,12 +180,16 @@ SELECT * FROM media_items WHERE library_id = $1 AND file_path = $2;
SELECT * FROM media_items WHERE id = $1; SELECT * FROM media_items WHERE id = $1;
-- name: ListMediaItems :many -- 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 SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.archived_at IS NULL WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0 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[])) 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; ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
@@ -212,6 +218,7 @@ JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.archived_at IS NULL WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0 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') AND mi.library_id = sqlc.narg('library_id')
ORDER BY ORDER BY
CASE CASE
@@ -658,6 +665,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 WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND ( AND (
mi.title ILIKE sqlc.narg('search_pattern') OR mi.title ILIKE sqlc.narg('search_pattern') OR
@@ -734,14 +742,21 @@ SELECT
mi.opf_uuid, mi.opf_uuid,
mi.hash_confidence, mi.hash_confidence,
l.name as library_name, l.name as library_name,
lt.name as library_type_name lt.name as library_type_name,
-- Viewer's own progress (search-result badges): NULL when the
-- requester never opened the book. Reuses the SAME requester param
-- the visibility join binds; UNIQUE(media_item, user) keeps the
-- join row-neutral.
rp.percentage as viewer_percentage
FROM media_items mi FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0 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 AND (sqlc.narg('library_id')::uuid IS NULL
OR mi.library_id = sqlc.narg('library_id')::uuid) OR mi.library_id = sqlc.narg('library_id')::uuid)
-- Fuzzy author filter — per-element against the authors[] array (A2c -- Fuzzy author filter — per-element against the authors[] array (A2c
@@ -926,6 +941,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -945,6 +961,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -965,6 +982,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') 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 CROSS JOIN LATERAL unnest(mi.tags_search) AS tag
WHERE COALESCE(lv.is_visible, true) = true 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND tag IS NOT NULL AND tag IS NOT NULL
AND word_similarity(sqlc.narg('search_query'), tag) > 0.3 AND word_similarity(sqlc.narg('search_query'), tag) > 0.3
@@ -982,6 +1000,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') 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 CROSS JOIN LATERAL unnest(mi.narrators) AS narrator
WHERE COALESCE(lv.is_visible, true) = true 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND narrator IS NOT NULL AND narrator IS NOT NULL
AND word_similarity(sqlc.narg('search_query'), narrator) > 0.3 AND word_similarity(sqlc.narg('search_query'), narrator) > 0.3
@@ -998,6 +1017,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1017,6 +1037,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1055,6 +1076,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') 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 CROSS JOIN LATERAL unnest(mi.authors) AS a
WHERE COALESCE(lv.is_visible, true) = true 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1123,6 +1145,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') 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 CROSS JOIN LATERAL unnest(mi.publishers) AS p
WHERE COALESCE(lv.is_visible, true) = true 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1190,6 +1213,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') 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 CROSS JOIN LATERAL unnest(mi.narrators) AS n
WHERE COALESCE(lv.is_visible, true) = true 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1259,6 +1283,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') 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 CROSS JOIN LATERAL unnest(mi.tags) AS t
WHERE COALESCE(lv.is_visible, true) = true 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1327,6 +1352,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1398,6 +1424,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1469,6 +1496,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1540,6 +1568,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1607,6 +1636,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1674,6 +1704,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1741,6 +1772,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1812,6 +1844,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1883,6 +1916,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -1954,6 +1988,7 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id 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') 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 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
@@ -2598,6 +2633,7 @@ SELECT
FROM media_items mi FROM media_items mi
JOIN reading_history rh ON rh.media_item_id = mi.id JOIN reading_history rh ON rh.media_item_id = mi.id
WHERE rh.user_id = $1 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 GROUP BY mi.id, mi.title, mi.author
ORDER BY read_count DESC ORDER BY read_count DESC
LIMIT $2; LIMIT $2;
@@ -2660,6 +2696,7 @@ SELECT
FROM reading_history rh FROM reading_history rh
JOIN media_items mi ON mi.id = rh.media_item_id JOIN media_items mi ON mi.id = rh.media_item_id
WHERE rh.user_id = sqlc.arg('user_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('start_time')
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_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[])) AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
@@ -2803,6 +2840,14 @@ SET
updated_at = NOW() updated_at = NOW()
WHERE id = $1; 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 -- name: DeleteDevice :exec
DELETE FROM devices WHERE id = $1; DELETE FROM devices WHERE id = $1;
@@ -3948,6 +3993,7 @@ INNER JOIN (
ORDER BY media_item_id, last_read_at DESC ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id ) 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) 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
ORDER BY rp.last_read_at DESC ORDER BY rp.last_read_at DESC
@@ -3956,6 +4002,7 @@ LIMIT sqlc.narg('limit');
-- name: GetRecentlyAddedItems :many -- name: GetRecentlyAddedItems :many
SELECT mi.* FROM media_items mi SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
@@ -3971,6 +4018,7 @@ INNER JOIN (
ORDER BY media_item_id, last_read_at DESC ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id ) 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) 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
ORDER BY rp.last_read_at DESC ORDER BY rp.last_read_at DESC
@@ -3979,6 +4027,7 @@ LIMIT sqlc.narg('limit');
-- name: GetNotStartedItems :many -- name: GetNotStartedItems :many
SELECT mi.* FROM media_items mi SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) 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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
AND NOT EXISTS ( AND NOT EXISTS (
@@ -4065,38 +4114,135 @@ SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) 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.archived_at IS NULL
AND mi.missing_scan_count = 0 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; ORDER BY mi.created_at DESC;
-- name: GetDistinctSeries :many -- name: GetDistinctSeries :many
-- Series are grouped PER LIBRARY: the same series name in two libraries -- Series are grouped PER LIBRARY: the same series name in two libraries
-- (novels vs manga adaptations, LN splits) stays two entries. Visible -- (novels vs manga adaptations, LN splits) stays two entries. Visible
-- filter enforces per-user library visibility at the source. -- filter enforces per-user library visibility at the source.
--
-- Series-page sort/filter contract (extensible registry in
-- services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
-- it filters in WHERE (the group key — counts stay true); AuthorQuery
-- and Status filter via HAVING so COUNT(*) keeps reporting the whole
-- series. ViewerUserId scopes the per-user progress aggregates (the rp
-- join can't multiply rows: reading_progress is UNIQUE per
-- media_item+user). Sort is a BOUND PARAM compared inside the CASE
-- cascade — never interpolated — and the GROUP BY tiebreaker stays
-- REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
-- transaction share a single NOW() timestamp, leaving many groups tied
-- on MAX(created_at), and Postgres does not guarantee tie order between
-- executions, so page boundaries could repeat or drop series.
SELECT library_id, series, COUNT(*) as book_count, SELECT library_id, series, COUNT(*) as book_count,
MAX(series_count) as total_in_series, MAX(series_count) as total_in_series,
MAX(created_at) as last_entry_at MAX(created_at) as last_entry_at,
COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count,
MAX(rp.last_read_at) AS last_read_at
FROM media_items FROM media_items
LEFT JOIN reading_progress rp
ON rp.media_item_id = media_items.id
AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) 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 archived_at IS NULL
AND missing_scan_count = 0 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 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
-- a WHERE filter: a group qualifies when ANY of its rows matches,
-- so COUNT(*) keeps reporting the whole series.
AND (sqlc.narg('series_query')::text IS NULL OR
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR author ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || sqlc.narg('series_query')::text || '%'
))
GROUP BY library_id, series GROUP BY library_id, series
-- Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
-- transaction share a single NOW() timestamp, leaving many groups tied on AND (
-- MAX(created_at). Postgres does not guarantee tie order between sqlc.narg('status')::text IS NULL
-- executions, so page boundaries could repeat or drop series. The GROUP BY OR CASE sqlc.narg('status')::text
-- keys make the sort total and deterministic. WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
ORDER BY MAX(created_at) DESC, library_id, series WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
ELSE true
END
)
ORDER BY
CASE WHEN COALESCE(sqlc.narg('sort')::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'name_asc' THEN series END ASC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'name_desc' THEN series END DESC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'progress_desc'
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
END DESC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'progress_asc'
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
END ASC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST,
library_id, series
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: GetDistinctSeriesCount :one -- name: GetDistinctSeriesCount :one
SELECT COUNT(DISTINCT (library_id, series))::int -- Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
-- HAVING so the pagination total matches what the page query returns
-- under the same filters (Sort is a page-ORDER concern, not a set
-- membership one, so it is absent here by design).
SELECT COUNT(*)::int FROM (
SELECT library_id, series
FROM media_items
LEFT JOIN reading_progress rp
ON rp.media_item_id = media_items.id
AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
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 || '%'
OR author ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || sqlc.narg('series_query')::text || '%'
))
GROUP BY library_id, series
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
AND (
sqlc.narg('status')::text IS NULL
OR CASE sqlc.narg('status')::text
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
ELSE true
END
)
) groups;
-- name: CountOtherMediaItemsInDir :one
-- 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.
SELECT COUNT(*)::int
FROM media_items FROM media_items
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) WHERE library_id = sqlc.narg('library_id')::uuid
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) AND id != sqlc.narg('id')::uuid
AND archived_at IS NULL AND archived_at IS NULL
AND missing_scan_count = 0 AND file_path LIKE (sqlc.narg('dir_prefix')::text || '%');
AND series IS NOT NULL AND series != '';
-- name: GetSeriesCovers :many -- name: GetSeriesCovers :many
SELECT cover_image_path, library_id SELECT cover_image_path, library_id
@@ -4482,3 +4628,481 @@ SET file_size = $2,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 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; 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 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 // executeSchema runs the entire schema.sql in a single transaction
func executeSchema(ctx context.Context, db *pgxpool.Pool) error { func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
tx, err := db.Begin(ctx) tx, err := db.Begin(ctx)
@@ -186,6 +196,12 @@ func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
} }
defer tx.Rollback(ctx) 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) _, err = tx.Exec(ctx, SchemaFile)
if err != nil { if err != nil {
log.Printf("HINT: Run manually to debug: psql -h localhost -U postgres -d bookhoard -f database/schema/schema.sql") 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. // SettingType enumerates the value types stored in system_settings.setting_type.
const ( const (
SettingTypeInt = "int" SettingTypeInt = "int"
SettingTypeFloat = "float"
SettingTypeBool = "bool" SettingTypeBool = "bool"
SettingTypeString = "string" SettingTypeString = "string"
SettingTypeStringList = "string_list" 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: "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_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: "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. // defaultBy indexes SettingDefaults by key for O(1) lookup.
@@ -172,6 +176,26 @@ func (r *SettingsRegistry) getInt(key string) int {
return n 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 { func (r *SettingsRegistry) getBool(key string) bool {
v := r.raw(key) v := r.raw(key)
b, err := strconv.ParseBool(v) 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 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. // AutoScanEnabled reports whether auto-scanning is on.
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") } 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 { if _, err := strconv.ParseBool(d.Value); err != nil {
t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value) 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: case SettingTypeString:
if d.Value == "" { if d.Value == "" {
t.Errorf("string setting %s has empty default", d.Key) t.Errorf("string setting %s has empty default", d.Key)
+3
View File
@@ -147,6 +147,9 @@ var Schemas = map[string]Schema{
"POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": { "POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": {
Response: json.RawMessage(`{"success": false, "message": ""}`), Response: json.RawMessage(`{"success": false, "message": ""}`),
}, },
"POST /api/libraries/:id/issues/:issueId/retry": {
Response: json.RawMessage(`{"success": false, "message": ""}`),
},
"DELETE /api/libraries/:id/issues/:issueId": { "DELETE /api/libraries/:id/issues/:issueId": {
Response: json.RawMessage(`{"success": false, "message": ""}`), Response: json.RawMessage(`{"success": false, "message": ""}`),
}, },
+3 -4
View File
@@ -3,7 +3,6 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"context"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -102,7 +101,7 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
// "today") never appear. // "today") never appear.
endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond) endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond)
ctx := context.Background() ctx := c.Request().Context()
history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{ history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{
UserID: user.ID, UserID: user.ID,
@@ -220,7 +219,7 @@ func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserRead
func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error { func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
user := c.Get("user").(database.Users) user := c.Get("user").(database.Users)
ctx := context.Background() ctx := c.Request().Context()
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID) usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) { if err != nil && !errors.Is(err, pgx.ErrNoRows) {
@@ -268,7 +267,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
limitInt = parsedLimit limitInt = parsedLimit
} }
ctx := context.Background() ctx := c.Request().Context()
books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{ books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{
UserID: user.ID, UserID: user.ID,
+19 -17
View File
@@ -10,6 +10,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"html"
"html/template" "html/template"
"math" "math"
"net/http" "net/http"
@@ -335,13 +336,9 @@ func (h *AuthHandler) Register(c *echo.Context) error {
} }
if c.Request().Header.Get("HX-Request") == "true" { if c.Request().Header.Get("HX-Request") == "true" {
html := fmt.Sprintf(`<div class="text-green-500">Registration successful! Redirecting...</div> // CSP: see the login handler — data attributes, not inline scripts.
<script> html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="/dashboard">Registration successful! Redirecting...</div>`,
localStorage.setItem('token', '%s'); accessToken)
localStorage.setItem('refreshToken', '%s');
localStorage.setItem('user', JSON.stringify(%s));
window.location.href = '/dashboard';
</script>`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username))
return c.HTML(http.StatusCreated, html) return c.HTML(http.StatusCreated, html)
} }
@@ -368,6 +365,10 @@ window.location.href = '/dashboard';
} }
// Login handles POST /api/auth/login // Login handles POST /api/auth/login
// invalidCredentialsHTML is the htmx error partial for failed logins;
// web/src/auth_forms.ts swaps 4xx/5xx HTML responses into the form target.
const invalidCredentialsHTML = `<div class="text-red-500">Invalid email/username or password.</div>`
func (h *AuthHandler) Login(c *echo.Context) error { func (h *AuthHandler) Login(c *echo.Context) error {
login := c.FormValue("login") login := c.FormValue("login")
password := c.FormValue("password") password := c.FormValue("password")
@@ -424,7 +425,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
} }
if c.Request().Header.Get("HX-Request") == "true" { if c.Request().Header.Get("HX-Request") == "true" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) return c.HTML(http.StatusUnauthorized, invalidCredentialsHTML)
} }
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
} }
@@ -440,7 +441,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
} }
if c.Request().Header.Get("HX-Request") == "true" { if c.Request().Header.Get("HX-Request") == "true" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) return c.HTML(http.StatusUnauthorized, invalidCredentialsHTML)
} }
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
} }
@@ -481,17 +482,18 @@ func (h *AuthHandler) Login(c *echo.Context) error {
} }
if c.Request().Header.Get("HX-Request") == "true" { if c.Request().Header.Get("HX-Request") == "true" {
// CSP: hash-mode script-src refuses inline scripts, so the htmx
// partial carries data attributes instead; web/src/auth_forms.ts
// stashes the token and navigates on htmx:afterSwap. The redirect
// target is path-only (open-redirect hygiene) and the old
// 'user'/'refreshToken' localStorage writes are gone — nothing
// ever read them.
redirect := c.FormValue("redirect") redirect := c.FormValue("redirect")
if redirect == "" { if redirect == "" || !strings.HasPrefix(redirect, "/") {
redirect = "/dashboard" redirect = "/dashboard"
} }
html := fmt.Sprintf(`<div class="text-green-500">Login successful! Redirecting...</div> html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="%s">Login successful! Redirecting...</div>`,
<script> accessToken, html.EscapeString(redirect))
localStorage.setItem('token', '%s');
localStorage.setItem('refreshToken', '%s');
localStorage.setItem('user', JSON.stringify(%s));
window.location.href = '%s';
</script>`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s","first_name":"%s","last_name":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username, user.FirstName.String, user.LastName.String), redirect)
return c.HTML(http.StatusOK, html) return c.HTML(http.StatusOK, html)
} }
+39 -15
View File
@@ -13,7 +13,7 @@ import (
// BookInfo is THE canonical wire+template card shape: every surface that // BookInfo is THE canonical wire+template card shape: every surface that
// lists a book card as JSON (collections detail, series, preview, // lists a book card as JSON (collections detail, series, preview,
// dashboard sections, search) or renders a templ card (BookCard in // dashboard sections, search) or renders a templ card (BookCard in
// dashboard.templ, BooksGrid, BrowseDetail) carries exactly these seven // dashboard.templ, BooksGrid, BrowseDetail) carries exactly these nine
// keys and no others — pinned by docs/contract_pins.md. // keys and no others — pinned by docs/contract_pins.md.
// //
// H2 doctrine: this struct is constructed in EXACTLY ONE place — // H2 doctrine: this struct is constructed in EXACTLY ONE place —
@@ -34,6 +34,15 @@ type BookInfo struct {
// comic vs manga rides the LIBRARY type (format_group only knows // comic vs manga rides the LIBRARY type (format_group only knows
// comic_archive — manga_type is unreliable across sources). // comic_archive — manga_type is unreliable across sources).
Badge string `json:"badge"` Badge string `json:"badge"`
// Viewer-scoped extras (2026-10-27 search-progress batch): nil
// percentage = the requesting user never opened the book, so cards
// render no progress bar and no completed badge. Adapters that
// don't load viewer progress leave it nil; the search-unified
// adapter is the only populated feed today. LibraryName rides the
// same deal (unified rows carry it as a column; other feeds leave
// it empty rather than pay a join).
ViewerPercentage *float64 `json:"viewer_percentage"`
LibraryName string `json:"library_name"`
} }
// LibraryTypeMap builds libraryID→library-type-name for badge resolution // LibraryTypeMap builds libraryID→library-type-name for badge resolution
@@ -79,12 +88,14 @@ func MediaBadgeKind(formatGroup, libraryTypeName string) string {
// adapters below normalize the sqlc row shapes into cardSource. // adapters below normalize the sqlc row shapes into cardSource.
func buildBookCard(src cardSource) BookInfo { func buildBookCard(src cardSource) BookInfo {
return BookInfo{ return BookInfo{
MediaItemID: src.ID.String(), MediaItemID: src.ID.String(),
Title: src.Title, Title: src.Title,
Author: src.Author, Author: src.Author,
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath), CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
FormatGroup: src.FormatGroup, FormatGroup: src.FormatGroup,
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType), Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
ViewerPercentage: src.ViewerProgress,
LibraryName: src.LibraryName,
} }
} }
@@ -102,6 +113,10 @@ type cardSource struct {
CoverPath pgtype.Text CoverPath pgtype.Text
FormatGroup string FormatGroup string
LibraryType string LibraryType string
// Viewer-scoped extras: nil progress = no data loaded for the
// requesting user (see BookInfo.ViewerPercentage).
ViewerProgress *float64
LibraryName string
} }
// BookCardsFromMediaItems builds cards from full media rows // BookCardsFromMediaItems builds cards from full media rows
@@ -167,18 +182,27 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
// BookCardsFromUnified builds cards from search-unified rows. Those // BookCardsFromUnified builds cards from search-unified rows. Those
// rows carry the library type name as a SQL column, so this adapter // rows carry the library type name as a SQL column, so this adapter
// needs no LibraryTypeMap query at all. // needs no LibraryTypeMap query at all — and the viewer's own
// progress (the 2026-10-27 search-progress batch), which no other
// feed loads.
func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo { func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo {
cards := make([]BookInfo, 0, len(rows)) cards := make([]BookInfo, 0, len(rows))
for _, r := range rows { for _, r := range rows {
var viewer *float64
if r.ViewerPercentage.Valid {
pct := r.ViewerPercentage.Float64
viewer = &pct
}
cards = append(cards, buildBookCard(cardSource{ cards = append(cards, buildBookCard(cardSource{
ID: uuid.UUID(r.ID.Bytes), ID: uuid.UUID(r.ID.Bytes),
Title: r.Title, Title: r.Title,
Author: utils.PgTextString(r.Author), Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID, LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath, CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup, FormatGroup: r.FormatGroup,
LibraryType: r.LibraryTypeName, LibraryType: r.LibraryTypeName,
ViewerProgress: viewer,
LibraryName: r.LibraryName,
})) }))
} }
return cards return cards
+2 -1
View File
@@ -496,7 +496,8 @@ func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
} }
if c.Request().Header.Get("HX-Request") == "true" { if c.Request().Header.Get("HX-Request") == "true" {
html := `<div class="text-green-500">Collection deleted! Redirecting...</div><script>window.location.href = '/collections';</script>` // CSP: no inline scripts in partials — auth_forms.ts navigates.
html := `<div class="text-green-500" data-redirect="/collections">Collection deleted! Redirecting...</div>`
return c.HTML(http.StatusOK, html) return c.HTML(http.StatusOK, html)
} }
+19 -20
View File
@@ -80,7 +80,7 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes
status = "unresolved" status = "unresolved"
} }
ctx := context.Background() ctx := c.Request().Context()
var conflicts interface{} var conflicts interface{}
var err error var err error
@@ -175,7 +175,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
} }
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true} 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found") 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") 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 { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media item") 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} 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found") 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 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 appliedTo["progress"] = true
} }
} }
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" { 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 appliedTo["annotations"] = true
} }
} }
@@ -283,7 +283,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
resolutionDataJSON, _ := json.Marshal(resolutionData) resolutionDataJSON, _ := json.Marshal(resolutionData)
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{ _, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
ID: conflictUUID, ID: conflictUUID,
ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true}, ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true},
ResolutionData: resolutionDataJSON, 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. // the service's LWW seed instead of being NULLed — that is the fix.
// chapter_progress keeps its stored value instead of being overwritten // chapter_progress keeps its stored value instead of being overwritten
// with the book-level percentage. // 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{ req := wsync.SaveProgressRequest{
MediaItemID: mediaItemID, MediaItemID: mediaItemID,
UserID: userID, UserID: userID,
@@ -361,12 +361,11 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
req.TotalPages = &tp req.TotalPages = &tp
} }
_, err := h.progressSvc.SaveProgress(context.Background(), req) _, err := h.progressSvc.SaveProgress(ctx, req)
return err return err
} }
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error { func (h *ConflictHandler) applyAnnotationResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
ctx := context.Background()
dedupKey, _ := winnerData["dedup_key"].(string) dedupKey, _ := winnerData["dedup_key"].(string)
if dedupKey == "" { if dedupKey == "" {
@@ -530,7 +529,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
} }
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true} 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found") 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") 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") 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 { func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
user := c.Get("user").(database.Users) 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 { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts") return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
} }
@@ -560,7 +559,7 @@ func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
deleted := 0 deleted := 0
for _, conflict := range conflicts { for _, conflict := range conflicts {
if conflict.ResolutionStatus.String == "user_resolved" || conflict.ResolutionStatus.String == "bulk_resolved" { 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++ deleted++
} }
} }
@@ -620,7 +619,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
} }
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true} 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 err != nil {
results = append(results, ConflictResult{ results = append(results, ConflictResult{
ConflictID: conflictIDStr, ConflictID: conflictIDStr,
@@ -702,7 +701,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
continue 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{ results = append(results, ConflictResult{
ConflictID: conflictIDStr, ConflictID: conflictIDStr,
Status: "error", Status: "error",
@@ -719,7 +718,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
} }
resolutionDataJSON, _ := json.Marshal(resolutionData) resolutionDataJSON, _ := json.Marshal(resolutionData)
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{ _, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
ID: conflictUUID, ID: conflictUUID,
ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true}, ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true},
ResolutionData: resolutionDataJSON, ResolutionData: resolutionDataJSON,
@@ -817,7 +816,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
} }
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true} 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 err != nil {
results = append(results, ConflictResult{ results = append(results, ConflictResult{
ConflictID: conflictIDStr, ConflictID: conflictIDStr,
@@ -838,7 +837,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
continue 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{ results = append(results, ConflictResult{
ConflictID: conflictIDStr, ConflictID: conflictIDStr,
Status: "error", Status: "error",
+10 -1
View File
@@ -433,6 +433,12 @@ func (h *DeviceHandler) DeleteDevice(c *echo.Context) error {
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"}) return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
} }
// Detach reading history first: the history FK to devices is NO
// ACTION on purpose (rows are real captured stats); NULLing the
// device association keeps the stats while freeing the device.
if err := h.db.DetachReadingHistoryFromDevice(c.Request().Context(), pgDeviceID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to detach device history"})
}
if err := h.db.DeleteDevice(c.Request().Context(), pgDeviceID); err != nil { if err := h.db.DeleteDevice(c.Request().Context(), pgDeviceID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete device"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete device"})
} }
@@ -499,7 +505,10 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
// Handle HTMX requests - return HTML with page reload script // Handle HTMX requests - return HTML with page reload script
if c.Request().Header.Get("HX-Request") == "true" { if c.Request().Header.Get("HX-Request") == "true" {
html := `<div class="text-green-500">Token regenerated! Reloading ... </div><script>window.location.reload();` // CSP: no inline scripts in partials; HX-Refresh makes htmx
// reload the page natively (the old script tag was also unclosed).
c.Response().Header().Set("HX-Refresh", "true")
html := `<div class="text-green-500">Token regenerated! Reloading ... </div>`
return c.HTML(http.StatusOK, html) return c.HTML(http.StatusOK, html)
} }
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
+12 -4
View File
@@ -1,12 +1,15 @@
package handlers package handlers
import ( import (
"context"
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/jackc/pgx/v5/pgtype"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/rls"
"bookhoard/internal/services" "bookhoard/internal/services"
) )
@@ -49,12 +52,17 @@ func (h *JobsHandler) CreateJob(c *echo.Context) error {
// Add database to params // Add database to params
req.Params["db"] = h.db req.Params["db"] = h.db
// RLS plan §6: the job's Context carries the requesting admin's
// identity — governed reads inside the job would raise otherwise.
userUUID, _ := uuid.Parse(c.Get("user_id").(string))
// Create job // Create job
job := &services.Job{ job := &services.Job{
ID: uuid.New().String(), ID: uuid.New().String(),
Type: jobType, Type: jobType,
Params: req.Params, Params: req.Params,
Status: services.JobStatusPending, Status: services.JobStatusPending,
Context: rls.WithUser(context.Background(), pgtype.UUID{Bytes: userUUID, Valid: true}),
} }
// Enqueue job // Enqueue job
+69
View File
@@ -1542,6 +1542,13 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
} }
// Edit segregation (plan D10, task 1.8): podcast episodes are
// fetcher-owned (D9 — the sole writer); the generic edit box would
// clobber feed metadata that the next refresh overwrites anyway.
if existing.Source == "podcast_episode" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "podcast episodes are managed by their feed and cannot be edited here"})
}
coverPath := existing.CoverImagePath.String coverPath := existing.CoverImagePath.String
coverUploaded := false coverUploaded := false
@@ -1801,6 +1808,57 @@ func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
return c.JSON(http.StatusOK, item) return c.JSON(http.StatusOK, item)
} }
// WriteMetadataToFile handles POST /api/media-items/:id/write-metadata
// (admin only). Persists the item's CURRENT database metadata into the
// artifact on disk — embedded OPF for EPUBs, a Calibre-style sidecar
// for formats that can't carry metadata (2026-10-27 feature) — then
// re-extracts from the file so the scan-derived columns agree with
// what was written. metadata_overrides are NOT reset: after the write,
// file and DB agree, and the overrides keep stale sidecars from
// clobbering future rescans.
func (mh *MediaHandler) WriteMetadataToFile(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
writer := services.NewMetadataWriter(mh.db)
result, err := writer.WriteMetadataToFile(c.Request().Context(), mediaUUID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Re-extract so the scan pipeline sees the file we just wrote. A
// rescan failure doesn't undo the write — it surfaces separately.
rescanStatus := "ok"
scanner := services.NewMediaScanner(mh.db)
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, false); err != nil {
rescanStatus = "error: " + err.Error()
}
defer scanner.Close()
return c.JSON(http.StatusOK, map[string]interface{}{
"write": result,
"rescan": rescanStatus,
"metadata_target": string(result.Target),
"fields_written": result.FieldsWritten,
})
}
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only) // DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error { func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c) user := MustGetAuthenticatedUser(c)
@@ -1823,6 +1881,17 @@ func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
// Invariant 2 (podcast plan §10, absolute form): episode rows are
// removed ONLY when the whole feed is removed — the feed must match
// its XML until then. Same segregation as the edit path (D9/D10):
// episodes are fetcher-owned; delete FILES via the podcast surfaces
// (feed page / admin podcast view) instead.
if media.Source == "podcast_episode" {
return c.JSON(http.StatusConflict, map[string]string{
"error": "podcast episodes are removed only by removing the whole feed — delete the episode's files instead (feed page or admin podcast view)",
})
}
// Storage-down guard (see HandleBulkDelete): refuse while the library // Storage-down guard (see HandleBulkDelete): refuse while the library
// storage is unreachable so the cascade can't destroy annotations for // storage is unreachable so the cascade can't destroy annotations for
// a file that will re-import on the next scan. // a file that will re-import on the next scan.
+25 -8
View File
@@ -1,5 +1,4 @@
package handlers package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/opds" "bookhoard/internal/opds"
@@ -18,6 +17,24 @@ import (
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
) )
// opdsVisibleLibraries is GetUserVisibleLibraries minus podcasts-type
// libraries (plan D16): feeds are not OPDS inventory day one — they
// surface via the /podcasts endpoints only.
func opdsVisibleLibraries(ctx context.Context, q *database.Queries, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) {
libs, err := q.GetUserVisibleLibraries(ctx, userID)
if err != nil {
return nil, err
}
out := make([]database.GetUserVisibleLibrariesRow, 0, len(libs))
for _, lib := range libs {
if lib.TypeName == services.LibraryTypePodcasts {
continue
}
out = append(out, lib)
}
return out, nil
}
type OPDSHandler struct { type OPDSHandler struct {
db *database.Queries db *database.Queries
libraryService *services.LibraryService libraryService *services.LibraryService
@@ -209,7 +226,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
// Get user's visible libraries // Get user's visible libraries
userID := device.UserID.Bytes userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil { if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries")) return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
} }
@@ -318,7 +335,7 @@ func (h *OPDSHandler) serveAllBooksCatalog(c *echo.Context) error {
// Get user's visible libraries // Get user's visible libraries
userID := device.UserID.Bytes userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil { if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries")) return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
} }
@@ -570,7 +587,7 @@ func (h *OPDSHandler) GetLibraryCatalog(c *echo.Context) error {
// The library must be visible to the device owner. // The library must be visible to the device owner.
userID := device.UserID.Bytes userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil { if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries")) return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
} }
@@ -677,7 +694,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
// Get user's visible libraries // Get user's visible libraries
userID := device.UserID.Bytes userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil { if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries")) return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
} }
@@ -818,7 +835,7 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
// Get user's visible libraries // Get user's visible libraries
userID := device.UserID.Bytes userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
} }
@@ -974,7 +991,7 @@ func (h *OPDSHandler) GetCoverImage(c *echo.Context) error {
// Get user's visible libraries // Get user's visible libraries
userID := device.UserID.Bytes userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
} }
@@ -1115,7 +1132,7 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
// Get user's visible libraries // Get user's visible libraries
userID := device.UserID.Bytes userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) libraries, err := opdsVisibleLibraries(c.Request().Context(), h.db, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
} }
+758
View File
@@ -0,0 +1,758 @@
// Podcast handlers (plan §8): JSON API over the RLS-governed podcast
// surfaces. Handlers parse requests and call services; visibility is
// the engine's job (D20) — Go checks only explicit-argument
// authorization (target-library rules, admin role) and subscription
// lookups (podcast_subscriptions is ungoverned BY DESIGN: it IS the
// read policy's lookup table).
package handlers
import (
"context"
"encoding/json"
"log"
"sort"
"bookhoard/internal/database"
"bookhoard/internal/services"
"net/http"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type PodcastHandler struct {
db *database.Queries
podcasts *services.PodcastService
worker *services.PodcastDownloadWorker
// episodeListRenderer renders the refreshed episode-list partial
// for htmx action responses (download/refresh/order/policy).
// Injected by the router at wiring time — the view assembly lives
// with the other templ view code (handlers must not import
// templates: templates imports handlers for MediaDetail). When nil
// (tests, API-only), htmx callers fall back to JSON + HX-Trigger.
episodeListRenderer EpisodeListRenderer
}
// EpisodeListRenderer renders the post-action episode list for an htmx
// request. includePolicySelect additionally out-of-band-swaps the
// auto-download select (Download All flips the caller's policy — the
// dropdown must reflect it immediately, issue #3);
// includeOrderSelect likewise rides along for the order endpoint (the
// "Reverse feed order" button flips to reverse — the dropdown must
// mirror the server state, §0 item 3).
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error
// SetEpisodeListRenderer wires the renderer (router-only; product wiring).
func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
h.episodeListRenderer = r
}
// hxEpisodeList answers an htmx action with the refreshed partial when
// a renderer is wired; otherwise falls back to 204 + HX-Trigger.
func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error {
if h.episodeListRenderer != nil {
return h.episodeListRenderer(c, feedID, includePolicySelect, includeOrderSelect)
}
return hxAccepted(c, "")
}
// AdminDeleteFeedFiles POST /api/podcasts/feeds/:id/delete-files —
// admin-only (§0 item 8): delete every downloaded episode file of the
// feed; rows reset to pending. HX answers HX-Refresh (the admin table
// re-renders with fresh aggregates).
func (h *PodcastHandler) AdminDeleteFeedFiles(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
}
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
// media_items writes run under the first-admin identity (invariant
// 12 — same contract as the fetcher/worker).
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
reset, err := h.podcasts.AdminDeleteFeedFiles(actx, feedID)
if err != nil {
return podcastErrorResponse(c, err)
}
log.Printf("[PODCAST] admin: deleted files for %d episode(s) of feed %s (user %s)", reset, feedID, user.Username)
if isHXRequest(c) {
c.Response().Header().Set("HX-Refresh", "true")
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
}
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
}
// AdminDeleteEpisodeFile POST /api/podcasts/episodes/:id/delete-file —
// admin-only (§0 item 8): ONE episode's file deleted, row reset to
// pending. HX answers with the refreshed episode list.
func (h *PodcastHandler) AdminDeleteEpisodeFile(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
}
episodeID, err := parseUUID(c.Param("eid"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid episode id"})
}
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
if err := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); err != nil {
return podcastErrorResponse(c, err)
}
log.Printf("[PODCAST] admin: deleted file of episode %s (user %s)", episodeID, user.Username)
if isHXRequest(c) {
// Re-render the episode list: the row needs its fresh state. The
// episode's feed id comes from the row we just reset.
if ep, err := h.db.GetPodcastEpisode(actx, episodeID); err == nil && ep.SourceFeedID.Valid {
return h.hxEpisodeList(c, ep.SourceFeedID.String(), false, false)
}
return hxAccepted(c, "")
}
return c.JSON(http.StatusOK, map[string]string{"message": "file deleted"})
}
// AdminDeleteEpisodeFilesBulk POST /api/podcasts/feeds/:id/episodes/delete-files —
// admin-only (§0 item 8): multi-select file deletion. Body carries the
// CHECKED boxes' values (form-encoded, repeated episode-file-select
// keys — hx-include gathers them; parsed directly, never bound: the
// repeated-key wire is another echo-bind gotcha). Each id is validated
// to belong to THIS feed; rows reset to pending, files removed.
func (h *PodcastHandler) AdminDeleteEpisodeFilesBulk(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
}
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
if err := c.Request().ParseForm(); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
rawIDs := c.Request().PostForm["episode-file-select"]
if len(rawIDs) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no episodes selected"})
}
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
deleted := 0
for _, raw := range rawIDs {
episodeID, perr := parseUUID(raw)
if perr != nil {
continue
}
// The checkbox list only renders this feed's rows, but the wire
// is user-controlled: a forged id for a foreign feed's episode
// is refused (feed-match guard), not silently applied.
ep, gerr := h.db.GetPodcastEpisode(actx, episodeID)
if gerr != nil || ep.SourceFeedID != feedID {
continue
}
if derr := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); derr == nil {
deleted++
}
}
log.Printf("[PODCAST] admin: bulk-deleted files for %d/%d episode(s) of feed %s (user %s)",
deleted, len(rawIDs), feedID, user.Username)
if isHXRequest(c) {
return h.hxEpisodeList(c, feedID.String(), false, false)
}
return c.JSON(http.StatusOK, map[string]interface{}{"deleted": deleted})
}
// flipSubscriptionToAll implements issue #3: Download All (button or
// add-feed checkbox) sets the CALLER's subscription policy to 'all' —
// per-user, never global (files are shared D6, policies are not). The
// dropdown reflects it via the renderer's oob swap; the user can turn
// it down afterwards.
func (h *PodcastHandler) flipSubscriptionToAll(ctx context.Context, userID, feedID pgtype.UUID) {
sub, err := h.db.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{
UserID: userID, FeedID: feedID,
})
if err != nil || sub.AutoDownload == "all" {
return // not subscribed (admin pass-through) or already all
}
_, _ = h.db.UpdatePodcastSubscription(ctx, database.UpdatePodcastSubscriptionParams{
UserID: userID,
FeedID: feedID,
DisplayName: sub.DisplayName,
AutoDownload: "all",
AutoDownloadCount: pgtype.Int4{}, // all needs no count
})
log.Printf("[PODCAST] flip: user %s → policy 'all' for feed %s (Download All / add checkbox)", userID, feedID)
}
func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worker *services.PodcastDownloadWorker) *PodcastHandler {
return &PodcastHandler{db: db, podcasts: svc, worker: worker}
}
// ---- request/response shapes ----
type AddPodcastFeedRequest struct {
LibraryID string `json:"library_id" form:"library_id" validate:"required"`
URL string `json:"url" form:"url" validate:"required"`
DownloadAll FlexBool `json:"download_all" form:"download_all"`
// DownloadOrder: the initial listening order for THIS feed, applied
// only with DownloadAll (§0 item 5c) — it both seeds the queue in
// listening order and sets the caller's order override. ""/"feed" =
// no override row.
DownloadOrder string `json:"download_order,omitempty" form:"download_order"`
}
// FlexBool binds checkbox-style booleans on every wire. echo bool-binds
// via strconv.ParseBool, which accepts "true"/"1" but REJECTS the
// browser-native checkbox value "on" — and a bind failure is fatal for
// the WHOLE request (the add modal would 400 "invalid request" on any
// form variant that posts "on"). §0 item 2 (2026-10-30).
//
// A checkbox only contributes its field when CHECKED, so for form
// posts presence really is the signal — JSON's native bool binding
// ("false"→false) is unaffected. Absence on either wire = false.
type FlexBool bool
func (b *FlexBool) UnmarshalParam(v string) error {
switch strings.ToLower(strings.TrimSpace(v)) {
case "false", "0", "off", "n", "no":
*b = false
default:
*b = true // "true", "on", "yes", "checked", "1" — or any truthy variant
}
return nil
}
type UpdatePodcastSubscriptionRequest struct {
DisplayName *string `json:"display_name" form:"display_name"`
AutoDownload *string `json:"auto_download" form:"auto_download" validate:"omitempty,oneof=off all newest"`
AutoDownloadCount *int `json:"auto_download_count" form:"auto_download_count"`
}
type DownloadEpisodesRequest struct {
EpisodeIDs []string `json:"episode_ids"`
}
// hxAccepted answers an htmx request with 204 + the podcast:changed
// trigger — the episode-list partial re-fetches itself (its hx-trigger
// listens for the event; polling covers download progress). JSON
// callers keep the JSON body. HX-Redirect/HX-Trigger headers are the
// sanctioned Go-handler response channels (CSP discipline).
func hxAccepted(c *echo.Context, redirect string) error {
if redirect != "" {
c.Response().Header().Set("HX-Redirect", redirect)
return c.NoContent(http.StatusNoContent)
}
c.Response().Header().Set("HX-Trigger", "podcast:changed")
return c.NoContent(http.StatusNoContent)
}
func isHXRequest(c *echo.Context) bool {
return c.Request().Header.Get("HX-Request") == "true"
}
// podcastErrorResponse maps service errors (incl. PodcastError statuses)
// onto HTTP responses.
func podcastErrorResponse(c *echo.Context, err error) error {
if pe, ok := err.(*services.PodcastError); ok {
return c.JSON(pe.Status, map[string]string{"error": pe.Message})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// podcastFeedSummaries is the list payload: subscription + feed metadata
// + the facet values the /podcasts page filters on (computed here —
// feed counts are tens, not thousands; no facet SQL needed).
type podcastFeedSummary struct {
Feed
SubscribedAt string `json:"subscribed_at"`
EpisodeTotal int `json:"episode_total"`
DownloadedN int `json:"episode_downloaded"`
UndownloadedN int `json:"episode_undownloaded"`
NewEpisodes bool `json:"new_episodes"`
}
// AddFeed POST /api/podcasts/feeds
func (h *PodcastHandler) AddFeed(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
var req AddPodcastFeedRequest
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()})
}
libraryID, err := parseUUID(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
}
res, err := h.podcasts.AddFeed(c.Request().Context(), user.ID, user.Role == "admin", libraryID, req.URL)
if err != nil {
return podcastErrorResponse(c, err)
}
if req.DownloadAll && res.Feed != nil {
// §0 item 5c: the add modal's order dropdown is the feed's
// INITIAL listening order — it IS the download order (the queue
// seeds in that order below). 'feed'/'native' = no override row.
order := "native"
switch req.DownloadOrder {
case "reverse", "oldest", "newest":
order = req.DownloadOrder
payload, _ := json.Marshal(map[string]string{"order": order})
if err := h.db.UpsertUserSettingByKey(c.Request().Context(), database.UpsertUserSettingByKeyParams{
UserID: user.ID, SettingKey: podcastOrderSettingKey(res.Feed.ID.String()), SettingValue: payload,
}); err != nil {
// subscription + queue stand; the dropdown just won't
// preselect it (surfaced on the next order set).
_ = err
}
}
if _, err := h.worker.QueueFeedDownloadsAs(c.Request().Context(), user.ID, res.Feed.ID); err != nil {
// subscription stands; download-all failure surfaces via the
// episode list states.
}
// Issue #3: the checkbox means "everything, now AND going
// forward" — reflect it in the caller's subscription policy.
h.flipSubscriptionToAll(c.Request().Context(), user.ID, res.Feed.ID)
}
if isHXRequest(c) {
// Post-redirect-get: land on the new feed's page. Fetch errors
// (subscription stands) surface via the flash query param.
if res.FeedError() != "" {
return hxAccepted(c, "/podcasts/"+uuidString(res.Feed.ID)+"?fetch_error=1")
}
return hxAccepted(c, "/podcasts/"+uuidString(res.Feed.ID))
}
return c.JSON(http.StatusCreated, res)
}
// ListFeeds GET /api/podcasts/feeds — the caller's subscriptions.
func (h *PodcastHandler) ListFeeds(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
ctx := c.Request().Context()
feeds, err := h.db.ListMyPodcastFeeds(ctx, user.ID)
if err != nil {
return podcastErrorResponse(c, err)
}
out := make([]podcastFeedSummary, 0, len(feeds))
for i := range feeds {
row := &feeds[i]
total, _ := h.db.CountPodcastEpisodesForFeed(ctx, row.ID)
downloaded, _ := h.db.CountDownloadedEpisodes(ctx, row.ID)
undownloaded, _ := h.db.CountUndownloadedEpisodes(ctx, row.ID)
out = append(out, podcastFeedSummary{
Feed: Feed{
ID: uuidString(row.ID), LibraryID: uuidString(row.LibraryID),
FeedUrl: row.FeedUrl, PodcastGuid: row.PodcastGuid.String,
Title: row.Title, Description: row.Description.String,
Host: row.Host.String, Publisher: row.Publisher.String,
Language: row.Language.String, Tags: row.Tags,
Explicit: row.Explicit, ArtworkPath: row.ArtworkPath.String,
FeedType: row.FeedType.String, Medium: row.Medium.String,
IsComplete: row.IsComplete, LastFetchedAt: timeString(row.LastFetchedAt),
},
SubscribedAt: timeString(row.SubscribedAt),
EpisodeTotal: int(total),
DownloadedN: int(downloaded),
UndownloadedN: int(undownloaded),
// "New episodes" (D18): the feed released something the user
// has not pulled — undownloaded rows exist beyond the last
// downloaded position.
NewEpisodes: undownloaded > 0,
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"feeds": out,
"facets": facetValuesFor(out),
})
}
// GetFeed GET /api/podcasts/feeds/:id — detail: metadata, episodes with
// download state, gap summary. RLS answers 404 for non-subscribers
// (existence leak) — no Go-level visibility plumbing.
func (h *PodcastHandler) GetFeed(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
ctx := c.Request().Context()
feed, err := h.db.GetPodcastFeed(ctx, feedID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"})
}
sub, subFound := h.db.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{UserID: user.ID, FeedID: feedID})
eps, err := h.db.ListPodcastEpisodes(ctx, feedID)
if err != nil {
return podcastErrorResponse(c, err)
}
episodeViews := make([]PodcastEpisodeView, 0, len(eps))
for i := range eps {
episodeViews = append(episodeViews, episodeView(&eps[i]))
}
return c.JSON(http.StatusOK, map[string]interface{}{
"feed": feedWithSubscription(&feed, sub, subFound == nil),
"episodes": episodeViews,
"gap_count": countGaps(episodeViews),
"undownloaded": len(episodeViews) - countDownloaded(episodeViews),
"has_library_access": true, // RLS already gated the row; kept for client convenience
})
}
// UpdateSubscription PUT /api/podcasts/feeds/:id/subscription — the
// only user-writable podcast state (D9): display name + auto-download.
func (h *PodcastHandler) UpdateSubscription(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
var req UpdatePodcastSubscriptionRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
ctx := c.Request().Context()
sub, err := h.db.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{UserID: user.ID, FeedID: feedID})
if err != nil {
// Not subscribed (or feed invisible): 404 semantics.
return c.JSON(http.StatusNotFound, map[string]string{"error": "not subscribed"})
}
displayName := sub.DisplayName
if req.DisplayName != nil {
displayName = pgtypeText(*req.DisplayName)
}
autoDownload := sub.AutoDownload
if req.AutoDownload != nil {
autoDownload = *req.AutoDownload
}
count := sub.AutoDownloadCount
if req.AutoDownloadCount != nil {
if *req.AutoDownloadCount < 0 || *req.AutoDownloadCount > 100 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "auto_download_count out of range"})
}
count = pgtype.Int4{Int32: int32(*req.AutoDownloadCount), Valid: *req.AutoDownloadCount > 0}
}
if autoDownload == "newest" && !count.Valid {
count = pgtype.Int4{Int32: 3, Valid: true} // sane default for newest-N
}
updated, err := h.db.UpdatePodcastSubscription(ctx, database.UpdatePodcastSubscriptionParams{
UserID: user.ID,
FeedID: feedID,
DisplayName: displayName,
AutoDownload: autoDownload,
AutoDownloadCount: count,
})
if err != nil {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
// Policy changes feed the auto-download union on the next poll.
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
return h.hxEpisodeList(c, feedID.String(), true, false)
}
return c.JSON(http.StatusOK, updated)
}
// DeleteSubscription DELETE /api/podcasts/feeds/:id/subscription —
// unsubscribe NEVER deletes files/episodes (D6; last unsubscribe
// retains them so re-subscribing must not re-download).
func (h *PodcastHandler) DeleteSubscription(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
rows, err := h.db.DeletePodcastSubscription(c.Request().Context(), database.DeletePodcastSubscriptionParams{UserID: user.ID, FeedID: feedID})
if err != nil {
return podcastErrorResponse(c, err)
}
if rows == 0 {
return c.JSON(http.StatusNotFound, map[string]string{"error": "not subscribed"})
}
if isHXRequest(c) {
return hxAccepted(c, "/podcasts")
}
return c.JSON(http.StatusOK, map[string]string{"message": "unsubscribed (files retained)"})
}
// RefreshFeed POST /api/podcasts/feeds/:id/refresh — manual poll.
func (h *PodcastHandler) RefreshFeed(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
_ = user
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
if err := h.podcasts.RefreshFeed(c.Request().Context(), feedID); err != nil {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
return h.hxEpisodeList(c, feedID.String(), false, false)
}
return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"})
}
// DownloadEpisodes POST /api/podcasts/feeds/:id/episodes/:eid/download
// (+ bulk via episode_ids[]; Download All = all non-downloaded). Retry
// is the same endpoint (idempotent state machine, plan §7).
func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
_ = user
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
ctx := c.Request().Context()
// The feed must be in the caller's scope; RLS answers 404.
if _, err := h.db.GetPodcastFeed(ctx, feedID); err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"})
}
// Bulk form?
var bulk DownloadEpisodesRequest
_ = c.Bind(&bulk)
if len(bulk.EpisodeIDs) > 0 {
queued := 0
for _, raw := range bulk.EpisodeIDs {
epID, err := parseUUID(raw)
if err != nil {
continue
}
if err := h.worker.QueueEpisode(ctx, epID, feedID); err == nil {
queued++
}
}
return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued})
}
// Single-episode path (:eid).
eidRaw := c.Param("eid")
if eidRaw == "all" {
// Download All affordance: exactly the non-downloaded set (pin).
// Queues in the CALLER's listening order (§0 item 5b).
queued, err := h.worker.QueueFeedDownloadsAs(ctx, user.ID, feedID)
if err != nil {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
// Download All flips the caller's policy to all (issue #3)
// — the oob select keeps the dropdown honest.
h.flipSubscriptionToAll(ctx, user.ID, feedID)
return h.hxEpisodeList(c, feedID.String(), true, false)
}
return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued})
}
epID, err := parseUUID(eidRaw)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid episode id"})
}
if err := h.worker.QueueEpisode(ctx, epID, feedID); err != nil {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
return h.hxEpisodeList(c, feedID.String(), false, false)
}
return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"})
}
// Search GET /api/podcasts/search?q= — subscription-scoped feed +
// episode search (D14): covers un-downloaded and historical episodes
// (rows are never deleted when a feed shrinks).
func (h *PodcastHandler) Search(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
q := strings.TrimSpace(c.QueryParam("q"))
ctx := c.Request().Context()
feeds, err := h.db.SearchMyPodcastFeeds(ctx, database.SearchMyPodcastFeedsParams{UserID: user.ID, SearchQuery: q})
if err != nil {
return podcastErrorResponse(c, err)
}
eps, err := h.db.SearchMyPodcastEpisodes(ctx, q)
if err != nil {
return podcastErrorResponse(c, err)
}
episodeViews := make([]PodcastEpisodeView, 0, len(eps))
for i := range eps {
episodeViews = append(episodeViews, episodeView(&eps[i]))
}
return c.JSON(http.StatusOK, map[string]interface{}{"feeds": feeds, "episodes": episodeViews})
}
// DeleteFeed DELETE /api/podcasts/feeds/:id — admin-only removal;
// optional ?files=false keeps the episode files on disk.
func (h *PodcastHandler) DeleteFeed(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
}
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
ctx := c.Request().Context()
if _, err := h.db.GetPodcastFeed(ctx, feedID); err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"})
}
keepFiles := c.QueryParam("files") == "false"
if !keepFiles {
// File deletion is best-effort before the row cascade; episodes'
// file_path values are relative to the download root.
if eps, err := h.db.ListPodcastEpisodes(ctx, feedID); err == nil {
if feed, err := h.db.GetPodcastFeed(ctx, feedID); err == nil {
h.podcasts.DeleteFeedFiles(ctx, &feed, eps)
}
}
}
if rows, err := h.db.DeletePodcastFeed(ctx, feedID); err != nil {
return podcastErrorResponse(c, err)
} else if rows == 0 {
return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"})
}
if isHXRequest(c) {
return hxAccepted(c, "/podcasts")
}
return c.JSON(http.StatusOK, map[string]string{"message": "feed removed"})
}
// ---- Sequence (plan §8 / Phase 2: D5 order override, D7 chaining) ----
// podcastOrderSettingKey is the per-user, per-feed order override key
// in the reader_settings KV (D5).
func podcastOrderSettingKey(feedID string) string {
return "podcast_order:" + feedID
}
// podcastOrderValid native (feed position) | reverse (position DESC,
// no dates) | oldest | newest.
func podcastOrderValid(o string) bool {
return services.ValidPodcastEpisodeOrder(o)
}
// GetFeedOrder reads the caller's effective order for a feed
// (default: native — feed-native order is the D5 default).
func (h *PodcastHandler) GetFeedOrder(c *echo.Context, feedID string) string {
user := MustGetAuthenticatedUser(c)
row, err := h.db.GetUserSettingByKey(c.Request().Context(), database.GetUserSettingByKeyParams{
UserID: user.ID,
SettingKey: podcastOrderSettingKey(feedID),
})
if err != nil {
return "native"
}
var v struct {
Order string `json:"order"`
}
if err := json.Unmarshal(row, &v); err != nil || !podcastOrderValid(v.Order) {
return "native"
}
return v.Order
}
// SetFeedOrder PUT /api/podcasts/feeds/:id/order — per-user override.
// Deleting the override (order=native) removes the KV row so the
// default and the explicit choice are indistinguishable.
func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
var req struct {
Order string `json:"order" form:"order" validate:"required,oneof=native reverse oldest newest"`
}
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()})
}
ctx := c.Request().Context()
if _, err := h.db.GetPodcastFeed(ctx, feedID); err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"})
}
key := podcastOrderSettingKey(feedID.String())
if req.Order == "native" {
_ = h.db.DeleteUserSettingByKey(ctx, database.DeleteUserSettingByKeyParams{
UserID: user.ID, SettingKey: key,
})
} else {
payload, _ := json.Marshal(map[string]string{"order": req.Order})
if err := h.db.UpsertUserSettingByKey(ctx, database.UpsertUserSettingByKeyParams{
UserID: user.ID, SettingKey: key, SettingValue: payload,
}); err != nil {
return podcastErrorResponse(c, err)
}
}
if isHXRequest(c) {
return h.hxEpisodeList(c, feedID.String(), false, true)
}
return c.JSON(http.StatusOK, map[string]string{"order": req.Order})
}
// GetFeedSequence GET /api/podcasts/feeds/:id/sequence — the effective
// listening order (override applied, feed-native fallback), DOWNLOADED
// episodes only, plus the gap list (D7/D8). This payload powers both
// the web player's chain mode and (later, §15) the media3 queue.
func (h *PodcastHandler) GetFeedSequence(c *echo.Context) error {
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
ctx := c.Request().Context()
order := h.GetFeedOrder(c, feedID.String())
eps, err := h.db.ListPodcastEpisodes(ctx, feedID)
if err != nil {
return podcastErrorResponse(c, err)
}
views := make([]PodcastEpisodeView, 0, len(eps))
for i := range eps {
views = append(views, episodeView(&eps[i]))
}
// Effective listening order (order consolidation §0 item 5a).
services.OrderEpisodes(views, order,
func(v PodcastEpisodeView) int32 { return int32(v.FeedPosition) },
func(v PodcastEpisodeView) string { return v.Date },
)
sequence := make([]PodcastEpisodeView, 0, len(views))
gaps := make([]PodcastEpisodeView, 0)
for _, v := range views {
if v.Downloaded {
sequence = append(sequence, v)
} else {
gaps = append(gaps, v)
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"feed_id": feedID.String(),
"order": order,
"episodes": sequence,
"gaps": gaps,
})
}
// sortSlice: sort.Slice with a controller-friendly wrapper.
func sortSlice[T any](s []T, less func(a, b T) bool) {
sort.Slice(s, func(i, j int) bool { return less(s[i], s[j]) })
}
+222
View File
@@ -0,0 +1,222 @@
// Podcast view types + mappers shared by the JSON API and the SSR
// partials (plan §8/§9). One shape per concept so the htmx partials and
// the API can never drift.
package handlers
import (
"sort"
"time"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
// Feed is the API-facing feed shape (subscription fields attached when
// the caller has one).
type Feed struct {
ID string `json:"id"`
LibraryID string `json:"library_id"`
FeedUrl string `json:"feed_url"`
PodcastGuid string `json:"podcast_guid,omitempty"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Host string `json:"host,omitempty"`
Publisher string `json:"publisher,omitempty"`
Language string `json:"language,omitempty"`
Tags []string `json:"tags,omitempty"`
Explicit bool `json:"explicit"`
ArtworkPath string `json:"artwork_path,omitempty"`
FeedType string `json:"feed_type,omitempty"`
Medium string `json:"medium,omitempty"`
IsComplete bool `json:"is_complete"`
LastFetchedAt string `json:"last_fetched_at,omitempty"`
// Subscription-owned (rendered when present).
DisplayName string `json:"display_name,omitempty"`
AutoDownload string `json:"auto_download,omitempty"`
AutoDownloadCount int `json:"auto_download_count,omitempty"`
}
// PodcastEpisodeView is the episode-list row: media facts + the
// download lifecycle the list renders inline (D8/D19).
type PodcastEpisodeView struct {
ID string `json:"id"`
FeedID string `json:"feed_id"`
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"`
Description string `json:"description,omitempty"`
Authors []string `json:"authors,omitempty"`
Hosts []string `json:"hosts,omitempty"`
Guests []string `json:"guests,omitempty"`
Date string `json:"date_published,omitempty"`
FeedPosition int `json:"feed_position"`
Season int `json:"season_number,omitempty"`
Episode int `json:"episode_number,omitempty"`
Duration int `json:"declared_duration_seconds,omitempty"`
MimeType string `json:"mime_type,omitempty"`
CoverImage string `json:"cover_image_path,omitempty"`
EnclosureURL string `json:"enclosure_url,omitempty"`
// Download lifecycle.
DownloadState string `json:"download_state"`
DownloadError string `json:"download_error,omitempty"`
DownloadAttempts int `json:"download_attempts"`
Downloaded bool `json:"downloaded"`
FileSize int64 `json:"file_size,omitempty"`
// Progress (from the shared reading_progress machinery).
ProgressPercent float64 `json:"progress_percent,omitempty"`
}
// episodeView maps a media_items row (podcast episode) onto the view.
func episodeView(ep *database.MediaItems) PodcastEpisodeView {
v := PodcastEpisodeView{
ID: uuidString(ep.ID),
FeedID: uuidString(ep.SourceFeedID),
Title: ep.Title,
Subtitle: ep.Subtitle.String,
Description: ep.Description.String,
Authors: ep.Authors,
Hosts: ep.Hosts,
Guests: ep.Guests,
FeedPosition: int(ep.FeedPosition.Int32),
Season: int(ep.SeasonNumber.Int32),
Episode: int(ep.EpisodeNumber.Int32),
Duration: int(ep.DeclaredDurationSeconds.Int32),
MimeType: ep.MimeType.String,
CoverImage: ep.CoverImagePath.String,
EnclosureURL: ep.EnclosureUrl.String,
DownloadState: ep.DownloadState,
DownloadError: ep.DownloadError.String,
DownloadAttempts: int(ep.DownloadAttempts),
Downloaded: ep.DownloadState == "downloaded",
FileSize: ep.FileSize.Int64,
}
if ep.DatePublished.Valid {
v.Date = ep.DatePublished.Time.Format("2006-01-02")
}
if !ep.FileSize.Valid {
v.FileSize = 0
}
return v
}
// feedWithSubscription merges the feed row + the caller's subscription
// (empty sub → bare feed).
func feedWithSubscription(feed *database.PodcastFeeds, sub database.PodcastSubscriptions, subFound bool) Feed {
f := Feed{
ID: uuidString(feed.ID),
LibraryID: uuidString(feed.LibraryID),
FeedUrl: feed.FeedUrl,
PodcastGuid: feed.PodcastGuid.String,
Title: feed.Title,
Description: feed.Description.String,
Host: feed.Host.String,
Publisher: feed.Publisher.String,
Language: feed.Language.String,
Tags: feed.Tags,
Explicit: feed.Explicit,
ArtworkPath: feed.ArtworkPath.String,
FeedType: feed.FeedType.String,
Medium: feed.Medium.String,
IsComplete: feed.IsComplete,
LastFetchedAt: timeString(feed.LastFetchedAt),
}
if subFound {
f.DisplayName = sub.DisplayName.String
f.AutoDownload = sub.AutoDownload
f.AutoDownloadCount = int(sub.AutoDownloadCount.Int32)
}
return f
}
// countGaps counts un-downloaded episodes that have a downloaded
// episode AFTER them in list order — the D8 "middle episodes missing"
// definition that drives the gap banner.
func countGaps(eps []PodcastEpisodeView) int {
seenDownloaded := false
gaps := 0
for i := len(eps) - 1; i >= 0; i-- {
if eps[i].Downloaded {
seenDownloaded = true
} else if seenDownloaded {
gaps++
}
}
return gaps
}
func countDownloaded(eps []PodcastEpisodeView) int {
n := 0
for _, e := range eps {
if e.Downloaded {
n++
}
}
return n
}
// facetValuesFor derives the /podcasts facet bar values (D18) from the
// subscription list — feed counts are small; no facet SQL.
func facetValuesFor(feeds []podcastFeedSummary) map[string][]string {
hosts := map[string]bool{}
tags := map[string]bool{}
langs := map[string]bool{}
media := map[string]bool{}
for _, f := range feeds {
if f.Host != "" {
hosts[f.Host] = true
}
for _, t := range f.Tags {
tags[t] = true
}
if f.Language != "" {
langs[f.Language] = true
}
if f.Medium != "" {
media[f.Medium] = true
}
}
return map[string][]string{
"hosts": sortedKeys(hosts),
"tags": sortedKeys(tags),
"language": sortedKeys(langs),
"medium": sortedKeys(media),
}
}
func sortedKeys(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// uuidString renders a pgtype.UUID (empty string when NULL).
func uuidString(id pgtype.UUID) string {
if !id.Valid {
return ""
}
return uuid.UUID(id.Bytes).String()
}
// timeString renders a timestamp (RFC3339; empty when NULL).
func timeString(t pgtype.Timestamptz) string {
if !t.Valid {
return ""
}
return t.Time.Format(time.RFC3339)
}
// pgtypeText (handlers-local) — service has its own.
func pgtypeText(s string) pgtype.Text {
if s == "" {
return pgtype.Text{}
}
return pgtype.Text{String: s, Valid: true}
}
+73
View File
@@ -3,6 +3,8 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"context" "context"
"errors"
"fmt"
"net/http" "net/http"
"time" "time"
@@ -218,6 +220,77 @@ func (h *ProcessingIssuesHandler) gateIssue(c *echo.Context, issueID uuid.UUID)
return nil return nil
} }
// RetryProcessingIssue re-runs the work that produced an open issue
// through the individual scanner — NOT a library-wide rescan:
//
// - item-backed issues rescan their media item (the book-detail
// RescanMediaItem mechanism: self-resolving folders, multi-file
// audiobook routing, write-after-success guards);
// - row-less import_failed issues re-import the single file through
// the watcher's standalone processMediaFile path.
//
// Success closes the issue (explicitly for the item shape; the create
// path's self-heal plus the explicit path resolve below for row-less).
// Failure returns the REAL error so the UI can say why it still fails.
func (h *ProcessingIssuesHandler) RetryProcessingIssue(c *echo.Context) error {
issueID, err := uuid.Parse(c.Param("issueId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"})
}
issue, err := h.db.GetProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Issue not found"})
}
if !issue.LibraryID.Valid {
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has no library attribution"})
}
if err := h.gateIssue(c, issueID); err != nil {
return err
}
if issue.Resolved.Valid && issue.Resolved.Bool {
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue is already resolved"})
}
scanner := services.NewMediaScanner(h.db)
defer scanner.Close()
if issue.MediaItemID.Valid {
if err := scanner.RetryMediaItemIssue(c.Request().Context(), issue.MediaItemID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{
"success": true,
"message": "Item rescanned; issue resolved",
})
}
// Row-less import failure: the file never became a row.
if !issue.FilePath.Valid || issue.FilePath.String == "" {
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has neither a media item nor a file path"})
}
relativePath := issue.FilePath.String
if err := scanner.RetryFailedImport(c.Request().Context(), issue.LibraryID, relativePath); err != nil {
if errors.Is(err, services.ErrRetryFileMissing) {
return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// The import chain's self-heal closes path-keyed issues on create;
// an already-imported duplicate (a previous partial retry) skips
// that path, so close explicitly here too. Idempotent.
if _, err := h.db.ResolveOpenProcessingIssuesForPath(c.Request().Context(), database.ResolveOpenProcessingIssuesForPathParams{
LibraryID: issue.LibraryID,
FilePath: pgtype.Text{String: relativePath, Valid: true},
}); err != nil {
fmt.Printf("Warning: retry imported %s but failed to close its issues: %v\n", relativePath, err)
}
return c.JSON(http.StatusOK, map[string]any{
"success": true,
"message": "File imported; issue resolved",
})
}
// GetProcessingIssueStatsData returns stats for SSR (not JSON response) // GetProcessingIssueStatsData returns stats for SSR (not JSON response)
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) { func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
return h.db.GetProcessingIssueStats(ctx, libraryID) return h.db.GetProcessingIssueStats(ctx, libraryID)
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"bookhoard/internal/database" "bookhoard/internal/database"
wsync "bookhoard/internal/sync" wsync "bookhoard/internal/sync"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
@@ -46,7 +45,7 @@ func (h *Handler) GetProgressHistory(c *echo.Context) error {
} }
} }
history, err := h.db.GetReadingHistory(context.Background(), database.GetReadingHistoryParams{ history, err := h.db.GetReadingHistory(c.Request().Context(), database.GetReadingHistoryParams{
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
Limit: int32(limit), Limit: int32(limit),
+17
View File
@@ -74,6 +74,23 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
// Logout handles POST /api/auth/logout // Logout handles POST /api/auth/logout
func (h *AuthHandler) Logout(c *echo.Context) error { func (h *AuthHandler) Logout(c *echo.Context) error {
// Clear the HttpOnly session cookie — the SSR pages authenticate
// with it, and JavaScript cannot clear an HttpOnly cookie by
// design. Without this, "logout" wiped localStorage but left the
// cookie session alive for up to session_duration_seconds (the
// fake-signout bug: users roamed every cookie-authed page). The
// cookie is cleared BEFORE anything else — even a malformed body
// must sign the browser out.
c.SetCookie(&http.Cookie{
Name: "token",
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure == "true",
SameSite: http.SameSiteLaxMode,
MaxAge: -1, // delete
})
var req RefreshTokenRequest var req RefreshTokenRequest
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusOK, map[string]string{"message": "logged out successfully"}) return c.JSON(http.StatusOK, map[string]string{"message": "logged out successfully"})
+41 -5
View File
@@ -8,6 +8,8 @@ import (
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
"bookhoard/internal/rls"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
) )
@@ -27,7 +29,12 @@ func (h *Handler) StartBackgroundTasks() {
type ScanLibraryRequest struct { type ScanLibraryRequest struct {
FolderPaths []string `json:"folder_paths,omitempty"` FolderPaths []string `json:"folder_paths,omitempty"`
LibraryID string `json:"library_id,omitempty"` LibraryID string `json:"library_id,omitempty"`
Force bool `json:"force,omitempty"` // FolderPath (singular) scopes a library scan to ONE of the library's
// registered folders (the LibraryPanel per-folder Scan buttons). It
// must be a registered folder — validated below, never trusted. The
// raw /api/scanner/scan route ignores it (no library attribution).
FolderPath string `json:"folder_path,omitempty"`
Force bool `json:"force,omitempty"`
} }
// ScanLibrary handles POST /api/scanner/scan (now runs in background) // ScanLibrary handles POST /api/scanner/scan (now runs in background)
@@ -68,9 +75,33 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
} }
// Extract folder paths if req.FolderPath != "" {
for _, folder := range libraryFolders { // Per-folder scope (LibraryPanel per-folder Scan buttons):
folderPaths = append(folderPaths, folder.FolderPath) // scan exactly one registered folder's subtree. The archive
// reconciliation pass skips partially-covered libraries
// (processScanJob → ScanFolders), so the other folders'
// items cannot be marked missing by this run.
registered := false
for _, folder := range libraryFolders {
if folder.FolderPath == req.FolderPath {
registered = true
// Podcast download roots are feed-managed (invariant
// 4): never scanner-ingestible, even scoped.
if folder.Purpose == services.FolderPurposePodcast {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "podcast download roots are feed-managed and cannot be scanned"})
}
break
}
}
if !registered {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "folder_path is not one of this library's registered folders"})
}
folderPaths = []string{req.FolderPath}
} else {
// Extract folder paths
for _, folder := range libraryFolders {
folderPaths = append(folderPaths, folder.FolderPath)
}
} }
if len(folderPaths) == 0 { if len(folderPaths) == 0 {
@@ -103,6 +134,11 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
jobID := uuid.New().String() jobID := uuid.New().String()
// RLS plan §6: the scan job's media reads run under the triggering
// admin's identity. h.ctx is a handler-lifetime Background context
// (outlives the request), so the identity is stamped explicitly —
// a bare context makes governed reads raise (placeholder reverting
// to '' on reused connections, or 42704 on fresh ones).
job := &services.Job{ job := &services.Job{
ID: jobID, ID: jobID,
Type: services.JobTypeScan, Type: services.JobTypeScan,
@@ -116,7 +152,7 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
"trigger": "admin", "trigger": "admin",
}, },
Status: services.JobStatusPending, Status: services.JobStatusPending,
Context: h.ctx, Context: rls.WithUser(h.ctx, pgtype.UUID{Bytes: userUUID, Valid: true}),
} }
if err := h.worker.EnqueueJob(job); err != nil { if err := h.worker.EnqueueJob(job); err != nil {
+20 -3
View File
@@ -64,7 +64,20 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"}) return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
} }
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset) // Sort/filter params (same contract as the SSR /series page):
// unknown values resolve to defaults inside the service.
opts := services.SeriesPageOptions{
Sort: c.QueryParam("sort"),
Dir: c.QueryParam("dir"),
Query: c.QueryParam("q"),
Author: c.QueryParam("author"),
Status: c.QueryParam("status"),
}
if u, ok := c.Get("user").(database.Users); ok {
opts.ViewerUserID = u.ID
}
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset, opts)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
} }
@@ -76,6 +89,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
TotalInSeries int `json:"total_in_series"` TotalInSeries int `json:"total_in_series"`
CoverPaths []string `json:"cover_paths"` CoverPaths []string `json:"cover_paths"`
LastEntryAt string `json:"last_entry_at"` LastEntryAt string `json:"last_entry_at"`
ReadCount int64 `json:"read_count"`
LastReadAt string `json:"last_read_at"`
} }
response := make([]SeriesResponse, 0, len(seriesList)) response := make([]SeriesResponse, 0, len(seriesList))
@@ -87,6 +102,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
TotalInSeries: s.TotalInSeries, TotalInSeries: s.TotalInSeries,
CoverPaths: s.CoverPaths, CoverPaths: s.CoverPaths,
LastEntryAt: s.LastEntryAt, LastEntryAt: s.LastEntryAt,
ReadCount: s.ReadCount,
LastReadAt: s.LastReadAt,
}) })
} }
@@ -150,7 +167,7 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
}) })
} }
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) { func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts services.SeriesPageOptions) ([]services.SeriesInfo, int, error) {
svc := services.NewSeriesService(db) svc := services.NewSeriesService(db)
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset) return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset, opts)
} }
+10 -4
View File
@@ -489,12 +489,18 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
defaultTimezone = tz defaultTimezone = tz
} }
// Render success message with updated form // Render success message with updated form. NOTE: this Go-source
// copy is the htmx-swapped twin of the templ form in
// templates/admin_settings.templ (handlers cannot import templates:
// templates/utils.go imports handlers types — cycle). It MUST keep
// the twin's functional attributes — id, hx-*, data-settings-save,
// hx-disabled-elt — or save feedback silently dies after the first
// swap. e2e/settings-feedback.spec.ts saves twice to pin this.
return c.HTML(http.StatusOK, fmt.Sprintf(` return c.HTML(http.StatusOK, fmt.Sprintf(`
<div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);"> <div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);">
<p style="color: var(--text-success);">✅ Settings saved successfully!</p> <p style="color: var(--text-success);">✅ Settings saved successfully!</p>
</div> </div>
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML"> <form id="settings-form" data-settings-save="settings" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> <div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Base URL</h3> <h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Base URL</h3>
@@ -513,7 +519,7 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
</div> </div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium"> <button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium" hx-disabled-elt="this">
Save Settings Save Settings
</button> </button>
</div> </div>
@@ -551,7 +557,7 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p> <p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
</div> </div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium"> <button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium" hx-disabled-elt="this">
Save Settings Save Settings
</button> </button>
</div> </div>
+15
View File
@@ -178,6 +178,21 @@ func validateSettingValue(def database.SettingDefault, value string) error {
if _, err := strconv.ParseBool(value); err != nil { if _, err := strconv.ParseBool(value); err != nil {
return fmt.Errorf("value must be true or false") return fmt.Errorf("value must be true or false")
} }
case database.SettingTypeFloat:
f, err := strconv.ParseFloat(value, 64)
if err != nil {
return fmt.Errorf("value must be a number")
}
if def.Min != "" {
if mn, err := strconv.ParseFloat(def.Min, 64); err == nil && f < mn {
return fmt.Errorf("value must be >= %s", def.Min)
}
}
if def.Max != "" {
if mx, err := strconv.ParseFloat(def.Max, 64); err == nil && f > mx {
return fmt.Errorf("value must be <= %s", def.Max)
}
}
case database.SettingTypeString: case database.SettingTypeString:
if value == "" { if value == "" {
return fmt.Errorf("value must not be empty") return fmt.Errorf("value must not be empty")
+7 -2
View File
@@ -11,6 +11,8 @@ import (
jwt "github.com/golang-jwt/jwt/v5" jwt "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid" "github.com/google/uuid"
"bookhoard/internal/rls"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
@@ -201,14 +203,17 @@ func (h *WSHandler) getInitialState(userID string) map[string]interface{} {
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true} pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
mediaItems, err := h.db.GetUserMediaItemsForSync(context.Background(), pgUserID) // RLS plan §6: this read is the connection's OWN user's media —
// declare that identity explicitly (context.Background() carries
// nothing through ScopedDB, and media_items is RLS-governed).
mediaItems, err := h.db.GetUserMediaItemsForSync(rls.WithUser(context.Background(), pgUserID), pgUserID)
if err != nil { if err != nil {
return map[string]interface{}{"error": "failed to fetch initial state"} return map[string]interface{}{"error": "failed to fetch initial state"}
} }
progressMap := make(map[string]map[string]interface{}) progressMap := make(map[string]map[string]interface{})
for _, item := range mediaItems { for _, item := range mediaItems {
progress, err := h.db.GetUniversalProgress(context.Background(), database.GetUniversalProgressParams{ progress, err := h.db.GetUniversalProgress(rls.WithUser(context.Background(), pgUserID), database.GetUniversalProgressParams{
MediaItemID: item.ID, MediaItemID: item.ID,
UserID: pgUserID, UserID: pgUserID,
}) })
@@ -0,0 +1,69 @@
package middleware_test
import (
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// Go-source CSP tripwire.
//
// The templ scan lives in templates/csp_source_test.go, but htmx partials
// can also be emitted from Go handler strings — auth.go used to answer a
// successful login with an inline <script>window.location…</script> that
// hash-mode CSP silently refused. This test scans the non-test Go sources
// for the same forbidden patterns: inline script tags and native on*=
// handler attributes in emitted HTML. Partials that need to navigate or
// stash state use data attributes + web/src/auth_forms.ts, or htmx's
// HX-Redirect/HX-Refresh headers.
var goScriptTagRe = regexp.MustCompile(`<script`)
var goHandlerAttrRe = regexp.MustCompile(
`on(click|error|submit|load|change|input|focus|blur|mouseover|keydown|keyup)\s*=\s*"`)
func TestGoSourcesAreCSPSafe(t *testing.T) {
root := "../.."
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case "node_modules", "static", ".git", "testdata", "docs":
return filepath.SkipDir
}
return nil
}
name := d.Name()
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
return nil
}
// Generated templ codegen is out of scope: its source of truth is
// the .templ file (scanned by templates/csp_source_test.go), and
// the codegen legitimately emits external <script src=…> tags.
if strings.HasSuffix(name, "_templ.go") {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
src := string(raw)
for _, m := range goScriptTagRe.FindAllStringIndex(src, -1) {
t.Errorf("%s:%d: literal <script> in Go source — hash-mode CSP refuses inline scripts in emitted partials; use data attributes + web/src/auth_forms.ts or HX-Redirect/HX-Refresh",
path, 1+strings.Count(src[:m[0]], "\n"))
}
for _, m := range goHandlerAttrRe.FindAllStringIndex(src, -1) {
t.Errorf("%s:%d: native on*= handler attribute in Go source — CSP hash-mode refuses it; use Alpine bindings or delegated listeners",
path, 1+strings.Count(src[:m[0]], "\n"))
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
+8
View File
@@ -2,6 +2,7 @@ package middleware
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/rls"
"context" "context"
"net/http" "net/http"
"strconv" "strconv"
@@ -183,6 +184,13 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
c.Set("device_ctx", ctx) c.Set("device_ctx", ctx)
c.Set("device_id", device.ID.Bytes) c.Set("device_id", device.ID.Bytes)
// RLS bridge (plan §5.1/A9): device-authenticated requests
// (KOReader/Kobo sync) read media as the DEVICE OWNER — echo's
// c.Set is echo-local, so the stdlib request context needs the
// identity explicitly or every governed read raises.
c.SetRequest(c.Request().WithContext(context.WithValue(
c.Request().Context(), rls.CtxKeyUserID, device.UserID)))
return next(c) return next(c)
} }
} }
+25 -3
View File
@@ -1,6 +1,8 @@
package middleware package middleware
import ( import (
"strings"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
) )
@@ -9,7 +11,14 @@ import (
// the rendered component by security_headers_test.go. // the rendered component by security_headers_test.go.
const ScriptHashThemeFlashGuard = "'sha256-JYIVpFyKdLUtWizMha0v69f6yHds+GbJRBWjvpuECBY='" const ScriptHashThemeFlashGuard = "'sha256-JYIVpFyKdLUtWizMha0v69f6yHds+GbJRBWjvpuECBY='"
// SecurityHeadersMiddleware adds security headers to all responses // SecurityHeadersMiddleware adds security headers to all responses.
// It also forces HTML pages to revalidate (Cache-Control: no-cache):
// SSR pages are dynamic, and heuristic browser caching of a PAGE let a
// stale page reference a stale main.js stamp long after the bundle was
// fixed — the "search dropdown still broken" report was exactly that.
// /api, /static and /uploads keep their own (or absent) caching: the
// stamped bundles are content-frozen by URL, media files are immutable
// by path.
func SecurityHeadersMiddleware() echo.MiddlewareFunc { func SecurityHeadersMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error { return func(c *echo.Context) error {
@@ -24,8 +33,8 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
// //
// The inline script allow-list: the theme flash guard is the // The inline script allow-list: the theme flash guard is the
// ONLY inline script in the app, and its byte-stable content is // ONLY inline script in the app, and its byte-stable content is
// hashed into script-src (sha256 of the bytes between // hashed into script-src (sha256 of the bytes between the
// <script> and </script> as rendered by // script tags as rendered by
// templates.ThemeFlashGuard()). Editing the guard requires // templates.ThemeFlashGuard()). Editing the guard requires
// recomputing the hash — enforced by // recomputing the hash — enforced by
// internal/middleware/security_headers_test.go, which renders // internal/middleware/security_headers_test.go, which renders
@@ -39,6 +48,19 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
// Pages must always revalidate (see doc comment). The check
// runs BEFORE next(): once a templ handler writes the body,
// response headers are already on the wire.
switch p := c.Request().URL.Path; {
case strings.HasPrefix(p, "/api/"),
strings.HasPrefix(p, "/static/"),
strings.HasPrefix(p, "/uploads/"),
p == "/health":
// Not a page: keep native caching behavior.
default:
c.Response().Header().Set("Cache-Control", "no-cache")
}
return next(c) return next(c)
} }
} }
@@ -4,12 +4,16 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"net/http"
"net/http/httptest"
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"bookhoard/internal/middleware" "bookhoard/internal/middleware"
"bookhoard/templates" "bookhoard/templates"
"github.com/labstack/echo/v5"
) )
// TestThemeFlashGuardHashMatchesCSP pins the CSP script hash to the theme // TestThemeFlashGuardHashMatchesCSP pins the CSP script hash to the theme
@@ -49,3 +53,38 @@ middleware.ScriptHashThemeFlashGuard and the CSP header string.`,
t.Error("script-src must not re-introduce 'unsafe-inline' alongside the hash") t.Error("script-src must not re-introduce 'unsafe-inline' alongside the hash")
} }
} }
// HTML pages must always revalidate (Cache-Control: no-cache): a
// heuristically cached PAGE referenced a stale main.js stamp and kept
// the old bundle running long after a fix deployed (the 2026-10-27
// "search dropdown still broken" report). Static/API/media routes keep
// their native caching — stamped bundles are content-frozen by URL.
func TestHTMLPagesRevalidate(t *testing.T) {
mw := middleware.SecurityHeadersMiddleware()
render := func(path string) string {
e := echo.New()
req, _ := http.NewRequest("GET", path, nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := mw(func(c *echo.Context) error {
return c.HTML(http.StatusOK, "<html></html>")
})
if err := handler(c); err != nil {
t.Fatalf("handler error: %v", err)
}
return rec.Header().Get("Cache-Control")
}
if got := render("/bookshelf"); got != "no-cache" {
t.Errorf("/bookshelf: Cache-Control = %q, want no-cache", got)
}
if got := render("/series"); got != "no-cache" {
t.Errorf("/series: Cache-Control = %q, want no-cache", got)
}
for _, p := range []string{"/static/main.js", "/api/media-items", "/uploads/library-x/book.epub", "/health"} {
if got := render(p); got == "no-cache" {
t.Errorf("%s: must keep native caching, got no-cache", p)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
// Package rls carries the authenticated end user's identity through
// the stdlib context so the database layer can declare it to Postgres
// per transaction (the `app.user_id` GUC the RLS policies read — see
// database/schema/schema.sql and
// ~/.opencode/plan/server-rls-library-visibility.md).
//
// WHY A SEPARATE PACKAGE: Echo's c.Set/c.Get is echo-local storage and
// never reaches the context.Context handed to database calls; the auth
// middleware therefore bridges the user id into the request context
// here, and ScopedDB (internal/database) reads it back. A query whose
// context carries no identity is REFUSED (fail-loud) — a code path
// that forgets the middleware breaks visibly, it never leaks.
package rls
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type ctxKey int
// CtxKeyUserID is the context key the auth middleware bridge sets and
// ScopedDB reads. Typed key — no string-key collisions.
const CtxKeyUserID ctxKey = iota
// FromContext returns the authenticated end user's UUID.
// ok=false means the caller forgot the middleware bridge — the
// decorator will refuse the query loudly.
func FromContext(ctx context.Context) (pgtype.UUID, bool) {
uid, ok := ctx.Value(CtxKeyUserID).(pgtype.UUID)
if !ok || !uid.Valid {
return pgtype.UUID{}, false
}
return uid, true
}
// WithUser re-attaches an identity to a (possibly detached) context.
// Background jobs MUST capture the acting user's UUID from the request
// context BEFORE `go func` detaches — context.Background() carries
// nothing — and rebuild their job context with this.
func WithUser(ctx context.Context, uid pgtype.UUID) context.Context {
return context.WithValue(ctx, CtxKeyUserID, uid)
}
// SeedIdentity is the harness seeding identity (plan §5): a valid UUID
// with NO library_visibility rows, so RLS grants it full visibility.
// It exists so test seeding and direct DB verification can read and
// write governed tables without impersonating a real user. Product
// code never runs as it.
var SeedIdentity = pgtype.UUID{Bytes: [16]byte(uuid.MustParse("00000000-0000-0000-0000-000000000002")), Valid: true}
// DeclareUser sets the RLS identity GUC inside an ALREADY-OPEN
// transaction. The `true` argument is is_local: transaction-scoped,
// evaporating at COMMIT/ROLLBACK — NEVER session-scoped on a pooled
// connection (cross-request identity bleed). For the manual-tx paths
// the ScopedDB decorator cannot wrap.
func DeclareUser(ctx context.Context, tx pgx.Tx, uid pgtype.UUID) error {
_, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, uid.String())
return err
}
+153 -4
View File
@@ -14,6 +14,7 @@ import (
"bookhoard/internal/config" "bookhoard/internal/config"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/rls"
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"bookhoard/internal/services" "bookhoard/internal/services"
"bookhoard/internal/utils" "bookhoard/internal/utils"
@@ -149,6 +150,36 @@ func registerFrontendRoutes(cfg *Config) {
} }
offset := (page - 1) * perSeriesPage offset := (page - 1) * perSeriesPage
// Series-page state (URL is the source of truth — plain GET form,
// no htmx on this page). Unknown values resolve to defaults in the
// service's registries, so hand-edited URLs stay safe. The URL
// carries the BASE sort key + direction; opts.Sort gets the
// composed registry key (full keys win on re-resolve). With no
// sort param at all, state stays empty so default URLs remain
// clean (the toolbar treats empty as recent/desc).
rawSort := c.QueryParam("sort")
sortKey, sortDir := services.ResolveSeriesSort(rawSort, c.QueryParam("dir"))
state := templates.SeriesPageState{
Sort: services.SeriesSortBase(sortKey),
Dir: sortDir,
Query: c.QueryParam("q"),
Status: c.QueryParam("status"),
}
if rawSort == "" {
state.Sort = ""
state.Dir = ""
}
opts := services.SeriesPageOptions{
Sort: sortKey,
Dir: sortDir,
Query: state.Query,
Author: c.QueryParam("author"), // API/deep-link back-compat; the toolbar's multi-field q covers authors
Status: state.Status,
}
if viewerID, vidErr := uuid.Parse(user.ID); vidErr == nil {
opts.ViewerUserID = uuidToPGType(viewerID)
}
var seriesCards []templates.SeriesCardData var seriesCards []templates.SeriesCardData
totalPages := 1 totalPages := 1
@@ -162,7 +193,7 @@ func registerFrontendRoutes(cfg *Config) {
} else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] { } else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] {
errorMsg = "Library not accessible" errorMsg = "Library not accessible"
} else { } else {
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset) seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset, opts)
if err != nil { if err != nil {
log.Printf("GetSeriesCardsData failed: %v", err) log.Printf("GetSeriesCardsData failed: %v", err)
errorMsg = "Error loading series" errorMsg = "Error loading series"
@@ -183,6 +214,7 @@ func registerFrontendRoutes(cfg *Config) {
BookCount: s.BookCount, BookCount: s.BookCount,
TotalInSeries: s.TotalInSeries, TotalInSeries: s.TotalInSeries,
CoverPaths: covers, CoverPaths: covers,
ReadCount: s.ReadCount,
}) })
} }
} }
@@ -194,7 +226,7 @@ func registerFrontendRoutes(cfg *Config) {
} }
var buf bytes.Buffer var buf bytes.Buffer
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, errorMsg).Render(c.Request().Context(), &buf) err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, state, errorMsg).Render(c.Request().Context(), &buf)
if err != nil { if err != nil {
return err return err
} }
@@ -1413,10 +1445,70 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
})) }))
// Content partial for the retry queue: after the client drains its
// sequential retry queue it swaps this in over #issues-content so
// resolved issues drop off server-side (unlinked_books matches
// partial precedent). Same family-lens gate as the page.
frontendProtected.GET("/admin/libraries/:id/issues/content", handlers.AdminMiddleware(func(c *echo.Context) error {
libraryID := c.Param("id")
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return renderErrorPage(c, "Invalid library ID", "invalid_id")
}
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
if visErr != nil {
return renderErrorPage(c, "Error checking library access", "visibility_error")
}
if !visibleSet[libraryID] {
return renderErrorPage(c, "Library not accessible", "library_not_accessible")
}
stats, err := cfg.Queries.GetProcessingIssueStats(c.Request().Context(), uuidToPGType(libUUID))
if err != nil {
return renderErrorPage(c, "Error loading stats", "stats_load_error")
}
dbIssues, err := cfg.Queries.ListProcessingIssuesByLibrary(c.Request().Context(), uuidToPGType(libUUID))
if err != nil {
return renderErrorPage(c, "Error loading issues", "issues_load_error")
}
issues := make([]templates.ProcessingIssueData, len(dbIssues))
for i, issue := range dbIssues {
issueUUID, _ := uuid.FromBytes(issue.ID.Bytes[0:16])
itemID := ""
if issue.MediaItemID.Valid {
itemID = uuid.UUID(issue.MediaItemID.Bytes).String()
}
title := issue.Title
if title == "" {
title = issue.FilePath
}
issues[i] = templates.ProcessingIssueData{
ID: issueUUID.String(),
MediaItemID: itemID,
Title: title,
FilePath: issue.FilePath,
FormatGroup: issue.FormatGroup,
LibraryTypeName: issue.LibraryTypeName,
IssueType: issue.IssueType,
IssueDescription: issue.IssueDescription,
Severity: issue.Severity,
CreatedAt: issue.CreatedAt.Time,
}
}
statsData := templates.IssueStats{
ErrorCount: stats.ErrorCount,
WarningCount: stats.WarningCount,
InfoCount: stats.InfoCount,
}
var buf bytes.Buffer
if err := templates.ProcessingIssuesContent(libraryID, issues, statsData).Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
// Admin hash conflicts page: content-duplicate groups flagged during hash // Admin hash conflicts page: content-duplicate groups flagged during hash
// backfill or rescan, resolved by keeping all copies or merging into one. // backfill or rescan, resolved by keeping all copies or merging into one.
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error { frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg)
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil { if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error") return renderErrorPage(c, "Error loading user", "user_load_error")
} }
@@ -1470,6 +1562,62 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
})) }))
// Admin all-podcasts view (§0 item 8): every feed from every user with
// maintenance aggregates (disk usage first). The aggregate read runs
// under the FIRST-ADMIN identity — the RLS admin escape (invariant
// 12, poller/fetcher contract) — so admins see feeds from libraries
// hidden even from them.
frontendProtected.GET("/admin/podcasts", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
adminUUID, err := cfg.Queries.GetFirstAdmin(context.Background())
if err != nil {
return renderErrorPage(c, "Error loading podcasts", "podcasts_load_error")
}
// cfg.Queries is ScopedDB-backed: the context identity is all the
// aggregate read needs.
actx := rls.WithUser(c.Request().Context(), adminUUID)
rows, err := cfg.Queries.ListAllPodcastFeedsAdmin(actx)
if err != nil {
return renderErrorPage(c, "Error loading podcasts", "podcasts_load_error")
}
feeds := make([]templates.AdminPodcastFeedData, 0, len(rows))
var totalBytes int64
totalDownloaded, totalEpisodes := 0, 0
for _, r := range rows {
lastFetch := "never"
if r.LastFetchedAt.Valid {
lastFetch = r.LastFetchedAt.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006 15:04")
}
feeds = append(feeds, templates.AdminPodcastFeedData{
ID: uuid.UUID(r.ID.Bytes).String(),
Title: r.Title,
Host: r.Host.String,
LibraryName: r.LibraryName,
Subscribers: int(r.SubscriberCount),
SubscriberNames: r.SubscriberNames,
EpisodeTotal: int(r.EpisodeTotal),
EpisodeDownloaded: int(r.EpisodeDownloaded),
DiskBytes: r.DiskBytes,
LastFetched: lastFetch,
})
totalBytes += r.DiskBytes
totalEpisodes += int(r.EpisodeTotal)
totalDownloaded += int(r.EpisodeDownloaded)
}
var buf bytes.Buffer
if err := templates.AdminPodcasts(user, feeds, len(feeds), totalDownloaded, totalEpisodes, totalBytes).Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
// Admin users page // Admin users page
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error { frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg) user, err := getTemplateUserWithTheme(c, cfg)
@@ -1892,6 +2040,7 @@ func (cfg *Config) GetHealth(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"status": "healthy", "status": "healthy",
"version": version.Version, "version": version.Version,
"commit": version.Commit,
"database": "connected", "database": "connected",
"scan": map[string]interface{}{ "scan": map[string]interface{}{
"scan_in_progress": cfg.Worker.HasActiveScans(), "scan_in_progress": cfg.Worker.HasActiveScans(),
+9
View File
@@ -55,6 +55,14 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
token = cookie.Value token = cookie.Value
} }
// Sidebar /podcasts gate (plan D13): >=1 visible podcasts library.
// One indexed COUNT per page render — the same class of per-request
// lookup the live-user guard already does.
showPodcasts := false
if n, err := cfg.Queries.CountPodcastLibrariesForUser(c.Request().Context(), uuidToPGType(userUUID)); err == nil && n > 0 {
showPodcasts = true
}
return templates.User{ return templates.User{
ID: userID, ID: userID,
Email: userEmail, Email: userEmail,
@@ -68,6 +76,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
CreatedAt: userDB.CreatedAt.Time, CreatedAt: userDB.CreatedAt.Time,
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed), DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
KeepScreenAwake: userDB.KeepScreenAwake, KeepScreenAwake: userDB.KeepScreenAwake,
ShowPodcastsNav: showPodcasts,
}, nil }, nil
} }
+1
View File
@@ -39,6 +39,7 @@ func registerLibraryRoutes(cfg *Config) {
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues) adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats) adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue) adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
adminLibrary.POST("/:id/issues/:issueId/retry", cfg.ProcessingIssuesHandler.RetryProcessingIssue)
adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue) adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
adminLibrary.POST("/:id/scan", func(c *echo.Context) error { adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
libraryID := c.Param("id") libraryID := c.Param("id")
+1
View File
@@ -74,6 +74,7 @@ func registerMediaRoutes(cfg *Config) {
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem) admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem) admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem) admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
admin.POST("/media-items/:id/write-metadata", cfg.MediaHandler.WriteMetadataToFile)
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem) admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems) admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem) admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem)
+83
View File
@@ -0,0 +1,83 @@
package router
// Podcast routes (plan §8). All under /api/podcasts with the JWT
// middleware; scoping is RLS's job (D20) — handlers add no visibility
// plumbing.
import (
"bytes"
"net/http"
"bookhoard/internal/handlers"
"bookhoard/templates"
"github.com/labstack/echo/v5"
)
func registerPodcastRoutes(cfg *Config) {
g := cfg.Echo.Group("/api/podcasts", createJWTMiddleware(cfg))
// Episode-list renderer (issue #1): htmx actions answer with the
// refreshed partial directly — no event indirection to miss. The
// view assembly lives here (templates) while the action logic lives
// in the handler; this closure is the sanctioned seam.
cfg.PodcastHandler.SetEpisodeListRenderer(func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return err
}
feedUUID, err := parsePodcastUUID(feedID)
if err != nil {
return err
}
data, err := buildPodcastDetailData(c, cfg, user, feedUUID)
if err != nil {
return err
}
var buf bytes.Buffer
render := templates.PodcastEpisodeList
if templates.HasActiveDownloads(data.Episodes) {
render = templates.PodcastEpisodeListPolling
}
if err := render(*data).Render(c.Request().Context(), &buf); err != nil {
return err
}
if includePolicySelect {
// Issue #3: out-of-band swap of the auto-download select —
// the dropdown reflects the flipped policy immediately.
if err := templates.PodcastAutoDownloadSelectOOB(data.Feed).Render(c.Request().Context(), &buf); err != nil {
return err
}
}
if includeOrderSelect {
// §0 item 3: out-of-band swap of the order select — the
// "Reverse feed order" button flips to reverse; the
// dropdown mirrors the server state immediately.
if err := templates.PodcastOrderSelectOOB(data.Feed.ID, data.Order).Render(c.Request().Context(), &buf); err != nil {
return err
}
}
return c.HTML(http.StatusOK, buf.String())
})
g.POST("/feeds", cfg.PodcastHandler.AddFeed)
g.GET("/feeds", cfg.PodcastHandler.ListFeeds)
g.GET("/search", cfg.PodcastHandler.Search)
// :id-scoped (RLS answers 404 for feeds outside the caller's scope).
feeds := g.Group("/feeds/:id")
feeds.GET("", cfg.PodcastHandler.GetFeed)
feeds.GET("/sequence", cfg.PodcastHandler.GetFeedSequence)
feeds.PUT("/order", cfg.PodcastHandler.SetFeedOrder)
feeds.PUT("/subscription", cfg.PodcastHandler.UpdateSubscription)
feeds.DELETE("/subscription", cfg.PodcastHandler.DeleteSubscription)
feeds.POST("/refresh", cfg.PodcastHandler.RefreshFeed)
feeds.POST("/episodes/:eid/download", cfg.PodcastHandler.DownloadEpisodes)
feeds.DELETE("", cfg.PodcastHandler.DeleteFeed) // admin-only (handler checks role)
// Admin maintenance (§0 item 8): file deletion frees the disk and
// resets rows to pending. Handler checks admin role.
feeds.POST("/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteFeedFiles))
feeds.POST("/episodes/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFilesBulk))
feeds.POST("/episodes/:eid/delete-file", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFile))
}
+474
View File
@@ -0,0 +1,474 @@
package router
// Podcast SSR pages (plan task 1.9): /podcasts (subscriptions grid +
// add-feed wizard) and /podcasts/:id (feed detail with the inline
// download lifecycle). Rendering ownership: ALL content is
// templ-rendered server data; htmx swaps partials; the sanctioned JS is
// the modal toggle + event delegation in web/src/podcasts.ts.
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"bookhoard/templates"
"bytes"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
func registerPodcastPages(cfg *Config) {
pages := cfg.Echo.Group("", createJWTMiddleware(cfg))
pages.GET("/podcasts", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
data, err := buildPodcastsPageData(c, cfg)
if err != nil {
data = &templates.PodcastsPageData{ErrorMsg: "Could not load subscriptions"}
}
var buf bytes.Buffer
if err := templates.PodcastsPage(user, *data, "/podcasts").Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
pages.GET("/podcasts/:id", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
fid, ferr := parsePodcastUUID(c.Param("id"))
if ferr != nil {
return c.NoContent(http.StatusNotFound)
}
data, err := buildPodcastDetailData(c, cfg, user, fid)
if err != nil {
var buf bytes.Buffer
_ = templates.ErrorPage("Feed not found", "not_found").Render(c.Request().Context(), &buf)
return c.HTML(http.StatusNotFound, buf.String())
}
var buf bytes.Buffer
if err := templates.PodcastDetailPage(user, *data).Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Episode list partial — the htmx refresh unit (poll while
// downloads are active; podcast:changed events swap it too).
pages.GET("/podcasts/:id/episodes", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
fid, ferr := parsePodcastUUID(c.Param("id"))
if ferr != nil {
return c.NoContent(http.StatusNotFound)
}
data, err := buildPodcastDetailData(c, cfg, user, fid)
if err != nil {
return c.NoContent(http.StatusNotFound)
}
var buf bytes.Buffer
if templates.HasActiveDownloads(data.Episodes) {
err = templates.PodcastEpisodeListPolling(*data).Render(c.Request().Context(), &buf)
} else {
err = templates.PodcastEpisodeList(*data).Render(c.Request().Context(), &buf)
}
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
}
// buildPodcastsPageData assembles the /podcasts page: subscription
// cards, facet values, server-side filters (q/host/tag/language).
func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPageData, error) {
user := handlers.MustGetAuthenticatedUser(c)
ctx := c.Request().Context()
libs, err := cfg.Queries.GetPodcastsLibraryForUser(ctx, user.ID)
data := &templates.PodcastsPageData{HasLibrary: len(libs) > 0}
if err != nil || len(libs) == 0 {
return data, err
}
for _, lib := range libs {
data.Libraries = append(data.Libraries, templates.LibraryData{
ID: uuid.UUID(lib.ID.Bytes).String(),
Name: lib.Name,
TypeName: "podcasts",
})
}
feeds, err := cfg.Queries.ListMyPodcastFeeds(ctx, user.ID)
if err != nil {
return data, err
}
// Continue aggregation (plan 2.5): first in-progress episode per
// feed (the query is already most-recent-first).
continueByFeed := make(map[string]*database.GetPodcastContinueForUserRow)
if continueRows, err := cfg.Queries.GetPodcastContinueForUser(ctx, user.ID); err == nil {
for i := range continueRows {
row := &continueRows[i]
key := uuid.UUID(row.SourceFeedID.Bytes).String()
if _, ok := continueByFeed[key]; !ok {
continueByFeed[key] = row
}
}
}
for i := range feeds {
row := &feeds[i]
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID)
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, row.ID)
data.Feeds = append(data.Feeds, templates.PodcastFeedCard{
ID: uuid.UUID(row.ID.Bytes).String(),
LibraryID: uuid.UUID(row.LibraryID.Bytes).String(),
Title: row.Title,
DisplayName: row.DisplayName.String,
Description: firstLine(row.Description.String, 220),
Host: row.Host.String,
Publisher: row.Publisher.String,
Language: row.Language.String,
Tags: row.Tags,
Explicit: row.Explicit,
ArtworkPath: row.ArtworkPath.String,
FeedType: row.FeedType.String,
Medium: row.Medium.String,
IsComplete: row.IsComplete,
FeedURL: row.FeedUrl,
LastFetched: pgTimeDisplay(row.LastFetchedAt, utils.PgTextString(user.Timezone)),
SubscribedAt: pgTimeDisplay(row.SubscribedAt, utils.PgTextString(user.Timezone)),
EpisodeTotal: int(total),
DownloadedN: int(downloaded),
UndownloadedN: int(total) - int(downloaded),
NewEpisodes: total-downloaded > 0,
AutoDownload: row.AutoDownload,
AutoDownloadCount: int(row.AutoDownloadCount.Int32),
Subscribed: true,
})
if cont := continueByFeed[uuid.UUID(row.ID.Bytes).String()]; cont != nil {
card := &data.Feeds[len(data.Feeds)-1]
card.HasContinue = true
card.ContinueTitle = cont.Title
card.ContinueEpisode = int(cont.FeedPosition.Int32)
card.ContinuePercent = cont.Percentage.Float64
card.ContinueLastRead = cont.LastReadAt.Time
}
}
// Facet values from the unfiltered subscription list (D18).
allData := *data
data.Facets = facetValuesFromCards(allData.Feeds)
// Server-side filters.
q := strings.ToLower(strings.TrimSpace(c.QueryParam("q")))
host := c.QueryParam("host")
tag := c.QueryParam("tag")
lang := c.QueryParam("language")
// Episode-level matches (issue #8 / D14): a query also hits episode
// titles + descriptions — the feed surfaces with a match count.
// SearchMyPodcastEpisodes is subscription-scoped via RLS (the
// caller's identity). Only consult it when a query is present.
matchesByFeed := make(map[string]int)
if q != "" {
if eps, err := cfg.Queries.SearchMyPodcastEpisodes(ctx, q); err == nil {
for i := range eps {
key := uuid.UUID(eps[i].SourceFeedID.Bytes).String()
matchesByFeed[key]++
}
}
}
filtered := data.Feeds[:0:0]
for _, f := range data.Feeds {
if q != "" {
hay := strings.ToLower(f.Title + " " + f.DisplayName + " " + f.Host + " " + f.Publisher + " " + f.Description)
matched := matchesByFeed[f.ID]
if !strings.Contains(hay, q) && matched == 0 {
continue
}
}
if host != "" && f.Host != host {
continue
}
if lang != "" && !strings.EqualFold(f.Language, lang) {
continue
}
if tag != "" {
found := false
for _, t := range f.Tags {
if strings.EqualFold(t, tag) {
found = true
break
}
}
if !found {
continue
}
}
filtered = append(filtered, f)
}
if q != "" {
for i := range filtered {
filtered[i].EpisodeMatches = matchesByFeed[filtered[i].ID]
}
}
data.Feeds = filtered
data.Query = q
data.ContinueFeeds = templates.PodcastContinueFeeds(data.Feeds)
return data, nil
}
// buildPodcastDetailData assembles the feed-detail page + episode list
// partial. RLS scopes the lookup (non-subscribers get sqlc.ErrNoRows →
// 404 semantics — no Go visibility plumbing, D20).
func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, feedID pgtype.UUID) (*templates.PodcastDetailData, error) {
actor := handlers.MustGetAuthenticatedUser(c)
ctx := c.Request().Context()
feed, err := cfg.Queries.GetPodcastFeed(ctx, feedID)
if err != nil {
return nil, err
}
sub, _ := cfg.Queries.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{
UserID: actor.ID, FeedID: feedID,
})
eps, err := cfg.Queries.ListPodcastEpisodes(ctx, feedID)
if err != nil {
return nil, err
}
progressRows, _ := cfg.Queries.GetPodcastEpisodeProgress(ctx, database.GetPodcastEpisodeProgressParams{
UserID: actor.ID, SourceFeedID: feedID,
})
progressByItem := make(map[string]struct {
Pct float64
LastRead pgtype.Timestamptz
}, len(progressRows))
for _, p := range progressRows {
progressByItem[uuid.UUID(p.MediaItemID.Bytes).String()] = struct {
Pct float64
LastRead pgtype.Timestamptz
}{p.Percentage.Float64, p.LastReadAt}
}
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, feedID)
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, feedID)
subscriberCount, _ := cfg.Queries.CountPodcastFeedSubscribers(ctx, feedID)
card := templates.PodcastFeedCard{
ID: uuid.UUID(feed.ID.Bytes).String(),
LibraryID: uuid.UUID(feed.LibraryID.Bytes).String(),
Title: feed.Title,
DisplayName: sub.DisplayName.String,
Description: feed.Description.String,
Host: feed.Host.String,
Publisher: feed.Publisher.String,
Language: feed.Language.String,
Tags: feed.Tags,
Explicit: feed.Explicit,
ArtworkPath: feed.ArtworkPath.String,
FeedType: feed.FeedType.String,
Medium: feed.Medium.String,
IsComplete: feed.IsComplete,
FeedURL: feed.FeedUrl,
LastFetched: pgTimeDisplay(feed.LastFetchedAt, user.Timezone),
EpisodeTotal: int(total),
DownloadedN: int(downloaded),
UndownloadedN: int(total) - int(downloaded),
NewEpisodes: total-downloaded > 0,
AutoDownload: sub.AutoDownload,
AutoDownloadCount: int(sub.AutoDownloadCount.Int32),
Subscribed: sub.ID.Valid,
}
rows := make([]templates.PodcastEpisodeRow, 0, len(eps))
gaps := 0
seenDownloadedFrom := false // walk reversed: gap = undownloaded with a downloaded LATER in order
for i := len(eps) - 1; i >= 0; i-- {
ep := &eps[i]
downloadedEp := ep.DownloadState == "downloaded"
if !downloadedEp && seenDownloadedFrom {
gaps++
}
if downloadedEp {
seenDownloadedFrom = true
}
}
// Forward pass for the actual rows (native order — the list keeps
// feed order; the ORDER selector drives the sequence endpoint, and
// the order control re-renders this list via the override applied
// below).
order := podcastOrderForFeed(cfg, c, feedID.String())
for i := range eps {
ep := &eps[i]
rowID := uuid.UUID(ep.ID.Bytes).String()
p := progressByItem[rowID]
dateDisplay := ""
dateSort := ""
if ep.DatePublished.Valid {
dateSort = ep.DatePublished.Time.Format("2006-01-02")
dateDisplay = ep.DatePublished.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006")
}
rows = append(rows, templates.PodcastEpisodeRow{
ID: uuid.UUID(ep.ID.Bytes).String(),
FeedID: uuid.UUID(ep.SourceFeedID.Bytes).String(),
Title: ep.Title,
Description: firstLine(ep.Description.String, 200),
Date: dateDisplay,
SortDate: dateSort,
Position: int(ep.FeedPosition.Int32),
Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)),
Size: templates.FormatEpisodeSize(ep.FileSize.Int64),
PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(),
ListenPct: p.Pct,
Downloaded: ep.DownloadState == "downloaded",
DownloadState: ep.DownloadState,
DownloadError: ep.DownloadError.String,
})
}
// Order override reshapes the LIST too (the sequence endpoint is the
// strict downloaded-only view; the list shows everything in the
// chosen order so pending episodes stay visible). Consolidated sort
// (§0 item 5a) — SortDate is the lexical pub-date key so the
// tiebreak is chronological even in display-tz date strings.
services.OrderEpisodes(rows, order,
func(r templates.PodcastEpisodeRow) int32 { return int32(r.Position) },
func(r templates.PodcastEpisodeRow) string { return r.SortDate },
)
detail := &templates.PodcastDetailData{
Feed: card,
Episodes: rows,
GapCount: gaps,
Order: order,
IsAdmin: actor.Role == "admin",
IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook",
SubscriberCount: int(subscriberCount),
}
// Live byte progress for in-flight downloads (issue #1): read the
// worker's snapshot; rows not actively downloading render states.
if cfg.PodcastWorker != nil {
for i := range detail.Episodes {
row := &detail.Episodes[i]
if row.DownloadState != "downloading" {
continue
}
if recv, tot, ok := cfg.PodcastWorker.EpisodeProgress(pgtype.UUID{Bytes: uuid.MustParse(row.ID), Valid: true}); ok {
row.ProgressKnown = true
row.ProgressReceived = recv
row.ProgressTotal = tot
if tot > 0 {
row.ProgressPct = float64(recv) / float64(tot) * 100
}
}
}
}
return detail, nil
}
// podcastOrderForFeed reads the caller's order override (D5).
func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string {
user := handlers.MustGetAuthenticatedUser(c)
row, err := cfg.Queries.GetUserSettingByKey(c.Request().Context(), database.GetUserSettingByKeyParams{
UserID: user.ID,
SettingKey: "podcast_order:" + feedID,
})
if err != nil {
return "native"
}
var v struct {
Order string `json:"order"`
}
if err := json.Unmarshal(row, &v); err != nil {
return "native"
}
if !services.ValidPodcastEpisodeOrder(v.Order) {
return "native"
}
return v.Order
}
// facetValuesFromCards derives the filter bar values.
// facetValuesFromCards derives the filter-bar values with feed counts,
// sorted by frequency DESC then alphabetically — the top of each group
// is what a user with hundreds of feeds actually filters by.
func facetValuesFromCards(feeds []templates.PodcastFeedCard) map[string][]templates.FacetValue {
hosts, tags, langs := map[string]int{}, map[string]int{}, map[string]int{}
for _, f := range feeds {
if f.Host != "" {
hosts[f.Host]++
}
for _, t := range f.Tags {
tags[t]++
}
if f.Language != "" {
langs[f.Language]++
}
}
return map[string][]templates.FacetValue{
"hosts": sortedFacet(hosts),
"tags": sortedFacet(tags),
"language": sortedFacet(langs),
}
}
// sortedFacet: count DESC, then value ASC (stable, scannable).
func sortedFacet(set map[string]int) []templates.FacetValue {
out := make([]templates.FacetValue, 0, len(set))
for k, n := range set {
out = append(out, templates.FacetValue{Value: k, Count: n})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Value < out[j].Value
})
return out
}
func firstLine(s string, max int) string {
s = strings.TrimSpace(s)
if i := strings.IndexAny(s, "\n\r"); i > 0 {
s = s[:i]
}
if len(s) > max {
s = s[:max] + "…"
}
return s
}
func pgTimeDisplay(t pgtype.Timestamptz, tz string) string {
if !t.Valid {
return ""
}
return utils.FormatTimeInTimezone(t.Time, tz)
}
func itoaPad(n int) string {
return fmt.Sprintf("%06d", n)
}
// parsePodcastUUID parses an id path param (handlers.parseUUID is
// package-private).
func parsePodcastUUID(s string) (pgtype.UUID, error) {
id, err := uuid.Parse(strings.TrimSpace(s))
if err != nil {
return pgtype.UUID{}, err
}
return pgtype.UUID{Bytes: id, Valid: true}, nil
}
+101
View File
@@ -1,12 +1,14 @@
package router package router
import ( import (
"bookhoard/internal/database"
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"bookhoard/internal/services" "bookhoard/internal/services"
"bookhoard/internal/sync" "bookhoard/internal/sync"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"bookhoard/templates" "bookhoard/templates"
"bytes" "bytes"
"encoding/json"
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
@@ -102,6 +104,18 @@ func registerReaderRoutes(cfg *Config) {
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup), TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
KeepScreenAwake: user.KeepScreenAwake, KeepScreenAwake: user.KeepScreenAwake,
} }
// Podcast chaining (plan D7): episodes render the audio reader
// with sequence context — the neighboring DOWNLOADED episodes in
// the caller's effective order (override applied). RLS already
// scoped the episode read; the subscription predicate covers the
// neighbors the same way. ?single=1 escapes chaining (the "play
// single episode" affordance).
if mediaItem.Source == "podcast_episode" && mediaItem.SourceFeedID.Valid && c.QueryParam("single") != "1" {
userPG := uuidToPGType(userUUID)
if seq := buildPodcastSequenceContext(c, cfg, &mediaItem, userPG, libraryWithType.TypeName); seq != nil {
metadata.PodcastSequence = seq
}
}
// Render template // Render template
var buf bytes.Buffer var buf bytes.Buffer
// Audiobooks get the audio player page — the foliate document // Audiobooks get the audio player page — the foliate document
@@ -144,3 +158,90 @@ func registerReaderRoutes(cfg *Config) {
reader.GET("/settings", cfg.ReaderHandler.GetSettings) reader.GET("/settings", cfg.ReaderHandler.GetSettings)
reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings) reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings)
} }
// buildPodcastSequenceContext computes the episode chain payload (plan
// D7): effective order — the D5 override applied, feed-native fallback —
// DOWNLOADED episodes only (gaps never enter the queue), the current
// episode's index, and prev/next reader URLs. RLS scopes the episode
// reads; non-subscribers never reach this path in the first place.
func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, libraryType string) *templates.PodcastSequenceContext {
ctx := c.Request().Context()
eps, err := cfg.Queries.ListPodcastEpisodes(ctx, item.SourceFeedID)
if err != nil || len(eps) == 0 {
return nil
}
feed, err := cfg.Queries.GetPodcastFeed(ctx, item.SourceFeedID)
if err != nil {
return nil
}
// Effective order override (D5): reader_settings KV.
order := "native"
if row, err := cfg.Queries.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
UserID: userID,
SettingKey: "podcast_order:" + uuid.UUID(item.SourceFeedID.Bytes).String(),
}); err == nil {
var v struct {
Order string `json:"order"`
}
if json.Unmarshal(row, &v) == nil && services.ValidPodcastEpisodeOrder(v.Order) {
order = v.Order
}
}
type seqEp struct {
id string
title string
date string
pos int32
}
ordered := make([]seqEp, 0, len(eps))
currentID := uuid.UUID(item.ID.Bytes).String()
for i := range eps {
ep := eps[i]
if ep.DownloadState != "downloaded" {
continue // un-downloaded episodes are skipped, not tracked (invariant 6)
}
date := ""
if ep.DatePublished.Valid {
date = ep.DatePublished.Time.Format("2006-01-02")
}
ordered = append(ordered, seqEp{
id: uuid.UUID(ep.ID.Bytes).String(),
title: ep.Title,
date: date,
pos: ep.FeedPosition.Int32,
})
}
// Effective listening order (order consolidation §0 item 5a).
services.OrderEpisodes(ordered, order,
func(e seqEp) int32 { return e.pos },
func(e seqEp) string { return e.date },
)
idx := -1
for i, ep := range ordered {
if ep.id == currentID {
idx = i
break
}
}
if idx < 0 {
return nil // current episode not downloaded (or not in sequence)
}
seq := &templates.PodcastSequenceContext{
FeedURL: "/podcasts/" + uuid.UUID(item.SourceFeedID.Bytes).String(),
FeedTitle: feed.Title,
EpisodeIndex: idx + 1,
IndexTotal: len(ordered),
}
if idx > 0 {
seq.PrevURL = "/readers/" + ordered[idx-1].id
}
if idx < len(ordered)-1 {
seq.NextURL = "/readers/" + ordered[idx+1].id
seq.NextTitle = ordered[idx+1].title
}
return seq
}
+52 -1
View File
@@ -6,14 +6,19 @@ import (
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"bookhoard/internal/middleware" "bookhoard/internal/middleware"
ratelimit "bookhoard/internal/middleware" ratelimit "bookhoard/internal/middleware"
"bookhoard/internal/rls"
"bookhoard/internal/services" "bookhoard/internal/services"
"bookhoard/internal/sync" "bookhoard/internal/sync"
"bookhoard/templates" "bookhoard/templates"
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"log" "log"
"mime" "mime"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -75,6 +80,9 @@ type Config struct {
SidecarHandler *handlers.SidecarHandler SidecarHandler *handlers.SidecarHandler
ReaderHandler *handlers.ReaderHandler ReaderHandler *handlers.ReaderHandler
LibraryService *services.LibraryService LibraryService *services.LibraryService
PodcastHandler *handlers.PodcastHandler
PodcastService *services.PodcastService
PodcastWorker *services.PodcastDownloadWorker
} }
// getBaseURL returns the configured base URL from the database, falling back to // getBaseURL returns the configured base URL from the database, falling back to
@@ -151,6 +159,12 @@ func createOptionalAuthMiddleware(cfg *Config) echo.MiddlewareFunc {
Timezone: liveUser.Timezone, Timezone: liveUser.Timezone,
Theme: liveUser.Theme, // SSR body class; see the required middleware Theme: liveUser.Theme, // SSR body class; see the required middleware
}) })
// RLS bridge: echo's c.Set is echo-local and never reaches
// the stdlib context.Context the database layer receives —
// ScopedDB refuses media queries without this (plan §5.1).
c.SetRequest(c.Request().WithContext(context.WithValue(
c.Request().Context(), rls.CtxKeyUserID,
pgtype.UUID{Bytes: userUUID, Valid: true})))
return next(c) return next(c)
} }
} }
@@ -218,6 +232,12 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
// admin/docs theme flash. // admin/docs theme flash.
Theme: liveUser.Theme, Theme: liveUser.Theme,
}) })
// RLS bridge (same as the JWT path above): echo-local c.Set
// never reaches the stdlib context.Context the database
// layer receives — ScopedDB refuses media queries without it.
c.SetRequest(c.Request().WithContext(context.WithValue(
c.Request().Context(), rls.CtxKeyUserID,
pgtype.UUID{Bytes: userUUID, Valid: true})))
return nil return nil
}, },
ErrorHandler: func(c *echo.Context, err error) error { ErrorHandler: func(c *echo.Context, err error) error {
@@ -327,12 +347,14 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
registerConflictRoutes(cfg) registerConflictRoutes(cfg)
registerAnalyticsRoutes(cfg) registerAnalyticsRoutes(cfg)
registerQueueRoutes(cfg) registerQueueRoutes(cfg)
registerPodcastRoutes(cfg)
registerJobRoutes(cfg) registerJobRoutes(cfg)
registerFiltersRoutes(cfg) registerFiltersRoutes(cfg)
registerOPDSRoutes(cfg) registerOPDSRoutes(cfg)
registerReaderRoutes(cfg) registerReaderRoutes(cfg)
registerWebSocketRoutes(cfg) registerWebSocketRoutes(cfg)
registerFrontendRoutes(cfg) registerFrontendRoutes(cfg)
registerPodcastPages(cfg)
registerDocumentationRoutes(cfg) registerDocumentationRoutes(cfg)
// Static bundles are served unhashed (reader.js, index.js), so the // Static bundles are served unhashed (reader.js, index.js), so the
@@ -349,6 +371,19 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
} }
}) })
// Bundle stamps: ?v=<content-hash> per bundle, computed at startup —
// a rebuild that changes a bundle's bytes changes its cache key with
// no manual stamp bump (see templates/bundles.go; the manual ?v=N
// stamp is how a fixed search dropdown stayed broken for a deploy).
for _, bundle := range []string{"main.js", "reader.js"} {
if data, err := os.ReadFile(filepath.Join("web/static", bundle)); err == nil {
sum := sha256.Sum256(data)
templates.SetBundleVersion(bundle, hex.EncodeToString(sum[:])[:8])
} else {
log.Printf("bundle %s not found for stamping: %v", bundle, err)
}
}
e.Static("/static", "web/static") e.Static("/static", "web/static")
// Catch-all 404 handler - must be last // Catch-all 404 handler - must be last
@@ -364,6 +399,14 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
// Start background tasks (queue processor and connection cleanup) // Start background tasks (queue processor and connection cleanup)
scannerHandler.StartBackgroundTasks() scannerHandler.StartBackgroundTasks()
// Podcast subsystem background loops: sequential download worker +
// feed poller (plan §7). The poller re-reads its interval setting
// every tick; both exit with the server context.
if cfg.PodcastWorker != nil && cfg.PodcastService != nil {
cfg.PodcastWorker.Start(context.Background())
services.NewPodcastPoller(cfg.Queries, cfg.PodcastService, cfg.PodcastWorker, cfg.Settings).Start(context.Background())
}
// Start watch mode for all libraries (after 2 second delay for DB) // Start watch mode for all libraries (after 2 second delay for DB)
go func() { go func() {
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
@@ -397,7 +440,15 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
// startup scans for disk I/O. // startup scans for disk I/O.
go func() { go func() {
time.Sleep(30 * time.Second) time.Sleep(30 * time.Second)
services.NewHashBackfillService(cfg.Queries).Run(context.Background()) // RLS plan §6: this self-heal sweeps ALL media — run it as the
// first admin (the scanner's documented fallback identity);
// a bare Background context makes governed reads raise.
adminID, err := cfg.Queries.GetFirstAdmin(context.Background())
if err != nil {
log.Printf("[HASH-BACKFILL] no admin found, skipping backfill: %v", err)
return
}
services.NewHashBackfillService(cfg.Queries).Run(rls.WithUser(context.Background(), adminID))
}() }()
// Register progress routes with actual handler // Register progress routes with actual handler
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff. Show more