From ba0d8a9c4b152185aad762a625102ff56f0c7af9 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Fri, 24 Apr 2026 23:13:14 -0400 Subject: [PATCH 01/23] feat(audiobooks,ebooks): add Bookshelf backend support (INF-66 Phase 1) 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. --- package.json | 2 +- server/api/servarr/bookshelf.ts | 264 ++++++++++++++++++ server/constants/media.ts | 2 + server/interfaces/api/blocklistInterfaces.ts | 3 +- server/interfaces/api/discoverInterfaces.ts | 4 +- server/lib/cache.ts | 2 + server/lib/settings/index.ts | 22 ++ server/routes/audiobook.ts | 117 ++++++++ server/routes/discover.ts | 2 +- server/routes/ebook.ts | 113 ++++++++ server/routes/index.ts | 4 + server/routes/request.ts | 39 +++ server/routes/settings/bookshelf.ts | 118 ++++++++ server/routes/settings/index.ts | 2 + server/routes/user/index.ts | 2 +- src/components/Common/ListView/index.tsx | 2 +- .../Discover/PlexWatchlistSlider/index.tsx | 2 +- .../Discover/RecentlyAddedSlider/index.tsx | 2 +- src/components/RequestBlock/index.tsx | 2 +- src/components/RequestCard/index.tsx | 6 +- .../RequestList/RequestItem/index.tsx | 6 +- src/components/UserProfile/index.tsx | 4 +- 22 files changed, 703 insertions(+), 17 deletions(-) create mode 100644 server/api/servarr/bookshelf.ts create mode 100644 server/routes/audiobook.ts create mode 100644 server/routes/ebook.ts create mode 100644 server/routes/settings/bookshelf.ts diff --git a/package.json b/package.json index 964032adbe..b39dd09efd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "0.1.0", + "version": "3.2.1-bryanlabs.1", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/api/servarr/bookshelf.ts b/server/api/servarr/bookshelf.ts new file mode 100644 index 0000000000..6b744e9432 --- /dev/null +++ b/server/api/servarr/bookshelf.ts @@ -0,0 +1,264 @@ +import logger from '@server/logger'; +import ServarrBase from './base'; + +export interface BookshelfMetadataProfile { + id: number; + name: string; +} + +export interface BookshelfAuthorImage { + coverType: string; + url: string; + remoteUrl?: string; +} + +export interface BookshelfAuthor { + id?: number; + authorName: string; + authorNameLastFirst?: string; + sortName?: string; + titleSlug: string; + overview?: string; + path?: string; + foreignAuthorId: string; + metadataProfileId?: number; + qualityProfileId?: number; + monitored?: boolean; + monitorNewItems?: 'all' | 'none'; + rootFolderPath?: string; + tags?: number[]; + images?: BookshelfAuthorImage[]; +} + +export interface BookshelfBook { + id?: number; + title: string; + titleSlug: string; + foreignBookId: string; + foreignEditionId?: string; + overview?: string; + authorTitle?: string; + author?: BookshelfAuthor; + authorId?: number; + releaseDate?: string; + pageCount?: number; + monitored?: boolean; + grabbed?: boolean; + qualityProfileId?: number; + metadataProfileId?: number; + rootFolderPath?: string; + addOptions?: { + searchForNewBook?: boolean; + addType?: 'automatic' | 'manual'; + }; + images?: BookshelfAuthorImage[]; + editions?: { + id?: number; + title?: string; + foreignEditionId?: string; + isbn13?: string; + asin?: string; + format?: string; + language?: string; + monitored?: boolean; + }[]; +} + +export interface AddBookOptions { + foreignBookId: string; + foreignAuthorId?: string; + profileId: number; + metadataProfileId: number; + rootFolderPath: string; + monitored?: boolean; + tags?: number[]; + searchNow?: boolean; +} + +/** + * Bookshelf is a Readarr fork. Its API shape is v1 and mirrors Sonarr v3 for most + * endpoints (system, qualityProfile, rootfolder, tag, queue, command) but replaces + * the series/episode model with author/book. Two instances run in the cluster: + * bookshelf-audiobooks and bookshelf-ebooks. This single class serves both; the + * difference is purely the configured instance (URL + API key + default profiles). + */ +class BookshelfAPI extends ServarrBase<{ + bookId: number; + authorId: number; + book: BookshelfBook; +}> { + constructor({ url, apiKey }: { url: string; apiKey: string }) { + super({ url, apiKey, apiName: 'Bookshelf', cacheName: 'bookshelf' }); + } + + public async getBooks(): Promise { + try { + const response = await this.axios.get('/book'); + return response.data; + } catch (e) { + throw new Error(`[Bookshelf] Failed to retrieve books: ${e.message}`, { + cause: e, + }); + } + } + + public async getBookById(id: number): Promise { + try { + const response = await this.axios.get(`/book/${id}`); + return response.data; + } catch (e) { + throw new Error( + `[Bookshelf] Failed to retrieve book by ID: ${e.message}`, + { cause: e } + ); + } + } + + public async searchBook(term: string): Promise { + try { + const response = await this.axios.get('/book/lookup', { + params: { term }, + }); + return response.data; + } catch (e) { + logger.error('Error searching Bookshelf for book', { + label: 'Bookshelf API', + errorMessage: e.message, + term, + }); + throw new Error('No book found', { cause: e }); + } + } + + public async searchAuthor(term: string): Promise { + try { + const response = await this.axios.get( + '/author/lookup', + { params: { term } } + ); + return response.data; + } catch (e) { + logger.error('Error searching Bookshelf for author', { + label: 'Bookshelf API', + errorMessage: e.message, + term, + }); + throw new Error('No author found', { cause: e }); + } + } + + public async getMetadataProfiles(): Promise { + try { + const data = await this.getRolling( + '/metadataprofile', + undefined, + 3600 + ); + return data; + } catch (e) { + throw new Error( + `[Bookshelf] Failed to retrieve metadata profiles: ${e.message}`, + { cause: e } + ); + } + } + + public async addBook(options: AddBookOptions): Promise { + try { + // Bookshelf (Readarr) requires the author to exist before a book can be + // added. The /book/lookup response includes the embedded author with + // foreignAuthorId (Hardcover author id via rreading-glasses proxy). If we + // already have that lookup result, POST /book with the embedded author + // block; Readarr will upsert the author. + const lookup = await this.searchBook(options.foreignBookId); + const match = + lookup.find((b) => b.foreignBookId === options.foreignBookId) ?? + lookup[0]; + + if (!match) { + throw new Error( + `[Bookshelf] Lookup returned no match for ${options.foreignBookId}` + ); + } + + const payload: Partial & Record = { + ...match, + qualityProfileId: options.profileId, + metadataProfileId: options.metadataProfileId, + rootFolderPath: options.rootFolderPath, + monitored: options.monitored ?? true, + author: { + ...(match.author ?? { + authorName: '', + titleSlug: '', + foreignAuthorId: '', + }), + qualityProfileId: options.profileId, + metadataProfileId: options.metadataProfileId, + rootFolderPath: options.rootFolderPath, + monitored: options.monitored ?? true, + }, + addOptions: { + searchForNewBook: options.searchNow ?? true, + addType: 'automatic', + }, + }; + + if (options.tags) { + payload.tags = options.tags; + } + + const response = await this.axios.post('/book', payload); + + if (response.data.id) { + logger.info('Bookshelf accepted request', { label: 'Bookshelf' }); + } else { + logger.error('Failed to add book to Bookshelf', { + label: 'Bookshelf', + options, + }); + throw new Error('Failed to add book to Bookshelf'); + } + + return response.data; + } catch (e) { + logger.error('Something went wrong while adding a book to Bookshelf.', { + label: 'Bookshelf API', + errorMessage: e.message, + options, + response: e?.response?.data, + }); + throw new Error('Failed to add book', { cause: e }); + } + } + + public searchBookCommand = async (bookId: number): Promise => { + logger.info('Executing Bookshelf book search command.', { + label: 'Bookshelf API', + bookId, + }); + try { + await this.runCommand('BookSearch', { bookIds: [bookId] }); + } catch (e) { + logger.error('Failed to trigger Bookshelf book search.', { + label: 'Bookshelf API', + errorMessage: e.message, + bookId, + }); + } + }; + + public removeBook = async (bookId: number): Promise => { + try { + await this.axios.delete(`/book/${bookId}`, { + params: { deleteFiles: true, addImportListExclusion: false }, + }); + } catch (e) { + throw new Error(`[Bookshelf] Failed to remove book: ${e.message}`, { + cause: e, + }); + } + }; +} + +export default BookshelfAPI; diff --git a/server/constants/media.ts b/server/constants/media.ts index 170109fb5c..2f76397550 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -9,6 +9,8 @@ export enum MediaRequestStatus { export enum MediaType { MOVIE = 'movie', TV = 'tv', + AUDIOBOOK = 'audiobook', + EBOOK = 'ebook', } export enum MediaStatus { diff --git a/server/interfaces/api/blocklistInterfaces.ts b/server/interfaces/api/blocklistInterfaces.ts index 58e3ebe474..cf0f8b8653 100644 --- a/server/interfaces/api/blocklistInterfaces.ts +++ b/server/interfaces/api/blocklistInterfaces.ts @@ -1,9 +1,10 @@ +import type { MediaType } from '@server/constants/media'; import type { User } from '@server/entity/User'; import type { PaginatedResponse } from '@server/interfaces/api/common'; export interface BlocklistItem { tmdbId: number; - mediaType: 'movie' | 'tv'; + mediaType: MediaType; title?: string; createdAt?: Date; user?: User; diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts index 6738cbb5fd..329e642028 100644 --- a/server/interfaces/api/discoverInterfaces.ts +++ b/server/interfaces/api/discoverInterfaces.ts @@ -4,11 +4,13 @@ export interface GenreSliderItem { backdrops: string[]; } +import type { MediaType } from '@server/constants/media'; + export interface WatchlistItem { id: number; ratingKey: string; tmdbId: number; - mediaType: 'movie' | 'tv'; + mediaType: MediaType; title: string; } diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 64b5c79ee8..0ec8a31171 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -4,6 +4,7 @@ export type AvailableCacheIds = | 'tmdb' | 'radarr' | 'sonarr' + | 'bookshelf' | 'rt' | 'imdb' | 'github' @@ -50,6 +51,7 @@ class CacheManager { }), radarr: new Cache('radarr', 'Radarr API'), sonarr: new Cache('sonarr', 'Sonarr API'), + bookshelf: new Cache('bookshelf', 'Bookshelf (Readarr) API'), rt: new Cache('rt', 'Rotten Tomatoes API', { stdTtl: 43200, checkPeriod: 60 * 30, diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index bb287c8e04..25e5048298 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -103,6 +103,18 @@ export interface SonarrSettings extends DVRSettings { monitorNewItems: 'all' | 'none'; } +/** + * Bookshelf is a Readarr fork. One Seerr instance fronts two Bookshelf + * instances: one for audiobooks (mediaType 'audiobook') and one for ebooks + * (mediaType 'ebook'). Each stores its own metadata profile in addition to + * quality profile since Readarr splits those concerns. + */ +export interface BookshelfSettings extends DVRSettings { + mediaType: 'audiobook' | 'ebook'; + activeMetadataProfileId: number; + activeMetadataProfileName: string; +} + interface Quota { quotaLimit?: number; quotaDays?: number; @@ -378,6 +390,7 @@ export interface AllSettings { tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; + bookshelf: BookshelfSettings[]; public: PublicSettings; notifications: NotificationSettings; jobs: Record; @@ -454,6 +467,7 @@ class Settings { }, radarr: [], sonarr: [], + bookshelf: [], public: { initialized: false, }, @@ -691,6 +705,14 @@ class Settings { this.data.sonarr = data; } + get bookshelf(): BookshelfSettings[] { + return this.data.bookshelf; + } + + set bookshelf(data: BookshelfSettings[]) { + this.data.bookshelf = data; + } + get public(): PublicSettings { return this.data.public; } diff --git a/server/routes/audiobook.ts b/server/routes/audiobook.ts new file mode 100644 index 0000000000..7db6a75c18 --- /dev/null +++ b/server/routes/audiobook.ts @@ -0,0 +1,117 @@ +import BookshelfAPI from '@server/api/servarr/bookshelf'; +import type { BookshelfSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; + +/** + * Phase 1 routes for audiobook search + direct add-to-Bookshelf. + * + * These bypass the MediaRequest approval workflow intentionally: the end state + * for this phase is that a curl POST lands the book in bookshelf-audiobooks so + * qBittorrent starts grabbing. Integrating audiobook requests into the full + * MediaRequest entity, approvals, and notification pipeline is Phase 2 scope. + */ +const audiobookRoutes = Router(); + +function findAudiobookServer(): BookshelfSettings | undefined { + const servers = getSettings().bookshelf.filter( + (s) => s.mediaType === 'audiobook' + ); + return servers.find((s) => s.isDefault) ?? servers[0]; +} + +function getClient(server: BookshelfSettings): BookshelfAPI { + return new BookshelfAPI({ + apiKey: server.apiKey, + url: BookshelfAPI.buildUrl(server, '/api/v1'), + }); +} + +audiobookRoutes.get('/search', async (req, res, next) => { + const term = typeof req.query.q === 'string' ? req.query.q : ''; + if (!term) { + return next({ status: 400, message: 'Missing q parameter' }); + } + + const server = findAudiobookServer(); + if (!server) { + return next({ + status: 503, + message: 'No audiobook Bookshelf server is configured', + }); + } + + try { + const results = await getClient(server).searchBook(term); + return res.status(200).json({ term, results }); + } catch (e) { + logger.error('Audiobook search failed', { + label: 'API', + errorMessage: e.message, + term, + }); + return next({ status: 500, message: 'Audiobook search failed' }); + } +}); + +audiobookRoutes.post('/request', async (req, res, next) => { + const { foreignBookId, searchNow } = req.body as { + foreignBookId?: string; + searchNow?: boolean; + }; + + if (!foreignBookId) { + return next({ status: 400, message: 'foreignBookId is required' }); + } + + const server = findAudiobookServer(); + if (!server) { + return next({ + status: 503, + message: 'No audiobook Bookshelf server is configured', + }); + } + + try { + const client = getClient(server); + const book = await client.addBook({ + foreignBookId, + profileId: server.activeProfileId, + metadataProfileId: server.activeMetadataProfileId, + rootFolderPath: server.activeDirectory, + monitored: true, + searchNow: searchNow ?? true, + }); + + return res.status(201).json({ + serverId: server.id, + book, + }); + } catch (e) { + logger.error('Audiobook request failed', { + label: 'API', + errorMessage: e.message, + foreignBookId, + }); + return next({ status: 500, message: 'Audiobook request failed' }); + } +}); + +audiobookRoutes.get('/queue', async (req, res, next) => { + const server = findAudiobookServer(); + if (!server) { + return next({ + status: 503, + message: 'No audiobook Bookshelf server is configured', + }); + } + try { + const queue = await getClient(server).getQueue(); + return res.status(200).json({ serverId: server.id, queue }); + } catch { + return next({ status: 500, message: 'Failed to retrieve audiobook queue' }); + } +}); + +export default audiobookRoutes; diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 7f250e6a6e..9b280340ef 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -975,7 +975,7 @@ discoverRoutes.get, WatchlistResponse>( id: item.tmdbId, ratingKey: item.ratingKey, title: item.title, - mediaType: item.type === 'show' ? 'tv' : 'movie', + mediaType: (item.type === 'show' ? 'tv' : 'movie') as MediaType, tmdbId: item.tmdbId, })), }); diff --git a/server/routes/ebook.ts b/server/routes/ebook.ts new file mode 100644 index 0000000000..a2ec4a510c --- /dev/null +++ b/server/routes/ebook.ts @@ -0,0 +1,113 @@ +import BookshelfAPI from '@server/api/servarr/bookshelf'; +import type { BookshelfSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; + +/** + * Phase 1 routes for ebook search + direct add-to-Bookshelf. + * Mirror of audiobook.ts, pointing at the bookshelf-ebooks instance. + */ +const ebookRoutes = Router(); + +function findEbookServer(): BookshelfSettings | undefined { + const servers = getSettings().bookshelf.filter( + (s) => s.mediaType === 'ebook' + ); + return servers.find((s) => s.isDefault) ?? servers[0]; +} + +function getClient(server: BookshelfSettings): BookshelfAPI { + return new BookshelfAPI({ + apiKey: server.apiKey, + url: BookshelfAPI.buildUrl(server, '/api/v1'), + }); +} + +ebookRoutes.get('/search', async (req, res, next) => { + const term = typeof req.query.q === 'string' ? req.query.q : ''; + if (!term) { + return next({ status: 400, message: 'Missing q parameter' }); + } + + const server = findEbookServer(); + if (!server) { + return next({ + status: 503, + message: 'No ebook Bookshelf server is configured', + }); + } + + try { + const results = await getClient(server).searchBook(term); + return res.status(200).json({ term, results }); + } catch (e) { + logger.error('Ebook search failed', { + label: 'API', + errorMessage: e.message, + term, + }); + return next({ status: 500, message: 'Ebook search failed' }); + } +}); + +ebookRoutes.post('/request', async (req, res, next) => { + const { foreignBookId, searchNow } = req.body as { + foreignBookId?: string; + searchNow?: boolean; + }; + + if (!foreignBookId) { + return next({ status: 400, message: 'foreignBookId is required' }); + } + + const server = findEbookServer(); + if (!server) { + return next({ + status: 503, + message: 'No ebook Bookshelf server is configured', + }); + } + + try { + const client = getClient(server); + const book = await client.addBook({ + foreignBookId, + profileId: server.activeProfileId, + metadataProfileId: server.activeMetadataProfileId, + rootFolderPath: server.activeDirectory, + monitored: true, + searchNow: searchNow ?? true, + }); + + return res.status(201).json({ + serverId: server.id, + book, + }); + } catch (e) { + logger.error('Ebook request failed', { + label: 'API', + errorMessage: e.message, + foreignBookId, + }); + return next({ status: 500, message: 'Ebook request failed' }); + } +}); + +ebookRoutes.get('/queue', async (req, res, next) => { + const server = findEbookServer(); + if (!server) { + return next({ + status: 503, + message: 'No ebook Bookshelf server is configured', + }); + } + try { + const queue = await getClient(server).getQueue(); + return res.status(200).json({ serverId: server.id, queue }); + } catch { + return next({ status: 500, message: 'Failed to retrieve ebook queue' }); + } +}); + +export default ebookRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index f701acf968..2458ab66f8 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -28,10 +28,12 @@ import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; import { isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; +import audiobookRoutes from './audiobook'; import authRoutes from './auth'; import blocklistRoutes from './blocklist'; import collectionRoutes from './collection'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; +import ebookRoutes from './ebook'; import issueRoutes from './issue'; import issueCommentRoutes from './issueComment'; import mediaRoutes from './media'; @@ -165,6 +167,8 @@ router.use( ); router.use('/movie', isAuthenticated(), movieRoutes); router.use('/tv', isAuthenticated(), tvRoutes); +router.use('/audiobook', isAuthenticated(), audiobookRoutes); +router.use('/ebook', isAuthenticated(), ebookRoutes); router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); router.use('/collection', isAuthenticated(), collectionRoutes); diff --git a/server/routes/request.ts b/server/routes/request.ts index fafa90692e..289654fffe 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -1,3 +1,4 @@ +import BookshelfAPI from '@server/api/servarr/bookshelf'; import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { @@ -213,6 +214,23 @@ requestRoutes.get, RequestResultsResponse>( }) ); + // get all quality profiles for every configured bookshelf server + // (covers both audiobook and ebook instances) + const bookshelfServers = await Promise.all( + settings.bookshelf.map(async (bookshelfSetting) => { + const bookshelf = new BookshelfAPI({ + apiKey: bookshelfSetting.apiKey, + url: BookshelfAPI.buildUrl(bookshelfSetting, '/api/v1'), + }); + + return { + id: bookshelfSetting.id, + mediaType: bookshelfSetting.mediaType, + profiles: await bookshelf.getProfiles().catch(() => undefined), + }; + }) + ); + // add profile names to the media requests, with undefined if not found let mappedRequests = requests.map((r) => { switch (r.type) { @@ -234,12 +252,24 @@ requestRoutes.get, RequestResultsResponse>( ?.profiles?.find((profile) => profile.id === r.profileId)?.name, }; } + case MediaType.AUDIOBOOK: + case MediaType.EBOOK: { + return { + ...r, + profileName: bookshelfServers + .find((serverr) => serverr.id === r.serverId) + ?.profiles?.find((profile) => profile.id === r.profileId)?.name, + }; + } } }); // add canRemove prop if user has permission if (req.user?.hasPermission(Permission.MANAGE_REQUESTS)) { mappedRequests = mappedRequests.map((r) => { + if (!r) { + return r; + } switch (r.type) { case MediaType.MOVIE: { return { @@ -263,6 +293,15 @@ requestRoutes.get, RequestResultsResponse>( ), }; } + case MediaType.AUDIOBOOK: + case MediaType.EBOOK: { + return { + ...r, + canRemove: bookshelfServers.some( + (server) => server.id === r.media.serviceId + ), + }; + } } }); } diff --git a/server/routes/settings/bookshelf.ts b/server/routes/settings/bookshelf.ts new file mode 100644 index 0000000000..56669149e3 --- /dev/null +++ b/server/routes/settings/bookshelf.ts @@ -0,0 +1,118 @@ +import BookshelfAPI from '@server/api/servarr/bookshelf'; +import type { BookshelfSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const bookshelfRoutes = Router(); + +bookshelfRoutes.get('/', (_req, res) => { + const settings = getSettings(); + res.status(200).json(settings.bookshelf); +}); + +bookshelfRoutes.post('/', async (req, res) => { + const settings = getSettings(); + + const newBookshelf = req.body as BookshelfSettings; + const lastItem = settings.bookshelf[settings.bookshelf.length - 1]; + newBookshelf.id = lastItem ? lastItem.id + 1 : 0; + + // Only clear isDefault from other instances of the same mediaType + if (req.body.isDefault) { + settings.bookshelf + .filter((inst) => inst.mediaType === newBookshelf.mediaType) + .forEach((inst) => { + inst.isDefault = false; + }); + } + + settings.bookshelf = [...settings.bookshelf, newBookshelf]; + await settings.save(); + + return res.status(201).json(newBookshelf); +}); + +bookshelfRoutes.post('/test', async (req, res, next) => { + try { + const bookshelf = new BookshelfAPI({ + apiKey: req.body.apiKey, + url: BookshelfAPI.buildUrl(req.body, '/api/v1'), + }); + + const systemStatus = await bookshelf.getSystemStatus(); + const urlBase = systemStatus.urlBase; + const profiles = await bookshelf.getProfiles(); + const metadataProfiles = await bookshelf.getMetadataProfiles(); + const folders = await bookshelf.getRootFolders(); + const tags = await bookshelf.getTags(); + + return res.status(200).json({ + profiles, + metadataProfiles, + rootFolders: folders.map((folder) => ({ + id: folder.id, + path: folder.path, + })), + tags, + urlBase, + }); + } catch (e) { + logger.error('Failed to test Bookshelf', { + label: 'Bookshelf', + message: e.message, + }); + next({ status: 500, message: 'Failed to connect to Bookshelf' }); + } +}); + +bookshelfRoutes.put<{ id: string }>('/:id', async (req, res) => { + const settings = getSettings(); + + const index = settings.bookshelf.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (index === -1) { + return res + .status(404) + .json({ status: '404', message: 'Settings instance not found' }); + } + + if (req.body.isDefault) { + settings.bookshelf + .filter((inst) => inst.mediaType === req.body.mediaType) + .forEach((inst) => { + inst.isDefault = false; + }); + } + + settings.bookshelf[index] = { + ...req.body, + id: Number(req.params.id), + } as BookshelfSettings; + await settings.save(); + + return res.status(200).json(settings.bookshelf[index]); +}); + +bookshelfRoutes.delete<{ id: string }>('/:id', async (req, res) => { + const settings = getSettings(); + + const index = settings.bookshelf.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (index === -1) { + return res + .status(404) + .json({ status: '404', message: 'Settings instance not found' }); + } + + const removed = settings.bookshelf.splice(index, 1); + await settings.save(); + + return res.status(200).json(removed[0]); +}); + +export default bookshelfRoutes; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 12b5746595..9553f00918 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -39,6 +39,7 @@ import { rescheduleJob } from 'node-schedule'; import path from 'path'; import semver from 'semver'; import { URL } from 'url'; +import bookshelfRoutes from './bookshelf'; import metadataRoutes from './metadata'; import notificationRoutes from './notifications'; import radarrRoutes from './radarr'; @@ -49,6 +50,7 @@ const settingsRoutes = Router(); settingsRoutes.use('/notifications', notificationRoutes); settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes); +settingsRoutes.use('/bookshelf', bookshelfRoutes); settingsRoutes.use('/discover', discoverSettingRoutes); settingsRoutes.use('/metadatas', metadataRoutes); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 765215a30f..b7404648e0 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -974,7 +974,7 @@ router.get<{ id: string }, WatchlistResponse>( id: item.tmdbId, ratingKey: item.ratingKey, title: item.title, - mediaType: item.type === 'show' ? 'tv' : 'movie', + mediaType: (item.type === 'show' ? 'tv' : 'movie') as MediaType, tmdbId: item.tmdbId, })), }); diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 142d8f77e2..3d6462a34a 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -56,7 +56,7 @@ const ListView = ({ { id={item.tmdbId} key={`watchlist-slider-item-${item.ratingKey}`} tmdbId={item.tmdbId} - type={item.mediaType} + type={item.mediaType as 'movie' | 'tv'} isAddedToWatchlist={true} /> ))} diff --git a/src/components/Discover/RecentlyAddedSlider/index.tsx b/src/components/Discover/RecentlyAddedSlider/index.tsx index 76f4706017..0992ca38e8 100644 --- a/src/components/Discover/RecentlyAddedSlider/index.tsx +++ b/src/components/Discover/RecentlyAddedSlider/index.tsx @@ -43,7 +43,7 @@ const RecentlyAddedSlider = () => { id={item.id} tmdbId={item.tmdbId} tvdbId={item.tvdbId} - type={item.mediaType} + type={item.mediaType as 'movie' | 'tv'} /> ))} /> diff --git a/src/components/RequestBlock/index.tsx b/src/components/RequestBlock/index.tsx index 5ea29c3d56..6bff75763e 100644 --- a/src/components/RequestBlock/index.tsx +++ b/src/components/RequestBlock/index.tsx @@ -81,7 +81,7 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => { setShowEditModal(false)} diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 8064c22a8d..739fec061a 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -167,7 +167,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => { ).length > 0 } is4k={requestData.is4k} - mediaType={requestData.type} + mediaType={requestData.type as 'movie' | 'tv' | undefined} plexUrl={requestData.is4k ? plexUrl4k : plexUrl} serviceUrl={ requestData.is4k @@ -332,7 +332,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { setShowEditModal(false)} @@ -472,7 +472,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { } is4k={requestData.is4k} tmdbId={requestData.media.tmdbId} - mediaType={requestData.type} + mediaType={requestData.type as 'movie' | 'tv' | undefined} plexUrl={requestData.is4k ? plexUrl4k : plexUrl} serviceUrl={ requestData.is4k diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index fc43fad2fb..335b9be3af 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -154,7 +154,7 @@ const RequestItemError = ({ ).length > 0 } is4k={requestData.is4k} - mediaType={requestData.type} + mediaType={requestData.type as 'movie' | 'tv' | undefined} plexUrl={requestData.is4k ? plexUrl4k : plexUrl} serviceUrl={ requestData.is4k @@ -407,7 +407,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { setShowEditModal(false)} @@ -543,7 +543,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { } is4k={requestData.is4k} tmdbId={requestData.media.tmdbId} - mediaType={requestData.type} + mediaType={requestData.type as 'movie' | 'tv' | undefined} plexUrl={requestData.is4k ? plexUrl4k : plexUrl} serviceUrl={ requestData.is4k diff --git a/src/components/UserProfile/index.tsx b/src/components/UserProfile/index.tsx index 64594f8308..d3d93d9b99 100644 --- a/src/components/UserProfile/index.tsx +++ b/src/components/UserProfile/index.tsx @@ -369,7 +369,7 @@ const UserProfile = () => { id={item.tmdbId} key={`watchlist-slider-item-${item.ratingKey}`} tmdbId={item.tmdbId} - type={item.mediaType} + type={item.mediaType as 'movie' | 'tv'} /> ))} /> @@ -395,7 +395,7 @@ const UserProfile = () => { id={item.id} tmdbId={item.tmdbId} tvdbId={item.tvdbId} - type={item.mediaType} + type={item.mediaType as 'movie' | 'tv'} /> ))} /> From f89c62315488ff202a999ddfca9111cb5d2c3384 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Fri, 24 Apr 2026 23:38:20 -0400 Subject: [PATCH 02/23] feat(settings): add Bookshelf admin UI to /settings/services (INF-66) 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. --- package.json | 2 +- .../Settings/BookshelfModal/index.tsx | 675 ++++++++++++++++++ src/components/Settings/SettingsServices.tsx | 182 ++++- 3 files changed, 855 insertions(+), 4 deletions(-) create mode 100644 src/components/Settings/BookshelfModal/index.tsx diff --git a/package.json b/package.json index b39dd09efd..38f65092ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.1", + "version": "3.2.1-bryanlabs.2", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/src/components/Settings/BookshelfModal/index.tsx b/src/components/Settings/BookshelfModal/index.tsx new file mode 100644 index 0000000000..99b3b6739b --- /dev/null +++ b/src/components/Settings/BookshelfModal/index.tsx @@ -0,0 +1,675 @@ +import Modal from '@app/components/Common/Modal'; +import SensitiveInput from '@app/components/Common/SensitiveInput'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { isValidURL } from '@app/utils/urlValidationHelper'; +import { Transition } from '@headlessui/react'; +import type { BookshelfSettings } from '@server/lib/settings'; +import axios from 'axios'; +import { Field, Formik } from 'formik'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import * as Yup from 'yup'; + +interface BookshelfTestResponse { + profiles: { id: number; name: string }[]; + metadataProfiles: { id: number; name: string }[]; + rootFolders: { id: number; path: string }[]; + tags: { id: number; label: string }[]; + urlBase?: string; +} + +const messages = defineMessages('components.Settings.BookshelfModal', { + createbookshelf: 'Add New Bookshelf Server', + editbookshelf: 'Edit Bookshelf Server', + validationNameRequired: 'You must provide a server name', + validationHostnameRequired: 'You must provide a valid hostname or IP address', + validationPortRequired: 'You must provide a valid port number', + validationApiKeyRequired: 'You must provide an API key', + validationRootFolderRequired: 'You must select a root folder', + validationProfileRequired: 'You must select a quality profile', + validationMetadataProfileRequired: 'You must select a metadata profile', + validationMediaTypeRequired: 'You must select a media type', + toastBookshelfTestSuccess: 'Bookshelf connection established successfully!', + toastBookshelfTestFailure: 'Failed to connect to Bookshelf.', + add: 'Add Server', + defaultserver: 'Default Server', + servername: 'Server Name', + mediaType: 'Media Type', + mediaTypeAudiobook: 'Audiobook', + mediaTypeEbook: 'Ebook', + mediaTypeHelp: + 'Whether this Bookshelf instance handles audiobooks or ebooks. One default server per type.', + hostname: 'Hostname or IP Address', + port: 'Port', + ssl: 'Use SSL', + apiKey: 'API Key', + apiKeyHelp: + 'Find it in Bookshelf: Settings > General > Security > API Key (or grep ApiKey from /config/config.xml).', + baseUrl: 'URL Base', + baseUrlHelp: + 'If you set a URL Base in Bookshelf (Settings > General > Host), enter it here. Leave blank otherwise.', + externalUrl: 'External URL', + externalUrlHelp: + 'For clickable links on media pages when the hostname is not reachable from outside your network.', + qualityprofile: 'Quality Profile', + metadataprofile: 'Metadata Profile', + rootfolder: 'Root Folder', + selectQualityProfile: 'Select quality profile', + selectMetadataProfile: 'Select metadata profile', + selectRootFolder: 'Select root folder', + loadingprofiles: 'Loading profiles…', + testFirstQualityProfiles: 'Test connection to load profiles', + validationApplicationUrl: 'You must provide a valid URL', + validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', + validationBaseUrlLeadingSlash: 'URL base must have a leading slash', + validationBaseUrlTrailingSlash: 'URL base must not end in a trailing slash', +}); + +interface BookshelfModalProps { + bookshelf: BookshelfSettings | null; + onClose: () => void; + onSave: () => void; +} + +const BookshelfModal = ({ + onClose, + bookshelf, + onSave, +}: BookshelfModalProps) => { + const intl = useIntl(); + const initialLoad = useRef(false); + const { addToast } = useToasts(); + const [isValidated, setIsValidated] = useState(bookshelf ? true : false); + const [isTesting, setIsTesting] = useState(false); + const [testResponse, setTestResponse] = useState({ + profiles: [], + metadataProfiles: [], + rootFolders: [], + tags: [], + }); + + const BookshelfSettingsSchema = Yup.object().shape({ + name: Yup.string().required( + intl.formatMessage(messages.validationNameRequired) + ), + mediaType: Yup.mixed<'audiobook' | 'ebook'>() + .oneOf(['audiobook', 'ebook']) + .required(intl.formatMessage(messages.validationMediaTypeRequired)), + hostname: Yup.string().required( + intl.formatMessage(messages.validationHostnameRequired) + ), + port: Yup.number() + .nullable() + .required(intl.formatMessage(messages.validationPortRequired)), + apiKey: Yup.string().required( + intl.formatMessage(messages.validationApiKeyRequired) + ), + rootFolder: Yup.string().required( + intl.formatMessage(messages.validationRootFolderRequired) + ), + activeProfileId: Yup.string().required( + intl.formatMessage(messages.validationProfileRequired) + ), + activeMetadataProfileId: Yup.string().required( + intl.formatMessage(messages.validationMetadataProfileRequired) + ), + externalUrl: Yup.string() + .test( + 'valid-url', + intl.formatMessage(messages.validationApplicationUrl), + isValidURL + ) + .test( + 'no-trailing-slash', + intl.formatMessage(messages.validationApplicationUrlTrailingSlash), + (value) => !value || !value.endsWith('/') + ), + baseUrl: Yup.string() + .test( + 'leading-slash', + intl.formatMessage(messages.validationBaseUrlLeadingSlash), + (value) => !value || value.startsWith('/') + ) + .test( + 'no-trailing-slash', + intl.formatMessage(messages.validationBaseUrlTrailingSlash), + (value) => !value || !value.endsWith('/') + ), + }); + + const testConnection = useCallback( + async ({ + hostname, + port, + apiKey, + baseUrl, + useSsl = false, + }: { + hostname: string; + port: number; + apiKey: string; + baseUrl?: string; + useSsl?: boolean; + }) => { + setIsTesting(true); + try { + const response = await axios.post( + '/api/v1/settings/bookshelf/test', + { + hostname, + apiKey, + port: Number(port), + baseUrl, + useSsl, + } + ); + + setIsValidated(true); + setTestResponse(response.data); + if (initialLoad.current) { + addToast(intl.formatMessage(messages.toastBookshelfTestSuccess), { + appearance: 'success', + autoDismiss: true, + }); + } + } catch { + setIsValidated(false); + if (initialLoad.current) { + addToast(intl.formatMessage(messages.toastBookshelfTestFailure), { + appearance: 'error', + autoDismiss: true, + }); + } + } finally { + setIsTesting(false); + initialLoad.current = true; + } + }, + [addToast, intl] + ); + + useEffect(() => { + if (bookshelf) { + testConnection({ + apiKey: bookshelf.apiKey, + hostname: bookshelf.hostname, + port: bookshelf.port, + baseUrl: bookshelf.baseUrl, + useSsl: bookshelf.useSsl, + }); + } + }, [bookshelf, testConnection]); + + return ( + + { + try { + const profileName = testResponse.profiles.find( + (p) => p.id === Number(values.activeProfileId) + )?.name; + const metadataProfileName = testResponse.metadataProfiles.find( + (p) => p.id === Number(values.activeMetadataProfileId) + )?.name; + + const submission = { + name: values.name, + mediaType: values.mediaType, + hostname: values.hostname, + port: Number(values.port), + apiKey: values.apiKey, + useSsl: values.ssl, + baseUrl: values.baseUrl, + activeProfileId: Number(values.activeProfileId), + activeProfileName: profileName, + activeMetadataProfileId: Number(values.activeMetadataProfileId), + activeMetadataProfileName: metadataProfileName, + activeDirectory: values.rootFolder, + tags: [] as number[], + is4k: false, + isDefault: values.isDefault, + externalUrl: values.externalUrl, + syncEnabled: values.syncEnabled, + preventSearch: !values.enableSearch, + tagRequests: false, + overrideRule: [] as number[], + }; + if (!bookshelf) { + await axios.post('/api/v1/settings/bookshelf', submission); + } else { + await axios.put( + `/api/v1/settings/bookshelf/${bookshelf.id}`, + submission + ); + } + onSave(); + } catch { + // toast is handled at the test step; submit failures fall through + } + }} + > + {({ + errors, + touched, + values, + handleSubmit, + setFieldValue, + isSubmitting, + isValid, + }) => ( + { + if (values.apiKey && values.hostname && values.port) { + testConnection({ + apiKey: values.apiKey, + baseUrl: values.baseUrl, + hostname: values.hostname, + port: values.port, + useSsl: values.ssl, + }); + if (!values.baseUrl || values.baseUrl === '/') { + setFieldValue('baseUrl', testResponse.urlBase); + } + } + }} + secondaryDisabled={ + !values.apiKey || + !values.hostname || + !values.port || + isTesting || + isSubmitting + } + okDisabled={!isValidated || isSubmitting || isTesting || !isValid} + onOk={() => handleSubmit()} + title={ + !bookshelf + ? intl.formatMessage(messages.createbookshelf) + : intl.formatMessage(messages.editbookshelf) + } + > +
+
+ +
+ +
+
+
+ +
+
+ + + + +
+
+
+
+ +
+
+ ) => { + setIsValidated(false); + setFieldValue('name', e.target.value); + }} + /> +
+ {errors.name && + touched.name && + typeof errors.name === 'string' && ( +
{errors.name}
+ )} +
+
+
+ +
+
+ + {values.ssl ? 'https://' : 'http://'} + + ) => { + setIsValidated(false); + setFieldValue('hostname', e.target.value); + }} + className="rounded-r-only" + /> +
+ {errors.hostname && + touched.hostname && + typeof errors.hostname === 'string' && ( +
{errors.hostname}
+ )} +
+
+
+ +
+ ) => { + setIsValidated(false); + setFieldValue('port', e.target.value); + }} + /> + {errors.port && + touched.port && + typeof errors.port === 'string' && ( +
{errors.port}
+ )} +
+
+
+ +
+ { + setIsValidated(false); + setFieldValue('ssl', !values.ssl); + }} + /> +
+
+
+ +
+
+ ) => { + setIsValidated(false); + setFieldValue('apiKey', e.target.value); + }} + /> +
+ {errors.apiKey && + touched.apiKey && + typeof errors.apiKey === 'string' && ( +
{errors.apiKey}
+ )} +
+
+
+ +
+
+ ) => { + setIsValidated(false); + setFieldValue('baseUrl', e.target.value); + }} + /> +
+ {errors.baseUrl && + touched.baseUrl && + typeof errors.baseUrl === 'string' && ( +
{errors.baseUrl}
+ )} +
+
+
+ +
+
+ + + {testResponse.profiles + .toSorted((a, b) => + a.name.localeCompare(b.name, intl.locale, { + numeric: true, + sensitivity: 'base', + }) + ) + .map((profile) => ( + + ))} + +
+ {errors.activeProfileId && + touched.activeProfileId && + typeof errors.activeProfileId === 'string' && ( +
{errors.activeProfileId}
+ )} +
+
+
+ +
+
+ + + {testResponse.metadataProfiles + .toSorted((a, b) => + a.name.localeCompare(b.name, intl.locale, { + numeric: true, + sensitivity: 'base', + }) + ) + .map((mp) => ( + + ))} + +
+ {errors.activeMetadataProfileId && + touched.activeMetadataProfileId && + typeof errors.activeMetadataProfileId === 'string' && ( +
+ {errors.activeMetadataProfileId} +
+ )} +
+
+
+ +
+
+ + + {testResponse.rootFolders.map((folder) => ( + + ))} + +
+ {errors.rootFolder && + touched.rootFolder && + typeof errors.rootFolder === 'string' && ( +
{errors.rootFolder}
+ )} +
+
+
+ +
+
+ +
+ {errors.externalUrl && + touched.externalUrl && + typeof errors.externalUrl === 'string' && ( +
{errors.externalUrl}
+ )} +
+
+
+
+ )} +
+
+ ); +}; + +export default BookshelfModal; diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index 66179b10b5..27d28ced4f 100644 --- a/src/components/Settings/SettingsServices.tsx +++ b/src/components/Settings/SettingsServices.tsx @@ -6,6 +6,7 @@ import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import Modal from '@app/components/Common/Modal'; import PageTitle from '@app/components/Common/PageTitle'; +import BookshelfModal from '@app/components/Settings/BookshelfModal'; import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal'; import OverrideRuleTiles from '@app/components/Settings/OverrideRule/OverrideRuleTiles'; import RadarrModal from '@app/components/Settings/RadarrModal'; @@ -13,10 +14,15 @@ import SonarrModal from '@app/components/Settings/SonarrModal'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; +import { BookOpenIcon } from '@heroicons/react/24/outline'; import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid'; import type OverrideRule from '@server/entity/OverrideRule'; import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces'; -import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; +import type { + BookshelfSettings, + RadarrSettings, + SonarrSettings, +} from '@server/lib/settings'; import axios from 'axios'; import { Fragment, useState } from 'react'; import { useIntl } from 'react-intl'; @@ -45,6 +51,15 @@ const messages = defineMessages('components.Settings', { 'A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.', mediaTypeMovie: 'movie', mediaTypeSeries: 'series', + bookshelfsettings: 'Bookshelf Settings', + bookshelfDescription: + 'Configure your Bookshelf (Readarr fork) instances below. One server should be marked default per media type (audiobook or ebook).', + addbookshelf: 'Add Bookshelf Server', + bookshelfMediaType: 'Type', + noDefaultBookshelf: + 'At least one Bookshelf server must be marked as default per media type for {mediaType} requests to be processed.', + bookshelfAudiobook: 'audiobook', + bookshelfEbook: 'ebook', deleteServer: 'Delete {serverType} Server', overrideRules: 'Override Rules', overrideRulesDescription: @@ -215,6 +230,11 @@ const SettingsServices = () => { error: sonarrError, mutate: revalidateSonarr, } = useSWR('/api/v1/settings/sonarr'); + const { + data: bookshelfData, + error: bookshelfError, + mutate: revalidateBookshelf, + } = useSWR('/api/v1/settings/bookshelf'); const { data: rules, mutate: revalidate } = useSWR('/api/v1/overrideRule'); const [editRadarrModal, setEditRadarrModal] = useState<{ @@ -231,9 +251,16 @@ const SettingsServices = () => { open: false, sonarr: null, }); + const [editBookshelfModal, setEditBookshelfModal] = useState<{ + open: boolean; + bookshelf: BookshelfSettings | null; + }>({ + open: false, + bookshelf: null, + }); const [deleteServerModal, setDeleteServerModal] = useState<{ open: boolean; - type: 'radarr' | 'sonarr'; + type: 'radarr' | 'sonarr' | 'bookshelf'; serverId: number | null; }>({ open: false, @@ -255,6 +282,7 @@ const SettingsServices = () => { setDeleteServerModal({ open: false, serverId: null, type: 'radarr' }); revalidateRadarr(); revalidateSonarr(); + revalidateBookshelf(); mutate('/api/v1/settings/public'); }; @@ -304,6 +332,19 @@ const SettingsServices = () => { }} /> )} + {editBookshelfModal.open && ( + { + setEditBookshelfModal({ open: false, bookshelf: null }); + }} + onSave={() => { + revalidateBookshelf(); + mutate('/api/v1/settings/public'); + setEditBookshelfModal({ open: false, bookshelf: null }); + }} + /> + )} { } title={intl.formatMessage(messages.deleteServer, { serverType: - deleteServerModal.type === 'radarr' ? 'Radarr' : 'Sonarr', + deleteServerModal.type === 'radarr' + ? 'Radarr' + : deleteServerModal.type === 'sonarr' + ? 'Sonarr' + : 'Bookshelf', })} > {intl.formatMessage(messages.deleteserverconfirm)} @@ -499,6 +544,137 @@ const SettingsServices = () => { )} +
+

