diff --git a/README.md b/README.md index 8719d3a..abd9b86 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 (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. +- **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), paste the raw `folderId`, or click **Browse folders** to fetch the available folders and populate the field. - **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. @@ -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 the library and resolves the folder name/ID) 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 **Browse folders** to confirm the library contents, run **Test connection** in Abogen’s settings (it verifies the library and resolves the folder), and use 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 7d7c698..63d5371 100644 --- a/abogen/integrations/audiobookshelf.py +++ b/abogen/integrations/audiobookshelf.py @@ -184,6 +184,27 @@ class AudiobookshelfClient: """Return the resolved folder (id, name, library name).""" return self._ensure_folder() + def list_folders(self) -> List[Dict[str, str]]: + """Return all folders for the configured library.""" + library_name, folders = self._load_library_metadata() + results: List[Dict[str, str]] = [] + for folder in folders: + folder_id = str(folder.get("id") or "").strip() + if not folder_id: + continue + name = self._folder_display_name(folder) + path = self._select_folder_path(folder) + results.append( + { + "id": folder_id, + "name": name, + "path": path, + "library": library_name, + } + ) + results.sort(key=lambda entry: (entry.get("path") or entry.get("name") or entry.get("id") or "").lower()) + return results + def _ensure_folder(self) -> Tuple[str, str, str]: if self._folder_cache: return self._folder_cache @@ -194,11 +215,56 @@ class AudiobookshelfClient: "Audiobookshelf folder is required; enter the folder name or ID in Settings." ) + identifier_norm = self._normalize_identifier(identifier) + library_name, folders = self._load_library_metadata() + + # direct ID match + for folder in folders: + folder_id = str(folder.get("id") or "").strip() + if folder_id and folder_id == identifier: + folder_name = self._folder_display_name(folder) or folder_id + self._folder_cache = (folder_id, folder_name, library_name) + return self._folder_cache + + has_path_component = "/" in identifier_norm + + for folder in folders: + folder_id = str(folder.get("id") or "").strip() + if not folder_id: + continue + folder_name = self._folder_display_name(folder) + name_norm = self._normalize_identifier(folder_name) + if name_norm and name_norm == identifier_norm: + self._folder_cache = (folder_id, folder_name or folder_id, library_name) + return self._folder_cache + + for candidate in self._folder_path_candidates(folder): + candidate_norm = self._normalize_identifier(candidate) + if not candidate_norm: + continue + if candidate_norm == identifier_norm: + self._folder_cache = (folder_id, folder_name or folder_id, library_name) + return self._folder_cache + if has_path_component and candidate_norm.endswith(identifier_norm): + self._folder_cache = (folder_id, folder_name or folder_id, library_name) + return self._folder_cache + if not has_path_component: + tail = candidate_norm.split("/")[-1] + if tail and tail == identifier_norm: + self._folder_cache = (folder_id, folder_name or folder_id, library_name) + return self._folder_cache + + raise AudiobookshelfUploadError( + f"Folder '{identifier}' was not found in library '{library_name}'. " + "Enter the folder name exactly as it appears in Audiobookshelf, a trailing path segment, or paste the folder ID." + ) + + def _load_library_metadata(self) -> Tuple[str, List[Mapping[str, Any]]]: 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() + payload = response.json() except httpx.HTTPStatusError as exc: status = exc.response.status_code if status == 404: @@ -222,48 +288,52 @@ class AudiobookshelfClient: 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)] + if not isinstance(payload, Mapping): + return self._config.library_id, [] - 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 + library_name = str(payload.get("name") or payload.get("label") or self._config.library_id) + raw_folders = payload.get("libraryFolders") or payload.get("folders") or [] + folders = [entry for entry in raw_folders if isinstance(entry, Mapping)] + return library_name, folders - 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 + @staticmethod + def _folder_path_candidates(folder: Mapping[str, Any]) -> List[str]: + candidates: List[str] = [] + for key in ("fullPath", "fullpath", "path", "folderPath", "virtualPath"): + value = folder.get(key) + if isinstance(value, str) and value.strip(): + candidates.append(value) + return candidates - 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." - ) + @staticmethod + def _folder_display_name(folder: Mapping[str, Any]) -> str: + name = str(folder.get("name") or folder.get("label") or "").strip() + if name: + return name + path = AudiobookshelfClient._select_folder_path(folder) + if path: + tail = path.strip("/ ") + tail = tail.split("/")[-1] if tail else "" + if tail: + return tail + return str(folder.get("id") or "").strip() - folder_id, folder_name = match - if not folder_id: - raise AudiobookshelfUploadError( - f"Unable to determine folder ID for '{identifier}' in library '{library_name}'." - ) + @staticmethod + def _select_folder_path(folder: Mapping[str, Any]) -> str: + for candidate in AudiobookshelfClient._folder_path_candidates(folder): + normalized = candidate.replace("\\", "/").strip() + if normalized: + return normalized + return "" - self._folder_cache = (folder_id, folder_name or folder_id, library_name) - return self._folder_cache + @staticmethod + def _normalize_identifier(value: str) -> str: + token = (value or "").strip() + token = token.replace("\\", "/") + if len(token) > 1 and token[1] == ":": + token = token[2:] + token = token.strip("/ ") + return token.lower() @staticmethod def _extract_title(metadata: Mapping[str, Any], audio_path: Path) -> str: diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 3bd0b7d..a574368 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -2535,7 +2535,7 @@ def _build_audiobookshelf_config(settings: Mapping[str, Any]) -> Optional[Audiob api_token=api_token, library_id=library_id, collection_id=(str(settings.get("collection_id") or "").strip() or None), - folder_id=(str(settings.get("folder_id") or "").strip() or None), + folder_id=(str(settings.get("folder_id") or "").strip() or None), verify_ssl=_coerce_bool(settings.get("verify_ssl"), True), send_cover=_coerce_bool(settings.get("send_cover"), True), send_chapters=_coerce_bool(settings.get("send_chapters"), True), @@ -3038,6 +3038,43 @@ def test_calibre_opds() -> ResponseReturnValue: }) +@api_bp.post("/integrations/audiobookshelf/folders") +def list_audiobookshelf_folders() -> ResponseReturnValue: + if not request.is_json: + return jsonify({"error": "Expected JSON payload."}), 400 + payload = request.get_json(silent=True) or {} + settings = _audiobookshelf_settings_from_payload(payload) + config = _build_audiobookshelf_config(settings) + if config is None: + return jsonify({"error": "Provide base URL, API token, and library ID before listing folders."}), 400 + + try: + client = AudiobookshelfClient(config) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + try: + folders = client.list_folders() + 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 + + if not folders: + return jsonify({ + "message": "No folders found for this library.", + "folders": [], + }) + + total = len(folders) + label = "folder" if total == 1 else "folders" + return jsonify({ + "message": f"Found {total} {label} in this library.", + "folders": folders, + }) + + @api_bp.post("/integrations/audiobookshelf/test") def test_audiobookshelf() -> ResponseReturnValue: if not request.is_json: diff --git a/abogen/web/static/settings.js b/abogen/web/static/settings.js index 36e7320..1a526b2 100644 --- a/abogen/web/static/settings.js +++ b/abogen/web/static/settings.js @@ -320,7 +320,7 @@ function collectAudiobookshelfFields() { base_url: baseUrl, library_id: libraryId, collection_id: collectionId, - folder_id: folderId, + folder_id: folderId, api_token: apiToken, use_saved_token: useSavedToken, clear_saved_token: clearToken, @@ -406,6 +406,79 @@ async function testAudiobookshelf(button) { } } +async function listAudiobookshelfFolders(button) { + const status = statusSelectors.audiobookshelf; + const fields = collectAudiobookshelfFields(); + if (!status) { + 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 before browsing folders.', 'error'); + return; + } + clearStatus(status); + setStatus(status, 'Loading folders…'); + button.disabled = true; + try { + const response = await fetch('/api/integrations/audiobookshelf/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Folder lookup failed.'); + } + const folders = Array.isArray(payload.folders) ? payload.folders : []; + if (!folders.length) { + setStatus(status, payload.message || 'No folders found for this library.', 'info'); + return; + } + const list = folders + .map((entry, index) => { + const id = String(entry.id || '').trim(); + const name = String(entry.name || '').trim(); + const path = String(entry.path || '').trim(); + const descriptorParts = []; + if (name) { + descriptorParts.push(name); + } + if (path && (!name || path.toLowerCase() !== name.toLowerCase())) { + descriptorParts.push(path); + } + if (!descriptorParts.length && id) { + descriptorParts.push(id); + } + const descriptor = descriptorParts.join(' — ') || '(unnamed folder)'; + return `${index + 1}. ${descriptor}`; + }) + .join('\n'); + const choice = window.prompt(`Select a folder by number:\n\n${list}`, '1'); + if (choice === null) { + setStatus(status, 'Folder selection cancelled.', 'info'); + return; + } + const selection = Number.parseInt(choice, 10); + if (!Number.isFinite(selection) || selection < 1 || selection > folders.length) { + setStatus(status, 'Enter a number from the list to select a folder.', 'error'); + return; + } + const selected = folders[selection - 1]; + const folderInput = form.querySelector('#audiobookshelf_folder_id'); + if (folderInput) { + folderInput.value = selected.id || ''; + folderInput.dispatchEvent(new Event('input', { bubbles: true })); + } + const label = String(selected.name || selected.path || selected.id || '').trim() || 'selected folder'; + setStatus(status, `Selected folder '${label}'.`, 'success'); + } catch (error) { + setStatus(status, error instanceof Error ? error.message : 'Folder lookup failed.', 'error'); + } finally { + button.disabled = false; + } +} + async function previewNormalization(button) { const status = statusSelectors.normalization; const output = outputAreas.normalization; @@ -515,6 +588,10 @@ function initActions() { if (audiobookshelfTestButton) { audiobookshelfTestButton.addEventListener('click', () => testAudiobookshelf(audiobookshelfTestButton)); } + const audiobookshelfBrowseButton = document.querySelector('[data-action="audiobookshelf-list-folders"]'); + if (audiobookshelfBrowseButton) { + audiobookshelfBrowseButton.addEventListener('click', () => listAudiobookshelfFolders(audiobookshelfBrowseButton)); + } } function initLLMStateWatchers() { diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 80ba1fa..4353f3b 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -444,11 +444,12 @@ -
- - -

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

-
+
+ + +

Enter the folder exactly as it appears in Audiobookshelf or paste the folder ID. You can also browse the available folders below.

+ +