mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add folder browsing functionality to Audiobookshelf integration and enhance related settings UI
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 (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 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
|
## 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:
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user