diff --git a/README.md b/README.md index 5a6baaf..8719d3a 100644 --- a/README.md +++ b/README.md @@ -152,7 +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. +- **Folder (name or ID)** – the destination folder inside that library. Enter the folder name exactly as it appears in Audiobookshelf (Abogen resolves it to the correct ID automatically), or paste the raw `folderId` from the address bar if you prefer. - **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. @@ -179,7 +179,7 @@ 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, locate your folder ID and test the API from the machine running Abogen: +After saving the proxy host, test the API from the machine running Abogen: ```bash curl -i "https://abs.example.com/api/libraries" \ @@ -188,7 +188,7 @@ curl -i "https://abs.example.com/api/libraries" \ 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 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. +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 the library and resolves the folder name/ID) 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 4af2e0c..7d7c698 100644 --- a/abogen/integrations/audiobookshelf.py +++ b/abogen/integrations/audiobookshelf.py @@ -54,6 +54,7 @@ class AudiobookshelfClient: normalized = config.normalized_base_url() or "" self._base_url = normalized.rstrip("/") or normalized self._client_base_url = f"{self._base_url}/" + self._folder_cache: Optional[Tuple[str, str, str]] = None def _api_path(self, suffix: str = "") -> str: """Join the API prefix with the provided suffix without losing proxies.""" @@ -71,8 +72,6 @@ class AudiobookshelfClient: ) -> Dict[str, Any]: if not audio_path.exists(): raise AudiobookshelfUploadError(f"Audio path does not exist: {audio_path}") - if not self._config.folder_id: - raise AudiobookshelfUploadError("Audiobookshelf folder ID is required for uploads") form_fields = self._build_upload_fields(audio_path, metadata, chapters) file_entries = self._build_file_entries(audio_path, cover_path, subtitles) @@ -117,13 +116,14 @@ class AudiobookshelfClient: metadata: Dict[str, Any], chapters: Optional[Iterable[Dict[str, Any]]], ) -> Dict[str, str]: + folder_id, _, _ = self._ensure_folder() title = self._extract_title(metadata, audio_path) author = self._extract_author(metadata) series = self._extract_series(metadata) fields: Dict[str, str] = { "library": self._config.library_id, - "folder": self._config.folder_id or "", + "folder": folder_id, "title": title, } if author: @@ -180,6 +180,91 @@ class AudiobookshelfClient: files.append((field_name, (path.name, handle, mime_type))) return files + def resolve_folder(self) -> Tuple[str, str, str]: + """Return the resolved folder (id, name, library name).""" + return self._ensure_folder() + + def _ensure_folder(self) -> Tuple[str, str, str]: + if self._folder_cache: + return self._folder_cache + + identifier = (self._config.folder_id or "").strip() + if not identifier: + raise AudiobookshelfUploadError( + "Audiobookshelf folder is required; enter the folder name or ID in Settings." + ) + + try: + with self._open_client() as client: + response = client.get(self._api_path(f"libraries/{self._config.library_id}")) + response.raise_for_status() + library_payload = response.json() + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + if status == 404: + message = f"Audiobookshelf library '{self._config.library_id}' not found." + else: + detail = (exc.response.text or "").strip() + if detail: + detail = detail[:200] + message = ( + f"Failed to load Audiobookshelf library '{self._config.library_id}' " + f"(status {status}): {detail}" + ) + else: + message = ( + f"Failed to load Audiobookshelf library '{self._config.library_id}' " + f"(status {status})." + ) + raise AudiobookshelfUploadError(message) from exc + except httpx.HTTPError as exc: + raise AudiobookshelfUploadError( + f"Failed to reach Audiobookshelf library '{self._config.library_id}': {exc}" + ) from exc + + library_name = str( + library_payload.get("name") + or library_payload.get("label") + or self._config.library_id + ) + 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)] + + identifier_lower = identifier.lower() + match: Optional[Tuple[str, str]] = None + for folder in folders: + folder_id = str(folder.get("id") or "").strip() + folder_name = str(folder.get("name") or folder.get("label") or "").strip() + if folder_id and folder_id == identifier: + match = (folder_id, folder_name or folder_id) + break + + if not match: + for folder in folders: + folder_id = str(folder.get("id") or "").strip() + folder_name = str(folder.get("name") or folder.get("label") or "").strip() + if folder_name and folder_name.lower() == identifier_lower: + match = (folder_id or identifier, folder_name) + break + + if not match: + raise AudiobookshelfUploadError( + f"Folder '{identifier}' was not found in library '{library_name}'. " + "Enter the folder name exactly as it appears in Audiobookshelf or paste the folder ID." + ) + + folder_id, folder_name = match + if not folder_id: + raise AudiobookshelfUploadError( + f"Unable to determine folder ID for '{identifier}' in library '{library_name}'." + ) + + self._folder_cache = (folder_id, folder_name or folder_id, library_name) + return self._folder_cache + @staticmethod def _extract_title(metadata: Mapping[str, Any], audio_path: Path) -> str: title = metadata.get("title") if isinstance(metadata, Mapping) else None diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 82cffb5..3bd0b7d 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -3054,75 +3054,15 @@ def test_audiobookshelf() -> ResponseReturnValue: return jsonify({"error": str(exc)}), 400 try: - with client._open_client() as http_client: # pylint: disable=protected-access - response = http_client.get(client._api_path("libraries"), params={"lite": "true"}) - response.raise_for_status() - payload_json = response.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"Audiobookshelf request failed with status {status_code}." - else: - message = f"Audiobookshelf request failed: {exc}" - return jsonify({"error": message}), 502 - - libraries: List[Mapping[str, Any]] = [] - if isinstance(payload_json, list): - libraries = [entry for entry in payload_json if isinstance(entry, Mapping)] - elif isinstance(payload_json, Mapping): - candidates = payload_json.get("libraries") or payload_json.get("items") - if isinstance(candidates, list): - libraries = [entry for entry in candidates if isinstance(entry, Mapping)] + resolved_folder_id, folder_name, library_name = client.resolve_folder() + except AudiobookshelfUploadError as exc: + cause = exc.__cause__ + status_code = getattr(getattr(cause, "response", None), "status_code", None) + http_status = 502 if status_code and status_code >= 500 else 400 + return jsonify({"error": str(exc)}), http_status library_id = settings.get("library_id", "") - library_name = library_id - matched = False - for entry in libraries: - entry_id = str(entry.get("id") or "").strip() - if entry_id != library_id: - continue - matched = True - library_name = entry.get("name") or entry.get("label") or library_id - break - - 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 + folder_id = resolved_folder_id collection_id = str(settings.get("collection_id") or "").strip() if collection_id: @@ -4994,7 +4934,7 @@ def send_job_to_audiobookshelf(job_id: str) -> ResponseReturnValue: return _panel_response() if not config.folder_id: job.add_log( - "Audiobookshelf upload skipped: configure a folder ID in the Audiobookshelf settings.", + "Audiobookshelf upload skipped: enter the folder name or ID in the Audiobookshelf settings.", level="warning", ) service._persist_state() diff --git a/abogen/web/service.py b/abogen/web/service.py index 5571ba5..3ba92a7 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -941,7 +941,7 @@ class ConversionService: return if not folder_id: job.add_log( - "Audiobookshelf upload skipped: configure a folder ID in the Audiobookshelf settings.", + "Audiobookshelf upload skipped: enter the folder name or ID in the Audiobookshelf settings.", level="warning", ) return diff --git a/abogen/web/static/settings.js b/abogen/web/static/settings.js index e18a061..36e7320 100644 --- a/abogen/web/static/settings.js +++ b/abogen/web/static/settings.js @@ -382,7 +382,7 @@ async function testAudiobookshelf(button) { } const hasToken = Boolean(fields.api_token) || Boolean(fields.use_saved_token); 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'); + setStatus(status, 'Enter the base URL, API token, library ID, and folder name or ID to test.', 'error'); return; } clearStatus(status); diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index c4abe39..80ba1fa 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -445,9 +445,9 @@
- + -

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

+

Enter the folder exactly as it appears in Audiobookshelf or paste the folder ID from the address bar.