feat: Update Audiobookshelf integration to support folder name or ID input and enhance related documentation

This commit is contained in:
JB
2025-10-31 14:41:07 -07:00
parent 5322c6406d
commit 1d9dcf8800
6 changed files with 103 additions and 78 deletions
+3 -3
View File
@@ -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`. - **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 librarys settings page in ABS). - **Library ID** the identifier of the target Audiobookshelf library (copy it from the librarys 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*. - **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. 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). 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 Abogens “Base URL” field. 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 Abogens “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 ```bash
curl -i "https://abs.example.com/api/libraries" \ 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. 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 Abogens 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 Abogens settings (it now verifies the library and resolves the folder name/ID) and the “Send to Audiobookshelf” button on completed jobs.
## Configuration reference ## Configuration reference
Most behaviour is controlled through the UI, but a few environment variables are helpful for automation: Most behaviour is controlled through the UI, but a few environment variables are helpful for automation:
+88 -3
View File
@@ -54,6 +54,7 @@ class AudiobookshelfClient:
normalized = config.normalized_base_url() or "" normalized = config.normalized_base_url() or ""
self._base_url = normalized.rstrip("/") or normalized self._base_url = normalized.rstrip("/") or normalized
self._client_base_url = f"{self._base_url}/" self._client_base_url = f"{self._base_url}/"
self._folder_cache: Optional[Tuple[str, str, str]] = None
def _api_path(self, suffix: str = "") -> str: def _api_path(self, suffix: str = "") -> str:
"""Join the API prefix with the provided suffix without losing proxies.""" """Join the API prefix with the provided suffix without losing proxies."""
@@ -71,8 +72,6 @@ class AudiobookshelfClient:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
if not audio_path.exists(): if not audio_path.exists():
raise AudiobookshelfUploadError(f"Audio path does not exist: {audio_path}") 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) form_fields = self._build_upload_fields(audio_path, metadata, chapters)
file_entries = self._build_file_entries(audio_path, cover_path, subtitles) file_entries = self._build_file_entries(audio_path, cover_path, subtitles)
@@ -117,13 +116,14 @@ class AudiobookshelfClient:
metadata: Dict[str, Any], metadata: Dict[str, Any],
chapters: Optional[Iterable[Dict[str, Any]]], chapters: Optional[Iterable[Dict[str, Any]]],
) -> Dict[str, str]: ) -> Dict[str, str]:
folder_id, _, _ = self._ensure_folder()
title = self._extract_title(metadata, audio_path) title = self._extract_title(metadata, audio_path)
author = self._extract_author(metadata) author = self._extract_author(metadata)
series = self._extract_series(metadata) series = self._extract_series(metadata)
fields: Dict[str, str] = { fields: Dict[str, str] = {
"library": self._config.library_id, "library": self._config.library_id,
"folder": self._config.folder_id or "", "folder": folder_id,
"title": title, "title": title,
} }
if author: if author:
@@ -180,6 +180,91 @@ class AudiobookshelfClient:
files.append((field_name, (path.name, handle, mime_type))) files.append((field_name, (path.name, handle, mime_type)))
return files 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 @staticmethod
def _extract_title(metadata: Mapping[str, Any], audio_path: Path) -> str: def _extract_title(metadata: Mapping[str, Any], audio_path: Path) -> str:
title = metadata.get("title") if isinstance(metadata, Mapping) else None title = metadata.get("title") if isinstance(metadata, Mapping) else None
+8 -68
View File
@@ -3054,75 +3054,15 @@ def test_audiobookshelf() -> ResponseReturnValue:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
try: try:
with client._open_client() as http_client: # pylint: disable=protected-access resolved_folder_id, folder_name, library_name = client.resolve_folder()
response = http_client.get(client._api_path("libraries"), params={"lite": "true"}) except AudiobookshelfUploadError as exc:
response.raise_for_status() cause = exc.__cause__
payload_json = response.json() status_code = getattr(getattr(cause, "response", None), "status_code", None)
except Exception as exc: # pragma: no cover - network guard http_status = 502 if status_code and status_code >= 500 else 400
status_code = getattr(getattr(exc, "response", None), "status_code", None) return jsonify({"error": str(exc)}), http_status
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)]
library_id = settings.get("library_id", "") library_id = settings.get("library_id", "")
library_name = library_id folder_id = resolved_folder_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
collection_id = str(settings.get("collection_id") or "").strip() collection_id = str(settings.get("collection_id") or "").strip()
if collection_id: if collection_id:
@@ -4994,7 +4934,7 @@ def send_job_to_audiobookshelf(job_id: str) -> ResponseReturnValue:
return _panel_response() return _panel_response()
if not config.folder_id: if not config.folder_id:
job.add_log( 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", level="warning",
) )
service._persist_state() service._persist_state()
+1 -1
View File
@@ -941,7 +941,7 @@ class ConversionService:
return return
if not folder_id: if not folder_id:
job.add_log( 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", level="warning",
) )
return return
+1 -1
View File
@@ -382,7 +382,7 @@ async function testAudiobookshelf(button) {
} }
const hasToken = Boolean(fields.api_token) || Boolean(fields.use_saved_token); const hasToken = Boolean(fields.api_token) || Boolean(fields.use_saved_token);
if (!fields.base_url || !hasToken || !fields.library_id || !fields.folder_id) { 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; return;
} }
clearStatus(status); clearStatus(status);
+2 -2
View File
@@ -445,9 +445,9 @@
<input type="text" id="audiobookshelf_collection_id" name="audiobookshelf_collection_id" value="{{ integrations.audiobookshelf.collection_id }}"> <input type="text" id="audiobookshelf_collection_id" name="audiobookshelf_collection_id" value="{{ integrations.audiobookshelf.collection_id }}">
</div> </div>
<div class="field__group"> <div class="field__group">
<label for="audiobookshelf_folder_id">Folder ID</label> <label for="audiobookshelf_folder_id">Folder (name or ID)</label>
<input type="text" id="audiobookshelf_folder_id" name="audiobookshelf_folder_id" value="{{ integrations.audiobookshelf.folder_id }}"> <input type="text" id="audiobookshelf_folder_id" name="audiobookshelf_folder_id" value="{{ integrations.audiobookshelf.folder_id }}">
<p class="hint">Open the target library in Audiobookshelf and copy the folder ID from the address bar.</p> <p class="hint">Enter the folder exactly as it appears in Audiobookshelf or paste the folder ID from the address bar.</p>
</div> </div>
</div> </div>
<div class="field field--inline"> <div class="field field--inline">