Skip to content

feat: Phase 1 audiobook + ebook backend (INF-66)#1

Open
danbryan wants to merge 23 commits intodevelopfrom
feat/audiobook-ebook-support
Open

feat: Phase 1 audiobook + ebook backend (INF-66)#1
danbryan wants to merge 23 commits intodevelopfrom
feat/audiobook-ebook-support

Conversation

@danbryan
Copy link
Copy Markdown

Summary

Phase 1 backend for audiobook + ebook support per INF-66. Routes the family's book requests into the cluster's Bookshelf (Readarr fork) instances via Seerr, bypassing the full MediaRequest approval workflow for this phase. Phase 2 will add the family-facing UI and wire requests through MediaRequest proper.

What's here

  • Enum. `MediaType.AUDIOBOOK` and `MediaType.EBOOK` added to `server/constants/media.ts`. Widened `WatchlistItem` and `BlocklistItem` to accept the full enum; UI sites that still render only movie/tv take a narrowing cast at the assignment since Plex watchlist/blocklist never surface books.
  • API client. `BookshelfAPI` (`server/api/servarr/bookshelf.ts`) extends `ServarrBase` and covers `/book`, `/book/lookup`, `/author/lookup`, `/metadataprofile`, `/rootfolder`, `/qualityProfile`, `/queue`, and `BookSearch` command. One class serves both Bookshelf instances; the instance difference is purely the configured URL + API key + profile IDs.
  • Settings. New `BookshelfSettings` (extends `DVRSettings` with `mediaType: 'audiobook' | 'ebook'` and metadata profile fields). Stored under `settings.bookshelf`. Admin CRUD + connection test at `POST/GET/PUT/DELETE /api/v1/settings/bookshelf`.
  • Routes. `/api/v1/audiobook/{search,request,queue}` and identical shape at `/api/v1/ebook/...` pick the `isDefault` (or first) server matching the requested mediaType and talk to it directly. `request.ts` switch-on-type handlers now cover AUDIOBOOK/EBOOK using the bookshelf server list.
  • No DB migration. The existing varchar `mediaType` column accepts the two new values. For Phase 1, `tmdbId` transiently holds the Hardcover book id for books; MediaRequest integration is Phase 2 and will revisit entity shape.
  • Version. `package.json` bumped to `3.2.1-bryanlabs.1` to tag the fork build distinctly from upstream.

What's deferred to Phase 2

  • Family-facing search/detail/request UI.
  • Wiring book requests through the `MediaRequest` entity (approval workflow, notifications, etc.).
  • Rich Hardcover integration in Seerr (cover art, related works, browse). Phase 1 intentionally talks only to Bookshelf; Hardcover data surfaces transparently through the rreading-glasses proxy that Bookshelf already calls internally.

Test plan

  • `pnpm typecheck` passes (server + client): verified locally.
  • `pnpm lint` passes with zero errors: verified locally (only pre-existing warnings in unrelated files).
  • Build image: `docker buildx build --platform linux/amd64 --build-arg COMMIT_TAG=$(git rev-parse HEAD) --build-arg SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) -t ghcr.io/bryanlabs/seerr:v3.2.1-bryanlabs.1 -t ghcr.io/bryanlabs/seerr:latest --push .` (orbstack; cloud-bryanlabs-builder is currently broken).
  • Pin digest into `cluster/media-suite/overseerr/deployment.yaml` (tag@sha256 form per pinned-image-tags policy), `kubectl diff -k cluster` then `kubectl apply -k cluster`, watch rollout.
  • Configure the two Bookshelf servers in the admin settings UI (same form pattern as Sonarr; URL + API key + quality profile + metadata profile + root folder). Pull the API keys with:
    ```
    kubectl exec -n media-suite deploy/bookshelf-audiobooks -- grep ApiKey /config/config.xml
    kubectl exec -n media-suite deploy/bookshelf-ebooks -- grep ApiKey /config/config.xml
    ```
  • Smoke test audiobook request end-to-end:
    ```
    curl -s "https://overseerr.media.bryanlabs.net:32110/api/v1/audiobook/search?q=Scarlet+Pimpernel\" -H "Cookie: $COOKIE" | jq '.results[0].foreignBookId'
    curl -s -X POST "https://overseerr.media.bryanlabs.net:32110/api/v1/audiobook/request\" -H "Cookie: $COOKIE" -H 'Content-Type: application/json' -d '{"foreignBookId":""}'
    ```
    then verify the book lands in bookshelf-audiobooks and qBittorrent begins a grab at MAM category 3030.
  • Smoke test ebook with "Project Hail Mary" (category 7020).

