160 lines
4.9 KiB
Python
160 lines
4.9 KiB
Python
"""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,
|
|
}
|