feat: Implement Folder ID support in Audiobookshelf integration and update related documentation

This commit is contained in:
JB
2025-10-31 14:12:08 -07:00
parent 1d4316ad18
commit 5322c6406d
6 changed files with 183 additions and 94 deletions
+6 -7
View File
@@ -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`. - **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.
- **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.
@@ -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). 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, 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 ```bash
curl -i "https://abs.example.com/api/uploads" \ curl -i "https://abs.example.com/api/libraries" \
-H "Authorization: Bearer YOUR_API_TOKEN" \ -H "Authorization: Bearer YOUR_API_TOKEN"
-H "Content-Type: application/json" \
-d '{"libraryId":"YOUR_LIBRARY_ID","mediaType":"audiobook"}'
``` ```
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 Abogens 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 Abogens settings (it now verifies both the library and folder IDs) 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:
+118 -80
View File
@@ -3,9 +3,10 @@ from __future__ import annotations
import json import json
import logging import logging
import mimetypes import mimetypes
from contextlib import ExitStack
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, Optional from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
import httpx import httpx
@@ -42,7 +43,7 @@ class AudiobookshelfConfig:
class AudiobookshelfClient: class AudiobookshelfClient:
"""Minimal client for Audiobookshelf's upload API.""" """Client for the legacy Audiobookshelf multipart upload endpoint."""
def __init__(self, config: AudiobookshelfConfig) -> None: def __init__(self, config: AudiobookshelfConfig) -> None:
if not config.api_token: if not config.api_token:
@@ -70,33 +71,33 @@ 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}")
session = self._create_upload_session() if not self._config.folder_id:
upload_id = session.get("id") or session.get("uploadId") or session.get("upload_id") raise AudiobookshelfUploadError("Audiobookshelf folder ID is required for uploads")
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") 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(): route = self._api_path("upload")
try: try:
self._upload_file(upload_id, cover_path, kind="cover") with self._open_client() as client, ExitStack() as stack:
except AudiobookshelfUploadError: files_payload = self._open_file_handles(file_entries, stack)
logger.warning("Failed to upload cover to Audiobookshelf; continuing without cover.") 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: return {}
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: def _open_client(self) -> httpx.Client:
headers = { headers = {
@@ -110,61 +111,98 @@ class AudiobookshelfClient:
verify=self._config.verify_ssl, verify=self._config.verify_ssl,
) )
def _create_upload_session(self) -> Dict[str, Any]: def _build_upload_fields(
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(
self, self,
audio_path: Path,
metadata: Dict[str, Any], metadata: Dict[str, Any],
chapters: Optional[Iterable[Dict[str, Any]]], chapters: Optional[Iterable[Dict[str, Any]]],
) -> Dict[str, Any]: ) -> Dict[str, str]:
payload: Dict[str, Any] = { title = self._extract_title(metadata, audio_path)
"metadata": metadata or {}, author = self._extract_author(metadata)
} series = self._extract_series(metadata)
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]: fields: Dict[str, str] = {
route = self._api_path(f"uploads/{upload_id}/finish") "library": self._config.library_id,
try: "folder": self._config.folder_id or "",
with self._open_client() as client: "title": title,
response = client.post(route, json=payload) }
response.raise_for_status() if author:
if response.content: fields["author"] = author
return json.loads(response.content.decode("utf-8")) if series:
except httpx.HTTPStatusError as exc: fields["series"] = series
raise AudiobookshelfUploadError( if self._config.collection_id:
f"Audiobookshelf finalize request failed with status {exc.response.status_code}" fields["collectionId"] = self._config.collection_id
) from exc
except httpx.HTTPError as exc: metadata_payload: Dict[str, Any] = metadata or {}
raise AudiobookshelfUploadError(f"Audiobookshelf finalize request failed: {exc}") from exc if chapters and self._config.send_chapters:
except json.JSONDecodeError: metadata_payload = dict(metadata_payload)
logger.debug("Audiobookshelf finalize response was not JSON; returning empty object") metadata_payload["chapters"] = list(chapters)
return {}
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 ""
+46 -2
View File
@@ -3088,6 +3088,42 @@ def test_audiobookshelf() -> ResponseReturnValue:
if not matched: if not matched:
return jsonify({"error": f"Library '{library_id}' not found on Audiobookshelf."}), 400 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:
try: try:
@@ -3103,10 +3139,11 @@ def test_audiobookshelf() -> ResponseReturnValue:
return jsonify({"error": message}), 502 return jsonify({"error": message}), 502
return jsonify({ 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, "library_id": library_id,
"collection_id": collection_id or None, "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() service._persist_state()
return _panel_response() 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) audio_path = _locate_job_audio(job)
if not audio_path or not audio_path.exists(): if not audio_path or not audio_path.exists():
+8 -1
View File
@@ -932,12 +932,19 @@ class ConversionService:
base_url = str(integration_cfg.get("base_url") or "").strip() base_url = str(integration_cfg.get("base_url") or "").strip()
api_token = str(integration_cfg.get("api_token") or "").strip() api_token = str(integration_cfg.get("api_token") or "").strip()
library_id = str(integration_cfg.get("library_id") 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: if not base_url or not api_token or not library_id:
job.add_log( job.add_log(
"Audiobookshelf upload skipped: configure base URL, API token, and library ID first.", "Audiobookshelf upload skipped: configure base URL, API token, and library ID first.",
level="warning", level="warning",
) )
return 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_ref = job.result.audio_path
audio_path = audio_ref if isinstance(audio_ref, Path) else Path(str(audio_ref)) if audio_ref else None 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, api_token=api_token,
library_id=library_id, library_id=library_id,
collection_id=(str(integration_cfg.get("collection_id") or "").strip() or None), 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), verify_ssl=self._coerce_bool(integration_cfg.get("verify_ssl"), True),
send_cover=self._coerce_bool(integration_cfg.get("send_cover"), True), send_cover=self._coerce_bool(integration_cfg.get("send_cover"), True),
send_chapters=self._coerce_bool(integration_cfg.get("send_chapters"), True), send_chapters=self._coerce_bool(integration_cfg.get("send_chapters"), True),
+2 -2
View File
@@ -381,8 +381,8 @@ async function testAudiobookshelf(button) {
return; return;
} }
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) { if (!fields.base_url || !hasToken || !fields.library_id || !fields.folder_id) {
setStatus(status, 'Enter the base URL, API token, and library ID to test.', 'error'); setStatus(status, 'Enter the base URL, API token, library ID, and folder ID to test.', 'error');
return; return;
} }
clearStatus(status); clearStatus(status);
+3 -2
View File
@@ -445,8 +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 (optional)</label> <label for="audiobookshelf_folder_id">Folder 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>
</div> </div>
</div> </div>
<div class="field field--inline"> <div class="field field--inline">