Caveats to flag for review

  • `addBook` payload. Readarr fork APIs tend to be sensitive about the `/book` POST body (embedded author block, `addOptions.searchForNewBook`, quality+metadata profile IDs on both book and author). The implementation mirrors what Readarr docs specify, but confirm against Bookshelf's actual behavior before merging; the response error JSON is logged if the add is rejected.
  • Settings UI (frontend form). The backend endpoints at `/api/v1/settings/bookshelf` exist and work, but no React settings page exists yet. Servers can be configured via direct API POST, or the Sonarr settings form can be copied to a new Bookshelf page as a small follow-up.
  • MediaRequest workflow intentionally skipped for Phase 1. Book requests go straight to Bookshelf; they do not create `MediaRequest` rows. Phase 2 will decide whether to reuse the existing entity (with `tmdbId` repurposed) or add a discriminated variant.

Closes INF-66 once Phase 1 smoke tests pass.

danbryan added 23 commits April 24, 2026 23:13
Extend MediaType with AUDIOBOOK and EBOOK. Add BookshelfAPI client
covering both audiobook and ebook Bookshelf (Readarr fork) instances:
searchBook, searchAuthor, addBook, metadataProfile, and queue/command
wrappers over the Readarr v1 API shape.

Settings: new BookshelfSettings entry (DVRSettings + mediaType +
metadata profile) stored under settings.bookshelf, with admin CRUD at
/api/v1/settings/bookshelf.

Routes: /api/v1/audiobook/{search,request,queue} and matching
/api/v1/ebook/{search,request,queue} wire directly to the configured
Bookshelf instance, bypassing MediaRequest for Phase 1.

Watchlist/Blocklist mediaType widened to full MediaType; UI sites that
still render only movie/tv receive a narrowing cast at the assignment
(Plex-side sources never surface book types). Existing MediaType
switches in request.ts handle AUDIOBOOK/EBOOK via the bookshelf server
list.

No DB schema change: the varchar mediaType column accepts new values,
and tmdbId will transiently hold the Hardcover book id for books in
Phase 1.

Version bumped to 3.2.1-bryanlabs.1.
New BookshelfModal mirroring RadarrModal but with media-type selector
(audiobook/ebook), metadata-profile dropdown, and root-folder/profile
loading from /api/v1/settings/bookshelf/test.

SettingsServices renders a Bookshelf section under Sonarr with
add/edit/delete tiles and per-mediaType default-server warnings.

