mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Add voice management functionality and voice synthesis preview
- Implemented voice management routes in `voices.py` for listing, saving, and deleting speaker configurations. - Added a test endpoint for voice synthesis preview, allowing users to test voice settings with provided text and speed. - Introduced utility functions in `voice.py` for building voice catalogs, resolving voice settings, and synthesizing audio from normalized text. - Enhanced speaker roster building and configuration application logic to support dynamic voice settings.
This commit is contained in:
@@ -23,7 +23,7 @@ class AudiobookshelfUploadError(RuntimeError):
|
|||||||
class AudiobookshelfConfig:
|
class AudiobookshelfConfig:
|
||||||
base_url: str
|
base_url: str
|
||||||
api_token: str
|
api_token: str
|
||||||
library_id: str
|
library_id: Optional[str] = None
|
||||||
collection_id: Optional[str] = None
|
collection_id: Optional[str] = None
|
||||||
folder_id: Optional[str] = None
|
folder_id: Optional[str] = None
|
||||||
verify_ssl: bool = True
|
verify_ssl: bool = True
|
||||||
@@ -50,14 +50,26 @@ class AudiobookshelfClient:
|
|||||||
def __init__(self, config: AudiobookshelfConfig) -> None:
|
def __init__(self, config: AudiobookshelfConfig) -> None:
|
||||||
if not config.api_token:
|
if not config.api_token:
|
||||||
raise ValueError("Audiobookshelf API token is required")
|
raise ValueError("Audiobookshelf API token is required")
|
||||||
if not config.library_id:
|
# library_id is now optional for discovery
|
||||||
raise ValueError("Audiobookshelf library ID is required")
|
|
||||||
self._config = config
|
self._config = config
|
||||||
normalized = config.normalized_base_url() or ""
|
normalized = config.normalized_base_url() or ""
|
||||||
self._base_url = normalized.rstrip("/") or normalized
|
self._base_url = normalized.rstrip("/") or normalized
|
||||||
self._client_base_url = f"{self._base_url}/"
|
self._client_base_url = f"{self._base_url}/"
|
||||||
self._folder_cache: Optional[Tuple[str, str, str]] = None
|
self._folder_cache: Optional[Tuple[str, str, str]] = None
|
||||||
|
|
||||||
|
def get_libraries(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch all libraries from the Audiobookshelf server."""
|
||||||
|
route = self._api_path("libraries")
|
||||||
|
try:
|
||||||
|
with self._open_client() as client:
|
||||||
|
response = client.get(route)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
# data['libraries'] is a list of library objects
|
||||||
|
return data.get("libraries", [])
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise AudiobookshelfUploadError(f"Failed to fetch libraries: {exc}") from exc
|
||||||
|
|
||||||
def _api_path(self, suffix: str = "") -> str:
|
def _api_path(self, suffix: str = "") -> str:
|
||||||
"""Join the API prefix with the provided suffix without losing proxies."""
|
"""Join the API prefix with the provided suffix without losing proxies."""
|
||||||
clean_suffix = suffix.lstrip("/")
|
clean_suffix = suffix.lstrip("/")
|
||||||
|
|||||||
+15
-2
@@ -95,9 +95,22 @@ def create_app(config: Optional[dict[str, Any]] = None) -> Flask:
|
|||||||
)
|
)
|
||||||
app.extensions["conversion_service"] = service
|
app.extensions["conversion_service"] = service
|
||||||
|
|
||||||
from .routes import web_bp, api_bp
|
from abogen.web.routes import (
|
||||||
|
main_bp,
|
||||||
|
jobs_bp,
|
||||||
|
settings_bp,
|
||||||
|
voices_bp,
|
||||||
|
entities_bp,
|
||||||
|
books_bp,
|
||||||
|
api_bp,
|
||||||
|
)
|
||||||
|
|
||||||
app.register_blueprint(web_bp)
|
app.register_blueprint(main_bp)
|
||||||
|
app.register_blueprint(jobs_bp, url_prefix="/jobs")
|
||||||
|
app.register_blueprint(settings_bp, url_prefix="/settings")
|
||||||
|
app.register_blueprint(voices_bp, url_prefix="/voices")
|
||||||
|
app.register_blueprint(entities_bp, url_prefix="/entities")
|
||||||
|
app.register_blueprint(books_bp, url_prefix="/find-books")
|
||||||
app.register_blueprint(api_bp, url_prefix="/api")
|
app.register_blueprint(api_bp, url_prefix="/api")
|
||||||
|
|
||||||
atexit.register(service.shutdown)
|
atexit.register(service.shutdown)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
from abogen.web.routes.main import main_bp
|
||||||
|
from abogen.web.routes.jobs import jobs_bp
|
||||||
|
from abogen.web.routes.settings import settings_bp
|
||||||
|
from abogen.web.routes.voices import voices_bp
|
||||||
|
from abogen.web.routes.entities import entities_bp
|
||||||
|
from abogen.web.routes.books import books_bp
|
||||||
|
from abogen.web.routes.api import api_bp
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"main_bp",
|
||||||
|
"jobs_bp",
|
||||||
|
"settings_bp",
|
||||||
|
"voices_bp",
|
||||||
|
"entities_bp",
|
||||||
|
"books_bp",
|
||||||
|
"api_bp",
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
from typing import Any, Dict, Mapping, List, Optional
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, send_file
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
|
from abogen.web.routes.utils.settings import (
|
||||||
|
load_settings,
|
||||||
|
coerce_float,
|
||||||
|
)
|
||||||
|
from abogen.voice_profiles import (
|
||||||
|
load_profiles,
|
||||||
|
save_profiles,
|
||||||
|
delete_profile,
|
||||||
|
)
|
||||||
|
from abogen.web.routes.utils.preview import synthesize_preview
|
||||||
|
from abogen.normalization_settings import (
|
||||||
|
build_llm_configuration,
|
||||||
|
build_apostrophe_config,
|
||||||
|
apply_overrides,
|
||||||
|
)
|
||||||
|
from abogen.llm_client import list_models, LLMClientError
|
||||||
|
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||||
|
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
|
||||||
|
|
||||||
|
api_bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
# --- Voice Profile Routes ---
|
||||||
|
|
||||||
|
@api_bp.get("/voice-profiles")
|
||||||
|
def api_get_voice_profiles() -> ResponseReturnValue:
|
||||||
|
profiles = load_profiles()
|
||||||
|
return jsonify(profiles)
|
||||||
|
|
||||||
|
@api_bp.post("/voice-profiles")
|
||||||
|
def api_save_voice_profile() -> ResponseReturnValue:
|
||||||
|
payload = request.get_json(force=True, silent=True) or {}
|
||||||
|
name = payload.get("name")
|
||||||
|
profile = payload.get("profile")
|
||||||
|
|
||||||
|
if not name or not profile:
|
||||||
|
return jsonify({"error": "Name and profile are required"}), 400
|
||||||
|
|
||||||
|
profiles = load_profiles()
|
||||||
|
profiles[name] = profile
|
||||||
|
save_profiles(profiles)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
@api_bp.delete("/voice-profiles/<path:name>")
|
||||||
|
def api_delete_voice_profile(name: str) -> ResponseReturnValue:
|
||||||
|
delete_profile(name)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
@api_bp.post("/speaker-preview")
|
||||||
|
def api_speaker_preview() -> ResponseReturnValue:
|
||||||
|
payload = request.get_json(force=True, silent=True) or {}
|
||||||
|
text = payload.get("text", "Hello world")
|
||||||
|
voice = payload.get("voice", "af_heart")
|
||||||
|
language = payload.get("language", "a")
|
||||||
|
speed = coerce_float(payload.get("speed"), 1.0)
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
use_gpu = settings.get("use_gpu", False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return synthesize_preview(
|
||||||
|
text=text,
|
||||||
|
voice_spec=voice,
|
||||||
|
language=language,
|
||||||
|
speed=speed,
|
||||||
|
use_gpu=use_gpu
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
# --- Integration Routes ---
|
||||||
|
|
||||||
|
@api_bp.post("/integrations/audiobookshelf/folders")
|
||||||
|
def api_abs_folders() -> ResponseReturnValue:
|
||||||
|
payload = request.get_json(force=True, silent=True) or {}
|
||||||
|
host = payload.get("host")
|
||||||
|
token = payload.get("token")
|
||||||
|
|
||||||
|
if not host or not token:
|
||||||
|
return jsonify({"error": "Host and token are required"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = AudiobookshelfConfig(base_url=host, api_token=token)
|
||||||
|
client = AudiobookshelfClient(config)
|
||||||
|
folders = client.get_libraries()
|
||||||
|
return jsonify({"folders": folders})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@api_bp.post("/integrations/audiobookshelf/test")
|
||||||
|
def api_abs_test() -> ResponseReturnValue:
|
||||||
|
payload = request.get_json(force=True, silent=True) or {}
|
||||||
|
host = payload.get("host")
|
||||||
|
token = payload.get("token")
|
||||||
|
|
||||||
|
if not host or not token:
|
||||||
|
return jsonify({"error": "Host and token are required"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = AudiobookshelfConfig(base_url=host, api_token=token)
|
||||||
|
client = AudiobookshelfClient(config)
|
||||||
|
# Just getting libraries is a good enough test
|
||||||
|
client.get_libraries()
|
||||||
|
return jsonify({"success": True})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
|
# --- LLM Routes ---
|
||||||
|
|
||||||
|
@api_bp.post("/llm/models")
|
||||||
|
def api_llm_models() -> ResponseReturnValue:
|
||||||
|
payload = request.get_json(force=True, silent=False) or {}
|
||||||
|
current_settings = load_settings()
|
||||||
|
|
||||||
|
base_url = str(payload.get("base_url") or payload.get("llm_base_url") or current_settings.get("llm_base_url") or "").strip()
|
||||||
|
if not base_url:
|
||||||
|
return jsonify({"error": "LLM base URL is required."}), 400
|
||||||
|
|
||||||
|
api_key = str(payload.get("api_key") or payload.get("llm_api_key") or current_settings.get("llm_api_key") or "")
|
||||||
|
timeout = coerce_float(payload.get("timeout"), current_settings.get("llm_timeout", 30.0))
|
||||||
|
|
||||||
|
overrides = {
|
||||||
|
"llm_base_url": base_url,
|
||||||
|
"llm_api_key": api_key,
|
||||||
|
"llm_timeout": timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = apply_overrides(current_settings, overrides)
|
||||||
|
configuration = build_llm_configuration(merged)
|
||||||
|
try:
|
||||||
|
models = list_models(configuration)
|
||||||
|
except LLMClientError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
return jsonify({"models": models})
|
||||||
|
|
||||||
|
@api_bp.post("/llm/preview")
|
||||||
|
def api_llm_preview() -> ResponseReturnValue:
|
||||||
|
payload = request.get_json(force=True, silent=False) or {}
|
||||||
|
sample_text = str(payload.get("text") or "").strip()
|
||||||
|
if not sample_text:
|
||||||
|
return jsonify({"error": "Text is required."}), 400
|
||||||
|
|
||||||
|
base_settings = load_settings()
|
||||||
|
overrides: Dict[str, Any] = {
|
||||||
|
"llm_base_url": str(
|
||||||
|
payload.get("base_url")
|
||||||
|
or payload.get("llm_base_url")
|
||||||
|
or base_settings.get("llm_base_url")
|
||||||
|
or ""
|
||||||
|
).strip(),
|
||||||
|
"llm_api_key": str(
|
||||||
|
payload.get("api_key")
|
||||||
|
or payload.get("llm_api_key")
|
||||||
|
or base_settings.get("llm_api_key")
|
||||||
|
or ""
|
||||||
|
),
|
||||||
|
"llm_model": str(
|
||||||
|
payload.get("model")
|
||||||
|
or payload.get("llm_model")
|
||||||
|
or base_settings.get("llm_model")
|
||||||
|
or ""
|
||||||
|
),
|
||||||
|
"llm_prompt": payload.get("prompt") or payload.get("llm_prompt") or base_settings.get("llm_prompt"),
|
||||||
|
"llm_context_mode": payload.get("context_mode") or base_settings.get("llm_context_mode"),
|
||||||
|
"llm_timeout": coerce_float(payload.get("timeout"), base_settings.get("llm_timeout", 30.0)),
|
||||||
|
"normalization_apostrophe_mode": "llm",
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = apply_overrides(base_settings, overrides)
|
||||||
|
if not merged.get("llm_base_url"):
|
||||||
|
return jsonify({"error": "LLM base URL is required."}), 400
|
||||||
|
if not merged.get("llm_model"):
|
||||||
|
return jsonify({"error": "Select an LLM model before previewing."}), 400
|
||||||
|
|
||||||
|
apostrophe_config = build_apostrophe_config(settings=merged)
|
||||||
|
try:
|
||||||
|
normalized_text = normalize_for_pipeline(sample_text, config=apostrophe_config, settings=merged)
|
||||||
|
except LLMClientError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"text": sample_text,
|
||||||
|
"normalized_text": normalized_text,
|
||||||
|
}
|
||||||
|
return jsonify(context)
|
||||||
|
|
||||||
|
# --- Normalization Routes ---
|
||||||
|
|
||||||
|
@api_bp.post("/normalization/preview")
|
||||||
|
def api_normalization_preview() -> ResponseReturnValue:
|
||||||
|
payload = request.get_json(force=True, silent=False) or {}
|
||||||
|
sample_text = str(payload.get("text") or "").strip()
|
||||||
|
if not sample_text:
|
||||||
|
return jsonify({"error": "Sample text is required."}), 400
|
||||||
|
|
||||||
|
base_settings = load_settings()
|
||||||
|
# We might want to apply overrides from payload if any normalization settings are passed
|
||||||
|
# For now, just use base settings as in original code (presumably)
|
||||||
|
|
||||||
|
apostrophe_config = build_apostrophe_config(settings=base_settings)
|
||||||
|
try:
|
||||||
|
normalized_text = normalize_for_pipeline(sample_text, config=apostrophe_config, settings=base_settings)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"text": sample_text,
|
||||||
|
"normalized_text": normalized_text,
|
||||||
|
})
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Mapping, Optional
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, jsonify, current_app, url_for
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
|
from abogen.web.routes.utils.settings import (
|
||||||
|
load_settings,
|
||||||
|
stored_integration_config,
|
||||||
|
)
|
||||||
|
from abogen.web.routes.utils.voice import template_options
|
||||||
|
from abogen.web.routes.utils.form import build_pending_job_from_extraction
|
||||||
|
from abogen.web.routes.utils.service import get_service
|
||||||
|
from abogen.integrations.calibre_opds import (
|
||||||
|
CalibreOPDSClient,
|
||||||
|
CalibreOPDSError,
|
||||||
|
feed_to_dict,
|
||||||
|
)
|
||||||
|
from abogen.text_extractor import extract_from_path
|
||||||
|
from abogen.voice_profiles import serialize_profiles
|
||||||
|
|
||||||
|
books_bp = Blueprint("books", __name__)
|
||||||
|
|
||||||
|
def _calibre_integration_enabled(integrations: Dict[str, Any]) -> bool:
|
||||||
|
calibre = integrations.get("calibre", {})
|
||||||
|
return bool(calibre.get("enabled") and calibre.get("url"))
|
||||||
|
|
||||||
|
def _build_calibre_client(payload: Dict[str, Any]) -> CalibreOPDSClient:
|
||||||
|
return CalibreOPDSClient(
|
||||||
|
base_url=payload.get("base_url") or "",
|
||||||
|
username=payload.get("username"),
|
||||||
|
password=payload.get("password"),
|
||||||
|
verify=bool(payload.get("verify_ssl", True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@books_bp.get("/")
|
||||||
|
def find_books_page() -> ResponseReturnValue:
|
||||||
|
settings = load_settings()
|
||||||
|
integrations = settings.get("integrations", {})
|
||||||
|
return render_template(
|
||||||
|
"find_books.html",
|
||||||
|
integrations=integrations,
|
||||||
|
opds_available=_calibre_integration_enabled(integrations),
|
||||||
|
options=template_options(),
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
@books_bp.get("/search")
|
||||||
|
def search_books() -> ResponseReturnValue:
|
||||||
|
# This seems to be handled by the feed endpoint in the original code
|
||||||
|
# But let's see if there is a separate search page or if it's all JS driven
|
||||||
|
return find_books_page()
|
||||||
|
|
||||||
|
@books_bp.get("/calibre/feed")
|
||||||
|
def calibre_opds_feed() -> ResponseReturnValue:
|
||||||
|
settings = load_settings()
|
||||||
|
integrations = settings.get("integrations", {})
|
||||||
|
calibre_settings = integrations.get("calibre", {})
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"base_url": calibre_settings.get("url"),
|
||||||
|
"username": calibre_settings.get("username"),
|
||||||
|
"password": calibre_settings.get("password"),
|
||||||
|
"verify_ssl": True, # Default
|
||||||
|
}
|
||||||
|
|
||||||
|
if not payload.get("base_url"):
|
||||||
|
return jsonify({"error": "Calibre OPDS base URL is not configured."}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _build_calibre_client(payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
href = request.args.get("href", type=str)
|
||||||
|
query = request.args.get("q", type=str)
|
||||||
|
letter = request.args.get("letter", type=str)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if letter:
|
||||||
|
feed = client.browse_letter(letter, start_href=href)
|
||||||
|
elif query:
|
||||||
|
feed = client.search(query)
|
||||||
|
else:
|
||||||
|
feed = client.fetch_feed(href)
|
||||||
|
except CalibreOPDSError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"feed": feed_to_dict(feed),
|
||||||
|
"href": href or "",
|
||||||
|
"query": query or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
@books_bp.post("/calibre/import")
|
||||||
|
def calibre_opds_import() -> ResponseReturnValue:
|
||||||
|
if not request.is_json:
|
||||||
|
return jsonify({"error": "Expected JSON payload."}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
href = str(data.get("href") or "").strip()
|
||||||
|
title = str(data.get("title") or "").strip()
|
||||||
|
|
||||||
|
if not href:
|
||||||
|
return jsonify({"error": "Download link missing."}), 400
|
||||||
|
|
||||||
|
metadata_payload = data.get("metadata") if isinstance(data, Mapping) else None
|
||||||
|
metadata_overrides: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
if isinstance(metadata_payload, Mapping):
|
||||||
|
def _stringify_metadata_value(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
parts = [str(item).strip() for item in value if item is not None]
|
||||||
|
parts = [part for part in parts if part]
|
||||||
|
return ", ".join(parts)
|
||||||
|
text = str(value).strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
raw_series = metadata_payload.get("series") or metadata_payload.get("series_name")
|
||||||
|
series_name = str(raw_series or "").strip()
|
||||||
|
if series_name:
|
||||||
|
metadata_overrides["series"] = series_name
|
||||||
|
metadata_overrides.setdefault("series_name", series_name)
|
||||||
|
|
||||||
|
series_index_value = (
|
||||||
|
metadata_payload.get("series_index")
|
||||||
|
or metadata_payload.get("series_position")
|
||||||
|
or metadata_payload.get("series_sequence")
|
||||||
|
or metadata_payload.get("book_number")
|
||||||
|
)
|
||||||
|
if series_index_value is not None:
|
||||||
|
series_index_text = str(series_index_value).strip()
|
||||||
|
if series_index_text:
|
||||||
|
metadata_overrides.setdefault("series_index", series_index_text)
|
||||||
|
metadata_overrides.setdefault("series_position", series_index_text)
|
||||||
|
metadata_overrides.setdefault("series_sequence", series_index_text)
|
||||||
|
metadata_overrides.setdefault("book_number", series_index_text)
|
||||||
|
|
||||||
|
tags_value = metadata_payload.get("tags") or metadata_payload.get("keywords")
|
||||||
|
if tags_value:
|
||||||
|
tags_text = _stringify_metadata_value(tags_value)
|
||||||
|
if tags_text:
|
||||||
|
metadata_overrides.setdefault("tags", tags_text)
|
||||||
|
metadata_overrides.setdefault("keywords", tags_text)
|
||||||
|
metadata_overrides.setdefault("genre", tags_text)
|
||||||
|
|
||||||
|
description_value = metadata_payload.get("description") or metadata_payload.get("summary")
|
||||||
|
if description_value:
|
||||||
|
description_text = _stringify_metadata_value(description_value)
|
||||||
|
if description_text:
|
||||||
|
metadata_overrides.setdefault("description", description_text)
|
||||||
|
metadata_overrides.setdefault("summary", description_text)
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
integrations = settings.get("integrations", {})
|
||||||
|
calibre_settings = integrations.get("calibre", {})
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"base_url": calibre_settings.get("url"),
|
||||||
|
"username": calibre_settings.get("username"),
|
||||||
|
"password": calibre_settings.get("password"),
|
||||||
|
"verify_ssl": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _build_calibre_client(payload)
|
||||||
|
temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads"))
|
||||||
|
temp_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# We don't know the filename yet, so we'll use a temp name and rename later if possible
|
||||||
|
# Or rely on content-disposition if the client supports it, but here we just download content
|
||||||
|
# The client.download_book returns bytes or path?
|
||||||
|
# Let's check CalibreClient.download_book
|
||||||
|
|
||||||
|
# Assuming it returns bytes for now based on typical usage
|
||||||
|
# But wait, I need to check abogen/integrations/calibre_opds.py
|
||||||
|
|
||||||
|
resource = client.download(href)
|
||||||
|
filename = resource.filename
|
||||||
|
content = resource.content
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
filename = f"{uuid.uuid4().hex}.epub" # Default to epub if unknown
|
||||||
|
|
||||||
|
file_path = temp_dir / f"{uuid.uuid4().hex}_{filename}"
|
||||||
|
file_path.write_bytes(content)
|
||||||
|
|
||||||
|
extraction = extract_from_path(file_path)
|
||||||
|
|
||||||
|
# Apply metadata overrides to extraction if possible, or pass them to build_pending_job
|
||||||
|
if metadata_overrides:
|
||||||
|
extraction.metadata.update(metadata_overrides)
|
||||||
|
|
||||||
|
result = build_pending_job_from_extraction(
|
||||||
|
stored_path=file_path,
|
||||||
|
original_name=filename,
|
||||||
|
extraction=extraction,
|
||||||
|
form={}, # No form data for defaults
|
||||||
|
settings=settings,
|
||||||
|
profiles=serialize_profiles(),
|
||||||
|
metadata_overrides=metadata_overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
get_service().store_pending_job(result.pending)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"status": "imported",
|
||||||
|
"pending_id": result.pending.id,
|
||||||
|
"redirect": url_for("main.wizard_step", step="chapters", pending_id=result.pending.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from typing import Mapping
|
||||||
|
from flask import Blueprint, request, jsonify, abort
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
|
from abogen.web.routes.utils.service import require_pending_job, get_service
|
||||||
|
from abogen.web.routes.utils.entity import (
|
||||||
|
refresh_entity_summary,
|
||||||
|
pending_entities_payload,
|
||||||
|
upsert_manual_override,
|
||||||
|
delete_manual_override,
|
||||||
|
search_manual_override_candidates,
|
||||||
|
)
|
||||||
|
from abogen.web.routes.utils.settings import coerce_int
|
||||||
|
|
||||||
|
entities_bp = Blueprint("entities", __name__)
|
||||||
|
|
||||||
|
@entities_bp.post("/analyze")
|
||||||
|
def analyze_entities() -> ResponseReturnValue:
|
||||||
|
# This might be triggered via wizard update, but if there's a specific route:
|
||||||
|
# In original routes.py, it was likely part of wizard logic or API.
|
||||||
|
# I'll assume this is for the API endpoint /api/pending/<id>/entities/refresh
|
||||||
|
pending_id = request.form.get("pending_id") or request.args.get("pending_id")
|
||||||
|
if not pending_id:
|
||||||
|
abort(400, "Pending ID required")
|
||||||
|
|
||||||
|
pending = require_pending_job(pending_id)
|
||||||
|
refresh_entity_summary(pending, pending.chapters)
|
||||||
|
get_service().store_pending_job(pending)
|
||||||
|
return jsonify(pending_entities_payload(pending))
|
||||||
|
|
||||||
|
@entities_bp.get("/pending/<pending_id>")
|
||||||
|
def get_entities(pending_id: str) -> ResponseReturnValue:
|
||||||
|
pending = require_pending_job(pending_id)
|
||||||
|
refresh_flag = (request.args.get("refresh") or "").strip().lower()
|
||||||
|
expected_cache = (request.args.get("cache_key") or "").strip()
|
||||||
|
refresh_requested = refresh_flag in {"1", "true", "yes", "force"}
|
||||||
|
|
||||||
|
if expected_cache and expected_cache != (pending.entity_cache_key or ""):
|
||||||
|
refresh_requested = True
|
||||||
|
|
||||||
|
if refresh_requested or not pending.entity_summary:
|
||||||
|
refresh_entity_summary(pending, pending.chapters)
|
||||||
|
get_service().store_pending_job(pending)
|
||||||
|
|
||||||
|
return jsonify(pending_entities_payload(pending))
|
||||||
|
|
||||||
|
@entities_bp.post("/pending/<pending_id>/refresh")
|
||||||
|
def refresh_entities(pending_id: str) -> ResponseReturnValue:
|
||||||
|
pending = require_pending_job(pending_id)
|
||||||
|
refresh_entity_summary(pending, pending.chapters)
|
||||||
|
get_service().store_pending_job(pending)
|
||||||
|
return jsonify(pending_entities_payload(pending))
|
||||||
|
|
||||||
|
@entities_bp.get("/pending/<pending_id>/overrides")
|
||||||
|
def list_manual_overrides(pending_id: str) -> ResponseReturnValue:
|
||||||
|
pending = require_pending_job(pending_id)
|
||||||
|
return jsonify({
|
||||||
|
"overrides": pending.manual_overrides or [],
|
||||||
|
"pronunciation_overrides": pending.pronunciation_overrides or [],
|
||||||
|
"language": pending.language or "en",
|
||||||
|
})
|
||||||
|
|
||||||
|
@entities_bp.post("/pending/<pending_id>/overrides")
|
||||||
|
def upsert_override(pending_id: str) -> ResponseReturnValue:
|
||||||
|
pending = require_pending_job(pending_id)
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
abort(400, "Invalid override payload")
|
||||||
|
|
||||||
|
try:
|
||||||
|
override = upsert_manual_override(pending, payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
abort(400, str(exc))
|
||||||
|
|
||||||
|
get_service().store_pending_job(pending)
|
||||||
|
return jsonify({"override": override, **pending_entities_payload(pending)})
|
||||||
|
|
||||||
|
@entities_bp.delete("/pending/<pending_id>/overrides/<override_id>")
|
||||||
|
def delete_override(pending_id: str, override_id: str) -> ResponseReturnValue:
|
||||||
|
pending = require_pending_job(pending_id)
|
||||||
|
deleted = delete_manual_override(pending, override_id)
|
||||||
|
if not deleted:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
get_service().store_pending_job(pending)
|
||||||
|
return jsonify({"deleted": True, **pending_entities_payload(pending)})
|
||||||
|
|
||||||
|
@entities_bp.get("/pending/<pending_id>/overrides/search")
|
||||||
|
def search_candidates(pending_id: str) -> ResponseReturnValue:
|
||||||
|
pending = require_pending_job(pending_id)
|
||||||
|
query = (request.args.get("q") or request.args.get("query") or "").strip()
|
||||||
|
limit_param = request.args.get("limit")
|
||||||
|
limit_value = coerce_int(limit_param, 15, minimum=1, maximum=50) if limit_param is not None else 15
|
||||||
|
|
||||||
|
results = search_manual_override_candidates(pending, query, limit=limit_value)
|
||||||
|
return jsonify({"query": query, "limit": limit_value, "results": results})
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, abort, redirect, render_template, request, url_for, send_file
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
|
from abogen.web.service import (
|
||||||
|
JobStatus,
|
||||||
|
load_audiobookshelf_chapters,
|
||||||
|
build_audiobookshelf_metadata,
|
||||||
|
)
|
||||||
|
from abogen.web.routes.utils.service import get_service
|
||||||
|
from abogen.web.routes.utils.form import render_jobs_panel
|
||||||
|
from abogen.web.routes.utils.voice import template_options
|
||||||
|
from abogen.web.routes.utils.epub import (
|
||||||
|
job_download_flags,
|
||||||
|
locate_job_epub,
|
||||||
|
locate_job_audio,
|
||||||
|
)
|
||||||
|
from abogen.web.routes.utils.settings import (
|
||||||
|
stored_integration_config,
|
||||||
|
build_audiobookshelf_config,
|
||||||
|
coerce_bool,
|
||||||
|
)
|
||||||
|
from abogen.web.routes.utils.common import existing_paths
|
||||||
|
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfUploadError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
jobs_bp = Blueprint("jobs", __name__)
|
||||||
|
|
||||||
|
@jobs_bp.get("/<job_id>")
|
||||||
|
def job_detail(job_id: str) -> str:
|
||||||
|
job = get_service().get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
abort(404)
|
||||||
|
return render_template(
|
||||||
|
"job_detail.html",
|
||||||
|
job=job,
|
||||||
|
options=template_options(),
|
||||||
|
JobStatus=JobStatus,
|
||||||
|
downloads=job_download_flags(job),
|
||||||
|
)
|
||||||
|
|
||||||
|
@jobs_bp.post("/<job_id>/pause")
|
||||||
|
def pause_job(job_id: str) -> ResponseReturnValue:
|
||||||
|
get_service().pause(job_id)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render_jobs_panel()
|
||||||
|
return redirect(url_for("jobs.job_detail", job_id=job_id))
|
||||||
|
|
||||||
|
@jobs_bp.post("/<job_id>/resume")
|
||||||
|
def resume_job(job_id: str) -> ResponseReturnValue:
|
||||||
|
get_service().resume(job_id)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render_jobs_panel()
|
||||||
|
return redirect(url_for("jobs.job_detail", job_id=job_id))
|
||||||
|
|
||||||
|
@jobs_bp.post("/<job_id>/cancel")
|
||||||
|
def cancel_job(job_id: str) -> ResponseReturnValue:
|
||||||
|
get_service().cancel(job_id)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render_jobs_panel()
|
||||||
|
return redirect(url_for("jobs.job_detail", job_id=job_id))
|
||||||
|
|
||||||
|
@jobs_bp.post("/<job_id>/delete")
|
||||||
|
def delete_job(job_id: str) -> ResponseReturnValue:
|
||||||
|
get_service().delete(job_id)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render_jobs_panel()
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
@jobs_bp.post("/<job_id>/retry")
|
||||||
|
def retry_job(job_id: str) -> ResponseReturnValue:
|
||||||
|
new_job = get_service().retry(job_id)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render_jobs_panel()
|
||||||
|
if new_job:
|
||||||
|
return redirect(url_for("jobs.job_detail", job_id=new_job.id))
|
||||||
|
return redirect(url_for("jobs.job_detail", job_id=job_id))
|
||||||
|
|
||||||
|
@jobs_bp.post("/<job_id>/audiobookshelf")
|
||||||
|
def send_job_to_audiobookshelf(job_id: str) -> ResponseReturnValue:
|
||||||
|
service = get_service()
|
||||||
|
job = service.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
def _panel_response() -> ResponseReturnValue:
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render_jobs_panel()
|
||||||
|
return redirect(url_for("jobs.job_detail", job_id=job.id))
|
||||||
|
|
||||||
|
if job.status != JobStatus.COMPLETED:
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
settings = stored_integration_config("audiobookshelf")
|
||||||
|
if not settings or not coerce_bool(settings.get("enabled"), False):
|
||||||
|
job.add_log("Audiobookshelf upload skipped: integration is disabled.", level="warning")
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
config = build_audiobookshelf_config(settings)
|
||||||
|
if config is None:
|
||||||
|
job.add_log(
|
||||||
|
"Audiobookshelf upload skipped: configure base URL, API token, and library ID first.",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
if not config.folder_id:
|
||||||
|
job.add_log(
|
||||||
|
"Audiobookshelf upload skipped: enter the folder name or ID in the Audiobookshelf settings.",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
audio_path = locate_job_audio(job)
|
||||||
|
if not audio_path or not audio_path.exists():
|
||||||
|
job.add_log("Audiobookshelf upload skipped: audio output not found.", level="warning")
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
cover_path = None
|
||||||
|
if config.send_cover and job.cover_image_path:
|
||||||
|
cover_candidate = job.cover_image_path
|
||||||
|
if not isinstance(cover_candidate, Path):
|
||||||
|
cover_candidate = Path(str(cover_candidate))
|
||||||
|
if cover_candidate.exists():
|
||||||
|
cover_path = cover_candidate
|
||||||
|
|
||||||
|
subtitles = existing_paths(job.result.subtitle_paths) if config.send_subtitles else None
|
||||||
|
chapters = load_audiobookshelf_chapters(job) if config.send_chapters else None
|
||||||
|
metadata = build_audiobookshelf_metadata(job)
|
||||||
|
display_title = metadata.get("title") or audio_path.stem
|
||||||
|
overwrite_requested = request.form.get("overwrite") == "true" or request.args.get("overwrite") == "true"
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = AudiobookshelfClient(config)
|
||||||
|
except ValueError as exc:
|
||||||
|
job.add_log(f"Audiobookshelf configuration error: {exc}", level="error")
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
|
||||||
|
except AudiobookshelfUploadError as exc:
|
||||||
|
job.add_log(f"Audiobookshelf lookup failed: {exc}", level="error")
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
if existing_items and not overwrite_requested:
|
||||||
|
job.add_log(
|
||||||
|
f"Audiobookshelf already contains '{display_title}'. Awaiting overwrite confirmation.",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
service._persist_state()
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
detail = {
|
||||||
|
"jobId": job.id,
|
||||||
|
"title": display_title,
|
||||||
|
"url": url_for("jobs.send_job_to_audiobookshelf", job_id=job.id),
|
||||||
|
"target": request.headers.get("HX-Target") or "#jobs-panel",
|
||||||
|
"message": f'Audiobookshelf already contains "{display_title}". Overwrite?',
|
||||||
|
}
|
||||||
|
headers = {"HX-Trigger": json.dumps({"audiobookshelf-overwrite-prompt": detail})}
|
||||||
|
return Response("", status=204, headers=headers)
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
if existing_items and overwrite_requested:
|
||||||
|
try:
|
||||||
|
client.delete_items(existing_items)
|
||||||
|
except AudiobookshelfUploadError as exc:
|
||||||
|
job.add_log(f"Audiobookshelf overwrite aborted: {exc}", level="error")
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
else:
|
||||||
|
job.add_log(
|
||||||
|
f"Removed {len(existing_items)} existing Audiobookshelf item(s) prior to overwrite.",
|
||||||
|
level="info",
|
||||||
|
)
|
||||||
|
|
||||||
|
job.add_log("Audiobookshelf upload triggered manually.", level="info")
|
||||||
|
try:
|
||||||
|
client.upload_audiobook(
|
||||||
|
audio_path,
|
||||||
|
metadata=metadata,
|
||||||
|
cover_path=cover_path,
|
||||||
|
chapters=chapters,
|
||||||
|
subtitles=subtitles,
|
||||||
|
)
|
||||||
|
except AudiobookshelfUploadError as exc:
|
||||||
|
job.add_log(f"Audiobookshelf upload failed: {exc}", level="error")
|
||||||
|
except Exception as exc:
|
||||||
|
job.add_log(f"Audiobookshelf integration error: {exc}", level="error")
|
||||||
|
else:
|
||||||
|
job.add_log("Audiobookshelf upload queued.", level="success")
|
||||||
|
finally:
|
||||||
|
service._persist_state()
|
||||||
|
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
@jobs_bp.post("/clear-finished")
|
||||||
|
def clear_finished_jobs() -> ResponseReturnValue:
|
||||||
|
get_service().clear_finished()
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render_jobs_panel()
|
||||||
|
return redirect(url_for("main.index", _anchor="queue"))
|
||||||
|
|
||||||
|
@jobs_bp.get("/<job_id>/epub")
|
||||||
|
def job_epub(job_id: str) -> ResponseReturnValue:
|
||||||
|
job = get_service().get_job(job_id)
|
||||||
|
if job is None or job.status != JobStatus.COMPLETED:
|
||||||
|
abort(404)
|
||||||
|
epub_path = locate_job_epub(job)
|
||||||
|
if not epub_path:
|
||||||
|
abort(404)
|
||||||
|
return send_file(
|
||||||
|
epub_path,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=epub_path.name,
|
||||||
|
mimetype="application/epub+zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
@jobs_bp.get("/<job_id>/download/<file_type>")
|
||||||
|
def download_file(job_id: str, file_type: str) -> ResponseReturnValue:
|
||||||
|
job = get_service().get_job(job_id)
|
||||||
|
if not job or job.status != JobStatus.COMPLETED:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if file_type == "audio":
|
||||||
|
path = locate_job_audio(job)
|
||||||
|
if not path or not path.exists():
|
||||||
|
abort(404)
|
||||||
|
return send_file(
|
||||||
|
path,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle other file types if needed (subtitles, etc.)
|
||||||
|
# For now, just audio and epub are explicitly handled
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
@jobs_bp.get("/<job_id>/logs")
|
||||||
|
def job_logs(job_id: str) -> str:
|
||||||
|
job = get_service().get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
abort(404)
|
||||||
|
return render_template("job_logs_static.html", job=job)
|
||||||
|
|
||||||
|
@jobs_bp.get("/<job_id>/logs/stream")
|
||||||
|
def stream_logs(job_id: str) -> ResponseReturnValue:
|
||||||
|
job = get_service().get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
last_index = 0
|
||||||
|
while True:
|
||||||
|
current_logs = job.logs
|
||||||
|
if len(current_logs) > last_index:
|
||||||
|
for log in current_logs[last_index:]:
|
||||||
|
yield f"data: {json.dumps({'timestamp': log.timestamp, 'level': log.level, 'message': log.message})}\n\n"
|
||||||
|
last_index = len(current_logs)
|
||||||
|
|
||||||
|
if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
|
||||||
|
break
|
||||||
|
|
||||||
|
import time
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
return Response(generate(), mimetype="text/event-stream")
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional, cast
|
||||||
|
|
||||||
|
from flask import Blueprint, redirect, render_template, request, url_for, jsonify, current_app
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from abogen.web.service import PendingJob
|
||||||
|
from abogen.web.routes.utils.service import get_service, remove_pending_job, submit_job
|
||||||
|
from abogen.web.routes.utils.settings import load_settings
|
||||||
|
from abogen.web.routes.utils.voice import template_options
|
||||||
|
from abogen.web.routes.utils.form import (
|
||||||
|
normalize_wizard_step,
|
||||||
|
wants_wizard_json,
|
||||||
|
render_wizard_partial,
|
||||||
|
wizard_json_response,
|
||||||
|
build_pending_job_from_extraction,
|
||||||
|
apply_book_step_form,
|
||||||
|
apply_prepare_form,
|
||||||
|
render_jobs_panel,
|
||||||
|
)
|
||||||
|
from abogen.text_extractor import extract_from_path
|
||||||
|
from abogen.voice_profiles import serialize_profiles
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
main_bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
@main_bp.app_template_filter("datetimeformat")
|
||||||
|
def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||||
|
if not value:
|
||||||
|
return "—"
|
||||||
|
from datetime import datetime
|
||||||
|
return datetime.fromtimestamp(value).strftime(fmt)
|
||||||
|
|
||||||
|
@main_bp.route("/")
|
||||||
|
def index():
|
||||||
|
pending_id = request.args.get("pending_id")
|
||||||
|
pending = get_service().get_pending_job(pending_id) if pending_id else None
|
||||||
|
|
||||||
|
# If we have a pending job, redirect to the wizard
|
||||||
|
if pending:
|
||||||
|
step_index = getattr(pending, "wizard_max_step_index", 0)
|
||||||
|
# Map index to step name roughly
|
||||||
|
steps = ["book", "chapters", "entities"]
|
||||||
|
step_name = steps[min(step_index, len(steps)-1)]
|
||||||
|
return redirect(url_for("main.wizard_step", step=step_name, pending_id=pending.id))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
options=template_options(),
|
||||||
|
settings=load_settings(),
|
||||||
|
jobs_panel=render_jobs_panel(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@main_bp.route("/wizard")
|
||||||
|
def wizard_start():
|
||||||
|
pending_id = request.args.get("pending_id")
|
||||||
|
if pending_id:
|
||||||
|
return redirect(url_for("main.wizard_step", step="book", pending_id=pending_id))
|
||||||
|
return redirect(url_for("main.wizard_step", step="book"))
|
||||||
|
|
||||||
|
@main_bp.route("/wizard/<step>")
|
||||||
|
def wizard_step(step: str):
|
||||||
|
pending_id = request.args.get("pending_id")
|
||||||
|
pending = get_service().get_pending_job(pending_id) if pending_id else None
|
||||||
|
|
||||||
|
normalized_step = normalize_wizard_step(step, pending)
|
||||||
|
if normalized_step != step:
|
||||||
|
return redirect(url_for("main.wizard_step", step=normalized_step, pending_id=pending_id))
|
||||||
|
|
||||||
|
if wants_wizard_json():
|
||||||
|
return wizard_json_response(pending, normalized_step)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
options=template_options(),
|
||||||
|
settings=load_settings(),
|
||||||
|
jobs_panel=render_jobs_panel(),
|
||||||
|
wizard_mode=True,
|
||||||
|
wizard_step=normalized_step,
|
||||||
|
wizard_partial=render_wizard_partial(pending, normalized_step),
|
||||||
|
)
|
||||||
|
|
||||||
|
@main_bp.route("/wizard/upload", methods=["POST"])
|
||||||
|
def wizard_upload():
|
||||||
|
file = request.files.get("file")
|
||||||
|
if not file or not file.filename:
|
||||||
|
if wants_wizard_json():
|
||||||
|
return wizard_json_response(None, "book", error="No file selected", status=400)
|
||||||
|
return redirect(url_for("main.wizard_step", step="book"))
|
||||||
|
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads"))
|
||||||
|
temp_dir.mkdir(exist_ok=True)
|
||||||
|
file_path = temp_dir / f"{uuid.uuid4().hex}_{filename}"
|
||||||
|
file.save(file_path)
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
profiles = serialize_profiles()
|
||||||
|
|
||||||
|
try:
|
||||||
|
extraction = extract_from_path(file_path)
|
||||||
|
|
||||||
|
result = build_pending_job_from_extraction(
|
||||||
|
stored_path=file_path,
|
||||||
|
original_name=filename,
|
||||||
|
extraction=extraction,
|
||||||
|
form=request.form,
|
||||||
|
settings=settings,
|
||||||
|
profiles=profiles,
|
||||||
|
)
|
||||||
|
|
||||||
|
get_service().store_pending_job(result.pending)
|
||||||
|
|
||||||
|
if wants_wizard_json():
|
||||||
|
return wizard_json_response(result.pending, "chapters")
|
||||||
|
|
||||||
|
return redirect(url_for("main.wizard_step", step="chapters", pending_id=result.pending.id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error processing upload")
|
||||||
|
if file_path.exists():
|
||||||
|
try:
|
||||||
|
file_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
error_msg = f"Failed to process file: {str(e)}"
|
||||||
|
if wants_wizard_json():
|
||||||
|
return wizard_json_response(None, "book", error=error_msg, status=500)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
options=template_options(),
|
||||||
|
settings=settings,
|
||||||
|
jobs_panel=render_jobs_panel(),
|
||||||
|
wizard_mode=True,
|
||||||
|
wizard_step="book",
|
||||||
|
wizard_partial=render_wizard_partial(None, "book", error=error_msg),
|
||||||
|
)
|
||||||
|
|
||||||
|
@main_bp.route("/wizard/text", methods=["POST"])
|
||||||
|
def wizard_text():
|
||||||
|
text = request.form.get("text", "").strip()
|
||||||
|
title = request.form.get("title", "").strip() or "Pasted Text"
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
if wants_wizard_json():
|
||||||
|
return wizard_json_response(None, "book", error="No text provided", status=400)
|
||||||
|
return redirect(url_for("main.wizard_step", step="book"))
|
||||||
|
|
||||||
|
temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads"))
|
||||||
|
temp_dir.mkdir(exist_ok=True)
|
||||||
|
file_path = temp_dir / f"{uuid.uuid4().hex}.txt"
|
||||||
|
file_path.write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
profiles = serialize_profiles()
|
||||||
|
|
||||||
|
try:
|
||||||
|
extraction = extract_from_path(file_path)
|
||||||
|
# Override title since text extraction might not find one
|
||||||
|
extraction.metadata["title"] = title
|
||||||
|
|
||||||
|
result = build_pending_job_from_extraction(
|
||||||
|
stored_path=file_path,
|
||||||
|
original_name=f"{title}.txt",
|
||||||
|
extraction=extraction,
|
||||||
|
form=request.form,
|
||||||
|
settings=settings,
|
||||||
|
profiles=profiles,
|
||||||
|
)
|
||||||
|
|
||||||
|
get_service().store_pending_job(result.pending)
|
||||||
|
|
||||||
|
if wants_wizard_json():
|
||||||
|
return wizard_json_response(result.pending, "chapters")
|
||||||
|
|
||||||
|
return redirect(url_for("main.wizard_step", step="chapters", pending_id=result.pending.id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error processing text")
|
||||||
|
if file_path.exists():
|
||||||
|
try:
|
||||||
|
file_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
error_msg = f"Failed to process text: {str(e)}"
|
||||||
|
if wants_wizard_json():
|
||||||
|
return wizard_json_response(None, "book", error=error_msg, status=500)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
options=template_options(),
|
||||||
|
settings=settings,
|
||||||
|
jobs_panel=render_jobs_panel(),
|
||||||
|
wizard_mode=True,
|
||||||
|
wizard_step="book",
|
||||||
|
wizard_partial=render_wizard_partial(None, "book", error=error_msg),
|
||||||
|
)
|
||||||
|
|
||||||
|
@main_bp.route("/wizard/update", methods=["POST"])
|
||||||
|
def wizard_update():
|
||||||
|
pending_id = request.form.get("pending_id")
|
||||||
|
if not pending_id:
|
||||||
|
if wants_wizard_json():
|
||||||
|
return wizard_json_response(None, "book", error="Missing job ID", status=400)
|
||||||
|
return redirect(url_for("main.wizard_step", step="book"))
|
||||||
|
|
||||||
|
pending = get_service().get_pending_job(pending_id)
|
||||||
|
if not pending:
|
||||||
|
if wants_wizard_json():
|
||||||
|
return wizard_json_response(None, "book", error="Job expired or not found", status=404)
|
||||||
|
return redirect(url_for("main.wizard_step", step="book"))
|
||||||
|
|
||||||
|
current_step = request.form.get("step", "book")
|
||||||
|
next_step = request.form.get("next_step")
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
profiles = serialize_profiles()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if current_step == "book":
|
||||||
|
apply_book_step_form(pending, request.form, settings=settings, profiles=profiles)
|
||||||
|
target_step = next_step or "chapters"
|
||||||
|
|
||||||
|
elif current_step == "chapters":
|
||||||
|
# This step involves re-analyzing chunks if needed
|
||||||
|
(
|
||||||
|
chunk_level,
|
||||||
|
overrides,
|
||||||
|
enabled_overrides,
|
||||||
|
errors,
|
||||||
|
selected_total,
|
||||||
|
selected_config,
|
||||||
|
apply_config_requested,
|
||||||
|
persist_config_requested,
|
||||||
|
) = apply_prepare_form(pending, request.form)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
if wants_wizard_json():
|
||||||
|
return wizard_json_response(pending, current_step, error="\n".join(errors), status=400)
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
options=template_options(),
|
||||||
|
settings=settings,
|
||||||
|
jobs_panel=render_jobs_panel(),
|
||||||
|
wizard_mode=True,
|
||||||
|
wizard_step=current_step,
|
||||||
|
wizard_partial=render_wizard_partial(pending, current_step, error="\n".join(errors)),
|
||||||
|
)
|
||||||
|
|
||||||
|
target_step = next_step or "entities"
|
||||||
|
|
||||||
|
elif current_step == "entities":
|
||||||
|
# Just saving entity overrides
|
||||||
|
apply_prepare_form(pending, request.form)
|
||||||
|
target_step = next_step or "entities" # Stay or finish
|
||||||
|
|
||||||
|
else:
|
||||||
|
target_step = "book"
|
||||||
|
|
||||||
|
get_service().store_pending_job(pending)
|
||||||
|
|
||||||
|
if wants_wizard_json():
|
||||||
|
return wizard_json_response(pending, target_step)
|
||||||
|
|
||||||
|
return redirect(url_for("main.wizard_step", step=target_step, pending_id=pending.id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error updating wizard step {current_step}")
|
||||||
|
error_msg = f"Update failed: {str(e)}"
|
||||||
|
if wants_wizard_json():
|
||||||
|
return wizard_json_response(pending, current_step, error=error_msg, status=500)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
options=template_options(),
|
||||||
|
settings=settings,
|
||||||
|
jobs_panel=render_jobs_panel(),
|
||||||
|
wizard_mode=True,
|
||||||
|
wizard_step=current_step,
|
||||||
|
wizard_partial=render_wizard_partial(pending, current_step, error=error_msg),
|
||||||
|
)
|
||||||
|
|
||||||
|
@main_bp.route("/wizard/cancel", methods=["POST"])
|
||||||
|
def wizard_cancel():
|
||||||
|
pending_id = request.form.get("pending_id")
|
||||||
|
if pending_id:
|
||||||
|
remove_pending_job(pending_id)
|
||||||
|
|
||||||
|
if wants_wizard_json():
|
||||||
|
return jsonify({"status": "cancelled", "redirect": url_for("main.index")})
|
||||||
|
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
@main_bp.route("/wizard/finish", methods=["POST"])
|
||||||
|
def wizard_finish():
|
||||||
|
pending_id = request.form.get("pending_id")
|
||||||
|
if not pending_id:
|
||||||
|
if wants_wizard_json():
|
||||||
|
return jsonify({"error": "Missing job ID"}), 400
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
pending = get_service().get_pending_job(pending_id)
|
||||||
|
if not pending:
|
||||||
|
if wants_wizard_json():
|
||||||
|
return jsonify({"error": "Job not found"}), 404
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
# Final update from form
|
||||||
|
apply_prepare_form(pending, request.form)
|
||||||
|
|
||||||
|
# Submit job
|
||||||
|
job_id = submit_job(pending)
|
||||||
|
|
||||||
|
if wants_wizard_json():
|
||||||
|
return jsonify({
|
||||||
|
"status": "submitted",
|
||||||
|
"job_id": job_id,
|
||||||
|
"redirect": url_for("main.index"),
|
||||||
|
"jobs_panel": render_jobs_panel()
|
||||||
|
})
|
||||||
|
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
|
from abogen.web.routes.utils.settings import (
|
||||||
|
load_settings,
|
||||||
|
save_settings,
|
||||||
|
coerce_bool,
|
||||||
|
coerce_int,
|
||||||
|
_NORMALIZATION_BOOLEAN_KEYS,
|
||||||
|
_NORMALIZATION_STRING_KEYS,
|
||||||
|
_DEFAULT_ANALYSIS_THRESHOLD,
|
||||||
|
)
|
||||||
|
from abogen.web.routes.utils.voice import template_options
|
||||||
|
|
||||||
|
settings_bp = Blueprint("settings", __name__)
|
||||||
|
|
||||||
|
@settings_bp.get("/")
|
||||||
|
def settings_page() -> str:
|
||||||
|
return render_template(
|
||||||
|
"settings.html",
|
||||||
|
settings=load_settings(),
|
||||||
|
options=template_options(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@settings_bp.post("/update")
|
||||||
|
def update_settings() -> ResponseReturnValue:
|
||||||
|
current = load_settings()
|
||||||
|
form = request.form
|
||||||
|
|
||||||
|
# General settings
|
||||||
|
current["language"] = (form.get("language") or "en").strip()
|
||||||
|
current["default_voice"] = (form.get("default_voice") or "").strip()
|
||||||
|
current["output_format"] = (form.get("output_format") or "mp3").strip()
|
||||||
|
current["subtitle_mode"] = (form.get("subtitle_mode") or "Disabled").strip()
|
||||||
|
current["subtitle_format"] = (form.get("subtitle_format") or "srt").strip()
|
||||||
|
current["save_mode"] = (form.get("save_mode") or "save_next_to_input").strip()
|
||||||
|
|
||||||
|
current["replace_single_newlines"] = coerce_bool(form.get("replace_single_newlines"), False)
|
||||||
|
current["use_gpu"] = coerce_bool(form.get("use_gpu"), False)
|
||||||
|
current["save_chapters_separately"] = coerce_bool(form.get("save_chapters_separately"), False)
|
||||||
|
current["merge_chapters_at_end"] = coerce_bool(form.get("merge_chapters_at_end"), True)
|
||||||
|
current["save_as_project"] = coerce_bool(form.get("save_as_project"), False)
|
||||||
|
current["separate_chapters_format"] = (form.get("separate_chapters_format") or "wav").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
current["silence_between_chapters"] = max(0.0, float(form.get("silence_between_chapters", 2.0)))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
current["chapter_intro_delay"] = max(0.0, float(form.get("chapter_intro_delay", 0.5)))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
current["read_title_intro"] = coerce_bool(form.get("read_title_intro"), False)
|
||||||
|
current["read_closing_outro"] = coerce_bool(form.get("read_closing_outro"), True)
|
||||||
|
current["normalize_chapter_opening_caps"] = coerce_bool(form.get("normalize_chapter_opening_caps"), True)
|
||||||
|
current["auto_prefix_chapter_titles"] = coerce_bool(form.get("auto_prefix_chapter_titles"), True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current["max_subtitle_words"] = max(1, int(form.get("max_subtitle_words", 50)))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
current["chunk_level"] = (form.get("chunk_level") or "paragraph").strip()
|
||||||
|
current["generate_epub3"] = coerce_bool(form.get("generate_epub3"), False)
|
||||||
|
|
||||||
|
current["speaker_analysis_threshold"] = coerce_int(
|
||||||
|
form.get("speaker_analysis_threshold"),
|
||||||
|
_DEFAULT_ANALYSIS_THRESHOLD,
|
||||||
|
minimum=1,
|
||||||
|
maximum=25,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalization settings
|
||||||
|
for key in _NORMALIZATION_BOOLEAN_KEYS:
|
||||||
|
current[key] = coerce_bool(form.get(key), False)
|
||||||
|
for key in _NORMALIZATION_STRING_KEYS:
|
||||||
|
current[key] = (form.get(key) or "").strip()
|
||||||
|
|
||||||
|
# Integrations
|
||||||
|
# Audiobookshelf
|
||||||
|
abs_enabled = coerce_bool(form.get("audiobookshelf_enabled"), False)
|
||||||
|
abs_url = (form.get("audiobookshelf_url") or "").strip()
|
||||||
|
abs_token = (form.get("audiobookshelf_token") or "").strip()
|
||||||
|
abs_library = (form.get("audiobookshelf_library_id") or "").strip()
|
||||||
|
abs_folder = (form.get("audiobookshelf_folder_id") or "").strip()
|
||||||
|
abs_cover = coerce_bool(form.get("audiobookshelf_send_cover"), True)
|
||||||
|
abs_chapters = coerce_bool(form.get("audiobookshelf_send_chapters"), True)
|
||||||
|
abs_subtitles = coerce_bool(form.get("audiobookshelf_send_subtitles"), True)
|
||||||
|
|
||||||
|
current["integrations"] = current.get("integrations", {})
|
||||||
|
current["integrations"]["audiobookshelf"] = {
|
||||||
|
"enabled": abs_enabled,
|
||||||
|
"url": abs_url,
|
||||||
|
"token": abs_token,
|
||||||
|
"library_id": abs_library,
|
||||||
|
"folder_id": abs_folder,
|
||||||
|
"send_cover": abs_cover,
|
||||||
|
"send_chapters": abs_chapters,
|
||||||
|
"send_subtitles": abs_subtitles,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calibre
|
||||||
|
calibre_enabled = coerce_bool(form.get("calibre_enabled"), False)
|
||||||
|
calibre_url = (form.get("calibre_url") or "").strip()
|
||||||
|
calibre_user = (form.get("calibre_username") or "").strip()
|
||||||
|
calibre_pass = (form.get("calibre_password") or "").strip()
|
||||||
|
calibre_library = (form.get("calibre_library_id") or "").strip()
|
||||||
|
|
||||||
|
current["integrations"]["calibre"] = {
|
||||||
|
"enabled": calibre_enabled,
|
||||||
|
"url": calibre_url,
|
||||||
|
"username": calibre_user,
|
||||||
|
"password": calibre_pass,
|
||||||
|
"library_id": calibre_library,
|
||||||
|
}
|
||||||
|
|
||||||
|
save_settings(current)
|
||||||
|
flash("Settings updated successfully.", "success")
|
||||||
|
return redirect(url_for("settings.settings_page"))
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from typing import Any, Optional, Tuple, Iterable, List
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def split_profile_spec(value: Any) -> Tuple[str, Optional[str]]:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return "", None
|
||||||
|
if text.lower().startswith("profile:"):
|
||||||
|
_, _, remainder = text.partition(":")
|
||||||
|
name = remainder.strip()
|
||||||
|
return "", name or None
|
||||||
|
return text, None
|
||||||
|
|
||||||
|
def existing_paths(paths: Optional[Iterable[Path]]) -> List[Path]:
|
||||||
|
if not paths:
|
||||||
|
return []
|
||||||
|
return [p for p in paths if p.exists()]
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||||
|
|
||||||
|
from abogen.web.service import PendingJob
|
||||||
|
from abogen.entity_analysis import (
|
||||||
|
extract_entities,
|
||||||
|
merge_override,
|
||||||
|
normalize_token as normalize_entity_token,
|
||||||
|
search_tokens as search_entity_tokens,
|
||||||
|
)
|
||||||
|
from abogen.pronunciation_store import (
|
||||||
|
delete_override as delete_pronunciation_override,
|
||||||
|
load_overrides as load_pronunciation_overrides,
|
||||||
|
save_override as save_pronunciation_override,
|
||||||
|
search_overrides as search_pronunciation_overrides,
|
||||||
|
)
|
||||||
|
from abogen.web.routes.utils.settings import load_settings
|
||||||
|
|
||||||
|
def collect_pronunciation_overrides(pending: PendingJob) -> List[Dict[str, Any]]:
|
||||||
|
language = pending.language or "en"
|
||||||
|
collected: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
summary = pending.entity_summary or {}
|
||||||
|
for group in ("people", "entities"):
|
||||||
|
entries = summary.get(group)
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
continue
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
override_payload = entry.get("override")
|
||||||
|
if not isinstance(override_payload, Mapping):
|
||||||
|
continue
|
||||||
|
token_value = str(entry.get("label") or override_payload.get("token") or "").strip()
|
||||||
|
pronunciation_value = str(override_payload.get("pronunciation") or "").strip()
|
||||||
|
if not token_value or not pronunciation_value:
|
||||||
|
continue
|
||||||
|
normalized = normalize_entity_token(entry.get("normalized") or token_value)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
collected[normalized] = {
|
||||||
|
"token": token_value,
|
||||||
|
"normalized": normalized,
|
||||||
|
"pronunciation": pronunciation_value,
|
||||||
|
"voice": str(override_payload.get("voice") or "").strip() or None,
|
||||||
|
"notes": str(override_payload.get("notes") or "").strip() or None,
|
||||||
|
"context": str(override_payload.get("context") or "").strip() or None,
|
||||||
|
"source": f"{group}-override",
|
||||||
|
"language": language,
|
||||||
|
}
|
||||||
|
|
||||||
|
if isinstance(pending.speakers, Mapping):
|
||||||
|
for speaker_payload in pending.speakers.values():
|
||||||
|
if not isinstance(speaker_payload, Mapping):
|
||||||
|
continue
|
||||||
|
token_value = str(speaker_payload.get("label") or "").strip()
|
||||||
|
pronunciation_value = str(speaker_payload.get("pronunciation") or "").strip()
|
||||||
|
if not token_value or not pronunciation_value:
|
||||||
|
continue
|
||||||
|
normalized = normalize_entity_token(token_value)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
collected[normalized] = {
|
||||||
|
"token": token_value,
|
||||||
|
"normalized": normalized,
|
||||||
|
"pronunciation": pronunciation_value,
|
||||||
|
"voice": str(
|
||||||
|
speaker_payload.get("resolved_voice")
|
||||||
|
or speaker_payload.get("voice")
|
||||||
|
or pending.voice
|
||||||
|
).strip()
|
||||||
|
or None,
|
||||||
|
"notes": None,
|
||||||
|
"context": None,
|
||||||
|
"source": "speaker",
|
||||||
|
"language": language,
|
||||||
|
}
|
||||||
|
|
||||||
|
for manual_entry in pending.manual_overrides or []:
|
||||||
|
if not isinstance(manual_entry, Mapping):
|
||||||
|
continue
|
||||||
|
token_value = str(manual_entry.get("token") or "").strip()
|
||||||
|
pronunciation_value = str(manual_entry.get("pronunciation") or "").strip()
|
||||||
|
if not token_value or not pronunciation_value:
|
||||||
|
continue
|
||||||
|
normalized = manual_entry.get("normalized") or normalize_entity_token(token_value)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
collected[normalized] = {
|
||||||
|
"token": token_value,
|
||||||
|
"normalized": normalized,
|
||||||
|
"pronunciation": pronunciation_value,
|
||||||
|
"voice": str(manual_entry.get("voice") or "").strip() or None,
|
||||||
|
"notes": str(manual_entry.get("notes") or "").strip() or None,
|
||||||
|
"context": str(manual_entry.get("context") or "").strip() or None,
|
||||||
|
"source": str(manual_entry.get("source") or "manual"),
|
||||||
|
"language": language,
|
||||||
|
}
|
||||||
|
|
||||||
|
return list(collected.values())
|
||||||
|
|
||||||
|
|
||||||
|
def sync_pronunciation_overrides(pending: PendingJob) -> None:
|
||||||
|
pending.pronunciation_overrides = collect_pronunciation_overrides(pending)
|
||||||
|
|
||||||
|
if not pending.pronunciation_overrides:
|
||||||
|
return
|
||||||
|
|
||||||
|
summary = pending.entity_summary or {}
|
||||||
|
manual_map: Dict[str, Mapping[str, Any]] = {}
|
||||||
|
for override in pending.manual_overrides or []:
|
||||||
|
if not isinstance(override, Mapping):
|
||||||
|
continue
|
||||||
|
normalized = override.get("normalized") or normalize_entity_token(override.get("token") or "")
|
||||||
|
pronunciation_value = str(override.get("pronunciation") or "").strip()
|
||||||
|
if not normalized or not pronunciation_value:
|
||||||
|
continue
|
||||||
|
manual_map[normalized] = override
|
||||||
|
for group in ("people", "entities"):
|
||||||
|
entries = summary.get(group)
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
continue
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
normalized = normalize_entity_token(entry.get("normalized") or entry.get("label") or "")
|
||||||
|
manual_override = manual_map.get(normalized)
|
||||||
|
if manual_override:
|
||||||
|
entry["override"] = {
|
||||||
|
"token": manual_override.get("token"),
|
||||||
|
"pronunciation": manual_override.get("pronunciation"),
|
||||||
|
"voice": manual_override.get("voice"),
|
||||||
|
"notes": manual_override.get("notes"),
|
||||||
|
"context": manual_override.get("context"),
|
||||||
|
"source": manual_override.get("source"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str, Any]]) -> None:
|
||||||
|
settings = load_settings()
|
||||||
|
if not bool(settings.get("enable_entity_recognition", True)):
|
||||||
|
pending.entity_summary = {}
|
||||||
|
pending.entity_cache_key = ""
|
||||||
|
pending.pronunciation_overrides = pending.pronunciation_overrides or []
|
||||||
|
return
|
||||||
|
|
||||||
|
language = pending.language or "en"
|
||||||
|
chapter_list: List[Mapping[str, Any]] = [chapter for chapter in chapters if isinstance(chapter, Mapping)]
|
||||||
|
if not chapter_list:
|
||||||
|
pending.entity_summary = {}
|
||||||
|
pending.entity_cache_key = ""
|
||||||
|
pending.pronunciation_overrides = pending.pronunciation_overrides or []
|
||||||
|
return
|
||||||
|
|
||||||
|
enabled_only = [chapter for chapter in chapter_list if chapter.get("enabled")]
|
||||||
|
target_chapters = enabled_only or chapter_list
|
||||||
|
result = extract_entities(target_chapters, language=language)
|
||||||
|
summary = dict(result.summary)
|
||||||
|
tokens: List[str] = []
|
||||||
|
for group in ("people", "entities"):
|
||||||
|
entries = summary.get(group)
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
continue
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
token_value = str(entry.get("normalized") or entry.get("label") or "").strip()
|
||||||
|
if token_value:
|
||||||
|
tokens.append(token_value)
|
||||||
|
|
||||||
|
overrides_from_store = load_pronunciation_overrides(language=language, tokens=tokens)
|
||||||
|
merged_summary = merge_override(summary, overrides_from_store)
|
||||||
|
if result.errors:
|
||||||
|
merged_summary["errors"] = list(result.errors)
|
||||||
|
merged_summary["cache_key"] = result.cache_key
|
||||||
|
pending.entity_summary = merged_summary
|
||||||
|
pending.entity_cache_key = result.cache_key
|
||||||
|
sync_pronunciation_overrides(pending)
|
||||||
|
|
||||||
|
|
||||||
|
def find_manual_override(pending: PendingJob, identifier: str) -> Optional[Dict[str, Any]]:
|
||||||
|
for entry in pending.manual_overrides or []:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
if entry.get("id") == identifier or entry.get("normalized") == identifier:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_manual_override(pending: PendingJob, payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
|
token_value = str(payload.get("token") or "").strip()
|
||||||
|
if not token_value:
|
||||||
|
raise ValueError("Token is required")
|
||||||
|
pronunciation_value = str(payload.get("pronunciation") or "").strip()
|
||||||
|
voice_value = str(payload.get("voice") or "").strip()
|
||||||
|
notes_value = str(payload.get("notes") or "").strip()
|
||||||
|
context_value = str(payload.get("context") or "").strip()
|
||||||
|
normalized = payload.get("normalized") or normalize_entity_token(token_value)
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("Token is required")
|
||||||
|
|
||||||
|
existing = find_manual_override(pending, payload.get("id", "")) or find_manual_override(pending, normalized)
|
||||||
|
timestamp = time.time()
|
||||||
|
language = pending.language or "en"
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.update(
|
||||||
|
{
|
||||||
|
"token": token_value,
|
||||||
|
"normalized": normalized,
|
||||||
|
"pronunciation": pronunciation_value,
|
||||||
|
"voice": voice_value,
|
||||||
|
"notes": notes_value,
|
||||||
|
"context": context_value,
|
||||||
|
"updated_at": timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
manual_entry = existing
|
||||||
|
else:
|
||||||
|
manual_entry = {
|
||||||
|
"id": payload.get("id") or uuid.uuid4().hex,
|
||||||
|
"token": token_value,
|
||||||
|
"normalized": normalized,
|
||||||
|
"pronunciation": pronunciation_value,
|
||||||
|
"voice": voice_value,
|
||||||
|
"notes": notes_value,
|
||||||
|
"context": context_value,
|
||||||
|
"language": language,
|
||||||
|
"source": payload.get("source") or "manual",
|
||||||
|
"created_at": timestamp,
|
||||||
|
"updated_at": timestamp,
|
||||||
|
}
|
||||||
|
if isinstance(pending.manual_overrides, list):
|
||||||
|
pending.manual_overrides.append(manual_entry)
|
||||||
|
else:
|
||||||
|
pending.manual_overrides = [manual_entry]
|
||||||
|
|
||||||
|
save_pronunciation_override(
|
||||||
|
language=language,
|
||||||
|
token=token_value,
|
||||||
|
pronunciation=pronunciation_value or None,
|
||||||
|
voice=voice_value or None,
|
||||||
|
notes=notes_value or None,
|
||||||
|
context=context_value or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_pronunciation_overrides(pending)
|
||||||
|
return dict(manual_entry)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_manual_override(pending: PendingJob, override_id: str) -> bool:
|
||||||
|
if not override_id:
|
||||||
|
return False
|
||||||
|
entries = pending.manual_overrides or []
|
||||||
|
for index, entry in enumerate(entries):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
if entry.get("id") == override_id:
|
||||||
|
token_value = entry.get("token") or ""
|
||||||
|
language = pending.language or "en"
|
||||||
|
delete_pronunciation_override(language=language, token=token_value)
|
||||||
|
entries.pop(index)
|
||||||
|
pending.manual_overrides = entries
|
||||||
|
sync_pronunciation_overrides(pending)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def search_manual_override_candidates(pending: PendingJob, query: str, *, limit: int = 15) -> List[Dict[str, Any]]:
|
||||||
|
normalized_query = (query or "").strip()
|
||||||
|
summary_index = (pending.entity_summary or {}).get("index", {})
|
||||||
|
matches = search_entity_tokens(summary_index, normalized_query, limit=limit)
|
||||||
|
registry: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for entry in matches:
|
||||||
|
normalized = normalize_entity_token(entry.get("normalized") or entry.get("token") or "")
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
registry.setdefault(
|
||||||
|
normalized,
|
||||||
|
{
|
||||||
|
"token": entry.get("token"),
|
||||||
|
"normalized": normalized,
|
||||||
|
"category": entry.get("category") or "entity",
|
||||||
|
"count": entry.get("count", 0),
|
||||||
|
"samples": entry.get("samples", []),
|
||||||
|
"source": "entity",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
language = pending.language or "en"
|
||||||
|
store_matches = search_pronunciation_overrides(language=language, query=normalized_query, limit=limit)
|
||||||
|
for entry in store_matches:
|
||||||
|
normalized = entry.get("normalized")
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
registry.setdefault(
|
||||||
|
normalized,
|
||||||
|
{
|
||||||
|
"token": entry.get("token"),
|
||||||
|
"normalized": normalized,
|
||||||
|
"category": "history",
|
||||||
|
"count": entry.get("usage_count", 0),
|
||||||
|
"samples": [entry.get("context")] if entry.get("context") else [],
|
||||||
|
"source": "history",
|
||||||
|
"pronunciation": entry.get("pronunciation"),
|
||||||
|
"voice": entry.get("voice"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
for entry in pending.manual_overrides or []:
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
normalized = entry.get("normalized")
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
registry.setdefault(
|
||||||
|
normalized,
|
||||||
|
{
|
||||||
|
"token": entry.get("token"),
|
||||||
|
"normalized": normalized,
|
||||||
|
"category": "manual",
|
||||||
|
"count": 0,
|
||||||
|
"samples": [entry.get("context")] if entry.get("context") else [],
|
||||||
|
"source": "manual",
|
||||||
|
"pronunciation": entry.get("pronunciation"),
|
||||||
|
"voice": entry.get("voice"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ordered = sorted(registry.values(), key=lambda item: (-int(item.get("count") or 0), item.get("token") or ""))
|
||||||
|
if limit:
|
||||||
|
return ordered[:limit]
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def pending_entities_payload(pending: PendingJob) -> Dict[str, Any]:
|
||||||
|
settings = load_settings()
|
||||||
|
recognition_enabled = bool(settings.get("enable_entity_recognition", True))
|
||||||
|
return {
|
||||||
|
"summary": pending.entity_summary or {},
|
||||||
|
"manual_overrides": pending.manual_overrides or [],
|
||||||
|
"pronunciation_overrides": pending.pronunciation_overrides or [],
|
||||||
|
"cache_key": pending.entity_cache_key,
|
||||||
|
"language": pending.language or "en",
|
||||||
|
"recognition_enabled": recognition_enabled,
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import json
|
||||||
|
import math
|
||||||
|
import posixpath
|
||||||
|
import zipfile
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from abogen.web.service import Job, JobStatus
|
||||||
|
|
||||||
|
def _coerce_path(value: Any) -> Optional[Path]:
|
||||||
|
if isinstance(value, Path):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
candidate = Path(value)
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_epub_path(base_dir: str, href: str) -> str:
|
||||||
|
if not href:
|
||||||
|
return ""
|
||||||
|
sanitized = href.split("#", 1)[0].split("?", 1)[0].strip()
|
||||||
|
sanitized = sanitized.replace("\\", "/")
|
||||||
|
if not sanitized:
|
||||||
|
return ""
|
||||||
|
if sanitized.startswith("/"):
|
||||||
|
sanitized = sanitized[1:]
|
||||||
|
base_dir = ""
|
||||||
|
normalized_base = base_dir.strip("/")
|
||||||
|
sanitized_lower = sanitized.lower()
|
||||||
|
if normalized_base:
|
||||||
|
base_lower = normalized_base.lower()
|
||||||
|
prefix = base_lower + "/"
|
||||||
|
if sanitized_lower.startswith(prefix):
|
||||||
|
remainder = sanitized[len(prefix):]
|
||||||
|
if remainder.lower().startswith(prefix):
|
||||||
|
sanitized = remainder
|
||||||
|
sanitized_lower = sanitized.lower()
|
||||||
|
base_dir = ""
|
||||||
|
elif sanitized_lower == base_lower:
|
||||||
|
base_dir = ""
|
||||||
|
base = base_dir.strip("/")
|
||||||
|
combined = posixpath.join(base, sanitized) if base else sanitized
|
||||||
|
normalized = posixpath.normpath(combined)
|
||||||
|
if normalized in {"", "."}:
|
||||||
|
return ""
|
||||||
|
normalized = normalized.replace("\\", "/")
|
||||||
|
segments = [segment for segment in normalized.split("/") if segment and segment != "."]
|
||||||
|
if not segments:
|
||||||
|
return ""
|
||||||
|
deduped: List[str] = []
|
||||||
|
last_lower: Optional[str] = None
|
||||||
|
for segment in segments:
|
||||||
|
segment_lower = segment.lower()
|
||||||
|
if last_lower == segment_lower:
|
||||||
|
continue
|
||||||
|
deduped.append(segment)
|
||||||
|
last_lower = segment_lower
|
||||||
|
normalized = "/".join(deduped)
|
||||||
|
if normalized.startswith("../") or normalized == "..":
|
||||||
|
return ""
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def decode_text(payload: bytes) -> str:
|
||||||
|
for encoding in ("utf-8", "utf-16", "windows-1252"):
|
||||||
|
try:
|
||||||
|
return payload.decode(encoding)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
return payload.decode("utf-8", "ignore")
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_positive_time(value: Any) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
numeric = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not math.isfinite(numeric) or numeric < 0:
|
||||||
|
return None
|
||||||
|
return numeric
|
||||||
|
|
||||||
|
|
||||||
|
def load_job_metadata(job: Job) -> Dict[str, Any]:
|
||||||
|
result = getattr(job, "result", None)
|
||||||
|
artifacts = getattr(result, "artifacts", None)
|
||||||
|
if not isinstance(artifacts, Mapping):
|
||||||
|
return {}
|
||||||
|
metadata_ref = artifacts.get("metadata")
|
||||||
|
if isinstance(metadata_ref, Path):
|
||||||
|
metadata_path = metadata_ref
|
||||||
|
elif isinstance(metadata_ref, str):
|
||||||
|
metadata_path = Path(metadata_ref)
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
if not metadata_path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_book_title(job: Job, *metadata_sources: Mapping[str, Any]) -> str:
|
||||||
|
for source in metadata_sources:
|
||||||
|
if not isinstance(source, Mapping):
|
||||||
|
continue
|
||||||
|
for key in ("title", "book_title", "name", "album", "album_title"):
|
||||||
|
value = source.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
candidate = value.strip()
|
||||||
|
if candidate:
|
||||||
|
return candidate
|
||||||
|
filename = job.original_filename or ""
|
||||||
|
stem = Path(filename).stem if filename else ""
|
||||||
|
return stem or filename
|
||||||
|
|
||||||
|
|
||||||
|
class _NavMapParser(HTMLParser):
|
||||||
|
def __init__(self, base_dir: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._base_dir = base_dir
|
||||||
|
self._in_nav = False
|
||||||
|
self._nav_depth = 0
|
||||||
|
self._current_href: Optional[str] = None
|
||||||
|
self._buffer: List[str] = []
|
||||||
|
self.links: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||||
|
tag_lower = tag.lower()
|
||||||
|
if tag_lower == "nav":
|
||||||
|
attributes = dict(attrs)
|
||||||
|
nav_type = (attributes.get("epub:type") or attributes.get("type") or "").strip().lower()
|
||||||
|
nav_role = (attributes.get("role") or "").strip().lower()
|
||||||
|
type_tokens = {token.strip() for token in nav_type.split() if token}
|
||||||
|
role_tokens = {token.strip() for token in nav_role.split() if token}
|
||||||
|
if "toc" in type_tokens or "doc-toc" in role_tokens:
|
||||||
|
self._in_nav = True
|
||||||
|
self._nav_depth = 1
|
||||||
|
return
|
||||||
|
if self._in_nav:
|
||||||
|
self._nav_depth += 1
|
||||||
|
return
|
||||||
|
if not self._in_nav:
|
||||||
|
return
|
||||||
|
if tag_lower == "a":
|
||||||
|
attributes = dict(attrs)
|
||||||
|
href = attributes.get("href") or ""
|
||||||
|
normalized = normalize_epub_path(self._base_dir, href)
|
||||||
|
if normalized:
|
||||||
|
self._current_href = normalized
|
||||||
|
self._buffer = []
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
tag_lower = tag.lower()
|
||||||
|
if tag_lower == "nav" and self._in_nav:
|
||||||
|
self._nav_depth -= 1
|
||||||
|
if self._nav_depth <= 0:
|
||||||
|
self._in_nav = False
|
||||||
|
return
|
||||||
|
if not self._in_nav:
|
||||||
|
return
|
||||||
|
if tag_lower == "a" and self._current_href:
|
||||||
|
text = "".join(self._buffer).strip()
|
||||||
|
if text:
|
||||||
|
self.links.setdefault(self._current_href, text)
|
||||||
|
self._current_href = None
|
||||||
|
self._buffer = []
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if self._in_nav and self._current_href and data:
|
||||||
|
self._buffer.append(data)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nav_document(payload: bytes, base_dir: str) -> Dict[str, str]:
|
||||||
|
parser = _NavMapParser(base_dir)
|
||||||
|
parser.feed(decode_text(payload))
|
||||||
|
parser.close()
|
||||||
|
return parser.links
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ncx_document(payload: bytes, base_dir: str) -> Dict[str, str]:
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(payload)
|
||||||
|
except ET.ParseError:
|
||||||
|
return {}
|
||||||
|
nav_map: Dict[str, str] = {}
|
||||||
|
for nav_point in root.findall(".//{*}navPoint"):
|
||||||
|
content = nav_point.find(".//{*}content")
|
||||||
|
if content is None:
|
||||||
|
continue
|
||||||
|
src = content.attrib.get("src", "")
|
||||||
|
normalized = normalize_epub_path(base_dir, src)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
label_el = nav_point.find(".//{*}text")
|
||||||
|
label = (label_el.text or "").strip() if label_el is not None and label_el.text else ""
|
||||||
|
if not label:
|
||||||
|
label = posixpath.basename(normalized) or f"Section {len(nav_map) + 1}"
|
||||||
|
nav_map.setdefault(normalized, label)
|
||||||
|
return nav_map
|
||||||
|
|
||||||
|
|
||||||
|
def extract_epub_chapters(epub_path: Path) -> List[Dict[str, str]]:
|
||||||
|
chapters: List[Dict[str, str]] = []
|
||||||
|
if not epub_path or not epub_path.exists():
|
||||||
|
return chapters
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(epub_path, "r") as archive:
|
||||||
|
container_bytes = archive.read("META-INF/container.xml")
|
||||||
|
container_root = ET.fromstring(container_bytes)
|
||||||
|
rootfile = container_root.find(".//{*}rootfile")
|
||||||
|
if rootfile is None:
|
||||||
|
return chapters
|
||||||
|
opf_path = (rootfile.attrib.get("full-path") or "").strip()
|
||||||
|
if not opf_path:
|
||||||
|
return chapters
|
||||||
|
opf_dir = posixpath.dirname(opf_path)
|
||||||
|
opf_bytes = archive.read(opf_path)
|
||||||
|
opf_root = ET.fromstring(opf_bytes)
|
||||||
|
|
||||||
|
manifest: Dict[str, Dict[str, str]] = {}
|
||||||
|
for item in opf_root.findall(".//{*}manifest/{*}item"):
|
||||||
|
item_id = item.attrib.get("id")
|
||||||
|
href = item.attrib.get("href")
|
||||||
|
if not item_id or not href:
|
||||||
|
continue
|
||||||
|
manifest[item_id] = {
|
||||||
|
"href": normalize_epub_path(opf_dir, href),
|
||||||
|
"properties": item.attrib.get("properties", ""),
|
||||||
|
"media_type": item.attrib.get("media-type", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
spine_hrefs: List[str] = []
|
||||||
|
nav_id: Optional[str] = None
|
||||||
|
spine = opf_root.find(".//{*}spine")
|
||||||
|
if spine is not None:
|
||||||
|
nav_id = spine.attrib.get("toc")
|
||||||
|
for itemref in spine.findall(".//{*}itemref"):
|
||||||
|
idref = itemref.attrib.get("idref")
|
||||||
|
if not idref:
|
||||||
|
continue
|
||||||
|
entry = manifest.get(idref)
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
href = entry["href"]
|
||||||
|
if href and href not in spine_hrefs:
|
||||||
|
spine_hrefs.append(href)
|
||||||
|
|
||||||
|
nav_href: Optional[str] = None
|
||||||
|
for entry in manifest.values():
|
||||||
|
properties = entry.get("properties") or ""
|
||||||
|
if "nav" in {token.strip() for token in properties.split() if token}:
|
||||||
|
nav_href = entry["href"]
|
||||||
|
break
|
||||||
|
if not nav_href and nav_id:
|
||||||
|
toc_entry = manifest.get(nav_id)
|
||||||
|
if toc_entry:
|
||||||
|
nav_href = toc_entry["href"]
|
||||||
|
|
||||||
|
nav_titles: Dict[str, str] = {}
|
||||||
|
if nav_href:
|
||||||
|
nav_base = posixpath.dirname(nav_href)
|
||||||
|
try:
|
||||||
|
nav_bytes = archive.read(nav_href)
|
||||||
|
except KeyError:
|
||||||
|
nav_bytes = None
|
||||||
|
if nav_bytes is not None:
|
||||||
|
if nav_href.lower().endswith(".ncx"):
|
||||||
|
nav_titles = parse_ncx_document(nav_bytes, nav_base)
|
||||||
|
else:
|
||||||
|
nav_titles = parse_nav_document(nav_bytes, nav_base)
|
||||||
|
|
||||||
|
if not nav_titles and nav_id and nav_id in manifest:
|
||||||
|
toc_entry = manifest[nav_id]
|
||||||
|
nav_base = posixpath.dirname(toc_entry["href"])
|
||||||
|
try:
|
||||||
|
nav_bytes = archive.read(toc_entry["href"])
|
||||||
|
except KeyError:
|
||||||
|
nav_bytes = None
|
||||||
|
if nav_bytes is not None:
|
||||||
|
nav_titles = parse_ncx_document(nav_bytes, nav_base)
|
||||||
|
|
||||||
|
for index, href in enumerate(spine_hrefs, start=1):
|
||||||
|
normalized = href
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
title = (
|
||||||
|
nav_titles.get(normalized)
|
||||||
|
or nav_titles.get(normalized.split("#", 1)[0])
|
||||||
|
or posixpath.basename(normalized)
|
||||||
|
or f"Chapter {index}"
|
||||||
|
)
|
||||||
|
chapters.append({"href": normalized, "title": title})
|
||||||
|
|
||||||
|
if not chapters and nav_titles:
|
||||||
|
for index, (href, title) in enumerate(nav_titles.items(), start=1):
|
||||||
|
normalized = href
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
label = title or posixpath.basename(normalized) or f"Chapter {index}"
|
||||||
|
chapters.append({"href": normalized, "title": label})
|
||||||
|
|
||||||
|
return chapters
|
||||||
|
except (FileNotFoundError, zipfile.BadZipFile, KeyError, ET.ParseError, UnicodeDecodeError):
|
||||||
|
return []
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
def read_epub_bytes(epub_path: Path, raw_href: str) -> bytes:
|
||||||
|
normalized = normalize_epub_path("", raw_href)
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("Invalid resource path")
|
||||||
|
with zipfile.ZipFile(epub_path, "r") as archive:
|
||||||
|
return archive.read(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_job_result_paths(job: Job) -> List[Path]:
|
||||||
|
result = getattr(job, "result", None)
|
||||||
|
if result is None:
|
||||||
|
return []
|
||||||
|
resolved_seen: Set[Path] = set()
|
||||||
|
collected: List[Path] = []
|
||||||
|
|
||||||
|
def _remember(candidate: Optional[Path]) -> None:
|
||||||
|
if not candidate:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if resolved in resolved_seen:
|
||||||
|
return
|
||||||
|
resolved_seen.add(resolved)
|
||||||
|
collected.append(candidate)
|
||||||
|
|
||||||
|
artifacts = getattr(result, "artifacts", None)
|
||||||
|
if isinstance(artifacts, Mapping):
|
||||||
|
for value in artifacts.values():
|
||||||
|
candidate = _coerce_path(value)
|
||||||
|
if candidate and candidate.exists() and candidate.is_file():
|
||||||
|
_remember(candidate)
|
||||||
|
|
||||||
|
for attr in ("audio_path", "epub_path"):
|
||||||
|
candidate = _coerce_path(getattr(result, attr, None))
|
||||||
|
if candidate and candidate.exists() and candidate.is_file():
|
||||||
|
_remember(candidate)
|
||||||
|
|
||||||
|
return collected
|
||||||
|
|
||||||
|
|
||||||
|
def iter_job_artifact_dirs(job: Job) -> List[Path]:
|
||||||
|
result = getattr(job, "result", None)
|
||||||
|
if result is None:
|
||||||
|
return []
|
||||||
|
artifacts = getattr(result, "artifacts", None)
|
||||||
|
directories: List[Path] = []
|
||||||
|
if isinstance(artifacts, Mapping):
|
||||||
|
for value in artifacts.values():
|
||||||
|
candidate = _coerce_path(value)
|
||||||
|
if candidate and candidate.exists() and candidate.is_dir():
|
||||||
|
directories.append(candidate)
|
||||||
|
return directories
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_suffixes(suffixes: Iterable[str]) -> List[str]:
|
||||||
|
normalized: List[str] = []
|
||||||
|
for suffix in suffixes:
|
||||||
|
if not suffix:
|
||||||
|
continue
|
||||||
|
cleaned = suffix.lower().strip()
|
||||||
|
if not cleaned:
|
||||||
|
continue
|
||||||
|
if not cleaned.startswith("."):
|
||||||
|
cleaned = f".{cleaned.lstrip('.')}"
|
||||||
|
normalized.append(cleaned)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def find_job_file(job: Job, suffixes: Iterable[str]) -> Optional[Path]:
|
||||||
|
ordered_suffixes = normalize_suffixes(suffixes)
|
||||||
|
if not ordered_suffixes:
|
||||||
|
return None
|
||||||
|
files = iter_job_result_paths(job)
|
||||||
|
for suffix in ordered_suffixes:
|
||||||
|
for candidate in files:
|
||||||
|
if candidate.suffix.lower() == suffix:
|
||||||
|
return candidate
|
||||||
|
directories = iter_job_artifact_dirs(job)
|
||||||
|
for suffix in ordered_suffixes:
|
||||||
|
pattern = f"*{suffix}"
|
||||||
|
for directory in directories:
|
||||||
|
try:
|
||||||
|
match = next((path for path in directory.rglob(pattern) if path.is_file()), None)
|
||||||
|
except OSError:
|
||||||
|
match = None
|
||||||
|
if match:
|
||||||
|
return match
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def locate_job_epub(job: Job) -> Optional[Path]:
|
||||||
|
path = find_job_file(job, [".epub"])
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def locate_job_m4b(job: Job) -> Optional[Path]:
|
||||||
|
return find_job_file(job, [".m4b"])
|
||||||
|
|
||||||
|
|
||||||
|
def locate_job_audio(job: Job, preferred_suffixes: Optional[Iterable[str]] = None) -> Optional[Path]:
|
||||||
|
suffix_order: List[str] = []
|
||||||
|
if preferred_suffixes:
|
||||||
|
suffix_order.extend(preferred_suffixes)
|
||||||
|
suffix_order.extend([".m4b", ".mp3", ".flac", ".opus", ".ogg", ".m4a", ".wav"])
|
||||||
|
path = find_job_file(job, suffix_order)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
files = iter_job_result_paths(job)
|
||||||
|
return files[0] if files else None
|
||||||
|
|
||||||
|
|
||||||
|
def job_download_flags(job: Job) -> Dict[str, bool]:
|
||||||
|
if job.status != JobStatus.COMPLETED:
|
||||||
|
return {"audio": False, "m4b": False, "epub3": False}
|
||||||
|
return {
|
||||||
|
"audio": locate_job_audio(job) is not None,
|
||||||
|
"m4b": locate_job_m4b(job) is not None,
|
||||||
|
"epub3": locate_job_epub(job) is not None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,989 @@
|
|||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
|
||||||
|
from flask import request, render_template, jsonify
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
|
from abogen.web.service import PendingJob, JobStatus
|
||||||
|
from abogen.web.routes.utils.service import get_service
|
||||||
|
from abogen.web.routes.utils.settings import (
|
||||||
|
load_settings,
|
||||||
|
coerce_bool,
|
||||||
|
coerce_int,
|
||||||
|
_CHUNK_LEVEL_VALUES,
|
||||||
|
_DEFAULT_ANALYSIS_THRESHOLD,
|
||||||
|
_NORMALIZATION_BOOLEAN_KEYS,
|
||||||
|
_NORMALIZATION_STRING_KEYS,
|
||||||
|
SAVE_MODE_LABELS,
|
||||||
|
audiobookshelf_manual_available,
|
||||||
|
)
|
||||||
|
from abogen.web.routes.utils.voice import (
|
||||||
|
parse_voice_formula,
|
||||||
|
formula_from_profile,
|
||||||
|
resolve_voice_setting,
|
||||||
|
resolve_voice_choice,
|
||||||
|
prepare_speaker_metadata,
|
||||||
|
template_options,
|
||||||
|
)
|
||||||
|
from abogen.web.routes.utils.entity import sync_pronunciation_overrides
|
||||||
|
from abogen.web.routes.utils.epub import job_download_flags
|
||||||
|
from abogen.web.routes.utils.common import split_profile_spec
|
||||||
|
from abogen.utils import calculate_text_length
|
||||||
|
from abogen.voice_profiles import serialize_profiles
|
||||||
|
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
||||||
|
from abogen.constants import VOICES_INTERNAL
|
||||||
|
from abogen.speaker_configs import get_config
|
||||||
|
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PendingBuildResult:
|
||||||
|
pending: PendingJob
|
||||||
|
selected_speaker_config: Optional[str]
|
||||||
|
config_languages: List[str]
|
||||||
|
speaker_config_payload: Optional[Dict[str, Any]]
|
||||||
|
|
||||||
|
_WIZARD_STEP_ORDER = ["book", "chapters", "entities"]
|
||||||
|
_WIZARD_STEP_META = {
|
||||||
|
"book": {
|
||||||
|
"index": 1,
|
||||||
|
"title": "Book parameters",
|
||||||
|
"hint": "Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.",
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"index": 2,
|
||||||
|
"title": "Select chapters",
|
||||||
|
"hint": "Choose which chapters to convert. We'll analyse entities automatically when you continue.",
|
||||||
|
},
|
||||||
|
"entities": {
|
||||||
|
"index": 3,
|
||||||
|
"title": "Review entities",
|
||||||
|
"hint": "Assign pronunciations, voices, and manual overrides before queueing the conversion.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_SUPPLEMENT_TITLE_PATTERNS: List[tuple[re.Pattern[str], float]] = [
|
||||||
|
(re.compile(r"\btitle\s+page\b"), 3.0),
|
||||||
|
(re.compile(r"\bcopyright\b"), 2.4),
|
||||||
|
(re.compile(r"\btable\s+of\s+contents\b"), 2.8),
|
||||||
|
(re.compile(r"\bcontents\b"), 2.0),
|
||||||
|
(re.compile(r"\backnowledg(e)?ments?\b"), 2.0),
|
||||||
|
(re.compile(r"\bdedication\b"), 2.0),
|
||||||
|
(re.compile(r"\babout\s+the\s+author(s)?\b"), 2.4),
|
||||||
|
(re.compile(r"\balso\s+by\b"), 2.0),
|
||||||
|
(re.compile(r"\bpraise\s+for\b"), 2.0),
|
||||||
|
(re.compile(r"\bcolophon\b"), 2.2),
|
||||||
|
(re.compile(r"\bpublication\s+data\b"), 2.2),
|
||||||
|
(re.compile(r"\btranscriber'?s?\s+note\b"), 2.2),
|
||||||
|
(re.compile(r"\bglossary\b"), 2.0),
|
||||||
|
(re.compile(r"\bindex\b"), 2.0),
|
||||||
|
(re.compile(r"\bbibliograph(y|ies)\b"), 2.0),
|
||||||
|
(re.compile(r"\breferences\b"), 1.8),
|
||||||
|
(re.compile(r"\bappendix\b"), 1.9),
|
||||||
|
]
|
||||||
|
|
||||||
|
_CONTENT_TITLE_PATTERNS: List[re.Pattern[str]] = [
|
||||||
|
re.compile(r"\bchapter\b"),
|
||||||
|
re.compile(r"\bbook\b"),
|
||||||
|
re.compile(r"\bpart\b"),
|
||||||
|
re.compile(r"\bsection\b"),
|
||||||
|
re.compile(r"\bscene\b"),
|
||||||
|
re.compile(r"\bprologue\b"),
|
||||||
|
re.compile(r"\bepilogue\b"),
|
||||||
|
re.compile(r"\bintroduction\b"),
|
||||||
|
re.compile(r"\bstory\b"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_SUPPLEMENT_TEXT_KEYWORDS: List[tuple[str, float]] = [
|
||||||
|
("copyright", 1.2),
|
||||||
|
("all rights reserved", 1.1),
|
||||||
|
("isbn", 0.9),
|
||||||
|
("library of congress", 1.0),
|
||||||
|
("table of contents", 1.0),
|
||||||
|
("dedicated to", 0.8),
|
||||||
|
("acknowledg", 0.8),
|
||||||
|
("printed in", 0.6),
|
||||||
|
("permission", 0.6),
|
||||||
|
("publisher", 0.5),
|
||||||
|
("praise for", 0.9),
|
||||||
|
("also by", 0.9),
|
||||||
|
("glossary", 0.8),
|
||||||
|
("index", 0.8),
|
||||||
|
("newsletter", 3.2),
|
||||||
|
("mailing list", 2.6),
|
||||||
|
("sign-up", 2.2),
|
||||||
|
]
|
||||||
|
|
||||||
|
def supplement_score(title: str, text: str, index: int) -> float:
|
||||||
|
normalized_title = (title or "").lower()
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
for pattern, weight in _SUPPLEMENT_TITLE_PATTERNS:
|
||||||
|
if pattern.search(normalized_title):
|
||||||
|
score += weight
|
||||||
|
|
||||||
|
for pattern in _CONTENT_TITLE_PATTERNS:
|
||||||
|
if pattern.search(normalized_title):
|
||||||
|
score -= 2.0
|
||||||
|
|
||||||
|
stripped_text = (text or "").strip()
|
||||||
|
length = len(stripped_text)
|
||||||
|
if length <= 150:
|
||||||
|
score += 0.9
|
||||||
|
elif length <= 400:
|
||||||
|
score += 0.6
|
||||||
|
elif length <= 800:
|
||||||
|
score += 0.35
|
||||||
|
|
||||||
|
lowercase_text = stripped_text.lower()
|
||||||
|
for keyword, weight in _SUPPLEMENT_TEXT_KEYWORDS:
|
||||||
|
if keyword in lowercase_text:
|
||||||
|
score += weight
|
||||||
|
|
||||||
|
if index == 0 and score > 0:
|
||||||
|
score += 0.25
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def should_preselect_chapter(
|
||||||
|
title: str,
|
||||||
|
text: str,
|
||||||
|
index: int,
|
||||||
|
total_count: int,
|
||||||
|
) -> bool:
|
||||||
|
if total_count <= 1:
|
||||||
|
return True
|
||||||
|
score = supplement_score(title, text, index)
|
||||||
|
return score < 1.9
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_at_least_one_chapter_enabled(chapters: List[Dict[str, Any]]) -> None:
|
||||||
|
if not chapters:
|
||||||
|
return
|
||||||
|
if any(chapter.get("enabled") for chapter in chapters):
|
||||||
|
return
|
||||||
|
best_index = max(range(len(chapters)), key=lambda idx: chapters[idx].get("characters", 0))
|
||||||
|
chapters[best_index]["enabled"] = True
|
||||||
|
|
||||||
|
def apply_prepare_form(
|
||||||
|
pending: PendingJob, form: Mapping[str, Any]
|
||||||
|
) -> tuple[
|
||||||
|
ChunkLevel,
|
||||||
|
List[Dict[str, Any]],
|
||||||
|
List[Dict[str, Any]],
|
||||||
|
List[str],
|
||||||
|
int,
|
||||||
|
str,
|
||||||
|
bool,
|
||||||
|
bool,
|
||||||
|
]:
|
||||||
|
raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower()
|
||||||
|
if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
|
||||||
|
raw_chunk_level = pending.chunk_level if pending.chunk_level in _CHUNK_LEVEL_VALUES else "paragraph"
|
||||||
|
pending.chunk_level = raw_chunk_level
|
||||||
|
chunk_level_literal = cast(ChunkLevel, pending.chunk_level)
|
||||||
|
|
||||||
|
pending.speaker_mode = "single"
|
||||||
|
|
||||||
|
pending.generate_epub3 = coerce_bool(form.get("generate_epub3"), False)
|
||||||
|
|
||||||
|
threshold_default = getattr(pending, "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD)
|
||||||
|
raw_threshold = form.get("speaker_analysis_threshold")
|
||||||
|
if raw_threshold is not None:
|
||||||
|
pending.speaker_analysis_threshold = coerce_int(
|
||||||
|
raw_threshold,
|
||||||
|
threshold_default,
|
||||||
|
minimum=1,
|
||||||
|
maximum=25,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pending.speaker_analysis_threshold = threshold_default
|
||||||
|
|
||||||
|
if not pending.speakers:
|
||||||
|
narrator: Dict[str, Any] = {
|
||||||
|
"id": "narrator",
|
||||||
|
"label": "Narrator",
|
||||||
|
"voice": pending.voice,
|
||||||
|
}
|
||||||
|
if pending.voice_profile:
|
||||||
|
narrator["voice_profile"] = pending.voice_profile
|
||||||
|
pending.speakers = {"narrator": narrator}
|
||||||
|
else:
|
||||||
|
existing_narrator = pending.speakers.get("narrator")
|
||||||
|
if isinstance(existing_narrator, dict):
|
||||||
|
existing_narrator.setdefault("id", "narrator")
|
||||||
|
existing_narrator["label"] = existing_narrator.get("label", "Narrator")
|
||||||
|
existing_narrator["voice"] = pending.voice
|
||||||
|
if pending.voice_profile:
|
||||||
|
existing_narrator["voice_profile"] = pending.voice_profile
|
||||||
|
pending.speakers["narrator"] = existing_narrator
|
||||||
|
|
||||||
|
selected_config = (form.get("applied_speaker_config") or "").strip()
|
||||||
|
apply_config_requested = str(form.get("apply_speaker_config", "")).strip() in {"1", "true", "on"}
|
||||||
|
persist_config_requested = str(form.get("save_speaker_config", "")).strip() in {"1", "true", "on"}
|
||||||
|
|
||||||
|
pending.applied_speaker_config = selected_config or None
|
||||||
|
|
||||||
|
errors: List[str] = []
|
||||||
|
|
||||||
|
if isinstance(pending.speakers, dict):
|
||||||
|
for speaker_id, payload in list(pending.speakers.items()):
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
field_key = f"speaker-{speaker_id}-pronunciation"
|
||||||
|
raw_value = form.get(field_key, "")
|
||||||
|
pronunciation = raw_value.strip()
|
||||||
|
if pronunciation:
|
||||||
|
payload["pronunciation"] = pronunciation
|
||||||
|
else:
|
||||||
|
payload.pop("pronunciation", None)
|
||||||
|
|
||||||
|
voice_value = (form.get(f"speaker-{speaker_id}-voice") or "").strip()
|
||||||
|
formula_key = f"speaker-{speaker_id}-formula"
|
||||||
|
formula_value = (form.get(formula_key) or "").strip()
|
||||||
|
has_formula = False
|
||||||
|
if formula_value:
|
||||||
|
try:
|
||||||
|
parse_voice_formula(formula_value)
|
||||||
|
except ValueError as exc:
|
||||||
|
label = payload.get("label") or speaker_id.replace("_", " ").title()
|
||||||
|
errors.append(f"Invalid custom mix for {label}: {exc}")
|
||||||
|
else:
|
||||||
|
payload["voice_formula"] = formula_value
|
||||||
|
payload["resolved_voice"] = formula_value
|
||||||
|
payload.pop("voice_profile", None)
|
||||||
|
has_formula = True
|
||||||
|
else:
|
||||||
|
payload.pop("voice_formula", None)
|
||||||
|
|
||||||
|
if voice_value == "__custom_mix":
|
||||||
|
voice_value = ""
|
||||||
|
|
||||||
|
if voice_value:
|
||||||
|
payload["voice"] = voice_value
|
||||||
|
if not has_formula:
|
||||||
|
payload["resolved_voice"] = voice_value
|
||||||
|
else:
|
||||||
|
payload.pop("voice", None)
|
||||||
|
if not has_formula:
|
||||||
|
payload.pop("resolved_voice", None)
|
||||||
|
|
||||||
|
lang_key = f"speaker-{speaker_id}-languages"
|
||||||
|
languages: List[str] = []
|
||||||
|
getter = getattr(form, "getlist", None)
|
||||||
|
if callable(getter):
|
||||||
|
values = cast(Iterable[str], getter(lang_key))
|
||||||
|
languages = [code.strip() for code in values if code]
|
||||||
|
else:
|
||||||
|
raw_langs = form.get(lang_key)
|
||||||
|
if isinstance(raw_langs, str):
|
||||||
|
languages = [item.strip() for item in raw_langs.split(",") if item.strip()]
|
||||||
|
payload["config_languages"] = languages
|
||||||
|
|
||||||
|
profiles = serialize_profiles()
|
||||||
|
raw_delay = form.get("chapter_intro_delay")
|
||||||
|
if raw_delay is not None:
|
||||||
|
raw_normalized = raw_delay.strip()
|
||||||
|
if raw_normalized:
|
||||||
|
try:
|
||||||
|
pending.chapter_intro_delay = max(0.0, float(raw_normalized))
|
||||||
|
except ValueError:
|
||||||
|
errors.append("Enter a valid number for the chapter intro delay.")
|
||||||
|
else:
|
||||||
|
pending.chapter_intro_delay = 0.0
|
||||||
|
|
||||||
|
intro_values: List[str] = []
|
||||||
|
getter = getattr(form, "getlist", None)
|
||||||
|
if callable(getter):
|
||||||
|
raw_intro_values = getter("read_title_intro")
|
||||||
|
if raw_intro_values:
|
||||||
|
intro_values = list(cast(Iterable[str], raw_intro_values))
|
||||||
|
else:
|
||||||
|
raw_intro = form.get("read_title_intro")
|
||||||
|
if raw_intro is not None:
|
||||||
|
intro_values = [raw_intro]
|
||||||
|
if intro_values:
|
||||||
|
pending.read_title_intro = coerce_bool(intro_values[-1], pending.read_title_intro)
|
||||||
|
elif hasattr(form, "__contains__") and "read_title_intro" in form:
|
||||||
|
pending.read_title_intro = False
|
||||||
|
|
||||||
|
outro_values: List[str] = []
|
||||||
|
if callable(getter):
|
||||||
|
raw_outro_values = getter("read_closing_outro")
|
||||||
|
if raw_outro_values:
|
||||||
|
outro_values = list(cast(Iterable[str], raw_outro_values))
|
||||||
|
else:
|
||||||
|
raw_outro = form.get("read_closing_outro")
|
||||||
|
if raw_outro is not None:
|
||||||
|
outro_values = [raw_outro]
|
||||||
|
if outro_values:
|
||||||
|
pending.read_closing_outro = coerce_bool(
|
||||||
|
outro_values[-1], getattr(pending, "read_closing_outro", True)
|
||||||
|
)
|
||||||
|
elif hasattr(form, "__contains__") and "read_closing_outro" in form:
|
||||||
|
pending.read_closing_outro = False
|
||||||
|
|
||||||
|
caps_values: List[str] = []
|
||||||
|
if callable(getter):
|
||||||
|
raw_caps_values = getter("normalize_chapter_opening_caps")
|
||||||
|
if raw_caps_values:
|
||||||
|
caps_values = list(cast(Iterable[str], raw_caps_values))
|
||||||
|
else:
|
||||||
|
raw_caps = form.get("normalize_chapter_opening_caps")
|
||||||
|
if raw_caps is not None:
|
||||||
|
caps_values = [raw_caps]
|
||||||
|
if caps_values:
|
||||||
|
pending.normalize_chapter_opening_caps = coerce_bool(
|
||||||
|
caps_values[-1], getattr(pending, "normalize_chapter_opening_caps", True)
|
||||||
|
)
|
||||||
|
elif hasattr(form, "__contains__") and "normalize_chapter_opening_caps" in form:
|
||||||
|
pending.normalize_chapter_opening_caps = False
|
||||||
|
|
||||||
|
overrides: List[Dict[str, Any]] = []
|
||||||
|
selected_total = 0
|
||||||
|
|
||||||
|
for index, chapter in enumerate(pending.chapters):
|
||||||
|
enabled = form.get(f"chapter-{index}-enabled") == "on"
|
||||||
|
title_input = (form.get(f"chapter-{index}-title") or "").strip()
|
||||||
|
title = title_input or chapter.get("title") or f"Chapter {index + 1}"
|
||||||
|
voice_selection = form.get(f"chapter-{index}-voice", "__default")
|
||||||
|
formula_input = (form.get(f"chapter-{index}-formula") or "").strip()
|
||||||
|
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
"id": chapter.get("id") or f"{index:04d}",
|
||||||
|
"index": index,
|
||||||
|
"order": index,
|
||||||
|
"source_title": chapter.get("title") or title,
|
||||||
|
"title": title,
|
||||||
|
"text": chapter.get("text", ""),
|
||||||
|
"enabled": enabled,
|
||||||
|
}
|
||||||
|
entry["characters"] = calculate_text_length(entry["text"])
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
if voice_selection.startswith("voice:"):
|
||||||
|
entry["voice"] = voice_selection.split(":", 1)[1]
|
||||||
|
entry["resolved_voice"] = entry["voice"]
|
||||||
|
elif voice_selection.startswith("profile:"):
|
||||||
|
profile_name = voice_selection.split(":", 1)[1]
|
||||||
|
entry["voice_profile"] = profile_name
|
||||||
|
profile_entry = profiles.get(profile_name) or {}
|
||||||
|
formula_value = formula_from_profile(profile_entry)
|
||||||
|
if formula_value:
|
||||||
|
entry["voice_formula"] = formula_value
|
||||||
|
entry["resolved_voice"] = formula_value
|
||||||
|
else:
|
||||||
|
errors.append(f"Profile '{profile_name}' has no configured voices.")
|
||||||
|
elif voice_selection == "formula":
|
||||||
|
if not formula_input:
|
||||||
|
errors.append(f"Provide a custom formula for chapter {index + 1}.")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
parse_voice_formula(formula_input)
|
||||||
|
except ValueError as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
|
else:
|
||||||
|
entry["voice_formula"] = formula_input
|
||||||
|
entry["resolved_voice"] = formula_input
|
||||||
|
selected_total += entry["characters"]
|
||||||
|
|
||||||
|
overrides.append(entry)
|
||||||
|
pending.chapters[index] = dict(entry)
|
||||||
|
|
||||||
|
enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
|
||||||
|
|
||||||
|
sync_pronunciation_overrides(pending)
|
||||||
|
|
||||||
|
return (
|
||||||
|
chunk_level_literal,
|
||||||
|
overrides,
|
||||||
|
enabled_overrides,
|
||||||
|
errors,
|
||||||
|
selected_total,
|
||||||
|
selected_config,
|
||||||
|
apply_config_requested,
|
||||||
|
persist_config_requested,
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply_book_step_form(
|
||||||
|
pending: PendingJob,
|
||||||
|
form: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
settings: Mapping[str, Any],
|
||||||
|
profiles: Mapping[str, Any],
|
||||||
|
) -> None:
|
||||||
|
language_fallback = pending.language or settings.get("language", "en")
|
||||||
|
raw_language = (form.get("language") or language_fallback or "en").strip()
|
||||||
|
if raw_language:
|
||||||
|
pending.language = raw_language
|
||||||
|
|
||||||
|
subtitle_mode = (form.get("subtitle_mode") or pending.subtitle_mode or "Disabled").strip()
|
||||||
|
if subtitle_mode:
|
||||||
|
pending.subtitle_mode = subtitle_mode
|
||||||
|
|
||||||
|
pending.generate_epub3 = coerce_bool(form.get("generate_epub3"), bool(pending.generate_epub3))
|
||||||
|
|
||||||
|
chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower()
|
||||||
|
raw_chunk_level = (form.get("chunk_level") or pending.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 (pending.chunk_level or "paragraph")
|
||||||
|
pending.chunk_level = raw_chunk_level
|
||||||
|
|
||||||
|
threshold_default = pending.speaker_analysis_threshold or settings.get("speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD)
|
||||||
|
raw_threshold = form.get("speaker_analysis_threshold")
|
||||||
|
if raw_threshold is not None:
|
||||||
|
pending.speaker_analysis_threshold = coerce_int(
|
||||||
|
raw_threshold,
|
||||||
|
threshold_default,
|
||||||
|
minimum=1,
|
||||||
|
maximum=25,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_delay = form.get("chapter_intro_delay")
|
||||||
|
if raw_delay is not None:
|
||||||
|
try:
|
||||||
|
pending.chapter_intro_delay = max(0.0, float(str(raw_delay).strip() or 0.0))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
intro_default = pending.read_title_intro if isinstance(pending.read_title_intro, bool) else bool(settings.get("read_title_intro", False))
|
||||||
|
intro_values: List[str] = []
|
||||||
|
getter = getattr(form, "getlist", None)
|
||||||
|
if callable(getter):
|
||||||
|
raw_intro_values = getter("read_title_intro")
|
||||||
|
if raw_intro_values:
|
||||||
|
intro_values = list(cast(Iterable[str], raw_intro_values))
|
||||||
|
else:
|
||||||
|
raw_intro_flag = form.get("read_title_intro")
|
||||||
|
if raw_intro_flag is not None:
|
||||||
|
intro_values = [raw_intro_flag]
|
||||||
|
if intro_values:
|
||||||
|
pending.read_title_intro = coerce_bool(intro_values[-1], intro_default)
|
||||||
|
elif hasattr(form, "__contains__") and "read_title_intro" in form:
|
||||||
|
pending.read_title_intro = False
|
||||||
|
else:
|
||||||
|
pending.read_title_intro = intro_default
|
||||||
|
|
||||||
|
outro_default = (
|
||||||
|
pending.read_closing_outro
|
||||||
|
if isinstance(getattr(pending, "read_closing_outro", None), bool)
|
||||||
|
else bool(settings.get("read_closing_outro", True))
|
||||||
|
)
|
||||||
|
outro_values: List[str] = []
|
||||||
|
if callable(getter):
|
||||||
|
raw_outro_values = getter("read_closing_outro")
|
||||||
|
if raw_outro_values:
|
||||||
|
outro_values = list(cast(Iterable[str], raw_outro_values))
|
||||||
|
else:
|
||||||
|
raw_outro_flag = form.get("read_closing_outro")
|
||||||
|
if raw_outro_flag is not None:
|
||||||
|
outro_values = [raw_outro_flag]
|
||||||
|
if outro_values:
|
||||||
|
pending.read_closing_outro = coerce_bool(outro_values[-1], outro_default)
|
||||||
|
elif hasattr(form, "__contains__") and "read_closing_outro" in form:
|
||||||
|
pending.read_closing_outro = False
|
||||||
|
else:
|
||||||
|
pending.read_closing_outro = outro_default
|
||||||
|
|
||||||
|
caps_default = (
|
||||||
|
pending.normalize_chapter_opening_caps
|
||||||
|
if isinstance(getattr(pending, "normalize_chapter_opening_caps", None), bool)
|
||||||
|
else bool(settings.get("normalize_chapter_opening_caps", True))
|
||||||
|
)
|
||||||
|
caps_values: List[str] = []
|
||||||
|
getter = getattr(form, "getlist", None)
|
||||||
|
if callable(getter):
|
||||||
|
raw_caps_values = getter("normalize_chapter_opening_caps")
|
||||||
|
if raw_caps_values:
|
||||||
|
caps_values = list(cast(Iterable[str], raw_caps_values))
|
||||||
|
else:
|
||||||
|
raw_caps_flag = form.get("normalize_chapter_opening_caps")
|
||||||
|
if raw_caps_flag is not None:
|
||||||
|
caps_values = [raw_caps_flag]
|
||||||
|
if caps_values:
|
||||||
|
pending.normalize_chapter_opening_caps = coerce_bool(caps_values[-1], caps_default)
|
||||||
|
elif hasattr(form, "__contains__") and "normalize_chapter_opening_caps" in form:
|
||||||
|
pending.normalize_chapter_opening_caps = False
|
||||||
|
else:
|
||||||
|
pending.normalize_chapter_opening_caps = caps_default
|
||||||
|
|
||||||
|
def _extract_checkbox(name: str, default: bool) -> bool:
|
||||||
|
values: List[str] = []
|
||||||
|
getter = getattr(form, "getlist", None)
|
||||||
|
if callable(getter):
|
||||||
|
raw_values = getter(name)
|
||||||
|
if raw_values:
|
||||||
|
values = list(cast(Iterable[str], raw_values))
|
||||||
|
else:
|
||||||
|
raw_flag = form.get(name)
|
||||||
|
if raw_flag is not None:
|
||||||
|
values = [raw_flag]
|
||||||
|
if values:
|
||||||
|
return coerce_bool(values[-1], default)
|
||||||
|
if hasattr(form, "__contains__") and name in form:
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
|
||||||
|
overrides_existing = getattr(pending, "normalization_overrides", None)
|
||||||
|
overrides: Dict[str, Any] = dict(overrides_existing or {})
|
||||||
|
for key in _NORMALIZATION_BOOLEAN_KEYS:
|
||||||
|
default_toggle = overrides.get(key, bool(settings.get(key, True)))
|
||||||
|
overrides[key] = _extract_checkbox(key, default_toggle)
|
||||||
|
for key in _NORMALIZATION_STRING_KEYS:
|
||||||
|
default_val = overrides.get(key, str(settings.get(key, "")))
|
||||||
|
val = form.get(key)
|
||||||
|
if val is not None:
|
||||||
|
overrides[key] = str(val)
|
||||||
|
else:
|
||||||
|
overrides[key] = default_val
|
||||||
|
pending.normalization_overrides = overrides
|
||||||
|
|
||||||
|
speed_value = form.get("speed")
|
||||||
|
if speed_value is not None:
|
||||||
|
try:
|
||||||
|
pending.speed = float(speed_value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip()
|
||||||
|
custom_formula_raw = (form.get("voice_formula") or "").strip()
|
||||||
|
narrator_voice_raw = (form.get("voice") or pending.voice or settings.get("default_voice") or "").strip()
|
||||||
|
|
||||||
|
profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
|
||||||
|
resolved_default_voice, inferred_profile, _ = resolve_voice_setting(
|
||||||
|
narrator_voice_raw,
|
||||||
|
profiles=profiles_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
if profile_selection in {"__standard", "", None} and inferred_profile:
|
||||||
|
profile_selection = inferred_profile
|
||||||
|
|
||||||
|
if profile_selection == "__formula":
|
||||||
|
profile_name = ""
|
||||||
|
custom_formula = custom_formula_raw
|
||||||
|
elif profile_selection in {"__standard", "", None}:
|
||||||
|
profile_name = ""
|
||||||
|
custom_formula = ""
|
||||||
|
else:
|
||||||
|
profile_name = profile_selection
|
||||||
|
custom_formula = ""
|
||||||
|
|
||||||
|
base_voice_spec = resolved_default_voice or narrator_voice_raw
|
||||||
|
if not base_voice_spec and VOICES_INTERNAL:
|
||||||
|
base_voice_spec = VOICES_INTERNAL[0]
|
||||||
|
|
||||||
|
voice_choice, resolved_language, selected_profile = resolve_voice_choice(
|
||||||
|
pending.language,
|
||||||
|
base_voice_spec,
|
||||||
|
profile_name,
|
||||||
|
custom_formula,
|
||||||
|
profiles_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resolved_language:
|
||||||
|
pending.language = resolved_language
|
||||||
|
|
||||||
|
if profile_selection == "__formula" and custom_formula_raw:
|
||||||
|
pending.voice = custom_formula_raw
|
||||||
|
pending.voice_profile = None
|
||||||
|
elif profile_selection not in {"__standard", "", None, "__formula"}:
|
||||||
|
pending.voice_profile = selected_profile or profile_selection
|
||||||
|
pending.voice = voice_choice
|
||||||
|
else:
|
||||||
|
pending.voice_profile = None
|
||||||
|
fallback_voice = base_voice_spec or narrator_voice_raw
|
||||||
|
pending.voice = voice_choice or fallback_voice
|
||||||
|
|
||||||
|
pending.applied_speaker_config = (form.get("speaker_config") or "").strip() or None
|
||||||
|
|
||||||
|
def persist_cover_image(extraction_result: Any, stored_path: Path) -> tuple[Optional[Path], Optional[str]]:
|
||||||
|
cover_bytes = getattr(extraction_result, "cover_image", None)
|
||||||
|
if not cover_bytes:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
mime = getattr(extraction_result, "cover_mime", None)
|
||||||
|
extension = mimetypes.guess_extension(mime or "") or ".png"
|
||||||
|
base_stem = Path(stored_path).stem or "cover"
|
||||||
|
candidate = stored_path.parent / f"{base_stem}_cover{extension}"
|
||||||
|
counter = 1
|
||||||
|
while candidate.exists():
|
||||||
|
candidate = stored_path.parent / f"{base_stem}_cover_{counter}{extension}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
candidate.write_bytes(cover_bytes)
|
||||||
|
except OSError:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
return candidate, mime
|
||||||
|
|
||||||
|
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:
|
||||||
|
normalized_keys = {str(existing_key).casefold(): str(existing_key) for existing_key in metadata_tags.keys()}
|
||||||
|
for key, value in metadata_overrides.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
key_text = str(key or "").strip()
|
||||||
|
if not key_text:
|
||||||
|
continue
|
||||||
|
value_text = str(value).strip()
|
||||||
|
if not value_text:
|
||||||
|
continue
|
||||||
|
lookup = key_text.casefold()
|
||||||
|
existing_key = normalized_keys.get(lookup)
|
||||||
|
if existing_key:
|
||||||
|
existing_value = str(metadata_tags.get(existing_key) or "").strip()
|
||||||
|
if existing_value:
|
||||||
|
continue
|
||||||
|
target_key = existing_key
|
||||||
|
else:
|
||||||
|
target_key = key_text
|
||||||
|
normalized_keys[lookup] = target_key
|
||||||
|
metadata_tags[target_key] = value_text
|
||||||
|
|
||||||
|
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"
|
||||||
|
profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
|
||||||
|
default_voice_setting = settings.get("default_voice") or ""
|
||||||
|
resolved_default_voice, inferred_profile, inferred_language = resolve_voice_setting(
|
||||||
|
default_voice_setting,
|
||||||
|
profiles=profiles_map,
|
||||||
|
)
|
||||||
|
base_voice_input = str(form.get("voice") or "").strip()
|
||||||
|
profile_selection = (form.get("voice_profile") or "__standard").strip()
|
||||||
|
custom_formula_raw = str(form.get("voice_formula") or "").strip()
|
||||||
|
|
||||||
|
if profile_selection in {"__standard", ""} and inferred_profile:
|
||||||
|
profile_selection = inferred_profile
|
||||||
|
|
||||||
|
base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip()
|
||||||
|
if not base_voice and VOICES_INTERNAL:
|
||||||
|
base_voice = VOICES_INTERNAL[0]
|
||||||
|
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 == "__formula":
|
||||||
|
profile_name = ""
|
||||||
|
custom_formula = custom_formula_raw
|
||||||
|
elif profile_selection in {"__standard", ""}:
|
||||||
|
profile_name = ""
|
||||||
|
custom_formula = ""
|
||||||
|
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"]
|
||||||
|
read_closing_outro = settings.get("read_closing_outro", True)
|
||||||
|
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,
|
||||||
|
normalization_overrides={
|
||||||
|
**{key: bool(settings.get(key, True)) for key in _NORMALIZATION_BOOLEAN_KEYS},
|
||||||
|
**{key: str(settings.get(key, "")) for key in _NORMALIZATION_STRING_KEYS},
|
||||||
|
},
|
||||||
|
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),
|
||||||
|
read_closing_outro=bool(read_closing_outro),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
def render_jobs_panel() -> str:
|
||||||
|
jobs = get_service().list_jobs()
|
||||||
|
active_statuses = {JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED}
|
||||||
|
active_jobs = [job for job in jobs if job.status in active_statuses]
|
||||||
|
active_jobs.sort(key=lambda job: ((job.queue_position or 10_000), -job.created_at))
|
||||||
|
finished_jobs = [job for job in jobs if job.status not in active_statuses]
|
||||||
|
download_flags = {job.id: job_download_flags(job) for job in jobs}
|
||||||
|
return render_template(
|
||||||
|
"partials/jobs.html",
|
||||||
|
active_jobs=active_jobs,
|
||||||
|
finished_jobs=finished_jobs[:5],
|
||||||
|
total_finished=len(finished_jobs),
|
||||||
|
JobStatus=JobStatus,
|
||||||
|
download_flags=download_flags,
|
||||||
|
audiobookshelf_manual_available=audiobookshelf_manual_available(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_wizard_step(step: Optional[str], pending: Optional[PendingJob] = None) -> str:
|
||||||
|
if pending is None:
|
||||||
|
default_step = "book"
|
||||||
|
else:
|
||||||
|
default_step = "chapters"
|
||||||
|
if not step:
|
||||||
|
chosen = default_step
|
||||||
|
else:
|
||||||
|
normalized = step.strip().lower()
|
||||||
|
if normalized in {"", "upload", "settings"}:
|
||||||
|
chosen = default_step
|
||||||
|
elif normalized == "speakers":
|
||||||
|
chosen = "entities"
|
||||||
|
elif normalized in _WIZARD_STEP_ORDER:
|
||||||
|
chosen = normalized
|
||||||
|
else:
|
||||||
|
chosen = default_step
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
|
def wants_wizard_json() -> bool:
|
||||||
|
format_hint = request.args.get("format", "").strip().lower()
|
||||||
|
if format_hint == "json":
|
||||||
|
return True
|
||||||
|
accept_header = (request.headers.get("Accept") or "").lower()
|
||||||
|
if "application/json" in accept_header:
|
||||||
|
return True
|
||||||
|
requested_with = (request.headers.get("X-Requested-With") or "").lower()
|
||||||
|
if requested_with in {"xmlhttprequest", "fetch"}:
|
||||||
|
return True
|
||||||
|
wizard_header = (request.headers.get("X-Abogen-Wizard") or "").lower()
|
||||||
|
return wizard_header == "json"
|
||||||
|
|
||||||
|
|
||||||
|
def render_wizard_partial(
|
||||||
|
pending: Optional[PendingJob],
|
||||||
|
step: str,
|
||||||
|
*,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
notice: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
templates = {
|
||||||
|
"book": "partials/new_job_step_book.html",
|
||||||
|
"chapters": "partials/new_job_step_chapters.html",
|
||||||
|
"entities": "partials/new_job_step_entities.html",
|
||||||
|
}
|
||||||
|
template_name = templates[step]
|
||||||
|
context: Dict[str, Any] = {
|
||||||
|
"pending": pending,
|
||||||
|
"readonly": False,
|
||||||
|
"options": template_options(),
|
||||||
|
"settings": load_settings(),
|
||||||
|
"error": error,
|
||||||
|
"notice": notice,
|
||||||
|
}
|
||||||
|
return render_template(template_name, **context)
|
||||||
|
|
||||||
|
|
||||||
|
def wizard_step_payload(
|
||||||
|
pending: Optional[PendingJob],
|
||||||
|
step: str,
|
||||||
|
html: str,
|
||||||
|
*,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
notice: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
meta = _WIZARD_STEP_META.get(step, {})
|
||||||
|
try:
|
||||||
|
active_index = _WIZARD_STEP_ORDER.index(step)
|
||||||
|
except ValueError:
|
||||||
|
active_index = 0
|
||||||
|
max_recorded_index = active_index
|
||||||
|
if pending is not None:
|
||||||
|
stored_index = int(getattr(pending, "wizard_max_step_index", -1))
|
||||||
|
if stored_index < 0:
|
||||||
|
stored_index = -1
|
||||||
|
max_recorded_index = max(active_index, stored_index)
|
||||||
|
max_allowed = len(_WIZARD_STEP_ORDER) - 1
|
||||||
|
if max_recorded_index > max_allowed:
|
||||||
|
max_recorded_index = max_allowed
|
||||||
|
if stored_index != max_recorded_index:
|
||||||
|
pending.wizard_max_step_index = max_recorded_index
|
||||||
|
get_service().store_pending_job(pending)
|
||||||
|
else:
|
||||||
|
max_allowed = len(_WIZARD_STEP_ORDER) - 1
|
||||||
|
if max_recorded_index > max_allowed:
|
||||||
|
max_recorded_index = max_allowed
|
||||||
|
completed = [slug for idx, slug in enumerate(_WIZARD_STEP_ORDER) if idx <= max_recorded_index]
|
||||||
|
return {
|
||||||
|
"step": step,
|
||||||
|
"step_index": int(meta.get("index", active_index + 1)),
|
||||||
|
"total_steps": len(_WIZARD_STEP_ORDER),
|
||||||
|
"title": meta.get("title", ""),
|
||||||
|
"hint": meta.get("hint", ""),
|
||||||
|
"html": html,
|
||||||
|
"completed_steps": completed,
|
||||||
|
"pending_id": pending.id if pending else "",
|
||||||
|
"filename": pending.original_filename if pending and pending.original_filename else "",
|
||||||
|
"error": error or "",
|
||||||
|
"notice": notice or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def wizard_json_response(
|
||||||
|
pending: Optional[PendingJob],
|
||||||
|
step: str,
|
||||||
|
*,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
notice: Optional[str] = None,
|
||||||
|
status: int = 200,
|
||||||
|
) -> ResponseReturnValue:
|
||||||
|
html = render_wizard_partial(pending, step, error=error, notice=notice)
|
||||||
|
payload = wizard_step_payload(pending, step, html, error=error, notice=notice)
|
||||||
|
return jsonify(payload), status
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import io
|
||||||
|
import threading
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from flask import current_app, send_file
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
|
from abogen.utils import load_numpy_kpipeline
|
||||||
|
from abogen.voice_formulas import get_new_voice
|
||||||
|
from abogen.web.conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
|
||||||
|
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||||
|
|
||||||
|
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||||
|
_preview_pipeline_lock = threading.Lock()
|
||||||
|
|
||||||
|
def get_preview_pipeline(language: str, device: str) -> Any:
|
||||||
|
key = (language, device)
|
||||||
|
with _preview_pipeline_lock:
|
||||||
|
pipeline = _preview_pipelines.get(key)
|
||||||
|
if pipeline is not None:
|
||||||
|
return pipeline
|
||||||
|
_, KPipeline = load_numpy_kpipeline()
|
||||||
|
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||||
|
_preview_pipelines[key] = pipeline
|
||||||
|
return pipeline
|
||||||
|
|
||||||
|
def synthesize_preview(
|
||||||
|
text: str,
|
||||||
|
voice_spec: str,
|
||||||
|
language: str,
|
||||||
|
speed: float,
|
||||||
|
use_gpu: bool,
|
||||||
|
max_seconds: float = 8.0,
|
||||||
|
) -> ResponseReturnValue:
|
||||||
|
if not text.strip():
|
||||||
|
raise ValueError("Preview text is required")
|
||||||
|
|
||||||
|
device = "cpu"
|
||||||
|
if use_gpu:
|
||||||
|
try:
|
||||||
|
device = _select_device()
|
||||||
|
except Exception:
|
||||||
|
device = "cpu"
|
||||||
|
use_gpu = False
|
||||||
|
|
||||||
|
pipeline = get_preview_pipeline(language, device)
|
||||||
|
if pipeline is None:
|
||||||
|
raise RuntimeError("Preview pipeline is unavailable")
|
||||||
|
|
||||||
|
voice_choice: Any = voice_spec
|
||||||
|
if voice_spec and "*" in voice_spec:
|
||||||
|
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
|
||||||
|
|
||||||
|
try:
|
||||||
|
normalized_text = normalize_for_pipeline(text)
|
||||||
|
except Exception:
|
||||||
|
current_app.logger.exception("Preview normalization failed; using raw text")
|
||||||
|
normalized_text = text
|
||||||
|
|
||||||
|
segments = pipeline(
|
||||||
|
normalized_text,
|
||||||
|
voice=voice_choice,
|
||||||
|
speed=speed,
|
||||||
|
split_pattern=SPLIT_PATTERN,
|
||||||
|
)
|
||||||
|
|
||||||
|
audio_chunks: List[np.ndarray] = []
|
||||||
|
accumulated = 0
|
||||||
|
max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
|
||||||
|
|
||||||
|
for segment in segments:
|
||||||
|
graphemes = getattr(segment, "graphemes", "").strip()
|
||||||
|
if not graphemes:
|
||||||
|
continue
|
||||||
|
audio = _to_float32(getattr(segment, "audio", None))
|
||||||
|
if audio.size == 0:
|
||||||
|
continue
|
||||||
|
remaining = max_samples - accumulated
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
if audio.shape[0] > remaining:
|
||||||
|
audio = audio[:remaining]
|
||||||
|
audio_chunks.append(audio)
|
||||||
|
accumulated += audio.shape[0]
|
||||||
|
if accumulated >= max_samples:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not audio_chunks:
|
||||||
|
raise RuntimeError("Preview could not be generated")
|
||||||
|
|
||||||
|
audio_data = np.concatenate(audio_chunks)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
response = send_file(
|
||||||
|
buffer,
|
||||||
|
mimetype="audio/wav",
|
||||||
|
as_attachment=False,
|
||||||
|
download_name="speaker_preview.wav",
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from typing import cast
|
||||||
|
from flask import current_app, abort
|
||||||
|
from abogen.web.service import ConversionService, PendingJob
|
||||||
|
|
||||||
|
def get_service() -> ConversionService:
|
||||||
|
return current_app.extensions["conversion_service"]
|
||||||
|
|
||||||
|
def require_pending_job(pending_id: str) -> PendingJob:
|
||||||
|
pending = get_service().get_pending_job(pending_id)
|
||||||
|
if not pending:
|
||||||
|
abort(404)
|
||||||
|
return cast(PendingJob, pending)
|
||||||
|
|
||||||
|
def remove_pending_job(pending_id: str) -> None:
|
||||||
|
get_service().pop_pending_job(pending_id)
|
||||||
|
|
||||||
|
def submit_job(pending: PendingJob) -> str:
|
||||||
|
service = get_service()
|
||||||
|
service.pop_pending_job(pending.id)
|
||||||
|
|
||||||
|
job = service.enqueue(
|
||||||
|
original_filename=pending.original_filename,
|
||||||
|
stored_path=pending.stored_path,
|
||||||
|
language=pending.language,
|
||||||
|
voice=pending.voice,
|
||||||
|
speed=pending.speed,
|
||||||
|
use_gpu=pending.use_gpu,
|
||||||
|
subtitle_mode=pending.subtitle_mode,
|
||||||
|
output_format=pending.output_format,
|
||||||
|
save_mode=pending.save_mode,
|
||||||
|
output_folder=pending.output_folder,
|
||||||
|
replace_single_newlines=pending.replace_single_newlines,
|
||||||
|
subtitle_format=pending.subtitle_format,
|
||||||
|
total_characters=pending.total_characters,
|
||||||
|
chapters=pending.chapters,
|
||||||
|
save_chapters_separately=pending.save_chapters_separately,
|
||||||
|
merge_chapters_at_end=pending.merge_chapters_at_end,
|
||||||
|
separate_chapters_format=pending.separate_chapters_format,
|
||||||
|
silence_between_chapters=pending.silence_between_chapters,
|
||||||
|
save_as_project=pending.save_as_project,
|
||||||
|
voice_profile=pending.voice_profile,
|
||||||
|
max_subtitle_words=pending.max_subtitle_words,
|
||||||
|
metadata_tags=pending.metadata_tags,
|
||||||
|
cover_image_path=pending.cover_image_path,
|
||||||
|
cover_image_mime=pending.cover_image_mime,
|
||||||
|
chapter_intro_delay=pending.chapter_intro_delay,
|
||||||
|
read_title_intro=pending.read_title_intro,
|
||||||
|
read_closing_outro=pending.read_closing_outro,
|
||||||
|
auto_prefix_chapter_titles=pending.auto_prefix_chapter_titles,
|
||||||
|
normalize_chapter_opening_caps=pending.normalize_chapter_opening_caps,
|
||||||
|
chunk_level=pending.chunk_level,
|
||||||
|
chunks=pending.chunks,
|
||||||
|
speakers=pending.speakers,
|
||||||
|
speaker_mode=pending.speaker_mode,
|
||||||
|
generate_epub3=pending.generate_epub3,
|
||||||
|
speaker_analysis=pending.speaker_analysis,
|
||||||
|
speaker_analysis_threshold=pending.speaker_analysis_threshold,
|
||||||
|
analysis_requested=pending.analysis_requested,
|
||||||
|
entity_summary=getattr(pending, "entity_summary", None),
|
||||||
|
manual_overrides=getattr(pending, "manual_overrides", None),
|
||||||
|
pronunciation_overrides=getattr(pending, "pronunciation_overrides", None),
|
||||||
|
normalization_overrides=pending.normalization_overrides,
|
||||||
|
)
|
||||||
|
return job.id
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, Mapping, Optional
|
||||||
|
|
||||||
|
from abogen.constants import (
|
||||||
|
LANGUAGE_DESCRIPTIONS,
|
||||||
|
SUBTITLE_FORMATS,
|
||||||
|
SUPPORTED_SOUND_FORMATS,
|
||||||
|
VOICES_INTERNAL,
|
||||||
|
)
|
||||||
|
from abogen.normalization_settings import (
|
||||||
|
DEFAULT_LLM_PROMPT,
|
||||||
|
environment_llm_defaults,
|
||||||
|
)
|
||||||
|
from abogen.utils import load_config, save_config
|
||||||
|
from abogen.integrations.calibre_opds import CalibreOPDSClient
|
||||||
|
from abogen.integrations.audiobookshelf import AudiobookshelfConfig
|
||||||
|
from abogen.web.routes.utils.common import split_profile_spec
|
||||||
|
|
||||||
|
SAVE_MODE_LABELS = {
|
||||||
|
"save_next_to_input": "Save next to input file",
|
||||||
|
"save_to_desktop": "Save to Desktop",
|
||||||
|
"choose_output_folder": "Choose output folder",
|
||||||
|
"default_output": "Use default save location",
|
||||||
|
}
|
||||||
|
|
||||||
|
LEGACY_SAVE_MODE_MAP = {label: key for key, label in SAVE_MODE_LABELS.items()}
|
||||||
|
|
||||||
|
_CHUNK_LEVEL_OPTIONS = [
|
||||||
|
{"value": "paragraph", "label": "Paragraphs"},
|
||||||
|
{"value": "sentence", "label": "Sentences"},
|
||||||
|
]
|
||||||
|
|
||||||
|
_CHUNK_LEVEL_VALUES = {option["value"] for option in _CHUNK_LEVEL_OPTIONS}
|
||||||
|
|
||||||
|
_DEFAULT_ANALYSIS_THRESHOLD = 3
|
||||||
|
|
||||||
|
_APOSTROPHE_MODE_OPTIONS = [
|
||||||
|
{"value": "off", "label": "Off"},
|
||||||
|
{"value": "spacy", "label": "spaCy (built-in)"},
|
||||||
|
{"value": "llm", "label": "LLM assisted"},
|
||||||
|
]
|
||||||
|
|
||||||
|
_NORMALIZATION_BOOLEAN_KEYS = {
|
||||||
|
"normalization_numbers",
|
||||||
|
"normalization_titles",
|
||||||
|
"normalization_terminal",
|
||||||
|
"normalization_phoneme_hints",
|
||||||
|
"normalization_caps_quotes",
|
||||||
|
"normalization_apostrophes_contractions",
|
||||||
|
"normalization_apostrophes_plural_possessives",
|
||||||
|
"normalization_apostrophes_sibilant_possessives",
|
||||||
|
"normalization_apostrophes_decades",
|
||||||
|
"normalization_apostrophes_leading_elisions",
|
||||||
|
"normalization_contraction_aux_be",
|
||||||
|
"normalization_contraction_aux_have",
|
||||||
|
"normalization_contraction_modal_will",
|
||||||
|
"normalization_contraction_modal_would",
|
||||||
|
"normalization_contraction_negation_not",
|
||||||
|
"normalization_contraction_let_us",
|
||||||
|
}
|
||||||
|
|
||||||
|
_NORMALIZATION_STRING_KEYS = {
|
||||||
|
"normalization_numbers_year_style",
|
||||||
|
"normalization_apostrophe_mode",
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOLEAN_SETTINGS = {
|
||||||
|
"replace_single_newlines",
|
||||||
|
"use_gpu",
|
||||||
|
"save_chapters_separately",
|
||||||
|
"merge_chapters_at_end",
|
||||||
|
"save_as_project",
|
||||||
|
"generate_epub3",
|
||||||
|
"enable_entity_recognition",
|
||||||
|
"read_title_intro",
|
||||||
|
"read_closing_outro",
|
||||||
|
"auto_prefix_chapter_titles",
|
||||||
|
"normalize_chapter_opening_caps",
|
||||||
|
"normalization_numbers",
|
||||||
|
"normalization_titles",
|
||||||
|
"normalization_terminal",
|
||||||
|
"normalization_phoneme_hints",
|
||||||
|
"normalization_caps_quotes",
|
||||||
|
"normalization_apostrophes_contractions",
|
||||||
|
"normalization_apostrophes_plural_possessives",
|
||||||
|
"normalization_apostrophes_sibilant_possessives",
|
||||||
|
"normalization_apostrophes_decades",
|
||||||
|
"normalization_apostrophes_leading_elisions",
|
||||||
|
"normalization_contraction_aux_be",
|
||||||
|
"normalization_contraction_aux_have",
|
||||||
|
"normalization_contraction_modal_will",
|
||||||
|
"normalization_contraction_modal_would",
|
||||||
|
"normalization_contraction_negation_not",
|
||||||
|
"normalization_contraction_let_us",
|
||||||
|
}
|
||||||
|
|
||||||
|
FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay", "llm_timeout"}
|
||||||
|
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": "",
|
||||||
|
"folder_id": "",
|
||||||
|
"verify_ssl": True,
|
||||||
|
"send_cover": True,
|
||||||
|
"send_chapters": True,
|
||||||
|
"send_subtitles": False,
|
||||||
|
"auto_send": False,
|
||||||
|
"timeout": 30.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def has_output_override() -> bool:
|
||||||
|
return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT"))
|
||||||
|
|
||||||
|
|
||||||
|
def settings_defaults() -> Dict[str, Any]:
|
||||||
|
llm_env_defaults = environment_llm_defaults()
|
||||||
|
return {
|
||||||
|
"output_format": "wav",
|
||||||
|
"subtitle_format": "srt",
|
||||||
|
"save_mode": "default_output" if has_output_override() else "save_next_to_input",
|
||||||
|
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
|
||||||
|
"replace_single_newlines": False,
|
||||||
|
"use_gpu": True,
|
||||||
|
"save_chapters_separately": False,
|
||||||
|
"merge_chapters_at_end": True,
|
||||||
|
"save_as_project": False,
|
||||||
|
"separate_chapters_format": "wav",
|
||||||
|
"silence_between_chapters": 2.0,
|
||||||
|
"chapter_intro_delay": 0.5,
|
||||||
|
"read_title_intro": False,
|
||||||
|
"read_closing_outro": True,
|
||||||
|
"normalize_chapter_opening_caps": True,
|
||||||
|
"max_subtitle_words": 50,
|
||||||
|
"chunk_level": "paragraph",
|
||||||
|
"enable_entity_recognition": True,
|
||||||
|
"generate_epub3": False,
|
||||||
|
"auto_prefix_chapter_titles": True,
|
||||||
|
"speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
|
||||||
|
"speaker_pronunciation_sentence": "This is {{name}} speaking.",
|
||||||
|
"speaker_random_languages": [],
|
||||||
|
"llm_base_url": llm_env_defaults.get("llm_base_url", ""),
|
||||||
|
"llm_api_key": llm_env_defaults.get("llm_api_key", ""),
|
||||||
|
"llm_model": llm_env_defaults.get("llm_model", ""),
|
||||||
|
"llm_timeout": llm_env_defaults.get("llm_timeout", 30.0),
|
||||||
|
"llm_prompt": llm_env_defaults.get("llm_prompt", DEFAULT_LLM_PROMPT),
|
||||||
|
"llm_context_mode": llm_env_defaults.get("llm_context_mode", "sentence"),
|
||||||
|
"normalization_numbers": True,
|
||||||
|
"normalization_titles": True,
|
||||||
|
"normalization_terminal": True,
|
||||||
|
"normalization_phoneme_hints": True,
|
||||||
|
"normalization_caps_quotes": True,
|
||||||
|
"normalization_apostrophes_contractions": True,
|
||||||
|
"normalization_apostrophes_plural_possessives": True,
|
||||||
|
"normalization_apostrophes_sibilant_possessives": True,
|
||||||
|
"normalization_apostrophes_decades": True,
|
||||||
|
"normalization_apostrophes_leading_elisions": True,
|
||||||
|
"normalization_apostrophe_mode": "spacy",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def llm_ready(settings: Mapping[str, Any]) -> bool:
|
||||||
|
base_url = str(settings.get("llm_base_url") or "").strip()
|
||||||
|
return bool(base_url)
|
||||||
|
|
||||||
|
|
||||||
|
_PROMPT_TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
|
||||||
|
|
||||||
|
|
||||||
|
def render_prompt_template(template: str, context: Mapping[str, str]) -> str:
|
||||||
|
if not template:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _replace(match: re.Match[str]) -> str:
|
||||||
|
key = match.group(1)
|
||||||
|
return context.get(key, "")
|
||||||
|
|
||||||
|
return _PROMPT_TOKEN_RE.sub(_replace, template)
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_bool(value: Any, default: bool) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.lower() in {"true", "1", "yes", "on"}
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_float(value: Any, default: float) -> float:
|
||||||
|
try:
|
||||||
|
return max(0.0, float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_int(value: Any, default: int, *, minimum: int = 1, maximum: int = 200) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return max(minimum, min(parsed, maximum))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_save_mode(value: Any, default: str) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value in SAVE_MODE_LABELS:
|
||||||
|
return value
|
||||||
|
if value in LEGACY_SAVE_MODE_MAP:
|
||||||
|
return LEGACY_SAVE_MODE_MAP[value]
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> Any:
|
||||||
|
if key in BOOLEAN_SETTINGS:
|
||||||
|
return coerce_bool(value, defaults[key])
|
||||||
|
if key in FLOAT_SETTINGS:
|
||||||
|
return coerce_float(value, defaults[key])
|
||||||
|
if key in INT_SETTINGS:
|
||||||
|
return coerce_int(value, defaults[key])
|
||||||
|
if key == "save_mode":
|
||||||
|
return normalize_save_mode(value, defaults[key])
|
||||||
|
if key == "output_format":
|
||||||
|
return value if value in SUPPORTED_SOUND_FORMATS else defaults[key]
|
||||||
|
if key == "subtitle_format":
|
||||||
|
valid = {item[0] for item in SUBTITLE_FORMATS}
|
||||||
|
return value if value in valid else defaults[key]
|
||||||
|
if key == "separate_chapters_format":
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.lower()
|
||||||
|
if normalized in {"wav", "flac", "mp3", "opus"}:
|
||||||
|
return normalized
|
||||||
|
return defaults[key]
|
||||||
|
if key == "default_voice":
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
return defaults[key]
|
||||||
|
spec, profile_name = split_profile_spec(text)
|
||||||
|
if profile_name:
|
||||||
|
return f"profile:{profile_name}"
|
||||||
|
return spec
|
||||||
|
return defaults[key]
|
||||||
|
if key == "chunk_level":
|
||||||
|
if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES:
|
||||||
|
return value
|
||||||
|
return defaults[key]
|
||||||
|
if key == "normalization_apostrophe_mode":
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized_mode = value.strip().lower()
|
||||||
|
if normalized_mode in {"off", "spacy", "llm"}:
|
||||||
|
return normalized_mode
|
||||||
|
return defaults[key]
|
||||||
|
if key == "llm_context_mode":
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized_scope = value.strip().lower()
|
||||||
|
if normalized_scope == "sentence":
|
||||||
|
return normalized_scope
|
||||||
|
return defaults[key]
|
||||||
|
if key == "llm_prompt":
|
||||||
|
candidate = str(value or "").strip()
|
||||||
|
return candidate if candidate else defaults[key]
|
||||||
|
if key in {"llm_base_url", "llm_api_key", "llm_model"}:
|
||||||
|
return str(value or "").strip()
|
||||||
|
if key == "speaker_random_languages":
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS]
|
||||||
|
if isinstance(value, str):
|
||||||
|
parts = [item.strip().lower() for item in value.split(",") if item.strip()]
|
||||||
|
return [code for code in parts if code in LANGUAGE_DESCRIPTIONS]
|
||||||
|
return defaults.get(key, [])
|
||||||
|
return value if value is not None else defaults.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings() -> Dict[str, Any]:
|
||||||
|
defaults = settings_defaults()
|
||||||
|
cfg = load_config() or {}
|
||||||
|
settings: Dict[str, Any] = {}
|
||||||
|
for key, default in defaults.items():
|
||||||
|
raw_value = cfg.get(key, default)
|
||||||
|
settings[key] = normalize_setting_value(key, raw_value, defaults)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def load_integration_settings() -> Dict[str, Dict[str, Any]]:
|
||||||
|
defaults = integration_defaults()
|
||||||
|
cfg = load_config() or {}
|
||||||
|
integrations: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for key, default in defaults.items():
|
||||||
|
stored = cfg.get(key)
|
||||||
|
merged: Dict[str, Any] = dict(default)
|
||||||
|
if isinstance(stored, Mapping):
|
||||||
|
for field, default_value in default.items():
|
||||||
|
value = stored.get(field, default_value)
|
||||||
|
if isinstance(default_value, bool):
|
||||||
|
merged[field] = coerce_bool(value, default_value)
|
||||||
|
elif isinstance(default_value, float):
|
||||||
|
try:
|
||||||
|
merged[field] = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
merged[field] = default_value
|
||||||
|
elif isinstance(default_value, int):
|
||||||
|
try:
|
||||||
|
merged[field] = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
merged[field] = default_value
|
||||||
|
else:
|
||||||
|
merged[field] = str(value or "")
|
||||||
|
if key == "calibre_opds":
|
||||||
|
merged["has_password"] = bool(isinstance(stored, Mapping) and stored.get("password"))
|
||||||
|
merged["password"] = ""
|
||||||
|
elif key == "audiobookshelf":
|
||||||
|
merged["has_api_token"] = bool(isinstance(stored, Mapping) and stored.get("api_token"))
|
||||||
|
merged["api_token"] = ""
|
||||||
|
integrations[key] = merged
|
||||||
|
return integrations
|
||||||
|
|
||||||
|
|
||||||
|
def stored_integration_config(name: str) -> Dict[str, Any]:
|
||||||
|
cfg = load_config() or {}
|
||||||
|
entry = cfg.get(name)
|
||||||
|
if isinstance(entry, Mapping):
|
||||||
|
return dict(entry)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def calibre_settings_from_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
|
defaults = integration_defaults()["calibre_opds"]
|
||||||
|
stored = stored_integration_config("calibre_opds")
|
||||||
|
|
||||||
|
base_url = str(
|
||||||
|
payload.get("base_url")
|
||||||
|
or payload.get("calibre_opds_base_url")
|
||||||
|
or stored.get("base_url")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
username = str(
|
||||||
|
payload.get("username")
|
||||||
|
or payload.get("calibre_opds_username")
|
||||||
|
or stored.get("username")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
password_input = str(
|
||||||
|
payload.get("password")
|
||||||
|
or payload.get("calibre_opds_password")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
use_saved_password = coerce_bool(
|
||||||
|
payload.get("use_saved_password")
|
||||||
|
or payload.get("calibre_opds_use_saved_password"),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
clear_saved_password = coerce_bool(
|
||||||
|
payload.get("clear_saved_password")
|
||||||
|
or payload.get("calibre_opds_password_clear"),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
password = ""
|
||||||
|
if password_input:
|
||||||
|
password = password_input
|
||||||
|
elif use_saved_password and not clear_saved_password:
|
||||||
|
password = str(stored.get("password") or "")
|
||||||
|
|
||||||
|
verify_ssl = coerce_bool(
|
||||||
|
payload.get("verify_ssl")
|
||||||
|
or payload.get("calibre_opds_verify_ssl"),
|
||||||
|
defaults["verify_ssl"],
|
||||||
|
)
|
||||||
|
enabled = coerce_bool(
|
||||||
|
payload.get("enabled")
|
||||||
|
or payload.get("calibre_opds_enabled"),
|
||||||
|
coerce_bool(stored.get("enabled"), False),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": enabled,
|
||||||
|
"base_url": base_url,
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"verify_ssl": verify_ssl,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def audiobookshelf_settings_from_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
|
defaults = integration_defaults()["audiobookshelf"]
|
||||||
|
stored = stored_integration_config("audiobookshelf")
|
||||||
|
|
||||||
|
base_url = str(
|
||||||
|
payload.get("base_url")
|
||||||
|
or payload.get("audiobookshelf_base_url")
|
||||||
|
or stored.get("base_url")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
library_id = str(
|
||||||
|
payload.get("library_id")
|
||||||
|
or payload.get("audiobookshelf_library_id")
|
||||||
|
or stored.get("library_id")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
collection_id = str(
|
||||||
|
payload.get("collection_id")
|
||||||
|
or payload.get("audiobookshelf_collection_id")
|
||||||
|
or stored.get("collection_id")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
folder_id = str(
|
||||||
|
payload.get("folder_id")
|
||||||
|
or payload.get("audiobookshelf_folder_id")
|
||||||
|
or stored.get("folder_id")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
token_input = str(
|
||||||
|
payload.get("api_token")
|
||||||
|
or payload.get("audiobookshelf_api_token")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
use_saved_token = coerce_bool(
|
||||||
|
payload.get("use_saved_token")
|
||||||
|
or payload.get("audiobookshelf_use_saved_token"),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
clear_saved_token = coerce_bool(
|
||||||
|
payload.get("clear_saved_token")
|
||||||
|
or payload.get("audiobookshelf_api_token_clear"),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
if token_input:
|
||||||
|
api_token = token_input
|
||||||
|
elif use_saved_token and not clear_saved_token:
|
||||||
|
api_token = str(stored.get("api_token") or "")
|
||||||
|
else:
|
||||||
|
api_token = ""
|
||||||
|
|
||||||
|
verify_ssl = coerce_bool(
|
||||||
|
payload.get("verify_ssl")
|
||||||
|
or payload.get("audiobookshelf_verify_ssl"),
|
||||||
|
defaults["verify_ssl"],
|
||||||
|
)
|
||||||
|
send_cover = coerce_bool(
|
||||||
|
payload.get("send_cover")
|
||||||
|
or payload.get("audiobookshelf_send_cover"),
|
||||||
|
defaults["send_cover"],
|
||||||
|
)
|
||||||
|
send_chapters = coerce_bool(
|
||||||
|
payload.get("send_chapters")
|
||||||
|
or payload.get("audiobookshelf_send_chapters"),
|
||||||
|
defaults["send_chapters"],
|
||||||
|
)
|
||||||
|
send_subtitles = coerce_bool(
|
||||||
|
payload.get("send_subtitles")
|
||||||
|
or payload.get("audiobookshelf_send_subtitles"),
|
||||||
|
defaults["send_subtitles"],
|
||||||
|
)
|
||||||
|
auto_send = coerce_bool(
|
||||||
|
payload.get("auto_send")
|
||||||
|
or payload.get("audiobookshelf_auto_send"),
|
||||||
|
defaults["auto_send"],
|
||||||
|
)
|
||||||
|
timeout_raw = (
|
||||||
|
payload.get("timeout")
|
||||||
|
or payload.get("audiobookshelf_timeout")
|
||||||
|
or stored.get("timeout")
|
||||||
|
or defaults["timeout"]
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
timeout = float(timeout_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout = defaults["timeout"]
|
||||||
|
|
||||||
|
enabled = coerce_bool(
|
||||||
|
payload.get("enabled")
|
||||||
|
or payload.get("audiobookshelf_enabled"),
|
||||||
|
coerce_bool(stored.get("enabled"), False),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": enabled,
|
||||||
|
"base_url": base_url,
|
||||||
|
"library_id": library_id,
|
||||||
|
"collection_id": collection_id,
|
||||||
|
"folder_id": folder_id,
|
||||||
|
"api_token": api_token,
|
||||||
|
"verify_ssl": verify_ssl,
|
||||||
|
"send_cover": send_cover,
|
||||||
|
"send_chapters": send_chapters,
|
||||||
|
"send_subtitles": send_subtitles,
|
||||||
|
"auto_send": auto_send,
|
||||||
|
"timeout": timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_audiobookshelf_config(settings: Mapping[str, Any]) -> Optional[AudiobookshelfConfig]:
|
||||||
|
base_url = str(settings.get("base_url") or "").strip()
|
||||||
|
api_token = str(settings.get("api_token") or "").strip()
|
||||||
|
library_id = str(settings.get("library_id") or "").strip()
|
||||||
|
if not (base_url and api_token and library_id):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
timeout = float(settings.get("timeout", 3600.0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout = 3600.0
|
||||||
|
return AudiobookshelfConfig(
|
||||||
|
base_url=base_url,
|
||||||
|
api_token=api_token,
|
||||||
|
library_id=library_id,
|
||||||
|
collection_id=(str(settings.get("collection_id") or "").strip() or None),
|
||||||
|
folder_id=(str(settings.get("folder_id") or "").strip() or None),
|
||||||
|
verify_ssl=coerce_bool(settings.get("verify_ssl"), True),
|
||||||
|
send_cover=coerce_bool(settings.get("send_cover"), True),
|
||||||
|
send_chapters=coerce_bool(settings.get("send_chapters"), True),
|
||||||
|
send_subtitles=coerce_bool(settings.get("send_subtitles"), False),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def calibre_integration_enabled(
|
||||||
|
integrations: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> bool:
|
||||||
|
if integrations is None:
|
||||||
|
integrations = load_integration_settings()
|
||||||
|
payload = integrations.get("calibre_opds") if isinstance(integrations, Mapping) else None
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
return False
|
||||||
|
base_url = str(payload.get("base_url") or "").strip()
|
||||||
|
enabled_flag = coerce_bool(payload.get("enabled"), False)
|
||||||
|
return bool(enabled_flag and base_url)
|
||||||
|
|
||||||
|
|
||||||
|
def audiobookshelf_manual_available() -> bool:
|
||||||
|
settings = stored_integration_config("audiobookshelf")
|
||||||
|
if not settings:
|
||||||
|
return False
|
||||||
|
if not coerce_bool(settings.get("enabled"), False):
|
||||||
|
return False
|
||||||
|
config = build_audiobookshelf_config(settings)
|
||||||
|
return config is not None
|
||||||
|
|
||||||
|
|
||||||
|
def build_calibre_client(settings: Mapping[str, Any]) -> CalibreOPDSClient:
|
||||||
|
base_url = str(settings.get("base_url") or "").strip()
|
||||||
|
if not base_url:
|
||||||
|
raise ValueError("Calibre OPDS base URL is required")
|
||||||
|
username = str(settings.get("username") or "").strip() or None
|
||||||
|
password = str(settings.get("password") or "").strip() or None
|
||||||
|
verify_ssl = coerce_bool(settings.get("verify_ssl"), True)
|
||||||
|
timeout_raw = settings.get("timeout", 15.0)
|
||||||
|
try:
|
||||||
|
timeout = float(timeout_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout = 15.0
|
||||||
|
return CalibreOPDSClient(
|
||||||
|
base_url,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=timeout,
|
||||||
|
verify=verify_ssl,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_integration_form(cfg: Dict[str, Any], form: Mapping[str, Any]) -> None:
|
||||||
|
defaults = integration_defaults()
|
||||||
|
|
||||||
|
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_folder = str(form.get("audiobookshelf_folder_id") or current_abs.get("folder_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,
|
||||||
|
"folder_id": abs_folder,
|
||||||
|
"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 save_settings(settings: Dict[str, Any]) -> None:
|
||||||
|
save_config(settings)
|
||||||
@@ -0,0 +1,786 @@
|
|||||||
|
import threading
|
||||||
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from abogen.speaker_configs import slugify_label
|
||||||
|
from abogen.speaker_analysis import analyze_speakers
|
||||||
|
from abogen.web.routes.utils.settings import load_settings, settings_defaults, _DEFAULT_ANALYSIS_THRESHOLD, _CHUNK_LEVEL_OPTIONS, _APOSTROPHE_MODE_OPTIONS
|
||||||
|
from abogen.web.routes.utils.common import split_profile_spec
|
||||||
|
from abogen.voice_profiles import (
|
||||||
|
load_profiles,
|
||||||
|
serialize_profiles,
|
||||||
|
)
|
||||||
|
from abogen.voice_formulas import get_new_voice, parse_formula_terms
|
||||||
|
from abogen.constants import (
|
||||||
|
LANGUAGE_DESCRIPTIONS,
|
||||||
|
SUBTITLE_FORMATS,
|
||||||
|
SUPPORTED_SOUND_FORMATS,
|
||||||
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
|
SAMPLE_VOICE_TEXTS,
|
||||||
|
VOICES_INTERNAL,
|
||||||
|
)
|
||||||
|
from abogen.speaker_configs import list_configs
|
||||||
|
from abogen.utils import load_numpy_kpipeline
|
||||||
|
from abogen.web.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
|
||||||
|
|
||||||
|
_preview_pipeline_lock = threading.RLock()
|
||||||
|
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||||
|
|
||||||
|
def build_narrator_roster(
|
||||||
|
voice: str,
|
||||||
|
voice_profile: Optional[str],
|
||||||
|
existing: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
roster: Dict[str, Any] = {
|
||||||
|
"narrator": {
|
||||||
|
"id": "narrator",
|
||||||
|
"label": "Narrator",
|
||||||
|
"voice": voice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if voice_profile:
|
||||||
|
roster["narrator"]["voice_profile"] = voice_profile
|
||||||
|
existing_entry: Optional[Mapping[str, Any]] = None
|
||||||
|
if existing is not None:
|
||||||
|
existing_entry = existing.get("narrator") if isinstance(existing, Mapping) else None
|
||||||
|
if isinstance(existing_entry, Mapping):
|
||||||
|
roster_entry = roster["narrator"]
|
||||||
|
for key in ("label", "voice", "voice_profile", "voice_formula", "pronunciation"):
|
||||||
|
value = existing_entry.get(key)
|
||||||
|
if value is not None and value != "":
|
||||||
|
roster_entry[key] = value
|
||||||
|
return roster
|
||||||
|
|
||||||
|
|
||||||
|
def build_speaker_roster(
|
||||||
|
analysis: Dict[str, Any],
|
||||||
|
base_voice: str,
|
||||||
|
voice_profile: Optional[str],
|
||||||
|
existing: Optional[Mapping[str, Any]] = None,
|
||||||
|
order: Optional[Iterable[str]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
roster = build_narrator_roster(base_voice, voice_profile, existing)
|
||||||
|
existing_map: Dict[str, Any] = dict(existing) if isinstance(existing, Mapping) else {}
|
||||||
|
speakers = analysis.get("speakers", {}) if isinstance(analysis, dict) else {}
|
||||||
|
ordered_ids: Iterable[str]
|
||||||
|
if order is not None:
|
||||||
|
ordered_ids = [sid for sid in order if sid in speakers]
|
||||||
|
else:
|
||||||
|
ordered_ids = speakers.keys()
|
||||||
|
|
||||||
|
for speaker_id in ordered_ids:
|
||||||
|
payload = speakers.get(speaker_id, {})
|
||||||
|
if speaker_id == "narrator":
|
||||||
|
continue
|
||||||
|
if isinstance(payload, Mapping) and payload.get("suppressed"):
|
||||||
|
continue
|
||||||
|
previous = existing_map.get(speaker_id)
|
||||||
|
roster[speaker_id] = {
|
||||||
|
"id": speaker_id,
|
||||||
|
"label": payload.get("label") or speaker_id.replace("_", " ").title(),
|
||||||
|
"analysis_confidence": payload.get("confidence"),
|
||||||
|
"analysis_count": payload.get("count"),
|
||||||
|
"gender": payload.get("gender", "unknown"),
|
||||||
|
}
|
||||||
|
detected_gender = payload.get("detected_gender")
|
||||||
|
if detected_gender:
|
||||||
|
roster[speaker_id]["detected_gender"] = detected_gender
|
||||||
|
samples = payload.get("sample_quotes")
|
||||||
|
if isinstance(samples, list):
|
||||||
|
roster[speaker_id]["sample_quotes"] = samples
|
||||||
|
if isinstance(previous, Mapping):
|
||||||
|
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"):
|
||||||
|
value = previous.get(key)
|
||||||
|
if value is not None and value != "":
|
||||||
|
roster[speaker_id][key] = value
|
||||||
|
if "sample_quotes" not in roster[speaker_id]:
|
||||||
|
prev_samples = previous.get("sample_quotes")
|
||||||
|
if isinstance(prev_samples, list):
|
||||||
|
roster[speaker_id]["sample_quotes"] = prev_samples
|
||||||
|
if "detected_gender" not in roster[speaker_id]:
|
||||||
|
prev_detected = previous.get("detected_gender")
|
||||||
|
if isinstance(prev_detected, str) and prev_detected:
|
||||||
|
roster[speaker_id]["detected_gender"] = prev_detected
|
||||||
|
return roster
|
||||||
|
|
||||||
|
|
||||||
|
def match_configured_speaker(
|
||||||
|
config_speakers: Mapping[str, Any],
|
||||||
|
roster_id: str,
|
||||||
|
roster_label: str,
|
||||||
|
) -> Optional[Mapping[str, Any]]:
|
||||||
|
if not config_speakers:
|
||||||
|
return None
|
||||||
|
entry = config_speakers.get(roster_id)
|
||||||
|
if entry:
|
||||||
|
return cast(Mapping[str, Any], entry)
|
||||||
|
slug = slugify_label(roster_label)
|
||||||
|
if slug != roster_id and slug in config_speakers:
|
||||||
|
return cast(Mapping[str, Any], config_speakers[slug])
|
||||||
|
lower_label = roster_label.strip().lower()
|
||||||
|
for record in config_speakers.values():
|
||||||
|
if not isinstance(record, Mapping):
|
||||||
|
continue
|
||||||
|
if str(record.get("label", "")).strip().lower() == lower_label:
|
||||||
|
return record
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_speaker_config_to_roster(
|
||||||
|
roster: Mapping[str, Any],
|
||||||
|
config: Optional[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
persist_changes: bool = False,
|
||||||
|
fallback_languages: Optional[Iterable[str]] = None,
|
||||||
|
) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
||||||
|
if not isinstance(roster, Mapping):
|
||||||
|
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
||||||
|
return {}, effective_languages, None
|
||||||
|
updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)}
|
||||||
|
if not config:
|
||||||
|
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
||||||
|
return updated_roster, effective_languages, None
|
||||||
|
|
||||||
|
speakers_map = config.get("speakers")
|
||||||
|
if not isinstance(speakers_map, Mapping):
|
||||||
|
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
||||||
|
return updated_roster, effective_languages, None
|
||||||
|
|
||||||
|
config_languages = config.get("languages")
|
||||||
|
if isinstance(config_languages, list):
|
||||||
|
allowed_languages = [code for code in config_languages if isinstance(code, str) and code]
|
||||||
|
else:
|
||||||
|
allowed_languages = []
|
||||||
|
if not allowed_languages and fallback_languages:
|
||||||
|
allowed_languages = [code for code in fallback_languages if isinstance(code, str) and code]
|
||||||
|
|
||||||
|
default_voice = config.get("default_voice") if isinstance(config.get("default_voice"), str) else ""
|
||||||
|
used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None}
|
||||||
|
narrator_voice = ""
|
||||||
|
narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None
|
||||||
|
if isinstance(narrator_entry, Mapping):
|
||||||
|
narrator_voice = str(
|
||||||
|
narrator_entry.get("resolved_voice")
|
||||||
|
or narrator_entry.get("default_voice")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if narrator_voice:
|
||||||
|
used_voices.add(narrator_voice)
|
||||||
|
|
||||||
|
config_changed = False
|
||||||
|
new_config_payload: Dict[str, Any] = {
|
||||||
|
"language": config.get("language", "a"),
|
||||||
|
"languages": allowed_languages,
|
||||||
|
"default_voice": default_voice,
|
||||||
|
"speakers": dict(speakers_map),
|
||||||
|
"version": config.get("version", 1),
|
||||||
|
"notes": config.get("notes", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
speakers_payload = new_config_payload["speakers"]
|
||||||
|
|
||||||
|
for speaker_id, roster_entry in updated_roster.items():
|
||||||
|
if speaker_id == "narrator":
|
||||||
|
continue
|
||||||
|
label = str(roster_entry.get("label") or speaker_id)
|
||||||
|
config_entry = match_configured_speaker(speakers_map, speaker_id, label)
|
||||||
|
if config_entry is None:
|
||||||
|
continue
|
||||||
|
voice_id = str(config_entry.get("voice") or "").strip()
|
||||||
|
voice_profile = str(config_entry.get("voice_profile") or "").strip()
|
||||||
|
voice_formula = str(config_entry.get("voice_formula") or "").strip()
|
||||||
|
resolved_voice = str(config_entry.get("resolved_voice") or "").strip()
|
||||||
|
languages = config_entry.get("languages") if isinstance(config_entry.get("languages"), list) else []
|
||||||
|
chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
|
||||||
|
usable_languages = languages or allowed_languages
|
||||||
|
|
||||||
|
if chosen_voice:
|
||||||
|
roster_entry["resolved_voice"] = chosen_voice
|
||||||
|
roster_entry["voice"] = chosen_voice if not voice_profile and not voice_formula else roster_entry.get("voice", chosen_voice)
|
||||||
|
if voice_profile:
|
||||||
|
roster_entry["voice_profile"] = voice_profile
|
||||||
|
if voice_formula:
|
||||||
|
roster_entry["voice_formula"] = voice_formula
|
||||||
|
roster_entry["resolved_voice"] = voice_formula
|
||||||
|
if not voice_formula and not voice_profile and resolved_voice:
|
||||||
|
roster_entry["resolved_voice"] = resolved_voice
|
||||||
|
roster_entry["config_languages"] = usable_languages or []
|
||||||
|
|
||||||
|
if chosen_voice:
|
||||||
|
used_voices.add(chosen_voice)
|
||||||
|
|
||||||
|
# persist updates back to config payload if required
|
||||||
|
if persist_changes:
|
||||||
|
slug = config_entry.get("id") or slugify_label(label)
|
||||||
|
speakers_payload[slug] = {
|
||||||
|
"id": slug,
|
||||||
|
"label": label,
|
||||||
|
"gender": config_entry.get("gender", "unknown"),
|
||||||
|
"voice": voice_id,
|
||||||
|
"voice_profile": voice_profile,
|
||||||
|
"voice_formula": voice_formula,
|
||||||
|
"resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id),
|
||||||
|
"languages": usable_languages,
|
||||||
|
}
|
||||||
|
|
||||||
|
new_config = new_config_payload if (persist_changes and config_changed) else None
|
||||||
|
return updated_roster, allowed_languages, new_config
|
||||||
|
|
||||||
|
|
||||||
|
def filter_voice_catalog(
|
||||||
|
catalog: Iterable[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
gender: str,
|
||||||
|
allowed_languages: Optional[Iterable[str]] = None,
|
||||||
|
) -> List[str]:
|
||||||
|
allowed_set = {code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code}
|
||||||
|
gender_normalized = (gender or "unknown").lower()
|
||||||
|
gender_code = ""
|
||||||
|
if gender_normalized == "male":
|
||||||
|
gender_code = "m"
|
||||||
|
elif gender_normalized == "female":
|
||||||
|
gender_code = "f"
|
||||||
|
|
||||||
|
matches: List[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
def _consider(entry: Mapping[str, Any]) -> None:
|
||||||
|
voice_id = entry.get("id")
|
||||||
|
if not isinstance(voice_id, str) or not voice_id:
|
||||||
|
return
|
||||||
|
if voice_id in seen:
|
||||||
|
return
|
||||||
|
seen.add(voice_id)
|
||||||
|
matches.append(voice_id)
|
||||||
|
|
||||||
|
primary: List[Mapping[str, Any]] = []
|
||||||
|
fallback: List[Mapping[str, Any]] = []
|
||||||
|
for entry in catalog:
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
voice_lang = str(entry.get("language", "")).lower()
|
||||||
|
voice_gender_code = str(entry.get("gender_code", "")).lower()
|
||||||
|
if allowed_set and voice_lang not in allowed_set:
|
||||||
|
continue
|
||||||
|
if gender_code and voice_gender_code != gender_code:
|
||||||
|
fallback.append(entry)
|
||||||
|
continue
|
||||||
|
primary.append(entry)
|
||||||
|
|
||||||
|
for entry in primary:
|
||||||
|
_consider(entry)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
for entry in fallback:
|
||||||
|
_consider(entry)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
for entry in catalog:
|
||||||
|
if isinstance(entry, Mapping):
|
||||||
|
_consider(entry)
|
||||||
|
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def build_voice_catalog() -> List[Dict[str, str]]:
|
||||||
|
catalog: List[Dict[str, str]] = []
|
||||||
|
gender_map = {"f": "Female", "m": "Male"}
|
||||||
|
for voice_id in VOICES_INTERNAL:
|
||||||
|
prefix, _, rest = voice_id.partition("_")
|
||||||
|
language_code = prefix[0] if prefix else "a"
|
||||||
|
gender_code = prefix[1] if len(prefix) > 1 else ""
|
||||||
|
catalog.append(
|
||||||
|
{
|
||||||
|
"id": voice_id,
|
||||||
|
"language": language_code,
|
||||||
|
"language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()),
|
||||||
|
"gender": gender_map.get(gender_code, "Unknown"),
|
||||||
|
"gender_code": gender_code,
|
||||||
|
"display_name": rest.replace("_", " ").title() if rest else voice_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
|
||||||
|
def inject_recommended_voices(
|
||||||
|
roster: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
fallback_languages: Optional[Iterable[str]] = None,
|
||||||
|
) -> None:
|
||||||
|
voice_catalog = build_voice_catalog()
|
||||||
|
fallback_list = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
||||||
|
for speaker_id, payload in roster.items():
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
languages = payload.get("config_languages")
|
||||||
|
if isinstance(languages, list) and languages:
|
||||||
|
language_list = languages
|
||||||
|
else:
|
||||||
|
language_list = fallback_list
|
||||||
|
gender = str(payload.get("gender", "unknown"))
|
||||||
|
payload["recommended_voices"] = filter_voice_catalog(
|
||||||
|
voice_catalog,
|
||||||
|
gender=gender,
|
||||||
|
allowed_languages=language_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str, Any], List[str]]:
|
||||||
|
getter = getattr(form, "getlist", None)
|
||||||
|
|
||||||
|
def _get_list(name: str) -> List[str]:
|
||||||
|
if callable(getter):
|
||||||
|
values = cast(Iterable[Any], getter(name))
|
||||||
|
return [str(value).strip() for value in values if value]
|
||||||
|
raw_value = form.get(name)
|
||||||
|
if isinstance(raw_value, str):
|
||||||
|
return [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
name = (form.get("config_name") or "").strip()
|
||||||
|
language = str(form.get("config_language") or "a").strip() or "a"
|
||||||
|
allowed_languages = []
|
||||||
|
default_voice = (form.get("config_default_voice") or "").strip()
|
||||||
|
notes = (form.get("config_notes") or "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = int(form.get("config_version") or 1)
|
||||||
|
version = max(1, min(parsed, 9999))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
speaker_rows = _get_list("speaker_rows")
|
||||||
|
speakers: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for row_key in speaker_rows:
|
||||||
|
prefix = f"speaker-{row_key}-"
|
||||||
|
label = (form.get(prefix + "label") or "").strip()
|
||||||
|
if not label:
|
||||||
|
continue
|
||||||
|
raw_gender = (form.get(prefix + "gender") or "unknown").strip().lower()
|
||||||
|
gender = raw_gender if raw_gender in {"male", "female", "unknown"} else "unknown"
|
||||||
|
voice = (form.get(prefix + "voice") or "").strip()
|
||||||
|
voice_profile = (form.get(prefix + "profile") or "").strip()
|
||||||
|
voice_formula = (form.get(prefix + "formula") or "").strip()
|
||||||
|
speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label)
|
||||||
|
speakers[speaker_id] = {
|
||||||
|
"id": speaker_id,
|
||||||
|
"label": label,
|
||||||
|
"gender": gender,
|
||||||
|
"voice": voice,
|
||||||
|
"voice_profile": voice_profile,
|
||||||
|
"voice_formula": voice_formula,
|
||||||
|
"resolved_voice": voice_formula or voice,
|
||||||
|
"languages": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"language": language,
|
||||||
|
"languages": allowed_languages,
|
||||||
|
"default_voice": default_voice,
|
||||||
|
"speakers": speakers,
|
||||||
|
"notes": notes,
|
||||||
|
"version": version,
|
||||||
|
}
|
||||||
|
|
||||||
|
errors: List[str] = []
|
||||||
|
if not name:
|
||||||
|
errors.append("Configuration name is required.")
|
||||||
|
if not speakers:
|
||||||
|
errors.append("Add at least one speaker to the configuration.")
|
||||||
|
|
||||||
|
return name, payload, errors
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_speaker_metadata(
|
||||||
|
*,
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
chunks: List[Dict[str, Any]],
|
||||||
|
analysis_chunks: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
voice: str,
|
||||||
|
voice_profile: Optional[str],
|
||||||
|
threshold: int,
|
||||||
|
existing_roster: Optional[Mapping[str, Any]] = None,
|
||||||
|
run_analysis: bool = True,
|
||||||
|
speaker_config: Optional[Mapping[str, Any]] = None,
|
||||||
|
apply_config: bool = False,
|
||||||
|
persist_config: bool = False,
|
||||||
|
) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
||||||
|
chunk_list = [dict(chunk) for chunk in chunks]
|
||||||
|
analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)]
|
||||||
|
threshold_value = max(1, int(threshold))
|
||||||
|
analysis_enabled = run_analysis
|
||||||
|
settings_state = load_settings()
|
||||||
|
global_random_languages = [
|
||||||
|
code
|
||||||
|
for code in settings_state.get("speaker_random_languages", [])
|
||||||
|
if isinstance(code, str) and code
|
||||||
|
]
|
||||||
|
|
||||||
|
if not analysis_enabled:
|
||||||
|
for chunk in chunk_list:
|
||||||
|
chunk["speaker_id"] = "narrator"
|
||||||
|
chunk["speaker_label"] = "Narrator"
|
||||||
|
analysis_payload = {
|
||||||
|
"version": "1.0",
|
||||||
|
"narrator": "narrator",
|
||||||
|
"assignments": {str(chunk.get("id")): "narrator" for chunk in chunk_list},
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {
|
||||||
|
"id": "narrator",
|
||||||
|
"label": "Narrator",
|
||||||
|
"count": len(chunk_list),
|
||||||
|
"confidence": "low",
|
||||||
|
"sample_quotes": [],
|
||||||
|
"suppressed": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"suppressed": [],
|
||||||
|
"stats": {
|
||||||
|
"total_chunks": len(chunk_list),
|
||||||
|
"explicit_chunks": 0,
|
||||||
|
"active_speakers": 0,
|
||||||
|
"unique_speakers": 1,
|
||||||
|
"suppressed": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
roster = build_narrator_roster(voice, voice_profile, existing_roster)
|
||||||
|
narrator_pron = roster["narrator"].get("pronunciation")
|
||||||
|
if narrator_pron:
|
||||||
|
analysis_payload["speakers"]["narrator"]["pronunciation"] = narrator_pron
|
||||||
|
return chunk_list, roster, analysis_payload, [], None
|
||||||
|
|
||||||
|
analysis_result = analyze_speakers(
|
||||||
|
chapters,
|
||||||
|
analysis_source,
|
||||||
|
threshold=threshold_value,
|
||||||
|
max_speakers=0,
|
||||||
|
)
|
||||||
|
analysis_payload = analysis_result.to_dict()
|
||||||
|
speakers_payload = analysis_payload.get("speakers", {})
|
||||||
|
ordered_ids = [
|
||||||
|
sid
|
||||||
|
for sid, meta in sorted(
|
||||||
|
(
|
||||||
|
(sid, meta)
|
||||||
|
for sid, meta in speakers_payload.items()
|
||||||
|
if sid != "narrator" and isinstance(meta, Mapping) and not meta.get("suppressed")
|
||||||
|
),
|
||||||
|
key=lambda item: item[1].get("count", 0),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
analysis_payload["ordered_speakers"] = ordered_ids
|
||||||
|
assignments = analysis_payload.get("assignments", {})
|
||||||
|
suppressed_ids = analysis_payload.get("suppressed", [])
|
||||||
|
suppressed_details: List[Dict[str, Any]] = []
|
||||||
|
speakers_payload = analysis_payload.get("speakers", {})
|
||||||
|
if isinstance(suppressed_ids, Iterable):
|
||||||
|
for suppressed_id in suppressed_ids:
|
||||||
|
speaker_meta = speakers_payload.get(suppressed_id) if isinstance(speakers_payload, dict) else None
|
||||||
|
if isinstance(speaker_meta, dict):
|
||||||
|
suppressed_details.append(
|
||||||
|
{
|
||||||
|
"id": suppressed_id,
|
||||||
|
"label": speaker_meta.get("label")
|
||||||
|
or str(suppressed_id).replace("_", " ").title(),
|
||||||
|
"pronunciation": speaker_meta.get("pronunciation"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
suppressed_details.append(
|
||||||
|
{
|
||||||
|
"id": suppressed_id,
|
||||||
|
"label": str(suppressed_id).replace("_", " ").title(),
|
||||||
|
"pronunciation": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
analysis_payload["suppressed_details"] = suppressed_details
|
||||||
|
roster = build_speaker_roster(
|
||||||
|
analysis_payload,
|
||||||
|
voice,
|
||||||
|
voice_profile,
|
||||||
|
existing=existing_roster,
|
||||||
|
order=analysis_payload.get("ordered_speakers"),
|
||||||
|
)
|
||||||
|
applied_languages: List[str] = []
|
||||||
|
updated_config: Optional[Dict[str, Any]] = None
|
||||||
|
if apply_config and speaker_config:
|
||||||
|
roster, applied_languages, updated_config = apply_speaker_config_to_roster(
|
||||||
|
roster,
|
||||||
|
speaker_config,
|
||||||
|
persist_changes=persist_config,
|
||||||
|
fallback_languages=global_random_languages,
|
||||||
|
)
|
||||||
|
speakers_payload = analysis_payload.get("speakers")
|
||||||
|
if isinstance(speakers_payload, dict):
|
||||||
|
for roster_id, roster_payload in roster.items():
|
||||||
|
speaker_meta = speakers_payload.get(roster_id)
|
||||||
|
if isinstance(speaker_meta, dict):
|
||||||
|
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice"):
|
||||||
|
value = roster_payload.get(key)
|
||||||
|
if value:
|
||||||
|
speaker_meta[key] = value
|
||||||
|
effective_languages: List[str] = []
|
||||||
|
if applied_languages:
|
||||||
|
effective_languages = applied_languages
|
||||||
|
elif isinstance(analysis_payload.get("config_languages"), list):
|
||||||
|
effective_languages = [
|
||||||
|
code for code in analysis_payload.get("config_languages", []) if isinstance(code, str) and code
|
||||||
|
]
|
||||||
|
elif global_random_languages:
|
||||||
|
effective_languages = list(global_random_languages)
|
||||||
|
|
||||||
|
if effective_languages:
|
||||||
|
analysis_payload["config_languages"] = effective_languages
|
||||||
|
speakers_payload = analysis_payload.get("speakers")
|
||||||
|
if isinstance(speakers_payload, dict):
|
||||||
|
for roster_id, roster_payload in roster.items():
|
||||||
|
if roster_id in speakers_payload and isinstance(roster_payload, dict):
|
||||||
|
pronunciation_value = roster_payload.get("pronunciation")
|
||||||
|
if pronunciation_value:
|
||||||
|
speakers_payload[roster_id]["pronunciation"] = pronunciation_value
|
||||||
|
|
||||||
|
fallback_languages = effective_languages or []
|
||||||
|
inject_recommended_voices(roster, fallback_languages=fallback_languages)
|
||||||
|
|
||||||
|
for chunk in chunk_list:
|
||||||
|
chunk_id = str(chunk.get("id"))
|
||||||
|
speaker_id = assignments.get(chunk_id, "narrator")
|
||||||
|
chunk["speaker_id"] = speaker_id
|
||||||
|
speaker_meta = roster.get(speaker_id)
|
||||||
|
chunk["speaker_label"] = speaker_meta.get("label") if isinstance(speaker_meta, dict) else speaker_id
|
||||||
|
|
||||||
|
return chunk_list, roster, analysis_payload, applied_languages, updated_config
|
||||||
|
|
||||||
|
|
||||||
|
def formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
|
||||||
|
voices = entry.get("voices") or []
|
||||||
|
if not voices:
|
||||||
|
return None
|
||||||
|
total = sum(weight for _, weight in voices)
|
||||||
|
if total <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _format_weight(value: float) -> str:
|
||||||
|
normalized = value / total if total else 0.0
|
||||||
|
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
|
||||||
|
|
||||||
|
parts = [f"{name}*{_format_weight(weight)}" for name, weight in voices if weight > 0]
|
||||||
|
return "+".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def template_options() -> Dict[str, Any]:
|
||||||
|
current_settings = load_settings()
|
||||||
|
profiles = serialize_profiles()
|
||||||
|
ordered_profiles = sorted(profiles.items())
|
||||||
|
profile_options = []
|
||||||
|
for name, entry in ordered_profiles:
|
||||||
|
profile_options.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"language": (entry or {}).get("language", ""),
|
||||||
|
"formula": formula_from_profile(entry or {}) or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
voice_catalog = build_voice_catalog()
|
||||||
|
return {
|
||||||
|
"languages": LANGUAGE_DESCRIPTIONS,
|
||||||
|
"voices": VOICES_INTERNAL,
|
||||||
|
"subtitle_formats": SUBTITLE_FORMATS,
|
||||||
|
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
|
"output_formats": SUPPORTED_SOUND_FORMATS,
|
||||||
|
"voice_profiles": ordered_profiles,
|
||||||
|
"voice_profile_options": profile_options,
|
||||||
|
"separate_formats": ["wav", "flac", "mp3", "opus"],
|
||||||
|
"voice_catalog": voice_catalog,
|
||||||
|
"voice_catalog_map": {entry["id"]: entry for entry in voice_catalog},
|
||||||
|
"sample_voice_texts": SAMPLE_VOICE_TEXTS,
|
||||||
|
"voice_profiles_data": profiles,
|
||||||
|
"speaker_configs": list_configs(),
|
||||||
|
"chunk_levels": _CHUNK_LEVEL_OPTIONS,
|
||||||
|
"speaker_analysis_threshold": current_settings.get(
|
||||||
|
"speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD
|
||||||
|
),
|
||||||
|
"speaker_pronunciation_sentence": current_settings.get(
|
||||||
|
"speaker_pronunciation_sentence", settings_defaults()["speaker_pronunciation_sentence"]
|
||||||
|
),
|
||||||
|
"apostrophe_modes": _APOSTROPHE_MODE_OPTIONS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_profile_voice(
|
||||||
|
profile_name: Optional[str],
|
||||||
|
*,
|
||||||
|
profiles: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> tuple[str, Optional[str]]:
|
||||||
|
if not profile_name:
|
||||||
|
return "", None
|
||||||
|
source = profiles if isinstance(profiles, Mapping) else None
|
||||||
|
if source is None:
|
||||||
|
source = load_profiles()
|
||||||
|
entry = source.get(profile_name) if isinstance(source, Mapping) else None
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
return "", None
|
||||||
|
formula = formula_from_profile(dict(entry)) or ""
|
||||||
|
language = entry.get("language") if isinstance(entry.get("language"), str) else None
|
||||||
|
if isinstance(language, str):
|
||||||
|
language = language.strip().lower() or None
|
||||||
|
return formula, language
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_voice_setting(
|
||||||
|
value: Any,
|
||||||
|
*,
|
||||||
|
profiles: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> tuple[str, Optional[str], Optional[str]]:
|
||||||
|
base_spec, profile_name = split_profile_spec(value)
|
||||||
|
if profile_name:
|
||||||
|
formula, language = resolve_profile_voice(profile_name, profiles=profiles)
|
||||||
|
return formula or "", profile_name, language
|
||||||
|
return base_spec, None, None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_voice_choice(
|
||||||
|
language: str,
|
||||||
|
base_voice: str,
|
||||||
|
profile_name: str,
|
||||||
|
custom_formula: str,
|
||||||
|
profiles: Dict[str, Any],
|
||||||
|
) -> tuple[str, str, Optional[str]]:
|
||||||
|
resolved_voice = base_voice
|
||||||
|
resolved_language = language
|
||||||
|
selected_profile = None
|
||||||
|
|
||||||
|
if profile_name:
|
||||||
|
entry = profiles.get(profile_name)
|
||||||
|
formula = formula_from_profile(entry or {}) if entry else None
|
||||||
|
if formula:
|
||||||
|
resolved_voice = formula
|
||||||
|
selected_profile = profile_name
|
||||||
|
profile_language = (entry or {}).get("language")
|
||||||
|
if profile_language:
|
||||||
|
resolved_language = profile_language
|
||||||
|
|
||||||
|
if custom_formula:
|
||||||
|
resolved_voice = custom_formula
|
||||||
|
selected_profile = None
|
||||||
|
|
||||||
|
return resolved_voice, resolved_language, selected_profile
|
||||||
|
|
||||||
|
|
||||||
|
def parse_voice_formula(formula: str) -> List[tuple[str, float]]:
|
||||||
|
voices = parse_formula_terms(formula)
|
||||||
|
total = sum(weight for _, weight in voices)
|
||||||
|
if total <= 0:
|
||||||
|
raise ValueError("Voice weights must sum to a positive value")
|
||||||
|
return voices
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_voice_entries(entries: Iterable[Any]) -> List[Dict[str, Any]]:
|
||||||
|
sanitized: List[Dict[str, Any]] = []
|
||||||
|
for entry in entries or []:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
voice_id = entry.get("id") or entry.get("voice")
|
||||||
|
if not voice_id:
|
||||||
|
continue
|
||||||
|
enabled = entry.get("enabled", True)
|
||||||
|
if not enabled:
|
||||||
|
continue
|
||||||
|
sanitized.append({"voice": voice_id, "weight": entry.get("weight")})
|
||||||
|
elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
|
||||||
|
sanitized.append({"voice": entry[0], "weight": entry[1]})
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]:
|
||||||
|
voices = [(voice, float(weight)) for voice, weight in pairs if float(weight) > 0]
|
||||||
|
if not voices:
|
||||||
|
return None
|
||||||
|
total = sum(weight for _, weight in voices)
|
||||||
|
if total <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _format_value(value: float) -> str:
|
||||||
|
normalized = value / total if total else 0.0
|
||||||
|
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
|
||||||
|
|
||||||
|
parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices]
|
||||||
|
return "+".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def profiles_payload() -> Dict[str, Any]:
|
||||||
|
return {"profiles": serialize_profiles()}
|
||||||
|
|
||||||
|
|
||||||
|
def get_preview_pipeline(language: str, device: str):
|
||||||
|
key = (language, device)
|
||||||
|
with _preview_pipeline_lock:
|
||||||
|
pipeline = _preview_pipelines.get(key)
|
||||||
|
if pipeline is not None:
|
||||||
|
return pipeline
|
||||||
|
_, KPipeline = load_numpy_kpipeline()
|
||||||
|
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||||
|
_preview_pipelines[key] = pipeline
|
||||||
|
return pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_audio_from_normalized(
|
||||||
|
*,
|
||||||
|
normalized_text: str,
|
||||||
|
voice_spec: str,
|
||||||
|
language: str,
|
||||||
|
speed: float,
|
||||||
|
use_gpu: bool,
|
||||||
|
max_seconds: float,
|
||||||
|
) -> np.ndarray:
|
||||||
|
if not normalized_text.strip():
|
||||||
|
raise ValueError("Preview text is required")
|
||||||
|
|
||||||
|
device = "cpu"
|
||||||
|
if use_gpu:
|
||||||
|
try:
|
||||||
|
device = _select_device()
|
||||||
|
except Exception:
|
||||||
|
device = "cpu"
|
||||||
|
use_gpu = False
|
||||||
|
|
||||||
|
pipeline = get_preview_pipeline(language, device)
|
||||||
|
if pipeline is None:
|
||||||
|
raise RuntimeError("Preview pipeline is unavailable")
|
||||||
|
|
||||||
|
voice_choice: Any = voice_spec
|
||||||
|
if voice_spec and "*" in voice_spec:
|
||||||
|
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
|
||||||
|
|
||||||
|
segments = pipeline(
|
||||||
|
normalized_text,
|
||||||
|
voice=voice_choice,
|
||||||
|
speed=speed,
|
||||||
|
split_pattern=SPLIT_PATTERN,
|
||||||
|
)
|
||||||
|
|
||||||
|
audio_chunks: List[np.ndarray] = []
|
||||||
|
accumulated = 0
|
||||||
|
max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
|
||||||
|
|
||||||
|
for segment in segments:
|
||||||
|
graphemes = getattr(segment, "graphemes", "").strip()
|
||||||
|
if not graphemes:
|
||||||
|
continue
|
||||||
|
audio = _to_float32(getattr(segment, "audio", None))
|
||||||
|
if audio.size == 0:
|
||||||
|
continue
|
||||||
|
remaining = max_samples - accumulated
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
if audio.shape[0] > remaining:
|
||||||
|
audio = audio[:remaining]
|
||||||
|
audio_chunks.append(audio)
|
||||||
|
accumulated += audio.shape[0]
|
||||||
|
if accumulated >= max_samples:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not audio_chunks:
|
||||||
|
raise RuntimeError("Preview could not be generated")
|
||||||
|
|
||||||
|
return np.concatenate(audio_chunks)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from flask import Blueprint, render_template, request, jsonify, abort, flash, redirect, url_for
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
|
from abogen.web.routes.utils.voice import (
|
||||||
|
template_options,
|
||||||
|
resolve_voice_setting,
|
||||||
|
resolve_voice_choice,
|
||||||
|
parse_voice_formula,
|
||||||
|
)
|
||||||
|
from abogen.web.routes.utils.settings import load_settings, coerce_bool
|
||||||
|
from abogen.web.routes.utils.preview import synthesize_preview
|
||||||
|
from abogen.speaker_configs import (
|
||||||
|
list_configs,
|
||||||
|
get_config,
|
||||||
|
load_configs,
|
||||||
|
save_configs,
|
||||||
|
delete_config,
|
||||||
|
)
|
||||||
|
from abogen.constants import VOICES_INTERNAL
|
||||||
|
|
||||||
|
voices_bp = Blueprint("voices", __name__)
|
||||||
|
|
||||||
|
@voices_bp.get("/")
|
||||||
|
def voices_list() -> ResponseReturnValue:
|
||||||
|
# This might not be a standalone page in the original app, but useful to have.
|
||||||
|
# Or maybe it redirects to settings or something.
|
||||||
|
# For now, I'll just redirect to settings as voices are managed there usually.
|
||||||
|
return redirect(url_for("settings.settings_page"))
|
||||||
|
|
||||||
|
@voices_bp.post("/test")
|
||||||
|
def test_voice() -> ResponseReturnValue:
|
||||||
|
text = (request.form.get("text") or "").strip()
|
||||||
|
voice = (request.form.get("voice") or "").strip()
|
||||||
|
speed = float(request.form.get("speed", 1.0))
|
||||||
|
|
||||||
|
# This seems to be the form-based preview
|
||||||
|
settings = load_settings()
|
||||||
|
use_gpu = coerce_bool(settings.get("use_gpu"), True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return synthesize_preview(
|
||||||
|
text=text,
|
||||||
|
voice_spec=voice,
|
||||||
|
language="a", # Default language
|
||||||
|
speed=speed,
|
||||||
|
use_gpu=use_gpu,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
abort(400, str(e))
|
||||||
|
|
||||||
|
@voices_bp.get("/configs")
|
||||||
|
def speaker_configs() -> ResponseReturnValue:
|
||||||
|
return jsonify({"configs": list_configs()})
|
||||||
|
|
||||||
|
@voices_bp.post("/configs/save")
|
||||||
|
def save_speaker_config() -> ResponseReturnValue:
|
||||||
|
payload = request.get_json(force=True)
|
||||||
|
name = (payload.get("name") or "").strip()
|
||||||
|
config = payload.get("config")
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
abort(400, "Config name is required")
|
||||||
|
if not config:
|
||||||
|
abort(400, "Config data is required")
|
||||||
|
|
||||||
|
configs = load_configs()
|
||||||
|
configs[name] = config
|
||||||
|
save_configs(configs)
|
||||||
|
return jsonify({"status": "saved", "configs": list_configs()})
|
||||||
|
|
||||||
|
@voices_bp.post("/configs/delete")
|
||||||
|
def delete_speaker_config() -> ResponseReturnValue:
|
||||||
|
payload = request.get_json(force=True)
|
||||||
|
name = (payload.get("name") or "").strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
abort(400, "Config name is required")
|
||||||
|
|
||||||
|
delete_config(name)
|
||||||
|
return jsonify({"status": "deleted", "configs": list_configs()})
|
||||||
@@ -335,7 +335,7 @@ def _extract_year(raw: Optional[str]) -> Optional[int]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _build_audiobookshelf_metadata(job: Job) -> Dict[str, Any]:
|
def build_audiobookshelf_metadata(job: Job) -> Dict[str, Any]:
|
||||||
tags = _normalize_metadata_casefold(job.metadata_tags)
|
tags = _normalize_metadata_casefold(job.metadata_tags)
|
||||||
filename = Path(job.original_filename or "").stem or job.original_filename or "Audiobook"
|
filename = Path(job.original_filename or "").stem or job.original_filename or "Audiobook"
|
||||||
title = _first_nonempty(
|
title = _first_nonempty(
|
||||||
@@ -474,7 +474,7 @@ def _normalize_series_sequence(raw: Any) -> Optional[str]:
|
|||||||
return cleaned or "0"
|
return cleaned or "0"
|
||||||
|
|
||||||
|
|
||||||
def _load_audiobookshelf_chapters(job: Job) -> Optional[List[Dict[str, Any]]]:
|
def load_audiobookshelf_chapters(job: Job) -> Optional[List[Dict[str, Any]]]:
|
||||||
metadata_ref = job.result.artifacts.get("metadata")
|
metadata_ref = job.result.artifacts.get("metadata")
|
||||||
if not metadata_ref:
|
if not metadata_ref:
|
||||||
return None
|
return None
|
||||||
@@ -1085,8 +1085,8 @@ class ConversionService:
|
|||||||
cover_path = cover_candidate
|
cover_path = cover_candidate
|
||||||
|
|
||||||
subtitles = _existing_paths(job.result.subtitle_paths) if config.send_subtitles else None
|
subtitles = _existing_paths(job.result.subtitle_paths) if config.send_subtitles else None
|
||||||
chapters = _load_audiobookshelf_chapters(job) if config.send_chapters else None
|
chapters = load_audiobookshelf_chapters(job) if config.send_chapters else None
|
||||||
metadata = _build_audiobookshelf_metadata(job)
|
metadata = build_audiobookshelf_metadata(job)
|
||||||
|
|
||||||
client = AudiobookshelfClient(config)
|
client = AudiobookshelfClient(config)
|
||||||
|
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ def test_prepare_project_layout_uses_timestamped_folder(monkeypatch: pytest.Monk
|
|||||||
|
|
||||||
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, tmp_path)
|
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, tmp_path)
|
||||||
|
|
||||||
assert project_root.name.startswith("20250101-120000_sample_title"), project_root.name
|
assert project_root.name.startswith("20250101-120000_Sample_Title"), project_root.name
|
||||||
assert audio_dir == project_root
|
assert audio_dir == project_root
|
||||||
assert subtitle_dir == project_root
|
assert subtitle_dir == project_root
|
||||||
assert metadata_dir is None
|
assert metadata_dir is None
|
||||||
|
|
||||||
output_path = _build_output_path(audio_dir, job.original_filename, "mp3")
|
output_path = _build_output_path(audio_dir, job.original_filename, "mp3")
|
||||||
assert output_path == project_root / "Sample Title.mp3"
|
assert output_path == project_root / "Sample_Title.mp3"
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_project_layout_creates_project_subdirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
def test_prepare_project_layout_creates_project_subdirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
@@ -66,4 +66,4 @@ def test_prepare_project_layout_creates_project_subdirs(monkeypatch: pytest.Monk
|
|||||||
assert metadata_dir is not None and metadata_dir.is_dir()
|
assert metadata_dir is not None and metadata_dir.is_dir()
|
||||||
|
|
||||||
output_path = _build_output_path(audio_dir, job.original_filename, "wav")
|
output_path = _build_output_path(audio_dir, job.original_filename, "wav")
|
||||||
assert output_path == audio_dir / "Sample Title.wav"
|
assert output_path == audio_dir / "Sample_Title.wav"
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from werkzeug.datastructures import MultiDict
|
from werkzeug.datastructures import MultiDict
|
||||||
|
|
||||||
from abogen.web.routes import _apply_prepare_form, _resolve_voice_setting
|
from abogen.web.routes.utils.form import apply_prepare_form
|
||||||
|
from abogen.web.routes.utils.voice import resolve_voice_setting
|
||||||
from abogen.web.service import PendingJob
|
from abogen.web.service import PendingJob
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ def test_apply_prepare_form_handles_custom_mix_for_speakers():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
_, _, _, errors, *_ = _apply_prepare_form(pending, form)
|
_, _, _, errors, *_ = apply_prepare_form(pending, form)
|
||||||
|
|
||||||
assert not errors
|
assert not errors
|
||||||
hero = pending.speakers["hero"]
|
hero = pending.speakers["hero"]
|
||||||
@@ -75,7 +76,7 @@ def test_resolve_voice_setting_handles_profile_reference():
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
voice, profile_name, language = _resolve_voice_setting("profile:Blend", profiles=profiles)
|
voice, profile_name, language = resolve_voice_setting("profile:Blend", profiles=profiles)
|
||||||
|
|
||||||
assert voice == "af_nova*0.5+am_liam*0.5"
|
assert voice == "af_nova*0.5+am_liam*0.5"
|
||||||
assert profile_name == "Blend"
|
assert profile_name == "Blend"
|
||||||
@@ -89,6 +90,6 @@ def test_apply_prepare_form_updates_closing_outro_flag():
|
|||||||
"read_closing_outro": "false",
|
"read_closing_outro": "false",
|
||||||
})
|
})
|
||||||
|
|
||||||
_apply_prepare_form(pending, form)
|
apply_prepare_form(pending, form)
|
||||||
|
|
||||||
assert pending.read_closing_outro is False
|
assert pending.read_closing_outro is False
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
from abogen.web.service import Job, JobStatus, build_service, _JOB_LOGGER, _build_audiobookshelf_metadata
|
from abogen.web.service import Job, JobStatus, build_service, _JOB_LOGGER, build_audiobookshelf_metadata
|
||||||
|
|
||||||
|
|
||||||
def test_service_processes_job(tmp_path):
|
def test_service_processes_job(tmp_path):
|
||||||
@@ -223,7 +223,7 @@ def test_audiobookshelf_metadata_uses_book_number(tmp_path):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
metadata = _build_audiobookshelf_metadata(job)
|
metadata = build_audiobookshelf_metadata(job)
|
||||||
|
|
||||||
assert metadata["seriesName"] == "Example Saga"
|
assert metadata["seriesName"] == "Example Saga"
|
||||||
assert metadata["seriesSequence"] == "7"
|
assert metadata["seriesSequence"] == "7"
|
||||||
@@ -254,7 +254,7 @@ def test_audiobookshelf_metadata_normalizes_sequence_value(tmp_path):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
metadata = _build_audiobookshelf_metadata(job)
|
metadata = build_audiobookshelf_metadata(job)
|
||||||
|
|
||||||
assert metadata["seriesName"] == "Example Saga"
|
assert metadata["seriesName"] == "Example Saga"
|
||||||
assert metadata["seriesSequence"] == "7"
|
assert metadata["seriesSequence"] == "7"
|
||||||
@@ -285,7 +285,7 @@ def test_audiobookshelf_metadata_allows_decimal_sequence(tmp_path):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
metadata = _build_audiobookshelf_metadata(job)
|
metadata = build_audiobookshelf_metadata(job)
|
||||||
|
|
||||||
assert metadata["seriesSequence"] == "4.5"
|
assert metadata["seriesSequence"] == "4.5"
|
||||||
|
|
||||||
@@ -316,7 +316,7 @@ def test_audiobookshelf_metadata_ignores_author_series_collision(tmp_path):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
metadata = _build_audiobookshelf_metadata(job)
|
metadata = build_audiobookshelf_metadata(job)
|
||||||
|
|
||||||
assert "seriesName" not in metadata
|
assert "seriesName" not in metadata
|
||||||
assert "seriesSequence" not in metadata
|
assert "seriesSequence" not in metadata
|
||||||
Reference in New Issue
Block a user