Skip to content

feat(books): upstream v3.5.0 sync, book status tracking fixes, and discovery filters - #10

Open
benjitobz wants to merge 375 commits into
jabloink:developfrom
benjitobz:develop
Open

benjitobz wants to merge 375 commits into
jabloink:developfrom
benjitobz:develop

Conversation

@benjitobz

@benjitobz benjitobz commented Sep 9, 2026 •

Copy link
Copy Markdown

Description

Brings feat-readarr up to date with upstream seerr v3.5.0 and fixes a set of book-specific bugs found while running this fork in production against Chaptarr (a Readarr fork) with separate ebook and audiobook instances.

Every change below also exists as a branch on benjitobz/seerr, named in brackets, if you would rather cherry-pick individual pieces than take the whole thing. Most are cut from feat-readarr and stand alone. Seven were cut from this combined branch instead: the two request pickers, fix/book-request-format-badge, fix/readarr-none-metadata-profile, fix/series-book-format-status, fix/series-primary-books and fix/image-proxy-stale-optimize. For those, cherry-pick the branch's own commits, after any entries they build on. The pickers need feature/sync-book-format-requests and feature/audiobook-tile-status first.

Upstream sync [sync/upstream-main]

Merges seerr-team/seerr main through v3.5.0, in two steps: v3.4.1, then the v3.5.0 tag.

At v3.4.1, three conflicts were resolved by combining both sides rather than picking one:

  • MediaRequest.ts — book quota checks folded into upstream's new ignoreQuota logic, so books participate in quota bypass like movies and series.
  • routes/media.ts — kept upstream's arrName refactor while preserving book-aware Readarr/Audiobook service naming.
  • ManageSlideOver — kept both sides' new i18n strings.

v3.5.0 changed several things the book code relies on, mostly without a textual conflict, so each needed a matching change on the book side:

  • node-cache was replaced by lru-cache, which requires a size bound on every cache. The Readarr cache holds 64 entries and the Hardcover cache 1,000. The Hardcover bound has to exceed a library's size, or a scan evicts entries it will need again while held to 1 request per second.
  • Override rules moved into overrideRules(), typed for TMDB media. Books skip the call. The fork's inline filter already rejected every book, so no rule has ever applied to one.
  • The request subscriber became transaction-aware. sendToReadarr now reads through the request's EntityManager, as the Radarr and Sonarr paths do. Its callback after the Readarr add uses the global repository, because it runs after the transaction commits.
  • sortBy is now validated per media type, so book discovery has its own schema listing the book sort keys.
  • Media.getRelatedMedia keeps its join on media.requests, which the per-format chips need.
  • Discover lists and sliders use upstream's new filter shape, and books follow the hide-available and hide-blocklisted settings as movies do. Request cards show upstream's download status and keep book titles.

Upstream's migrations are unchanged; on a production database the status remap changed no rows. The fork's removal of .github/workflows/release.yml was preserved.

Bug fixes

Book status could never change once available [fix/book-status-downgrade] — baseScanner.processBook wrapped its entire status-update block in if (status !== AVAILABLE), so once a book was marked available its status was frozen. Deleting the files in Readarr, unmonitoring the book, or clearing the library left seerr reporting "Available" indefinitely. The guard now also runs when Readarr reports no files, downgrading to PROCESSING while the book is still monitored/grabbed and UNKNOWN otherwise. Deliberately scoped to processBook; the identical block in processMovie is untouched, since movies get deletion handling from availabilitySync, which has no book support.

Orphan cleanup ignored available books [fix/book-status-cleanup] — cleanupOrphanedBooks only queried books with status PROCESSING, so a book that was available and then removed from Readarr entirely was never reset. Now covers PROCESSING, AVAILABLE, and PARTIALLY_AVAILABLE.

Removals took up to 24h to surface [fix/book-deletion-fast-detect] — cleanup was gated behind !isRecentOnly, so only the daily full scan noticed deletions. The recently-added scan already fetches Readarr's complete book list, so it now records that list and runs the same cleanup. Safe by construction: an unreachable Readarr throws before cleanup runs, so an outage cannot wipe statuses.

Noisy "Invalid Hardcover ID" warnings [fix/scanner-invalid-hcid-noise] — reported by a user with a large catalog. Books with an unparseable foreignBookId are now skipped silently when unmonitored, ungrabbed, and fileless; monitored, grabbed, or downloaded books still warn. Uses bookFileCount > 0 rather than percentOfBooks, matching the existing note in processReadarrBook about forks where percentOfBooks reports 0 for unmonitored books that have files.

