Files
abogen/abogen/integrations/audiobookshelf.py
T
JB de0f17cd57 feat: Add Audiobookshelf and Calibre OPDS integration
- Implemented Audiobookshelf integration for uploading audiobooks with metadata, cover, chapters, and subtitles.
- Added configuration options for Audiobookshelf in the settings page, including base URL, API token, library ID, and upload preferences.
- Introduced Calibre OPDS integration for fetching and downloading resources from a Calibre OPDS catalog.
- Enhanced job processing to include post-completion hooks for Audiobookshelf uploads.
- Updated settings template to include new integration options and fields.
- Added utility functions for metadata normalization and chapter extraction.
- Included HTTP client functionality for both Audiobookshelf and Calibre OPDS interactions.
- Updated dependencies to include httpx for HTTP requests.
2025-10-27 17:10:48 -07:00

158 lines
6.0 KiB
Python

from __future__ import annotations
import json
import logging
import mimetypes
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, Optional
import httpx
logger = logging.getLogger(__name__)
class AudiobookshelfUploadError(RuntimeError):
"""Raised when an upload to Audiobookshelf fails."""
@dataclass(frozen=True)
class AudiobookshelfConfig:
base_url: str
api_token: str
library_id: str
collection_id: Optional[str] = None
verify_ssl: bool = True
send_cover: bool = True
send_chapters: bool = True
send_subtitles: bool = True
timeout: float = 30.0
def normalized_base_url(self) -> str:
base = (self.base_url or "").strip()
if not base:
raise ValueError("Audiobookshelf base URL is required")
return base[:-1] if base.endswith("/") else base
class AudiobookshelfClient:
"""Minimal client for Audiobookshelf's upload API."""
def __init__(self, config: AudiobookshelfConfig) -> None:
if not config.api_token:
raise ValueError("Audiobookshelf API token is required")
if not config.library_id:
raise ValueError("Audiobookshelf library ID is required")
self._config = config
self._base_url = config.normalized_base_url()
def upload_audiobook(
self,
audio_path: Path,
*,
metadata: Dict[str, Any],
cover_path: Optional[Path] = None,
chapters: Optional[Iterable[Dict[str, Any]]] = None,
subtitles: Optional[Iterable[Path]] = None,
) -> Dict[str, Any]:
if not audio_path.exists():
raise AudiobookshelfUploadError(f"Audio path does not exist: {audio_path}")
session = self._create_upload_session()
upload_id = session.get("id") or session.get("uploadId") or session.get("upload_id")
if not upload_id:
raise AudiobookshelfUploadError("Audiobookshelf upload session did not return an identifier")
logger.debug("Audiobookshelf upload session %s created", upload_id)
self._upload_file(upload_id, audio_path, kind="audio")
if cover_path and self._config.send_cover and cover_path.exists():
try:
self._upload_file(upload_id, cover_path, kind="cover")
except AudiobookshelfUploadError:
logger.warning("Failed to upload cover to Audiobookshelf; continuing without cover.")
if subtitles and self._config.send_subtitles:
for subtitle in subtitles:
if not subtitle.exists():
continue
try:
self._upload_file(upload_id, subtitle, kind="subtitle")
except AudiobookshelfUploadError:
logger.warning("Failed to upload subtitle %s to Audiobookshelf", subtitle)
payload = self._build_finalize_payload(metadata, chapters)
result = self._finalize_upload(upload_id, payload)
logger.debug("Audiobookshelf upload %s finalized", upload_id)
return result
def _open_client(self) -> httpx.Client:
headers = {
"Authorization": f"Bearer {self._config.api_token}",
"Accept": "application/json",
}
return httpx.Client(
base_url=self._base_url,
headers=headers,
timeout=self._config.timeout,
verify=self._config.verify_ssl,
)
def _create_upload_session(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"libraryId": self._config.library_id,
"mediaType": "audiobook",
}
if self._config.collection_id:
payload["collectionId"] = self._config.collection_id
try:
with self._open_client() as client:
response = client.post("/api/uploads", json=payload)
response.raise_for_status()
return response.json()
except httpx.HTTPError as exc:
raise AudiobookshelfUploadError(f"Unable to create Audiobookshelf upload session: {exc}") from exc
def _upload_file(self, upload_id: str, path: Path, *, kind: str) -> None:
mime_type, _ = mimetypes.guess_type(path.name)
mime_type = mime_type or "application/octet-stream"
data = {"kind": kind, "filename": path.name}
route = f"/api/uploads/{upload_id}/files"
try:
with path.open("rb") as handle:
files = {"file": (path.name, handle, mime_type)}
with self._open_client() as client:
response = client.post(route, data=data, files=files)
response.raise_for_status()
except httpx.HTTPError as exc:
raise AudiobookshelfUploadError(f"Audiobookshelf file upload failed for {path.name}: {exc}") from exc
def _build_finalize_payload(
self,
metadata: Dict[str, Any],
chapters: Optional[Iterable[Dict[str, Any]]],
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"metadata": metadata or {},
}
if chapters and self._config.send_chapters:
payload["chapters"] = list(chapters)
return payload
def _finalize_upload(self, upload_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
route = f"/api/uploads/{upload_id}/finish"
try:
with self._open_client() as client:
response = client.post(route, json=payload)
response.raise_for_status()
if response.content:
return json.loads(response.content.decode("utf-8"))
except httpx.HTTPStatusError as exc:
raise AudiobookshelfUploadError(
f"Audiobookshelf finalize request failed with status {exc.response.status_code}"
) from exc
except httpx.HTTPError as exc:
raise AudiobookshelfUploadError(f"Audiobookshelf finalize request failed: {exc}") from exc
except json.JSONDecodeError:
logger.debug("Audiobookshelf finalize response was not JSON; returning empty object")
return {}