diff --git a/README.md b/README.md index f22a923..25d230a 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** + +Notes: + +- 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 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. @@ -140,4 +165,5 @@ Head to the [local development](/wiki/Local-Development) page in the wiki. AudioBookRequest builds on top of a some other great open-source tools. A big thanks goes out to these developers. - [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 4c548ef..436e8bb 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,49 @@ 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 +143,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 +153,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 0000000..e69de29 diff --git a/app/internal/readarr/client.py b/app/internal/readarr/client.py new file mode 100644 index 0000000..2a4624a --- /dev/null +++ b/app/internal/readarr/client.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +import re + +import aiohttp +from aiohttp import ClientSession +from pydantic import TypeAdapter +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 ( + 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 + +# 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) + +_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) + 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[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, + ) as resp: + if not resp.ok: + logger.error( + "Readarr: search failed", + status=resp.status, + reason=resp.reason, + ) + return [] + 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: ReadarrBook +) -> 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.model_dump(mode="json"), + 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.title, + foreignBookId=book.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: ReadarrBook, norm_authors: set[str]) -> bool: + """Check if the book's author matches any of the expected authors.""" + 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[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 + 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} + + # Pass 1: exact title + author match + 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 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 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 results: + if result.book is None: + continue + if _normalize(result.book.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 or not book_match.book: + 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.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 + author = book.author + if not author or not author.foreignAuthorId: + logger.error( + "Readarr: search result missing author data", + 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 = ReadarrAuthorAddOptions( + monitor="all", + searchForMissingBooks=False, + ) + + book.monitored = True + book.addOptions = ReadarrBookAddOptions( + 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. + + 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) + ) 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) + 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 + + +# --------------------------------------------------------------------------- +# Settings helpers — used by the settings page to populate dropdowns +# --------------------------------------------------------------------------- + + +async def readarr_get_quality_profiles( + session: Session, client_session: ClientSession +) -> list[ReadarrQualityProfile]: + 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 _QualityProfiles.validate_python(await resp.json()) + except Exception: + return [] + + +async def readarr_get_metadata_profiles( + session: Session, client_session: ClientSession +) -> list[ReadarrMetadataProfile]: + 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 _MetadataProfiles.validate_python(await resp.json()) + except Exception: + return [] + + +async def readarr_get_root_folders( + session: Session, client_session: ClientSession +) -> list[ReadarrRootFolder]: + 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 _RootFolders.validate_python(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 0000000..5492b4d --- /dev/null +++ b/app/internal/readarr/config.py @@ -0,0 +1,77 @@ +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", +] + + +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) + + +readarr_config = ReadarrConfig() diff --git a/app/internal/readarr/types.py b/app/internal/readarr/types.py new file mode 100644 index 0000000..c5beddf --- /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/requests.py b/app/routers/api/requests.py index f5632a2..3c53b03 100644 --- a/app/routers/api/requests.py +++ b/app/routers/api/requests.py @@ -40,6 +40,11 @@ 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 ( + 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 from app.util.db import get_session @@ -108,7 +113,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, @@ -350,25 +358,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 2df734a..a4cacd0 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 0000000..0360034 --- /dev/null +++ b/app/routers/api/settings/readarr.py @@ -0,0 +1,166 @@ +from typing import Annotated + +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.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 + +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 + quality_profiles: list[ReadarrQualityProfile] + metadata_profiles: list[ReadarrMetadataProfile] + root_folders: list[ReadarrRootFolder] + + +@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 "" + + 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) + 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, + 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) + + +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 df88ca1..036dbd2 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 0000000..d4f8ff7 --- /dev/null +++ b/app/routers/pages/settings/readarr.py @@ -0,0 +1,115 @@ +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.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, + 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"}) diff --git a/templates/layouts/SettingsLayout.jinja b/templates/layouts/SettingsLayout.jinja index 2dc8f29..8d6de3a 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. +

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