Commit Graph

130 Commits

Author SHA1 Message Date
neon 33952322f2 feat(settings): add Prowlarr and SABnzbd support (#1416)
* feat(settings): add SABnzbd download client

Support API-key authentication, NZB uploads, queue monitoring, reconciliation, and completed-download imports through SABnzbd.

Expose the client in request settings and validate its required category and credentials.

Ref #1312

* feat(settings): add Prowlarr indexer management

Manage synchronized torrent and Usenet sources through a shared Prowlarr connection.

Ref #1312

* fix(settings): correct SABnzbd adoption and managed search retries

Address four defects found in review of the Prowlarr and SABnzbd work.

SABnzbd keeps a failed job in its history until somebody clears it, and BookOrbit removes a client
download only on an explicit action. Because the client key is a stable digest, re-grabbing the same
release adopted that dead history entry, never uploaded the NZB, and failed instantly and forever.
Adopt only an entry that is not already failed; a queue entry still wins, so a fresh upload reports
correctly.

The category-expansion retry reused the title-and-author query, making it strictly narrower on text
than the title-only attempt that had just returned nothing. A release matching only a title-only
search with expanded categories was therefore unreachable. Drop the author there so each rung of the
ladder is genuinely broader than the last.

Changing the download client type through the create form's select bypassed the picker's handler, so
a username typed for qBittorrent was still sent when SABnzbd was chosen. The server stores it, the
API-key form hides it, and it can no longer be edited away. Watch the credential kind instead, so
both controls clear it.

Finally, a swallowed managed-release refresh failure left nothing behind at all; give it its own
fail log while still reporting the original refusal to the caller.

* fix(requests): cast managed search health flags to boolean

The bulk search-health update bound each case branch as an untyped
parameter, so Postgres could not resolve it against the boolean column.
Cast every branch explicitly and assert the cast in the repository test.

* fix(requests): close review gaps in Prowlarr and SABnzbd support

Inventory the eight indexer-manager routes so the authorization matrix
probes them, resolve a managed indexer without reading its manager's
whole source list, keep an unreadable credential out of the test-failed
mapping, translate manager error codes, and show per-source search
health. Documents Prowlarr connections in docs/BOOK_REQUESTS.md.

* i18n(client): keep the retired sources empty-state key

Dropping bodyOneWay from en.json forced pruning it from five Crowdin
target catalogs, which the localization policy check rejects outright.
Leave the now-unused key in place; nothing validates unreferenced
English source keys, so Crowdin can retire it on its own schedule.

* db: regenerate the SABnzbd and Prowlarr migration onto main

Rebasing collided with main's own 0091 and 0092, which left this branch's
two migrations on disk with no journal entry referencing them. Drop both
and regenerate a single migration on main's lineage; the three affected
tables are schema-identical to the pre-rebase snapshot.
2026-09-20 18:38:36 -06:00
neon c239cfa2dd feat(books): rebuild duplicate books as a ledger
Sort groups by reclaimable space, pre-pick a keeper with a rule that states
its reasoning, and expand any row into a field-by-field comparison that shows
only what differs. Persist "not duplicates" so a judgement survives a rescan,
and review every copy before deleting. Reading progress no longer renders 0%.
2026-09-20 20:11:34 +00:00
neon d3f3382bfa feat(reader): synchronize Storyteller reading progress
Keep audiobook, EPUB, Kobo, and KOReader positions coherent while serving each client the safest suitable book variant.
2026-09-20 14:12:18 +00:00
neon e0d3d42150 feat: add read-aloud and media playback support
Add EPUB narration, shared media controls, and native device session integration.

Apply the generated migrations and update all first-party clients alongside the server.

BREAKING CHANGE: introduce incompatible database and API contracts for media playback
2026-09-19 02:27:05 +00:00
neon c292d6ccd2 fix(metadata): honor configured Amazon domain in book links
Use the sanitized provider-link setting so book detail URLs target the selected marketplace.

Fixes #1423
2026-09-18 16:32:18 +00:00
neon 3bb33b615c feat(book-requests): add per-indexer seed goals
Add configurable seed ratio and seed time goals to request indexers, supporting both static goals and tracker-advertised ratio/seed-time limits via torznab attributes. Enforce indexer-specific seed policy during request fulfillment and display active seed targets in the request seed panel.

Closes #1360
2026-09-13 17:42:12 +00:00
neon fcdd72a384 feat(book-requests): add newznab and nzbget support
Support Usenet indexers via the built-in Newznab adapter and Usenet downloads via NZBGet. Generalize active download tracking from torrent client hashes to protocol-agnostic client keys, add NZB release scoring and file handling, and expose Newznab indexer and NZBGet client options in settings and grab flows.

Closes #1312
2026-09-13 06:47:06 +00:00
neon ceda7428b0 feat(reader): add dedicated audiobook playback API 2026-09-11 05:41:29 +00:00
SergeiAK 73ac0bb722 feat(authors): collapse series on author pages (#1271)
Reduce large author listings to one card per series while preserving book counts, scoped visibility, and row-based pagination. Compute both totals in the collapsed query so deep pages remain consistent without a second author-wide scan.

Closes #301

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
2026-09-10 12:39:14 -06:00
neon 26712eb02a security(metadata): scope catalog suggestions to visible books (#1366)
Derive catalog typeahead values only from books in libraries the requester can access, and enforce their content filters. Preserve superuser visibility, exact-match ranking, and user-scoped collection lookup.
2026-09-10 11:59:24 -06:00
bobberb 17aa7425f8 feat(library): per-library added_at source (imported/file_modified/file_created) (#1239)
* feat(scanner): set book added_at from earliest on-disk file mtime

A book's "date added" now reflects when its content files first landed on
disk (the earliest mtime across its content-role files) instead of the
moment BookOrbit imported it. Cover/metadata/supplement sidecars are
excluded. When no content file has a usable mtime, addedAt is left unset so
the DB defaultNow() fallback (import time) still applies. Only set at
initial book creation; re-scan/restore paths do not touch it.

* feat(library): add per-library added_at source with recompute

Add an addedAtSource setting to libraries so "date added" can follow the
book's files on disk rather than the moment the scanner happened to see
them. The setting takes imported (unchanged default), file_modified, or
file_created, guarded by a check constraint on the column.

Expose a recompute action so the chosen source can be applied to books
that are already imported. file_modified runs as a single set-based
update over the stored mtimes; file_created has to stat the files, since
birthtime is not persisted, so it batches those reads.

Leave every existing library on imported so behavior is unchanged unless
a user opts in.

* feat(library): add added_at source control to the library editor

Surface the new addedAtSource setting in the library editor as a radio
group, alongside a button that triggers the recompute for the library
being edited.

Keep the recompute out of the save path so changing the setting stays
cheap: the setting alone only affects books imported from then on, and
applying it to existing books is an explicit choice.

* i18n(library): translate added_at source strings (de/nl/pt/sl)

* fix(library): correct create payload, recompute floor and failure logging

The library creator posts its whole form, so omitting addedAtSource from
CreateLibraryDto made the global whitelist reject every create request
with a 400. Declare the field, validate it against the same source list
as the update DTO, and persist it so the initial scan uses the requested
source rather than silently falling back to imported.

Apply the scanner's usable-time floor to the mtime recompute query too.
It previously excluded only NULL, so a filesystem reporting epoch 0 would
rewrite added_at to 1970 instead of leaving the import time in place. The
floor now lives in library.constants rather than being spelled out twice.

Wrap the recompute in try/catch so a database or stat failure emits the
[fail] log the logging conventions require instead of propagating with
only a [start] line recorded.

* fix(library): gate recompute on the saved source and label the radios

The recompute button stayed enabled while the selected source differed
from the stored one. Because the request carries no source, clicking it
recomputed against the old stored value while the UI showed the new
selection. A hint already said to save first; disable the control as well
so the mismatch is not actionable.

Give the source list radiogroup semantics with aria-checked so screen
readers can tell which option is active, since selection was conveyed by
styling alone.

* test(library): assert the create DTO under the real validation options

The previous comment claimed the case exercised whitelist and
forbidNonWhitelisted, but the shared helper calls validate without them,
so it only proved @IsIn rejects an unknown value.

Drop the misleading comment and add a case that validates with the same
options main.ts gives ValidationPipe, so an undeclared addedAtSource
fails the way it would in a real request.

* docs(library): remove redundant date-added documentation

---------

Co-authored-by: Bob Bobber <user@example.com>
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
2026-09-10 10:43:47 -06:00
neon e514be151a fix(library): ignore current library during folder prescan
Prevent an edited library from treating its own persisted folders as conflicts while preserving overlap warnings for other libraries.

Fixes #1340
2026-09-10 11:02:32 +00:00
neon 3a4d467255 fix(server): support nested subfolders in book dock discovery
Recurse into nested subfolders during book dock watcher scans and discovery so that books organized under category or author subdirectories are processed. Keep shared folders from claiming exclusive directory ownership so siblings and multi-track units can finalize and discard cleanly without collisions.

Closes #1311
2026-09-10 10:48:00 +00:00
neon 2dcf166572 fix(server): resolve hybrid torrent hashes in qbittorrent
Preserve the stored v1 identity while resolving primary hashes through bounded,
cached lookups for polling, retries, tracker checks, removal, and reconciliation.

Cover alias handling and lookup failures with unit tests and verify completion,
import scheduling, duplicate adoption, and removal against qBittorrent 5.2.3.

Closes #1318
2026-09-09 10:45:25 +00:00
neon f0bf903849 fix(books): hide completed audiobooks from continue listening
Keep the listening scroller consistent with terminal per-user read statuses and cover the completion transition with focused regression tests.
2026-09-07 05:35:16 +00:00
neon 5303a50604 security(books): enforce per-file mutation permissions
Require explicit delete or metadata-edit permission before mutating a book file. Keep catalog state intact when physical deletion fails.

Closes #1293
2026-09-07 05:08:33 +00:00
neon 9165dbed1d feat(auth): add SSO-only authentication mode
Require an enabled OIDC-linked superuser before disabling password flows. Track session authentication methods and fail closed across the API, web client, WebSockets, and administrative mutations.

Closes #1002
2026-09-02 05:04:18 +00:00
wmTJc9IK0Q 04ec495c25 feat(reader): add PDF highlights and annotations on web only (#1213)
* feat(reader): add PDF highlights and annotations

Persist and render PDF highlights through the same annotation system as
EPUB. The annotation_positions table already supported a 'pdf' format, so
this wires the web create path, response, and reader UI to it.

Backend:
- create-annotation DTO accepts a pdf position (page + segment rects) as an
  alternative to a cfi; exactly one is required
- repository createPdf() persists a format='pdf' position (geometry in pos0,
  pageno in extras); selectWithCfi exposes the pdf geometry and status
- response DTO parses the pdf position and derives positionStatus

Frontend (pdf-v4 reader):
- usePdfAnnotations REST store and usePdfHighlights orchestration render
  persisted highlights via the EmbedPDF annotation plugin (autoCommit off,
  DB stays source of truth)
- selection popup (colour/style/note/copy/delete), note dialog, and a
  Highlights sidebar tab with search/colour/notes filters, navigate, delete
- NoteDialog relocated to reader/shared for reuse by both readers

Shared types gain AnnotationRect/AnnotationPdfPosition and an optional pdf
field on AnnotationItem.

* fix(reader): preserve input on failed PDF annotation save; convert mixed PDF+CFI for KOReader

- usePdfHighlights: createFromSelection/restyleExisting now return success;
  applyHighlight and saveNote only clear the selection, dismiss the popup, and
  close the note dialog on success. On failure the mark, popup, and typed note
  are preserved so the user can retry without losing input.
- koreader annotation exchange: the PDF push-down guard now skips only PDF-only
  annotations (no EPUB anchor). A mixed PDF+CFI annotation still converts its CFI
  to xpointer instead of being dropped.
- Tests for both paths.

* fix(reader): label PDF selection buttons and dedupe multi-page highlight retries

- PdfSelectionPopup: add aria-labels to the copy, highlight, note, and delete
  icon-only buttons so screen readers can identify them.
- usePdfHighlights: createFromSelection keeps only the pages that failed, so a
  retry after a partial multi-page failure never duplicates already-saved pages.
- Test for the multi-page partial-failure retry path.

* fix(reader): harden PDF annotation store, validation, and create logging

- usePdfAnnotations: catch rejected api() calls (session expiry / network) so
  load() sets loadError and mutations return null/false instead of rejecting.
- create-annotation DTO: require positive rect width/height so degenerate PDF
  geometry is rejected; tests for zero/negative dimensions.
- annotation.service: add [annotation.create] start/end/fail lifecycle logs with
  IDs, durationMs, and sanitized error for the multi-step create.

* test(reader): cover PDF annotations in the hub and add missing docstrings

Adds a real-DB e2e proving a PDF highlight persists as a pdf position and surfaces in the library-wide Annotations hub with the exact fields the page deep link needs (jumpFileFormat=pdf, cfi=null, pageno). Adds a hub-service unit test for the pdf row mapping. Documents the PDF render helpers, the annotation deep-link builder, and the createPdf repository method.

* fix(annotations): make PDF highlights reliable across reloads

Reconcile persisted highlights after EmbedPDF finishes loading its annotation state. Keep file-scoped pagination bounded and prevent PDF-only positions from starving device sync.

Ref #1195

* fix(annotations): keep pdf annotations stable during reload

Use authenticated native pagination fetches and discard responses that overlap successful mutations. Align shared dialog styling and annotation tests with project conventions.

Ref #1195

---------

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
2026-09-01 09:16:01 -06:00
neon f41a58ac58 fix(stats): stop estimating sessions from KOReader progress
Keep KOSync limited to position synchronization and rely on measured page timings from the BookOrbit plugin for KOReader reading sessions. Remove legacy estimates at startup and rebuild affected daily totals.

Fixes #1233
2026-09-01 05:05:52 +00:00
neon fc6e6f03d5 feat(book-requests): add automated request fulfillment
Let users request books, route approvals, search configured sources, and import fulfilled releases through the Book Dock. Include scoped permissions, automation, download-client integrations, plugin support, requester visibility, and one consolidated schema migration.

Closes #248
Ref #1115
2026-08-31 16:52:43 +00:00
neon 79b02e5b35 test(books): cover same-day reading completion
Protect equal manual start and finish dates through the client and status API.

Closes #1097
2026-08-31 02:15:34 +00:00
neon f555f080fe fix(server): merge imported read statuses safely
Preserve target-only statuses and update conflicts only when the source carries newer evidence. Treat timestamp-free source rows as insert-only so they cannot overwrite curated target state.

Fixes #1104
2026-08-31 02:07:33 +00:00
neon 3ea6063f31 fix(users): add safe superuser management
Expose promotion and demotion to existing superusers while keeping shared and self targets blocked. Serialize role lifecycle changes, preserve one active superuser, and invalidate stale credentials.

Closes #1208
2026-08-30 05:44:37 +00:00
neon 67da9a2491 fix(server): reject ambiguous KOReader file hashes
Treat partial-hash matches across distinct books as unresolved so progress cannot be written to an unrelated file. Preserve same-book duplicates and user-scoped manual links as valid disambiguation paths.

Fixes #1135
2026-08-30 05:08:58 +00:00
neon 93c8136899 fix(series): repair legacy series index upgrades
Normalize legacy float values before migration 0076 so 2.7 databases can upgrade without constraint failures or precision loss. Serialize the compatibility conversion across concurrent startup processes.

Fixes #1189
2026-08-29 00:23:39 +00:00
neon 5a668b0015 feat(collections): add public collections with shared read access
Add an isPublic flag to collections allowing users to share collections across the instance. Public collections are readable by all authenticated users while write, edit, reorder, and delete operations remain restricted to the owner.
2026-08-28 16:13:28 +00:00
neon 1dd39bf0eb feat(books): add primary file size filter rule
Add fileSize as a filterable rule field resolved against the book's primary file size in bytes. In the filter builder UI, allow operators to input sizes in megabytes with automatic serialization to bytes for queries.
2026-08-28 15:32:28 +00:00
neon ddb296cade fix(books): show read status on the book detail discovery shelf
The series, author and similar endpoints never selected a read status, so
every cover in the detail shelf rendered bare. Attach the requesting user's
status in one bulk lookup per lane, and draw the badge in BookCarousel using
the same icons and placement as the library grid, plus an sr-only label.
BookRepository has no user in scope, so it now returns UnscopedBookRecommendation
and the type system forces the status on where the user exists.

Also adds the untracked scanner overlap repro for issue 1094.

Closes #1163
2026-08-27 21:56:17 +00:00
neon b0b21b2b78 fix(kobo): version cover ids from the cover, not the metadata row
Any write to book_metadata bumped updated_at and minted a fresh
CoverImageId, so a device holding that cover locally fell back to the
placeholder offline. Stamp cover_updated_at at each cover write instead;
migration 0080 backfills it from updated_at so no id churns on upgrade.

Also trims the DECIMAL(10,2) scale Booklore hands back for a series
index, which sorted 2.50 after 2.9. Closes #943.
2026-08-27 17:36:32 +00:00
neon c403ccb44c fix(books): read back user-supplied dates Postgres stored
Decode added dates and manual reading dates with an explicit parser instead
of new Date(), which reads a zero-padded year below 100 as a two-digit year:
years 0013 to 0031 became an Invalid Date and made every listing holding the
book answer 500, and years 0001 to 0099 shifted silently into 19xx or 20xx.
The column type is unchanged on the wire, so no migration is needed.

Closes #1143
2026-08-27 16:31:17 +00:00
neon 399129e447 fix(scanner): keep pattern folders when filing into File as Book libraries
Imports resolved the naming pattern with the download resolver, which keeps only the last
path segment, so the shipped default's author and series folders were dropped and every file
landed at the library root. Rename, bulk rename and move all kept them, leaving the app
disagreeing with itself about where its own import belonged.

Closes #1157
2026-08-27 15:55:32 +00:00
neon 63d4dc3bb4 feat(reader): continue a comic series from the last page
Finishing an issue offered no way onward, so the next one had to be opened from the library.
Resolve the next readable book server-side with one keyset query, offer it on the last page, and
let an opt-in setting cross over on a further page turn. The reader route now remounts on a param
change, without which it would keep rendering the book it was mounted with.

Also cast the compared value in compareSeriesIndexSql: as a bare parameter Postgres could not type
it inside the null test and rejected the statement.

Closes #998
2026-08-25 04:26:31 +00:00
neon 3de365a0c9 fix(stats): count reading attempts in completion analytics
Use completed reading attempts as the canonical source for timelines, goals, and completion latency. Preserve user and library scoping while excluding deleted, skimmed, and undated attempts.

Fixes #1132
2026-08-24 23:43:26 +00:00
neon b264ef1132 fix(series): preserve exact series index labels
Treat series positions as validated string labels so values such as 5.10 retain their exact representation across storage, metadata providers, file formats, and clients.

Closes #1119
2026-08-24 21:40:41 +00:00
neon 5ec34aef23 feat(settings): redesign users, field rules, file naming and libraries
Rebuilds the users roster, metadata field rules, file naming and metadata
score panels, and replaces the libraries list with an expandable ledger.
Libraries now batch their counts and last scan into one request instead of a
per-library fan-out, and surface scan history from scan_jobs on demand.
2026-08-23 23:57:17 +00:00
neon 3a095efcbb fix(books): let manual reading dates drive the reading lifecycle
Editing Date Started or Date Finished sent a date-only patch that the attempt layer had nothing to
attach it to. On an unread book no attempt was created, so the request returned 200 with null dates
and the field cleared itself. On a book being read the date landed on the open attempt as an end
date with no outcome, violating reading_attempts_closed_has_outcome_chk and surfacing as a 500.

Treat a date-only patch as a lifecycle statement: a start date opens an attempt and projects
reading, a finish date closes one as completed and projects read, and endedOn is never written
without its outcome. Clearing a finish date keeps the completion, because an outcome with no
completion date is legal and is already what re-read seeding writes. Setting a finish date on an
abandoned book now completes it. Reject a status that contradicts its dates with a stable error
code the client can translate, and derive statusChanged from the resulting status so a date-driven
transition still reaches achievements and the Kobo projection.

Keep unread and want_to_read free of dates they cannot own. The attempt keeps its own, so a book
marked unread no longer projects a start and finish date once a historical attempt is logged, and
marking a finished book unread no longer strands its finish date on the projection.

Stop the Hardcover import sending dates a status cannot carry. A want-to-read row that still holds
a start date would otherwise fail its row and skip the status import entirely.

Closes #1073
2026-08-21 19:01:54 +00:00
neon cbd5f56778 fix(metadata): stop hardcover sync colliding on reading attempt read ids
Read selection was positional: prefer any open remote read, else a cached book-level id, else the
first read returned, while the primary attempt was re-derived each sync from book dates. The two
could disagree, assigning one Hardcover read to two attempts and violating
reading_attempts_external_uidx, which aborted the sync and persisted the failure.

Treat reading_attempts.external_id as the mapping of record: an attempt keeps the read it owns, an
unlinked attempt adopts only an unclaimed read and claims it locally before the remote write, and a
fresh read is minted when nothing is adoptable. Never merge or delete attempts to resolve a
conflict, and keep soft-deleted ids reserved so importExternalRead tombstones still hold.

Also skip the link write when an attempt already owns its read, and map the aggregated attempt
timestamp to a Date so change detection stops comparing a driver string against lastSyncedAt.

Closes #907
2026-08-21 18:18:29 +00:00
neon 1e5fcedb2a fix(koreader): stop a rebuilt library silently dropping reading history
Deleting a library cascades away its books, files, page stats and reading sessions, and a rescan
mints new rows. The device's sync state is keyed by the file's partial md5, so an unchanged file
kept its cursors across that swap: the plugin asked only for events newer than a watermark earned
against rows the server no longer had, and nothing recorded before the swap was ever resent.
"Recheck all book matches" did not help, because it funnels back through setMatched.

Treat a changed book file id or book id as a fresh sync target and keep only the local file path.
Both ids matter: statistics hang off the file row while highlights, status, rating and review hang
off the book row, and an existing-folder merge re-parents a file to another book without changing
its id. Resending costs nothing because the page-stat dedup key makes a replay a server-side no-op.

Order both hash resolvers by book file id so content that exists as two files always resolves to
the same row. They previously took whichever row Postgres returned first, so they could disagree
with each other and, with the reset above, move a device's sync target under it.

Closes #1049
2026-08-21 16:38:51 +00:00
neon b1ba3a03e7 fix(koreader): stop a stale device position undoing a progress reset
Clearing a book's progress deleted the shared row and left the KOReader device row behind, so
the next pull served the deleted position straight back. Clearing that row too is not enough
either: an empty response reads as "this server knows nothing about your book", which is the
one reply that leaves a device certain its own sidecar is the only position there is, and it
pushes that back on the next sync.

Record the reset as a durable fact and answer the pull with a real start position attributed to
BookOrbit. Route the position by format, because KOReader's own kosync plugin feeds it straight
to GotoPage or GotoXPointer with no percentage fallback and an empty value leaves the reader
where it was. Stamp each delivery as current, because both clients compare that against their
last page turn and default backward syncs to disabled, so the original reset time would be
dropped on any pull deferred past a page turn without ever prompting.

Serving a reset proves nothing, since either client can classify it as a backward sync and drop
it silently, so the marker survives delivery and a device is held until its own push reports the
start. Retire it once a device that took the reset reads on: the pull carries no device
identity, so a marker kept alive past its purpose is answered to every device and would send
that one back to the start on every sync. The bound this leaves is deliberate. A second device
that never took the reset is protected only until the first reads on, and closing that needs
device_id on the pull, which is a plugin change.

Judge convergence on the reported position rather than the percentage, so page 1 of a hundred
page comic is not mistaken for a position no short book could ever reach. Wind Kobo bookmarks
back through the same paths, since a Kobo device otherwise resumes from its own bookmark and
reports it back the same way.

Closes #1032
2026-08-18 21:29:36 +00:00
neon 1199e3cdd4 fix(metadata): stop one book record being assembled from several books
Field rules resolve each field from whichever provider ranks first for it, and nothing ever
compared candidates to one another. A provider that matched the wrong book still won every field
it led on, so a single record could carry a title from one book, a cover from a second and a
subtitle from a third. The stock defaults make that the ordinary case rather than an unlucky one,
since cover leads with Amazon while title and subtitle lead with Goodreads.

Relevance scoring is what let those wrong matches in. A candidate survived on any score above
zero, and token overlap counted stopwords, so "The Girl on the Train" scored against a query for
"The Hobbit" on the strength of a shared "the". Substring matching ignored word boundaries as
well, which is how "Ubik" matched "Rubik Cube Solutions". Drop stopwords from the query side,
require a majority of the words that remain, match whole words only, and give the surviving floor
a name.

Then hold every candidate to an anchor before field rules ever see it. The anchor is the edition
confirmed by the book's own ISBN when there is one, otherwise the candidate the query supports
most strongly, with the title rule breaking ties because that is where the reader declares which
provider decides identity. Agreement admits subtitle extensions, reordered words and translated
editions, and rejects on a contradicting author, which is the only signal that separates a book
from a companion volume that extends its title. Reject solely on positive evidence: a candidate
with no title, or with no author to compare against, keeps the benefit of the doubt, and a lone
provider is never second-guessed. Rejected candidates no longer contribute their provider ids
either, which would otherwise pin the wrong book for every later refresh.

Normalizing titles to [a-z0-9] emptied every title written in Japanese, Chinese, Russian or
Greek. That scored two providers reporting an identical title as a disagreement, which under the
new anchor would drop all but one of them in those libraries, and it had already denied the
relevance gate any way to score them at all. Decompose instead, strip nonspacing marks so Soseki
matches Sōseki, and keep letters of any script.

Two sets of fixtures described different books per provider while testing something else
entirely, field-rule precedence and metadata locking, and now describe one book so that they
still test what they were written for.

Closes #1023
2026-08-18 19:30:13 +00:00
neon 42f175f1ef feat(authors): add Goodreads as an author metadata source
Audnexus was the only author provider, and it returns just an ASIN, name,
description and image. Goodreads carries a far richer author record, so add it
as a second provider and let each field choose where it comes from.

The Goodreads API was retired in 2020, so the provider scrapes: a name resolves
to an author id through the WAF-free /book/auto_complete endpoint, falling back
to the author search page for names that only differ by a middle initial, and
the author page yields the biography, photo, birth and death dates, website,
genres and influences. Two rendering quirks are worth knowing about, since both
silently return the wrong value: free-text fields appear twice, truncated and
whole, so the parser takes whichever copy carries the most content; and the
birthplace sits in a bare text node while only the date is wrapped in a
dataItem, so authors with a place but no date correctly store nothing.

Preferences are now per field, mirroring the book-side FieldPreference model,
with an ordered provider list and a merge strategy each. They are global,
because an author row is shared across every library and cannot hold a
different biography per library. Providers declare which fields they can return
and both the resolver and the settings UI filter on that, so Audnexus can never
be assigned to a field it has no way to fill. The single global write mode is
migrated into each field's merge strategy on first read, keeping an existing
install's overwrite behaviour instead of silently adopting the default.

The executor now queries every listed provider and resolves each field
independently, first non-empty value winning, and records which provider
supplied the record so a re-run can pin the match instead of searching by name
again. overwriteIfProvided skips empty values, which is what lets a matched
author with an empty Audnexus biography fall through to Goodreads.
2026-08-18 03:48:20 +00:00
neon ec4072e4be feat(server): add Audiobookshelf and Calibre-Web Automated migration sources
Extend the migration module beyond Booklore and Grimmory so an existing Audiobookshelf or
Calibre-Web Automated library can be moved into BookOrbit without rebuilding user state by
hand. Audiobookshelf supports both a live API connection and an offline backup file, while
Calibre-Web Automated reads a stopped snapshot of the app and metadata databases.

Match source books by ASIN in addition to ISBN, and batch the ISBN, ASIN, file hash, file
path, and title/author lookups so planning a large library issues a bounded number of
queries rather than one per book. Route the batched author comparison through the shared
accent-insensitive predicate, which now accepts a SQL fragment so a per-row pattern column
is matched the same way a bound literal is.

Extract the shared source connection form into MigrationSourceFields so the modal and the
settings page stay in sync as source types are added, and allow a mapping to skip a source
user instead of forcing every account to be imported.

Closes #986
2026-08-16 05:15:34 +00:00
neon 7b43756258 fix(stats): track last read separately from progress row updates
Sorting by "Last Read" ordered on reading_progress.updated_at, but the KOReader sync path
deliberately freezes that column so it stays a reliable "last local write" marker when resolving
conflicts against device progress. Readers who sync from KOReader therefore kept the timestamp of
their first ever sync of each book forever, so the smart scope sort, the jump-bucket rails and the
dashboard Continue Reading list all looked unordered.

Add reading_progress.last_read_at, which advances on every write unless a path opts out, and move
every "last read" reader onto it. Leave updated_at to the conflict-resolution and progress-resume
paths that genuinely mean "last local write". Kobo bookmark-location stamping opts out because
recording which Location a bookmark maps to is bookkeeping rather than reading, while the Kobo
progress write and the migration importer pass their source timestamps through so imported history
is not stamped as read today.

Move the ebook-versus-audiobook recency tiebreaks onto the new column as well. They compared a
frozen timestamp against a live one, which selected the wrong percentage for the same reason.

Swap the (user_id, updated_at) index for (user_id, last_read_at), since the ordering consumer that
justified it moved. Backfill last_read_at from updated_at in the migration, as the generated
DEFAULT now() would otherwise make every existing book look as though it had just been read.

Fixes #1013
2026-08-16 01:04:34 +00:00
neon e2aeaf95ea fix(scanner): resolve case-insensitive path identity to prevent duplicate books
When library items are scanned or moved on case-insensitive filesystems, paths with non-standard author or folder capitalization point to the same physical disk entries. Treating previous path spellings as distinct caused scanner, upload, dock, and rename services to fail path-reuse lookups and create duplicate book records.

Introduce path identity helpers to check whether paths resolve to the same underlying filesystem entry, treating matching entries as path updates rather than creating duplicate books.

Closes #1012
2026-08-16 00:29:32 +00:00
neon 5d55b8aa0d fix(stats): evaluate achievements for updated KOReader sessions
KOReader page-stat uploads can grow an existing derived session as more page events arrive, but
only newly inserted sessions reached achievement evaluation. A session that crossed a threshold by
being updated showed progress past that threshold while the badge stayed locked.

Carry the updated sessions out of the repository instead of a bare count and emit for them too.
The oversized-upload branch emitted a backfill event that nothing listened for, so subscribe
achievement evaluation to it as well; those runs are serialized per user and a burst collapses into
one trailing re-run, since each backfill re-evaluates the whole catalogue. Awards from that branch
stay silent: an upload that large is a device catching up, and notifying per badge would burst a
toast for every historical unlock.

Fixes #991
2026-08-14 06:09:03 +00:00
neon 26b7c602f8 security(settings): gate Book Dock settings behind manage_book_dock
Require manage_book_dock for the settings UI and the scoped API so regular Book Dock users cannot
view or modify administrative configuration.

Reject every Book Dock key on the generic PATCH /app-settings/:key route as well, including
book_dock_paused. That route accepted the same keys with any string value, so an operator with
manage_app_settings could store an out-of-range threshold or an unknown metadata mode and bypass
the validation the scoped endpoints apply.

Closes #1003
2026-08-14 03:15:30 +00:00
neon 2b8e1d6a79 fix(kobo): stop endless sync retries and cut round trips
Report success when Kobo rejects a delete for a tag it no longer has, so the device stops retrying
one it can never complete. Skip the full-library reconcile while a device is still draining pages,
and page fifty books at a time so a large re-announce costs a few round trips instead of hundreds.

Ref #1005
2026-08-14 02:26:46 +00:00
neon 208d04d218 fix(opds): advertise search inline for clients that skip OpenSearch
Moon+ Reader and CrossPoint never fetch the OpenSearch description document, so they
detected no search capability and hid the search affordance entirely. Both look only for a
rel="search" link whose href already carries {searchTerms}, and they match the link type by
equality rather than by substring.

Emit that templated link alongside the existing OpenSearch pointer, keeping the pointer
first so KOReader's has_opensearch guard still wins and its behaviour is unchanged. The
type stays bare application/atom+xml and the href stays relative like every other href in
the feed, so nothing has to reconstruct an external base URL behind a reverse proxy.
Calibre-Web ships the same two-link pairing.

Add the pair to the libraries, collections, smart-scopes, authors and series feeds too.
Those carried only a self link, so search disappeared once a client descended into a
sub-folder even when it did support OpenSearch.

Closes #860
2026-08-11 00:58:47 +00:00
Jadehawk 80223b5e67 fix(koreader): accept optional KOSync document metadata (#966)
* fix(koreader): accept optional KOSync document metadata

* fix(koreader): reject null progress metadata
2026-08-10 17:42:59 -06:00
neon ca3c637271 fix(auth): split Book Dock access from Book Dock administration
Book Dock was a single permission that the standard preset handed to every user, so an ordinary
account could pause processing, trigger a rescan, and finalize straight into the library. Keep
book_dock_access for personal upload and review, and move global control behind a new
manage_book_dock permission in the administration group.

Finalization now also requires library_upload, since it writes into a library. Per-file reads and
mutations are scoped to the uploader unless the caller can manage the dock, which closes the gap
that let any Book Dock user read, edit, or discard another user's entry by id. The summary
websocket now announces a change instead of broadcasting global counts, so each client refetches
its own scoped totals.

Closes #961
2026-08-09 21:27:33 +00:00