From 6acfe9e4f00d235e5d65433545af7e876ad54ec0 Mon Sep 17 00:00:00 2001 From: Juan Chong Date: Mon, 9 Feb 2026 12:49:43 -0800 Subject: [PATCH 1/4] feat: add Readarr integration for managed downloads Add optional Readarr support so ABR can add books to Readarr and trigger indexer searches, letting Readarr handle the full download/import/rename pipeline. When configured, ABR tries Readarr first and falls back to the direct Prowlarr download path on failure. Uses the /api/v1/search endpoint (same as pyarr) which returns book results with the full author object, avoiding the need for a separate author lookup. Books are matched by title and author name to handle cases where multiple books share the same title. New settings tab under Settings > Readarr for base URL, API key, quality/metadata profile, root folder, and search-on-add toggle. Co-authored-by: Cursor --- README.md | 28 ++- app/internal/query.py | 56 +++-- app/internal/readarr/__init__.py | 0 app/internal/readarr/client.py | 305 +++++++++++++++++++++++++ app/internal/readarr/config.py | 84 +++++++ app/routers/api/requests.py | 61 +++-- app/routers/api/settings/__init__.py | 2 + app/routers/api/settings/readarr.py | 174 ++++++++++++++ app/routers/pages/settings/__init__.py | 2 + app/routers/pages/settings/readarr.py | 131 +++++++++++ templates/layouts/SettingsLayout.jinja | 4 + templates/pages/Settings/Readarr.jinja | 159 +++++++++++++ 12 files changed, 973 insertions(+), 33 deletions(-) create mode 100644 app/internal/readarr/__init__.py create mode 100644 app/internal/readarr/client.py create mode 100644 app/internal/readarr/config.py create mode 100644 app/routers/api/settings/readarr.py create mode 100644 app/routers/pages/settings/readarr.py create mode 100644 templates/pages/Settings/Readarr.jinja diff --git a/README.md b/README.md index 45a3a90d..7728db43 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ If you've heard of Overseer, Ombi, or Jellyseer; this is in the similar vein, General and copy the **API Key**. +3. In ABR, go to Settings > Readarr and enter: + - **Base URL** of your Readarr instance (e.g. `http://readarr:8787`) + - **API Key** from step 2 + - Select a **Quality Profile**, **Metadata Profile**, and **Root Folder** + - Enable **Search for book on add** if you want Readarr to immediately search indexers after adding the book + +Notes: + +- When Readarr is configured, ABR tries Readarr first for all downloads. If the Readarr add/search fails for any reason, ABR falls back to the direct Prowlarr download path automatically. +- ABR matches books from Audible to Readarr's metadata by title and author. If no match is found in Readarr's metadata, the download falls back to Prowlarr. +- Readarr's add-book operation can take 30 seconds to several minutes for new authors, since it fetches metadata from upstream providers. This runs as a background task — users see an immediate response. + ### OpenID Connect Head to the [OpenID Connect](/wiki/OpenID-Connect) page in the wiki to learn how to set up OIDC authentication with your favorite auth provider. @@ -143,4 +168,5 @@ AudioBookRequest builds on top of a some other great open-source tools. A big th - [Audimeta](https://github.com/Vito0912/AudiMeta) - Main audiobook metadata provider. Active in development and quick to fix issues. - [Audnexus](https://github.com/laxamentumtech/audnexus) - Backup audiobook metadata provider. - [Prowlarr](https://github.com/Prowlarr/Prowlarr) - Does a lot of the heavy lifting concerning searching through indexers and forwarding download requests to download clients. Saves me the ordeal of having to reimplement everything again. +- [Readarr](https://github.com/Readarr/Readarr) - Book management and download automation. ABR can optionally add books to Readarr and let it handle the full download-to-library pipeline. - [External Audible API](https://audible.readthedocs.io/en/latest/misc/external_api.html) - Audible exposes key API endpoints which are used to, for example, search for books. diff --git a/app/internal/query.py b/app/internal/query.py index 4c548ef8..6f9268a6 100644 --- a/app/internal/query.py +++ b/app/internal/query.py @@ -14,7 +14,10 @@ from app.internal.prowlarr.prowlarr import query_prowlarr, start_download from app.internal.prowlarr.util import prowlarr_config from app.internal.ranking.download_ranking import rank_sources +from app.internal.readarr.client import readarr_add_and_search +from app.internal.readarr.config import ReadarrMisconfigured, readarr_config from app.util.db import get_session +from app.util.log import logger querying: set[str] = set() @@ -84,16 +87,45 @@ async def query_sources( # start download if requested if start_auto_download and not book.downloaded and len(ranked) > 0: - resp = await start_download( - session=session, - client_session=client_session, - guid=ranked[0].guid, - indexer_id=ranked[0].indexer_id, - requester=requester, - book_asin=asin, - prowlarr_source=ranked[0], - ) - if resp.ok: + download_success = False + + # Try Readarr first if configured + if readarr_config.is_valid(session): + try: + download_success = await readarr_add_and_search( + session, client_session, book + ) + if not download_success: + logger.warning( + "Readarr add+search returned False, falling back to Prowlarr", + asin=asin, + ) + except ReadarrMisconfigured: + logger.warning("Readarr misconfigured, falling back to Prowlarr", asin=asin) + except Exception as e: + logger.error( + "Readarr add+search failed, falling back to Prowlarr", + asin=asin, + error=str(e), + ) + + # Fallback to Prowlarr direct download + if not download_success: + resp = await start_download( + session=session, + client_session=client_session, + guid=ranked[0].guid, + indexer_id=ranked[0].indexer_id, + requester=requester, + book_asin=asin, + prowlarr_source=ranked[0], + ) + if resp.ok: + download_success = True + else: + raise HTTPException(status_code=500, detail="Failed to start download") + + if download_success: same_books = session.exec( select(Audiobook).where(Audiobook.asin == asin) ).all() @@ -107,8 +139,6 @@ async def query_sources( await abs_trigger_scan(session, client_session) except Exception: pass - else: - raise HTTPException(status_code=500, detail="Failed to start download") return QueryResult( sources=ranked, @@ -119,7 +149,7 @@ async def query_sources( async def background_start_query(asin: str, requester: User, auto_download: bool): with next(get_session()) as session: - async with ClientSession(timeout=aiohttp.ClientTimeout(60)) as client_session: + async with ClientSession(timeout=aiohttp.ClientTimeout(300)) as client_session: await query_sources( asin=asin, session=session, diff --git a/app/internal/readarr/__init__.py b/app/internal/readarr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/internal/readarr/client.py b/app/internal/readarr/client.py new file mode 100644 index 00000000..72439769 --- /dev/null +++ b/app/internal/readarr/client.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import re +from typing import Any + +import aiohttp +from aiohttp import ClientSession +from sqlmodel import Session + +from app.internal.models import Audiobook +from app.internal.readarr.config import readarr_config +from app.util.connection import USER_AGENT +from app.util.db import get_session +from app.util.log import logger + +# Extended timeout for Readarr API calls — metadata fetches from Hardcover +# can take 30-180 seconds for new books/authors. +READARR_TIMEOUT = aiohttp.ClientTimeout(total=180) + + +def _headers(session: Session) -> dict[str, str]: + api_key = readarr_config.get_api_key(session) + assert api_key is not None + return {"X-Api-Key": api_key, "User-Agent": USER_AGENT} + + +def _normalize(s: str) -> str: + """Lowercase, strip punctuation, collapse whitespace.""" + s = s.lower().strip() + s = re.sub(r"[^a-z0-9]+", " ", s) + return re.sub(r"\s+", " ", s).strip() + + +# --------------------------------------------------------------------------- +# Core search + add flow +# --------------------------------------------------------------------------- + + +async def _search( + session: Session, client_session: ClientSession, title: str +) -> list[dict[str, Any]]: + """GET /api/v1/search — returns mixed author/book results with full author nested in books.""" + base_url = readarr_config.get_base_url(session) + if not base_url: + return [] + url = f"{base_url}/api/v1/search" + try: + async with client_session.get( + url, headers=_headers(session), params={"term": title}, timeout=READARR_TIMEOUT + ) as resp: + if not resp.ok: + logger.error( + "Readarr: search failed", + status=resp.status, + reason=resp.reason, + ) + return [] + return await resp.json() + except Exception as e: + logger.error("Readarr: exception during search", error=str(e)) + return [] + + +async def _add_book( + session: Session, client_session: ClientSession, book_data: dict[str, Any] +) -> bool: + """POST /api/v1/book — add a book (and its author if new) to Readarr.""" + base_url = readarr_config.get_base_url(session) + if not base_url: + return False + url = f"{base_url}/api/v1/book" + try: + async with client_session.post( + url, headers=_headers(session), json=book_data, timeout=READARR_TIMEOUT + ) as resp: + if not resp.ok: + body = await resp.text() + logger.error( + "Readarr: add book failed", + status=resp.status, + reason=resp.reason, + body=body[:500], + ) + return False + logger.info( + "Readarr: book added successfully", + title=book_data.get("title"), + foreignBookId=book_data.get("foreignBookId"), + ) + return True + except Exception as e: + logger.error("Readarr: exception adding book", error=str(e)) + return False + + +async def _trigger_book_search( + session: Session, client_session: ClientSession, book_id: int +) -> bool: + """POST /api/v1/command — trigger a BookSearch for an existing book.""" + base_url = readarr_config.get_base_url(session) + if not base_url: + return False + url = f"{base_url}/api/v1/command" + payload = {"name": "BookSearch", "bookIds": [book_id]} + try: + async with client_session.post( + url, headers=_headers(session), json=payload, timeout=READARR_TIMEOUT + ) as resp: + if not resp.ok: + logger.error( + "Readarr: trigger search failed", + status=resp.status, + reason=resp.reason, + ) + return False + logger.info("Readarr: search triggered", book_id=book_id) + return True + except Exception as e: + logger.error("Readarr: exception triggering search", error=str(e)) + return False + + +def _author_matches( + book: dict[str, Any], norm_authors: set[str] +) -> bool: + """Check if the book's author matches any of the expected authors.""" + author_name = _normalize(book.get("author", {}).get("authorName", "")) + if not author_name or not norm_authors: + return False + return any(a in author_name or author_name in a for a in norm_authors) + + +def _find_best_book_match( + results: list[dict[str, Any]], audiobook: Audiobook +) -> dict[str, Any] | None: + """Find the best matching book result from mixed search results. + + Multiple books can share the same title (e.g. "Talking to Strangers" by + Malcolm Gladwell vs Marianne Boucher), so author matching is ALWAYS + required alongside title matching. + """ + norm_title = _normalize(audiobook.title) + norm_authors = {_normalize(a) for a in audiobook.authors} + + # Filter to book results only (skip author-only results) + book_results = [r for r in results if "book" in r and r["book"]] + + # Pass 1: exact title + author match + for result in book_results: + book = result["book"] + if _normalize(book.get("title", "")) == norm_title and _author_matches(book, norm_authors): + return result + + # Pass 2: title containment (either direction) + author match + # Handles "Talking to Strangers" matching "Talking to Strangers: What We Should Know..." + for result in book_results: + book = result["book"] + result_title = _normalize(book.get("title", "")) + if (norm_title in result_title or result_title in norm_title) and _author_matches(book, norm_authors): + return result + + # Pass 3: author match only (title may differ significantly between Audible and Hardcover) + for result in book_results: + book = result["book"] + if _author_matches(book, norm_authors): + return result + + # Pass 4: last resort — exact title match without author (only if we have no author data) + if not norm_authors: + for result in book_results: + book = result["book"] + if _normalize(book.get("title", "")) == norm_title: + return result + + return None + + +async def readarr_add_and_search( + session: Session, client_session: ClientSession, audiobook: Audiobook +) -> bool: + """ + Main entry point: search Readarr for the book, add it if missing, and + trigger an indexer search. Returns True on success. + """ + readarr_config.raise_if_invalid(session) + + # Step 1: Single search call — returns books with full author nested + results = await _search(session, client_session, audiobook.title) + if not results: + logger.warning("Readarr: no search results", title=audiobook.title) + return False + + # Step 2: Find best matching book result + book_match = _find_best_book_match(results, audiobook) + if not book_match: + logger.warning("Readarr: no matching book", title=audiobook.title) + return False + + book = book_match["book"] + + # Step 3: If book already in Readarr, just trigger search + if book.get("id") and book["id"] > 0: + logger.info("Readarr: book exists, triggering search", book_id=book["id"]) + return await _trigger_book_search(session, client_session, book["id"]) + + # Step 4: Add book — set config fields on the author object (pyarr pattern) + author = book.get("author") + if not author or not author.get("foreignAuthorId"): + logger.error( + "Readarr: search result missing author data", + title=book.get("title"), + ) + return False + + author["qualityProfileId"] = readarr_config.get_quality_profile_id(session) + author["metadataProfileId"] = readarr_config.get_metadata_profile_id(session) + author["rootFolderPath"] = readarr_config.get_root_folder_path(session) + author["monitored"] = True + author["addOptions"] = { + "monitor": "all", + "searchForMissingBooks": False, + } + + book["monitored"] = True + book["addOptions"] = { + "searchForNewBook": readarr_config.get_search_on_add(session), + } + + return await _add_book(session, client_session, book) + + +async def background_readarr_add_and_search(asin: str) -> None: + """Background task wrapper for readarr_add_and_search.""" + with next(get_session()) as session: + async with ClientSession( + timeout=aiohttp.ClientTimeout(total=300) + ) as client_session: + audiobook = session.get(Audiobook, asin) + if not audiobook: + logger.warning("Readarr background: book not found", asin=asin) + return + logger.info("Readarr background: starting add+search", asin=asin) + success = await readarr_add_and_search(session, client_session, audiobook) + logger.info( + "Readarr background: complete", asin=asin, success=success + ) + + +# --------------------------------------------------------------------------- +# Settings helpers — used by the settings page to populate dropdowns +# --------------------------------------------------------------------------- + + +async def readarr_get_quality_profiles( + session: Session, client_session: ClientSession +) -> list[dict[str, Any]]: + base_url = readarr_config.get_base_url(session) + if not base_url: + return [] + url = f"{base_url}/api/v1/qualityprofile" + try: + async with client_session.get( + url, headers=_headers(session), timeout=READARR_TIMEOUT + ) as resp: + if not resp.ok: + return [] + return await resp.json() + except Exception: + return [] + + +async def readarr_get_metadata_profiles( + session: Session, client_session: ClientSession +) -> list[dict[str, Any]]: + base_url = readarr_config.get_base_url(session) + if not base_url: + return [] + url = f"{base_url}/api/v1/metadataprofile" + try: + async with client_session.get( + url, headers=_headers(session), timeout=READARR_TIMEOUT + ) as resp: + if not resp.ok: + return [] + return await resp.json() + except Exception: + return [] + + +async def readarr_get_root_folders( + session: Session, client_session: ClientSession +) -> list[dict[str, Any]]: + base_url = readarr_config.get_base_url(session) + if not base_url: + return [] + url = f"{base_url}/api/v1/rootfolder" + try: + async with client_session.get( + url, headers=_headers(session), timeout=READARR_TIMEOUT + ) as resp: + if not resp.ok: + return [] + return await resp.json() + except Exception: + return [] diff --git a/app/internal/readarr/config.py b/app/internal/readarr/config.py new file mode 100644 index 00000000..51154537 --- /dev/null +++ b/app/internal/readarr/config.py @@ -0,0 +1,84 @@ +from typing import Literal, Optional + +from sqlmodel import Session + +from app.util.cache import StringConfigCache + + +class ReadarrMisconfigured(ValueError): + pass + + +ReadarrConfigKey = Literal[ + "readarr_base_url", + "readarr_api_key", + "readarr_quality_profile_id", + "readarr_metadata_profile_id", + "readarr_root_folder_path", + "readarr_search_on_add", +] + + +class ReadarrConfig(StringConfigCache[ReadarrConfigKey]): + def is_valid(self, session: Session) -> bool: + return ( + self.get_base_url(session) is not None + and self.get_api_key(session) is not None + and self.get_quality_profile_id(session) is not None + and self.get_metadata_profile_id(session) is not None + and self.get_root_folder_path(session) is not None + ) + + def raise_if_invalid(self, session: Session): + if not self.get_base_url(session): + raise ReadarrMisconfigured("Readarr base URL not set") + if not self.get_api_key(session): + raise ReadarrMisconfigured("Readarr API key not set") + if not self.get_quality_profile_id(session): + raise ReadarrMisconfigured("Readarr quality profile not set") + if not self.get_metadata_profile_id(session): + raise ReadarrMisconfigured("Readarr metadata profile not set") + if not self.get_root_folder_path(session): + raise ReadarrMisconfigured("Readarr root folder path not set") + + def get_base_url(self, session: Session) -> Optional[str]: + path = self.get(session, "readarr_base_url") + if path: + return path.rstrip("/") + return None + + def set_base_url(self, session: Session, base_url: str): + self.set(session, "readarr_base_url", base_url) + + def get_api_key(self, session: Session) -> Optional[str]: + return self.get(session, "readarr_api_key") + + def set_api_key(self, session: Session, api_key: str): + self.set(session, "readarr_api_key", api_key) + + def get_quality_profile_id(self, session: Session) -> Optional[int]: + return self.get_int(session, "readarr_quality_profile_id") + + def set_quality_profile_id(self, session: Session, profile_id: int): + self.set_int(session, "readarr_quality_profile_id", profile_id) + + def get_metadata_profile_id(self, session: Session) -> Optional[int]: + return self.get_int(session, "readarr_metadata_profile_id") + + def set_metadata_profile_id(self, session: Session, profile_id: int): + self.set_int(session, "readarr_metadata_profile_id", profile_id) + + def get_root_folder_path(self, session: Session) -> Optional[str]: + return self.get(session, "readarr_root_folder_path") + + def set_root_folder_path(self, session: Session, path: str): + self.set(session, "readarr_root_folder_path", path) + + def get_search_on_add(self, session: Session) -> bool: + return bool(self.get_bool(session, "readarr_search_on_add") or False) + + def set_search_on_add(self, session: Session, enabled: bool): + self.set_bool(session, "readarr_search_on_add", enabled) + + +readarr_config = ReadarrConfig() diff --git a/app/routers/api/requests.py b/app/routers/api/requests.py index 35efefcf..d2541dfa 100644 --- a/app/routers/api/requests.py +++ b/app/routers/api/requests.py @@ -40,6 +40,8 @@ from app.internal.prowlarr.prowlarr import start_download from app.internal.prowlarr.util import ProwlarrMisconfigured, prowlarr_config from app.internal.query import QueryResult, background_start_query, query_sources +from app.internal.readarr.client import readarr_add_and_search +from app.internal.readarr.config import ReadarrMisconfigured, readarr_config from app.internal.ranking.quality import quality_config from app.util.connection import get_connection from app.util.db import get_session @@ -338,25 +340,46 @@ async def download_book( client_session: Annotated[ClientSession, Depends(get_connection)], admin_user: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], ): - try: - resp = await start_download( - session=session, - client_session=client_session, - guid=body.guid, - indexer_id=body.indexer_id, - requester=admin_user, - book_asin=asin, - ) - except ProwlarrMisconfigured as e: - raise HTTPException(status_code=500, detail=str(e)) - if not resp.ok: - raise HTTPException(status_code=500, detail="Failed to start download") - - book = session.exec(select(Audiobook).where(Audiobook.asin == asin)).first() - if book: - book.downloaded = True - session.add(book) - session.commit() + download_success = False + + # Try Readarr first if configured + if readarr_config.is_valid(session): + book = session.exec(select(Audiobook).where(Audiobook.asin == asin)).first() + if book: + try: + download_success = await readarr_add_and_search( + session, client_session, book + ) + except (ReadarrMisconfigured, Exception) as e: + logger.warning( + "Readarr download failed, falling back to Prowlarr", + asin=asin, + error=str(e), + ) + + # Fallback to Prowlarr direct download + if not download_success: + try: + resp = await start_download( + session=session, + client_session=client_session, + guid=body.guid, + indexer_id=body.indexer_id, + requester=admin_user, + book_asin=asin, + ) + except ProwlarrMisconfigured as e: + raise HTTPException(status_code=500, detail=str(e)) + if not resp.ok: + raise HTTPException(status_code=500, detail="Failed to start download") + download_success = True + + if download_success: + book = session.exec(select(Audiobook).where(Audiobook.asin == asin)).first() + if book: + book.downloaded = True + session.add(book) + session.commit() if abs_config.is_valid(session): background_task.add_task(background_abs_trigger_scan) diff --git a/app/routers/api/settings/__init__.py b/app/routers/api/settings/__init__.py index 2df734a6..a4cacd03 100644 --- a/app/routers/api/settings/__init__.py +++ b/app/routers/api/settings/__init__.py @@ -5,6 +5,7 @@ from app.routers.api.settings.download import router as download_router from app.routers.api.settings.notifications import router as notifications_router from app.routers.api.settings.prowlarr import router as prowlarr_router +from app.routers.api.settings.readarr import router as readarr_router from app.routers.api.settings.security import router as security_router router = APIRouter(prefix="/settings", tags=["Settings"]) @@ -14,4 +15,5 @@ router.include_router(download_router) router.include_router(notifications_router) router.include_router(prowlarr_router) +router.include_router(readarr_router) router.include_router(security_router) diff --git a/app/routers/api/settings/readarr.py b/app/routers/api/settings/readarr.py new file mode 100644 index 00000000..f936b33f --- /dev/null +++ b/app/routers/api/settings/readarr.py @@ -0,0 +1,174 @@ +from typing import Annotated, Any + +from aiohttp import ClientSession +from fastapi import APIRouter, Depends, Form, HTTPException, Response, Security +from pydantic import BaseModel +from sqlmodel import Session + +from app.internal.auth.authentication import AnyAuth, DetailedUser +from app.internal.models import GroupEnum +from app.internal.readarr.client import ( + readarr_get_metadata_profiles, + readarr_get_quality_profiles, + readarr_get_root_folders, +) +from app.internal.readarr.config import readarr_config +from app.util.connection import get_connection +from app.util.db import get_session +from app.util.log import logger + +router = APIRouter(prefix="/readarr") + + +class ReadarrResponse(BaseModel): + readarr_base_url: str + readarr_api_key: str + readarr_quality_profile_id: int | None + readarr_metadata_profile_id: int | None + readarr_root_folder_path: str + readarr_search_on_add: bool + quality_profiles: list[dict[str, Any]] + metadata_profiles: list[dict[str, Any]] + root_folders: list[dict[str, Any]] + + +@router.get("") +async def read_readarr( + session: Annotated[Session, Depends(get_session)], + client_session: Annotated[ClientSession, Depends(get_connection)], + admin_user: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], +): + _ = admin_user + base_url = readarr_config.get_base_url(session) or "" + api_key = readarr_config.get_api_key(session) or "" + quality_profile_id = readarr_config.get_quality_profile_id(session) + metadata_profile_id = readarr_config.get_metadata_profile_id(session) + root_folder_path = readarr_config.get_root_folder_path(session) or "" + search_on_add = readarr_config.get_search_on_add(session) + + quality_profiles: list[dict[str, Any]] = [] + metadata_profiles: list[dict[str, Any]] = [] + root_folders: list[dict[str, Any]] = [] + if base_url and api_key: + quality_profiles = await readarr_get_quality_profiles(session, client_session) + metadata_profiles = await readarr_get_metadata_profiles(session, client_session) + root_folders = await readarr_get_root_folders(session, client_session) + + return ReadarrResponse( + readarr_base_url=base_url, + readarr_api_key=api_key, + readarr_quality_profile_id=quality_profile_id, + readarr_metadata_profile_id=metadata_profile_id, + readarr_root_folder_path=root_folder_path, + readarr_search_on_add=search_on_add, + quality_profiles=quality_profiles, + metadata_profiles=metadata_profiles, + root_folders=root_folders, + ) + + +@router.put("/base-url") +def update_readarr_base_url( + base_url: Annotated[str, Form()], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], +): + _ = admin_user + readarr_config.set_base_url(session, base_url) + return Response(status_code=204) + + +@router.put("/api-key") +def update_readarr_api_key( + api_key: Annotated[str, Form(alias="api_key")], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], +): + _ = admin_user + readarr_config.set_api_key(session, api_key) + return Response(status_code=204) + + +@router.put("/quality-profile") +def update_readarr_quality_profile( + quality_profile_id: Annotated[int, Form(alias="quality_profile_id")], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], +): + _ = admin_user + readarr_config.set_quality_profile_id(session, quality_profile_id) + return Response(status_code=204) + + +@router.put("/metadata-profile") +def update_readarr_metadata_profile( + metadata_profile_id: Annotated[int, Form(alias="metadata_profile_id")], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], +): + _ = admin_user + readarr_config.set_metadata_profile_id(session, metadata_profile_id) + return Response(status_code=204) + + +@router.put("/root-folder") +def update_readarr_root_folder( + root_folder_path: Annotated[str, Form(alias="root_folder_path")], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], +): + _ = admin_user + readarr_config.set_root_folder_path(session, root_folder_path) + return Response(status_code=204) + + +@router.put("/search-on-add") +def update_readarr_search_on_add( + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], + search_on_add: Annotated[bool, Form()] = False, +): + _ = admin_user + readarr_config.set_search_on_add(session, search_on_add) + return Response(status_code=204) + + +class ReadarrTestResponse(BaseModel): + quality_profile_count: int + metadata_profile_count: int + root_folder_count: int + + +@router.get("/test-connection") +async def test_readarr_connection( + session: Annotated[Session, Depends(get_session)], + client_session: Annotated[ClientSession, Depends(get_connection)], + _: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], +): + base_url = readarr_config.get_base_url(session) + api_key = readarr_config.get_api_key(session) + if not base_url or not api_key: + raise HTTPException( + status_code=400, detail="Readarr base URL and API key are required" + ) + + quality_profiles = await readarr_get_quality_profiles(session, client_session) + if not quality_profiles: + raise HTTPException( + status_code=400, detail="Failed to connect to Readarr — could not fetch quality profiles" + ) + metadata_profiles = await readarr_get_metadata_profiles(session, client_session) + root_folders = await readarr_get_root_folders(session, client_session) + + logger.info( + "Readarr: test connection successful", + quality_profiles=len(quality_profiles), + metadata_profiles=len(metadata_profiles), + root_folders=len(root_folders), + ) + + return ReadarrTestResponse( + quality_profile_count=len(quality_profiles), + metadata_profile_count=len(metadata_profiles), + root_folder_count=len(root_folders), + ) diff --git a/app/routers/pages/settings/__init__.py b/app/routers/pages/settings/__init__.py index df88ca1e..036dbd25 100644 --- a/app/routers/pages/settings/__init__.py +++ b/app/routers/pages/settings/__init__.py @@ -7,6 +7,7 @@ indexers, notification, prowlarr, + readarr, security, users, ) @@ -19,5 +20,6 @@ router.include_router(indexers.router) router.include_router(notification.router) router.include_router(prowlarr.router) +router.include_router(readarr.router) router.include_router(security.router) router.include_router(users.router) diff --git a/app/routers/pages/settings/readarr.py b/app/routers/pages/settings/readarr.py new file mode 100644 index 00000000..cd43a30b --- /dev/null +++ b/app/routers/pages/settings/readarr.py @@ -0,0 +1,131 @@ +from typing import Annotated + +from aiohttp import ClientSession +from fastapi import APIRouter, Depends, Form, Response, Security +from sqlmodel import Session + +from app.internal.auth.authentication import ABRAuth, DetailedUser +from app.internal.models import GroupEnum +from app.routers.api.settings.readarr import ( + read_readarr as api_read_readarr, +) +from app.routers.api.settings.readarr import ( + update_readarr_api_key as api_update_readarr_api_key, +) +from app.routers.api.settings.readarr import ( + update_readarr_base_url as api_update_readarr_base_url, +) +from app.routers.api.settings.readarr import ( + update_readarr_metadata_profile as api_update_readarr_metadata_profile, +) +from app.routers.api.settings.readarr import ( + update_readarr_quality_profile as api_update_readarr_quality_profile, +) +from app.routers.api.settings.readarr import ( + update_readarr_root_folder as api_update_readarr_root_folder, +) +from app.routers.api.settings.readarr import ( + update_readarr_search_on_add as api_update_readarr_search_on_add, +) +from app.util.connection import get_connection +from app.util.db import get_session +from app.util.templates import catalog_response + +router = APIRouter(prefix="/readarr") + + +@router.get("") +async def read_readarr( + session: Annotated[Session, Depends(get_session)], + client_session: Annotated[ClientSession, Depends(get_connection)], + admin_user: Annotated[DetailedUser, Security(ABRAuth(GroupEnum.admin))], +): + response = await api_read_readarr( + session=session, + client_session=client_session, + admin_user=admin_user, + ) + + return catalog_response( + "Settings.Readarr", + user=admin_user, + readarr_base_url=response.readarr_base_url, + readarr_api_key=response.readarr_api_key, + readarr_quality_profile_id=response.readarr_quality_profile_id, + readarr_metadata_profile_id=response.readarr_metadata_profile_id, + readarr_root_folder_path=response.readarr_root_folder_path, + readarr_search_on_add=response.readarr_search_on_add, + quality_profiles=response.quality_profiles, + metadata_profiles=response.metadata_profiles, + root_folders=response.root_folders, + ) + + +@router.put("/hx-base-url") +def update_readarr_base_url( + base_url: Annotated[str, Form()], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[DetailedUser, Security(ABRAuth(GroupEnum.admin))], +): + api_update_readarr_base_url(base_url=base_url, session=session, admin_user=admin_user) + return Response(status_code=204, headers={"HX-Refresh": "true"}) + + +@router.put("/hx-api-key") +def update_readarr_api_key( + api_key: Annotated[str, Form(alias="api_key")], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[DetailedUser, Security(ABRAuth(GroupEnum.admin))], +): + api_update_readarr_api_key(api_key=api_key, session=session, admin_user=admin_user) + return Response(status_code=204, headers={"HX-Refresh": "true"}) + + +@router.put("/hx-quality-profile") +def update_readarr_quality_profile( + quality_profile_id: Annotated[int, Form(alias="quality_profile_id")], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[DetailedUser, Security(ABRAuth(GroupEnum.admin))], +): + api_update_readarr_quality_profile( + quality_profile_id=quality_profile_id, session=session, admin_user=admin_user + ) + return Response(status_code=204, headers={"HX-Refresh": "true"}) + + +@router.put("/hx-metadata-profile") +def update_readarr_metadata_profile( + metadata_profile_id: Annotated[int, Form(alias="metadata_profile_id")], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[DetailedUser, Security(ABRAuth(GroupEnum.admin))], +): + api_update_readarr_metadata_profile( + metadata_profile_id=metadata_profile_id, session=session, admin_user=admin_user + ) + return Response(status_code=204, headers={"HX-Refresh": "true"}) + + +@router.put("/hx-root-folder") +def update_readarr_root_folder( + root_folder_path: Annotated[str, Form(alias="root_folder_path")], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[DetailedUser, Security(ABRAuth(GroupEnum.admin))], +): + api_update_readarr_root_folder( + root_folder_path=root_folder_path, session=session, admin_user=admin_user + ) + return Response(status_code=204, headers={"HX-Refresh": "true"}) + + +@router.put("/hx-search-on-add") +def update_readarr_search_on_add( + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[DetailedUser, Security(ABRAuth(GroupEnum.admin))], + search_on_add: Annotated[bool, Form()] = False, +): + api_update_readarr_search_on_add( + session=session, + admin_user=admin_user, + search_on_add=search_on_add, + ) + return Response(status_code=204, headers={"HX-Refresh": "true"}) diff --git a/templates/layouts/SettingsLayout.jinja b/templates/layouts/SettingsLayout.jinja index 2dc8f292..8d6de3aa 100644 --- a/templates/layouts/SettingsLayout.jinja +++ b/templates/layouts/SettingsLayout.jinja @@ -43,6 +43,10 @@ href="{{ base_url }}/settings/audiobookshelf" role="tab" class="tab {% if page=='audiobookshelf' %}tab-active{% endif %}">Audiobookshelf + Readarr + +
+