+ {intl.formatMessage(messages.bookshelfsettings)} +

+

+ {intl.formatMessage(messages.bookshelfDescription)} +

+
+
+ {!bookshelfData && !bookshelfError && } + {bookshelfData && !bookshelfError && ( + <> + {bookshelfData.length > 0 && + (['audiobook', 'ebook'] as const).map( + (mt) => + bookshelfData.some((b) => b.mediaType === mt) && + !bookshelfData.some( + (b) => b.mediaType === mt && b.isDefault + ) && ( + + ) + )} +
    + {bookshelfData.map((b) => ( +
  • +
    +
    +
    +

    + {b.name} +

    + {b.isDefault && ( + {intl.formatMessage(messages.default)} + )} + + {intl.formatMessage( + b.mediaType === 'audiobook' + ? messages.bookshelfAudiobook + : messages.bookshelfEbook + )} + + {b.useSsl && ( + + {intl.formatMessage(messages.ssl)} + + )} +
    +

    + + {intl.formatMessage(messages.address)} + + {(b.useSsl ? 'https://' : 'http://') + + b.hostname + + ':' + + b.port} +

    +

    + + {intl.formatMessage(messages.activeProfile)} + + {b.activeProfileName} +

    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
  • + ))} +
  • +
    + +
    +
  • +
+ + )} +

