diff --git a/package.json b/package.json index 964032adbe..4c03ce314c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seerr", - "version": "0.1.0", + "version": "3.2.1-bryanlabs.49", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { diff --git a/seerr-api.yml b/seerr-api.yml index 9600375ccd..eab7d5d802 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -6315,7 +6315,7 @@ paths: name: mediaType schema: type: string - enum: [movie, tv, all] + enum: [movie, tv, audiobook, ebook, all] nullable: true default: all responses: diff --git a/server/api/hardcover.ts b/server/api/hardcover.ts new file mode 100644 index 0000000000..a59918014f --- /dev/null +++ b/server/api/hardcover.ts @@ -0,0 +1,725 @@ +import cacheManager from '@server/lib/cache'; +import logger from '@server/logger'; +import axios from 'axios'; + +export interface HardcoverBook { + id: number; + title: string; + slug: string; + release_date: string | null; + users_count: number; + rating: number | null; + pages: number | null; + description: string | null; + image: { url: string } | null; + contributions: { author: { name: string } | null }[]; +} + +type SortField = + | 'popularity' + | 'title' + | 'release_date' + | 'rating' + | 'users_count' + | 'trending'; +type SortDir = 'asc' | 'desc'; +export type TrendingPeriod = 'month' | 'quarter' | 'year' | 'all'; + +interface DiscoverParams { + sort?: SortField; + dir?: SortDir; + limit?: number; + offset?: number; + releaseFrom?: string; + releaseTo?: string; + pagesMin?: number; + pagesMax?: number; + ratingMin?: number; + ratingMax?: number; + usersCountMin?: number; + /** Tag IDs (any-match) the book must have at least one of */ + tagIds?: number[]; + /** Trending window — only used when sort='trending' */ + trendingPeriod?: TrendingPeriod; +} + +const ORDER_BY: Record = { + popularity: 'users_count', + users_count: 'users_count', + title: 'title', + release_date: 'release_date', + rating: 'rating', + trending: 'users_count', // unused; trending is dispatched to a different code path +}; + +class HardcoverAPI { + private endpoint = 'https://api.hardcover.app/v1/graphql'; + private token: string; + + constructor(token: string) { + this.token = token.startsWith('Bearer ') ? token : `Bearer ${token}`; + } + + /** + * Map tag IDs to their category. Cached because the tag→category mapping is + * stable. Returns Map for the input list. + */ + private async groupTagsByCategory( + tagIds: number[] + ): Promise> { + if (!tagIds.length) return new Map(); + const cache = cacheManager.getCache('hardcover').data; + const out = new Map(); + const missing: number[] = []; + for (const id of tagIds) { + const cached = cache.get(`tagcat:${id}`); + if (cached != null) { + if (!out.has(cached)) out.set(cached, []); + out.get(cached)!.push(id); + } else { + missing.push(id); + } + } + if (missing.length === 0) return out; + try { + const r = await axios.post( + this.endpoint, + { + query: `query Cats($ids: [bigint!]!) { tags(where: {id: {_in: $ids}}) { id tag_category_id } }`, + variables: { ids: missing }, + }, + { + headers: { + Authorization: this.token, + 'Content-Type': 'application/json', + }, + timeout: 5000, + } + ); + const rows = (r.data?.data?.tags ?? []) as { + id: number; + tag_category_id: number; + }[]; + for (const row of rows) { + cache.set(`tagcat:${row.id}`, row.tag_category_id, 86400); + if (!out.has(row.tag_category_id)) out.set(row.tag_category_id, []); + out.get(row.tag_category_id)!.push(row.id); + } + } catch (e) { + logger.warn('Hardcover groupTagsByCategory failed', { + label: 'Hardcover', + message: (e as Error).message, + }); + } + return out; + } + + public async getTopTags( + categoryId: number, + limit = 40 + ): Promise<{ id: number; tag: string; count: number }[]> { + const cache = cacheManager.getCache('hardcover').data; + const cacheKey = `tags:cat=${categoryId}:limit=${limit}`; + const cached = + cache.get<{ id: number; tag: string; count: number }[]>(cacheKey); + if (cached) return cached; + const query = ` + query Tags($cat: Int!, $limit: Int!) { + tags(where: {tag_category_id: {_eq: $cat}}, order_by: {count: desc}, limit: $limit) { + id tag count + } + } + `; + try { + const r = await axios.post( + this.endpoint, + { query, variables: { cat: categoryId, limit } }, + { + headers: { + Authorization: this.token, + 'Content-Type': 'application/json', + }, + timeout: 10000, + } + ); + const tags = (r.data?.data?.tags ?? []) as { + id: number; + tag: string; + count: number; + }[]; + // Cache for 24h — tag list barely changes. + cache.set(cacheKey, tags, 86400); + return tags; + } catch (e) { + logger.error('Hardcover getTopTags failed', { + label: 'Hardcover', + categoryId, + message: (e as Error).message, + }); + return []; + } + } + + public async getTrending( + period: TrendingPeriod, + limit: number + ): Promise { + const cache = cacheManager.getCache('hardcover').data; + const cacheKey = `trending:${period}:${limit}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + const today = new Date(); + const fromDate = new Date(today); + if (period === 'month') fromDate.setMonth(today.getMonth() - 1); + else if (period === 'quarter') fromDate.setMonth(today.getMonth() - 3); + else if (period === 'year') fromDate.setFullYear(today.getFullYear() - 1); + else fromDate.setFullYear(today.getFullYear() - 5); + const from = fromDate.toISOString().slice(0, 10); + const to = today.toISOString().slice(0, 10); + const trendingQuery = ` + query Trending($from: date!, $to: date!, $limit: Int!) { + books_trending(from: $from, to: $to, limit: $limit, offset: 0) { + ids + error + } + } + `; + try { + const t = await axios.post( + this.endpoint, + { query: trendingQuery, variables: { from, to, limit } }, + { + headers: { + Authorization: this.token, + 'Content-Type': 'application/json', + }, + timeout: 15000, + } + ); + const ids = (t.data?.data?.books_trending?.ids ?? []) as number[]; + if (!ids.length) { + cache.set(cacheKey, [], 1800); + return []; + } + const detailQuery = ` + query TrendingDetails($ids: [Int!]!) { + books(where: {id: {_in: $ids}}) { + id title slug release_date users_count rating pages description + image { url } + contributions(limit: 3) { author { name } } + } + } + `; + const d = await axios.post( + this.endpoint, + { query: detailQuery, variables: { ids } }, + { + headers: { + Authorization: this.token, + 'Content-Type': 'application/json', + }, + timeout: 15000, + } + ); + const books = (d.data?.data?.books ?? []) as HardcoverBook[]; + // Re-order to match the trending ranking. + const sorted = ids + .map((id) => books.find((b) => b.id === id)) + .filter((b): b is HardcoverBook => !!b); + cache.set(cacheKey, sorted, 1800); + return sorted; + } catch (e) { + logger.error('Hardcover getTrending failed', { + label: 'Hardcover', + period, + message: (e as Error).message, + }); + return []; + } + } + + public async discover(params: DiscoverParams = {}): Promise { + const { + sort = 'popularity', + dir = 'desc', + limit = 36, + offset = 0, + releaseFrom, + releaseTo, + pagesMin, + pagesMax, + ratingMin, + ratingMax, + usersCountMin, + tagIds, + trendingPeriod, + } = params; + + if (sort === 'trending') { + return this.getTrending(trendingPeriod ?? 'month', limit); + } + const orderField = ORDER_BY[sort] ?? 'users_count'; + const filterParts = [ + releaseFrom ?? '', + releaseTo ?? '', + pagesMin ?? '', + pagesMax ?? '', + ratingMin ?? '', + ratingMax ?? '', + usersCountMin ?? '', + (tagIds ?? []) + .slice() + .sort((a, b) => a - b) + .join(','), + ].join(':'); + const cacheKey = `discover:${orderField}:${dir}:${limit}:${offset}:${filterParts}`; + const cache = cacheManager.getCache('hardcover').data; + const cached = cache.get(cacheKey); + if (cached) return cached; + + // Build the where clause: sort-driven defaults plus user-supplied filters. + const today = new Date().toISOString().slice(0, 10); + const conditions: string[] = []; + + // users_count floor — pulled from filter if supplied, else sort-default. + // When a tag filter is active, drop the floor low so small comics / niche + // genre books still make the candidate pool. The post-sort by tag count + // surfaces the best matches regardless of how popular the book is overall. + let usersFloor = usersCountMin ?? 50; + if (orderField === 'release_date' && usersCountMin == null) { + usersFloor = 10; + } else if (orderField === 'rating' && usersCountMin == null) { + usersFloor = 200; + } else if (tagIds && tagIds.length > 0 && usersCountMin == null) { + usersFloor = 10; + } + conditions.push(`users_count: { _gt: ${usersFloor} }`); + + // release_date: sort-default + user range + const releaseConds: string[] = []; + if (orderField === 'release_date') { + releaseConds.push(`_is_null: false`); + if (dir === 'desc' && !releaseTo) releaseConds.push(`_lte: "${today}"`); + } + if (releaseFrom) releaseConds.push(`_gte: "${releaseFrom}"`); + if (releaseTo) releaseConds.push(`_lte: "${releaseTo}"`); + if (releaseConds.length) { + conditions.push(`release_date: { ${releaseConds.join(', ')} }`); + } + + // pages range + const pagesConds: string[] = []; + if (typeof pagesMin === 'number' && pagesMin > 0) + pagesConds.push(`_gte: ${pagesMin}`); + if (typeof pagesMax === 'number' && pagesMax > 0) + pagesConds.push(`_lte: ${pagesMax}`); + if (pagesConds.length) { + conditions.push(`pages: { ${pagesConds.join(', ')} }`); + } + + // rating range (1-decimal float) + const ratingConds: string[] = []; + if (typeof ratingMin === 'number' && ratingMin > 0) + ratingConds.push(`_gte: ${ratingMin}`); + if (typeof ratingMax === 'number' && ratingMax > 0 && ratingMax < 5) + ratingConds.push(`_lte: ${ratingMax}`); + if (ratingConds.length) { + conditions.push(`rating: { ${ratingConds.join(', ')} }`); + } + + // Tag filter — Hardcover lets anyone tag any book, so a naive existence + // check matches noise. Require a per-category minimum number of users to + // have applied a matching tag, AND when ranking by tag we sort by the + // filtered tagging count so the most-tagged book per genre/mood/tag + // surfaces first (matching Hardcover's own "Tags Counts" sort). + const allFilterTagIds: number[] = []; + const tagAndConds: string[] = []; + if (tagIds && tagIds.length > 0) { + const validIds = tagIds.filter((n) => Number.isFinite(n)); + if (validIds.length > 0) { + const groups = await this.groupTagsByCategory(validIds); + // The sort step (filtered tag count desc) does the actual ranking. + // The threshold's only job is to define the candidate pool size and + // exclude books that were tagged once by a random reviewer. + // + // Comics has 76k books tagged but only 10 with >=3 taggings — so a + // strict floor would shrink the visible result set to 10 even with + // a 100-book pool. Threshold 2 keeps real comics + a few classics + // with stray tags; the tag-count sort buries the latter. + const TAG_THRESHOLDS: Record = { + 1: 2, // Genre + 2: 1, // Tag (niche, sparse coverage) + 4: 2, // Mood + }; + for (const [cat, ids] of groups.entries()) { + if (!ids.length) continue; + const threshold = TAG_THRESHOLDS[cat] ?? 3; + // Each category becomes its own taggings_aggregate sub-filter; we + // _and them together so a Romance + Funny query returns books that + // satisfy BOTH thresholds. Putting them as sibling top-level keys + // would just duplicate the field name and silently overwrite. + tagAndConds.push( + `{ taggings_aggregate: { count: { predicate: { _gte: ${threshold} }, filter: { tag_id: { _in: [${ids.join(', ')}] } } } } }` + ); + allFilterTagIds.push(...ids); + } + } + } + if (tagAndConds.length === 1) { + // Strip the wrapping {} when only one category — keeps the query tidy. + conditions.push(tagAndConds[0].slice(2, -2)); + } else if (tagAndConds.length > 1) { + conditions.push(`_and: [${tagAndConds.join(', ')}]`); + } + + const whereClause = `{ ${conditions.join(', ')} }`; + + // When filtering by tags, fetch a wider candidate pool, then sort in code + // by the filtered tagging count. This matches Hardcover's behavior on + // their /moods/funny page where Project Hail Mary (190 funny taggings) + // ranks above Harry Potter (which has more total readers but fewer + // funny-specific taggings). + const tagSorted = allFilterTagIds.length > 0; + // When a tag is selected, fetch a much wider pool so the post-sort by + // tagging count surfaces the best results. Hardcover caps queries at + // 100 rows per request; 100 is enough for niche tags and bigger pools + // would just churn through low-signal books anyway. + const fetchLimit = tagSorted ? 100 : limit; + + const tagCountField = tagSorted + ? `tagCount: taggings_aggregate(where: {tag_id: {_in: [${allFilterTagIds.join(', ')}]}}) { aggregate { count } }` + : ''; + + const query = ` + query Discover($limit: Int!, $offset: Int!) { + books( + limit: $limit + offset: $offset + order_by: { ${orderField}: ${dir} } + where: ${whereClause} + ) { + id + title + slug + release_date + users_count + rating + pages + description + image { url } + contributions(limit: 3) { author { name } } + ${tagCountField} + } + } + `; + try { + const r = await axios.post( + this.endpoint, + { query, variables: { limit: fetchLimit, offset } }, + { + headers: { + Authorization: this.token, + 'Content-Type': 'application/json', + }, + timeout: 15000, + } + ); + if (r.data.errors) { + logger.warn('Hardcover discover returned errors', { + label: 'Hardcover', + errors: r.data.errors, + }); + return []; + } + let books = (r.data.data?.books ?? []) as (HardcoverBook & { + tagCount?: { aggregate?: { count?: number } }; + })[]; + if (tagSorted) { + books = books + .slice() + .sort( + (a, b) => + (b.tagCount?.aggregate?.count ?? 0) - + (a.tagCount?.aggregate?.count ?? 0) + ) + .slice(0, limit); + } + cache.set(cacheKey, books); + return books; + } catch (e) { + logger.error('Hardcover discover failed', { + label: 'Hardcover', + message: (e as Error).message, + }); + return []; + } + } + + public async getWantToRead( + username: string + ): Promise<{ id: number; title: string; slug: string }[]> { + const cache = cacheManager.getCache('hardcover').data; + const cacheKey = `wantToRead:${username.toLowerCase()}`; + const cached = + cache.get<{ id: number; title: string; slug: string }[]>(cacheKey); + if (cached !== undefined) return cached; + const query = ` + query WantToRead($username: citext!) { + user_books( + where: { user: { username: { _eq: $username } }, status_id: { _eq: 1 } } + limit: 100 + order_by: { date_added: desc } + ) { + book { id title slug } + } + } + `; + try { + const r = await axios.post( + this.endpoint, + { query, variables: { username } }, + { + headers: { + Authorization: this.token, + 'Content-Type': 'application/json', + }, + timeout: 15000, + } + ); + const rows = (r.data?.data?.user_books ?? []) as { + book: { id: number; title: string; slug: string } | null; + }[]; + const books = rows + .map((r) => r.book) + .filter((b): b is { id: number; title: string; slug: string } => !!b); + // Cache for 60s — pairs with the 1-min cron so a Want-to-Read mark + // surfaces within roughly a minute without hammering Hardcover. + cache.set(cacheKey, books, 60); + return books; + } catch (e) { + logger.error('Hardcover getWantToRead failed', { + label: 'Hardcover', + username, + message: (e as Error).message, + }); + return []; + } + } + + /** + * Other books by the same author(s) of the given book, ordered by users_count + * desc. One Hardcover round-trip via a nested aggregate join — the source + * book's author IDs and the matching books are fetched together. + */ + public async getMoreByAuthor( + bookId: number, + limit = 20 + ): Promise { + const cache = cacheManager.getCache('hardcover').data; + const cacheKey = `moreByAuthor:${bookId}:${limit}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + const query = ` + query MoreByAuthor($id: Int!, $limit: Int!) { + source: books_by_pk(id: $id) { + contributions { + author { + contributions( + where: { book: { id: { _neq: $id }, users_count: { _gt: 0 } } } + order_by: { book: { users_count: desc } } + limit: $limit + ) { + book { + id title slug release_date users_count rating pages description + image { url } + contributions(limit: 3) { author { name } } + } + } + } + } + } + } + `; + try { + const r = await axios.post( + this.endpoint, + { query, variables: { id: bookId, limit } }, + { + headers: { + Authorization: this.token, + 'Content-Type': 'application/json', + }, + timeout: 10000, + } + ); + const contributions = (r.data?.data?.source?.contributions ?? []) as { + author: { contributions: { book: HardcoverBook | null }[] } | null; + }[]; + const seen = new Set(); + const books: HardcoverBook[] = []; + for (const c of contributions) { + for (const inner of c.author?.contributions ?? []) { + if (!inner.book) continue; + if (seen.has(inner.book.id)) continue; + seen.add(inner.book.id); + books.push(inner.book); + if (books.length >= limit) break; + } + if (books.length >= limit) break; + } + books.sort((a, b) => (b.users_count ?? 0) - (a.users_count ?? 0)); + cache.set(cacheKey, books, 3600); + return books; + } catch (e) { + logger.error('Hardcover getMoreByAuthor failed', { + label: 'Hardcover', + bookId, + message: (e as Error).message, + }); + return []; + } + } + + /** + * Full book detail in a single Hardcover round-trip — used as the primary + * source for /audiobook/:id and /ebook/:id detail pages so we don't have + * to wait on Bookshelf's 5-15s chained lookups. + */ + public async getBookFullDetail(id: number): Promise<{ + id: number; + title: string; + slug: string | null; + release_date: string | null; + users_count: number; + rating: number | null; + pages: number | null; + description: string | null; + image: { url: string } | null; + cached_tags: { + Genre?: { tag: string }[]; + Mood?: { tag: string }[]; + Tag?: { tag: string }[]; + } | null; + contributions: { + author: { + id: number; + name: string; + slug: string | null; + bio: string | null; + image: { url: string } | null; + } | null; + }[]; + editions: { + id: number; + title: string | null; + pages: number | null; + release_date: string | null; + isbn_13: string | null; + audio_seconds: number | null; + image: { url: string } | null; + }[]; + } | null> { + const cache = cacheManager.getCache('hardcover').data; + const cacheKey = `fullDetail:${id}`; + const cached = cache.get(cacheKey); + if (cached !== undefined) return cached as never; + const query = ` + query FullDetail($id: Int!) { + books_by_pk(id: $id) { + id title slug release_date users_count rating pages description + cached_tags + image { url } + contributions { + author { id name slug bio image { url } } + } + editions(limit: 8, order_by: {users_count: desc}) { + id title pages release_date isbn_13 audio_seconds image { url } + } + } + } + `; + try { + const r = await axios.post( + this.endpoint, + { query, variables: { id } }, + { + headers: { + Authorization: this.token, + 'Content-Type': 'application/json', + }, + timeout: 5000, + } + ); + const row = r.data?.data?.books_by_pk ?? null; + cache.set(cacheKey, row, 1800); + return row; + } catch (e) { + logger.error('Hardcover getBookFullDetail failed', { + label: 'Hardcover', + id, + message: (e as Error).message, + }); + return null; + } + } + + public async getBookInfo( + id: number + ): Promise<{ description: string | null; slug: string | null }> { + const cache = cacheManager.getCache('hardcover').data; + const cacheKey = `info:${id}`; + const cached = cache.get<{ + description: string | null; + slug: string | null; + }>(cacheKey); + if (cached !== undefined) return cached; + const query = ` + query Info($id: Int!) { + books_by_pk(id: $id) { description slug } + } + `; + try { + const r = await axios.post( + this.endpoint, + { query, variables: { id } }, + { + headers: { + Authorization: this.token, + 'Content-Type': 'application/json', + }, + timeout: 10000, + } + ); + const row = r.data?.data?.books_by_pk; + const info = { + description: row?.description ?? null, + slug: row?.slug ?? null, + }; + cache.set(cacheKey, info); + return info; + } catch (e) { + logger.error('Hardcover getBookInfo failed', { + label: 'Hardcover', + message: (e as Error).message, + id, + }); + return { description: null, slug: null }; + } + } +} + +export default HardcoverAPI; + +let cachedClient: HardcoverAPI | null = null; +export function getHardcoverClient(): HardcoverAPI | null { + if (cachedClient) return cachedClient; + const token = process.env.HARDCOVER_TOKEN; + if (!token) { + logger.warn('HARDCOVER_TOKEN env var not set; discover disabled', { + label: 'Hardcover', + }); + return null; + } + cachedClient = new HardcoverAPI(token); + return cachedClient; +} diff --git a/server/api/servarr/bookshelf.ts b/server/api/servarr/bookshelf.ts new file mode 100644 index 0000000000..0236b81fa2 --- /dev/null +++ b/server/api/servarr/bookshelf.ts @@ -0,0 +1,390 @@ +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' | 'new'; + rootFolderPath?: string; + tags?: number[]; + images?: BookshelfAuthorImage[]; + addOptions?: { + monitor?: + | 'all' + | 'future' + | 'missing' + | 'existing' + | 'firstBook' + | 'latestBook' + | 'none'; + booksToMonitor?: string[]; + searchForMissingBooks?: boolean; + }; +} + +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[]; + remoteCover?: string; + ratings?: { + value?: number; + votes?: number; + popularity?: number; + }; + genres?: string[]; + links?: { + url: string; + name: string; + }[]; + editions?: { + id?: number; + title?: string; + foreignEditionId?: string; + isbn13?: string; + asin?: string; + format?: string; + language?: string; + pageCount?: number; + monitored?: boolean; + }[]; + statistics?: { + bookFileCount?: number; + bookCount?: number; + sizeOnDisk?: number; + percentOfBooks?: number; + }; +} + +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; + 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, 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 = + bookLookup.find((b) => b.foreignBookId === options.foreignBookId) ?? + bookLookup[0]; + + if (!match) { + throw new Error( + `[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; + } + + // 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, + }, + ] + : []; + + // Mirror Bookshelf UI's "Add New Book" with Monitor=Only This Book + + // Monitor New Books=None. Without these overrides Readarr falls back to the + // root folder defaults (defaultMonitorOption=all, defaultNewItemMonitorOption=all) + // and grabs the entire author bibliography. + const payload: Partial & Record = { + ...match, + editions, + qualityProfileId: options.profileId, + metadataProfileId: options.metadataProfileId, + rootFolderPath: options.rootFolderPath, + monitored: options.monitored ?? true, + author: { + ...(resolvedAuthor ?? { + authorName: options.authorName ?? '', + titleSlug: foreignAuthorId, + foreignAuthorId, + }), + foreignAuthorId, + qualityProfileId: options.profileId, + metadataProfileId: options.metadataProfileId, + rootFolderPath: options.rootFolderPath, + monitored: options.monitored ?? true, + monitorNewItems: 'none', + addOptions: { + monitor: 'none', + booksToMonitor: [options.foreignBookId], + searchForMissingBooks: false, + }, + }, + addOptions: { + searchForNewBook: options.searchNow ?? true, + addType: 'automatic', + }, + }; + + if (options.tags) { + payload.tags = options.tags; + } + + 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', { + 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', + bookId: existing.id, + foreignBookId: existing.foreignBookId, + }); + return existing; + } + } 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 }); + } + } + + 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', + 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/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/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 0252799220..2f5d657154 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,3 +1,4 @@ +import { getHardcoverClient } from '@server/api/hardcover'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; @@ -729,7 +730,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 +821,56 @@ export class MediaRequest { }, ], }); + } else if ( + entity.type === MediaType.AUDIOBOOK || + entity.type === MediaType.EBOOK + ) { + // Look up the title + author + cover from Hardcover so the subject + // reads as the actual book name and the message includes a real + // overview. Falls back to the placeholder if Hardcover is unreachable. + let subject = `${mediaType} (Hardcover work ${media.tmdbId})`; + let message = ''; + let image: string | undefined; + const hardcover = getHardcoverClient(); + if (hardcover) { + try { + const detail = await hardcover.getBookFullDetail(media.tmdbId); + if (detail) { + const author = detail.contributions + .map((c) => c.author?.name) + .filter(Boolean)[0]; + const year = detail.release_date + ? ` (${detail.release_date.slice(0, 4)})` + : ''; + subject = `${detail.title}${year}${author ? `, ${author}` : ''}`; + if (detail.description) { + message = truncate(detail.description, { + length: 500, + separator: /\s/, + omission: '…', + }); + } + image = detail.image?.url; + } + } catch (e) { + logger.warn('Hardcover enrichment for notification failed', { + label: 'Notifications', + tmdbId: media.tmdbId, + message: (e as Error).message, + }); + } + } + notificationManager.sendNotification(type, { + media, + request: entity, + notifyAdmin, + notifySystem, + notifyUser: notifyAdmin ? undefined : entity.requestedBy, + event, + subject, + message, + image, + }); } } catch (e) { logger.error('Something went wrong sending media notification(s)', { diff --git a/server/entity/User.ts b/server/entity/User.ts index 739fff32ea..cd0f823ff8 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -134,6 +134,18 @@ export class User { @Column({ nullable: true }) public tvQuotaDays?: number; + @Column({ nullable: true }) + public audiobookQuotaLimit?: number; + + @Column({ nullable: true }) + public audiobookQuotaDays?: number; + + @Column({ nullable: true }) + public ebookQuotaLimit?: number; + + @Column({ nullable: true }) + public ebookQuotaDays?: number; + @OneToOne(() => UserSettings, (settings) => settings.user, { cascade: true, eager: true, @@ -347,6 +359,48 @@ export class User { ).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0) : 0; + const audiobookQuotaLimit = !canBypass + ? (this.audiobookQuotaLimit ?? defaultQuotas.audiobook?.quotaLimit) + : 0; + const audiobookQuotaDays = + this.audiobookQuotaDays ?? defaultQuotas.audiobook?.quotaDays; + const audiobookDate = new Date(); + if (audiobookQuotaDays) { + audiobookDate.setDate(audiobookDate.getDate() - audiobookQuotaDays); + } + const audiobookQuotaUsed = audiobookQuotaLimit + ? await requestRepository.count({ + where: { + requestedBy: { id: this.id }, + ...(audiobookQuotaDays + ? { createdAt: AfterDate(audiobookDate) } + : {}), + type: MediaType.AUDIOBOOK, + status: Not(MediaRequestStatus.DECLINED), + }, + }) + : 0; + + const ebookQuotaLimit = !canBypass + ? (this.ebookQuotaLimit ?? defaultQuotas.ebook?.quotaLimit) + : 0; + const ebookQuotaDays = + this.ebookQuotaDays ?? defaultQuotas.ebook?.quotaDays; + const ebookDate = new Date(); + if (ebookQuotaDays) { + ebookDate.setDate(ebookDate.getDate() - ebookQuotaDays); + } + const ebookQuotaUsed = ebookQuotaLimit + ? await requestRepository.count({ + where: { + requestedBy: { id: this.id }, + ...(ebookQuotaDays ? { createdAt: AfterDate(ebookDate) } : {}), + type: MediaType.EBOOK, + status: Not(MediaRequestStatus.DECLINED), + }, + }) + : 0; + return { movie: { days: movieQuotaDays, @@ -368,6 +422,28 @@ export class User { : undefined, restricted: !!(tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0), }, + audiobook: { + days: audiobookQuotaDays, + limit: audiobookQuotaLimit, + used: audiobookQuotaUsed, + remaining: audiobookQuotaLimit + ? Math.max(0, audiobookQuotaLimit - audiobookQuotaUsed) + : undefined, + restricted: !!( + audiobookQuotaLimit && audiobookQuotaLimit - audiobookQuotaUsed <= 0 + ), + }, + ebook: { + days: ebookQuotaDays, + limit: ebookQuotaLimit, + used: ebookQuotaUsed, + remaining: ebookQuotaLimit + ? Math.max(0, ebookQuotaLimit - ebookQuotaUsed) + : undefined, + restricted: !!( + ebookQuotaLimit && ebookQuotaLimit - ebookQuotaUsed <= 0 + ), + }, }; } } diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 82671fe3b3..4b70c394cb 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -72,6 +72,15 @@ export class UserSettings { @Column({ nullable: true }) public watchlistSyncTv?: boolean; + @Column({ nullable: true }) + public hardcoverUsername?: string; + + @Column({ nullable: true }) + public autoRequestAudiobooks?: boolean; + + @Column({ nullable: true }) + public autoRequestEbooks?: boolean; + @Column({ type: 'text', nullable: true, 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, }) ); /** 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/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts index 2ac75c5e1f..54cbe3e2fc 100644 --- a/server/interfaces/api/userInterfaces.ts +++ b/server/interfaces/api/userInterfaces.ts @@ -22,6 +22,8 @@ export interface QuotaStatus { export interface QuotaResponse { movie: QuotaStatus; tv: QuotaStatus; + audiobook: QuotaStatus; + ebook: QuotaStatus; } export interface UserWatchDataResponse { diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 327764618e..d6843d2cf0 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -12,12 +12,19 @@ export interface UserSettingsGeneralResponse { movieQuotaDays?: number; tvQuotaLimit?: number; tvQuotaDays?: number; + audiobookQuotaLimit?: number; + audiobookQuotaDays?: number; + ebookQuotaLimit?: number; + ebookQuotaDays?: number; globalMovieQuotaDays?: number; globalMovieQuotaLimit?: number; globalTvQuotaLimit?: number; globalTvQuotaDays?: number; watchlistSyncMovies?: boolean; watchlistSyncTv?: boolean; + hardcoverUsername?: string; + autoRequestAudiobooks?: boolean; + autoRequestEbooks?: boolean; } export type NotificationAgentTypes = Record; diff --git a/server/job/schedule.ts b/server/job/schedule.ts index a9afd2f4d6..ea4407fd49 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,7 +1,9 @@ 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 hardcoverWatchlistSync from '@server/lib/hardcoverWatchlistSync'; import ImageProxy from '@server/lib/imageproxy'; import refreshToken from '@server/lib/refreshToken'; import { @@ -209,6 +211,50 @@ 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(), + }); + + // Hardcover Want-to-Read sync — for users who set hardcoverUsername + + // autoRequestAudiobooks/Ebooks, fetches their want-to-read list and creates + // requests for new entries (scoped to the matching user). + scheduledJobs.push({ + id: 'hardcover-watchlist-sync', + name: 'Hardcover Watchlist Sync', + type: 'process', + interval: 'minutes', + cronSchedule: + jobs['hardcover-watchlist-sync']?.schedule ?? '*/60 * * * * *', + job: schedule.scheduleJob( + jobs['hardcover-watchlist-sync']?.schedule ?? '*/60 * * * * *', + () => { + logger.debug('Starting scheduled job: Hardcover Watchlist Sync', { + label: 'Jobs', + }); + hardcoverWatchlistSync.run(); + } + ), + running: () => hardcoverWatchlistSync.status().running, + cancelFn: () => hardcoverWatchlistSync.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/cache.ts b/server/lib/cache.ts index 64b5c79ee8..cec5019e8a 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -4,6 +4,8 @@ export type AvailableCacheIds = | 'tmdb' | 'radarr' | 'sonarr' + | 'bookshelf' + | 'hardcover' | 'rt' | 'imdb' | 'github' @@ -50,6 +52,11 @@ class CacheManager { }), radarr: new Cache('radarr', 'Radarr API'), sonarr: new Cache('sonarr', 'Sonarr API'), + bookshelf: new Cache('bookshelf', 'Bookshelf (Readarr) API'), + hardcover: new Cache('hardcover', 'Hardcover GraphQL API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), rt: new Cache('rt', 'Rotten Tomatoes API', { stdTtl: 43200, checkPeriod: 60 * 30, diff --git a/server/lib/hardcoverWatchlistSync.ts b/server/lib/hardcoverWatchlistSync.ts new file mode 100644 index 0000000000..d47a7f64c3 --- /dev/null +++ b/server/lib/hardcoverWatchlistSync.ts @@ -0,0 +1,198 @@ +import { getHardcoverClient } from '@server/api/hardcover'; +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 { User } from '@server/entity/User'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Not } from 'typeorm'; + +class HardcoverWatchlistSync { + 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('Hardcover watchlist sync already running, skipping', { + label: 'HardcoverWatchlistSync', + }); + return; + } + this.isRunning = true; + try { + const client = getHardcoverClient(); + if (!client) { + return; + } + + const userRepo = getRepository(User); + const mediaRepo = getRepository(Media); + const requestRepo = getRepository(MediaRequest); + const settings = getSettings(); + + const audiobookServer = + settings.bookshelf.find( + (s) => s.mediaType === 'audiobook' && s.isDefault + ) ?? settings.bookshelf.find((s) => s.mediaType === 'audiobook'); + const ebookServer = + settings.bookshelf.find( + (s) => s.mediaType === 'ebook' && s.isDefault + ) ?? settings.bookshelf.find((s) => s.mediaType === 'ebook'); + + const users = await userRepo.find({}); + let createdCount = 0; + let totalChecked = 0; + + for (const user of users) { + const username = user.settings?.hardcoverUsername?.trim(); + if (!username) continue; + const wantAudiobook = !!user.settings?.autoRequestAudiobooks; + const wantEbook = !!user.settings?.autoRequestEbooks; + if (!wantAudiobook && !wantEbook) continue; + + const wantToRead = await client.getWantToRead(username); + if (wantToRead.length === 0) continue; + totalChecked += wantToRead.length; + + const mediaTypes: MediaType[] = []; + if (wantAudiobook && audiobookServer) + mediaTypes.push(MediaType.AUDIOBOOK); + if (wantEbook && ebookServer) mediaTypes.push(MediaType.EBOOK); + if (mediaTypes.length === 0) continue; + + for (const book of wantToRead) { + for (const mediaType of mediaTypes) { + const server = + mediaType === MediaType.AUDIOBOOK ? audiobookServer : ebookServer; + if (!server) continue; + + // Skip if a request already exists for this book + mediaType + user. + const existingMedia = await mediaRepo.findOne({ + where: { tmdbId: book.id, mediaType }, + relations: { requests: { requestedBy: true } }, + }); + const alreadyRequestedByUser = + existingMedia?.requests?.some( + (r) => + r.requestedBy?.id === user.id && + r.status !== MediaRequestStatus.DECLINED + ) ?? false; + if (alreadyRequestedByUser) continue; + if (existingMedia?.status === MediaStatus.BLOCKLISTED) continue; + // Skip if the book is already in the library (downloaded). The + // Want-to-Read intent is satisfied; no need for a redundant + // request log entry that would resolve immediately. + if ( + existingMedia?.status === MediaStatus.AVAILABLE || + existingMedia?.status === MediaStatus.PARTIALLY_AVAILABLE + ) { + continue; + } + + // Quota check. + try { + const quotas = await user.getQuota(); + const q = + mediaType === MediaType.AUDIOBOOK + ? quotas.audiobook + : quotas.ebook; + if (q.restricted) continue; + } catch (e) { + logger.warn( + 'Quota check failed during hardcover sync, skipping user', + { + label: 'HardcoverWatchlistSync', + userId: user.id, + message: (e as Error).message, + } + ); + continue; + } + + const media = + existingMedia ?? + new Media({ + tmdbId: book.id, + mediaType, + status: MediaStatus.PENDING, + }); + await mediaRepo.save(media); + + const autoApprove = user.hasPermission( + [Permission.AUTO_APPROVE, Permission.MANAGE_REQUESTS], + { type: 'or' } + ); + + const request = new MediaRequest({ + type: mediaType, + media, + requestedBy: user, + status: autoApprove + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: autoApprove ? user : undefined, + is4k: false, + serverId: server.id, + profileId: server.activeProfileId, + rootFolder: server.activeDirectory, + tags: [], + isAutoRequest: true, + }); + + // sendToBookshelf hook in MediaRequestSubscriber will fire on + // afterInsert when status is APPROVED, mirroring the manual flow. + await requestRepo.save(request); + + createdCount += 1; + logger.info( + `Auto-requested ${mediaType} "${book.title}" for ${user.email} from Hardcover want-to-read`, + { + label: 'HardcoverWatchlistSync', + userId: user.id, + hardcoverUsername: username, + foreignBookId: book.id, + bookSlug: book.slug, + } + ); + } + } + } + + // Quiet log unless something happened. + if (createdCount > 0 || totalChecked > 0) { + logger.debug( + `Hardcover watchlist sync: ${createdCount} request(s) created from ${totalChecked} want-to-read entr${ + totalChecked === 1 ? 'y' : 'ies' + }`, + { label: 'HardcoverWatchlistSync' } + ); + } + + // Used to silence unused warning if (Not) ever drops out. + void Not; + } catch (e) { + logger.error('Hardcover watchlist sync failed', { + label: 'HardcoverWatchlistSync', + errorMessage: (e as Error).message, + }); + } finally { + this.isRunning = false; + } + } +} + +const hardcoverWatchlistSync = new HardcoverWatchlistSync(); +export default hardcoverWatchlistSync; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index bb287c8e04..510c0ef3da 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; @@ -138,6 +150,8 @@ export interface MainSettings { defaultQuotas: { movie: Quota; tv: Quota; + audiobook?: Quota; + ebook?: Quota; }; hideAvailable: boolean; hideBlocklisted: boolean; @@ -359,6 +373,8 @@ export type JobId = | 'plex-refresh-token' | 'radarr-scan' | 'sonarr-scan' + | 'bookshelf-sync' + | 'hardcover-watchlist-sync' | 'download-sync' | 'download-sync-reset' | 'jellyfin-recently-added-scan' @@ -378,6 +394,7 @@ export interface AllSettings { tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; + bookshelf: BookshelfSettings[]; public: PublicSettings; notifications: NotificationSettings; jobs: Record; @@ -409,6 +426,8 @@ class Settings { defaultQuotas: { movie: {}, tv: {}, + audiobook: {}, + ebook: {}, }, hideAvailable: false, hideBlocklisted: false, @@ -454,6 +473,7 @@ class Settings { }, radarr: [], sonarr: [], + bookshelf: [], public: { initialized: false, }, @@ -582,6 +602,12 @@ class Settings { 'sonarr-scan': { schedule: '0 30 4 * * *', }, + 'bookshelf-sync': { + schedule: '0 */5 * * * *', + }, + 'hardcover-watchlist-sync': { + schedule: '*/60 * * * * *', + }, 'availability-sync': { schedule: '0 0 5 * * *', }, @@ -691,6 +717,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/migration/postgres/1777500000000-AddBookQuotaColumns.ts b/server/migration/postgres/1777500000000-AddBookQuotaColumns.ts new file mode 100644 index 0000000000..79dc74ff54 --- /dev/null +++ b/server/migration/postgres/1777500000000-AddBookQuotaColumns.ts @@ -0,0 +1,27 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookQuotaColumns1777500000000 implements MigrationInterface { + name = 'AddBookQuotaColumns1777500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "audiobookQuotaLimit" integer` + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "audiobookQuotaDays" integer` + ); + await queryRunner.query(`ALTER TABLE "user" ADD "ebookQuotaLimit" integer`); + await queryRunner.query(`ALTER TABLE "user" ADD "ebookQuotaDays" integer`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "ebookQuotaDays"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "ebookQuotaLimit"`); + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "audiobookQuotaDays"` + ); + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "audiobookQuotaLimit"` + ); + } +} diff --git a/server/migration/postgres/1777600000000-AddHardcoverWatchlistFields.ts b/server/migration/postgres/1777600000000-AddHardcoverWatchlistFields.ts new file mode 100644 index 0000000000..c51cfda28b --- /dev/null +++ b/server/migration/postgres/1777600000000-AddHardcoverWatchlistFields.ts @@ -0,0 +1,29 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddHardcoverWatchlistFields1777600000000 implements MigrationInterface { + name = 'AddHardcoverWatchlistFields1777600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "hardcoverUsername" varchar` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "autoRequestAudiobooks" boolean` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "autoRequestEbooks" boolean` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "autoRequestEbooks"` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "autoRequestAudiobooks"` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "hardcoverUsername"` + ); + } +} diff --git a/server/migration/sqlite/1777500000000-AddBookQuotaColumns.ts b/server/migration/sqlite/1777500000000-AddBookQuotaColumns.ts new file mode 100644 index 0000000000..79dc74ff54 --- /dev/null +++ b/server/migration/sqlite/1777500000000-AddBookQuotaColumns.ts @@ -0,0 +1,27 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookQuotaColumns1777500000000 implements MigrationInterface { + name = 'AddBookQuotaColumns1777500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "audiobookQuotaLimit" integer` + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "audiobookQuotaDays" integer` + ); + await queryRunner.query(`ALTER TABLE "user" ADD "ebookQuotaLimit" integer`); + await queryRunner.query(`ALTER TABLE "user" ADD "ebookQuotaDays" integer`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "ebookQuotaDays"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "ebookQuotaLimit"`); + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "audiobookQuotaDays"` + ); + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "audiobookQuotaLimit"` + ); + } +} diff --git a/server/migration/sqlite/1777600000000-AddHardcoverWatchlistFields.ts b/server/migration/sqlite/1777600000000-AddHardcoverWatchlistFields.ts new file mode 100644 index 0000000000..c51cfda28b --- /dev/null +++ b/server/migration/sqlite/1777600000000-AddHardcoverWatchlistFields.ts @@ -0,0 +1,29 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddHardcoverWatchlistFields1777600000000 implements MigrationInterface { + name = 'AddHardcoverWatchlistFields1777600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "hardcoverUsername" varchar` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "autoRequestAudiobooks" boolean` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "autoRequestEbooks" boolean` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "autoRequestEbooks"` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "autoRequestAudiobooks"` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "hardcoverUsername"` + ); + } +} diff --git a/server/models/Book.ts b/server/models/Book.ts new file mode 100644 index 0000000000..d544e1de86 --- /dev/null +++ b/server/models/Book.ts @@ -0,0 +1,264 @@ +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; + /** Hardcover slug used to build hardcover.app URLs (e.g. "mistborn") */ + hardcoverSlug?: 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 + * "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 guessAuthorCandidates = ( + 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 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'], + resolvedAuthor?: BookshelfAuthor, + media?: Media +): BookDetails => { + const authorName = + resolvedAuthor?.authorName ?? guessAuthorName(book.authorTitle, book.title); + return { + bookshelfId: book.id, + foreignBookId: book.foreignBookId, + hardcoverSlug: (book as BookshelfBook & { hardcoverSlug?: string }) + .hardcoverSlug, + 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: book.remoteCover, + images: book.images, + ratings: book.ratings, + genres: book.genres, + links: book.links, + editions: book.editions, + mediaType, + mediaInfo: media, + }; +}; + +interface HardcoverDetailShape { + id: number; + title: string; + slug: string | null; + release_date: string | null; + users_count: number; + rating: number | null; + pages: number | null; + description: string | null; + image: { url: string } | null; + cached_tags: { Genre?: { tag: string }[] } | null; + contributions: { + author: { + id: number; + name: string; + slug: string | null; + bio: string | null; + image: { url: string } | null; + } | null; + }[]; + editions: { + id: number; + title: string | null; + pages: number | null; + release_date: string | null; + isbn_13: string | null; + audio_seconds: number | null; + image: { url: string } | null; + }[]; +} + +/** + * Map the single-shot Hardcover GraphQL response to the BookDetails shape + * the UI expects. We keep parity with the Bookshelf-mapper output so the + * detail page renders identically — only difference is bookshelfId is + * sourced from the local Media row (only present after a request was made) + * instead of the synthetic Bookshelf-side id. + */ +export const mapHardcoverToBookDetails = ( + d: HardcoverDetailShape, + mediaType: BookDetails['mediaType'], + media?: Media +): BookDetails => { + const primary = d.contributions + .map((c) => c.author) + .filter((a): a is NonNullable => !!a)[0]; + const authorName = primary?.name ?? ''; + const genres = (d.cached_tags?.Genre ?? []).map((g) => g.tag).filter(Boolean); + return { + bookshelfId: media?.externalServiceId ?? undefined, + foreignBookId: String(d.id), + hardcoverSlug: d.slug ?? undefined, + foreignEditionId: d.editions?.[0]?.id + ? String(d.editions[0].id) + : undefined, + title: d.title, + overview: d.description ?? undefined, + authorTitle: authorName ? `${authorName} ${d.title}` : undefined, + author: primary + ? { + authorName: primary.name, + foreignAuthorId: String(primary.id), + titleSlug: primary.slug ?? undefined, + overview: primary.bio ?? undefined, + images: primary.image + ? [ + { + coverType: 'poster', + url: primary.image.url, + remoteUrl: primary.image.url, + }, + ] + : undefined, + } + : undefined, + authorName, + releaseDate: d.release_date ?? undefined, + pageCount: d.pages ?? undefined, + remoteCover: d.image?.url ?? undefined, + images: d.image + ? [{ coverType: 'cover', url: d.image.url, remoteUrl: d.image.url }] + : undefined, + ratings: + d.rating != null ? { value: d.rating, votes: d.users_count } : undefined, + genres: genres.length ? genres : undefined, + links: undefined, + editions: d.editions?.map((e) => ({ + id: e.id, + title: e.title ?? undefined, + foreignEditionId: String(e.id), + isbn13: e.isbn_13 ?? undefined, + pageCount: e.pages ?? undefined, + format: e.audio_seconds && e.audio_seconds > 0 ? 'audio' : undefined, + })), + mediaType, + mediaInfo: media, + }; +}; diff --git a/server/routes/audiobook.ts b/server/routes/audiobook.ts new file mode 100644 index 0000000000..c6cc4dd20d --- /dev/null +++ b/server/routes/audiobook.ts @@ -0,0 +1,486 @@ +import { getHardcoverClient } from '@server/api/hardcover'; +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 { Permission } from '@server/lib/permissions'; +import type { BookshelfSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { mapHardcoverToBookDetails } from '@server/models/Book'; +import { Router } from 'express'; + +/** + * Phase 1+ routes for audiobook search + request. + * + * 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(); + +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); + const mediaRows = await Media.getRelatedMedia( + req.user!, + results.map((r) => ({ + tmdbId: Number(r.foreignBookId), + mediaType: 'audiobook', + })) + ); + const enriched = results.map((r) => ({ + ...r, + mediaInfo: mediaRows.find( + (m) => + m.tmdbId === Number(r.foreignBookId) && + m.mediaType === MediaType.AUDIOBOOK + ), + })); + return res.status(200).json({ term, results: enriched }); + } catch (e) { + logger.error('Audiobook search failed', { + label: 'API', + errorMessage: e.message, + term, + }); + return next({ status: 500, message: 'Audiobook search failed' }); + } +}); + +audiobookRoutes.get('/tags', async (req, res) => { + const client = getHardcoverClient(); + if (!client) return res.status(200).json({ results: [] }); + const cat = Number(req.query.category) || 1; + const limit = Math.min(Number(req.query.limit) || 40, 100); + const tags = await client.getTopTags(cat, limit); + return res.status(200).json({ results: tags }); +}); + +// Best-effort cache to throttle pre-warm calls — we only want to warm a given +// foreignBookId once per process boot. Detail-page path goes Hardcover-direct, +// so we warm the Hardcover detail cache (not Bookshelf). +const warmedBookIds = new Set(); +function preWarmHardcover(ids: (number | string | undefined)[]) { + const client = getHardcoverClient(); + if (!client) return; + for (const raw of ids) { + const id = Number(raw); + if (!Number.isFinite(id) || warmedBookIds.has(id)) continue; + warmedBookIds.add(id); + client.getBookFullDetail(id).catch(() => { + warmedBookIds.delete(id); + }); + } +} + +audiobookRoutes.get('/discover', async (req, res) => { + const client = getHardcoverClient(); + if (!client) return res.status(200).json({ results: [] }); + const sort = (req.query.sort as string) || 'popularity'; + const dir = (req.query.dir as string) === 'asc' ? 'asc' : 'desc'; + const limit = Math.min(Number(req.query.limit) || 36, 60); + const offset = Math.max(Number(req.query.offset) || 0, 0); + const optStr = (k: string) => + typeof req.query[k] === 'string' && req.query[k] + ? (req.query[k] as string) + : undefined; + const optNum = (k: string) => { + const v = req.query[k]; + if (v == null || v === '') return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; + }; + const tagIds = + typeof req.query.tagIds === 'string' && req.query.tagIds + ? req.query.tagIds + .split(',') + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n)) + : undefined; + const books = await client.discover({ + sort: sort as + | 'popularity' + | 'title' + | 'release_date' + | 'rating' + | 'trending', + dir, + limit, + offset, + releaseFrom: optStr('releaseFrom'), + releaseTo: optStr('releaseTo'), + pagesMin: optNum('pagesMin'), + pagesMax: optNum('pagesMax'), + ratingMin: optNum('ratingMin'), + ratingMax: optNum('ratingMax'), + usersCountMin: optNum('usersCountMin'), + tagIds, + trendingPeriod: + (optStr('trendingPeriod') as 'month' | 'quarter' | 'year' | 'all') ?? + 'month', + }); + const mediaRows = await Media.getRelatedMedia( + req.user!, + books.map((b) => ({ tmdbId: b.id, mediaType: 'audiobook' })) + ); + const enriched = books.map((b) => ({ + foreignBookId: String(b.id), + title: b.title, + slug: b.slug, + releaseDate: b.release_date, + rating: b.rating, + usersCount: b.users_count, + pageCount: b.pages, + overview: b.description, + remoteCover: b.image?.url, + authorTitle: b.contributions + .map((c) => c.author?.name) + .filter(Boolean) + .join(', '), + mediaInfo: mediaRows.find( + (m) => m.tmdbId === b.id && m.mediaType === MediaType.AUDIOBOOK + ), + })); + // Background pre-warm: trigger Hardcover detail lookups for the + // discovered books so the user's click on any tile renders instantly. + preWarmHardcover(enriched.map((b) => b.foreignBookId)); + return res.status(200).json({ sort, dir, results: enriched }); +}); + +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, profileId } = + req.body as { + foreignBookId?: string; + foreignAuthorId?: string; + authorName?: string; + profileId?: number; + }; + + if (!foreignBookId) { + return next({ status: 400, message: 'foreignBookId is required' }); + } + if (!foreignAuthorId && !authorName) { + return next({ + status: 400, + message: 'foreignAuthorId or authorName is required', + }); + } + if (!req.user) { + return next({ status: 401, message: 'Authentication required' }); + } + + const server = findAudiobookServer(); + if (!server) { + return next({ + status: 503, + message: 'No audiobook Bookshelf server is configured', + }); + } + + 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 { + 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); + + // 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 quotas = await req.user.getQuota(); + if (quotas.audiobook.restricted) { + return next({ + status: 403, + message: 'Audiobook request quota exceeded', + }); + } + + const autoApprove = req.user.hasPermission( + [Permission.AUTO_APPROVE, Permission.MANAGE_REQUESTS], + { type: 'or' } + ); + + const request = new MediaRequest({ + type: MediaType.AUDIOBOOK, + media, + requestedBy: req.user, + status: autoApprove + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: autoApprove ? req.user : undefined, + is4k: false, + serverId: server.id, + profileId: profileId ?? server.activeProfileId, + rootFolder: server.activeDirectory, + tags: [], + isAutoRequest: false, + }); + + // MediaRequestSubscriber.afterInsert/afterUpdate will fire sendToBookshelf + // when status is APPROVED, which performs the Bookshelf addBook and + // updates Media.status accordingly. Mirrors the Radarr/Sonarr flow. + await requestRepository.save(request); + + return res.status(201).json({ + request, + media, + message: autoApprove + ? 'Request approved; Bookshelf will pick it up shortly' + : 'Request pending admin approval', + }); + } catch (e) { + logger.error('Audiobook request flow 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' }); + } +}); + +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('/: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 { + // Hardcover-first: a single GraphQL call returns title/desc/cover/rating/ + // pages/genres/author/editions in ~100ms. Bookshelf would chain multiple + // calls and take 5-15s for cold books. We skip Bookshelf in this path and + // map directly. Bookshelf state (bookshelfId, file count) gets joined + // when there's a Media row, so that information still appears once the + // book has actually been requested. + const hardcover = getHardcoverClient(); + const mediaRepo = getRepository(Media); + const [detail, media] = await Promise.all([ + hardcover ? hardcover.getBookFullDetail(tmdbId) : Promise.resolve(null), + mediaRepo.findOne({ + where: { tmdbId, mediaType: MediaType.AUDIOBOOK }, + relations: { requests: true }, + }), + ]); + if (!detail) { + return next({ status: 404, message: 'Book not found' }); + } + return res + .status(200) + .json( + mapHardcoverToBookDetails( + detail, + MediaType.AUDIOBOOK, + 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.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 == null || media.externalServiceId == null) { + 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) => { + const id = Number(req.params.foreignBookId); + if (!Number.isFinite(id)) return res.status(200).json({ results: [] }); + const hardcover = getHardcoverClient(); + if (!hardcover) return res.status(200).json({ results: [] }); + const books = await hardcover.getMoreByAuthor(id, 20); + const results = books.map((b) => ({ + foreignBookId: String(b.id), + title: b.title, + slug: b.slug, + releaseDate: b.release_date, + rating: b.rating, + pageCount: b.pages, + remoteCover: b.image?.url, + authorTitle: b.contributions + .map((c) => c.author?.name) + .filter(Boolean) + .join(', '), + })); + return res.status(200).json({ results }); +}); + +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..127ff1ddf8 --- /dev/null +++ b/server/routes/ebook.ts @@ -0,0 +1,464 @@ +import { getHardcoverClient } from '@server/api/hardcover'; +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 { Permission } from '@server/lib/permissions'; +import type { BookshelfSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { mapHardcoverToBookDetails } from '@server/models/Book'; +import { Router } from 'express'; + +/** Mirror of audiobook.ts for the ebook Bookshelf 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); + const mediaRows = await Media.getRelatedMedia( + req.user!, + results.map((r) => ({ + tmdbId: Number(r.foreignBookId), + mediaType: 'ebook', + })) + ); + const enriched = results.map((r) => ({ + ...r, + mediaInfo: mediaRows.find( + (m) => + m.tmdbId === Number(r.foreignBookId) && + m.mediaType === MediaType.EBOOK + ), + })); + return res.status(200).json({ term, results: enriched }); + } catch (e) { + logger.error('Ebook search failed', { + label: 'API', + errorMessage: e.message, + term, + }); + return next({ status: 500, message: 'Ebook search failed' }); + } +}); + +ebookRoutes.get('/tags', async (req, res) => { + const client = getHardcoverClient(); + if (!client) return res.status(200).json({ results: [] }); + const cat = Number(req.query.category) || 1; + const limit = Math.min(Number(req.query.limit) || 40, 100); + const tags = await client.getTopTags(cat, limit); + return res.status(200).json({ results: tags }); +}); + +// Best-effort cache to throttle pre-warm calls — same as audiobook side; the +// Hardcover cache is the only one we need to warm now that detail pages go +// Hardcover-direct. +const warmedEbookIds = new Set(); +function preWarmHardcover(ids: (number | string | undefined)[]) { + const client = getHardcoverClient(); + if (!client) return; + for (const raw of ids) { + const id = Number(raw); + if (!Number.isFinite(id) || warmedEbookIds.has(id)) continue; + warmedEbookIds.add(id); + client.getBookFullDetail(id).catch(() => { + warmedEbookIds.delete(id); + }); + } +} + +ebookRoutes.get('/discover', async (req, res) => { + const client = getHardcoverClient(); + if (!client) return res.status(200).json({ results: [] }); + const sort = (req.query.sort as string) || 'popularity'; + const dir = (req.query.dir as string) === 'asc' ? 'asc' : 'desc'; + const limit = Math.min(Number(req.query.limit) || 36, 60); + const offset = Math.max(Number(req.query.offset) || 0, 0); + const optStr = (k: string) => + typeof req.query[k] === 'string' && req.query[k] + ? (req.query[k] as string) + : undefined; + const optNum = (k: string) => { + const v = req.query[k]; + if (v == null || v === '') return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; + }; + const tagIds = + typeof req.query.tagIds === 'string' && req.query.tagIds + ? req.query.tagIds + .split(',') + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n)) + : undefined; + const books = await client.discover({ + sort: sort as + | 'popularity' + | 'title' + | 'release_date' + | 'rating' + | 'trending', + dir, + limit, + offset, + releaseFrom: optStr('releaseFrom'), + releaseTo: optStr('releaseTo'), + pagesMin: optNum('pagesMin'), + pagesMax: optNum('pagesMax'), + ratingMin: optNum('ratingMin'), + ratingMax: optNum('ratingMax'), + usersCountMin: optNum('usersCountMin'), + tagIds, + trendingPeriod: + (optStr('trendingPeriod') as 'month' | 'quarter' | 'year' | 'all') ?? + 'month', + }); + const mediaRows = await Media.getRelatedMedia( + req.user!, + books.map((b) => ({ tmdbId: b.id, mediaType: 'ebook' })) + ); + const enriched = books.map((b) => ({ + foreignBookId: String(b.id), + title: b.title, + slug: b.slug, + releaseDate: b.release_date, + rating: b.rating, + usersCount: b.users_count, + pageCount: b.pages, + overview: b.description, + remoteCover: b.image?.url, + authorTitle: b.contributions + .map((c) => c.author?.name) + .filter(Boolean) + .join(', '), + mediaInfo: mediaRows.find( + (m) => m.tmdbId === b.id && m.mediaType === MediaType.EBOOK + ), + })); + preWarmHardcover(enriched.map((b) => b.foreignBookId)); + return res.status(200).json({ sort, dir, results: enriched }); +}); + +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, profileId } = + req.body as { + foreignBookId?: string; + foreignAuthorId?: string; + authorName?: string; + profileId?: number; + }; + + if (!foreignBookId) { + return next({ status: 400, message: 'foreignBookId is required' }); + } + if (!foreignAuthorId && !authorName) { + return next({ + status: 400, + message: 'foreignAuthorId or authorName is required', + }); + } + if (!req.user) { + return next({ status: 401, message: 'Authentication required' }); + } + + const server = findEbookServer(); + if (!server) { + return next({ + status: 503, + message: 'No ebook Bookshelf server is configured', + }); + } + + 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 { + 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, + }); + } + + const quotas = await req.user.getQuota(); + if (quotas.ebook.restricted) { + return next({ + status: 403, + message: 'Ebook request quota exceeded', + }); + } + + const autoApprove = req.user.hasPermission( + [Permission.AUTO_APPROVE, Permission.MANAGE_REQUESTS], + { type: 'or' } + ); + + const request = new MediaRequest({ + type: MediaType.EBOOK, + media, + requestedBy: req.user, + status: autoApprove + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: autoApprove ? req.user : undefined, + is4k: false, + serverId: server.id, + profileId: profileId ?? server.activeProfileId, + rootFolder: server.activeDirectory, + tags: [], + isAutoRequest: false, + }); + + // sendToBookshelf in MediaRequestSubscriber handles the Bookshelf addBook + // when status is APPROVED. + await requestRepository.save(request); + + return res.status(201).json({ + request, + media, + message: autoApprove + ? 'Request approved; Bookshelf will pick it up shortly' + : 'Request pending admin approval', + }); + } catch (e) { + logger.error('Ebook request flow 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' }); + } +}); + +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('/: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 { + // Hardcover-first: see audiobook.ts for the rationale. Single ~100ms + // Hardcover call replaces 5-15s of chained Bookshelf lookups. + const hardcover = getHardcoverClient(); + const mediaRepo = getRepository(Media); + const [detail, media] = await Promise.all([ + hardcover ? hardcover.getBookFullDetail(tmdbId) : Promise.resolve(null), + mediaRepo.findOne({ + where: { tmdbId, mediaType: MediaType.EBOOK }, + relations: { requests: true }, + }), + ]); + if (!detail) { + return next({ status: 404, message: 'Book not found' }); + } + return res + .status(200) + .json( + mapHardcoverToBookDetails(detail, MediaType.EBOOK, 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.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 == null || media.externalServiceId == null) { + 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) => { + const id = Number(req.params.foreignBookId); + if (!Number.isFinite(id)) return res.status(200).json({ results: [] }); + const hardcover = getHardcoverClient(); + if (!hardcover) return res.status(200).json({ results: [] }); + const books = await hardcover.getMoreByAuthor(id, 20); + const results = books.map((b) => ({ + foreignBookId: String(b.id), + title: b.title, + slug: b.slug, + releaseDate: b.release_date, + rating: b.rating, + pageCount: b.pages, + remoteCover: b.image?.url, + authorTitle: b.contributions + .map((c) => c.author?.name) + .filter(Boolean) + .join(', '), + })); + return res.status(200).json({ results }); +}); + +export default ebookRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index f701acf968..6da1e2bd2f 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -1,4 +1,3 @@ -import GithubAPI from '@server/api/github'; import PushoverAPI from '@server/api/pushover'; import TheMovieDb from '@server/api/themoviedb'; import type { @@ -28,10 +27,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'; @@ -47,50 +48,15 @@ const router = Router(); router.use(checkUser); -router.get('/status', async (req, res) => { - const githubApi = new GithubAPI(); - - const currentVersion = getAppVersion(); - const commitTag = getCommitTag(); - let updateAvailable = false; - let commitsBehind = 0; - - if (currentVersion.startsWith('develop-') && commitTag !== 'local') { - const commits = await githubApi.getSeerrCommits(); - - if (commits.length) { - const filteredCommits = commits.filter( - (commit) => !commit.commit.message.includes('[skip ci]') - ); - if (filteredCommits[0].sha !== commitTag) { - updateAvailable = true; - } - - const commitIndex = filteredCommits.findIndex( - (commit) => commit.sha === commitTag - ); - - if (updateAvailable) { - commitsBehind = commitIndex; - } - } - } else if (commitTag !== 'local') { - const releases = await githubApi.getSeerrReleases(); - - if (releases.length) { - const latestVersion = releases[0]; - - if (!latestVersion.name.includes(currentVersion)) { - updateAvailable = true; - } - } - } - +router.get('/status', async (_req, res) => { + // Bryanlabs fork: we don't track upstream Seerr releases, so the + // upstream-update check is disabled. Always report up-to-date so the UI + // doesn't surface a yellow "Out of Date" badge. return res.status(200).json({ version: getAppVersion(), commitTag: getCommitTag(), - updateAvailable, - commitsBehind, + updateAvailable: false, + commitsBehind: 0, restartRequired: restartFlag.isSet(), }); }); @@ -165,6 +131,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/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/server/routes/request.ts b/server/routes/request.ts index fafa90692e..f733d0d1d7 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 { @@ -173,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 @@ -213,6 +224,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 +262,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 +303,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/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 784f7b5fd8..65617be1c1 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -57,12 +57,19 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( movieQuotaDays: user.movieQuotaDays, tvQuotaLimit: user.tvQuotaLimit, tvQuotaDays: user.tvQuotaDays, + audiobookQuotaLimit: user.audiobookQuotaLimit, + audiobookQuotaDays: user.audiobookQuotaDays, + ebookQuotaLimit: user.ebookQuotaLimit, + ebookQuotaDays: user.ebookQuotaDays, globalMovieQuotaDays: defaultQuotas.movie.quotaDays, globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit, globalTvQuotaDays: defaultQuotas.tv.quotaDays, globalTvQuotaLimit: defaultQuotas.tv.quotaLimit, watchlistSyncMovies: user.settings?.watchlistSyncMovies, watchlistSyncTv: user.settings?.watchlistSyncTv, + hardcoverUsername: user.settings?.hardcoverUsername, + autoRequestAudiobooks: user.settings?.autoRequestAudiobooks, + autoRequestEbooks: user.settings?.autoRequestEbooks, }); } catch (e) { next({ status: 500, message: e.message }); @@ -117,6 +124,10 @@ userSettingsRoutes.post< user.movieQuotaLimit = req.body.movieQuotaLimit; user.tvQuotaDays = req.body.tvQuotaDays; user.tvQuotaLimit = req.body.tvQuotaLimit; + user.audiobookQuotaDays = req.body.audiobookQuotaDays; + user.audiobookQuotaLimit = req.body.audiobookQuotaLimit; + user.ebookQuotaDays = req.body.ebookQuotaDays; + user.ebookQuotaLimit = req.body.ebookQuotaLimit; } if (!user.settings) { @@ -129,6 +140,9 @@ userSettingsRoutes.post< originalLanguage: req.body.originalLanguage, watchlistSyncMovies: req.body.watchlistSyncMovies, watchlistSyncTv: req.body.watchlistSyncTv, + hardcoverUsername: req.body.hardcoverUsername, + autoRequestAudiobooks: req.body.autoRequestAudiobooks, + autoRequestEbooks: req.body.autoRequestEbooks, }); } else { user.settings.discordId = req.body.discordId; @@ -138,6 +152,9 @@ userSettingsRoutes.post< user.settings.originalLanguage = req.body.originalLanguage; user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies; user.settings.watchlistSyncTv = req.body.watchlistSyncTv; + user.settings.hardcoverUsername = req.body.hardcoverUsername; + user.settings.autoRequestAudiobooks = req.body.autoRequestAudiobooks; + user.settings.autoRequestEbooks = req.body.autoRequestEbooks; } const savedUser = await userRepository.save(user); @@ -151,6 +168,9 @@ userSettingsRoutes.post< originalLanguage: savedUser.settings?.originalLanguage, watchlistSyncMovies: savedUser.settings?.watchlistSyncMovies, watchlistSyncTv: savedUser.settings?.watchlistSyncTv, + hardcoverUsername: savedUser.settings?.hardcoverUsername, + autoRequestAudiobooks: savedUser.settings?.autoRequestAudiobooks, + autoRequestEbooks: savedUser.settings?.autoRequestEbooks, email: savedUser.email, }); } catch (e) { diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index 0f10c33804..45e3a999c4 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -817,6 +817,154 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { + if ( + entity.status !== MediaRequestStatus.APPROVED || + (entity.type !== MediaType.AUDIOBOOK && entity.type !== MediaType.EBOOK) + ) { + return; + } + try { + const mediaRepository = getRepository(Media); + const settings = getSettings(); + const targetMediaType = + entity.type === MediaType.AUDIOBOOK ? 'audiobook' : 'ebook'; + const server = + settings.bookshelf.find( + (b) => b.id === entity.serverId && b.mediaType === targetMediaType + ) ?? + settings.bookshelf.find( + (b) => b.mediaType === targetMediaType && b.isDefault + ); + if (!server) { + logger.warn( + 'No Bookshelf instance configured for approved book request', + { + label: 'Media Request', + requestId: entity.id, + mediaType: entity.type, + } + ); + return; + } + + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + }); + if (!media) { + return; + } + + // Already added to Bookshelf — skip duplicate addBook call. + if (media.externalServiceId != null) { + return; + } + + // Pull the author name from the resolved metadata so addBook can do + // its /author/lookup. We don't have a stored author on Media; fetch + // by foreignBookId from Bookshelf. + const BookshelfAPI = (await import('@server/api/servarr/bookshelf')) + .default; + const { guessAuthorCandidates } = await import('@server/models/Book'); + const client = new BookshelfAPI({ + apiKey: server.apiKey, + url: BookshelfAPI.buildUrl(server, '/api/v1'), + }); + const lookup = await client.searchBook(`work:${media.tmdbId}`); + const match = lookup[0]; + if (!match) { + logger.warn('Bookshelf lookup empty for approved book request', { + label: 'Media Request', + requestId: entity.id, + tmdbId: media.tmdbId, + }); + return; + } + + // Bookshelf's authorTitle is junk-formatted; try multiple + // candidate parses against /author/lookup until one matches. + const candidates = guessAuthorCandidates(match.authorTitle, match.title); + let resolvedForeignAuthorId: string | undefined; + let resolvedAuthorName: string | undefined; + for (const candidate of candidates) { + const result = await client.searchAuthor(candidate).catch(() => []); + 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), + 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, + searchNow: true, + }); + + await mediaRepository.update(media.id, { + status: MediaStatus.PROCESSING, + serviceId: server.id, + externalServiceId: book.id ?? null, + externalServiceSlug: book.titleSlug ?? null, + }); + logger.info('Bookshelf accepted approved book request', { + label: 'Media Request', + requestId: entity.id, + bookId: book.id, + }); + } catch (e) { + logger.error( + 'Failed to add approved book to Bookshelf, marking FAILED', + { + label: 'Media Request', + requestId: entity.id, + errorMessage: (e as Error).message, + } + ); + const requestRepository = getRepository(MediaRequest); + entity.status = MediaRequestStatus.FAILED; + await requestRepository.save(entity); + MediaRequest.sendNotification(entity, media, Notification.MEDIA_FAILED); + } + } catch (e) { + logger.error('sendToBookshelf failed unexpectedly', { + label: 'Media Request', + requestId: entity.id, + errorMessage: (e as Error).message, + }); + } + } + public async updateParentStatus(entity: MediaRequest): Promise { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ @@ -985,6 +1133,7 @@ export class MediaRequestSubscriber 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; } diff --git a/src/components/BookDetails/index.tsx b/src/components/BookDetails/index.tsx new file mode 100644 index 0000000000..812783ccfd --- /dev/null +++ b/src/components/BookDetails/index.tsx @@ -0,0 +1,520 @@ +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'; +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'; +import { + ArrowDownTrayIcon, + ArrowTopRightOnSquareIcon, + CheckIcon, + CloudIcon, + 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'; +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'; +} + +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 { data: recs } = useSWR<{ results: BookshelfRecommendationItem[] }>( + id ? `${apiBase}/${id}/recommendations` : null + ); + + const [showRequestModal, setShowRequestModal] = 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); + } + }; + + 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 ; + } + 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 + ); + + // 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; + + 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 && ( + + + + )} + {data.mediaInfo && + hasPermission( + [Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES], + { type: 'or' } + ) && ( + + )} +
+
+ + {data.mediaInfo && hasPermission(Permission.MANAGE_REQUESTS) && ( +
+ + Admin: + + + + + + + +
+ )} +
+
+
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} ↗ + + ))} +
+ )} +
+
+ + {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)} + /> + )} + + setShowRequestModal(false)} + onComplete={() => mutate()} + /> +
+ ); +}; + +export default BookDetails; diff --git a/src/components/BookDiscover/index.tsx b/src/components/BookDiscover/index.tsx new file mode 100644 index 0000000000..51195cba20 --- /dev/null +++ b/src/components/BookDiscover/index.tsx @@ -0,0 +1,147 @@ +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import axios from 'axios'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; + +interface DiscoverHit { + foreignBookId: string; + title: string; + releaseDate?: string | null; + rating?: number | null; + usersCount?: number; + pageCount?: number | null; + remoteCover?: string; + authorTitle?: string; + mediaInfo?: { status: number }; +} + +interface Props { + mediaType: 'audiobook' | 'ebook'; +} + +const STATUS_LABEL: Record< + number, + { text: string; color: string } | undefined +> = { + 2: { text: 'Pending', color: 'bg-yellow-500' }, + 3: { text: 'Processing', color: 'bg-indigo-500' }, + 4: { text: 'Partially Available', color: 'bg-cyan-500' }, + 5: { text: 'Available', color: 'bg-green-500' }, +}; + +const SORT_OPTIONS: { value: string; label: string }[] = [ + { value: 'popularity:desc', label: 'Popularity (descending)' }, + { value: 'popularity:asc', label: 'Popularity (ascending)' }, + { value: 'title:asc', label: 'Title (A → Z)' }, + { value: 'title:desc', label: 'Title (Z → A)' }, + { value: 'release_date:desc', label: 'Release date (newest)' }, + { value: 'release_date:asc', label: 'Release date (oldest)' }, + { value: 'rating:desc', label: 'Rating (highest)' }, + { value: 'rating:asc', label: 'Rating (lowest)' }, +]; + +const BookDiscover = ({ mediaType }: Props) => { + const isAudio = mediaType === 'audiobook'; + const apiBase = isAudio ? '/api/v1/audiobook' : '/api/v1/ebook'; + const detailBase = isAudio ? '/audiobooks' : '/ebooks'; + const [sortKey, setSortKey] = useState('popularity:desc'); + const [hits, setHits] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const [sort, dir] = sortKey.split(':'); + let cancelled = false; + setLoading(true); + axios + .get<{ results: DiscoverHit[] }>(`${apiBase}/discover`, { + params: { sort, dir, limit: 36 }, + }) + .then((r) => { + if (!cancelled) setHits(r.data.results ?? []); + }) + .catch(() => { + if (!cancelled) setHits([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [apiBase, sortKey]); + + return ( +
+
+

+ Popular {isAudio ? 'Audiobooks' : 'Ebooks'} +

+ +
+ + {loading && !hits && } + + {hits && hits.length === 0 && !loading && ( +
+ No {isAudio ? 'audiobook' : 'ebook'} discovery results. Hardcover may + be unreachable. +
+ )} + + {hits && hits.length > 0 && ( +
    + {hits.map((b) => ( +
  • + + {b.mediaInfo && STATUS_LABEL[b.mediaInfo.status] && ( + + {STATUS_LABEL[b.mediaInfo.status]?.text} + + )} + {b.remoteCover ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
    + )} +
    + {b.title} +
    + {b.authorTitle && ( +
    + {b.authorTitle} +
    + )} +
    + {b.releaseDate?.slice(0, 4) ?? ''} + {b.rating ? ` · ★ ${b.rating.toFixed(1)}` : ''} +
    + +
  • + ))} +
+ )} +
+ ); +}; + +export default BookDiscover; diff --git a/src/components/BookFilterSlideover/index.tsx b/src/components/BookFilterSlideover/index.tsx new file mode 100644 index 0000000000..ae6e615e2f --- /dev/null +++ b/src/components/BookFilterSlideover/index.tsx @@ -0,0 +1,362 @@ +import Button from '@app/components/Common/Button'; +import SlideOver from '@app/components/Common/SlideOver'; +import type { ParsedUrlQuery } from 'querystring'; +import { useState } from 'react'; +import type { MultiValue } from 'react-select'; +import Select from 'react-select'; +import useSWR from 'swr'; + +export interface BookFilterValues { + releaseFrom?: string; + releaseTo?: string; + pagesMin?: number; + pagesMax?: number; + ratingMin?: number; + ratingMax?: number; + usersCountMin?: number; + /** Tag IDs (genre + mood IDs combined into one array for the API) */ + tagIds?: number[]; +} + +interface TagOption { + id: number; + tag: string; + count: number; +} + +export const parseBookFilters = (q: ParsedUrlQuery): BookFilterValues => { + const num = (k: string) => { + const v = q[k]; + if (typeof v !== 'string' || !v) return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; + }; + const str = (k: string) => + typeof q[k] === 'string' && q[k] ? (q[k] as string) : undefined; + const ids = str('tagIds'); + return { + releaseFrom: str('releaseFrom'), + releaseTo: str('releaseTo'), + pagesMin: num('pagesMin'), + pagesMax: num('pagesMax'), + ratingMin: num('ratingMin'), + ratingMax: num('ratingMax'), + usersCountMin: num('usersCountMin'), + tagIds: ids + ? ids + .split(',') + .map((s) => Number(s)) + .filter((n) => Number.isFinite(n)) + : undefined, + }; +}; + +export const countActiveBookFilters = (f: BookFilterValues): number => { + let n = 0; + if (f.releaseFrom) n++; + if (f.releaseTo) n++; + if (f.pagesMin) n++; + if (f.pagesMax) n++; + if (f.ratingMin) n++; + if (f.ratingMax && f.ratingMax < 5) n++; + if (f.usersCountMin) n++; + if (f.tagIds && f.tagIds.length > 0) n += f.tagIds.length; + return n; +}; + +interface Props { + show: boolean; + onClose: () => void; + values: BookFilterValues; + onApply: (next: BookFilterValues) => void; + mediaType: 'audiobook' | 'ebook'; +} + +interface SelectItem { + label: string; + value: number; +} + +const titleCase = (s: string): string => + s.replace(/(^|\s)\S/g, (c) => c.toUpperCase()); + +const TagMultiSelect = ({ + options, + selectedIds, + onChange, + placeholder, +}: { + options: TagOption[] | undefined; + selectedIds: Set; + onChange: (ids: number[]) => void; + placeholder: string; +}) => { + const items: SelectItem[] = (options ?? []).map((o) => ({ + label: `${titleCase(o.tag)} (${o.count.toLocaleString()})`, + value: o.id, + })); + const value = items.filter((i) => selectedIds.has(i.value)); + return ( + + update('releaseFrom', e.target.value || undefined) + } + className="w-full rounded-md border border-gray-600 bg-gray-800 px-2 py-1 text-sm text-white" + /> + + +
+
+ +
+

Pages

+
+ + update( + 'pagesMin', + e.target.value ? Number(e.target.value) : undefined + ) + } + className="w-24 rounded-md border border-gray-600 bg-gray-800 px-2 py-1 text-sm text-white" + /> + to + + update( + 'pagesMax', + e.target.value ? Number(e.target.value) : undefined + ) + } + className="w-24 rounded-md border border-gray-600 bg-gray-800 px-2 py-1 text-sm text-white" + /> + pages +
+
+ +
+

+ Hardcover Rating +

+
+ + update( + 'ratingMin', + e.target.value ? Number(e.target.value) : undefined + ) + } + className="w-20 rounded-md border border-gray-600 bg-gray-800 px-2 py-1 text-sm text-white" + /> + to + + update( + 'ratingMax', + e.target.value ? Number(e.target.value) : undefined + ) + } + className="w-20 rounded-md border border-gray-600 bg-gray-800 px-2 py-1 text-sm text-white" + /> + ★ (0-5) +
+
+ +
+

