mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
5418 lines
198 KiB
Python
5418 lines
198 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import io
|
|
import json
|
|
import math
|
|
import mimetypes
|
|
import os
|
|
import posixpath
|
|
import re
|
|
import threading
|
|
import time
|
|
import uuid
|
|
import zipfile
|
|
from datetime import datetime
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple, cast
|
|
from xml.etree import ElementTree as ET
|
|
|
|
from flask import (
|
|
Blueprint,
|
|
Response,
|
|
abort,
|
|
current_app,
|
|
jsonify,
|
|
redirect,
|
|
render_template,
|
|
request,
|
|
send_file,
|
|
url_for,
|
|
)
|
|
from flask.typing import ResponseReturnValue
|
|
|
|
import numpy as np
|
|
import soundfile as sf
|
|
|
|
from werkzeug.datastructures import MultiDict
|
|
from werkzeug.utils import secure_filename
|
|
|
|
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
|
from abogen.constants import (
|
|
LANGUAGE_DESCRIPTIONS,
|
|
SAMPLE_VOICE_TEXTS,
|
|
SUBTITLE_FORMATS,
|
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
|
SUPPORTED_SOUND_FORMATS,
|
|
VOICES_INTERNAL,
|
|
)
|
|
from abogen.kokoro_text_normalization import normalize_for_pipeline, normalize_roman_numeral_titles
|
|
from abogen.normalization_settings import (
|
|
DEFAULT_LLM_PROMPT,
|
|
NORMALIZATION_SAMPLE_TEXTS,
|
|
apply_overrides as apply_normalization_overrides,
|
|
build_apostrophe_config,
|
|
build_llm_configuration,
|
|
clear_cached_settings,
|
|
environment_llm_defaults,
|
|
get_runtime_settings,
|
|
)
|
|
from abogen.llm_client import LLMClientError, LLMConfiguration, generate_completion, list_models
|
|
from abogen.utils import (
|
|
calculate_text_length,
|
|
get_user_output_path,
|
|
load_config,
|
|
load_numpy_kpipeline,
|
|
save_config,
|
|
)
|
|
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,
|
|
all_overrides as all_pronunciation_overrides,
|
|
save_override as save_pronunciation_override,
|
|
search_overrides as search_pronunciation_overrides,
|
|
)
|
|
from abogen.voice_profiles import (
|
|
delete_profile,
|
|
duplicate_profile,
|
|
export_profiles_payload,
|
|
import_profiles_data,
|
|
load_profiles,
|
|
normalize_voice_entries,
|
|
remove_profile,
|
|
save_profile,
|
|
save_profiles,
|
|
serialize_profiles,
|
|
)
|
|
|
|
from abogen.voice_formulas import get_new_voice, parse_formula_terms
|
|
from abogen.speaker_analysis import analyze_speakers
|
|
from abogen.speaker_configs import (
|
|
delete_config,
|
|
get_config,
|
|
list_configs,
|
|
load_configs,
|
|
save_configs,
|
|
upsert_config,
|
|
slugify_label,
|
|
)
|
|
from abogen.text_extractor import extract_from_path
|
|
from abogen.integrations.calibre_opds import CalibreOPDSClient, CalibreOPDSError, feed_to_dict
|
|
from abogen.integrations.audiobookshelf import (
|
|
AudiobookshelfClient,
|
|
AudiobookshelfConfig,
|
|
AudiobookshelfUploadError,
|
|
)
|
|
from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
|
|
from .service import (
|
|
ConversionService,
|
|
Job,
|
|
JobStatus,
|
|
PendingJob,
|
|
_build_audiobookshelf_metadata,
|
|
_existing_paths,
|
|
_load_audiobookshelf_chapters,
|
|
)
|
|
|
|
web_bp = Blueprint("web", __name__)
|
|
api_bp = Blueprint("api", __name__)
|
|
|
|
|
|
_preview_pipeline_lock = threading.RLock()
|
|
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
|
|
|
|
|
_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
|
|
|
|
|
|
_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.",
|
|
},
|
|
}
|
|
|
|
|
|
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,
|
|
}
|
|
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 _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()
|
|
version = _coerce_int(form.get("config_version"), 1, minimum=1, maximum=9999)
|
|
|
|
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 _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,
|
|
}
|
|
|
|
|
|
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, bool] = dict(overrides_existing or {})
|
|
for key in _APOSTROPHE_OVERRIDE_KEYS:
|
|
default_toggle = overrides.get(key, bool(settings.get(key, True)))
|
|
overrides[key] = _extract_checkbox(key, default_toggle)
|
|
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
|
|
|
|
_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 _service() -> ConversionService:
|
|
return current_app.extensions["conversion_service"]
|
|
|
|
|
|
def _require_pending_job(pending_id: str) -> PendingJob:
|
|
pending = _service().get_pending_job(pending_id)
|
|
if not pending:
|
|
abort(404)
|
|
return cast(PendingJob, pending)
|
|
|
|
|
|
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 _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"]
|
|
),
|
|
}
|
|
|
|
|
|
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 _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
|
|
|
|
|
|
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()}
|
|
|
|
_APOSTROPHE_MODE_OPTIONS = [
|
|
{"value": "off", "label": "Off"},
|
|
{"value": "spacy", "label": "spaCy (built-in)"},
|
|
{"value": "llm", "label": "LLM assisted"},
|
|
]
|
|
|
|
_LLM_CONTEXT_OPTIONS = [
|
|
{"value": "sentence", "label": "Sentence only"},
|
|
]
|
|
|
|
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_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"}
|
|
|
|
_APOSTROPHE_OVERRIDE_KEYS = (
|
|
"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",
|
|
)
|
|
|
|
|
|
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_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", 30.0))
|
|
except (TypeError, ValueError):
|
|
timeout = 30.0
|
|
return AudiobookshelfConfig(
|
|
base_url=base_url,
|
|
api_token=api_token,
|
|
library_id=library_id,
|
|
collection_id=(str(settings.get("collection_id") or "").strip() or None),
|
|
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 _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 _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 _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 _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)
|
|
|
|
|
|
@web_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)
|
|
|
|
|
|
@web_bp.get("/")
|
|
def index() -> str:
|
|
integrations = _load_integration_settings()
|
|
return render_template(
|
|
"index.html",
|
|
options=_template_options(),
|
|
settings=_load_settings(),
|
|
integrations=integrations,
|
|
opds_available=_calibre_integration_enabled(integrations),
|
|
)
|
|
|
|
|
|
@web_bp.get("/queue")
|
|
def queue_page() -> ResponseReturnValue:
|
|
return render_template(
|
|
"queue.html",
|
|
jobs_panel=_render_jobs_panel(),
|
|
)
|
|
|
|
|
|
@web_bp.get("/find-books")
|
|
def find_books_page() -> ResponseReturnValue:
|
|
integrations = _load_integration_settings()
|
|
return render_template(
|
|
"find_books.html",
|
|
integrations=integrations,
|
|
opds_available=_calibre_integration_enabled(integrations),
|
|
options=_template_options(),
|
|
settings=_load_settings(),
|
|
)
|
|
|
|
|
|
@api_bp.get("/integrations/calibre-opds/feed")
|
|
def calibre_opds_feed() -> ResponseReturnValue:
|
|
stored_settings = _stored_integration_config("calibre_opds")
|
|
payload = {
|
|
"base_url": stored_settings.get("base_url"),
|
|
"username": stored_settings.get("username"),
|
|
"password": stored_settings.get("password"),
|
|
"verify_ssl": stored_settings.get("verify_ssl", True),
|
|
}
|
|
if not payload.get("base_url"):
|
|
return jsonify({"error": "Calibre OPDS base URL is not configured."}), 400
|
|
try:
|
|
client = _build_calibre_client(payload)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
href = request.args.get("href", type=str)
|
|
query = request.args.get("q", type=str)
|
|
try:
|
|
if query:
|
|
feed = client.search(query)
|
|
else:
|
|
feed = client.fetch_feed(href)
|
|
except CalibreOPDSError as exc:
|
|
return jsonify({"error": str(exc)}), 502
|
|
|
|
return jsonify({
|
|
"feed": feed_to_dict(feed),
|
|
"href": href or "",
|
|
"query": query or "",
|
|
})
|
|
|
|
|
|
@api_bp.post("/integrations/calibre-opds/import")
|
|
def calibre_opds_import() -> ResponseReturnValue:
|
|
if not request.is_json:
|
|
return jsonify({"error": "Expected JSON payload."}), 400
|
|
data = request.get_json(silent=True) or {}
|
|
href = str(data.get("href") or "").strip()
|
|
title = str(data.get("title") or "").strip()
|
|
if not href:
|
|
return jsonify({"error": "Download link missing."}), 400
|
|
|
|
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)
|
|
published_value = metadata_payload.get("published") or metadata_payload.get("publication_date")
|
|
if published_value:
|
|
published_text = _stringify_metadata_value(published_value)
|
|
if published_text:
|
|
metadata_overrides.setdefault("published", published_text)
|
|
metadata_overrides.setdefault("publication_date", published_text)
|
|
publication_year = metadata_payload.get("publication_year") or metadata_payload.get("year")
|
|
if publication_year:
|
|
year_text = _stringify_metadata_value(publication_year)
|
|
if year_text:
|
|
metadata_overrides.setdefault("publication_year", year_text)
|
|
metadata_overrides.setdefault("year", year_text)
|
|
rating_value = metadata_payload.get("rating")
|
|
if rating_value is not None:
|
|
rating_text = _stringify_metadata_value(rating_value)
|
|
if rating_text:
|
|
metadata_overrides.setdefault("rating", rating_text)
|
|
rating_max = metadata_payload.get("rating_max")
|
|
if rating_max is not None:
|
|
rating_max_text = _stringify_metadata_value(rating_max)
|
|
if rating_max_text:
|
|
metadata_overrides.setdefault("rating_max", rating_max_text)
|
|
for key, value in metadata_payload.items():
|
|
if value is None:
|
|
continue
|
|
text_value = _stringify_metadata_value(value)
|
|
if not text_value:
|
|
continue
|
|
metadata_overrides.setdefault(str(key), text_value)
|
|
|
|
stored_settings = _stored_integration_config("calibre_opds")
|
|
if not stored_settings or not _coerce_bool(stored_settings.get("enabled"), False):
|
|
return jsonify({"error": "Calibre OPDS integration is not enabled."}), 400
|
|
|
|
payload = {
|
|
"base_url": stored_settings.get("base_url"),
|
|
"username": stored_settings.get("username"),
|
|
"password": stored_settings.get("password"),
|
|
"verify_ssl": stored_settings.get("verify_ssl", True),
|
|
}
|
|
try:
|
|
client = _build_calibre_client(payload)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
try:
|
|
resource = client.download(href)
|
|
except (CalibreOPDSError, ValueError) as exc:
|
|
return jsonify({"error": str(exc)}), 502
|
|
|
|
filename = resource.filename or (f"{title}.epub" if title else "download.epub")
|
|
sanitized = secure_filename(filename) or "download.epub"
|
|
uploads_dir = Path(current_app.config["UPLOAD_FOLDER"])
|
|
uploads_dir.mkdir(parents=True, exist_ok=True)
|
|
stored_path = uploads_dir / f"{uuid.uuid4().hex}_{sanitized}"
|
|
|
|
try:
|
|
stored_path.write_bytes(resource.content)
|
|
except OSError as exc:
|
|
return jsonify({"error": f"Unable to store downloaded book: {exc}"}), 500
|
|
|
|
try:
|
|
extraction = extract_from_path(stored_path)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
stored_path.unlink(missing_ok=True)
|
|
return jsonify({"error": f"Unable to read the downloaded book: {exc}"}), 400
|
|
|
|
settings = _load_settings()
|
|
profiles = load_profiles()
|
|
build_result = _build_pending_job_from_extraction(
|
|
stored_path=stored_path,
|
|
original_name=sanitized,
|
|
extraction=extraction,
|
|
form=MultiDict(),
|
|
settings=settings,
|
|
profiles=profiles,
|
|
metadata_overrides=metadata_overrides or None,
|
|
)
|
|
|
|
pending = build_result.pending
|
|
_refresh_entity_summary(pending, pending.chapters)
|
|
service = _service()
|
|
service.store_pending_job(pending)
|
|
|
|
if build_result.selected_speaker_config:
|
|
pending.applied_speaker_config = build_result.selected_speaker_config
|
|
if build_result.config_languages:
|
|
pending.speaker_voice_languages = list(build_result.config_languages)
|
|
elif isinstance(build_result.speaker_config_payload, Mapping):
|
|
languages = build_result.speaker_config_payload.get("languages")
|
|
if isinstance(languages, list):
|
|
pending.speaker_voice_languages = [code for code in languages if isinstance(code, str)]
|
|
|
|
service.store_pending_job(pending)
|
|
|
|
redirect_url = url_for("web.prepare_job", pending_id=pending.id, step="book")
|
|
return jsonify({
|
|
"pending_id": pending.id,
|
|
"redirect_url": redirect_url,
|
|
})
|
|
|
|
|
|
@api_bp.post("/integrations/calibre-opds/test")
|
|
def test_calibre_opds() -> ResponseReturnValue:
|
|
if not request.is_json:
|
|
return jsonify({"error": "Expected JSON payload."}), 400
|
|
payload = request.get_json(silent=True) or {}
|
|
settings = _calibre_settings_from_payload(payload)
|
|
if not settings.get("base_url"):
|
|
return jsonify({"error": "Enter a Calibre OPDS base URL before testing."}), 400
|
|
try:
|
|
client = _build_calibre_client(settings)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
try:
|
|
feed = client.fetch_feed()
|
|
except CalibreOPDSError as exc:
|
|
return jsonify({"error": str(exc)}), 502
|
|
entries = len(feed.entries)
|
|
catalog_title = feed.title or "catalog"
|
|
return jsonify({
|
|
"message": f"Connected to {catalog_title}. Found {entries} item{'s' if entries != 1 else ''}.",
|
|
"entries": entries,
|
|
"title": catalog_title,
|
|
})
|
|
|
|
|
|
@api_bp.post("/integrations/audiobookshelf/folders")
|
|
def list_audiobookshelf_folders() -> ResponseReturnValue:
|
|
if not request.is_json:
|
|
return jsonify({"error": "Expected JSON payload."}), 400
|
|
payload = request.get_json(silent=True) or {}
|
|
settings = _audiobookshelf_settings_from_payload(payload)
|
|
config = _build_audiobookshelf_config(settings)
|
|
if config is None:
|
|
return jsonify({"error": "Provide base URL, API token, and library ID before listing folders."}), 400
|
|
|
|
try:
|
|
client = AudiobookshelfClient(config)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
try:
|
|
folders = client.list_folders()
|
|
except AudiobookshelfUploadError as exc:
|
|
cause = exc.__cause__
|
|
status_code = getattr(getattr(cause, "response", None), "status_code", None)
|
|
http_status = 502 if status_code and status_code >= 500 else 400
|
|
return jsonify({"error": str(exc)}), http_status
|
|
|
|
if not folders:
|
|
return jsonify({
|
|
"message": "No folders found for this library.",
|
|
"folders": [],
|
|
})
|
|
|
|
total = len(folders)
|
|
label = "folder" if total == 1 else "folders"
|
|
return jsonify({
|
|
"message": f"Found {total} {label} in this library.",
|
|
"folders": folders,
|
|
})
|
|
|
|
|
|
@api_bp.post("/integrations/audiobookshelf/test")
|
|
def test_audiobookshelf() -> ResponseReturnValue:
|
|
if not request.is_json:
|
|
return jsonify({"error": "Expected JSON payload."}), 400
|
|
payload = request.get_json(silent=True) or {}
|
|
settings = _audiobookshelf_settings_from_payload(payload)
|
|
config = _build_audiobookshelf_config(settings)
|
|
if config is None:
|
|
return jsonify({"error": "Provide base URL, API token, and library ID before testing."}), 400
|
|
|
|
try:
|
|
client = AudiobookshelfClient(config)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
try:
|
|
resolved_folder_id, folder_name, library_name = client.resolve_folder()
|
|
except AudiobookshelfUploadError as exc:
|
|
cause = exc.__cause__
|
|
status_code = getattr(getattr(cause, "response", None), "status_code", None)
|
|
http_status = 502 if status_code and status_code >= 500 else 400
|
|
return jsonify({"error": str(exc)}), http_status
|
|
|
|
library_id = settings.get("library_id", "")
|
|
folder_id = resolved_folder_id
|
|
|
|
collection_id = str(settings.get("collection_id") or "").strip()
|
|
if collection_id:
|
|
try:
|
|
with client._open_client() as http_client: # pylint: disable=protected-access
|
|
collection_resp = http_client.get(client._api_path(f"collections/{collection_id}"))
|
|
collection_resp.raise_for_status()
|
|
except Exception as exc: # pragma: no cover - network guard
|
|
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
|
if status_code:
|
|
message = f"Collection lookup failed with status {status_code}."
|
|
else:
|
|
message = f"Collection lookup failed: {exc}"
|
|
return jsonify({"error": message}), 502
|
|
|
|
return jsonify({
|
|
"message": f"Connected to Audiobookshelf library '{library_name}' (folder '{folder_name}').",
|
|
"library_id": library_id,
|
|
"collection_id": collection_id or None,
|
|
"folder_id": folder_id,
|
|
"folder_name": folder_name,
|
|
})
|
|
|
|
|
|
@web_bp.route("/settings", methods=["GET", "POST"])
|
|
def settings_page() -> ResponseReturnValue:
|
|
options = _template_options()
|
|
current_settings = _load_settings()
|
|
|
|
if request.method == "POST":
|
|
form = request.form
|
|
defaults = _settings_defaults()
|
|
updated: Dict[str, Any] = {}
|
|
|
|
updated["output_format"] = _normalize_setting_value(
|
|
"output_format", form.get("output_format"), defaults
|
|
)
|
|
updated["subtitle_format"] = _normalize_setting_value(
|
|
"subtitle_format", form.get("subtitle_format"), defaults
|
|
)
|
|
updated["save_mode"] = _normalize_setting_value(
|
|
"save_mode", form.get("save_mode"), defaults
|
|
)
|
|
updated["default_voice"] = _normalize_setting_value(
|
|
"default_voice", form.get("default_voice"), defaults
|
|
)
|
|
for key in sorted(BOOLEAN_SETTINGS):
|
|
updated[key] = _coerce_bool(form.get(key), False)
|
|
updated["chunk_level"] = _normalize_setting_value(
|
|
"chunk_level", form.get("chunk_level"), defaults
|
|
)
|
|
updated["separate_chapters_format"] = _normalize_setting_value(
|
|
"separate_chapters_format", form.get("separate_chapters_format"), defaults
|
|
)
|
|
updated["silence_between_chapters"] = _coerce_float(
|
|
form.get("silence_between_chapters"), defaults["silence_between_chapters"]
|
|
)
|
|
updated["chapter_intro_delay"] = _coerce_float(
|
|
form.get("chapter_intro_delay"), defaults["chapter_intro_delay"]
|
|
)
|
|
updated["max_subtitle_words"] = _coerce_int(
|
|
form.get("max_subtitle_words"), defaults["max_subtitle_words"]
|
|
)
|
|
updated["speaker_analysis_threshold"] = _coerce_int(
|
|
form.get("speaker_analysis_threshold"),
|
|
defaults["speaker_analysis_threshold"],
|
|
minimum=1,
|
|
maximum=25,
|
|
)
|
|
sentence_value = (form.get("speaker_pronunciation_sentence") or "").strip()
|
|
if not sentence_value:
|
|
sentence_value = defaults["speaker_pronunciation_sentence"]
|
|
updated["speaker_pronunciation_sentence"] = sentence_value
|
|
|
|
random_languages = [
|
|
code.lower()
|
|
for code in form.getlist("speaker_random_languages")
|
|
if isinstance(code, str) and code.lower() in LANGUAGE_DESCRIPTIONS
|
|
]
|
|
updated["speaker_random_languages"] = random_languages
|
|
|
|
updated["llm_base_url"] = _normalize_setting_value(
|
|
"llm_base_url", form.get("llm_base_url"), defaults
|
|
)
|
|
updated["llm_api_key"] = _normalize_setting_value(
|
|
"llm_api_key", form.get("llm_api_key"), defaults
|
|
)
|
|
updated["llm_model"] = _normalize_setting_value("llm_model", form.get("llm_model"), defaults)
|
|
updated["llm_prompt"] = _normalize_setting_value("llm_prompt", form.get("llm_prompt"), defaults)
|
|
updated["llm_context_mode"] = _normalize_setting_value(
|
|
"llm_context_mode", form.get("llm_context_mode"), defaults
|
|
)
|
|
updated["llm_timeout"] = _normalize_setting_value("llm_timeout", form.get("llm_timeout"), defaults)
|
|
updated["normalization_apostrophe_mode"] = _normalize_setting_value(
|
|
"normalization_apostrophe_mode",
|
|
form.get("normalization_apostrophe_mode"),
|
|
defaults,
|
|
)
|
|
|
|
cfg = load_config() or {}
|
|
cfg.update(updated)
|
|
_apply_integration_form(cfg, form)
|
|
save_config(cfg)
|
|
clear_cached_settings()
|
|
return redirect(url_for("web.settings_page", saved="1"))
|
|
|
|
save_locations = [
|
|
{"value": key, "label": label} for key, label in SAVE_MODE_LABELS.items()
|
|
]
|
|
context = {
|
|
"options": options,
|
|
"settings": current_settings,
|
|
"save_locations": save_locations,
|
|
"default_output_dir": get_user_output_path(),
|
|
"saved": request.args.get("saved") == "1",
|
|
"apostrophe_modes": _APOSTROPHE_MODE_OPTIONS,
|
|
"llm_context_options": _LLM_CONTEXT_OPTIONS,
|
|
"llm_ready": _llm_ready(current_settings),
|
|
"normalization_samples": NORMALIZATION_SAMPLE_TEXTS,
|
|
"integrations": _load_integration_settings(),
|
|
}
|
|
return render_template("settings.html", **context)
|
|
|
|
|
|
@api_bp.post("/llm/models")
|
|
def api_llm_models() -> ResponseReturnValue:
|
|
payload = request.get_json(force=True, silent=False) or {}
|
|
current_settings = get_runtime_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_normalization_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 = get_runtime_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_normalization_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)
|
|
|
|
|
|
@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 = get_runtime_settings()
|
|
normalization_payload = payload.get("normalization") or {}
|
|
overrides: Dict[str, Any] = {}
|
|
|
|
boolean_keys = (
|
|
"normalization_numbers",
|
|
"normalization_titles",
|
|
"normalization_terminal",
|
|
"normalization_phoneme_hints",
|
|
"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",
|
|
)
|
|
for key in boolean_keys:
|
|
if key in normalization_payload:
|
|
overrides[key] = _coerce_bool(normalization_payload.get(key), base_settings.get(key, True))
|
|
if "normalization_apostrophe_mode" in normalization_payload:
|
|
overrides["normalization_apostrophe_mode"] = normalization_payload.get("normalization_apostrophe_mode")
|
|
|
|
llm_payload = payload.get("llm") or {}
|
|
for field in ("llm_base_url", "llm_api_key", "llm_model", "llm_prompt", "llm_context_mode"):
|
|
if field in llm_payload:
|
|
overrides[field] = llm_payload[field]
|
|
if "llm_timeout" in llm_payload:
|
|
overrides["llm_timeout"] = llm_payload.get("llm_timeout")
|
|
|
|
merged = apply_normalization_overrides(base_settings, overrides)
|
|
|
|
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
|
|
|
|
raw_voice_spec = str(payload.get("voice") or base_settings.get("default_voice") or "").strip()
|
|
profiles_map = load_profiles()
|
|
resolved_voice_spec, _, profile_language = _resolve_voice_setting(
|
|
raw_voice_spec,
|
|
profiles=profiles_map,
|
|
)
|
|
voice_spec = resolved_voice_spec or raw_voice_spec
|
|
if not voice_spec and VOICES_INTERNAL:
|
|
voice_spec = VOICES_INTERNAL[0]
|
|
language = str(payload.get("language") or base_settings.get("language") or "a").strip() or "a"
|
|
if (not str(payload.get("language") or "").strip()) and profile_language:
|
|
language = profile_language
|
|
try:
|
|
speed = float(payload.get("speed", 1.0) or 1.0)
|
|
except (TypeError, ValueError):
|
|
speed = 1.0
|
|
try:
|
|
max_seconds = max(1.0, min(15.0, float(payload.get("max_seconds", 8.0) or 8.0)))
|
|
except (TypeError, ValueError):
|
|
max_seconds = 8.0
|
|
|
|
use_gpu_default = base_settings.get("use_gpu", True)
|
|
use_gpu = _coerce_bool(payload.get("use_gpu"), use_gpu_default)
|
|
|
|
try:
|
|
audio_data = _synthesize_audio_from_normalized(
|
|
normalized_text=normalized_text,
|
|
voice_spec=voice_spec,
|
|
language=language,
|
|
speed=speed,
|
|
use_gpu=use_gpu,
|
|
max_seconds=max_seconds,
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
except RuntimeError as exc:
|
|
return jsonify({"error": str(exc)}), 500
|
|
|
|
buffer = io.BytesIO()
|
|
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
|
|
audio_base64 = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
|
|
return jsonify(
|
|
{
|
|
"normalized_text": normalized_text,
|
|
"audio_base64": audio_base64,
|
|
"sample_rate": SAMPLE_RATE,
|
|
}
|
|
)
|
|
|
|
|
|
@web_bp.get("/voices")
|
|
def voice_profiles_page() -> str:
|
|
options = _template_options()
|
|
return render_template("voices.html", options=options)
|
|
|
|
|
|
@web_bp.get("/entities")
|
|
def entities_page() -> ResponseReturnValue:
|
|
options = _template_options()
|
|
settings = _load_settings()
|
|
languages_map = options.get("languages", {})
|
|
|
|
raw_language = (request.args.get("lang") or settings.get("language") or "a").strip().lower()
|
|
language = raw_language if raw_language in languages_map else "a"
|
|
|
|
status_code = (request.args.get("status") or "").strip().lower()
|
|
status_token = (request.args.get("token") or "").strip()
|
|
status_error = (request.args.get("error") or "").strip()
|
|
|
|
query = (request.args.get("q") or "").strip()
|
|
voice_filter = (request.args.get("voice") or "all").strip().lower()
|
|
pronunciation_filter = (request.args.get("pronunciation") or "all").strip().lower()
|
|
limit_value = _coerce_int(request.args.get("limit"), 200, minimum=10, maximum=500)
|
|
|
|
if query:
|
|
overrides = search_pronunciation_overrides(language, query, limit=limit_value)
|
|
else:
|
|
overrides = all_pronunciation_overrides(language)
|
|
if limit_value and len(overrides) > limit_value:
|
|
overrides = overrides[:limit_value]
|
|
|
|
display_rows: List[Dict[str, Any]] = []
|
|
for entry in overrides:
|
|
has_voice = bool((entry.get("voice") or "").strip())
|
|
has_pronunciation = bool((entry.get("pronunciation") or "").strip())
|
|
if voice_filter == "with-voice" and not has_voice:
|
|
continue
|
|
if voice_filter == "without-voice" and has_voice:
|
|
continue
|
|
if pronunciation_filter == "with-pronunciation" and not has_pronunciation:
|
|
continue
|
|
if pronunciation_filter == "without-pronunciation" and has_pronunciation:
|
|
continue
|
|
row = dict(entry)
|
|
row["has_voice"] = has_voice
|
|
row["has_pronunciation"] = has_pronunciation
|
|
try:
|
|
updated_dt = datetime.fromtimestamp(float(entry.get("updated_at") or 0))
|
|
created_dt = datetime.fromtimestamp(float(entry.get("created_at") or 0))
|
|
except (TypeError, ValueError):
|
|
updated_dt = datetime.fromtimestamp(0)
|
|
created_dt = datetime.fromtimestamp(0)
|
|
row["updated_at_label"] = updated_dt.strftime("%Y-%m-%d %H:%M")
|
|
row["created_at_label"] = created_dt.strftime("%Y-%m-%d %H:%M")
|
|
display_rows.append(row)
|
|
|
|
stats = {
|
|
"total": len(overrides),
|
|
"filtered": len(display_rows),
|
|
"with_voice": sum(1 for row in display_rows if row["has_voice"]),
|
|
"with_pronunciation": sum(1 for row in display_rows if row["has_pronunciation"]),
|
|
}
|
|
|
|
language_options = sorted(languages_map.items(), key=lambda item: item[1])
|
|
voice_filters = [
|
|
{"value": "all", "label": "All voices"},
|
|
{"value": "with-voice", "label": "Assigned voice"},
|
|
{"value": "without-voice", "label": "No voice"},
|
|
]
|
|
pronunciation_filters = [
|
|
{"value": "all", "label": "All pronunciations"},
|
|
{"value": "with-pronunciation", "label": "Has pronunciation"},
|
|
{"value": "without-pronunciation", "label": "No pronunciation"},
|
|
]
|
|
|
|
status_message = ""
|
|
if status_code in {"saved", "updated"}:
|
|
status_message = f"Updated override for {status_token or 'override'}."
|
|
elif status_code == "created":
|
|
status_message = f"Added override for {status_token or 'override'}."
|
|
elif status_code == "deleted":
|
|
status_message = f"Deleted override for {status_token or 'override'}."
|
|
|
|
context = {
|
|
"options": options,
|
|
"language": language,
|
|
"language_label": languages_map.get(language, language.upper()),
|
|
"languages": language_options,
|
|
"query": query,
|
|
"voice_filter": voice_filter,
|
|
"pronunciation_filter": pronunciation_filter,
|
|
"voice_filter_options": voice_filters,
|
|
"pronunciation_filter_options": pronunciation_filters,
|
|
"limit": limit_value,
|
|
"overrides": display_rows,
|
|
"stats": stats,
|
|
"status_message": status_message,
|
|
"status_error": status_error,
|
|
}
|
|
return render_template("entities.html", **context)
|
|
|
|
|
|
@web_bp.post("/entities/override")
|
|
def entities_override_update() -> ResponseReturnValue:
|
|
options = _template_options()
|
|
languages_map = options.get("languages", {})
|
|
|
|
raw_language = (request.form.get("lang") or "").strip().lower()
|
|
language = raw_language if raw_language in languages_map else "a"
|
|
|
|
token_value = (request.form.get("token") or "").strip()
|
|
action = (request.form.get("action") or "save").strip().lower()
|
|
pronunciation_value = (request.form.get("pronunciation") or "").strip()
|
|
voice_value = (request.form.get("voice") or "").strip()
|
|
notes_present = "notes" in request.form
|
|
notes_value = (request.form.get("notes") or "").strip() if notes_present else ""
|
|
|
|
redirect_params: Dict[str, Any] = {"lang": language}
|
|
state_mappings = (
|
|
("state_voice", "voice"),
|
|
("state_pronunciation", "pronunciation"),
|
|
("state_limit", "limit"),
|
|
("state_query", "q"),
|
|
)
|
|
for form_key, query_key in state_mappings:
|
|
value = (request.form.get(form_key) or "").strip()
|
|
if value:
|
|
redirect_params[query_key] = value
|
|
|
|
if not token_value:
|
|
redirect_params["status"] = "error"
|
|
redirect_params["error"] = "Missing override token."
|
|
return redirect(url_for("web.entities_page", **redirect_params))
|
|
|
|
normalized_token = normalize_entity_token(token_value)
|
|
if not normalized_token:
|
|
redirect_params["status"] = "error"
|
|
redirect_params["error"] = "Token is too generic to override."
|
|
return redirect(url_for("web.entities_page", **redirect_params))
|
|
|
|
existing_map = load_pronunciation_overrides(language=language, tokens=[token_value])
|
|
existing_override = existing_map.get(normalized_token)
|
|
|
|
if notes_present:
|
|
notes_payload: Optional[str] = notes_value or None
|
|
elif existing_override:
|
|
notes_payload = existing_override.get("notes")
|
|
else:
|
|
notes_payload = None
|
|
|
|
status_code = "updated"
|
|
saved_override: Optional[Dict[str, Any]] = None
|
|
try:
|
|
if action == "delete":
|
|
delete_pronunciation_override(language=language, token=token_value)
|
|
status_code = "deleted"
|
|
else:
|
|
saved_override = save_pronunciation_override(
|
|
language=language,
|
|
token=token_value,
|
|
pronunciation=pronunciation_value or None,
|
|
voice=voice_value or None,
|
|
notes=notes_payload,
|
|
context=None,
|
|
)
|
|
status_code = "updated" if existing_override else "created"
|
|
except ValueError as exc:
|
|
redirect_params["status"] = "error"
|
|
redirect_params["error"] = str(exc)
|
|
return redirect(url_for("web.entities_page", **redirect_params))
|
|
except Exception as exc: # pragma: no cover - defensive logging
|
|
current_app.logger.exception("Failed to %s override for token %s", action, token_value)
|
|
redirect_params["status"] = "error"
|
|
redirect_params["error"] = "Failed to update override."
|
|
return redirect(url_for("web.entities_page", **redirect_params))
|
|
|
|
redirect_params["status"] = status_code
|
|
redirect_params["token"] = (saved_override or {}).get("token") or token_value
|
|
return redirect(url_for("web.entities_page", **redirect_params))
|
|
|
|
|
|
@api_bp.post("/entities/preview")
|
|
def api_entity_pronunciation_preview() -> ResponseReturnValue:
|
|
payload = request.get_json(force=True, silent=False) or {}
|
|
token = str(payload.get("token") or "").strip()
|
|
pronunciation = str(payload.get("pronunciation") or "").strip()
|
|
if not token and not pronunciation:
|
|
return jsonify({"error": "Provide a token or pronunciation to preview."}), 400
|
|
|
|
settings = _load_settings()
|
|
sample_template = settings.get("speaker_pronunciation_sentence", "This is {{name}} speaking.")
|
|
spoken_label = pronunciation or token or ""
|
|
preview_text = _render_prompt_template(sample_template, {"name": spoken_label, "token": token})
|
|
if not preview_text.strip():
|
|
preview_text = spoken_label or token
|
|
if not preview_text:
|
|
return jsonify({"error": "Unable to construct preview text."}), 400
|
|
|
|
runtime_settings = get_runtime_settings()
|
|
apostrophe_config = build_apostrophe_config(settings=runtime_settings)
|
|
try:
|
|
normalized_text = normalize_for_pipeline(preview_text, config=apostrophe_config, settings=runtime_settings)
|
|
except LLMClientError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
raw_voice_spec = str(payload.get("voice") or settings.get("default_voice") or "").strip()
|
|
profiles_map = load_profiles()
|
|
resolved_voice_spec, _, profile_language = _resolve_voice_setting(
|
|
raw_voice_spec,
|
|
profiles=profiles_map,
|
|
)
|
|
voice_spec = resolved_voice_spec or raw_voice_spec
|
|
if not voice_spec and VOICES_INTERNAL:
|
|
voice_spec = VOICES_INTERNAL[0]
|
|
|
|
language = str(payload.get("language") or runtime_settings.get("language") or "a").strip() or "a"
|
|
if (not str(payload.get("language") or "").strip()) and profile_language:
|
|
language = profile_language
|
|
use_gpu = runtime_settings.get("use_gpu", True)
|
|
max_seconds = 6.0
|
|
try:
|
|
preview_speed = float(payload.get("speed", 1.0) or 1.0)
|
|
except (TypeError, ValueError):
|
|
preview_speed = 1.0
|
|
try:
|
|
audio_data = _synthesize_audio_from_normalized(
|
|
normalized_text=normalized_text,
|
|
voice_spec=voice_spec,
|
|
language=language,
|
|
speed=preview_speed,
|
|
use_gpu=use_gpu,
|
|
max_seconds=max_seconds,
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
except RuntimeError as exc:
|
|
return jsonify({"error": str(exc)}), 500
|
|
|
|
buffer = io.BytesIO()
|
|
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
|
|
audio_base64 = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
|
|
return jsonify(
|
|
{
|
|
"text": preview_text,
|
|
"normalized_text": normalized_text,
|
|
"audio_base64": audio_base64,
|
|
"sample_rate": SAMPLE_RATE,
|
|
}
|
|
)
|
|
|
|
|
|
@web_bp.route("/speakers", methods=["GET", "POST"])
|
|
def speaker_configs_page() -> ResponseReturnValue:
|
|
options = _template_options()
|
|
configs = list_configs()
|
|
message = None
|
|
error = None
|
|
|
|
if request.method == "POST":
|
|
name, config_payload, errors = _extract_speaker_config_form(request.form)
|
|
editing_payload = config_payload
|
|
editing_name = name
|
|
if errors:
|
|
error = " ".join(errors)
|
|
context = {
|
|
"options": options,
|
|
"configs": configs,
|
|
"editing_name": editing_name,
|
|
"editing": editing_payload,
|
|
"message": message,
|
|
"error": error,
|
|
}
|
|
return render_template("speakers.html", **context)
|
|
upsert_config(name, config_payload)
|
|
return redirect(url_for("web.speaker_configs_page", config=name, saved="1"))
|
|
|
|
editing_name = request.args.get("config") or ""
|
|
editing_payload = get_config(editing_name) if editing_name else None
|
|
if editing_payload is None and configs:
|
|
editing_name = configs[0]["name"]
|
|
editing_payload = get_config(editing_name)
|
|
if editing_payload is None:
|
|
editing_payload = {
|
|
"language": "a",
|
|
"languages": [],
|
|
"default_voice": "",
|
|
"speakers": {},
|
|
"notes": "",
|
|
"version": 1,
|
|
}
|
|
|
|
if request.args.get("saved") == "1":
|
|
message = "Speaker configuration saved."
|
|
|
|
context = {
|
|
"options": options,
|
|
"configs": configs,
|
|
"editing_name": editing_name,
|
|
"editing": editing_payload,
|
|
"message": message,
|
|
"error": error,
|
|
}
|
|
return render_template("speakers.html", **context)
|
|
|
|
|
|
@web_bp.post("/speakers/<name>/delete")
|
|
def delete_speaker_config_route(name: str) -> ResponseReturnValue:
|
|
delete_config(name)
|
|
return redirect(url_for("web.speaker_configs_page"))
|
|
|
|
|
|
@web_bp.post("/voices")
|
|
def save_voice_profile_route() -> ResponseReturnValue:
|
|
name = request.form.get("name", "").strip()
|
|
language = request.form.get("language", "a").strip() or "a"
|
|
formula = request.form.get("formula", "").strip()
|
|
if not name or not formula:
|
|
abort(400, "Name and formula are required")
|
|
voices = _parse_voice_formula(formula)
|
|
profiles = load_profiles()
|
|
profiles[name] = {"voices": voices, "language": language}
|
|
save_profiles(profiles)
|
|
return redirect(url_for("web.voice_profiles_page"))
|
|
|
|
|
|
@web_bp.post("/voices/<name>/delete")
|
|
def delete_voice_profile_route(name: str) -> ResponseReturnValue:
|
|
delete_profile(name)
|
|
return redirect(url_for("web.voice_profiles_page"))
|
|
|
|
|
|
@api_bp.get("/voice-profiles")
|
|
def api_list_voice_profiles() -> ResponseReturnValue:
|
|
return jsonify(_profiles_payload())
|
|
|
|
|
|
@api_bp.post("/voice-profiles")
|
|
def api_save_voice_profile() -> ResponseReturnValue:
|
|
payload = request.get_json(force=True, silent=False)
|
|
name = (payload.get("name") or "").strip()
|
|
if not name:
|
|
abort(400, "Profile name is required")
|
|
|
|
original = (payload.get("originalName") or "").strip()
|
|
language = (payload.get("language") or "a").strip() or "a"
|
|
formula = (payload.get("formula") or "").strip()
|
|
|
|
try:
|
|
if formula:
|
|
voices = _parse_voice_formula(formula)
|
|
else:
|
|
voices_raw = _sanitize_voice_entries(payload.get("voices", []))
|
|
voices = normalize_voice_entries(voices_raw)
|
|
if not voices:
|
|
raise ValueError("At least one voice must be enabled with a weight above zero")
|
|
save_profile(name, language=language, voices=voices)
|
|
if original and original != name:
|
|
remove_profile(original)
|
|
except ValueError as exc:
|
|
abort(400, str(exc))
|
|
|
|
return jsonify({"ok": True, "profile": name, **_profiles_payload()})
|
|
|
|
|
|
@api_bp.delete("/voice-profiles/<name>")
|
|
def api_delete_voice_profile(name: str) -> ResponseReturnValue:
|
|
remove_profile(name)
|
|
return jsonify({"ok": True, **_profiles_payload()})
|
|
|
|
|
|
@api_bp.post("/voice-profiles/<name>/duplicate")
|
|
def api_duplicate_voice_profile(name: str) -> ResponseReturnValue:
|
|
payload = request.get_json(silent=True) or {}
|
|
new_name = (payload.get("name") or payload.get("new_name") or "").strip()
|
|
if not new_name:
|
|
abort(400, "Duplicate name is required")
|
|
duplicate_profile(name, new_name)
|
|
return jsonify({"ok": True, "profile": new_name, **_profiles_payload()})
|
|
|
|
|
|
@api_bp.post("/voice-profiles/import")
|
|
def api_import_voice_profiles() -> ResponseReturnValue:
|
|
replace = False
|
|
data: Optional[Dict[str, Any]] = None
|
|
if "file" in request.files:
|
|
file_storage = request.files["file"]
|
|
try:
|
|
file_storage.stream.seek(0)
|
|
raw_bytes = file_storage.read()
|
|
text_payload = raw_bytes.decode("utf-8")
|
|
data = json.loads(text_payload)
|
|
except UnicodeDecodeError as exc:
|
|
abort(400, f"JSON file must be UTF-8 encoded: {exc}")
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
abort(400, f"Invalid JSON file: {exc}")
|
|
replace = request.form.get("replace_existing") in {"true", "1", "on"}
|
|
else:
|
|
payload = request.get_json(force=True, silent=False)
|
|
replace = bool(payload.get("replace_existing", False))
|
|
data = payload.get("profiles") or payload.get("data") or payload
|
|
if not isinstance(data, dict):
|
|
data = None
|
|
if data is None:
|
|
abort(400, "Import payload must be a dictionary")
|
|
data_dict = cast(Dict[str, Any], data)
|
|
imported: List[str] = []
|
|
try:
|
|
imported = import_profiles_data(data_dict, replace_existing=replace)
|
|
except ValueError as exc:
|
|
abort(400, str(exc))
|
|
return jsonify({"ok": True, "imported": imported, **_profiles_payload()})
|
|
|
|
|
|
@api_bp.get("/voice-profiles/export")
|
|
def api_export_voice_profiles() -> ResponseReturnValue:
|
|
names_param = request.args.get("names")
|
|
names = None
|
|
if names_param:
|
|
names = [name.strip() for name in names_param.split(",") if name.strip()]
|
|
payload = export_profiles_payload(names)
|
|
buffer = io.BytesIO()
|
|
buffer.write(json.dumps(payload, indent=2).encode("utf-8"))
|
|
buffer.seek(0)
|
|
filename = request.args.get("filename") or "voice_profiles.json"
|
|
return send_file(
|
|
buffer,
|
|
mimetype="application/json",
|
|
as_attachment=True,
|
|
download_name=filename,
|
|
)
|
|
|
|
|
|
@api_bp.post("/voice-profiles/preview")
|
|
def api_preview_voice_mix() -> ResponseReturnValue:
|
|
payload = request.get_json(force=True, silent=False)
|
|
language = (payload.get("language") or "a").strip() or "a"
|
|
text = (payload.get("text") or "").strip()
|
|
speed = float(payload.get("speed", 1.0) or 1.0)
|
|
try:
|
|
requested_preview = float(payload.get("max_seconds", 60.0) or 60.0)
|
|
except (TypeError, ValueError):
|
|
requested_preview = 60.0
|
|
max_seconds = max(1.0, min(60.0, requested_preview))
|
|
profile_name = (payload.get("profile") or payload.get("profile_name") or "").strip()
|
|
formula = (payload.get("formula") or "").strip()
|
|
|
|
voices: List[Tuple[str, float]] = []
|
|
if profile_name:
|
|
profiles = load_profiles()
|
|
entry = profiles.get(profile_name)
|
|
if entry is None:
|
|
abort(404, "Profile not found")
|
|
if not isinstance(entry, dict):
|
|
abort(400, "Profile data is invalid")
|
|
entry_dict = cast(Dict[str, Any], entry)
|
|
language = entry_dict.get("language", language)
|
|
profile_voices = entry_dict.get("voices", [])
|
|
for item in profile_voices:
|
|
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
try:
|
|
voices.append((str(item[0]), float(item[1])))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
else:
|
|
try:
|
|
if formula:
|
|
voices = _parse_voice_formula(formula)
|
|
else:
|
|
voices_raw = _sanitize_voice_entries(payload.get("voices", []))
|
|
voices = normalize_voice_entries(voices_raw)
|
|
except ValueError as exc:
|
|
abort(400, str(exc))
|
|
|
|
if not voices:
|
|
abort(400, "At least one voice must be provided for preview")
|
|
|
|
if not text:
|
|
text = SAMPLE_VOICE_TEXTS.get(language, SAMPLE_VOICE_TEXTS.get("a", "This is a sample of the selected voice."))
|
|
|
|
settings = _load_settings()
|
|
use_gpu_default = settings.get("use_gpu", True)
|
|
if "use_gpu" in payload:
|
|
use_gpu = _coerce_bool(payload.get("use_gpu"), use_gpu_default)
|
|
else:
|
|
use_gpu = use_gpu_default
|
|
device = "cpu"
|
|
if use_gpu:
|
|
try:
|
|
device = _select_device()
|
|
except Exception: # pragma: no cover - fallback
|
|
device = "cpu"
|
|
use_gpu = False
|
|
|
|
pipeline: Any = None
|
|
try:
|
|
pipeline = _get_preview_pipeline(language, device)
|
|
except Exception as exc: # pragma: no cover - defensive guard
|
|
abort(500, f"Failed to initialise preview pipeline: {exc}")
|
|
if pipeline is None: # pragma: no cover - defensive double-check
|
|
abort(500, "Preview pipeline initialisation failed")
|
|
|
|
voice_choice: Any = None
|
|
if len(voices) == 1:
|
|
voice_choice = voices[0][0]
|
|
else:
|
|
formula_value = _pairs_to_formula(voices)
|
|
if not formula_value:
|
|
abort(400, "Invalid voice weights provided")
|
|
try:
|
|
voice_choice = get_new_voice(pipeline, formula_value, use_gpu)
|
|
except ValueError as exc:
|
|
abort(400, str(exc))
|
|
if voice_choice is None:
|
|
abort(400, "Unable to resolve voice selection")
|
|
|
|
try:
|
|
text = normalize_for_pipeline(text)
|
|
except Exception:
|
|
current_app.logger.exception("Voice preview normalization failed; using raw text")
|
|
|
|
segments = pipeline(
|
|
text,
|
|
voice=voice_choice,
|
|
speed=speed,
|
|
split_pattern=SPLIT_PATTERN,
|
|
)
|
|
|
|
audio_chunks: List[np.ndarray] = []
|
|
accumulated = 0
|
|
max_samples = int(max_seconds * SAMPLE_RATE)
|
|
|
|
for segment in segments:
|
|
graphemes = segment.graphemes.strip()
|
|
if not graphemes:
|
|
continue
|
|
audio = _to_float32(segment.audio)
|
|
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:
|
|
abort(500, "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="voice_preview.wav",
|
|
)
|
|
response.headers["Cache-Control"] = "no-store"
|
|
return response
|
|
|
|
|
|
@api_bp.post("/speaker-preview")
|
|
def api_speaker_preview() -> ResponseReturnValue:
|
|
payload = request.get_json(force=True, silent=False)
|
|
text = (payload.get("text") or "").strip()
|
|
raw_voice_spec = (payload.get("voice") or "").strip()
|
|
profiles_map = load_profiles()
|
|
resolved_voice_spec, _, profile_language = _resolve_voice_setting(
|
|
raw_voice_spec,
|
|
profiles=profiles_map,
|
|
)
|
|
voice_spec = resolved_voice_spec or raw_voice_spec
|
|
language_override = payload.get("language")
|
|
language = (language_override or "a").strip() or "a"
|
|
if (not isinstance(language_override, str) or not language_override.strip()) and profile_language:
|
|
language = profile_language
|
|
speed_input = payload.get("speed", 1.0)
|
|
try:
|
|
speed = float(speed_input)
|
|
except (TypeError, ValueError):
|
|
speed = 1.0
|
|
max_seconds_input = payload.get("max_seconds", 8.0)
|
|
try:
|
|
max_seconds = max(1.0, min(15.0, float(max_seconds_input)))
|
|
except (TypeError, ValueError):
|
|
max_seconds = 8.0
|
|
|
|
if not text:
|
|
abort(400, "Preview text is required")
|
|
if not voice_spec:
|
|
abort(400, "Voice selection is required")
|
|
|
|
settings = _load_settings()
|
|
use_gpu_default = settings.get("use_gpu", True)
|
|
if "use_gpu" in payload:
|
|
use_gpu = _coerce_bool(payload.get("use_gpu"), use_gpu_default)
|
|
else:
|
|
use_gpu = use_gpu_default
|
|
|
|
device = "cpu"
|
|
if use_gpu:
|
|
try:
|
|
device = _select_device()
|
|
except Exception: # pragma: no cover - fallback
|
|
device = "cpu"
|
|
use_gpu = False
|
|
|
|
try:
|
|
pipeline = _get_preview_pipeline(language, device)
|
|
except Exception as exc: # pragma: no cover - defensive guard
|
|
abort(500, f"Failed to initialise preview pipeline: {exc}")
|
|
if pipeline is None: # pragma: no cover - defensive double-check
|
|
abort(500, "Preview pipeline initialisation failed")
|
|
|
|
voice_choice: Any = voice_spec
|
|
if "*" in voice_spec:
|
|
try:
|
|
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
|
|
except ValueError as exc:
|
|
abort(400, str(exc))
|
|
|
|
try:
|
|
text = normalize_for_pipeline(text)
|
|
except Exception:
|
|
current_app.logger.exception("Preview normalization failed; using raw text")
|
|
|
|
segments = pipeline(
|
|
text,
|
|
voice=voice_choice,
|
|
speed=speed,
|
|
split_pattern=SPLIT_PATTERN,
|
|
)
|
|
|
|
audio_chunks: List[np.ndarray] = []
|
|
accumulated = 0
|
|
max_samples = int(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:
|
|
abort(500, "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
|
|
|
|
|
|
@api_bp.get("/pending/<pending_id>/entities")
|
|
def api_pending_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)
|
|
_service().store_pending_job(pending)
|
|
return jsonify(_pending_entities_payload(pending))
|
|
|
|
|
|
@api_bp.post("/pending/<pending_id>/entities/refresh")
|
|
def api_refresh_pending_entities(pending_id: str) -> ResponseReturnValue:
|
|
pending = _require_pending_job(pending_id)
|
|
_refresh_entity_summary(pending, pending.chapters)
|
|
_service().store_pending_job(pending)
|
|
return jsonify(_pending_entities_payload(pending))
|
|
|
|
|
|
@api_bp.get("/pending/<pending_id>/manual-overrides")
|
|
def api_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",
|
|
}
|
|
)
|
|
|
|
|
|
@api_bp.post("/pending/<pending_id>/manual-overrides")
|
|
def api_upsert_manual_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))
|
|
_service().store_pending_job(pending)
|
|
return jsonify({"override": override, **_pending_entities_payload(pending)})
|
|
|
|
|
|
@api_bp.delete("/pending/<pending_id>/manual-overrides/<override_id>")
|
|
def api_delete_manual_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)
|
|
_service().store_pending_job(pending)
|
|
return jsonify({"deleted": True, **_pending_entities_payload(pending)})
|
|
|
|
|
|
@api_bp.get("/pending/<pending_id>/manual-overrides/search")
|
|
def api_search_manual_override_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})
|
|
|
|
|
|
@dataclass
|
|
class PendingBuildResult:
|
|
pending: PendingJob
|
|
selected_speaker_config: Optional[str]
|
|
config_languages: List[str]
|
|
speaker_config_payload: Optional[Mapping[str, Any]]
|
|
|
|
|
|
def _build_pending_job_from_extraction(
|
|
*,
|
|
stored_path: Path,
|
|
original_name: str,
|
|
extraction: Any,
|
|
form: Mapping[str, Any],
|
|
settings: Mapping[str, Any],
|
|
profiles: Mapping[str, Any],
|
|
metadata_overrides: Optional[Mapping[str, Any]] = None,
|
|
) -> PendingBuildResult:
|
|
profiles_map = dict(profiles)
|
|
cover_path, cover_mime = _persist_cover_image(extraction, stored_path)
|
|
|
|
if getattr(extraction, "chapters", None):
|
|
original_titles = [chapter.title for chapter in extraction.chapters]
|
|
normalized_titles = normalize_roman_numeral_titles(original_titles)
|
|
if normalized_titles != original_titles:
|
|
for chapter, new_title in zip(extraction.chapters, normalized_titles):
|
|
chapter.title = new_title
|
|
|
|
metadata_tags = dict(getattr(extraction, "metadata", {}) or {})
|
|
if metadata_overrides:
|
|
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 _APOSTROPHE_OVERRIDE_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,
|
|
)
|
|
|
|
|
|
@web_bp.post("/jobs")
|
|
def enqueue_job() -> ResponseReturnValue:
|
|
service = _service()
|
|
uploads_dir = Path(current_app.config["UPLOAD_FOLDER"])
|
|
uploads_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
file = request.files.get("source_file")
|
|
text_input = request.form.get("source_text", "").strip()
|
|
pending_id = (request.form.get("pending_id") or "").strip()
|
|
|
|
settings = _load_settings()
|
|
profiles = load_profiles()
|
|
|
|
if pending_id and not file and not text_input:
|
|
pending = service.get_pending_job(pending_id)
|
|
if not pending:
|
|
abort(404, "Pending job not found")
|
|
previous_language = pending.language
|
|
_apply_book_step_form(pending, request.form, settings=settings, profiles=profiles)
|
|
setattr(pending, "analysis_requested", False)
|
|
if pending.language != previous_language:
|
|
_refresh_entity_summary(pending, pending.chapters)
|
|
service.store_pending_job(pending)
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(pending, "chapters")
|
|
return redirect(url_for("web.index"))
|
|
|
|
if not file and not text_input:
|
|
return redirect(url_for("web.index"))
|
|
|
|
stored_path: Path
|
|
original_name: str
|
|
|
|
if file and file.filename:
|
|
filename = secure_filename(file.filename)
|
|
if not filename:
|
|
return redirect(url_for("web.index"))
|
|
stored_path = uploads_dir / f"{uuid.uuid4().hex}_{filename}"
|
|
file.save(stored_path)
|
|
original_name = filename
|
|
else:
|
|
original_name = "direct_text.txt"
|
|
stored_path = uploads_dir / f"{uuid.uuid4().hex}_{original_name}"
|
|
stored_path.write_text(text_input, encoding="utf-8")
|
|
|
|
extraction = None
|
|
try:
|
|
extraction = extract_from_path(stored_path)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
try:
|
|
stored_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
abort(400, f"Unable to read the supplied content: {exc}")
|
|
|
|
if extraction is None: # pragma: no cover - defensive
|
|
abort(400, "Unable to read the supplied content")
|
|
|
|
assert extraction is not None
|
|
|
|
build_result = _build_pending_job_from_extraction(
|
|
stored_path=stored_path,
|
|
original_name=original_name,
|
|
extraction=extraction,
|
|
form=request.form,
|
|
settings=settings,
|
|
profiles=profiles,
|
|
)
|
|
|
|
pending = build_result.pending
|
|
_refresh_entity_summary(pending, pending.chapters)
|
|
|
|
service.store_pending_job(pending)
|
|
if build_result.selected_speaker_config:
|
|
pending.applied_speaker_config = build_result.selected_speaker_config
|
|
if build_result.config_languages:
|
|
pending.speaker_voice_languages = list(build_result.config_languages)
|
|
elif isinstance(build_result.speaker_config_payload, Mapping):
|
|
languages = build_result.speaker_config_payload.get("languages")
|
|
if isinstance(languages, list):
|
|
pending.speaker_voice_languages = [code for code in languages if isinstance(code, str)]
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(pending, "chapters")
|
|
return redirect(url_for("web.index"))
|
|
|
|
|
|
@web_bp.get("/jobs/prepare/<pending_id>")
|
|
def prepare_job(pending_id: str) -> ResponseReturnValue:
|
|
pending = _require_pending_job(pending_id)
|
|
requested_step = request.args.get("step") or "chapters"
|
|
normalized_step = _normalize_wizard_step(requested_step, pending)
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(pending, normalized_step)
|
|
return redirect(url_for("web.index"))
|
|
|
|
|
|
@web_bp.post("/jobs/prepare/<pending_id>/analyze")
|
|
def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
|
service = _service()
|
|
pending = _require_pending_job(pending_id)
|
|
|
|
(
|
|
chunk_level_literal,
|
|
overrides,
|
|
enabled_overrides,
|
|
errors,
|
|
selected_total,
|
|
selected_config,
|
|
apply_config_requested,
|
|
persist_config_requested,
|
|
) = _apply_prepare_form(pending, request.form)
|
|
|
|
if errors:
|
|
message = " ".join(errors)
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
"chapters",
|
|
error=message,
|
|
status=400,
|
|
)
|
|
abort(400, message)
|
|
|
|
if not enabled_overrides:
|
|
setattr(pending, "analysis_requested", False)
|
|
pending.chunks = []
|
|
pending.speaker_analysis = {}
|
|
error_message = "Select at least one chapter to analyze."
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
"chapters",
|
|
error=error_message,
|
|
status=400,
|
|
)
|
|
abort(400, error_message)
|
|
|
|
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
|
|
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
|
|
|
|
existing_roster: Optional[Mapping[str, Any]]
|
|
if getattr(pending, "analysis_requested", False):
|
|
existing_roster = pending.speakers
|
|
else:
|
|
existing_roster = None
|
|
|
|
config_name = pending.applied_speaker_config or selected_config
|
|
speaker_config_payload = get_config(config_name) if config_name else None
|
|
processed_chunks, roster, analysis_payload, config_languages, updated_config = _prepare_speaker_metadata(
|
|
chapters=enabled_overrides,
|
|
chunks=raw_chunks,
|
|
analysis_chunks=analysis_chunks,
|
|
voice=pending.voice,
|
|
voice_profile=pending.voice_profile,
|
|
threshold=pending.speaker_analysis_threshold,
|
|
existing_roster=existing_roster,
|
|
run_analysis=True,
|
|
speaker_config=speaker_config_payload,
|
|
apply_config=apply_config_requested or bool(speaker_config_payload),
|
|
persist_config=persist_config_requested,
|
|
)
|
|
|
|
pending.chunks = processed_chunks
|
|
pending.speakers = roster
|
|
pending.speaker_analysis = analysis_payload
|
|
if config_languages:
|
|
pending.speaker_voice_languages = list(config_languages)
|
|
config_name = getattr(pending, "applied_speaker_config", None)
|
|
if updated_config and isinstance(config_name, str) and config_name:
|
|
configs = load_configs()
|
|
configs[config_name] = updated_config
|
|
save_configs(configs)
|
|
setattr(pending, "analysis_requested", True)
|
|
if selected_total:
|
|
pending.total_characters = selected_total
|
|
|
|
_refresh_entity_summary(pending, enabled_overrides)
|
|
_sync_pronunciation_overrides(pending)
|
|
|
|
service.store_pending_job(pending)
|
|
|
|
notice_message = "Entity insights updated."
|
|
if persist_config_requested and config_name:
|
|
notice_message = "Entity insights updated and configuration saved."
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
"entities",
|
|
notice=notice_message,
|
|
)
|
|
return redirect(url_for("web.index"))
|
|
|
|
|
|
@web_bp.post("/jobs/prepare/<pending_id>")
|
|
def finalize_job(pending_id: str) -> ResponseReturnValue:
|
|
service = _service()
|
|
pending = _require_pending_job(pending_id)
|
|
|
|
(
|
|
chunk_level_literal,
|
|
overrides,
|
|
enabled_overrides,
|
|
errors,
|
|
selected_total,
|
|
selected_config,
|
|
apply_config_requested,
|
|
persist_config_requested,
|
|
) = _apply_prepare_form(pending, request.form)
|
|
|
|
if errors:
|
|
active_hint = request.form.get("active_step") or "entities"
|
|
normalized_step = _normalize_wizard_step(active_hint, pending)
|
|
message = " ".join(errors)
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
normalized_step,
|
|
error=message,
|
|
status=400,
|
|
)
|
|
abort(400, message)
|
|
|
|
if not enabled_overrides:
|
|
pending.chunks = []
|
|
error_message = "Select at least one chapter to convert."
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
"chapters",
|
|
error=error_message,
|
|
status=400,
|
|
)
|
|
abort(400, error_message)
|
|
|
|
active_step = (request.form.get("active_step") or "chapters").strip().lower()
|
|
if active_step == "speakers":
|
|
active_step = "entities"
|
|
|
|
normalized_step = _normalize_wizard_step(active_step, pending)
|
|
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
|
|
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
|
|
analysis_requested = bool(getattr(pending, "analysis_requested", False))
|
|
should_force_entities = analysis_requested and normalized_step != "entities"
|
|
|
|
if analysis_requested:
|
|
existing_roster: Optional[Mapping[str, Any]] = pending.speakers
|
|
else:
|
|
narrator_only: Dict[str, Any] = {}
|
|
if isinstance(pending.speakers, dict):
|
|
narrator_payload = pending.speakers.get("narrator")
|
|
if isinstance(narrator_payload, Mapping):
|
|
narrator_only["narrator"] = dict(narrator_payload)
|
|
existing_roster = narrator_only or None
|
|
|
|
config_name = pending.applied_speaker_config or selected_config
|
|
speaker_config_payload = get_config(config_name) if config_name else None
|
|
run_analysis = should_force_entities or analysis_requested
|
|
processed_chunks, roster, analysis_payload, config_languages, updated_config = _prepare_speaker_metadata(
|
|
chapters=enabled_overrides,
|
|
chunks=raw_chunks,
|
|
analysis_chunks=analysis_chunks,
|
|
voice=pending.voice,
|
|
voice_profile=pending.voice_profile,
|
|
threshold=pending.speaker_analysis_threshold,
|
|
existing_roster=existing_roster,
|
|
run_analysis=run_analysis,
|
|
speaker_config=speaker_config_payload,
|
|
apply_config=apply_config_requested or bool(speaker_config_payload),
|
|
persist_config=persist_config_requested,
|
|
)
|
|
|
|
pending.chunks = processed_chunks
|
|
pending.speakers = roster
|
|
if analysis_payload:
|
|
pending.speaker_analysis = analysis_payload
|
|
if run_analysis:
|
|
setattr(pending, "analysis_requested", True)
|
|
|
|
if config_languages:
|
|
pending.speaker_voice_languages = list(config_languages)
|
|
config_key = getattr(pending, "applied_speaker_config", None)
|
|
if updated_config and isinstance(config_key, str) and config_key:
|
|
configs = load_configs()
|
|
configs[config_key] = updated_config
|
|
save_configs(configs)
|
|
|
|
if selected_total:
|
|
pending.total_characters = selected_total
|
|
|
|
_refresh_entity_summary(pending, enabled_overrides)
|
|
_sync_pronunciation_overrides(pending)
|
|
|
|
requested_step = normalized_step
|
|
should_render_entities = should_force_entities or requested_step == "entities"
|
|
if should_render_entities:
|
|
notice_message = ""
|
|
if should_force_entities:
|
|
notice_message = "Review entity settings before queuing."
|
|
if persist_config_requested and config_key:
|
|
notice_message = "Configuration saved. Review entity settings before queuing."
|
|
elif persist_config_requested and config_key:
|
|
notice_message = "Configuration saved."
|
|
service.store_pending_job(pending)
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
"entities",
|
|
notice=notice_message or None,
|
|
)
|
|
return redirect(url_for("web.index"))
|
|
|
|
total_characters = selected_total or pending.total_characters
|
|
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=total_characters,
|
|
chapters=overrides,
|
|
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=getattr(pending, "read_closing_outro", True),
|
|
normalize_chapter_opening_caps=getattr(pending, "normalize_chapter_opening_caps", True),
|
|
auto_prefix_chapter_titles=getattr(pending, "auto_prefix_chapter_titles", True),
|
|
chunk_level=pending.chunk_level,
|
|
chunks=processed_chunks,
|
|
speakers=roster,
|
|
speaker_mode=pending.speaker_mode,
|
|
generate_epub3=pending.generate_epub3,
|
|
speaker_analysis=pending.speaker_analysis,
|
|
speaker_analysis_threshold=pending.speaker_analysis_threshold,
|
|
analysis_requested=getattr(pending, "analysis_requested", False),
|
|
entity_summary=pending.entity_summary,
|
|
manual_overrides=pending.manual_overrides,
|
|
pronunciation_overrides=pending.pronunciation_overrides,
|
|
normalization_overrides=pending.normalization_overrides,
|
|
)
|
|
|
|
if config_languages:
|
|
job.speaker_voice_languages = list(config_languages)
|
|
elif pending.speaker_voice_languages:
|
|
job.speaker_voice_languages = list(pending.speaker_voice_languages)
|
|
|
|
if isinstance(config_key, str) and config_key:
|
|
job.applied_speaker_config = config_key
|
|
|
|
redirect_url = url_for("web.index", _anchor="queue")
|
|
if _wants_wizard_json():
|
|
return jsonify({"redirect_url": redirect_url})
|
|
return redirect(redirect_url)
|
|
|
|
|
|
@web_bp.post("/jobs/prepare/<pending_id>/cancel")
|
|
def cancel_pending_job(pending_id: str) -> ResponseReturnValue:
|
|
pending = _service().pop_pending_job(pending_id)
|
|
if pending and pending.stored_path.exists():
|
|
try:
|
|
pending.stored_path.unlink()
|
|
except OSError:
|
|
pass
|
|
if pending and pending.cover_image_path and pending.cover_image_path.exists():
|
|
try:
|
|
pending.cover_image_path.unlink()
|
|
except OSError:
|
|
pass
|
|
redirect_url = url_for("web.index", _anchor="queue")
|
|
if _wants_wizard_json():
|
|
return jsonify({"cancelled": True, "redirect_url": redirect_url})
|
|
return redirect(redirect_url)
|
|
|
|
|
|
def _render_jobs_panel() -> str:
|
|
jobs = _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
|
|
_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
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>")
|
|
def job_detail(job_id: str) -> str:
|
|
job = _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),
|
|
)
|
|
|
|
|
|
@web_bp.post("/jobs/<job_id>/pause")
|
|
def pause_job(job_id: str) -> ResponseReturnValue:
|
|
_service().pause(job_id)
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
return redirect(url_for("web.job_detail", job_id=job_id))
|
|
|
|
|
|
@web_bp.post("/jobs/<job_id>/resume")
|
|
def resume_job(job_id: str) -> ResponseReturnValue:
|
|
_service().resume(job_id)
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
return redirect(url_for("web.job_detail", job_id=job_id))
|
|
|
|
|
|
@web_bp.post("/jobs/<job_id>/cancel")
|
|
def cancel_job(job_id: str) -> ResponseReturnValue:
|
|
_service().cancel(job_id)
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
return redirect(url_for("web.job_detail", job_id=job_id))
|
|
|
|
|
|
@web_bp.post("/jobs/<job_id>/delete")
|
|
def delete_job(job_id: str) -> ResponseReturnValue:
|
|
_service().delete(job_id)
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
return redirect(url_for("web.index"))
|
|
|
|
|
|
@web_bp.post("/jobs/<job_id>/retry")
|
|
def retry_job(job_id: str) -> ResponseReturnValue:
|
|
new_job = _service().retry(job_id)
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
if new_job:
|
|
return redirect(url_for("web.job_detail", job_id=new_job.id))
|
|
return redirect(url_for("web.job_detail", job_id=job_id))
|
|
|
|
|
|
@web_bp.post("/jobs/<job_id>/audiobookshelf")
|
|
def send_job_to_audiobookshelf(job_id: str) -> ResponseReturnValue:
|
|
service = _service()
|
|
job = service.get_job(job_id)
|
|
if job is None:
|
|
abort(404)
|
|
|
|
def _panel_response() -> ResponseReturnValue:
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
return redirect(url_for("web.job_detail", job_id=job.id))
|
|
|
|
if job.status != JobStatus.COMPLETED:
|
|
return _panel_response()
|
|
|
|
settings = _stored_integration_config("audiobookshelf")
|
|
if not settings or not _coerce_bool(settings.get("enabled"), False):
|
|
job.add_log("Audiobookshelf upload skipped: integration is disabled.", level="warning")
|
|
service._persist_state()
|
|
return _panel_response()
|
|
|
|
config = _build_audiobookshelf_config(settings)
|
|
if config is None:
|
|
job.add_log(
|
|
"Audiobookshelf upload skipped: configure base URL, API token, and library ID first.",
|
|
level="warning",
|
|
)
|
|
service._persist_state()
|
|
return _panel_response()
|
|
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: # pragma: no cover - defensive guard
|
|
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("web.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: # pragma: no cover - defensive guard
|
|
job.add_log(f"Audiobookshelf integration error: {exc}", level="error")
|
|
else:
|
|
job.add_log("Audiobookshelf upload queued.", level="success")
|
|
finally:
|
|
service._persist_state()
|
|
|
|
return _panel_response()
|
|
|
|
|
|
@web_bp.post("/jobs/clear-finished")
|
|
def clear_finished_jobs() -> ResponseReturnValue:
|
|
_service().clear_finished()
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
return redirect(url_for("web.index", _anchor="queue"))
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/epub")
|
|
def job_epub(job_id: str) -> ResponseReturnValue:
|
|
job = _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,
|
|
mimetype="application/epub+zip",
|
|
as_attachment=False,
|
|
download_name=epub_path.name,
|
|
conditional=True,
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/audio-stream")
|
|
def job_audio_stream(job_id: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None or job.status != JobStatus.COMPLETED:
|
|
abort(404)
|
|
audio_path = _locate_job_audio(job)
|
|
if not audio_path:
|
|
abort(404)
|
|
mime_type, _ = mimetypes.guess_type(str(audio_path))
|
|
return send_file(
|
|
audio_path,
|
|
mimetype=mime_type or "audio/mpeg",
|
|
as_attachment=False,
|
|
conditional=True,
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/reader")
|
|
def job_reader(job_id: str) -> ResponseReturnValue:
|
|
job = _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)
|
|
chapters = _extract_epub_chapters(epub_path)
|
|
audio_path = _locate_job_audio(job)
|
|
chapter_url = url_for("web.job_reader_chapter", job_id=job.id)
|
|
asset_base = url_for("web.job_reader_asset", job_id=job.id, asset_path="").rstrip("/") + "/"
|
|
audio_url = url_for("web.job_audio_stream", job_id=job.id) if audio_path else ""
|
|
epub_url = url_for("web.job_epub", job_id=job.id)
|
|
metadata_payload = _load_job_metadata(job)
|
|
metadata_section_raw = metadata_payload.get("metadata") if isinstance(metadata_payload, Mapping) else {}
|
|
metadata_section = metadata_section_raw if isinstance(metadata_section_raw, Mapping) else {}
|
|
job_metadata = job.metadata_tags if isinstance(job.metadata_tags, Mapping) else {}
|
|
display_title = _resolve_book_title(job, metadata_section, job_metadata)
|
|
|
|
timing_map: Dict[int, Dict[str, Any]] = {}
|
|
chapter_entries = metadata_payload.get("chapters") if isinstance(metadata_payload, Mapping) else []
|
|
for entry in chapter_entries or []:
|
|
if not isinstance(entry, Mapping):
|
|
continue
|
|
index_raw = entry.get("index")
|
|
index_value: Optional[int]
|
|
if isinstance(index_raw, (int, float)) and not isinstance(index_raw, bool):
|
|
index_value = int(index_raw) - 1
|
|
elif isinstance(index_raw, str):
|
|
stripped = index_raw.strip()
|
|
if not stripped:
|
|
continue
|
|
try:
|
|
index_value = int(stripped) - 1
|
|
except ValueError:
|
|
continue
|
|
else:
|
|
continue
|
|
if index_value < 0:
|
|
continue
|
|
start_value = _coerce_positive_time(entry.get("start"))
|
|
end_value = _coerce_positive_time(entry.get("end"))
|
|
title_value: Optional[str] = None
|
|
for key in ("title", "display_title", "spoken_title", "original_title"):
|
|
value = entry.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
title_value = value.strip()
|
|
break
|
|
timing_map[index_value] = {
|
|
"start": start_value,
|
|
"end": end_value,
|
|
"title": title_value,
|
|
}
|
|
|
|
chapter_timings: List[Dict[str, Any]] = []
|
|
for idx, chapter in enumerate(chapters):
|
|
marker = timing_map.get(idx)
|
|
if marker and marker.get("title") and isinstance(chapter, dict):
|
|
chapter_title = marker["title"]
|
|
if isinstance(chapter_title, str) and chapter_title.strip():
|
|
chapter["title"] = chapter_title
|
|
chapter_timings.append(
|
|
{
|
|
"index": idx,
|
|
"start": marker.get("start") if marker else None,
|
|
"end": marker.get("end") if marker else None,
|
|
"title": marker.get("title") if marker else None,
|
|
}
|
|
)
|
|
|
|
return render_template(
|
|
"reader_embed.html",
|
|
job=job,
|
|
audio_url=audio_url,
|
|
epub_url=epub_url,
|
|
chapters=chapters,
|
|
chapter_url=chapter_url,
|
|
asset_base=asset_base,
|
|
chapter_timings=chapter_timings,
|
|
display_title=display_title,
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/reader/chapter")
|
|
def job_reader_chapter(job_id: str) -> ResponseReturnValue:
|
|
job = _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)
|
|
raw_href = request.args.get("href", "").strip()
|
|
if not raw_href:
|
|
abort(400)
|
|
try:
|
|
chapter_bytes = _read_epub_bytes(epub_path, raw_href)
|
|
except (ValueError, FileNotFoundError, KeyError):
|
|
abort(404)
|
|
content = _decode_text(chapter_bytes)
|
|
return jsonify({"content": content})
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/reader/asset/<path:asset_path>")
|
|
def job_reader_asset(job_id: str, asset_path: str) -> ResponseReturnValue:
|
|
job = _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)
|
|
try:
|
|
payload = _read_epub_bytes(epub_path, asset_path)
|
|
except (ValueError, FileNotFoundError, KeyError):
|
|
abort(404)
|
|
mime_type, _ = mimetypes.guess_type(asset_path)
|
|
buffer = io.BytesIO(payload)
|
|
buffer.seek(0)
|
|
return send_file(
|
|
buffer,
|
|
mimetype=mime_type or "application/octet-stream",
|
|
as_attachment=False,
|
|
download_name=posixpath.basename(asset_path) or "asset",
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/download")
|
|
def download_job(job_id: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None or job.status != JobStatus.COMPLETED:
|
|
abort(404)
|
|
audio_path = _locate_job_audio(job)
|
|
if not audio_path:
|
|
abort(404)
|
|
mime_type, _ = mimetypes.guess_type(str(audio_path))
|
|
return send_file(
|
|
audio_path,
|
|
mimetype=mime_type or "application/octet-stream",
|
|
as_attachment=True,
|
|
download_name=audio_path.name,
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/download/m4b")
|
|
def download_job_m4b(job_id: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None or job.status != JobStatus.COMPLETED:
|
|
abort(404)
|
|
audio_path = _locate_job_m4b(job)
|
|
if not audio_path:
|
|
abort(404)
|
|
mime_type, _ = mimetypes.guess_type(str(audio_path))
|
|
return send_file(
|
|
audio_path,
|
|
mimetype=mime_type or "audio/mpeg",
|
|
as_attachment=True,
|
|
download_name=audio_path.name,
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/download/epub3")
|
|
def download_job_epub3(job_id: str) -> ResponseReturnValue:
|
|
job = _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,
|
|
mimetype="application/epub+zip",
|
|
as_attachment=True,
|
|
download_name=epub_path.name,
|
|
conditional=True,
|
|
)
|
|
|
|
|
|
@web_bp.get("/partials/jobs")
|
|
def jobs_partial() -> str:
|
|
return _render_jobs_panel()
|
|
|
|
@web_bp.get("/partials/jobs/<job_id>/logs")
|
|
def job_logs_partial(job_id: str) -> str:
|
|
job = _service().get_job(job_id)
|
|
if not job:
|
|
abort(404)
|
|
return render_template("partials/logs.html", job=job, static_view=False)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/logs/static")
|
|
def job_logs_static(job_id: str) -> str:
|
|
job = _service().get_job(job_id)
|
|
if not job:
|
|
abort(404)
|
|
log_lines = [
|
|
f"{datetime.fromtimestamp(entry.timestamp).strftime('%Y-%m-%d %H:%M:%S')} [{entry.level.upper()}] {entry.message}"
|
|
for entry in job.logs
|
|
]
|
|
return render_template(
|
|
"job_logs_static.html",
|
|
job=job,
|
|
log_text="\n".join(log_lines),
|
|
static_view=True,
|
|
)
|
|
|
|
|
|
@api_bp.get("/jobs/<job_id>")
|
|
def job_json(job_id: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None:
|
|
abort(404)
|
|
if not isinstance(job, Job): # pragma: no cover - defensive guard
|
|
abort(404)
|
|
job_obj = cast(Job, job)
|
|
payload = job_obj.as_dict()
|
|
return jsonify(payload)
|