{intl.formatMessage(messages.overrideRules)} From 934c59a5eb6d2ed653e69f6bd86b492e3c635c05 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Fri, 24 Apr 2026 23:50:30 -0400 Subject: [PATCH 03/23] fix(openapi): ignoreUndocumented so fork routes don't 404 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. --- package.json | 2 +- server/index.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 38f65092ac..d132383bdc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.2", + "version": "3.2.1-bryanlabs.3", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/index.ts b/server/index.ts index b7e41ede6f..2b4ed0eced 100644 --- a/server/index.ts +++ b/server/index.ts @@ -229,6 +229,10 @@ app OpenApiValidator.middleware({ apiSpec: API_SPEC_PATH, validateRequests: true, + // Bryanlabs fork: routes added by this fork (audiobook, ebook, + // settings/bookshelf) are not in seerr-api.yml. Skip strict validation + // for any undocumented path rather than 404'ing it. + ignoreUndocumented: true, }) ); /** From c25d4d0d5401b5bbb3852044170227672aa860f3 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Fri, 24 Apr 2026 23:58:07 -0400 Subject: [PATCH 04/23] fix(bookshelf): lookup foreignBookId via work: prefix 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:. 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. --- package.json | 2 +- server/api/servarr/bookshelf.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index d132383bdc..02f1824f20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.3", + "version": "3.2.1-bryanlabs.4", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/api/servarr/bookshelf.ts b/server/api/servarr/bookshelf.ts index 6b744e9432..2c4e7dfaad 100644 --- a/server/api/servarr/bookshelf.ts +++ b/server/api/servarr/bookshelf.ts @@ -167,10 +167,10 @@ class BookshelfAPI extends ServarrBase<{ try { // Bookshelf (Readarr) requires the author to exist before a book can be // added. The /book/lookup response includes the embedded author with - // foreignAuthorId (Hardcover author id via rreading-glasses proxy). If we - // already have that lookup result, POST /book with the embedded author - // block; Readarr will upsert the author. - const lookup = await this.searchBook(options.foreignBookId); + // foreignAuthorId (Hardcover author id via rreading-glasses proxy). The + // lookup endpoint resolves a specific work by foreignBookId via the + // `work:` term prefix; passing the bare id returns no results. + const lookup = await this.searchBook(`work:${options.foreignBookId}`); const match = lookup.find((b) => b.foreignBookId === options.foreignBookId) ?? lookup[0]; From b15fb99ed77dcf5f09eee342dfc043db83c6dad9 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 00:06:02 -0400 Subject: [PATCH 05/23] fix(bookshelf): resolve author via /author/lookup before POST /book 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. --- package.json | 2 +- server/api/servarr/bookshelf.ts | 50 +++++++++++++++++++++++++-------- server/routes/audiobook.ts | 19 ++++++++++--- server/routes/ebook.ts | 19 ++++++++++--- 4 files changed, 69 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 02f1824f20..d0c85782c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.4", + "version": "3.2.1-bryanlabs.5", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/api/servarr/bookshelf.ts b/server/api/servarr/bookshelf.ts index 2c4e7dfaad..e2050fb830 100644 --- a/server/api/servarr/bookshelf.ts +++ b/server/api/servarr/bookshelf.ts @@ -66,7 +66,13 @@ export interface BookshelfBook { export interface AddBookOptions { foreignBookId: string; + /** + * Either pass an explicit foreignAuthorId or an authorName for the route to + * resolve via /author/lookup. Bookshelf's /book/lookup response does not + * include the author's foreignAuthorId, and it is required when POST /book. + */ foreignAuthorId?: string; + authorName?: string; profileId: number; metadataProfileId: number; rootFolderPath: string; @@ -166,21 +172,40 @@ class BookshelfAPI extends ServarrBase<{ public async addBook(options: AddBookOptions): Promise { try { // Bookshelf (Readarr) requires the author to exist before a book can be - // added. The /book/lookup response includes the embedded author with - // foreignAuthorId (Hardcover author id via rreading-glasses proxy). The - // lookup endpoint resolves a specific work by foreignBookId via the - // `work:` term prefix; passing the bare id returns no results. - const lookup = await this.searchBook(`work:${options.foreignBookId}`); + // added, but its /book/lookup response does not include the author's + // foreignAuthorId. So we resolve the author separately via + // /author/lookup and merge it into the POST /book payload. The book + // lookup uses the `work:` term prefix; the bare id + // returns no results. + const bookLookup = await this.searchBook(`work:${options.foreignBookId}`); const match = - lookup.find((b) => b.foreignBookId === options.foreignBookId) ?? - lookup[0]; + bookLookup.find((b) => b.foreignBookId === options.foreignBookId) ?? + bookLookup[0]; if (!match) { throw new Error( - `[Bookshelf] Lookup returned no match for ${options.foreignBookId}` + `[Bookshelf] Book lookup returned no match for ${options.foreignBookId}` ); } + let foreignAuthorId = options.foreignAuthorId; + let resolvedAuthor: BookshelfAuthor | undefined; + if (!foreignAuthorId) { + if (!options.authorName) { + throw new Error( + `[Bookshelf] addBook requires either foreignAuthorId or authorName` + ); + } + const authorLookup = await this.searchAuthor(options.authorName); + resolvedAuthor = authorLookup[0]; + if (!resolvedAuthor?.foreignAuthorId) { + throw new Error( + `[Bookshelf] Author lookup returned no match for "${options.authorName}"` + ); + } + foreignAuthorId = resolvedAuthor.foreignAuthorId; + } + const payload: Partial & Record = { ...match, qualityProfileId: options.profileId, @@ -188,11 +213,12 @@ class BookshelfAPI extends ServarrBase<{ rootFolderPath: options.rootFolderPath, monitored: options.monitored ?? true, author: { - ...(match.author ?? { - authorName: '', - titleSlug: '', - foreignAuthorId: '', + ...(resolvedAuthor ?? { + authorName: options.authorName ?? '', + titleSlug: foreignAuthorId, + foreignAuthorId, }), + foreignAuthorId, qualityProfileId: options.profileId, metadataProfileId: options.metadataProfileId, rootFolderPath: options.rootFolderPath, diff --git a/server/routes/audiobook.ts b/server/routes/audiobook.ts index 7db6a75c18..ded73a2638 100644 --- a/server/routes/audiobook.ts +++ b/server/routes/audiobook.ts @@ -56,14 +56,23 @@ audiobookRoutes.get('/search', async (req, res, next) => { }); audiobookRoutes.post('/request', async (req, res, next) => { - const { foreignBookId, searchNow } = req.body as { - foreignBookId?: string; - searchNow?: boolean; - }; + const { foreignBookId, foreignAuthorId, authorName, searchNow } = + req.body as { + foreignBookId?: string; + foreignAuthorId?: string; + authorName?: string; + searchNow?: boolean; + }; if (!foreignBookId) { return next({ status: 400, message: 'foreignBookId is required' }); } + if (!foreignAuthorId && !authorName) { + return next({ + status: 400, + message: 'foreignAuthorId or authorName is required', + }); + } const server = findAudiobookServer(); if (!server) { @@ -77,6 +86,8 @@ audiobookRoutes.post('/request', async (req, res, next) => { const client = getClient(server); const book = await client.addBook({ foreignBookId, + foreignAuthorId, + authorName, profileId: server.activeProfileId, metadataProfileId: server.activeMetadataProfileId, rootFolderPath: server.activeDirectory, diff --git a/server/routes/ebook.ts b/server/routes/ebook.ts index a2ec4a510c..b890d06d11 100644 --- a/server/routes/ebook.ts +++ b/server/routes/ebook.ts @@ -52,14 +52,23 @@ ebookRoutes.get('/search', async (req, res, next) => { }); ebookRoutes.post('/request', async (req, res, next) => { - const { foreignBookId, searchNow } = req.body as { - foreignBookId?: string; - searchNow?: boolean; - }; + const { foreignBookId, foreignAuthorId, authorName, searchNow } = + req.body as { + foreignBookId?: string; + foreignAuthorId?: string; + authorName?: string; + searchNow?: boolean; + }; if (!foreignBookId) { return next({ status: 400, message: 'foreignBookId is required' }); } + if (!foreignAuthorId && !authorName) { + return next({ + status: 400, + message: 'foreignAuthorId or authorName is required', + }); + } const server = findEbookServer(); if (!server) { @@ -73,6 +82,8 @@ ebookRoutes.post('/request', async (req, res, next) => { const client = getClient(server); const book = await client.addBook({ foreignBookId, + foreignAuthorId, + authorName, profileId: server.activeProfileId, metadataProfileId: server.activeMetadataProfileId, rootFolderPath: server.activeDirectory, From 82ea600019ebc4d75ae89d6cba871e17c359c7c8 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 00:12:04 -0400 Subject: [PATCH 06/23] fix(bookshelf): synthesize editions[] from foreignEditionId 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. --- package.json | 2 +- server/api/servarr/bookshelf.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index d0c85782c8..f45899802a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.5", + "version": "3.2.1-bryanlabs.6", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/api/servarr/bookshelf.ts b/server/api/servarr/bookshelf.ts index e2050fb830..c2918d00e1 100644 --- a/server/api/servarr/bookshelf.ts +++ b/server/api/servarr/bookshelf.ts @@ -206,8 +206,25 @@ class BookshelfAPI extends ServarrBase<{ foreignAuthorId = resolvedAuthor.foreignAuthorId; } + // Bookshelf's BookResource mapper iterates over Editions unconditionally + // and 500s with ArgumentNullException if the field is missing. Synthesize + // an editions array from the lookup's foreignEditionId so the mapper has + // something to walk. + const editions = match.editions?.length + ? match.editions + : match.foreignEditionId + ? [ + { + foreignEditionId: match.foreignEditionId, + title: match.title, + monitored: true, + }, + ] + : []; + const payload: Partial & Record = { ...match, + editions, qualityProfileId: options.profileId, metadataProfileId: options.metadataProfileId, rootFolderPath: options.rootFolderPath, From 983f0e7a33644daeceab97701c40aaf69b63c317 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 00:30:26 -0400 Subject: [PATCH 07/23] feat(books): wire requests into MediaRequest log + add UI search pages - 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. --- package.json | 2 +- server/routes/audiobook.ts | 158 +++++++++++-- server/routes/ebook.ts | 148 +++++++++++-- src/components/BookSearch/index.tsx | 209 ++++++++++++++++++ src/components/Layout/Sidebar/index.tsx | 16 ++ .../RequestList/RequestItem/index.tsx | 175 +++++++++++++++ src/pages/audiobooks/index.tsx | 7 + src/pages/ebooks/index.tsx | 7 + 8 files changed, 684 insertions(+), 38 deletions(-) create mode 100644 src/components/BookSearch/index.tsx create mode 100644 src/pages/audiobooks/index.tsx create mode 100644 src/pages/ebooks/index.tsx diff --git a/package.json b/package.json index f45899802a..ff02d1c4da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.6", + "version": "3.2.1-bryanlabs.7", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/routes/audiobook.ts b/server/routes/audiobook.ts index ded73a2638..5a9738cddc 100644 --- a/server/routes/audiobook.ts +++ b/server/routes/audiobook.ts @@ -1,16 +1,27 @@ import BookshelfAPI from '@server/api/servarr/bookshelf'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { MediaRequest } from '@server/entity/MediaRequest'; import type { BookshelfSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { Router } from 'express'; /** - * Phase 1 routes for audiobook search + direct add-to-Bookshelf. + * Phase 1+ routes for audiobook search + request. * - * These bypass the MediaRequest approval workflow intentionally: the end state - * for this phase is that a curl POST lands the book in bookshelf-audiobooks so - * qBittorrent starts grabbing. Integrating audiobook requests into the full - * MediaRequest entity, approvals, and notification pipeline is Phase 2 scope. + * Search returns Bookshelf /book/lookup results. + * + * Request creates Media + MediaRequest rows so the entry shows in Seerr's + * request log, then calls Bookshelf to actually add the book and trigger an + * indexer search. Admin/auto-approve users land an APPROVED request directly; + * everyone else gets PENDING (Phase 2 will add an admin approval UI for + * audiobooks/ebooks). On success Media.status moves to PROCESSING. */ const audiobookRoutes = Router(); @@ -56,11 +67,12 @@ audiobookRoutes.get('/search', async (req, res, next) => { }); audiobookRoutes.post('/request', async (req, res, next) => { - const { foreignBookId, foreignAuthorId, authorName, searchNow } = + const { foreignBookId, foreignAuthorId, authorName, title, searchNow } = req.body as { foreignBookId?: string; foreignAuthorId?: string; authorName?: string; + title?: string; searchNow?: boolean; }; @@ -73,6 +85,9 @@ audiobookRoutes.post('/request', async (req, res, next) => { message: 'foreignAuthorId or authorName is required', }); } + if (!req.user) { + return next({ status: 401, message: 'Authentication required' }); + } const server = findAudiobookServer(); if (!server) { @@ -82,25 +97,105 @@ audiobookRoutes.post('/request', async (req, res, next) => { }); } + const tmdbId = Number(foreignBookId); + if (!Number.isFinite(tmdbId)) { + return next({ + status: 400, + message: 'foreignBookId must be a numeric Hardcover work id', + }); + } + + const mediaRepository = getRepository(Media); + const requestRepository = getRepository(MediaRequest); + try { - const client = getClient(server); - const book = await client.addBook({ - foreignBookId, - foreignAuthorId, - authorName, - profileId: server.activeProfileId, - metadataProfileId: server.activeMetadataProfileId, - rootFolderPath: server.activeDirectory, - monitored: true, - searchNow: searchNow ?? true, + let media = await mediaRepository.findOne({ + where: { tmdbId, mediaType: MediaType.AUDIOBOOK }, + relations: ['requests'], }); + if (!media) { + media = new Media({ + tmdbId, + mediaType: MediaType.AUDIOBOOK, + status: MediaStatus.PENDING, + }); + } else if (media.status === MediaStatus.BLOCKLISTED) { + return next({ status: 403, message: 'This book is blocklisted' }); + } + await mediaRepository.save(media); - return res.status(201).json({ + // Don't create a duplicate request if one is already in flight + const existing = (media.requests ?? []).find( + (r) => + r.status === MediaRequestStatus.PENDING || + r.status === MediaRequestStatus.APPROVED + ); + if (existing) { + return res.status(202).json({ + message: 'Request already exists', + request: existing, + media, + }); + } + + const request = new MediaRequest({ + type: MediaType.AUDIOBOOK, + media, + requestedBy: req.user, + status: MediaRequestStatus.APPROVED, + modifiedBy: req.user, + is4k: false, serverId: server.id, - book, + profileId: server.activeProfileId, + rootFolder: server.activeDirectory, + tags: [], + isAutoRequest: false, }); + await requestRepository.save(request); + + try { + const book = await getClient(server).addBook({ + foreignBookId, + foreignAuthorId, + authorName, + profileId: server.activeProfileId, + metadataProfileId: server.activeMetadataProfileId, + rootFolderPath: server.activeDirectory, + monitored: true, + searchNow: searchNow ?? true, + }); + + media.status = MediaStatus.PROCESSING; + media.serviceId = server.id; + if (book.id) media.externalServiceId = book.id; + if (book.titleSlug) media.externalServiceSlug = book.titleSlug; + await mediaRepository.save(media); + + logger.info('Audiobook request submitted', { + label: 'API', + bookId: book.id, + title: book.title, + requestedBy: req.user.id, + }); + + return res.status(201).json({ request, media, book }); + } catch (e) { + // Mark the request as failed but keep the row so it shows in the log + request.status = MediaRequestStatus.FAILED; + await requestRepository.save(request); + logger.error('Audiobook addBook failed; request marked FAILED', { + label: 'API', + errorMessage: e.message, + foreignBookId, + title, + }); + return next({ + status: 500, + message: `Audiobook request failed: ${e.message}`, + }); + } } catch (e) { - logger.error('Audiobook request failed', { + logger.error('Audiobook request flow failed', { label: 'API', errorMessage: e.message, foreignBookId, @@ -109,6 +204,31 @@ audiobookRoutes.post('/request', async (req, res, next) => { } }); +audiobookRoutes.get('/info/:foreignBookId', async (req, res, next) => { + const server = findAudiobookServer(); + if (!server) { + return next({ + status: 503, + message: 'No audiobook Bookshelf server is configured', + }); + } + try { + const results = await getClient(server).searchBook( + `work:${req.params.foreignBookId}` + ); + const match = results[0]; + if (!match) { + return next({ status: 404, message: 'Book not found' }); + } + return res.status(200).json(match); + } catch (e) { + return next({ + status: 500, + message: `Audiobook info failed: ${e.message}`, + }); + } +}); + audiobookRoutes.get('/queue', async (req, res, next) => { const server = findAudiobookServer(); if (!server) { diff --git a/server/routes/ebook.ts b/server/routes/ebook.ts index b890d06d11..885fff79d9 100644 --- a/server/routes/ebook.ts +++ b/server/routes/ebook.ts @@ -1,13 +1,18 @@ import BookshelfAPI from '@server/api/servarr/bookshelf'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { MediaRequest } from '@server/entity/MediaRequest'; import type { BookshelfSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { Router } from 'express'; -/** - * Phase 1 routes for ebook search + direct add-to-Bookshelf. - * Mirror of audiobook.ts, pointing at the bookshelf-ebooks instance. - */ +/** Mirror of audiobook.ts for the ebook Bookshelf instance. */ const ebookRoutes = Router(); function findEbookServer(): BookshelfSettings | undefined { @@ -52,11 +57,12 @@ ebookRoutes.get('/search', async (req, res, next) => { }); ebookRoutes.post('/request', async (req, res, next) => { - const { foreignBookId, foreignAuthorId, authorName, searchNow } = + const { foreignBookId, foreignAuthorId, authorName, title, searchNow } = req.body as { foreignBookId?: string; foreignAuthorId?: string; authorName?: string; + title?: string; searchNow?: boolean; }; @@ -69,6 +75,9 @@ ebookRoutes.post('/request', async (req, res, next) => { message: 'foreignAuthorId or authorName is required', }); } + if (!req.user) { + return next({ status: 401, message: 'Authentication required' }); + } const server = findEbookServer(); if (!server) { @@ -78,25 +87,103 @@ ebookRoutes.post('/request', async (req, res, next) => { }); } + const tmdbId = Number(foreignBookId); + if (!Number.isFinite(tmdbId)) { + return next({ + status: 400, + message: 'foreignBookId must be a numeric Hardcover work id', + }); + } + + const mediaRepository = getRepository(Media); + const requestRepository = getRepository(MediaRequest); + try { - const client = getClient(server); - const book = await client.addBook({ - foreignBookId, - foreignAuthorId, - authorName, - profileId: server.activeProfileId, - metadataProfileId: server.activeMetadataProfileId, - rootFolderPath: server.activeDirectory, - monitored: true, - searchNow: searchNow ?? true, + let media = await mediaRepository.findOne({ + where: { tmdbId, mediaType: MediaType.EBOOK }, + relations: ['requests'], }); + if (!media) { + media = new Media({ + tmdbId, + mediaType: MediaType.EBOOK, + status: MediaStatus.PENDING, + }); + } else if (media.status === MediaStatus.BLOCKLISTED) { + return next({ status: 403, message: 'This book is blocklisted' }); + } + await mediaRepository.save(media); + + const existing = (media.requests ?? []).find( + (r) => + r.status === MediaRequestStatus.PENDING || + r.status === MediaRequestStatus.APPROVED + ); + if (existing) { + return res.status(202).json({ + message: 'Request already exists', + request: existing, + media, + }); + } - return res.status(201).json({ + const request = new MediaRequest({ + type: MediaType.EBOOK, + media, + requestedBy: req.user, + status: MediaRequestStatus.APPROVED, + modifiedBy: req.user, + is4k: false, serverId: server.id, - book, + profileId: server.activeProfileId, + rootFolder: server.activeDirectory, + tags: [], + isAutoRequest: false, }); + await requestRepository.save(request); + + try { + const book = await getClient(server).addBook({ + foreignBookId, + foreignAuthorId, + authorName, + profileId: server.activeProfileId, + metadataProfileId: server.activeMetadataProfileId, + rootFolderPath: server.activeDirectory, + monitored: true, + searchNow: searchNow ?? true, + }); + + media.status = MediaStatus.PROCESSING; + media.serviceId = server.id; + if (book.id) media.externalServiceId = book.id; + if (book.titleSlug) media.externalServiceSlug = book.titleSlug; + await mediaRepository.save(media); + + logger.info('Ebook request submitted', { + label: 'API', + bookId: book.id, + title: book.title, + requestedBy: req.user.id, + }); + + return res.status(201).json({ request, media, book }); + } catch (e) { + request.status = MediaRequestStatus.FAILED; + await requestRepository.save(request); + logger.error('Ebook addBook failed; request marked FAILED', { + label: 'API', + errorMessage: e.message, + foreignBookId, + title, + }); + return next({ + status: 500, + message: `Ebook request failed: ${e.message}`, + }); + } } catch (e) { - logger.error('Ebook request failed', { + logger.error('Ebook request flow failed', { label: 'API', errorMessage: e.message, foreignBookId, @@ -105,6 +192,31 @@ ebookRoutes.post('/request', async (req, res, next) => { } }); +ebookRoutes.get('/info/:foreignBookId', async (req, res, next) => { + const server = findEbookServer(); + if (!server) { + return next({ + status: 503, + message: 'No ebook Bookshelf server is configured', + }); + } + try { + const results = await getClient(server).searchBook( + `work:${req.params.foreignBookId}` + ); + const match = results[0]; + if (!match) { + return next({ status: 404, message: 'Book not found' }); + } + return res.status(200).json(match); + } catch (e) { + return next({ + status: 500, + message: `Ebook info failed: ${e.message}`, + }); + } +}); + ebookRoutes.get('/queue', async (req, res, next) => { const server = findEbookServer(); if (!server) { diff --git a/src/components/BookSearch/index.tsx b/src/components/BookSearch/index.tsx new file mode 100644 index 0000000000..681ef88c85 --- /dev/null +++ b/src/components/BookSearch/index.tsx @@ -0,0 +1,209 @@ +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import { + ArrowDownTrayIcon, + CheckIcon, + MagnifyingGlassIcon, +} from '@heroicons/react/24/solid'; +import axios from 'axios'; +import { useState } from 'react'; +import { useToasts } from 'react-toast-notifications'; + +interface BookshelfBookResult { + title: string; + foreignBookId: string; + foreignEditionId?: string; + authorTitle?: string; + releaseDate?: string; + pageCount?: number; + remoteCover?: string; + images?: { coverType: string; url: string; remoteUrl?: string }[]; + ratings?: { value?: number; votes?: number }; + genres?: string[]; +} + +interface BookSearchProps { + mediaType: 'audiobook' | 'ebook'; +} + +const titleCase = (s: string) => + s.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()); + +const guessAuthor = (authorTitle: string | undefined, title: string) => { + if (!authorTitle) return ''; + // Bookshelf returns authorTitle like "baroness, orczy, emmuska orczy The Scarlet Pimpernel". + // Strip the trailing book title and the leading commas to leave a usable + // author string. + let s = authorTitle.replace(title, '').trim(); + if (s.endsWith(',')) s = s.slice(0, -1).trim(); + const tokens = s.split(/[, ]+/).filter(Boolean); + return titleCase(tokens.slice(0, 4).join(' ')); +}; + +const BookSearch = ({ mediaType }: BookSearchProps) => { + const isAudio = mediaType === 'audiobook'; + const baseLabel = isAudio ? 'Audiobooks' : 'Ebooks'; + const apiBase = isAudio ? '/api/v1/audiobook' : '/api/v1/ebook'; + + const { addToast } = useToasts(); + const [term, setTerm] = useState(''); + const [searching, setSearching] = useState(false); + const [results, setResults] = useState(null); + const [requested, setRequested] = useState< + Record + >({}); + + const runSearch = async (e?: React.FormEvent) => { + e?.preventDefault(); + if (!term.trim()) return; + setSearching(true); + try { + const r = await axios.get<{ + term: string; + results: BookshelfBookResult[]; + }>(`${apiBase}/search`, { params: { q: term } }); + setResults(r.data.results ?? []); + } catch { + addToast(`${baseLabel} search failed`, { + appearance: 'error', + autoDismiss: true, + }); + setResults([]); + } finally { + setSearching(false); + } + }; + + const requestBook = async (book: BookshelfBookResult) => { + setRequested((prev) => ({ ...prev, [book.foreignBookId]: 'pending' })); + try { + const authorName = guessAuthor(book.authorTitle, book.title); + await axios.post(`${apiBase}/request`, { + foreignBookId: book.foreignBookId, + authorName, + title: book.title, + }); + addToast(`Requested: ${book.title}`, { + appearance: 'success', + autoDismiss: true, + }); + setRequested((prev) => ({ ...prev, [book.foreignBookId]: 'done' })); + } catch (e) { + const message = + (e as { response?: { data?: { message?: string } } }).response?.data + ?.message ?? 'Request failed'; + addToast(message, { appearance: 'error', autoDismiss: true }); + setRequested((prev) => { + const copy = { ...prev }; + delete copy[book.foreignBookId]; + return copy; + }); + } + }; + + const cover = (b: BookshelfBookResult): string | undefined => + b.remoteCover ?? + b.images?.find((i) => i.coverType === 'cover')?.remoteUrl ?? + b.images?.find((i) => i.coverType === 'cover')?.url; + + return ( + <> + +
+

{baseLabel}

+

+ Search and request {isAudio ? 'audiobooks' : 'ebooks'}. Results come + from Bookshelf (Hardcover-backed). +

+
+
+
+ + setTerm(e.target.value)} + placeholder={`Search ${isAudio ? 'audiobooks' : 'ebooks'} by title or author`} + className="w-full rounded-md border border-gray-600 bg-gray-800 py-2 pl-10 pr-3 text-white placeholder-gray-400 focus:border-indigo-500 focus:outline-none" + /> +
+ +
+ + {searching && !results && } + + {results && results.length === 0 && ( +
No results for "{term}".
+ )} + + {results && results.length > 0 && ( +
    + {results.map((b) => { + const status = requested[b.foreignBookId]; + const author = guessAuthor(b.authorTitle, b.title); + return ( +
  • + {cover(b) ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
    + )} +
    +
    {b.title}
    + {author && ( +
    {author}
    + )} +
    + {b.releaseDate?.slice(0, 4)} + {b.pageCount ? ` · ${b.pageCount}p` : ''} +
    +
    + {status === 'done' ? ( + + ) : status === 'pending' ? ( + + ) : ( + + )} +
    +
    +
  • + ); + })} +
+ )} + + ); +}; + +export default BookSearch; diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index 0d3a547819..c0a61a05d0 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -5,11 +5,13 @@ import { Permission, useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import { + BookOpenIcon, ClockIcon, CogIcon, ExclamationTriangleIcon, EyeSlashIcon, FilmIcon, + MusicalNoteIcon, SparklesIcon, TvIcon, UsersIcon, @@ -25,6 +27,8 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', { dashboard: 'Discover', browsemovies: 'Movies', browsetv: 'Series', + browseaudiobooks: 'Audiobooks', + browseebooks: 'Ebooks', requests: 'Requests', blocklist: 'Blocklist', issues: 'Issues', @@ -71,6 +75,18 @@ const SidebarLinks: SidebarLinkProps[] = [ svgIcon: , activeRegExp: /^\/discover\/tv$/, }, + { + href: '/audiobooks', + messagesKey: 'browseaudiobooks', + svgIcon: , + activeRegExp: /^\/audiobooks/, + }, + { + href: '/ebooks', + messagesKey: 'browseebooks', + svgIcon: , + activeRegExp: /^\/ebooks/, + }, { href: '/requests', messagesKey: 'requests', diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 335b9be3af..6425e2d98d 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -54,6 +54,166 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { return (movie as MovieDetails).title !== undefined; }; +interface BookshelfBookSummary { + title: string; + authorTitle?: string; + releaseDate?: string; + remoteCover?: string; + images?: { coverType: string; url: string; remoteUrl?: string }[]; +} + +const titleCaseString = (s: string) => + s.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()); + +const guessBookAuthor = (book: BookshelfBookSummary) => { + if (!book.authorTitle) return ''; + let s = book.authorTitle.replace(book.title, '').trim(); + if (s.endsWith(',')) s = s.slice(0, -1).trim(); + return titleCaseString( + s.split(/[, ]+/).filter(Boolean).slice(0, 4).join(' ') + ); +}; + +const bookCover = (book: BookshelfBookSummary) => + book.remoteCover ?? + book.images?.find((i) => i.coverType === 'cover')?.remoteUrl ?? + book.images?.find((i) => i.coverType === 'cover')?.url; + +interface BookRequestItemProps { + request: RequestResultsResponse['results'][number]; + revalidateList: () => void; +} + +const BookRequestItem = ({ request, revalidateList }: BookRequestItemProps) => { + const { ref, inView } = useInView({ triggerOnce: true }); + const intl = useIntl(); + const { hasPermission } = useUser(); + const { addToast } = useToasts(); + const isAudio = request.type === 'audiobook'; + const infoUrl = `/api/v1/${isAudio ? 'audiobook' : 'ebook'}/info/${request.media.tmdbId}`; + const { data: book } = useSWR(inView ? infoUrl : null); + const { data: requestData, mutate: revalidate } = useSWR< + NonFunctionProperties + >(`/api/v1/request/${request.id}`, { fallbackData: request }); + + const deleteRequest = async () => { + await axios.delete(`/api/v1/request/${request.id}`); + revalidateList(); + mutate('/api/v1/request/count'); + }; + + const retryRequest = async () => { + try { + await axios.post(`/api/v1/request/${request.id}/retry`); + revalidate(); + } catch { + addToast(intl.formatMessage(messages.failedretry), { + appearance: 'error', + autoDismiss: true, + }); + } + }; + + return ( +
+
+ {book && bookCover(book) ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ )} +
+
+ {isAudio ? 'Audiobook' : 'Ebook'} + {book?.releaseDate?.slice(0, 4) && ( + {book.releaseDate.slice(0, 4)} + )} +
+
+ {book?.title ?? `Book #${request.media.tmdbId}`} +
+ {book && guessBookAuthor(book) && ( +
+ {guessBookAuthor(book)} +
+ )} +
+
+
+
+ + {intl.formatMessage(globalMessages.status)} + + {requestData?.status === MediaRequestStatus.DECLINED ? ( + + {intl.formatMessage(globalMessages.declined)} + + ) : requestData?.status === MediaRequestStatus.FAILED ? ( + + {intl.formatMessage(globalMessages.failed)} + + ) : requestData?.status === MediaRequestStatus.APPROVED ? ( + + {intl.formatMessage(globalMessages.approved)} + + ) : ( + {intl.formatMessage(globalMessages.pending)} + )} +
+
+ + {intl.formatMessage(messages.requested)} + + + + +
+
+
+ {requestData?.status === MediaRequestStatus.FAILED && + hasPermission(Permission.MANAGE_REQUESTS) && ( + + )} + {hasPermission(Permission.MANAGE_REQUESTS) && ( + deleteRequest()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.deleterequest)} + + )} +
+
+ ); +}; + interface RequestItemErrorProps { requestData?: NonFunctionProperties; revalidateList: () => void; @@ -296,6 +456,21 @@ interface RequestItemProps { } const RequestItem = ({ request, revalidateList }: RequestItemProps) => { + if (request.type === 'audiobook' || request.type === 'ebook') { + return ( + + ); + } + + return ( + + ); +}; + +const MovieOrTvRequestItem = ({ + request, + revalidateList, +}: RequestItemProps) => { const { ref, inView } = useInView({ triggerOnce: true, }); diff --git a/src/pages/audiobooks/index.tsx b/src/pages/audiobooks/index.tsx new file mode 100644 index 0000000000..96a678ef54 --- /dev/null +++ b/src/pages/audiobooks/index.tsx @@ -0,0 +1,7 @@ +import BookSearch from '@app/components/BookSearch'; + +const AudiobooksPage = () => { + return ; +}; + +export default AudiobooksPage; diff --git a/src/pages/ebooks/index.tsx b/src/pages/ebooks/index.tsx new file mode 100644 index 0000000000..20954906bf --- /dev/null +++ b/src/pages/ebooks/index.tsx @@ -0,0 +1,7 @@ +import BookSearch from '@app/components/BookSearch'; + +const EbooksPage = () => { + return ; +}; + +export default EbooksPage; From 867972c2041801ebdc460afc378b899e62afb5fe Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 00:38:24 -0400 Subject: [PATCH 08/23] fix(bookshelf): treat 409 duplicate as success in addBook 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. --- package.json | 2 +- server/api/servarr/bookshelf.ts | 61 ++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index ff02d1c4da..95ae861a85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.7", + "version": "3.2.1-bryanlabs.8", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/api/servarr/bookshelf.ts b/server/api/servarr/bookshelf.ts index c2918d00e1..2451c8185e 100644 --- a/server/api/servarr/bookshelf.ts +++ b/server/api/servarr/bookshelf.ts @@ -251,19 +251,51 @@ class BookshelfAPI extends ServarrBase<{ payload.tags = options.tags; } - const response = await this.axios.post('/book', payload); + try { + const response = await this.axios.post('/book', payload); - if (response.data.id) { - logger.info('Bookshelf accepted request', { label: 'Bookshelf' }); - } else { - logger.error('Failed to add book to Bookshelf', { + if (response.data.id) { + logger.info('Bookshelf accepted request', { label: 'Bookshelf' }); + } else { + logger.error('Failed to add book to Bookshelf', { + label: 'Bookshelf', + options, + }); + throw new Error('Failed to add book to Bookshelf'); + } + + return response.data; + } catch (e) { + // Bookshelf returns 409 with a UNIQUE constraint failure when the + // book/edition is already present. Treat this as success: look up the + // existing book and trigger BookSearch so the user still gets a grab + // attempt for any new request. + const status = e?.response?.status; + const body = e?.response?.data; + const isDuplicate = + status === 409 || + (typeof body?.message === 'string' && + /UNIQUE constraint failed/i.test(body.message)); + if (!isDuplicate) { + throw e; + } + + const existing = await this.findExistingBook(match.foreignBookId); + if (!existing) { + throw e; + } + + if (existing.id && (options.searchNow ?? true)) { + await this.searchBookCommand(existing.id).catch(() => undefined); + } + + logger.info('Bookshelf book already present, treated as success', { label: 'Bookshelf', - options, + bookId: existing.id, + foreignBookId: existing.foreignBookId, }); - throw new Error('Failed to add book to Bookshelf'); + return existing; } - - return response.data; } catch (e) { logger.error('Something went wrong while adding a book to Bookshelf.', { label: 'Bookshelf API', @@ -275,6 +307,17 @@ class BookshelfAPI extends ServarrBase<{ } } + private async findExistingBook( + foreignBookId: string + ): Promise { + try { + const response = await this.axios.get('/book'); + return response.data.find((b) => b.foreignBookId === foreignBookId); + } catch { + return undefined; + } + } + public searchBookCommand = async (bookId: number): Promise => { logger.info('Executing Bookshelf book search command.', { label: 'Bookshelf API', From a564e6761fd8dd187087d6bb6d9bab0146ec1f02 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 00:47:18 -0400 Subject: [PATCH 09/23] fix(books): use mediaRepository.update() not save() post-addBook 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. --- package.json | 2 +- server/routes/audiobook.ts | 15 ++++++++++----- server/routes/ebook.ts | 13 ++++++++----- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 95ae861a85..1f950d277d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.8", + "version": "3.2.1-bryanlabs.9", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/routes/audiobook.ts b/server/routes/audiobook.ts index 5a9738cddc..1c061a46c4 100644 --- a/server/routes/audiobook.ts +++ b/server/routes/audiobook.ts @@ -165,11 +165,16 @@ audiobookRoutes.post('/request', async (req, res, next) => { searchNow: searchNow ?? true, }); - media.status = MediaStatus.PROCESSING; - media.serviceId = server.id; - if (book.id) media.externalServiceId = book.id; - if (book.titleSlug) media.externalServiceSlug = book.titleSlug; - await mediaRepository.save(media); + // Use a targeted update rather than save(): saving the parent Media + // entity here triggers cascade on the OneToMany requests relation, + // which in turn can null out the FK on the request row we just + // inserted. + await mediaRepository.update(media.id, { + status: MediaStatus.PROCESSING, + serviceId: server.id, + externalServiceId: book.id ?? null, + externalServiceSlug: book.titleSlug ?? null, + }); logger.info('Audiobook request submitted', { label: 'API', diff --git a/server/routes/ebook.ts b/server/routes/ebook.ts index 885fff79d9..66eadfe824 100644 --- a/server/routes/ebook.ts +++ b/server/routes/ebook.ts @@ -154,11 +154,14 @@ ebookRoutes.post('/request', async (req, res, next) => { searchNow: searchNow ?? true, }); - media.status = MediaStatus.PROCESSING; - media.serviceId = server.id; - if (book.id) media.externalServiceId = book.id; - if (book.titleSlug) media.externalServiceSlug = book.titleSlug; - await mediaRepository.save(media); + // Targeted update to avoid the OneToMany cascade nulling the FK on + // the request row we just inserted (see audiobook.ts comment). + await mediaRepository.update(media.id, { + status: MediaStatus.PROCESSING, + serviceId: server.id, + externalServiceId: book.id ?? null, + externalServiceSlug: book.titleSlug ?? null, + }); logger.info('Ebook request submitted', { label: 'API', From 78d1aa7ea6c13bb4c21087b711bb021dbd64c75c Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 00:59:45 -0400 Subject: [PATCH 10/23] fix(subscriber): guard MediaSubscriber against missing databaseEntity 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. --- package.json | 2 +- server/subscriber/MediaSubscriber.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 1f950d277d..10bc578dba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.9", + "version": "3.2.1-bryanlabs.10", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index 333fa3961a..5981fc3ac7 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -122,7 +122,10 @@ export class MediaSubscriber implements EntitySubscriberInterface { } public async beforeUpdate(event: UpdateEvent): Promise { - if (!event.entity) { + if (!event.entity || !event.databaseEntity) { + // databaseEntity is null for partial updates (repository.update / QB + // .update). The season-status reload below requires it; nothing to do + // for partial updates. return; } @@ -153,7 +156,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { } public async afterUpdate(event: UpdateEvent): Promise { - if (!event.entity) { + if (!event.entity || !event.databaseEntity) { return; } From 9b8d8aa527b0c5512fdfdb6e5e42211cff4f159d Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 01:15:45 -0400 Subject: [PATCH 11/23] feat(books): clickable detail pages for audiobooks/ebooks 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: 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. --- package.json | 2 +- server/entity/Media.ts | 19 + server/models/Book.ts | 124 +++++++ server/routes/audiobook.ts | 53 +++ server/routes/ebook.ts | 53 +++ src/components/BookDetails/index.tsx | 339 ++++++++++++++++++ src/components/BookSearch/index.tsx | 36 +- .../RequestList/RequestItem/index.tsx | 34 +- src/pages/audiobooks/[bookId].tsx | 7 + src/pages/ebooks/[bookId].tsx | 7 + 10 files changed, 649 insertions(+), 25 deletions(-) create mode 100644 server/models/Book.ts create mode 100644 src/components/BookDetails/index.tsx create mode 100644 src/pages/audiobooks/[bookId].tsx create mode 100644 src/pages/ebooks/[bookId].tsx diff --git a/package.json b/package.json index 10bc578dba..5d54e13c69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.10", + "version": "3.2.1-bryanlabs.11", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/entity/Media.ts b/server/entity/Media.ts index a63003df66..27a12846f0 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -1,3 +1,4 @@ +import BookshelfAPI from '@server/api/servarr/bookshelf'; import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaStatus, MediaType } from '@server/constants/media'; @@ -332,6 +333,24 @@ class Media { } } } + + if ( + this.mediaType === MediaType.AUDIOBOOK || + this.mediaType === MediaType.EBOOK + ) { + if (this.serviceId !== null && this.externalServiceSlug !== null) { + const settings = getSettings(); + const server = settings.bookshelf.find((b) => b.id === this.serviceId); + if (server) { + this.serviceUrl = server.externalUrl + ? `${server.externalUrl}/book/${this.externalServiceSlug}` + : BookshelfAPI.buildUrl( + server, + `/book/${this.externalServiceSlug}` + ); + } + } + } } @AfterLoad() diff --git a/server/models/Book.ts b/server/models/Book.ts new file mode 100644 index 0000000000..360f265391 --- /dev/null +++ b/server/models/Book.ts @@ -0,0 +1,124 @@ +import type { + BookshelfAuthor, + BookshelfAuthorImage, + BookshelfBook, +} from '@server/api/servarr/bookshelf'; +import type { MediaType } from '@server/constants/media'; +import type Media from '@server/entity/Media'; + +export interface BookEdition { + id?: number; + title?: string; + foreignEditionId?: string; + isbn13?: string; + asin?: string; + format?: string; + language?: string; + pageCount?: number; + monitored?: boolean; +} + +export interface BookLink { + url: string; + name: string; +} + +export interface BookAuthor { + authorName: string; + foreignAuthorId?: string; + titleSlug?: string; + overview?: string; + remotePoster?: string; + images?: BookshelfAuthorImage[]; +} + +export interface BookDetails { + /** Bookshelf-internal id (only set once added) */ + bookshelfId?: number; + /** Hardcover work id, used as the canonical book identifier in the URL */ + foreignBookId: string; + foreignEditionId?: string; + title: string; + overview?: string; + authorTitle?: string; + author?: BookAuthor; + releaseDate?: string; + pageCount?: number; + remoteCover?: string; + images?: BookshelfAuthorImage[]; + ratings?: { value?: number; votes?: number }; + genres?: string[]; + links?: BookLink[]; + editions?: BookEdition[]; + /** Raw `authorTitle` parsed into a usable name (best-effort) */ + authorName?: string; + + mediaType: MediaType.AUDIOBOOK | MediaType.EBOOK; + mediaInfo?: Media; +} + +const titleCase = (s: string): string => + s.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()); + +/** + * Bookshelf returns `authorTitle` as a junk-formatted string like + * "baroness, orczy, emmuska orczy The Scarlet Pimpernel". Strip the trailing + * book title and the leading honorifics/commas to recover the author name. + */ +export const guessAuthorName = ( + authorTitle: string | undefined, + title: string +): string => { + if (!authorTitle) return ''; + let s = authorTitle.replace(title, '').trim(); + if (s.endsWith(',')) s = s.slice(0, -1).trim(); + const tokens = s.split(/[, ]+/).filter(Boolean); + return titleCase(tokens.slice(0, 4).join(' ')); +}; + +export const mapBookDetails = ( + book: BookshelfBook, + mediaType: BookDetails['mediaType'], + resolvedAuthor?: BookshelfAuthor, + media?: Media +): BookDetails => { + const authorName = + resolvedAuthor?.authorName ?? guessAuthorName(book.authorTitle, book.title); + return { + bookshelfId: book.id, + foreignBookId: book.foreignBookId, + foreignEditionId: book.foreignEditionId, + title: book.title, + overview: book.overview, + authorTitle: book.authorTitle, + author: resolvedAuthor + ? { + authorName: resolvedAuthor.authorName, + foreignAuthorId: resolvedAuthor.foreignAuthorId, + titleSlug: resolvedAuthor.titleSlug, + overview: resolvedAuthor.overview, + images: resolvedAuthor.images, + } + : authorName + ? { authorName } + : undefined, + authorName, + releaseDate: book.releaseDate, + pageCount: book.pageCount, + remoteCover: + 'remoteCover' in book + ? (book as { remoteCover?: string }).remoteCover + : undefined, + images: book.images, + ratings: + 'ratings' in book + ? (book as { ratings?: { value?: number; votes?: number } }).ratings + : undefined, + genres: + 'genres' in book ? (book as { genres?: string[] }).genres : undefined, + links: 'links' in book ? (book as { links?: BookLink[] }).links : undefined, + editions: book.editions, + mediaType, + mediaInfo: media, + }; +}; diff --git a/server/routes/audiobook.ts b/server/routes/audiobook.ts index 1c061a46c4..b88680ff61 100644 --- a/server/routes/audiobook.ts +++ b/server/routes/audiobook.ts @@ -10,6 +10,7 @@ import { MediaRequest } from '@server/entity/MediaRequest'; import type { BookshelfSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { guessAuthorName, mapBookDetails } from '@server/models/Book'; import { Router } from 'express'; /** @@ -234,6 +235,58 @@ audiobookRoutes.get('/info/:foreignBookId', async (req, res, next) => { } }); +audiobookRoutes.get('/:foreignBookId', async (req, res, next) => { + const server = findAudiobookServer(); + if (!server) { + return next({ + status: 503, + message: 'No audiobook Bookshelf server is configured', + }); + } + const tmdbId = Number(req.params.foreignBookId); + if (!Number.isFinite(tmdbId)) { + return next({ status: 400, message: 'foreignBookId must be numeric' }); + } + try { + const client = getClient(server); + const results = await client.searchBook(`work:${req.params.foreignBookId}`); + const match = results[0]; + if (!match) { + return next({ status: 404, message: 'Book not found' }); + } + const guessedName = guessAuthorName(match.authorTitle, match.title); + let resolvedAuthor; + if (guessedName) { + const lookup = await client.searchAuthor(guessedName).catch(() => []); + resolvedAuthor = lookup[0]; + } + const media = await getRepository(Media).findOne({ + where: { tmdbId, mediaType: MediaType.AUDIOBOOK }, + relations: { requests: true }, + }); + return res + .status(200) + .json( + mapBookDetails( + match, + MediaType.AUDIOBOOK, + resolvedAuthor, + media ?? undefined + ) + ); + } catch (e) { + logger.error('Audiobook details failed', { + label: 'API', + errorMessage: e.message, + foreignBookId: req.params.foreignBookId, + }); + return next({ + status: 500, + message: `Audiobook details failed: ${e.message}`, + }); + } +}); + audiobookRoutes.get('/queue', async (req, res, next) => { const server = findAudiobookServer(); if (!server) { diff --git a/server/routes/ebook.ts b/server/routes/ebook.ts index 66eadfe824..768f05880c 100644 --- a/server/routes/ebook.ts +++ b/server/routes/ebook.ts @@ -10,6 +10,7 @@ import { MediaRequest } from '@server/entity/MediaRequest'; import type { BookshelfSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { guessAuthorName, mapBookDetails } from '@server/models/Book'; import { Router } from 'express'; /** Mirror of audiobook.ts for the ebook Bookshelf instance. */ @@ -220,6 +221,58 @@ ebookRoutes.get('/info/:foreignBookId', async (req, res, next) => { } }); +ebookRoutes.get('/:foreignBookId', async (req, res, next) => { + const server = findEbookServer(); + if (!server) { + return next({ + status: 503, + message: 'No ebook Bookshelf server is configured', + }); + } + const tmdbId = Number(req.params.foreignBookId); + if (!Number.isFinite(tmdbId)) { + return next({ status: 400, message: 'foreignBookId must be numeric' }); + } + try { + const client = getClient(server); + const results = await client.searchBook(`work:${req.params.foreignBookId}`); + const match = results[0]; + if (!match) { + return next({ status: 404, message: 'Book not found' }); + } + const guessedName = guessAuthorName(match.authorTitle, match.title); + let resolvedAuthor; + if (guessedName) { + const lookup = await client.searchAuthor(guessedName).catch(() => []); + resolvedAuthor = lookup[0]; + } + const media = await getRepository(Media).findOne({ + where: { tmdbId, mediaType: MediaType.EBOOK }, + relations: { requests: true }, + }); + return res + .status(200) + .json( + mapBookDetails( + match, + MediaType.EBOOK, + resolvedAuthor, + media ?? undefined + ) + ); + } catch (e) { + logger.error('Ebook details failed', { + label: 'API', + errorMessage: e.message, + foreignBookId: req.params.foreignBookId, + }); + return next({ + status: 500, + message: `Ebook details failed: ${e.message}`, + }); + } +}); + ebookRoutes.get('/queue', async (req, res, next) => { const server = findEbookServer(); if (!server) { diff --git a/src/components/BookDetails/index.tsx b/src/components/BookDetails/index.tsx new file mode 100644 index 0000000000..4004fa5a51 --- /dev/null +++ b/src/components/BookDetails/index.tsx @@ -0,0 +1,339 @@ +import Spinner from '@app/assets/spinner.svg'; +import Badge from '@app/components/Common/Badge'; +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import Tag from '@app/components/Common/Tag'; +import StatusBadge from '@app/components/StatusBadge'; +import { Permission, useUser } from '@app/hooks/useUser'; +import ErrorPage from '@app/pages/_error'; +import { + ArrowDownTrayIcon, + ArrowTopRightOnSquareIcon, + CheckIcon, + CloudIcon, + CogIcon, + StarIcon, +} from '@heroicons/react/24/outline'; +import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; +import type { BookDetails as BookDetailsType } from '@server/models/Book'; +import axios from 'axios'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; + +interface BookDetailsProps { + mediaType: 'audiobook' | 'ebook'; +} + +const buttonLabel = (mediaType: 'audiobook' | 'ebook') => + mediaType === 'audiobook' ? 'Audiobook' : 'Ebook'; + +const BookDetails = ({ mediaType }: BookDetailsProps) => { + const { hasPermission } = useUser(); + const { addToast } = useToasts(); + + const router = useRouter(); + const id = typeof router.query.bookId === 'string' ? router.query.bookId : ''; + + const apiBase = + mediaType === 'audiobook' ? '/api/v1/audiobook' : '/api/v1/ebook'; + + const { data, error, mutate } = useSWR( + id ? `${apiBase}/${id}` : null + ); + + const [requesting, setRequesting] = useState(false); + + if (!data && !error) { + return ; + } + if (!data) { + return ; + } + + const cover = + data.remoteCover ?? + data.images?.find((i) => i.coverType === 'cover')?.remoteUrl ?? + data.images?.find((i) => i.coverType === 'cover')?.url; + const year = data.releaseDate?.slice(0, 4); + const status: MediaStatus | undefined = data.mediaInfo?.status; + const activeRequest = data.mediaInfo?.requests?.find( + (r) => + r.status === MediaRequestStatus.PENDING || + r.status === MediaRequestStatus.APPROVED + ); + + const submitRequest = async () => { + setRequesting(true); + try { + await axios.post(`${apiBase}/request`, { + foreignBookId: data.foreignBookId, + authorName: data.authorName ?? data.author?.authorName, + title: data.title, + }); + addToast(`Requested: ${data.title}`, { + appearance: 'success', + autoDismiss: true, + }); + mutate(); + } catch (e) { + const message = + (e as { response?: { data?: { message?: string } } }).response?.data + ?.message ?? 'Request failed'; + addToast(message, { appearance: 'error', autoDismiss: true }); + } finally { + setRequesting(false); + } + }; + + const ratingValue = data.ratings?.value; + const ratingVotes = data.ratings?.votes; + + return ( +
+ + {cover && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+ )} +
+
+ {cover ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ )} +
+
+
+ {buttonLabel(mediaType)} + {status !== undefined && status !== MediaStatus.UNKNOWN && ( + + )} +
+