+ Minimum Hardcover Readers +

+ + update( + 'usersCountMin', + e.target.value ? Number(e.target.value) : undefined + ) + } + className="w-32 rounded-md border border-gray-600 bg-gray-800 px-2 py-1 text-sm text-white" + /> +
+ Only show books with at least this many Hardcover readers + (popularity floor). +
+
+ +
+ + +
+ + + ); +}; + +export default BookFilterSlideover; 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 new file mode 100644 index 0000000000..dd38dc8ef5 --- /dev/null +++ b/src/components/BookSearch/index.tsx @@ -0,0 +1,247 @@ +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 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; + authorTitle?: string; + releaseDate?: string; + pageCount?: number; + remoteCover?: string; + images?: { coverType: string; url: string; remoteUrl?: string }[]; + ratings?: { value?: number; votes?: number }; + 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 ''; + // 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 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 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 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}
+ {!query && ( +
+
+ + + + +
+
+ +
+
+ )} +
+ + 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.`} +
+ )} + + {hits && hits.length > 0 && ( +
    + {hits.map((b) => ( +
  • + +
  • + ))} +
+ )} + + ); +}; + +export default BookSearch; 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..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/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/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: (
diff --git a/src/components/Search/SearchBookSection.tsx b/src/components/Search/SearchBookSection.tsx new file mode 100644 index 0000000000..ea3542c5b0 --- /dev/null +++ b/src/components/Search/SearchBookSection.tsx @@ -0,0 +1,151 @@ +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import axios from 'axios'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; + +interface BookHit { + title: string; + foreignBookId: string; + authorTitle?: string; + releaseDate?: string; + remoteCover?: string; + images?: { coverType: string; url: string; remoteUrl?: string }[]; + mediaInfo?: { status: number }; +} + +const STATUS_LABEL: Record< + number, + { text: string; color: string } | undefined +> = { + 2: { text: 'Pending', color: 'bg-yellow-500' }, + 3: { text: 'Processing', color: 'bg-indigo-500' }, + 4: { text: 'Partially Available', color: 'bg-cyan-500' }, + 5: { text: 'Available', color: 'bg-green-500' }, +}; + +interface Props { + query: string; + 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 ''; + 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 SearchBookSection = ({ query, mediaType }: Props) => { + const isAudio = mediaType === 'audiobook'; + const baseLabel = isAudio ? 'Audiobooks' : 'Ebooks'; + const apiBase = isAudio ? '/api/v1/audiobook' : '/api/v1/ebook'; + const [hits, setHits] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + if (!query) { + setHits(null); + return; + } + setLoading(true); + axios + .get<{ results: BookHit[] }>(`${apiBase}/search`, { + params: { q: query }, + }) + .then((r) => { + if (!cancelled) setHits(r.data.results ?? []); + }) + .catch(() => { + if (!cancelled) setHits([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [query, apiBase]); + + if (!query) return null; + if (loading && !hits) { + return ( +
+

{baseLabel}

+ +
+ ); + } + if (!hits || hits.length === 0) { + return ( +
+

{baseLabel}

+
+ No {isAudio ? 'audiobook' : 'ebook'} results for "{query}". +
+
+ ); + } + + const top = hits.slice(0, 12); + + return ( +
+

{baseLabel}

+
    + {top.map((b) => { + const author = guessAuthor(b.authorTitle, b.title); + const detailHref = `/${isAudio ? 'audiobooks' : 'ebooks'}/${b.foreignBookId}`; + return ( +
  • + + {b.mediaInfo && STATUS_LABEL[b.mediaInfo.status] && ( + + {STATUS_LABEL[b.mediaInfo.status]?.text} + + )} + {cover(b) ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
    + )} +
    + {b.title} +
    + {author && ( +
    {author}
    + )} + {b.releaseDate && ( +
    + {b.releaseDate.slice(0, 4)} +
    + )} + +
  • + ); + })} +
+
+ ); +}; + +export default SearchBookSection; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index e5a54180bb..19a320e9b8 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,6 +1,7 @@ import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; +import SearchBookSection from '@app/components/Search/SearchBookSection'; import useDiscover from '@app/hooks/useDiscover'; import ErrorPage from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; @@ -56,6 +57,14 @@ const Search = () => { isReachingEnd={isReachingEnd} onScrollBottom={fetchMore} /> + + ); }; 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/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 53d27c4d14..50b4b81f74 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -88,6 +88,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages( 'sonarr-scan': 'Sonarr Scan', 'download-sync': 'Download Sync', 'download-sync-reset': 'Download Sync Reset', + 'bookshelf-sync': 'Bookshelf Sync', + 'hardcover-watchlist-sync': 'Hardcover Watchlist Sync', 'image-cache-cleanup': 'Image Cache Cleanup', 'process-blocklisted-tags': 'Process Blocklisted Tags', editJobSchedule: 'Modify Job', diff --git a/src/components/Settings/SettingsMetadata.tsx b/src/components/Settings/SettingsMetadata.tsx index 4716471181..f9f66378d1 100644 --- a/src/components/Settings/SettingsMetadata.tsx +++ b/src/components/Settings/SettingsMetadata.tsx @@ -10,7 +10,7 @@ import defineMessages from '@app/utils/defineMessages'; import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; import axios from 'axios'; import { Form, Formik } from 'formik'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; @@ -307,6 +307,8 @@ const SettingsMetadata = () => {
+ +
{ ); }; +const BooksMetadataInfo = () => { + const [audiobookHealth, setAudiobookHealth] = useState< + 'ok' | 'failed' | 'unknown' + >('unknown'); + const [ebookHealth, setEbookHealth] = useState<'ok' | 'failed' | 'unknown'>( + 'unknown' + ); + useEffect(() => { + let cancelled = false; + axios + .get('/api/v1/audiobook/profiles') + .then(() => !cancelled && setAudiobookHealth('ok')) + .catch(() => !cancelled && setAudiobookHealth('failed')); + axios + .get('/api/v1/ebook/profiles') + .then(() => !cancelled && setEbookHealth('ok')) + .catch(() => !cancelled && setEbookHealth('failed')); + return () => { + cancelled = true; + }; + }, []); + const badge = (h: 'ok' | 'failed' | 'unknown') => { + if (h === 'ok') return Operational; + if (h === 'failed') return Unreachable; + return Checking…; + }; + return ( +
+

Books

+

+ Audiobook and ebook metadata comes from Hardcover via + the cluster's rreading-glasses proxy, which Bookshelf + (Readarr fork) calls for lookups, covers, ratings, and editions. + Switching providers requires re-pointing the rreading-glasses backend, + not a Seerr setting. The Hardcover API token is rotated annually under{' '} + INF-4. +

+
+
+ Bookshelf-Audiobooks: + {badge(audiobookHealth)} +
+
+ Bookshelf-Ebooks: + {badge(ebookHealth)} +
+
+
+ ); +}; + export default SettingsMetadata; 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)} diff --git a/src/components/TitleCard/BookMediaCard.tsx b/src/components/TitleCard/BookMediaCard.tsx new file mode 100644 index 0000000000..f1cbda625c --- /dev/null +++ b/src/components/TitleCard/BookMediaCard.tsx @@ -0,0 +1,73 @@ +import TitleCard from '@app/components/TitleCard'; +import BookTitleCard from '@app/components/TitleCard/BookTitleCard'; +import { Permission, useUser } from '@app/hooks/useUser'; +import type { BookDetails } from '@server/models/Book'; +import { useInView } from 'react-intersection-observer'; +import useSWR from 'swr'; + +interface Props { + id: number; + foreignBookId: number; + mediaType: 'audiobook' | 'ebook'; + canExpand?: boolean; +} + +const titleCase = (s: string) => + s.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()); + +const guessAuthor = (authorTitle: string | undefined, title: string) => { + if (!authorTitle) return ''; + 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 BookMediaCard = ({ id, foreignBookId, mediaType, canExpand }: Props) => { + const { hasPermission } = useUser(); + const { ref, inView } = useInView({ triggerOnce: true }); + const apiBase = + mediaType === 'audiobook' ? '/api/v1/audiobook' : '/api/v1/ebook'; + const { data, error } = useSWR( + inView ? `${apiBase}/${foreignBookId}` : null + ); + + if (!data && !error) { + return ( +
+ +
+ ); + } + if (!data) { + return hasPermission(Permission.ADMIN) ? ( + + ) : null; + } + const cover = + data.remoteCover ?? + data.images?.find((i) => i.coverType === 'cover')?.remoteUrl ?? + data.images?.find((i) => i.coverType === 'cover')?.url; + return ( + + ); +}; + +export default BookMediaCard; diff --git a/src/components/TitleCard/BookTitleCard.tsx b/src/components/TitleCard/BookTitleCard.tsx new file mode 100644 index 0000000000..b67fb11e84 --- /dev/null +++ b/src/components/TitleCard/BookTitleCard.tsx @@ -0,0 +1,192 @@ +import BookRequestModal from '@app/components/BookRequestModal'; +import Button from '@app/components/Common/Button'; +import StatusBadgeMini from '@app/components/Common/StatusBadgeMini'; +import { useIsTouch } from '@app/hooks/useIsTouch'; +import { Transition } from '@headlessui/react'; +import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'; +import { MediaStatus } from '@server/constants/media'; +import Link from 'next/link'; +import { Fragment, useState } from 'react'; + +interface BookTitleCardProps { + foreignBookId: string; + mediaType: 'audiobook' | 'ebook'; + image?: string; + title: string; + author?: string; + year?: string; + rating?: number | null; + summary?: string; + status?: MediaStatus; + canExpand?: boolean; +} + +const BookTitleCard = ({ + foreignBookId, + mediaType, + image, + title, + author, + year, + rating, + summary, + status, + canExpand = false, +}: BookTitleCardProps) => { + const isTouch = useIsTouch(); + const [showDetail, setShowDetail] = useState(false); + const [showRequestModal, setShowRequestModal] = useState(false); + const isAudio = mediaType === 'audiobook'; + const detailHref = `/${isAudio ? 'audiobooks' : 'ebooks'}/${foreignBookId}`; + const showRequestButton = + !status || status === MediaStatus.UNKNOWN || status === MediaStatus.DELETED; + + return ( +
+ setShowRequestModal(false)} + onComplete={() => setShowRequestModal(false)} + /> +
{ + if (!isTouch) setShowDetail(true); + }} + onMouseLeave={() => setShowDetail(false)} + onClick={() => setShowDetail(true)} + onKeyDown={(e) => { + if (e.key === 'Enter') setShowDetail(true); + }} + role="link" + tabIndex={0} + > +
+ {image ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ )} + +
+
+
+ {isAudio ? 'Audiobook' : 'Ebook'} +
+
+ {status && status !== MediaStatus.UNKNOWN && ( +
+ +
+ )} +
+ + +
+ +
+
+ {year &&
{year}
} +

+ {title} +

+ {author && ( +
+ {author} + {rating ? ` · ★ ${rating.toFixed(1)}` : ''} +
+ )} + {summary && ( +
+ {summary} +
+ )} +
+
+ + {showRequestButton && ( +
+ +
+ )} +
+
+
+
+
+ ); +}; + +export default BookTitleCard; diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index ed09bb6344..26e6b61546 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -58,6 +58,8 @@ const messages = defineMessages( streamingRegionTip: 'Show streaming sites by regional availability', movierequestlimit: 'Movie Request Limit', seriesrequestlimit: 'Series Request Limit', + audiobookrequestlimit: 'Audiobook Request Limit', + ebookrequestlimit: 'Ebook Request Limit', enableOverride: 'Override Global Limit', applanguage: 'Display Language', languageDefault: 'Default ({language})', @@ -73,6 +75,15 @@ const messages = defineMessages( plexwatchlistsyncseries: 'Auto-Request Series', plexwatchlistsyncseriestip: 'Automatically request series on your Plex Watchlist', + hardcoverUsername: 'Hardcover Username', + hardcoverUsernameTip: + 'Your username on hardcover.app (used to read your Want-to-Read list).', + autoRequestAudiobooks: 'Auto-Request Audiobooks', + autoRequestAudiobooksTip: + 'Automatically request audiobooks on your Hardcover Want-to-Read list.', + autoRequestEbooks: 'Auto-Request Ebooks', + autoRequestEbooksTip: + 'Automatically request ebooks on your Hardcover Want-to-Read list.', } ); @@ -82,6 +93,8 @@ const UserGeneralSettings = () => { const { locale, setLocale } = useLocale(); const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false); const [tvQuotaEnabled, setTvQuotaEnabled] = useState(false); + const [audiobookQuotaEnabled, setAudiobookQuotaEnabled] = useState(false); + const [ebookQuotaEnabled, setEbookQuotaEnabled] = useState(false); const router = useRouter(); const { user, @@ -131,6 +144,13 @@ const UserGeneralSettings = () => { setTvQuotaEnabled( data?.tvQuotaLimit != undefined && data?.tvQuotaDays != undefined ); + setAudiobookQuotaEnabled( + data?.audiobookQuotaLimit != undefined && + data?.audiobookQuotaDays != undefined + ); + setEbookQuotaEnabled( + data?.ebookQuotaLimit != undefined && data?.ebookQuotaDays != undefined + ); }, [data]); if (!data && !error) { @@ -167,8 +187,15 @@ const UserGeneralSettings = () => { movieQuotaDays: data?.movieQuotaDays, tvQuotaLimit: data?.tvQuotaLimit, tvQuotaDays: data?.tvQuotaDays, + audiobookQuotaLimit: data?.audiobookQuotaLimit, + audiobookQuotaDays: data?.audiobookQuotaDays, + ebookQuotaLimit: data?.ebookQuotaLimit, + ebookQuotaDays: data?.ebookQuotaDays, watchlistSyncMovies: data?.watchlistSyncMovies, watchlistSyncTv: data?.watchlistSyncTv, + hardcoverUsername: data?.hardcoverUsername ?? '', + autoRequestAudiobooks: data?.autoRequestAudiobooks ?? false, + autoRequestEbooks: data?.autoRequestEbooks ?? false, }} validationSchema={UserGeneralSettingsSchema} enableReinitialize @@ -189,8 +216,21 @@ const UserGeneralSettings = () => { movieQuotaDays: movieQuotaEnabled ? values.movieQuotaDays : null, tvQuotaLimit: tvQuotaEnabled ? values.tvQuotaLimit : null, tvQuotaDays: tvQuotaEnabled ? values.tvQuotaDays : null, + audiobookQuotaLimit: audiobookQuotaEnabled + ? values.audiobookQuotaLimit + : null, + audiobookQuotaDays: audiobookQuotaEnabled + ? values.audiobookQuotaDays + : null, + ebookQuotaLimit: ebookQuotaEnabled + ? values.ebookQuotaLimit + : null, + ebookQuotaDays: ebookQuotaEnabled ? values.ebookQuotaDays : null, watchlistSyncMovies: values.watchlistSyncMovies, watchlistSyncTv: values.watchlistSyncTv, + hardcoverUsername: values.hardcoverUsername || null, + autoRequestAudiobooks: !!values.autoRequestAudiobooks, + autoRequestEbooks: !!values.autoRequestEbooks, }); if (currentUser?.id === user?.id && setLocale) { @@ -540,6 +580,71 @@ const UserGeneralSettings = () => {

+
+ +
+
+
+ + 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/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'} /> ))} /> 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", 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/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/[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; 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;