Bumps to 3.2.1-bryanlabs.2.
The OpenAPI validator runs with validateRequests:true and 404s any
path missing from seerr-api.yml. Audiobook/ebook/bookshelf routes are
not in the spec, so /api/v1/audiobook/*, /api/v1/ebook/*, and
/api/v1/settings/bookshelf returned 404 even with the routes
registered.

Set ignoreUndocumented:true so undocumented paths flow through to
their handlers. Documented paths are still validated.

Bumps to 3.2.1-bryanlabs.3.
Bookshelf (Readarr fork) /book/lookup treats the bare foreignBookId
as a free-text search term and returns no match. The Readarr lookup
convention for resolving a specific work is term=work:<id>. Without
that prefix, addBook never found the book it was meant to add and
returned 500.

Verified against bookshelf-audiobooks: term=work:340965 returns
"The Scarlet Pimpernel" by Emmuska Orczy.

Bumps to 3.2.1-bryanlabs.4.
Bookshelf /book/lookup does not include the author's foreignAuthorId,
which is required by POST /book ('Author.ForeignAuthorId must not be
empty' validation rejection). addBook now accepts either an explicit
foreignAuthorId or an authorName; when only the name is provided, it
calls /author/lookup, takes the top match, and merges that author into
the book payload.

The /audiobook/request and /ebook/request route handlers forward the
new foreignAuthorId / authorName fields. One of them is required
alongside foreignBookId.

Bumps to 3.2.1-bryanlabs.5.
Bookshelf's BookResource mapper unconditionally iterates Editions and
crashes with ArgumentNullException if the field is null on POST /book.
The /book/lookup response omits editions entirely, so the previous
addBook payload triggered that 500.

Build a single-element editions array from match.foreignEditionId when
the lookup doesn't supply one. Existing editions in the lookup are
preserved when present.

Bumps to 3.2.1-bryanlabs.6.
- Audiobook/ebook /request endpoints now create Media + MediaRequest
  rows so requests appear in Seerr's request log. Auto-approved for
  the requesting user.
- New GET /audiobook/info/:foreignBookId and /ebook/info/:foreignBookId
  for the request list to render book metadata.
- New /audiobooks and /ebooks pages with a search box, result tiles
  with covers/title/author, and a Request button per result.
- Sidebar nav entries (MusicalNoteIcon for audiobooks, BookOpenIcon
  for ebooks) below Series.
- RequestList/RequestItem branches on type: audiobook/ebook render via
  a new BookRequestItem (cover, title, author, status, retry/delete);
  movie/tv keep the existing render path.

Bumps to 3.2.1-bryanlabs.7.
Bookshelf returns 409 with "UNIQUE constraint failed:
Editions.ForeignEditionId" when a book/edition already exists. This
breaks idempotency: re-requesting the same book (different user, prior
test, etc.) failed the request even though Bookshelf already had the
book monitored.

addBook now detects the conflict, falls back to GET /book to find the
existing entry, optionally retriggers BookSearch, and returns it.

Bumps to 3.2.1-bryanlabs.8.
After successfully adding a book to Bookshelf, the route saved the
Media entity again to bump status to PROCESSING. That second save
triggered the OneToMany cascade on Media.requests, which orphaned
(NULL'd the mediaId on) the just-inserted MediaRequest row. The
listing endpoint joins media and the orphaned rows became invisible.

Switch to mediaRepository.update(id, partial) for the status bump:
no cascade, no orphaning. The new MediaRequest row keeps its FK and
shows up in the request log.

Bumps to 3.2.1-bryanlabs.9.
When Media is updated via repository.update() or QueryBuilder
.update().set().where() (partial update), TypeORM does not load
databaseEntity. The existing subscriber crashed in beforeUpdate at
event.databaseEntity.id because it assumed save() semantics. The
audiobook/ebook routes use partial update for the post-addBook status
bump, which surfaced this crash.

Bail out of before/afterUpdate when databaseEntity is missing.
Movies/TV continue to use save() upstream, so their behavior is
unchanged.

Bumps to 3.2.1-bryanlabs.10.
Add /audiobooks/:id and /ebooks/:id detail pages that mirror the movie
page structure (poster + blurred backdrop hero, status badge, title,
author/genre tags, ratings, overview, editions, author bio, sidebar
metadata, links).

Server:
- New BookDetails model + mapBookDetails helper (server/models/Book.ts)
- GET /api/v1/audiobook/:foreignBookId and /api/v1/ebook/:foreignBookId
  resolve the book via Bookshelf work:<id> lookup, do an /author/lookup
  to fill in author overview, and merge with the local Media row so
  mediaInfo (status, requests, mediaUrl, serviceUrl) is available.
- Media.setServiceUrl now produces a Bookshelf book URL for
  AUDIOBOOK/EBOOK so admins can deep-link to the bookshelf instance.

Client:
- BookDetails React component (~280 LOC) with overview, editions list,
  author bio, ratings, page count, request button, status-aware UI,
  Plex deep link, "Open in Bookshelf" admin link, hardcover.app
  external link.
- BookSearch tiles: cover and title now link to the detail page.
- RequestList/BookRequestItem: cover and title link to the detail page.

Bumps to 3.2.1-bryanlabs.11.
- New GET /api/v1/audiobook/:id/recommendations and matching ebook
  endpoint return up to 20 other books by the same author (resolved
  from authorTitle parsing).
- BookDetails renders a "More by <author>" grid below the overview,
  cards link to the corresponding detail page.
- Report Issue button on BookDetails opens the existing IssueModal
  for the media row, gated on CREATE_ISSUES/MANAGE_ISSUES.

Bumps to 3.2.1-bryanlabs.12.
- Move audiobookRoutes /queue and ebookRoutes /queue handlers ahead of
  the /:foreignBookId handler so Express doesn't shadow them with the
  parametric route.
- Add BookRequestCard for the discover slider's RecentRequestsSlider
  (which uses RequestCard rather than RequestList/RequestItem). Books
  in that slider now render with cover, title, author, year, and a
  status badge, and link through to the book detail page.
Admin actions on the BookDetails page (gated on MANAGE_REQUESTS):
- Mark as Available / Pending / Processing — reuses the existing
  POST /api/v1/media/:id/:status endpoint (works for books since the
  MediaSubscriber now guards against missing databaseEntity).
- Delete from Bookshelf — new DELETE /api/v1/media/:id/bookfile route
  that calls BookshelfAPI.removeBook against the recorded
  externalServiceId on the configured bookshelf instance.
- Refresh — re-pulls book details from /api/v1/{audiobook,ebook}/:id.

Notification path for books:
- MediaRequest.sendNotification now recognizes AUDIOBOOK/EBOOK as
  valid types and sends a minimal notification (subject names the
  Hardcover work id, no TMDB lookup) so existing email/Slack/etc
  notifier agents fire on book request events.

Bookshelf interface cleanup:
- BookshelfBook now declares remoteCover, ratings, genres, links, and
  statistics directly, removing the ugly `'X' in book` casts in
  mapBookDetails.

Bumps to 3.2.1-bryanlabs.13.
Polls each configured Bookshelf instance every 5 minutes (default
schedule '0 */5 * * * *'). For each Media row in PENDING/PROCESSING/
PARTIALLY_AVAILABLE state with a recorded externalServiceId, looks up
the corresponding book and flips Media.status to AVAILABLE when
book.statistics.bookFileCount > 0 (i.e., the grab landed and the book
imported into the library).