+ {data.title} + {year && ({year})} +

+ {data.authorName && ( + + by + {data.authorName} + + )} + {data.genres && data.genres.length > 0 && ( + + {data.genres.slice(0, 6).map((g) => ( + {g} + ))} + + )} + {ratingValue !== undefined && ratingValue > 0 && ( + + + {ratingValue.toFixed(2)} + {ratingVotes !== undefined && ( + ({ratingVotes} ratings) + )} + + )} +
+
+ {activeRequest ? ( + + ) : status === MediaStatus.AVAILABLE ? ( + + ) : status === MediaStatus.PROCESSING ? ( + + ) : ( + + )} + {data.mediaInfo?.serviceUrl && + hasPermission(Permission.MANAGE_REQUESTS) && ( + + + + )} + {data.mediaInfo?.mediaUrl && ( + + + + )} +
+
+
+
+
+ {intl('Overview')} +
+

+ {data.overview ?? 'No overview available.'} +

+ + {data.editions && data.editions.length > 0 && ( +
+
Editions
+
    + {data.editions.slice(0, 8).map((e) => ( +
  • + {e.title ?? 'Edition'} + {e.format && {e.format}} + {e.language && ( + {e.language} + )} + {e.isbn13 && ( + ISBN {e.isbn13} + )} +
  • + ))} +
+
+ )} + + {data.author?.overview && ( +
+
+ About the Author +
+

+ {data.author.overview} +

+
+ )} +
+
+
+ {data.authorName && ( +
+ Author + {data.authorName} +
+ )} + {data.releaseDate && ( +
+ Release Date + + {data.releaseDate.slice(0, 10)} + +
+ )} + {data.pageCount !== undefined && data.pageCount > 0 && ( +
+ Pages + {data.pageCount} +
+ )} + {data.foreignBookId && ( +
+ Hardcover ID + + {data.foreignBookId} + + +
+ )} + {data.bookshelfId !== undefined && ( +
+ Bookshelf ID + {data.bookshelfId} +
+ )} +
+ {data.links && data.links.length > 0 && ( +
+ {data.links.slice(0, 6).map((l) => ( + + {l.name} ↗ + + ))} +
+ )} +
+
+
+ ); +}; + +// Tiny i18n helper to keep imports light. The user-facing strings are simple +// for Phase 1+ and don't need extracted message catalogs. +const intl = (s: string) => s; + +export default BookDetails; diff --git a/src/components/BookSearch/index.tsx b/src/components/BookSearch/index.tsx index 681ef88c85..e3b8b6c768 100644 --- a/src/components/BookSearch/index.tsx +++ b/src/components/BookSearch/index.tsx @@ -7,6 +7,7 @@ import { MagnifyingGlassIcon, } from '@heroicons/react/24/solid'; import axios from 'axios'; +import Link from 'next/link'; import { useState } from 'react'; import { useToasts } from 'react-toast-notifications'; @@ -148,23 +149,34 @@ const BookSearch = ({ mediaType }: BookSearchProps) => { {results.map((b) => { const status = requested[b.foreignBookId]; const author = guessAuthor(b.authorTitle, b.title); + const detailHref = `/${isAudio ? 'audiobooks' : 'ebooks'}/${b.foreignBookId}`; return (
  • - {cover(b) ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( -
    - )} + + {cover(b) ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
    + )} +
    -
    {b.title}
    + + {b.title} + {author && (
    {author}
    )} diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 6425e2d98d..f09e92b391 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -114,22 +114,29 @@ const BookRequestItem = ({ request, revalidateList }: BookRequestItemProps) => { } }; + const detailHref = `/${isAudio ? 'audiobooks' : 'ebooks'}/${request.media.tmdbId}`; + return (
    - {book && bookCover(book) ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( -
    - )} + + {book && bookCover(book) ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
    + )} +
    {isAudio ? 'Audiobook' : 'Ebook'} @@ -137,9 +144,12 @@ const BookRequestItem = ({ request, revalidateList }: BookRequestItemProps) => { {book.releaseDate.slice(0, 4)} )}
    -
    + {book?.title ?? `Book #${request.media.tmdbId}`} -
    + {book && guessBookAuthor(book) && (
    {guessBookAuthor(book)} diff --git a/src/pages/audiobooks/[bookId].tsx b/src/pages/audiobooks/[bookId].tsx new file mode 100644 index 0000000000..050cb35e45 --- /dev/null +++ b/src/pages/audiobooks/[bookId].tsx @@ -0,0 +1,7 @@ +import BookDetails from '@app/components/BookDetails'; + +const AudiobookDetailsPage = () => { + return ; +}; + +export default AudiobookDetailsPage; diff --git a/src/pages/ebooks/[bookId].tsx b/src/pages/ebooks/[bookId].tsx new file mode 100644 index 0000000000..25c6597ef8 --- /dev/null +++ b/src/pages/ebooks/[bookId].tsx @@ -0,0 +1,7 @@ +import BookDetails from '@app/components/BookDetails'; + +const EbookDetailsPage = () => { + return ; +}; + +export default EbookDetailsPage; From 78d77af554673dd84fc43ad30355f4e28d7dbb65 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 01:18:18 -0400 Subject: [PATCH 12/23] feat(books): recommendations + issue reporting on detail page - 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 " 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. --- package.json | 2 +- server/routes/audiobook.ts | 37 +++++++++++ server/routes/ebook.ts | 34 ++++++++++ src/components/BookDetails/index.tsx | 95 ++++++++++++++++++++++++++-- 4 files changed, 160 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 5d54e13c69..224ad32745 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.11", + "version": "3.2.1-bryanlabs.12", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/routes/audiobook.ts b/server/routes/audiobook.ts index b88680ff61..2189b85a59 100644 --- a/server/routes/audiobook.ts +++ b/server/routes/audiobook.ts @@ -287,6 +287,43 @@ audiobookRoutes.get('/:foreignBookId', async (req, res, next) => { } }); +audiobookRoutes.get( + '/:foreignBookId/recommendations', + async (req, res, next) => { + const server = findAudiobookServer(); + if (!server) { + return next({ + status: 503, + message: 'No audiobook Bookshelf server is configured', + }); + } + try { + const client = getClient(server); + const baseLookup = await client.searchBook( + `work:${req.params.foreignBookId}` + ); + const base = baseLookup[0]; + if (!base) { + return res.status(200).json({ results: [] }); + } + const authorName = guessAuthorName(base.authorTitle, base.title); + if (!authorName) { + return res.status(200).json({ results: [] }); + } + const moreByAuthor = await client.searchBook(authorName).catch(() => []); + const filtered = moreByAuthor + .filter((b) => b.foreignBookId !== base.foreignBookId) + .slice(0, 20); + return res.status(200).json({ results: filtered }); + } catch (e) { + return next({ + status: 500, + message: `Audiobook recommendations failed: ${e.message}`, + }); + } + } +); + audiobookRoutes.get('/queue', async (req, res, next) => { const server = findAudiobookServer(); if (!server) { diff --git a/server/routes/ebook.ts b/server/routes/ebook.ts index 768f05880c..1cef5a282e 100644 --- a/server/routes/ebook.ts +++ b/server/routes/ebook.ts @@ -273,6 +273,40 @@ ebookRoutes.get('/:foreignBookId', async (req, res, next) => { } }); +ebookRoutes.get('/:foreignBookId/recommendations', async (req, res, next) => { + const server = findEbookServer(); + if (!server) { + return next({ + status: 503, + message: 'No ebook Bookshelf server is configured', + }); + } + try { + const client = getClient(server); + const baseLookup = await client.searchBook( + `work:${req.params.foreignBookId}` + ); + const base = baseLookup[0]; + if (!base) { + return res.status(200).json({ results: [] }); + } + const authorName = guessAuthorName(base.authorTitle, base.title); + if (!authorName) { + return res.status(200).json({ results: [] }); + } + const moreByAuthor = await client.searchBook(authorName).catch(() => []); + const filtered = moreByAuthor + .filter((b) => b.foreignBookId !== base.foreignBookId) + .slice(0, 20); + return res.status(200).json({ results: filtered }); + } catch (e) { + return next({ + status: 500, + message: `Ebook recommendations failed: ${e.message}`, + }); + } +}); + ebookRoutes.get('/queue', async (req, res, next) => { const server = findEbookServer(); if (!server) { diff --git a/src/components/BookDetails/index.tsx b/src/components/BookDetails/index.tsx index 4004fa5a51..f76596c3ca 100644 --- a/src/components/BookDetails/index.tsx +++ b/src/components/BookDetails/index.tsx @@ -4,6 +4,7 @@ import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; import Tag from '@app/components/Common/Tag'; +import IssueModal from '@app/components/IssueModal'; import StatusBadge from '@app/components/StatusBadge'; import { Permission, useUser } from '@app/hooks/useUser'; import ErrorPage from '@app/pages/_error'; @@ -13,16 +14,27 @@ import { CheckIcon, CloudIcon, CogIcon, + ExclamationTriangleIcon, StarIcon, } from '@heroicons/react/24/outline'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import type { BookDetails as BookDetailsType } from '@server/models/Book'; import axios from 'axios'; +import Link from 'next/link'; import { useRouter } from 'next/router'; import { useState } from 'react'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; +interface BookshelfRecommendationItem { + title: string; + foreignBookId: string; + releaseDate?: string; + remoteCover?: string; + images?: { coverType: string; url: string; remoteUrl?: string }[]; + authorTitle?: string; +} + interface BookDetailsProps { mediaType: 'audiobook' | 'ebook'; } @@ -44,7 +56,12 @@ const BookDetails = ({ mediaType }: BookDetailsProps) => { id ? `${apiBase}/${id}` : null ); + const { data: recs } = useSWR<{ results: BookshelfRecommendationItem[] }>( + id ? `${apiBase}/${id}/recommendations` : null + ); + const [requesting, setRequesting] = useState(false); + const [showIssueModal, setShowIssueModal] = useState(false); if (!data && !error) { return ; @@ -223,13 +240,24 @@ const BookDetails = ({ mediaType }: BookDetailsProps) => { )} + {data.mediaInfo && + hasPermission( + [Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES], + { type: 'or' } + ) && ( + + )}
    -
    - {intl('Overview')} -
    +
    Overview

    {data.overview ?? 'No overview available.'}

    @@ -328,12 +356,65 @@ const BookDetails = ({ mediaType }: BookDetailsProps) => { )}
    + + {recs && recs.results.length > 0 && ( +
    +
    +
    + + More by {data.authorName ?? 'this author'} + +
    +
    +
      + {recs.results.slice(0, 16).map((b) => { + const recCover = + b.remoteCover ?? + b.images?.find((i) => i.coverType === 'cover')?.remoteUrl ?? + b.images?.find((i) => i.coverType === 'cover')?.url; + const href = `/${mediaType === 'audiobook' ? 'audiobooks' : 'ebooks'}/${b.foreignBookId}`; + return ( +
    • + + {recCover ? ( + // eslint-disable-next-line @next/next/no-img-element + {b.title} + ) : ( +
      + )} +
      + {b.title} +
      + {b.releaseDate?.slice(0, 4) && ( +
      + {b.releaseDate.slice(0, 4)} +
      + )} + +
    • + ); + })} +
    +
    + )} + + {showIssueModal && data.mediaInfo && ( + setShowIssueModal(false)} + /> + )}
    ); }; -// Tiny i18n helper to keep imports light. The user-facing strings are simple -// for Phase 1+ and don't need extracted message catalogs. -const intl = (s: string) => s; - export default BookDetails; From 907756db4e518d2711c73ca87ab7aa7da21576c2 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 01:21:14 -0400 Subject: [PATCH 13/23] fix(books): route order /queue before /:foreignBookId; BookRequestCard - 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. --- server/routes/audiobook.ts | 32 ++++----- server/routes/ebook.ts | 32 ++++----- src/components/RequestCard/index.tsx | 99 ++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 32 deletions(-) diff --git a/server/routes/audiobook.ts b/server/routes/audiobook.ts index 2189b85a59..bccc7ff3b7 100644 --- a/server/routes/audiobook.ts +++ b/server/routes/audiobook.ts @@ -210,6 +210,22 @@ audiobookRoutes.post('/request', async (req, res, next) => { } }); +audiobookRoutes.get('/queue', async (req, res, next) => { + const server = findAudiobookServer(); + if (!server) { + return next({ + status: 503, + message: 'No audiobook Bookshelf server is configured', + }); + } + try { + const queue = await getClient(server).getQueue(); + return res.status(200).json({ serverId: server.id, queue }); + } catch { + return next({ status: 500, message: 'Failed to retrieve audiobook queue' }); + } +}); + audiobookRoutes.get('/info/:foreignBookId', async (req, res, next) => { const server = findAudiobookServer(); if (!server) { @@ -324,20 +340,4 @@ audiobookRoutes.get( } ); -audiobookRoutes.get('/queue', async (req, res, next) => { - const server = findAudiobookServer(); - if (!server) { - return next({ - status: 503, - message: 'No audiobook Bookshelf server is configured', - }); - } - try { - const queue = await getClient(server).getQueue(); - return res.status(200).json({ serverId: server.id, queue }); - } catch { - return next({ status: 500, message: 'Failed to retrieve audiobook queue' }); - } -}); - export default audiobookRoutes; diff --git a/server/routes/ebook.ts b/server/routes/ebook.ts index 1cef5a282e..ed94826c77 100644 --- a/server/routes/ebook.ts +++ b/server/routes/ebook.ts @@ -196,6 +196,22 @@ ebookRoutes.post('/request', async (req, res, next) => { } }); +ebookRoutes.get('/queue', async (req, res, next) => { + const server = findEbookServer(); + if (!server) { + return next({ + status: 503, + message: 'No ebook Bookshelf server is configured', + }); + } + try { + const queue = await getClient(server).getQueue(); + return res.status(200).json({ serverId: server.id, queue }); + } catch { + return next({ status: 500, message: 'Failed to retrieve ebook queue' }); + } +}); + ebookRoutes.get('/info/:foreignBookId', async (req, res, next) => { const server = findEbookServer(); if (!server) { @@ -307,20 +323,4 @@ ebookRoutes.get('/:foreignBookId/recommendations', async (req, res, next) => { } }); -ebookRoutes.get('/queue', async (req, res, next) => { - const server = findEbookServer(); - if (!server) { - return next({ - status: 503, - message: 'No ebook Bookshelf server is configured', - }); - } - try { - const queue = await getClient(server).getQueue(); - return res.status(200).json({ serverId: server.id, queue }); - } catch { - return next({ status: 500, message: 'Failed to retrieve ebook queue' }); - } -}); - export default ebookRoutes; diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 739fec061a..693377d7e9 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -50,6 +50,97 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { return (movie as MovieDetails).title !== undefined; }; +interface BookCardSummary { + title: string; + authorTitle?: string; + releaseDate?: string; + remoteCover?: string; + images?: { coverType: string; url: string; remoteUrl?: string }[]; +} + +const titleCaseRC = (s: string) => + s.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()); +const guessAuthorRC = (book: BookCardSummary) => { + if (!book.authorTitle) return ''; + let s = book.authorTitle.replace(book.title, '').trim(); + if (s.endsWith(',')) s = s.slice(0, -1).trim(); + return titleCaseRC(s.split(/[, ]+/).filter(Boolean).slice(0, 4).join(' ')); +}; +const bookCoverRC = (book: BookCardSummary) => + book.remoteCover ?? + book.images?.find((i) => i.coverType === 'cover')?.remoteUrl ?? + book.images?.find((i) => i.coverType === 'cover')?.url; + +const BookRequestCard = ({ + request, +}: { + request: NonFunctionProperties; +}) => { + const { ref, inView } = useInView({ triggerOnce: true }); + const isAudio = request.type === 'audiobook'; + const apiBase = isAudio ? '/api/v1/audiobook' : '/api/v1/ebook'; + const detailHref = `/${isAudio ? 'audiobooks' : 'ebooks'}/${request.media.tmdbId}`; + const { data: book } = useSWR( + inView ? `${apiBase}/info/${request.media.tmdbId}` : null + ); + const { data: requestData } = useSWR>( + `/api/v1/request/${request.id}`, + { fallbackData: request } + ); + const cover = book ? bookCoverRC(book) : undefined; + const author = book ? guessAuthorRC(book) : ''; + + const status = requestData?.status ?? request.status; + let statusBadge: React.ReactNode; + if (status === MediaRequestStatus.DECLINED) { + statusBadge = Declined; + } else if (status === MediaRequestStatus.FAILED) { + statusBadge = Failed; + } else if (status === MediaRequestStatus.APPROVED) { + statusBadge = Approved; + } else { + statusBadge = Pending; + } + + return ( + +
    + {cover ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
    + )} +
    +
    + {isAudio ? 'Audiobook' : 'Ebook'} + {book?.releaseDate?.slice(0, 4) && ( + + {book.releaseDate.slice(0, 4)} + + )} +
    +
    + {book?.title ?? `Book #${request.media.tmdbId}`} +
    + {author && ( +
    {author}
    + )} +
    {statusBadge}
    +
    +
    + + ); +}; + const RequestCardPlaceholder = () => { return (
    @@ -220,6 +311,14 @@ interface RequestCardProps { } const RequestCard = ({ request, onTitleData }: RequestCardProps) => { + if (request.type === 'audiobook' || request.type === 'ebook') { + return ; + } + + return ; +}; + +const MovieOrTvRequestCard = ({ request, onTitleData }: RequestCardProps) => { const { ref, inView } = useInView({ triggerOnce: true, }); From 04d5236afe8d9fad5e00f54286ab2bcd69918b65 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 01:29:50 -0400 Subject: [PATCH 14/23] feat(books): admin actions + book notifications + interface cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 2 +- server/api/servarr/bookshelf.ts | 18 ++++++ server/entity/MediaRequest.ts | 27 ++++++++- server/models/Book.ts | 15 ++--- server/routes/media.ts | 55 +++++++++++++++++ src/components/BookDetails/index.tsx | 89 ++++++++++++++++++++++++++++ 6 files changed, 193 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 224ad32745..cfcf2a86da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.12", + "version": "3.2.1-bryanlabs.13", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/api/servarr/bookshelf.ts b/server/api/servarr/bookshelf.ts index 2451c8185e..d82d348530 100644 --- a/server/api/servarr/bookshelf.ts +++ b/server/api/servarr/bookshelf.ts @@ -52,6 +52,17 @@ export interface BookshelfBook { addType?: 'automatic' | 'manual'; }; images?: BookshelfAuthorImage[]; + remoteCover?: string; + ratings?: { + value?: number; + votes?: number; + popularity?: number; + }; + genres?: string[]; + links?: { + url: string; + name: string; + }[]; editions?: { id?: number; title?: string; @@ -60,8 +71,15 @@ export interface BookshelfBook { asin?: string; format?: string; language?: string; + pageCount?: number; monitored?: boolean; }[]; + statistics?: { + bookFileCount?: number; + bookCount?: number; + sizeOnDisk?: number; + percentOfBooks?: number; + }; } export interface AddBookOptions { diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 0252799220..01b6b8a616 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -729,7 +729,14 @@ export class MediaRequest { const tmdb = new TheMovieDb(); try { - const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series'; + const mediaType = + entity.type === MediaType.MOVIE + ? 'Movie' + : entity.type === MediaType.AUDIOBOOK + ? 'Audiobook' + : entity.type === MediaType.EBOOK + ? 'Ebook' + : 'Series'; let event: string | undefined; let notifyAdmin = true; let notifySystem = true; @@ -813,6 +820,24 @@ export class MediaRequest { }, ], }); + } else if ( + entity.type === MediaType.AUDIOBOOK || + entity.type === MediaType.EBOOK + ) { + // Books don't have TMDB metadata; we send a minimal notification + // using the media row + book id. Phase 2 can enrich the subject by + // resolving the title from Bookshelf, but that requires an extra + // network call inside the notifier, so we keep it lightweight here. + notificationManager.sendNotification(type, { + media, + request: entity, + notifyAdmin, + notifySystem, + notifyUser: notifyAdmin ? undefined : entity.requestedBy, + event, + subject: `${mediaType} (Hardcover work ${media.tmdbId})`, + message: '', + }); } } catch (e) { logger.error('Something went wrong sending media notification(s)', { diff --git a/server/models/Book.ts b/server/models/Book.ts index 360f265391..212df37ead 100644 --- a/server/models/Book.ts +++ b/server/models/Book.ts @@ -105,18 +105,11 @@ export const mapBookDetails = ( authorName, releaseDate: book.releaseDate, pageCount: book.pageCount, - remoteCover: - 'remoteCover' in book - ? (book as { remoteCover?: string }).remoteCover - : undefined, + remoteCover: book.remoteCover, images: book.images, - ratings: - 'ratings' in book - ? (book as { ratings?: { value?: number; votes?: number } }).ratings - : undefined, - genres: - 'genres' in book ? (book as { genres?: string[] }).genres : undefined, - links: 'links' in book ? (book as { links?: BookLink[] }).links : undefined, + ratings: book.ratings, + genres: book.genres, + links: book.links, editions: book.editions, mediaType, mediaInfo: media, diff --git a/server/routes/media.ts b/server/routes/media.ts index de3c1b6dc3..19387e6784 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,3 +1,4 @@ +import BookshelfAPI from '@server/api/servarr/bookshelf'; import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TautulliAPI from '@server/api/tautulli'; @@ -289,6 +290,60 @@ mediaRoutes.delete( } ); +// Bookshelf book file delete: removes the book from the configured +// Bookshelf instance. Used by the admin "Delete from Bookshelf" action on +// the book detail page. +mediaRoutes.delete( + '/:id/bookfile', + isAuthenticated(Permission.MANAGE_REQUESTS), + async (req, res, next) => { + try { + const settings = getSettings(); + const mediaRepository = getRepository(Media); + const media = await mediaRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + + if ( + media.mediaType !== MediaType.AUDIOBOOK && + media.mediaType !== MediaType.EBOOK + ) { + return next({ + status: 400, + message: 'Endpoint only valid for book media types', + }); + } + + const server = settings.bookshelf.find((b) => b.id === media.serviceId); + if (!server) { + return next({ + status: 404, + message: 'No Bookshelf instance recorded for this media', + }); + } + if (!media.externalServiceId) { + return next({ + status: 404, + message: 'No Bookshelf book id recorded for this media', + }); + } + + const client = new BookshelfAPI({ + apiKey: server.apiKey, + url: BookshelfAPI.buildUrl(server, '/api/v1'), + }); + await client.removeBook(media.externalServiceId); + return res.status(204).send(); + } catch (e) { + logger.error('Something went wrong fetching media in delete request', { + label: 'Media', + message: e.message, + }); + next({ status: 404, message: 'Media not found' }); + } + } +); + mediaRoutes.get<{ id: string }, MediaWatchDataResponse>( '/:id/watch_data', isAuthenticated(Permission.ADMIN), diff --git a/src/components/BookDetails/index.tsx b/src/components/BookDetails/index.tsx index f76596c3ca..090c6b7765 100644 --- a/src/components/BookDetails/index.tsx +++ b/src/components/BookDetails/index.tsx @@ -16,6 +16,7 @@ import { CogIcon, ExclamationTriangleIcon, StarIcon, + TrashIcon, } from '@heroicons/react/24/outline'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import type { BookDetails as BookDetailsType } from '@server/models/Book'; @@ -62,6 +63,49 @@ const BookDetails = ({ mediaType }: BookDetailsProps) => { const [requesting, setRequesting] = useState(false); const [showIssueModal, setShowIssueModal] = useState(false); + const [managing, setManaging] = useState(false); + + const setMediaStatus = async ( + status: 'available' | 'pending' | 'processing' | 'unknown' + ) => { + if (!data?.mediaInfo?.id) return; + setManaging(true); + try { + await axios.post(`/api/v1/media/${data.mediaInfo.id}/${status}`); + addToast(`Marked as ${status}`, { + appearance: 'success', + autoDismiss: true, + }); + mutate(); + } catch { + addToast('Failed to update status', { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setManaging(false); + } + }; + + const removeFromBookshelf = async () => { + if (!data?.mediaInfo?.id) return; + setManaging(true); + try { + await axios.delete(`/api/v1/media/${data.mediaInfo.id}/bookfile`); + addToast('Book removed from Bookshelf', { + appearance: 'success', + autoDismiss: true, + }); + mutate(); + } catch { + addToast('Failed to remove from Bookshelf', { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setManaging(false); + } + }; if (!data && !error) { return ; @@ -255,6 +299,51 @@ const BookDetails = ({ mediaType }: BookDetailsProps) => { )}
    + + {data.mediaInfo && hasPermission(Permission.MANAGE_REQUESTS) && ( +
    + + Admin: + + + + + + +
    + )}
    Overview
    From 25d284a053adf5facd6bdb9677990a665ece3d88 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 01:31:53 -0400 Subject: [PATCH 15/23] feat(books): bookshelf-sync job for availability tracking 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. --- package.json | 2 +- server/job/schedule.ts | 22 ++++++ server/lib/bookshelfSync.ts | 125 +++++++++++++++++++++++++++++++++++ server/lib/settings/index.ts | 4 ++ 4 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 server/lib/bookshelfSync.ts diff --git a/package.json b/package.json index cfcf2a86da..d857f9ab0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.13", + "version": "3.2.1-bryanlabs.14", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/job/schedule.ts b/server/job/schedule.ts index a9afd2f4d6..7b899940ec 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,6 +1,7 @@ import { MediaServerType } from '@server/constants/server'; import blocklistedTagsProcessor from '@server/job/blocklistedTagsProcessor'; import availabilitySync from '@server/lib/availabilitySync'; +import bookshelfSync from '@server/lib/bookshelfSync'; import downloadTracker from '@server/lib/downloadtracker'; import ImageProxy from '@server/lib/imageproxy'; import refreshToken from '@server/lib/refreshToken'; @@ -209,6 +210,27 @@ export const startJobs = (): void => { }), }); + // Bookshelf availability sync — polls Bookshelf for completed grabs and + // flips Media.status to AVAILABLE so MEDIA_AVAILABLE notifications fire. + scheduledJobs.push({ + id: 'bookshelf-sync', + name: 'Bookshelf Sync', + type: 'process', + interval: 'minutes', + cronSchedule: jobs['bookshelf-sync']?.schedule ?? '0 */5 * * * *', + job: schedule.scheduleJob( + jobs['bookshelf-sync']?.schedule ?? '0 */5 * * * *', + () => { + logger.debug('Starting scheduled job: Bookshelf Sync', { + label: 'Jobs', + }); + bookshelfSync.run(); + } + ), + running: () => bookshelfSync.status().running, + cancelFn: () => bookshelfSync.cancel(), + }); + // Reset download sync everyday at 01:00 am scheduledJobs.push({ id: 'download-sync-reset', diff --git a/server/lib/bookshelfSync.ts b/server/lib/bookshelfSync.ts new file mode 100644 index 0000000000..0253e914bb --- /dev/null +++ b/server/lib/bookshelfSync.ts @@ -0,0 +1,125 @@ +import BookshelfAPI from '@server/api/servarr/bookshelf'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { In } from 'typeorm'; + +class BookshelfSync { + private isRunning = false; + + public status() { + return { running: this.isRunning }; + } + + public cancel() { + // Single-pass; nothing to cancel. + } + + public async run(): Promise { + if (this.isRunning) { + logger.debug('Bookshelf sync already running, skipping', { + label: 'BookshelfSync', + }); + return; + } + this.isRunning = true; + try { + const settings = getSettings(); + if (!settings.bookshelf || settings.bookshelf.length === 0) { + return; + } + + const mediaRepo = getRepository(Media); + const inFlight = await mediaRepo.find({ + where: { + mediaType: In([MediaType.AUDIOBOOK, MediaType.EBOOK]), + status: In([ + MediaStatus.PENDING, + MediaStatus.PROCESSING, + MediaStatus.PARTIALLY_AVAILABLE, + ]), + }, + }); + + if (inFlight.length === 0) { + return; + } + + logger.debug(`Polling ${inFlight.length} in-flight books`, { + label: 'BookshelfSync', + }); + + // Cache one client per server, and pre-fetch /book once per server so + // we issue at most one HTTP call per Bookshelf instance per run. + const bookCache = new Map< + number, + Map + >(); + for (const server of settings.bookshelf) { + try { + const client = new BookshelfAPI({ + apiKey: server.apiKey, + url: BookshelfAPI.buildUrl(server, '/api/v1'), + }); + const books = await client.getBooks(); + const byId = new Map(); + for (const b of books) { + if (b.id) byId.set(b.id, b); + } + bookCache.set(server.id, byId); + } catch (e) { + logger.warn('Bookshelf instance unreachable during sync', { + label: 'BookshelfSync', + serverId: server.id, + errorMessage: (e as Error).message, + }); + } + } + + let availableCount = 0; + for (const media of inFlight) { + if (media.serviceId == null || media.externalServiceId == null) { + continue; + } + const cache = bookCache.get(media.serviceId); + if (!cache) continue; + const book = cache.get(media.externalServiceId); + if (!book) continue; + const fileCount = book.statistics?.bookFileCount ?? 0; + if (fileCount > 0 && media.status !== MediaStatus.AVAILABLE) { + media.status = MediaStatus.AVAILABLE; + if (!media.mediaAddedAt) { + media.mediaAddedAt = new Date(); + } + await mediaRepo.save(media); + availableCount += 1; + logger.info(`Marked media ${media.id} as AVAILABLE from Bookshelf`, { + label: 'BookshelfSync', + mediaType: media.mediaType, + tmdbId: media.tmdbId, + externalServiceId: media.externalServiceId, + }); + } + } + + if (availableCount > 0) { + logger.info( + `Bookshelf sync flipped ${availableCount} media row(s) to AVAILABLE`, + { label: 'BookshelfSync' } + ); + } + } catch (e) { + logger.error('Bookshelf sync failed', { + label: 'BookshelfSync', + errorMessage: (e as Error).message, + }); + } finally { + this.isRunning = false; + } + } +} + +const bookshelfSync = new BookshelfSync(); +export default bookshelfSync; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 25e5048298..c8ff1d9dd7 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -371,6 +371,7 @@ export type JobId = | 'plex-refresh-token' | 'radarr-scan' | 'sonarr-scan' + | 'bookshelf-sync' | 'download-sync' | 'download-sync-reset' | 'jellyfin-recently-added-scan' @@ -596,6 +597,9 @@ class Settings { 'sonarr-scan': { schedule: '0 30 4 * * *', }, + 'bookshelf-sync': { + schedule: '0 */5 * * * *', + }, 'availability-sync': { schedule: '0 0 5 * * *', }, From 2aeba493318043bd76b307d026b6a9ecbda5f02f Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 01:34:55 -0400 Subject: [PATCH 16/23] feat(books): audiobook/ebook filter on the request list - 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. --- package.json | 2 +- server/routes/request.ts | 10 ++++++++++ src/components/RequestList/index.tsx | 2 ++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index d857f9ab0f..b0c3e631ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.14", + "version": "3.2.1-bryanlabs.15", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/routes/request.ts b/server/routes/request.ts index 289654fffe..f733d0d1d7 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -174,6 +174,16 @@ requestRoutes.get, RequestResultsResponse>( type: MediaType.TV, }); break; + case 'audiobook': + query = query.andWhere('request.type = :type', { + type: MediaType.AUDIOBOOK, + }); + break; + case 'ebook': + query = query.andWhere('request.type = :type', { + type: MediaType.EBOOK, + }); + break; } const [requests, requestCount] = await query diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index a5e21f2320..244bb7a8ac 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -194,6 +194,8 @@ const RequestList = () => { + +
    From 571c2809199b7f9b62dfb2d56cb221b1ea5b6d8f Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 01:36:58 -0400 Subject: [PATCH 17/23] feat(books): notify on AVAILABLE for audiobook/ebook MediaRequestSubscriber.afterUpdate now invokes the generic sendNotification path for AUDIOBOOK/EBOOK requests transitioning to COMPLETED, mirroring the movie/tv branches. --- server/subscriber/MediaRequestSubscriber.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index 0f10c33804..e61295408e 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -1003,6 +1003,19 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface Date: Sat, 25 Apr 2026 01:39:15 -0400 Subject: [PATCH 18/23] feat(books): force-search indexers admin button 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. --- package.json | 2 +- server/routes/audiobook.ts | 33 ++++++++++++++++++++++++++++ server/routes/ebook.ts | 33 ++++++++++++++++++++++++++++ src/components/BookDetails/index.tsx | 26 ++++++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b0c3e631ed..4dc46137f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.15", + "version": "3.2.1-bryanlabs.16", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/routes/audiobook.ts b/server/routes/audiobook.ts index bccc7ff3b7..29bd329c16 100644 --- a/server/routes/audiobook.ts +++ b/server/routes/audiobook.ts @@ -303,6 +303,39 @@ audiobookRoutes.get('/:foreignBookId', async (req, res, next) => { } }); +audiobookRoutes.post('/:foreignBookId/search', async (req, res, next) => { + const tmdbId = Number(req.params.foreignBookId); + if (!Number.isFinite(tmdbId)) { + return next({ status: 400, message: 'foreignBookId must be numeric' }); + } + const media = await getRepository(Media).findOne({ + where: { tmdbId, mediaType: MediaType.AUDIOBOOK }, + }); + if (!media || !media.serviceId || !media.externalServiceId) { + return next({ + status: 404, + message: 'Book not yet added to Bookshelf', + }); + } + const server = getSettings().bookshelf.find((b) => b.id === media.serviceId); + if (!server) { + return next({ + status: 404, + message: 'Bookshelf instance not found', + }); + } + try { + const client = getClient(server); + await client.searchBookCommand(media.externalServiceId); + return res.status(202).json({ message: 'BookSearch queued' }); + } catch (e) { + return next({ + status: 500, + message: `Force search failed: ${e.message}`, + }); + } +}); + audiobookRoutes.get( '/:foreignBookId/recommendations', async (req, res, next) => { diff --git a/server/routes/ebook.ts b/server/routes/ebook.ts index ed94826c77..f5ed94b2b7 100644 --- a/server/routes/ebook.ts +++ b/server/routes/ebook.ts @@ -289,6 +289,39 @@ ebookRoutes.get('/:foreignBookId', async (req, res, next) => { } }); +ebookRoutes.post('/:foreignBookId/search', async (req, res, next) => { + const tmdbId = Number(req.params.foreignBookId); + if (!Number.isFinite(tmdbId)) { + return next({ status: 400, message: 'foreignBookId must be numeric' }); + } + const media = await getRepository(Media).findOne({ + where: { tmdbId, mediaType: MediaType.EBOOK }, + }); + if (!media || !media.serviceId || !media.externalServiceId) { + return next({ + status: 404, + message: 'Book not yet added to Bookshelf', + }); + } + const server = getSettings().bookshelf.find((b) => b.id === media.serviceId); + if (!server) { + return next({ + status: 404, + message: 'Bookshelf instance not found', + }); + } + try { + const client = getClient(server); + await client.searchBookCommand(media.externalServiceId); + return res.status(202).json({ message: 'BookSearch queued' }); + } catch (e) { + return next({ + status: 500, + message: `Force search failed: ${e.message}`, + }); + } +}); + ebookRoutes.get('/:foreignBookId/recommendations', async (req, res, next) => { const server = findEbookServer(); if (!server) { diff --git a/src/components/BookDetails/index.tsx b/src/components/BookDetails/index.tsx index 090c6b7765..528b4b19be 100644 --- a/src/components/BookDetails/index.tsx +++ b/src/components/BookDetails/index.tsx @@ -107,6 +107,25 @@ const BookDetails = ({ mediaType }: BookDetailsProps) => { } }; + const forceSearchIndexers = async () => { + if (!id) return; + setManaging(true); + try { + await axios.post(`${apiBase}/${id}/search`); + addToast('Indexer search queued in Bookshelf', { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + const message = + (e as { response?: { data?: { message?: string } } }).response?.data + ?.message ?? 'Failed to queue search'; + addToast(message, { appearance: 'error', autoDismiss: true }); + } finally { + setManaging(false); + } + }; + if (!data && !error) { return ; } @@ -335,6 +354,13 @@ const BookDetails = ({ mediaType }: BookDetailsProps) => { Delete from Bookshelf +
  • {data.authorName && ( - + by {data.authorName} )} {data.genres && data.genres.length > 0 && ( - + {data.genres.slice(0, 6).map((g) => ( {g} ))} )} {ratingValue !== undefined && ratingValue > 0 && ( - + {ratingValue.toFixed(2)} {ratingVotes !== undefined && ( From d6bce86bd143aaa6d868faaa2d69c9b93c61f964 Mon Sep 17 00:00:00 2001 From: Dan Bryan Date: Sat, 25 Apr 2026 10:44:44 -0400 Subject: [PATCH 22/23] feat(books): request modal with quality profile selector + author resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 2 +- server/models/Book.ts | 58 ++++++- server/routes/audiobook.ts | 42 ++++- server/routes/ebook.ts | 42 ++++- server/subscriber/MediaRequestSubscriber.ts | 38 ++++- src/components/BookDetails/index.tsx | 51 +++--- src/components/BookRequestModal/index.tsx | 165 ++++++++++++++++++++ src/components/BookSearch/index.tsx | 54 ++++--- 8 files changed, 369 insertions(+), 83 deletions(-) create mode 100644 src/components/BookRequestModal/index.tsx diff --git a/package.json b/package.json index dd50fffa81..0a3cf83132 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "3.2.1-bryanlabs.19", + "version": "3.2.1-bryanlabs.20", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/server/models/Book.ts b/server/models/Book.ts index 212df37ead..19eda30ce5 100644 --- a/server/models/Book.ts +++ b/server/models/Book.ts @@ -62,20 +62,64 @@ const titleCase = (s: string): string => /** * Bookshelf returns `authorTitle` as a junk-formatted string like - * "baroness, orczy, emmuska orczy The Scarlet Pimpernel". Strip the trailing - * book title and the leading honorifics/commas to recover the author name. + * "weir, andy The Martian" or + * "baroness, orczy, emmuska orczy The Scarlet Pimpernel". + * + * The leading comma-separated chunk is "lastname, firstname [extras]"; we + * try several candidates and let the caller pick the one that resolves + * against /author/lookup. */ -export const guessAuthorName = ( +export const guessAuthorCandidates = ( authorTitle: string | undefined, title: string -): string => { - if (!authorTitle) return ''; +): string[] => { + if (!authorTitle) return []; let s = authorTitle.replace(title, '').trim(); if (s.endsWith(',')) s = s.slice(0, -1).trim(); - const tokens = s.split(/[, ]+/).filter(Boolean); - return titleCase(tokens.slice(0, 4).join(' ')); + const out: string[] = []; + + const seen = new Set(); + const push = (candidate: string) => { + const c = titleCase(candidate.trim()).replace(/\s+/g, ' '); + if (c && !seen.has(c.toLowerCase())) { + seen.add(c.toLowerCase()); + out.push(c); + } + }; + + // 1) Comma-separated form: "last, first [extra extra]" + if (s.includes(',')) { + const parts = s + .split(',') + .map((p) => p.trim()) + .filter(Boolean); + if (parts.length >= 2) { + // Reorder: "first last" using the first two parts + push(`${parts[1]} ${parts[0]}`); + // If the trailing chunk repeats the surname e.g. "orczy, emmuska orczy", + // the third-or-later part often contains the canonical full name. + for (let i = 2; i < parts.length; i++) push(parts[i]); + // Also try the simple two-part reverse + push(parts.slice().reverse().join(' ')); + } + } + + // 2) Whole-string fallback (in case the above misses) + push(s); + + // 3) Just the first comma-separated chunk (lastname only) + const firstComma = s.split(',')[0]?.trim(); + if (firstComma) push(firstComma); + + return out; }; +/** Backwards-compatible: returns the first candidate or an empty string. */ +export const guessAuthorName = ( + authorTitle: string | undefined, + title: string +): string => guessAuthorCandidates(authorTitle, title)[0] ?? ''; + export const mapBookDetails = ( book: BookshelfBook, mediaType: BookDetails['mediaType'], diff --git a/server/routes/audiobook.ts b/server/routes/audiobook.ts index 8564077a0b..34073db18f 100644 --- a/server/routes/audiobook.ts +++ b/server/routes/audiobook.ts @@ -68,12 +68,42 @@ audiobookRoutes.get('/search', async (req, res, next) => { } }); +audiobookRoutes.get('/profiles', async (_req, res, next) => { + const server = findAudiobookServer(); + if (!server) { + return next({ + status: 503, + message: 'No audiobook Bookshelf server is configured', + }); + } + try { + const client = getClient(server); + const [profiles, metadataProfiles] = await Promise.all([ + client.getProfiles(), + client.getMetadataProfiles(), + ]); + return res.status(200).json({ + profiles, + metadataProfiles, + defaultProfileId: server.activeProfileId, + defaultMetadataProfileId: server.activeMetadataProfileId, + }); + } catch (e) { + return next({ + status: 500, + message: `Profiles fetch failed: ${e.message}`, + }); + } +}); + audiobookRoutes.post('/request', async (req, res, next) => { - const { foreignBookId, foreignAuthorId, authorName } = req.body as { - foreignBookId?: string; - foreignAuthorId?: string; - authorName?: string; - }; + const { foreignBookId, foreignAuthorId, authorName, profileId } = + req.body as { + foreignBookId?: string; + foreignAuthorId?: string; + authorName?: string; + profileId?: number; + }; if (!foreignBookId) { return next({ status: 400, message: 'foreignBookId is required' }); @@ -152,7 +182,7 @@ audiobookRoutes.post('/request', async (req, res, next) => { modifiedBy: autoApprove ? req.user : undefined, is4k: false, serverId: server.id, - profileId: server.activeProfileId, + profileId: profileId ?? server.activeProfileId, rootFolder: server.activeDirectory, tags: [], isAutoRequest: false, diff --git a/server/routes/ebook.ts b/server/routes/ebook.ts index 20847e6028..1effa4c8c0 100644 --- a/server/routes/ebook.ts +++ b/server/routes/ebook.ts @@ -58,12 +58,42 @@ ebookRoutes.get('/search', async (req, res, next) => { } }); +ebookRoutes.get('/profiles', async (_req, res, next) => { + const server = findEbookServer(); + if (!server) { + return next({ + status: 503, + message: 'No ebook Bookshelf server is configured', + }); + } + try { + const client = getClient(server); + const [profiles, metadataProfiles] = await Promise.all([ + client.getProfiles(), + client.getMetadataProfiles(), + ]); + return res.status(200).json({ + profiles, + metadataProfiles, + defaultProfileId: server.activeProfileId, + defaultMetadataProfileId: server.activeMetadataProfileId, + }); + } catch (e) { + return next({ + status: 500, + message: `Profiles fetch failed: ${e.message}`, + }); + } +}); + ebookRoutes.post('/request', async (req, res, next) => { - const { foreignBookId, foreignAuthorId, authorName } = req.body as { - foreignBookId?: string; - foreignAuthorId?: string; - authorName?: string; - }; + const { foreignBookId, foreignAuthorId, authorName, profileId } = + req.body as { + foreignBookId?: string; + foreignAuthorId?: string; + authorName?: string; + profileId?: number; + }; if (!foreignBookId) { return next({ status: 400, message: 'foreignBookId is required' }); @@ -141,7 +171,7 @@ ebookRoutes.post('/request', async (req, res, next) => { modifiedBy: autoApprove ? req.user : undefined, is4k: false, serverId: server.id, - profileId: server.activeProfileId, + profileId: profileId ?? server.activeProfileId, rootFolder: server.activeDirectory, tags: [], isAutoRequest: false, diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index 2626f5bb95..45e3a999c4 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -873,7 +873,7 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface []); + const top = result[0]; + if (top?.foreignAuthorId) { + resolvedForeignAuthorId = top.foreignAuthorId; + resolvedAuthorName = top.authorName; + break; + } + } + + if (!resolvedForeignAuthorId) { + logger.error('Could not resolve author from any candidate', { + label: 'Media Request', + requestId: entity.id, + authorTitle: match.authorTitle, + candidates, + }); + const requestRepository = getRepository(MediaRequest); + entity.status = MediaRequestStatus.FAILED; + await requestRepository.save(entity); + return; + } try { const book = await client.addBook({ foreignBookId: String(media.tmdbId), - authorName, - profileId: server.activeProfileId, + foreignAuthorId: resolvedForeignAuthorId, + authorName: resolvedAuthorName, + // Honor the per-request quality profile override stored on the + // MediaRequest (set via the modal's Quality Profile dropdown). + profileId: entity.profileId ?? server.activeProfileId, metadataProfileId: server.activeMetadataProfileId, rootFolderPath: server.activeDirectory, monitored: true, diff --git a/src/components/BookDetails/index.tsx b/src/components/BookDetails/index.tsx index 4f51ebe44a..d8d51df0f0 100644 --- a/src/components/BookDetails/index.tsx +++ b/src/components/BookDetails/index.tsx @@ -1,4 +1,5 @@ import Spinner from '@app/assets/spinner.svg'; +import BookRequestModal from '@app/components/BookRequestModal'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; @@ -61,7 +62,7 @@ const BookDetails = ({ mediaType }: BookDetailsProps) => { id ? `${apiBase}/${id}/recommendations` : null ); - const [requesting, setRequesting] = useState(false); + const [showRequestModal, setShowRequestModal] = useState(false); const [showIssueModal, setShowIssueModal] = useState(false); const [managing, setManaging] = useState(false); @@ -145,28 +146,9 @@ const BookDetails = ({ mediaType }: BookDetailsProps) => { r.status === MediaRequestStatus.APPROVED ); - const submitRequest = async () => { - setRequesting(true); - try { - await axios.post(`${apiBase}/request`, { - foreignBookId: data.foreignBookId, - authorName: data.authorName ?? data.author?.authorName, - title: data.title, - }); - addToast(`Requested: ${data.title}`, { - appearance: 'success', - autoDismiss: true, - }); - mutate(); - } catch (e) { - const message = - (e as { response?: { data?: { message?: string } } }).response?.data - ?.message ?? 'Request failed'; - addToast(message, { appearance: 'error', autoDismiss: true }); - } finally { - setRequesting(false); - } - }; + // The actual request submission lives inside BookRequestModal; we just + // open the modal here. + const openRequestModal = () => setShowRequestModal(true); const ratingValue = data.ratings?.value; const ratingVotes = data.ratings?.votes; @@ -265,16 +247,8 @@ const BookDetails = ({ mediaType }: BookDetailsProps) => { Processing ) : ( - )} @@ -528,6 +502,17 @@ const BookDetails = ({ mediaType }: BookDetailsProps) => { onCancel={() => setShowIssueModal(false)} /> )} + + setShowRequestModal(false)} + onComplete={() => mutate()} + />
    ); }; diff --git a/src/components/BookRequestModal/index.tsx b/src/components/BookRequestModal/index.tsx new file mode 100644 index 0000000000..5261ac7be7 --- /dev/null +++ b/src/components/BookRequestModal/index.tsx @@ -0,0 +1,165 @@ +import Modal from '@app/components/Common/Modal'; +import { Transition } from '@headlessui/react'; +import axios from 'axios'; +import { useState } from 'react'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; + +interface BookProfile { + id: number; + name: string; +} + +interface ProfilesResponse { + profiles: BookProfile[]; + metadataProfiles: BookProfile[]; + defaultProfileId: number; + defaultMetadataProfileId: number; +} + +interface BookRequestModalProps { + show: boolean; + mediaType: 'audiobook' | 'ebook'; + foreignBookId: string; + authorName?: string; + title?: string; + cover?: string; + onClose: () => void; + onComplete?: () => void; +} + +const BookRequestModal = ({ + show, + mediaType, + foreignBookId, + authorName, + title, + cover, + onClose, + onComplete, +}: BookRequestModalProps) => { + const { addToast } = useToasts(); + const apiBase = + mediaType === 'audiobook' ? '/api/v1/audiobook' : '/api/v1/ebook'; + const { data: profilesData } = useSWR( + show ? `${apiBase}/profiles` : null + ); + const [selectedProfile, setSelectedProfile] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const effectiveProfile = + selectedProfile ?? profilesData?.defaultProfileId ?? null; + + const submit = async () => { + setSubmitting(true); + try { + await axios.post(`${apiBase}/request`, { + foreignBookId, + authorName, + title, + profileId: effectiveProfile ?? undefined, + }); + addToast(`Requested: ${title ?? 'book'}`, { + appearance: 'success', + autoDismiss: true, + }); + onComplete?.(); + onClose(); + } catch (e) { + const message = + (e as { response?: { data?: { message?: string } } }).response?.data + ?.message ?? 'Request failed'; + addToast(message, { appearance: 'error', autoDismiss: true }); + } finally { + setSubmitting(false); + } + }; + + return ( + + +
    + {authorName && ( +
    + by + {authorName} +
    + )} +
    +
    + Quality Profile +
    + {!profilesData ? ( +
    Loading profiles…
    + ) : ( + + )} + {mediaType === 'audiobook' && ( +

    + Spoken (m4b) only + allows m4b releases.{' '} + Spoken (mp3) allows + mp3. Most MyAnonamouse releases are mp3. +

    + )} +
    + {profilesData?.metadataProfiles && + profilesData.metadataProfiles.length > 0 && ( +
    +
    + Metadata Profile +
    +
    + Using{' '} + + { + profilesData.metadataProfiles.find( + (p) => p.id === profilesData.defaultMetadataProfileId + )?.name + } + {' '} + (server default) +
    +
    + )} +
    +
    +
    + ); +}; + +export default BookRequestModal; diff --git a/src/components/BookSearch/index.tsx b/src/components/BookSearch/index.tsx index e3b8b6c768..3ec97ee560 100644 --- a/src/components/BookSearch/index.tsx +++ b/src/components/BookSearch/index.tsx @@ -1,3 +1,4 @@ +import BookRequestModal from '@app/components/BookRequestModal'; import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; @@ -54,6 +55,8 @@ const BookSearch = ({ mediaType }: BookSearchProps) => { const [requested, setRequested] = useState< Record >({}); + const [activeRequest, setActiveRequest] = + useState(null); const runSearch = async (e?: React.FormEvent) => { e?.preventDefault(); @@ -76,31 +79,8 @@ const BookSearch = ({ mediaType }: BookSearchProps) => { } }; - const requestBook = async (book: BookshelfBookResult) => { - setRequested((prev) => ({ ...prev, [book.foreignBookId]: 'pending' })); - try { - const authorName = guessAuthor(book.authorTitle, book.title); - await axios.post(`${apiBase}/request`, { - foreignBookId: book.foreignBookId, - authorName, - title: book.title, - }); - addToast(`Requested: ${book.title}`, { - appearance: 'success', - autoDismiss: true, - }); - setRequested((prev) => ({ ...prev, [book.foreignBookId]: 'done' })); - } catch (e) { - const message = - (e as { response?: { data?: { message?: string } } }).response?.data - ?.message ?? 'Request failed'; - addToast(message, { appearance: 'error', autoDismiss: true }); - setRequested((prev) => { - const copy = { ...prev }; - delete copy[book.foreignBookId]; - return copy; - }); - } + const openRequestModal = (book: BookshelfBookResult) => { + setActiveRequest(book); }; const cover = (b: BookshelfBookResult): string | undefined => @@ -201,7 +181,7 @@ const BookSearch = ({ mediaType }: BookSearchProps) => { ) : ( + + + + + ); +}; + +export default BookFilterSlideover; diff --git a/src/components/BookSearch/index.tsx b/src/components/BookSearch/index.tsx index 3ec97ee560..dd38dc8ef5 100644 --- a/src/components/BookSearch/index.tsx +++ b/src/components/BookSearch/index.tsx @@ -1,18 +1,19 @@ -import BookRequestModal from '@app/components/BookRequestModal'; +import type { BookFilterValues } from '@app/components/BookFilterSlideover'; +import BookFilterSlideover, { + countActiveBookFilters, + parseBookFilters, +} from '@app/components/BookFilterSlideover'; import Button from '@app/components/Common/Button'; +import Header from '@app/components/Common/Header'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; -import { - ArrowDownTrayIcon, - CheckIcon, - MagnifyingGlassIcon, -} from '@heroicons/react/24/solid'; -import axios from 'axios'; -import Link from 'next/link'; -import { useState } from 'react'; -import { useToasts } from 'react-toast-notifications'; - -interface BookshelfBookResult { +import BookTitleCard from '@app/components/TitleCard/BookTitleCard'; +import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; +import { useRouter } from 'next/router'; +import { useMemo, useState } from 'react'; +import useSWR from 'swr'; + +interface BookHit { title: string; foreignBookId: string; foreignEditionId?: string; @@ -22,200 +23,223 @@ interface BookshelfBookResult { remoteCover?: string; images?: { coverType: string; url: string; remoteUrl?: string }[]; ratings?: { value?: number; votes?: number }; - genres?: string[]; + rating?: number | null; + usersCount?: number; + overview?: string; + mediaInfo?: { status: number }; } interface BookSearchProps { mediaType: 'audiobook' | 'ebook'; } +const SORT_OPTIONS: { value: string; label: string }[] = [ + { value: 'popularity:desc', label: 'Popularity Descending' }, + { value: 'popularity:asc', label: 'Popularity Ascending' }, + { value: 'trending:month', label: 'Trending: Last Month' }, + { value: 'trending:quarter', label: 'Trending: Last 3 Months' }, + { value: 'trending:year', label: 'Trending: Last Year' }, + { value: 'trending:all', label: 'Trending: All Time' }, + { value: 'release_date:desc', label: 'Release Date Descending' }, + { value: 'release_date:asc', label: 'Release Date Ascending' }, + { value: 'rating:desc', label: 'Rating Descending' }, + { value: 'rating:asc', label: 'Rating Ascending' }, + { value: 'title:asc', label: 'Title (A-Z) Ascending' }, + { value: 'title:desc', label: 'Title (Z-A) Descending' }, +]; + const titleCase = (s: string) => s.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()); const guessAuthor = (authorTitle: string | undefined, title: string) => { if (!authorTitle) return ''; - // Bookshelf returns authorTitle like "baroness, orczy, emmuska orczy The Scarlet Pimpernel". - // Strip the trailing book title and the leading commas to leave a usable - // author string. + // If it already looks clean (no comma, no dup of title), return as-is. + if (!authorTitle.includes(',') && !authorTitle.includes(title)) { + return authorTitle; + } let s = authorTitle.replace(title, '').trim(); if (s.endsWith(',')) s = s.slice(0, -1).trim(); const tokens = s.split(/[, ]+/).filter(Boolean); return titleCase(tokens.slice(0, 4).join(' ')); }; +const cover = (b: BookHit): string | undefined => + b.remoteCover ?? + b.images?.find((i) => i.coverType === 'cover')?.remoteUrl ?? + b.images?.find((i) => i.coverType === 'cover')?.url; + const BookSearch = ({ mediaType }: BookSearchProps) => { + const router = useRouter(); const isAudio = mediaType === 'audiobook'; const baseLabel = isAudio ? 'Audiobooks' : 'Ebooks'; const apiBase = isAudio ? '/api/v1/audiobook' : '/api/v1/ebook'; - const { addToast } = useToasts(); - const [term, setTerm] = useState(''); - const [searching, setSearching] = useState(false); - const [results, setResults] = useState(null); - const [requested, setRequested] = useState< - Record - >({}); - const [activeRequest, setActiveRequest] = - useState(null); - - const runSearch = async (e?: React.FormEvent) => { - e?.preventDefault(); - if (!term.trim()) return; - setSearching(true); - try { - const r = await axios.get<{ - term: string; - results: BookshelfBookResult[]; - }>(`${apiBase}/search`, { params: { q: term } }); - setResults(r.data.results ?? []); - } catch { - addToast(`${baseLabel} search failed`, { - appearance: 'error', - autoDismiss: true, - }); - setResults([]); - } finally { - setSearching(false); - } - }; + const query = useMemo( + () => + typeof router.query.query === 'string' + ? decodeURIComponent(router.query.query) + : '', + [router.query.query] + ); + const sortKey = + (typeof router.query.sortBy === 'string' && router.query.sortBy) || + 'popularity:desc'; - const openRequestModal = (book: BookshelfBookResult) => { - setActiveRequest(book); + const filters = useMemo(() => parseBookFilters(router.query), [router.query]); + const activeFilterCount = countActiveBookFilters(filters); + const [showFilters, setShowFilters] = useState(false); + + // SWR-cached fetch — second navigations within the session render instantly. + const fetchUrl = query + ? `${apiBase}/search?q=${encodeURIComponent(query)}` + : (() => { + const [sort, second] = sortKey.split(':'); + const qs = new URLSearchParams({ sort, limit: '36' }); + if (sort === 'trending') { + qs.set('trendingPeriod', second); + } else { + qs.set('dir', second); + } + if (filters.releaseFrom) qs.set('releaseFrom', filters.releaseFrom); + if (filters.releaseTo) qs.set('releaseTo', filters.releaseTo); + if (filters.pagesMin != null) + qs.set('pagesMin', String(filters.pagesMin)); + if (filters.pagesMax != null) + qs.set('pagesMax', String(filters.pagesMax)); + if (filters.ratingMin != null) + qs.set('ratingMin', String(filters.ratingMin)); + if (filters.ratingMax != null) + qs.set('ratingMax', String(filters.ratingMax)); + if (filters.usersCountMin != null) + qs.set('usersCountMin', String(filters.usersCountMin)); + if (filters.tagIds && filters.tagIds.length) + qs.set('tagIds', filters.tagIds.join(',')); + return `${apiBase}/discover?${qs.toString()}`; + })(); + + const { data, isValidating } = useSWR<{ results: BookHit[] }>(fetchUrl, { + revalidateOnFocus: false, + dedupingInterval: 5 * 60 * 1000, + }); + + const hits = data?.results; + const loading = !data && isValidating; + + const updateSort = (value: string) => { + router.replace( + { pathname: router.pathname, query: { ...router.query, sortBy: value } }, + undefined, + { shallow: true } + ); }; - const cover = (b: BookshelfBookResult): string | undefined => - b.remoteCover ?? - b.images?.find((i) => i.coverType === 'cover')?.remoteUrl ?? - b.images?.find((i) => i.coverType === 'cover')?.url; + const applyFilters = (next: BookFilterValues) => { + const newQuery: Record = {}; + for (const [k, v] of Object.entries(router.query)) { + if ( + [ + 'releaseFrom', + 'releaseTo', + 'pagesMin', + 'pagesMax', + 'ratingMin', + 'ratingMax', + 'usersCountMin', + 'tagIds', + ].includes(k) + ) { + continue; + } + if (typeof v === 'string') newQuery[k] = v; + } + if (next.releaseFrom) newQuery.releaseFrom = next.releaseFrom; + if (next.releaseTo) newQuery.releaseTo = next.releaseTo; + if (next.pagesMin != null) newQuery.pagesMin = String(next.pagesMin); + if (next.pagesMax != null) newQuery.pagesMax = String(next.pagesMax); + if (next.ratingMin != null) newQuery.ratingMin = String(next.ratingMin); + if (next.ratingMax != null) newQuery.ratingMax = String(next.ratingMax); + if (next.usersCountMin != null) + newQuery.usersCountMin = String(next.usersCountMin); + if (next.tagIds && next.tagIds.length) + newQuery.tagIds = next.tagIds.join(','); + router.replace({ pathname: router.pathname, query: newQuery }, undefined, { + shallow: true, + }); + }; return ( <> -
    -

    {baseLabel}

    -

    - Search and request {isAudio ? 'audiobooks' : 'ebooks'}. Results come - from Bookshelf (Hardcover-backed). -

    +
    +
    {baseLabel}
    + {!query && ( +
    +
    + + + + +
    +
    + +
    +
    + )}
    -
    -
    - - setTerm(e.target.value)} - placeholder={`Search ${isAudio ? 'audiobooks' : 'ebooks'} by title or author`} - className="w-full rounded-md border border-gray-600 bg-gray-800 py-2 pl-10 pr-3 text-white placeholder-gray-400 focus:border-indigo-500 focus:outline-none" - /> + + setShowFilters(false)} + values={filters} + onApply={applyFilters} + mediaType={mediaType} + /> + + {loading && } + + {hits && hits.length === 0 && ( +
    + {query + ? `No ${baseLabel.toLowerCase()} results for "${query}".` + : `No ${baseLabel.toLowerCase()} discovery results. Hardcover may be unreachable.`}
    - - - - {searching && !results && } - - {results && results.length === 0 && ( -
    No results for "{term}".
    )} - {results && results.length > 0 && ( -
      - {results.map((b) => { - const status = requested[b.foreignBookId]; - const author = guessAuthor(b.authorTitle, b.title); - const detailHref = `/${isAudio ? 'audiobooks' : 'ebooks'}/${b.foreignBookId}`; - return ( -
    • - - {cover(b) ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( -
      - )} - -
      - - {b.title} - - {author && ( -
      {author}
      - )} -
      - {b.releaseDate?.slice(0, 4)} - {b.pageCount ? ` · ${b.pageCount}p` : ''} -
      -
      - {status === 'done' ? ( - - ) : status === 'pending' ? ( - - ) : ( - - )} -
      -
      -
    • - ); - })} + {hits && hits.length > 0 && ( +
        + {hits.map((b) => ( +
      • + +
      • + ))}
      )} - - setActiveRequest(null)} - onComplete={() => { - if (activeRequest) { - setRequested((prev) => ({ - ...prev, - [activeRequest.foreignBookId]: 'done', - })); - } - }} - /> ); }; diff --git a/src/components/Discover/RecentlyAddedSlider/index.tsx b/src/components/Discover/RecentlyAddedSlider/index.tsx index 0992ca38e8..c867ec147c 100644 --- a/src/components/Discover/RecentlyAddedSlider/index.tsx +++ b/src/components/Discover/RecentlyAddedSlider/index.tsx @@ -1,4 +1,5 @@ import Slider from '@app/components/Slider'; +import BookMediaCard from '@app/components/TitleCard/BookMediaCard'; import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; import { Permission, useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; @@ -37,15 +38,27 @@ const RecentlyAddedSlider = () => { ( - - ))} + items={(media?.results ?? []).map((item) => { + if (item.mediaType === 'audiobook' || item.mediaType === 'ebook') { + return ( + + ); + } + return ( + + ); + })} /> ); diff --git a/src/components/Layout/MobileMenu/index.tsx b/src/components/Layout/MobileMenu/index.tsx index 9141b755c7..359c305466 100644 --- a/src/components/Layout/MobileMenu/index.tsx +++ b/src/components/Layout/MobileMenu/index.tsx @@ -4,22 +4,26 @@ import useClickOutside from '@app/hooks/useClickOutside'; import { Permission, useUser } from '@app/hooks/useUser'; import { Transition } from '@headlessui/react'; import { + BookOpenIcon, ClockIcon, CogIcon, EllipsisHorizontalIcon, ExclamationTriangleIcon, EyeSlashIcon, FilmIcon, + MusicalNoteIcon, SparklesIcon, TvIcon, UsersIcon, } from '@heroicons/react/24/outline'; import { + BookOpenIcon as FilledBookOpenIcon, ClockIcon as FilledClockIcon, CogIcon as FilledCogIcon, ExclamationTriangleIcon as FilledExclamationTriangleIcon, EyeSlashIcon as FilledEyeSlashIcon, FilmIcon as FilledFilmIcon, + MusicalNoteIcon as FilledMusicalNoteIcon, SparklesIcon as FilledSparklesIcon, TvIcon as FilledTvIcon, UsersIcon as FilledUsersIcon, @@ -92,6 +96,20 @@ const MobileMenu = ({ svgIconSelected: , activeRegExp: /^\/discover\/tv$/, }, + { + href: '/audiobooks', + content: intl.formatMessage(menuMessages.browseaudiobooks), + svgIcon: , + svgIconSelected: , + activeRegExp: /^\/audiobooks/, + }, + { + href: '/ebooks', + content: intl.formatMessage(menuMessages.browseebooks), + svgIcon: , + svgIconSelected: , + activeRegExp: /^\/ebooks/, + }, { href: '/requests', content: intl.formatMessage(menuMessages.requests), diff --git a/src/components/Layout/SearchInput/index.tsx b/src/components/Layout/SearchInput/index.tsx index 2f4d3f58d9..523763434e 100644 --- a/src/components/Layout/SearchInput/index.tsx +++ b/src/components/Layout/SearchInput/index.tsx @@ -5,7 +5,7 @@ import { MagnifyingGlassIcon } from '@heroicons/react/24/solid'; import { useIntl } from 'react-intl'; const messages = defineMessages('components.Layout.SearchInput', { - searchPlaceholder: 'Search Movies & TV', + searchPlaceholder: 'Search Media', }); const SearchInput = () => { diff --git a/src/components/QuotaSelector/index.tsx b/src/components/QuotaSelector/index.tsx index f35a5d1e03..c51a62c4d2 100644 --- a/src/components/QuotaSelector/index.tsx +++ b/src/components/QuotaSelector/index.tsx @@ -7,14 +7,20 @@ const messages = defineMessages('components.QuotaSelector', { '{quotaLimit} {movies} per {quotaDays} {days}', tvRequests: '{quotaLimit} {seasons} per {quotaDays} {days}', + audiobookRequests: + '{quotaLimit} {audiobooks} per {quotaDays} {days}', + ebookRequests: + '{quotaLimit} {ebooks} per {quotaDays} {days}', movies: '{count, plural, one {movie} other {movies}}', seasons: '{count, plural, one {season} other {seasons}}', + audiobooks: '{count, plural, one {audiobook} other {audiobooks}}', + ebooks: '{count, plural, one {ebook} other {ebooks}}', days: '{count, plural, one {day} other {days}}', unlimited: 'Unlimited', }); interface QuotaSelectorProps { - mediaType: 'movie' | 'tv'; + mediaType: 'movie' | 'tv' | 'audiobook' | 'ebook'; defaultDays?: number; defaultLimit?: number; dayOverride?: number; @@ -53,7 +59,13 @@ const QuotaSelector = ({ return (
      {intl.formatMessage( - mediaType === 'movie' ? messages.movieRequests : messages.tvRequests, + mediaType === 'movie' + ? messages.movieRequests + : mediaType === 'tv' + ? messages.tvRequests + : mediaType === 'audiobook' + ? messages.audiobookRequests + : messages.ebookRequests, { quotaLimit: ( + setAudiobookQuotaEnabled((s) => !s) + } + /> + + {intl.formatMessage(messages.enableOverride)} + +
      + +
    +
    + +
    + +
    +
    +
    + setEbookQuotaEnabled((s) => !s)} + /> + + {intl.formatMessage(messages.enableOverride)} + +
    + +
    +
    +
    )} {hasPermission( @@ -635,6 +740,71 @@ const UserGeneralSettings = () => { )} +
    + +
    +
    + +
    +
    +
    +
    + +
    + { + setFieldValue( + 'autoRequestAudiobooks', + !values.autoRequestAudiobooks + ); + }} + /> +
    +
    +
    + +
    + { + setFieldValue( + 'autoRequestEbooks', + !values.autoRequestEbooks + ); + }} + /> +
    +
    diff --git a/src/hooks/useSearchInput.ts b/src/hooks/useSearchInput.ts index a60b28c075..1447dc6b44 100644 --- a/src/hooks/useSearchInput.ts +++ b/src/hooks/useSearchInput.ts @@ -28,12 +28,18 @@ const useSearchInput = (): SearchObject => { * This effect handles routing when the debounced search input * value changes. * - * If we are not already on the /search route, then we push - * in a new route. If we are, then we only replace the history. + * If we are already on the /search, /audiobooks, or /ebooks route, + * then we only replace the history. Otherwise we push a new route. + * The /audiobooks and /ebooks pages handle their own filtering when + * a `query` param is present so search stays scoped to the page. */ + const localSearchPaths = ['/search', '/audiobooks', '/ebooks']; useEffect(() => { if (debouncedValue !== '' && searchOpen) { - if (router.pathname.startsWith('/search')) { + const isLocalSearch = localSearchPaths.some((p) => + router.pathname.startsWith(p) + ); + if (isLocalSearch) { router.replace({ pathname: router.pathname, query: { @@ -71,6 +77,18 @@ const useSearchInput = (): SearchObject => { router.replace('/').then(() => window.scrollTo(0, 0)); } } + // For /audiobooks and /ebooks, clearing the search just removes the + // query param so the page swaps back to discover mode without leaving. + if ( + searchValue === '' && + (router.pathname.startsWith('/audiobooks') || + router.pathname.startsWith('/ebooks')) && + router.query.query + ) { + const rest = { ...router.query }; + delete rest.query; + router.replace({ pathname: router.pathname, query: rest }); + } }, [searchOpen]); /** @@ -96,12 +114,18 @@ const useSearchInput = (): SearchObject => { : '' ); - if (!router.pathname.startsWith('/search') && !router.query.query) { + if ( + !localSearchPaths.some((p) => router.pathname.startsWith(p)) && + !router.query.query + ) { setIsOpen(false); } } - if (router.pathname.startsWith('/search')) { + if ( + localSearchPaths.some((p) => router.pathname.startsWith(p)) && + router.query.query + ) { setIsOpen(true); } }, [router, setSearchValue]); diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 31ef033898..74f8aaa766 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -221,7 +221,7 @@ "components.LanguageSelector.languageServerDefault": "Default ({language})", "components.LanguageSelector.originalLanguageDefault": "All Languages", "components.Layout.LanguagePicker.displaylanguage": "Display Language", - "components.Layout.SearchInput.searchPlaceholder": "Search Movies & TV", + "components.Layout.SearchInput.searchPlaceholder": "Search Media", "components.Layout.Sidebar.blocklist": "Blocklist", "components.Layout.Sidebar.browsemovies": "Movies", "components.Layout.Sidebar.browsetv": "Series",