Compare commits

..
28 Commits
Author SHA1 Message Date
John O'Keefe f7dc8189a1 feat(css): cross-document view transitions
Release / build-and-push (push) Successful in 3m15s
@view-transition { navigation: auto; } lets Chrome 126+ / Safari 18+
crossfade same-origin navigations — sidebar links and the series
page's library-switch navigation get a subtle fade with zero
JavaScript, softening the switch to full SSR navigation. Pure
progressive enhancement: Firefox and older browsers simply navigate
instantly. style.css regenerated via build:css:prod.
2026-10-19 12:40:44 -04:00
John O'Keefe a8641ab343 chore(ui): bump the main.js cache-bust stamp to v6
web/static/*.js is gitignored and built at image-build time, so the
hardcoded ?v= on the module script tag is the ONLY mechanism that
evicts a stale bundle from browsers. Shipping the series twin deletion
without a bump would let cached main.js (with the deleted renderer)
keep running on prod, making the paginator fix appear not to work.
Found while shipping; easy to miss because the bundle never appears in
git status.
2026-10-19 12:40:31 -04:00
John O'Keefe 1ce66b2213 chore(templ): warn against change handlers on the bookshelf library select
The bookshelf select (#library-select) sits inside the htmx filter
form and the SSR route honors ?offset= — wiring a change handler or
hx-trigger="change" on it would let header.ts's synthetic restore
event re-fetch the grid at offset 0, the same stomp class that broke
the /series paginator. Comment-only guard; regenerated
bookshelf_templ.go carries it into the emitted markup.
2026-10-19 12:40:13 -04:00
John O'Keefe 18f52d240b docs(agents): record the dashboard sections rendering exception
renderDashboardCollections/renderSectionHTML re-render dashboard
sections from /api/dashboard/sections JSON on library switch, settings
save, and refresh — deliberately kept in TypeScript to preserve the
switch fade on the app's most interactive page. First-paint section
markup remains templ (Dashboard handler in internal/router/frontend.go).

Recorded in the Rendering ownership section as a sanctioned exception
so it is not silently 'fixed' without restoring a templ-partial fade
path, and so the pattern is not cloned to new pages. (User-directed:
the dashboard keeps its fade; every other page stays templ-rendered.)
2026-10-19 12:40:02 -04:00
John O'Keefe 220d97cfe0 test(series): pin the SSR pagination contract under tied timestamps
New integration test mirroring a prod bulk import: a dedicated test
library with 30 series whose items share ONE identical created_at
(single-transaction NOW()), the exact data shape that made prod
pagination look broken.

Pins, via the real SSR /series route:
- page 1 renders exactly 24 deterministic series (01-24) with chip
  "Page 1 of 2", no Previous, and a Next href carrying library_id and
  page (templ escapes & as & inside attributes)
- page 2 renders the remaining 6, disjoint from page 1, chip
  "Page 2 of 2", no Next, Previous href correct
- missing page param defaults to page 1; page=0/-3/two clamp to page 1

Guards both the SQL tiebreaker and the /series handler contract;
would fail on any return of the stomp or an unstable sort.
2026-10-19 12:39:47 -04:00
John O'Keefe 38efbff7e1 fix(db): total ordering for series OFFSET pagination
GetDistinctSeries ordered by MAX(created_at) DESC alone. Bulk imports
inserted in a single transaction share one NOW() timestamp, leaving
large numbers of (library_id, series) groups tied on MAX(created_at);
Postgres does not guarantee tie order between executions, so
LIMIT/OFFSET pages could repeat or drop series across page loads.

Add the GROUP BY keys (library_id, series) as tiebreakers so the sort
is total and deterministic. Comment documents why the tiebreaker is
required so it is not dropped as noise. sqlc regenerated (interface
unchanged; the comment flows into querier.go docs).
2026-10-19 12:39:32 -04:00
John O'Keefe f4414aa5b6 style(series): plain-text page indicator instead of the chip pill
"Page N of M" now renders as small secondary-color text on the page
background rather than an accent chip (user preference; the old TS
twin happened to render this way and the chip was only being seen
after JS took over). With the twin deleted, this templ markup is the
only paginator rendering, so the look is stable.
2026-10-19 12:39:20 -04:00
John O'Keefe 3daa834a94 refactor(series): drop the TS rendering twin; library switch navigates SSR
web/src/series.ts carried a full TypeScript twin of the templ page —
cards and paginator rendered via innerHTML from /api/series JSON,
always hardcoded to page 1. Combined with the header restore dispatch
(previous commit) it made every /series load stomp server-rendered
pagination back to page 1, which is the paginator bug reported on prod
(9 pages of series, Next kept landing on page 1, per-library only).

Rendering ownership (AGENTS.md): this page now has NO client renderer.
- grid and paginator are rendered only by templates/series.templ
- a library switch is a plain SSR navigation to
  /series?library_id=...&page=1, keeping the URL the single source of
  truth for both library and page state
- drops the duplicate /api/series fetch that ran on every page load

rendering-allowlist.txt: series.ts baseline entry removed — the file
no longer builds HTML anywhere.
2026-10-19 12:39:09 -04:00
John O'Keefe 202a090811 fix(ui): library restore no longer re-fires change on synced dropdowns
restoreLibrarySelection() ran on every page load via the header's
x-init and always dispatched a synthetic `change` on #library-select
after setting its value. On SSR pages the dropdown already renders the
stored library (setSelectedLibrary keeps the cookie in sync and
resolveLibrary renders from it), so the synthetic event re-ran the
page's switch handler, whose fetches are hardcoded to page 1 /
offset 0 — stomping freshly rendered content.

Visible impact: the /series paginator snapped back to page 1 on every
navigation whenever a specific library was stored (All Libraries
short-circuits restore, which is why it only failed per-library).
Silent impact: dashboard and collection-detail pages re-fetched their
content on every single load.

The restore now re-fires `change` only when the dropdown genuinely
drifted from storage; the common synced case is a visual no-op.
2026-10-19 12:38:50 -04:00
John O'Keefe ee98c0fb3b docs(dev): test-running policy + media-fixture rules; ignore testdata
Release / build-and-push (push) Successful in 3m29s
Integration tests require the containerized dev DB and bind-mounted
paths — a bare host `go test ./cmd/server/tests` fails on database
auth on every machine not running the compose stack (the old doc
prescribed exactly that flow). development.md now documents the
container runner and the iteration rule: run ONLY your own tests via
a filtered container invocation while working, and reserve full-suite
runs for pre-commit validation — several tests read live shared state
(system_settings, real library folders, the periodic backup scan's
websocket broadcasts) and flap on data or timing a change never
touched.

Binary media fixtures are never committed: internal/services/
testdata/ is gitignored by policy (the audio metadata tests read
real-format binaries that ffmpeg cannot synthesize — Audible freeform
atoms — so they live untracked on the machines that run the suite).
The new "Media fixtures" section carries the ffmpeg recipes for every
supported container, tag writing, embedded covers and ffmetadata
chapters, plus the manual end-to-end recipe for the live system
(one book per subfolder — a directory with ≥2 audio files ingests as
one folder book).
2026-10-19 07:54:55 -04:00
John O'Keefe 79ce5ab89d feat(web): multi-track audiobook player — per-track src switching, global-seconds position
The audio reader pointed <audio src> at ONE file — correct for m4b but
impossible for folder-of-tracks and zip books (the element holds a
single source). The player now generalizes to a track list built from
the /chapters manifest: single-file books are exactly one track whose
url is the whole file (zero behavior change — same element, same URL
flow), multi-file books swap src at track boundaries.

Mapping helpers (exported, unit-testable): trackIndexFor resolves a
global position to the owning track, localOffsetFor/globalSecondsFor
convert between global seconds — the coordinate system of chapters,
bookmarks and audio_offset_seconds — and (track, local offset). Every
consumer keeps global semantics: scrubber, skip ±N, bookmark saves,
?loc=audio: deep links and progress pushes are byte-identical payloads
server-side; the server contract does not move.

Mechanics: setActiveTrack swaps src and queues the position for
loadedmetadata (setting currentTime before the new metadata loads
resets it); ended advances to the next track and keeps playing;
timeupdate reports globalPosition(); playbackRate/volume re-apply
after swaps; src is deferred until the manifest loads (data-file-url
is a directory/zip root for multi-file books, not playable) with the
catch branch falling back to it for offline single-file starts.
2026-10-19 07:54:45 -04:00
John O'Keefe 6216bd9bcf feat(audiobooks): multi-file books (folder-of-tracks + zip) + exhaustive metadata ingestion
Multi-file book ingest: a directory of ≥2 audio files (recursively —
disc-part folders like CD1/Disc 2 merge upward, siblings never merge)
ingests as ONE book whose file_path is the book-root directory; a zip
of audio tracks is its own book (file_path = the archive). Both write
the documented chapters abstraction {index,title,start,end} into
audio_metadata and add additive keys: layout ("folder"|"zip") and a
tracks[] manifest (per-track path, global-seconds start/end, duration,
size). Pre-grouping one-track strays under a book root are absorbed on
rescan (annotations cascade). Rescans take a stat-only fast path
(track count + total size + known durations unchanged → no re-probe —
a 1189-track book must not re-ffprobe every auto-scan); a zero-duration
book counts as changed so a ffprobe-equipped rescan fills durations in.
Folder-book roots count as present in the archive lifecycle pass, and
per-item rescan (RescanMediaItem) routes multi-file books through a
book-level refresh — the generic updater would read the root/zip as if
it were the book file and clobber format_group with "unknown".

Serving: GET /api/media-items/:id/chapters gains additive layout +
tracks keys (urls → the new endpoint). New GET
/api/media-items/:id/audio/:trackIndex streams one track — folder
tracks served from the real file (full Range via ServeContent), zip
tracks via a reopen-and-skip ReadSeeker over the entry (deflate has no
random access; acceptable at audio bitrates; stored entries stay
section readers). Both endpoints gate on library visibility (403 on
hidden libraries — family-lens rule). ServeFile and OPDS DownloadBook
refuse directory paths (folder books have no single-file download).

Web player (next commit) consumes the manifest; progress/bookmarks/
deep-links keep their global-seconds coordinates, so the server-side
progress model is untouched. sync learns FormatGroupAudiobook
(percentage passthrough in ConvertProgress); detectFormatType stops
reporting "unknown" for audio.

Metadata ingestion (Batch B) — read everything available, degrade
gracefully, documented precedence sidecars → folder structure →
embedded tags → filename: ffprobe tags are now a case-insensitive
lookup map (freeform keys pass through verbatim); composer fills
narrators (Audible's own convention, kept as ONE string — no reliable
multi-narrator delimiter); grouping + Audible freeform atoms
(SERIES/SERIES-PART/SUBTITLE/ASIN/ABRIDGED) fill series/index/
subtitle-join/ASIN/abridged (explicit sidecar abridged-false is
guarded); date/genre fall back to tags; album/album_artist/artist
fill title/author. Genre persists at create for all formats.
Embedded cover art (mp3 APIC, m4b covr, flac picture) extracts via one
optional ffmpeg call as a "<book>.cover.jpg" sibling — sidecar covers
win. CUE sheets (FILE-matched, INDEX 01 at 75fps) are the lowest-
priority chapter source under sidecars and embedded m4b chapters. A
lone m3u/m3u8 in a book root imposes playlist ordering + #EXTINF
names; (disc, track) tags reorder tracks when coverage is complete.
extractMetadata's default branch no longer pre-fills the filename as
title (tags must outrank it) — final filename fallbacks now exist on
BOTH the create and rescan paths (TestRescanKeepsFilenameTitle pins
that rescans cannot blank a title); tagless zip books take the zip's
own name, not their parent folder's.

Accepted containers widen to .m4b .m4a .mp3 .flac .zip .opus .ogg .oga
.aac .wav with real media types registered (audio/ogg per RFC 7845 for
Opus-in-Ogg) — all browser-playable; .wma deliberately excluded.

Tests: unit tests for CUE parsing, playlist hints, tag fallbacks
(synthetic ffprobe reports — ffmpeg cannot write Audible freeform
atoms), ordinal parsing, tag ordering, natural sort, grouping rules;
integration suite (11 tests) over the live dev DB: folder/zip ingest +
idempotency, stray absorption, chapters manifest, per-track streaming
with Range, hidden-library 403s, embedded covers + sidecar-wins, CUE
chapters, embedded m4b chapters, playlist ordering, tag fallbacks, all
five new containers with content-type assertions, rescan title pin.
Fixture media is real committed-format binary content living untracked
under internal/services/testdata/ (gitignored by policy; recipes in
docs/contributing/development.md → Media fixtures). Test image now
ships ffmpeg to match the runtime's audio toolchain.
2026-10-19 07:54:28 -04:00
John O'Keefe 24228e7147 test(scan): cancellation endpoint semantics and mid-run safety
- endpoint semantics: non-admin cancel is 403, unknown job is 404
- mid-scan safety: start a scan of 1200 unparseable .epub files and
  cancel it, then pin the invariant that no media item is marked
  missing or archived. The canary item's file is lexically LAST in
  the folder, so an abort that (incorrectly) ran the archive pass
  would treat it as unvisited and trip the assertion. Assertions
  hold under every interleaving - whether the cancel lands mid-walk
  or the scan already finished - so the test cannot flap against
  timing.
- re-cancelling a terminal job is a 409

The test library is created through the shared
createTestLibraryWithFolder helper, which registers API-delete
cleanup so runs leave no residue in the dev database.

Worker-level unit tests live in internal/services/worker_cancel_test.go
(previous commit).
2026-10-18 18:02:45 -04:00
John O'Keefe e1153b2c4b feat(admin): cancel buttons for in-flight scans + cancelled state UI
- per-row Cancel button in the scan-progress card; listeners are bound
  after the TS island renders its rows (module scope has no inline
  handlers), and the button is removed once its job turns terminal
- cancel-all icon button in the card header: resolves the server's
  active job list first, so it also covers scans this page never
  started (e.g. started before a refresh), then cancels each one
- polling treats cancelled as terminal, averages overall progress
  only over jobs that can still advance (so the bar can reach 100%),
  and the results panel reports cancelled scans instead of claiming
  "Scan Complete!"
- sidebar spinner resets on the scan_cancelled websocket event (same
  quiet handling as scan_error)
- setup wizard shows "Cancelled" as a terminal status in its poller

No new innerHTML assignment sites - rendering-ownership baseline
unchanged.
2026-10-18 18:02:34 -04:00
John O'Keefe 5b81e19f2f feat(scanner): POST /api/scanner/cancel/:jobId (admin)
Cancels a pending or running scan job. Responses: 200 with the job id
and status on acceptance, 404 for unknown jobs, 409 for jobs already
in a terminal state (completed/failed/cancelled).

Additive route in the existing admin scanner group; no contract pins
affected.
2026-10-18 18:02:25 -04:00
John O'Keefe 30ecdd4bf3 feat(scanner): ScanFolders honors its context; cancelling never archives
ScanFolders never checked its context, so a cancelled job's walk ran
to completion no matter what the worker's cancel did.

- ctx checks at every stage boundary (file-counting pass, per folder,
  before and during the archive pass) and inside the walk callback
  (fs.SkipAll): the file currently being processed finishes, then the
  scan aborts with context.Canceled, which processJob classifies as a
  cancelled job rather than a failed one
- the archive/purge pass is skipped entirely when the scan was
  cancelled. It only visits what the walk visited, so after a partial
  walk the unvisited (but on-disk) files would be treated as missing,
  archived, and eventually purged. Cancelling is a UI convenience and
  must never destroy library data.
2026-10-18 18:02:19 -04:00
John O'Keefe b2220756e2 feat(worker): real per-job cancellation for the background job pool
CancelJob existed but was dead code: it only flipped a status in the
in-memory results map - it never cancelled anything, and processJob's
unconditional completion write overwrote the marker anyway.

- derive a per-job context in processJob (parent is the handler ctx
  for manual scans, Background for auto-scans) and register its
  cancel func under the job id; previously every manual scan shared
  the handler's process-lifetime context, so no targeted cancel was
  possible without killing everything
- skip execution at dequeue for jobs cancelled while still queued
- record a pending JobResult on BOTH enqueue paths (Enqueue used by
  auto-scans, EnqueueJob used by the API): queued jobs were invisible
  to status polling (404 until a worker picked them up) and could not
  be cancelled before execution started
- cancelled jobs keep their last real progress instead of a fake 100%
  and drop the meaningless "context canceled" error text
- broadcast the new scan_cancelled websocket event for cancelled scan
  jobs instead of scan_complete, so clients reset their scanning
  indicators rather than reading the abort as a finished scan
- sentinel errors (ErrJobNotFound, ErrJobNotCancellable) so HTTP
  handlers map outcomes to 404/409 without string matching

Unit tests (worker_cancel_test.go) pin: queued-cancel never executes,
running-cancel invokes the context, error mapping, pending recording
on both enqueue paths.
2026-10-18 18:02:11 -04:00
John O'Keefe d74db03cf3 feat(sync): unlinked-books queue — ingest, page route, dashboard banner
Release / build-and-push (push) Successful in 3m7s
The identification queue for device files the sync cannot match is now
live end-to-end. Previously the whole feature was dark: unmatched device
files were silently dropped (losing their reading progress), the queue
table sat empty by construction, and the page template had no route.

- Ingest: the KOReader sync loop, on an unresolvable file, upserts
  unlinked_books (one row per device + content id; re-syncs bump
  last_seen_at and refresh device-reported metadata) instead of
  dropping the file. Schema gains the (device_id, content_id) unique
  index the upsert needs.
- Page: GET /unlinked-books routes the existing page template, listing
  the caller's own unresolved rows (ListUnresolvedUnlinkedBooksForUser
  — devices.user_id scoping; no cross-user leakage).
- Dashboard banner: a warning card above the shelves, rendered only
  when the caller's queue is non-empty (hidden-unless-needed), linking
  to the page.
- Resolve flow unchanged and already gated: MatchingHandler.LinkBook
  verifies device ownership and target-item visibility (403s) and
  creates the device file alias, so the next sync matches by path.
  SyncHandler.LinkBook and Handler.LinkBook are unrouted dead handlers
  — triage candidates, not part of the live flow.
2026-10-18 16:45:48 -04:00
John O'Keefe 9e831085ef fix(ui): derive Library Type dropdowns from library_types — audiobooks was missing
The audiobook library type shipped backend-complete (LibraryTypeAudiobooks
constant, allowed extensions, MIME map, schema seed row, GET
/api/libraries/types) but neither create-library form offered it: the
admin Create Library modal and the setup wizard's add-library step both
hardcoded their <option> lists to the original three types
(ebooks/comics/manga), so an audiobook library could not be created from
the UI.

Both dropdowns now render from the same DB-backed library_types table
the create API resolves types against — a future type appears without
template edits (the exact drift the rendering-ownership rule warns
about):

- templates/utils.go: OrderedLibraryTypes pins the historical dropdown
  order (ebooks, comics, manga, audiobooks) and appends unknown future
  types by name; LibraryTypeDisplayName renders labels with a
  capitalize-the-name fallback for types added without a label
- templates/admin_library.templ: new LibraryTypeOptions fragment used
  by the modal's select; AdminLibrary takes the types list
- templates/setup.templ: Setup takes the types list, same fragment
- internal/router/helpers.go: libraryTypeOptionsFor fetches via
  LibraryService.GetLibraryTypes, falling back to the built-in type
  names on DB failure so the forms stay usable (CreateLibrary still
  resolves the name against the DB, so the fallback is never
  authoritative)
- internal/router/frontend.go, internal/router/setup.go: pass the list
  into the two pages

Tests:
- templates/utils_test.go: pure unit tests for ordering + labels
- cmd/server/tests/admin_library_page_test.go: SSR regression —
  /admin/library must offer all four library types, audiobooks exactly
  once (options come from the DB loop, not a hardcode)
2026-10-18 16:38:03 -04:00
John O'Keefe 327fc2c0f9 feat(matching): match results as a scoped templ partial + enriched matches
- BookMatch now carries title/author/cover (the fields the old client
  renderer always assumed but the API never sent — cards would have
  shown 'undefined'); filled during the family-lens pass in QueryBooks
- new partial route GET /unlinked-books/matches renders the scoped
  match cascade server-side; searchMatches swaps it via htmx.ajax
  instead of rebuilding cards from JSON
- NOTE: the unlinked-books surface is currently dark end-to-end — the
  templ page has no route, the live linking.ts renderer targets a
  container no template has, and its Link button calls a
  window.showMatchModal that does not exist. This commit ships the
  scoped backend + partial; wiring the UI is a product decision.
2026-10-18 16:00:04 -04:00
John O'Keefe a31da89df5 feat(ui): View Transitions crossfade for library switches
Where the browser supports it (Chrome 111+, Safari 18+, Firefox 139+),
switchWithTransition now uses the native View Transitions API: the old
shelves are snapshotted, the fetch + swap happen behind the dim, and the
two snapshots crossfade — no blank phase, no manual opacity
choreography. The dim + spinner is hidden inside the update so it blends
away as part of the transition. Browsers without the API keep the
existing dim + fade sequence unchanged. Zero dependencies — the
crossfade is the browser's own.
2026-10-18 16:00:04 -04:00
John O'Keefe a11b4a9cc4 fix(ui): library-switch overlay was fully opaque — the 'new page' wipe
The switch overlay intended a 50% dim (bg-opacity-50) but set its
background with an inline style — Tailwind's bg-opacity variable does
not apply to inline styles, so the overlay rendered fully opaque. Every
library switch wiped the material area to a solid sheet (chrome stays
visible: the sidebar sits at z-60 above the overlay's z-50) and
hard-cut back when the overlay dropped — reading exactly as a new page
instead of a fade. The dashboard showed it worst because its switch
holds the overlay longest (sections fetch + awaited week-widget refresh
behind it).

- overlay background is now theme-aware translucent
  (color-mix 55% bg-primary over transparent); the dead bg-opacity-50
  class is gone
- the dim lifts before the fade-in starts, so the new content's fade
  plays fully visible rather than being hard-cut mid-transition
2026-10-18 13:16:35 -04:00
John O'Keefe 040c10ac76 feat(matching): family-lens scoping for book matching and linking
/api/books/match returned media metadata from ALL libraries regardless
of visibility: the resolver lookups (GetMediaItemBySHA256 and friends)
are global, so match results leaked hidden-library titles, covers and
ids to any authenticated user, and LinkBook accepted a link target from
a hidden library. Both now enforce the family-lens rule:

- QueryBooks post-filters the cascade by the requester's visible-library
  set (absent-from-results — a hidden library's book simply never
  matches), before determineAction so the suggested action reflects the
  filtered set. BookQueryRequest gains a server-side-only
  VisibleLibraryIDs field; nil keeps legacy callers unfiltered.
- LinkBook verifies the device belongs to the caller
  (DeviceExistsForUser) and the target item's library is visible —
  403 otherwise. Linking was previously unscoped on both axes.
- GetUnlinkedBooks likewise verifies device ownership (its service is
  still a stub, but the route no longer answers for other users'
  devices).

Also: the collections books-grid partial uses a light loader —
requested library validated against the visible set directly instead of
the page-only library resolution (folder reachability, counts,
dropdown payload), removing the extra queries that made library
switching hold the spinner longer.

And the dashboard library switch now folds the This Week widget's
htmx refresh behind the spinner (refreshWeekWidget awaits the widget's
swap, with a safety timeout) instead of letting it pop in after the
fade-in — the two-stage update introduced with the widget's
library-scoped re-fetch.

Guardrail: the rendering lint now counts all innerHTML assignments (not
just template-literal ones) — the old pattern undercounted 20 files at
80 sites, including .join("") builders like renderDashboardCollections.
Baseline rebalanced accordingly.
2026-10-18 13:10:57 -04:00
John O'Keefe 4a68fe7cab fix(conflicts): the status filter, select-all, and bulk actions actually work
They never did: filterConflicts/toggleAllConflicts/updateBulkActions
were referenced in the templ but defined nowhere, and the bulk buttons
called bulkResolve()/bulkDismiss() with no arguments against functions
expecting (strategy, conflictIds).

- conflict cards carry data-status; the status dropdown filters them
  client-side (pure client state — allowed to be JS), applied on page
  init and re-applied after every partial refresh, with a
  'no conflicts match this filter' note when everything is filtered out
- select-all toggles only the visible cards; the bulk bar shows the
  selected count and resolve/dismiss now gather the checked ids and the
  chosen strategy (the bar's own buttons use resolveSelected()/
  dismissSelected() wrappers)
- dead onchange= globals became Alpine @change handlers on the server-
  rendered controls; partial swaps re-bind via Alpine.initTree (already
  in refreshConflicts) and re-apply filter + bulk state

Visible change: the dropdown's stated default (Unresolved Only) now
actually applies on load, so resolved conflicts are hidden until the
filter is changed.
2026-10-18 12:42:50 -04:00
John O'Keefe cb4e892b10 test(templates): pin the rendering partials — standalone render, OOB markers, empty state 2026-10-18 12:30:55 -04:00
John O'Keefe 506a04b55d chore(web): library-admin inline script moves to admin.ts; rendering guardrail
The library admin page carried an inline <script> block of global
functions (toggleLibraryPanel, openEditModal, openDeleteModal,
openFolderBrowser plus the delete-modal close listener). They move into
the admin Alpine component as typed methods; the page body gains
x-data="admin" and the call sites switch onclick= -> @click.
Behavior unchanged.

Guardrail: make lint-rendering (wired into make test) fails when a
web/src file builds more HTML template literals than its recorded
baseline (web/src/rendering-allowlist.txt, seeded at 18 sites across 9
files post-twin-deletion) or when a .templ file gains an inline script
block. theme_flash_guard.templ is the only sanctioned inline script;
web/src/reader is a sanctioned island.

theme.ts: Array.from(classList) — DOMTokenList spread needs
lib.dom.iterable, which the tsconfig does not include.
2026-10-18 12:28:50 -04:00
John O'Keefe f1844591b4 feat(web): templ partials replace the last UI twins (collections grid, conflicts list)
Both pages had a templ render (initial load) and a TypeScript twin
(innerHTML rebuild on refresh) of the same fragment. The twins are
deleted; the server-rendered markup is now the only markup.

- collections: the library switcher swaps in the new books-grid partial
  (GET /collections/:id/books-grid) instead of re-rendering cards from
  the /api/collections JSON. Book loading is extracted into
  Config.loadCollectionBooks, shared verbatim by the page and the
  partial (smart / dashboard-query / manual branches, family-lens
  scoping via resolveLibrary + UserVisibleLibraryIDs) so the two cannot
  drift.
- conflicts: resolve/dismiss refreshes swap the new list partial
  (GET /conflicts/list) with the stats bar as an out-of-band swap,
  instead of the TS twin — which rendered a degraded card (no
  checkboxes, no timestamps), pointed at a window.showResolveModal that
  does not exist, and updated stats via element ids the SSR never had.
- shims.d.ts: window.htmx typing (ajax/process) for TS callers.

Visibility note: the books-grid partial inherits its family-lens
scoping from the shared loader (identical to the detail page); a
dedicated hidden-library pin test for the partial route is still worth
adding in the next test pass.
2026-10-18 12:28:25 -04:00
John O'Keefe 1fdf306f2d docs(agents): rendering-ownership rule — templ renders, JS decides what shows
Codifies the standing rule discussed for the templ/JS split: content
derived from server data is rendered by templ and refreshed via templ
partials (htmx.ajax), pure client-local state may render in TS/Alpine,
web/src is TypeScript-only, and inline <script> in templ files requires
a pre-paint justification (theme_flash_guard.templ only). Lists the
sanctioned JS islands (reader, Chart.js, toasts, password validation).

Also refreshes gotcha #1: the 'harmlessly masked' aside is stale — the
quoted-attribute sweep fixed every occurrence; the note now points at
templates.JSONArg for free text.
2026-10-18 12:28:25 -04:00
84 changed files with 7835 additions and 2065 deletions

No files matched your search

+4
View File
@@ -82,3 +82,7 @@ session-*.md
# Local prod-export scratch: personal annotation/reading data, never commit
/export/
# Binary media fixtures: never commit (generate with ffmpeg at test time,
# see docs/contributing/development.md → Media fixtures)
internal/services/testdata/
+60 -3
View File
@@ -4,6 +4,43 @@ Standing rules for any agent or developer working in this repo.
Project-wide context lives in the sibling repos' AGENTS.md files
(../bookhoard-app/AGENTS.md is the Android client's).
## Rendering ownership (templ vs TypeScript) — ALWAYS apply
The point of templ + htmx + Alpine is: the server renders the UI, and
JavaScript is kept minimal. The rule that keeps it that way:
> **JavaScript decides what shows. templ decides what it looks like.**
- Content derived from **server data** (DB rows, search results, match
lists, conflict cards, any list/card/badge/table) is rendered by
templ. When it changes client-side, fetch a **templ partial** from an
SSR route and swap it in (`htmx.ajax(...)`), never rebuild it from a
JSON payload with `innerHTML` string templates.
- **Pure functions of client-local state** (open/closed panels, tabs,
password strength from a typed value, toasts) may render in
TS/Alpine — no server round trip needed.
- `web/src` is **TypeScript only** — no new `.js` source files.
Vendored third-party and vite build output under `web/static` are
exempt.
- Inline `<script>` in a `.templ` file needs a reason a bundled module
cannot do the job (pre-paint execution). Currently sanctioned:
`theme_flash_guard.templ` only.
Sanctioned JS islands (engines, not UI renderers — exempt from the
above): the reader (foliate-js + `web/src/reader/`), Chart.js graphs in
stats, toasts, password validation. Anything else that renders app UI
in TypeScript is a defect; if a templ twin of it exists, one of the two
must be deleted.
**Documented exception — dashboard sections**: `renderDashboardCollections`
/ `renderSectionHTML` in `web/src/dashboard.ts` re-render dashboard
sections from `/api/dashboard/sections` JSON (library switch, settings
save, refresh button) to preserve the switch fade/transition on a page
with heavy interactivity. First-paint section markup is still templ
(Dashboard handler in `internal/router/frontend.go`). This exception is
deliberate; do not clone it to new pages, and do not "fix" the dashboard
without restoring a templ-partial fade path first.
## Library visibility (family-lens rule) — ALWAYS apply
Per-user library visibility (`library_visibility` table, default-ALLOW:
@@ -57,6 +94,24 @@ were name-only across ALL libraries. Fixed; do not regress.
cleanups** — sync_integration's `DELETE FROM reading_history WHERE
true` destroyed the live reader's stats data on every suite run
until scoped to the test-sync users (cascade via user delete).
- **RUN ONLY YOUR OWN TESTS while iterating**: integration tests
require the containerized dev DB — a bare host `go test
./cmd/server/tests` fails on auth on every machine not running the
compose stack. Use the filtered container runner
(`docker compose -f docker-compose.yml -f
docker-compose.dev.yml --profile tests run --rm tests go test
./cmd/server/tests -run 'TestYourPrefix' -v`) and save full-suite
runs for pre-commit validation; several tests read live shared
state (system_settings, real library folders, the periodic backup
scan's websocket broadcasts) and flap on data/timing your change
never touched. Full recipes: docs/contributing/development.md
→ Testing.
- **NEVER COMMIT BINARY MEDIA** (mp3/m4b/flac/... fixtures):
`internal/services/testdata/` is gitignored by policy; generate
media at test runtime with ffmpeg instead (test images ship it).
ffmpeg cannot write Audible freeform atoms — those paths are unit-
tested with synthetic ffprobeReport values. Recipes:
docs/contributing/development.md → Media fixtures.
## Timezones
@@ -96,9 +151,11 @@ of a server bug: stop and flag the user.
values** — it emits the literal text. `x-init="f('{ col.ID }')"`
shipped a request to `/api/collections/{ collection.ID }` found by
the request logs; the whole attribute value must BE an expression:
`x-init={ "f('" + col.ID + "')" }`. Note
`class="theme-{ user.Theme }"` has always been literal (harmlessly
masked by the JS theme engine).
`x-init={ "f('" + col.ID + "')" }`. (Every occurrence of this bug —
including `class="theme-{ user.Theme }"` on 22 page bodies and 26
attribute interpolations — was swept and fixed in 2026-10; when
adding attributes with dynamic values, the whole value is an
expression, and free text goes through `templates.JSONArg`.)
2. **SettingsRegistry caches**: writes must go through the settings
API (Reload on save). Direct SQL writes to system_settings leave
the cache stale until the next API-path write — never "preview"
+4 -1
View File
@@ -59,7 +59,10 @@ RUN cp -r /go/pkg/mod /app/go-module-cache
# This stage is ONLY used for running tests, never deployed to production
FROM golang:1.26-alpine AS test-runner
RUN apk --no-cache add ca-certificates curl poppler-utils
# ffmpeg/ffprobe match the runtime stage: the audio ingestion (tag
# fallbacks, embedded covers, cue probing) must be exercised in tests
# exactly as it runs in production.
RUN apk --no-cache add ca-certificates curl poppler-utils ffmpeg
WORKDIR /app
+26 -1
View File
@@ -47,9 +47,34 @@ help:
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
# Run unit tests locally (fast, no containers)
test:
test: lint-rendering
go test ./internal/... -v -short
# Rendering-ownership guard (AGENTS.md → Rendering ownership): templ renders
# UI markup, JS orchestrates. Fails when a web/src file builds more HTML
# template literals than its recorded baseline (web/src/rendering-allowlist.txt),
# or when a .templ file gains an inline <script> block (theme_flash_guard.templ
# is the only sanctioned one). Shrink baselines as fragments move to templ
# partials; grow one only with a written reason.
lint-rendering:
@violations=""; \
for f in $$(grep -rlE 'innerHTML[[:space:]]*=[[:space:]]*' web/src --include='*.ts' | grep -v 'web/src/reader/'); do \
count=$$(grep -E 'innerHTML[[:space:]]*=[[:space:]]*' $$f | grep -cvE 'innerHTML[[:space:]]*=[[:space:]]*""[[:space:]]*$$'); \
allowed=$$(grep -F "$$f:" web/src/rendering-allowlist.txt | cut -d: -f2); \
allowed=$${allowed:-0}; \
if [ $$count -gt $$allowed ]; then \
violations="$$violations $$f ($$count > $$allowed)"; \
fi; \
done; \
for f in $$(grep -rln '<script>' templates --include='*.templ'); do \
case $$f in *theme_flash_guard*) continue;; esac; \
violations="$$violations $$f (inline script)"; \
done; \
if [ -n "$$violations" ]; then \
echo "rendering-ownership violations:$$violations"; exit 1; \
fi; \
echo "rendering ownership OK"
# Run integration tests in containers (matches production environment)
test-integration:
@echo "Building test containers..."
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Audio Chapters
type: http
seq: 30
http:
method: GET
url: "{{base_url}}/api/media-items/{{media_item_id}}/chapters"
headers:
- name: ""
value: application/json
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
## Get Audio Chapters
The format-abstraction chapter list for audiobooks
(media_items.audio_metadata). Single-file books return `layout: ""` and
an empty `tracks` array; folder-of-tracks books return `layout:
"folder"` and zip books `layout: "zip"`, each with one `tracks` entry
per playable file (global-seconds start/end plus the per-track `url`).
**Method:** GET
**Endpoint:** /api/media-items/{id}/chapters
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Audio Track
type: http
seq: 31
http:
method: GET
url: "{{base_url}}/api/media-items/{{media_item_id}}/audio/0"
headers:
- name: Range
value: bytes=0-1023
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
## Get Audio Track
Streams one track of a multi-file audiobook (folder or zip layout).
Range requests are honored: folder tracks are served from the real
file; zip tracks are extracted in place (backward seeks re-open the
entry). Requires the item's library to be visible to the requester
(403 otherwise). Single-file books have no track manifest — 404.
**Method:** GET
**Endpoint:** /api/media-items/{id}/audio/{trackIndex}
@@ -0,0 +1,36 @@
package main
import (
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The Library Type dropdown on /admin/library (and the setup wizard, which
// shares LibraryTypeOptions) must render from the DB-backed library_types
// table. Regression: audiobooks shipped backend-complete (constant, allowed
// extensions, MIME map, schema seed row, /api/libraries/types) while the
// dropdowns hardcoded the original three types, so an audiobook library
// could not be created from the UI.
func TestAdminLibraryPageDropdownCoversAllLibraryTypes(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
code, html := ssrGet(t, setup.Server.URL+"/admin/library", setup.Token)
require.Equal(t, http.StatusOK, code)
for _, typ := range []string{"ebooks", "comics", "manga", "audiobooks"} {
assert.Contains(t, html, `value="`+typ+`"`,
"library type %q missing from the create-library dropdown", typ)
}
assert.Contains(t, html, "Audiobooks")
// Options come from the DB loop, not the deleted hardcode: exactly one
// audiobooks option per page.
assert.Equal(t, 1, strings.Count(html, `value="audiobooks"`))
}
@@ -0,0 +1,571 @@
package main
// Multi-file audiobook integration tests: folder-of-tracks and zip-of-
// audio books must ingest as ONE item each (never per-track items), the
// /chapters manifest must expose the track list, the per-track streaming
// endpoint must serve bytes behind the library-visibility gate, and
// rescans must be idempotent while absorbing pre-grouping one-track
// strays. Live dev DB — all fixtures use test-named libraries under
// t.TempDir() folders and cascade away with the library delete.
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/require"
)
// writeTestTrack renders a short silent mp3 via ffmpeg when available so
// ffprobe can produce real durations; otherwise falls back to dummy bytes
// (ingest is ffprobe-optional by design — structure assertions don't need
// real audio).
func writeTestTrack(t *testing.T, path string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
"-i", "sine=frequency=440:duration=0.3", "-codec:a", "libmp3lame", "-b:a", "32k", path)
if out, err := cmd.CombinedOutput(); err == nil {
return
} else {
t.Logf("ffmpeg track render failed (%s), falling back to dummy bytes: %s", err, out)
}
}
require.NoError(t, os.WriteFile(path, []byte("ID3 dummy audio"), 0o644))
}
// audioTestLibrary builds an audiobooks-type library with a fresh temp
// folder, returning its UUID and the folder path.
func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) {
t.Helper()
libID, folder := createTypedTestLibrary(t, setup, name, "audiobooks")
return libID, folder
}
// listLibraryAudioItems returns every media item row of the library.
func listLibraryAudioItems(t *testing.T, setup *TestServerSetup, libID pgtype.UUID) []database.ListMediaItemsByLibraryIncludingArchivedRow {
t.Helper()
items, err := setup.DB.ListMediaItemsByLibraryIncludingArchived(context.Background(), libID)
require.NoError(t, err)
return items
}
// findAudioItemByPath returns the item with the given file_path.
func findAudioItemByPath(t *testing.T, setup *TestServerSetup, libID pgtype.UUID, filePath string) (database.ListMediaItemsByLibraryIncludingArchivedRow, bool) {
t.Helper()
for _, item := range listLibraryAudioItems(t, setup, libID) {
if item.FilePath == filePath {
return item, true
}
}
return database.ListMediaItemsByLibraryIncludingArchivedRow{}, false
}
// decodeAudioPayload parses the audio_metadata JSONB of an item.
func decodeAudioPayload(t *testing.T, item database.ListMediaItemsByLibraryIncludingArchivedRow) (layout string, trackCount, chapterCount int, duration float64) {
t.Helper()
require.NotNil(t, item.AudioMetadata, "audio_metadata must be present")
var payload struct {
Layout string `json:"layout"`
DurationSeconds float64 `json:"duration_seconds"`
Tracks []struct {
Index int `json:"index"`
Path string `json:"path"`
} `json:"tracks"`
Chapters []json.RawMessage `json:"chapters"`
}
require.NoError(t, json.Unmarshal(item.AudioMetadata, &payload))
return payload.Layout, len(payload.Tracks), len(payload.Chapters), payload.DurationSeconds
}
func TestAudiobookFolderOfTracks(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Folder %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// Author/Title/01.mp3, 02.mp3 — the canonical folder book.
writeTestTrack(t, filepath.Join(folder, "Test Author", "Test Title", "02 Second.mp3"))
writeTestTrack(t, filepath.Join(folder, "Test Author", "Test Title", "01 First.mp3"))
// A lone single-file book in a sibling folder must stay single-file.
writeTestTrack(t, filepath.Join(folder, "Test Author", "Lone Book", "lonely.mp3"))
scanTestFolder(t, setup, admin, folder)
// The folder is ONE item whose file_path is the directory.
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Test Author/Test Title"))
require.True(t, found, "folder book item must exist at the directory path")
layout, tracks, chapters, duration := decodeAudioPayload(t, book)
require.Equal(t, "folder", layout)
require.Equal(t, 2, tracks)
require.Equal(t, 2, chapters)
if duration <= 0 {
t.Log("no ffprobe durations available in this environment (degraded ingest)")
}
// The lone mp3 keeps the single-file model.
_, loneFound := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Test Author/Lone Book/lonely.mp3"))
require.True(t, loneFound, "lone mp3 must stay its own single-file item")
require.Len(t, listLibraryAudioItems(t, setup, libID), 2, "exactly two items: one folder book + one single-file book")
// Rescan idempotency: nothing duplicates, manifest unchanged.
scanTestFolder(t, setup, admin, folder)
require.Len(t, listLibraryAudioItems(t, setup, libID), 2)
again, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Test Author/Test Title"))
require.True(t, found)
require.JSONEq(t, string(book.AudioMetadata), string(again.AudioMetadata))
}
func TestAudiobookAbsorbsOneTrackStrays(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Merge %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// First scan: a single mp3 → single-file item (the pre-grouping model).
writeTestTrack(t, filepath.Join(folder, "Merge Book", "01 only.mp3"))
scanTestFolder(t, setup, admin, folder)
_, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Merge Book/01 only.mp3"))
require.True(t, found, "single track ingests as its own item first")
// Second track arrives; rescan must create the folder book and delete
// the stray one-track item.
writeTestTrack(t, filepath.Join(folder, "Merge Book", "02 and.mp3"))
scanTestFolder(t, setup, admin, folder)
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Merge Book"))
require.True(t, found, "folder book must exist after rescan")
layout, tracks, _, _ := decodeAudioPayload(t, book)
require.Equal(t, "folder", layout)
require.Equal(t, 2, tracks)
items := listLibraryAudioItems(t, setup, libID)
require.Len(t, items, 1, "the one-track stray must be absorbed into the folder book")
}
func TestAudiobookZipBook(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Zip %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// Build a zip of mp3s via ffmpeg-rendered tracks.
dir := t.TempDir()
writeTestTrack(t, filepath.Join(dir, "a.mp3"))
writeTestTrack(t, filepath.Join(dir, "b.mp3"))
zipPath := filepath.Join(folder, "Zipped Book.zip")
zipFiles(t, zipPath, map[string]string{
"01 first.mp3": filepath.Join(dir, "a.mp3"),
"02 second.mp3": filepath.Join(dir, "b.mp3"),
})
scanTestFolder(t, setup, admin, folder)
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Zipped Book.zip"))
require.True(t, found, "zip book item must exist at the zip path")
layout, tracks, chapters, _ := decodeAudioPayload(t, book)
require.Equal(t, "zip", layout)
require.Equal(t, 2, tracks)
require.Equal(t, 2, chapters)
// Rescan idempotency.
scanTestFolder(t, setup, admin, folder)
require.Len(t, listLibraryAudioItems(t, setup, libID), 1)
}
func TestAudiobookTrackEndpoints(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
regularID := getRegularUserID(t, setup.DB)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Serve %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrack(t, filepath.Join(folder, "Serve Book", "01 one.mp3"))
writeTestTrack(t, filepath.Join(folder, "Serve Book", "02 two.mp3"))
scanTestFolder(t, setup, admin, folder)
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Serve Book"))
require.True(t, found)
bookID := uuid.UUID(book.ID.Bytes)
client := &http.Client{}
t.Run("chapters manifest exposes tracks", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/chapters", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
var data struct {
Layout string `json:"layout"`
Tracks []struct {
Index int `json:"index"`
URL string `json:"url"`
Path string `json:"path"`
Start float64 `json:"start"`
} `json:"tracks"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
require.Equal(t, "folder", data.Layout)
require.Len(t, data.Tracks, 2)
require.Equal(t, "/api/media-items/"+bookID.String()+"/audio/0", data.Tracks[0].URL)
})
t.Run("track streams with range support", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio/0", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
req.Header.Set("Range", "bytes=0-99")
resp, err := client.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusPartialContent, resp.StatusCode)
})
t.Run("out-of-range track index is 404", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio/9", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("hidden library is 403 on chapters and tracks", func(t *testing.T) {
_, err := setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: regularID, Valid: true},
LibraryID: libID,
IsVisible: false,
})
require.NoError(t, err)
for _, path := range []string{
"/api/media-items/" + bookID.String() + "/chapters",
"/api/media-items/" + bookID.String() + "/audio/0",
} {
req, _ := http.NewRequest("GET", setup.Server.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := client.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s must be gated", path)
}
// Restore visibility so harness cleanup ordering never sees a
// stale hidden row for a deleted library's user.
_, err = setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: regularID, Valid: true},
LibraryID: libID,
IsVisible: true,
})
require.NoError(t, err)
})
}
// zipFiles builds a zip archive at zipPath mapping entry names to source
// file paths on disk.
func zipFiles(t *testing.T, zipPath string, entries map[string]string) {
t.Helper()
out, err := os.Create(zipPath)
require.NoError(t, err)
defer func() { _ = out.Close() }()
w := zip.NewWriter(out)
for name, src := range entries {
data, err := os.ReadFile(src)
require.NoError(t, err)
f, err := w.Create(name)
require.NoError(t, err)
_, err = f.Write(data)
require.NoError(t, err)
}
require.NoError(t, w.Close())
}
// fixtureAudio copies a committed binary fixture (real tagged media —
// the test container has no way to generate it) into a library folder.
// Path is relative from cmd/server/tests (three deep) to the services
// testdata tree.
func fixtureAudio(t *testing.T, name, dest string) {
t.Helper()
src := filepath.Join("..", "..", "..", "internal", "services", "testdata", "audio", name)
data, err := os.ReadFile(src)
require.NoError(t, err, "fixture %s must exist in the repo", name)
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755))
require.NoError(t, os.WriteFile(dest, data, 0o644))
}
// getJSONWithAuth fetches an API endpoint with the admin token and
// decodes the body into dst.
func getJSONWithAuth(t *testing.T, setup *TestServerSetup, path string, dst interface{}) int {
t.Helper()
req, _ := http.NewRequest("GET", setup.Server.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
if dst != nil {
require.NoError(t, json.NewDecoder(resp.Body).Decode(dst))
}
return resp.StatusCode
}
func TestAudiobookNewContainerFormats(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Formats %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
for _, name := range []string{"book.opus", "book.ogg", "book.oga", "book.aac", "book.wav"} {
// one book per folder — a directory with ≥2 audio files is ONE
// book by the grouping doctrine
fixtureAudio(t, name, filepath.Join(folder, "Formats "+name, name))
}
scanTestFolder(t, setup, admin, folder)
items := listLibraryAudioItems(t, setup, libID)
require.Len(t, items, 5, "each new container ingests as its own single-file book")
mimes := map[string]string{}
for _, item := range items {
require.Equal(t, "audiobook", item.FormatGroup, "%s must classify as audiobook", item.FilePath)
mimes[filepath.Ext(item.FilePath)] = item.MimeType.String
}
require.Equal(t, map[string]string{
".opus": "audio/ogg", ".ogg": "audio/ogg", ".oga": "audio/ogg",
".aac": "audio/aac", ".wav": "audio/wav",
}, mimes)
// The download endpoint must serve the REAL content types — a wrong
// audio/mpeg fallback makes browsers refuse the stream.
for _, name := range []string{"book.opus", "book.aac", "book.wav"} {
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Formats "+name+"/"+name))
require.True(t, found, name)
bookID := uuid.UUID(item.ID.Bytes)
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/download", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
// Drain but discard: a 0.3s sine is a few KB — nothing to save.
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, item.MimeType.String, resp.Header.Get("Content-Type"), "%s content type", name)
}
}
func TestAudiobookTagFallbacks(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Tags %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// No sidecars anywhere: the file's own tags are the only source.
fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Tag Author", "The Tagged Album", "tagged.mp3"))
scanTestFolder(t, setup, admin, folder)
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Tag Author/The Tagged Album/tagged.mp3"))
require.True(t, found)
require.Equal(t, "The Tagged Album", item.Title, "album tag names the book")
require.Equal(t, "Tag Author", item.Author.String, "album_artist tag names the author")
require.Len(t, item.Narrators, 1, "composer tag fills narrators (Audible convention)")
require.Equal(t, "Tag Narrator", item.Narrators[0])
require.Equal(t, "Tag Series", item.Series.String, "grouping tag fills series")
require.Equal(t, "Audiobook", item.Genre.String, "genre tag fills genre")
require.False(t, item.DatePublished.Time.IsZero(), "date tag fills publish date")
require.Equal(t, 2020, item.DatePublished.Time.Year())
// ASIN/abridged/subtitle arrive only through Audible freeform atoms,
// which ffmpeg cannot write into a fixture — those fallbacks are
// pinned by TestApplyAudioTagFallbacks (synthetic reports).
}
func TestAudiobookCueSheetChapters(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Cue %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
fixtureAudio(t, "cuebook.flac", filepath.Join(folder, "Cue Book", "cuebook.flac"))
fixtureAudio(t, "cuebook.cue", filepath.Join(folder, "Cue Book", "cuebook.cue"))
scanTestFolder(t, setup, admin, folder)
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Cue Book/cuebook.flac"))
require.True(t, found)
bookID := uuid.UUID(item.ID.Bytes)
var data struct {
Chapters []struct {
Index int `json:"index"`
Title string `json:"title"`
Start float64 `json:"start"`
End float64 `json:"end"`
} `json:"chapters"`
}
status := getJSONWithAuth(t, setup, "/api/media-items/"+bookID.String()+"/chapters", &data)
require.Equal(t, http.StatusOK, status)
require.Len(t, data.Chapters, 3, "cue tracks become chapters")
require.Equal(t, "Opening", data.Chapters[0].Title)
require.Equal(t, "The Middle Bit", data.Chapters[1].Title)
require.InDelta(t, 20.0, data.Chapters[1].Start, 0.01)
require.Equal(t, "Closing", data.Chapters[2].Title)
}
func TestAudiobookEmbeddedM4BChapters(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio M4B %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
fixtureAudio(t, "chaptered.m4b", filepath.Join(folder, "Chaptered Book.m4b"))
scanTestFolder(t, setup, admin, folder)
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Chaptered Book.m4b"))
require.True(t, found)
bookID := uuid.UUID(item.ID.Bytes)
var data struct {
Chapters []struct {
Title string `json:"title"`
} `json:"chapters"`
DurationSeconds float64 `json:"duration_seconds"`
}
status := getJSONWithAuth(t, setup, "/api/media-items/"+bookID.String()+"/chapters", &data)
require.Equal(t, http.StatusOK, status)
require.Len(t, data.Chapters, 2, "embedded m4b chapters parse")
require.Equal(t, "Part One", data.Chapters[0].Title)
require.Equal(t, "Part Two", data.Chapters[1].Title)
require.Greater(t, data.DurationSeconds, float64(0))
}
func TestAudiobookPlaylistOrdering(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio M3U %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// Three tag-less tracks (natural order 01,02,10) plus a playlist
// imposing a DIFFERENT order and names.
fixtureAudio(t, "plain.mp3", filepath.Join(folder, "Playlist Book", "01_one.mp3"))
fixtureAudio(t, "plain.mp3", filepath.Join(folder, "Playlist Book", "02_two.mp3"))
fixtureAudio(t, "plain.mp3", filepath.Join(folder, "Playlist Book", "10_ten.mp3"))
playlist := "#EXTM3U\n#EXTINF:60,Epilogue\n./10_ten.mp3\n#EXTINF:60,Prologue\n01_one.mp3\n"
require.NoError(t, os.WriteFile(filepath.Join(folder, "Playlist Book", "book.m3u"), []byte(playlist), 0o644))
scanTestFolder(t, setup, admin, folder)
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Playlist Book"))
require.True(t, found, "folder book created")
require.NotNil(t, item.AudioMetadata)
var payload struct {
Tracks []struct {
Index int `json:"index"`
Path string `json:"path"`
Title string `json:"title"`
} `json:"tracks"`
}
require.NoError(t, json.Unmarshal(item.AudioMetadata, &payload))
require.Len(t, payload.Tracks, 3)
require.Equal(t, "Playlist Book/10_ten.mp3", payload.Tracks[0].Path, "playlist order beats natural sort")
require.Equal(t, "Epilogue", payload.Tracks[0].Title)
require.Equal(t, "Playlist Book/01_one.mp3", payload.Tracks[1].Path)
require.Equal(t, "Prologue", payload.Tracks[1].Title)
require.Equal(t, "Playlist Book/02_two.mp3", payload.Tracks[2].Path, "unlisted track appended in natural order")
}
func TestAudiobookEmbeddedCoverArt(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Cover %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// Single file: embedded APIC → "<file>.cover.jpg" sibling.
fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Cover Book", "tagged.mp3"))
// Folder book: sidecar cover.jpg must WIN over the embedded art.
fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Sidecar Book", "01 track.mp3"))
fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Sidecar Book", "02 track.mp3"))
// A real (if tiny) sidecar cover next to the folder book:
fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Sidecar Book", "cover.jpg"))
scanTestFolder(t, setup, admin, folder)
single, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Cover Book/tagged.mp3"))
require.True(t, found)
if !ffmpegAvailableForTest() {
t.Skip("ffmpeg unavailable in this environment; embedded covers degrade gracefully")
}
require.NotEmpty(t, single.CoverImagePath.String, "embedded APIC becomes the cover")
coverRel := single.CoverImagePath.String
_, err := os.Stat(filepath.Join(folder, coverRel))
require.NoError(t, err, "cover sibling file exists on disk")
folder_, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Sidecar Book"))
require.True(t, found)
require.Equal(t, filepath.ToSlash("Sidecar Book/cover.jpg"), folder_.CoverImagePath.String,
"sidecar cover wins over embedded art")
}
func ffmpegAvailableForTest() bool {
_, err := os.Stat("/usr/bin/ffmpeg")
if err == nil {
return true
}
_, err = exec.LookPath("ffmpeg")
return err == nil
}
func TestRescanKeepsFilenameTitle(t *testing.T) {
// Regression pin: extractMetadata's default branch (.mobi/.azw/.pdb
// and unknown types) no longer pre-fills the filename as title, so
// the rescan path MUST apply the final filename fallback itself —
// a sidecar-less, tag-less book at a library root keeps its title
// instead of being blanked by a rescan.
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Rescan Title %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// Library-root .mobi with no sidecars, no tags, no folder structure.
require.NoError(t, os.WriteFile(filepath.Join(folder, "Plain Book.mobi"), []byte("dummy mobi bytes"), 0o644))
scanTestFolder(t, setup, admin, folder)
item, found := findAudioItemByPath(t, setup, libID, "Plain Book.mobi")
require.True(t, found)
require.Equal(t, "Plain Book", item.Title, "create path falls back to filename")
bookID := uuid.UUID(item.ID.Bytes)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+bookID.String()+"/rescan", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
rescanned, err := setup.DB.GetMediaItem(context.Background(), item.ID)
require.NoError(t, err)
require.Equal(t, "Plain Book", rescanned.Title, "rescan must not blank the filename-derived title")
}
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// POST /api/scanner/cancel/:jobId is admin-only and must answer with
// distinct codes for unknown (404) and already-terminal (409) jobs.
func TestScanCancelEndpointSemantics(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
t.Run("cancel without admin role is forbidden", func(t *testing.T) {
url := fmt.Sprintf("%s/api/scanner/cancel/%s", setup.Server.URL, uuid.New().String())
req, _ := http.NewRequest("POST", url, nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("cancel unknown job is not found", func(t *testing.T) {
url := fmt.Sprintf("%s/api/scanner/cancel/%s", setup.Server.URL, uuid.New().String())
req, _ := http.NewRequest("POST", url, nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}
// Cancelling a manual scan mid-run must never corrupt library state:
// the abort skips the archive pass, so unvisited (but on-disk) files
// are not treated as missing. The canary item points at a file that is
// lexically LAST in the folder, so a mid-walk abort that (incorrectly)
// ran the archive pass would mark it missing and fail the assertion.
// The assertions hold under every interleaving — whether the cancel
// lands mid-scan or the scan already finished — so the test cannot
// flap against timing.
func TestScanCancelMidScanSafety(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
token := setup.Token
// Test library (helper registers API-delete cleanup so the run
// leaves no residue in the dev database).
libraryID := createTestLibraryWithFolder(t, setup.Server, token, "Test Scan Cancel Library", false)
libUUID, err := uuid.Parse(libraryID)
require.NoError(t, err)
// Folder on disk: many junk .epub files (zero bytes cannot parse as
// a zip/epub, so processMediaFile rejects each one and creates no
// rows) plus the canary file, lexically last.
dir := t.TempDir()
const fileCount = 1200
junk := bytes.Repeat([]byte{0}, 1024)
for i := 0; i < fileCount; i++ {
name := filepath.Join(dir, fmt.Sprintf("book-%04d.epub", i))
require.NoError(t, os.WriteFile(name, junk, 0o644))
}
canaryRel := "zzz-last.epub"
require.NoError(t, os.WriteFile(filepath.Join(dir, canaryRel), junk, 0o644))
// Register the folder with the library.
folderURL := fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID)
body, _ := json.Marshal(map[string]interface{}{"folder_path": dir})
req, _ := http.NewRequest("POST", folderURL, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode, "folder creation should succeed")
// Pre-existing item whose file exists on disk. A normal completed
// scan never marks it missing; neither may a cancelled one.
_, err = setup.DBPool.Exec(context.Background(),
"INSERT INTO media_items (library_id, title, file_path) VALUES ($1, $2, $3)",
pgtype.UUID{Bytes: libUUID, Valid: true}, "Test Cancel Canary", canaryRel)
require.NoError(t, err)
// Start the scan and cancel it as soon as it is accepted.
jobID := startScan(t, client, setup.Server.URL, token, libraryID)
cancelURL := fmt.Sprintf("%s/api/scanner/cancel/%s", setup.Server.URL, jobID)
cancelReq, _ := http.NewRequest("POST", cancelURL, nil)
cancelReq.Header.Set("Authorization", "Bearer "+token)
cancelResp, err := client.Do(cancelReq)
require.NoError(t, err)
_ = cancelResp.Body.Close()
// 200 = cancelled mid-run; 409 = the scan already finished before
// the cancel landed. Both are acceptable; the assertions below hold
// either way.
assert.Contains(t, []int{http.StatusOK, http.StatusConflict}, cancelResp.StatusCode)
// Poll to a terminal state.
final := pollJobToTerminal(t, client, setup.Server.URL, token, jobID)
assert.Contains(t, []string{"completed", "cancelled"}, final["status"],
"scan must finish cleanly or be cancelled, got %v (error: %v)", final["status"], final["error"])
// The safety invariant: no item was marked missing or archived, and
// the junk files created no phantom rows.
var items, missingSum, archived int
err = setup.DBPool.QueryRow(context.Background(),
`SELECT COUNT(*),
COALESCE(SUM(missing_scan_count), 0),
COUNT(*) FILTER (WHERE archived_at IS NOT NULL)
FROM media_items WHERE library_id = $1`,
pgtype.UUID{Bytes: libUUID, Valid: true}).Scan(&items, &missingSum, &archived)
require.NoError(t, err)
assert.Equal(t, 1, items, "junk files must not create media items")
assert.Zero(t, missingSum, "cancelled/completed scan must not mark on-disk files missing")
assert.Zero(t, archived, "no item may be archived by this scan")
// Cancelling a terminal job is a conflict, whichever terminal state
// the job reached.
cancelReq2, _ := http.NewRequest("POST", cancelURL, nil)
cancelReq2.Header.Set("Authorization", "Bearer "+token)
cancelResp2, err := client.Do(cancelReq2)
require.NoError(t, err)
_ = cancelResp2.Body.Close()
assert.Equal(t, http.StatusConflict, cancelResp2.StatusCode)
}
func startScan(t *testing.T, client *http.Client, baseURL, token, libraryID string) string {
url := fmt.Sprintf("%s/api/libraries/%s/scan", baseURL, libraryID)
req, _ := http.NewRequest("POST", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusAccepted, resp.StatusCode)
var decoded map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
jobID, ok := decoded["job_id"].(string)
require.True(t, ok, "job_id should be a string")
require.NotEmpty(t, jobID)
return jobID
}
func pollJobToTerminal(t *testing.T, client *http.Client, baseURL, token, jobID string) map[string]interface{} {
url := fmt.Sprintf("%s/api/scanner/status/%s", baseURL, jobID)
for i := 0; i < 30; i++ {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
var status map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&status)
_ = resp.Body.Close()
require.NoError(t, err)
switch status["status"] {
case "completed", "failed", "cancelled":
return status
}
time.Sleep(500 * time.Millisecond)
}
t.Fatalf("job %s did not reach a terminal state within 15s", jobID)
return nil
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"context"
"fmt"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSeriesPagination pins the SSR series-page pagination contract
// (2026-10-19 paginator fix). The /series page renders its grid and
// paginator entirely server-side (templates/series.templ); page state
// lives in the ?page= query param. The fixture mirrors a prod bulk
// import: 30 series whose items share ONE identical created_at (a single
// transaction's NOW()), which historically left the ORDER BY tied — the
// tiebreaker (library_id, series) makes pages deterministic.
func TestSeriesPagination(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Pagination Library", false)
ctx := context.Background()
const totalSeries = 30
for i := 1; i <= totalSeries; i++ {
_, err := setup.DBPool.Exec(ctx,
`INSERT INTO media_items (library_id, title, file_path, series, created_at, imported_at, updated_at)
VALUES ($1, $2, $3, $4, '2026-01-01 00:00:00+00', '2026-01-01 00:00:00+00', '2026-01-01 00:00:00+00')`,
libID, fmt.Sprintf("Pagination Probe Book %02d", i),
fmt.Sprintf("/tmp/test-pagination/book-%02d.epub", i),
fmt.Sprintf("Pagination Probe Series %02d", i),
)
require.NoError(t, err)
}
t.Cleanup(func() {
_, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
})
// templ separates dynamic text segments with HTML comments; strip them
// so plain regexes work on names and the page chip.
stripComments := func(html string) string {
return regexp.MustCompile(`<!--.*?-->`).ReplaceAllString(html, "")
}
seriesNames := func(html string) map[string]bool {
names := map[string]bool{}
for _, m := range regexp.MustCompile(`<h3[^>]*>([^<]+)</h3>`).FindAllStringSubmatch(stripComments(html), -1) {
names[strings.TrimSpace(m[1])] = true
}
return names
}
pageChip := func(html string) string {
m := regexp.MustCompile(`Page\s+(\d+)\s+of\s+(\d+)`).FindStringSubmatch(stripComments(html))
if m == nil {
return ""
}
return fmt.Sprintf("Page %s of %s", m[1], m[2])
}
perPage := 24 // internal/router/frontend.go perSeriesPage
t.Run("page1 defaults and next link", func(t *testing.T) {
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.Token)
require.Equal(t, 200, code)
assert.Equal(t, fmt.Sprintf("Page 1 of 2"), pageChip(body))
names := seriesNames(body)
assert.Len(t, names, perPage, "page 1 renders exactly 24 cards")
assert.Contains(t, names, "Pagination Probe Series 01", "deterministic order: page 1 starts at the first series")
assert.Contains(t, names, fmt.Sprintf("Pagination Probe Series %02d", perPage), "page 1 ends at the 24th series")
assert.NotContains(t, names, fmt.Sprintf("Pagination Probe Series %02d", perPage+1), "page 1 must not leak page-2 rows")
assert.NotContains(t, body, "Previous", "no Previous link on page 1")
// templ HTML-escapes & as &amp; inside attribute values.
assert.Contains(t, body, fmt.Sprintf(`href="/series?library_id=%s&amp;page=2"`, libID), "Next link carries library_id and page")
})
t.Run("page2 is disjoint and terminal", func(t *testing.T) {
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+"&page=2", setup.Token)
require.Equal(t, 200, code)
assert.Equal(t, "Page 2 of 2", pageChip(body))
names := seriesNames(body)
assert.Len(t, names, totalSeries-perPage, "page 2 renders the remainder")
code1, body1 := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+"&page=1", setup.Token)
require.Equal(t, 200, code1)
page1 := seriesNames(body1)
for name := range names {
assert.NotContains(t, page1, name, "pages must be disjoint even with fully tied created_at")
}
assert.NotContains(t, body, "Next", "no Next link on the last page")
assert.Contains(t, body, fmt.Sprintf(`href="/series?library_id=%s&amp;page=1"`, libID), "Previous link carries library_id and page")
})
t.Run("missing page param defaults to page 1", func(t *testing.T) {
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.Token)
require.Equal(t, 200, code)
assert.Equal(t, "Page 1 of 2", pageChip(body))
})
t.Run("invalid page param falls back to page 1", func(t *testing.T) {
for _, bad := range []string{"0", "-3", "two"} {
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+"&page="+bad, setup.Token)
require.Equal(t, 200, code, "page=%s", bad)
assert.Equal(t, "Page 1 of 2", pageChip(body), "page=%s must clamp to page 1", bad)
}
})
}
+8 -2
View File
@@ -244,8 +244,12 @@ CREATE TABLE IF NOT EXISTS media_items (
-- { "narrators": [], "duration_seconds": N, "codec_profile": "LC",
-- "bit_rate": N, "asin": "...", "chapters": [
-- { "index": 0, "title": "...", "start": 0.0, "end": 168.0 } ] }
-- The chapters array IS the format abstraction: embedded m4b chapters
-- and (later) folder-as-book files both write this exact shape.
-- The chapters array IS the format abstraction: embedded m4b chapters,
-- folder-of-tracks books and zip-of-audio books all write this exact
-- shape. Multi-file books add additive keys: "layout"
-- ("folder" | "zip"; absent = single file) and "tracks" (one entry
-- per playable file with global-seconds start/end plus the path the
-- /audio/:trackIndex endpoint resolves).
audio_metadata JSONB
);
@@ -1353,6 +1357,8 @@ CREATE TABLE IF NOT EXISTS unlinked_books (
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
-- One queue row per (device, document): the sync ingest upserts on this pair.
CREATE UNIQUE INDEX IF NOT EXISTS idx_unlinked_books_device_content ON unlinked_books(device_id, content_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_resolved ON unlinked_books(resolved);
-- Create saved_filters for users to be able to filter different parts of the application
+17
View File
@@ -102,3 +102,20 @@ cover_image_path, has_conflict, format_group, badge`.
origin — its rows are unchanged. Request-parse widening documented per
rule 2; response shapes untouched; locked by
TestOriginWriterAttribution.
## Changelog entry — multi-file audiobooks (2026-10-18)
- `GET /api/media-items/:id/chapters` gained ADDITIVE response keys
`layout` ("" for single-file books, `"folder"` or `"zip"` for
multi-file books) and `tracks[]` (`index, url, title, path, start,
end, duration_seconds, size_bytes`; empty for single-file books).
Existing keys (`media_item_id, chapters, duration_seconds,
codec_profile`) unchanged — no pin existed on this endpoint, so no
pin widened; recorded here per rule 2's spirit.
- New sibling endpoint `GET /api/media-items/:id/audio/:trackIndex`
streams one track of a multi-file book (Range-capable). New surface,
not a modification; gated by library visibility like `ServeFile`
(403 on hidden libraries).
- `media_items.audio_metadata` payload gained additive JSON keys
`layout` and `tracks` (see schema.sql comment). Consumers parse
additively; stored single-file payloads are unchanged.
+72 -7
View File
@@ -258,17 +258,82 @@ go tool cover -html=coverage.out
### Integration Tests
Integration tests hit the LIVE dev database and bind-mounted paths, both
of which exist only inside the Docker stack. A bare host `go test
./cmd/server/tests` fails with database-auth errors on every machine
that isn't running the compose stack — the container is the only
supported runner.
```bash
# Run integration tests (may hit rate limits)
go test ./cmd/server/tests -v
# Run the whole integration suite (pre-commit validation — NOT for
# iterating on a change; some tests are coupled to the shared dev DB's
# live state and this environment)
make test-integration
# Run with test mode (recommended)
TEST_MODE=true RATE_LIMIT_ENABLED=false go test ./cmd/server/tests -v
# Run specific test
go test ./cmd/server/tests -run TestAuth -v
# Iterate on YOUR change: run only your own tests, in the container
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile tests run --rm tests \
go test ./cmd/server/tests -run 'TestYourPrefix' -v -timeout 600s
```
**Run only your own tests while iterating.** A full-suite run rebuilds
the test image and takes minutes; more importantly, several tests read
live state (the dev DB's `system_settings`, real library folders, a
periodic backup scan broadcasting over websocket) and can fail on
timing or on data your change never touched. Filtered runs are cheap,
deterministic for your scope, and don't fight the live reader for the
database.
Test-created data rules (harness cleanup depends on them): users use
`@tests.bookhoard.internal` / `@example.com` emails, libraries and
collections carry "test"-prefixed names, and cleanups delete test
libraries (cascades handle the rest) rather than table-wide deletes.
### Media Fixtures (audio/books)
**Never commit binary media.** The Go toolchain and Docker prod builds
ignore `internal/services/testdata/` entirely (gitignored by policy —
see `.gitignore`), and the production image never copies `internal/`.
Generate what a test needs at runtime with ffmpeg (shipped in both the
test-runner and runtime images):
```bash
# The five audio containers (audiobook library accepts all of these)
ffmpeg -f lavfi -i sine=frequency=440:duration=0.3 -codec:a libmp3lame -b:a 32k plain.mp3
ffmpeg -f lavfi -i sine=frequency=440:duration=0.3 -codec:a flac book.flac
ffmpeg -f lavfi -i sine=frequency=440:duration=0.3 -codec:a libopus -b:a 24k book.opus
ffmpeg -f lavfi -i sine=frequency=440:duration=0.3 -codec:a libvorbis -q:a 1 book.ogg
ffmpeg -f lavfi -i sine=frequency=440:duration=0.3 -codec:a aac -b:a 32k book.aac
ffmpeg -f lavfi -i sine=frequency=440:duration=0.3 -codec:a pcm_s16le book.wav
# Tags (album=book title, album_artist=author, composer=narrator —
# Audible's own convention — plus disc/track ordering keys)
ffmpeg -i in.mp3 -codec copy \
-metadata title="Chapter Three" -metadata album="The Book" \
-metadata album_artist="Author" -metadata composer="Narrator" \
-metadata track="3/9" -metadata disc="2/3" \
-metadata grouping="Series" -metadata genre="Audiobook" -metadata date=2020 out.mp3
# Embedded cover art (mp3 APIC / m4b covr): the picture rides as the
# only video stream
ffmpeg -i cover.jpg -i audio-only.mp3 -map 1:a -map 0:v -c:v copy \
-disposition:v attached_pic tagged.mp3
# Embedded m4b chapters: an ffmetadata sidecar file
printf ';FFMETADATA1\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=500\ntitle=Part One\n' > chapters.txt
ffmpeg -i in.m4b -i chapters.txt -map 0:a -map 1 -map_metadata 1 -codec copy -f ipod chaptered.m4b
```
CUE sheets and m3u playlists are plain text — write them inline in the
test. **The one thing ffmpeg cannot synthesize: Audible freeform atoms**
(`----:com.apple.iTunes:SERIES`, `:SUBTITLE`, `:ASIN`, `:ABRIDGED`) —
those ingestion paths are covered by unit tests using synthetic
`ffprobeReport` values instead of binary files.
Manual end-to-end testing against the live system: drop fixture books
into `uploads/Audiobooks/Ingestion Fixtures/` — **one book per
subfolder** (a directory with ≥2 audio files ingests as ONE
folder-of-tracks book) — and run a library scan from the admin UI.
### API Testing with Bruno OpenCollection YAML
```bash
@@ -0,0 +1,53 @@
# Get Audio Chapters
The format-abstraction chapter list for audiobooks, sourced from
`media_items.audio_metadata`. Embedded m4b chapters, folder-of-tracks
books and zip-of-audio books all write the same chapter shape; the
locator `{chapter, audio_offset_seconds}` and this endpoint never learn
which file model produced them.
**Endpoint**: `GET /api/media-items/{id}/chapters`
**Auth**: Required (JWT)
## Response
```json
{
"media_item_id": "…",
"chapters": [
{ "index": 0, "title": "Chapter One", "start": 0.0, "end": 168.0 }
],
"duration_seconds": 36000.0,
"codec_profile": "mp3",
"layout": "folder",
"tracks": [
{
"index": 0,
"url": "/api/media-items/{id}/audio/0",
"title": "01 First",
"path": "Author/Title/01 First.mp3",
"start": 0.0,
"end": 168.0,
"duration_seconds": 168.0,
"size_bytes": 2700000
}
]
}
```
- `layout` is `""` for single-file books (the Batch A model — the whole
file streams through the `/uploads` path), `"folder"` for a directory
of track files and `"zip"` for a zip-of-audio book.
- `tracks` is empty for single-file books and carries one entry per
playable file otherwise. `start`/`end` are cumulative GLOBAL seconds —
the same coordinate system chapters, bookmarks and
`audio_offset_seconds` use.
## Error Responses
| Code | Description |
| ---- | ------------------------------- |
| 400 | Invalid media item id |
| 401 | Missing/invalid token |
| 404 | Book not found |
| 500 | audio_metadata failed to parse |
@@ -0,0 +1,44 @@
# Get Audio Track
Streams one playable track of a multi-file audiobook (folder-of-tracks
or zip-of-audio book). Track indexes and bounds come from the track
manifest on [Get Audio Chapters](get_audio_chapters.md).
**Endpoint**: `GET /api/media-items/{id}/audio/{trackIndex}`
**Auth**: Required (JWT — Bearer header or session cookie; the web
player's `<audio>` element authenticates by cookie)
## Serving
- **Folder books**: the track is served straight from its real file
(`http.ServeContent`) — full `Range` support.
- **Zip books**: the entry is extracted in place. Forward seeks discard
bytes in the open stream; backward seeks re-open the entry and skip
(deflate has no random access — cheap at audio bitrates). `Range`
requests are honored.
Single-file books have no track manifest — they stream through the
`/uploads` path exactly as before and this endpoint answers 404.
## Library visibility
The item's library must be visible to the requester; a hidden library
answers **403** on every track (same gate as the download endpoint).
## Error Responses
| Code | Description |
| ---- | -------------------------------------------- |
| 400 | Invalid media item id or track index |
| 401 | Missing/invalid token |
| 403 | Library not visible to the requester |
| 404 | Book/track not found, or single-file book |
| 500 | audio_metadata failed to parse |
## Example
```bash
curl -H "Authorization: Bearer $TOKEN" \
-H "Range: bytes=0-1023" \
"http://localhost:8765/api/media-items/550e8400-.../audio/0"
```
@@ -90,8 +90,11 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
### Absent data
`Audio Tracks` (Audiobookshelf's per-file count) is deliberately absent:
Bookhoard audiobooks are one file per book until multi-file support
lands, so a file count would just equal `items`. See HANDOFF item 9.
multi-file audiobooks (folder-of-tracks / zip books) now ingest as ONE
item whose track list lives in `audio_metadata.tracks`, so a file count
would still not equal a per-book "tracks" metric without a new query.
Add it by aggregating `jsonb_array_length(audio_metadata->'tracks')` if
the panel is ever wanted.
## Error Responses
+83
View File
@@ -0,0 +1,83 @@
# Audiobook metadata: sources & precedence
Every audiobook ingestion path (single-file, folder-of-tracks, zip-of-
audio) fills the same fields from the same ranked sources. Each source
**only fills fields the earlier ones left empty** — curated data always
outranks guesses — and every source is optional: a book still ingests
(title from the filename) when all of them are absent. Admin edits made
after ingest are protected by the metadata-override mechanism on
rescans, regardless of source quality.
## Book-level metadata (title, author, narrators, series, …)
| Rank | Source | Feeds |
|---|---|---|
| 1 | **Sidecars** — `metadata.opf` (Calibre), `metadata.json` (Audiobookshelf/Libation, array-or-string tolerant) | title (+subtitle join), author, series + index, narrators, abridged, ASIN, tags/genres, dates, publisher, description |
| 2 | **Folder structure** — `Author/Title`, `Author/Series, Book #N - Title`, `Author/Series/Title` | title, author, series + index |
| 3 | **Embedded tags** (via ffprobe) | `album` → title; `album_artist` → author (`artist` fallback); `composer` → **narrators** (Audible's own convention); `grouping` → series; `date` → publish date; `genre` → genre; Audible freeform atoms `----:com.apple.iTunes:SERIES`/`:SERIES-PART`/`:SUBTITLE`/`:ASIN`/`:ABRIDGED` |
| 4 | **Filename/folder name** | title last of all (created only — never lets a rescan blank a title) |
Abridged is the one guarded field: an explicit sidecar `false` wins even
when a tag says `true` (the sidecar's presence is tracked, not just its
value). Narrators from the composer tag stay ONE string — composer
values have no reliable multi-narrator delimiter, and splitting
"Vance, Simon" would manufacture fake people.
## Chapters (the `{index, title, start, end}` array)
| Rank | Source |
|---|---|
| 1 | Sidecar `metadata.json` `chapters[]` (curated, global seconds) |
| 2 | Embedded m4b chapter atoms (`chpl` / QuickTime text track, read by ffprobe) |
| 3 | **CUE sheet** — `.cue` next to the audio file (FILE-directive matched, `INDEX 01 MM:SS:FF` at 75 fps); the classic one-big-flac + cue rip shape |
| 4 | Track boundaries — for folder/zip books, each file is a chapter |
## Track ordering (multi-file books)
| Rank | Source |
|---|---|
| 1 | **Playlist** — exactly one `.m3u`/`.m3u8` among the book root's direct children; its entry order wins and `#EXTINF` names the tracks |
| 2 | **Tags** — `(disc, track)` sort when *every* track carries a track number (fixes flat `01.mp3` dumps from multi-disc rips) |
| 3 | **Natural sort** of relative paths (`02` before `10`), recursive — the default |
Track display titles, highest first: `movement_name` → `title` tag →
playlist `#EXTINF` → cleaned filename (leading ordinals/separators
stripped).
## Book grouping (which files form one book)
- The book root is the nearest ancestor holding audio files DIRECTLY,
merged upward through disc-part directories (`CD1`, `Disc 2`, `Part 3`,
`05`) when the parent has no audio of its own.
- Sibling directories are NEVER merged (two books under one author stay
two books); a root with a single track falls back to the single-file
model.
- A lone `.zip` of audio files is its own book (layout "zip"); a folder
of tracks is one book with `file_path` = the directory (layout
"folder"); a single audio file is the Batch A model (no layout key).
- Rescans take a fast path (track count + total size + known durations
unchanged → no re-probe) and absorb pre-grouping one-track strays.
## Covers
| Rank | Source |
|---|---|
| 1 | Sidecar `cover.jpg` / `folder.jpg` / … next to the book |
| 2 | **Embedded art** (`APIC` mp3, `covr` m4b, flac picture) — one optional ffmpeg call, written as a `<book>.cover.jpg` sibling following the EPUB convention |
## Accepted containers (audiobook libraries)
`.m4b` `.m4a` `.mp3` `.flac` `.zip` (book container) `.opus` `.ogg`
`.oga` `.aac` `.wav` — all browser-playable; served with their real
media types (`audio/ogg` per RFC 7845 for Opus-in-Ogg, etc.).
## Documented non-goals
- **ID3v2 CHAP/CTOC chapter frames** — the written MP3 standard for
embedded chapters, near-zero audiobook adoption; would need a
hand-rolled ID3v2.4 parser. Revisit only if real files show up.
- **NFO sidecars** — no genuine audiobook NFO standard exists (Jellyfin's
Bookshelf uses Calibre OPF, which we read).
- **ReplayGain / EBU R128 loudness tags** — a playback feature, not
ingestion; separate plan if ever wanted.
- **`.wma`** — browsers cannot play it; excluded deliberately.
+17
View File
@@ -48,6 +48,8 @@ type Querier interface {
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
// Count unlinked books for a device
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
// Banner count: unresolved queue rows across the caller's own devices.
CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
// COLLECTIONS QUERIES
@@ -219,6 +221,11 @@ type Querier interface {
// Series are grouped PER LIBRARY: the same series name in two libraries
// (novels vs manga adaptations, LN splits) stays two entries. Visible
// filter enforces per-user library visibility at the source.
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied on
// MAX(created_at). Postgres does not guarantee tie order between
// executions, so page boundaries could repeat or drop series. The GROUP BY
// keys make the sort total and deterministic.
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
@@ -469,6 +476,8 @@ type Querier interface {
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
// Page list: unresolved queue rows for the caller's own devices.
ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk.
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
@@ -570,6 +579,11 @@ type Querier interface {
UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error)
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
// Focused refresh for multi-file audiobooks (folder-of-tracks / zip
// books): rescans recompute the track manifest and the aggregate facts
// that follow from it. Editorial fields stay untouched — admins may have
// edited title/narrators after ingest.
UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (MediaItems, error)
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
// Update media item format
UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error)
@@ -607,6 +621,9 @@ type Querier interface {
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
// Ingest: insert or refresh the queue row for a device file that could not
// be identified. One row per (device, content_id); re-syncs bump last_seen.
UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error)
}
var _ Querier = (*Queries)(nil)
+240 -1
View File
@@ -332,6 +332,21 @@ func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID)
return count, err
}
const CountUnresolvedUnlinkedBooksForUser = `-- name: CountUnresolvedUnlinkedBooksForUser :one
SELECT COUNT(*) as count
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
`
// Banner count: unresolved queue rows across the caller's own devices.
func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID)
var count int64
err := row.Scan(&count)
return count, err
}
const CountUserDevices = `-- name: CountUserDevices :one
SELECT COUNT(*) FROM devices WHERE user_id = $1
`
@@ -3825,7 +3840,7 @@ WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
GROUP BY library_id, series
ORDER BY MAX(created_at) DESC
ORDER BY MAX(created_at) DESC, library_id, series
LIMIT $4 OFFSET $3
`
@@ -3847,6 +3862,11 @@ type GetDistinctSeriesRow struct {
// Series are grouped PER LIBRARY: the same series name in two libraries
// (novels vs manga adaptations, LN splits) stays two entries. Visible
// filter enforces per-user library visibility at the source.
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied on
// MAX(created_at). Postgres does not guarantee tie order between
// executions, so page boundaries could repeat or drop series. The GROUP BY
// keys make the sort total and deterministic.
func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
rows, err := q.db.Query(ctx, GetDistinctSeries,
arg.LibraryID,
@@ -11365,6 +11385,69 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
return items, nil
}
const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
ORDER BY ub.last_seen_at DESC
`
type ListUnresolvedUnlinkedBooksForUserRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Page list: unresolved queue rows for the caller's own devices.
func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) {
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListUnresolvedUnlinkedBooksForUserRow{}
for rows.Next() {
var i ListUnresolvedUnlinkedBooksForUserRow
if err := rows.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListUsers = `-- name: ListUsers :many
SELECT
u.id,
@@ -14219,6 +14302,111 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
return i, err
}
const UpdateMediaItemAudioBookPayload = `-- name: UpdateMediaItemAudioBookPayload :one
UPDATE media_items
SET file_size = $2,
mime_type = $3,
audio_metadata = $4,
chapter_count = $5,
updated_at = NOW()
WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
`
type UpdateMediaItemAudioBookPayloadParams struct {
ID pgtype.UUID `db:"id" json:"id"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
}
// Focused refresh for multi-file audiobooks (folder-of-tracks / zip
// books): rescans recompute the track manifest and the aggregate facts
// that follow from it. Editorial fields stay untouched — admins may have
// edited title/narrators after ingest.
func (q *Queries) UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (MediaItems, error) {
row := q.db.QueryRow(ctx, UpdateMediaItemAudioBookPayload,
arg.ID,
arg.FileSize,
arg.MimeType,
arg.AudioMetadata,
arg.ChapterCount,
)
var i MediaItems
err := row.Scan(
&i.ID,
&i.LibraryID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Narrators,
&i.Abridged,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
&i.Language,
&i.Edition,
&i.PageCount,
&i.Genre,
&i.CopyrightYear,
&i.GoodreadsID,
&i.OpenlibraryID,
&i.GoogleBooksID,
&i.AddedByAdminID,
&i.CreatedAt,
&i.ImportedAt,
&i.UpdatedAt,
&i.FormatGroup,
&i.FormatMimetype,
&i.IsReflowable,
&i.HasFixedLayout,
&i.TotalCharacters,
&i.ChapterCount,
&i.EntitlementID,
&i.RevisionNumber,
&i.KoboContentID,
&i.KoboMetadata,
&i.MangaType,
&i.ReadingDirection,
&i.SeriesCount,
&i.Volume,
&i.Imprint,
&i.AgeRating,
&i.WebUrl,
&i.StoryArc,
&i.IsBlackAndWhite,
&i.MetadataNotes,
&i.CommunityRating,
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch,
&i.ContributorsSearch,
&i.NarratorsSearch,
&i.FileSha256,
&i.OpfIdentifier,
&i.OpfUuid,
&i.HashConfidence,
)
return i, err
}
const UpdateMediaItemChapterMetadata = `-- name: UpdateMediaItemChapterMetadata :one
UPDATE media_items
SET chapter_metadata = $2, updated_at = NOW()
@@ -15382,3 +15570,54 @@ func (q *Queries) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSetti
)
return i, err
}
const UpsertUnlinkedBook = `-- name: UpsertUnlinkedBook :one
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (device_id, content_id) DO UPDATE
SET file_path = EXCLUDED.file_path,
title = EXCLUDED.title,
author = EXCLUDED.author,
confidence_score = EXCLUDED.confidence_score,
last_seen_at = NOW()
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
`
type UpsertUnlinkedBookParams struct {
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
}
// 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.
func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error) {
row := q.db.QueryRow(ctx, UpsertUnlinkedBook,
arg.DeviceID,
arg.ContentID,
arg.FilePath,
arg.Title,
arg.Author,
arg.ConfidenceScore,
)
var i UnlinkedBooks
err := row.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
)
return i, err
}
+48 -1
View File
@@ -2619,6 +2619,34 @@ WHERE ub.resolved = false
ORDER BY ub.last_seen_at DESC
LIMIT $1 OFFSET $2;
-- 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.
-- name: UpsertUnlinkedBook :one
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (device_id, content_id) DO UPDATE
SET file_path = EXCLUDED.file_path,
title = EXCLUDED.title,
author = EXCLUDED.author,
confidence_score = EXCLUDED.confidence_score,
last_seen_at = NOW()
RETURNING *;
-- Banner count: unresolved queue rows across the caller's own devices.
-- name: CountUnresolvedUnlinkedBooksForUser :one
SELECT COUNT(*) as count
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false;
-- Page list: unresolved queue rows for the caller's own devices.
-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
ORDER BY ub.last_seen_at DESC;
-- ============================================
-- CAROUSEL-STYLE DASHBOARD
-- ============================================
@@ -2852,7 +2880,12 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
GROUP BY library_id, series
ORDER BY MAX(created_at) DESC
-- Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
-- transaction share a single NOW() timestamp, leaving many groups tied on
-- MAX(created_at). Postgres does not guarantee tie order between
-- executions, so page boundaries could repeat or drop series. The GROUP BY
-- keys make the sort total and deterministic.
ORDER BY MAX(created_at) DESC, library_id, series
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: GetDistinctSeriesCount :one
@@ -3201,3 +3234,17 @@ WHERE mi.archived_at IS NULL
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
ORDER BY mi.file_size DESC, mi.title ASC
LIMIT 5;
-- name: UpdateMediaItemAudioBookPayload :one
-- Focused refresh for multi-file audiobooks (folder-of-tracks / zip
-- books): rescans recompute the track manifest and the aggregate facts
-- that follow from it. Editorial fields stay untouched — admins may have
-- edited title/narrators after ingest.
UPDATE media_items
SET file_size = $2,
mime_type = $3,
audio_metadata = $4,
chapter_count = $5,
updated_at = NOW()
WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence;
+94 -5
View File
@@ -80,6 +80,48 @@ func (h *Handler) LinkBook(c *echo.Context) error {
})
}
// Ownership: the device must belong to the caller.
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "user not authenticated",
})
}
deviceOwned, err := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
UserID: user.ID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to verify device",
})
}
if !deviceOwned {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Device not found",
})
}
// Family-lens: the link target must sit in a library the caller can
// see — a hidden library's book can never be linked.
visibleSet, visErr := h.visibleLibrarySetFor(c)
if visErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to resolve visible libraries",
})
}
item, itemErr := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: req.MediaItemID, Valid: true})
if itemErr != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "Media item not found",
})
}
if !item.LibraryID.Valid || !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Book is not available to you",
})
}
alias, err := matchingService.LinkBook(c.Request().Context(), deviceID, &req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
@@ -112,6 +154,27 @@ func (h *Handler) GetUnlinkedBooks(c *echo.Context) error {
})
}
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "user not authenticated",
})
}
deviceOwned, devErr := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
UserID: user.ID,
})
if devErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to verify device",
})
}
if !deviceOwned {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Device not found",
})
}
unlinked, err := matchingService.GetUnlinkedBooks(c.Request().Context(), deviceID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
@@ -509,10 +572,35 @@ func (h *Handler) DeleteDeviceFileAlias(c *echo.Context) error {
})
}
// visibleLibrarySetFor resolves the requesting user's visible-library set
// (family-lens) from the request context identity.
func (h *Handler) visibleLibrarySetFor(c *echo.Context) (map[string]bool, error) {
user, ok := c.Get("user").(database.Users)
if !ok {
return nil, echo.NewHTTPError(http.StatusUnauthorized, "user not authenticated")
}
set, _, err := services.NewLibraryService(h.db).VisibleLibrarySet(
c.Request().Context(), user.ID)
return set, err
}
// GetBookMatches handles GET /api/books/match
func (h *Handler) GetBookMatches(c *echo.Context) error {
matchingService := h.getMatchingService()
// Family-lens: restrict matches to libraries the requester can see
// (absent-from-results — a hidden library's book simply never matches).
visibleSet, visErr := h.visibleLibrarySetFor(c)
if visErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to resolve visible libraries",
})
}
visibleIDs := make([]string, 0, len(visibleSet))
for id := range visibleSet {
visibleIDs = append(visibleIDs, id)
}
// Get query parameters
identifiers := c.QueryParams()["identifier"]
sha256 := c.QueryParam("sha256")
@@ -532,11 +620,12 @@ func (h *Handler) GetBookMatches(c *echo.Context) error {
}
req := &services.BookQueryRequest{
Identifiers: identifiers,
SHA256: sha256,
Title: title,
Author: author,
FileSize: fileSize,
Identifiers: identifiers,
SHA256: sha256,
Title: title,
Author: author,
FileSize: fileSize,
VisibleLibraryIDs: visibleIDs,
}
response, err := matchingService.QueryBooks(c.Request().Context(), req)
+43 -1
View File
@@ -451,6 +451,10 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
if !mediaItemID.Valid {
// Unidentified file: queue it for manual identification
// (unlinked-books page) instead of silently dropping the
// file's progress. One row per (device, content id).
h.queueUnlinkedBook(c, device.ID, book)
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
@@ -498,9 +502,47 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
})
}
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) {
// queueUnlinkedBook records a device file that could not be identified, so
// the user can resolve it on the unlinked-books page. One row per
// (device, content id); re-syncs refresh the row instead of duplicating it.
// Best-effort: queueing failures never fail the sync response.
func (h *KOReaderHandler) queueUnlinkedBook(c *echo.Context, deviceID pgtype.UUID, book KOReaderBookProgress) {
ctx := c.Request().Context()
contentID := book.UUID
if contentID == "" {
contentID = book.SHA256
}
if contentID == "" {
// Nothing stable to identify the file by — it could never resolve.
return
}
var title pgtype.Text
if book.Title != "" {
title = pgtype.Text{String: book.Title, Valid: true}
}
var author pgtype.Text
if len(book.Authors) > 0 && book.Authors[0] != "" {
author = pgtype.Text{String: book.Authors[0], Valid: true}
}
var filePath pgtype.Text
if book.FilePath != "" {
filePath = pgtype.Text{String: book.FilePath, Valid: true}
}
_, _ = h.db.UpsertUnlinkedBook(ctx, database.UpsertUnlinkedBookParams{
DeviceID: deviceID,
ContentID: contentID,
FilePath: filePath,
Title: title,
Author: author,
ConfidenceScore: pgtype.Float8{Float64: 0, Valid: true},
})
}
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) { ctx := c.Request().Context()
// Priority 1: UUID provided (highest confidence - 1.0)
if book.UUID != "" {
mediaUUID, err := uuid.Parse(book.UUID)
+231 -4
View File
@@ -1,6 +1,7 @@
package handlers
import (
"archive/zip"
"bookhoard/internal/database"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
@@ -2550,9 +2551,16 @@ func (mh *MediaHandler) ServeFile(c *echo.Context) error {
}
// Check if file exists
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
fullInfo, err := os.Stat(fullPath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
}
// Folder-of-tracks audiobooks store their book root DIRECTORY in
// file_path; there is no single file to serve and ServeFile must not
// fall through to directory listing behavior.
if fullInfo.IsDir() {
return c.JSON(http.StatusNotFound, map[string]string{"error": "multi-file books have no single download"})
}
ext := strings.ToLower(filepath.Ext(fullPath))
contentType := services.MimeTypes[ext]
@@ -2648,9 +2656,12 @@ func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file
// GetAudioChapters handles GET /api/media-items/:id/chapters — the
// format-abstraction chapter list for audiobooks (media_items
// .audio_metadata). Embedded m4b chapters and folder-as-book files write
// the same shape. Empty list when the item isn't audio or hasn't been
// probed.
// .audio_metadata). Embedded m4b chapters, folder-of-tracks books and zip
// books write the same shape. Multi-file books additionally carry
// `layout` and a `tracks` manifest whose URLs point at the per-track
// streaming endpoint; single-file books keep the empty layout their
// existing consumers expect. Empty list when the item isn't audio or
// hasn't been probed.
func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
@@ -2662,6 +2673,20 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}
if !item.LibraryID.Valid {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book has no library"})
}
// Library visibility gate (family-lens rule): chapter lists are media
// metadata — a hidden library must not leak them any more than its
// file bytes.
visibleSet, _, err := mh.visibleLibrarySet(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
}
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
var payload services.AudioMetadataPayload
if len(item.AudioMetadata) > 0 {
@@ -2670,10 +2695,212 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
}
}
tracks := make([]map[string]interface{}, 0, len(payload.Tracks))
for _, tr := range payload.Tracks {
tracks = append(tracks, map[string]interface{}{
"index": tr.Index,
"url": fmt.Sprintf("/api/media-items/%s/audio/%d", mediaID, tr.Index),
"title": tr.Title,
"path": tr.Path,
"start": tr.Start,
"end": tr.End,
"duration_seconds": tr.DurationSeconds,
"size_bytes": tr.SizeBytes,
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": item.ID,
"chapters": payload.Chapters,
"duration_seconds": payload.DurationSeconds,
"codec_profile": payload.CodecProfile,
"layout": payload.Layout,
"tracks": tracks,
})
}
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
// per-track byte stream for multi-file audiobooks (folder books serve the
// real file; zip books extract the entry in place). Library visibility is
// the permission gate, same as ServeFile.
func (mh *MediaHandler) GetAudioTrack(c *echo.Context) error {
mediaUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
trackIndex, err := strconv.Atoi(c.Param("trackIndex"))
if err != nil || trackIndex < 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid track index"})
}
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}
if !item.LibraryID.Valid {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book has no library"})
}
// Library visibility gate (family-lens rule): a hidden library means
// its audiobooks' bytes are not reachable either.
visibleSet, _, err := mh.visibleLibrarySet(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
}
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
var payload services.AudioMetadataPayload
if len(item.AudioMetadata) > 0 {
if err := json.Unmarshal(item.AudioMetadata, &payload); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to parse audio metadata"})
}
}
if trackIndex >= len(payload.Tracks) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "track not found"})
}
track := payload.Tracks[trackIndex]
switch payload.Layout {
case "folder":
fullPath, err := mh.getFullFilePath(c.Request().Context(), item.LibraryID, track.Path)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "track file not found"})
}
f, err := os.Open(fullPath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "track file not found"})
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil || info.IsDir() {
return c.JSON(http.StatusNotFound, map[string]string{"error": "track file not found"})
}
c.Response().Header().Set("Content-Type", audioTrackContentType(track.Path))
c.Response().Header().Set("Cache-Control", "private, max-age=86400")
// http.ServeContent wants a ReadSeeker for Range support; the os
// file provides both.
http.ServeContent(c.Response(), c.Request(), filepath.Base(fullPath), info.ModTime(), f)
return nil
case "zip":
zipPath, err := mh.getFullFilePath(c.Request().Context(), item.LibraryID, item.FilePath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "archive not found"})
}
seeker, err := newZipEntrySeeker(zipPath, track.Path)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "track not found in archive"})
}
defer func() { _ = seeker.Close() }()
c.Response().Header().Set("Content-Type", audioTrackContentType(track.Path))
c.Response().Header().Set("Cache-Control", "private, max-age=86400")
// Zip entries re-open+skip for backward seeks (deflate has no
// random access), which audio bitrates make cheap enough.
http.ServeContent(c.Response(), c.Request(), filepath.Base(track.Path), time.Time{}, seeker)
return nil
default:
// Single-file books stream through the /uploads path; the track
// endpoint has nothing to slice.
return c.JSON(http.StatusNotFound, map[string]string{"error": "book has no track manifest"})
}
}
// audioTrackContentType maps a track path/entry name to its mime type.
func audioTrackContentType(name string) string {
if m := services.MimeTypes[strings.ToLower(filepath.Ext(name))]; m != "" {
return m
}
return "audio/mpeg"
}
// zipEntrySeeker is an io.ReadSeeker over one zip entry. Forward seeks
// discard bytes in the open stream; backward seeks re-open the entry and
// skip — the only option over a deflate stream, and cheap at audio
// bitrates. Owns the archive reader for the life of the request.
type zipEntrySeeker struct {
zrc *zip.ReadCloser
rc io.ReadCloser
pos int64
size int64
entryFn func() (io.ReadCloser, error)
}
func newZipEntrySeeker(zipPath, entryName string) (*zipEntrySeeker, error) {
zrc, err := zip.OpenReader(zipPath)
if err != nil {
return nil, err
}
var entry *zip.File
for _, f := range zrc.File {
if filepath.ToSlash(f.Name) == filepath.ToSlash(entryName) {
entry = f
break
}
}
if entry == nil {
_ = zrc.Close()
return nil, fmt.Errorf("entry %q not found", entryName)
}
open := func() (io.ReadCloser, error) { return entry.Open() }
rc, err := open()
if err != nil {
_ = zrc.Close()
return nil, err
}
return &zipEntrySeeker{
zrc: zrc,
rc: rc,
size: int64(entry.UncompressedSize64),
entryFn: open,
}, nil
}
func (z *zipEntrySeeker) Read(p []byte) (int, error) {
n, err := z.rc.Read(p)
z.pos += int64(n)
return n, err
}
func (z *zipEntrySeeker) Seek(offset int64, whence int) (int64, error) {
var target int64
switch whence {
case io.SeekStart:
target = offset
case io.SeekCurrent:
target = z.pos + offset
case io.SeekEnd:
target = z.size + offset
default:
return z.pos, fmt.Errorf("invalid whence %d", whence)
}
if target < 0 {
return z.pos, fmt.Errorf("negative seek target %d", target)
}
if target < z.pos {
// Deflate streams can't rewind: restart the entry and skip.
if err := z.rc.Close(); err != nil {
return z.pos, err
}
rc, err := z.entryFn()
if err != nil {
return z.pos, err
}
z.rc = rc
z.pos = 0
}
if remaining := target - z.pos; remaining > 0 {
if _, err := io.CopyN(io.Discard, z.rc, remaining); err != nil {
return z.pos, err
}
z.pos = target
}
return z.pos, nil
}
func (z *zipEntrySeeker) Close() error {
_ = z.rc.Close()
return z.zrc.Close()
}
+9 -6
View File
@@ -916,9 +916,16 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
}
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
fileInfo, err := os.Stat(filePath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
}
// Folder-of-tracks audiobooks point at a book root DIRECTORY: there
// is no single file to stream for OPDS clients (a directory fd reads
// EISDIR). Zip books are real files and keep working.
if fileInfo.IsDir() {
return c.JSON(http.StatusNotFound, map[string]string{"error": "multi-file books have no single download"})
}
// Open file
file, err := os.Open(filePath)
@@ -927,11 +934,7 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
}
defer file.Close()
// Get file info
fileInfo, err := file.Stat()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get file info"})
}
// Get file info (fileInfo already statted above)
// Set headers
filename := filepath.Base(filePath)
+23
View File
@@ -3,6 +3,7 @@ package handlers
import (
"bookhoard/internal/services"
"context"
"errors"
"fmt"
"net/http"
@@ -209,6 +210,28 @@ func (h *Handler) GetScanSummary(c *echo.Context) error {
})
}
// CancelScan handles POST /api/scanner/cancel/:jobId (admin): requests
// cancellation of a pending or running scan job. Cancellation is
// cooperative — the file currently being processed finishes, the walk
// stops at its next context check, and the archive pass is skipped so
// unvisited files are never treated as missing.
func (h *Handler) CancelScan(c *echo.Context) error {
jobID := c.Param("jobId")
if err := h.worker.CancelJob(jobID); err != nil {
if errors.Is(err, services.ErrJobNotFound) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "job not found"})
}
return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "scan cancellation requested",
"job_id": jobID,
"status": "cancelled",
})
}
// StartWatchModeForLibrary starts watching a specific library's folders
func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype.UUID, adminID pgtype.UUID) error {
h.mu.Lock()
+166 -91
View File
@@ -475,8 +475,18 @@ func registerFrontendRoutes(cfg *Config) {
}
}
userUUID, userUUIDErr := uuid.Parse(user.ID)
if userUUIDErr != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
unlinkedCount, countErr := cfg.Queries.CountUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if countErr != nil {
log.Printf("CountUnresolvedUnlinkedBooksForUser failed: %v", countErr)
unlinkedCount = 0
}
var buf bytes.Buffer
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg).Render(c.Request().Context(), &buf)
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg, int(unlinkedCount)).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -660,18 +670,19 @@ func registerFrontendRoutes(cfg *Config) {
})
// Collection detail page (works for both system and user collections)
// Collection detail page. Book loading lives in loadCollectionBooks —
// shared verbatim with the books-grid partial below so the SSR page and
// the partial can never drift (rendering-ownership rule).
frontendProtected.GET("/collections/:id", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
// Parse collection ID from URL
collectionID := c.Param("id")
collUUID, err := uuid.Parse(collectionID)
if err != nil {
return renderErrorPage(c, "Invalid collection ID", "invalid_id")
}
// Fetch collection details
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
if err.Error() == "no rows in result set" {
@@ -679,106 +690,146 @@ func registerFrontendRoutes(cfg *Config) {
}
return renderErrorPage(c, "Error loading collection", "collection_load_error")
}
// Fetch books in collection
userUUID, _ := uuid.Parse(user.ID)
var books []handlers.BookInfo
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
var libScope pgtype.UUID
if !libRes.IsAll {
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
}
}
colService := cfg.CollectionHandler.CollectionService()
isSmart := colService.IsSmartCollection(collection.AutoAssignRules)
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if visErr != nil {
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
visibleIDs = nil
}
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
if isSmart {
// Smart (dynamic) collection: membership is evaluated live —
// visible libraries, live items only, any enabled rule matches.
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
LibraryID: libScope,
VisibleIDs: visibleIDs,
})
if evalErr != nil {
return renderErrorPage(c, "Error loading books", "books_load_error")
}
books = handlers.BookCardsFromMediaItems(matches, libTypes)
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
dashboardSvc := services.NewDashboardService(cfg.Queries)
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
if secErr != nil {
return renderErrorPage(c, "Error loading books", "books_load_error")
}
for _, section := range sections {
if section.CollectionID.String() == collectionID {
books = handlers.BookCardsFromMediaItems(section.Items, libTypes)
break
}
}
} else {
if libraryID != "" && !libRes.IsAll {
libUUID, parseErr := uuid.Parse(libraryID)
if parseErr != nil {
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
}
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Limit: pgtype.Int4{Int32: 1000, Valid: true},
})
if collErr != nil {
books = handlers.BookCardsFromCollectionDashRows(nil, libTypes)
} else {
// The query filters excluded rows in SQL.
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
}
} else {
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if collErr != nil {
collItems = nil
}
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
}
}
// Build collection data
colData := templates.CollectionData{
ID: collectionID,
Name: collection.Name,
Description: collection.Description.String,
Color: collection.Color.String,
Icon: collection.Icon.String,
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: isSmart,
BookCount: len(books),
books, libRes, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID)
if errCode != "" {
msg := map[string]string{
"books_load_error": "Error loading books",
"invalid_library_id": "Invalid library ID",
"invalid_id": "Invalid collection ID",
}[errCode]
return renderErrorPage(c, msg, errCode)
}
colData := collectionDataFromRow(collection, collectionID, isSmart, len(books))
var buf bytes.Buffer
err = templates.CollectionDetail(user, colData, books, libraryID, libRes.Libraries).Render(c.Request().Context(), &buf)
err = templates.CollectionDetail(user, colData, books, libRes.LibraryID, libRes.Libraries).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Books-grid partial: the collection detail page's #books-container
// content. The library switcher swaps this in (htmx.ajax) instead of
// re-rendering the grid client-side from JSON. Family-lens scoped via
// loadCollectionBooksLite — the requested library must be in the
// caller's visible set, without the page-only library resolution.
frontendProtected.GET("/collections/:id/books-grid", func(c *echo.Context) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
userUUID, uuidErr := uuid.Parse(user.ID.String())
if uuidErr != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
collectionID := c.Param("id")
collUUID, parseErr := uuid.Parse(collectionID)
if parseErr != nil {
return c.HTML(http.StatusBadRequest, "<div>Invalid collection ID</div>")
}
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusNotFound, "<div>Collection not found</div>")
}
books, isSmart, errCode := cfg.loadCollectionBooksLite(c, userUUID, collection, collectionID)
if errCode == "library_forbidden" {
return c.HTML(http.StatusForbidden, "<div>Library not available</div>")
}
if errCode != "" {
return c.HTML(http.StatusBadRequest, "<div>Error loading books</div>")
}
colData := collectionDataFromRow(collection, collectionID, isSmart, len(books))
var buf bytes.Buffer
if renderErr := templates.CollectionBooksGrid(colData, books).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// Week-widget fragment: the collapsible "This Week" strip re-fetches
// itself here (htmx) whenever the library switcher dispatches
// week-refresh, carrying the current #library-select value. Same
// resolution rules as the dashboard page (family-lens safe).
// Unlinked-books page: the identification queue for device files the
// sync could not match (personal data — the caller's own devices only).
frontendProtected.GET("/unlinked-books", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
userUUID, userUUIDErr := uuid.Parse(user.ID)
if userUUIDErr != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
rows, listErr := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if listErr != nil {
log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr)
rows = []database.ListUnresolvedUnlinkedBooksForUserRow{}
}
books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows {
books = append(books, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, user.Timezone),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// Unlinked-book match results partial: the match cascade for one device
// file, rendered server-side and scoped to the requester's visible
// libraries (absent-from-results) — rendering-ownership: templ renders,
// JS only swaps. NOTE: nothing routes to the unlinked-books page yet;
// this endpoint is ready for whichever UI wires it up.
frontendProtected.GET("/unlinked-books/matches", func(c *echo.Context) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
userUUID, uuidErr := uuid.Parse(user.ID.String())
if uuidErr != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
set, _, setErr := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if setErr != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to resolve visible libraries</div>")
}
visibleIDs := make([]string, 0, len(set))
for id := range set {
visibleIDs = append(visibleIDs, id)
}
req := &services.BookQueryRequest{
SHA256: c.QueryParam("sha256"),
Title: c.QueryParam("title"),
VisibleLibraryIDs: visibleIDs,
}
resp, matchErr := services.NewBookMatchingService(cfg.Queries).QueryBooks(c.Request().Context(), req)
if matchErr != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to search</div>")
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedMatchCards(c.QueryParam("progress_id"), resp.Matches).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
frontendProtected.GET("/dashboard/week-widget", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
@@ -945,6 +996,29 @@ func registerFrontendRoutes(cfg *Config) {
}
return c.HTML(http.StatusOK, buf.String())
})
// Conflicts list partial: fresh list plus the stats bar as an
// out-of-band swap. The client refreshes through this instead of
// re-rendering conflict cards from JSON (rendering-ownership rule:
// templ renders, JS only swaps).
frontendProtected.GET("/conflicts/list", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
conflictsData, total, unresolved, dataErr := cfg.ConflictHandler.GetConflictsData(c)
if dataErr != nil {
log.Printf("GetConflictsData failed: %v", dataErr)
conflictsData = []handlers.ConflictDetailResponse{}
total = 0
unresolved = 0
}
var buf bytes.Buffer
if renderErr := templates.ConflictsRefreshFragment(user, conflictsData, total, unresolved).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// Analytics page → /stats (the rebuilt reading-statistics page;
// nobody bookmarks orphan pages twice).
@@ -1118,7 +1192,8 @@ func registerFrontendRoutes(cfg *Config) {
var buf bytes.Buffer
archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
err = templates.AdminLibrary(user, libData, userData, archivedCount).Render(c.Request().Context(), &buf)
libraryTypes := libraryTypeOptionsFor(c.Request().Context(), cfg)
err = templates.AdminLibrary(user, libData, userData, archivedCount, libraryTypes).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
+169
View File
@@ -10,6 +10,8 @@ import (
"time"
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"bookhoard/templates"
@@ -257,3 +259,170 @@ func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgty
}
return cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), uuidToPGType(userU))
}
// collectionDataFromRow builds the template collection payload shared by
// the collection detail page and its books-grid partial.
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
return templates.CollectionData{
ID: collectionID,
Name: collection.Name,
Description: collection.Description.String,
Color: collection.Color.String,
Icon: collection.Icon.String,
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: isSmart,
BookCount: bookCount,
}
}
// collectionBooksForScope runs the per-scope book loading shared by the
// detail page and the books-grid partial: smart evaluation, a dashboard
// query section, or manual membership. Callers resolve the scope:
// libUUID/libScope/isAll/scoped per their own resolution rules.
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
colService := cfg.CollectionHandler.CollectionService()
isSmart = colService.IsSmartCollection(collection.AutoAssignRules)
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
if isSmart {
// Smart (dynamic) collection: membership is evaluated live —
// visible libraries, live items only, any enabled rule matches.
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
LibraryID: libScope,
VisibleIDs: visibleIDs,
})
if evalErr != nil {
return nil, isSmart, "books_load_error"
}
books = handlers.BookCardsFromMediaItems(matches, libTypes)
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
dashboardSvc := services.NewDashboardService(cfg.Queries)
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if secErr != nil {
return nil, isSmart, "books_load_error"
}
for _, section := range sections {
if section.CollectionID.String() == collectionID {
books = handlers.BookCardsFromMediaItems(section.Items, libTypes)
break
}
}
} else {
if scoped && libUUID.Valid {
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: collUUID,
LibraryID: libUUID,
Limit: pgtype.Int4{Int32: 1000, Valid: true},
})
if collErr != nil {
books = handlers.BookCardsFromCollectionDashRows(nil, libTypes)
} else {
// The query filters excluded rows in SQL.
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
}
} else {
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), collUUID)
if collErr != nil {
collItems = nil
}
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
}
}
return books, isSmart, ""
}
// loadCollectionBooks resolves the books shown for a collection detail
// page: full library resolution (visible-library payload, folder
// reachability, counts) because the page render needs it. The books-grid
// partial uses the lighter loadCollectionBooksLite instead.
// errCode is "" on success, otherwise a renderErrorPage code.
func (cfg *Config) loadCollectionBooks(c *echo.Context, user templates.User, collection database.Collections, collectionID string) (books []handlers.BookInfo, libRes LibraryResolution, isSmart bool, errCode string) {
collUUID, parseErr := uuid.Parse(collectionID)
if parseErr != nil {
return nil, libRes, isSmart, "invalid_id"
}
userUUID, _ := uuid.Parse(user.ID)
libRes = resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
var libScope pgtype.UUID
if !libRes.IsAll {
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
}
}
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if visErr != nil {
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
visibleIDs = nil
}
scoped := libraryID != "" && !libRes.IsAll
books, isSmart, errCode = cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID, libRes.LibUUID, libScope, scoped, visibleIDs)
return books, libRes, isSmart, errCode
}
// loadCollectionBooksLite is the books-grid partial's loader. The switcher
// refresh only needs "is the requested library visible, and its books" —
// so it validates the requested library against the caller's visible set
// directly (family-lens: out-of-set is rejected) and skips the page-only
// library resolution (folder reachability, counts, dropdown payload).
func (cfg *Config) loadCollectionBooksLite(c *echo.Context, userUUID uuid.UUID, collection database.Collections, collectionID string) (books []handlers.BookInfo, isSmart bool, errCode string) {
collUUID, parseErr := uuid.Parse(collectionID)
if parseErr != nil {
return nil, isSmart, "invalid_id"
}
libraryID := c.QueryParam("library_id")
var libUUID pgtype.UUID
if libraryID != "" {
parsed, parseErr := uuid.Parse(libraryID)
if parseErr != nil {
return nil, isSmart, "invalid_library_id"
}
set, _, setErr := cfg.LibraryService.VisibleLibrarySet(
c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if setErr != nil {
return nil, isSmart, "books_load_error"
}
if !set[libraryID] {
return nil, isSmart, "library_forbidden"
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if visErr != nil {
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
visibleIDs = nil
}
return cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID,
libUUID, libUUID, libraryID != "", visibleIDs)
}
// libraryTypeOptionsFor loads the library_types rows for the create-library
// dropdowns (admin page + setup wizard), ordered for display. On DB failure
// it falls back to the built-in type names so the form stays usable —
// CreateLibrary resolves the name against the DB, so this is never
// authoritative.
func libraryTypeOptionsFor(ctx context.Context, cfg *Config) []database.LibraryTypes {
types, err := cfg.LibraryService.GetLibraryTypes(ctx)
if err != nil {
log.Printf("libraryTypeOptionsFor: GetLibraryTypes failed, using built-in type names: %v", err)
for _, name := range []string{
services.LibraryTypeEbooks,
services.LibraryTypeComics,
services.LibraryTypeManga,
services.LibraryTypeAudiobooks,
} {
types = append(types, database.LibraryTypes{Name: name})
}
}
return templates.OrderedLibraryTypes(types)
}
+4 -3
View File
@@ -18,10 +18,11 @@ func registerMediaRoutes(cfg *Config) {
// Book download endpoint - same ServeFile flow as /uploads/library-:id/*
// (JWT + library-visibility gated), addressed by media item ID.
protected.GET("/media-items/:id/download", cfg.MediaHandler.ServeFile)
// The format-abstraction chapter list (audio_metadata). Streaming
// itself rides the existing /uploads Range-serving path with cookie
// auth — no separate endpoint.
// The format-abstraction chapter list (audio_metadata). Single-file
// books stream through the existing /uploads Range-serving path;
// multi-file books additionally expose per-track streaming.
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
// Media rating routes (all authenticated users)
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)
+5
View File
@@ -217,6 +217,11 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
mime.AddExtensionType(".m4a", "audio/mp4")
mime.AddExtensionType(".mp3", "audio/mpeg")
mime.AddExtensionType(".flac", "audio/flac")
mime.AddExtensionType(".opus", "audio/ogg")
mime.AddExtensionType(".ogg", "audio/ogg")
mime.AddExtensionType(".oga", "audio/ogg")
mime.AddExtensionType(".aac", "audio/aac")
mime.AddExtensionType(".wav", "audio/wav")
e := cfg.Echo
// Set up validator
+1
View File
@@ -17,6 +17,7 @@ func registerScannerRoutes(protected, admin *echo.Group, h *handlers.Handler) {
admin.POST("/scanner/scan", h.ScanLibrary)
admin.POST("/scanner/start", h.StartScanner)
admin.POST("/scanner/stop", h.StopScanner)
admin.POST("/scanner/cancel/:jobId", h.CancelScan)
admin.GET("/scanner/status/:jobId", h.GetScanStatus)
admin.GET("/scanner/active", h.GetActiveScans)
+2 -1
View File
@@ -81,7 +81,8 @@ func registerSetupRoutes(cfg *Config) {
return c.Redirect(http.StatusFound, "/")
}
var buf bytes.Buffer
if err := templates.Setup().Render(c.Request().Context(), &buf); err != nil {
libraryTypes := libraryTypeOptionsFor(c.Request().Context(), cfg)
if err := templates.Setup(libraryTypes).Render(c.Request().Context(), &buf); err != nil {
log.Printf("Failed to render setup template: %v", err)
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
}
+41 -1
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"math"
"bookhoard/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
@@ -16,6 +18,10 @@ type BookMatch struct {
BookhoardUUID uuid.UUID `json:"bookhoard_uuid"`
Confidence float64 `json:"confidence"`
MatchMethod string `json:"match_method"`
// Display fields, filled during the family-lens pass in QueryBooks.
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
}
// BookQueryRequest represents a book query from a device
@@ -24,7 +30,11 @@ type BookQueryRequest struct {
SHA256 string `json:"sha256"`
Title string `json:"title"`
Author string `json:"author"`
FileSize int64 `json:"file_size"`
// VisibleLibraryIDs scopes results to the requesting user's visible
// libraries (family-lens, absent-from-results). Set server-side by the
// handler — never from client input. nil keeps legacy callers unfiltered.
VisibleLibraryIDs []string `json:"-"`
FileSize int64 `json:"file_size"`
}
// BookQueryResponse represents the response to a book query
@@ -106,6 +116,36 @@ func (s *BookMatchingService) QueryBooks(ctx context.Context, req *BookQueryRequ
}
}
// Family-lens: matches may only surface items from libraries the
// requesting user can see — a hidden library's book simply never
// matches (absent-from-results). Runs before determineAction so the
// suggested action reflects the filtered set. nil (unset) keeps
// legacy callers unfiltered.
// Enrich + scope in one pass over the cascade hits: display fields come
// from the resolved item, and when scoped the family-lens filter drops
// matches from libraries the requester cannot see
// (absent-from-results).
scoped := req.VisibleLibraryIDs != nil
allowed := make(map[string]bool, len(req.VisibleLibraryIDs))
for _, id := range req.VisibleLibraryIDs {
allowed[id] = true
}
filtered := matches[:0]
for _, m := range matches {
item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: m.MediaItemID, Valid: true})
if err != nil || !item.LibraryID.Valid {
continue // vanished between cascade and filter — drop
}
m.Title = item.Title
m.Author = utils.PgTextString(item.Author)
m.CoverImagePath = utils.PgTextString(item.CoverImagePath)
if scoped && !allowed[uuid.UUID(item.LibraryID.Bytes).String()] {
continue
}
filtered = append(filtered, m)
}
matches = filtered
// Determine action
action := s.determineAction(matches)
+14 -1
View File
@@ -38,7 +38,11 @@ var AllowedExtensions = map[string][]string{
// All four decided breadth formats registered from day one (the
// DOC/LIT doctrine: extensions are a deliberate list); only m4b
// scans richly in Batch A — the chapter abstraction carries the rest.
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac"},
// .zip joins as the zip-of-audio book container (multi-file ingest);
// comics libraries keep using .cbz so the two never collide. The
// browser-playable lossless/next-gen containers join for Batch B
// metadata parity (.wma deliberately absent: browsers can't play it).
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav"},
}
var MimeTypes = map[string]string{
@@ -74,6 +78,15 @@ var MimeTypes = map[string]string{
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
".flac": "audio/flac",
// Opus is registered as audio/ogg per RFC 7845 (Ogg encapsulation);
// every browser <audio> element accepts it that way.
".opus": "audio/ogg",
".ogg": "audio/ogg",
".oga": "audio/ogg",
".aac": "audio/aac",
".wav": "audio/wav",
// Zip-of-audio book container (audiobooks libraries only).
".zip": "application/zip",
}
// GetLibraryTypes retrieves all available library types
+166 -35
View File
@@ -140,6 +140,12 @@ type MediaScanner struct {
newItems int
errors int
job *Job
// audioBookRoots tracks the folder-book roots (absolute paths)
// already ingested during THIS scan walk, so per-track walk visits
// skip instead of reprocessing. Reset at the top of ScanFolders; the
// archive pass reuses it to keep book roots off the missing list.
audioBookRoots map[string]bool
}
// CalibreOPFMetadata represents intermediate parsed metadata from Calibre metadata.opf files
@@ -419,6 +425,14 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder string) {
}
func (s *MediaScanner) ScanFolders(ctx context.Context) error {
// Cancellation is checked at every stage boundary and inside the
// walk callback: the in-flight file finishes, then the scan aborts
// and reports context.Canceled (processJob classifies that as a
// cancelled job, not a failed one).
if ctx.Err() != nil {
return ctx.Err()
}
if len(s.folders) == 0 {
return fmt.Errorf("no folders set")
}
@@ -426,8 +440,12 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
s.totalFiles = 0
s.newItems = 0
s.errors = 0
s.audioBookRoots = make(map[string]bool)
for _, folder := range s.folders {
if ctx.Err() != nil {
return ctx.Err()
}
if err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() && s.isScannableFile(path) {
s.totalFiles++
@@ -445,6 +463,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
mediaFiles := 0
for _, folder := range s.folders {
if ctx.Err() != nil {
return ctx.Err()
}
fmt.Printf("Scanning folder: %s\n", folder)
if _, err := os.Stat(folder); os.IsNotExist(err) {
@@ -454,6 +476,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
}
err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
if ctx.Err() != nil {
return fs.SkipAll
}
if err != nil {
fmt.Printf("Error accessing path %s: %v\n", path, err)
s.errors++
@@ -480,6 +506,19 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
}
}
// Multi-file audiobooks first: zip-of-audio books and
// folder-of-tracks books ingest as whole units, and tracks
// inside an already-ingested root skip entirely. Everything
// else (lone audio files, all non-audio media) falls
// through to the single-file path below.
if handled, err := s.maybeProcessAudioBook(ctx, path); handled {
if err != nil {
fmt.Printf("Error processing audio book %s: %v\n", path, err)
s.errors++
}
return nil
}
wasNew, err := s.processMediaFile(ctx, path)
if err != nil {
fmt.Printf("Error processing media file %s: %v\n", path, err)
@@ -502,6 +541,14 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
fmt.Printf("Scan completed: %d total files scanned, %d media files found, %d new items, %d errors\n",
processedFiles, mediaFiles, s.newItems, s.errors)
// A cancelled scan must not run the archive lifecycle pass below:
// it never visited every folder, so unvisited files would be
// treated as missing and archived (and eventually purged). Abort
// instead — cancelling never destroys library data.
if ctx.Err() != nil {
return ctx.Err()
}
// Archive lifecycle pass. Items whose files vanished from disk are
// archived after two consecutive missing scans (reading history kept,
// item hidden), and purged for good once archived older than the
@@ -509,6 +556,11 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
// Every branch logs - the previous hard-delete cleanup failed silently
// and left orphaned rows undetected.
for _, folder := range s.folders {
if ctx.Err() != nil {
fmt.Printf("[ARCHIVE] Scan cancelled, stopping archive pass\n")
return ctx.Err()
}
lib, err := s.db.GetLibraryByFolder(ctx, folder)
if err != nil {
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping archive pass: %v\n", folder, err)
@@ -537,6 +589,14 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
continue // Skip to next folder to avoid false archivals
}
// Folder-book roots count as present: the item's file_path is the
// DIRECTORY, which the file walk above never emits.
for root := range s.audioBookRoots {
if strings.HasPrefix(root, folder) {
scannedPaths[s.getRelativePath(root)] = true
}
}
for _, item := range dbItems {
if item.FilePath == "" || scannedPaths[item.FilePath] {
continue // File present; unarchive is handled in processMediaFile
@@ -795,16 +855,6 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
metadata = &MediaMetadata{}
}
// Audiobooks: sidecar narrators/chapters + ffprobe facts into the
// audio_metadata JSONB (sidecar-first, ffprobe-fills; the payload's
// shape is the chapter abstraction every format writes).
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
}
// Extract hash information during metadata extraction
hashInfo, formatInfo, err := s.extractHashInfo(path)
if err != nil {
@@ -921,6 +971,21 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
}
}
// Audiobooks: sidecar narrators/chapters + ffprobe facts (incl. the
// full tag fallbacks) into the audio_metadata JSONB. Runs AFTER the
// folder-structure pass so the precedence chain holds: sidecars →
// folder structure → tags → filename. The payload's shape is the
// chapter abstraction every format writes.
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
if metadata.CoverPath == "" {
s.applyEmbeddedCoverFallback(path, metadata)
}
}
// Final fallback if still missing essential metadata
if metadata.Title == "" {
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
@@ -956,6 +1021,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
Contributors: metadata.Contributors,
ContributorsSearch: contributorsSearch,
Narrators: nilToEmpty(metadata.Narrators),
@@ -1086,6 +1152,16 @@ func (s *MediaScanner) applyEmbeddedCoverFallback(path string, metadata *MediaMe
if coverPath, err := s.extractEPUBCover(path); err == nil && coverPath != "" {
metadata.CoverPath = s.getRelativePath(coverPath)
}
default:
// Audio containers (mp3 APIC, m4b/flac embedded picture): one
// ffmpeg call, written as a "<file>.cover.jpg" sibling — the
// same convention the EPUB path uses. Purely optional: absent
// ffmpeg or no embedded art simply leaves the cover empty.
if isAudioFile(path) && !strings.EqualFold(filepath.Ext(path), ".zip") {
if cover := extractEmbeddedAudioCover(path, path); cover != "" {
metadata.CoverPath = s.getRelativePath(cover)
}
}
}
}
@@ -1680,10 +1756,10 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
}
return metadata, nil
default:
// For other formats, return basic metadata
return &MediaMetadata{
Title: strings.TrimSuffix(filepath.Base(path), ext),
}, nil
// Audio containers and other unknown types: NO filename title
// here — processMediaFile's final fallback applies the filename
// LAST, after the audio tag-fallback chain has had its turn.
return &MediaMetadata{}, nil
}
}
@@ -3161,20 +3237,29 @@ func countArchiveImages(filePath string) (int, error) {
// from the existing row so rescans never clobber user customizations; only
// reset-to-scanned-defaults (RescanMediaItem with reset) clears them.
func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.MediaItems, path string) error {
// Re-extract metadata for the update
metadata, err := s.extractMetadata(path)
if err != nil {
fmt.Printf("Warning: failed to extract metadata for force rescan %s: %v\n", path, err)
metadata = &MediaMetadata{}
}
return s.updateMediaItemWithMetadata(ctx, existing, path, nil)
}
// Rescans must RE-DERIVE audio facts, never preserve stale ones
// (gotcha 39's principle applied to audio_metadata).
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
// updateMediaItemWithMetadata is updateMediaItem with an optional
// pre-extracted metadata set. Multi-file audiobooks arrive through
// RescanMediaItem with book-level metadata (sidecars, folder structure,
// first-track tags) because the generic per-file extraction would read
// the book ROOT/CONTAINER as if it were the book file.
func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing database.MediaItems, path string, preExtracted *MediaMetadata) error {
var metadata *MediaMetadata
if preExtracted != nil {
metadata = preExtracted
} else {
// Re-extract metadata for the update
extracted, err := s.extractMetadata(path)
if err != nil {
fmt.Printf("Warning: failed to extract metadata for force rescan %s: %v\n", path, err)
metadata = &MediaMetadata{}
} else if extracted == nil {
metadata = &MediaMetadata{}
} else {
metadata = extracted
}
s.enrichAudioMetadata(path, metadata)
}
// Folder-structure fallback, mirroring the CREATE path
@@ -3201,6 +3286,35 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
}
}
// Audiobooks (single-file): re-derive the audio facts + tag fallbacks
// AFTER the folder-structure pass — the precedence chain (sidecars →
// folder structure → tags → filename) must hold on rescans too.
// Multi-file books arrive with preExtracted metadata already carrying
// their enriched payload.
// Rescans must RE-DERIVE audio facts, never preserve stale ones
// (gotcha 39's principle applied to audio_metadata).
if preExtracted == nil && isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
if metadata.CoverPath == "" {
s.applyEmbeddedCoverFallback(path, metadata)
}
}
// Final filename fallback, mirroring processMediaFile's create path:
// extractMetadata's default branch no longer pre-fills the filename
// as title (embedded tags must outrank it), so the update path needs
// the same last rung — a sidecar-less, tag-less book at a library
// root (no folder structure to match) keeps its filename-derived
// title instead of being blanked. Guarded to single-file updates:
// multi-file representatives are sentinels/containers whose names
// must never leak as titles.
if preExtracted == nil && metadata.Title == "" {
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
// Normalize metadata fields
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
metadata.Tags = utils.NormalizeTags(metadata.Tags)
@@ -3257,21 +3371,26 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
utils.ApplyMetadataOverrides(&params, existing)
params.MetadataOverrides = utils.MergeOverrides(existing.MetadataOverrides)
if _, err = s.db.UpdateMediaItem(ctx, params); err != nil {
if _, err := s.db.UpdateMediaItem(ctx, params); err != nil {
return err
}
// Format flags are file-derived facts, not user metadata: refresh them on
// every update so a force rescan (the admin's backfill tool) can
// reclassify rows created before a classifyFormatGroup arm existed.
// Mirrors the creation path above.
ext := strings.ToLower(filepath.Ext(path))
var epubIsFixedLayout bool
if ext == ".epub" {
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
epubIsFixedLayout = fixedErr == nil && isFixed
// Mirrors the creation path above. Multi-file audiobooks keep the
// "audiobook" group: their representative path is a directory or a zip
// container, which no classify arm could name.
formatGroup, isReflowable, hasFixedLayout := "audiobook", false, false
if preExtracted == nil || preExtracted.AudioMeta == nil || preExtracted.AudioMeta.Layout == "" {
ext := strings.ToLower(filepath.Ext(path))
var epubIsFixedLayout bool
if ext == ".epub" {
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
epubIsFixedLayout = fixedErr == nil && isFixed
}
formatGroup, isReflowable, hasFixedLayout = classifyFormatGroup(ext, epubIsFixedLayout)
}
formatGroup, isReflowable, hasFixedLayout := classifyFormatGroup(ext, epubIsFixedLayout)
mimeType := s.getMimeType(path)
if err := s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
ID: existing.ID,
@@ -3335,6 +3454,16 @@ func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.U
return fmt.Errorf("failed to stat media file: %w", err)
}
// Multi-file audiobooks (folder/zip layout) must NOT go through the
// generic per-file updater: it would read the book ROOT directory (or
// the zip container) as if it were the book file — deriving a filename
// title and classifying format_group "unknown", which drops the item
// out of the audio reader. The book-level path rebuilds the track
// manifest and re-derives metadata from sidecars/tags instead.
if item.FormatGroup == "audiobook" && audioLayoutOf(item.AudioMetadata) != "" {
return s.rescanMultiFileAudioBook(ctx, item, fullPath)
}
if err := s.updateMediaItem(ctx, item, fullPath); err != nil {
return fmt.Errorf("failed to update media item: %w", err)
}
@@ -3980,6 +4109,8 @@ func (s *MediaScanner) detectFormatType(filePath string) string {
return "pdf"
case ".cbz", ".cbr", ".cb7", ".cbt":
return "comic_archive"
case ".m4b", ".m4a", ".mp3", ".flac", ".opus", ".ogg", ".oga", ".aac", ".wav":
return "audiobook"
case ".mobi":
return "mobi"
case ".azw", ".azw3":
@@ -4010,7 +4141,7 @@ func classifyFormatGroup(ext string, epubIsFixedLayout bool) (formatGroup string
return "fixed_layout", false, true
case ".cbz", ".cbr", ".cb7", ".cbt":
return "comic_archive", false, true
case ".m4b", ".m4a", ".mp3", ".flac":
case ".m4b", ".m4a", ".mp3", ".flac", ".opus", ".ogg", ".oga", ".aac", ".wav":
// Audio is neither reflowable text nor fixed-layout pages; the
// "audiobook" group routes readers to the audio player instead
// of any document engine.
+222 -5
View File
@@ -9,10 +9,17 @@ package services
// until a rescan runs with ffprobe present).
//
// The chapters array written into media_items.audio_metadata IS the
// format abstraction: embedded m4b chapters and (later) folder-as-book
// files both produce {index, title, start, end} entries, so the locator
// {chapter, offset_seconds} and the /chapters endpoint never learn which
// file model produced them.
// format abstraction: embedded m4b chapters, folder-as-book files and
// zip-of-audio books all produce {index, title, start, end} entries, so
// the locator {chapter, offset_seconds} and the /chapters endpoint never
// learn which file model produced them.
//
// Multi-file books (layout "folder" | "zip", see
// media_scanner_audio_multifile.go) additionally carry a tracks array:
// one entry per playable file, with its global-seconds start/end and the
// path the /audio/:trackIndex streaming endpoint resolves. Single-file
// books leave layout and tracks empty — the <audio> element streams the
// whole file exactly as before.
import (
"encoding/json"
@@ -33,6 +40,21 @@ type AudioChapter struct {
End float64 `json:"end"`
}
// AudioTrack is one playable file of a multi-file audiobook. Path is the
// library-relative file path (layout "folder") or the in-zip entry name
// (layout "zip"); the /audio/:trackIndex endpoint resolves it to bytes.
// Start/End are cumulative global seconds across the book — the same
// coordinate system chapters, bookmarks and audio_offset_seconds use.
type AudioTrack struct {
Index int `json:"index"`
Path string `json:"path"`
Title string `json:"title,omitempty"`
Start float64 `json:"start"`
End float64 `json:"end"`
DurationSeconds float64 `json:"duration_seconds,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
}
// AudioMetadataPayload is the JSONB stored in media_items.audio_metadata.
// Only the audio paths read it — ebook paths never do (the kobo_metadata
// precedent).
@@ -44,16 +66,33 @@ type AudioMetadataPayload struct {
ASIN string `json:"asin,omitempty"`
Abridged *bool `json:"abridged,omitempty"`
Chapters []AudioChapter `json:"chapters,omitempty"`
// Layout: "" = single file (the Batch A model), "folder" = a
// directory of track files, "zip" = a zip archive of tracks.
Layout string `json:"layout,omitempty"`
// Tracks: one entry per playable file for multi-file layouts. Empty
// for single-file books (the whole file IS the track).
Tracks []AudioTrack `json:"tracks,omitempty"`
}
func isAudioFile(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".m4b", ".m4a", ".mp3", ".flac":
case ".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav":
return true
}
return false
}
// isAudioTrackFile reports whether a path is a PLAYABLE track of a
// multi-file book. Zips are book CONTAINERS (layout "zip"), never tracks
// of a folder book — a nested zip inside a book root must not join the
// track list, and pre-grouping one-track strays were never zips.
func isAudioTrackFile(path string) bool {
if strings.EqualFold(filepath.Ext(path), ".zip") {
return false
}
return isAudioFile(path)
}
// ffprobeAvailable is resolved once per scan process; a missing binary is
// a degradation, not an error.
var ffprobeAvailable = func() bool {
@@ -61,10 +100,55 @@ var ffprobeAvailable = func() bool {
return err == nil
}()
// ffmpegAvailable mirrors ffprobeAvailable for the embedded-cover
// extractor (ffprobe cannot write image streams; ffmpeg can).
var ffmpegAvailable = func() bool {
_, err := exec.LookPath("ffmpeg")
return err == nil
}()
// extractEmbeddedAudioCover pulls the embedded cover art (mp3 APIC /
// m4b covr / flac picture) out of an audio file with one ffmpeg call —
// the attached picture presents itself as the file's only video stream.
// Writes outputBase+".cover.jpg" following the EPUB sidecar convention
// ("<book>.cover.jpg"). Returns "" when ffmpeg is absent, the file
// carries no picture, or anything fails — purely optional enrichment.
func extractEmbeddedAudioCover(inputPath, outputBase string) string {
if !ffmpegAvailable {
return ""
}
outPath := outputBase + ".cover.jpg"
cmd := exec.Command("ffmpeg",
"-y", "-v", "error",
"-i", inputPath,
"-map", "0:v:0",
"-frames:v", "1",
outPath,
)
if out, err := cmd.CombinedOutput(); err != nil {
// "matches no streams" is the no-embedded-art case; stay quiet
// about it, log anything unexpected.
if len(out) > 0 && !strings.Contains(string(out), "matches no streams") {
fmt.Printf("ffmpeg cover extraction note for %s: %s\n", inputPath, strings.TrimSpace(string(out)))
}
return ""
}
if info, err := os.Stat(outPath); err != nil || info.Size() == 0 {
_ = os.Remove(outPath)
return ""
}
return outPath
}
type ffprobeReport struct {
Format struct {
Duration flexFloat `json:"duration"`
BitRate flexFloat `json:"bit_rate"`
// Tags: ffprobe normalizes well-known atoms to lowercase keys but
// passes freeform keys (Audible's `----:com.apple.iTunes:*`)
// through verbatim — a map with case-insensitive lookup is the
// only honest shape. See tag().
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
CodecName string `json:"codec_name"`
@@ -134,6 +218,24 @@ func runFFProbe(path string) *ffprobeReport {
return &report
}
// tag returns the first non-empty ffprobe tag among the candidate keys,
// matched case-insensitively and trimmed. Empty string when none match.
func (r *ffprobeReport) tag(keys ...string) string {
if r == nil {
return ""
}
for _, key := range keys {
for k, v := range r.Format.Tags {
if strings.EqualFold(k, key) {
if trimmed := strings.TrimSpace(v); trimmed != "" {
return trimmed
}
}
}
}
return ""
}
// enrichAudioMetadata merges ffprobe facts into the (possibly sidecar-
// seeded) audio payload, creating it when no sidecar existed. Sidecar
// narrators/chapters win (curated); ffprobe fills duration, codec,
@@ -192,6 +294,24 @@ func (s *MediaScanner) enrichAudioMetadata(path string, metadata *MediaMetadata)
if payload.Abridged != nil {
metadata.Abridged = *payload.Abridged
}
// Tag fallbacks run LAST in the single-file chain (sidecars → folder
// structure handled by the caller → tags → filename): they only fill
// fields the earlier sources left empty.
applyAudioTagFallbacks(metadata, report)
// CUE sheets are the last chapter source: sidecar chapters and
// embedded container chapters (m4b) outrank them.
if len(payload.Chapters) == 0 {
if cueChapters := readCueChapters(path); len(cueChapters) > 0 {
payload.Chapters = cueChapters
if payload.DurationSeconds == 0 {
if last := cueChapters[len(cueChapters)-1]; last.End > 0 {
payload.DurationSeconds = last.End
}
}
}
}
}
// parseSidecarAudioFields extracts the audio-only fields from a
@@ -261,6 +381,103 @@ func marshalAudioMetadata(payload *AudioMetadataPayload) []byte {
return out
}
// applyAudioTagFallbacks fills EMPTY media metadata from the file's own
// tags — the last rung of the ingestion precedence chain (curated
// sidecars → folder structure → embedded tags → filename). Field map:
//
// album / album_artist / artist → title / author
// composer → narrators (Audible's own
// convention: the narrator lives in the composer tag)
// grouping → series
// ----:com.apple.iTunes:SERIES[-PART] → series + index (Libation
// preserves these Audible freeform atoms)
// ----:com.apple.iTunes:SUBTITLE → joined into the title
// ("Title: Subtitle"), mirroring the ABS sidecar path
// ----:com.apple.iTunes:ASIN / ABRIDGED → asin / abridged flag
// date → publish date; genre → genre
//
// Abridged is the one field where an explicit sidecar false must not be
// overridden by a tag — the payload pointer marks sidecar presence.
func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
if metadata == nil || report == nil {
return
}
if metadata.Title == "" {
if album := report.tag("album"); album != "" {
metadata.Title = album
}
}
// Subtitle joins the title Calibre-style, mirroring the ABS sidecar
// path — there is no separate subtitle column. Skip when the title
// already contains it (tools sometimes pre-join).
if subtitle := report.tag("----:com.apple.itunes:subtitle", "subtitle"); subtitle != "" && metadata.Title != "" &&
!strings.Contains(strings.ToLower(metadata.Title), strings.ToLower(subtitle)) {
metadata.Title = metadata.Title + ": " + subtitle
}
if metadata.Author == "" {
if artist := report.tag("album_artist"); artist != "" {
metadata.Author = artist
} else if artist := report.tag("artist"); artist != "" {
metadata.Author = artist
}
}
if len(metadata.Narrators) == 0 {
// Kept as ONE narrator string: composer values have no reliable
// multi-narrator delimiter, and splitting "Vance, Simon" would
// manufacture two fake people. Admins can edit the column.
if composer := report.tag("composer"); composer != "" {
metadata.Narrators = []string{composer}
if metadata.AudioMeta != nil && len(metadata.AudioMeta.Narrators) == 0 {
metadata.AudioMeta.Narrators = []string{composer}
}
}
}
if metadata.Series == "" {
if series := report.tag("grouping", "----:com.apple.itunes:series"); series != "" {
metadata.Series = series
if part := report.tag("----:com.apple.itunes:series-part"); part != "" {
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 32); err == nil {
metadata.SeriesNumber = int32(idx)
}
}
}
}
if metadata.ASIN == "" {
if asin := report.tag("----:com.apple.itunes:asin", "asin"); asin != "" {
metadata.ASIN = asin
if metadata.AudioMeta != nil && metadata.AudioMeta.ASIN == "" {
metadata.AudioMeta.ASIN = asin
}
}
}
if metadata.AudioMeta == nil || metadata.AudioMeta.Abridged == nil {
if !metadata.Abridged {
switch strings.ToLower(report.tag("----:com.apple.itunes:abridged")) {
case "1", "true", "yes":
metadata.Abridged = true
if metadata.AudioMeta != nil {
abridged := true
metadata.AudioMeta.Abridged = &abridged
}
}
}
}
if metadata.PublishDate.IsZero() {
if date := report.tag("date"); date != "" {
for _, layout := range []string{"2006-01-02", "2006-01", "2006"} {
if t, err := time.Parse(layout, strings.TrimSpace(date)); err == nil {
metadata.PublishDate = t
break
}
}
}
}
if metadata.Genre == "" {
metadata.Genre = report.tag("genre")
}
}
// audioDurationLabel renders the duration for logs/detail surfaces.
func audioDurationLabel(seconds float64) string {
if seconds <= 0 {
@@ -0,0 +1,173 @@
package services
// CUE sheet support: a `.cue` sidecar describing the chapter marks of a
// single big audio file (the classic CD-rip shape: one flac/mp3 + one
// cue). CUE is the LOWEST-priority chapter source — curated sidecar
// chapters and embedded m4b chapters outrank it — and a purely optional
// one: no cue, no chapters, no error.
import (
"bufio"
"os"
"path/filepath"
"strconv"
"strings"
)
// cueFramesPerSecond is the CD framing CUE INDEX timestamps use
// (MM:SS:FF with 75 frames per second).
const cueFramesPerSecond = 75.0
// cueTimeToSeconds converts a CUE INDEX timestamp ("03:25:12") to
// seconds. Minutes may exceed 59 and hours are folded into the minutes
// field per the spec (MM:SS:FF, MM unbounded).
func cueTimeToSeconds(v string) float64 {
parts := strings.Split(strings.TrimSpace(v), ":")
if len(parts) != 3 {
return 0
}
minutes, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
seconds, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
frames, err3 := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
if err1 != nil || err2 != nil || err3 != nil {
return 0
}
return minutes*60 + seconds + frames/cueFramesPerSecond
}
// readCueChapters finds a CUE sheet describing the given audio file and
// returns its tracks as chapters (global seconds, ends derived from the
// next start; the last end stays 0 = unknown). A cue matches when its
// FILE directive names the audio file (basename, case-insensitive), or
// — for single-cue directories — when the directory holds exactly one.
func readCueChapters(path string) []AudioChapter {
dir := filepath.Dir(path)
base := strings.ToLower(filepath.Base(path))
cuePaths, err := filepath.Glob(filepath.Join(dir, "*.cue"))
if err != nil || len(cuePaths) == 0 {
return nil
}
var matched string
for _, cuePath := range cuePaths {
if referencedByCue(cuePath, base) {
matched = cuePath
break
}
}
if matched == "" && len(cuePaths) == 1 {
// Single-cue directory: the cue almost certainly describes the
// only audio file next to it.
matched = cuePaths[0]
}
if matched == "" {
return nil
}
return parseCueSheet(matched, base)
}
// referencedByCue reports whether the cue's FILE directive names the
// given audio basename.
func referencedByCue(cuePath, audioBase string) bool {
f, err := os.Open(cuePath)
if err != nil {
return false
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if len(line) >= 4 && strings.EqualFold(line[:4], "FILE") {
rest := strings.TrimSpace(line[4:])
rest = strings.Trim(rest, "\"")
// FILE "name" WAVE / FILE name MP3 — drop the type word.
if idx := strings.LastIndexAny(rest, " \t"); idx != -1 {
rest = strings.TrimSpace(rest[:idx])
}
rest = strings.Trim(rest, "\"")
return strings.EqualFold(filepath.Base(rest), audioBase)
}
}
return false
}
// parseCueSheet extracts TRACK/TITLE/INDEX 01 entries as chapters.
// INDEX timestamps are absolute from the file start per the spec; the
// pregap (INDEX 00) is ignored. Multi-file cues contribute only the
// TRACK blocks under the FILE directive naming our audio file (or all
// blocks when the cue declares no FILE at all).
func parseCueSheet(cuePath, audioBase string) []AudioChapter {
f, err := os.Open(cuePath)
if err != nil {
return nil
}
defer func() { _ = f.Close() }()
var chapters []AudioChapter
var current *AudioChapter
title := ""
inOurFile := true
flush := func() {
if current != nil && current.Start >= 0 {
current.Title = strings.TrimSpace(title)
if current.Title == "" {
current.Title = "Chapter " + strconv.Itoa(current.Index+1)
}
if n := len(chapters); n > 0 && current.Start >= chapters[n-1].Start {
chapters[n-1].End = current.Start
}
chapters = append(chapters, *current)
}
current = nil
title = ""
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
upper := strings.ToUpper(line)
switch {
case strings.HasPrefix(upper, "FILE"):
flush()
rest := strings.Trim(strings.TrimSpace(line[4:]), "\"")
if idx := strings.LastIndexAny(rest, " \t"); idx != -1 {
rest = strings.TrimSpace(rest[:idx])
}
rest = strings.Trim(rest, "\"")
inOurFile = strings.EqualFold(filepath.Base(rest), audioBase)
case strings.HasPrefix(upper, "TRACK"):
flush()
if !inOurFile {
continue
}
current = &AudioChapter{Index: len(chapters)}
if fields := strings.Fields(line); len(fields) >= 2 {
if n, err := strconv.Atoi(fields[1]); err == nil && n > 0 {
current.Index = n - 1
}
}
case strings.HasPrefix(upper, "TITLE") && current != nil:
if q1 := strings.Index(line, "\""); q1 != -1 {
if q2 := strings.LastIndex(line, "\""); q2 > q1 {
title = line[q1+1 : q2]
}
}
case strings.HasPrefix(upper, "INDEX"):
if current == nil || !inOurFile {
continue
}
if fields := strings.Fields(line); len(fields) >= 3 && fields[1] == "01" {
current.Start = cueTimeToSeconds(fields[2])
}
}
}
flush()
if len(chapters) == 0 {
return nil
}
return chapters
}
@@ -0,0 +1,277 @@
package services
// Unit tests for the exhaustive audiobook metadata ingestion: CUE
// sheets, m3u playlists, tag fallbacks (incl. Audible freeform atoms)
// and tag-based track ordering.
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestCueTimeToSeconds(t *testing.T) {
cases := map[string]float64{
"00:00:00": 0,
"01:00:00": 60,
"03:25:12": 3*60 + 25 + 12.0/75.0,
"75:00:37": 75*60 + 37.0/75.0, // minutes exceed 59 per spec
"not-a-time": 0,
"1:2": 0,
}
for in, want := range cases {
if got := cueTimeToSeconds(in); (got-want) > 0.001 || (want-got) > 0.001 {
t.Errorf("cueTimeToSeconds(%q) = %v, want %v", in, got, want)
}
}
}
func TestParseCueSheet(t *testing.T) {
dir := t.TempDir()
cuePath := filepath.Join(dir, "book.cue")
cue := `TITLE "Cue Book"
FILE "book.flac" WAVE
TRACK 01 AUDIO
TITLE "Opening"
INDEX 01 00:00:00
TRACK 02 AUDIO
TITLE "The Middle Bit"
INDEX 01 00:01:30
TRACK 03 AUDIO
TITLE "Closing"
INDEX 01 00:03:00
FILE "other.flac" WAVE
TRACK 01 AUDIO
TITLE "Foreign Track"
INDEX 01 00:00:00
`
if err := os.WriteFile(cuePath, []byte(cue), 0o644); err != nil {
t.Fatal(err)
}
chapters := parseCueSheet(cuePath, "book.flac")
if len(chapters) != 3 {
t.Fatalf("chapters = %d (%+v), want 3 (foreign FILE block excluded)", len(chapters), chapters)
}
if chapters[0].Title != "Opening" || chapters[0].Start != 0 {
t.Fatalf("first = %+v", chapters[0])
}
// MM:SS:FF: "00:01:30" = 1 second + 30/75 frames = 1.4s
if chapters[1].Start != 1.4 || chapters[1].End != 3.0 {
t.Fatalf("second = %+v", chapters[1])
}
if chapters[2].End != 0 {
t.Fatalf("last end must stay 0 (unknown): %+v", chapters[2])
}
}
func TestReadCueChaptersMatched(t *testing.T) {
dir := t.TempDir()
cue := "FILE \"audio.mp3\" WAVE\n TRACK 01 AUDIO\n TITLE \"One\"\n INDEX 01 00:00:00\n TRACK 02 AUDIO\n TITLE \"Two\"\n INDEX 01 00:00:10\n"
if err := os.WriteFile(filepath.Join(dir, "audio.cue"), []byte(cue), 0o644); err != nil {
t.Fatal(err)
}
chapters := readCueChapters(filepath.Join(dir, "audio.mp3"))
if len(chapters) != 2 {
t.Fatalf("chapters = %d", len(chapters))
}
// No cue at all → nil, never an error.
if chapters := readCueChapters(filepath.Join(dir, "other.mp3")); chapters != nil {
t.Fatalf("expected nil for missing cue, got %+v", chapters)
}
}
func TestFindAndApplyPlaylistHint(t *testing.T) {
dir := t.TempDir()
playlist := "#EXTM3U\n#EXTINF:120,Chapter Zero\n02_Second.mp3\n#EXTINF:60,Chapter One\n./01_First.mp3\nmissing.mp3\n"
if err := os.WriteFile(filepath.Join(dir, "book.m3u"), []byte(playlist), 0o644); err != nil {
t.Fatal(err)
}
// Exactly one playlist → hint; two → none.
hint := findPlaylistHint(dir)
if hint == nil {
t.Fatal("expected hint")
}
if err := os.WriteFile(filepath.Join(dir, "other.m3u8"), []byte("#EXTM3U\n"), 0o644); err != nil {
t.Fatal(err)
}
if findPlaylistHint(dir) != nil {
t.Fatal("multiple playlists must produce no hint")
}
abs := []string{
filepath.Join(dir, "01_First.mp3"),
filepath.Join(dir, "02_Second.mp3"),
filepath.Join(dir, "10_Later.mp3"), // not in playlist → appended last
}
ordered := hint.reorder(abs)
if len(ordered) != 3 || ordered[0] != abs[1] || ordered[1] != abs[0] || ordered[2] != abs[2] {
t.Fatalf("playlist order not applied: %v", ordered)
}
if got := hint.titleFor(abs[0]); got != "Chapter One" {
t.Fatalf("titleFor(./01_First.mp3) = %q", got)
}
if got := hint.titleFor(abs[1]); got != "Chapter Zero" {
t.Fatalf("titleFor(02_Second.mp3) = %q", got)
}
if got := hint.titleFor(abs[2]); got != "" {
t.Fatalf("unlisted track must have no title, got %q", got)
}
if h := (*playlistHint)(nil); h.titleFor(abs[0]) != "" {
t.Fatal("nil hint must be safe")
}
}
func syntheticReport(tags map[string]string) *ffprobeReport {
r := &ffprobeReport{}
r.Format.Tags = tags
return r
}
func TestApplyAudioTagFallbacks(t *testing.T) {
t.Run("full fill", func(t *testing.T) {
meta := &MediaMetadata{}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
"album": "The Tagged Album",
"album_artist": "Tag Author",
"artist": "Wrong Author",
"composer": "Tag Narrator",
"grouping": "Tag Series",
"genre": "Audiobook",
"date": "2020",
"----:com.apple.iTunes:SUBTITLE": "Or, A Life",
"----:com.apple.iTunes:SERIES": "Freeform Series",
"----:com.apple.iTunes:SERIES-PART": "4",
"----:com.apple.iTunes:ASIN": "B0ASIN1234",
"----:com.apple.iTunes:ABRIDGED": "1",
}))
if meta.Title != "The Tagged Album: Or, A Life" {
t.Fatalf("title = %q", meta.Title)
}
if meta.Author != "Tag Author" {
t.Fatalf("author = %q", meta.Author)
}
if len(meta.Narrators) != 1 || meta.Narrators[0] != "Tag Narrator" {
t.Fatalf("narrators = %v", meta.Narrators)
}
// grouping outranks freeform SERIES for the series name only
// because it is checked first; both are tag-level fallbacks.
if meta.Series == "" {
t.Fatal("series not filled from tags")
}
if meta.SeriesNumber != 4 {
t.Fatalf("series number = %d", meta.SeriesNumber)
}
if meta.ASIN != "B0ASIN1234" {
t.Fatalf("asin = %q", meta.ASIN)
}
if !meta.Abridged {
t.Fatal("abridged not filled")
}
if meta.PublishDate.Year() != 2020 {
t.Fatalf("publish date = %v", meta.PublishDate)
}
if meta.Genre != "Audiobook" {
t.Fatalf("genre = %q", meta.Genre)
}
})
t.Run("fills only empty fields", func(t *testing.T) {
meta := &MediaMetadata{Title: "Sidecar Title", Author: "Sidecar Author", Narrators: []string{"Sidecar Narrator"}}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
"album": "Tag Album", "artist": "Tag Artist", "composer": "Tag Narrator",
}))
if meta.Title != "Sidecar Title" || meta.Author != "Sidecar Author" {
t.Fatalf("tags must not override earlier sources: %+v", meta)
}
if len(meta.Narrators) != 1 || meta.Narrators[0] != "Sidecar Narrator" {
t.Fatalf("narrators = %v", meta.Narrators)
}
})
t.Run("explicit sidecar false survives abridged tag", func(t *testing.T) {
abridgedFalse := false
meta := &MediaMetadata{AudioMeta: &AudioMetadataPayload{Abridged: &abridgedFalse}}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
"----:com.apple.iTunes:ABRIDGED": "1",
}))
if meta.Abridged {
t.Fatal("explicit sidecar false must not be overridden")
}
})
t.Run("nil report is a no-op", func(t *testing.T) {
meta := &MediaMetadata{}
applyAudioTagFallbacks(meta, nil)
if meta.Title != "" {
t.Fatal("nil report must change nothing")
}
})
}
func TestParseTagOrdinal(t *testing.T) {
cases := map[string]int{
"2": 2,
"3/9": 3,
" 12 ": 12,
"": 0,
"x": 0,
"0": 0,
"2/": 2,
"-1": 0,
}
for in, want := range cases {
if got := parseTagOrdinal(in); got != want {
t.Errorf("parseTagOrdinal(%q) = %d, want %d", in, got, want)
}
}
}
func TestOrderTracksByTags(t *testing.T) {
mk := func(disc, track int, rel string) audioTrackInfo {
return audioTrackInfo{disc: disc, trackNum: track, relPath: rel}
}
t.Run("tag order wins when complete", func(t *testing.T) {
probed := []audioTrackInfo{
mk(2, 1, "b/01.mp3"), mk(1, 2, "a/02.mp3"), mk(1, 10, "a/10.mp3"),
}
orderTracksByTags(probed)
want := []string{"a/02.mp3", "a/10.mp3", "b/01.mp3"}
for i, tr := range probed {
if tr.relPath != want[i] {
t.Fatalf("order = %v", probed)
}
}
})
t.Run("incomplete coverage keeps natural order", func(t *testing.T) {
probed := []audioTrackInfo{mk(1, 1, "a/01.mp3"), mk(0, 0, "a/02.mp3")}
orderTracksByTags(probed)
if probed[0].relPath != "a/01.mp3" || probed[1].relPath != "a/02.mp3" {
t.Fatalf("order = %v", probed)
}
})
}
func TestTagLookupCaseInsensitive(t *testing.T) {
r := syntheticReport(map[string]string{
"ALBUM": "Cased Album",
"----:com.apple.iTunes:ASIN": "B0CASE",
})
if got := r.tag("album"); got != "Cased Album" {
t.Fatalf("tag(album) = %q", got)
}
if got := r.tag("album", "title"); got != "Cased Album" {
t.Fatalf("multi-key lookup = %q", got)
}
if got := r.tag("----:COM.APPLE.ITUNES:asin"); !strings.EqualFold(got, "B0CASE") {
t.Fatalf("freeform case-insensitive lookup = %q", got)
}
if got := r.tag("missing"); got != "" {
t.Fatalf("missing key = %q", got)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,203 @@
package services
// Unit tests for the multi-file audiobook grouping/manifest helpers
// (media_scanner_audio_multifile.go). Pure filesystem logic — no DB.
import (
"archive/zip"
"os"
"path/filepath"
"testing"
)
func TestNaturalLess(t *testing.T) {
cases := []struct {
a, b string
less bool
}{
{"01 Track.mp3", "02 Track.mp3", true},
{"02 Track.mp3", "10 Track.mp3", true}, // numeric, not lexicographic
{"10 Track.mp3", "9 Track.mp3", false},
{"Track 2", "Track 10", true},
{"abc", "abd", true},
{"01", "1", false}, // leading zeros compare equal → strict-less false
{"a1b", "a1c", true},
{"Disc 1", "Disc 2", true},
}
for _, tc := range cases {
if got := naturalLess(tc.a, tc.b); got != tc.less {
t.Errorf("naturalLess(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.less)
}
}
}
func TestSortTracksNatural(t *testing.T) {
in := []string{"/x/10.mp3", "/x/2.mp3", "/x/1.mp3"}
sortTracksNatural(in)
want := []string{"/x/1.mp3", "/x/2.mp3", "/x/10.mp3"}
for i := range want {
if in[i] != want[i] {
t.Fatalf("sorted = %v, want %v", in, want)
}
}
}
// writeDummyTrack creates a file with plausible audio bytes (never probed
// in these tests — only structure matters).
func writeDummyTrack(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("ID3 fake audio bytes"), 0o644); err != nil {
t.Fatal(err)
}
}
func TestBookRootForTrackFile(t *testing.T) {
root := t.TempDir()
// Layout:
// root/Author/Title/01.mp3, 02.mp3 → one book at Title
// root/Author/Parts/CD1/01.mp3, CD2/01.mp3 → one book at Parts
// root/Author/Lone/book.mp3 → no folder book
writeDummyTrack(t, filepath.Join(root, "Author", "Title", "01.mp3"))
writeDummyTrack(t, filepath.Join(root, "Author", "Title", "02.mp3"))
writeDummyTrack(t, filepath.Join(root, "Author", "Parts", "CD1", "01.mp3"))
writeDummyTrack(t, filepath.Join(root, "Author", "Parts", "CD2", "01.mp3"))
writeDummyTrack(t, filepath.Join(root, "Author", "Lone", "book.mp3"))
t.Run("simple track folder", func(t *testing.T) {
got, tracks, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Title", "02.mp3"), root)
if !ok {
t.Fatalf("expected folder book, got none")
}
if got != filepath.Join(root, "Author", "Title") {
t.Fatalf("root = %q", got)
}
if len(tracks) != 2 {
t.Fatalf("tracks = %v", tracks)
}
})
t.Run("disc folders merge into parent", func(t *testing.T) {
got, tracks, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Parts", "CD1", "01.mp3"), root)
if !ok {
t.Fatalf("expected folder book")
}
if got != filepath.Join(root, "Author", "Parts") {
t.Fatalf("root = %q (disc folders must merge up)", got)
}
if len(tracks) != 2 {
t.Fatalf("tracks = %v", tracks)
}
})
t.Run("lone file is not a folder book", func(t *testing.T) {
_, _, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Lone", "book.mp3"), root)
if ok {
t.Fatalf("single track must not form a folder book")
}
})
t.Run("sibling books never merge at author level", func(t *testing.T) {
writeDummyTrack(t, filepath.Join(root, "Author", "BookB", "01.mp3"))
writeDummyTrack(t, filepath.Join(root, "Author", "BookB", "02.mp3"))
got, _, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Title", "01.mp3"), root)
if !ok || got != filepath.Join(root, "Author", "Title") {
t.Fatalf("root = %q, ok = %v; Title/BookB must stay separate", got, ok)
}
})
}
func TestBuildMultiFileAudioPayload(t *testing.T) {
tracks := []audioTrackInfo{
{relPath: "Author/Title/01.mp3", title: "Chapter One", duration: 100, size: 10},
{relPath: "Author/Title/02.mp3", title: "Chapter Two", duration: 50, size: 5},
}
payload := buildMultiFileAudioPayload("folder", nil, tracks)
if payload.Layout != "folder" {
t.Fatalf("layout = %q", payload.Layout)
}
if payload.DurationSeconds != 150 {
t.Fatalf("duration = %v", payload.DurationSeconds)
}
if len(payload.Tracks) != 2 {
t.Fatalf("tracks = %d", len(payload.Tracks))
}
if payload.Tracks[1].Start != 100 || payload.Tracks[1].End != 150 {
t.Fatalf("cumulative bounds wrong: %+v", payload.Tracks[1])
}
// No sidecar chapters: tracks ARE the chapters.
if len(payload.Chapters) != 2 || payload.Chapters[0].Start != 0 || payload.Chapters[1].Start != 100 {
t.Fatalf("chapters = %+v", payload.Chapters)
}
// Curated sidecar chapters keep priority; tracks still carry bounds.
sidecar := &AudioMetadataPayload{
Chapters: []AudioChapter{{Index: 0, Title: "Part I", Start: 0, End: 150}},
}
payload2 := buildMultiFileAudioPayload("zip", sidecar, tracks)
if len(payload2.Chapters) != 1 || payload2.Chapters[0].Title != "Part I" {
t.Fatalf("sidecar chapters must win: %+v", payload2.Chapters)
}
if len(payload2.Tracks) != 2 {
t.Fatalf("tracks lost: %+v", payload2.Tracks)
}
}
func TestZipAudioEntries(t *testing.T) {
zipPath := filepath.Join(t.TempDir(), "book.zip")
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
for _, name := range []string{
"__MACOSX/._junk.mp3",
".hidden.mp3",
"02 second.mp3",
"10 tenth.mp3",
"01 first.mp3",
"cover.jpg",
"notes.txt",
} {
if _, err := w.Create(name); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
_ = f.Close()
entries, err := zipAudioEntries(zipPath)
if err != nil {
t.Fatal(err)
}
want := []string{"01 first.mp3", "02 second.mp3", "10 tenth.mp3"}
if len(entries) != len(want) {
t.Fatalf("entries = %v", entries)
}
for i, e := range entries {
if e.name != want[i] {
t.Fatalf("entry[%d] = %q, want %q", i, e.name, want[i])
}
}
}
func TestCleanTrackTitle(t *testing.T) {
cases := map[string]string{
"/x/01_Genesis001.mp3": "Genesis001",
"/x/01 - Track.mp3": "Track",
"/x/12_.Intro.mp3": "Intro",
"/x/Chapter 3.mp3": "Chapter 3",
"/x/JustTitle.m4b": "JustTitle",
"/x/01.mp3": "01", // number-only name: number stays the title
}
for in, want := range cases {
if got := cleanTrackTitle(in); got != want {
t.Errorf("cleanTrackTitle(%q) = %q, want %q", in, got, want)
}
}
}
+107 -25
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"context"
"errors"
"fmt"
"net/http"
"path/filepath"
@@ -27,6 +28,13 @@ const (
JobStatusCancelled JobStatus = "cancelled"
)
// Sentinel errors surfaced by CancelJob so HTTP handlers can map them
// to 404/409 without string matching.
var (
ErrJobNotFound = errors.New("job not found")
ErrJobNotCancellable = errors.New("job cannot be cancelled")
)
type JobType string
const (
@@ -45,11 +53,29 @@ var WorkerInstance *Worker
func (w *Worker) Enqueue(job *Job) {
select {
case w.jobQueue <- job:
w.recordPending(job)
default:
fmt.Printf("Worker queue full, rejecting job: %s\n", job.ID)
}
}
// recordPending registers a queued job's pending result so the status
// endpoint and GetActiveScanJobs can report jobs still waiting for a
// worker, and so CancelJob can cancel them before execution starts.
// The entry is replaced when processJob dequeues the job.
func (w *Worker) recordPending(job *Job) {
libID, _ := job.Params["library_id"].(string)
w.mu.Lock()
defer w.mu.Unlock()
if _, exists := w.results[job.ID]; !exists {
w.results[job.ID] = &JobResult{
JobID: job.ID,
Status: JobStatusPending,
LibraryID: libID,
}
}
}
type Job struct {
ID string
Type JobType
@@ -146,8 +172,14 @@ type AnalyticsJobResult struct {
ReportPath string `json:"report_path"`
}
type Worker struct {
jobQueue chan *Job
results map[string]*JobResult
jobQueue chan *Job
results map[string]*JobResult
// cancels maps a running job's id to its per-job context cancel
// func, registered by processJob and removed at completion. Lets
// CancelJob stop a running job without disturbing siblings (all
// manual scans used to share the handler's process-lifetime
// context, so no targeted cancellation was possible).
cancels map[string]context.CancelFunc
connManager *wsync.ConnectionManager
mu sync.RWMutex
wg sync.WaitGroup
@@ -192,6 +224,7 @@ func NewWorkerWithConfig(numWorkers, queueCap int, connManager *wsync.Connection
w := &Worker{
jobQueue: make(chan *Job, queueCap),
results: make(map[string]*JobResult),
cancels: make(map[string]context.CancelFunc),
ctx: ctx,
cancel: cancel,
}
@@ -243,22 +276,40 @@ func (w *Worker) worker() {
func (w *Worker) processJob(job *Job) {
libID, _ := job.Params["library_id"].(string)
w.mu.Lock()
// A job cancelled while queued must never execute: honour the
// pending-cancel marker and leave its result untouched.
if result, exists := w.results[job.ID]; exists && result.Status == JobStatusCancelled {
w.mu.Unlock()
return
}
w.results[job.ID] = &JobResult{
JobID: job.ID,
Status: JobStatusRunning,
LibraryID: libID,
}
// Derive a per-job context so cancelling this job (CancelJob) stops
// only this job. The parent stays whatever the caller passed (the
// handler's context for manual scans, Background for auto-scans).
parent := job.Context
if parent == nil {
parent = context.Background()
}
jobCtx, cancel := context.WithCancel(parent)
job.Context = jobCtx
w.cancels[job.ID] = cancel
w.mu.Unlock()
defer func() {
w.mu.Lock()
delete(w.cancels, job.ID)
w.mu.Unlock()
cancel()
}()
started := time.Now()
job.StartedAt = &started
w.mu.Lock()
if result, exists := w.results[job.ID]; exists {
result.Status = JobStatusRunning
}
w.mu.Unlock()
var err error
var result interface{}
@@ -318,27 +369,43 @@ func (w *Worker) processJob(job *Job) {
}
}
// A cancelled job stopped mid-flight: keep its last real progress
// instead of claiming 100%, and drop the meaningless "context
// canceled" processing error.
finalProgress := 1.0
finalError := ""
if status == JobStatusCancelled {
finalError = ""
if old, exists := w.results[job.ID]; exists {
finalProgress = old.Progress
}
} else if err != nil {
finalError = err.Error()
}
w.results[job.ID] = &JobResult{
JobID: job.ID,
Status: status,
LibraryID: libID,
Error: func() string {
if err != nil {
return err.Error()
} else {
return ""
}
}(),
Result: result,
Progress: 1.0,
Error: finalError,
Result: result,
Progress: finalProgress,
FilesScanned: filesScanned,
NewItems: newItems,
Errors: errors,
}
if job.Type == JobTypeScan && w.connManager != nil && job.UserID != "" {
// Cancelled scans announce scan_cancelled so clients reset
// their scanning indicators; a "scan_complete" would read as
// a finished scan.
msgType := wsync.MessageTypeScanComplete
if status == JobStatusCancelled {
msgType = wsync.MessageTypeScanCancelled
}
w.connManager.BroadcastToUser(job.UserID, wsync.BroadcastMessage{
Type: wsync.MessageTypeScanComplete,
Type: msgType,
Data: map[string]interface{}{
"job_id": job.ID,
"files_scanned": filesScanned,
@@ -972,6 +1039,7 @@ func (w *Worker) EnqueueJob(job *Job) error {
select {
case w.jobQueue <- job:
w.recordPending(job)
return nil
case <-w.ctx.Done():
return fmt.Errorf("worker is shutting down")
@@ -1005,19 +1073,33 @@ func (w *Worker) GetActiveScanJobs() []JobResult {
return active
}
// CancelJob cancels a pending or running job by id. Pending jobs are
// marked cancelled so processJob skips them at dequeue (no context
// exists yet); running jobs have their per-job context cancelled — the
// job stops at its next context check and processJob finalises the
// status as cancelled.
func (w *Worker) CancelJob(jobID string) error {
w.mu.Lock()
defer w.mu.Unlock()
if result, exists := w.results[jobID]; exists {
if result.Status == JobStatusRunning || result.Status == JobStatusPending {
result.Status = JobStatusCancelled
return nil
}
return fmt.Errorf("job cannot be cancelled")
result, exists := w.results[jobID]
if !exists {
return ErrJobNotFound
}
return fmt.Errorf("job not found")
switch result.Status {
case JobStatusPending:
result.Status = JobStatusCancelled
return nil
case JobStatusRunning:
result.Status = JobStatusCancelled
if cancel, ok := w.cancels[jobID]; ok {
cancel()
}
return nil
default:
return ErrJobNotCancellable
}
}
func (w *Worker) Shutdown() {
+157
View File
@@ -0,0 +1,157 @@
package services
import (
"context"
"errors"
"testing"
)
// A job cancelled while still queued must never execute. processJob
// sees the pending-cancel marker at dequeue and leaves the result
// untouched; a scan without params would otherwise fail with
// "library_id required" and flip the status to failed — so an empty
// error field proves the job body never ran.
func TestCancelQueuedJobNeverExecutes(t *testing.T) {
// Zero workers: the job stays queued until processJob is driven by
// hand below.
w := NewWorkerWithConfig(0, 10, nil)
job := &Job{
ID: "queued-cancel",
Type: JobTypeScan,
Context: context.Background(),
Params: map[string]interface{}{},
}
if err := w.EnqueueJob(job); err != nil {
t.Fatalf("enqueue failed: %v", err)
}
// The pending result must exist before any worker picks the job up,
// so status polling can see (and cancel) queued jobs.
result, ok := w.GetJobStatus("queued-cancel")
if !ok {
t.Fatal("queued job has no result entry")
}
if result.Status != JobStatusPending {
t.Fatalf("expected pending status, got %q", result.Status)
}
if err := w.CancelJob("queued-cancel"); err != nil {
t.Fatalf("CancelJob failed: %v", err)
}
// Simulate the dequeue that races the cancellation.
w.processJob(job)
result, _ = w.GetJobStatus("queued-cancel")
if result.Status != JobStatusCancelled {
t.Fatalf("expected cancelled status after dequeue, got %q", result.Status)
}
if result.Error != "" {
t.Errorf("skipped job must carry no processing error, got %q", result.Error)
}
}
// Cancelling a running job must invoke its per-job context cancel func
// — the old CancelJob only flipped a map status and the job kept
// running to completion.
func TestCancelRunningJobCancelsContext(t *testing.T) {
w := NewWorker(1, nil)
ctx, cancel := context.WithCancel(context.Background())
job := &Job{ID: "running-cancel", Type: JobTypeScan, Context: ctx}
// Seed the state processJob establishes while a job runs.
w.mu.Lock()
w.results[job.ID] = &JobResult{JobID: job.ID, Status: JobStatusRunning}
w.cancels[job.ID] = cancel
w.mu.Unlock()
if err := w.CancelJob(job.ID); err != nil {
t.Fatalf("CancelJob failed: %v", err)
}
if ctx.Err() == nil {
t.Fatal("CancelJob must cancel the job's context")
}
result, _ := w.GetJobStatus(job.ID)
if result.Status != JobStatusCancelled {
t.Fatalf("expected cancelled status, got %q", result.Status)
}
}
func TestCancelJobErrors(t *testing.T) {
w := NewWorker(1, nil)
if err := w.CancelJob("missing-job"); !errors.Is(err, ErrJobNotFound) {
t.Errorf("unknown job: expected ErrJobNotFound, got %v", err)
}
w.mu.Lock()
w.results["done-job"] = &JobResult{JobID: "done-job", Status: JobStatusCompleted}
w.results["failed-job"] = &JobResult{JobID: "failed-job", Status: JobStatusFailed}
w.results["cancelled-job"] = &JobResult{JobID: "cancelled-job", Status: JobStatusCancelled}
w.mu.Unlock()
for _, id := range []string{"done-job", "failed-job", "cancelled-job"} {
if err := w.CancelJob(id); !errors.Is(err, ErrJobNotCancellable) {
t.Errorf("terminal job %q: expected ErrJobNotCancellable, got %v", id, err)
}
}
}
// Both enqueue paths must register a pending result: auto-scans arrive
// via Enqueue (fire-and-forget) and manual scans via EnqueueJob, and a
// queued job must be visible to status polling and cancellable before
// a worker dequeues it.
func TestEnqueueRecordsPendingResult(t *testing.T) {
w := NewWorkerWithConfig(0, 10, nil)
fireAndForget := &Job{
ID: "via-enqueue",
Type: JobTypeScan,
Context: context.Background(),
Params: map[string]interface{}{"library_id": "lib-auto"},
}
w.Enqueue(fireAndForget)
tracked := &Job{
ID: "via-enqueue-job",
Type: JobTypeScan,
Context: context.Background(),
Params: map[string]interface{}{"library_id": "lib-manual"},
}
if err := w.EnqueueJob(tracked); err != nil {
t.Fatalf("EnqueueJob failed: %v", err)
}
for id, libID := range map[string]string{
"via-enqueue": "lib-auto",
"via-enqueue-job": "lib-manual",
} {
result, ok := w.GetJobStatus(id)
if !ok {
t.Fatalf("%s: no pending result recorded", id)
}
if result.Status != JobStatusPending {
t.Errorf("%s: expected pending, got %q", id, result.Status)
}
if result.LibraryID != libID {
t.Errorf("%s: expected library_id %q, got %q", id, libID, result.LibraryID)
}
}
}
// A scan whose context is already cancelled must abort immediately
// with context.Canceled — before counting, walking, or running the
// archive pass. processJob classifies that error as a cancelled job.
func TestScanFoldersCancelledContext(t *testing.T) {
s := NewMediaScanner(nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := s.ScanFolders(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
+1
View File
@@ -12,6 +12,7 @@ const (
FormatGroupReflowable FormatGroup = "reflowable"
FormatGroupFixedLayout FormatGroup = "fixed_layout"
FormatGroupComicArchive FormatGroup = "comic_archive"
FormatGroupAudiobook FormatGroup = "audiobook"
FormatGroupUnknown FormatGroup = "unknown"
)
+10 -1
View File
@@ -87,6 +87,15 @@ func ConvertProgress(sourceFormat, targetFormat FormatGroup, sourceData map[stri
result["panel"] = panel
}
case FormatGroupAudiobook:
// Audio progress is percentage + global seconds; there is no page
// grid to re-derive, so the percentage carries across as-is and
// the second-based locator follows when present.
result["percentage"] = percentage
if offset, ok := sourceData["audio_offset_seconds"].(int); ok {
result["audio_offset_seconds"] = offset
}
default:
return nil, fmt.Errorf("unsupported target format: %s", targetFormat)
}
@@ -108,7 +117,7 @@ func extractPercentage(sourceFormat FormatGroup, sourceData map[string]interface
}
}
case FormatGroupFixedLayout, FormatGroupComicArchive:
case FormatGroupFixedLayout, FormatGroupComicArchive, FormatGroupAudiobook:
if page, ok := sourceData["page"].(int); ok {
if total, ok := sourceData["total_pages"].(int); ok && total > 0 {
return float64(page) / float64(total)
+1
View File
@@ -20,6 +20,7 @@ const (
MessageTypeScanProgress = "scan_progress"
MessageTypeScanComplete = "scan_complete"
MessageTypeScanError = "scan_error"
MessageTypeScanCancelled = "scan_cancelled"
)
// BroadcastMessage represents a message to broadcast to connected clients
+8 -3
View File
@@ -97,9 +97,14 @@ templ Admin(user User, stats AdminStats) {
@Icon("refresh", "h-5 w-5")
Scanning Libraries
</h3>
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
<div class="flex items-center gap-2">
<button id="scan-cancel-all-btn" @click="cancelAllScans()" class="hidden icon-btn" aria-label="Cancel all scans" title="Cancel all scans">
@Icon("x-circle", "h-5 w-5")
</button>
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
</div>
<div class="mb-4">
<div class="flex justify-between text-sm mb-2">
+19 -56
View File
@@ -1,6 +1,8 @@
package templates
templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64) {
import "bookhoard/internal/database"
templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64, libraryTypes []database.LibraryTypes) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -11,7 +13,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body class={ "theme-" + user.Theme }>
<body x-data="admin" class={ "theme-" + user.Theme }>
@Header(user, "/admin/library")
<main class="p-8">
<div class="max-w-4xl">
@@ -88,9 +90,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Type</label>
<select name="type" class="input" required>
<option value="ebooks">Ebooks</option>
<option value="comics">Comics</option>
<option value="manga">Manga</option>
@LibraryTypeOptions(libraryTypes)
</select>
</div>
<div class="flex justify-end space-x-3">
@@ -173,57 +173,20 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
</div>
</div>
</div>
<script>
function toggleLibraryPanel(btn) {
var libId = btn.dataset.libId;
var panel = document.getElementById('library-panel-' + libId);
if (panel.innerHTML.trim() !== '') {
panel.innerHTML = '';
} else {
htmx.ajax('GET', '/admin/library/' + libId + '/panel', {
target: '#library-panel-' + libId,
swap: 'innerHTML'
});
}
}
function openEditModal(id, name, description) {
document.getElementById('edit-library-form').setAttribute('hx-put', '/admin/library/' + id);
document.getElementById('edit-library-name').value = name;
document.getElementById('edit-library-desc').value = description;
htmx.process(document.getElementById('edit-library-form'));
document.getElementById('edit-library-modal').classList.remove('hidden');
}
function openDeleteModal(id, name) {
const btn = document.getElementById('delete-library-confirm');
btn.setAttribute('hx-delete', '/admin/library/' + id);
btn.setAttribute('hx-target', '#libraries-container');
btn.setAttribute('hx-swap', 'innerHTML');
document.getElementById('delete-library-name').textContent = name;
htmx.process(btn);
document.getElementById('delete-library-modal').classList.remove('hidden');
}
function openFolderBrowser(targetInputId, libraryId) {
const content = document.getElementById('folder-browser-content');
content.setAttribute('hx-get', '/admin/library/browse');
content.setAttribute('hx-vals', '{"target_input": "' + targetInputId + '", "library_id": "' + libraryId + '"}');
content.setAttribute('hx-trigger', 'load');
htmx.process(content);
document.getElementById('folder-browser-modal').classList.remove('hidden');
}
// Close the delete-library modal after a successful delete: the
// htmx swap already removes the deleted row from the libraries
// list, but the modal lives outside the swapped container and
// would otherwise stay open.
document.getElementById('delete-library-confirm').addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.successful) {
document.getElementById('delete-library-modal').classList.add('hidden');
}
});
</script>
</body>
</html>
}
// LibraryTypeOptions renders the <option> list for a library-type <select>
// from the DB-backed library_types rows, so new types show up without
// template edits.
templ LibraryTypeOptions(types []database.LibraryTypes) {
for _, t := range types {
<option value={ t.Name }>{ LibraryTypeDisplayName(t.Name) }</option>
}
}
templ LibraryList(user User, libraries []LibraryData, users []User) {
if len(libraries) == 0 {
<div class="card text-center py-16">
@@ -273,7 +236,7 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
</button>
<button
data-lib-id={ library.ID }
onclick="toggleLibraryPanel(this)"
@click="toggleLibraryPanel($el)"
class="btn btn-secondary text-xs px-3 py-1.5"
>
@Icon("chevron-down", "h-4 w-4")
@@ -331,7 +294,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
type="button"
data-library-id={ libraryID }
data-target-input={ "folder-input-" + libraryID }
onclick="openFolderBrowser(this.dataset.targetInput, this.dataset.libraryId)"
@click="openFolderBrowser($el.dataset.targetInput, $el.dataset.libraryId)"
class="btn btn-secondary text-sm shrink-0"
>
@Icon("folder", "h-4 w-4")
@@ -392,7 +355,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
data-edit-id={ libraryID }
data-edit-name={ library.Name }
data-edit-desc={ library.Description }
onclick="openEditModal(this.dataset.editId, this.dataset.editName, this.dataset.editDesc)"
@click="openEditModal($el.dataset.editId, $el.dataset.editName, $el.dataset.editDesc)"
class="btn btn-secondary text-sm"
>
@Icon("edit", "h-4 w-4")
@@ -401,7 +364,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
<button
data-delete-id={ libraryID }
data-delete-name={ library.Name }
onclick="openDeleteModal(this.dataset.deleteId, this.dataset.deleteName)"
@click="openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)"
class="btn btn-danger text-sm"
>
@Icon("trash", "h-4 w-4")
File diff suppressed because it is too large. Load diff
+11 -3
View File
@@ -183,7 +183,15 @@ func Admin(user User, stats AdminStats) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"icon-btn\" aria-label=\"Close\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Scanning Libraries</h3><div class=\"flex items-center gap-2\"><button id=\"scan-cancel-all-btn\" @click=\"cancelAllScans()\" class=\"hidden icon-btn\" aria-label=\"Cancel all scans\" title=\"Cancel all scans\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("x-circle", "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, 18, "</button> <button @click=\"hideScanProgress()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -191,7 +199,7 @@ func Admin(user User, stats AdminStats) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</button></div><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full rounded-full h-3\" style=\"background-color: var(--surface-hover);\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><div id=\"library-progress-list\" class=\"space-y-3\"></div><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-xl border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2 flex items-center gap-2\" style=\"color: var(--status-success);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</button></div></div><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full rounded-full h-3\" style=\"background-color: var(--surface-hover);\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><div id=\"library-progress-list\" class=\"space-y-3\"></div><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-xl border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2 flex items-center gap-2\" style=\"color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -199,7 +207,7 @@ func Admin(user User, stats AdminStats) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn btn-primary\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn btn-secondary\">Dismiss</button></div></div></div></div></main></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn btn-primary\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn btn-secondary\">Dismiss</button></div></div></div></div></main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+6
View File
@@ -139,6 +139,12 @@ templ BookShelf(
<div class="p-6 space-y-5">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library</label>
<!-- Do NOT wire a `change` handler (or hx-trigger="change")
on this select: header.ts restoreLibrarySelection may
fire a synthetic change on page load, and a handler
here would re-fetch the grid at offset 0, stomping
SSR pagination (?offset=). User changes go through
bookshelf.ts's own wiring only. -->
<select id="library-select" name="library_id" class="input">
if len(libraries) == 0 {
<option value="">No libraries available</option>
+19 -19
View File
@@ -177,7 +177,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button></div><div class=\"p-6 space-y-5\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library</label> <select id=\"library-select\" name=\"library_id\" class=\"input\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button></div><div class=\"p-6 space-y-5\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library</label><!-- Do NOT wire a `change` handler (or hx-trigger=\"change\")\n\t\t\t\t\t\t\t\t\t\ton this select: header.ts restoreLibrarySelection may\n\t\t\t\t\t\t\t\t\t\tfire a synthetic change on page load, and a handler\n\t\t\t\t\t\t\t\t\t\there would re-fetch the grid at offset 0, stomping\n\t\t\t\t\t\t\t\t\t\tSSR pagination (?offset=). User changes go through\n\t\t\t\t\t\t\t\t\t\tbookshelf.ts's own wiring only. --><select id=\"library-select\" name=\"library_id\" class=\"input\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -195,7 +195,7 @@ func BookShelf(
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 147, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 153, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -213,7 +213,7 @@ func BookShelf(
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 149, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 155, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -234,7 +234,7 @@ func BookShelf(
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -247,7 +247,7 @@ func BookShelf(
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -260,7 +260,7 @@ func BookShelf(
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -278,7 +278,7 @@ func BookShelf(
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -291,7 +291,7 @@ func BookShelf(
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -304,7 +304,7 @@ func BookShelf(
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -323,7 +323,7 @@ func BookShelf(
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -336,7 +336,7 @@ func BookShelf(
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -349,7 +349,7 @@ func BookShelf(
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -367,7 +367,7 @@ func BookShelf(
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -380,7 +380,7 @@ func BookShelf(
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -393,7 +393,7 @@ func BookShelf(
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -422,7 +422,7 @@ func BookShelf(
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 287, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 293, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -445,7 +445,7 @@ func BookShelf(
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 296, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 302, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -476,7 +476,7 @@ func BookShelf(
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 305, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 311, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -489,7 +489,7 @@ func BookShelf(
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 310, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 316, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
+71 -62
View File
@@ -14,6 +14,76 @@ func pluralizeBooks(n int) string {
return "books"
}
// CollectionBooksGrid renders the content of #books-container: the
// collection's book cards, or the empty state. Shared by the collection
// detail page and the /collections/:id/books-grid partial route (the
// library switcher refreshes the grid through it — rendering ownership:
// templ renders, JS only swaps).
templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) {
if len(books) == 0 {
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
}
for _, book := range books {
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
<div class="flex gap-4">
if !collection.IsSystem && !collection.IsSmart {
<div class="flex-shrink-0 pt-1">
<label class="flex items-center cursor-pointer p-2 -m-2">
<input
type="checkbox"
class="w-5 h-5"
:checked={ "selectedBooks.includes(" + JSONArg(book.MediaItemID) + ")" }
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
/>
</label>
</div>
}
<div class="flex-1 min-w-0">
<a href={ "/media/" + book.MediaItemID }>
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
{ book.Title }
</h3>
</a>
if book.Author != "" {
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
by { book.Author }
</p>
}
</div>
<div class="flex-shrink-0 w-16 sm:w-20">
<a href={ "/media/" + book.MediaItemID }>
if book.CoverImagePath != "" {
<img
src={ book.CoverImagePath }
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<img
src="/static/placeholder-book.svg"
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
/>
}
</a>
</div>
</div>
if !collection.IsSystem && !collection.IsSmart {
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
class="btn btn-secondary w-full"
>
@Icon("trash", "h-4 w-4")
<span>Remove from Collection</span>
</button>
</div>
}
</div>
}
}
templ Collection(user User, collections []CollectionData, errorMessage string) {
<!DOCTYPE html>
<html lang="en">
@@ -210,68 +280,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</div>
</div>
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
if len(books) == 0 {
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
}
for _, book := range books {
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
<div class="flex gap-4">
if !collection.IsSystem && !collection.IsSmart {
<div class="flex-shrink-0 pt-1">
<label class="flex items-center cursor-pointer p-2 -m-2">
<input
type="checkbox"
class="w-5 h-5"
:checked={ "selectedBooks.includes(" + JSONArg(book.MediaItemID) + ")" }
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
/>
</label>
</div>
}
<div class="flex-1 min-w-0">
<a href={ "/media/" + book.MediaItemID }>
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
{ book.Title }
</h3>
</a>
if book.Author != "" {
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
by { book.Author }
</p>
}
</div>
<div class="flex-shrink-0 w-16 sm:w-20">
<a href={ "/media/" + book.MediaItemID }>
if book.CoverImagePath != "" {
<img
src={ book.CoverImagePath }
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<img
src="/static/placeholder-book.svg"
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
/>
}
</a>
</div>
</div>
if !collection.IsSystem && !collection.IsSmart {
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
class="btn btn-secondary w-full"
>
@Icon("trash", "h-4 w-4")
<span>Remove from Collection</span>
</button>
</div>
}
</div>
}
@CollectionBooksGrid(collection, books)
</div>
</div>
if !collection.IsSystem && !collection.IsSmart {
File diff suppressed because it is too large. Load diff
+88 -60
View File
@@ -2,6 +2,89 @@ package templates
import "bookhoard/internal/handlers"
// ConflictsListContent renders the inner content of #conflicts-list:
// bulk-select controls, the empty state, or the conflict cards. Shared by
// the conflicts page and the /conflicts/list partial route.
templ ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse) {
<div id="select-all-container" class="mb-4 hidden">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" @change="toggleAllConflicts()"/>
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span>
</label>
</div>
if len(conflicts) == 0 {
<div class="card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
</div>
}
for _, conflict := range conflicts {
<div class="conflict-item card p-6" data-conflict-id={ conflict.ID } data-status={ conflict.ResolutionStatus } style="border-left-width: 4px; border-left-color: var(--status-warning);">
<div class="flex justify-between items-start mb-4 gap-4">
<div class="flex items-start space-x-4">
<input
type="checkbox"
class="conflict-checkbox w-5 h-5 rounded mt-1"
data-conflict-id={ conflict.ID }
@change="updateBulkActions()"
/>
<div class="flex items-start gap-3">
<span style="color: var(--status-warning);">
@Icon("alert", "h-5 w-5")
</span>
<div>
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
</div>
</div>
</div>
<button @click={ "showResolveModal(" + JSONArg(conflict.ID) + ")" } class="btn btn-primary">Resolve</button>
</div>
<div class="text-sm" style="color: var(--text-secondary)">
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
if conflict.ResolvedBy != "" {
| Resolved by: { conflict.ResolvedBy }
}
</div>
</div>
}
<p id="filter-empty" class="hidden text-center p-4" style="color: var(--text-secondary)">
No conflicts match this filter.
</p>
}
// ConflictStatsBar renders the total/unresolved/resolved stat cards.
templ ConflictStatsBar(total int, unresolved int) {
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
</div>
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
</div>
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
</div>
}
// ConflictsRefreshFragment is the /conflicts/list partial response: a
// fresh list plus the stats bar as an out-of-band swap. The client swaps
// this instead of re-rendering conflict cards from JSON (rendering
// ownership: templ renders, JS only swaps).
templ ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int) {
<div id="conflicts-list" class="space-y-4">
@ConflictsListContent(user, conflicts)
</div>
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg" hx-swap-oob="true">
@ConflictStatsBar(total, unresolved)
</div>
}
templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) {
<!DOCTYPE html>
<html lang="en">
@@ -30,7 +113,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<div class="flex space-x-3">
<select
id="status-filter"
onchange="filterConflicts()"
@change="filterConflicts()"
class="input"
>
<option value="unresolved">Unresolved Only</option>
@@ -41,18 +124,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
</div>
</div>
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg">
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
</div>
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
</div>
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
</div>
@ConflictStatsBar(total, unresolved)
</div>
<div id="bulk-actions" class="mt-4 hidden">
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
@@ -69,11 +141,11 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
</select>
</div>
<div class="flex space-x-2">
<button @click="bulkResolve()" class="btn btn-primary text-sm">
<button @click="resolveSelected()" class="btn btn-primary text-sm">
@Icon("check", "h-4 w-4")
Resolve Selected
</button>
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
<button @click="dismissSelected()" class="btn btn-danger text-sm">
Dismiss Selected
</button>
</div>
@@ -92,51 +164,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
</div>
<div id="conflicts-list" class="space-y-4">
<div id="select-all-container" class="mb-4 hidden">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span>
</label>
</div>
if len(conflicts) == 0 {
<div class="card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
</div>
}
for _, conflict := range conflicts {
<div class="conflict-item card p-6" data-conflict-id={ conflict.ID } style="border-left-width: 4px; border-left-color: var(--status-warning);">
<div class="flex justify-between items-start mb-4 gap-4">
<div class="flex items-start space-x-4">
<input
type="checkbox"
class="conflict-checkbox w-5 h-5 rounded mt-1"
data-conflict-id={ conflict.ID }
onchange="updateBulkActions()"
/>
<div class="flex items-start gap-3">
<span style="color: var(--status-warning);">
@Icon("alert", "h-5 w-5")
</span>
<div>
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
</div>
</div>
</div>
<button @click={ "showResolveModal(" + JSONArg(conflict.ID) + ")" } class="btn btn-primary">Resolve</button>
</div>
<div class="text-sm" style="color: var(--text-secondary)">
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
if conflict.ResolvedBy != "" {
| Resolved by: { conflict.ResolvedBy }
}
</div>
</div>
}
@ConflictsListContent(user, conflicts)
</div>
<div id="conflict-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 p-6 w-full max-w-4xl my-8" style="box-shadow: var(--shadow-pop);">
+278 -138
View File
@@ -10,7 +10,10 @@ import templruntime "github.com/a-h/templ/runtime"
import "bookhoard/internal/handlers"
func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) templ.Component {
// ConflictsListContent renders the inner content of #conflicts-list:
// bulk-select controls, the empty state, or the conflict cards. Shared by
// the conflicts page and the /conflicts/list partial route.
func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse) 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 {
@@ -31,105 +34,12 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Sync Conflicts - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><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_Var2 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body x-data=\"conflicts\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Header(user, "/conflicts").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex justify-between items-center gap-4 flex-wrap\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("alert", "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, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Sync Conflicts</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve conflicts when reading progress differs across devices</p></div><div class=\"flex space-x-3\"><select id=\"status-filter\" onchange=\"filterConflicts()\" class=\"input\"><option value=\"unresolved\">Unresolved Only</option> <option value=\"all\">All Conflicts</option> <option value=\"resolved\">Resolved Only</option></select> <button @click=\"dismissAllResolved()\" class=\"btn btn-secondary\">Dismiss All Resolved</button></div></div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\"><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 45, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Unresolved</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 53, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Resolved</div></div></div><div id=\"bulk-actions\" class=\"mt-4 hidden\"><div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div class=\"flex items-center space-x-4\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\"><span id=\"selected-count\">0</span> conflicts selected</span> <select id=\"bulk-strategy\" class=\"input w-auto\"><option value=\"most_recent\">Most Recent</option> <option value=\"highest_progress\">Highest Progress</option></select></div><div class=\"flex space-x-2\"><button @click=\"bulkResolve()\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check", "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, 9, "Resolve Selected</button> <button @click=\"bulkDismiss()\" class=\"btn btn-danger text-sm\">Dismiss Selected</button></div></div></div></div><div id=\"loading\" class=\"hidden text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading conflicts...</p></div><div id=\"empty-state\" class=\"hidden card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "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, 10, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div><div id=\"conflicts-list\" class=\"space-y-4\"><div id=\"select-all-container\" class=\"mb-4 hidden\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"checkbox\" id=\"select-all-conflicts\" class=\"w-5 h-5 rounded\" onchange=\"toggleAllConflicts()\"> <span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Select All</span></label></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"select-all-container\" class=\"mb-4 hidden\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"checkbox\" id=\"select-all-conflicts\" class=\"w-5 h-5 rounded\" @change=\"toggleAllConflicts()\"> <span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Select All</span></label></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(conflicts) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -137,39 +47,52 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, conflict := range conflicts {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"conflict-item card p-6\" data-conflict-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"conflict-item card p-6\" data-conflict-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 111, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 25, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" style=\"border-left-width: 4px; border-left-color: var(--status-warning);\"><div class=\"flex justify-between items-start mb-4 gap-4\"><div class=\"flex items-start space-x-4\"><input type=\"checkbox\" class=\"conflict-checkbox w-5 h-5 rounded mt-1\" data-conflict-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" data-status=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ResolutionStatus)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 117, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 25, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" onchange=\"updateBulkActions()\"><div class=\"flex items-start gap-3\"><span style=\"color: var(--status-warning);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" style=\"border-left-width: 4px; border-left-color: var(--status-warning);\"><div class=\"flex justify-between items-start mb-4 gap-4\"><div class=\"flex items-start space-x-4\"><input type=\"checkbox\" class=\"conflict-checkbox w-5 h-5 rounded mt-1\" data-conflict-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 31, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" @change=\"updateBulkActions()\"><div class=\"flex items-start gap-3\"><span style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -177,83 +100,300 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span><div><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 125, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 39, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Type: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Type: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 126, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 40, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div></div></div><button @click=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p></div></div></div><button @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")")
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 130, Col: 73}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 44, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"btn btn-primary\">Resolve</button></div><div class=\"text-sm\" style=\"color: var(--text-secondary)\">Created: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"btn btn-primary\">Resolve</button></div><div class=\"text-sm\" style=\"color: var(--text-secondary)\">Created: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 133, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 47, 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_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if conflict.ResolvedBy != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "| Resolved by: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "| Resolved by: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 135, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div><div id=\"conflict-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 p-6 w-full max-w-4xl my-8\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Resolve Conflict</h2><p class=\"text-sm\" id=\"conflict-book-title\" style=\"color: var(--text-secondary);\">Book Title</p></div><button @click=\"hideResolveModal()\" class=\"icon-btn\" aria-label=\"Close\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p id=\"filter-empty\" class=\"hidden text-center p-4\" style=\"color: var(--text-secondary)\">No conflicts match this filter.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// ConflictStatsBar renders the total/unresolved/resolved stat cards.
func ConflictStatsBar(total int, unresolved int) 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)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(total)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 62, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 66, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Unresolved</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 70, Col: 97}
}
_, 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, 19, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Resolved</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// ConflictsRefreshFragment is the /conflicts/list partial response: a
// fresh list plus the stats bar as an out-of-band swap. The client swaps
// this instead of re-rendering conflict cards from JSON (rendering
// ownership: templ renders, JS only swaps).
func ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int) 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_Var14 := templ.GetChildren(ctx)
if templ_7745c5c3_Var14 == nil {
templ_7745c5c3_Var14 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div id=\"conflicts-list\" class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ConflictsListContent(user, conflicts).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\" hx-swap-oob=\"true\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ConflictStatsBar(total, unresolved).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage 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_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var15 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Sync Conflicts - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><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_Var16 = []any{"theme-" + user.Theme}
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, "<body x-data=\"conflicts\" 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: `conflicts.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 = Header(user, "/conflicts").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex justify-between items-center gap-4 flex-wrap\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("alert", "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, 27, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Sync Conflicts</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve conflicts when reading progress differs across devices</p></div><div class=\"flex space-x-3\"><select id=\"status-filter\" @change=\"filterConflicts()\" class=\"input\"><option value=\"unresolved\">Unresolved Only</option> <option value=\"all\">All Conflicts</option> <option value=\"resolved\">Resolved Only</option></select> <button @click=\"dismissAllResolved()\" class=\"btn btn-secondary\">Dismiss All Resolved</button></div></div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ConflictStatsBar(total, unresolved).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div id=\"bulk-actions\" class=\"mt-4 hidden\"><div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div class=\"flex items-center space-x-4\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\"><span id=\"selected-count\">0</span> conflicts selected</span> <select id=\"bulk-strategy\" class=\"input w-auto\"><option value=\"most_recent\">Most Recent</option> <option value=\"highest_progress\">Highest Progress</option></select></div><div class=\"flex space-x-2\"><button @click=\"resolveSelected()\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check", "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, 29, "Resolve Selected</button> <button @click=\"dismissSelected()\" class=\"btn btn-danger text-sm\">Dismiss Selected</button></div></div></div></div><div id=\"loading\" class=\"hidden text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading conflicts...</p></div><div id=\"empty-state\" class=\"hidden card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "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, 30, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div><div id=\"conflicts-list\" class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ConflictsListContent(user, conflicts).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><div id=\"conflict-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 p-6 w-full max-w-4xl my-8\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Resolve Conflict</h2><p class=\"text-sm\" id=\"conflict-book-title\" style=\"color: var(--text-secondary);\">Book Title</p></div><button @click=\"hideResolveModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -261,7 +401,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</button></div><form id=\"conflict-resolution-form\" @submit=\"handleResolveSubmit($event)\"><input type=\"hidden\" id=\"conflict-id\"><div id=\"conflict-details\" class=\"mb-6\"></div><div class=\"card p-4 mb-6\"><h3 class=\"font-semibold mb-4\" style=\"color: var(--text-primary)\">Resolution Options</h3><div class=\"space-y-3 mb-6\"><label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"koreader\" class=\"w-4 h-4\" required><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep KOReader Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your KOReader device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"kobo\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Kobo Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your Kobo device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"web\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Web Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from the web interface</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"manual\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Manual Override</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Specify a custom progress value</p></div></label></div><div id=\"manual-override\" class=\"hidden mb-4 p-4 rounded-lg\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary);\">Manual Progress</h4><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Percentage (0-100)</label> <input type=\"number\" id=\"manual-percentage\" min=\"0\" max=\"100\" step=\"0.01\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Page Number</label> <input type=\"number\" id=\"manual-page\" min=\"1\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">EPUB CFI</label> <input type=\"text\" id=\"manual-epubcfi\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Chapter</label> <input type=\"number\" id=\"manual-chapter\" min=\"1\" class=\"input\"></div></div></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"apply-to-all\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary);\">Apply this resolution to all future conflicts</span></label></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Resolution Reason (optional)</label> <input type=\"text\" id=\"resolution-reason\" class=\"input\" placeholder=\"e.g., Device was offline, using most recent progress\"></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideResolveModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"deleteConflict()\" class=\"btn btn-danger\">Dismiss</button> <button type=\"submit\" class=\"btn btn-primary\">Resolve Conflict</button></div></form></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</button></div><form id=\"conflict-resolution-form\" @submit=\"handleResolveSubmit($event)\"><input type=\"hidden\" id=\"conflict-id\"><div id=\"conflict-details\" class=\"mb-6\"></div><div class=\"card p-4 mb-6\"><h3 class=\"font-semibold mb-4\" style=\"color: var(--text-primary)\">Resolution Options</h3><div class=\"space-y-3 mb-6\"><label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"koreader\" class=\"w-4 h-4\" required><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep KOReader Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your KOReader device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"kobo\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Kobo Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your Kobo device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"web\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Web Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from the web interface</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"manual\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Manual Override</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Specify a custom progress value</p></div></label></div><div id=\"manual-override\" class=\"hidden mb-4 p-4 rounded-lg\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary);\">Manual Progress</h4><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Percentage (0-100)</label> <input type=\"number\" id=\"manual-percentage\" min=\"0\" max=\"100\" step=\"0.01\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Page Number</label> <input type=\"number\" id=\"manual-page\" min=\"1\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">EPUB CFI</label> <input type=\"text\" id=\"manual-epubcfi\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Chapter</label> <input type=\"number\" id=\"manual-chapter\" min=\"1\" class=\"input\"></div></div></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"apply-to-all\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary);\">Apply this resolution to all future conflicts</span></label></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Resolution Reason (optional)</label> <input type=\"text\" id=\"resolution-reason\" class=\"input\" placeholder=\"e.g., Device was offline, using most recent progress\"></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideResolveModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"deleteConflict()\" class=\"btn btn-danger\">Dismiss</button> <button type=\"submit\" class=\"btn btn-primary\">Resolve Conflict</button></div></form></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -269,7 +409,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+19 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
)
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string) {
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string, unlinkedCount int) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -23,6 +23,24 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
re-render wipes that container's innerHTML, and anything
server-rendered inside it dies on the first library
switch. The widget re-fetches itself via htmx instead. -->
if unlinkedCount > 0 {
<div class="mx-auto container px-4 sm:px-6 lg:px-8 pt-4">
<a
href="/unlinked-books"
class="card p-4 flex items-center gap-3 transition-colors hover:bg-surface-hover"
style="border-left-width: 4px; border-left-color: var(--status-warning);"
>
<span class="grid place-items-center h-8 w-8 rounded-lg shrink-0" style="background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);">
@Icon("alert", "h-4 w-4")
</span>
<span class="flex-1 text-sm" style="color: var(--text-primary)">
<span class="font-semibold">{ fmt.Sprintf("%d", unlinkedCount) }</span>
{ pluralizeBooks(unlinkedCount) } on your devices could not be identified — reading progress for them is paused until they are linked.
</span>
<span class="text-sm font-medium shrink-0" style="color: var(--accent)">Review →</span>
</a>
</div>
}
if week != nil {
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
@DashboardWeekWidget(*week, activeLibType)
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -241,7 +241,7 @@ templ Header(user User, currentPath string) {
</header>
<!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate
builds across app restarts (browser caches are keyed by URL) -->
<script type="module" src="/static/main.js?v=5"></script>
<script type="module" src="/static/main.js?v=6"></script>
</div>
}
+1 -1
View File
@@ -751,7 +751,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=5\"></script></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=6\"></script></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+2 -2
View File
@@ -43,8 +43,8 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
</div>
<div
id="loading-spinner"
class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
style="background-color: var(--bg-primary);"
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>
+1 -1
View File
@@ -275,7 +275,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><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, 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+93
View File
@@ -0,0 +1,93 @@
package templates
import (
"context"
"strings"
"testing"
"bookhoard/internal/handlers"
)
// The rendering-ownership partials must render standalone (they are
// fetched by htmx.ajax at runtime) and must never leak templ literal
// expressions into their markup.
func TestRenderingPartialsRender(t *testing.T) {
var b strings.Builder
col := CollectionData{ID: "c1", Name: "Test Shelf", IsSmart: false}
books := []handlers.BookInfo{{MediaItemID: "m1", Title: "A Book", Author: "An Author"}}
if err := CollectionBooksGrid(col, books).Render(context.Background(), &b); err != nil {
t.Fatalf("CollectionBooksGrid: %v", err)
}
out := b.String()
for _, want := range []string{"A Book", `data-media-id="m1"`, "Remove from Collection"} {
if !strings.Contains(out, want) {
t.Errorf("books grid missing %q", want)
}
}
// Empty collection renders the empty state, not a literal loop.
b.Reset()
if err := CollectionBooksGrid(col, nil).Render(context.Background(), &b); err != nil {
t.Fatalf("CollectionBooksGrid empty: %v", err)
}
if !strings.Contains(b.String(), "No books in this collection yet.") {
t.Error("books grid empty state missing")
}
b.Reset()
user := User{ID: "u1", Timezone: "UTC"}
conflicts := []handlers.ConflictDetailResponse{{ID: "cf1", MediaItemTitle: "War and Peace", ConflictType: "progress"}}
if err := ConflictsRefreshFragment(user, conflicts, 3, 1).Render(context.Background(), &b); err != nil {
t.Fatalf("ConflictsRefreshFragment: %v", err)
}
out = b.String()
for _, want := range []string{`id="conflicts-list"`, `id="stats-bar"`, "hx-swap-oob", "Resolve"} {
if !strings.Contains(out, want) {
t.Errorf("conflicts fragment missing %q", want)
}
}
}
// The unlinked-books queue banner renders only when the queue is
// non-empty (hidden-unless-needed).
func TestDashboardUnlinkedBanner(t *testing.T) {
render := func(count int) string {
var b strings.Builder
err := Dashboard(User{ID: "u1"}, nil, nil, nil, "", nil, 0, nil, "", "", count).Render(context.Background(), &b)
if err != nil {
t.Fatalf("render: %v", err)
}
return b.String()
}
if out := render(0); strings.Contains(out, "/unlinked-books") {
t.Error("banner rendered with empty queue")
}
out := render(2)
if !strings.Contains(out, "/unlinked-books") || !strings.Contains(out, "could not be identified") {
t.Error("banner missing with non-empty queue")
}
}
// The unlinked-books page renders queue rows standalone.
func TestUnlinkedBooksPageRender(t *testing.T) {
var b strings.Builder
user := User{ID: "u1", Timezone: "UTC"}
books := []UnlinkedBookData{{
ProgressID: "ub1",
DeviceName: "Kobo Clara",
DeviceType: "kobo",
FilePath: "/mnt/onboard/book.epub",
SHA256: "abc123",
TitleFromDevice: "Some Sideloaded Book",
LastSyncTime: "2026-10-18 12:00",
}}
if err := UnlinkedBooks(user, books).Render(context.Background(), &b); err != nil {
t.Fatalf("render: %v", err)
}
out := b.String()
for _, want := range []string{"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches"} {
if !strings.Contains(out, want) {
t.Errorf("unlinked page missing %q", want)
}
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
<span>Previous</span>
</a>
}
<span class="chip">
<span class="text-sm" style="color: var(--text-secondary)">
Page { fmt.Sprintf("%d", currentPage) } of { fmt.Sprintf("%d", totalPages) }
</span>
if currentPage < totalPages {
+1 -1
View File
@@ -140,7 +140,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"chip\">Page ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"text-sm\" style=\"color: var(--text-secondary)\">Page ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+4 -4
View File
@@ -1,6 +1,8 @@
package templates
templ Setup() {
import "bookhoard/internal/database"
templ Setup(libraryTypes []database.LibraryTypes) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -211,9 +213,7 @@ templ Setup() {
x-model="newLibrary.type"
class="input mt-2"
>
<option value="ebooks">Ebooks</option>
<option value="comics">Comics</option>
<option value="manga">Manga</option>
@LibraryTypeOptions(libraryTypes)
</select>
</div>
<div>
+24 -14
View File
@@ -8,7 +8,9 @@ package templates
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Setup() templ.Component {
import "bookhoard/internal/database"
func Setup(libraryTypes []database.LibraryTypes) 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 {
@@ -85,7 +87,15 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</button></div></template></div><form @submit.prevent=\"addLibrary()\" class=\"space-y-4 card p-4\" style=\"background-color: var(--bg-primary);\"><div><label for=\"lib-name\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" id=\"lib-name\" x-model=\"newLibrary.name\" placeholder=\"My Ebook Library\" class=\"input mt-2\" required></div><div><label for=\"lib-type\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Library Type</label> <select id=\"lib-type\" x-model=\"newLibrary.type\" class=\"input mt-2\"><option value=\"ebooks\">Ebooks</option> <option value=\"comics\">Comics</option> <option value=\"manga\">Manga</option></select></div><div><label for=\"lib-desc\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Description (optional)</label> <input type=\"text\" id=\"lib-desc\" x-model=\"newLibrary.description\" placeholder=\"Optional\" class=\"input mt-2\"></div><button type=\"submit\" class=\"btn btn-secondary w-full\" :disabled=\"loading || !newLibrary.name.trim()\" x-text=\"loading ? 'Creating…' : '+ Add This Library'\"></button></form><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 1\" class=\"btn btn-secondary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</button></div></template></div><form @submit.prevent=\"addLibrary()\" class=\"space-y-4 card p-4\" style=\"background-color: var(--bg-primary);\"><div><label for=\"lib-name\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" id=\"lib-name\" x-model=\"newLibrary.name\" placeholder=\"My Ebook Library\" class=\"input mt-2\" required></div><div><label for=\"lib-type\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Library Type</label> <select id=\"lib-type\" x-model=\"newLibrary.type\" class=\"input mt-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = LibraryTypeOptions(libraryTypes).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</select></div><div><label for=\"lib-desc\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Description (optional)</label> <input type=\"text\" id=\"lib-desc\" x-model=\"newLibrary.description\" placeholder=\"Optional\" class=\"input mt-2\"></div><button type=\"submit\" class=\"btn btn-secondary w-full\" :disabled=\"loading || !newLibrary.name.trim()\" x-text=\"loading ? 'Creating…' : '+ Add This Library'\"></button></form><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 1\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -93,7 +103,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Back</button> <button type=\"button\" @click=\"currentStep = 3\" class=\"btn btn-primary\"><span x-text=\"libraries.length > 0 ? 'Next: Configure Folders' : 'Skip – Continue'\"></span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Back</button> <button type=\"button\" @click=\"currentStep = 3\" class=\"btn btn-primary\"><span x-text=\"libraries.length > 0 ? 'Next: Configure Folders' : 'Skip – Continue'\"></span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -101,7 +111,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</button></div></div><!-- ==================== STEP 3: Configure Folders ==================== --><div x-show=\"currentStep === 3\" class=\"card p-6\"><h2 class=\"text-2xl font-bold mb-2\" style=\"color: var(--text-primary)\">Configure Library Folders</h2><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Add the filesystem folders where your media files are stored.</p><div x-show=\"libraries.length === 0\" class=\"text-center py-8\" style=\"color: var(--text-secondary)\"><p>No libraries created. You can add folders later from the admin panel.</p></div><div class=\"space-y-6\"><template x-for=\"lib in libraries\" :key=\"lib.id\"><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><div class=\"flex items-center gap-2 mb-3\"><span class=\"grid place-items-center h-8 w-8 rounded-lg\" style=\"background-color: var(--accent-muted); color: var(--accent);\"><span class=\"inline-flex\" x-show=\"lib.type === 'ebooks'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</button></div></div><!-- ==================== STEP 3: Configure Folders ==================== --><div x-show=\"currentStep === 3\" class=\"card p-6\"><h2 class=\"text-2xl font-bold mb-2\" style=\"color: var(--text-primary)\">Configure Library Folders</h2><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Add the filesystem folders where your media files are stored.</p><div x-show=\"libraries.length === 0\" class=\"text-center py-8\" style=\"color: var(--text-secondary)\"><p>No libraries created. You can add folders later from the admin panel.</p></div><div class=\"space-y-6\"><template x-for=\"lib in libraries\" :key=\"lib.id\"><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><div class=\"flex items-center gap-2 mb-3\"><span class=\"grid place-items-center h-8 w-8 rounded-lg\" style=\"background-color: var(--accent-muted); color: var(--accent);\"><span class=\"inline-flex\" x-show=\"lib.type === 'ebooks'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -109,7 +119,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span> <span class=\"inline-flex\" x-show=\"lib.type === 'comics'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span> <span class=\"inline-flex\" x-show=\"lib.type === 'comics'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -117,7 +127,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span> <span class=\"inline-flex\" x-show=\"lib.type === 'manga'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> <span class=\"inline-flex\" x-show=\"lib.type === 'manga'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -125,7 +135,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span></span><h4 class=\"font-semibold\" style=\"color: var(--text-primary)\" x-text=\"lib.name\"></h4></div><div class=\"space-y-1 mb-3\" x-show=\"lib.folders && lib.folders.length > 0\"><template x-for=\"(folder, fIdx) in (lib.folders || [])\" :key=\"folder\"><div class=\"flex items-center justify-between p-2 rounded-lg text-sm\" style=\"background-color: var(--bg-secondary);\"><span class=\"flex items-center gap-2 truncate\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></span><h4 class=\"font-semibold\" style=\"color: var(--text-primary)\" x-text=\"lib.name\"></h4></div><div class=\"space-y-1 mb-3\" x-show=\"lib.folders && lib.folders.length > 0\"><template x-for=\"(folder, fIdx) in (lib.folders || [])\" :key=\"folder\"><div class=\"flex items-center justify-between p-2 rounded-lg text-sm\" style=\"background-color: var(--bg-secondary);\"><span class=\"flex items-center gap-2 truncate\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -133,7 +143,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"truncate\" x-text=\"folder\"></span></span> <button type=\"button\" @click=\"removeFolder(lib.id, folder, fIdx)\" class=\"icon-btn shrink-0\" style=\"color: var(--status-danger);\" aria-label=\"Remove folder\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<span class=\"truncate\" x-text=\"folder\"></span></span> <button type=\"button\" @click=\"removeFolder(lib.id, folder, fIdx)\" class=\"icon-btn shrink-0\" style=\"color: var(--status-danger);\" aria-label=\"Remove folder\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -141,7 +151,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button></div></template></div><div class=\"flex gap-2\"><input type=\"text\" :placeholder=\"'/path/to/' + lib.type\" :id=\"'folderInput-' + lib.id\" class=\"input flex-1 text-sm\"> <button type=\"button\" @click=\"showFolderBrowser('folderInput-' + lib.id)\" class=\"btn btn-secondary px-3 text-xs\">Browse</button> <button type=\"button\" @click=\"addFolderToLibrary(lib)\" class=\"btn btn-primary px-3 text-xs\">Add</button></div></div></template></div><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 2\" class=\"btn btn-secondary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div></template></div><div class=\"flex gap-2\"><input type=\"text\" :placeholder=\"'/path/to/' + lib.type\" :id=\"'folderInput-' + lib.id\" class=\"input flex-1 text-sm\"> <button type=\"button\" @click=\"showFolderBrowser('folderInput-' + lib.id)\" class=\"btn btn-secondary px-3 text-xs\">Browse</button> <button type=\"button\" @click=\"addFolderToLibrary(lib)\" class=\"btn btn-primary px-3 text-xs\">Add</button></div></div></template></div><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 2\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -149,7 +159,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Back</button> <button type=\"button\" @click=\"currentStep = 4\" class=\"btn btn-primary\">Next: Scan")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Back</button> <button type=\"button\" @click=\"currentStep = 4\" class=\"btn btn-primary\">Next: Scan")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -157,7 +167,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button></div></div><!-- ==================== STEP 4: Scan & Finish ==================== --><div x-show=\"currentStep === 4\" class=\"card p-6\"><h2 class=\"text-2xl font-bold mb-2\" style=\"color: var(--text-primary)\">Scan & Finish</h2><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Review your configuration and start the initial scan.</p><div class=\"space-y-3 mb-6\"><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Server URL</p><p style=\"color: var(--text-primary)\" x-text=\"admin.baseUrl\"></p></div><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Administrator</p><p style=\"color: var(--text-primary)\" x-text=\"admin.username + ' (' + admin.email + ')'\"></p></div><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Libraries (<span x-text=\"libraries.length\"></span>)</p><template x-for=\"lib in libraries\" :key=\"lib.id\"><div class=\"flex items-start justify-between text-sm py-1\"><div class=\"flex items-center gap-2\"><span style=\"color: var(--text-primary)\" x-text=\"lib.name\"></span> <span class=\"chip\" x-text=\"lib.type\"></span></div><div class=\"text-right\" style=\"color: var(--text-secondary)\"><span x-text=\"(lib.folders || []).length + ' folder(s)'\"></span></div></div></template></div></div><div x-show=\"scanStarted\" class=\"mb-6 space-y-3\"><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Scan Progress</h3><div id=\"library-progress-list\" class=\"space-y-3\"><template x-for=\"job in scanJobs\" :key=\"job.jobId\"><div class=\"card p-3\" style=\"background-color: var(--bg-primary);\"><div class=\"flex justify-between items-center mb-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\" x-text=\"job.libraryName\"></span> <span class=\"text-sm\" style=\"color: var(--text-secondary)\" x-text=\"job.statusText\"></span></div><div class=\"w-full rounded-full h-2\" style=\"background-color: var(--bg-secondary)\"><div class=\"h-2 rounded-full transition-all duration-500\" :style=\"'width: ' + job.progress + '%; background-color: var(--accent);'\"></div></div></div></template></div><div x-show=\"scanComplete\" class=\"card p-4 text-sm\" style=\"border-color: var(--status-success); background-color: color-mix(in srgb, var(--status-success) 12%, transparent); color: var(--status-success);\">Initial scan complete! Your libraries are ready to use.</div></div><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 3\" class=\"btn btn-secondary\" :disabled=\"loading\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</button></div></div><!-- ==================== STEP 4: Scan & Finish ==================== --><div x-show=\"currentStep === 4\" class=\"card p-6\"><h2 class=\"text-2xl font-bold mb-2\" style=\"color: var(--text-primary)\">Scan & Finish</h2><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Review your configuration and start the initial scan.</p><div class=\"space-y-3 mb-6\"><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Server URL</p><p style=\"color: var(--text-primary)\" x-text=\"admin.baseUrl\"></p></div><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Administrator</p><p style=\"color: var(--text-primary)\" x-text=\"admin.username + ' (' + admin.email + ')'\"></p></div><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Libraries (<span x-text=\"libraries.length\"></span>)</p><template x-for=\"lib in libraries\" :key=\"lib.id\"><div class=\"flex items-start justify-between text-sm py-1\"><div class=\"flex items-center gap-2\"><span style=\"color: var(--text-primary)\" x-text=\"lib.name\"></span> <span class=\"chip\" x-text=\"lib.type\"></span></div><div class=\"text-right\" style=\"color: var(--text-secondary)\"><span x-text=\"(lib.folders || []).length + ' folder(s)'\"></span></div></div></template></div></div><div x-show=\"scanStarted\" class=\"mb-6 space-y-3\"><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Scan Progress</h3><div id=\"library-progress-list\" class=\"space-y-3\"><template x-for=\"job in scanJobs\" :key=\"job.jobId\"><div class=\"card p-3\" style=\"background-color: var(--bg-primary);\"><div class=\"flex justify-between items-center mb-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\" x-text=\"job.libraryName\"></span> <span class=\"text-sm\" style=\"color: var(--text-secondary)\" x-text=\"job.statusText\"></span></div><div class=\"w-full rounded-full h-2\" style=\"background-color: var(--bg-secondary)\"><div class=\"h-2 rounded-full transition-all duration-500\" :style=\"'width: ' + job.progress + '%; background-color: var(--accent);'\"></div></div></div></template></div><div x-show=\"scanComplete\" class=\"card p-4 text-sm\" style=\"border-color: var(--status-success); background-color: color-mix(in srgb, var(--status-success) 12%, transparent); color: var(--status-success);\">Initial scan complete! Your libraries are ready to use.</div></div><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 3\" class=\"btn btn-secondary\" :disabled=\"loading\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -165,7 +175,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "Back</button><div class=\"flex gap-3\"><button x-show=\"!scanStarted\" type=\"button\" @click=\"startScan()\" class=\"btn btn-primary\" :disabled=\"loading\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Back</button><div class=\"flex gap-3\"><button x-show=\"!scanStarted\" type=\"button\" @click=\"startScan()\" class=\"btn btn-primary\" :disabled=\"loading\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -173,7 +183,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Start Scan</button> <button x-show=\"scanStarted && !scanComplete\" type=\"button\" @click=\"finishSetup()\" class=\"btn btn-secondary\" :disabled=\"loading\">Skip to Dashboard</button> <button x-show=\"scanComplete\" type=\"button\" @click=\"finishSetup()\" class=\"btn btn-primary\" :disabled=\"loading\" x-text=\"loading ? 'Finishing…' : 'Go to Dashboard'\"></button></div></div></div></div><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" @click=\"hideFolderBrowser()\" class=\"icon-btn\" aria-label=\"Close\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Start Scan</button> <button x-show=\"scanStarted && !scanComplete\" type=\"button\" @click=\"finishSetup()\" class=\"btn btn-secondary\" :disabled=\"loading\">Skip to Dashboard</button> <button x-show=\"scanComplete\" type=\"button\" @click=\"finishSetup()\" class=\"btn btn-primary\" :disabled=\"loading\" x-text=\"loading ? 'Finishing…' : 'Go to Dashboard'\"></button></div></div></div></div><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" @click=\"hideFolderBrowser()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -181,7 +191,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</button></div><div id=\"folder-browser-content\"></div></div></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</button></div><div id=\"folder-browser-content\"></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+48
View File
@@ -1,5 +1,53 @@
package templates
import (
"fmt"
"bookhoard/internal/services"
)
// UnlinkedMatchCards renders the match candidates for one device file's
// "find matches" reveal — the /unlinked-books/matches partial response.
// Cards are clicked through the delegated data-action handler (rendering
// ownership: templ renders, JS only swaps and handles the click).
templ UnlinkedMatchCards(progressId string, matches []services.BookMatch) {
if len(matches) == 0 {
<p class="text-sm text-center p-4" style="color: var(--text-secondary)">No matches found. Try manual linking.</p>
} else {
for _, match := range matches {
<div class="card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-primary); border-color: var(--border);"
data-action="auto-link"
data-progress-id={ progressId }
data-media-item-id={ match.MediaItemID.String() }
data-confidence={ fmt.Sprintf("%v", match.Confidence) }
>
<div class="flex gap-4">
<img
src={ match.CoverImagePath }
alt="Cover"
class="w-16 h-24 object-cover rounded"
onerror="this.src='/static/placeholder-book.svg'"
/>
<div>
<h5 class="font-semibold" style="color: var(--text-primary)">{ match.Title }</h5>
if match.Author != "" {
<p class="text-sm" style="color: var(--text-secondary)">by { match.Author }</p>
}
<div class="mt-2 flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary);">
{ fmt.Sprintf("%.0f%% confidence", match.Confidence*100) }
</span>
<span class="text-xs" style="color: var(--text-secondary)">{ match.MatchMethod }</span>
</div>
</div>
</div>
</div>
}
}
}
templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
<!DOCTYPE html>
<html lang="en">
+262 -100
View File
@@ -8,7 +8,17 @@ package templates
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component {
import (
"fmt"
"bookhoard/internal/services"
)
// UnlinkedMatchCards renders the match candidates for one device file's
// "find matches" reveal — the /unlinked-books/matches partial response.
// Cards are clicked through the delegated data-action handler (rendering
// ownership: templ renders, JS only swaps and handles the click).
func UnlinkedMatchCards(progressId string, matches []services.BookMatch) 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 {
@@ -29,29 +39,181 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Unlinked Books - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if len(matches) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<p class=\"text-sm text-center p-4\" style=\"color: var(--text-secondary)\">No matches found. Try manual linking.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
for _, match := range matches {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow\" style=\"background-color: var(--bg-primary); border-color: var(--border);\" data-action=\"auto-link\" data-progress-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(progressId)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 22, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" data-media-item-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(match.MediaItemID.String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 23, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" data-confidence=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", match.Confidence))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 24, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><div class=\"flex gap-4\"><img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(match.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 28, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" alt=\"Cover\" class=\"w-16 h-24 object-cover rounded\" onerror=\"this.src='/static/placeholder-book.svg'\"><div><h5 class=\"font-semibold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(match.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 34, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</h5>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if match.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">by ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(match.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 36, Col: 80}
}
_, 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, 9, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"mt-2 flex items-center gap-2\"><span class=\"text-xs px-2 py-1 rounded\" style=\"background-color: var(--accent); color: var(--bg-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%% confidence", match.Confidence*100))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 40, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(match.MatchMethod)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 42, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
return nil
})
}
func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) 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)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Unlinked Books - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><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_Var2 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
var templ_7745c5c3_Var11 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<body class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" x-data=\"unlinkedBooks\" x-init=\"setupEventDelegation()\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" x-data=\"unlinkedBooks\" x-init=\"setupEventDelegation()\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -59,7 +221,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><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
}
@@ -67,12 +229,12 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Unlinked Books</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve books that couldn't be automatically matched between devices</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Unlinked Books</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve books that couldn't be automatically matched between devices</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(unlinkedBooks) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"card p-4 mb-6 flex flex-wrap justify-between items-center gap-3\"><div class=\"flex items-center gap-4\"><input type=\"checkbox\" id=\"select-all-unlinked\" @change=\"toggleAllUnlinked()\" class=\"w-5 h-5\"> <label for=\"select-all-unlinked\" class=\"cursor-pointer text-sm font-medium\" style=\"color: var(--text-primary)\">Select All</label> <span id=\"selected-count\" class=\"text-sm\" style=\"color: var(--text-secondary)\">0 selected</span></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"bulkAutoLink()\" class=\"btn btn-secondary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"card p-4 mb-6 flex flex-wrap justify-between items-center gap-3\"><div class=\"flex items-center gap-4\"><input type=\"checkbox\" id=\"select-all-unlinked\" @change=\"toggleAllUnlinked()\" class=\"w-5 h-5\"> <label for=\"select-all-unlinked\" class=\"cursor-pointer text-sm font-medium\" style=\"color: var(--text-primary)\">Select All</label> <span id=\"selected-count\" class=\"text-sm\" style=\"color: var(--text-secondary)\">0 selected</span></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"bulkAutoLink()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -80,7 +242,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Auto-Link Selected (High Confidence)</button> <button @click=\"bulkGetSuggestions()\" class=\"btn btn-secondary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Auto-Link Selected (High Confidence)</button> <button @click=\"bulkGetSuggestions()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -88,7 +250,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Get Suggestions for Selected</button> <button @click=\"showBulkManualLink()\" class=\"btn btn-primary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Get Suggestions for Selected</button> <button @click=\"showBulkManualLink()\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -96,17 +258,17 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Bulk Manual Link</button></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "Bulk Manual Link</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div id=\"unlinked-container\" class=\"space-y-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div id=\"unlinked-container\" class=\"space-y-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(unlinkedBooks) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -114,39 +276,39 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">All Books Linked!</h3><p style=\"color: var(--text-secondary)\">There are no unlinked books that need manual resolution.</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">All Books Linked!</h3><p style=\"color: var(--text-secondary)\">There are no unlinked books that need manual resolution.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, book := range unlinkedBooks {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card p-6\"><div class=\"flex gap-6\"><div class=\"flex items-start pt-2\"><input type=\"checkbox\" class=\"unlinked-checkbox w-5 h-5\" data-progress-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"card p-6\"><div class=\"flex gap-6\"><div class=\"flex items-start pt-2\"><input type=\"checkbox\" class=\"unlinked-checkbox w-5 h-5\" data-progress-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID)
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 63, Col: 100}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 111, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" data-title=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" data-title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice)
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 63, Col: 136}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 111, Col: 136}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
_, 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, 15, "\" onchange=\"updateSelectedCount()\"></div><div class=\"flex-1 min-w-0\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" onchange=\"updateSelectedCount()\"></div><div class=\"flex-1 min-w-0\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -166,46 +328,46 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span><div class=\"min-w-0\"><h3 class=\"font-semibold text-lg truncate\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span><div class=\"min-w-0\"><h3 class=\"font-semibold text-lg truncate\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice)
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 77, Col: 111}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 125, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Device: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Device: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName)
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 79, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 127, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
_, 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, 18, " (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType)
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 79, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 127, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
_, 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, 19, ")</p></div></div><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">File Path:</span> <code class=\"chip font-mono\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ")</p></div></div><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">File Path:</span> <code class=\"chip font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -213,101 +375,101 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath)
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 88, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 136, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
_, 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, 20, "</code></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.SHA256 != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">SHA-256:</span> <code class=\"chip font-mono text-xs\" title=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">SHA-256:</span> <code class=\"chip font-mono text-xs\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256)
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 94, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 142, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256)
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 94, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 142, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
_, 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, 23, "</code></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Last Synced:</span> <span style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Last Synced:</span> <span style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime)
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 99, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 147, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
_, 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, 25, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.ConfidenceScore > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Auto-Link Confidence:</span> <span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Auto-Link Confidence:</span> <span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore)
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 105, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 153, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
_, 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, 27, "% confidence</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "% confidence</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div class=\"mb-4 flex gap-2 flex-wrap\"><button @click=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div><div class=\"mb-4 flex gap-2 flex-wrap\"><button @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")")
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 112, Col: 138}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 160, Col: 138}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"btn btn-secondary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -315,20 +477,20 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Find Potential Matches</button> <button @click=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "Find Potential Matches</button> <button @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")")
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 119, Col: 114}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 167, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
_, 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, 31, "\" class=\"btn btn-primary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -336,38 +498,38 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Manually Link</button></div><div id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "Manually Link</button></div><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-" + book.ProgressID)
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 126, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 174, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"hidden\"><div class=\"border-t pt-4 mt-2\" style=\"border-color: var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Potential Matches</h4><div id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" class=\"hidden\"><div class=\"border-t pt-4 mt-2\" style=\"border-color: var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Potential Matches</h4><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-list-" + book.ProgressID)
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-list-" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 129, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 177, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
_, 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, 34, "\" class=\"space-y-3\"><p style=\"color: var(--text-secondary)\">Click \"Find Potential Matches\" to search</p></div></div></div></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"space-y-3\"><p style=\"color: var(--text-secondary)\">Click \"Find Potential Matches\" to search</p></div></div></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div></main><div id=\"manual-link-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Manually Link Book</h2><button @click=\"hideManualLinkModal()\" class=\"icon-btn\" aria-label=\"Close\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div></main><div id=\"manual-link-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Manually Link Book</h2><button @click=\"hideManualLinkModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -375,7 +537,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</button></div><input type=\"hidden\" id=\"link-progress-id\"> <input type=\"hidden\" id=\"link-book-sha256\"><div class=\"mb-4\"><label for=\"link-book-title\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Book Title from Device</label> <input type=\"text\" id=\"link-book-title\" readonly class=\"input\" style=\"color: var(--text-secondary);\"></div><div class=\"mb-4\"><label for=\"link-search-input\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search for Book to Link</label><div class=\"flex gap-2\"><input type=\"text\" id=\"link-search-input\" placeholder=\"Search by title, author…\" class=\"input flex-1\"> <button type=\"button\" @click=\"searchBooksForLink()\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</button></div><input type=\"hidden\" id=\"link-progress-id\"> <input type=\"hidden\" id=\"link-book-sha256\"><div class=\"mb-4\"><label for=\"link-book-title\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Book Title from Device</label> <input type=\"text\" id=\"link-book-title\" readonly class=\"input\" style=\"color: var(--text-secondary);\"></div><div class=\"mb-4\"><label for=\"link-search-input\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search for Book to Link</label><div class=\"flex gap-2\"><input type=\"text\" id=\"link-search-input\" placeholder=\"Search by title, author…\" class=\"input flex-1\"> <button type=\"button\" @click=\"searchBooksForLink()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -383,7 +545,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Search</button></div></div><div id=\"link-search-results\" class=\"mb-4 max-h-48 overflow-y-auto space-y-2\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Search for books to link</p></div><div class=\"mb-4\"><label for=\"link-confidence\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Confidence Score</label> <input type=\"number\" id=\"link-confidence\" min=\"0\" max=\"1\" step=\"0.1\" value=\"1.0\" class=\"input\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">1.0 = manual override (highest confidence)</p></div><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"hideManualLinkModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"confirmManualLink()\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "Search</button></div></div><div id=\"link-search-results\" class=\"mb-4 max-h-48 overflow-y-auto space-y-2\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Search for books to link</p></div><div class=\"mb-4\"><label for=\"link-confidence\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Confidence Score</label> <input type=\"number\" id=\"link-confidence\" min=\"0\" max=\"1\" step=\"0.1\" value=\"1.0\" class=\"input\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">1.0 = manual override (highest confidence)</p></div><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"hideManualLinkModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"confirmManualLink()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -391,7 +553,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Link Book</button></div></div></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "Link Book</button></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+58
View File
@@ -409,3 +409,61 @@ func badgeLabel(badge string) string {
}
return ""
}
// Library type dropdown helpers. The option list comes from the DB-backed
// library_types table (the same source the create API resolves types
// against), so a new type appears in the UI without template edits.
// libraryTypeDisplayOrder pins the long-standing library types to their
// historical dropdown order.
var libraryTypeDisplayOrder = map[string]int{
"ebooks": 0,
"comics": 1,
"manga": 2,
"audiobooks": 3,
}
const libraryTypeRankUnknown = 1 << 30
func libraryTypeRank(name string) int {
if rank, ok := libraryTypeDisplayOrder[name]; ok {
return rank
}
return libraryTypeRankUnknown
}
// OrderedLibraryTypes sorts library types for a create-library dropdown:
// the original types keep their UI order, any newer types follow by name.
func OrderedLibraryTypes(types []database.LibraryTypes) []database.LibraryTypes {
sorted := make([]database.LibraryTypes, len(types))
copy(sorted, types)
sort.SliceStable(sorted, func(i, j int) bool {
ri, rj := libraryTypeRank(sorted[i].Name), libraryTypeRank(sorted[j].Name)
if ri != rj {
return ri < rj
}
return sorted[i].Name < sorted[j].Name
})
return sorted
}
// LibraryTypeDisplayName renders a library_types.name for the UI
// ("audiobooks" → "Audiobooks"); unknown names just get their first letter
// capitalized so a future type still reads sensibly.
func LibraryTypeDisplayName(name string) string {
switch name {
case "ebooks":
return "Ebooks"
case "comics":
return "Comics"
case "manga":
return "Manga"
case "audiobooks":
return "Audiobooks"
}
if name == "" {
return name
}
r := []rune(name)
return strings.ToUpper(string(r[0])) + string(r[1:])
}
+42 -1
View File
@@ -1,6 +1,10 @@
package templates
import "testing"
import (
"testing"
"bookhoard/internal/database"
)
func TestGroupTunableSettings(t *testing.T) {
entries := []SettingEntry{
@@ -54,3 +58,40 @@ func TestThemeCheckClass(t *testing.T) {
t.Errorf("inactive theme should be hidden, got %q", got)
}
}
// The library-type dropdown renders from DB rows; ordering and labels are
// presentation policy (OrderedLibraryTypes / LibraryTypeDisplayName).
func TestOrderedLibraryTypes(t *testing.T) {
// The DB query returns ORDER BY name; the dropdown must not reshuffle
// the historical UI order, and unknown (future) types append by name.
types := []database.LibraryTypes{
{Name: "audiobooks"}, {Name: "comics"}, {Name: "ebooks"},
{Name: "manga"}, {Name: "videos"},
}
got := OrderedLibraryTypes(types)
want := []string{"ebooks", "comics", "manga", "audiobooks", "videos"}
if len(got) != len(want) {
t.Fatalf("got %d types, want %d", len(got), len(want))
}
for i, name := range want {
if got[i].Name != name {
t.Errorf("position %d = %q, want %q", i, got[i].Name, name)
}
}
}
func TestLibraryTypeDisplayName(t *testing.T) {
cases := map[string]string{
"ebooks": "Ebooks",
"comics": "Comics",
"manga": "Manga",
"audiobooks": "Audiobooks",
"videos": "Videos", // unknown type: capitalized fallback
"": "",
}
for name, want := range cases {
if got := LibraryTypeDisplayName(name); got != want {
t.Errorf("LibraryTypeDisplayName(%q) = %q, want %q", name, got, want)
}
}
}
+202 -6
View File
@@ -80,8 +80,14 @@ function showScanProgress(
<span class="font-medium" style="color: var(--text-primary)">
${libraryNames[jobId]}
</span>
<span id="status-${jobId}" class="text-sm" style="color: var(--text-secondary)">
Pending...
<span class="flex items-center gap-2">
<span id="status-${jobId}" class="text-sm" style="color: var(--text-secondary)">
Pending...
</span>
<button data-cancel-job="${jobId}"
class="text-xs px-2 py-0.5 rounded border shrink-0"
style="color: var(--text-secondary); border-color: var(--border);"
title="Cancel this scan">Cancel</button>
</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-2">
@@ -95,6 +101,13 @@ function showScanProgress(
)
.join("");
// The rows are TS-rendered (sanctioned scan-progress island); bind the
// cancel buttons here since inline handlers can't reach module scope.
list.querySelectorAll<HTMLButtonElement>("[data-cancel-job]").forEach((btn) => {
btn.addEventListener("click", () => cancelScan(btn.dataset.cancelJob || ""));
});
setCancelAllVisible(true);
pollScanProgress(jobIds, libraryNames);
}
@@ -106,9 +119,12 @@ function pollScanProgress(
): void {
const token = localStorage.getItem("token");
const startTime = Date.now();
const cancelledJobs = new Set<string>();
scanPollInterval = setInterval(async () => {
let allComplete = true;
let totalProgress = 0;
let progressJobs = 0;
let totalFiles = 0;
let totalNewItems = 0;
let totalErrors = 0;
@@ -124,12 +140,25 @@ function pollScanProgress(
updateLibraryProgress(jobId, status);
totalProgress += status.progress || 0;
totalFiles += status.files_scanned || 0;
totalNewItems += status.new_items || 0;
totalErrors += status.errors || 0;
if (status.status !== "completed" && status.status !== "failed") {
// Cancelled jobs are terminal and keep their last real
// progress; average only jobs that can still advance so the
// bar can reach 100%.
if (status.status === "cancelled") {
cancelledJobs.add(jobId);
} else {
totalProgress += status.progress || 0;
progressJobs++;
}
if (
status.status !== "completed" &&
status.status !== "failed" &&
status.status !== "cancelled"
) {
allComplete = false;
}
}
@@ -139,7 +168,8 @@ function pollScanProgress(
}
// status.progress arrives 0-1 from the server; render as percent.
const overallProgress = Math.round((totalProgress / jobIds.length) * 100);
const overallProgress =
progressJobs > 0 ? Math.round((totalProgress / progressJobs) * 100) : 0;
const progressBar = document.getElementById(
"scan-progress-bar",
) as HTMLElement;
@@ -158,12 +188,14 @@ function pollScanProgress(
if (allComplete) {
clearInterval(scanPollInterval!);
setCancelAllVisible(false);
showScanResults(
jobIds.length,
totalFiles,
totalNewItems,
totalErrors,
elapsed,
cancelledJobs.size,
);
}
}, 2000);
@@ -187,9 +219,88 @@ function updateLibraryProgress(jobId: string, status: any): void {
running: `Scanning... ${percent}%`,
completed: `✓ Complete (${status.new_items || 0} items)`,
failed: `✗ Failed`,
cancelled: `✗ Cancelled`,
};
statusText.textContent = statusMessages[status.status] || status.status;
}
// Terminal jobs no longer need their per-row cancel button.
if (
status.status === "completed" ||
status.status === "failed" ||
status.status === "cancelled"
) {
document.querySelector(`[data-cancel-job="${jobId}"]`)?.remove();
}
}
// Cancel one scan job (POST /api/scanner/cancel/:jobId). The server
// aborts the walk at its next context check and skips the archive pass,
// so cancelling never archives unvisited files.
async function cancelScan(jobId: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
const btn = document.querySelector<HTMLButtonElement>(
`[data-cancel-job="${jobId}"]`,
);
if (btn) {
btn.disabled = true;
btn.textContent = "Cancelling...";
}
try {
const resp = await fetch(`/api/scanner/cancel/${jobId}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to cancel scan");
}
showToast("Scan cancellation requested", "success");
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to cancel scan",
"error",
);
if (btn) {
btn.disabled = false;
btn.textContent = "Cancel";
}
}
}
// Cancel every active scan (admin): resolves the server's active job
// list so it also covers jobs this page never started (e.g. started
// before a refresh), then cancels each one.
async function cancelAllScans(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const resp = await fetch("/api/scanner/active", {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) {
throw new Error("Failed to list active scans");
}
const data = await resp.json();
const jobs = (data.jobs || []) as Array<{ job_id: string }>;
if (jobs.length === 0) return;
await Promise.allSettled(jobs.map((job) => cancelScan(job.job_id)));
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to cancel scans",
"error",
);
}
}
function setCancelAllVisible(visible: boolean): void {
const btn = document.getElementById("scan-cancel-all-btn");
if (btn) btn.classList.toggle("hidden", !visible);
}
// On page load: if a scan is already running (started before this page
@@ -247,6 +358,7 @@ function showScanResults(
items: number,
errors: number,
elapsed: number,
cancelled: number,
): void {
const resultsDiv = document.getElementById("scan-results") as HTMLElement;
const contentDiv = document.getElementById(
@@ -259,6 +371,7 @@ function showScanResults(
<p>• ${libCount} librar${libCount === 1 ? "y" : "ies"} scanned</p>
<p>• ${files} files processed</p>
<p>• ${items} new items added</p>
${cancelled > 0 ? `<p>• ${cancelled} scan${cancelled === 1 ? "" : "s"} cancelled</p>` : ""}
${errors > 0 ? `<p style="color: var(--accent);">• ${errors} errors</p>` : ""}
<p style="color: var(--text-secondary)">Completed in ${elapsed} seconds</p>
`;
@@ -266,7 +379,12 @@ function showScanResults(
resultsDiv.classList.remove("hidden");
const statusText = document.getElementById("scan-status") as HTMLElement;
if (statusText) statusText.textContent = "Scan complete!";
if (statusText) {
statusText.textContent =
cancelled > 0
? "Scan finished (some scans were cancelled)"
: "Scan complete!";
}
}
function hideScanProgress(): void {
@@ -274,6 +392,7 @@ function hideScanProgress(): void {
"scan-progress-container",
) as HTMLElement;
if (container) container.classList.add("hidden");
setCancelAllVisible(false);
}
async function loadWatchStatus(): Promise<void> {
@@ -450,10 +569,87 @@ export {
stopScanStatusPolling,
};
// Library admin page helpers (rendering-ownership: these orchestrate htmx
// swaps and modal visibility on server-rendered DOM; they render nothing).
function toggleLibraryPanel(btn: HTMLElement): void {
const libId = btn.dataset.libId || "";
const panel = document.getElementById(`library-panel-${libId}`);
if (!panel) return;
if (panel.innerHTML.trim() !== "") {
panel.innerHTML = "";
} else {
window.htmx.ajax("GET", `/admin/library/${libId}/panel`, {
target: `#library-panel-${libId}`,
swap: "innerHTML",
});
}
}
function openEditModal(id: string, name: string, description: string): void {
const form = document.getElementById("edit-library-form");
if (!form) return;
form.setAttribute("hx-put", `/admin/library/${id}`);
const nameInput = document.getElementById("edit-library-name");
if (nameInput) (nameInput as HTMLInputElement).value = name;
const descInput = document.getElementById("edit-library-desc");
if (descInput) (descInput as HTMLInputElement).value = description;
window.htmx.process(form);
document.getElementById("edit-library-modal")?.classList.remove("hidden");
}
function openDeleteModal(id: string, name: string): void {
const btn = document.getElementById("delete-library-confirm");
if (!btn) return;
btn.setAttribute("hx-delete", `/admin/library/${id}`);
btn.setAttribute("hx-target", "#libraries-container");
btn.setAttribute("hx-swap", "innerHTML");
const nameEl = document.getElementById("delete-library-name");
if (nameEl) nameEl.textContent = name;
window.htmx.process(btn);
document.getElementById("delete-library-modal")?.classList.remove("hidden");
}
function openFolderBrowser(targetInputId: string, libraryId: string): void {
const content = document.getElementById("folder-browser-content");
if (!content) return;
content.setAttribute("hx-get", "/admin/library/browse");
content.setAttribute(
"hx-vals",
JSON.stringify({ target_input: targetInputId, library_id: libraryId }),
);
content.setAttribute("hx-trigger", "load");
window.htmx.process(content);
document.getElementById("folder-browser-modal")?.classList.remove("hidden");
}
// Close the delete-library modal after a successful delete: the htmx swap
// already removes the deleted row from the libraries list, but the modal
// lives outside the swapped container and would otherwise stay open.
function bindDeleteLibraryModalClose(): void {
document
.getElementById("delete-library-confirm")
?.addEventListener("htmx:afterRequest", function (evt) {
const detail = (evt as CustomEvent).detail as { successful?: boolean };
if (detail.successful) {
document
.getElementById("delete-library-modal")
?.classList.add("hidden");
}
});
}
Alpine.data("admin", () => ({
init() {
bindDeleteLibraryModalClose();
},
cancelAllScans,
checkActiveScans,
hideScanProgress,
loadWatchStatus,
scanAllLibraries,
stopScanStatusPolling,
openDeleteModal,
openEditModal,
openFolderBrowser,
toggleLibraryPanel,
}));
+131 -11
View File
@@ -18,6 +18,36 @@ import { showToast } from "./toast";
type AudioChapter = { index: number; title: string; start: number; end: number };
// One playable file of an audiobook. Multi-file books (folder/zip
// layouts) carry several; single-file books carry exactly one spanning
// the whole file. start/end are GLOBAL seconds — the same coordinate
// system as chapters, bookmarks and audio_offset_seconds.
export type AudioTrack = { url: string; start: number; end: number; title?: string };
// trackIndexFor finds the track holding a global position: the last one
// whose start is at or before it (zero-duration/unknown-end tracks keep
// working because only start matters for membership).
export function trackIndexFor(tracks: AudioTrack[], globalSeconds: number): number {
for (let i = tracks.length - 1; i > 0; i -= 1) {
if (globalSeconds >= tracks[i].start) return i;
}
return 0;
}
// localOffsetFor converts a global position to seconds within track i.
export function localOffsetFor(tracks: AudioTrack[], index: number, globalSeconds: number): number {
const track = tracks[index];
if (!track) return Math.max(0, globalSeconds);
return Math.max(0, globalSeconds - track.start);
}
// globalSecondsFor converts (track i, local seconds) back to the global
// position — the inverse of localOffsetFor.
export function globalSecondsFor(tracks: AudioTrack[], index: number, localSeconds: number): number {
const track = tracks[index];
return (track ? track.start : 0) + Math.max(0, localSeconds);
}
const PROGRESS_INTERVAL_MS = 30_000;
interface AudioConfig {
@@ -57,6 +87,17 @@ Alpine.data("audioReader", () => ({
scrubbing: false,
scrubValue: 0,
// Track manifest: one AudioTrack per playable file. Single-file books
// (m4b/mp3) carry exactly one track whose URL is the whole file, so
// every code path below can treat playback as (track, local seconds)
// and map through globalSecondsFor/localOffsetFor.
fileUrl: "",
tracks: [] as AudioTrack[],
activeTrack: 0,
// Local seconds to apply to the <audio> element once the freshly
// switched track's metadata loads (src swaps reset currentTime).
pendingSeek: null as number | null,
// Bookmarks (A2d): time-positioned, with notes. Audio locator
// convention: position = "audio:<seconds>" (chapter_number filled for
// display); dedup + cross-device sync happen server-side.
@@ -105,17 +146,19 @@ Alpine.data("audioReader", () => ({
document.title = "REJ: " + (e.reason?.message || String(e.reason));
});
const body = document.body as HTMLElement;
const config = readConfig();
const mediaId = body.getAttribute("data-media-item-id") || "";
const title = body.getAttribute("data-title") || "";
const cover = body.getAttribute("data-cover") || "";
const fileUrl = body.getAttribute("data-file-url") || "";
this.mediaItemId = mediaId;
this.title = title;
this.fileUrl = fileUrl;
const audio = this.audio();
if (!audio) return;
audio.src = fileUrl;
// src is set by restorePosition once the track manifest is known —
// for multi-file books data-file-url (a directory or a zip root) is
// NOT directly playable. The catch branch below falls back to it.
audio.playbackRate = Number(this.speed) || 1;
audio.preload = "auto";
// Restored volume/mute from the settings strip's persisted state.
@@ -145,8 +188,17 @@ Alpine.data("audioReader", () => ({
}
// Element events.
audio.addEventListener("loadedmetadata", () => {
// A freshly switched track applies its queued position once the
// browser knows the duration; same-track seeks set currentTime
// directly in setActiveTrack.
if (this.pendingSeek != null) {
audio.currentTime = Math.max(0, this.pendingSeek);
this.pendingSeek = null;
}
});
audio.addEventListener("timeupdate", () => {
if (!this.scrubbing) this.currentTime = audio.currentTime;
if (!this.scrubbing) this.currentTime = this.globalPosition();
this.tickChapter();
// Honest session accounting: only ACTUAL PLAYING time counts. The
// prevSnapshot walk keeps internal seeks/crossfades from inflating
@@ -158,6 +210,13 @@ Alpine.data("audioReader", () => ({
}
this.prevTick = now;
});
audio.addEventListener("ended", () => {
// Natural end of track n: roll into n+1 and keep playing. At the
// last track the book is done — nothing to advance into.
if (this.activeTrack < this.tracks.length - 1) {
this.setActiveTrack(this.activeTrack + 1, 0, true);
}
});
audio.addEventListener("play", () => {
this.playing = true;
this.prevTick = performance.now();
@@ -199,6 +258,53 @@ Alpine.data("audioReader", () => ({
return document.getElementById("audio-element") as HTMLAudioElement | null;
},
// ---- track manifest (multi-file books) ----
buildTracks(data: Record<string, unknown>) {
const raw = (data.tracks || []) as Array<Record<string, unknown>>;
if (raw.length > 0) {
this.tracks = raw.map((t) => ({
url: String(t.url || ""),
start: Number(t.start) || 0,
end: Number(t.end) || 0,
title: t.title ? String(t.title) : undefined,
}));
} else {
// Single-file book: the whole file is the one track. end stays 0
// (unknown) — the browser's own duration governs the element and
// membership never needs it for a single track.
this.tracks = [{ url: this.fileUrl, start: 0, end: 0 }];
}
this.activeTrack = 0;
},
globalPosition(): number {
const audio = this.audio();
if (!audio) return this.currentTime;
return globalSecondsFor(this.tracks, this.activeTrack, audio.currentTime);
},
// setActiveTrack points the <audio> element at track i at localSeconds.
// Same-track moves set currentTime directly; a src swap queues the
// position for loadedmetadata (setting currentTime before the new
// metadata loads throws or silently resets).
setActiveTrack(index: number, localSeconds: number, resume: boolean) {
const audio = this.audio();
if (!audio || index < 0 || index >= this.tracks.length) return;
const track = this.tracks[index];
this.activeTrack = index;
if (audio.getAttribute("src") === track.url) {
this.pendingSeek = null;
audio.currentTime = Math.max(0, localSeconds);
} else {
this.pendingSeek = Math.max(0, localSeconds);
audio.setAttribute("src", track.url);
audio.playbackRate = Number(this.speed) || 1;
audio.load();
}
if (resume) audio.play().catch(() => showToast("Playback failed to start", "error"));
},
async restorePosition() {
try {
const [chaptersResp, progress] = await Promise.all([
@@ -212,6 +318,11 @@ Alpine.data("audioReader", () => ({
const data = await chaptersResp.json();
this.chapters = (data.chapters || []) as AudioChapter[];
this.bookDuration = data.duration_seconds || this.chapterEndEstimate();
this.buildTracks(data);
const audio = this.audio();
if (audio && !audio.getAttribute("src") && this.tracks[0]) {
audio.setAttribute("src", this.tracks[0].url);
}
}
if (progress.ok) {
const p = await progress.json();
@@ -233,7 +344,13 @@ Alpine.data("audioReader", () => ({
this.tickChapter();
}
} catch {
// Offline start: still playable; sync resumes when reachable.
// Offline start: still playable from the plain file URL (single-
// file books only; multi-file books need the manifest).
const audio = this.audio();
if (audio && !audio.getAttribute("src") && this.fileUrl) {
this.tracks = [{ url: this.fileUrl, start: 0, end: 0 }];
audio.setAttribute("src", this.fileUrl);
}
}
},
@@ -282,14 +399,16 @@ Alpine.data("audioReader", () => ({
const audio = this.audio();
if (!audio) return;
const target = Number(seconds);
if (Number.isFinite(target)) audio.currentTime = Math.max(0, target);
this.currentTime = audio.currentTime;
if (!Number.isFinite(target)) return;
// Global seconds in: resolve the owning track and move within it.
const index = trackIndexFor(this.tracks, target);
this.setActiveTrack(index, localOffsetFor(this.tracks, index, target), false);
this.currentTime = target;
},
skip(direction: 1 | -1) {
const audio = this.audio();
if (!audio) return;
audio.currentTime = Math.max(0, audio.currentTime + direction * (Number(this.skipSeconds) || 10));
const target = Math.max(0, this.globalPosition() + direction * (Number(this.skipSeconds) || 10));
this.seekTo(target);
},
stepChapter(delta: 1 | -1) {
@@ -406,7 +525,7 @@ Alpine.data("audioReader", () => ({
async saveBookmark() {
const audio = this.audio();
if (!audio) return;
const at = Math.round(audio.currentTime);
const at = Math.round(this.globalPosition());
const chapter = this.chapters[this.currentChapterIdx];
const title = chapter
? `${chapter.title || `Chapter ${chapter.index + 1}`} — ${fmtTime(at)}`
@@ -502,7 +621,8 @@ Alpine.data("audioReader", () => ({
if (this.chaptersDuration() <= 0) return;
const audio = this.audio();
if (!audio) return;
const at = audio.currentTime;
// Global book position, whatever track is physically playing.
const at = this.globalPosition();
let chapter = 0;
let offset = 0;
for (let i = 0; i < this.chapters.length; i += 1) {
+9 -68
View File
@@ -53,62 +53,6 @@ function initializeCollectionWebSocket(): void {
});
}
function renderCollectionBooks(books: BookInfo[]): void {
const container = document.getElementById("books-container");
if (!container) return;
const dataEl = document.getElementById("collection-data");
const isSystem = dataEl?.dataset.isSystem === "true";
// Smart collections are rule-managed: no hand-pick actions either way.
const canEditMembership =
!isSystem && dataEl?.dataset.isSmart !== "true";
if (books.length === 0) {
container.innerHTML = `<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>`;
return;
}
container.innerHTML = books
.map(
(book) => `
<div class="card p-4 rounded-2xl collection-book-card" data-title="${book.title}" data-author="${book.author || ""}" data-media-id="${book.media_item_id}">
<div class="flex gap-4">
${canEditMembership ? `
<div class="flex-shrink-0 pt-1">
<label class="flex items-center cursor-pointer p-2 -m-2">
<input type="checkbox" class="w-5 h-5"
:checked="selectedBooks.includes('${book.media_item_id}')"
@change="toggleSelection('${book.media_item_id}')"/>
</label>
</div>` : ""}
<div class="flex-1 min-w-0">
<a href="/media/${book.media_item_id}">
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">${book.title}</h3>
</a>
${book.author ? `<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">by ${book.author}</p>` : ""}
</div>
<div class="flex-shrink-0 w-16 sm:w-20">
<a href="/media/${book.media_item_id}">
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="Cover"
class="w-full aspect-[3/4] object-cover rounded shadow-md"
onerror="this.src='/static/placeholder-book.svg'"/>
</a>
</div>
</div>
${canEditMembership ? `
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button @click="requestRemoveBook('${book.media_item_id}')" class="btn btn-secondary w-full">
🗑️ Remove from Collection
</button>
</div>` : ""}
</div>
`,
)
.join("");
Alpine.initTree(container);
}
async function loadCollectionRules(collectionId: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
@@ -480,19 +424,16 @@ function initCollectionsPage(): void {
if (dataEl) dataEl.dataset.libraryId = libraryId;
await switchWithTransition("books-container", async () => {
const param = libraryId ? `?library_id=${libraryId}` : "";
const response = await fetch(
`/api/collections/${collId}${param}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
// Rendering ownership: the grid is server-rendered markup. Swap
// in the templ partial instead of re-rendering from JSON.
const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
await window.htmx.ajax(
"GET",
`/collections/${collId}/books-grid${param}`,
{ target: "#books-container", swap: "innerHTML" },
);
if (!response.ok) throw new Error("Failed to load collection");
const data = await response.json();
renderCollectionBooks(data.books || []);
const container = document.getElementById("books-container");
if (container) Alpine.initTree(container);
});
},
});
+94 -54
View File
@@ -2,24 +2,94 @@ import { Alpine } from "./alpine";
import { showToast } from "./toast";
async function refreshConflicts(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
// Rendering ownership: the list and stats are server-rendered markup.
// Swap in the templ partial (stats arrive as an out-of-band swap)
// instead of re-rendering cards from JSON.
try {
const response = await fetch("/api/conflicts", {
headers: { Authorization: `Bearer ${token}` },
await window.htmx.ajax("GET", "/conflicts/list", {
target: "#conflicts-list",
swap: "outerHTML",
});
if (response.ok) {
const data: ConflictListResponse = await response.json();
renderConflicts(data.conflicts);
updateConflictStats(data);
}
const list = document.getElementById("conflicts-list");
if (list) Alpine.initTree(list);
filterConflicts();
updateBulkActions();
} catch (error) {
console.error("Failed to refresh conflicts:", error);
}
}
// --- Client-side filter and bulk selection -------------------------------
// Pure functions over the server-rendered conflict cards (rendering
// ownership: JS decides what shows; templ rendered what they look like).
function filterConflicts(): void {
const select = document.getElementById(
"status-filter",
) as HTMLSelectElement | null;
const filter = select?.value ?? "unresolved";
let total = 0;
let visible = 0;
document.querySelectorAll<HTMLElement>(".conflict-item").forEach((item) => {
total++;
const status = item.dataset.status ?? "unresolved";
const show = filter === "all" || status === filter;
item.style.display = show ? "" : "none";
if (show) visible++;
});
const empty = document.getElementById("filter-empty");
if (empty) empty.classList.toggle("hidden", visible > 0 || total === 0);
// Cards hidden by the filter can't stay selected.
if (visible !== total) updateBulkActions();
}
function toggleAllConflicts(): void {
const selectAll = document.getElementById(
"select-all-conflicts",
) as HTMLInputElement | null;
if (!selectAll) return;
document.querySelectorAll<HTMLElement>(".conflict-item").forEach((item) => {
if (item.style.display === "none") return;
const box = item.querySelector<HTMLInputElement>(".conflict-checkbox");
if (box) box.checked = selectAll.checked;
});
updateBulkActions();
}
function getSelectedConflictIds(): string[] {
return Array.from(
document.querySelectorAll<HTMLInputElement>(".conflict-checkbox:checked"),
)
.map((box) => box.dataset.conflictId || "")
.filter((id) => id !== "");
}
function updateBulkActions(): void {
const selected = getSelectedConflictIds();
const bar = document.getElementById("bulk-actions");
if (bar) bar.classList.toggle("hidden", selected.length === 0);
const count = document.getElementById("selected-count");
if (count) count.textContent = String(selected.length);
}
function resolveSelected(): void {
const ids = getSelectedConflictIds();
if (ids.length === 0) return;
const strategyEl = document.getElementById(
"bulk-strategy",
) as HTMLSelectElement | null;
const strategy = (strategyEl?.value ?? "most_recent") as
| "most_recent"
| "highest_progress";
void bulkResolve(strategy, ids);
}
function dismissSelected(): void {
const ids = getSelectedConflictIds();
if (ids.length === 0) return;
void bulkDismiss(ids);
}
async function resolveConflict(
conflictId: string,
winner: string,
@@ -123,49 +193,6 @@ async function dismissAllResolved(): Promise<void> {
}
}
function renderConflicts(conflicts: ConflictDetailResponse[]): void {
const container = document.getElementById("conflicts-list");
if (!container) return;
if (conflicts.length === 0) {
container.innerHTML =
'<p class="text-center p-4" style="color: var(--text-secondary)">No conflicts found</p>';
return;
}
container.innerHTML = conflicts
.map(
(conflict) => `
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-start">
<div>
<h3 class="font-medium" style="color: var(--text-primary)">${conflict.media_item_title}</h3>
<p class="text-sm" style="color: var(--text-secondary)">${conflict.conflict_type} - ${conflict.resolution_status}</p>
</div>
${
conflict.resolution_status === "unresolved"
? `
<div class="flex space-x-2">
<button onclick="window.showResolveModal('${conflict.id}')" class="btn-primary px-3 py-1 rounded text-sm">Resolve</button>
</div>
`
: ""
}
</div>
</div>
`,
)
.join("");
}
function updateConflictStats(data: ConflictListResponse): void {
const totalEl = document.getElementById("conflicts-total");
const unresolvedEl = document.getElementById("conflicts-unresolved");
if (totalEl) totalEl.textContent = String(data.total);
if (unresolvedEl) unresolvedEl.textContent = String(data.unresolved);
}
function showResolveModal(conflictId: string): void {
const modal = document.getElementById("resolve-modal");
const conflictIdInput = document.getElementById(
@@ -214,9 +241,17 @@ export {
refreshConflicts,
resolveConflict,
showResolveModal,
filterConflicts,
toggleAllConflicts,
updateBulkActions,
resolveSelected,
dismissSelected,
};
Alpine.data("conflicts", () => ({
init() {
filterConflicts();
},
bulkDismiss,
bulkResolve,
dismissAllResolved,
@@ -225,4 +260,9 @@ Alpine.data("conflicts", () => ({
refreshConflicts,
resolveConflict,
showResolveModal,
filterConflicts,
toggleAllConflicts,
updateBulkActions,
resolveSelected,
dismissSelected,
}));
+32 -1
View File
@@ -435,6 +435,34 @@ function initDragAndDrop(): void {
});
}
// The week widget re-fetches itself when the body sees `week-refresh`
// (hx-trigger on #week-widget). Resolve once that swap has landed so the
// library switch fades in with the refreshed widget already in place.
// Safety timeout keeps a stalled request from holding the switch hostage,
// and a missing widget (stats disabled) resolves immediately.
function refreshWeekWidget(): Promise<void> {
return new Promise((resolve) => {
if (!document.getElementById("week-widget")) {
resolve();
return;
}
let settled = false;
const finish = (): void => {
if (settled) return;
settled = true;
document.removeEventListener("htmx:afterSwap", onSwap);
resolve();
};
const onSwap = (evt: Event): void => {
const target = (evt as CustomEvent).detail?.target as HTMLElement | undefined;
if (target && target.id === "week-widget") finish();
};
document.addEventListener("htmx:afterSwap", onSwap);
setTimeout(finish, 4000);
document.body.dispatchEvent(new CustomEvent("week-refresh"));
});
}
function initDashboard() {
initDragAndDrop();
@@ -444,7 +472,10 @@ function initDashboard() {
await fetchAndRenderSections(libraryId);
// The "This Week" widget tracks the switched library: htmx
// re-fetches its fragment with the current #library-select value.
document.body.dispatchEvent(new CustomEvent("week-refresh"));
// Await the widget's swap so it lands behind the spinner —
// otherwise it pops in during/after the fade-in and the switch
// reads as two disjoint updates.
await refreshWeekWidget();
}),
});
+13
View File
@@ -32,6 +32,14 @@ const restoreLibrarySelection = (): void => {
if (dropdown) {
const option = dropdown.querySelector(`option[value="${savedLibrary}"]`);
if (option) {
// SSR pages already render the select with the stored library
// (setSelectedLibrary keeps the cookie in sync and resolveLibrary
// renders from it). Re-firing `change` here made every page load
// re-run the page's switch handler — which re-fetches content at
// hardcoded page 1 / offset 0 and stomps URL-tracked state (this
// broke the /series paginator: page 2 snapped back to page 1).
// Only re-fire when the select actually drifted from storage.
if (dropdown.value === savedLibrary) return;
dropdown.value = savedLibrary;
dropdown.dispatchEvent(new Event("change", { bubbles: true }));
console.log("Restored library selection:", savedLibrary);
@@ -92,6 +100,11 @@ const initializeScanListener = function (this: any) {
this.scanning = false;
this.scanProgress = 0;
break;
case "scan_cancelled":
// A cancelled scan is not an error: stop the spinner quietly.
this.scanning = false;
this.scanProgress = 0;
break;
}
};
+32
View File
@@ -15,6 +15,33 @@ export async function switchWithTransition(
if (!container || !loading) return;
// View Transitions (native, where supported): the browser snapshots the
// old shelves, fetchFn swaps the DOM, and the two snapshots crossfade —
// no blank phase, no manual opacity choreography. The dim + spinner is
// shown before the snapshot and hidden inside the update, so the dim
// blends away as part of the crossfade. Browsers without the API fall
// through to the dim + fade sequence below.
const doc = document as Document & {
startViewTransition?: (updateCallback: () => void | Promise<void>) => {
finished: Promise<void>;
};
};
if (doc.startViewTransition) {
try {
loading.classList.remove("hidden");
const transition = doc.startViewTransition(async () => {
await fetchFn();
loading.classList.add("hidden");
});
await transition.finished;
} catch (error) {
loading.classList.add("hidden");
showToast("Failed to load library", "error");
console.error("Switch library error:", error);
}
return;
}
try {
container.classList.add(
"opacity-0",
@@ -27,6 +54,11 @@ export async function switchWithTransition(
await fetchFn();
// Lift the dim BEFORE the fade-in starts, so the new content's fade
// plays fully visible instead of being hard-cut by the overlay
// disappearing mid-transition.
loading.classList.add("hidden");
container.classList.remove("duration-150");
container.classList.add("duration-300");
void container.offsetHeight;
+26
View File
@@ -0,0 +1,26 @@
# Rendering-ownership baseline (AGENTS.md → Rendering ownership).
# Files allowed to build HTML in TypeScript, with their current innerHTML
# assignment counts (trivial `innerHTML = ""` clears don't count; the
# reader — web/src/reader/ — is a sanctioned island and not counted).
# `make lint-rendering` fails when a count grows above its baseline.
# Shrink counts as fragments move to templ partials — the end state of
# the triage is an empty list.
web/src/admin.ts:3
web/src/api-explorer.ts:4
web/src/bookshelf.ts:1
web/src/collection-rules.ts:6
web/src/collections.ts:5
web/src/custom-section-builder.ts:10
web/src/device-management.ts:4
web/src/docs.ts:5
web/src/dashboard.ts:2
web/src/linking.ts:3
web/src/markdown.ts:1
web/src/dom.ts:1
web/src/password_validation.ts:1
web/src/queue.ts:3
web/src/search.ts:4
web/src/setup.ts:1
web/src/stats.ts:9
web/src/toast.ts:2
web/src/unlinked_books.ts:13
+12 -118
View File
@@ -1,128 +1,22 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
import { initLibrarySwitcher, switchWithTransition } from "./library-switcher";
import { initLibrarySwitcher } from "./library-switcher";
interface SeriesItem {
name: string;
library_id: string;
book_count: number;
total_in_series: number;
cover_paths: string[];
last_entry_at: string;
}
function renderSeriesCard(series: SeriesItem): string {
const href = `/series/detail?name=${encodeURIComponent(series.name)}&library_id=${encodeURIComponent(series.library_id ?? "")}`;
const coverCount = series.cover_paths.length;
const coverClass = `cover-count-${coverCount}`;
let coversHtml = "";
for (let i = 0; i < coverCount; i++) {
coversHtml += `<img src="${series.cover_paths[i]}" class="cover-img cover-${i}" alt="${series.name}" loading="lazy" onerror="this.src='/static/placeholder-book.svg'">`;
}
if (coverCount === 0) {
coversHtml = `<div class="cover-img cover-0 flex items-center justify-center" style="background: var(--bg-primary);"><span class="text-3xl">📚</span></div>`;
}
return `
<a href="${href}" class="block">
<div class="series-card rounded-lg overflow-hidden cursor-pointer hover:shadow-lg transition-shadow" style="background-color: var(--bg-secondary);">
<div class="stacked-covers ${coverClass}">
${coversHtml}
</div>
<div class="series-card-info px-3 py-2">
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">${series.name}</h3>
<p class="text-xs mt-1" style="color: var(--text-secondary)">
${series.book_count} of ${series.total_in_series} books
</p>
</div>
</div>
</a>`;
}
function renderSeriesContent(
seriesList: SeriesItem[],
libraryId: string,
totalPages: number,
currentPage: number,
): string {
if (seriesList.length === 0) {
return `
<main class="w-full px-4 py-8">
<h1 class="text-3xl font-bold mb-6" style="color: var(--text-primary)">Series</h1>
<div class="text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📚</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Series Found</h3>
<p>Books with series metadata will appear here</p>
</div>
</main>`;
}
const cardsHtml = seriesList.map((s) => renderSeriesCard(s)).join("");
let paginationHtml = "";
if (totalPages > 1) {
const libParam = libraryId ? `library_id=${libraryId}&` : "";
const prevLink =
currentPage > 1
? `<a href="/series?${libParam}page=${currentPage - 1}" class="px-4 py-2 rounded-lg border" style="border-color: var(--border); color: var(--text-primary);">← Previous</a>`
: "";
const nextLink =
currentPage < totalPages
? `<a href="/series?${libParam}page=${currentPage + 1}" class="px-4 py-2 rounded-lg border" style="border-color: var(--border); color: var(--text-primary);">Next →</a>`
: "";
paginationHtml = `
<div class="flex justify-center items-center gap-4 mt-8">
${prevLink}
<span class="text-sm" style="color: var(--text-secondary)">Page ${currentPage} of ${totalPages}</span>
${nextLink}
</div>`;
}
return `
<main class="w-full px-4 py-8">
<h1 class="text-3xl font-bold mb-6" style="color: var(--text-primary)">Series</h1>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
${cardsHtml}
</div>
${paginationHtml}
</main>`;
}
async function switchLibrary(libraryId: string): Promise<void> {
await switchWithTransition("series-container", async () => {
const param = libraryId ? `library_id=${libraryId}&` : "";
const response = await fetch(
`/api/series?${param}limit=24&offset=0`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to load series");
}
const data = await response.json();
const seriesList: SeriesItem[] = data.series || [];
const total: number = data.total || 0;
const totalPages = Math.max(1, Math.ceil(total / 24));
const container = document.getElementById("series-container");
if (container) {
container.innerHTML = renderSeriesContent(seriesList, libraryId, totalPages, 1);
}
});
// Rendering ownership (AGENTS.md): the series grid and its paginator are
// rendered by templates/series.templ. This page intentionally has NO
// TypeScript renderer — the old one here duplicated the templ markup via
// innerHTML and re-fetched at hardcoded page 1, stomping SSR pagination
// whenever the header's library restore fired. A library switch is a plain
// SSR navigation so the URL stays the single source of truth for
// library_id and page state.
function navigateToSeries(libraryId: string): void {
const param = libraryId ? `library_id=${encodeURIComponent(libraryId)}&` : "";
window.location.href = `/series?${param}page=1`;
}
Alpine.data("seriesPage", () => ({
initSeriesPage() {
initLibrarySwitcher({
onSwitch: switchLibrary,
onSwitch: navigateToSeries,
});
},
}));
+6 -1
View File
@@ -432,10 +432,15 @@ function initSetup(): void {
running: `Scanning... ${Math.round((status.progress || 0) * 100)}%`,
completed: `✓ Complete (${status.new_items || 0} items)`,
failed: "✗ Failed",
cancelled: "✗ Cancelled",
};
job.statusText = statusMessages[status.status] || status.status;
if (status.status !== "completed" && status.status !== "failed") {
if (
status.status !== "completed" &&
status.status !== "failed" &&
status.status !== "cancelled"
) {
allComplete = false;
}
} else {
+20
View File
@@ -1,2 +1,22 @@
declare module "markdown-it-footnote";
declare module "turndown-plugin-gfm";
// htmx is loaded as a classic script on every page and used from TS for
// partial swaps (rendering-ownership rule: templ renders, JS swaps).
interface HtmxAjaxOptions {
target?: string;
swap?: string;
values?: Record<string, string>;
headers?: Record<string, string>;
}
interface Window {
htmx: {
ajax(
method: string,
url: string,
options?: HtmxAjaxOptions,
): Promise<boolean>;
process(element: Element): void;
};
}
+1 -1
View File
@@ -52,7 +52,7 @@ const TOKEN_STORAGE_KEY = "token";
// auth pages survive a theme change.
const applyTheme = (theme: string): void => {
const setThemeClass = (el: Element): void => {
[...el.classList].forEach((c) => {
Array.from(el.classList).forEach((c) => {
if (c.startsWith("theme-")) el.classList.remove(c);
});
el.classList.add(`theme-${theme}`);
+15 -44
View File
@@ -18,52 +18,23 @@ function searchMatches(
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
const token = getToken();
const url = sha256
? `/api/books/match?sha256=${sha256}`
: `/api/books/match?title=${encodeURIComponent(title)}`;
// Rendering ownership: results are server-rendered markup (scoped to
// the caller's visible libraries); swap in the templ partial instead of
// rebuilding cards from JSON.
const params = new URLSearchParams({ progress_id: progressId });
if (sha256) params.set("sha256", sha256);
if (title) params.set("title", title);
fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => response.json())
.then((result) => {
if (result.matches && result.matches.length > 0) {
matchesList.innerHTML = result.matches
.map(
(match: any) => `
<div class="card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-primary); border-color: var(--border);"
data-action="auto-link" data-progress-id="${progressId}" data-media-item-id="${match.media_item_id}" data-confidence="${match.confidence}">
<div class="flex gap-4">
<img src="${match.cover_image_path || "/static/placeholder-book.svg"}"
alt="Cover" class="w-16 h-24 object-cover rounded">
<div>
<h5 class="font-semibold" style="color: var(--text-primary)">${match.title}</h5>
<p class="text-sm" style="color: var(--text-secondary)">by ${match.author || "Unknown"}</p>
<div class="mt-2 flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary);">
${Math.round(match.confidence * 100)}% confidence
</span>
<span class="text-xs" style="color: var(--text-secondary)">${match.match_method}</span>
</div>
</div>
</div>
</div>
`,
)
.join("");
} else {
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">No matches found. Try manual linking.</p>';
}
window.htmx
.ajax("GET", `/unlinked-books/matches?${params.toString()}`, {
target: `#matches-list-${progressId}`,
swap: "innerHTML",
})
.catch((error) => {
console.error("Failed to search", error);
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--error)">Failed to search</p>';
.then((ok) => {
if (!ok) {
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--status-danger)">Failed to search</p>';
}
});
}
+12
View File
@@ -3,6 +3,18 @@
@tailwind components;
@tailwind utilities;
/*
* Cross-document view transitions (the optional sweetener): modern
* browsers (Chrome 126+, Safari 18+) crossfade same-origin navigations —
* sidebar links and the series page's library-switch navigation get a
* subtle fade for free, with zero JavaScript. Firefox and older browsers
* simply navigate instantly; the rule is pure progressive enhancement.
*/
@view-transition {
navigation: auto;
}
@layer base {
/*
* Default :root mirrors the Tokyo Night theme so the palette is correct
+1 -1
View File
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated