mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Add Calibre OPDS and Audiobookshelf integration features
- Implemented functions to handle Calibre OPDS settings and test connections. - Added Audiobookshelf settings handling and test connections. - Enhanced the UI to allow users to test connections for both integrations. - Created new API endpoints for importing books from Calibre OPDS and testing Audiobookshelf connections. - Updated the find_books.html template to include an OPDS browser for searching Calibre catalogs. - Added JavaScript functionality for handling OPDS browsing and importing books. - Updated settings.html to include fields for Audiobookshelf configuration. - Enhanced job management to allow sending jobs to Audiobookshelf.
This commit is contained in:
+537
-6
@@ -106,8 +106,21 @@ from abogen.speaker_configs import (
|
|||||||
)
|
)
|
||||||
from abogen.text_extractor import extract_from_path
|
from abogen.text_extractor import extract_from_path
|
||||||
from abogen.integrations.calibre_opds import CalibreOPDSClient, CalibreOPDSError, feed_to_dict
|
from abogen.integrations.calibre_opds import CalibreOPDSClient, CalibreOPDSError, feed_to_dict
|
||||||
|
from abogen.integrations.audiobookshelf import (
|
||||||
|
AudiobookshelfClient,
|
||||||
|
AudiobookshelfConfig,
|
||||||
|
AudiobookshelfUploadError,
|
||||||
|
)
|
||||||
from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
|
from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
|
||||||
from .service import ConversionService, Job, JobStatus, PendingJob
|
from .service import (
|
||||||
|
ConversionService,
|
||||||
|
Job,
|
||||||
|
JobStatus,
|
||||||
|
PendingJob,
|
||||||
|
_build_audiobookshelf_metadata,
|
||||||
|
_existing_paths,
|
||||||
|
_load_audiobookshelf_chapters,
|
||||||
|
)
|
||||||
|
|
||||||
web_bp = Blueprint("web", __name__)
|
web_bp = Blueprint("web", __name__)
|
||||||
api_bp = Blueprint("api", __name__)
|
api_bp = Blueprint("api", __name__)
|
||||||
@@ -2190,6 +2203,239 @@ def _load_integration_settings() -> Dict[str, Dict[str, Any]]:
|
|||||||
return integrations
|
return integrations
|
||||||
|
|
||||||
|
|
||||||
|
def _stored_integration_config(name: str) -> Dict[str, Any]:
|
||||||
|
cfg = load_config() or {}
|
||||||
|
entry = cfg.get(name)
|
||||||
|
if isinstance(entry, Mapping):
|
||||||
|
return dict(entry)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _calibre_settings_from_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
|
defaults = _integration_defaults()["calibre_opds"]
|
||||||
|
stored = _stored_integration_config("calibre_opds")
|
||||||
|
|
||||||
|
base_url = str(
|
||||||
|
payload.get("base_url")
|
||||||
|
or payload.get("calibre_opds_base_url")
|
||||||
|
or stored.get("base_url")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
username = str(
|
||||||
|
payload.get("username")
|
||||||
|
or payload.get("calibre_opds_username")
|
||||||
|
or stored.get("username")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
password_input = str(
|
||||||
|
payload.get("password")
|
||||||
|
or payload.get("calibre_opds_password")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
use_saved_password = _coerce_bool(
|
||||||
|
payload.get("use_saved_password")
|
||||||
|
or payload.get("calibre_opds_use_saved_password"),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
clear_saved_password = _coerce_bool(
|
||||||
|
payload.get("clear_saved_password")
|
||||||
|
or payload.get("calibre_opds_password_clear"),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
password = ""
|
||||||
|
if password_input:
|
||||||
|
password = password_input
|
||||||
|
elif use_saved_password and not clear_saved_password:
|
||||||
|
password = str(stored.get("password") or "")
|
||||||
|
|
||||||
|
verify_ssl = _coerce_bool(
|
||||||
|
payload.get("verify_ssl")
|
||||||
|
or payload.get("calibre_opds_verify_ssl"),
|
||||||
|
defaults["verify_ssl"],
|
||||||
|
)
|
||||||
|
enabled = _coerce_bool(
|
||||||
|
payload.get("enabled")
|
||||||
|
or payload.get("calibre_opds_enabled"),
|
||||||
|
_coerce_bool(stored.get("enabled"), False),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": enabled,
|
||||||
|
"base_url": base_url,
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"verify_ssl": verify_ssl,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _audiobookshelf_settings_from_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
|
defaults = _integration_defaults()["audiobookshelf"]
|
||||||
|
stored = _stored_integration_config("audiobookshelf")
|
||||||
|
|
||||||
|
base_url = str(
|
||||||
|
payload.get("base_url")
|
||||||
|
or payload.get("audiobookshelf_base_url")
|
||||||
|
or stored.get("base_url")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
library_id = str(
|
||||||
|
payload.get("library_id")
|
||||||
|
or payload.get("audiobookshelf_library_id")
|
||||||
|
or stored.get("library_id")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
collection_id = str(
|
||||||
|
payload.get("collection_id")
|
||||||
|
or payload.get("audiobookshelf_collection_id")
|
||||||
|
or stored.get("collection_id")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
token_input = str(
|
||||||
|
payload.get("api_token")
|
||||||
|
or payload.get("audiobookshelf_api_token")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
use_saved_token = _coerce_bool(
|
||||||
|
payload.get("use_saved_token")
|
||||||
|
or payload.get("audiobookshelf_use_saved_token"),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
clear_saved_token = _coerce_bool(
|
||||||
|
payload.get("clear_saved_token")
|
||||||
|
or payload.get("audiobookshelf_api_token_clear"),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
if token_input:
|
||||||
|
api_token = token_input
|
||||||
|
elif use_saved_token and not clear_saved_token:
|
||||||
|
api_token = str(stored.get("api_token") or "")
|
||||||
|
else:
|
||||||
|
api_token = ""
|
||||||
|
|
||||||
|
verify_ssl = _coerce_bool(
|
||||||
|
payload.get("verify_ssl")
|
||||||
|
or payload.get("audiobookshelf_verify_ssl"),
|
||||||
|
defaults["verify_ssl"],
|
||||||
|
)
|
||||||
|
send_cover = _coerce_bool(
|
||||||
|
payload.get("send_cover")
|
||||||
|
or payload.get("audiobookshelf_send_cover"),
|
||||||
|
defaults["send_cover"],
|
||||||
|
)
|
||||||
|
send_chapters = _coerce_bool(
|
||||||
|
payload.get("send_chapters")
|
||||||
|
or payload.get("audiobookshelf_send_chapters"),
|
||||||
|
defaults["send_chapters"],
|
||||||
|
)
|
||||||
|
send_subtitles = _coerce_bool(
|
||||||
|
payload.get("send_subtitles")
|
||||||
|
or payload.get("audiobookshelf_send_subtitles"),
|
||||||
|
defaults["send_subtitles"],
|
||||||
|
)
|
||||||
|
auto_send = _coerce_bool(
|
||||||
|
payload.get("auto_send")
|
||||||
|
or payload.get("audiobookshelf_auto_send"),
|
||||||
|
defaults["auto_send"],
|
||||||
|
)
|
||||||
|
timeout_raw = (
|
||||||
|
payload.get("timeout")
|
||||||
|
or payload.get("audiobookshelf_timeout")
|
||||||
|
or stored.get("timeout")
|
||||||
|
or defaults["timeout"]
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
timeout = float(timeout_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout = defaults["timeout"]
|
||||||
|
|
||||||
|
enabled = _coerce_bool(
|
||||||
|
payload.get("enabled")
|
||||||
|
or payload.get("audiobookshelf_enabled"),
|
||||||
|
_coerce_bool(stored.get("enabled"), False),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": enabled,
|
||||||
|
"base_url": base_url,
|
||||||
|
"library_id": library_id,
|
||||||
|
"collection_id": collection_id,
|
||||||
|
"api_token": api_token,
|
||||||
|
"verify_ssl": verify_ssl,
|
||||||
|
"send_cover": send_cover,
|
||||||
|
"send_chapters": send_chapters,
|
||||||
|
"send_subtitles": send_subtitles,
|
||||||
|
"auto_send": auto_send,
|
||||||
|
"timeout": timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_audiobookshelf_config(settings: Mapping[str, Any]) -> Optional[AudiobookshelfConfig]:
|
||||||
|
base_url = str(settings.get("base_url") or "").strip()
|
||||||
|
api_token = str(settings.get("api_token") or "").strip()
|
||||||
|
library_id = str(settings.get("library_id") or "").strip()
|
||||||
|
if not (base_url and api_token and library_id):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
timeout = float(settings.get("timeout", 30.0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout = 30.0
|
||||||
|
return AudiobookshelfConfig(
|
||||||
|
base_url=base_url,
|
||||||
|
api_token=api_token,
|
||||||
|
library_id=library_id,
|
||||||
|
collection_id=(str(settings.get("collection_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),
|
||||||
|
send_subtitles=_coerce_bool(settings.get("send_subtitles"), False),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _calibre_integration_enabled(
|
||||||
|
integrations: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> bool:
|
||||||
|
if integrations is None:
|
||||||
|
integrations = _load_integration_settings()
|
||||||
|
payload = integrations.get("calibre_opds") if isinstance(integrations, Mapping) else None
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
return False
|
||||||
|
base_url = str(payload.get("base_url") or "").strip()
|
||||||
|
enabled_flag = _coerce_bool(payload.get("enabled"), False)
|
||||||
|
return bool(enabled_flag and base_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _audiobookshelf_manual_available() -> bool:
|
||||||
|
settings = _stored_integration_config("audiobookshelf")
|
||||||
|
if not settings:
|
||||||
|
return False
|
||||||
|
if not _coerce_bool(settings.get("enabled"), False):
|
||||||
|
return False
|
||||||
|
config = _build_audiobookshelf_config(settings)
|
||||||
|
return config is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_calibre_client(settings: Mapping[str, Any]) -> CalibreOPDSClient:
|
||||||
|
base_url = str(settings.get("base_url") or "").strip()
|
||||||
|
if not base_url:
|
||||||
|
raise ValueError("Calibre OPDS base URL is required")
|
||||||
|
username = str(settings.get("username") or "").strip() or None
|
||||||
|
password = str(settings.get("password") or "").strip() or None
|
||||||
|
verify_ssl = _coerce_bool(settings.get("verify_ssl"), True)
|
||||||
|
timeout_raw = settings.get("timeout", 15.0)
|
||||||
|
try:
|
||||||
|
timeout = float(timeout_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout = 15.0
|
||||||
|
return CalibreOPDSClient(
|
||||||
|
base_url,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=timeout,
|
||||||
|
verify=verify_ssl,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _apply_integration_form(cfg: Dict[str, Any], form: Mapping[str, Any]) -> None:
|
def _apply_integration_form(cfg: Dict[str, Any], form: Mapping[str, Any]) -> None:
|
||||||
defaults = _integration_defaults()
|
defaults = _integration_defaults()
|
||||||
|
|
||||||
@@ -2446,10 +2692,13 @@ def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
|||||||
|
|
||||||
@web_bp.get("/")
|
@web_bp.get("/")
|
||||||
def index() -> str:
|
def index() -> str:
|
||||||
|
integrations = _load_integration_settings()
|
||||||
return render_template(
|
return render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
options=_template_options(),
|
options=_template_options(),
|
||||||
settings=_load_settings(),
|
settings=_load_settings(),
|
||||||
|
integrations=integrations,
|
||||||
|
opds_available=_calibre_integration_enabled(integrations),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2463,11 +2712,222 @@ def queue_page() -> ResponseReturnValue:
|
|||||||
|
|
||||||
@web_bp.get("/find-books")
|
@web_bp.get("/find-books")
|
||||||
def find_books_page() -> ResponseReturnValue:
|
def find_books_page() -> ResponseReturnValue:
|
||||||
# Potential integration target: Standard Ebooks OPDS feed
|
integrations = _load_integration_settings()
|
||||||
# https://standardebooks.org/feeds/opds
|
return render_template(
|
||||||
# Potential integration target: Project Gutenberg OPDS search
|
"find_books.html",
|
||||||
# https://www.gutenberg.org/ebooks/search.opds/
|
integrations=integrations,
|
||||||
return render_template("find_books.html")
|
opds_available=_calibre_integration_enabled(integrations),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.get("/integrations/calibre-opds/feed")
|
||||||
|
def calibre_opds_feed() -> ResponseReturnValue:
|
||||||
|
stored_settings = _stored_integration_config("calibre_opds")
|
||||||
|
payload = {
|
||||||
|
"base_url": stored_settings.get("base_url"),
|
||||||
|
"username": stored_settings.get("username"),
|
||||||
|
"password": stored_settings.get("password"),
|
||||||
|
"verify_ssl": stored_settings.get("verify_ssl", True),
|
||||||
|
}
|
||||||
|
if not payload.get("base_url"):
|
||||||
|
return jsonify({"error": "Calibre OPDS base URL is not configured."}), 400
|
||||||
|
try:
|
||||||
|
client = _build_calibre_client(payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
href = request.args.get("href", type=str)
|
||||||
|
query = request.args.get("q", type=str)
|
||||||
|
try:
|
||||||
|
if query:
|
||||||
|
feed = client.search(query)
|
||||||
|
else:
|
||||||
|
feed = client.fetch_feed(href)
|
||||||
|
except CalibreOPDSError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"feed": feed_to_dict(feed),
|
||||||
|
"href": href or "",
|
||||||
|
"query": query or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.post("/integrations/calibre-opds/import")
|
||||||
|
def calibre_opds_import() -> ResponseReturnValue:
|
||||||
|
if not request.is_json:
|
||||||
|
return jsonify({"error": "Expected JSON payload."}), 400
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
href = str(data.get("href") or "").strip()
|
||||||
|
title = str(data.get("title") or "").strip()
|
||||||
|
if not href:
|
||||||
|
return jsonify({"error": "Download link missing."}), 400
|
||||||
|
|
||||||
|
stored_settings = _stored_integration_config("calibre_opds")
|
||||||
|
if not stored_settings or not _coerce_bool(stored_settings.get("enabled"), False):
|
||||||
|
return jsonify({"error": "Calibre OPDS integration is not enabled."}), 400
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"base_url": stored_settings.get("base_url"),
|
||||||
|
"username": stored_settings.get("username"),
|
||||||
|
"password": stored_settings.get("password"),
|
||||||
|
"verify_ssl": stored_settings.get("verify_ssl", True),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
client = _build_calibre_client(payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
resource = client.download(href)
|
||||||
|
except (CalibreOPDSError, ValueError) as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
|
||||||
|
filename = resource.filename or (f"{title}.epub" if title else "download.epub")
|
||||||
|
sanitized = secure_filename(filename) or "download.epub"
|
||||||
|
uploads_dir = Path(current_app.config["UPLOAD_FOLDER"])
|
||||||
|
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
stored_path = uploads_dir / f"{uuid.uuid4().hex}_{sanitized}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
stored_path.write_bytes(resource.content)
|
||||||
|
except OSError as exc:
|
||||||
|
return jsonify({"error": f"Unable to store downloaded book: {exc}"}), 500
|
||||||
|
|
||||||
|
try:
|
||||||
|
extraction = extract_from_path(stored_path)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
stored_path.unlink(missing_ok=True)
|
||||||
|
return jsonify({"error": f"Unable to read the downloaded book: {exc}"}), 400
|
||||||
|
|
||||||
|
settings = _load_settings()
|
||||||
|
profiles = load_profiles()
|
||||||
|
build_result = _build_pending_job_from_extraction(
|
||||||
|
stored_path=stored_path,
|
||||||
|
original_name=sanitized,
|
||||||
|
extraction=extraction,
|
||||||
|
form=MultiDict(),
|
||||||
|
settings=settings,
|
||||||
|
profiles=profiles,
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = build_result.pending
|
||||||
|
_refresh_entity_summary(pending, pending.chapters)
|
||||||
|
service = _service()
|
||||||
|
service.store_pending_job(pending)
|
||||||
|
|
||||||
|
if build_result.selected_speaker_config:
|
||||||
|
pending.applied_speaker_config = build_result.selected_speaker_config
|
||||||
|
if build_result.config_languages:
|
||||||
|
pending.speaker_voice_languages = list(build_result.config_languages)
|
||||||
|
elif isinstance(build_result.speaker_config_payload, Mapping):
|
||||||
|
languages = build_result.speaker_config_payload.get("languages")
|
||||||
|
if isinstance(languages, list):
|
||||||
|
pending.speaker_voice_languages = [code for code in languages if isinstance(code, str)]
|
||||||
|
|
||||||
|
service.store_pending_job(pending)
|
||||||
|
|
||||||
|
redirect_url = url_for("web.prepare_job", pending_id=pending.id)
|
||||||
|
return jsonify({
|
||||||
|
"pending_id": pending.id,
|
||||||
|
"redirect_url": redirect_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.post("/integrations/calibre-opds/test")
|
||||||
|
def test_calibre_opds() -> ResponseReturnValue:
|
||||||
|
if not request.is_json:
|
||||||
|
return jsonify({"error": "Expected JSON payload."}), 400
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
settings = _calibre_settings_from_payload(payload)
|
||||||
|
if not settings.get("base_url"):
|
||||||
|
return jsonify({"error": "Enter a Calibre OPDS base URL before testing."}), 400
|
||||||
|
try:
|
||||||
|
client = _build_calibre_client(settings)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
try:
|
||||||
|
feed = client.fetch_feed()
|
||||||
|
except CalibreOPDSError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
entries = len(feed.entries)
|
||||||
|
catalog_title = feed.title or "catalog"
|
||||||
|
return jsonify({
|
||||||
|
"message": f"Connected to {catalog_title}. Found {entries} item{'s' if entries != 1 else ''}.",
|
||||||
|
"entries": entries,
|
||||||
|
"title": catalog_title,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.post("/integrations/audiobookshelf/test")
|
||||||
|
def test_audiobookshelf() -> 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 testing."}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = AudiobookshelfClient(config)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
with client._open_client() as http_client: # pylint: disable=protected-access
|
||||||
|
response = http_client.get("/api/libraries", params={"lite": "true"})
|
||||||
|
response.raise_for_status()
|
||||||
|
payload_json = response.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"Audiobookshelf request failed with status {status_code}."
|
||||||
|
else:
|
||||||
|
message = f"Audiobookshelf request failed: {exc}"
|
||||||
|
return jsonify({"error": message}), 502
|
||||||
|
|
||||||
|
libraries: List[Mapping[str, Any]] = []
|
||||||
|
if isinstance(payload_json, list):
|
||||||
|
libraries = [entry for entry in payload_json if isinstance(entry, Mapping)]
|
||||||
|
elif isinstance(payload_json, Mapping):
|
||||||
|
candidates = payload_json.get("libraries") or payload_json.get("items")
|
||||||
|
if isinstance(candidates, list):
|
||||||
|
libraries = [entry for entry in candidates if isinstance(entry, Mapping)]
|
||||||
|
|
||||||
|
library_id = settings.get("library_id", "")
|
||||||
|
library_name = library_id
|
||||||
|
matched = False
|
||||||
|
for entry in libraries:
|
||||||
|
entry_id = str(entry.get("id") or "").strip()
|
||||||
|
if entry_id != library_id:
|
||||||
|
continue
|
||||||
|
matched = True
|
||||||
|
library_name = entry.get("name") or entry.get("label") or library_id
|
||||||
|
break
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
return jsonify({"error": f"Library '{library_id}' not found on Audiobookshelf."}), 400
|
||||||
|
|
||||||
|
collection_id = str(settings.get("collection_id") or "").strip()
|
||||||
|
if collection_id:
|
||||||
|
try:
|
||||||
|
with client._open_client() as http_client: # pylint: disable=protected-access
|
||||||
|
collection_resp = http_client.get(f"/api/collections/{collection_id}")
|
||||||
|
collection_resp.raise_for_status()
|
||||||
|
except Exception as exc: # pragma: no cover - network guard
|
||||||
|
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
||||||
|
if status_code:
|
||||||
|
message = f"Collection lookup failed with status {status_code}."
|
||||||
|
else:
|
||||||
|
message = f"Collection lookup failed: {exc}"
|
||||||
|
return jsonify({"error": message}), 502
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"message": f"Connected to Audiobookshelf library '{library_name}'.",
|
||||||
|
"library_id": library_id,
|
||||||
|
"collection_id": collection_id or None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@web_bp.route("/settings", methods=["GET", "POST"])
|
@web_bp.route("/settings", methods=["GET", "POST"])
|
||||||
@@ -4044,6 +4504,7 @@ def _render_jobs_panel() -> str:
|
|||||||
total_finished=len(finished_jobs),
|
total_finished=len(finished_jobs),
|
||||||
JobStatus=JobStatus,
|
JobStatus=JobStatus,
|
||||||
download_flags=download_flags,
|
download_flags=download_flags,
|
||||||
|
audiobookshelf_manual_available=_audiobookshelf_manual_available(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4219,6 +4680,76 @@ def retry_job(job_id: str) -> ResponseReturnValue:
|
|||||||
return redirect(url_for("web.job_detail", job_id=job_id))
|
return redirect(url_for("web.job_detail", job_id=job_id))
|
||||||
|
|
||||||
|
|
||||||
|
@web_bp.post("/jobs/<job_id>/audiobookshelf")
|
||||||
|
def send_job_to_audiobookshelf(job_id: str) -> ResponseReturnValue:
|
||||||
|
service = _service()
|
||||||
|
job = service.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
def _panel_response() -> ResponseReturnValue:
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return _render_jobs_panel()
|
||||||
|
return redirect(url_for("web.job_detail", job_id=job.id))
|
||||||
|
|
||||||
|
if job.status != JobStatus.COMPLETED:
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
settings = _stored_integration_config("audiobookshelf")
|
||||||
|
if not settings or not _coerce_bool(settings.get("enabled"), False):
|
||||||
|
job.add_log("Audiobookshelf upload skipped: integration is disabled.", level="warning")
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
config = _build_audiobookshelf_config(settings)
|
||||||
|
if config is None:
|
||||||
|
job.add_log(
|
||||||
|
"Audiobookshelf upload skipped: configure base URL, API token, and library ID first.",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
audio_path = _locate_job_audio(job)
|
||||||
|
if not audio_path or not audio_path.exists():
|
||||||
|
job.add_log("Audiobookshelf upload skipped: audio output not found.", level="warning")
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
cover_path = None
|
||||||
|
if config.send_cover and job.cover_image_path:
|
||||||
|
cover_candidate = job.cover_image_path
|
||||||
|
if not isinstance(cover_candidate, Path):
|
||||||
|
cover_candidate = Path(str(cover_candidate))
|
||||||
|
if cover_candidate.exists():
|
||||||
|
cover_path = cover_candidate
|
||||||
|
|
||||||
|
subtitles = _existing_paths(job.result.subtitle_paths) if config.send_subtitles else None
|
||||||
|
chapters = _load_audiobookshelf_chapters(job) if config.send_chapters else None
|
||||||
|
metadata = _build_audiobookshelf_metadata(job)
|
||||||
|
|
||||||
|
job.add_log("Audiobookshelf upload triggered manually.", level="info")
|
||||||
|
try:
|
||||||
|
client = AudiobookshelfClient(config)
|
||||||
|
client.upload_audiobook(
|
||||||
|
audio_path,
|
||||||
|
metadata=metadata,
|
||||||
|
cover_path=cover_path,
|
||||||
|
chapters=chapters,
|
||||||
|
subtitles=subtitles,
|
||||||
|
)
|
||||||
|
except AudiobookshelfUploadError as exc:
|
||||||
|
job.add_log(f"Audiobookshelf upload failed: {exc}", level="error")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive guard
|
||||||
|
job.add_log(f"Audiobookshelf integration error: {exc}", level="error")
|
||||||
|
else:
|
||||||
|
job.add_log("Audiobookshelf upload queued.", level="success")
|
||||||
|
finally:
|
||||||
|
service._persist_state()
|
||||||
|
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
|
||||||
@web_bp.post("/jobs/clear-finished")
|
@web_bp.post("/jobs/clear-finished")
|
||||||
def clear_finished_jobs() -> ResponseReturnValue:
|
def clear_finished_jobs() -> ResponseReturnValue:
|
||||||
_service().clear_finished()
|
_service().clear_finished()
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
const browser = document.querySelector('[data-role="opds-browser"]');
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
const statusEl = browser.querySelector('[data-role="opds-status"]');
|
||||||
|
const resultsEl = browser.querySelector('[data-role="opds-results"]');
|
||||||
|
const navEl = browser.querySelector('[data-role="opds-nav"]');
|
||||||
|
const searchForm = browser.querySelector('[data-role="opds-search"]');
|
||||||
|
const searchInput = searchForm?.querySelector('input[name="q"]');
|
||||||
|
const refreshButton = browser.querySelector('[data-action="opds-refresh"]');
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
query: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = (message, level) => {
|
||||||
|
if (!statusEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = message || '';
|
||||||
|
if (level) {
|
||||||
|
statusEl.dataset.state = level;
|
||||||
|
} else {
|
||||||
|
delete statusEl.dataset.state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStatus = () => setStatus('', null);
|
||||||
|
|
||||||
|
const resolveRelLink = (links, rel) => {
|
||||||
|
if (!links) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (links[rel]) {
|
||||||
|
return links[rel];
|
||||||
|
}
|
||||||
|
const key = Object.keys(links).find((entry) => entry === rel || entry.endsWith(rel));
|
||||||
|
return key ? links[key] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncate = (text, limit = 320) => {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (text.length <= limit) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return `${text.slice(0, limit - 1).trim()}…`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAuthors = (authors) => {
|
||||||
|
if (!Array.isArray(authors) || !authors.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return authors.filter((author) => !!author).join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNav = (links) => {
|
||||||
|
if (!navEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navEl.innerHTML = '';
|
||||||
|
const descriptors = [
|
||||||
|
{ key: 'up', label: 'Up' },
|
||||||
|
{ key: 'previous', label: 'Previous' },
|
||||||
|
{ key: 'next', label: 'Next' },
|
||||||
|
];
|
||||||
|
descriptors.forEach(({ key, label }) => {
|
||||||
|
const link = resolveRelLink(links, key) || resolveRelLink(links, `/${key}`);
|
||||||
|
if (!link || !link.href) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'button button--ghost';
|
||||||
|
button.dataset.href = link.href;
|
||||||
|
button.dataset.rel = key;
|
||||||
|
button.textContent = label;
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
clearStatus();
|
||||||
|
loadFeed({ href: link.href, query: state.query });
|
||||||
|
});
|
||||||
|
navEl.appendChild(button);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEntry = (entry) => {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.className = 'opds-browser__entry';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'opds-browser__entry-head';
|
||||||
|
|
||||||
|
const title = document.createElement('h3');
|
||||||
|
title.className = 'opds-browser__title';
|
||||||
|
title.textContent = entry.title || 'Untitled';
|
||||||
|
header.appendChild(title);
|
||||||
|
|
||||||
|
const authors = formatAuthors(entry.authors);
|
||||||
|
if (authors) {
|
||||||
|
const meta = document.createElement('p');
|
||||||
|
meta.className = 'opds-browser__meta';
|
||||||
|
meta.textContent = authors;
|
||||||
|
header.appendChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.appendChild(header);
|
||||||
|
|
||||||
|
if (entry.summary) {
|
||||||
|
const summary = document.createElement('p');
|
||||||
|
summary.className = 'opds-browser__summary';
|
||||||
|
summary.textContent = truncate(entry.summary, 380);
|
||||||
|
item.appendChild(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'opds-browser__actions';
|
||||||
|
|
||||||
|
const downloadLink = entry.download && entry.download.href ? entry.download.href : null;
|
||||||
|
const alternateLink = entry.alternate && entry.alternate.href ? entry.alternate.href : null;
|
||||||
|
|
||||||
|
if (downloadLink) {
|
||||||
|
const queueButton = document.createElement('button');
|
||||||
|
queueButton.type = 'button';
|
||||||
|
queueButton.className = 'button';
|
||||||
|
queueButton.textContent = 'Queue for conversion';
|
||||||
|
queueButton.addEventListener('click', () => importEntry(entry, queueButton));
|
||||||
|
actions.appendChild(queueButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alternateLink) {
|
||||||
|
const previewLink = document.createElement('a');
|
||||||
|
previewLink.className = 'button button--ghost';
|
||||||
|
previewLink.href = alternateLink;
|
||||||
|
previewLink.target = '_blank';
|
||||||
|
previewLink.rel = 'noreferrer';
|
||||||
|
previewLink.textContent = 'Open in Calibre';
|
||||||
|
actions.appendChild(previewLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actions.childElementCount) {
|
||||||
|
const fallback = document.createElement('span');
|
||||||
|
fallback.className = 'opds-browser__hint';
|
||||||
|
fallback.textContent = 'No downloadable formats exposed.';
|
||||||
|
actions.appendChild(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.appendChild(actions);
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEntries = (entries) => {
|
||||||
|
if (!resultsEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
if (!Array.isArray(entries) || !entries.length) {
|
||||||
|
const empty = document.createElement('li');
|
||||||
|
empty.className = 'opds-browser__empty';
|
||||||
|
empty.textContent = 'No books found in this view.';
|
||||||
|
resultsEl.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
fragment.appendChild(createEntry(entry));
|
||||||
|
});
|
||||||
|
resultsEl.appendChild(fragment);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importEntry = async (entry, trigger) => {
|
||||||
|
if (!entry?.download?.href) {
|
||||||
|
setStatus('This entry cannot be imported automatically.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const button = trigger;
|
||||||
|
if (button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.dataset.loading = 'true';
|
||||||
|
}
|
||||||
|
setStatus(`Queueing “${entry.title || 'Untitled'}”…`, 'loading');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/integrations/calibre-opds/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ href: entry.download.href, title: entry.title || '' }),
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || 'Unable to queue this book.');
|
||||||
|
}
|
||||||
|
setStatus('Book queued. Opening the conversion wizard…', 'success');
|
||||||
|
if (payload.redirect_url) {
|
||||||
|
window.location.href = payload.redirect_url;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : 'Unable to queue this book.', 'error');
|
||||||
|
} finally {
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
delete button.dataset.loading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFeed = async ({ href = '', query = '' } = {}) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (href) {
|
||||||
|
params.set('href', href);
|
||||||
|
}
|
||||||
|
if (query) {
|
||||||
|
params.set('q', query);
|
||||||
|
}
|
||||||
|
setStatus('Loading catalog…', 'loading');
|
||||||
|
try {
|
||||||
|
const url = `/api/integrations/calibre-opds/feed${params.toString() ? `?${params.toString()}` : ''}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || 'Unable to load the Calibre catalog.');
|
||||||
|
}
|
||||||
|
const feed = payload.feed || {};
|
||||||
|
state.query = query;
|
||||||
|
renderNav(feed.links);
|
||||||
|
renderEntries(feed.entries || []);
|
||||||
|
if (Array.isArray(feed.entries) && feed.entries.length) {
|
||||||
|
setStatus(`Showing ${feed.entries.length} item${feed.entries.length === 1 ? '' : 's'}.`, 'success');
|
||||||
|
} else {
|
||||||
|
setStatus('No books found in this view.', 'info');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : 'Unable to load the Calibre catalog.', 'error');
|
||||||
|
renderEntries([]);
|
||||||
|
if (navEl) {
|
||||||
|
navEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (searchForm && searchInput) {
|
||||||
|
searchForm.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
loadFeed({ query });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshButton && searchInput) {
|
||||||
|
refreshButton.addEventListener('click', () => {
|
||||||
|
searchInput.value = '';
|
||||||
|
loadFeed({});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFeed({});
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ const llmNavButton = navButtons.find((button) => button.dataset.section === 'llm
|
|||||||
const statusSelectors = {
|
const statusSelectors = {
|
||||||
llm: document.querySelector('[data-role="llm-preview-status"]'),
|
llm: document.querySelector('[data-role="llm-preview-status"]'),
|
||||||
normalization: document.querySelector('[data-role="normalization-preview-status"]'),
|
normalization: document.querySelector('[data-role="normalization-preview-status"]'),
|
||||||
|
calibre: document.querySelector('[data-role="calibre-test-status"]'),
|
||||||
|
audiobookshelf: document.querySelector('[data-role="audiobookshelf-test-status"]'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const outputAreas = {
|
const outputAreas = {
|
||||||
@@ -267,6 +269,61 @@ function collectNormalizationSettings() {
|
|||||||
return normalization;
|
return normalization;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectCalibreFields() {
|
||||||
|
if (!form) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const enabled = Boolean(form.querySelector('input[name="calibre_opds_enabled"]')?.checked);
|
||||||
|
const baseUrl = form.querySelector('#calibre_opds_base_url')?.value?.trim() || '';
|
||||||
|
const username = form.querySelector('#calibre_opds_username')?.value?.trim() || '';
|
||||||
|
const passwordInput = form.querySelector('#calibre_opds_password');
|
||||||
|
const password = passwordInput ? passwordInput.value : '';
|
||||||
|
const hasSecret = passwordInput?.dataset.hasSecret === 'true';
|
||||||
|
const clearSaved = Boolean(form.querySelector('input[name="calibre_opds_password_clear"]')?.checked);
|
||||||
|
const useSavedPassword = !password && hasSecret && !clearSaved;
|
||||||
|
const verify = Boolean(form.querySelector('input[name="calibre_opds_verify_ssl"]')?.checked);
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
base_url: baseUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
verify_ssl: verify,
|
||||||
|
use_saved_password: useSavedPassword,
|
||||||
|
clear_saved_password: clearSaved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAudiobookshelfFields() {
|
||||||
|
if (!form) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const baseUrl = form.querySelector('#audiobookshelf_base_url')?.value?.trim() || '';
|
||||||
|
const libraryId = form.querySelector('#audiobookshelf_library_id')?.value?.trim() || '';
|
||||||
|
const collectionId = form.querySelector('#audiobookshelf_collection_id')?.value?.trim() || '';
|
||||||
|
const tokenInput = form.querySelector('#audiobookshelf_api_token');
|
||||||
|
const apiToken = tokenInput?.value?.trim() || '';
|
||||||
|
const hasSecret = tokenInput?.dataset.hasSecret === 'true';
|
||||||
|
const clearToken = Boolean(form.querySelector('input[name="audiobookshelf_api_token_clear"]')?.checked);
|
||||||
|
const useSavedToken = !apiToken && hasSecret && !clearToken;
|
||||||
|
const timeoutInput = form.querySelector('#audiobookshelf_timeout');
|
||||||
|
const timeout = parseNumber(timeoutInput?.value, 30);
|
||||||
|
return {
|
||||||
|
enabled: Boolean(form.querySelector('input[name="audiobookshelf_enabled"]')?.checked),
|
||||||
|
auto_send: Boolean(form.querySelector('input[name="audiobookshelf_auto_send"]')?.checked),
|
||||||
|
verify_ssl: Boolean(form.querySelector('input[name="audiobookshelf_verify_ssl"]')?.checked),
|
||||||
|
base_url: baseUrl,
|
||||||
|
library_id: libraryId,
|
||||||
|
collection_id: collectionId,
|
||||||
|
api_token: apiToken,
|
||||||
|
use_saved_token: useSavedToken,
|
||||||
|
clear_saved_token: clearToken,
|
||||||
|
timeout,
|
||||||
|
send_cover: Boolean(form.querySelector('input[name="audiobookshelf_send_cover"]')?.checked),
|
||||||
|
send_chapters: Boolean(form.querySelector('input[name="audiobookshelf_send_chapters"]')?.checked),
|
||||||
|
send_subtitles: Boolean(form.querySelector('input[name="audiobookshelf_send_subtitles"]')?.checked),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function updateLLMNavState() {
|
function updateLLMNavState() {
|
||||||
if (!llmNavButton) {
|
if (!llmNavButton) {
|
||||||
return;
|
return;
|
||||||
@@ -279,6 +336,69 @@ function updateLLMNavState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testCalibre(button) {
|
||||||
|
const status = statusSelectors.calibre;
|
||||||
|
const fields = collectCalibreFields();
|
||||||
|
if (!status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fields.base_url) {
|
||||||
|
setStatus(status, 'Enter a Calibre OPDS base URL to test.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearStatus(status);
|
||||||
|
setStatus(status, 'Testing connection…');
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/integrations/calibre-opds/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(fields),
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || 'Calibre test failed.');
|
||||||
|
}
|
||||||
|
setStatus(status, payload.message || 'Connection successful.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(status, error instanceof Error ? error.message : 'Calibre test failed.', 'error');
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testAudiobookshelf(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 to test.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearStatus(status);
|
||||||
|
setStatus(status, 'Testing connection…');
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/integrations/audiobookshelf/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(fields),
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || 'Audiobookshelf test failed.');
|
||||||
|
}
|
||||||
|
setStatus(status, payload.message || 'Connection successful.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(status, error instanceof Error ? error.message : 'Audiobookshelf test 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;
|
||||||
@@ -380,6 +500,14 @@ function initActions() {
|
|||||||
if (normalizationButton) {
|
if (normalizationButton) {
|
||||||
normalizationButton.addEventListener('click', () => previewNormalization(normalizationButton));
|
normalizationButton.addEventListener('click', () => previewNormalization(normalizationButton));
|
||||||
}
|
}
|
||||||
|
const calibreTestButton = document.querySelector('[data-action="calibre-test"]');
|
||||||
|
if (calibreTestButton) {
|
||||||
|
calibreTestButton.addEventListener('click', () => testCalibre(calibreTestButton));
|
||||||
|
}
|
||||||
|
const audiobookshelfTestButton = document.querySelector('[data-action="audiobookshelf-test"]');
|
||||||
|
if (audiobookshelfTestButton) {
|
||||||
|
audiobookshelfTestButton.addEventListener('click', () => testAudiobookshelf(audiobookshelfTestButton));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initLLMStateWatchers() {
|
function initLLMStateWatchers() {
|
||||||
|
|||||||
@@ -5,29 +5,28 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__title">Find Books</div>
|
<div class="card__title">Find Books</div>
|
||||||
<p class="card__subtitle">Browse curated public-domain ebooks, then download and queue them for conversion.</p>
|
<p class="card__subtitle">Browse your Calibre OPDS catalog and queue downloads directly into the conversion wizard.</p>
|
||||||
<div class="card__body">
|
{% if not opds_available %}
|
||||||
<article class="resource-tile">
|
<div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div>
|
||||||
<h2>Standard Ebooks</h2>
|
{% else %}
|
||||||
<p>Discover meticulously produced public-domain ebooks with consistent typography, metadata, and clean EPUB sources.</p>
|
<div class="opds-browser" data-role="opds-browser">
|
||||||
<p>
|
<form class="opds-browser__search" data-role="opds-search">
|
||||||
<a class="button" href="https://standardebooks.org/ebooks" target="_blank" rel="noopener">
|
<label for="opds-search-input">Search your catalog</label>
|
||||||
Visit standardebooks.org
|
<input type="search" id="opds-search-input" name="q" placeholder="Title, author, or keyword">
|
||||||
</a>
|
<button type="submit" class="button">Search</button>
|
||||||
</p>
|
<button type="button" class="button button--ghost" data-action="opds-refresh">Reset</button>
|
||||||
<p class="hint">Opens in a new tab so you can grab EPUB downloads and drag them into abogen.</p>
|
</form>
|
||||||
</article>
|
<div class="opds-browser__status" data-role="opds-status"></div>
|
||||||
|
<div class="opds-browser__nav" data-role="opds-nav"></div>
|
||||||
<article class="resource-tile">
|
<ul class="opds-browser__results" data-role="opds-results"></ul>
|
||||||
<h2>Project Gutenberg</h2>
|
|
||||||
<p>The world’s largest public-domain ebook library, offering thousands of EPUB and plain-text editions sourced from volunteer transcriptions.</p>
|
|
||||||
<p>
|
|
||||||
<a class="button" href="https://gutenberg.org/ebooks/" target="_blank" rel="noopener">
|
|
||||||
Visit gutenberg.org
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p class="hint">Download the EPUB version for best results, then import it into abogen.</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
{% if opds_available %}
|
||||||
|
<script type="module" src="{{ url_for('static', filename='find_books.js') }}"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
<p class="upload-card__hint">Drag & drop a supported file here, or click to choose one in the next step.</p>
|
<p class="upload-card__hint">Drag & drop a supported file here, or click to choose one in the next step.</p>
|
||||||
<p class="upload-card__filename" data-role="upload-dropzone-filename" hidden></p>
|
<p class="upload-card__filename" data-role="upload-dropzone-filename" hidden></p>
|
||||||
<button type="button" class="button" data-role="open-upload-modal">Open upload & settings</button>
|
<button type="button" class="button" data-role="open-upload-modal">Open upload & settings</button>
|
||||||
|
{% if opds_available %}
|
||||||
|
<a class="button button--ghost" href="{{ url_for('web.find_books_page') }}">Browse Calibre library</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -82,6 +82,15 @@
|
|||||||
{% if flags.get('epub3') %}
|
{% if flags.get('epub3') %}
|
||||||
<a class="button button--ghost" href="{{ url_for('web.download_job_epub3', job_id=job.id) }}">Download EPUB 3</a>
|
<a class="button button--ghost" href="{{ url_for('web.download_job_epub3', job_id=job.id) }}">Download EPUB 3</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if audiobookshelf_manual_available %}
|
||||||
|
<button type="button"
|
||||||
|
class="button button--ghost"
|
||||||
|
hx-post="{{ url_for('web.send_job_to_audiobookshelf', job_id=job.id) }}"
|
||||||
|
hx-target="#jobs-panel"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Send to Audiobookshelf
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set reader_source = None %}
|
{% set reader_source = None %}
|
||||||
{% if job.status == JobStatus.COMPLETED and job.result %}
|
{% if job.status == JobStatus.COMPLETED and job.result %}
|
||||||
|
|||||||
@@ -344,7 +344,7 @@
|
|||||||
<div class="field field--inline">
|
<div class="field field--inline">
|
||||||
<div class="field__group">
|
<div class="field__group">
|
||||||
<label for="calibre_opds_password">Password</label>
|
<label for="calibre_opds_password">Password</label>
|
||||||
<input type="password" id="calibre_opds_password" name="calibre_opds_password" value="" placeholder="{% if integrations.calibre_opds.has_password %}••••••••{% endif %}">
|
<input type="password" id="calibre_opds_password" name="calibre_opds_password" value="" placeholder="{% if integrations.calibre_opds.has_password %}••••••••{% endif %}" data-has-secret="{{ 'true' if integrations.calibre_opds.has_password else 'false' }}">
|
||||||
<p class="hint">Leave blank to keep the stored password.</p>
|
<p class="hint">Leave blank to keep the stored password.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field__group field__group--checkbox">
|
<div class="field__group field__group--checkbox">
|
||||||
@@ -354,6 +354,10 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field field--inline">
|
||||||
|
<button type="button" class="button button--ghost" data-action="calibre-test">Test connection</button>
|
||||||
|
<span class="field__status" data-role="calibre-test-status" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="settings__section">
|
<fieldset class="settings__section">
|
||||||
@@ -389,7 +393,7 @@
|
|||||||
<div class="field field--inline">
|
<div class="field field--inline">
|
||||||
<div class="field__group">
|
<div class="field__group">
|
||||||
<label for="audiobookshelf_api_token">API token</label>
|
<label for="audiobookshelf_api_token">API token</label>
|
||||||
<input type="password" id="audiobookshelf_api_token" name="audiobookshelf_api_token" value="" placeholder="{% if integrations.audiobookshelf.has_api_token %}••••••••{% endif %}">
|
<input type="password" id="audiobookshelf_api_token" name="audiobookshelf_api_token" value="" placeholder="{% if integrations.audiobookshelf.has_api_token %}••••••••{% endif %}" data-has-secret="{{ 'true' if integrations.audiobookshelf.has_api_token else 'false' }}">
|
||||||
<p class="hint">Leave blank to keep the stored token.</p>
|
<p class="hint">Leave blank to keep the stored token.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field__group field__group--checkbox">
|
<div class="field__group field__group--checkbox">
|
||||||
@@ -419,6 +423,10 @@
|
|||||||
<span>Send subtitle files</span>
|
<span>Send subtitle files</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field field--inline">
|
||||||
|
<button type="button" class="button button--ghost" data-action="audiobookshelf-test">Test connection</button>
|
||||||
|
<span class="field__status" data-role="audiobookshelf-test-status" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user