+ Readarr +

+

+ Connect to Readarr to add books and trigger searches. Readarr handles downloading, + importing, renaming, and organizing files into your Audiobookshelf library. +

+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ +

+ When enabled, Readarr will automatically search indexers for the book after adding it. + This triggers Readarr's full download pipeline (search, grab, download, import, rename). +

+
+
+ + From cbc73df8b6e69f72b87e6fb2b9566d4d5dc8a76e Mon Sep 17 00:00:00 2001 From: Juan Chong Date: Mon, 9 Feb 2026 13:28:33 -0800 Subject: [PATCH 2/4] ci: fix strict typing and formatting for readarr module Co-authored-by: Cursor --- app/internal/query.py | 8 +- app/internal/readarr/client.py | 145 +++++++++++++++----------- app/internal/readarr/types.py | 61 +++++++++++ app/routers/api/settings/readarr.py | 22 ++-- app/routers/pages/settings/readarr.py | 4 +- 5 files changed, 168 insertions(+), 72 deletions(-) create mode 100644 app/internal/readarr/types.py diff --git a/app/internal/query.py b/app/internal/query.py index 6f9268a6..436e8bbb 100644 --- a/app/internal/query.py +++ b/app/internal/query.py @@ -101,7 +101,9 @@ async def query_sources( asin=asin, ) except ReadarrMisconfigured: - logger.warning("Readarr misconfigured, falling back to Prowlarr", asin=asin) + logger.warning( + "Readarr misconfigured, falling back to Prowlarr", asin=asin + ) except Exception as e: logger.error( "Readarr add+search failed, falling back to Prowlarr", @@ -123,7 +125,9 @@ async def query_sources( if resp.ok: download_success = True else: - raise HTTPException(status_code=500, detail="Failed to start download") + raise HTTPException( + status_code=500, detail="Failed to start download" + ) if download_success: same_books = session.exec( diff --git a/app/internal/readarr/client.py b/app/internal/readarr/client.py index 72439769..13ab0e3f 100644 --- a/app/internal/readarr/client.py +++ b/app/internal/readarr/client.py @@ -1,14 +1,23 @@ from __future__ import annotations import re -from typing import Any import aiohttp from aiohttp import ClientSession +from pydantic import TypeAdapter from sqlmodel import Session from app.internal.models import Audiobook from app.internal.readarr.config import readarr_config +from app.internal.readarr.types import ( + ReadarrAuthorAddOptions, + ReadarrBook, + ReadarrBookAddOptions, + ReadarrMetadataProfile, + ReadarrQualityProfile, + ReadarrRootFolder, + ReadarrSearchResult, +) from app.util.connection import USER_AGENT from app.util.db import get_session from app.util.log import logger @@ -17,6 +26,11 @@ # can take 30-180 seconds for new books/authors. READARR_TIMEOUT = aiohttp.ClientTimeout(total=180) +_SearchResults = TypeAdapter(list[ReadarrSearchResult]) +_QualityProfiles = TypeAdapter(list[ReadarrQualityProfile]) +_MetadataProfiles = TypeAdapter(list[ReadarrMetadataProfile]) +_RootFolders = TypeAdapter(list[ReadarrRootFolder]) + def _headers(session: Session) -> dict[str, str]: api_key = readarr_config.get_api_key(session) @@ -38,15 +52,18 @@ def _normalize(s: str) -> str: async def _search( session: Session, client_session: ClientSession, title: str -) -> list[dict[str, Any]]: - """GET /api/v1/search — returns mixed author/book results with full author nested in books.""" +) -> list[ReadarrSearchResult]: + """GET /api/v1/search — returns mixed author/book results.""" base_url = readarr_config.get_base_url(session) if not base_url: return [] url = f"{base_url}/api/v1/search" try: async with client_session.get( - url, headers=_headers(session), params={"term": title}, timeout=READARR_TIMEOUT + url, + headers=_headers(session), + params={"term": title}, + timeout=READARR_TIMEOUT, ) as resp: if not resp.ok: logger.error( @@ -55,14 +72,14 @@ async def _search( reason=resp.reason, ) return [] - return await resp.json() + return _SearchResults.validate_python(await resp.json()) except Exception as e: logger.error("Readarr: exception during search", error=str(e)) return [] async def _add_book( - session: Session, client_session: ClientSession, book_data: dict[str, Any] + session: Session, client_session: ClientSession, book: ReadarrBook ) -> bool: """POST /api/v1/book — add a book (and its author if new) to Readarr.""" base_url = readarr_config.get_base_url(session) @@ -71,7 +88,10 @@ async def _add_book( url = f"{base_url}/api/v1/book" try: async with client_session.post( - url, headers=_headers(session), json=book_data, timeout=READARR_TIMEOUT + url, + headers=_headers(session), + json=book.model_dump(mode="json"), + timeout=READARR_TIMEOUT, ) as resp: if not resp.ok: body = await resp.text() @@ -84,8 +104,8 @@ async def _add_book( return False logger.info( "Readarr: book added successfully", - title=book_data.get("title"), - foreignBookId=book_data.get("foreignBookId"), + title=book.title, + foreignBookId=book.foreignBookId, ) return True except Exception as e: @@ -120,19 +140,19 @@ async def _trigger_book_search( return False -def _author_matches( - book: dict[str, Any], norm_authors: set[str] -) -> bool: +def _author_matches(book: ReadarrBook, norm_authors: set[str]) -> bool: """Check if the book's author matches any of the expected authors.""" - author_name = _normalize(book.get("author", {}).get("authorName", "")) + if not book.author: + return False + author_name = _normalize(book.author.authorName) if not author_name or not norm_authors: return False return any(a in author_name or author_name in a for a in norm_authors) def _find_best_book_match( - results: list[dict[str, Any]], audiobook: Audiobook -) -> dict[str, Any] | None: + results: list[ReadarrSearchResult], audiobook: Audiobook +) -> ReadarrSearchResult | None: """Find the best matching book result from mixed search results. Multiple books can share the same title (e.g. "Talking to Strangers" by @@ -142,34 +162,39 @@ def _find_best_book_match( norm_title = _normalize(audiobook.title) norm_authors = {_normalize(a) for a in audiobook.authors} - # Filter to book results only (skip author-only results) - book_results = [r for r in results if "book" in r and r["book"]] - # Pass 1: exact title + author match - for result in book_results: - book = result["book"] - if _normalize(book.get("title", "")) == norm_title and _author_matches(book, norm_authors): + for result in results: + if result.book is None: + continue + if _normalize(result.book.title) == norm_title and _author_matches( + result.book, norm_authors + ): return result # Pass 2: title containment (either direction) + author match # Handles "Talking to Strangers" matching "Talking to Strangers: What We Should Know..." - for result in book_results: - book = result["book"] - result_title = _normalize(book.get("title", "")) - if (norm_title in result_title or result_title in norm_title) and _author_matches(book, norm_authors): + for result in results: + if result.book is None: + continue + result_title = _normalize(result.book.title) + if ( + norm_title in result_title or result_title in norm_title + ) and _author_matches(result.book, norm_authors): return result # Pass 3: author match only (title may differ significantly between Audible and Hardcover) - for result in book_results: - book = result["book"] - if _author_matches(book, norm_authors): + for result in results: + if result.book is None: + continue + if _author_matches(result.book, norm_authors): return result # Pass 4: last resort — exact title match without author (only if we have no author data) if not norm_authors: - for result in book_results: - book = result["book"] - if _normalize(book.get("title", "")) == norm_title: + for result in results: + if result.book is None: + continue + if _normalize(result.book.title) == norm_title: return result return None @@ -192,39 +217,39 @@ async def readarr_add_and_search( # Step 2: Find best matching book result book_match = _find_best_book_match(results, audiobook) - if not book_match: + if not book_match or not book_match.book: logger.warning("Readarr: no matching book", title=audiobook.title) return False - book = book_match["book"] + book = book_match.book # Step 3: If book already in Readarr, just trigger search - if book.get("id") and book["id"] > 0: - logger.info("Readarr: book exists, triggering search", book_id=book["id"]) - return await _trigger_book_search(session, client_session, book["id"]) + if book.id > 0: + logger.info("Readarr: book exists, triggering search", book_id=book.id) + return await _trigger_book_search(session, client_session, book.id) - # Step 4: Add book — set config fields on the author object (pyarr pattern) - author = book.get("author") - if not author or not author.get("foreignAuthorId"): + # Step 4: Add book — set config fields on the author object + author = book.author + if not author or not author.foreignAuthorId: logger.error( "Readarr: search result missing author data", - title=book.get("title"), + title=book.title, ) return False - author["qualityProfileId"] = readarr_config.get_quality_profile_id(session) - author["metadataProfileId"] = readarr_config.get_metadata_profile_id(session) - author["rootFolderPath"] = readarr_config.get_root_folder_path(session) - author["monitored"] = True - author["addOptions"] = { - "monitor": "all", - "searchForMissingBooks": False, - } + author.qualityProfileId = readarr_config.get_quality_profile_id(session) + author.metadataProfileId = readarr_config.get_metadata_profile_id(session) + author.rootFolderPath = readarr_config.get_root_folder_path(session) + author.monitored = True + author.addOptions = ReadarrAuthorAddOptions( + monitor="all", + searchForMissingBooks=False, + ) - book["monitored"] = True - book["addOptions"] = { - "searchForNewBook": readarr_config.get_search_on_add(session), - } + book.monitored = True + book.addOptions = ReadarrBookAddOptions( + searchForNewBook=readarr_config.get_search_on_add(session), + ) return await _add_book(session, client_session, book) @@ -241,9 +266,7 @@ async def background_readarr_add_and_search(asin: str) -> None: return logger.info("Readarr background: starting add+search", asin=asin) success = await readarr_add_and_search(session, client_session, audiobook) - logger.info( - "Readarr background: complete", asin=asin, success=success - ) + logger.info("Readarr background: complete", asin=asin, success=success) # --------------------------------------------------------------------------- @@ -253,7 +276,7 @@ async def background_readarr_add_and_search(asin: str) -> None: async def readarr_get_quality_profiles( session: Session, client_session: ClientSession -) -> list[dict[str, Any]]: +) -> list[ReadarrQualityProfile]: base_url = readarr_config.get_base_url(session) if not base_url: return [] @@ -264,14 +287,14 @@ async def readarr_get_quality_profiles( ) as resp: if not resp.ok: return [] - return await resp.json() + return _QualityProfiles.validate_python(await resp.json()) except Exception: return [] async def readarr_get_metadata_profiles( session: Session, client_session: ClientSession -) -> list[dict[str, Any]]: +) -> list[ReadarrMetadataProfile]: base_url = readarr_config.get_base_url(session) if not base_url: return [] @@ -282,14 +305,14 @@ async def readarr_get_metadata_profiles( ) as resp: if not resp.ok: return [] - return await resp.json() + return _MetadataProfiles.validate_python(await resp.json()) except Exception: return [] async def readarr_get_root_folders( session: Session, client_session: ClientSession -) -> list[dict[str, Any]]: +) -> list[ReadarrRootFolder]: base_url = readarr_config.get_base_url(session) if not base_url: return [] @@ -300,6 +323,6 @@ async def readarr_get_root_folders( ) as resp: if not resp.ok: return [] - return await resp.json() + return _RootFolders.validate_python(await resp.json()) except Exception: return [] diff --git a/app/internal/readarr/types.py b/app/internal/readarr/types.py new file mode 100644 index 00000000..c5beddfc --- /dev/null +++ b/app/internal/readarr/types.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel, ConfigDict + + +# --- Settings API models (used by settings page dropdowns) --- + + +class ReadarrQualityProfile(BaseModel): + id: int + name: str + + +class ReadarrMetadataProfile(BaseModel): + id: int + name: str + + +class ReadarrRootFolder(BaseModel): + model_config = ConfigDict(extra="allow") # pyright: ignore[reportUnannotatedClassAttribute] + id: int = 0 + path: str + name: str = "" + + +# --- Search / Add API models (used internally for book matching) --- + + +class ReadarrAuthorAddOptions(BaseModel): + model_config = ConfigDict(extra="allow") # pyright: ignore[reportUnannotatedClassAttribute] + monitor: str = "all" + searchForMissingBooks: bool = False + + +class ReadarrBookAddOptions(BaseModel): + model_config = ConfigDict(extra="allow") # pyright: ignore[reportUnannotatedClassAttribute] + searchForNewBook: bool = False + + +class ReadarrAuthor(BaseModel): + model_config = ConfigDict(extra="allow") # pyright: ignore[reportUnannotatedClassAttribute] + authorName: str = "" + foreignAuthorId: str = "" + qualityProfileId: int | None = None + metadataProfileId: int | None = None + rootFolderPath: str | None = None + monitored: bool = False + addOptions: ReadarrAuthorAddOptions | None = None + + +class ReadarrBook(BaseModel): + model_config = ConfigDict(extra="allow") # pyright: ignore[reportUnannotatedClassAttribute] + id: int = 0 + title: str = "" + foreignBookId: str = "" + author: ReadarrAuthor | None = None + monitored: bool = False + addOptions: ReadarrBookAddOptions | None = None + + +class ReadarrSearchResult(BaseModel): + model_config = ConfigDict(extra="allow") # pyright: ignore[reportUnannotatedClassAttribute] + book: ReadarrBook | None = None diff --git a/app/routers/api/settings/readarr.py b/app/routers/api/settings/readarr.py index f936b33f..3b2ab5f8 100644 --- a/app/routers/api/settings/readarr.py +++ b/app/routers/api/settings/readarr.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any +from typing import Annotated from aiohttp import ClientSession from fastapi import APIRouter, Depends, Form, HTTPException, Response, Security @@ -13,6 +13,11 @@ readarr_get_root_folders, ) from app.internal.readarr.config import readarr_config +from app.internal.readarr.types import ( + ReadarrMetadataProfile, + ReadarrQualityProfile, + ReadarrRootFolder, +) from app.util.connection import get_connection from app.util.db import get_session from app.util.log import logger @@ -27,9 +32,9 @@ class ReadarrResponse(BaseModel): readarr_metadata_profile_id: int | None readarr_root_folder_path: str readarr_search_on_add: bool - quality_profiles: list[dict[str, Any]] - metadata_profiles: list[dict[str, Any]] - root_folders: list[dict[str, Any]] + quality_profiles: list[ReadarrQualityProfile] + metadata_profiles: list[ReadarrMetadataProfile] + root_folders: list[ReadarrRootFolder] @router.get("") @@ -46,9 +51,9 @@ async def read_readarr( root_folder_path = readarr_config.get_root_folder_path(session) or "" search_on_add = readarr_config.get_search_on_add(session) - quality_profiles: list[dict[str, Any]] = [] - metadata_profiles: list[dict[str, Any]] = [] - root_folders: list[dict[str, Any]] = [] + quality_profiles: list[ReadarrQualityProfile] = [] + metadata_profiles: list[ReadarrMetadataProfile] = [] + root_folders: list[ReadarrRootFolder] = [] if base_url and api_key: quality_profiles = await readarr_get_quality_profiles(session, client_session) metadata_profiles = await readarr_get_metadata_profiles(session, client_session) @@ -155,7 +160,8 @@ async def test_readarr_connection( quality_profiles = await readarr_get_quality_profiles(session, client_session) if not quality_profiles: raise HTTPException( - status_code=400, detail="Failed to connect to Readarr — could not fetch quality profiles" + status_code=400, + detail="Failed to connect to Readarr — could not fetch quality profiles", ) metadata_profiles = await readarr_get_metadata_profiles(session, client_session) root_folders = await readarr_get_root_folders(session, client_session) diff --git a/app/routers/pages/settings/readarr.py b/app/routers/pages/settings/readarr.py index cd43a30b..fd3eb499 100644 --- a/app/routers/pages/settings/readarr.py +++ b/app/routers/pages/settings/readarr.py @@ -67,7 +67,9 @@ def update_readarr_base_url( session: Annotated[Session, Depends(get_session)], admin_user: Annotated[DetailedUser, Security(ABRAuth(GroupEnum.admin))], ): - api_update_readarr_base_url(base_url=base_url, session=session, admin_user=admin_user) + api_update_readarr_base_url( + base_url=base_url, session=session, admin_user=admin_user + ) return Response(status_code=204, headers={"HX-Refresh": "true"}) From d85028afbb33457a9aceed47f11f73add65fe889 Mon Sep 17 00:00:00 2001 From: Juan Chong Date: Mon, 9 Feb 2026 13:59:29 -0800 Subject: [PATCH 3/4] refactor: hand off requests to Readarr immediately and remove search_on_add toggle Co-authored-by: Cursor --- app/internal/readarr/client.py | 25 ++++++++++++++++++++++--- app/internal/readarr/config.py | 7 ------- app/routers/api/requests.py | 10 ++++++++-- app/routers/api/settings/readarr.py | 14 -------------- app/routers/pages/settings/readarr.py | 18 ------------------ templates/pages/Settings/Readarr.jinja | 16 ---------------- 6 files changed, 30 insertions(+), 60 deletions(-) diff --git a/app/internal/readarr/client.py b/app/internal/readarr/client.py index 13ab0e3f..2a4624ad 100644 --- a/app/internal/readarr/client.py +++ b/app/internal/readarr/client.py @@ -5,8 +5,10 @@ import aiohttp from aiohttp import ClientSession from pydantic import TypeAdapter -from sqlmodel import Session +from sqlmodel import Session, select +from app.internal.audiobookshelf.client import abs_trigger_scan +from app.internal.audiobookshelf.config import abs_config from app.internal.models import Audiobook from app.internal.readarr.config import readarr_config from app.internal.readarr.types import ( @@ -248,14 +250,18 @@ async def readarr_add_and_search( book.monitored = True book.addOptions = ReadarrBookAddOptions( - searchForNewBook=readarr_config.get_search_on_add(session), + searchForNewBook=True, ) return await _add_book(session, client_session, book) async def background_readarr_add_and_search(asin: str) -> None: - """Background task wrapper for readarr_add_and_search.""" + """Background task wrapper for readarr_add_and_search. + + On success, marks matching books as downloaded and triggers an ABS + library scan so new media is picked up quickly. + """ with next(get_session()) as session: async with ClientSession( timeout=aiohttp.ClientTimeout(total=300) @@ -267,6 +273,19 @@ async def background_readarr_add_and_search(asin: str) -> None: logger.info("Readarr background: starting add+search", asin=asin) success = await readarr_add_and_search(session, client_session, audiobook) logger.info("Readarr background: complete", asin=asin, success=success) + if success: + same_books = session.exec( + select(Audiobook).where(Audiobook.asin == asin) + ).all() + for b in same_books: + b.downloaded = True + session.add(b) + session.commit() + try: + if abs_config.is_valid(session): + await abs_trigger_scan(session, client_session) + except Exception: + pass # --------------------------------------------------------------------------- diff --git a/app/internal/readarr/config.py b/app/internal/readarr/config.py index 51154537..5492b4d0 100644 --- a/app/internal/readarr/config.py +++ b/app/internal/readarr/config.py @@ -15,7 +15,6 @@ class ReadarrMisconfigured(ValueError): "readarr_quality_profile_id", "readarr_metadata_profile_id", "readarr_root_folder_path", - "readarr_search_on_add", ] @@ -74,11 +73,5 @@ def get_root_folder_path(self, session: Session) -> Optional[str]: def set_root_folder_path(self, session: Session, path: str): self.set(session, "readarr_root_folder_path", path) - def get_search_on_add(self, session: Session) -> bool: - return bool(self.get_bool(session, "readarr_search_on_add") or False) - - def set_search_on_add(self, session: Session, enabled: bool): - self.set_bool(session, "readarr_search_on_add", enabled) - readarr_config = ReadarrConfig() diff --git a/app/routers/api/requests.py b/app/routers/api/requests.py index d2541dfa..9ffd9aab 100644 --- a/app/routers/api/requests.py +++ b/app/routers/api/requests.py @@ -40,7 +40,10 @@ from app.internal.prowlarr.prowlarr import start_download from app.internal.prowlarr.util import ProwlarrMisconfigured, prowlarr_config from app.internal.query import QueryResult, background_start_query, query_sources -from app.internal.readarr.client import readarr_add_and_search +from app.internal.readarr.client import ( + background_readarr_add_and_search, + readarr_add_and_search, +) from app.internal.readarr.config import ReadarrMisconfigured, readarr_config from app.internal.ranking.quality import quality_config from app.util.connection import get_connection @@ -98,7 +101,10 @@ async def create_request( book_asin=asin, ) - if quality_config.get_auto_download(session) and user.is_above(GroupEnum.trusted): + if readarr_config.is_valid(session): + # hand off to Readarr immediately — it manages the full pipeline + background_task.add_task(background_readarr_add_and_search, asin=asin) + elif quality_config.get_auto_download(session) and user.is_above(GroupEnum.trusted): # start querying and downloading if auto download is enabled background_task.add_task( background_start_query, diff --git a/app/routers/api/settings/readarr.py b/app/routers/api/settings/readarr.py index 3b2ab5f8..03600341 100644 --- a/app/routers/api/settings/readarr.py +++ b/app/routers/api/settings/readarr.py @@ -31,7 +31,6 @@ class ReadarrResponse(BaseModel): readarr_quality_profile_id: int | None readarr_metadata_profile_id: int | None readarr_root_folder_path: str - readarr_search_on_add: bool quality_profiles: list[ReadarrQualityProfile] metadata_profiles: list[ReadarrMetadataProfile] root_folders: list[ReadarrRootFolder] @@ -49,7 +48,6 @@ async def read_readarr( quality_profile_id = readarr_config.get_quality_profile_id(session) metadata_profile_id = readarr_config.get_metadata_profile_id(session) root_folder_path = readarr_config.get_root_folder_path(session) or "" - search_on_add = readarr_config.get_search_on_add(session) quality_profiles: list[ReadarrQualityProfile] = [] metadata_profiles: list[ReadarrMetadataProfile] = [] @@ -65,7 +63,6 @@ async def read_readarr( readarr_quality_profile_id=quality_profile_id, readarr_metadata_profile_id=metadata_profile_id, readarr_root_folder_path=root_folder_path, - readarr_search_on_add=search_on_add, quality_profiles=quality_profiles, metadata_profiles=metadata_profiles, root_folders=root_folders, @@ -127,17 +124,6 @@ def update_readarr_root_folder( return Response(status_code=204) -@router.put("/search-on-add") -def update_readarr_search_on_add( - session: Annotated[Session, Depends(get_session)], - admin_user: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], - search_on_add: Annotated[bool, Form()] = False, -): - _ = admin_user - readarr_config.set_search_on_add(session, search_on_add) - return Response(status_code=204) - - class ReadarrTestResponse(BaseModel): quality_profile_count: int metadata_profile_count: int diff --git a/app/routers/pages/settings/readarr.py b/app/routers/pages/settings/readarr.py index fd3eb499..d4f8ff71 100644 --- a/app/routers/pages/settings/readarr.py +++ b/app/routers/pages/settings/readarr.py @@ -24,9 +24,6 @@ from app.routers.api.settings.readarr import ( update_readarr_root_folder as api_update_readarr_root_folder, ) -from app.routers.api.settings.readarr import ( - update_readarr_search_on_add as api_update_readarr_search_on_add, -) from app.util.connection import get_connection from app.util.db import get_session from app.util.templates import catalog_response @@ -54,7 +51,6 @@ async def read_readarr( readarr_quality_profile_id=response.readarr_quality_profile_id, readarr_metadata_profile_id=response.readarr_metadata_profile_id, readarr_root_folder_path=response.readarr_root_folder_path, - readarr_search_on_add=response.readarr_search_on_add, quality_profiles=response.quality_profiles, metadata_profiles=response.metadata_profiles, root_folders=response.root_folders, @@ -117,17 +113,3 @@ def update_readarr_root_folder( root_folder_path=root_folder_path, session=session, admin_user=admin_user ) return Response(status_code=204, headers={"HX-Refresh": "true"}) - - -@router.put("/hx-search-on-add") -def update_readarr_search_on_add( - session: Annotated[Session, Depends(get_session)], - admin_user: Annotated[DetailedUser, Security(ABRAuth(GroupEnum.admin))], - search_on_add: Annotated[bool, Form()] = False, -): - api_update_readarr_search_on_add( - session=session, - admin_user=admin_user, - search_on_add=search_on_add, - ) - return Response(status_code=204, headers={"HX-Refresh": "true"}) diff --git a/templates/pages/Settings/Readarr.jinja b/templates/pages/Settings/Readarr.jinja index 3af8f1f8..f64a7142 100644 --- a/templates/pages/Settings/Readarr.jinja +++ b/templates/pages/Settings/Readarr.jinja @@ -5,7 +5,6 @@ readarr_quality_profile_id: int | None, readarr_metadata_profile_id: int | None, readarr_root_folder_path: str, - readarr_search_on_add: bool, quality_profiles: list, metadata_profiles: list, root_folders: list, @@ -139,21 +138,6 @@ -
- -

- When enabled, Readarr will automatically search indexers for the book after adding it. - This triggers Readarr's full download pipeline (search, grab, download, import, rename). -

-
From 2cba7d8dd5d5eafd29be87586ee258f6d5d98130 Mon Sep 17 00:00:00 2001 From: Juan Chong Date: Mon, 9 Feb 2026 14:12:39 -0800 Subject: [PATCH 4/4] docs: update Readarr integration section for immediate request handoff Co-authored-by: Cursor --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7728db43..834e8642 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,9 @@ Notes: ### Readarr Integration -Readarr integration lets ABR hand off downloads to [Readarr](https://github.com/Readarr/Readarr) (or compatible forks like [Bookshelf](https://github.com/pennydreadful/bookshelf)). When a user requests an audiobook, ABR adds the book to Readarr and triggers an indexer search. Readarr then handles the full pipeline: searching, grabbing, downloading, importing, renaming, and organizing files into your library folder. +Readarr integration lets ABR hand off downloads to [Readarr](https://github.com/Readarr/Readarr) (or compatible forks like [Bookshelf](https://github.com/pennydreadful/bookshelf)). When a user requests an audiobook, ABR immediately adds the book to Readarr and triggers an indexer search in the background. Readarr then handles the full pipeline: searching, grabbing, downloading, importing, renaming, and organizing files into your library folder. -This is particularly useful if you want Readarr to manage filenames and folder structure before files land in Audiobookshelf. +This is particularly useful if you want Readarr to manage filenames and folder structure before files land in Audiobookshelf or Plex. Setup steps: @@ -130,13 +130,13 @@ Setup steps: - **Base URL** of your Readarr instance (e.g. `http://readarr:8787`) - **API Key** from step 2 - Select a **Quality Profile**, **Metadata Profile**, and **Root Folder** - - Enable **Search for book on add** if you want Readarr to immediately search indexers after adding the book Notes: -- When Readarr is configured, ABR tries Readarr first for all downloads. If the Readarr add/search fails for any reason, ABR falls back to the direct Prowlarr download path automatically. -- ABR matches books from Audible to Readarr's metadata by title and author. If no match is found in Readarr's metadata, the download falls back to Prowlarr. +- When Readarr is configured, every new request is sent to Readarr automatically as a background task. No manual intervention or auto-download toggle is required. +- ABR matches books from Audible to Readarr's metadata by title and author. If no match is found, the book remains in the request queue for manual handling. - Readarr's add-book operation can take 30 seconds to several minutes for new authors, since it fetches metadata from upstream providers. This runs as a background task — users see an immediate response. +- If Readarr is not configured, ABR falls back to the standard Prowlarr auto-download flow (if enabled). ### OpenID Connect