Audiobook-only availability missing from media lists [fix/media-list-4k-availability] — the media route's status filter only matched status, so a book available solely as an audiobook (and a movie available solely in 4K) never appeared in Recently Added. It now matches either axis.

Hardcover rate limiting [fix/hardcover-rate-limit] — the client was throttled to 5 req/s while Hardcover allows 60/min, so cold-cache discover loads reliably hit 429 and surfaced as a 500 on the popular-books slider. Now 1 req/s. A follow-up on the same branch fixes the larger problem: every route built its own Hardcover client and the axios rate limiter lived on the client, so the limit only ever applied within a single request — a quickly-typed search, or a page of uncached book cards, sent its requests together and got 429s anyway. All clients now share one rate-limited instance per API key so bursts queue, and a 429 is retried up to twice honouring Retry-After (capped at 10s, falling back to 2s then 4s), which covers other consumers spending the same token's allowance.

Book request button shown without book permission [fix/book-titlecard-request-permission] — TitleCard gated the button on REQUEST_MOVIE for books, so any user with movie-request rights saw it. The server correctly rejected the request, but the button should not appear; it is now gated on REQUEST_BOOK.

Book quota selector mislabeled [fix/book-quota-label] — the per-user book quota selector read "Series Request Limit"; now "Book Request Limit".

Permission labels did not name books [fix/book-permission-labels] — the book permission rows in the permission editor reused the generic wording, so an administrator could not tell which toggle governed ebooks and which audiobooks. Both are now named explicitly.

Transient fetch errors flashed "Not Found" [fix/transient-error-grace] — on cold loads the per-book detail fetches queue behind the Hardcover throttle, and a first-attempt failure briefly rendered the error card before SWR's retry succeeded. Title cards, request cards, and the book detail page now hold their loading state for 20s while retries run, and sliders keep their skeletons through transient errors.

Readarr's "None" metadata profile offered as a request option [fix/readarr-none-metadata-profile] — Readarr recreates an undeletable profile named "None" on every start, whose popularity threshold of 1e10 rejects every book. It exists so an author can be tracked with none of their books, which is never a valid choice for a request: picking it would add the book under a profile that hides it, leaving a request that can never complete. It is also typed General, so it appeared under both media types. It is now filtered out of the request panel and the server settings picker by name — filtering on the General profile type instead would also drop legitimate shared profiles.

Ebook side of a book request left unlabelled [fix/book-request-format-badge] — a request block badged the audiobook format but nothing else, so the two rows of a book requested in both formats read as one labelled and one unlabelled request. The ebook side is now named too.

Series status and card chips did not name the format [fix/series-book-format-status] — a series held in both formats rendered "Partially Available" twice with nothing saying which format each badge referred to: the first badge was missing its media type, and StatusBadge drops the format prefix for books when partially available, which only reads correctly on a book page where one format is shown at a time. StatusBadge gains an opt-in to keep the prefix and the series page uses it for both badges. The series' book cards were also passed only the ebook status, so their audiobook chip never appeared; they now receive the audiobook status and the requests behind it.

Series listed editions and extras as books of the series [fix/series-primary-books] — Hardcover places a series' dramatized adaptations, split editions ("Part 1 of 2") and bonus chapters at fractional positions next to the books themselves. The series page therefore listed them as books of the series and counted them in its book count and status, and a series request selected them. Books at fractional positions are now flagged as extras. The series page lists them in their own "Editions & Extras" section, outside the series' count and status. The series request lists them in a separate table that starts unselected, and Full Series covers only the series' own books.

Book covers reverted to full size every few hours [fix/image-proxy-stale-optimize] — refreshing an expired image-cache entry wrote the original download back over the optimized copy and never optimized it again. Every cover went back to full size once its cache lifetime ran out, which is 4 hours for Hardcover's CDN. A refresh now optimizes the new download too.

The first view of each cover sent the full-size original [fix/image-proxy-first-request-optimize] — on a cache miss the image proxy returned the original download and optimized it in the background. The first person to view any cover therefore received the full-size file, and their browser kept it for the cache lifetime. A page of 40 covers nobody had viewed came to 10.5 MB, against 2.5 MB once optimized; one cover was a 552 KB JPEG as downloaded and a 54 KB WebP after. The proxy now waits for the transform and serves the original only if the transform fails. Only the Hardcover proxy has a transform, so TMDB, TVDB and avatar images are unaffected. Adds the first tests for the image proxy.

