Homelabtoolkit v2

This commit is contained in:
2026-06-08 21:58:16 +12:00
parent 040fbacc70
commit 3c77066beb
75 changed files with 16945 additions and 374 deletions
+159
View File
@@ -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,
}