feat: Phase 1 audiobook + ebook backend (INF-66)#1
Open
feat: Phase 1 audiobook + ebook backend (INF-66)#1
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
What's deferred to Phase 2
Test plan
```
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
```
```
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.
Caveats to flag for review
Closes INF-66 once Phase 1 smoke tests pass.