The MediaSubscriber.afterUpdate hook then transitions the related
MediaRequest to COMPLETED and triggers the MEDIA_AVAILABLE
notification path that the previous commit added for AUDIOBOOK/EBOOK,
so users get email/Slack/etc notifications when their requested book
is ready.

Single-pass per run; reuses one /book listing per Bookshelf instance
to keep poll cost predictable.

Bumps to 3.2.1-bryanlabs.14.
- Server: /api/v1/request now accepts mediaType=audiobook and
  mediaType=ebook in addition to movie/tv/all.
- UI: the request list media-type dropdown gets two new options so
  users can filter the log to just their book requests.
MediaRequestSubscriber.afterUpdate now invokes the generic
sendNotification path for AUDIOBOOK/EBOOK requests transitioning
to COMPLETED, mirroring the movie/tv branches.
Add POST /api/v1/audiobook/:foreignBookId/search and ebook variant
that look up the Media row, find the configured Bookshelf instance,
and call BookSearch via Bookshelf's command endpoint.

Surface as a "Search Indexers" admin button in BookDetails. Useful
when a previous search returned 0 results (e.g., quality profile too
restrictive) and the admin has fixed the configuration and wants to
re-trigger.
- /:foreignBookId/search route falsy-checked media.serviceId, but
  Bookshelf instances are 0-indexed so the audiobook server has
  serviceId=0 which fails the truthy check. Use explicit nullish.
