Compare commits

..
41 Commits
Author SHA1 Message Date
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
118 changed files with 14292 additions and 736 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
+41 -21
View File
@@ -106,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
+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);
});
+21 -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,16 @@ 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 + sequential download worker +
// poller. One instance trio per server (per-feed locks dedupe).
podcastService := services.NewPodcastService(queries)
podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService)
podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker)
e := echo.New() e := echo.New()
// Set up validator // Set up validator
@@ -229,6 +245,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,
+6 -1
View File
@@ -289,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
+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",
+10 -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"},
@@ -752,6 +752,15 @@ 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",
} }
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)
+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{},
+884
View File
@@ -0,0 +1,884 @@
// Podcast integration tests (plan task 1.10): RLS surfaces (the
// auditor — hidden-library + cross-user invisibility through every
// podcast surface), add-feed flow with allow_member_feeds gating,
// upsert-without-delete (shrunken feeds), edit segregation, search
// scoping, and the download worker state machine (ffmpeg-generated
// audio at runtime — no binary fixtures in the repo).
//
// Doctrine: shared dev DB — test libraries/feeds named "test*"; the
// library delete at cleanup cascades feeds/episodes/subscriptions.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"bookhoard/internal/database"
"bookhoard/internal/rls"
"bookhoard/internal/services"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// podcastFixture builds a minimal RSS 2.0 doc with the given episodes
// (guid/title pairs) — committable-text fixtures only, generated inline.
func podcastFixture(channelTitle string, episodes map[string]string) string {
items := ""
for guid, title := range episodes {
items += fmt.Sprintf(`
<item>
<guid>%s</guid>
<title>%s</title>
<itunes:subtitle>sub of %s</itunes:subtitle>
<description>Description of %s</description>
<itunes:duration>600</itunes:duration>
<pubDate>Mon, 26 Oct 2026 08:00:00 +0000</pubDate>
<enclosure url="https://cdn.invalid/%s.mp3" length="65536" type="audio/mpeg"/>
</item>`, guid, title, guid, title, guid)
}
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
<title>` + channelTitle + `</title>
<language>en-us</language>
<description>Integration test feed</description>
<itunes:author>Test Host</itunes:author>
<itunes:type>serial</itunes:type>` + items + `
</channel>
</rss>`
}
// swapFeedServer hosts feed XML over httptest TLS and wires the
// fetcher's test client (the production client refuses loopback by
// design — the SSRF boundary; restored on cleanup).
func swapFeedServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewTLSServer(handler)
t.Cleanup(func() {
services.SetPodcastHTTPClientForTests(nil)
srv.Close()
})
services.SetPodcastHTTPClientForTests(srv.Client())
return srv
}
// createPodcastsLibrary provisions a podcasts-type library with a
// download root in a temp dir (test-prefixed name; cleanup cascades).
// Returns the download root for file-layout assertions.
func createPodcastsLibrary(t *testing.T, setup *TestServerSetup, name string) (database.Libraries, string) {
t.Helper()
ctx := seedCtx()
libType, err := setup.DB.GetLibraryTypeByName(context.Background(), "podcasts")
require.NoError(t, err, "podcasts library type must be seeded")
lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: name,
Description: pgtype.Text{String: "podcast integration test library", Valid: true},
LibraryTypeID: libType.ID,
})
require.NoError(t, err)
root := t.TempDir() // download root
_, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: lib.ID,
FolderPath: root,
Purpose: services.FolderPurposePodcast,
})
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
return lib, root
}
// upsertTestEpisode seeds one metadata-only episode row.
func upsertTestEpisode(t *testing.T, setup *TestServerSetup, feed database.PodcastFeeds, guid, title string, position int) database.MediaItems {
t.Helper()
ep, err := setup.DB.UpsertPodcastEpisode(adminSeedCtx(t, setup), database.UpsertPodcastEpisodeParams{
LibraryID: feed.LibraryID,
Title: title,
Authors: []string{},
AuthorsSearch: []string{},
FilePath: fmt.Sprintf("test-feed/%04d-%s.mp3", position, guid),
Tags: []string{},
SourceFeedID: feed.ID,
EpisodeGuid: pgtype.Text{String: guid, Valid: true},
FeedPosition: pgtype.Int4{Int32: int32(position), Valid: true},
EnclosureUrl: pgtype.Text{String: "https://cdn.invalid/" + guid + ".mp3", Valid: true},
Hosts: []string{},
Guests: []string{},
})
require.NoError(t, err, "seed episode %q", title)
return ep
}
// subscribeRaw inserts a subscription row (podcast_subscriptions is
// ungoverned by design — the read policy's lookup table).
func subscribeRaw(t *testing.T, setup *TestServerSetup, userID uuid.UUID, feedID pgtype.UUID) {
t.Helper()
_, err := setup.DBPool.Exec(context.Background(),
`INSERT INTO podcast_subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
userID, feedID)
require.NoError(t, err)
}
// adminSeedCtx declares the FIRST-ADMIN identity — the same one the
// fetcher/worker use (invariant 12). The harness seed identity has
// neither a subscription nor a users row, so podcast INSERT..RETURNING
// (which evaluates the SELECT policy under FORCE) 42501s for it; real
// admin writes are exactly what production pods run as.
func adminSeedCtx(t *testing.T, setup *TestServerSetup) context.Context {
t.Helper()
admin, err := setup.DB.GetFirstAdmin(context.Background())
require.NoError(t, err, "podcast tests need an admin identity")
return rls.WithUser(context.Background(), admin)
}
// adminCtxValue is the raw pgtype.UUID behind adminSeedCtx (for
// ScopedDB.Exec calls that want the same identity).
func adminCtxValue(t *testing.T, setup *TestServerSetup) pgtype.UUID {
t.Helper()
admin, err := setup.DB.GetFirstAdmin(context.Background())
require.NoError(t, err)
return admin
}
func podcastAuthed(t *testing.T, setup *TestServerSetup, token, method, path, body string) (int, []byte) {
t.Helper()
var req *http.Request
if body == "" {
req, _ = http.NewRequest(method, setup.Server.URL+path, nil)
} else {
req, _ = http.NewRequest(method, setup.Server.URL+path, bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
return resp.StatusCode, buf.Bytes()
}
// ---- the RLS auditor for podcast surfaces (plan task 1.10 / invariant 13) ----
func TestPodcastRLSSurfaces(t *testing.T) {
setup := setupTestServer(t)
regularID := getRegularUserID(t, setup.DB)
marker := "PODRLS" + uuid.New().String()[:8]
lib, _ := createPodcastsLibrary(t, setup, "test RLS Podcasts "+marker)
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + ".xml",
Title: "RLS Probe Feed " + marker,
})
require.NoError(t, err)
ep := upsertTestEpisode(t, setup, feed, "guid-1", marker+" Episode One", 1)
adminToken := setup.Token
regularToken := setup.RegularToken
t.Run("non_subscriber_gets_404_everywhere", func(t *testing.T) {
code, _ := podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/feeds/"+uuid.UUID(feed.ID.Bytes).String(), "")
assert.Equal(t, http.StatusNotFound, code, "unsubscribed feed must be invisible (existence leak)")
code, _ = podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/search?q="+marker, "")
require.Equal(t, http.StatusOK, code)
// empty via body check below in subscriber test's mirror
code, _ = podcastAuthed(t, setup, regularToken, "GET", "/podcasts/"+uuid.UUID(feed.ID.Bytes).String(), "")
assert.Equal(t, http.StatusNotFound, code, "detail page must 404 for non-subscribers")
})
t.Run("episode_hidden_from_generic_media_reads", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regularToken, "GET", "/api/media-items?limit=500", "")
require.Equal(t, http.StatusOK, code)
assert.NotContains(t, string(body), marker, "LEAK: podcast episode surfaced through generic media list")
})
t.Run("admin_sees_unsubscribed_feed", func(t *testing.T) {
code, body := podcastAuthed(t, setup, adminToken, "GET", "/api/podcasts/feeds/"+uuid.UUID(feed.ID.Bytes).String(), "")
assert.Equal(t, http.StatusOK, code, "admin escape must see any feed")
assert.Contains(t, string(body), marker)
_ = ep
})
t.Run("subscriber_sees_feed_then_hidden_library_hides_it", func(t *testing.T) {
feedIDStr := uuid.UUID(feed.ID.Bytes).String()
subscribeRaw(t, setup, regularID, feed.ID)
code, body := podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/feeds/"+feedIDStr, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), marker, "subscriber must see their feed")
code, body = podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/search?q="+marker, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), marker, "subscription-scoped search covers the subscriber's feed")
// Hide the library → the engine hides the feed row again (the
// feed policy carries the library-visibility join).
_, err := setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: regularID, Valid: true},
LibraryID: lib.ID,
IsVisible: false,
})
require.NoError(t, err)
t.Cleanup(func() {
_, _ = setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: regularID, Valid: true},
LibraryID: lib.ID,
IsVisible: true,
})
})
code, _ = podcastAuthed(t, setup, regularToken, "GET", "/api/podcasts/feeds/"+feedIDStr, "")
assert.Equal(t, http.StatusNotFound, code, "hidden library must hide its feeds even from subscribers")
})
}
// ---- add-feed flow: fetch+parse+upsert+subscribe; allow_member_feeds gate ----
func TestPodcastAddFeedFlow(t *testing.T) {
setup := setupTestServer(t)
regularID := getRegularUserID(t, setup.DB)
lib, _ := createPodcastsLibrary(t, setup, "test AddFeed Podcasts")
mux := http.NewServeMux()
feedXML := podcastFixture("AddFlow Feed", map[string]string{"ep-a": "Episode A", "ep-b": "Episode B"})
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(feedXML))
})
mux.HandleFunc("/other.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("Other Feed", map[string]string{"other-1": "Other Episode"})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
feedURL := srv.URL + "/feed.xml"
libID := uuid.UUID(lib.ID.Bytes).String()
addBody := fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, feedURL)
regularPG := pgtype.UUID{Bytes: regularID, Valid: true}
t.Run("member_adds_feed_subscribes_and_episodes_upsert_metadata_only", func(t *testing.T) {
resp, body := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", addBody)
require.Equal(t, http.StatusCreated, resp, "member add should succeed: %s", string(body))
// The subscription exists; the feed is on the member's list.
code, body := podcastAuthed(t, setup, setup.RegularToken, "GET", "/api/podcasts/feeds", "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), "AddFlow Feed")
// Episode rows are metadata-only: pending, no file facts.
feeds, err := setup.DB.ListMyPodcastFeeds(rls.WithUser(context.Background(), regularPG), regularPG)
require.NoError(t, err)
require.Len(t, feeds, 1)
eps, err := setup.DB.ListPodcastEpisodes(rls.WithUser(context.Background(), pgtype.UUID{Bytes: regularID, Valid: true}), feeds[0].ID)
require.NoError(t, err)
require.Len(t, eps, 2)
for _, ep := range eps {
assert.Equal(t, "pending", ep.DownloadState, "metadata-only rows stay pending until a download")
assert.NotEmpty(t, ep.EnclosureUrl.String, "enclosure URL persisted for the download worker")
assert.NotZero(t, ep.FeedPosition.Int32)
}
})
t.Run("duplicate_url_attaches_without_new_row", func(t *testing.T) {
resp, _ := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", addBody)
assert.Equal(t, http.StatusCreated, resp)
feeds, err := setup.DB.ListMyPodcastFeeds(rls.WithUser(context.Background(), regularPG), regularPG)
require.NoError(t, err)
assert.Len(t, feeds, 1, "URL dedupe: no second feed row")
})
t.Run("allow_member_feeds_false_rejects_members_allows_admins", func(t *testing.T) {
_, err := setup.DBPool.Exec(context.Background(),
`UPDATE libraries SET allow_member_feeds = false WHERE id = $1`, lib.ID)
require.NoError(t, err)
t.Cleanup(func() {
_, _ = setup.DBPool.Exec(context.Background(),
`UPDATE libraries SET allow_member_feeds = true WHERE id = $1`, lib.ID)
})
// A second feed URL from a member → rejected.
otherURL := srv.URL + "/other.xml"
resp, body := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds",
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, otherURL))
assert.Equal(t, http.StatusForbidden, resp, "member add must be refused: %s", string(body))
// Admin keeps the right.
resp, _ = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds",
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, otherURL))
assert.Equal(t, http.StatusCreated, resp, "admin add must succeed")
// Subscribing to an EXISTING feed stays allowed for members
// (the gate governs ADDING feeds, not subscribing).
code, _ := podcastAuthed(t, setup, setup.RegularToken, "GET", "/api/podcasts/feeds", "")
require.Equal(t, http.StatusOK, code)
})
}
// ---- upsert-without-delete: shrunken feeds keep their history (D14) ----
func TestPodcastShrunkenFeedRetention(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test Shrink Podcasts")
current := podcastFixture("Shrink Feed", map[string]string{"ep-1": "Kept One", "ep-2": "Doomed Two"})
mux := http.NewServeMux()
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(current))
})
srv := swapFeedServer(t, mux.ServeHTTP)
libID := uuid.UUID(lib.ID.Bytes).String()
resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds",
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, srv.URL+"/feed.xml"))
require.Equal(t, http.StatusCreated, resp, string(body))
adminCtx := adminSeedCtx(t, setup)
allFeeds, err := setup.DB.ListPodcastFeedsForPolling(adminCtx)
require.NoError(t, err)
var feedID pgtype.UUID
for _, f := range allFeeds {
if f.Title == "Shrink Feed" {
feedID = f.ID
}
}
feedIDStr := uuid.UUID(feedID.Bytes).String()
eps, err := setup.DB.ListPodcastEpisodes(adminSeedCtx(t, setup), feedID)
require.NoError(t, err)
require.Len(t, eps, 2)
// The publisher purges an episode from the XML.
current = podcastFixture("Shrink Feed", map[string]string{"ep-1": "Kept One"})
resp, _ = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "")
require.Equal(t, http.StatusOK, resp)
eps, err = setup.DB.ListPodcastEpisodes(adminCtx, feedID)
require.NoError(t, err)
require.Len(t, eps, 2, "PIN: shrunken feeds NEVER delete rows — history accumulates for search")
}
// ---- edit segregation (D10) ----
func TestPodcastEditSegregation(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test EditSeg Podcasts")
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/edit-seg.xml",
Title: "EditSeg Feed",
})
require.NoError(t, err)
ep := upsertTestEpisode(t, setup, feed, "seg-1", "Segregated Episode", 1)
epID := uuid.UUID(ep.ID.Bytes).String()
t.Run("generic_edit_rejects_podcast_episodes", func(t *testing.T) {
resp, body := podcastAuthed(t, setup, setup.Token, "PUT", "/api/media-items/"+epID,
`{"title":"Hacked Title","description":"clobbered"}`)
assert.Equal(t, http.StatusBadRequest, resp, "PIN: generic edit must reject episodes: %s", string(body))
})
t.Run("write_to_file_rejects_podcast_episodes", func(t *testing.T) {
resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/media-items/"+epID+"/write-metadata", "")
assert.NotEqual(t, http.StatusOK, resp, "PIN: metadata write-to-file must refuse episodes: %s", string(body))
})
t.Run("book_edit_still_works", func(t *testing.T) {
// Control: the rejection is podcast-specific, not a broken route.
libType, err := setup.DB.GetLibraryTypeByName(seedCtx(), "audiobooks")
require.NoError(t, err)
book, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: func() pgtype.UUID {
lib2, cerr := setup.DB.CreateLibrary(seedCtx(), database.CreateLibraryParams{
Name: "test EditSeg Books",
LibraryTypeID: libType.ID,
})
require.NoError(t, cerr)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(seedCtx(), lib2.ID) })
return lib2.ID
}(),
Title: "Edit Control Book",
FilePath: "control-" + uuid.New().String() + ".m4b",
})
require.NoError(t, err)
resp, body := podcastAuthed(t, setup, setup.Token, "PUT", "/api/media-items/"+uuid.UUID(book.ID.Bytes).String(),
`{"title":"Edited Control"}`)
assert.True(t, resp == http.StatusOK || resp == http.StatusBadRequest,
"control book edit must not 404/500 (podcast-specific gate): %d %s", resp, string(body))
})
}
// ---- download worker: state machine, idempotency, file layout ----
func TestPodcastDownloadWorker(t *testing.T) {
setup := setupTestServer(t)
if _, err := exec.LookPath("ffmpeg"); err != nil {
if _, err := os.Stat("/usr/bin/ffmpeg"); err != nil {
t.Skip("ffmpeg unavailable: worker pipeline test needs runtime-generated audio")
}
}
lib, root := createPodcastsLibrary(t, setup, "test Download Podcasts")
// Generate a small valid MP3 (1s tone) at runtime — no binary fixtures.
mp3Path := filepath.Join(t.TempDir(), "tone.mp3")
gen := exec.Command("ffmpeg", "-y", "-v", "error", "-f", "lavfi",
"-i", "sine=frequency=440:duration=1", "-b:a", "32k", mp3Path)
out, err := gen.CombinedOutput()
require.NoError(t, err, "ffmpeg generate: %s", string(out))
audio, err := os.ReadFile(mp3Path)
require.NoError(t, err)
var enclosureHits int
mux := http.NewServeMux()
mux.HandleFunc("/audio/ep.mp3", func(w http.ResponseWriter, _ *http.Request) {
enclosureHits++
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write(audio)
})
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("Download Feed", map[string]string{"dl-1": "Downloaded Episode"})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
libID := uuid.UUID(lib.ID.Bytes).String()
resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds",
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, srv.URL+"/feed.xml"))
require.Equal(t, http.StatusCreated, resp, string(body))
feeds, err := setup.DB.ListPodcastFeedsForPolling(adminSeedCtx(t, setup))
require.NoError(t, err)
var feedRow database.PodcastFeeds
for _, f := range feeds {
if f.Title == "Download Feed" {
feedRow = f
}
}
require.NotZero(t, feedRow.ID, "feed must exist after add")
eps, err := setup.DB.ListPodcastEpisodes(adminSeedCtx(t, setup), feedRow.ID)
require.NoError(t, err)
require.Len(t, eps, 1)
ep := eps[0]
// Point the enclosure at the TEST server (fixtures use cdn.invalid).
// The write goes through an ADMIN-declared ScopedDB — the episode
// read policy hides such rows from the raw pool's seed identity, and
// onUpdate UPDATE FOR UPDATE reads the target row first.
adminCtx := adminSeedCtx(t, setup)
adminScope := database.NewScopedDB(setup.DBPool)
_, err = adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)),
`UPDATE media_items SET enclosure_url = $2 WHERE id = $1`,
ep.ID, srv.URL+"/audio/ep.mp3")
require.NoError(t, err)
updated, err := setup.DB.GetPodcastEpisode(adminCtx, ep.ID)
require.NoError(t, err)
assert.Equal(t, srv.URL+"/audio/ep.mp3", updated.EnclosureUrl.String, "enclosure re-pointed at the test server")
worker := services.NewPodcastDownloadWorker(setup.DB, services.NewPodcastService(setup.DB))
workerCtx, cancel := context.WithCancel(context.Background())
defer cancel()
worker.Start(workerCtx)
feedIDStr := uuid.UUID(feedRow.ID.Bytes).String()
epIDStr := uuid.UUID(ep.ID.Bytes).String()
resp, _ = podcastAuthed(t, setup, setup.Token, "POST",
"/api/podcasts/feeds/"+feedIDStr+"/episodes/"+epIDStr+"/download", "")
require.Equal(t, http.StatusAccepted, resp)
// Terminal state within the worker's drain cadence.
var done database.MediaItems
require.Eventually(t, func() bool {
row, err := setup.DB.GetPodcastEpisode(adminCtx, ep.ID)
if err != nil {
return false
}
done = row
return row.DownloadState == "downloaded" || row.DownloadState == "failed"
}, 30*time.Second, 250*time.Millisecond, "download must reach a terminal state (got %s)", done.DownloadState)
require.Equal(t, "downloaded", done.DownloadState, "inline reason (if failed): %s", done.DownloadError.String)
assert.True(t, done.FileSize.Int64 > 0)
assert.NotEmpty(t, done.AudioMetadata, "ffprobe facts must land in audio_metadata")
assert.FileExists(t, filepath.Join(root, done.FilePath), "episode lands under <download-root>/<feed-slug>/ per the pinned layout")
t.Run("redownload_of_downloaded_episode_is_a_noop", func(t *testing.T) {
hitsBefore := enclosureHits
resp, _ := podcastAuthed(t, setup, setup.Token, "POST",
"/api/podcasts/feeds/"+feedIDStr+"/episodes/"+epIDStr+"/download", "")
require.Equal(t, http.StatusAccepted, resp)
time.Sleep(1500 * time.Millisecond)
assert.Equal(t, hitsBefore, enclosureHits, "PIN: re-download of a downloaded episode must not re-fetch")
})
t.Run("failed_download_persists_reason_inline", func(t *testing.T) {
// A dead enclosure: fetch fails, state+reason persist, existing
// metadata is never cleared. Writes run under the acting admin
// (the seed identity would read-match zero rows under the
// subscription policy).
// The failure path only exercises with the file ABSENT — an
// on-disk, probe-clean file re-marks downloaded without fetching
// (the idempotency reconciliation, already pinned by the no-op
// subtest above).
require.NoError(t, os.Remove(filepath.Join(root, done.FilePath)))
_, err := adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)),
`UPDATE media_items SET enclosure_url = 'https://127.0.0.1:1/none.mp3',
download_state = 'pending', download_error = NULL, download_attempts = 0
WHERE id = $1`, ep.ID)
require.NoError(t, err)
resp, _ := podcastAuthed(t, setup, setup.Token, "POST",
"/api/podcasts/feeds/"+feedIDStr+"/episodes/"+epIDStr+"/download", "")
require.Equal(t, http.StatusAccepted, resp)
require.Eventually(t, func() bool {
r, err := setup.DB.GetPodcastEpisode(adminCtx, ep.ID)
if err != nil {
return false
}
if r.DownloadState != "failed" {
t.Logf("still not failed: state=%s enclosure=%q attempts=%d err=%q",
r.DownloadState, r.EnclosureUrl.String, r.DownloadAttempts, r.DownloadError.String)
return false
}
return true
}, 30*time.Second, 2*time.Second, "dead enclosure must end failed")
r, err := setup.DB.GetPodcastEpisode(adminCtx, ep.ID)
require.NoError(t, err)
assert.NotEmpty(t, r.DownloadError.String, "PIN: the reason text persists (renders inline in the episode list)")
assert.Equal(t, "Downloaded Episode", r.Title, "PIN: failures never clear episode metadata")
})
}
// ---- invariant 4 pin: the scanner never sees podcasts folders ----
func TestPodcastScannerExclusion(t *testing.T) {
setup := setupTestServer(t)
lib, root := createPodcastsLibrary(t, setup, "test ScanSkip Podcasts")
// A stray audio file inside the download root must never ingest.
require.NoError(t, os.MkdirAll(filepath.Join(root, "some-feed"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, "some-feed", "01-ep.mp3"), []byte("not really audio"), 0o644))
scanner := services.NewMediaScanner(setup.DB)
err := scanner.SetFolders([]string{root}, false)
if err != nil {
// Zero scannable folders errors at the watcher (watch=true) —
// scan jobs (watch=false) still configure; assert either way.
assert.Contains(t, err.Error(), "feed-managed")
}
assert.Empty(t, scanner.FolderCount(), "PIN: podcasts download roots are never scanner folders")
assert.NotZero(t, lib.ID, "library must exist for the pin to be meaningful")
}
// ---- invariant 3 / task 2.6: order flips never move progress ----
func TestPodcastReorderSafety(t *testing.T) {
setup := setupTestServer(t)
adminCtx := adminSeedCtx(t, setup)
lib, _ := createPodcastsLibrary(t, setup, "test Reorder Podcasts")
feed, err := setup.DB.CreatePodcastFeed(adminCtx, database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/reorder.xml",
Title: "Reorder Feed",
})
require.NoError(t, err)
e1 := upsertTestEpisode(t, setup, feed, "ro-1", "Reorder One", 1)
e2 := upsertTestEpisode(t, setup, feed, "ro-2", "Reorder Two", 2)
// Mark both DOWNLOADED so they enter the sequence (the lifecycle
// itself is pinned by TestPodcastDownloadWorker).
adminScopeExec(t, setup, adminCtxValue(t, setup),
`UPDATE media_items SET download_state = 'downloaded', file_size = 1000
WHERE id IN ($1, $2) AND source = 'podcast_episode'`, e1.ID, e2.ID)
adminID := adminCtxValue(t, setup)
// Listener is 40% through episode TWO (direct row write — the pin
// is about the row's LIFE under renumbering, not the write API).
adminScopeExec(t, setup, adminID,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, chapter, character_offset)
VALUES ($1, $2, 0.4, 0, 0)
ON CONFLICT (media_item_id, user_id) DO UPDATE SET percentage = 0.4, last_read_at = NOW()`,
e2.ID, adminID)
before, err := setup.DB.GetReadingProgress(adminCtx, database.GetReadingProgressParams{
MediaItemID: e2.ID, UserID: adminID,
})
require.NoError(t, err)
pctBefore := before.Percentage.Float64
// FLIP the feed's native positions (the fetcher renumbering case:
// the publisher reorders; positions renumber on the next fetch).
adminScopeExec(t, setup, adminID,
`UPDATE media_items SET feed_position = CASE id WHEN $1 THEN 2 WHEN $2 THEN 1 END
WHERE id IN ($1, $2)`, e1.ID, e2.ID)
after, err := setup.DB.GetReadingProgress(adminCtx, database.GetReadingProgressParams{
MediaItemID: e2.ID, UserID: adminID,
})
require.NoError(t, err)
assert.Equal(t, pctBefore, after.Percentage.Float64,
"PIN: progress anchors on the episode (guid/identity), never the ordinal — reordering must not move it")
// The sequence still lists both downloaded episodes; the override
// only changes ORDER, never membership.
memberID := getRegularUserID(t, setup.DB)
subscribeRaw(t, setup, memberID, feed.ID)
_, body := podcastAuthed(t, setup, setup.Token, "GET",
"/api/podcasts/feeds/"+uuid.UUID(feed.ID.Bytes).String()+"/sequence", "")
var seq struct {
Episodes []struct {
ID string `json:"id"`
} `json:"episodes"`
Gaps []interface{} `json:"gaps"`
}
require.NoError(t, json.Unmarshal(body, &seq))
assert.Len(t, seq.Episodes, 2, "sequence membership survives renumbering")
assert.Empty(t, seq.Gaps, "no un-downloaded rows → no gaps")
}
// adminScopeExec runs raw SQL under the first-admin identity (helper
// for pins that need direct row surgery the Queries API lacks).
func adminScopeExec(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, sql string, args ...any) {
t.Helper()
scope := database.NewScopedDB(setup.DBPool)
_, err := scope.Exec(rls.WithUser(context.Background(), adminID), sql, args...)
require.NoError(t, err)
}
// ---- task 1.9 residue: the htmx wire format is FORM-encoded (issue #2) ----
func TestPodcastOrderFormEncoding(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test OrderForm Podcasts")
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/order-form.xml",
Title: "Order Form Feed",
})
require.NoError(t, err)
feedIDStr := uuid.UUID(feed.ID.Bytes).String()
// The UI's hx-put submits urlencoded form bodies (htmx non-GET
// default) — the binder must map them (form tag), not just JSON.
formPut := func(body string) int {
req, _ := http.NewRequest(http.MethodPut,
setup.Server.URL+"/api/podcasts/feeds/"+feedIDStr+"/order",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode
}
assert.Equal(t, http.StatusOK, formPut("order=oldest"), "form-encoded order must bind")
assert.Equal(t, http.StatusOK, formPut("order=native"), "native binds too")
assert.Equal(t, http.StatusBadRequest, formPut(""), "missing order stays rejected (required)")
assert.Equal(t, http.StatusBadRequest, formPut("order=sideways"), "invalid order stays rejected")
}
// ---- issue #8: page search hits EPISODE titles/descriptions too ----
func TestPodcastPageSearchEpisodes(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test PageSearch Podcasts")
// Feed title deliberately does NOT contain the needle.
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/page-search.xml",
Title: "Trader Tales Show",
})
require.NoError(t, err)
upsertTestEpisode(t, setup, feed, "ps-1", "Zarkorrin Blaster", 1)
subscribeRaw(t, setup, getRegularUserID(t, setup.DB), feed.ID)
// SSR page search: the needle exists only in an EPISODE title.
req, _ := http.NewRequest(http.MethodGet, setup.Server.URL+"/podcasts?q=Zarkorrin", nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
body := buf.String()
assert.Contains(t, body, "Trader Tales Show",
"PIN: a feed surfaces when an EPISODE matches the query, even when the feed's own metadata does not")
assert.Contains(t, body, "matching episode",
"the match count renders on the card")
}
// ---- issue #3: Download All flips the CALLER's auto-download to all ----
func TestPodcastDownloadAllPolicyFlip(t *testing.T) {
setup := setupTestServer(t)
regularID := getRegularUserID(t, setup.DB)
lib, _ := createPodcastsLibrary(t, setup, "test Flip Podcasts")
mux := http.NewServeMux()
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("Flip Feed", map[string]string{"f-1": "Flip One"})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
libID := uuid.UUID(lib.ID.Bytes).String()
regularPG := pgtype.UUID{Bytes: regularID, Valid: true}
t.Run("add_feed_with_download_all_sets_policy_all", func(t *testing.T) {
resp, body := podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds",
fmt.Sprintf(`{"library_id":%q,"url":%q,"download_all":true}`, libID, srv.URL+"/feed.xml"))
require.Equal(t, http.StatusCreated, resp, string(body))
sub, err := setup.DB.GetPodcastSubscription(rls.WithUser(context.Background(), regularPG), database.GetPodcastSubscriptionParams{
UserID: regularPG, FeedID: func() pgtype.UUID {
feeds, _ := setup.DB.ListPodcastFeedsForPolling(rls.WithUser(context.Background(), regularPG))
for _, f := range feeds {
if f.Title == "Flip Feed" {
return f.ID
}
}
return pgtype.UUID{}
}(),
})
require.NoError(t, err)
assert.Equal(t, "all", sub.AutoDownload, "PIN: the add-feed checkbox flips the caller's policy to all")
})
t.Run("download_all_button_flips_policy_and_returns_oob_select", func(t *testing.T) {
// Set the policy to newest first: the flip must override it.
feeds, _ := setup.DB.ListPodcastFeedsForPolling(rls.WithUser(context.Background(), regularPG))
var feedID pgtype.UUID
for _, f := range feeds {
if f.Title == "Flip Feed" {
feedID = f.ID
}
}
_, err := setup.DB.UpdatePodcastSubscription(rls.WithUser(context.Background(), regularPG), database.UpdatePodcastSubscriptionParams{
UserID: regularPG, FeedID: feedID,
DisplayName: pgtype.Text{}, AutoDownload: "newest",
AutoDownloadCount: pgtype.Int4{Int32: 3, Valid: true},
})
require.NoError(t, err)
feedIDStr := uuid.UUID(feedID.Bytes).String()
req, _ := http.NewRequest(http.MethodPost,
setup.Server.URL+"/api/podcasts/feeds/"+feedIDStr+"/episodes/all/download", nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
req.Header.Set("HX-Request", "true")
hxResp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = hxResp.Body.Close() }()
require.Equal(t, http.StatusOK, hxResp.StatusCode, "HX action answers with the refreshed partial")
var buf bytes.Buffer
_, _ = buf.ReadFrom(hxResp.Body)
body := buf.String()
assert.Contains(t, body, `id="episode-list"`, "direct swap: the episode list partial IS the response")
assert.Contains(t, body, `hx-swap-oob="true"`, "issue #3: the policy select rides along out-of-band")
sub, err := setup.DB.GetPodcastSubscription(rls.WithUser(context.Background(), regularPG), database.GetPodcastSubscriptionParams{
UserID: regularPG, FeedID: feedID,
})
require.NoError(t, err)
assert.Equal(t, "all", sub.AutoDownload, "PIN: Download All flips newest → all (dynamic + visible)")
})
}
// ---- logout actually signs out (fake-signout bug, 2026-10-30) ----
func TestLogoutClearsSession(t *testing.T) {
setup := setupTestServer(t)
// Real login: capture the session cookie AND the refresh token.
req, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/login",
strings.NewReader(`{"login":"testuser@tests.bookhoard.internal","password":"Test@Pass123!"}`))
req.Header.Set("Content-Type", "application/json")
loginResp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = loginResp.Body.Close() }()
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var cookies []*http.Cookie
for _, c := range loginResp.Cookies() {
if c.Name == "token" && c.Value != "" {
cookies = append(cookies, c)
}
}
require.NotEmpty(t, cookies, "login must set the session cookie")
var loginBody struct {
RefreshToken string `json:"refresh_token"`
}
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&loginBody))
require.NotEmpty(t, loginBody.RefreshToken, "login must hand back a refresh token")
// Logout WITH the session cookie + refresh token.
logoutReq, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/logout",
strings.NewReader(`{"refresh_token":"`+loginBody.RefreshToken+`"}`))
logoutReq.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
logoutReq.AddCookie(c)
}
logoutResp, err := http.DefaultClient.Do(logoutReq)
require.NoError(t, err)
defer func() { _ = logoutResp.Body.Close() }()
require.Equal(t, http.StatusOK, logoutResp.StatusCode)
// PIN 1: the response deletes the session cookie.
var cleared bool
for _, c := range logoutResp.Cookies() {
if c.Name == "token" && c.Value == "" && c.MaxAge < 0 {
cleared = true
}
}
assert.True(t, cleared, "PIN: logout must Set-Cookie an empty/expired token (HttpOnly — the browser is the only eraser)")
// PIN 2: the refresh token is revoked server-side.
refreshReq, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/refresh",
strings.NewReader(`{"refresh_token":"`+loginBody.RefreshToken+`"}`))
refreshReq.Header.Set("Content-Type", "application/json")
refreshResp, err := http.DefaultClient.Do(refreshReq)
require.NoError(t, err)
defer func() { _ = refreshResp.Body.Close() }()
assert.NotEqual(t, http.StatusOK, refreshResp.StatusCode,
"PIN: a logged-out refresh token must not mint new access tokens")
// Logout WITHOUT a body still clears the cookie (malformed/absent
// payloads must never leave the browser signed in).
logoutReq2, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/logout", nil)
for _, c := range cookies {
logoutReq2.AddCookie(c)
}
logoutResp2, err := http.DefaultClient.Do(logoutReq2)
require.NoError(t, err)
defer func() { _ = logoutResp2.Body.Close() }()
cleared = false
for _, c := range logoutResp2.Cookies() {
if c.Name == "token" && c.Value == "" && c.MaxAge < 0 {
cleared = true
}
}
assert.True(t, cleared, "cookie clear must not depend on the request body")
}
+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"},
+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,
+266 -2
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,9 @@ 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')
ON CONFLICT (setting_key) DO NOTHING; ON CONFLICT (setting_key) DO NOTHING;
-- Create refresh_tokens table -- Create refresh_tokens table
@@ -1877,3 +1880,264 @@ 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;
-- 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';
-- ============================================================
-- 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
+27
View File
@@ -432,3 +432,30 @@ library_name`.
partial (layout=dropdown on the same endpoint, HX-Request) instead partial (layout=dropdown on the same endpoint, HX-Request) instead
of the retired innerHTML renderer in web/src/search.ts — same of the retired innerHTML renderer in web/src/search.ts — same
endpoint, same auth, content server-rendered. 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.
+54
View File
@@ -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 {
@@ -338,6 +340,21 @@ type MediaItems struct {
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"`
} }
type MediaNotes struct { type MediaNotes struct {
@@ -405,6 +422,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"`
+133
View File
@@ -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.
@@ -53,11 +55,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 // Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
// metadata.opf may only be written when NO other non-archived item // metadata.opf may only be written when NO other non-archived item
// shares the directory; otherwise the sidecar goes to // shares the directory; otherwise the sidecar goes to
// <basename>.opf so one book's metadata can never poison dir-mates. // <basename>.opf so one book's metadata can never poison dir-mates.
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error) 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
@@ -116,6 +126,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
@@ -160,6 +186,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)
@@ -170,7 +201,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.
@@ -408,6 +446,8 @@ type Querier interface {
// GetMediaScanState reads one item's manifest (per-item rescan paths // GetMediaScanState reads one item's manifest (per-item rescan paths
// and the manifest integration tests). // and the manifest integration tests).
GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error) GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error)
// Sequential download worker's claim source (single worker, day one).
GetNextQueuedEpisode(ctx context.Context) (MediaItems, error)
GetNextRetryTime(ctx context.Context) (interface{}, error) GetNextRetryTime(ctx context.Context) (interface{}, error)
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error) GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
// Get OPDS token // Get OPDS token
@@ -415,6 +455,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.
@@ -515,6 +574,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
@@ -546,6 +611,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
@@ -557,6 +625,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)
@@ -565,11 +636,21 @@ 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;
@@ -577,6 +658,8 @@ type Querier interface {
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error) ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, 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
@@ -585,6 +668,14 @@ 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
// 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.
@@ -648,14 +739,30 @@ 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)
// Worker claim: pending|failed -> queued. Attempts incremented at
// claim time so a crashed worker still counts the try.
SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error)
SetPodcastEpisodeDownloading(ctx context.Context, id pgtype.UUID) 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
@@ -720,6 +827,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)
@@ -745,11 +866,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
+447 -1
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
@@ -748,6 +756,7 @@ LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.
WHERE COALESCE(lv.is_visible, true) = true 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
@@ -932,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)
@@ -951,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)
@@ -971,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
@@ -988,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
@@ -1004,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)
@@ -1023,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)
@@ -1061,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)
@@ -1129,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)
@@ -1196,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)
@@ -1265,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)
@@ -1333,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)
@@ -1404,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)
@@ -1475,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)
@@ -1546,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)
@@ -1613,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)
@@ -1680,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)
@@ -1747,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)
@@ -1818,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)
@@ -1889,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)
@@ -1960,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)
@@ -2604,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;
@@ -2666,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[]))
@@ -2809,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;
@@ -3954,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
@@ -3962,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
@@ -3977,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
@@ -3985,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 (
@@ -4071,6 +4114,7 @@ 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
@@ -4103,6 +4147,7 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) AND (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 -- 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 -- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
@@ -4163,6 +4208,7 @@ SELECT COUNT(*)::int FROM (
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) AND (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 != ''
AND (sqlc.narg('series_query')::text IS NULL OR AND (sqlc.narg('series_query')::text IS NULL OR
series ILIKE '%' || sqlc.narg('series_query')::text || '%' series ILIKE '%' || sqlc.narg('series_query')::text || '%'
@@ -4582,3 +4628,403 @@ 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: 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
-- Worker claim: pending|failed -> queued. Attempts incremented at
-- claim time so a crashed worker still counts the try.
UPDATE media_items
SET download_state = 'queued',
download_error = NULL,
download_attempts = download_attempts + 1,
updated_at = NOW()
WHERE id = $1
AND source = 'podcast_episode'
AND download_state IN ('pending', 'failed');
-- name: SetPodcastEpisodeDownloading :exec
UPDATE media_items
SET download_state = 'downloading', updated_at = NOW()
WHERE id = $1 AND source = 'podcast_episode';
-- 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: GetNextQueuedEpisode :one
-- Sequential download worker's claim source (single worker, day one).
SELECT * FROM media_items
WHERE source = 'podcast_episode' AND download_state = 'queued'
ORDER BY updated_at ASC
LIMIT 1;
-- 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}
}
+8
View File
@@ -82,6 +82,7 @@ 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)"},
} }
// defaultBy indexes SettingDefaults by key for O(1) lookup. // defaultBy indexes SettingDefaults by key for O(1) lookup.
@@ -188,6 +189,13 @@ 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 the feed-poller tick (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
}
// 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") }
+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 -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",
+6
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"})
} }
+8
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
+7
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
+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"})
} }
+610
View File
@@ -0,0 +1,610 @@
// 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"
"fmt"
"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).
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect 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 bool) error {
if h.episodeListRenderer != nil {
return h.episodeListRenderer(c, feedID, includePolicySelect)
}
return hxAccepted(c, "")
}
// 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
})
}
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 bool `json:"download_all" form:"download_all"`
}
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 {
if _, err := h.worker.QueueFeedDownloads(c.Request().Context(), 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)
}
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)
}
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).
queued, err := h.worker.QueueFeedDownloads(ctx, 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)
}
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)
}
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) | oldest | newest.
func podcastOrderValid(o string) bool {
return o == "native" || o == "oldest" || o == "newest"
}
// 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 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)
}
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]))
}
sortSlice(views, func(a, b PodcastEpisodeView) bool {
switch order {
case "oldest":
return episodeSortKey(a) < episodeSortKey(b)
case "newest":
return episodeSortKey(a) > episodeSortKey(b)
default: // native: feed position (fetch-maintained)
if a.FeedPosition != b.FeedPosition {
return a.FeedPosition < b.FeedPosition
}
return a.Date < b.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,
})
}
// episodeSortKey: chronological key for oldest/newest (date, then
// native position as the same-day tiebreaker).
func episodeSortKey(v PodcastEpisodeView) string {
return v.Date + fmt.Sprintf("%06d", v.FeedPosition)
}
// 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}
}
+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"})
+13 -1
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"
) )
@@ -83,6 +85,11 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
for _, folder := range libraryFolders { for _, folder := range libraryFolders {
if folder.FolderPath == req.FolderPath { if folder.FolderPath == req.FolderPath {
registered = true 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 break
} }
} }
@@ -127,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,
@@ -140,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 {
+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,
}) })
+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)
} }
} }
+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
}
+1
View File
@@ -1984,6 +1984,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
} }
+68
View File
@@ -0,0 +1,68 @@
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/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 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
}
}
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)
}
+467
View File
@@ -0,0 +1,467 @@
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/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 := ""
if ep.DatePublished.Valid {
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,
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).
if order == "oldest" || order == "newest" {
sort.SliceStable(rows, func(a, b int) bool {
ka := rows[a].Date + itoaPad(rows[a].Position)
kb := rows[b].Date + itoaPad(rows[b].Position)
if order == "newest" {
return ka > kb
}
return ka < kb
})
}
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"
}
switch v.Order {
case "oldest", "newest":
return v.Order
}
return "native"
}
// facetValuesFromCards derives the filter bar values.
func facetValuesFromCards(feeds []templates.PodcastFeedCard) map[string][]string {
hosts, tags, langs := map[string]bool{}, map[string]bool{}, 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
}
}
return map[string][]string{
"hosts": sortedSet(hosts),
"tags": sortedSet(tags),
"language": sortedSet(langs),
}
}
func sortedSet(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
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
}
+109
View File
@@ -1,13 +1,17 @@
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"
"fmt"
"net/http" "net/http"
"sort"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@@ -102,6 +106,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 +160,96 @@ 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 && (v.Order == "oldest" || v.Order == "newest") {
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,
})
}
sort.SliceStable(ordered, func(a, b int) bool {
ka := ordered[a].date + fmt.Sprintf("%06d", ordered[a].pos)
kb := ordered[b].date + fmt.Sprintf("%06d", ordered[b].pos)
if order == "newest" {
return ka > kb
}
if order == "oldest" {
return ka < kb
}
return ordered[a].pos < ordered[b].pos
})
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
}
+35 -1
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/services" "bookhoard/internal/services"
"bookhoard/internal/sync" "bookhoard/internal/sync"
"bookhoard/templates" "bookhoard/templates"
@@ -79,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
@@ -155,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)
} }
} }
@@ -222,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 {
@@ -331,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
@@ -381,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)
@@ -414,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
+38
View File
@@ -29,6 +29,12 @@ const (
LibraryTypeComics = "comics" LibraryTypeComics = "comics"
LibraryTypeManga = "manga" LibraryTypeManga = "manga"
LibraryTypeAudiobooks = "audiobooks" LibraryTypeAudiobooks = "audiobooks"
// Podcasts libraries are feed containers (RSS subscribe+download),
// not FS-scanned media: AllowedExtensions is deliberately empty —
// the scanner skips these folders wholesale (podcast downloads are
// written by the fetcher's download worker, never discovered by
// the walker). See podcast_service.go.
LibraryTypePodcasts = "podcasts"
) )
var AllowedExtensions = map[string][]string{ var AllowedExtensions = map[string][]string{
@@ -43,6 +49,9 @@ var AllowedExtensions = map[string][]string{
// browser-playable lossless/next-gen containers join for Batch B // browser-playable lossless/next-gen containers join for Batch B
// metadata parity (.wma deliberately absent: browsers can't play it). // metadata parity (.wma deliberately absent: browsers can't play it).
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav"}, LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav"},
// Empty BY DESIGN: nothing in a podcasts library is scanner-facing.
// The scanner additionally skips these folders outright.
LibraryTypePodcasts: {},
} }
var MimeTypes = map[string]string{ var MimeTypes = map[string]string{
@@ -149,10 +158,39 @@ func (s *LibraryService) DeleteLibrary(ctx context.Context, libraryID pgtype.UUI
} }
// AddLibraryFolder adds a folder to a library // AddLibraryFolder adds a folder to a library
// Folder purposes (library_folders.purpose): 'scan' roots feed the FS
// walker; 'podcast' roots are download targets written only by the
// podcast download worker.
const (
FolderPurposeScan = "scan"
FolderPurposePodcast = "podcast"
)
// AddLibraryFolder registers a folder against a library. The purpose is
// derived from the library type: podcasts libraries register their
// download root (purpose='podcast', exactly ONE folder — feeds lay out
// <download-root>/<feed-slug>/...); every other type registers a
// scanner root (purpose='scan').
func (s *LibraryService) AddLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) (*database.LibraryFolders, error) { func (s *LibraryService) AddLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) (*database.LibraryFolders, error) {
purpose := FolderPurposeScan
lib, err := s.db.GetLibrary(ctx, libraryID)
if err == nil && lib.TypeName == LibraryTypePodcasts {
// Podcasts libraries own exactly one folder: the download root.
existing, err := s.db.GetLibraryFolders(ctx, libraryID)
if err != nil {
return nil, err
}
if len(existing) > 0 {
return nil, fmt.Errorf("podcasts libraries have a single download root; %s is already registered", existing[0].FolderPath)
}
purpose = FolderPurposePodcast
}
folder, err := s.db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{ folder, err := s.db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: libraryID, LibraryID: libraryID,
FolderPath: folderPath, FolderPath: folderPath,
Purpose: purpose,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
+59 -7
View File
@@ -43,6 +43,8 @@ import (
epub "github.com/ArcadiaLin/go-epub" epub "github.com/ArcadiaLin/go-epub"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/google/uuid" "github.com/google/uuid"
"bookhoard/internal/rls"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/nwaples/rardecode" "github.com/nwaples/rardecode"
@@ -502,6 +504,12 @@ func (s *MediaScanner) GetStats() (int, int, int) {
return s.totalFiles, s.newItems, s.errors return s.totalFiles, s.newItems, s.errors
} }
// FolderCount reports the active root-folder count (test/audit helper:
// the podcast-exclusion pin asserts it stays 0 for podcasts roots).
func (s *MediaScanner) FolderCount() int {
return len(s.folders)
}
// SetFolders configures the scanner's folders and (optionally) sets up an // SetFolders configures the scanner's folders and (optionally) sets up an
// fsnotify watcher over the full directory tree. // fsnotify watcher over the full directory tree.
// //
@@ -510,9 +518,34 @@ func (s *MediaScanner) GetStats() (int, int, int) {
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never // watcher (and thus no fd/inotify watches) is allocated — the watcher is never
// read by scan jobs and previously leaked one watcher per job. // read by scan jobs and previously leaked one watcher per job.
func (s *MediaScanner) SetFolders(folders []string, watch bool) error { func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
s.folders = folders // Podcasts libraries are feed-managed (plan: podcast-integration.md
// task 1.2, invariant 4): their download roots are written by the
// download worker, never discovered by the walker — including
// scoped single-folder scans (0f1d0abb), which all funnel through
// here. Filter them out before anything else touches the list.
scannable := folders[:0:0]
for _, folder := range folders {
lib, err := s.db.GetLibraryByFolder(context.Background(), folder)
if err != nil {
// Unresolvable folders keep the old behavior (scan walk
// skips them per-file; watch adds them harmlessly).
scannable = append(scannable, folder)
continue
}
libType, err := s.db.GetLibraryType(context.Background(), lib.LibraryTypeID)
if err != nil {
scannable = append(scannable, folder)
continue
}
if libType.Name == LibraryTypePodcasts {
fmt.Printf("Scanner: skipping %s (podcasts library %q — feed-managed, not scanned)\n", folder, lib.Name)
continue
}
scannable = append(scannable, folder)
}
folders = scannable
// Always close any previously-owned watcher so reconfiguration doesn't leak. s.folders = folders // Always close any previously-owned watcher so reconfiguration doesn't leak.
if s.watcher != nil { if s.watcher != nil {
if err := s.watcher.Close(); err != nil { if err := s.watcher.Close(); err != nil {
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err) fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
@@ -522,6 +555,12 @@ func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
// Create + populate a fresh watcher only when the caller intends to read events. // Create + populate a fresh watcher only when the caller intends to read events.
if watch { if watch {
if len(folders) == 0 {
// All requested folders filtered (e.g. a podcasts library:
// nothing scannable, nothing to watch). Fail loudly at the
// API boundary instead of panicking on folders[0] below.
return fmt.Errorf("no scannable folders (podcasts libraries are feed-managed and cannot be scanned)")
}
watcher, err := fsnotify.NewWatcher() watcher, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
// Return an error instead of panicking so a failed watcher can't // Return an error instead of panicking so a failed watcher can't
@@ -621,17 +660,23 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
folderPaths[i] = f.FolderPath folderPaths[i] = f.FolderPath
} }
// The acting admin: the library owner, or the first admin as the
// documented fallback (existing pattern). Used BOTH for the job's
// UserID and — since RLS (plan §6) — as the identity the job's
// media reads run under via its Context.
adminIDStr := "" adminIDStr := ""
if libRow.CreatedByAdminID.Valid { adminUUID := libRow.CreatedByAdminID
adminIDStr = uuid.UUID(libRow.CreatedByAdminID.Bytes).String() if adminUUID.Valid {
adminIDStr = uuid.UUID(adminUUID.Bytes).String()
} }
if adminIDStr == "" { if !adminUUID.Valid {
fmt.Printf("[MTIME-POLL] Library has no owner, falling back to first admin\n") fmt.Printf("[MTIME-POLL] Library has no owner, falling back to first admin\n")
fallbackAdmin, err := s.db.GetFirstAdmin(context.Background()) fallbackAdmin, err := s.db.GetFirstAdmin(context.Background())
if err != nil { if err != nil {
fmt.Printf("[MTIME-POLL] Warning: no admin found in database, skipping scan\n") fmt.Printf("[MTIME-POLL] Warning: no admin found in database, skipping scan\n")
return return
} }
adminUUID = fallbackAdmin
adminIDStr = uuid.UUID(fallbackAdmin.Bytes).String() adminIDStr = uuid.UUID(fallbackAdmin.Bytes).String()
} }
@@ -650,7 +695,7 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
Type: JobTypeScan, Type: JobTypeScan,
Status: JobStatusPending, Status: JobStatusPending,
UserID: adminIDStr, UserID: adminIDStr,
Context: context.Background(), Context: rls.WithUser(context.Background(), adminUUID),
Params: map[string]any{ Params: map[string]any{
"library_id": libraryIDStr, "library_id": libraryIDStr,
"folders": folderPaths, "folders": folderPaths,
@@ -667,6 +712,13 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
} }
func (s *MediaScanner) ScanFolders(ctx context.Context) error { func (s *MediaScanner) ScanFolders(ctx context.Context) error {
// RLS plan §6: a scan is server maintenance over library media —
// self-declare the scanner's configured admin identity so internal
// reads work regardless of the caller's context (worker jobs carry
// one; test/direct callers may not). No-op when unconfigured.
if s.adminID.Valid {
ctx = rls.WithUser(ctx, s.adminID)
}
// Cancellation is checked at every stage boundary and inside the // Cancellation is checked at every stage boundary and inside the
// walk callback: the in-flight file finishes, then the scan aborts // walk callback: the in-flight file finishes, then the scan aborts
// and reports context.Canceled (processJob classifies that as a // and reports context.Canceled (processJob classifies that as a
@@ -4771,7 +4823,7 @@ func (s *MediaScanner) performInitialScan(ctx context.Context) {
Type: JobTypeScan, Type: JobTypeScan,
Status: JobStatusPending, Status: JobStatusPending,
UserID: adminIDStr, UserID: adminIDStr,
Context: context.Background(), Context: rls.WithUser(ctx, s.adminID), // RLS plan §6: startup scans run as the configured admin
Params: map[string]any{ Params: map[string]any{
"library_id": libraryIDStr, "library_id": libraryIDStr,
"folders": folderPaths, "folders": folderPaths,
+8
View File
@@ -110,6 +110,14 @@ func (w *MetadataWriter) WriteMetadataToFile(ctx context.Context, itemID uuid.UU
return nil, fmt.Errorf("media item not found: %w", err) return nil, fmt.Errorf("media item not found: %w", err)
} }
// Edit segregation (plan D10/D9, task 1.8): podcast episodes are
// feed-managed; writing their metadata into the file would be
// clobbered by the next download/refresh and violates the
// fetcher's sole-writer rule.
if item.Source == "podcast_episode" {
return nil, fmt.Errorf("podcast episodes are managed by their feed; metadata write-to-file is not available")
}
absPath, err := w.libraries.ResolveMediaPath(ctx, item.LibraryID, item.FilePath) absPath, err := w.libraries.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot resolve library folder: %w", err) return nil, fmt.Errorf("cannot resolve library folder: %w", err)
+206
View File
@@ -0,0 +1,206 @@
// SSRF-guarded HTTP fetching for the podcast fetcher (plan §7).
//
// This is the code path that turns USER-SUPPLIED URLs into server
// fetches — treat it as a security boundary, not a convenience wrapper:
//
// - https-only schemes (feed URL, enclosure, artwork, EVERY redirect hop)
// - private/loopback/link-local/multicast/unspecified IP ranges blocked
// at DIAL time via Dialer.Control — the check sees the RESOLVED
// address, so a DNS name that rebinding-resolves to 127.0.0.1 (or a
// redirect into the internal network) is refused at the socket, not
// merely at URL-parse time
// - hard response size caps on every body (feed XML, artwork, enclosures)
//
// Go's redirect follower re-dials through the same dialer per hop, so
// the IP guard re-applies automatically; the scheme guard re-applies
// via CheckRedirect.
package services
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"syscall"
"time"
)
// Response size caps (bytes). Feeds are XML; artwork is an image;
// enclosures are audio episodes — the enclosure cap is deliberately the
// large one (audiobook chapters run long) but still bounded.
const (
maxFeedSizeBytes = 25 << 20 // 25 MiB
maxArtworkSizeBytes = 20 << 20 // 20 MiB
maxEnclosureCapBytes = 1 << 30 // 1 GiB
)
// ErrBlockedPrivateAddress marks SSRF-guard rejections (dial-time IP or
// scheme checks). Handlers surface it as a validation failure, and the
// download worker records it as the download_error text.
var ErrBlockedPrivateAddress = errors.New("address blocked: private, loopback, or otherwise non-public")
// podcastHTTPClient builds the shared guard client. Timeout is generous
// (podcast servers are slow) but bounded; enclosures stream through a
// LimitReader so a lying Content-Length cannot exhaust disk.
func podcastHTTPClient() *http.Client {
dialer := &net.Dialer{
Timeout: 15 * time.Second,
Control: func(network, address string, _ syscall.RawConn) error {
return guardDialAddress(address)
},
}
transport := &http.Transport{
DialContext: dialer.DialContext,
TLSHandshakeTimeout: 15 * time.Second,
ResponseHeaderTimeout: 60 * time.Second,
// Enclosure downloads are long; idle conn reuse still applies.
IdleConnTimeout: 90 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
Proxy: nil, // never route through a user-controllable proxy
}
return &http.Client{
Transport: transport,
Timeout: 15 * time.Minute, // outer bound (biggest enclosures on slow lines)
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
if err := guardURLScheme(req.URL); err != nil {
return err
}
return nil
},
}
}
// guardDialAddress validates the RESOLVED host:port at dial time.
func guardDialAddress(address string) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("dial guard: %w", err)
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("dial guard: unparseable address %q", host)
}
if !isPublicIP(ip) {
return ErrBlockedPrivateAddress
}
return nil
}
// guardURLScheme enforces https on any URL we are about to touch.
func guardURLScheme(u *url.URL) error {
if u == nil || u.Scheme != "https" {
return fmt.Errorf("only https feed/enclosure URLs are supported")
}
return nil
}
// isPublicIP reports whether ip is a routable public unicast address.
// Everything loopback/private/link-local/multicast/unspecified —
// including IPv4-mapped IPv6 spellings of those — is rejected.
func isPublicIP(ip net.IP) bool {
if ip == nil {
return false
}
// Normalize IPv4-mapped IPv6 (::ffff:10.0.0.1) to its v4 form so
// the v4 range checks below actually fire.
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() || ip.IsUnspecified() {
return false
}
return true
}
// guardFetch performs a GET with the scheme/IP guards, conditional-GET
// validators, and a hard body cap. Returns the body bytes, the FINAL
// URL after redirects (new-feed-url / migration detection), validators
// to persist, and whether the server answered 304 Not Modified.
type fetchResult struct {
Body []byte
FinalURL string
ETag string
LastModified string
NotModified bool
StatusCode int
}
// podcastHTTPClientFactory is the indirection TESTS use: the SSRF suite
// exercises the REAL client; fetch-driven integration tests swap in a
// client that trusts httptest's loopback TLS cert. Never reassign
// outside tests (use SetPodcastHTTPClientForTests).
var podcastHTTPClientFactory = podcastHTTPClient
// SetPodcastHTTPClientForTests swaps the fetch client. nil restores the
// production client (SSRF guards intact).
func SetPodcastHTTPClientForTests(c *http.Client) {
if c == nil {
podcastHTTPClientFactory = podcastHTTPClient
return
}
podcastHTTPClientFactory = func() *http.Client { return c }
}
func guardFetch(ctx context.Context, rawURL, etag, lastModified string, maxSize int64) (*fetchResult, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
if err := guardURLScheme(u); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
// A recognizable UA; some podcast hosts 403 empty/default agents.
req.Header.Set("User-Agent", "Bookhoard/1.0 (podcast fetcher; +https://github.com/bookhoard)")
req.Header.Set("Accept", "*/*")
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
if lastModified != "" {
req.Header.Set("If-Modified-Since", lastModified)
}
resp, err := podcastHTTPClientFactory().Do(req)
if err != nil {
// Unwrap redirect/scheme guard errors so callers can classify.
return nil, err
}
defer func() { _ = resp.Body.Close() }()
res := &fetchResult{
FinalURL: resp.Request.URL.String(),
ETag: resp.Header.Get("ETag"),
LastModified: resp.Header.Get("Last-Modified"),
StatusCode: resp.StatusCode,
}
if resp.StatusCode == http.StatusNotModified {
res.NotModified = true
return res, nil
}
if resp.StatusCode != http.StatusOK {
return res, fmt.Errorf("fetch %s: HTTP %d", rawURL, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
if err != nil {
return res, fmt.Errorf("fetch %s: %w", rawURL, err)
}
if int64(len(body)) > maxSize {
return res, fmt.Errorf("fetch %s: response exceeds %d byte cap", rawURL, maxSize)
}
res.Body = body
return res, nil
}
+78
View File
@@ -0,0 +1,78 @@
// Podcast feed poller (plan §7 task 1.6): one periodic worker ticking
// on the podcast_poll_interval_seconds system setting (re-read each
// cycle — the settings API path keeps the registry cache honest),
// polling every non-complete feed and applying subscriber auto-download
// policies after each successful fetch (D6).
//
// RLS: detached work under the first-admin identity — the service's
// adminCtx supplies it (invariant 12). Back-off: itunes:complete feeds
// are skipped by the ticker (explicit refresh still works).
package services
import (
"context"
"log"
"time"
"bookhoard/internal/database"
)
type PodcastPoller struct {
db *database.Queries
svc *PodcastService
worker *PodcastDownloadWorker
settings *database.SettingsRegistry
}
func NewPodcastPoller(db *database.Queries, svc *PodcastService, worker *PodcastDownloadWorker, settings *database.SettingsRegistry) *PodcastPoller {
return &PodcastPoller{db: db, svc: svc, worker: worker, settings: settings}
}
// Start launches the poll loop; exits when ctx is cancelled.
func (p *PodcastPoller) Start(ctx context.Context) {
go func() {
for {
interval := p.settings.PodcastPollInterval()
if interval < time.Minute {
interval = time.Minute // floor against bad settings rows
}
select {
case <-ctx.Done():
return
case <-time.After(interval):
}
p.tick(ctx)
}
}()
}
// tick polls every non-complete feed once.
func (p *PodcastPoller) tick(ctx context.Context) {
actx, err := p.svc.adminCtx(ctx)
if err != nil {
log.Printf("[PODCAST-POLL] no acting identity, skipping tick: %v", err)
return
}
feeds, err := p.db.ListPodcastFeedsForPolling(actx)
if err != nil {
log.Printf("[PODCAST-POLL] worklist: %v", err)
return
}
for _, feed := range feeds {
if ctx.Err() != nil {
return
}
if feed.IsComplete {
continue // back off ended feeds (explicit refresh still works)
}
if err := p.svc.PollFeed(actx, &feed); err != nil {
log.Printf("[PODCAST-POLL] %s: %v", feed.Title, err)
continue
}
// Fresh episodes → apply the subscriber policy union (auto-
// download without waiting for the next tick).
if _, err := p.worker.ApplyAutoPolicies(actx, feed.ID); err != nil {
log.Printf("[PODCAST-POLL] auto-download policies for %s: %v", feed.Title, err)
}
}
}
+560
View File
@@ -0,0 +1,560 @@
// RSS 2.0 + iTunes + Podcasting-2.0 feed parsing for the podcast
// fetcher (plan podcast-integration.md §5/§7). Stdlib encoding/xml only
// — no new deps.
//
// Namespace strategy: iTunes fields MUST be namespace-qualified in the
// struct tags (the itunes URI) because their local names collide with
// core RSS ("title", "subtitle", "author", "summary", "explicit",
// "image", "duration"). Podcasting-2.0 fields (podcast:guid,
// podcast:transcript, ...) have collision-free local names, so they
// match on local name — encoding/xml matches Name.Local when the tag
// carries no namespace, which also tolerates both declared namespace
// URIs the ecosystem uses.
//
// Out of scope at parse time (plan §5): podcast:value, liveItem,
// soundbite, alternateEnclosure, socialInteract, remoteItem/podroll,
// locked/block, txt, chat. lastBuildDate/ttl are poll hints only.
package services
import (
"bytes"
"encoding/xml"
"fmt"
"strconv"
"strings"
"time"
)
// itunesNS is the canonical iTunes podcast namespace URI.
const itunesNS = "http://www.itunes.com/dtds/podcast-1.0.dtd"
// PodcastFeedXML is one parsed RSS document.
type PodcastFeedXML struct {
XMLName xml.Name `xml:"rss"`
Channel PodcastChannelXML `xml:"channel"`
}
type PodcastChannelXML struct {
Title string `xml:"title"`
Link string `xml:"link"`
Language string `xml:"language"`
Description string `xml:"description"`
// iTunes channel namespace.
ITunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
ITunesOwner struct {
Name string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd name"`
Email string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd email"`
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd owner"`
ITunesImage struct {
Href string `xml:"href,attr"`
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
ITunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
ITunesType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd type"`
ITunesCategory struct {
Text string `xml:"text,attr"`
Subcategory struct {
Text string `xml:"text,attr"`
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
ITunesNewFeedURL string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd new-feed-url"`
ITunesComplete string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd complete"`
// Podcasting 2.0 (matched on local name; see file comment).
PodcastGUID string `xml:"guid"`
PodcastMedium string `xml:"medium"`
LastBuildDate string `xml:"lastBuildDate"`
Items []PodcastItemXML `xml:"item"`
}
type PodcastItemXML struct {
GUID string `xml:"guid"`
Title string `xml:"title"`
ITunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
Subtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd subtitle"`
// Description resolution order (plan §5): description >
// itunes:summary > content:encoded.
Description string `xml:"description"`
ITunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
ContentEncoded string `xml:"encoded"`
// Item author: itunes:author preferred, then RSS author, then dc:creator.
ITunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
Author string `xml:"author"`
Creator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
ITunesImage struct {
Href string `xml:"href,attr"`
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
ITunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
ITunesDuration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
ITunesEpisode string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episode"`
ITunesSeason string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd season"`
PubDate string `xml:"pubDate"`
Enclosure struct {
URL string `xml:"url,attr"`
Length int64 `xml:"length,attr"`
Type string `xml:"type,attr"`
} `xml:"enclosure"`
// Podcasting 2.0: store-now, feature-later URLs.
Transcript struct {
URL string `xml:"url,attr"`
} `xml:"transcript"`
Chapters struct {
URL string `xml:"url,attr"`
} `xml:"chapters"`
Persons []struct {
Href string `xml:"href,attr"`
Role string `xml:"role,attr"`
} `xml:"person"`
}
// ParsePodcastFeed decodes a feed document via a namespace-aware token
// decoder (see decodePodcastFeed). Returns an error when the XML is
// unparseable or the document is not an RSS channel.
func ParsePodcastFeed(data []byte) (*PodcastFeedXML, error) {
feed, err := decodePodcastFeed(data)
if err != nil {
return nil, fmt.Errorf("feed parse: %w", err)
}
if feed.Channel.Title == "" && len(feed.Channel.Items) == 0 {
return nil, fmt.Errorf("feed parse: not an RSS channel (no title, no items)")
}
return feed, nil
}
// EpisodeTitle prefers itunes:title over the raw RSS title (plan §5).
func (it *PodcastItemXML) EpisodeTitle() string {
if t := strings.TrimSpace(it.ITunesTitle); t != "" {
return t
}
return strings.TrimSpace(it.Title)
}
// EpisodeDescription resolves the description fallback chain.
func (it *PodcastItemXML) EpisodeDescription() string {
for _, c := range []string{it.Description, it.ITunesSummary, it.ContentEncoded} {
if t := strings.TrimSpace(c); t != "" {
return t
}
}
return ""
}
// EpisodeAuthor resolves the author fallback chain.
func (it *PodcastItemXML) EpisodeAuthor() string {
for _, c := range []string{it.ITunesAuthor, it.Author, it.Creator} {
if t := strings.TrimSpace(c); t != "" {
return t
}
}
return ""
}
// EpisodeExplicit maps the itunes:explicit spelling variants to bool.
func (it *PodcastItemXML) EpisodeExplicit() bool {
return parseExplicit(it.ITunesExplicit)
}
func parseExplicit(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "yes", "explicit", "1":
return true
}
return false
}
// HostsAndGuests splits podcast:person entries by role (plan §5).
func (it *PodcastItemXML) HostsAndGuests() (hosts []string, guests []string) {
for _, p := range it.Persons {
name := p.Href
if name == "" {
continue
}
switch strings.ToLower(p.Role) {
case "guest":
guests = append(guests, name)
default: // host and unlabeled roles surface as hosts
hosts = append(hosts, name)
}
}
return hosts, guests
}
// parseITunesDuration accepts the three spellings the spec allows —
// plain seconds, HH:MM:SS, MM:SS — plus a best-effort prose fallback
// ("58 minutes"). Returns 0 for anything unparseable (the column stays
// NULL; ffprobe reconciles after download anyway).
func parseITunesDuration(v string) int {
v = strings.TrimSpace(v)
if v == "" {
return 0
}
if secs, err := strconv.Atoi(v); err == nil {
return secs
}
parts := strings.Split(v, ":")
if len(parts) <= 3 {
total := 0
ok := true
for _, p := range parts {
n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
ok = false
break
}
total = total*60 + n
}
if ok {
return total
}
}
// Prose best-effort: "<N> minutes/hours/seconds".
lower := strings.ToLower(v)
fields := strings.Fields(lower)
for i, f := range fields {
if n, err := strconv.Atoi(f); err == nil && i+1 < len(fields) {
switch {
case strings.HasPrefix(fields[i+1], "hour"):
return n * 3600
case strings.HasPrefix(fields[i+1], "minute"):
return n * 60
case strings.HasPrefix(fields[i+1], "second"):
return n
}
}
}
return 0
}
// parseFeedTime parses the RFC-822/1123 variants feeds actually emit.
func parseFeedTime(v string) (t time.Time, ok bool) {
v = strings.TrimSpace(v)
if v == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC1123Z,
time.RFC1123,
time.RFC822Z,
time.RFC822,
"Mon, 2 Jan 2006 15:04:05 -0700",
"Mon, 2 Jan 2006 15:04:05 MST",
"2 Jan 2006 15:04:05 -0700",
"2 Jan 2006 15:04:05 MST",
time.ANSIC,
time.UnixDate,
}
for _, layout := range layouts {
if t, err := time.Parse(layout, v); err == nil {
return t, true
}
}
return time.Time{}, false
}
// ============================================================================
// Namespace-aware decoding.
//
// WHY NOT encoding/xml: its field matching for UNQUALIFIED tags ignores
// namespaces — a field tagged `xml:"title"` also matches
// `<itunes:title>` (first field in declaration order wins, document
// order clobbers), so the two collide unpredictably. RSS feeds stack
// core RSS, iTunes and Podcasting-2.0 namespaces over the SAME local
// names (title, author, description, guid), so a token-loop decoder
// dispatching on (namespace, local) is the only deterministic shape.
// It also tolerates BOTH podcast-namespace URIs the ecosystem uses and
// arbitrary prefix spellings, because matching is on resolved
// namespace, never on the prefix string.
// ============================================================================
// decodePodcastFeed runs the token loop and fills the document struct.
func decodePodcastFeed(data []byte) (*PodcastFeedXML, error) {
dec := xml.NewDecoder(bytes.NewReader(data))
dec.Strict = false // feeds in the wild carry stray entities; be liberal
feed := &PodcastFeedXML{}
// Find the <rss> start element (tolerate comments/PIs/doctype).
for {
tok, err := dec.Token()
if err != nil {
return nil, err
}
if se, ok := tok.(xml.StartElement); ok {
if se.Name.Local == "rss" {
break
}
// Some feeds wrap in nothing else; only rss is accepted.
return nil, fmt.Errorf("unexpected root element %q", se.Name.Local)
}
}
for {
tok, err := dec.Token()
if err != nil {
return nil, err
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Local == "channel" {
if err := decodeChannel(dec, &feed.Channel); err != nil {
return nil, err
}
continue
}
if err := skipElement(dec); err != nil {
return nil, err
}
case xml.EndElement:
// </rss> — done.
return feed, nil
}
}
}
// decodeChannel walks the channel's children.
func decodeChannel(dec *xml.Decoder, ch *PodcastChannelXML) error {
for {
tok, err := dec.Token()
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
ns, local := t.Name.Space, t.Name.Local
switch {
case ns == "" && local == "title":
ch.Title, err = elementText(dec)
case ns == "" && local == "description":
ch.Description, err = elementText(dec)
case ns == "" && local == "language":
ch.Language, err = elementText(dec)
case ns == "" && local == "link":
ch.Link, err = elementText(dec)
case ns == "" && local == "lastBuildDate":
ch.LastBuildDate, err = elementText(dec)
case ns == itunesNS && local == "author":
ch.ITunesAuthor, err = elementText(dec)
case ns == itunesNS && local == "owner":
err = decodeITunesOwner(dec, ch)
case ns == itunesNS && local == "image":
ch.ITunesImage.Href = attrValue(t.Attr, "href")
err = skipElement(dec)
case ns == itunesNS && local == "explicit":
ch.ITunesExplicit, err = elementText(dec)
case ns == itunesNS && local == "type":
ch.ITunesType, err = elementText(dec)
case ns == itunesNS && local == "new-feed-url":
ch.ITunesNewFeedURL, err = elementText(dec)
case ns == itunesNS && local == "complete":
ch.ITunesComplete, err = elementText(dec)
case ns == itunesNS && local == "category":
ch.ITunesCategory.Text = attrValue(t.Attr, "text")
err = decodeITunesCategory(dec, ch)
case ns != "" && local == "guid":
// podcast:guid (either declared URI); core RSS has no
// channel-level guid, so any namespaced guid is it.
ch.PodcastGUID, err = elementText(dec)
case ns != "" && local == "medium":
ch.PodcastMedium, err = elementText(dec)
case ns == "" && local == "item":
var item PodcastItemXML
if err := decodeItem(dec, &item); err != nil {
return err
}
ch.Items = append(ch.Items, item)
continue
default:
err = skipElement(dec)
}
if err != nil {
return err
}
case xml.EndElement:
return nil // </channel>
}
}
}
func decodeITunesOwner(dec *xml.Decoder, ch *PodcastChannelXML) error {
for {
tok, err := dec.Token()
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Space == itunesNS && t.Name.Local == "name" {
if ch.ITunesOwner.Name, err = elementText(dec); err != nil {
return err
}
continue
}
if t.Name.Space == itunesNS && t.Name.Local == "email" {
if ch.ITunesOwner.Email, err = elementText(dec); err != nil {
return err
}
continue
}
if err := skipElement(dec); err != nil {
return err
}
case xml.EndElement:
return nil
}
}
}
func decodeITunesCategory(dec *xml.Decoder, ch *PodcastChannelXML) error {
for {
tok, err := dec.Token()
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Space == itunesNS && t.Name.Local == "category" {
ch.ITunesCategory.Subcategory.Text = attrValue(t.Attr, "text")
}
if err := skipElement(dec); err != nil {
return err
}
case xml.EndElement:
return nil
}
}
}
// decodeItem walks one <item>.
func decodeItem(dec *xml.Decoder, it *PodcastItemXML) error {
for {
tok, err := dec.Token()
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
ns, local := t.Name.Space, t.Name.Local
switch {
case ns == "" && local == "guid":
it.GUID, err = elementText(dec)
case ns == "" && local == "title":
it.Title, err = elementText(dec)
case ns == "" && local == "description":
it.Description, err = elementText(dec)
case ns == "" && local == "pubDate":
it.PubDate, err = elementText(dec)
case ns == "" && local == "author":
it.Author, err = elementText(dec)
case ns == "" && local == "enclosure":
it.Enclosure.URL = attrValue(t.Attr, "url")
it.Enclosure.Type = attrValue(t.Attr, "type")
if raw := attrValue(t.Attr, "length"); raw != "" {
it.Enclosure.Length, _ = strconv.ParseInt(raw, 10, 64)
}
err = skipElement(dec)
case ns == itunesNS && local == "title":
it.ITunesTitle, err = elementText(dec)
case ns == itunesNS && local == "subtitle":
it.Subtitle, err = elementText(dec)
case ns == itunesNS && local == "summary":
it.ITunesSummary, err = elementText(dec)
case ns == itunesNS && local == "author":
it.ITunesAuthor, err = elementText(dec)
case ns == itunesNS && local == "explicit":
it.ITunesExplicit, err = elementText(dec)
case ns == itunesNS && local == "duration":
it.ITunesDuration, err = elementText(dec)
case ns == itunesNS && local == "episode":
it.ITunesEpisode, err = elementText(dec)
case ns == itunesNS && local == "season":
it.ITunesSeason, err = elementText(dec)
case ns == itunesNS && local == "image":
it.ITunesImage.Href = attrValue(t.Attr, "href")
err = skipElement(dec)
case ns == "http://purl.org/dc/elements/1.1/" && local == "creator":
it.Creator, err = elementText(dec)
case ns == "http://purl.org/rss/1.0/modules/content/" && local == "encoded":
it.ContentEncoded, err = elementText(dec)
case ns != "" && local == "transcript":
it.Transcript.URL = attrValue(t.Attr, "url")
err = skipElement(dec)
case ns != "" && local == "chapters":
it.Chapters.URL = attrValue(t.Attr, "url")
err = skipElement(dec)
case ns != "" && local == "person":
href, role := attrValue(t.Attr, "href"), attrValue(t.Attr, "role")
it.Persons = append(it.Persons, struct {
Href string `xml:"href,attr"`
Role string `xml:"role,attr"`
}{Href: href, Role: role})
err = skipElement(dec)
default:
err = skipElement(dec)
}
if err != nil {
return err
}
case xml.EndElement:
return nil // </item>
}
}
}
// elementText accumulates chardata until this element closes (leaf-ish
// elements: CDATA sections come through as xml.CharData and are
// included; child elements are skipped).
func elementText(dec *xml.Decoder) (string, error) {
var sb strings.Builder
depth := 1
for depth > 0 {
tok, err := dec.Token()
if err != nil {
return "", err
}
switch t := tok.(type) {
case xml.StartElement:
depth++
case xml.EndElement:
depth--
case xml.CharData:
if depth == 1 {
sb.Write(t)
}
}
}
return strings.TrimSpace(sb.String()), nil
}
// skipElement consumes an element we don't model (depth-safe).
func skipElement(dec *xml.Decoder) error {
depth := 1
for depth > 0 {
tok, err := dec.Token()
if err != nil {
return err
}
switch tok.(type) {
case xml.StartElement:
depth++
case xml.EndElement:
depth--
}
}
return nil
}
// attrValue returns the named attribute (local-name match; the
// attributes we read carry no namespace).
func attrValue(attrs []xml.Attr, local string) string {
for _, a := range attrs {
if a.Name.Local == local {
return strings.TrimSpace(a.Value)
}
}
return ""
}
+303
View File
@@ -0,0 +1,303 @@
// Unit tests for the podcast fetcher's pure logic (plan task 1.4/1.10):
// RSS/iTunes/Podcasting-2.0 parsing, duration/pubDate spellings, slug/
// path layout, and the SSRF guard (scheme + resolved-IP checks). No DB.
package services
import (
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// pgUUID alias keeps the helper signatures short.
type pgUUID = pgtype.UUID
// Full-fixture feed covering the three namespaces and the fallback
// chains (itunes:title vs title; description vs itunes:summary vs
// content:encoded; itunes:author vs author vs dc:creator).
const podcastFixtureXML = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>The Test Feed</title>
<link>https://example.com/show</link>
<language>en-us</language>
<description>&lt;p&gt;A feed about &lt;b&gt;testing&lt;/b&gt;.&lt;/p&gt;</description>
<itunes:author>Host Name</itunes:author>
<itunes:owner>
<itunes:name>Owner Name</itunes:name>
<itunes:email>owner@example.com</itunes:email>
</itunes:owner>
<itunes:image href="https://cdn.example.com/art.jpg"/>
<itunes:explicit>false</itunes:explicit>
<itunes:type>serial</itunes:type>
<itunes:category text="Fiction">
<itunes:category text="Drama"/>
</itunes:category>
<podcast:guid>9b0241c2-xxxx-4xxx-yxxx-xxxxxxxxxxxx</podcast:guid>
<podcast:medium>audiobook</podcast:medium>
<lastBuildDate>Mon, 26 Oct 2026 08:00:00 +0000</lastBuildDate>
<item>
<guid isPermaLink="false">episode-guid-1</guid>
<itunes:title>Episode One: The Beginning</itunes:title>
<title>Ep 1</title>
<itunes:subtitle>First chapter</itunes:subtitle>
<description><![CDATA[<p>Chapter one text.</p>]]></description>
<itunes:author>Narrator A</itunes:author>
<itunes:duration>1:02:03</itunes:duration>
<itunes:episode>1</itunes:episode>
<itunes:season>2</itunes:season>
<pubDate>Sun, 25 Oct 2026 12:00:00 +0000</pubDate>
<enclosure url="https://cdn.example.com/ep1.mp3" length="52428800" type="audio/mpeg"/>
<podcast:transcript url="https://cdn.example.com/ep1.json" type="application/json"/>
<podcast:chapters url="https://cdn.example.com/ep1-chapters.json" type="application/json+chapters"/>
<podcast:person href="Narrator A" role="host"/>
<podcast:person href="Guest B" role="guest"/>
</item>
<item>
<guid>episode-guid-2</guid>
<title>Episode Two</title>
<itunes:summary>Summary only description.</itunes:summary>
<dc:creator>Writer C</dc:creator>
<itunes:duration>5400</itunes:duration>
<pubDate>Mon, 26 Oct 2026 12:00:00 GMT</pubDate>
<enclosure url="https://cdn.example.com/ep2.m4a" length="1048576" type="audio/mp4"/>
</item>
</channel>
</rss>`
func TestParsePodcastFeed(t *testing.T) {
feed, err := ParsePodcastFeed([]byte(podcastFixtureXML))
require.NoError(t, err)
ch := feed.Channel
assert.Equal(t, "The Test Feed", ch.Title)
assert.Equal(t, "en-us", ch.Language)
assert.Equal(t, "Host Name", ch.ITunesAuthor)
assert.Equal(t, "Owner Name", ch.ITunesOwner.Name)
assert.Equal(t, "owner@example.com", ch.ITunesOwner.Email)
assert.Equal(t, "https://cdn.example.com/art.jpg", ch.ITunesImage.Href)
assert.Equal(t, "serial", ch.ITunesType)
assert.Equal(t, "Fiction", ch.ITunesCategory.Text)
assert.Equal(t, "Drama", ch.ITunesCategory.Subcategory.Text)
assert.Equal(t, "9b0241c2-xxxx-4xxx-yxxx-xxxxxxxxxxxx", ch.PodcastGUID)
assert.Equal(t, "audiobook", ch.PodcastMedium)
assert.False(t, parseExplicit(ch.ITunesExplicit))
buildAt, ok := parseFeedTime(ch.LastBuildDate)
require.True(t, ok)
assert.Equal(t, 2026, buildAt.Year())
require.Len(t, ch.Items, 2)
// Item 1: itunes:title preferred, description chain, hosts/guests.
it1 := &ch.Items[0]
assert.Equal(t, "Episode One: The Beginning", it1.EpisodeTitle())
assert.Equal(t, "First chapter", it1.Subtitle)
assert.Equal(t, "<p>Chapter one text.</p>", it1.EpisodeDescription()) // raw HTML; stripping happens at storage time
assert.Equal(t, "Narrator A", it1.EpisodeAuthor())
assert.Equal(t, 3723, parseITunesDuration(it1.ITunesDuration)) // 1:02:03
assert.Equal(t, 2, intMust(it1.ITunesSeason))
assert.Equal(t, 1, intMust(it1.ITunesEpisode))
assert.Equal(t, "https://cdn.example.com/ep1.mp3", it1.Enclosure.URL)
assert.Equal(t, int64(52428800), it1.Enclosure.Length)
assert.Equal(t, "audio/mpeg", it1.Enclosure.Type)
assert.Equal(t, "https://cdn.example.com/ep1.json", it1.Transcript.URL)
assert.Equal(t, "https://cdn.example.com/ep1-chapters.json", it1.Chapters.URL)
hosts, guests := it1.HostsAndGuests()
assert.Equal(t, []string{"Narrator A"}, hosts)
assert.Equal(t, []string{"Guest B"}, guests)
assert.Equal(t, "2026-10-25", mustDate(it1.PubDate))
// Item 2: fallback chains — plain title, itunes:summary, dc:creator.
it2 := &ch.Items[1]
assert.Equal(t, "Episode Two", it2.EpisodeTitle())
assert.Equal(t, "Summary only description.", it2.EpisodeDescription())
assert.Equal(t, "Writer C", it2.EpisodeAuthor())
assert.Equal(t, 5400, parseITunesDuration(it2.ITunesDuration)) // seconds
assert.Equal(t, "https://cdn.example.com/ep2.m4a", it2.Enclosure.URL)
}
func intMust(s string) int {
n, _ := parseSeasonOrEpisode(s)
return n
}
func parseSeasonOrEpisode(s string) (int, bool) {
if s == "" {
return 0, false
}
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return 0, false
}
n = n*10 + int(r-'0')
}
return n, true
}
func mustDate(pubDate string) string {
if t, ok := parseFeedTime(pubDate); ok {
return t.UTC().Format("2006-01-02")
}
return ""
}
func TestParseITunesDuration(t *testing.T) {
assert.Equal(t, 0, parseITunesDuration(""))
assert.Equal(t, 90, parseITunesDuration("90"))
assert.Equal(t, 3723, parseITunesDuration("1:02:03"))
assert.Equal(t, 123, parseITunesDuration("2:03"))
assert.Equal(t, 3600, parseITunesDuration("1 hour"))
assert.Equal(t, 120, parseITunesDuration("2 minutes"))
assert.Equal(t, 0, parseITunesDuration("soonish"))
}
func TestParseFeedTimeVariants(t *testing.T) {
for _, tc := range []struct {
in string
year int
}{
{"Mon, 26 Oct 2026 08:00:00 +0000", 2026},
{"Mon, 26 Oct 2026 08:00:00 GMT", 2026},
{"26 Oct 2026 08:00:00 +0000", 2026},
} {
tm, ok := parseFeedTime(tc.in)
require.True(t, ok, "parse %q", tc.in)
assert.Equal(t, tc.year, tm.Year())
}
_, ok := parseFeedTime("not a date")
assert.False(t, ok)
}
func TestEpisodeExplicitSpellings(t *testing.T) {
assert.True(t, parseExplicit("true"))
assert.True(t, parseExplicit("yes"))
assert.True(t, parseExplicit("explicit"))
assert.False(t, parseExplicit("false"))
assert.False(t, parseExplicit("clean"))
assert.False(t, parseExplicit(""))
}
func TestSlugifyAndPaths(t *testing.T) {
assert.Equal(t, "the-test-feed", slugify("The Test Feed!", 60))
assert.Equal(t, "a-b-c", slugify(" A -- B // C ", 60))
assert.Equal(t, "abcdefghij", slugify("ABCDEFGHIJ", 10))
slug := feedSlug("My Feed", testUUID("11111111-1111-1111-1111-111111111111"))
assert.Equal(t, "my-feed-11111111", slug)
rel := episodeRelPath(slug, 42, "Episode: The Test", ".mp3")
assert.Equal(t, "my-feed-11111111/0042-episode-the-test.mp3", rel)
assert.Equal(t, "my-feed-11111111/artwork.jpg", artworkRelPath(slug, ".jpg"))
}
func TestEnclosureExtension(t *testing.T) {
assert.Equal(t, ".mp3", enclosureExtension("https://x.test/a/b/ep.mp3?token=1", ""))
assert.Equal(t, ".m4a", enclosureExtension("https://x.test/ep", "audio/mp4"))
assert.Equal(t, ".flac", enclosureExtension("https://x.test/ep", "audio/flac"))
assert.Equal(t, ".mp3", enclosureExtension("https://x.test/ep", "weird/type"))
}
func TestStripHTML(t *testing.T) {
assert.Equal(t, "hello world", stripHTML("<p>hello <b>world</b></p>"))
assert.Equal(t, "a b", stripHTML("a\n\n b"))
assert.Equal(t, "", stripHTML(""))
}
func TestIsPublicIP(t *testing.T) {
blocked := []string{
"127.0.0.1", "10.0.0.5", "172.16.0.9", "192.168.1.1",
"169.254.169.254", // cloud metadata endpoint
"0.0.0.0", "::1", "fe80::1", "fd00::5", "::ffff:127.0.0.1",
"::ffff:10.0.0.1", "224.0.0.1",
}
for _, s := range blocked {
assert.False(t, isPublicIP(net.ParseIP(s)), "%s must be blocked", s)
}
allowed := []string{
"93.184.216.34", "1.1.1.1", "2606:4700:4700::1111",
}
for _, s := range allowed {
assert.True(t, isPublicIP(net.ParseIP(s)), "%s must be allowed", s)
}
assert.False(t, isPublicIP(nil))
}
func TestGuardURLScheme(t *testing.T) {
u, _ := url.Parse("http://example.com/feed.xml")
assert.Error(t, guardURLScheme(u))
u, _ = url.Parse("ftp://example.com/feed.xml")
assert.Error(t, guardURLScheme(u))
u, _ = url.Parse("https://example.com/feed.xml")
assert.NoError(t, guardURLScheme(u))
assert.Error(t, guardURLScheme(nil))
}
// The REAL client (production factory) must refuse loopback targets —
// this is the SSRF boundary; httptest servers live on 127.0.0.1.
func TestGuardFetchBlocksLoopback(t *testing.T) {
srv := newInsecureFeedServer(t, podcastFixtureXML)
defer srv.Close()
_, err := guardFetch(t.Context(), srv.URL, "", "", maxFeedSizeBytes)
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "blocked") ||
strings.Contains(err.Error(), "https"), "expected an SSRF/scheme rejection, got: %v", err)
}
func TestGuardFetchRejectsPlainHTTP(t *testing.T) {
// A public-looking http:// URL: scheme guard fires before any dial.
_, err := guardFetch(t.Context(), "http://example.com/feed.xml", "", "", maxFeedSizeBytes)
require.Error(t, err)
assert.Contains(t, err.Error(), "https")
}
func TestClassifyFetchError(t *testing.T) {
err := classifyFetchError(ErrBlockedPrivateAddress)
assert.Contains(t, err.Error(), "blocked")
someErr := &url.Error{Op: "Get", Err: errors.New("boom")}
assert.Equal(t, someErr.Error(), classifyFetchError(someErr).Error())
}
// testUUID builds a pgtype.UUID from a string (test helper).
func testUUID(s string) pgUUID {
u, _ := uuid.Parse(s)
return pgUUID{Bytes: u, Valid: true}
}
// newInsecureFeedServer serves body over httptest TLS (loopback) —
// used to prove the production client REFUSES it.
func newInsecureFeedServer(t *testing.T, body string) *httptest.Server {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
// Apple Podcast launcher schemes normalize to the https feed they wrap.
func TestNormalizeFeedURL(t *testing.T) {
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("itpc://x.test/feed.xml"))
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("podcasts://x.test/feed.xml"))
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("feed://x.test/feed.xml"))
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("ITPC://x.test/feed.xml"), "scheme match is case-insensitive")
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("https://x.test/feed.xml"))
assert.Equal(t, "http://x.test/feed.xml", normalizeFeedURL("http://x.test/feed.xml"), "http stays — the guard rejects it downstream")
assert.Equal(t, "", normalizeFeedURL(" "))
}
+785
View File
@@ -0,0 +1,785 @@
// Podcast fetcher (plan podcast-integration.md §7): fetch → parse →
// upsert-without-delete. The fetcher is the SOLE writer of feed/episode
// metadata (D9) — user-facing podcast writes are limited to
// podcast_subscriptions rows and the reader_settings order override.
//
// RLS identity (invariant 12): fetches run detached from any request,
// so every governed statement runs under the FIRST-ADMIN identity
// (scanner fallback precedent). With the D20 admin escape that identity
// sees and updates every feed/episode row. NEVER a bypass flag, never
// the bootstrap superuser.
//
// Upsert semantics: episodes are created/updated METADATA-ONLY, keyed
// on (source_feed_id, episode_guid); rows for items that vanish from
// the XML are NEVER deleted (history accumulates — D14 search covers
// gone episodes). Download state is owned by the download worker; the
// upsert cannot clobber it (guarded in the query itself).
package services
import (
"context"
"errors"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"bookhoard/internal/database"
"bookhoard/internal/rls"
"github.com/jackc/pgx/v5/pgtype"
)
// PodcastError is a status-carrying service error: handlers map it to
// the HTTP response directly (services stay echo-free).
type PodcastError struct {
Status int
Message string
}
func (e *PodcastError) Error() string { return e.Message }
func errNotFound(msg string) error { return &PodcastError{Status: 404, Message: msg} }
func errForbidden(msg string) error { return &PodcastError{Status: 403, Message: msg} }
func errBadRequest(msg string) error { return &PodcastError{Status: 400, Message: msg} }
// PodcastService carries the fetcher/worker dependencies. One instance
// per server (handlers + poller share it so the per-feed locks dedupe).
type PodcastService struct {
db *database.Queries
adminMu sync.Mutex
adminID pgtype.UUID
adminOnce bool
// Per-feed mutexes: a poll and a download for the same feed must
// not race (e.g. both writing the episode row). Keyed by feed UUID.
feedLocksMu sync.Mutex
feedLocks map[string]*sync.Mutex
}
func NewPodcastService(db *database.Queries) *PodcastService {
return &PodcastService{
db: db,
feedLocks: make(map[string]*sync.Mutex),
}
}
// feedLock returns the per-feed mutex (creates on first use).
func (s *PodcastService) feedLock(feedID string) *sync.Mutex {
s.feedLocksMu.Lock()
defer s.feedLocksMu.Unlock()
if m, ok := s.feedLocks[feedID]; ok {
return m
}
m := &sync.Mutex{}
s.feedLocks[feedID] = m
return m
}
// adminIdentity resolves the acting identity for detached podcast work:
// the first admin, resolved once per service lifetime. Errors surface
// to the caller (no admin → no fetch — same contract as the scanner).
func (s *PodcastService) adminIdentity() (pgtype.UUID, error) {
s.adminMu.Lock()
defer s.adminMu.Unlock()
if !s.adminOnce && !s.adminID.Valid {
id, err := s.db.GetFirstAdmin(context.Background())
if err != nil {
return pgtype.UUID{}, fmt.Errorf("podcasts: no admin identity available for detached work: %w", err)
}
s.adminID = id
s.adminOnce = true
}
if !s.adminID.Valid {
return pgtype.UUID{}, fmt.Errorf("podcasts: no admin identity available for detached work")
}
return s.adminID, nil
}
// adminCtx wraps ctx with the first-admin identity (invariant 12).
func (s *PodcastService) adminCtx(ctx context.Context) (context.Context, error) {
id, err := s.adminIdentity()
if err != nil {
return nil, err
}
return rls.WithUser(ctx, id), nil
}
// AddFeedResult carries what the add-feed flow did (the handler turns
// it into the response / serial feed hint).
type AddFeedResult struct {
Feed *database.PodcastFeeds
// Created is true when this call created the feed row; false when
// an existing row was reused (URL dedupe or guid migration match).
Created bool
// Subscribed is true when the requesting user's subscription was
// created by this call (false = already subscribed).
Subscribed bool
// EpisodeCount is the number of episode rows the fetch upserted.
EpisodeCount int
// FetchError, when set, means the feed row exists but the fetch
// failed — the subscription stands, the error surfaces to the user.
FetchError string
}
// FeedError returns the fetch failure text ("" when the fetch was fine
// or there is no feed row).
func (r *AddFeedResult) FeedError() string {
return r.FetchError
}
// AddFeed implements POST /api/podcasts/feeds (plan §8): fetch+parse the
// user-supplied URL, create-or-reuse the feed row, upsert metadata-only
// episodes, subscribe the creator. Authorization on explicit arguments
// (D20 scoping model): the target library must be visible to the actor
// and, for non-admins, allow member feeds.
func (s *PodcastService) AddFeed(ctx context.Context, userID pgtype.UUID, isAdmin bool, libraryID pgtype.UUID, rawFeedURL string) (*AddFeedResult, error) {
// ---- explicit-argument authorization (the ONLY Go-level gate) ----
lib, err := s.db.GetLibrary(ctx, libraryID)
if err != nil {
// Invisible-or-nonexistent: 404 semantics (existence leak).
return nil, errNotFound("library not found")
}
if !isAdmin {
if lib.TypeName != LibraryTypePodcasts {
return nil, errNotFound("library not found")
}
if !lib.AllowMemberFeeds {
return nil, errForbidden("this library does not allow members to add feeds")
}
}
feedURL := normalizeFeedURL(rawFeedURL)
if feedURL == "" {
return nil, errBadRequest("feed URL is required")
}
// The fetch/upsert runs under the first-admin identity (detached-
// equivalent); the request context never carries special powers into
// feed writes.
actx, err := s.adminCtx(ctx)
if err != nil {
return nil, err
}
res := &AddFeedResult{}
// ---- fetch + parse (SSRF-guarded; plan §7) ----
fetched, err := guardFetch(ctx, feedURL, "", "", maxFeedSizeBytes)
if err != nil {
return nil, fmt.Errorf("could not fetch feed: %w", classifyFetchError(err))
}
parsed, err := ParsePodcastFeed(fetched.Body)
if err != nil {
return nil, fmt.Errorf("could not parse feed: %w", err)
}
// Follow itunes:new-feed-url once at add time so the row is born
// pointing at the publisher's current home (migration semantics).
finalURL := fetched.FinalURL
if neu := strings.TrimSpace(parsed.Channel.ITunesNewFeedURL); neu != "" && neu != finalURL {
if retried, err := guardFetch(ctx, neu, "", "", maxFeedSizeBytes); err == nil {
if reparsed, err := ParsePodcastFeed(retried.Body); err == nil {
parsed = reparsed
finalURL = retried.FinalURL
}
}
}
title := parsed.Channel.Title
if title == "" {
title = "Untitled feed"
}
// ---- create-or-reuse (UNIQUE(library_id, feed_url) + guid dedupe) ----
feed, err := s.db.CreatePodcastFeed(actx, database.CreatePodcastFeedParams{
LibraryID: libraryID,
FeedUrl: finalURL,
Title: truncate(title, 500),
CreatedByAdminID: pgtype.UUID{}, // fetcher owns feed metadata; creator attribution is not user metadata
})
if err != nil {
return nil, fmt.Errorf("could not create feed: %w", err)
}
res.Feed = &feed
res.Created = true
// Guid migration dedupe: a row with the same canonical podcast_guid
// under a different URL reuses THAT row (do not fork the feed).
if guid := strings.TrimSpace(parsed.Channel.PodcastGUID); guid != "" {
if feed.PodcastGuid.String == "" || feed.PodcastGuid.String != guid {
if existing, err := s.db.GetPodcastFeedByGuid(actx, database.GetPodcastFeedByGuidParams{
LibraryID: feed.LibraryID,
PodcastGuid: pgtypeText(guid),
}); err == nil && existing.ID != feed.ID {
// Attach the subscriber to the canonical row; drop the
// freshly created stub (nothing subscribed to it yet).
_, _ = s.db.DeletePodcastFeed(actx, feed.ID)
feed = existing
res.Feed = &existing
// Re-point the canonical row at the fetched URL if it moved.
if feedUrlForComparison(existing.FeedUrl) != feedUrlForComparison(finalURL) {
_ = s.db.UpdatePodcastFeedRedirect(actx, database.UpdatePodcastFeedRedirectParams{
ID: existing.ID,
FeedUrl: finalURL,
})
}
}
}
}
if _, err := s.db.SubscribePodcastFeed(actx, database.SubscribePodcastFeedParams{
UserID: userID,
FeedID: feed.ID,
}); err == nil {
res.Subscribed = true
}
// ---- metadata + episodes ----
if err := s.applyFetch(actx, &feed, parsed, fetched); err != nil {
// Feed exists + subscribed, but the metadata pass failed: not
// fatal for the request; the poller reconciles later.
res.FetchError = err.Error()
return res, nil
}
updated, err := s.db.GetPodcastFeed(actx, feed.ID)
if err == nil {
res.Feed = &updated
}
return res, nil
}
// RefreshFeed implements POST /api/podcasts/feeds/:id/refresh: a manual
// poll. The per-feed lock serializes against poller ticks and downloads.
func (s *PodcastService) RefreshFeed(ctx context.Context, feedID pgtype.UUID) error {
actx, err := s.adminCtx(ctx)
if err != nil {
return err
}
feed, err := s.db.GetPodcastFeed(actx, feedID)
if err != nil {
return errNotFound("feed not found")
}
return s.PollFeed(actx, &feed)
}
// PollFeed fetches + reconciles ONE feed (caller supplies the acting
// identity in ctx). Safe to call concurrently per feed — the per-feed
// lock serializes poll/download.
func (s *PodcastService) PollFeed(ctx context.Context, feed *database.PodcastFeeds) error {
lock := s.feedLock(feed.ID.String())
lock.Lock()
defer lock.Unlock()
// Complete feeds back off: they are fetched only on explicit
// refresh (the poller skips them entirely — this path is manual).
fetched, err := guardFetch(ctx, feed.FeedUrl, feed.Etag.String, feed.LastModified.String, maxFeedSizeBytes)
if err != nil {
if feed.LastFetchedAt.Valid {
// A previously-good feed with a transient failure: keep the
// old state; the poller retries next tick.
return fmt.Errorf("fetch %s: %w", feed.Title, classifyFetchError(err))
}
return fmt.Errorf("fetch %s: %w", feed.Title, classifyFetchError(err))
}
if fetched.NotModified {
_, _ = s.db.UpdatePodcastFeedFetch(ctx, database.UpdatePodcastFeedFetchParams{
ID: feed.ID,
Etag: pgtypeText(feed.Etag.String),
LastModified: pgtypeText(feed.LastModified.String),
LastBuildDate: feed.LastBuildDate,
})
return nil
}
parsed, err := ParsePodcastFeed(fetched.Body)
if err != nil {
return fmt.Errorf("parse %s: %w", feed.Title, err)
}
// itunes:new-feed-url: the publisher MOVED the feed. Rewrite the
// stored URL (row, subscriptions and episodes stay put) and refetch
// the new location once so this poll applies its content.
if neu := strings.TrimSpace(parsed.Channel.ITunesNewFeedURL); neu != "" && neu != feed.FeedUrl {
_ = s.db.UpdatePodcastFeedRedirect(ctx, database.UpdatePodcastFeedRedirectParams{ID: feed.ID, FeedUrl: neu})
if retried, err := guardFetch(ctx, neu, "", "", maxFeedSizeBytes); err == nil {
if reparsed, err := ParsePodcastFeed(retried.Body); err == nil {
parsed = reparsed
fetched = retried
}
}
} else if fetched.FinalURL != "" && feedUrlForComparison(fetched.FinalURL) != feedUrlForComparison(feed.FeedUrl) {
// A permanent HTTP redirect chain moved the feed; persist the
// final URL so conditional validators stop bouncing.
_ = s.db.UpdatePodcastFeedRedirect(ctx, database.UpdatePodcastFeedRedirectParams{ID: feed.ID, FeedUrl: fetched.FinalURL})
}
if err := s.applyFetch(ctx, feed, parsed, fetched); err != nil {
return err
}
return nil
}
// applyFetch writes channel metadata, upserts episodes metadata-only,
// touches validators, and downloads artwork. Write-after-success rule
// (invariant 7): failures here never clear existing rows.
func (s *PodcastService) applyFetch(ctx context.Context, feed *database.PodcastFeeds, parsed *PodcastFeedXML, fetched *fetchResult) error {
// --- channel metadata (fetcher-owned) ---
title := parsed.Channel.Title
if title == "" {
title = feed.Title // never blank out a title on a broken doc
}
desc := stripHTML(parsed.Channel.Description)
host := parsed.Channel.ITunesAuthor
publisher := parsed.Channel.ITunesOwner.Name
tagSet := map[string]bool{}
if cat := strings.TrimSpace(parsed.Channel.ITunesCategory.Text); cat != "" {
tagSet[cat] = true
}
if sub := strings.TrimSpace(parsed.Channel.ITunesCategory.Subcategory.Text); sub != "" {
tagSet[sub] = true
}
tags := sortedStringSet(tagSet)
feedType := strings.TrimSpace(parsed.Channel.ITunesType)
if feedType != "serial" && feedType != "episodic" {
feedType = "" // unknown → column stays NULL
}
medium := strings.TrimSpace(parsed.Channel.PodcastMedium)
if _, err := s.db.UpdatePodcastFeedMetadata(ctx, database.UpdatePodcastFeedMetadataParams{
ID: feed.ID,
Title: truncate(title, 500),
Description: pgtypeText(desc),
Host: pgtypeText(truncate(host, 500)),
Publisher: pgtypeText(truncate(publisher, 500)),
OwnerName: pgtypeText(truncate(parsed.Channel.ITunesOwner.Name, 500)),
OwnerEmail: pgtypeText(truncate(parsed.Channel.ITunesOwner.Email, 500)),
Language: pgtypeText(truncate(parsed.Channel.Language, 50)),
Tags: tags,
Explicit: parseExplicit(parsed.Channel.ITunesExplicit),
FeedType: pgtypeText(feedType),
Medium: pgtypeText(truncate(medium, 20)),
IsComplete: parseExplicit(parsed.Channel.ITunesComplete),
PodcastGuid: pgtypeText(strings.TrimSpace(parsed.Channel.PodcastGUID)),
}); err != nil {
return fmt.Errorf("feed metadata update: %w", err)
}
// itunes:complete flips the poller's back-off flag (complete feeds
// poll rarely); explicit refresh still works.
// --- episodes (metadata-only upsert, positions from THIS parse) ---
root, err := s.downloadRootAbs(ctx, feed)
if err != nil {
return fmt.Errorf("download root: %w", err)
}
feedSlug := feedSlug(title, feed.ID)
count := 0
for i := range parsed.Channel.Items {
if err := s.upsertEpisode(ctx, feed, feedSlug, &parsed.Channel.Items[i], i+1); err != nil {
// One bad item must not sink the batch.
log.Printf("[PODCAST] episode upsert failed for feed %s item %d: %v", feed.ID, i+1, err)
continue
}
count++
}
// --- validators + fetch timestamp ---
var lastBuildDate pgtype.Timestamptz
if t, ok := parseFeedTime(parsed.Channel.LastBuildDate); ok {
lastBuildDate = pgtypeTimestamptz(t)
}
if _, err := s.db.UpdatePodcastFeedFetch(ctx, database.UpdatePodcastFeedFetchParams{
ID: feed.ID,
Etag: pgtypeText(fetched.ETag),
LastModified: pgtypeText(fetched.LastModified),
LastBuildDate: lastBuildDate,
}); err != nil {
return fmt.Errorf("fetch touch: %w", err)
}
// --- artwork (best-effort; feed renders fine without it) ---
s.refreshArtwork(ctx, feed, root, parsed)
return nil
}
// upsertEpisode maps one XML item onto a media_items row (plan §5 field
// inventory). Positions are 1-based, renumbered per fetch; file_path is
// the PLANNED final layout — the upsert only applies it to rows that
// are not yet downloaded (query-side guard); the abs root resolves at
// download time via the library folders.
func (s *PodcastService) upsertEpisode(ctx context.Context, feed *database.PodcastFeeds, feedSlug string, item *PodcastItemXML, position int) error {
title := item.EpisodeTitle()
if title == "" {
title = fmt.Sprintf("Episode %d", position)
}
guid := strings.TrimSpace(item.GUID)
if guid == "" {
// GUID-less feeds exist; fall back to a stable identity from the
// enclosure URL (the next-best immutable per-episode key).
if item.Enclosure.URL != "" {
guid = "enc:" + item.Enclosure.URL
} else {
return fmt.Errorf("item %q has neither guid nor enclosure; skipping", title)
}
}
ext := enclosureExtension(item.Enclosure.URL, item.Enclosure.Type)
relPath := episodeRelPath(feedSlug, position, title, ext)
var published pgtype.Date
if t, ok := parseFeedTime(item.PubDate); ok {
published = pgtypeDate(t)
}
authors := []string{}
if a := item.EpisodeAuthor(); a != "" {
authors = append(authors, truncate(a, 255))
}
hosts, guests := item.HostsAndGuests()
_, err := s.db.UpsertPodcastEpisode(ctx, database.UpsertPodcastEpisodeParams{
LibraryID: feed.LibraryID,
Title: truncate(title, 255),
Subtitle: pgtypeText(truncate(item.Subtitle, 500)),
Description: pgtypeText(stripHTML(item.EpisodeDescription())),
Authors: authors,
AuthorsSearch: lowerSearchSlice(authors),
FilePath: relPath,
FileSize: pgtypeInt8(item.Enclosure.Length),
MimeType: pgtypeText(truncate(item.Enclosure.Type, 100)),
CoverImagePath: pgtypeText(artworkRelPath(feedSlug, artworkExtFromURL(item.ITunesImage.Href))),
Explicit: item.EpisodeExplicit(),
DatePublished: published,
Language: pgtypeText(truncate(feed.Language.String, 10)),
Tags: feed.Tags,
Genre: pgtypeText(strings.Join(feed.Tags, ", ")),
SourceFeedID: feed.ID,
EpisodeGuid: pgtypeText(truncate(guid, 500)),
FeedPosition: pgtypeInt4(int32(position)),
SeasonNumber: pgtypeInt4FromStr(item.ITunesSeason),
EpisodeNumber: pgtypeInt4FromStr(item.ITunesEpisode),
EnclosureUrl: pgtypeText(item.Enclosure.URL),
DeclaredDurationSeconds: pgtypeInt4(int32(parseITunesDuration(item.ITunesDuration))),
TranscriptUrl: pgtypeText(item.Transcript.URL),
ChaptersUrl: pgtypeText(item.Chapters.URL),
Hosts: truncateSlice(hosts, 255),
Guests: truncateSlice(guests, 255),
})
return err
}
// refreshArtwork downloads the channel image once per changed URL.
// Artwork is feed-level: episodes point at the same relative path.
func (s *PodcastService) refreshArtwork(ctx context.Context, feed *database.PodcastFeeds, downloadRoot string, parsed *PodcastFeedXML) {
imgURL := strings.TrimSpace(parsed.Channel.ITunesImage.Href)
if imgURL == "" {
return
}
target := filepath.Join(downloadRoot, artworkRelPath(feedSlug(feed.Title, feed.ID), artworkExtFromURL(imgURL)))
if feed.ArtworkPath.Valid {
if existing := filepath.Join(downloadRoot, feed.ArtworkPath.String); existing == target {
if _, err := os.Stat(existing); err == nil {
return // unchanged
}
}
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return
}
res, err := guardFetch(ctx, imgURL, "", "", maxArtworkSizeBytes)
if err != nil || len(res.Body) == 0 {
log.Printf("[PODCAST] artwork fetch failed for feed %s: %v", feed.ID, err)
return
}
if err := os.WriteFile(target, res.Body, 0o644); err != nil {
log.Printf("[PODCAST] artwork write failed for feed %s: %v", feed.ID, err)
return
}
rel, err := filepath.Rel(downloadRoot, target)
if err != nil {
return
}
_ = s.db.UpdatePodcastFeedArtwork(ctx, database.UpdatePodcastFeedArtworkParams{
ID: feed.ID,
ArtworkPath: pgtypeText(rel),
})
}
// DeleteFeedFiles removes a feed's episode files + artwork from the
// download root (admin feed-removal path; ?files=false skips it).
// Best-effort per file — the row cascade proceeds regardless.
func (s *PodcastService) DeleteFeedFiles(ctx context.Context, feed *database.PodcastFeeds, episodes []database.MediaItems) {
root, err := s.downloadRootAbs(ctx, feed)
if err != nil {
return
}
for _, ep := range episodes {
if ep.FilePath != "" {
_ = os.Remove(filepath.Join(root, ep.FilePath))
// Embedded-cover sibling (extractEmbeddedAudioCover convention).
base := filepath.Join(root, strings.TrimSuffix(ep.FilePath, filepath.Ext(ep.FilePath)))
_ = os.Remove(base + ".cover.jpg")
}
}
if feed.ArtworkPath.Valid {
_ = os.Remove(filepath.Join(root, feed.ArtworkPath.String))
}
// The feed directory itself: remove when empty (other feeds never
// share a slug — the feed-id suffix guarantees that).
_ = os.Remove(filepath.Join(root, feedSlug(feed.Title, feed.ID)))
}
// downloadRootAbs resolves the podcasts library's single download root
// (purpose='podcast') as an absolute filesystem path.
func (s *PodcastService) downloadRootAbs(ctx context.Context, feed *database.PodcastFeeds) (string, error) {
folders, err := s.db.GetPodcastLibraryFolder(ctx, feed.LibraryID)
if err != nil || len(folders) == 0 {
return "", fmt.Errorf("library %s has no download root registered", feed.LibraryID)
}
return folders[0].FolderPath, nil
}
// episodeRelPath is the pinned episode layout (plan §6):
// <feed-slug>/<feed_position:04d>-<slug>.<ext> — relative to the
// download root. getFullFilePath/ServeFile/Range streaming resolve it
// unchanged.
func episodeRelPath(feedSlug string, position int, title, ext string) string {
return fmt.Sprintf("%s/%04d-%s%s", feedSlug, position, slugify(title, 80), ext)
}
// artworkRelPath is the feed-level artwork location (feed-level, so
// every episode row can point at the same file).
func artworkRelPath(feedSlug, ext string) string {
return fmt.Sprintf("%s/artwork%s", feedSlug, ext)
}
// feedSlug derives the stable per-feed directory name: slugged title +
// short feed-id suffix so two identically-titled feeds never collide.
func feedSlug(title string, feedID pgtype.UUID) string {
base := slugify(title, 60)
suffix := "feed"
if feedID.Valid {
s := feedID.String()
if len(s) >= 8 {
suffix = s[:8]
}
}
if base == "" {
base = "feed"
}
return base + "-" + suffix
}
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
// slugify lowercases, strips non-alphanumerics to dashes, and clamps
// the length (file-name-safe; unicode letters drop — file layout is
// an internal key, never display text).
func slugify(s string, maxLen int) string {
s = strings.ToLower(strings.TrimSpace(s))
s = slugStrip.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if len(s) > maxLen {
s = strings.TrimRight(s[:maxLen], "-")
}
return s
}
// enclosureExtension picks a file extension from the URL, then the
// MIME type, then a safe default.
func enclosureExtension(rawURL, mimeType string) string {
if u, err := urlParse(rawURL); err == nil {
if ext := strings.ToLower(filepath.Ext(u.Path)); ext == ".mp3" || ext == ".m4a" || ext == ".m4b" ||
ext == ".flac" || ext == ".ogg" || ext == ".oga" || ext == ".opus" || ext == ".wav" || ext == ".aac" {
return ext
}
}
switch {
case strings.Contains(mimeType, "mp4") || strings.Contains(mimeType, "m4a"):
return ".m4a"
case strings.Contains(mimeType, "mpeg") || strings.Contains(mimeType, "mp3"):
return ".mp3"
case strings.Contains(mimeType, "flac"):
return ".flac"
case strings.Contains(mimeType, "ogg"):
return ".ogg"
case strings.Contains(mimeType, "wav"):
return ".wav"
}
return ".mp3"
}
// artworkExtFromURL keeps the source extension for artwork (jpg/png/webp).
func artworkExtFromURL(rawURL string) string {
if u, err := urlParse(rawURL); err == nil {
switch strings.ToLower(filepath.Ext(u.Path)) {
case ".png":
return ".png"
case ".webp":
return ".webp"
case ".gif":
return ".gif"
}
}
return ".jpg"
}
// stripHTML removes tags and collapses whitespace — feed descriptions
// are HTML; storage wants text (the TUI-ish surfaces render it plain).
func stripHTML(s string) string {
if s == "" {
return ""
}
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
case r == '>':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
out := b.String()
// Collapse runs of whitespace.
out = strings.Join(strings.Fields(out), " ")
return strings.TrimSpace(out)
}
// truncate clamps a string to max runes (VARCHAR safety at the DB edge).
func truncate(s string, max int) string {
s = strings.TrimSpace(s)
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max])
}
// truncateSlice clamps each element and drops empties.
func truncateSlice(in []string, max int) []string {
out := make([]string, 0, len(in))
for _, s := range in {
if t := truncate(s, max); t != "" {
out = append(out, t)
}
}
return out
}
// lowerSearchSlice mirrors authors into authors_search (scanner convention).
func lowerSearchSlice(in []string) []string {
out := make([]string, 0, len(in))
for _, s := range in {
out = append(out, strings.ToLower(s))
}
return out
}
// sortedStringSet → deterministic TEXT[] writes.
func sortedStringSet(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
if strings.TrimSpace(k) == "" {
continue
}
out = append(out, strings.TrimSpace(k))
}
// tiny insertion sort — tag sets are 1–5 entries
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
// classifyFetchError maps guard/HTTP errors to user-facing text (the
// add-feed dialog and the download_error column both render it).
// normalizeFeedURL accepts the launcher-scheme spellings podcast
// pages hand out alongside plain RSS (issue follow-up: "one says RSS,
// the other says Apple Podcast") and rewrites them to the https URL
// they wrap: itpc:// and podcasts:// are Apple's subscribe-launcher
// aliases for the SAME feed document; feed:// is the generic form.
// Anything else keeps its scheme — guardFetch enforces https.
func normalizeFeedURL(raw string) string {
u := strings.TrimSpace(raw)
lowered := strings.ToLower(u)
for _, prefix := range []string{"itpc://", "podcasts://", "feed://"} {
if strings.HasPrefix(lowered, prefix) {
return "https://" + u[len(prefix):]
}
}
return u
}
func classifyFetchError(err error) error {
if errors.Is(err, ErrBlockedPrivateAddress) {
return fmt.Errorf("blocked: private/loopback addresses are not fetchable")
}
return err
}
// urlParse local helper so podcast files don't each import net/url.
func urlParse(raw string) (*url.URL, error) {
return url.Parse(raw)
}
// ---- pgtype helpers shared by the podcast files ----
func pgtypeText(s string) pgtype.Text {
if strings.TrimSpace(s) == "" {
return pgtype.Text{}
}
return pgtype.Text{String: s, Valid: true}
}
func pgtypeInt4(v int32) pgtype.Int4 {
if v == 0 {
return pgtype.Int4{}
}
return pgtype.Int4{Int32: v, Valid: true}
}
func pgtypeInt4FromStr(s string) pgtype.Int4 {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return pgtype.Int4{}
}
return pgtype.Int4{Int32: int32(n), Valid: true}
}
func pgtypeInt8(v int64) pgtype.Int8 {
if v <= 0 {
return pgtype.Int8{}
}
return pgtype.Int8{Int64: v, Valid: true}
}
func pgtypeTimestamptz(t time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: t, Valid: true}
}
func pgtypeDate(t time.Time) pgtype.Date {
return pgtype.Date{Time: t.UTC().Truncate(24 * time.Hour), Valid: true}
}
// feedUrlForComparison normalizes trailing slashes for the redirect check.
func feedUrlForComparison(u string) string {
return strings.TrimRight(strings.TrimSpace(u), "/")
}
+627
View File
@@ -0,0 +1,627 @@
// Podcast download worker (plan §7 / D6 / D8 / D19): ONE sequential
// worker (day one) driving the episode state machine
//
// pending → queued → downloading → downloaded
// ↘ failed (+ download_error)
//
// queued rows are the DB-backed worklist — the state is persisted so
// the episode list ALWAYS renders truth (no in-memory queue to lose).
//
// Idempotency: a download request for an episode whose file already
// exists and probes cleanly re-marks it downloaded without re-fetching
// (crash-between-move-and-update reconciliation); a downloaded episode
// re-request is a full no-op.
//
// Retry semantics: manual retry (same endpoint as download) resets
// download_attempts; auto-download policies re-queue failed episodes on
// later polls up to maxAutoDownloadAttempts, then stay failed until a
// human retries.
//
// RLS: the worker runs detached — every statement executes under the
// first-admin identity (invariant 12), same contract as the fetcher.
package services
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"net/http"
"net/url"
"strings"
"sync"
"time"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
// maxAutoDownloadAttempts caps POLICY-driven re-queues of a failing
// episode; a human retry always resets the budget.
const maxAutoDownloadAttempts = 5
// PodcastDownloadWorker processes queued episode downloads.
type PodcastDownloadWorker struct {
db *database.Queries
svc *PodcastService
wake chan struct{}
// Live byte-progress slots for in-flight downloads (issue #1).
// Keyed by episode UUID string; guarded by progMu.
progMu sync.Mutex
progress map[string]*episodeProgress
}
func NewPodcastDownloadWorker(db *database.Queries, svc *PodcastService) *PodcastDownloadWorker {
return &PodcastDownloadWorker{
db: db,
svc: svc,
wake: make(chan struct{}, 1),
progress: make(map[string]*episodeProgress),
}
}
// Wake nudges the loop (new work queued).
func (w *PodcastDownloadWorker) Wake() {
select {
case w.wake <- struct{}{}:
default:
}
}
// Start launches the loop; it exits when ctx is cancelled. Called once
// from route registration (scanner background-tasks precedent).
func (w *PodcastDownloadWorker) Start(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done():
return
case <-w.wake:
case <-time.After(5 * time.Second):
// Poll fallback: a queued row written by another process
// (or a crashed worker's leftovers) still gets picked up.
}
w.drain(ctx)
}
}()
}
// drain processes queued episodes until the queue is empty.
func (w *PodcastDownloadWorker) drain(ctx context.Context) {
for {
if ctx.Err() != nil {
return
}
acted, err := w.processNext(ctx)
if err != nil {
log.Printf("[PODCAST-DL] worker step: %v", err)
return
}
if !acted {
return
}
}
}
// QueueEpisode marks one episode queued (manual download / retry path).
// Idempotent by design: downloaded → no-op. The per-feed lock
// serializes against polls rewriting the same row.
func (w *PodcastDownloadWorker) QueueEpisode(ctx context.Context, episodeID, feedID pgtype.UUID) error {
actx, err := w.svc.adminCtx(ctx)
if err != nil {
return err
}
lock := w.svc.feedLock(feedID.String())
lock.Lock()
defer lock.Unlock()
ep, err := w.db.GetPodcastEpisode(actx, episodeID)
if err != nil {
return errNotFound("episode not found")
}
if ep.DownloadState == "downloaded" {
return nil // idempotent no-op
}
// Manual path resets the attempt budget (SetPodcastEpisodePending)
// then queues — a human retry always overrides auto-retry capping.
if err := w.db.SetPodcastEpisodePending(actx, episodeID); err != nil {
return err
}
if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, episodeID); err != nil {
return err
}
w.Wake()
return nil
}
// QueueFeedDownloads implements "Download All" (D8): queue exactly the
// non-downloaded set — pending AND failed rows (retry is the same path).
func (w *PodcastDownloadWorker) QueueFeedDownloads(ctx context.Context, feedID pgtype.UUID) (int, error) {
actx, err := w.svc.adminCtx(ctx)
if err != nil {
return 0, err
}
lock := w.svc.feedLock(feedID.String())
lock.Lock()
defer lock.Unlock()
eps, err := w.db.ListUndownloadedEpisodes(actx, feedID)
if err != nil {
return 0, err
}
queued := 0
for _, ep := range eps {
if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, ep.ID); err == nil {
queued++
}
}
w.Wake()
return queued, nil
}
// ApplyAutoPolicies unions subscriber policies for a feed (D6: most
// aggressive wins for what ARRIVES — deletion stays admin-only) and
// queues the target set, respecting the auto-retry attempt cap.
func (w *PodcastDownloadWorker) ApplyAutoPolicies(ctx context.Context, feedID pgtype.UUID) (int, error) {
actx, err := w.svc.adminCtx(ctx)
if err != nil {
return 0, err
}
subs, err := w.db.ListFeedSubscribers(actx, feedID)
if err != nil {
return 0, err
}
wantAll := false
newestN := 0
for _, sub := range subs {
switch sub.AutoDownload {
case "all":
wantAll = true
case "newest":
if n := int(sub.AutoDownloadCount.Int32); n > newestN {
newestN = n
}
}
}
if !wantAll && newestN == 0 {
return 0, nil // nobody's policy asks for anything
}
lock := w.svc.feedLock(feedID.String())
lock.Lock()
defer lock.Unlock()
eps, err := w.db.ListUndownloadedEpisodes(actx, feedID)
if err != nil {
return 0, err
}
// ListUndownloadedEpisodes is native-order (oldest first); "newest
// N" takes the TAIL (highest positions = latest releases).
targets := eps
if !wantAll && newestN > 0 && len(eps) > newestN {
targets = eps[len(eps)-newestN:]
}
queued := 0
for _, ep := range targets {
// Auto-retry budget: only re-queue within the cap. A failed
// episode past the cap waits for a human (its row renders the
// reason + retry button — D8/D19).
if ep.DownloadState == "failed" && int(ep.DownloadAttempts) >= maxAutoDownloadAttempts {
continue
}
if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, ep.ID); err == nil {
queued++
}
}
if queued > 0 {
w.Wake()
}
return queued, nil
}
// processNext claims and processes one queued episode. Returns false
// when the queue was empty.
func (w *PodcastDownloadWorker) processNext(ctx context.Context) (bool, error) {
actx, err := w.svc.adminCtx(ctx)
if err != nil {
return false, err
}
ep, err := w.db.GetNextQueuedEpisode(actx)
if err != nil {
return false, nil // empty queue (or read failure — logged upstream)
}
_ = w.db.SetPodcastEpisodeDownloading(actx, ep.ID)
if err := w.runPipeline(actx, ep); err != nil {
// failed/rejected: state + reason persist; metadata/file facts
// untouched (invariant 7 — write-after-success only).
_ = w.db.MarkPodcastEpisodeFailed(actx, database.MarkPodcastEpisodeFailedParams{
ID: ep.ID,
DownloadError: pgtypeText(err.Error()),
Column3: false, // auto failure: never resets the attempt budget
})
log.Printf("[PODCAST-DL] episode %s failed: %v", ep.ID, err)
}
return true, nil
}
// runPipeline: fetch enclosure (SSRF/size guards) → temp file → MIME/
// size validation → ffprobe reconcile → move into place → SUCCESS-only
// row update.
func (w *PodcastDownloadWorker) runPipeline(ctx context.Context, ep database.MediaItems) error {
if !ep.EnclosureUrl.Valid || ep.EnclosureUrl.String == "" {
return fmt.Errorf("episode has no enclosure URL")
}
if !ep.SourceFeedID.Valid {
return fmt.Errorf("episode has no feed reference")
}
feed, err := w.db.GetPodcastFeed(ctx, ep.SourceFeedID)
if err != nil {
return fmt.Errorf("feed lookup: %w", errNotFound("feed not found"))
}
root, err := w.svc.downloadRootAbs(ctx, &feed)
if err != nil {
return err
}
// Declared-size pre-check: reject hopeless downloads before
// transferring a byte (validation rejections render like failures,
// with their reason — D8).
if ep.FileSize.Valid && ep.FileSize.Int64 > maxEnclosureCapBytes {
return fmt.Errorf("rejected: enclosure declares %d bytes (cap %d)", ep.FileSize.Int64, maxEnclosureCapBytes)
}
feedDir := filepath.Join(root, filepath.Dir(ep.FilePath))
finalPath := filepath.Join(root, ep.FilePath)
if err := os.MkdirAll(feedDir, 0o755); err != nil {
return fmt.Errorf("download dir: %w", err)
}
// Idempotency reconciliation: file already on disk and probes?
if _, err := os.Stat(finalPath); err == nil {
if payload := probeEpisodeAudio(finalPath); payload != nil {
return w.markDownloaded(ctx, ep, &feed, finalPath, payload)
}
// Unprobeable stray: fall through and re-fetch over it.
}
// ---- fetch to temp (streamed with byte-progress tracking) ----
// Enclosures stream straight to disk through a counting loop — no
// whole-body buffering — so the episode list can render a real
// progress bar while the worker runs (issue #1).
tmpPath := finalPath + ".part-" + randomSuffix()
cleanup := func() { _ = os.Remove(tmpPath) }
received, total, fetchErr := w.streamEnclosure(ctx, ep, tmpPath)
w.endProgress(ep.ID) // terminal either way; the row state is the truth
if fetchErr != nil {
cleanup()
return fetchErr
}
_, _ = received, total
// ---- validation ----
info, err := os.Stat(tmpPath)
if err != nil || info.Size() == 0 {
cleanup()
return fmt.Errorf("rejected: empty download")
}
if info.Size() > maxEnclosureCapBytes {
cleanup()
return fmt.Errorf("rejected: enclosure exceeds %d byte cap", maxEnclosureCapBytes)
}
if !looksLikeAudioFile(tmpPath) {
cleanup()
return fmt.Errorf("rejected: downloaded file does not look like audio media")
}
// ---- ffprobe reconcile + embedded cover (scanner machinery reuse) ----
payload := probeEpisodeAudio(tmpPath)
// ---- move into place (crash-safe: rename is atomic on-same-fs) ----
if err := os.Rename(tmpPath, finalPath); err != nil {
cleanup()
return fmt.Errorf("finalize: %w", err)
}
return w.markDownloaded(ctx, ep, &feed, finalPath, payload)
}
// ---- byte-progress tracking (issue #1) ----
// In-memory, deliberately NOT persisted: a downloading row renders a
// bar from this snapshot while the worker holds it; the DB row's state
// remains the durable truth (queued/downloading/downloaded).
type episodeProgress struct {
Received int64
Total int64 // 0 = unknown (no Content-Length, no declared length)
}
// beginProgress registers a download slot (total 0 = unknown until the
// response's Content-Length arrives).
func (w *PodcastDownloadWorker) beginProgress(episodeID pgtype.UUID, total int64) {
w.progMu.Lock()
defer w.progMu.Unlock()
w.progress[episodeID.String()] = &episodeProgress{Total: total}
}
// addProgress accumulates streamed bytes.
func (w *PodcastDownloadWorker) addProgress(episodeID pgtype.UUID, n int64) {
w.progMu.Lock()
defer w.progMu.Unlock()
if p, ok := w.progress[episodeID.String()]; ok {
p.Received += n
if p.Total == 0 {
// Total unknown at start; keep received-only (bar renders
// indeterminate).
}
}
}
// setProgressTotal records the authoritative total once known.
func (w *PodcastDownloadWorker) setProgressTotal(episodeID pgtype.UUID, total int64) {
w.progMu.Lock()
defer w.progMu.Unlock()
if p, ok := w.progress[episodeID.String()]; ok && total > 0 {
p.Total = total
}
}
// endProgress drops the slot (terminal state reached — the row itself
// now tells the story).
func (w *PodcastDownloadWorker) endProgress(episodeID pgtype.UUID) {
w.progMu.Lock()
defer w.progMu.Unlock()
delete(w.progress, episodeID.String())
}
// EpisodeProgress reports the live byte counts for a downloading
// episode (ok=false when nothing is in flight for it).
func (w *PodcastDownloadWorker) EpisodeProgress(episodeID pgtype.UUID) (received, total int64, ok bool) {
w.progMu.Lock()
defer w.progMu.Unlock()
p, ok := w.progress[episodeID.String()]
if !ok {
return 0, 0, false
}
return p.Received, p.Total, true
}
// streamEnclosure performs the SSRF-guarded enclosure download straight
// to tmpPath while updating the progress slot. Returns bytes received
// and the authoritative total (0 when unknown). Scheme and size guards
// match guardFetch; redirects re-dial through the same client so the
// IP guard re-applies per hop.
func (w *PodcastDownloadWorker) streamEnclosure(ctx context.Context, ep database.MediaItems, tmpPath string) (int64, int64, error) {
u, err := url.Parse(ep.EnclosureUrl.String)
if err != nil {
return 0, 0, fmt.Errorf("download failed: invalid enclosure URL: %w", err)
}
if err := guardURLScheme(u); err != nil {
return 0, 0, fmt.Errorf("download failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.EnclosureUrl.String, nil)
if err != nil {
return 0, 0, fmt.Errorf("download failed: %w", err)
}
req.Header.Set("User-Agent", "Bookhoard/1.0 (podcast fetcher; +https://github.com/bookhoard)")
// Declared total (feed's enclosure length) seeds the bar until the
// response's Content-Length replaces it.
declared := int64(0)
if ep.FileSize.Valid {
declared = ep.FileSize.Int64
}
w.beginProgress(ep.ID, declared)
resp, err := podcastHTTPClientFactory().Do(req)
if err != nil {
return 0, declared, fmt.Errorf("download failed: %w", classifyFetchError(err))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return 0, declared, fmt.Errorf("download failed: HTTP %d for enclosure", resp.StatusCode)
}
total := resp.ContentLength // -1 when unknown
if total < 0 {
total = declared
}
w.setProgressTotal(ep.ID, total)
out, err := os.Create(tmpPath)
if err != nil {
return 0, total, fmt.Errorf("write: %w", err)
}
defer func() { _ = out.Close() }()
buf := make([]byte, 64<<10)
var received int64
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
received += int64(n)
if received > maxEnclosureCapBytes {
return received, total, fmt.Errorf("rejected: enclosure exceeds %d byte cap", maxEnclosureCapBytes)
}
w.addProgress(ep.ID, int64(n))
if _, werr := out.Write(buf[:n]); werr != nil {
return received, total, fmt.Errorf("write: %w", werr)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return received, total, fmt.Errorf("download failed: %w", readErr)
}
}
return received, total, nil
}
// markDownloaded is the SUCCESS-ONLY row write (invariant 7): the file
// is on disk and (when ffprobe is available) probed before this runs.
func (w *PodcastDownloadWorker) markDownloaded(ctx context.Context, ep database.MediaItems, feed *database.PodcastFeeds, absPath string, payload *AudioMetadataPayload) error {
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("post-move stat: %w", err)
}
// Embedded cover extraction (optional enrichment; scanner precedent).
coverRel := pgtype.Text{}
base := strings.TrimSuffix(absPath, filepath.Ext(absPath))
if coverAbs := extractEmbeddedAudioCover(absPath, base); coverAbs != "" {
if root, err := w.svc.downloadRootAbs(ctx, feed); err == nil {
if rel, err := filepath.Rel(root, coverAbs); err == nil {
coverRel = pgtypeText(rel)
}
}
}
var chapterCount pgtype.Int4
if payload != nil && len(payload.Chapters) > 0 {
chapterCount = pgtypeInt4(int32(len(payload.Chapters)))
}
_, err = w.db.MarkPodcastEpisodeDownloaded(ctx, database.MarkPodcastEpisodeDownloadedParams{
ID: ep.ID,
FilePath: ep.FilePath,
FileSize: pgtypeInt8(info.Size()),
MimeType: pgtypeText(strings.TrimSpace(pickMimeType(ep, absPath))),
CoverImagePath: coverRel,
AudioMetadata: audioPayloadJSONB(payload),
ChapterCount: chapterCount,
})
if err != nil {
return fmt.Errorf("mark downloaded: %w", err)
}
log.Printf("[PODCAST-DL] downloaded %q (%s) for feed %s", ep.Title, ep.FilePath, feed.Title)
return nil
}
// probeEpisodeAudio shells out to ffprobe and builds the audio_metadata
// payload (scanner machinery reuse; nil when ffprobe is absent — the
// episode still downloads, facts degrade to declared values).
func probeEpisodeAudio(path string) *AudioMetadataPayload {
report := runFFProbe(path)
if report == nil {
return nil
}
payload := &AudioMetadataPayload{}
if report.Format.Duration > 0 {
payload.DurationSeconds = float64(report.Format.Duration)
}
if report.Format.BitRate > 0 {
payload.BitRate = int64(report.Format.BitRate)
}
if len(report.Streams) > 0 {
profile := report.Streams[0].CodecName
if p := report.Streams[0].Profile; p != "" && p != "unknown" {
profile += " / " + p
}
payload.CodecProfile = profile
}
for _, ch := range report.Chapters {
payload.Chapters = append(payload.Chapters, AudioChapter{
Index: ch.ID,
Title: ch.Tags.Title,
Start: float64(ch.StartTime),
End: float64(ch.EndTime),
})
}
return payload
}
// looksLikeAudioFile sniffs content: an audio container starts with
// known magic bytes, or (ID3v1/v2, ADTS) has recognizable heads. HTML
// error pages and JSON never pass.
func looksLikeAudioFile(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer func() { _ = f.Close() }()
head := make([]byte, 16)
n, _ := io.ReadFull(f, head)
if n < 4 {
return false
}
h := head[:n]
switch {
// MP3: ID3 tag or MPEG frame sync.
case string(h[:3]) == "ID3":
return true
case h[0] == 0xFF && h[1]&0xE0 == 0xE0:
return true
// MP4/M4A/M4B: ftyp box.
case string(h[4:8]) == "ftyp":
return true
// FLAC
case string(h[:4]) == "fLaC":
return true
// OGG/Opus
case string(h[:4]) == "OggS":
return true
// WAV/RIFF
case string(h[:4]) == "RIFF":
return true
// AAC ADTS (sync word)
case h[0] == 0xFF && (h[1]&0xF6) == 0xF0:
return true
}
return false
}
// pickMimeType prefers the feed-declared type; falls back by extension.
func pickMimeType(ep database.MediaItems, absPath string) string {
if ep.MimeType.Valid && strings.HasPrefix(ep.MimeType.String, "audio/") {
return ep.MimeType.String
}
switch strings.ToLower(filepath.Ext(absPath)) {
case ".m4a", ".m4b":
return "audio/mp4"
case ".mp3":
return "audio/mpeg"
case ".flac":
return "audio/flac"
case ".ogg", ".oga", ".opus":
return "audio/ogg"
case ".wav":
return "audio/wav"
case ".aac":
return "audio/aac"
}
return "audio/mpeg"
}
// audioPayloadJSONB marshals the probed payload for the JSONB column
// (nil payload → NULL: keep the column honest, never an empty object).
func audioPayloadJSONB(p *AudioMetadataPayload) []byte {
if p == nil {
return nil
}
b, err := json.Marshal(p)
if err != nil {
return nil
}
return b
}
// randomSuffix disambiguates concurrent temp files (single worker, but
// a crashed worker's stale .part files must never collide).
func randomSuffix() string {
sum := sha256.Sum256([]byte(time.Now().Format(time.RFC3339Nano) + fmt.Sprint(os.Getpid())))
return hex.EncodeToString(sum[:4])
}
+7
View File
@@ -296,6 +296,13 @@ func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID,
entry.Sessions++ entry.Sessions++
summary.SessionCount++ summary.SessionCount++
// Podcast episodes (plan D15): sessions flow into time-spent
// (the minutes/pages/sessions math above already ran), but an
// episode is not a book — never enters BooksActive/BooksFinished.
if strings.EqualFold(strings.TrimSpace(sess.LibraryTypeName.String), "podcasts") {
continue
}
mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16]) mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16])
if err == nil { if err == nil {
key := mediaUUID.String() key := mediaUUID.String()
+8
View File
@@ -8,3 +8,11 @@ package version
// The release workflow passes the git tag (e.g. v1.5) as the APP_VERSION // The release workflow passes the git tag (e.g. v1.5) as the APP_VERSION
// Docker build arg, which the Dockerfile forwards to this flag. // Docker build arg, which the Dockerfile forwards to this flag.
var Version = "dev" var Version = "dev"
// Commit is the git commit the binary was built from (short hash),
// injected alongside Version via ldflags (GIT_COMMIT build arg). It
// exists so "the UI I'm looking at" vs "the code in the repo" drift is
// VISIBLE — the 2026-10-29 podcast debugging session burned an hour on
// a stale container that predated the feature. "none" for unstampeded
// local builds.
var Commit = "none"
+1 -1
View File
@@ -10,7 +10,7 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class={ "theme-" + user.Theme }> <body x-data="admin" class={ "theme-" + user.Theme }>
@Header(user, "/admin/archived") @Header(user, "/admin/archived")
<main class="p-8"> <main class="p-8">
<div class="max-w-5xl"> <div class="max-w-5xl">
+47
View File
@@ -0,0 +1,47 @@
package templates
import (
"context"
"strings"
"testing"
"time"
)
// The archived-items admin page binds its buttons through the page-wide
// Alpine component: Purge All is @click="purgeArchivedItems()" and the
// per-row Delete/Restore/Merge buttons rely on listeners main.ts binds at
// module load. The body tag therefore MUST carry x-data="admin" — the
// other admin pages have it, and when this page's body lost it the purge
// button rendered as an inert attribute (Alpine only evaluates directives
// inside an x-data tree; the click did literally nothing).
//
// Regression: /admin/archived rendered <body class="theme-…"> with no
// Alpine scope, so "Purge All Archived" was dead (prod report 2026-10).
func TestAdminArchivedPageHasAlpineScope(t *testing.T) {
archivedAt := time.Date(2026, 10, 28, 4, 38, 0, 0, time.UTC)
var b strings.Builder
err := AdminArchived(User{ID: "u1", Timezone: "UTC"}, []ArchivedItem{{
ID: "m1",
Title: "Lost Book",
Author: "An Author",
LibraryName: "Audiobooks",
FilePath: "Author/Book/file.m4b",
MissingScans: 2,
ArchivedAt: &archivedAt,
Status: "Archived - pending purge",
}}, 0).Render(context.Background(), &b)
if err != nil {
t.Fatalf("render: %v", err)
}
out := b.String()
if !strings.Contains(out, `<body x-data="admin"`) {
t.Error("archived page body missing x-data=\"admin\" — the Alpine-bound purge button would be dead")
}
// The action buttons the page depends on must be present in the same
// render, so the pin cannot pass on an empty shell.
for _, want := range []string{"purgeArchivedItems()", `data-delete-archived="m1"`} {
if !strings.Contains(out, want) {
t.Errorf("archived page missing %q", want)
}
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body x-data=\"admin\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+46 -4
View File
@@ -7,7 +7,32 @@ package templates
// <audio> element streams the m4b through the existing cookie-authed // <audio> element streams the m4b through the existing cookie-authed
// Range-serving /uploads path. // Range-serving /uploads path.
import "fmt" import (
"encoding/json"
"fmt"
)
// AudioReaderConfigJSON builds the reader's body[data-json] config: the
// chapter/progress endpoints plus, for podcast episodes, the sequence
// context (chain playback — plan D7). Books render the same JSON shape
// WITHOUT the sequence key, so the reader code path is identical.
func AudioReaderConfigJSON(metadata ReaderMetadata) string {
cfg := map[string]any{
"chapterEndpoint": "/api/media-items/" + metadata.MediaItemID + "/chapters",
"progressEndpoint": "/api/media-items/" + metadata.MediaItemID + "/progress",
}
if metadata.PodcastSequence != nil {
cfg["podcastSequence"] = metadata.PodcastSequence
}
b, err := json.Marshal(cfg)
if err != nil {
// Unreachable for these shapes; degrade to the book-only config.
return fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
"/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress")
}
return string(b)
}
templ AudioReader(user User, metadata ReaderMetadata) { templ AudioReader(user User, metadata ReaderMetadata) {
<!DOCTYPE html> <!DOCTYPE html>
@@ -41,9 +66,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
data-cover={ metadata.CoverImagePath } data-cover={ metadata.CoverImagePath }
data-file-url={ metadata.FileURL } data-file-url={ metadata.FileURL }
data-keep-awake={ metadata.KeepScreenAwake } data-keep-awake={ metadata.KeepScreenAwake }
data-json={ fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`, data-json={ AudioReaderConfigJSON(metadata) }
"/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress") }
> >
<!-- §17 wind-down dim: the sleep ticker eases this toward 35% <!-- §17 wind-down dim: the sleep ticker eases this toward 35%
black across a countdown's final 30s; pointer-events-none black across a countdown's final 30s; pointer-events-none
@@ -55,6 +78,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<!-- Header: back to the detail page (reader-style exit) and the <!-- Header: back to the detail page (reader-style exit) and the
chapter launcher. Floating drawer, never in-flow (A2b). --> chapter launcher. Floating drawer, never in-flow (A2b). -->
<div class="absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3"> <div class="absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3">
<span class="inline-flex items-center gap-1">
<button <button
type="button" type="button"
@click="backToDetail()" @click="backToDetail()"
@@ -67,6 +91,24 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<path d="m12 19-7-7 7-7"></path> <path d="m12 19-7-7 7-7"></path>
</svg> </svg>
</button> </button>
<!-- Podcast sequence (D7): episode prev/next + "Ep N of M".
Rendered only when the server supplied sequence context. -->
if metadata.PodcastSequence != nil {
<span class="text-xs tabular-nums ml-1" style="color: var(--text-secondary)">
Ep { fmt.Sprintf("%d", metadata.PodcastSequence.EpisodeIndex) }/{ fmt.Sprintf("%d", metadata.PodcastSequence.IndexTotal) }
</span>
if metadata.PodcastSequence.PrevURL != "" {
<a href={ metadata.PodcastSequence.PrevURL } class="rounded-full p-2 hover:bg-surface-hover transition-colors" style="color: var(--text-primary);" aria-label="Previous episode" title="Previous episode">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 5h2v14H6zM20 5v14l-11-7z"></path></svg>
</a>
}
if metadata.PodcastSequence.NextURL != "" {
<a href={ metadata.PodcastSequence.NextURL } class="rounded-full p-2 hover:bg-surface-hover transition-colors" style="color: var(--text-primary);" aria-label={ "Next episode: " + metadata.PodcastSequence.NextTitle } title={ "Next: " + metadata.PodcastSequence.NextTitle }>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 5h2v14h-2zM4 5v14l11-7z"></path></svg>
</a>
}
}
</span>
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
<button <button
type="button" type="button"
+178 -51
View File
@@ -15,7 +15,32 @@ import templruntime "github.com/a-h/templ/runtime"
// <audio> element streams the m4b through the existing cookie-authed // <audio> element streams the m4b through the existing cookie-authed
// Range-serving /uploads path. // Range-serving /uploads path.
import "fmt" import (
"encoding/json"
"fmt"
)
// AudioReaderConfigJSON builds the reader's body[data-json] config: the
// chapter/progress endpoints plus, for podcast episodes, the sequence
// context (chain playback — plan D7). Books render the same JSON shape
// WITHOUT the sequence key, so the reader code path is identical.
func AudioReaderConfigJSON(metadata ReaderMetadata) string {
cfg := map[string]any{
"chapterEndpoint": "/api/media-items/" + metadata.MediaItemID + "/chapters",
"progressEndpoint": "/api/media-items/" + metadata.MediaItemID + "/progress",
}
if metadata.PodcastSequence != nil {
cfg["podcastSequence"] = metadata.PodcastSequence
}
b, err := json.Marshal(cfg)
if err != nil {
// Unreachable for these shapes; degrade to the book-only config.
return fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
"/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress")
}
return string(b)
}
func AudioReader(user User, metadata ReaderMetadata) templ.Component { func AudioReader(user User, metadata ReaderMetadata) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
@@ -45,7 +70,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 16, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -80,7 +105,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 39, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 64, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -93,7 +118,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 40, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 65, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -106,7 +131,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, Col: 39} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 66, Col: 39}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -119,7 +144,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 42, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 67, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -132,7 +157,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 43, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 68, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -143,120 +168,222 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`, templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(AudioReaderConfigJSON(metadata))
"/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 46, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 69, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts &amp; interactions\" title=\"Shortcuts &amp; interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-seLine truncated templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><span class=\"inline-flex items-center gap-1\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button><!-- Podcast sequence (D7): episode prev/next + \"Ep N of M\".\n\t\t\t\t\t Rendered only when the server supplied sequence context. -->")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, rate := range SpeedOptions { if metadata.PodcastSequence != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"text-xs tabular-nums ml-1\" style=\"color: var(--text-secondary)\">Ep ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate)) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", metadata.PodcastSequence.EpisodeIndex))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 41} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 98, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" :selected=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "/")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate)) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", metadata.PodcastSequence.IndexTotal))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 61} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 98, Col: 127}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string if metadata.PodcastSequence.PrevURL != "" {
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate)) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 171, Col: 37} return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(metadata.PodcastSequence.PrevURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 101, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Previous episode\" title=\"Previous episode\"><svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M6 5h2v14H6zM20 5v14l-11-7z\"></path></svg></a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if metadata.CoverImagePath != "" { if metadata.PodcastSequence.NextURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<img src=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(metadata.PodcastSequence.NextURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 36} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 106, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" alt=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next episode: " + metadata.PodcastSequence.NextTitle)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 186, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 106, Col: 220}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next: " + metadata.PodcastSequence.NextTitle)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 195, Col: 117} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 106, Col: 276}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"onScrubInput($event)\" @change=\"commitScrub()\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"railTickStyle(tick)\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button type=\"button\" class=\"inline-block min-w-[4.75rem] text-right cursor-pointer\" x-text=\"endTimeText()\" :aria-label=\"endTimeAria()\" :title=\"endTimeAria()\" @click=\"cycleEndTLine truncated templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M16 5h2v14h-2zM4 5v14l11-7z\"></path></svg></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts &amp; interactions\" title=\"Shortcuts &amp; interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Player settings</span> <button type=\"button\" @click=\"settingsOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 6 6 18\"></path> <path d=\"m6 6 12 12\"></path></svg></button></div><!-- Option rows: autoplay first, panel structured for more --><div class=\"flex items-center justify-between gap-3 px-1 py-2 rounded-lg\"><span class=\"text-sm\" style=\"color: var(--text-secondary);\">Autoplay on open</span> <button type=\"button\" role=\"switch\" :aria-checked=\"autoplayOn ? 'true' : 'false'\" @click=\"toggleAutoplay()\" class=\"relative h-5 w-9 rounded-full transition-colors shrink-0\" :style=\"autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'\" aria-label=\"Autoplay on open\"><span class=\Line truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, rate := range SpeedOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 211, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" :selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 212, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 213, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 227, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 228, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 237, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"onScrubInput($event)\" @change=\"commitScrub()\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"railTickStyle(tick)\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button type=\"button\" class=\"inline-block min-w-[4.75rem] text-right cursor-pointer\" x-text=\"endTimeText()\" :aria-label=\"endTimeAria()\" :title=\"endTimeAria()\" @click=\"cycleEndTLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -176,7 +176,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span> <span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
} }
</button> </button>
if user.Role == "admin" { if user.Role == "admin" && book.LibraryTypeName != "podcasts" {
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5"> <button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
@Icon("edit", "h-4 w-4") @Icon("edit", "h-4 w-4")
<span>Edit</span> <span>Edit</span>
+1 -1
View File
@@ -566,7 +566,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Role == "admin" { if user.Role == "admin" && book.LibraryTypeName != "podcasts" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<button @click=\"showMetadataEditor()\" class=\"btn btn-secondary px-5 py-2.5\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<button @click=\"showMetadataEditor()\" class=\"btn btn-secondary px-5 py-2.5\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
+1 -1
View File
@@ -303,7 +303,7 @@ templ BookShelf(
</button> </button>
</template> </template>
</div> </div>
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"> <div id="books-grid" class="grid grid-cols-3 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-4">
@BooksGrid(books, limit, offset, count, currentLibraryID) @BooksGrid(books, limit, offset, count, currentLibraryID)
if errorMessage != "" { if errorMessage != "" {
<div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);"> <div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);">
+1 -1
View File
@@ -406,7 +406,7 @@ func BookShelf(
} }
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware valueLine truncated templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware valueLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+26
View File
@@ -70,6 +70,12 @@ templ Header(user User, currentPath string) {
@Icon("layers", "h-5 w-5 shrink-0") @Icon("layers", "h-5 w-5 shrink-0")
<span>Series</span> <span>Series</span>
</a> </a>
if user.ShowPodcastsNav {
<a href="/podcasts" class={ activeClass(currentPath, "/podcasts") }>
@Icon("podcast", "h-5 w-5 shrink-0")
<span>Podcasts</span>
</a>
}
<a href="/collections" class={ activeClass(currentPath, "/collections") }> <a href="/collections" class={ activeClass(currentPath, "/collections") }>
@Icon("folder", "h-5 w-5 shrink-0") @Icon("folder", "h-5 w-5 shrink-0")
<span>Collections</span> <span>Collections</span>
@@ -346,4 +352,24 @@ templ SidebarSignIn(currentPath string) {
} }
</div> </div>
</div> </div>
<!-- Global confirm modal (issue #5): ONE styled dialog answers every
hx-confirm sitewide via the htmx:confirm interceptor in
web/src/confirm.ts, and exposes the confirmDialog() promise for
legacy JS call sites. Inert until shown. -->
<div
id="confirm-modal"
class="hidden fixed inset-0 z-[60] flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
>
<div class="card w-full max-w-sm" style="box-shadow: var(--shadow-pop);">
<div class="p-5 border-b" style="border-color: var(--border);">
<p id="confirm-modal-message" class="text-sm font-medium" style="color: var(--text-primary)"></p>
</div>
<div class="p-4 flex justify-end gap-2">
<button id="confirm-modal-cancel" class="btn btn-secondary">Cancel</button>
<button id="confirm-modal-accept" class="btn btn-primary">Confirm</button>
</div>
</div>
</div>
} }
+260 -228
View File
@@ -131,12 +131,13 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 = []any{activeClass(currentPath, "/collections")} if user.ShowPodcastsNav {
var templ_7745c5c3_Var8 = []any{activeClass(currentPath, "/podcasts")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"/collections\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"/podcasts\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -153,20 +154,21 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("folder", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("podcast", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span>Collections</span></a> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span>Podcasts</span></a> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 = []any{activeClass(currentPath, "/progress")} }
var templ_7745c5c3_Var10 = []any{activeClass(currentPath, "/collections")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"/progress\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"/collections\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -183,20 +185,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("clock", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("folder", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span>Progress</span></a> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span>Collections</span></a> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 = []any{activeClass(currentPath, "/stats")} var templ_7745c5c3_Var12 = []any{activeClass(currentPath, "/progress")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"/stats\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"/progress\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -213,20 +215,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("chart", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("clock", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span>Stats</span></a> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span>Progress</span></a> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 = []any{activeClass(currentPath, "/devices")} var templ_7745c5c3_Var14 = []any{activeClass(currentPath, "/stats")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"/devices\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"/stats\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -243,24 +245,54 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("chart", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span>Stats</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 = []any{activeClass(currentPath, "/devices")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<a href=\"/devices\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("device", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("device", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span>Devices</span></a></nav><!-- Bottom: theme picker + user --><div x-data=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span>Devices</span></a></nav><!-- Bottom: theme picker + user --><div x-data=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')") templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 92, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 98, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"shrink-0 px-3 py-3 space-y-2 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"shrink-0 px-3 py-3 space-y-2 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -275,7 +307,7 @@ func Header(user User, currentPath string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<!-- Theme picker --><div class=\"sidebar-panel\"><button type=\"button\" @click=\"toggleAppearancePanel()\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<!-- Theme picker --><div class=\"sidebar-panel\"><button type=\"button\" @click=\"toggleAppearancePanel()\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -283,7 +315,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span>Appearance</span> <span class=\"ml-auto h-4 w-4 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'appearance' }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span>Appearance</span> <span class=\"ml-auto h-4 w-4 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'appearance' }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -291,48 +323,14 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span></button><div x-show=\"openPanel === 'appearance'\" x-cloak x-transition class=\"mt-1 pl-1\"><!-- Dark / Light filter --><div class=\"grid grid-cols-2 gap-1 px-2 pt-1\"><button type=\"button\" @click=\"setThemeMode('dark')\" class=\"flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors\" :class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span></button><div x-show=\"openPanel === 'appearance'\" x-cloak x-transition class=\"mt-1 pl-1\"><!-- Dark / Light filter --><div class=\"grid grid-cols-2 gap-1 px-2 pt-1\"><button type=\"button\" @click=\"setThemeMode('dark')\" class=\"flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors\" :class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 121, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("moon", "h-3.5 w-3.5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span>Dark</span> <span class=\"tabular-nums opacity-50\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeDark)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 125, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span></button> <button type=\"button\" @click=\"setThemeMode('light')\" class=\"flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors\" :class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'") templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 131, Col: 105} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 127, Col: 104}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -342,116 +340,150 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("sun", "h-3.5 w-3.5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("moon", "h-3.5 w-3.5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span>Light</span> <span class=\"tabular-nums opacity-50\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span>Dark</span> <span class=\"tabular-nums opacity-50\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeLight))) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeDark)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 135, Col: 79} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 131, Col: 78}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span></button></div><!-- Scrollable theme list (filtered by the mode above) --><div class=\"mt-1 max-h-72 overflow-y-auto pr-1 space-y-0.5\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span></button> <button type=\"button\" @click=\"setThemeMode('light')\" class=\"flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors\" :class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, opt := range ThemeOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<button type=\"button\" @click=\"selectTheme(opt.Name)\" class=\"theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" x-show=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === '" + string(opt.Mode) + "'") templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 145, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 137, Col: 105}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-theme=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("sun", "h-3.5 w-3.5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span>Light</span> <span class=\"tabular-nums opacity-50\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeLight)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 146, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 141, Col: 79}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" data-mode=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span></button></div><!-- Scrollable theme list (filtered by the mode above) --><div class=\"mt-1 max-h-72 overflow-y-auto pr-1 space-y-0.5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, opt := range ThemeOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<button type=\"button\" @click=\"selectTheme(opt.Name)\" class=\"theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" x-show=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(opt.Mode)) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === '" + string(opt.Mode) + "'")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 147, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 151, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" style=\"color: var(--text-secondary);\"><span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" data-theme=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 150, Col: 131} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 152, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"></span> <span class=\"flex-1 text-left\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" data-mode=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(opt.Mode))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 151, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 153, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" style=\"color: var(--text-secondary);\"><span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var26 = []any{"theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme)} var templ_7745c5c3_Var26 string
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...) templ_7745c5c3_Var26, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 156, Col: 131}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"></span> <span class=\"flex-1 text-left\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var27 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var26).String()) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 157, Col: 51}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 = []any{"theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var28).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -459,94 +491,94 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span></button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span></button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div><div class=\"my-1.5 mx-3 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"></div><p class=\"px-3 pb-1 text-xs font-medium uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Bookshelf</p>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div><div class=\"my-1.5 mx-3 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"></div><p class=\"px-3 pb-1 text-xs font-medium uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Bookshelf</p>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, w := range WoodOptions { for _, w := range WoodOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<button type=\"button\" @click=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<button type=\"button\" @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 163, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" class=\"wood-paneling-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors\" data-wood=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 165, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if w.Name == "none" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);\"></span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var30 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;") templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 171, Col: 191} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 169, Col: 55}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"></span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" class=\"wood-paneling-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors\" data-wood=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"flex-1 text-left\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var31 string var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label) templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 173, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 171, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span></button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if w.Name == "none" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);\"></span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 177, Col: 191}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\"></span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></div><!-- Admin section (visible to admins only) -->") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"flex-1 text-left\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 179, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</span></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div></div><!-- Admin section (visible to admins only) -->")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Role == "admin" { if user.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'admin' ? null : 'admin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'admin' ? null : 'admin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -554,46 +586,16 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span>Administration</span> <svg class=\"h-4 w-4 ml-auto transition-transform\" :class=\"{ 'rotate-180': openPanel === 'admin' }\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M19 9l-7 7-7-7\"></path></svg></button><div x-show=\"openPanel === 'admin'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span>Administration</span> <svg class=\"h-4 w-4 ml-auto transition-transform\" :class=\"{ 'rotate-180': openPanel === 'admin' }\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M19 9l-7 7-7-7\"></path></svg></button><div x-show=\"openPanel === 'admin'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var32 = []any{activeClass(currentPath, "/admin")} var templ_7745c5c3_Var34 = []any{activeClass(currentPath, "/admin")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<a href=\"/admin\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var32).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("grid", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span>Dashboard</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 = []any{activeClass(currentPath, "/admin/library")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<a href=\"/admin/library\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<a href=\"/admin\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -610,20 +612,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("library", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("grid", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<span>Libraries</span></a> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<span>Dashboard</span></a> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var36 = []any{activeClass(currentPath, "/admin/hash-conflicts")} var templ_7745c5c3_Var36 = []any{activeClass(currentPath, "/admin/library")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<a href=\"/admin/hash-conflicts\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<a href=\"/admin/library\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -640,20 +642,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("copy", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("library", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<span>Hash Conflicts</span></a> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<span>Libraries</span></a> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/users")} var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/hash-conflicts")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<a href=\"/admin/users\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<a href=\"/admin/hash-conflicts\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -670,20 +672,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("copy", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span>Users</span></a> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span>Hash Conflicts</span></a> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/settings")} var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/users")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var40...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var40...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<a href=\"/admin/settings\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<a href=\"/admin/users\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -700,16 +702,46 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span>Users</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 = []any{activeClass(currentPath, "/admin/settings")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<a href=\"/admin/settings\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var42).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span>Settings</span></a></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<span>Settings</span></a></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</div></aside><!-- Topbar --><header class=\"app-topbar\"><div class=\"flex items-center gap-3 px-4 sm:px-6 h-full\"><button type=\"button\" class=\"app-mobile-only icon-btn\" @click=\"mobileMenuOpen = true\" aria-label=\"Open menu\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</div></aside><!-- Topbar --><header class=\"app-topbar\"><div class=\"flex items-center gap-3 px-4 sm:px-6 h-full\"><button type=\"button\" class=\"app-mobile-only icon-btn\" @click=\"mobileMenuOpen = true\" aria-label=\"Open menu\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -717,7 +749,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</button><div class=\"relative flex-1 max-w-xl\" x-data=\"headerSearch\" @click.outside=\"close()\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</button><div class=\"relative flex-1 max-w-xl\" x-data=\"headerSearch\" @click.outside=\"close()\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -725,20 +757,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <input type=\"text\" id=\"header-search\" name=\"q\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\" x-ref=\"input\" role=\"combobox\" :aria-expanded=\"open ? 'true' : 'false'\" aria-label=\"Search your library\" @input=\"onInput()\" @focus=\"onFocus()\" @keydown.arrow-down.prevent=\"moveSelection(1)\" @keydown.arrow-up.prevent=\"moveSelection(-1)\" @keydown.enter.prevent=\"openSelected()\" @keydown.escape=\"close()\" hx-get=\"/api/media-items/search?layout=dropdown\" hx-trigger=\"search\" hx-target=\"#search-results\" hx-swap=\"innerHTML\"><div id=\"search-results\" x-show=\"open\" x-cloak class=\"absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"></div></div></div></header><!-- module bundle; ?v=<content hash> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL);\n\t\t the hash is computed at startup from the served file --><script type=\"module\" src=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span> <input type=\"text\" id=\"header-search\" name=\"q\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\" x-ref=\"input\" role=\"combobox\" :aria-expanded=\"open ? 'true' : 'false'\" aria-label=\"Search your library\" @input=\"onInput()\" @focus=\"onFocus()\" @keydown.arrow-down.prevent=\"moveSelection(1)\" @keydown.arrow-up.prevent=\"moveSelection(-1)\" @keydown.enter.prevent=\"openSelected()\" @keydown.escape=\"close()\" hx-get=\"/api/media-items/search?layout=dropdown\" hx-trigger=\"search\" hx-target=\"#search-results\" hx-swap=\"innerHTML\"><div id=\"search-results\" x-show=\"open\" x-cloak class=\"absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"></div></div></div></header><!-- module bundle; ?v=<content hash> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL);\n\t\t the hash is computed at startup from the served file --><script type=\"module\" src=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var42 string var templ_7745c5c3_Var44 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion()) templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 272, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 278, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\"></script></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\"></script></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -763,12 +795,12 @@ func SidebarUserMenu(user User) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var43 := templ.GetChildren(ctx) templ_7745c5c3_Var45 := templ.GetChildren(ctx)
if templ_7745c5c3_Var43 == nil { if templ_7745c5c3_Var45 == nil {
templ_7745c5c3_Var43 = templ.NopComponent templ_7745c5c3_Var45 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -776,20 +808,20 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span> <span class=\"flex-1 text-left truncate\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span> <span class=\"flex-1 text-left truncate\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var44 string var templ_7745c5c3_Var46 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 288, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 294, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -797,7 +829,7 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -805,7 +837,7 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -813,7 +845,7 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span>Logout</span></button></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<span>Logout</span></button></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -839,12 +871,12 @@ func SidebarSignIn(currentPath string) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var45 := templ.GetChildren(ctx) templ_7745c5c3_Var47 := templ.GetChildren(ctx)
if templ_7745c5c3_Var45 == nil { if templ_7745c5c3_Var47 == nil {
templ_7745c5c3_Var45 = templ.NopComponent templ_7745c5c3_Var47 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -852,7 +884,7 @@ func SidebarSignIn(currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -860,30 +892,30 @@ func SidebarSignIn(currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var46 string var templ_7745c5c3_Var48 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath) templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 336, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 342, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if SignupVisible() { if SignupVisible() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</div></div><!-- Global confirm modal (issue #5): ONE styled dialog answers every\n\t hx-confirm sitewide via the htmx:confirm interceptor in\n\t web/src/confirm.ts, and exposes the confirmDialog() promise for\n\t legacy JS call sites. Inert until shown. --><div id=\"confirm-modal\" class=\"hidden fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-sm\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"p-5 border-b\" style=\"border-color: var(--border);\"><p id=\"confirm-modal-message\" class=\"text-sm font-medium\" style=\"color: var(--text-primary)\"></p></div><div class=\"p-4 flex justify-end gap-2\"><button id=\"confirm-modal-cancel\" class=\"btn btn-secondary\">Cancel</button> <button id=\"confirm-modal-accept\" class=\"btn btn-primary\">Confirm</button></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+6
View File
@@ -73,6 +73,12 @@ templ Icon(name string, extra string) {
case "library": case "library":
<path d="M3 21h18"></path> <path d="M3 21h18"></path>
<path d="M5 21V7l7-4 7 4v14"></path> <path d="M5 21V7l7-4 7 4v14"></path>
case "podcast":
<path d="M12 9a3 3 0 0 0-3 3v1a3 3 0 0 0 6 0v-1a3 3 0 0 0-3-3z"></path>
<path d="M6.3 6.3a8 8 0 0 0 0 11.4"></path>
<path d="M17.7 6.3a8 8 0 0 1 0 11.4"></path>
<path d="M3.5 3.5a12 12 0 0 0 0 17"></path>
<path d="M20.5 3.5a12 12 0 0 1 0 17"></path>
case "layers": case "layers":
<path d="m12 3 9 5-9 5-9-5 9-5z"></path> <path d="m12 3 9 5-9 5-9-5 9-5z"></path>
<path d="m3 13 9 5 9-5"></path> <path d="m3 13 9 5 9-5"></path>
+46 -41
View File
@@ -161,208 +161,213 @@ func Icon(name string, extra string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "podcast":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<path d=\"M12 9a3 3 0 0 0-3 3v1a3 3 0 0 0 6 0v-1a3 3 0 0 0-3-3z\"></path> <path d=\"M6.3 6.3a8 8 0 0 0 0 11.4\"></path> <path d=\"M17.7 6.3a8 8 0 0 1 0 11.4\"></path> <path d=\"M3.5 3.5a12 12 0 0 0 0 17\"></path> <path d=\"M20.5 3.5a12 12 0 0 1 0 17\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "layers": case "layers":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<path d=\"m12 3 9 5-9 5-9-5 9-5z\"></path> <path d=\"m3 13 9 5 9-5\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<path d=\"m12 3 9 5-9 5-9-5 9-5z\"></path> <path d=\"m3 13 9 5 9-5\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "folder": case "folder":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<path d=\"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<path d=\"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "chart": case "chart":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<path d=\"M3 3v18h18\"></path> <path d=\"M7 15v3M12 11v7M17 7v11\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<path d=\"M3 3v18h18\"></path> <path d=\"M7 15v3M12 11v7M17 7v11\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "device": case "device":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<rect x=\"7\" y=\"2\" width=\"10\" height=\"20\" rx=\"2\"></rect> <path d=\"M11 18h2\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<rect x=\"7\" y=\"2\" width=\"10\" height=\"20\" rx=\"2\"></rect> <path d=\"M11 18h2\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "trash": case "trash":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<path d=\"M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<path d=\"M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "save": case "save":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<path d=\"M5 3h11l3 3v15H5z\"></path> <path d=\"M8 3v6h7V3\"></path> <path d=\"M8 14h8v7H8z\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<path d=\"M5 3h11l3 3v15H5z\"></path> <path d=\"M8 3v6h7V3\"></path> <path d=\"M8 14h8v7H8z\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "filter": case "filter":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<path d=\"M3 4h18l-7 8v6l-4 2v-8z\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<path d=\"M3 4h18l-7 8v6l-4 2v-8z\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "check": case "check":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<path d=\"m4 12 5 5 11-11\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<path d=\"m4 12 5 5 11-11\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "check-circle": case "check-circle":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m8 12 3 3 5-6\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m8 12 3 3 5-6\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "x-circle": case "x-circle":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m9 9 6 6M15 9l-6 6\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m9 9 6 6M15 9l-6 6\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "circle": case "circle":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<circle cx=\"12\" cy=\"12\" r=\"8\"></circle>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<circle cx=\"12\" cy=\"12\" r=\"8\"></circle>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "bookmark": case "bookmark":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<path d=\"M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<path d=\"M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "star": case "star":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<path d=\"m12 3 2.7 5.5 6 .9-4.3 4.2 1 6L12 17l-5.4 2.6 1-6L3.3 9.4l6-.9z\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<path d=\"m12 3 2.7 5.5 6 .9-4.3 4.2 1 6L12 17l-5.4 2.6 1-6L3.3 9.4l6-.9z\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "arrow-right": case "arrow-right":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<path d=\"M5 12h14M13 6l6 6-6 6\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<path d=\"M5 12h14M13 6l6 6-6 6\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "arrow-left": case "arrow-left":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<path d=\"M19 12H5M11 6l-6 6 6 6\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<path d=\"M19 12H5M11 6l-6 6 6 6\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "home": case "home":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<path d=\"M3 11 12 3l9 8\"></path> <path d=\"M5 10v10h14V10\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<path d=\"M3 11 12 3l9 8\"></path> <path d=\"M5 10v10h14V10\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "logout": case "logout":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<path d=\"M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3\"></path> <path d=\"M10 17l5-5-5-5\"></path> <path d=\"M15 12H3\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<path d=\"M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3\"></path> <path d=\"M10 17l5-5-5-5\"></path> <path d=\"M15 12H3\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "plus": case "plus":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<path d=\"M12 5v14M5 12h14\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<path d=\"M12 5v14M5 12h14\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "edit": case "edit":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<path d=\"M4 20h4L18 10l-4-4L4 16z\"></path> <path d=\"m14 6 4 4\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<path d=\"M4 20h4L18 10l-4-4L4 16z\"></path> <path d=\"m14 6 4 4\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "info": case "info":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 11v5M12 8h.01\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 11v5M12 8h.01\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "alert": case "alert":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<path d=\"M12 3 2 20h20z\"></path> <path d=\"M12 10v4M12 17h.01\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<path d=\"M12 3 2 20h20z\"></path> <path d=\"M12 10v4M12 17h.01\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "clock": case "clock":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 7v5l3 2\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 7v5l3 2\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "sync": case "sync":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<path d=\"M3 8a9 9 0 0 1 15-3l3 3\"></path> <path d=\"M21 16a9 9 0 0 1-15 3l-3-3\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<path d=\"M3 8a9 9 0 0 1 15-3l3 3\"></path> <path d=\"M21 16a9 9 0 0 1-15 3l-3-3\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "download": case "download":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<path d=\"M12 3v12M7 10l5 5 5-5\"></path> <path d=\"M5 21h14\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<path d=\"M12 3v12M7 10l5 5 5-5\"></path> <path d=\"M5 21h14\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "upload": case "upload":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<path d=\"M12 21V9M7 14l5-5 5 5\"></path> <path d=\"M5 3h14\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<path d=\"M12 21V9M7 14l5-5 5 5\"></path> <path d=\"M5 3h14\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "copy": case "copy":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2\"></rect> <path d=\"M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2\"></rect> <path d=\"M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "external": case "external":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<path d=\"M14 4h6v6\"></path> <path d=\"M20 4 10 14\"></path> <path d=\"M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<path d=\"M14 4h6v6\"></path> <path d=\"M20 4 10 14\"></path> <path d=\"M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "list": case "list":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "grid": case "grid":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "play": case "play":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<path d=\"M6 4l14 8-14 8z\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<path d=\"M6 4l14 8-14 8z\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "heart": case "heart":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<path d=\"M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<path d=\"M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "calendar": case "calendar":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\"></rect> <path d=\"M3 9h18M8 2v4M16 2v4\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\"></rect> <path d=\"M3 9h18M8 2v4M16 2v4\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "tag": case "tag":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<path d=\"M3 11V5a2 2 0 0 1 2-2h6l9 9-8 8-9-9z\"></path> <circle cx=\"7.5\" cy=\"7.5\" r=\"1\"></circle>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<path d=\"M3 11V5a2 2 0 0 1 2-2h6l9 9-8 8-9-9z\"></path> <circle cx=\"7.5\" cy=\"7.5\" r=\"1\"></circle>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "globe": case "globe":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "shield": case "shield":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<path d=\"M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<path d=\"M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "database": case "database":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"></ellipse> <path d=\"M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"></path> <path d=\"M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"></ellipse> <path d=\"M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"></path> <path d=\"M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "headphones": case "headphones":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<path d=\"M4 14v-2a8 8 0 0 1 16 0v2\"></path> <rect x=\"3\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect> <rect x=\"17\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<path d=\"M4 14v-2a8 8 0 0 1 16 0v2\"></path> <rect x=\"3\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect> <rect x=\"17\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "comic": case "comic":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<path d=\"M4 4h16v12l-3 4H7l-3-4z\"></path> <path d=\"M4 9h16M9 9v11\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<path d=\"M4 4h16v12l-3 4H7l-3-4z\"></path> <path d=\"M4 9h16M9 9v11\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "manga": case "manga":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<path d=\"M4 4h16v13a3 3 0 0 1-3 3H4z\"></path> <path d=\"M4 4v16M10 4h10v8\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<path d=\"M4 4h16v13a3 3 0 0 1-3 3H4z\"></path> <path d=\"M4 4v16M10 4h10v8\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
default: default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</svg>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</svg>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+7
View File
@@ -26,6 +26,13 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
> >
<option value="">All Libraries ({ TotalMediaCount(libData) })</option> <option value="">All Libraries ({ TotalMediaCount(libData) })</option>
for _, lib := range libData { for _, lib := range libData {
// Podcasts libraries are feed-managed (plan D13): never
// in the book-library switcher — feeds surface via
// /podcasts only. Presentation-layer filter so every
// switcher consumer is correct at once.
if !IsBookLibrary(lib.TypeName) {
continue
}
if lib.Offline { if lib.Offline {
if lib.ID == currentLibraryID { if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount }) — storage offline</option> <option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
+49 -35
View File
@@ -82,187 +82,201 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, lib := range libData { for _, lib := range libData {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !IsBookLibrary(lib.TypeName) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "continue")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if lib.Offline { if lib.Offline {
if lib.ID == currentLibraryID { if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" selected>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" selected>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 51}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 71} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ") — storage offline</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ") — storage offline</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 40, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 40, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 40, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ") — storage offline</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ") — storage offline</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
} else if lib.ID == currentLibraryID { } else if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 43, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" selected>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" selected>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 50} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 43, Col: 50}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 43, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ")</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ")</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 45, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 41} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 45, Col: 41}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 61} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 45, Col: 61}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ")</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ")</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</select></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</select></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -273,7 +287,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
} }
} }
if len(actions) > 0 { if len(actions) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"flex items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"flex items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -283,12 +297,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 flex items-center justify-center z-50\" style=\"background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 flex items-center justify-center z-50\" style=\"background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -317,7 +331,7 @@ func DashboardActions() templ.Component {
templ_7745c5c3_Var16 = templ.NopComponent templ_7745c5c3_Var16 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -325,7 +339,7 @@ func DashboardActions() templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -333,7 +347,7 @@ func DashboardActions() templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+434
View File
@@ -0,0 +1,434 @@
package templates
// Feed detail page (plan task 1.9, D5/D7/D8/D19): hero (artwork,
// metadata, admin cluster), action row (Download All, Refresh, order
// override, subscription policy, admin delete), and the episode list
// with the download lifecycle inline. The episode list is a self-
// refreshing htmx partial: it re-fetches on the podcast:changed event
// AND polls every 4s ONLY while downloads are active (server decides —
// the polling attributes render only then).
import "fmt"
templ PodcastDetailPage(user User, data PodcastDetailData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ data.Feed.DisplayTitle() } - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="podcastDetail" class={ "theme-" + user.Theme }>
@Header(user, "/podcasts")
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<a href="/podcasts" class="inline-flex items-center gap-1 text-sm mb-4 hover:underline" style="color: var(--text-secondary)">
@Icon("arrow-left", "h-4 w-4")
<span>Podcasts</span>
</a>
<!-- Hero -->
<div class="flex flex-col sm:flex-row gap-6 mb-8">
<div class="h-44 w-44 rounded-xl shrink-0 overflow-hidden grid place-items-center" style="background-color: var(--surface-secondary); color: var(--text-secondary);">
if data.Feed.ArtworkPath != "" {
<img
src={ "/uploads/library-" + data.Feed.LibraryID + "/" + data.Feed.ArtworkPath }
alt={ "Cover for " + data.Feed.DisplayTitle() }
class="h-full w-full object-cover"
data-image-fallback
/>
} else {
@Icon("podcast", "h-12 w-12")
}
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent)">{ data.Feed.TypeLabel() }</span>
if data.Feed.IsComplete {
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">complete</span>
}
if data.Feed.Language != "" {
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">{ data.Feed.Language }</span>
}
</div>
<h1 class="text-2xl font-bold tracking-tight mb-1" style="color: var(--text-primary)">{ data.Feed.DisplayTitle() }</h1>
if data.Feed.Host != "" || data.Feed.Publisher != "" {
<p class="text-sm mb-2" style="color: var(--text-secondary)">
if data.Feed.Host != "" {
{ "Host: " + data.Feed.Host }
}
if data.Feed.Publisher != "" && data.Feed.Publisher != data.Feed.Host {
{ " · Publisher: " + data.Feed.Publisher }
}
</p>
}
if data.Feed.Description != "" {
<p class="text-sm line-clamp-3 mb-2" style="color: var(--text-secondary)">{ data.Feed.Description }</p>
}
if len(data.Feed.Tags) > 0 {
<div class="flex flex-wrap gap-1.5 mb-2">
for _, t := range data.Feed.Tags {
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">{ t }</span>
}
</div>
}
<p class="text-xs" style="color: var(--text-secondary)">
{ podcastInt(data.Feed.DownloadedN) + " of " + podcastInt(data.Feed.EpisodeTotal) + " episodes downloaded" }
if data.Feed.LastFetched != "" {
{ " · last checked " + data.Feed.LastFetched }
}
</p>
</div>
</div>
<!-- Actions -->
<div class="flex flex-wrap items-center gap-2 mb-6">
<button
class="btn btn-primary"
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/episodes/all/download" }
hx-target="#episode-list"
hx-swap="outerHTML"
title="Queue every episode that is not downloaded yet"
>
@Icon("save", "h-4 w-4")
<span>Download All</span>
</button>
<button
class="btn btn-secondary"
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/refresh" }
hx-target="#episode-list"
hx-swap="outerHTML"
title="Check the feed for new episodes now"
>
@Icon("refresh", "h-4 w-4")
<span>Refresh</span>
</button>
<!-- Order override (D5): per-user; refreshes the episode list -->
<label class="text-sm ml-2" for="podcast-order-select" style="color: var(--text-secondary)">Order</label>
<select
id="podcast-order-select"
name="order"
class="input w-auto py-1.5 cursor-pointer"
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
hx-target="#episode-list"
hx-swap="outerHTML"
>
<option value="native" selected={ data.Order == "native" }>Feed order</option>
<option value="oldest" selected={ data.Order == "oldest" }>Oldest first</option>
<option value="newest" selected={ data.Order == "newest" }>Newest first</option>
</select>
<!-- Serial suggestion (D5): one-click, never silent -->
if data.IsSerial && data.Order == "native" {
<button
class="btn btn-secondary text-sm"
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
hx-vals='{"order": "oldest"}'
hx-target="#episode-list"
hx-swap="outerHTML"
title="Serial feed: publishers list newest first — listen oldest-first"
>
<span>Serial? Listen oldest-first</span>
</button>
}
<!-- Auto-download policy (D6) -->
<label class="text-sm ml-2" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label>
@PodcastAutoDownloadSelect(data.Feed)
if data.Feed.AutoDownload == "newest" {
<input
type="number"
id="podcast-autodl-count"
name="auto_download_count"
min="1"
max="100"
value={ podcastInt(data.Feed.AutoDownloadCount) }
class="input w-20 py-1.5"
aria-label="Newest N episodes"
/>
}
<!-- Unsubscribe (issues #4/#7): the sole subscriber who is
an admin gets the three-choice modal (unsubscribe /
remove feed / cancel); everyone else gets a plain
confirm — file retention is admin knowledge only. -->
if data.IsAdmin && data.SubscriberCount <= 1 {
<button class="btn btn-secondary ml-auto" @click="unsubOpen = true">
<span>Unsubscribe</span>
</button>
} else if data.IsAdmin {
<button
class="btn btn-secondary ml-auto"
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
hx-confirm="Unsubscribe from this feed? Episodes and files are kept."
hx-swap="none"
>
<span>Unsubscribe</span>
</button>
} else {
<button
class="btn btn-secondary ml-auto"
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
hx-confirm="Unsubscribe from this feed?"
hx-swap="none"
>
<span>Unsubscribe</span>
</button>
}
if data.IsAdmin {
<button
class="btn btn-secondary"
style="color: var(--status-danger);"
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID }
hx-confirm="ADMIN: remove this feed for everyone? Episode rows AND downloaded files are deleted."
hx-swap="none"
>
@Icon("trash", "h-4 w-4")
<span>Remove Feed</span>
</button>
}
</div>
<!-- Gap banner (D8): middle episodes missing -->
if data.GapCount > 0 {
<div class="mb-4 card p-4 flex items-center gap-3" style="border-color: var(--status-warning, #d97706);">
@Icon("circle", "h-5 w-5")
<div>
<p class="font-semibold text-sm" style="color: var(--text-primary)">{ podcastInt(data.GapCount) + " episode(s) not downloaded in the middle of this feed" }</p>
<p class="text-xs" style="color: var(--text-secondary)">Playback skips them and keeps going — use Download All to fill the gaps.</p>
</div>
</div>
}
<!-- Episode list (self-refreshing htmx partial) -->
@PodcastEpisodeList(data)
</main>
@PodcastUnsubscribeModal(data)
</body>
</html>
}
// PodcastEpisodeList is the refresh unit: re-fetched on the
// podcast:changed event (download/refresh/order endpoints fire it) and
// polled every 4s only while the server says downloads are active.
templ PodcastEpisodeList(data PodcastDetailData) {
<div
id="episode-list"
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
hx-trigger="podcast:changed from:body"
hx-swap="outerHTML"
>
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
if len(data.Episodes) == 0 {
<div class="card p-6 text-center text-sm" style="color: var(--text-secondary)">
No episodes yet — hit Refresh to check the feed.
</div>
} else {
<div class="flex flex-col gap-2">
for _, ep := range data.Episodes {
@PodcastEpisodeRowView(ep)
}
</div>
}
</div>
}
// PodcastEpisodeListPolling renders the poll-active variant of the list
// container (same content; the hx-trigger adds the 4s poll + load). The
// SSR episode route picks one of the two by download activity.
templ PodcastEpisodeListPolling(data PodcastDetailData) {
<div
id="episode-list"
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
hx-trigger="podcast:changed from:body, every 2s"
hx-swap="outerHTML"
>
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
<div class="flex flex-col gap-2">
for _, ep := range data.Episodes {
@PodcastEpisodeRowView(ep)
}
</div>
</div>
}
templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
<div class="card p-3 flex items-center gap-3">
<span class="text-xs font-mono w-10 shrink-0 text-right" style="color: var(--text-secondary)">
{ podcastInt(ep.Position) }
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<a href={ ep.PlayURL } class="font-medium text-sm hover:underline line-clamp-1" style="color: var(--text-primary)">{ ep.Title }</a>
if ep.DownloadState == "failed" {
<span class="badge shrink-0" style="background-color: color-mix(in srgb, var(--status-danger) 15%, transparent); color: var(--status-danger);">{ ep.StateBadge() }</span>
} else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" {
<span class="badge shrink-0 animate-pulse" style="background-color: var(--accent-muted); color: var(--accent);">{ ep.StateBadge() }</span>
} else if ep.DownloadState == "pending" {
<span class="badge shrink-0" style="background-color: var(--surface-secondary); color: var(--text-secondary);">{ ep.StateBadge() }</span>
}
</div>
if ep.DownloadError != "" {
<p class="text-xs mt-0.5 line-clamp-2" style="color: var(--status-danger)">{ ep.DownloadError }</p>
}
if ep.DownloadState == "downloading" && ep.ProgressKnown {
<div class="mt-1.5">
if ep.ProgressTotal > 0 {
<div class="h-1.5 w-full rounded-full overflow-hidden" style="background-color: var(--surface-secondary);">
<div class="h-full rounded-full transition-all" style={ progressWidth(ep.ProgressPct) + " background-color: var(--accent);" }></div>
</div>
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">
{ FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal) }
</p>
} else {
<div class="h-1.5 w-full rounded-full overflow-hidden animate-pulse" style="background-color: var(--surface-secondary);">
<div class="h-full w-1/3 rounded-full" style="background-color: var(--accent);"></div>
</div>
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">{ FormatEpisodeSize(ep.ProgressReceived) }</p>
}
</div>
}
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
{ ep.Date }
if ep.Duration != "" {
{ " · " + ep.Duration }
}
if ep.Size != "" {
{ " · " + ep.Size }
}
</p>
</div>
if ep.Downloaded {
<a href={ ep.PlayURL } class="btn btn-secondary shrink-0" title="Play — chains into the next downloaded episode (append ?single=1 to the URL to play just this one)">
@Icon("book-open", "h-4 w-4")
<span>Play</span>
</a>
} else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" {
<span class="btn btn-secondary shrink-0 pointer-events-none opacity-60" aria-busy="true">
@Icon("refresh", "h-4 w-4")
</span>
} else {
<!-- pending AND failed: (re)download — same endpoint, idempotent -->
<button
class={ "btn shrink-0", templ.KV("btn-secondary", ep.DownloadState != "failed"), templ.KV("btn-primary", ep.DownloadState == "failed") }
hx-post={ "/api/podcasts/feeds/" + ep.FeedID + "/episodes/" + ep.ID + "/download" }
hx-swap="none"
title={ downloadButtonTitle(ep.DownloadState) }
>
@Icon("save", "h-4 w-4")
<span>{ downloadButtonLabel(ep.DownloadState) }</span>
</button>
}
</div>
}
// downloadButtonLabel: pending → Download; failed → Retry (the reason
// text above it explains what went wrong — D8/D19 inline failure UX).
func downloadButtonLabel(state string) string {
if state == "failed" {
return "Retry"
}
return "Download"
}
// progressWidth renders the bar width as a quote-free inline style
// fragment (templ gotcha #8: style attribute values must not carry
// quote characters).
func progressWidth(pct float64) string {
if pct < 1 {
pct = 1
}
if pct > 100 {
pct = 100
}
return fmt.Sprintf("width: %.1f%%;", pct)
}
func downloadButtonTitle(state string) string {
if state == "failed" {
return "Retry the download"
}
return "Download this episode"
}
// PodcastAutoDownloadSelect is the policy dropdown (issue #3): extracted
// so the Download All response can out-of-band-swap it after flipping
// the caller's policy — the dropdown must reflect what the user gets.
templ PodcastAutoDownloadSelect(feed PodcastFeedCard) {
<select
id="podcast-autodl-select"
name="auto_download"
class="input w-auto py-1.5 cursor-pointer"
hx-put={ "/api/podcasts/feeds/" + feed.ID + "/subscription" }
hx-target="#episode-list"
hx-swap="outerHTML"
>
<option value="off" selected={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
<option value="all" selected={ feed.AutoDownload == "all" }>All episodes</option>
<option value="newest" selected={ feed.AutoDownload == "newest" }>Newest</option>
</select>
}
// PodcastAutoDownloadSelectOOB is the out-of-band variant: same element
// id + hx-swap-oob so htmx replaces the action-row select in place.
templ PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) {
<select
id="podcast-autodl-select"
name="auto_download"
hx-swap-oob="true"
class="input w-auto py-1.5 cursor-pointer"
hx-put={ "/api/podcasts/feeds/" + feed.ID + "/subscription" }
hx-target="#episode-list"
hx-swap="outerHTML"
>
<option value="off" selected={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
<option value="all" selected={ feed.AutoDownload == "all" }>All episodes</option>
<option value="newest" selected={ feed.AutoDownload == "newest" }>Newest</option>
</select>
}
// PodcastUnsubscribeModal is the last-subscriber admin choice (issue
// #7): unsubscribe (files kept — the D6 default), remove feed + files
// entirely, or cancel. Reachable only for admins (the button that opens
// it renders for admins alone).
templ PodcastUnsubscribeModal(data PodcastDetailData) {
<div
id="podcast-unsub-modal"
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
style="background-color: var(--surface-overlay);"
x-show="unsubOpen"
x-cloak
@click.self="unsubOpen = false"
>
<div class="card w-full max-w-md" style="box-shadow: var(--shadow-pop);">
<div class="p-6 border-b" style="border-color: var(--border);">
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Unsubscribe from { data.Feed.DisplayTitle() }?</h2>
<p class="text-sm mt-1" style="color: var(--text-secondary)">You are the only subscriber.</p>
</div>
<div class="p-6 flex flex-col gap-2">
<button
class="btn btn-secondary w-full"
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
hx-swap="none"
@click="unsubOpen = false"
>
<span>Unsubscribe — files kept</span>
</button>
<button
class="btn btn-secondary w-full"
style="color: var(--status-danger);"
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID }
hx-swap="none"
@click="unsubOpen = false"
>
<span>Remove feed — delete episodes and files</span>
</button>
<button type="button" class="btn btn-secondary w-full" @click="unsubOpen = false">Cancel</button>
</div>
</div>
</div>
}
File diff suppressed because it is too large. Load diff
+230
View File
@@ -0,0 +1,230 @@
// Podcast template view types (plan task 1.9): the SSR shape shared by
// the /podcasts page and the feed-detail page. The server formats
// everything display-ready (dates in the requesting user's timezone —
// D17; durations human) so templates stay logic-free.
package templates
import (
"fmt"
"sort"
"strings"
"time"
"bookhoard/internal/utils"
)
// PodcastFeedCard is one grid card on /podcasts (or the hero on the
// detail page).
type PodcastFeedCard struct {
ID string
LibraryID string
Title string
DisplayName string // subscription-owned; renders instead of Title when set
Description string
Host string
Publisher string
Language string
Tags []string
Explicit bool
ArtworkPath string
FeedType string // serial | episodic
Medium string
IsComplete bool
FeedURL string
LastFetched string // display-formatted
SubscribedAt string
EpisodeTotal int
DownloadedN int
UndownloadedN int
NewEpisodes bool
// Subscription-owned policy (detail page settings block).
AutoDownload string
AutoDownloadCount int
Subscribed bool
// EpisodeMatches (issue #8): when a search query is active, the
// number of the feed's episodes whose title/description matched —
// feeds match on EPISODES too, not just their own metadata.
EpisodeMatches int
// Continue aggregation (plan 2.5): the episode the user is midway
// through, most recent first across feeds.
HasContinue bool
ContinueTitle string
ContinueEpisode int
ContinuePercent float64
ContinueLastRead time.Time // section sort key (most recent first)
}
// DisplayTitle renders the subscription display name when set (D9).
func (c PodcastFeedCard) DisplayTitle() string {
if c.DisplayName != "" {
return c.DisplayName
}
return c.Title
}
// TypeLabel renders the feed's shape for the card badge.
func (c PodcastFeedCard) TypeLabel() string {
if c.FeedType == "serial" {
return "Serial"
}
if c.Medium != "" {
return strings.ToUpper(c.Medium[:1]) + c.Medium[1:]
}
return "Podcast"
}
// PodcastEpisodeRow is one episode-list line with the download
// lifecycle inline (D8/D19: pending greyed + download button,
// queued/downloading spinner, failed/rejected red + reason + retry).
type PodcastEpisodeRow struct {
ID string
FeedID string
Title string
Description string
Date string // display-formatted (user tz)
Position int
Duration string // human ("58:02" / "1:02:10")
Size string // human MB/GB when known
PlayURL string // reader URL (audio reader serves episodes like audiobooks)
CoverImage string
Downloaded bool
DownloadState string // pending|queued|downloading|downloaded|failed
DownloadError string
// Playback progress (reading_progress; the row badge) — reuses the
// ProgressPct field below when the row is downloading? No: keep the
// two concerns separate (issue #1).
ListenPct float64
// Live byte progress for rows in the downloading state (issue #1):
// the worker's in-flight snapshot. ProgressKnown=false renders the
// indeterminate bar; ProgressPct (0-100) drives the width when the
// total is authoritative.
ProgressKnown bool
ProgressPct float64
ProgressReceived int64
ProgressTotal int64
}
// StateBadge renders the canonical state label.
func (r PodcastEpisodeRow) StateBadge() string {
switch r.DownloadState {
case "pending":
return "Not downloaded"
case "queued":
return "Queued"
case "downloading":
return "Downloading…"
case "failed":
return "Failed"
default:
return ""
}
}
// HasActiveDownloads: any episode queued/downloading — the partial
// polls only while this is true (server-driven polling cadence).
func HasActiveDownloads(rows []PodcastEpisodeRow) bool {
for _, r := range rows {
if r.DownloadState == "queued" || r.DownloadState == "downloading" {
return true
}
}
return false
}
// FormatEpisodeDuration renders seconds as H:MM:SS or M:SS.
func FormatEpisodeDuration(seconds int) string {
if seconds <= 0 {
return ""
}
h := seconds / 3600
m := (seconds % 3600) / 60
s := seconds % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}
return fmt.Sprintf("%d:%02d", m, s)
}
// FormatEpisodeSize renders bytes human (MB/GB).
func FormatEpisodeSize(bytes int64) string {
switch {
case bytes <= 0:
return ""
case bytes >= 1<<30:
return fmt.Sprintf("%.1f GB", float64(bytes)/(1<<30))
default:
return fmt.Sprintf("%.1f MB", float64(bytes)/(1<<20))
}
}
// PodcastsPageData carries everything the /podcasts page renders.
type PodcastsPageData struct {
// ContinueFeeds: the in-progress subset, most recently listened
// first (plan 2.5) — split in the page builder (templ bodies can't
// declare locals).
ContinueFeeds []PodcastFeedCard
Feeds []PodcastFeedCard
Libraries []LibraryData // podcasts-type libraries (add-feed targets)
Facets map[string][]string
Query string
ErrorMsg string
HasLibrary bool
}
// PodcastDetailData carries the feed-detail page render.
type PodcastDetailData struct {
Feed PodcastFeedCard
Episodes []PodcastEpisodeRow
GapCount int
Order string // native | oldest | newest (the caller's override)
IsAdmin bool
IsSerial bool // serial/audiobook feeds get the oldest-first suggestion (D5)
// SubscriberCount (issue #7): drives the last-subscriber
// unsubscribe flow — the sole subscriber who is an admin gets the
// three-choice modal (unsubscribe / remove feed / cancel).
SubscriberCount int
}
// podcastInt renders an int for templates.
func podcastInt(n int) string {
return fmt.Sprintf("%d", n)
}
// podcastPlural returns "" for 1 and "s" otherwise.
func podcastPlural(n int) string {
if n == 1 {
return ""
}
return "s"
}
// timeStringPtr helper: display-format with empty fallback.
func podcastTimeOrEmpty(t time.Time, valid bool, tz string) string {
if !valid || t.IsZero() {
return ""
}
return utils.FormatTimeInTimezone(t, tz)
}
// PodcastContinueFeeds returns the in-progress feeds, most recently
// listened first (plan 2.5). ≥95% progress counts as finished and is
// excluded (the continue query already applies that bound).
func PodcastContinueFeeds(feeds []PodcastFeedCard) []PodcastFeedCard {
out := make([]PodcastFeedCard, 0, len(feeds))
for _, f := range feeds {
if f.HasContinue {
out = append(out, f)
}
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].ContinueLastRead.After(out[j].ContinueLastRead)
})
return out
}
+256
View File
@@ -0,0 +1,256 @@
package templates
import "net/url"
// PodcastFacetLink links a facet value back into the podcasts page's
// server-side filter (the /bookshelf FacetLink targets the wrong
// surface for podcasts).
templ PodcastFacetLink(facet string, value string) {
<a href={ "/podcasts?" + facet + "=" + url.QueryEscape(value) } class="badge hover:opacity-80 transition-opacity" style="background-color: var(--surface-secondary); color: var(--text-secondary); text-decoration: none;">{ value }</a>
}
// The /podcasts page (plan task 1.9, D13/D14/D18): Add Feed wizard,
// subscription-scoped search, facet bar, All-Feeds grid. Server-side
// filtering (GET form, like the series page — no JS needed); the grid
// is templ-rendered server data per the rendering-ownership rule.
templ PodcastsPage(user User, data PodcastsPageData, currentPath string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Podcasts - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="podcastsPage" class={ "theme-" + user.Theme }>
@Header(user, currentPath)
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("podcast", "h-5 w-5")
</span>
<div>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Podcasts</h1>
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Your subscriptions</p>
</div>
</div>
if data.HasLibrary {
<button
class="btn btn-primary"
@click="showAddFeed()"
>
@Icon("plus", "h-4 w-4")
<span>Add Feed</span>
</button>
}
</div>
if data.ErrorMsg != "" {
<div class="mb-4 card p-4" style="border-color: var(--status-danger); color: var(--status-danger);">
{ data.ErrorMsg }
</div>
}
if !data.HasLibrary {
<div class="card p-8 text-center">
<p class="mb-2 font-semibold" style="color: var(--text-primary)">No podcasts library yet</p>
<p class="text-sm" style="color: var(--text-secondary)">An admin creates a Podcasts library (with a download folder) first — then feeds can be added here.</p>
</div>
} else {
@PodcastsFilterBar(data)
if len(data.Feeds) == 0 {
<div class="card p-8 text-center">
<p class="mb-2 font-semibold" style="color: var(--text-primary)">No subscriptions</p>
<p class="text-sm" style="color: var(--text-secondary)">Add an RSS feed to start listening — episodes land here and download on demand.</p>
</div>
} else {
@PodcastsSections(data)
}
}
</main>
@PodcastAddFeedModal(data.Libraries)
</body>
</html>
}
templ PodcastsFilterBar(data PodcastsPageData) {
<form method="get" action="/podcasts" class="mb-6 flex flex-wrap items-center gap-3">
<div class="relative flex-1 min-w-48">
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary)">
@Icon("search", "h-4 w-4")
</span>
<input
type="text"
name="q"
value={ data.Query }
placeholder="Search feeds, hosts, episodes…"
class="input w-full pl-9"
aria-label="Search your subscriptions"
/>
</div>
<button type="submit" class="btn btn-secondary" aria-label="Search">
@Icon("search", "h-4 w-4")
<span>Search</span>
</button>
</form>
if len(data.Facets["hosts"]) > 0 || len(data.Facets["tags"]) > 0 || len(data.Facets["language"]) > 0 {
<div class="mb-6 flex flex-wrap items-center gap-2 text-sm">
<span class="font-semibold mr-1" style="color: var(--text-secondary)">Filter:</span>
for _, h := range data.Facets["hosts"] {
@PodcastFacetLink("host", h)
}
for _, t := range data.Facets["tags"] {
@PodcastFacetLink("tag", t)
}
for _, l := range data.Facets["language"] {
@PodcastFacetLink("language", l)
}
if data.Query != "" {
<a href="/podcasts" class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">clear</a>
}
</div>
}
}
// PodcastsSections renders the Continue section (feeds with in-progress
// episodes, most recently listened first — plan 2.5) ahead of the
// alphabetical All Feeds grid.
templ PodcastsSections(data PodcastsPageData) {
if len(data.ContinueFeeds) > 0 {
<h2 class="text-lg font-bold mb-3 mt-2" style="color: var(--text-primary)">Continue</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-8">
for _, feed := range data.ContinueFeeds {
@PodcastFeedCardView(feed)
}
</div>
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">All Feeds</h2>
}
@PodcastsGrid(data.Feeds)
}
templ PodcastsGrid(feeds []PodcastFeedCard) {
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
for _, feed := range feeds {
@PodcastFeedCardView(feed)
}
</div>
}
templ PodcastFeedCardView(feed PodcastFeedCard) {
<a
href={ "/podcasts/" + feed.ID }
class="card p-4 flex flex-col gap-3 hover:-translate-y-0.5 transition-transform"
>
<div class="flex items-start gap-3">
<div class="h-16 w-16 rounded-lg shrink-0 overflow-hidden grid place-items-center" style="background-color: var(--surface-secondary); color: var(--text-secondary);">
if feed.ArtworkPath != "" {
<img
src={ "/uploads/library-" + feed.LibraryID + "/" + feed.ArtworkPath }
alt={ "Cover for " + feed.DisplayTitle() }
class="h-full w-full object-cover"
data-image-fallback
/>
} else {
@Icon("podcast", "h-7 w-7")
}
</div>
<div class="min-w-0">
<p class="font-semibold leading-snug line-clamp-2" style="color: var(--text-primary)">{ feed.DisplayTitle() }</p>
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
{ feed.TypeLabel() }
if feed.Host != "" {
{ " · " + feed.Host }
}
</p>
</div>
</div>
if feed.HasContinue {
<p class="text-xs font-medium line-clamp-1" style="color: var(--accent)">
@Icon("book-open", "h-3.5 w-3.5 inline")
{ " " + podcastInt(feed.ContinueEpisode) + ". " + feed.ContinueTitle }
</p>
} else if feed.Description != "" {
<p class="text-xs line-clamp-2" style="color: var(--text-secondary)">{ feed.Description }</p>
}
<div class="mt-auto flex flex-wrap items-center gap-1.5 text-xs">
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">
{ podcastInt(feed.DownloadedN) + " / " + podcastInt(feed.EpisodeTotal) + " downloaded" }
</span>
if feed.EpisodeMatches > 0 {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent)">
{ podcastInt(feed.EpisodeMatches) + " matching episode" + podcastPlural(feed.EpisodeMatches) }
</span>
}
if feed.NewEpisodes {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent)">new episodes</span>
}
if feed.IsComplete {
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">complete</span>
}
if feed.Explicit {
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">explicit</span>
}
</div>
</a>
}
templ PodcastAddFeedModal(libraries []LibraryData) {
<div
id="podcast-add-feed-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
style="background-color: var(--surface-overlay);"
>
<div class="card w-full max-w-lg" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center p-6 border-b" style="border-color: var(--border);">
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Add Podcast Feed</h2>
<button class="icon-btn" aria-label="Close" @click="hideAddFeed()">
@Icon("close", "h-5 w-5")
</button>
</div>
<form
hx-post="/api/podcasts/feeds"
hx-swap="none"
class="p-6 flex flex-col gap-4"
>
<div>
<label class="text-sm font-medium mb-1 block" for="podcast-feed-url" style="color: var(--text-secondary)">RSS feed URL</label>
<input
type="url"
id="podcast-feed-url"
name="url"
placeholder="https://example.com/feed.xml"
class="input w-full"
required
/>
</div>
if len(libraries) > 1 {
<div>
<label class="text-sm font-medium mb-1 block" for="podcast-feed-library" style="color: var(--text-secondary)">Library</label>
<select id="podcast-feed-library" name="library_id" class="input w-full">
for _, lib := range libraries {
<option value={ lib.ID }>{ lib.Name }</option>
}
</select>
</div>
} else if len(libraries) == 1 {
<input type="hidden" name="library_id" value={ libraries[0].ID }/>
}
<label class="flex items-center gap-2 text-sm" style="color: var(--text-secondary)">
<input type="checkbox" name="download_all" value="true" class="cursor-pointer"/>
Download all episodes now
</label>
<div id="podcast-add-feed-error" class="text-sm hidden" style="color: var(--status-danger)"></div>
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-secondary" @click="hideAddFeed()">Cancel</button>
<button type="submit" class="btn btn-primary">
@Icon("plus", "h-4 w-4")
<span>Add &amp; Subscribe</span>
</button>
</div>
</form>
</div>
</div>
}
+728
View File
@@ -0,0 +1,728 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "net/url"
// PodcastFacetLink links a facet value back into the podcasts page's
// server-side filter (the /bookshelf FacetLink targets the wrong
// surface for podcasts).
func PodcastFacetLink(facet string, value string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 templ.SafeURL
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts?" + facet + "=" + url.QueryEscape(value))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 9, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"badge hover:opacity-80 transition-opacity\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary); text-decoration: none;\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 9, Col: 227}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// The /podcasts page (plan task 1.9, D13/D14/D18): Add Feed wizard,
// subscription-scoped search, facet bar, All-Feeds grid. Server-side
// filtering (GET form, like the series page — no JS needed); the grid
// is templ-rendered server data per the rendering-ownership rule.
func PodcastsPage(user User, data PodcastsPageData, currentPath string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Podcasts - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<body x-data=\"podcastsPage\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Header(user, currentPath).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("podcast", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Podcasts</h1><p class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Your subscriptions</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.HasLibrary {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<button class=\"btn btn-primary\" @click=\"showAddFeed()\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<span>Add Feed</span></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.ErrorMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"mb-4 card p-4\" style=\"border-color: var(--status-danger); color: var(--status-danger);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.ErrorMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 53, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if !data.HasLibrary {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"card p-8 text-center\"><p class=\"mb-2 font-semibold\" style=\"color: var(--text-primary)\">No podcasts library yet</p><p class=\"text-sm\" style=\"color: var(--text-secondary)\">An admin creates a Podcasts library (with a download folder) first — then feeds can be added here.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = PodcastsFilterBar(data).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data.Feeds) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"card p-8 text-center\"><p class=\"mb-2 font-semibold\" style=\"color: var(--text-primary)\">No subscriptions</p><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Add an RSS feed to start listening — episodes land here and download on demand.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = PodcastsSections(data).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PodcastAddFeedModal(data.Libraries).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func PodcastsFilterBar(data PodcastsPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil {
templ_7745c5c3_Var8 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<form method=\"get\" action=\"/podcasts\" class=\"mb-6 flex flex-wrap items-center gap-3\"><div class=\"relative flex-1 min-w-48\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> <input type=\"text\" name=\"q\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Query)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 88, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" placeholder=\"Search feeds, hosts, episodes…\" class=\"input w-full pl-9\" aria-label=\"Search your subscriptions\"></div><button type=\"submit\" class=\"btn btn-secondary\" aria-label=\"Search\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span>Search</span></button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data.Facets["hosts"]) > 0 || len(data.Facets["tags"]) > 0 || len(data.Facets["language"]) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"mb-6 flex flex-wrap items-center gap-2 text-sm\"><span class=\"font-semibold mr-1\" style=\"color: var(--text-secondary)\">Filter:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, h := range data.Facets["hosts"] {
templ_7745c5c3_Err = PodcastFacetLink("host", h).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, t := range data.Facets["tags"] {
templ_7745c5c3_Err = PodcastFacetLink("tag", t).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, l := range data.Facets["language"] {
templ_7745c5c3_Err = PodcastFacetLink("language", l).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if data.Query != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<a href=\"/podcasts\" class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">clear</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
// PodcastsSections renders the Continue section (feeds with in-progress
// episodes, most recently listened first — plan 2.5) ahead of the
// alphabetical All Feeds grid.
func PodcastsSections(data PodcastsPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if len(data.ContinueFeeds) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<h2 class=\"text-lg font-bold mb-3 mt-2\" style=\"color: var(--text-primary)\">Continue</h2><div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, feed := range data.ContinueFeeds {
templ_7745c5c3_Err = PodcastFeedCardView(feed).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div><h2 class=\"text-lg font-bold mb-3\" style=\"color: var(--text-primary)\">All Feeds</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = PodcastsGrid(data.Feeds).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func PodcastsGrid(feeds []PodcastFeedCard) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, feed := range feeds {
templ_7745c5c3_Err = PodcastFeedCardView(feed).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func PodcastFeedCardView(feed PodcastFeedCard) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
if templ_7745c5c3_Var12 == nil {
templ_7745c5c3_Var12 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts/" + feed.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 144, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"card p-4 flex flex-col gap-3 hover:-translate-y-0.5 transition-transform\"><div class=\"flex items-start gap-3\"><div class=\"h-16 w-16 rounded-lg shrink-0 overflow-hidden grid place-items-center\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if feed.ArtworkPath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/library-" + feed.LibraryID + "/" + feed.ArtworkPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 151, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("Cover for " + feed.DisplayTitle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 152, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" class=\"h-full w-full object-cover\" data-image-fallback>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = Icon("podcast", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div><div class=\"min-w-0\"><p class=\"font-semibold leading-snug line-clamp-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(feed.DisplayTitle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 161, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(feed.TypeLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 163, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if feed.Host != "" {
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + feed.Host)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 165, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if feed.HasContinue {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<p class=\"text-xs font-medium line-clamp-1\" style=\"color: var(--accent)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book-open", "h-3.5 w-3.5 inline").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(" " + podcastInt(feed.ContinueEpisode) + ". " + feed.ContinueTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 173, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if feed.Description != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"text-xs line-clamp-2\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(feed.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 176, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"mt-auto flex flex-wrap items-center gap-1.5 text-xs\"><span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(feed.DownloadedN) + " / " + podcastInt(feed.EpisodeTotal) + " downloaded")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 180, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if feed.EpisodeMatches > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(feed.EpisodeMatches) + " matching episode" + podcastPlural(feed.EpisodeMatches))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 184, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if feed.NewEpisodes {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent)\">new episodes</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if feed.IsComplete {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">complete</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if feed.Explicit {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">explicit</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func PodcastAddFeedModal(libraries []LibraryData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var23 := templ.GetChildren(ctx)
if templ_7745c5c3_Var23 == nil {
templ_7745c5c3_Var23 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div id=\"podcast-add-feed-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-lg\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Add Podcast Feed</h2><button class=\"icon-btn\" aria-label=\"Close\" @click=\"hideAddFeed()\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div><form hx-post=\"/api/podcasts/feeds\" hx-swap=\"none\" class=\"p-6 flex flex-col gap-4\"><div><label class=\"text-sm font-medium mb-1 block\" for=\"podcast-feed-url\" style=\"color: var(--text-secondary)\">RSS feed URL</label> <input type=\"url\" id=\"podcast-feed-url\" name=\"url\" placeholder=\"https://example.com/feed.xml\" class=\"input w-full\" required></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(libraries) > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<div><label class=\"text-sm font-medium mb-1 block\" for=\"podcast-feed-library\" style=\"color: var(--text-secondary)\">Library</label> <select id=\"podcast-feed-library\" name=\"library_id\" class=\"input w-full\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, lib := range libraries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 234, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 234, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if len(libraries) == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<input type=\"hidden\" name=\"library_id\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraries[0].ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 239, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-secondary)\"><input type=\"checkbox\" name=\"download_all\" value=\"true\" class=\"cursor-pointer\"> Download all episodes now</label><div id=\"podcast-add-feed-error\" class=\"text-sm hidden\" style=\"color: var(--status-danger)\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" class=\"btn btn-secondary\" @click=\"hideAddFeed()\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<span>Add &amp; Subscribe</span></button></div></form></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+1 -1
View File
@@ -170,7 +170,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
</div> </div>
} }
} }
<div id="series-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6"> <div id="series-grid" class="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
for _, series := range seriesList { for _, series := range seriesList {
@SeriesCard(series) @SeriesCard(series)
} }
+1 -1
View File
@@ -344,7 +344,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
} }
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div id=\"series-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div id=\"series-grid\" class=\"grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more