diff --git a/README.md b/README.md index 0a186c9..5a6baaf 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ Abogen can push finished audiobooks directly into Audiobookshelf. Configure this - **Base URL** – the HTTPS origin (and optional path prefix) where your Audiobookshelf server is reachable, for example `https://abs.example.com` or `https://media.example.com/abs`. Do **not** append `/api`. - **Library ID** – the identifier of the target Audiobookshelf library (copy it from the library’s settings page in ABS). +- **Folder ID** – the destination folder inside that library. Open the library in Audiobookshelf, select the folder you want new uploads to land in, and copy the `folderId` from the address bar. - **API token** – a personal access token generated in Audiobookshelf under *Account → API tokens*. You can enable automatic uploads for future jobs or trigger individual uploads from the queue once the connection succeeds. @@ -178,18 +179,16 @@ When Audiobookshelf sits behind Nginx Proxy Manager (NPM), make sure the API pat 5. Enable **Websockets Support** on the main proxy screen (Audiobookshelf uses it for the web UI, and it keeps the reverse proxy configuration consistent). 6. If you publish Audiobookshelf under a path prefix (for example `/abs`), add a **Custom Location** with `Location: /abs/` and set the **Forward Path** to `/`. That rewrite strips the `/abs` prefix before traffic reaches Audiobookshelf so `/abs/api/...` on the internet becomes `/api/...` on the backend. Use the same prefixed URL in Abogen’s “Base URL” field. -After saving the proxy host, test the API from the machine running Abogen: +After saving the proxy host, locate your folder ID and test the API from the machine running Abogen: ```bash -curl -i "https://abs.example.com/api/uploads" \ - -H "Authorization: Bearer YOUR_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"libraryId":"YOUR_LIBRARY_ID","mediaType":"audiobook"}' +curl -i "https://abs.example.com/api/libraries" \ + -H "Authorization: Bearer YOUR_API_TOKEN" ``` -If you still receive `Cannot GET /api/...`, the proxy is rewriting paths. Double-check the **Custom Locations** table (the `Forward Path` column should be empty for `/abs/`) and review the NPM access/error logs while issuing the curl request to confirm the backend sees the full `/api/uploads` URL. +If you still receive `Cannot GET /api/...`, the proxy is rewriting paths. Double-check the **Custom Locations** table (the `Forward Path` column should be empty for `/abs/`) and review the NPM access/error logs while issuing the curl request to confirm the backend sees the full `/api/libraries` URL. -A JSON response containing an `id` or `uploadId` confirms the proxy is routing API calls correctly. You can then use **Test connection** in Abogen’s settings and the “Send to Audiobookshelf” button on completed jobs. +A JSON response confirming the libraries list means the proxy is routing API calls correctly. You can then use **Test connection** in Abogen’s settings (it now verifies both the library and folder IDs) and the “Send to Audiobookshelf” button on completed jobs. ## Configuration reference Most behaviour is controlled through the UI, but a few environment variables are helpful for automation: diff --git a/abogen/integrations/audiobookshelf.py b/abogen/integrations/audiobookshelf.py index 03c8002..4af2e0c 100644 --- a/abogen/integrations/audiobookshelf.py +++ b/abogen/integrations/audiobookshelf.py @@ -3,9 +3,10 @@ from __future__ import annotations import json import logging import mimetypes +from contextlib import ExitStack from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, Iterable, Optional +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple import httpx @@ -42,7 +43,7 @@ class AudiobookshelfConfig: class AudiobookshelfClient: - """Minimal client for Audiobookshelf's upload API.""" + """Client for the legacy Audiobookshelf multipart upload endpoint.""" def __init__(self, config: AudiobookshelfConfig) -> None: if not config.api_token: @@ -70,33 +71,33 @@ class AudiobookshelfClient: ) -> 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) + if not self._config.folder_id: + raise AudiobookshelfUploadError("Audiobookshelf folder ID is required for uploads") - self._upload_file(upload_id, audio_path, kind="audio") + form_fields = self._build_upload_fields(audio_path, metadata, chapters) + file_entries = self._build_file_entries(audio_path, cover_path, subtitles) - 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.") + route = self._api_path("upload") + try: + with self._open_client() as client, ExitStack() as stack: + files_payload = self._open_file_handles(file_entries, stack) + response = client.post(route, data=form_fields, files=files_payload) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + detail = (exc.response.text or "").strip() + if detail: + detail = detail[:200] + message = f"Audiobookshelf upload failed with status {status}: {detail}" + else: + message = f"Audiobookshelf upload failed with status {status}" + raise AudiobookshelfUploadError( + message + ) from exc + except httpx.HTTPError as exc: + raise AudiobookshelfUploadError(f"Audiobookshelf upload failed: {exc}") from exc - 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 + return {} def _open_client(self) -> httpx.Client: headers = { @@ -110,61 +111,98 @@ class AudiobookshelfClient: 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(self._api_path("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 = self._api_path(f"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( + def _build_upload_fields( self, + audio_path: Path, 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 + ) -> Dict[str, str]: + title = self._extract_title(metadata, audio_path) + author = self._extract_author(metadata) + series = self._extract_series(metadata) - def _finalize_upload(self, upload_id: str, payload: Dict[str, Any]) -> Dict[str, Any]: - route = self._api_path(f"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 {} + fields: Dict[str, str] = { + "library": self._config.library_id, + "folder": self._config.folder_id or "", + "title": title, + } + if author: + fields["author"] = author + if series: + fields["series"] = series + if self._config.collection_id: + fields["collectionId"] = self._config.collection_id + + metadata_payload: Dict[str, Any] = metadata or {} + if chapters and self._config.send_chapters: + metadata_payload = dict(metadata_payload) + metadata_payload["chapters"] = list(chapters) + + if metadata_payload: + try: + fields["metadata"] = json.dumps(metadata_payload, ensure_ascii=False) + except (TypeError, ValueError): + logger.debug("Failed to serialize Audiobookshelf metadata payload") + + return fields + + def _build_file_entries( + self, + audio_path: Path, + cover_path: Optional[Path], + subtitles: Optional[Iterable[Path]], + ) -> List[Tuple[str, Path]]: + entries: List[Tuple[str, Path]] = [("file0", audio_path)] + index = 1 + + if cover_path and self._config.send_cover and cover_path.exists(): + entries.append((f"file{index}", cover_path)) + index += 1 + + if subtitles and self._config.send_subtitles: + for subtitle in subtitles: + if subtitle.exists(): + entries.append((f"file{index}", subtitle)) + index += 1 + + return entries + + def _open_file_handles( + self, + entries: Sequence[Tuple[str, Path]], + stack: ExitStack, + ) -> List[Tuple[str, Tuple[str, Any, str]]]: + files: List[Tuple[str, Tuple[str, Any, str]]] = [] + for field_name, path in entries: + mime_type, _ = mimetypes.guess_type(path.name) + mime_type = mime_type or "application/octet-stream" + handle = stack.enter_context(path.open("rb")) + files.append((field_name, (path.name, handle, mime_type))) + return files + + @staticmethod + def _extract_title(metadata: Mapping[str, Any], audio_path: Path) -> str: + title = metadata.get("title") if isinstance(metadata, Mapping) else None + candidate = str(title).strip() if isinstance(title, str) else "" + if candidate: + return candidate + return audio_path.stem or audio_path.name + + @staticmethod + def _extract_author(metadata: Mapping[str, Any]) -> str: + authors = metadata.get("authors") if isinstance(metadata, Mapping) else None + if isinstance(authors, str): + candidate = authors.strip() + return candidate + if isinstance(authors, Iterable) and not isinstance(authors, (str, Mapping)): + names = [str(entry).strip() for entry in authors if isinstance(entry, str) and entry.strip()] + if names: + return ", ".join(names) + return "" + + @staticmethod + def _extract_series(metadata: Mapping[str, Any]) -> str: + series_name = metadata.get("seriesName") if isinstance(metadata, Mapping) else None + if isinstance(series_name, str) and series_name.strip(): + return series_name.strip() + return "" diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 45fad94..82cffb5 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -3088,6 +3088,42 @@ def test_audiobookshelf() -> ResponseReturnValue: if not matched: return jsonify({"error": f"Library '{library_id}' not found on Audiobookshelf."}), 400 + folder_id = str(settings.get("folder_id") or "").strip() + if not folder_id: + return jsonify({"error": "Provide a folder ID before testing."}), 400 + + folder_name = folder_id + try: + with client._open_client() as http_client: # pylint: disable=protected-access + library_resp = http_client.get(client._api_path(f"libraries/{library_id}")) + library_resp.raise_for_status() + library_payload = library_resp.json() + except Exception as exc: # pragma: no cover - network guard + status_code = getattr(getattr(exc, "response", None), "status_code", None) + if status_code: + message = f"Library lookup failed with status {status_code}." + else: + message = f"Library lookup failed: {exc}" + return jsonify({"error": message}), 502 + + folders: List[Mapping[str, Any]] = [] + if isinstance(library_payload, Mapping): + candidates = library_payload.get("libraryFolders") or library_payload.get("folders") + if isinstance(candidates, list): + folders = [entry for entry in candidates if isinstance(entry, Mapping)] + + folder_matched = False + for folder in folders: + entry_id = str(folder.get("id") or "").strip() + if entry_id != folder_id: + continue + folder_matched = True + folder_name = folder.get("name") or folder.get("label") or folder_id + break + + if not folder_matched: + return jsonify({"error": f"Folder '{folder_id}' not found in library '{library_name}'."}), 400 + collection_id = str(settings.get("collection_id") or "").strip() if collection_id: try: @@ -3103,10 +3139,11 @@ def test_audiobookshelf() -> ResponseReturnValue: return jsonify({"error": message}), 502 return jsonify({ - "message": f"Connected to Audiobookshelf library '{library_name}'.", + "message": f"Connected to Audiobookshelf library '{library_name}' (folder '{folder_name}').", "library_id": library_id, "collection_id": collection_id or None, - "folder_id": settings.get("folder_id") or None, + "folder_id": folder_id, + "folder_name": folder_name, }) @@ -4955,6 +4992,13 @@ def send_job_to_audiobookshelf(job_id: str) -> ResponseReturnValue: ) service._persist_state() return _panel_response() + if not config.folder_id: + job.add_log( + "Audiobookshelf upload skipped: configure a folder ID in the Audiobookshelf settings.", + level="warning", + ) + service._persist_state() + return _panel_response() audio_path = _locate_job_audio(job) if not audio_path or not audio_path.exists(): diff --git a/abogen/web/service.py b/abogen/web/service.py index 4e1fce6..5571ba5 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -932,12 +932,19 @@ class ConversionService: base_url = str(integration_cfg.get("base_url") or "").strip() api_token = str(integration_cfg.get("api_token") or "").strip() library_id = str(integration_cfg.get("library_id") or "").strip() + folder_id = str(integration_cfg.get("folder_id") or "").strip() if not base_url or not api_token or not library_id: job.add_log( "Audiobookshelf upload skipped: configure base URL, API token, and library ID first.", level="warning", ) return + if not folder_id: + job.add_log( + "Audiobookshelf upload skipped: configure a folder ID in the Audiobookshelf settings.", + level="warning", + ) + return audio_ref = job.result.audio_path audio_path = audio_ref if isinstance(audio_ref, Path) else Path(str(audio_ref)) if audio_ref else None @@ -956,7 +963,7 @@ class ConversionService: api_token=api_token, library_id=library_id, collection_id=(str(integration_cfg.get("collection_id") or "").strip() or None), - folder_id=(str(integration_cfg.get("folder_id") or "").strip() or None), + folder_id=folder_id, verify_ssl=self._coerce_bool(integration_cfg.get("verify_ssl"), True), send_cover=self._coerce_bool(integration_cfg.get("send_cover"), True), send_chapters=self._coerce_bool(integration_cfg.get("send_chapters"), True), diff --git a/abogen/web/static/settings.js b/abogen/web/static/settings.js index 195ab05..e18a061 100644 --- a/abogen/web/static/settings.js +++ b/abogen/web/static/settings.js @@ -381,8 +381,8 @@ async function testAudiobookshelf(button) { return; } const hasToken = Boolean(fields.api_token) || Boolean(fields.use_saved_token); - if (!fields.base_url || !hasToken || !fields.library_id) { - setStatus(status, 'Enter the base URL, API token, and library ID to test.', 'error'); + if (!fields.base_url || !hasToken || !fields.library_id || !fields.folder_id) { + setStatus(status, 'Enter the base URL, API token, library ID, and folder ID to test.', 'error'); return; } clearStatus(status); diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 0722c99..c4abe39 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -445,8 +445,9 @@
- - + + +

Open the target library in Audiobookshelf and copy the folder ID from the address bar.