"""Audiobookshelf integration via its REST API. Authentication uses a Bearer API token (Audiobookshelf account → API token). All functions take an ``httpx.AsyncClient`` so they share the app-wide client. This is the foundation for upcoming audiobook tooling (renaming, etc.); for now it covers connection status, libraries, and aggregate stats. """ from __future__ import annotations import os import httpx ABS_URL = os.environ.get("AUDIOBOOKSHELF_URL", "") ABS_TOKEN = os.environ.get("AUDIOBOOKSHELF_TOKEN", "") class AudiobookshelfError(Exception): def __init__(self, message: str, status: int = 502): self.message = message self.status = status super().__init__(message) def is_configured() -> bool: return bool(ABS_URL and ABS_TOKEN) def _require_configured() -> None: if not is_configured(): raise AudiobookshelfError( "Audiobookshelf is not configured. Set AUDIOBOOKSHELF_URL and AUDIOBOOKSHELF_TOKEN.", status=503, ) def _base_url() -> str: return ABS_URL.rstrip("/") def _headers() -> dict: return {"Authorization": f"Bearer {ABS_TOKEN}", "Accept": "application/json"} async def _call(client: httpx.AsyncClient, path: str, params: dict | None = None) -> dict: _require_configured() url = f"{_base_url()}{path}" try: response = await client.get(url, params=params, headers=_headers()) except httpx.RequestError as exc: raise AudiobookshelfError(f"Could not reach Audiobookshelf at {ABS_URL}: {exc}") from exc if response.status_code in (401, 403): raise AudiobookshelfError("Audiobookshelf rejected the API token.", status=401) try: response.raise_for_status() except httpx.HTTPStatusError as exc: raise AudiobookshelfError( f"Audiobookshelf returned HTTP {response.status_code} for {path}.", status=502 ) from exc try: return response.json() except ValueError as exc: raise AudiobookshelfError("Audiobookshelf returned an invalid response.", status=502) from exc async def ping(client: httpx.AsyncClient) -> dict: if not is_configured(): return {"connected": False, "configured": False, "url": ABS_URL} try: me = await _call(client, "/api/me") return { "connected": True, "configured": True, "url": ABS_URL, "username": me.get("username"), } except AudiobookshelfError as exc: return {"connected": False, "configured": True, "url": ABS_URL, "error": exc.message} async def get_libraries(client: httpx.AsyncClient) -> list[dict]: payload = await _call(client, "/api/libraries") raw = payload.get("libraries") or [] return [ { "id": lib.get("id"), "name": lib.get("name") or "", "media_type": lib.get("mediaType") or "book", "provider": lib.get("provider"), } for lib in raw ] async def _library_item_total(client: httpx.AsyncClient, library_id: str) -> int: try: payload = await _call(client, f"/api/libraries/{library_id}/items", {"limit": 0}) return int(payload.get("total") or 0) except AudiobookshelfError: return 0 async def get_stats(client: httpx.AsyncClient) -> dict: """Aggregate stats across all libraries for the overview page.""" libraries = await get_libraries(client) book_items = podcast_items = author_count = 0 total_duration = total_size = num_tracks = 0 library_views: list[dict] = [] for lib in libraries: stats: dict = {} try: stats = await _call(client, f"/api/libraries/{lib['id']}/stats") except AudiobookshelfError: stats = {} items = int(stats.get("totalItems") or 0) if not items: items = await _library_item_total(client, lib["id"]) duration = int(stats.get("totalDuration") or 0) size = int(stats.get("totalSize") or 0) authors = int(stats.get("totalAuthors") or 0) tracks = int(stats.get("numAudioTracks") or 0) if lib["media_type"] == "podcast": podcast_items += items else: book_items += items author_count += authors total_duration += duration total_size += size num_tracks += tracks library_views.append( { "id": lib["id"], "name": lib["name"], "media_type": lib["media_type"], "items": items, "authors": authors, "duration": duration, "size": size, } ) return { "library_count": len(libraries), "book_count": book_items, "podcast_count": podcast_items, "author_count": author_count, "total_duration": total_duration, "total_size": total_size, "num_audio_tracks": num_tracks, "libraries": library_views, }