mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
feat: Implement Folder ID support in Audiobookshelf integration and update related documentation
This commit is contained in:
@@ -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 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.
|
||||||
- **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 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, 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 Abogen’s 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 Abogen’s 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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
"folder": self._config.folder_id or "",
|
||||||
|
"title": title,
|
||||||
|
}
|
||||||
|
if author:
|
||||||
|
fields["author"] = author
|
||||||
|
if series:
|
||||||
|
fields["series"] = series
|
||||||
|
if self._config.collection_id:
|
||||||
|
fields["collectionId"] = self._config.collection_id
|
||||||
|
|
||||||
|
metadata_payload: Dict[str, Any] = metadata or {}
|
||||||
|
if chapters and self._config.send_chapters:
|
||||||
|
metadata_payload = dict(metadata_payload)
|
||||||
|
metadata_payload["chapters"] = list(chapters)
|
||||||
|
|
||||||
|
if metadata_payload:
|
||||||
try:
|
try:
|
||||||
with self._open_client() as client:
|
fields["metadata"] = json.dumps(metadata_payload, ensure_ascii=False)
|
||||||
response = client.post(route, json=payload)
|
except (TypeError, ValueError):
|
||||||
response.raise_for_status()
|
logger.debug("Failed to serialize Audiobookshelf metadata payload")
|
||||||
if response.content:
|
|
||||||
return json.loads(response.content.decode("utf-8"))
|
return fields
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
raise AudiobookshelfUploadError(
|
def _build_file_entries(
|
||||||
f"Audiobookshelf finalize request failed with status {exc.response.status_code}"
|
self,
|
||||||
) from exc
|
audio_path: Path,
|
||||||
except httpx.HTTPError as exc:
|
cover_path: Optional[Path],
|
||||||
raise AudiobookshelfUploadError(f"Audiobookshelf finalize request failed: {exc}") from exc
|
subtitles: Optional[Iterable[Path]],
|
||||||
except json.JSONDecodeError:
|
) -> List[Tuple[str, Path]]:
|
||||||
logger.debug("Audiobookshelf finalize response was not JSON; returning empty object")
|
entries: List[Tuple[str, Path]] = [("file0", audio_path)]
|
||||||
return {}
|
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
@@ -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():
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user