mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: Update Audiobookshelf integration to support folder name or ID input and enhance related documentation
This commit is contained in:
@@ -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 library’s settings page in ABS).
|
- **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*.
|
- **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 Abogen’s “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 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
|
```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 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
|
## 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:
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user