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.
This commit is contained in:
JB
2025-10-27 17:10:48 -07:00
parent 6e536c6e3b
commit de0f17cd57
7 changed files with 1211 additions and 169 deletions
+1
View File
@@ -0,0 +1 @@
"""Integration clients for external services."""
+157
View File
@@ -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 {}
+329
View File
@@ -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()
+347 -167
View File
@@ -15,6 +15,7 @@ import zipfile
from datetime import datetime from datetime import datetime
from html.parser import HTMLParser from html.parser import HTMLParser
from pathlib import Path from pathlib import Path
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple, cast from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple, cast
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
@@ -35,6 +36,7 @@ from flask.typing import ResponseReturnValue
import numpy as np import numpy as np
import soundfile as sf import soundfile as sf
from werkzeug.datastructures import MultiDict
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from abogen.chunking import ChunkLevel, build_chunks_for_chapters from abogen.chunking import ChunkLevel, build_chunks_for_chapters
@@ -103,6 +105,7 @@ from abogen.speaker_configs import (
slugify_label, slugify_label,
) )
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 .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
@@ -1967,6 +1970,31 @@ FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay", "llm_timeou
INT_SETTINGS = {"max_subtitle_words", "speaker_analysis_threshold"} 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: def _has_output_override() -> bool:
return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT")) 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) settings[key] = _normalize_setting_value(key, raw_value, defaults)
return settings 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]: def _formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
voices = entry.get("voices") or [] voices = entry.get("voices") or []
if not voices: if not voices:
@@ -2423,6 +2547,7 @@ def settings_page() -> ResponseReturnValue:
cfg = load_config() or {} cfg = load_config() or {}
cfg.update(updated) cfg.update(updated)
_apply_integration_form(cfg, form)
save_config(cfg) save_config(cfg)
clear_cached_settings() clear_cached_settings()
return redirect(url_for("web.settings_page", saved="1")) return redirect(url_for("web.settings_page", saved="1"))
@@ -2440,6 +2565,7 @@ def settings_page() -> ResponseReturnValue:
"llm_context_options": _LLM_CONTEXT_OPTIONS, "llm_context_options": _LLM_CONTEXT_OPTIONS,
"llm_ready": _llm_ready(current_settings), "llm_ready": _llm_ready(current_settings),
"normalization_samples": NORMALIZATION_SAMPLE_TEXTS, "normalization_samples": NORMALIZATION_SAMPLE_TEXTS,
"integrations": _load_integration_settings(),
} }
return render_template("settings.html", **context) 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}) 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") @web_bp.post("/jobs")
def enqueue_job() -> ResponseReturnValue: def enqueue_job() -> ResponseReturnValue:
service = _service() service = _service()
@@ -3367,179 +3701,25 @@ def enqueue_job() -> ResponseReturnValue:
assert extraction is not None assert extraction is not None
cover_path, cover_mime = _persist_cover_image(extraction, stored_path) build_result = _build_pending_job_from_extraction(
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,
stored_path=stored_path, stored_path=stored_path,
language=language, original_name=original_name,
voice=voice, extraction=extraction,
speed=speed, form=request.form,
use_gpu=use_gpu, settings=settings,
subtitle_mode=subtitle_mode, profiles=profiles,
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,
) )
pending = build_result.pending
_refresh_entity_summary(pending, pending.chapters) _refresh_entity_summary(pending, pending.chapters)
service.store_pending_job(pending) service.store_pending_job(pending)
pending.applied_speaker_config = selected_speaker_config or None if build_result.selected_speaker_config:
if config_languages: pending.applied_speaker_config = build_result.selected_speaker_config
pending.speaker_voice_languages = list(config_languages) if build_result.config_languages:
elif isinstance(speaker_config_payload, Mapping): pending.speaker_voice_languages = list(build_result.config_languages)
languages = speaker_config_payload.get("languages") elif isinstance(build_result.speaker_config_payload, Mapping):
languages = build_result.speaker_config_payload.get("languages")
if isinstance(languages, list): if isinstance(languages, list):
pending.speaker_voice_languages = [code for code in languages if isinstance(code, str)] pending.speaker_voice_languages = [code for code in languages if isinstance(code, str)]
if _wants_wizard_json(): if _wants_wizard_json():
+271 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import logging import logging
import os import os
import re
import shutil import shutil
import sys import sys
import threading import threading
@@ -14,8 +15,13 @@ from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping 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.voice_cache import bootstrap_voice_cache
from abogen.integrations.audiobookshelf import (
AudiobookshelfClient,
AudiobookshelfConfig,
AudiobookshelfUploadError,
)
def _create_set_event() -> threading.Event: 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: def _emit_job_log(job_id: str, level: str, message: str) -> None:
normalized = (level or "info").lower() normalized = (level or "info").lower()
log_level = _JOB_LEVEL_MAP.get(normalized, logging.INFO) 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 @dataclass
class PendingJob: class PendingJob:
id: str id: str
@@ -683,6 +879,7 @@ class ConversionService:
elif job.status != JobStatus.FAILED: elif job.status != JobStatus.FAILED:
job.status = JobStatus.COMPLETED job.status = JobStatus.COMPLETED
job.add_log("Job completed", level="success") job.add_log("Job completed", level="success")
self._post_completion_hooks(job)
job.finished_at = time.time() job.finished_at = time.time()
finally: finally:
job.pause_event.set() job.pause_event.set()
@@ -703,6 +900,79 @@ class ConversionService:
self._queue.remove(job_id) self._queue.remove(job_id)
self._update_queue_positions_locked() 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 ------------------------------------------------------ # Persistence ------------------------------------------------------
def _serialize_job(self, job: Job) -> Dict[str, Any]: def _serialize_job(self, job: Job) -> Dict[str, Any]:
result_audio = str(job.result.audio_path) if job.result.audio_path else None result_audio = str(job.result.audio_path) if job.result.audio_path else None
+104
View File
@@ -19,6 +19,7 @@
<button type="button" class="settings-nav__item" data-section="performance">Performance</button> <button type="button" class="settings-nav__item" data-section="performance">Performance</button>
<button type="button" class="settings-nav__item{% if not llm_ready %} is-disabled{% endif %}" data-section="llm">LLM</button> <button type="button" class="settings-nav__item{% if not llm_ready %} is-disabled{% endif %}" data-section="llm">LLM</button>
<button type="button" class="settings-nav__item" data-section="normalization">Text Normalization</button> <button type="button" class="settings-nav__item" data-section="normalization">Text Normalization</button>
<button type="button" class="settings-nav__item" data-section="integrations">Integrations</button>
</nav> </nav>
<form action="{{ url_for('web.settings_page') }}" method="post" class="settings__form"> <form action="{{ url_for('web.settings_page') }}" method="post" class="settings__form">
<div class="settings-panels"> <div class="settings-panels">
@@ -318,6 +319,108 @@
</div> </div>
</fieldset> </fieldset>
</section> </section>
<section class="settings-panel" data-section="integrations">
<fieldset class="settings__section">
<legend>Calibre OPDS</legend>
<div class="field field--choices">
<label class="toggle-pill">
<input type="checkbox" name="calibre_opds_enabled" value="true" {% if integrations.calibre_opds.enabled %}checked{% endif %}>
<span>Enable Calibre integration</span>
</label>
<label class="toggle-pill">
<input type="checkbox" name="calibre_opds_verify_ssl" value="true" {% if integrations.calibre_opds.verify_ssl %}checked{% endif %}>
<span>Verify TLS certificates</span>
</label>
</div>
<div class="field">
<label for="calibre_opds_base_url">Base URL</label>
<input type="url" id="calibre_opds_base_url" name="calibre_opds_base_url" value="{{ integrations.calibre_opds.base_url }}" placeholder="https://calibre.example.com/opds">
</div>
<div class="field">
<label for="calibre_opds_username">Username</label>
<input type="text" id="calibre_opds_username" name="calibre_opds_username" value="{{ integrations.calibre_opds.username }}">
</div>
<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 %}">
<p class="hint">Leave blank to keep the stored password.</p>
</div>
<div class="field__group field__group--checkbox">
<label class="toggle-pill">
<input type="checkbox" name="calibre_opds_password_clear" value="true">
<span>Clear saved password</span>
</label>
</div>
</div>
</fieldset>
<fieldset class="settings__section">
<legend>Audiobookshelf</legend>
<div class="field field--choices">
<label class="toggle-pill">
<input type="checkbox" name="audiobookshelf_enabled" value="true" {% if integrations.audiobookshelf.enabled %}checked{% endif %}>
<span>Enable Audiobookshelf uploads</span>
</label>
<label class="toggle-pill">
<input type="checkbox" name="audiobookshelf_auto_send" value="true" {% if integrations.audiobookshelf.auto_send %}checked{% endif %}>
<span>Upload finished jobs automatically</span>
</label>
<label class="toggle-pill">
<input type="checkbox" name="audiobookshelf_verify_ssl" value="true" {% if integrations.audiobookshelf.verify_ssl %}checked{% endif %}>
<span>Verify TLS certificates</span>
</label>
</div>
<div class="field">
<label for="audiobookshelf_base_url">Base URL</label>
<input type="url" id="audiobookshelf_base_url" name="audiobookshelf_base_url" value="{{ integrations.audiobookshelf.base_url }}" placeholder="https://abs.local:13378/api">
</div>
<div class="field field--inline">
<div class="field__group">
<label for="audiobookshelf_library_id">Library ID</label>
<input type="text" id="audiobookshelf_library_id" name="audiobookshelf_library_id" value="{{ integrations.audiobookshelf.library_id }}">
</div>
<div class="field__group">
<label for="audiobookshelf_collection_id">Collection ID (optional)</label>
<input type="text" id="audiobookshelf_collection_id" name="audiobookshelf_collection_id" value="{{ integrations.audiobookshelf.collection_id }}">
</div>
</div>
<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 %}">
<p class="hint">Leave blank to keep the stored token.</p>
</div>
<div class="field__group field__group--checkbox">
<label class="toggle-pill">
<input type="checkbox" name="audiobookshelf_api_token_clear" value="true">
<span>Clear saved token</span>
</label>
</div>
</div>
<div class="field field--inline">
<div class="field__group">
<label for="audiobookshelf_timeout">Request timeout (seconds)</label>
<input type="number" step="1" min="5" id="audiobookshelf_timeout" name="audiobookshelf_timeout" value="{{ integrations.audiobookshelf.timeout }}">
</div>
</div>
<div class="field field--choices">
<label class="toggle-pill">
<input type="checkbox" name="audiobookshelf_send_cover" value="true" {% if integrations.audiobookshelf.send_cover %}checked{% endif %}>
<span>Include cover artwork</span>
</label>
<label class="toggle-pill">
<input type="checkbox" name="audiobookshelf_send_chapters" value="true" {% if integrations.audiobookshelf.send_chapters %}checked{% endif %}>
<span>Send chapter markers</span>
</label>
<label class="toggle-pill">
<input type="checkbox" name="audiobookshelf_send_subtitles" value="true" {% if integrations.audiobookshelf.send_subtitles %}checked{% endif %}>
<span>Send subtitle files</span>
</label>
</div>
</fieldset>
</section>
</div> </div>
<div class="settings__actions"> <div class="settings__actions">
@@ -332,3 +435,4 @@
{{ super() }} {{ super() }}
<script type="module" src="{{ url_for('static', filename='settings.js') }}"></script> <script type="module" src="{{ url_for('static', filename='settings.js') }}"></script>
{% endblock %} {% endblock %}
+2 -1
View File
@@ -32,7 +32,8 @@ dependencies = [
"Flask>=3.0.3", "Flask>=3.0.3",
"numpy>=1.24.0", "numpy>=1.24.0",
"gpustat>=1.1.1", "gpustat>=1.1.1",
"num2words>=0.5.13" "num2words>=0.5.13",
"httpx>=0.27.0"
] ]
classifiers = [ classifiers = [