From de0f17cd5712c139ab53865b0aa9e43cfed6da32 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 27 Oct 2025 17:10:48 -0700 Subject: [PATCH] feat: Add Audiobookshelf and Calibre OPDS integration - Implemented Audiobookshelf integration for uploading audiobooks with metadata, cover, chapters, and subtitles. - Added configuration options for Audiobookshelf in the settings page, including base URL, API token, library ID, and upload preferences. - Introduced Calibre OPDS integration for fetching and downloading resources from a Calibre OPDS catalog. - Enhanced job processing to include post-completion hooks for Audiobookshelf uploads. - Updated settings template to include new integration options and fields. - Added utility functions for metadata normalization and chapter extraction. - Included HTTP client functionality for both Audiobookshelf and Calibre OPDS interactions. - Updated dependencies to include httpx for HTTP requests. --- abogen/integrations/__init__.py | 1 + abogen/integrations/audiobookshelf.py | 157 ++++++++ abogen/integrations/calibre_opds.py | 329 +++++++++++++++++ abogen/web/routes.py | 514 +++++++++++++++++--------- abogen/web/service.py | 272 +++++++++++++- abogen/web/templates/settings.html | 104 ++++++ pyproject.toml | 3 +- 7 files changed, 1211 insertions(+), 169 deletions(-) create mode 100644 abogen/integrations/__init__.py create mode 100644 abogen/integrations/audiobookshelf.py create mode 100644 abogen/integrations/calibre_opds.py diff --git a/abogen/integrations/__init__.py b/abogen/integrations/__init__.py new file mode 100644 index 0000000..06e8198 --- /dev/null +++ b/abogen/integrations/__init__.py @@ -0,0 +1 @@ +"""Integration clients for external services.""" diff --git a/abogen/integrations/audiobookshelf.py b/abogen/integrations/audiobookshelf.py new file mode 100644 index 0000000..5fe8c53 --- /dev/null +++ b/abogen/integrations/audiobookshelf.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import json +import logging +import mimetypes +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, Optional + +import httpx + +logger = logging.getLogger(__name__) + + +class AudiobookshelfUploadError(RuntimeError): + """Raised when an upload to Audiobookshelf fails.""" + + +@dataclass(frozen=True) +class AudiobookshelfConfig: + base_url: str + api_token: str + library_id: str + collection_id: Optional[str] = None + verify_ssl: bool = True + send_cover: bool = True + send_chapters: bool = True + send_subtitles: bool = True + timeout: float = 30.0 + + def normalized_base_url(self) -> str: + base = (self.base_url or "").strip() + if not base: + raise ValueError("Audiobookshelf base URL is required") + return base[:-1] if base.endswith("/") else base + + +class AudiobookshelfClient: + """Minimal client for Audiobookshelf's upload API.""" + + def __init__(self, config: AudiobookshelfConfig) -> None: + if not config.api_token: + raise ValueError("Audiobookshelf API token is required") + if not config.library_id: + raise ValueError("Audiobookshelf library ID is required") + self._config = config + self._base_url = config.normalized_base_url() + + def upload_audiobook( + self, + audio_path: Path, + *, + metadata: Dict[str, Any], + cover_path: Optional[Path] = None, + chapters: Optional[Iterable[Dict[str, Any]]] = None, + subtitles: Optional[Iterable[Path]] = None, + ) -> Dict[str, Any]: + if not audio_path.exists(): + raise AudiobookshelfUploadError(f"Audio path does not exist: {audio_path}") + session = self._create_upload_session() + upload_id = session.get("id") or session.get("uploadId") or session.get("upload_id") + 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") + + if cover_path and self._config.send_cover and cover_path.exists(): + try: + self._upload_file(upload_id, cover_path, kind="cover") + except AudiobookshelfUploadError: + logger.warning("Failed to upload cover to Audiobookshelf; continuing without cover.") + + if subtitles and self._config.send_subtitles: + 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: + headers = { + "Authorization": f"Bearer {self._config.api_token}", + "Accept": "application/json", + } + return httpx.Client( + base_url=self._base_url, + headers=headers, + timeout=self._config.timeout, + verify=self._config.verify_ssl, + ) + + def _create_upload_session(self) -> Dict[str, Any]: + 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("/api/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 = f"/api/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, + metadata: Dict[str, Any], + chapters: Optional[Iterable[Dict[str, Any]]], + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "metadata": metadata or {}, + } + 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]: + route = f"/api/uploads/{upload_id}/finish" + try: + with self._open_client() as client: + response = client.post(route, json=payload) + response.raise_for_status() + if response.content: + return json.loads(response.content.decode("utf-8")) + except httpx.HTTPStatusError as exc: + raise AudiobookshelfUploadError( + f"Audiobookshelf finalize request failed with status {exc.response.status_code}" + ) from exc + except httpx.HTTPError as exc: + raise AudiobookshelfUploadError(f"Audiobookshelf finalize request failed: {exc}") from exc + except json.JSONDecodeError: + logger.debug("Audiobookshelf finalize response was not JSON; returning empty object") + return {} diff --git a/abogen/integrations/calibre_opds.py b/abogen/integrations/calibre_opds.py new file mode 100644 index 0000000..d202282 --- /dev/null +++ b/abogen/integrations/calibre_opds.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +import dataclasses +import html +import re +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Mapping, Optional +from urllib.parse import urljoin, urlparse +from xml.etree import ElementTree as ET + +import httpx + + +ATOM_NS = "http://www.w3.org/2005/Atom" +OPDS_NS = "http://opds-spec.org/2010/catalog" +DC_NS = "http://purl.org/dc/terms/" +NS = {"atom": ATOM_NS, "opds": OPDS_NS, "dc": DC_NS} + + +_TAG_STRIP_RE = re.compile(r"<[^>]+>") +_EPUB_MIME_TYPES = { + "application/epub+zip", + "application/zip", + "application/x-zip", + "application/x-zip-compressed", +} + + +class CalibreOPDSError(RuntimeError): + """Raised when the Calibre OPDS client encounters an unrecoverable error.""" + + +@dataclass +class OPDSLink: + href: str + rel: Optional[str] = None + type: Optional[str] = None + title: Optional[str] = None + + def to_dict(self) -> Dict[str, Optional[str]]: + return { + "href": self.href, + "rel": self.rel, + "type": self.type, + "title": self.title, + } + + +@dataclass +class OPDSEntry: + id: str + title: str + authors: List[str] = field(default_factory=list) + updated: Optional[str] = None + summary: Optional[str] = None + download: Optional[OPDSLink] = None + alternate: Optional[OPDSLink] = None + thumbnail: Optional[OPDSLink] = None + links: List[OPDSLink] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "title": self.title, + "authors": list(self.authors), + "updated": self.updated, + "summary": self.summary, + "download": self.download.to_dict() if self.download else None, + "alternate": self.alternate.to_dict() if self.alternate else None, + "thumbnail": self.thumbnail.to_dict() if self.thumbnail else None, + "links": [link.to_dict() for link in self.links], + } + + +@dataclass +class OPDSFeed: + id: Optional[str] + title: Optional[str] + entries: List[OPDSEntry] + links: Dict[str, OPDSLink] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "title": self.title, + "entries": [entry.to_dict() for entry in self.entries], + "links": {key: link.to_dict() for key, link in self.links.items()}, + } + + +@dataclass +class DownloadedResource: + filename: str + mime_type: str + content: bytes + + +class CalibreOPDSClient: + """Client for interacting with a Calibre-Web OPDS catalog.""" + + def __init__( + self, + base_url: str, + *, + username: Optional[str] = None, + password: Optional[str] = None, + timeout: float = 15.0, + verify: bool = True, + ) -> None: + if not base_url: + raise ValueError("Calibre OPDS base URL is required") + normalized = base_url.strip() + if not normalized: + raise ValueError("Calibre OPDS base URL is required") + if not normalized.endswith("/"): + normalized = f"{normalized}/" + self._base_url = normalized + self._auth = None + if username: + self._auth = httpx.BasicAuth(username, password or "") + self._timeout = timeout + self._verify = verify + self._headers = { + "User-Agent": "abogen-calibre-opds/1.0", + "Accept": "application/atom+xml,application/xml;q=0.9,*/*;q=0.8", + } + + @staticmethod + def _strip_html(value: Optional[str]) -> Optional[str]: + if not value: + return None + cleaned = _TAG_STRIP_RE.sub("", value) + return html.unescape(cleaned).strip() or None + + def _make_url(self, href: Optional[str]) -> str: + if not href: + return self._base_url + href = href.strip() + if href.startswith("http://") or href.startswith("https://"): + return href + return urljoin(self._base_url, href) + + def _open_client(self) -> httpx.Client: + return httpx.Client( + auth=self._auth, + headers=dict(self._headers), + timeout=self._timeout, + verify=self._verify, + ) + + def fetch_feed(self, href: Optional[str] = None, *, params: Optional[Mapping[str, Any]] = None) -> OPDSFeed: + target = self._make_url(href) + try: + with self._open_client() as client: + response = client.get(target, params=params, follow_redirects=True) + response.raise_for_status() + except httpx.HTTPStatusError as exc: # pragma: no cover - thin wrapper + raise CalibreOPDSError(f"Calibre OPDS request failed: {exc.response.status_code}") from exc + except httpx.HTTPError as exc: # pragma: no cover - thin wrapper + raise CalibreOPDSError(f"Calibre OPDS request failed: {exc}") from exc + + return self._parse_feed(response.text, base_url=target) + + def search(self, query: str) -> OPDSFeed: + cleaned = (query or "").strip() + if not cleaned: + return self.fetch_feed() + candidates = [ + ("search", {"query": cleaned}), + ("search", {"q": cleaned}), + (None, {"search": cleaned}), + ] + last_error: Optional[Exception] = None + for path, params in candidates: + try: + return self.fetch_feed(path, params=params) + except CalibreOPDSError as exc: + last_error = exc + continue + if last_error is not None: + raise last_error + return self.fetch_feed() + + def download(self, href: str) -> DownloadedResource: + if not href: + raise ValueError("Download link missing") + target = self._make_url(href) + try: + with self._open_client() as client: + response = client.get(target, follow_redirects=True) + response.raise_for_status() + except httpx.HTTPStatusError as exc: # pragma: no cover - thin wrapper + raise CalibreOPDSError( + f"Download failed with status {exc.response.status_code}" + ) from exc + except httpx.HTTPError as exc: # pragma: no cover - thin wrapper + raise CalibreOPDSError(f"Download failed: {exc}") from exc + + mime_type = response.headers.get("Content-Type", "application/octet-stream").split(";")[0].strip() + filename = self._deduce_filename(response, target, mime_type) + return DownloadedResource(filename=filename, mime_type=mime_type, content=response.content) + + def _deduce_filename(self, response: httpx.Response, url: str, mime_type: str) -> str: + header = response.headers.get("Content-Disposition", "") + match = re.search(r'filename="?([^";]+)"?', header) + if match: + candidate = match.group(1).strip() + if candidate: + return candidate + parsed = urlparse(url) + stem = (parsed.path or "").strip("/").split("/")[-1] + if not stem: + stem = "download" + if "." not in stem: + extension = self._extension_for_mime(mime_type) + if extension: + stem = f"{stem}{extension}" + return stem + + @staticmethod + def _extension_for_mime(mime_type: str) -> str: + normalized = mime_type.lower() + if normalized in _EPUB_MIME_TYPES: + return ".epub" + if normalized == "application/pdf": + return ".pdf" + if normalized in {"text/plain", "text/html"}: + return ".txt" + return "" + + def _parse_feed(self, xml_payload: str, *, base_url: str) -> OPDSFeed: + try: + root = ET.fromstring(xml_payload) + except ET.ParseError as exc: + raise CalibreOPDSError(f"Unable to parse OPDS feed: {exc}") from exc + + feed_id = root.findtext("atom:id", default=None, namespaces=NS) + feed_title = root.findtext("atom:title", default=None, namespaces=NS) + links = self._parse_links(root.findall("atom:link", NS), base_url) + entries = [self._parse_entry(node, base_url) for node in root.findall("atom:entry", NS)] + return OPDSFeed(id=feed_id, title=feed_title, entries=entries, links=links) + + def _parse_entry(self, node: ET.Element, base_url: str) -> OPDSEntry: + entry_id = node.findtext("atom:id", default="", namespaces=NS).strip() + title = node.findtext("atom:title", default="Untitled", namespaces=NS).strip() or "Untitled" + updated = node.findtext("atom:updated", default=None, namespaces=NS) + summary = node.findtext("atom:summary", default=None, namespaces=NS) or node.findtext( + "atom:content", default=None, namespaces=NS + ) + dc_summary = node.findtext("dc:description", default=None, namespaces=NS) + summary = summary or dc_summary + cleaned_summary = self._strip_html(summary) + authors: List[str] = [] + for author_node in node.findall("atom:author", NS): + name = author_node.findtext("atom:name", default="", namespaces=NS).strip() + if name: + authors.append(name) + if not authors: + creators = node.findall("dc:creator", NS) + for creator in creators: + value = (creator.text or "").strip() + if value: + authors.append(value) + + links = node.findall("atom:link", NS) + parsed_links = self._parse_links(links, base_url) + download_link = self._select_download_link(parsed_links.values()) + alternate_link = parsed_links.get("alternate") + thumb_link = parsed_links.get("http://opds-spec.org/image/thumbnail") or parsed_links.get( + "thumbnail" + ) + return OPDSEntry( + id=entry_id or title, + title=title, + authors=authors, + updated=updated, + summary=cleaned_summary, + download=download_link, + alternate=alternate_link, + thumbnail=thumb_link, + links=list(parsed_links.values()), + ) + + def _parse_links(self, link_nodes: List[ET.Element], base_url: str) -> Dict[str, OPDSLink]: + results: Dict[str, OPDSLink] = {} + for link in link_nodes: + href = link.attrib.get("href") + if not href: + continue + rel = link.attrib.get("rel") + link_type = link.attrib.get("type") + title = link.attrib.get("title") + base_for_join = base_url or self._base_url + absolute_href = urljoin(base_for_join, href) + entry = OPDSLink(href=absolute_href, rel=rel, type=link_type, title=title) + key = rel or absolute_href + results[key] = entry + return results + + @staticmethod + def _select_download_link(links: Mapping[str, OPDSLink] | Iterable[OPDSLink]) -> Optional[OPDSLink]: + if isinstance(links, Mapping): + iterable: List[OPDSLink] = list(links.values()) + else: + iterable = list(links) + best: Optional[OPDSLink] = None + for link in iterable: + rel = (link.rel or "").lower() + if "acquisition" not in rel: + continue + mime = (link.type or "").lower() + if mime in _EPUB_MIME_TYPES: + return link + if best is None: + best = link + if best: + return best + # Fallback to first epub-like link even without explicit acquisition rel + for link in iterable: + mime = (link.type or "").lower() + if mime in _EPUB_MIME_TYPES: + return link + return iterable[0] if iterable else None + + +def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]: + """Helper used by APIs to convert a feed into JSON-serialisable payloads.""" + + return feed.to_dict() diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 0a49734..dba61b5 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -15,6 +15,7 @@ import zipfile from datetime import datetime from html.parser import HTMLParser from pathlib import Path +from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple, cast from xml.etree import ElementTree as ET @@ -35,6 +36,7 @@ from flask.typing import ResponseReturnValue import numpy as np import soundfile as sf +from werkzeug.datastructures import MultiDict from werkzeug.utils import secure_filename from abogen.chunking import ChunkLevel, build_chunks_for_chapters @@ -103,6 +105,7 @@ from abogen.speaker_configs import ( slugify_label, ) from abogen.text_extractor import extract_from_path +from abogen.integrations.calibre_opds import CalibreOPDSClient, CalibreOPDSError, feed_to_dict from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32 from .service import ConversionService, Job, JobStatus, PendingJob @@ -1967,6 +1970,31 @@ FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay", "llm_timeou INT_SETTINGS = {"max_subtitle_words", "speaker_analysis_threshold"} +def _integration_defaults() -> Dict[str, Dict[str, Any]]: + return { + "calibre_opds": { + "enabled": False, + "base_url": "", + "username": "", + "password": "", + "verify_ssl": True, + }, + "audiobookshelf": { + "enabled": False, + "base_url": "", + "api_token": "", + "library_id": "", + "collection_id": "", + "verify_ssl": True, + "send_cover": True, + "send_chapters": True, + "send_subtitles": False, + "auto_send": False, + "timeout": 30.0, + }, + } + + def _has_output_override() -> bool: return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT")) @@ -2127,6 +2155,102 @@ def _load_settings() -> Dict[str, Any]: settings[key] = _normalize_setting_value(key, raw_value, defaults) return settings + +def _load_integration_settings() -> Dict[str, Dict[str, Any]]: + defaults = _integration_defaults() + cfg = load_config() or {} + integrations: Dict[str, Dict[str, Any]] = {} + for key, default in defaults.items(): + stored = cfg.get(key) + merged: Dict[str, Any] = dict(default) + if isinstance(stored, Mapping): + for field, default_value in default.items(): + value = stored.get(field, default_value) + if isinstance(default_value, bool): + merged[field] = _coerce_bool(value, default_value) + elif isinstance(default_value, float): + try: + merged[field] = float(value) + except (TypeError, ValueError): + merged[field] = default_value + elif isinstance(default_value, int): + try: + merged[field] = int(value) + except (TypeError, ValueError): + merged[field] = default_value + else: + merged[field] = str(value or "") + if key == "calibre_opds": + merged["has_password"] = bool(isinstance(stored, Mapping) and stored.get("password")) + merged["password"] = "" + elif key == "audiobookshelf": + merged["has_api_token"] = bool(isinstance(stored, Mapping) and stored.get("api_token")) + merged["api_token"] = "" + integrations[key] = merged + return integrations + + +def _apply_integration_form(cfg: Dict[str, Any], form: Mapping[str, Any]) -> None: + defaults = _integration_defaults() + + current_calibre = dict(cfg.get("calibre_opds") or {}) + calibre_enabled = _coerce_bool(form.get("calibre_opds_enabled"), False) + calibre_base = str(form.get("calibre_opds_base_url") or current_calibre.get("base_url") or "").strip() + calibre_username = str(form.get("calibre_opds_username") or current_calibre.get("username") or "").strip() + calibre_password_input = str(form.get("calibre_opds_password") or "") + calibre_clear = _coerce_bool(form.get("calibre_opds_password_clear"), False) + if calibre_password_input: + calibre_password = calibre_password_input + elif calibre_clear: + calibre_password = "" + else: + calibre_password = str(current_calibre.get("password") or "") + calibre_verify = _coerce_bool(form.get("calibre_opds_verify_ssl"), defaults["calibre_opds"]["verify_ssl"]) + cfg["calibre_opds"] = { + "enabled": calibre_enabled, + "base_url": calibre_base, + "username": calibre_username, + "password": calibre_password, + "verify_ssl": calibre_verify, + } + + current_abs = dict(cfg.get("audiobookshelf") or {}) + abs_enabled = _coerce_bool(form.get("audiobookshelf_enabled"), False) + abs_base = str(form.get("audiobookshelf_base_url") or current_abs.get("base_url") or "").strip() + abs_library = str(form.get("audiobookshelf_library_id") or current_abs.get("library_id") or "").strip() + abs_collection = str(form.get("audiobookshelf_collection_id") or current_abs.get("collection_id") or "").strip() + abs_token_input = str(form.get("audiobookshelf_api_token") or "") + abs_token_clear = _coerce_bool(form.get("audiobookshelf_api_token_clear"), False) + if abs_token_input: + abs_token = abs_token_input + elif abs_token_clear: + abs_token = "" + else: + abs_token = str(current_abs.get("api_token") or "") + abs_verify = _coerce_bool(form.get("audiobookshelf_verify_ssl"), defaults["audiobookshelf"]["verify_ssl"]) + abs_send_cover = _coerce_bool(form.get("audiobookshelf_send_cover"), defaults["audiobookshelf"]["send_cover"]) + abs_send_chapters = _coerce_bool(form.get("audiobookshelf_send_chapters"), defaults["audiobookshelf"]["send_chapters"]) + abs_send_subtitles = _coerce_bool(form.get("audiobookshelf_send_subtitles"), defaults["audiobookshelf"]["send_subtitles"]) + abs_auto_send = _coerce_bool(form.get("audiobookshelf_auto_send"), defaults["audiobookshelf"]["auto_send"]) + timeout_raw = form.get("audiobookshelf_timeout", current_abs.get("timeout", defaults["audiobookshelf"]["timeout"])) + try: + abs_timeout = float(timeout_raw) + except (TypeError, ValueError): + abs_timeout = defaults["audiobookshelf"]["timeout"] + cfg["audiobookshelf"] = { + "enabled": abs_enabled, + "base_url": abs_base, + "api_token": abs_token, + "library_id": abs_library, + "collection_id": abs_collection, + "verify_ssl": abs_verify, + "send_cover": abs_send_cover, + "send_chapters": abs_send_chapters, + "send_subtitles": abs_send_subtitles, + "auto_send": abs_auto_send, + "timeout": abs_timeout, + } + def _formula_from_profile(entry: Dict[str, Any]) -> Optional[str]: voices = entry.get("voices") or [] if not voices: @@ -2423,6 +2547,7 @@ def settings_page() -> ResponseReturnValue: cfg = load_config() or {} cfg.update(updated) + _apply_integration_form(cfg, form) save_config(cfg) clear_cached_settings() return redirect(url_for("web.settings_page", saved="1")) @@ -2440,6 +2565,7 @@ def settings_page() -> ResponseReturnValue: "llm_context_options": _LLM_CONTEXT_OPTIONS, "llm_ready": _llm_ready(current_settings), "normalization_samples": NORMALIZATION_SAMPLE_TEXTS, + "integrations": _load_integration_settings(), } return render_template("settings.html", **context) @@ -3307,6 +3433,214 @@ def api_search_manual_override_candidates(pending_id: str) -> ResponseReturnValu return jsonify({"query": query, "limit": limit_value, "results": results}) +@dataclass +class PendingBuildResult: + pending: PendingJob + selected_speaker_config: Optional[str] + config_languages: List[str] + speaker_config_payload: Optional[Mapping[str, Any]] + + +def _build_pending_job_from_extraction( + *, + stored_path: Path, + original_name: str, + extraction: Any, + form: Mapping[str, Any], + settings: Mapping[str, Any], + profiles: Mapping[str, Any], + metadata_overrides: Optional[Mapping[str, Any]] = None, +) -> PendingBuildResult: + profiles_map = dict(profiles) + cover_path, cover_mime = _persist_cover_image(extraction, stored_path) + + if getattr(extraction, "chapters", None): + original_titles = [chapter.title for chapter in extraction.chapters] + normalized_titles = normalize_roman_numeral_titles(original_titles) + if normalized_titles != original_titles: + for chapter, new_title in zip(extraction.chapters, normalized_titles): + chapter.title = new_title + + metadata_tags = dict(getattr(extraction, "metadata", {}) or {}) + if metadata_overrides: + for key, value in metadata_overrides.items(): + if value is None: + continue + metadata_tags[str(key)] = str(value) + + total_chars = getattr(extraction, "total_characters", None) or calculate_text_length( + getattr(extraction, "combined_text", "") + ) + chapters_source = getattr(extraction, "chapters", []) or [] + total_chapter_count = len(chapters_source) + chapters_payload: List[Dict[str, Any]] = [] + for index, chapter in enumerate(chapters_source): + enabled = _should_preselect_chapter(chapter.title, chapter.text, index, total_chapter_count) + chapters_payload.append( + { + "id": f"{index:04d}", + "index": index, + "title": chapter.title, + "text": chapter.text, + "characters": calculate_text_length(chapter.text), + "enabled": enabled, + } + ) + + if not chapters_payload: + chapters_payload.append( + { + "id": "0000", + "index": 0, + "title": original_name, + "text": "", + "characters": 0, + "enabled": True, + } + ) + + _ensure_at_least_one_chapter_enabled(chapters_payload) + + language = str(form.get("language") or "a").strip() or "a" + default_voice = str(settings.get("default_voice") or "af_alloy") + base_voice = str(form.get("voice") or default_voice or "af_alloy").strip() + profile_selection = (form.get("voice_profile") or "__standard").strip() + custom_formula_raw = str(form.get("voice_formula") or "").strip() + selected_speaker_config = (form.get("speaker_config") or "").strip() + speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None + + if profile_selection in {"__standard", ""}: + profile_name = "" + custom_formula = "" + elif profile_selection == "__formula": + profile_name = "" + custom_formula = custom_formula_raw + else: + profile_name = profile_selection + custom_formula = "" + + voice, language, selected_profile = _resolve_voice_choice( + language, + base_voice, + profile_name, + custom_formula, + profiles_map, + ) + + try: + speed = float(form.get("speed", 1.0)) + except (TypeError, ValueError): + speed = 1.0 + + subtitle_mode = str(form.get("subtitle_mode") or "Disabled") + output_format = settings["output_format"] + subtitle_format = settings["subtitle_format"] + save_mode_key = settings["save_mode"] + save_mode = SAVE_MODE_LABELS.get(save_mode_key, SAVE_MODE_LABELS["save_next_to_input"]) + replace_single_newlines = settings["replace_single_newlines"] + use_gpu = settings["use_gpu"] + save_chapters_separately = settings["save_chapters_separately"] + merge_chapters_at_end = settings["merge_chapters_at_end"] or not save_chapters_separately + save_as_project = settings["save_as_project"] + separate_chapters_format = settings["separate_chapters_format"] + silence_between_chapters = settings["silence_between_chapters"] + chapter_intro_delay = settings["chapter_intro_delay"] + read_title_intro = settings["read_title_intro"] + normalize_chapter_opening_caps = settings["normalize_chapter_opening_caps"] + max_subtitle_words = settings["max_subtitle_words"] + auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"] + + chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower() + raw_chunk_level = str(form.get("chunk_level") or chunk_level_default).strip().lower() + if raw_chunk_level not in _CHUNK_LEVEL_VALUES: + raw_chunk_level = chunk_level_default if chunk_level_default in _CHUNK_LEVEL_VALUES else "paragraph" + chunk_level_value = raw_chunk_level + chunk_level_literal = cast(ChunkLevel, chunk_level_value) + + speaker_mode_value = "single" + + generate_epub3_default = bool(settings.get("generate_epub3", False)) + generate_epub3 = _coerce_bool(form.get("generate_epub3"), generate_epub3_default) + + selected_chapter_sources = [entry for entry in chapters_payload if entry.get("enabled")] + raw_chunks = build_chunks_for_chapters(selected_chapter_sources, level=chunk_level_literal) + analysis_chunks = build_chunks_for_chapters(selected_chapter_sources, level="sentence") + + analysis_threshold = _coerce_int( + settings.get("speaker_analysis_threshold"), + _DEFAULT_ANALYSIS_THRESHOLD, + minimum=1, + maximum=25, + ) + + initial_analysis = False + ( + processed_chunks, + speakers, + analysis_payload, + config_languages, + _, + ) = _prepare_speaker_metadata( + chapters=selected_chapter_sources, + chunks=raw_chunks, + analysis_chunks=analysis_chunks, + voice=voice, + voice_profile=selected_profile or None, + threshold=analysis_threshold, + run_analysis=initial_analysis, + speaker_config=speaker_config_payload, + apply_config=bool(speaker_config_payload), + ) + + pending = PendingJob( + id=uuid.uuid4().hex, + original_filename=original_name, + stored_path=stored_path, + language=language, + voice=voice, + speed=speed, + use_gpu=use_gpu, + subtitle_mode=subtitle_mode, + output_format=output_format, + save_mode=save_mode, + output_folder=None, + replace_single_newlines=replace_single_newlines, + subtitle_format=subtitle_format, + total_characters=total_chars, + save_chapters_separately=save_chapters_separately, + merge_chapters_at_end=merge_chapters_at_end, + separate_chapters_format=separate_chapters_format, + silence_between_chapters=silence_between_chapters, + save_as_project=save_as_project, + voice_profile=selected_profile or None, + max_subtitle_words=max_subtitle_words, + metadata_tags=metadata_tags, + chapters=chapters_payload, + created_at=time.time(), + cover_image_path=cover_path, + cover_image_mime=cover_mime, + chapter_intro_delay=chapter_intro_delay, + read_title_intro=bool(read_title_intro), + normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps), + auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles), + chunk_level=chunk_level_value, + speaker_mode=speaker_mode_value, + generate_epub3=generate_epub3, + chunks=processed_chunks, + speakers=speakers, + speaker_analysis=analysis_payload, + speaker_analysis_threshold=analysis_threshold, + analysis_requested=initial_analysis, + ) + + return PendingBuildResult( + pending=pending, + selected_speaker_config=selected_speaker_config or None, + config_languages=list(config_languages or []), + speaker_config_payload=speaker_config_payload, + ) + + @web_bp.post("/jobs") def enqueue_job() -> ResponseReturnValue: service = _service() @@ -3367,179 +3701,25 @@ def enqueue_job() -> ResponseReturnValue: assert extraction is not None - cover_path, cover_mime = _persist_cover_image(extraction, stored_path) - - if extraction.chapters: - original_titles = [chapter.title for chapter in extraction.chapters] - normalized_titles = normalize_roman_numeral_titles(original_titles) - if normalized_titles != original_titles: - for chapter, new_title in zip(extraction.chapters, normalized_titles): - chapter.title = new_title - - metadata_tags = extraction.metadata or {} - total_chars = extraction.total_characters or calculate_text_length(extraction.combined_text) - total_chapter_count = len(extraction.chapters) - chapters_payload: List[Dict[str, Any]] = [] - for index, chapter in enumerate(extraction.chapters): - enabled = _should_preselect_chapter( - chapter.title, - chapter.text, - index, - total_chapter_count, - ) - chapters_payload.append( - { - "id": f"{index:04d}", - "index": index, - "title": chapter.title, - "text": chapter.text, - "characters": calculate_text_length(chapter.text), - "enabled": enabled, - } - ) - - if not chapters_payload: - chapters_payload.append( - { - "id": "0000", - "index": 0, - "title": original_name, - "text": "", - "characters": 0, - "enabled": True, - } - ) - - _ensure_at_least_one_chapter_enabled(chapters_payload) - - language = request.form.get("language", "a") - base_voice = request.form.get("voice", "af_alloy") - profile_selection = (request.form.get("voice_profile") or "__standard").strip() - custom_formula_raw = request.form.get("voice_formula", "").strip() - selected_speaker_config = (request.form.get("speaker_config") or "").strip() - speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None - - if profile_selection in {"__standard", ""}: - profile_name = "" - custom_formula = "" - elif profile_selection == "__formula": - profile_name = "" - custom_formula = custom_formula_raw - else: - profile_name = profile_selection - custom_formula = "" - - voice, language, selected_profile = _resolve_voice_choice( - language, - base_voice, - profile_name, - custom_formula, - profiles, - ) - speed = float(request.form.get("speed", "1.0")) - subtitle_mode = request.form.get("subtitle_mode", "Disabled") - output_format = settings["output_format"] - subtitle_format = settings["subtitle_format"] - save_mode_key = settings["save_mode"] - save_mode = SAVE_MODE_LABELS.get(save_mode_key, SAVE_MODE_LABELS["save_next_to_input"]) - replace_single_newlines = settings["replace_single_newlines"] - use_gpu = settings["use_gpu"] - save_chapters_separately = settings["save_chapters_separately"] - merge_chapters_at_end = settings["merge_chapters_at_end"] or not save_chapters_separately - save_as_project = settings["save_as_project"] - separate_chapters_format = settings["separate_chapters_format"] - silence_between_chapters = settings["silence_between_chapters"] - chapter_intro_delay = settings["chapter_intro_delay"] - read_title_intro = settings["read_title_intro"] - normalize_chapter_opening_caps = settings["normalize_chapter_opening_caps"] - max_subtitle_words = settings["max_subtitle_words"] - auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"] - - chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower() - raw_chunk_level = (request.form.get("chunk_level") or chunk_level_default).strip().lower() - if raw_chunk_level not in _CHUNK_LEVEL_VALUES: - raw_chunk_level = chunk_level_default if chunk_level_default in _CHUNK_LEVEL_VALUES else "paragraph" - chunk_level_value = raw_chunk_level - chunk_level_literal = cast(ChunkLevel, chunk_level_value) - - speaker_mode_value = "single" - - generate_epub3_default = bool(settings.get("generate_epub3", False)) - generate_epub3 = _coerce_bool(request.form.get("generate_epub3"), generate_epub3_default) - - selected_chapter_sources = [entry for entry in chapters_payload if entry.get("enabled")] - raw_chunks = build_chunks_for_chapters(selected_chapter_sources, level=chunk_level_literal) - analysis_chunks = build_chunks_for_chapters(selected_chapter_sources, level="sentence") - - analysis_threshold = _coerce_int( - settings.get("speaker_analysis_threshold"), - _DEFAULT_ANALYSIS_THRESHOLD, - minimum=1, - maximum=25, - ) - - initial_analysis = False - processed_chunks, speakers, analysis_payload, config_languages, _ = _prepare_speaker_metadata( - chapters=selected_chapter_sources, - chunks=raw_chunks, - analysis_chunks=analysis_chunks, - voice=voice, - voice_profile=selected_profile or None, - threshold=analysis_threshold, - run_analysis=initial_analysis, - speaker_config=speaker_config_payload, - apply_config=bool(speaker_config_payload), - ) - - pending = PendingJob( - id=uuid.uuid4().hex, - original_filename=original_name, + build_result = _build_pending_job_from_extraction( stored_path=stored_path, - language=language, - voice=voice, - speed=speed, - use_gpu=use_gpu, - subtitle_mode=subtitle_mode, - output_format=output_format, - save_mode=save_mode, - output_folder=None, - replace_single_newlines=replace_single_newlines, - subtitle_format=subtitle_format, - total_characters=total_chars, - save_chapters_separately=save_chapters_separately, - merge_chapters_at_end=merge_chapters_at_end, - separate_chapters_format=separate_chapters_format, - silence_between_chapters=silence_between_chapters, - save_as_project=save_as_project, - voice_profile=selected_profile or None, - max_subtitle_words=max_subtitle_words, - metadata_tags=metadata_tags, - chapters=chapters_payload, - created_at=time.time(), - cover_image_path=cover_path, - cover_image_mime=cover_mime, - chapter_intro_delay=chapter_intro_delay, - read_title_intro=bool(read_title_intro), - normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps), - auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles), - chunk_level=chunk_level_value, - speaker_mode=speaker_mode_value, - generate_epub3=generate_epub3, - chunks=processed_chunks, - speakers=speakers, - speaker_analysis=analysis_payload, - speaker_analysis_threshold=analysis_threshold, - analysis_requested=initial_analysis, + original_name=original_name, + extraction=extraction, + form=request.form, + settings=settings, + profiles=profiles, ) + pending = build_result.pending _refresh_entity_summary(pending, pending.chapters) service.store_pending_job(pending) - pending.applied_speaker_config = selected_speaker_config or None - if config_languages: - pending.speaker_voice_languages = list(config_languages) - elif isinstance(speaker_config_payload, Mapping): - languages = speaker_config_payload.get("languages") + 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)] if _wants_wizard_json(): diff --git a/abogen/web/service.py b/abogen/web/service.py index 06128b1..0794450 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import logging import os +import re import shutil import sys import threading @@ -14,8 +15,13 @@ from enum import Enum from pathlib import Path from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping -from abogen.utils import get_internal_cache_path, get_user_settings_dir +from abogen.utils import get_internal_cache_path, get_user_settings_dir, load_config from abogen.voice_cache import bootstrap_voice_cache +from abogen.integrations.audiobookshelf import ( + AudiobookshelfClient, + AudiobookshelfConfig, + AudiobookshelfUploadError, +) def _create_set_event() -> threading.Event: @@ -46,6 +52,9 @@ _JOB_LEVEL_MAP: Dict[str, int] = { } +_PEOPLE_SPLIT_RE = re.compile(r"[;,/&]|\band\b", re.IGNORECASE) + + def _emit_job_log(job_id: str, level: str, message: str) -> None: normalized = (level or "info").lower() log_level = _JOB_LEVEL_MAP.get(normalized, logging.INFO) @@ -210,6 +219,193 @@ class Job: } +def _normalize_metadata_casefold(values: Optional[Mapping[str, Any]]) -> Dict[str, str]: + normalized: Dict[str, str] = {} + if not values: + return normalized + for key, value in values.items(): + if value is None: + continue + key_text = str(key).strip().lower() + if not key_text: + continue + text = str(value).strip() + if text: + normalized[key_text] = text + return normalized + + +def _split_people_field(raw: Any) -> List[str]: + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + results: List[str] = [] + for item in raw: + results.extend(_split_people_field(item)) + return results + text = str(raw or "").strip() + if not text: + return [] + tokens = [_token.strip() for _token in _PEOPLE_SPLIT_RE.split(text) if _token.strip()] + seen: set[str] = set() + ordered: List[str] = [] + for token in tokens: + key = token.casefold() + if key in seen: + continue + seen.add(key) + ordered.append(token) + return ordered + + +_LIST_SPLIT_RE = re.compile(r"[;,\n]") + + +def _split_simple_list(raw: Any) -> List[str]: + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + results: List[str] = [] + for item in raw: + results.extend(_split_simple_list(item)) + return results + text = str(raw or "").strip() + if not text: + return [] + tokens = [_token.strip() for _token in _LIST_SPLIT_RE.split(text) if _token.strip()] + seen: set[str] = set() + ordered: List[str] = [] + for token in tokens: + key = token.casefold() + if key in seen: + continue + seen.add(key) + ordered.append(token) + return ordered + + +def _first_nonempty(*values: Optional[str]) -> Optional[str]: + for value in values: + if value is None: + continue + text = str(value).strip() + if text: + return text + return None + + +def _extract_year(raw: Optional[str]) -> Optional[int]: + if not raw: + return None + text = str(raw).strip() + if not text: + return None + match = re.search(r"(19|20)\d{2}", text) + if match: + try: + return int(match.group(0)) + except ValueError: + return None + try: + parsed = int(text) + except ValueError: + return None + if 0 < parsed < 3000: + return parsed + return None + + +def _build_audiobookshelf_metadata(job: Job) -> Dict[str, Any]: + tags = _normalize_metadata_casefold(job.metadata_tags) + filename = Path(job.original_filename or "").stem or job.original_filename or "Audiobook" + title = _first_nonempty( + tags.get("title"), + tags.get("book_title"), + tags.get("name"), + tags.get("album"), + filename, + ) + authors = _split_people_field( + tags.get("authors") + or tags.get("author") + or tags.get("album_artist") + or tags.get("artist") + ) + narrators = _split_people_field(tags.get("narrators") or tags.get("narrator")) + description = _first_nonempty(tags.get("description"), tags.get("summary"), tags.get("comment")) + genres = _split_simple_list(tags.get("genre")) + keywords = _split_simple_list(tags.get("tags") or tags.get("keywords")) + language = _first_nonempty(tags.get("language"), tags.get("lang")) or job.language or "" + series_name = _first_nonempty(tags.get("series"), tags.get("series_name")) + series_sequence = _first_nonempty(tags.get("series_index"), tags.get("series_position")) + data: Dict[str, Any] = { + "title": title, + "authors": authors, + "narrators": narrators, + "description": description, + "genres": genres, + "tags": keywords, + "language": language, + "publishedYear": _extract_year(tags.get("published") or tags.get("publication_year") or tags.get("date") or tags.get("year")), + "seriesName": series_name, + "seriesSequence": series_sequence, + "isbn": _first_nonempty(tags.get("isbn"), tags.get("asin")), + } + # Remove empty values + cleaned: Dict[str, Any] = {} + for key, value in data.items(): + if value is None: + continue + if isinstance(value, str) and not value.strip(): + continue + if isinstance(value, (list, tuple)) and not value: + continue + cleaned[key] = value + return cleaned + + +def _load_audiobookshelf_chapters(job: Job) -> Optional[List[Dict[str, Any]]]: + metadata_ref = job.result.artifacts.get("metadata") + if not metadata_ref: + return None + metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref)) + if not metadata_path.exists(): + return None + try: + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + chapters = payload.get("chapters") + if not isinstance(chapters, list): + return None + cleaned: List[Dict[str, Any]] = [] + for entry in chapters: + if not isinstance(entry, Mapping): + continue + title = _first_nonempty(entry.get("title"), entry.get("original_title")) + start = entry.get("start") + end = entry.get("end") + if title is None or not isinstance(start, (int, float)): + continue + chapter_payload: Dict[str, Any] = { + "title": title, + "start": float(start), + } + if isinstance(end, (int, float)): + chapter_payload["end"] = float(end) + cleaned.append(chapter_payload) + return cleaned or None + + +def _existing_paths(paths: Iterable[Any]) -> List[Path]: + resolved: List[Path] = [] + for item in paths: + candidate = item if isinstance(item, Path) else Path(str(item)) + if candidate.exists(): + resolved.append(candidate) + return resolved + + @dataclass class PendingJob: id: str @@ -683,6 +879,7 @@ class ConversionService: elif job.status != JobStatus.FAILED: job.status = JobStatus.COMPLETED job.add_log("Job completed", level="success") + self._post_completion_hooks(job) job.finished_at = time.time() finally: job.pause_event.set() @@ -703,6 +900,79 @@ class ConversionService: self._queue.remove(job_id) self._update_queue_positions_locked() + def _post_completion_hooks(self, job: Job) -> None: + try: + self._maybe_send_to_audiobookshelf(job) + 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") + + def _maybe_send_to_audiobookshelf(self, job: Job) -> None: + cfg = load_config() or {} + integration_cfg = cfg.get("audiobookshelf") + if not isinstance(integration_cfg, Mapping): + return + enabled = self._coerce_bool(integration_cfg.get("enabled"), False) + auto_send = self._coerce_bool(integration_cfg.get("auto_send"), False) + if not (enabled and auto_send): + return + + base_url = str(integration_cfg.get("base_url") or "").strip() + api_token = str(integration_cfg.get("api_token") or "").strip() + library_id = str(integration_cfg.get("library_id") or "").strip() + if not base_url or not api_token or not library_id: + job.add_log( + "Audiobookshelf upload skipped: configure base URL, API token, and library ID first.", + level="warning", + ) + return + + 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 + if not audio_path or not audio_path.exists(): + job.add_log("Audiobookshelf upload skipped: audio output not found.", level="warning") + return + + timeout_raw = integration_cfg.get("timeout", 30.0) + try: + timeout_value = float(timeout_raw) + except (TypeError, ValueError): + timeout_value = 30.0 + + config = AudiobookshelfConfig( + base_url=base_url, + api_token=api_token, + library_id=library_id, + collection_id=(str(integration_cfg.get("collection_id") or "").strip() or None), + verify_ssl=self._coerce_bool(integration_cfg.get("verify_ssl"), True), + send_cover=self._coerce_bool(integration_cfg.get("send_cover"), True), + send_chapters=self._coerce_bool(integration_cfg.get("send_chapters"), True), + send_subtitles=self._coerce_bool(integration_cfg.get("send_subtitles"), False), + timeout=timeout_value, + ) + + cover_ref = job.cover_image_path + cover_path = None + if config.send_cover and cover_ref: + cover_candidate = cover_ref if isinstance(cover_ref, Path) else Path(str(cover_ref)) + 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) + + client = AudiobookshelfClient(config) + client.upload_audiobook( + audio_path, + metadata=metadata, + cover_path=cover_path, + chapters=chapters, + subtitles=subtitles, + ) + job.add_log("Audiobookshelf upload queued.", level="info") + # Persistence ------------------------------------------------------ def _serialize_job(self, job: Job) -> Dict[str, Any]: result_audio = str(job.result.audio_path) if job.result.audio_path else None diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index acbaf9e..d4fabed 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -19,6 +19,7 @@ +
@@ -318,6 +319,108 @@
+ +
+
+ Calibre OPDS +
+ + +
+
+ + +
+
+ + +
+
+
+ + +

Leave blank to keep the stored password.

+
+
+ +
+
+
+ +
+ Audiobookshelf +
+ + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +

Leave blank to keep the stored token.

+
+
+ +
+
+
+
+ + +
+
+
+ + + +
+
+
@@ -332,3 +435,4 @@ {{ super() }} {% endblock %} + diff --git a/pyproject.toml b/pyproject.toml index ae11488..e29d45c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,8 @@ dependencies = [ "Flask>=3.0.3", "numpy>=1.24.0", "gpustat>=1.1.1", - "num2words>=0.5.13" + "num2words>=0.5.13", + "httpx>=0.27.0" ] classifiers = [