Features

Per-format status on book title cards [feature/audiobook-tile-status] — book tiles previously showed ebook status only. They now show one chip per format, each styled like the existing badge (same fill, border weight, and check glyph) with a book or speaker icon marking the format: green check when available, green minus when that format is available but the other is not, red X when Readarr monitors the book but has no file and no seerr request exists, and the existing clock while requested and searching. The audiobook chip is gated on bookAudioEnabled plus MANAGE_REQUESTS/REQUEST_4K/REQUEST_AUDIO_BOOK. Movie and series badges are unchanged. On /book/{id}, badges now read "Ebook Available" / "Audiobook Available", and the available format shows "Partially Available" while the other is missing. This required getRelatedMedia to join media.requests so list payloads can distinguish "requested" from "merely monitored".

Sort and filters on book discovery [feature/book-discover-filters] — the Books discover page gains the sort dropdown and filter slide-over that movies and series already have, limited to filters Hardcover can back: release-date range and genre multi-select. With nothing applied the page returns the trending list exactly as before; any filter or non-default sort switches to a filtered catalog query. Two Hardcover API notes for anyone extending this: the endpoint rejects books_bool_exp and books_order_by passed as GraphQL variables ("not a valid graphql query"), so the where clause and ordering are built inline with strict sanitization (genre slugs matched against ^[a-z0-9-]+$, dates against YYYY-MM-DD, sort keys from a fixed map); and sorting by newest clamps to one year out, because the catalog contains corrupted far-future release dates (year 20222).

One action can cover both book formats [feature/sync-book-format-requests] — adds a syncBookFormatRequests main setting, surfaced as "Request Both Book Formats by Default", which pre-selects both formats when requesting a book. It is disabled unless a default Audiobook Readarr server is configured, matching the existing audiobook permission gating. Because it is global rather than a permission, it applies to users holding only one of REQUEST_BOOK / REQUEST_AUDIO_BOOK: only permitted formats are submitted, and lacking the other bit never fails the request. The book quota counts distinct books rather than request rows, so taking a book in both formats costs one unit, not two.

Format picker for book requests [feature/book-format-request-picker] — requesting a book previously decided its formats behind a single button, and the audiobook was reachable only from a dropdown on the detail page. The request modal now lists every format the user may request with its own toggle and status, in the same shape as the existing season picker, with formats that are already available or requested shown locked. Book detail pages open that same modal from one Request button rather than a dropdown. Advanced options render one panel per selected format, each bound to its own Readarr instance, so requesting both formats no longer applies one instance's server, profile and root folder to the other. Title cards show their request button whenever any format is still open, so an available ebook no longer hides its audiobook, and they track both formats after a request completes. Formats are submitted independently, so one that is already requested cannot sink the other.

Per-book format picker for series requests [feature/series-format-request-picker] — a series request covered one format at a time, chosen from a dropdown, so a series wanted partly as ebooks and partly as audiobooks took two passes. The modal now opens with a Full Series table in the same shape as the single-book picker: a toggle for the whole series, then one row per format with its toggle and the series' status in that format. Below it, each book has a request toggle followed by its own ebook and audiobook toggles and their status. A book follows the series-level formats until its own are changed. Switching a book, or the whole series, off disables its format toggles but keeps their status visible, and books already held show as on and cannot be toggled. Each book is submitted only in the formats selected for it, so a format already held cannot fail the batch, and a book still costs one unit against the book quota however many of its formats are taken. Series requests can also be turned off entirely from Settings, which hides the request button while leaving series browsable.

Housekeeping

pnpm i18n:extract had not been run against the book work, so en.json was missing keys from this fork's existing components (BookDetails, HardcoverSetup, SearchInput, Sidebar, book permissions) as well as the ones added here. The extract commit adds them and picks up two string updates already made in the source ("Request 4K" → "Request 4K/Audiobooks"). Two merge-resolution files also needed pnpm format.

How Has This Been Tested?

Running in production on a home media server: Docker image built from this branch, Jellyfin, and Chaptarr configured as two Readarr instances (ebook and audiobook) addressed via URL bases, backed by a Calibre library.

Repository checks, all run against this branch:

  • pnpm build — passes (the image build runs it)
  • pnpm test — 242/242 passing, 0 failures
  • pnpm typecheck — clean
  • pnpm lint — 0 errors (24 pre-existing warnings)
  • pnpm format:check — clean
  • pnpm i18n:extract — run, output committed; running it again changes nothing

