feat: Add folder browsing functionality to Audiobookshelf integration and enhance related settings UI

This commit is contained in:
JB
2025-11-01 06:07:29 -07:00
parent 1d9dcf8800
commit 28652d9e78
5 changed files with 232 additions and 47 deletions
+2 -2
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 (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*. - **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.
@@ -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 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 Abogens settings (it verifies the library and resolves the folder), and use 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:
+108 -38
View File
@@ -184,6 +184,27 @@ class AudiobookshelfClient:
"""Return the resolved folder (id, name, library name).""" """Return the resolved folder (id, name, library name)."""
return self._ensure_folder() 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]: def _ensure_folder(self) -> Tuple[str, str, str]:
if self._folder_cache: if self._folder_cache:
return 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." "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: try:
with self._open_client() as client: with self._open_client() as client:
response = client.get(self._api_path(f"libraries/{self._config.library_id}")) response = client.get(self._api_path(f"libraries/{self._config.library_id}"))
response.raise_for_status() response.raise_for_status()
library_payload = response.json() payload = response.json()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
status = exc.response.status_code status = exc.response.status_code
if status == 404: if status == 404:
@@ -222,48 +288,52 @@ class AudiobookshelfClient:
f"Failed to reach Audiobookshelf library '{self._config.library_id}': {exc}" f"Failed to reach Audiobookshelf library '{self._config.library_id}': {exc}"
) from exc ) from exc
library_name = str( if not isinstance(payload, Mapping):
library_payload.get("name") return self._config.library_id, []
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() library_name = str(payload.get("name") or payload.get("label") or self._config.library_id)
match: Optional[Tuple[str, str]] = None raw_folders = payload.get("libraryFolders") or payload.get("folders") or []
for folder in folders: folders = [entry for entry in raw_folders if isinstance(entry, Mapping)]
folder_id = str(folder.get("id") or "").strip() return library_name, folders
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: @staticmethod
for folder in folders: def _folder_path_candidates(folder: Mapping[str, Any]) -> List[str]:
folder_id = str(folder.get("id") or "").strip() candidates: List[str] = []
folder_name = str(folder.get("name") or folder.get("label") or "").strip() for key in ("fullPath", "fullpath", "path", "folderPath", "virtualPath"):
if folder_name and folder_name.lower() == identifier_lower: value = folder.get(key)
match = (folder_id or identifier, folder_name) if isinstance(value, str) and value.strip():
break candidates.append(value)
return candidates
if not match: @staticmethod
raise AudiobookshelfUploadError( def _folder_display_name(folder: Mapping[str, Any]) -> str:
f"Folder '{identifier}' was not found in library '{library_name}'. " name = str(folder.get("name") or folder.get("label") or "").strip()
"Enter the folder name exactly as it appears in Audiobookshelf or paste the folder ID." 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 @staticmethod
if not folder_id: def _select_folder_path(folder: Mapping[str, Any]) -> str:
raise AudiobookshelfUploadError( for candidate in AudiobookshelfClient._folder_path_candidates(folder):
f"Unable to determine folder ID for '{identifier}' in library '{library_name}'." normalized = candidate.replace("\\", "/").strip()
) if normalized:
return normalized
return ""
self._folder_cache = (folder_id, folder_name or folder_id, library_name) @staticmethod
return self._folder_cache 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 @staticmethod
def _extract_title(metadata: Mapping[str, Any], audio_path: Path) -> str: def _extract_title(metadata: Mapping[str, Any], audio_path: Path) -> str:
+38 -1
View File
@@ -2535,7 +2535,7 @@ def _build_audiobookshelf_config(settings: Mapping[str, Any]) -> Optional[Audiob
api_token=api_token, api_token=api_token,
library_id=library_id, library_id=library_id,
collection_id=(str(settings.get("collection_id") or "").strip() or None), 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), verify_ssl=_coerce_bool(settings.get("verify_ssl"), True),
send_cover=_coerce_bool(settings.get("send_cover"), True), send_cover=_coerce_bool(settings.get("send_cover"), True),
send_chapters=_coerce_bool(settings.get("send_chapters"), 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") @api_bp.post("/integrations/audiobookshelf/test")
def test_audiobookshelf() -> ResponseReturnValue: def test_audiobookshelf() -> ResponseReturnValue:
if not request.is_json: if not request.is_json:
+78 -1
View File
@@ -320,7 +320,7 @@ function collectAudiobookshelfFields() {
base_url: baseUrl, base_url: baseUrl,
library_id: libraryId, library_id: libraryId,
collection_id: collectionId, collection_id: collectionId,
folder_id: folderId, folder_id: folderId,
api_token: apiToken, api_token: apiToken,
use_saved_token: useSavedToken, use_saved_token: useSavedToken,
clear_saved_token: clearToken, 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) { async function previewNormalization(button) {
const status = statusSelectors.normalization; const status = statusSelectors.normalization;
const output = outputAreas.normalization; const output = outputAreas.normalization;
@@ -515,6 +588,10 @@ function initActions() {
if (audiobookshelfTestButton) { if (audiobookshelfTestButton) {
audiobookshelfTestButton.addEventListener('click', () => testAudiobookshelf(audiobookshelfTestButton)); audiobookshelfTestButton.addEventListener('click', () => testAudiobookshelf(audiobookshelfTestButton));
} }
const audiobookshelfBrowseButton = document.querySelector('[data-action="audiobookshelf-list-folders"]');
if (audiobookshelfBrowseButton) {
audiobookshelfBrowseButton.addEventListener('click', () => listAudiobookshelfFolders(audiobookshelfBrowseButton));
}
} }
function initLLMStateWatchers() { function initLLMStateWatchers() {
+6 -5
View File
@@ -444,11 +444,12 @@
<label for="audiobookshelf_collection_id">Collection ID (optional)</label> <label for="audiobookshelf_collection_id">Collection ID (optional)</label>
<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 (name or 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">Enter the folder exactly as it appears in Audiobookshelf or paste 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. You can also browse the available folders below.</p>
</div> <button type="button" class="button button--ghost button--small" data-action="audiobookshelf-list-folders">Browse folders</button>
</div>
</div> </div>
<div class="field field--inline"> <div class="field field--inline">
<div class="field__group"> <div class="field__group">