Homelabtoolkit v2
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user