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:
JB
2025-10-29 05:15:49 -07:00
parent 67f4493d06
commit 0fc32e9f3a
7 changed files with 965 additions and 32 deletions
+537 -6
View File
@@ -106,8 +106,21 @@ from abogen.speaker_configs import (
)
from abogen.text_extractor import extract_from_path
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 .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__)
api_bp = Blueprint("api", __name__)
@@ -2190,6 +2203,239 @@ def _load_integration_settings() -> Dict[str, Dict[str, Any]]:
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:
defaults = _integration_defaults()
@@ -2446,10 +2692,13 @@ def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
@web_bp.get("/")
def index() -> str:
integrations = _load_integration_settings()
return render_template(
"index.html",
options=_template_options(),
settings=_load_settings(),
integrations=integrations,
opds_available=_calibre_integration_enabled(integrations),
)
@@ -2463,11 +2712,222 @@ def queue_page() -> ResponseReturnValue:
@web_bp.get("/find-books")
def find_books_page() -> ResponseReturnValue:
# Potential integration target: Standard Ebooks OPDS feed
# https://standardebooks.org/feeds/opds
# Potential integration target: Project Gutenberg OPDS search
# https://www.gutenberg.org/ebooks/search.opds/
return render_template("find_books.html")
integrations = _load_integration_settings()
return render_template(
"find_books.html",
integrations=integrations,
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"])
@@ -4044,6 +4504,7 @@ def _render_jobs_panel() -> str:
total_finished=len(finished_jobs),
JobStatus=JobStatus,
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))
@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")
def clear_finished_jobs() -> ResponseReturnValue:
_service().clear_finished()
+255
View File
@@ -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({});
}
+128
View File
@@ -6,6 +6,8 @@ const llmNavButton = navButtons.find((button) => button.dataset.section === 'llm
const statusSelectors = {
llm: document.querySelector('[data-role="llm-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 = {
@@ -267,6 +269,61 @@ function collectNormalizationSettings() {
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() {
if (!llmNavButton) {
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) {
const status = statusSelectors.normalization;
const output = outputAreas.normalization;
@@ -380,6 +500,14 @@ function initActions() {
if (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() {
+23 -24
View File
@@ -5,29 +5,28 @@
{% block content %}
<section class="card">
<div class="card__title">Find Books</div>
<p class="card__subtitle">Browse curated public-domain ebooks, then download and queue them for conversion.</p>
<div class="card__body">
<article class="resource-tile">
<h2>Standard Ebooks</h2>
<p>Discover meticulously produced public-domain ebooks with consistent typography, metadata, and clean EPUB sources.</p>
<p>
<a class="button" href="https://standardebooks.org/ebooks" target="_blank" rel="noopener">
Visit standardebooks.org
</a>
</p>
<p class="hint">Opens in a new tab so you can grab EPUB downloads and drag them into abogen.</p>
</article>
<article class="resource-tile">
<h2>Project Gutenberg</h2>
<p>The worlds 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>
<p class="card__subtitle">Browse your Calibre OPDS catalog and queue downloads directly into the conversion wizard.</p>
{% if not opds_available %}
<div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div>
{% else %}
<div class="opds-browser" data-role="opds-browser">
<form class="opds-browser__search" data-role="opds-search">
<label for="opds-search-input">Search your catalog</label>
<input type="search" id="opds-search-input" name="q" placeholder="Title, author, or keyword">
<button type="submit" class="button">Search</button>
<button type="button" class="button button--ghost" data-action="opds-refresh">Reset</button>
</form>
<div class="opds-browser__status" data-role="opds-status"></div>
<div class="opds-browser__nav" data-role="opds-nav"></div>
<ul class="opds-browser__results" data-role="opds-results"></ul>
</div>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
{{ super() }}
{% if opds_available %}
<script type="module" src="{{ url_for('static', filename='find_books.js') }}"></script>
{% endif %}
{% endblock %}
+3
View File
@@ -13,6 +13,9 @@
<p class="upload-card__hint">Drag &amp; 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>
<button type="button" class="button" data-role="open-upload-modal">Open upload &amp; settings</button>
{% if opds_available %}
<a class="button button--ghost" href="{{ url_for('web.find_books_page') }}">Browse Calibre library</a>
{% endif %}
</div>
</div>
</section>
+9
View File
@@ -82,6 +82,15 @@
{% if flags.get('epub3') %}
<a class="button button--ghost" href="{{ url_for('web.download_job_epub3', job_id=job.id) }}">Download EPUB 3</a>
{% 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 %}
{% set reader_source = None %}
{% if job.status == JobStatus.COMPLETED and job.result %}
+10 -2
View File
@@ -344,7 +344,7 @@
<div class="field field--inline">
<div class="field__group">
<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>
</div>
<div class="field__group field__group--checkbox">
@@ -354,6 +354,10 @@
</label>
</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 class="settings__section">
@@ -389,7 +393,7 @@
<div class="field field--inline">
<div class="field__group">
<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>
</div>
<div class="field__group field__group--checkbox">
@@ -419,6 +423,10 @@
<span>Send subtitle files</span>
</label>
</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>
</section>
</div>