- seerr-api.yml: extend the request listing's mediaType enum to
  include audiobook and ebook so the OpenAPI validator accepts the
  new filter values surfaced by the UI dropdown.
Move the Bookshelf addBook call out of the request route into a new
MediaRequestSubscriber.sendToBookshelf hook, mirroring the existing
sendToRadarr/sendToSonarr pattern.

The route now:
- Auto-approves only for users with AUTO_APPROVE or MANAGE_REQUESTS;
  otherwise creates a PENDING request (admin must approve).
- Saves the MediaRequest and returns immediately. The afterInsert
  subscriber fires sendToBookshelf for APPROVED requests.

The subscriber:
- Skips books that already have externalServiceId set (idempotent).
- Looks up the book by foreignBookId via Bookshelf, runs author
  resolution, calls addBook, and updates Media.status to PROCESSING.
- On addBook failure, marks the request FAILED and fires
  Notification.MEDIA_FAILED.

This means non-admin family members now create PENDING requests that
require admin review before grabs fire, matching the movie/tv flow.
Admin auto-approval continues to work transparently.

Bumps to 3.2.1-bryanlabs.18.
I had used the singular 'media-attribute' for the author/genres/rating
spans on the BookDetails page, but the CSS rule is named
'media-attributes' (plural, matches MovieDetails usage). The lines
rendered without the metadata-strip styling.
…olver

Two related fixes:

1) Multi-candidate author resolver. Bookshelf returns authorTitle in
   "lastname, firstname [extras] Title" form. The previous parser took
   the first 4 tokens which gave "Weir Andy" — Bookshelf's author
   /author/lookup wants "Andy Weir". guessAuthorCandidates now
   produces several normalized candidates (reordered, last-only,
   trailing chunk) and sendToBookshelf tries each against
   /author/lookup until one resolves a foreignAuthorId. addBook is
   then called with the resolved id directly. Closes the failure path
   visible on previous Hail Mary / Martian requests.

2) BookRequestModal. New /api/v1/{audiobook,ebook}/profiles endpoint
   returns the configured quality + metadata profiles for the active
   Bookshelf instance. The new BookRequestModal opens off the Request
   button on BookDetails and BookSearch tiles, lets the user pick a
   Quality Profile (so audiobook requesters can switch between
   Spoken (m4b) and Spoken (mp3) at request time), and POSTs the
   selection. The route stores the override on MediaRequest.profileId
   and sendToBookshelf passes that to addBook instead of the server
   default. Mirrors the Radarr/Sonarr request modal pattern.
Closes INF-66, INF-67, INF-68, INF-69, INF-70, INF-71, INF-73.

- Hardcover-direct detail path replaces Bookshelf chain (cold load 100ms vs 10-20s)
- Per-user request quotas for audiobooks and ebooks
- Top-bar global search includes books
- Browse/discover page with Hardcover-matching sort and multi-select filters
  (Genres, Moods, Tags via react-select, plus Release Date / Pages / Rating /
  Min Readers); status badges on tiles
- Detail page shows author bio, recommendations, real "more by author" via
  Hardcover contributions query
- Mobile bottom-nav entries for Audiobooks and Ebooks
- Per-user Hardcover Want-to-Read auto-request, attributed to the requesting
  account, honors quotas, 60s cron
- Notification subjects enriched with real title, year, and author
- Bookshelf monitor scope fix so requesting one book no longer pulls the
  entire author bibliography
- Update-available banner removed (fork does not track upstream)
- Settings page metadata block with health badges
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.

1 participant