Functional testing:

  • Status tracking: with books deliberately deleted from Chaptarr, a full Readarr scan reset every stale row to match reality — ebooks with files stayed available, books whose files had been removed dropped to unknown, and all audiobooks cleared on the instance that holds no files. A book that had a file but was not yet in seerr was discovered in the same pass. Before the fix, all of those rows stayed "Available" permanently.
  • Requests: ebook and audiobook requests route to the correct Chaptarr instance by format. Requesting both formats creates two media_request rows, one per format, which remain independently approvable and fulfillable.
  • Permissions: verified against users holding each book permission in isolation — a user with only REQUEST_BOOK is offered only the ebook format, a user with only REQUEST_AUDIO_BOOK only the audiobook, a user with neither gets no request button, and a hand-made request for an unheld format is rejected by the server.
  • Quota: three request rows across two distinct books report two units used, confirming a both-formats request costs one.
  • Discovery: multi-genre (AND), date ranges, and every sort order verified against the live Hardcover API.
  • Loading states: confirmed on cold-cache page loads, which previously flashed "Book Not Found" before resolving.
  • Upstream v3.5.0: the upgrade ran in place on the production database; upstream's migrations applied and the status remap changed no rows.
  • Covers: measured in production through the CDN. Before the fix, the first view of 40 covers nobody had viewed returned 10.5 MB of originals. After it, the first view of 39 such covers returned 2.3 MB of WebP, byte-for-byte what later views get from the cache.

Screenshots / Logs (if applicable)

Global Discovery Page:
image
Discovery page with added filters and status chips:
image
Discovery page filtering:
image
Discovery Book Request Form:
image
Book page with availability chips:
image
Book page request form:
image
Series page with availability chips, per book cover chips and separation of extras:
image
Series page request form:
image
Settings:
image
More Settings:
image

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly — no changes needed; the README's book setup instructions are unaffected by these fixes and features.
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract
  • Database migration (if required) — not required; no schema changes

jabloink and others added 30 commits July 16, 2026 13:52
Co-authored-by: fallenbagel <98979876+fallenbagel@users.noreply.github.com>
fallenbagel and others added 23 commits September 28, 2026 07:06
Media.getMedia() loads the requests and issues relations for GET /movie/:id and GET /tv/:id, and the
User relations hanging off them are eager, so every authenticated user could read any other user's
email and plexId out of mediaInfo. The same payload carried full issue contents, including comment
bodies, to viewers who are refused GET /issue. Media.filter() now strips the nested users and
withholds the issues from anyone lacking the perms that the endpoing requires, and drops the issue
fields nothing reads. Reported as a security advisory, GHSA-xc6j-6p6h-p799.
The series header was a bespoke stack of a toggle, a rule and a pair of
labels, which looked nothing like the format picker a single book request
opens.

It is now the same table: a toggle, the format, and its status, headed Full
Series so it is clear the row covers every book. The status column reports
the series as a whole, available only when every book is, and partly
available when some are. The books below keep their own table, now headed
Books.
…ns them

A book switched off, or a series with every book switched off, still showed
its format toggles lit and clickable, so the modal claimed formats that were
not going to be requested.

Those toggles now read off and are inert while the switch above them is off,
and come back to their previous values when it is turned on again: the
choice is still held, it is only hidden while nothing can act on it. A
book's own switch also moves onto the format row, divided from it by a rule,
rather than sitting above the cover.
A book's switch stayed on when the formats above had been cleared, so it
claimed a book that nothing was going to fetch, and its format toggles sat
there greyed with nothing to act on.

The switch now reads on only when the book really is in the request, which
means clearing the formats above reads through to every book, and a book's
formats are rendered only while its switch is on rather than shown disabled.
Switching a book back on restores its formats, falling back to everything it
can take when the formats above are off as well.

Books are also flush left with the full width for their titles, and the
switch is labelled Request to say what it does.
…luded

A book with nothing left to request was exempted from the switch above it,
so clearing the formats turned every other book off and left those two on
with their formats still listed, which read as the header not working.

The exemption is gone: a book's switch reads on only while the book is in
the request, so the formats above now govern the whole list. Its own label
is dropped, leaving the switch beside the formats it controls.
Making every book follow the formats above hid the status of the books that
are already available, leaving them as a bare title under a switch that only
said they could not be requested.

