mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
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:
@@ -0,0 +1 @@
|
|||||||
|
"""Integration clients for external services."""
|
||||||
@@ -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 {}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user