A book with nothing left to request shows its switch on and lists what it
already holds again. Its switch stays inert, so this says what the book has
rather than offering to fetch it.
Switching a book on while the formats above were all off gave that book
formats of its own. That silently detached it: from then on it ignored the
formats chosen for the rest of the series, so changing them moved every
book but that one.

A book is only ever given formats of its own by changing its own format
switches. Switching it on now just puts it back in, and a book that was
emptied rejoins the formats above. A switch with nothing above it to take
is inert rather than inventing something to take.
Hiding a book's formats while its switch was off took the status of each
format away with them, so a book you were not requesting said nothing about
what it already had.

The formats stay on show. Only their switches go inert, following the switch
beside them, while the labels and status keep their full weight.
Hardcover files dramatized adaptations, split editions and bonus chapters
under the book they belong to, numbered off its position, so a seven book
series listed seventeen entries. Ten of them were editions of a book already
in the list, which crowded the series page and its request.

Only whole positions are the series' own books now. The rest stay reachable
through search and keep their own pages, and the series status no longer
counts editions that will never be held against it.
Dropping every fractional position left dramatized adaptations, split
editions and bonus chapters out of the series entirely, so none of them
could be requested from it.

They are kept and marked as editions instead. The series page lists them in
an Editions & Extras section under the series' own books, and the request
modal gives them their own table where they start switched off. Full Series,
the book count and the series status all cover the series' own books, so an
edition is only requested when it is chosen individually.
Brings seerr up to v3.5.0. Conflicts were resolved by combining both sides:

- MediaRequest: upstream moved override rules into overrideRules(), typed for
  TMDB media only. The fork's inline version never applied a rule to a book (its
  filter returned false for BookDetails), so books skip the new call and behave
  as before. metadataProfileId was only ever reassigned on that path, so it is
  now const.
- MediaRequestSubscriber: upstream made updateParentStatus, sendToRadarr and
  sendToSonarr take the request's EntityManager. sendToReadarr follows suit:
  its synchronous reads and writes join the transaction, and the add-book
  callback, which runs after commit, reaches the database directly, as the
  Radarr path does.
- cache: upstream replaced node-cache with lru-cache and requires a key bound
  on every tier. Readarr is bounded like Radarr and Sonarr, since it only holds
  profile and root folder lookups. Hardcover gets 1000 keys, above the size of
  a Readarr library, because scans look every book up there and evicting
  mid-scan would re-fetch at Hardcover's rate limit.
- useDiscover, MediaSlider: upstream stopped the hide-available and
  hide-blocklisted filters dropping everything that is not a movie or series.
  Books keep the filters, and non-media results are no longer dropped.
- RequestCard, RequestItem: upstream's precomputed download status, with the
  fork's book title kept.
- AdvancedRequester: books stay in the type union alongside upstream's tmdbId.
- seerr-api.yml: upstream's approve and decline responses, then the book path.
- release.yml stays removed, as the fork removed it.
Brings develop up to seerr v3.5.0 through the synced upstream branch, where
the upstream conflicts were resolved.

- Media: getRelatedMedia keeps the requests join that per-format book chips
  rely on to tell requested apart from monitored, alongside upstream's cleanup
  of the line it sits on.
- discover: upstream now validates sortBy against each media type's own sort
  options instead of accepting any string. The books route, which read sortBy
  from the shared schema, gets a schema of its own on the same pattern, backed
  by the six orders the Hardcover query already knows.
@benjitobz benjitobz changed the title feat(books): upstream v3.4.1 sync, book status tracking fixes, and discovery filters feat(books): upstream v3.5.0 sync, book status tracking fixes, and discovery filters Sep 28, 2026
A cache miss returned the original download and optimized it in the
background, so the first viewer of every cover received the full-size
file, four to ten times the size of the optimized copy for Hardcover
covers, and their browser kept it for the whole cache lifetime. Wait
for the transform and fall back to the original only when it fails.
The audiobook section only offered its remove button when the media sat
on the default 4K Sonarr server, a check with no book branch, so it never
matched a book and an audiobook could only be deleted from Readarr by
hand. Check the default audiobook Readarr server instead, and title the
ebook section "Ebook Media" so the two sections read as a pair.
A book's two remove buttons read "Remove from Readarr" and "Remove from
Audiobook Readarr", which left the ebook one unlabelled. They now read
"Remove Ebook from Readarr" and "Remove Audiobook from Readarr". Movies
and series keep their wording.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.