mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
Resolve book_handler conflict: keep PyQt in pyqt/
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
# Special thanks to @geo38 from Reddit, who provided this Dockerfile:
|
||||
# https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/
|
||||
|
||||
# Use a docker base image that runs a window manager that can be viewed
|
||||
# outside the image with a web browser or VNC client.
|
||||
# https://github.com/jlesage/docker-baseimage-gui
|
||||
FROM jlesage/baseimage-gui:debian-12-v4
|
||||
|
||||
# Load stuff needed by abogen
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
python3-pyqt6 \
|
||||
espeak-ng \
|
||||
libxcb-cursor0 \
|
||||
libgl1 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# The base image will run /startapp.sh on launch.
|
||||
#
|
||||
# The base image runs that script as user 'app' uid=1000. That user
|
||||
# does not exist in the base image but is created at run time.
|
||||
#
|
||||
# We need to install abogen in python venv (requirement of newer python3).
|
||||
#
|
||||
# The python venv has to be writable by the 'app' user as abogen dynamically
|
||||
# installs python packages, so create the venv as that user
|
||||
#
|
||||
# We intend to share the /shared directory with the host using a bind volume
|
||||
# in order to access any source files and the created files.
|
||||
|
||||
RUN echo '#!/bin/bash\nsource /app/venv/bin/activate\nexec abogen' > /startapp.sh \
|
||||
&& chmod 555 /startapp.sh \
|
||||
&& mkdir /app /shared \
|
||||
&& chown 1000:1000 /app /shared \
|
||||
&& chmod 755 /app /shared
|
||||
USER 1000:1000
|
||||
RUN python3 -m venv /app/venv
|
||||
RUN /bin/bash -c "source /app/venv/bin/activate && pip install abogen"
|
||||
# Change back to user ROOT as the startup scripts inside base image needs it
|
||||
USER root
|
||||
+11
-116
@@ -1,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
import re
|
||||
import base64
|
||||
from bs4 import BeautifulSoup, NavigableString
|
||||
@@ -13,126 +14,17 @@ from PyQt6.QtWidgets import (
|
||||
QDialogButtonBox,
|
||||
QSplitter,
|
||||
QWidget,
|
||||
QCheckBox,
|
||||
QTreeWidgetItemIterator,
|
||||
QLabel,
|
||||
QMenu,
|
||||
)
|
||||
from PyQt6.QtCore import (
|
||||
Qt,
|
||||
QThread,
|
||||
pyqtSignal,
|
||||
QSize,
|
||||
)
|
||||
from abogen.utils import (
|
||||
detect_encoding,
|
||||
get_resource_path,
|
||||
)
|
||||
from abogen.book_parser import get_book_parser
|
||||
"""Backwards-compatible re-export of the PyQt book handler.
|
||||
|
||||
from abogen.subtitle_utils import (
|
||||
clean_text,
|
||||
calculate_text_length,
|
||||
)
|
||||
The actual implementation lives in abogen.pyqt.book_handler.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import urllib.parse
|
||||
import textwrap
|
||||
from __future__ import annotations
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
from abogen.pyqt.book_handler import * # noqa: F401, F403
|
||||
from abogen.pyqt.book_handler import HandlerDialog
|
||||
|
||||
_HTML_TAG_PATTERN = re.compile(r"<[^>]+>")
|
||||
_LEADING_DASH_PATTERN = re.compile(r"^\s*[-–—]\s*")
|
||||
_LEADING_SIMPLE_DASH_PATTERN = re.compile(r"^\s*-\s*")
|
||||
|
||||
|
||||
class HandlerDialog(QDialog):
|
||||
# Class variables to remember checkbox states between dialog instances
|
||||
_save_chapters_separately = False
|
||||
_merge_chapters_at_end = True
|
||||
_save_as_project = False # New class variable for save_as_project option
|
||||
|
||||
# Cache for processed book content to avoid reprocessing
|
||||
# Key: (book_path, modification_time, file_type)
|
||||
# Value: dict with content_texts, content_lengths, doc_content (for epub), markdown_toc (for markdown)
|
||||
_content_cache = {}
|
||||
|
||||
class _LoaderThread(QThread):
|
||||
"""Minimal QThread that runs a callable and emits an error string on exception."""
|
||||
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, target_callable):
|
||||
super().__init__()
|
||||
self._target = target_callable
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self._target()
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
@classmethod
|
||||
def clear_content_cache(cls, book_path=None):
|
||||
"""Clear the content cache. If book_path is provided, only clear that book's cache."""
|
||||
if book_path is None:
|
||||
cls._content_cache.clear()
|
||||
logging.info("Cleared all content cache")
|
||||
else:
|
||||
keys_to_remove = [
|
||||
key for key in cls._content_cache.keys() if key[0] == book_path
|
||||
]
|
||||
for key in keys_to_remove:
|
||||
del cls._content_cache[key]
|
||||
if keys_to_remove:
|
||||
logging.info(f"Cleared content cache for {os.path.basename(book_path)}")
|
||||
|
||||
def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Normalize path
|
||||
book_path = os.path.normpath(os.path.abspath(book_path))
|
||||
self.book_path = book_path
|
||||
|
||||
# Initialize Parser
|
||||
try:
|
||||
# Factory handles file type detection if file_type is None
|
||||
self.parser = get_book_parser(book_path, file_type=file_type)
|
||||
# Parser loads automatically in init now
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to initialize parser for {book_path}: {e}")
|
||||
raise
|
||||
|
||||
# Extract book name from file path
|
||||
book_name = os.path.splitext(os.path.basename(book_path))[0]
|
||||
|
||||
# Set window title based on file type and book name
|
||||
item_type = "Chapters" if self.parser.file_type in ["epub", "markdown"] else "Pages"
|
||||
self.setWindowTitle(f"Select {item_type} - {book_name}")
|
||||
self.resize(1200, 900)
|
||||
self._block_signals = False # Flag to prevent recursive signals
|
||||
# Configure window: remove help button and allow resizing
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.Window
|
||||
| Qt.WindowType.WindowCloseButtonHint
|
||||
| Qt.WindowType.WindowMaximizeButtonHint
|
||||
)
|
||||
self.setWindowModality(Qt.WindowModality.NonModal)
|
||||
# Initialize save chapters flags from class variables
|
||||
self.save_chapters_separately = HandlerDialog._save_chapters_separately
|
||||
self.merge_chapters_at_end = HandlerDialog._merge_chapters_at_end
|
||||
self.save_as_project = HandlerDialog._save_as_project
|
||||
|
||||
# Initialize metadata dict; will be populated in _preprocess_content by the background loader
|
||||
self.book_metadata = {}
|
||||
|
||||
# Initialize UI elements that are used in other methods
|
||||
self.save_chapters_checkbox = None
|
||||
self.merge_chapters_checkbox = None
|
||||
__all__ = ["HandlerDialog"]
|
||||
|
||||
# Build treeview
|
||||
self.treeWidget = QTreeWidget(self)
|
||||
@@ -1652,3 +1544,6 @@ class HandlerDialog(QDialog):
|
||||
menu.exec(self.treeWidget.mapToGlobal(pos))
|
||||
|
||||
|
||||
=======
|
||||
__all__ = ["HandlerDialog"]
|
||||
>>>>>>> main
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, Iterator, List, Literal, Optional, Tuple
|
||||
from typing import Pattern
|
||||
|
||||
import re
|
||||
|
||||
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline
|
||||
from abogen.normalization_settings import build_apostrophe_config, get_runtime_settings
|
||||
|
||||
ChunkLevel = Literal["paragraph", "sentence"]
|
||||
|
||||
_SENTENCE_SPLIT_REGEX = re.compile(r"(?<!\b[A-Z])[.!?][\s\n]+")
|
||||
_WHITESPACE_REGEX = re.compile(r"\s+")
|
||||
_PARAGRAPH_SPLIT_REGEX = re.compile(r"(?:\r?\n){2,}")
|
||||
_ABBREVIATION_END_RE = re.compile(
|
||||
r"\b(?:Mr|Mrs|Ms|Dr|Prof|Rev|Sr|Jr|St|Gen|Lt|Col|Sgt|Capt|Adm|Cmdr|vs|etc)\.$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_PIPELINE_APOSTROPHE_CONFIG = ApostropheConfig()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Chunk:
|
||||
id: str
|
||||
chapter_index: int
|
||||
chunk_index: int
|
||||
level: ChunkLevel
|
||||
text: str
|
||||
speaker_id: str = "narrator"
|
||||
voice: Optional[str] = None
|
||||
voice_profile: Optional[str] = None
|
||||
voice_formula: Optional[str] = None
|
||||
display_text: Optional[str] = None
|
||||
|
||||
def as_dict(self) -> Dict[str, object]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"chapter_index": self.chapter_index,
|
||||
"chunk_index": self.chunk_index,
|
||||
"level": self.level,
|
||||
"text": self.text,
|
||||
"speaker_id": self.speaker_id,
|
||||
"voice": self.voice,
|
||||
"voice_profile": self.voice_profile,
|
||||
"voice_formula": self.voice_formula,
|
||||
"display_text": self.display_text,
|
||||
}
|
||||
|
||||
|
||||
def _iter_paragraphs(text: str) -> Iterator[str]:
|
||||
for raw_segment in _PARAGRAPH_SPLIT_REGEX.split(text.strip()):
|
||||
normalized = raw_segment.strip()
|
||||
if normalized:
|
||||
yield normalized
|
||||
|
||||
|
||||
def _iter_sentences(paragraph: str) -> Iterator[Tuple[str, str]]:
|
||||
if not paragraph:
|
||||
return
|
||||
start = 0
|
||||
for match in _SENTENCE_SPLIT_REGEX.finditer(paragraph):
|
||||
end = match.end()
|
||||
raw_segment = paragraph[start:end]
|
||||
candidate = raw_segment.strip()
|
||||
if candidate:
|
||||
yield candidate, raw_segment
|
||||
start = match.end()
|
||||
tail_raw = paragraph[start:]
|
||||
tail = tail_raw.strip()
|
||||
if tail:
|
||||
yield tail, tail_raw
|
||||
|
||||
|
||||
def _normalize_whitespace(value: str) -> str:
|
||||
return _WHITESPACE_REGEX.sub(" ", value).strip()
|
||||
|
||||
|
||||
def _normalize_chunk_text(value: str) -> str:
|
||||
settings = get_runtime_settings()
|
||||
config = build_apostrophe_config(settings=settings, base=_PIPELINE_APOSTROPHE_CONFIG)
|
||||
normalized = normalize_for_pipeline(value, config=config, settings=settings)
|
||||
return _normalize_whitespace(normalized)
|
||||
|
||||
|
||||
def _split_sentences(paragraph: str) -> List[Tuple[str, str]]:
|
||||
sentences = list(_iter_sentences(paragraph))
|
||||
if not sentences:
|
||||
return []
|
||||
|
||||
merged: List[Tuple[str, str]] = []
|
||||
buffer_norm: List[str] = []
|
||||
buffer_raw: List[str] = []
|
||||
|
||||
for normalized_sentence, raw_sentence in sentences:
|
||||
if buffer_norm:
|
||||
buffer_norm.append(normalized_sentence)
|
||||
buffer_raw.append(raw_sentence)
|
||||
else:
|
||||
buffer_norm = [normalized_sentence]
|
||||
buffer_raw = [raw_sentence]
|
||||
|
||||
if _ABBREVIATION_END_RE.search(normalized_sentence.rstrip()):
|
||||
continue
|
||||
|
||||
merged.append((" ".join(buffer_norm), "".join(buffer_raw)))
|
||||
buffer_norm = []
|
||||
buffer_raw = []
|
||||
|
||||
if buffer_norm:
|
||||
merged.append((" ".join(buffer_norm), "".join(buffer_raw)))
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def chunk_text(
|
||||
*,
|
||||
chapter_index: int,
|
||||
chapter_title: str,
|
||||
text: str,
|
||||
level: ChunkLevel,
|
||||
speaker_id: str = "narrator",
|
||||
voice: Optional[str] = None,
|
||||
voice_profile: Optional[str] = None,
|
||||
voice_formula: Optional[str] = None,
|
||||
chunk_prefix: Optional[str] = None,
|
||||
) -> List[Dict[str, object]]:
|
||||
"""Split text into ordered chunk dictionaries."""
|
||||
|
||||
prefix = chunk_prefix or f"chap{chapter_index:04d}"
|
||||
chunks: List[Dict[str, object]] = []
|
||||
|
||||
if level == "paragraph":
|
||||
paragraphs = list(_iter_paragraphs(text)) or [text.strip()]
|
||||
for para_index, paragraph in enumerate(paragraphs):
|
||||
normalized = _normalize_whitespace(paragraph)
|
||||
if not normalized:
|
||||
continue
|
||||
chunk_id = f"{prefix}_p{para_index:04d}"
|
||||
payload = Chunk(
|
||||
id=chunk_id,
|
||||
chapter_index=chapter_index,
|
||||
chunk_index=len(chunks),
|
||||
level=level,
|
||||
text=normalized,
|
||||
speaker_id=speaker_id,
|
||||
voice=voice,
|
||||
voice_profile=voice_profile,
|
||||
voice_formula=voice_formula,
|
||||
).as_dict()
|
||||
payload["normalized_text"] = _normalize_chunk_text(paragraph)
|
||||
payload["original_text"] = paragraph
|
||||
chunks.append(payload)
|
||||
_attach_display_text(text, chunks)
|
||||
return chunks
|
||||
|
||||
# Sentence level – flatten paragraphs into individual sentences
|
||||
sentence_index = 0
|
||||
for para_index, paragraph in enumerate(list(_iter_paragraphs(text)) or [text.strip()]):
|
||||
normalized_para = _normalize_whitespace(paragraph)
|
||||
if not normalized_para:
|
||||
continue
|
||||
sentence_pairs = _split_sentences(paragraph) or [(normalized_para, paragraph)]
|
||||
for sent_local_index, (normalized_sentence, raw_sentence) in enumerate(sentence_pairs):
|
||||
normalized_sentence = _normalize_whitespace(normalized_sentence)
|
||||
if not normalized_sentence:
|
||||
continue
|
||||
chunk_id = f"{prefix}_p{para_index:04d}_s{sent_local_index:04d}"
|
||||
payload = Chunk(
|
||||
id=chunk_id,
|
||||
chapter_index=chapter_index,
|
||||
chunk_index=sentence_index,
|
||||
level=level,
|
||||
text=normalized_sentence,
|
||||
speaker_id=speaker_id,
|
||||
voice=voice,
|
||||
voice_profile=voice_profile,
|
||||
voice_formula=voice_formula,
|
||||
).as_dict()
|
||||
payload["normalized_text"] = _normalize_chunk_text(raw_sentence)
|
||||
payload["display_text"] = raw_sentence
|
||||
payload["original_text"] = raw_sentence
|
||||
chunks.append(payload)
|
||||
sentence_index += 1
|
||||
|
||||
_attach_display_text(text, chunks)
|
||||
return chunks
|
||||
|
||||
|
||||
_DISPLAY_PATTERN_CACHE: Dict[str, Pattern[str]] = {}
|
||||
|
||||
|
||||
def _build_display_pattern(text: str) -> Pattern[str]:
|
||||
cached = _DISPLAY_PATTERN_CACHE.get(text)
|
||||
if cached is not None:
|
||||
return cached
|
||||
escaped = re.escape(text)
|
||||
escaped = escaped.replace(r"\ ", r"\s+")
|
||||
pattern = re.compile(r"(\s*" + escaped + r"\s*)", re.DOTALL)
|
||||
_DISPLAY_PATTERN_CACHE[text] = pattern
|
||||
return pattern
|
||||
|
||||
|
||||
def _search_source_span(source: str, normalized: str, start: int) -> Optional[Tuple[int, int]]:
|
||||
if not normalized:
|
||||
return None
|
||||
pattern = _build_display_pattern(normalized)
|
||||
match = pattern.search(source, start)
|
||||
if not match:
|
||||
return None
|
||||
return match.start(1), match.end(1)
|
||||
|
||||
|
||||
def _attach_display_text(source: str, chunks: List[Dict[str, object]]) -> None:
|
||||
if not source or not chunks:
|
||||
return
|
||||
cursor = 0
|
||||
for chunk in chunks:
|
||||
candidate = str(chunk.get("display_text") or chunk.get("text") or "")
|
||||
if not candidate:
|
||||
continue
|
||||
match = _search_source_span(source, candidate, cursor)
|
||||
if match is None and cursor:
|
||||
match = _search_source_span(source, candidate, 0)
|
||||
if match is None:
|
||||
chunk.setdefault("display_text", candidate)
|
||||
chunk.setdefault("original_text", chunk.get("display_text") or candidate)
|
||||
continue
|
||||
start, end = match
|
||||
chunk["display_text"] = source[start:end]
|
||||
chunk["original_text"] = source[start:end]
|
||||
cursor = end
|
||||
|
||||
|
||||
def build_chunks_for_chapters(
|
||||
chapters: Iterable[Dict[str, object]],
|
||||
*,
|
||||
level: ChunkLevel,
|
||||
speaker_id: str = "narrator",
|
||||
) -> List[Dict[str, object]]:
|
||||
"""Generate chunk dictionaries for a sequence of chapter payloads."""
|
||||
all_chunks: List[Dict[str, object]] = []
|
||||
for chapter_index, entry in enumerate(chapters):
|
||||
if not isinstance(entry, dict): # defensive
|
||||
continue
|
||||
text = str(entry.get("text", "") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
voice = entry.get("voice")
|
||||
voice_profile = entry.get("voice_profile")
|
||||
voice_formula = entry.get("voice_formula")
|
||||
prefix = entry.get("id") or f"chap{chapter_index:04d}"
|
||||
chapter_chunks = chunk_text(
|
||||
chapter_index=chapter_index,
|
||||
chapter_title=str(entry.get("title") or f"Chapter {chapter_index + 1}"),
|
||||
text=text,
|
||||
level=level,
|
||||
speaker_id=speaker_id,
|
||||
voice=str(voice) if voice else None,
|
||||
voice_profile=str(voice_profile) if voice_profile else None,
|
||||
voice_formula=str(voice_formula) if voice_formula else None,
|
||||
chunk_prefix=str(prefix),
|
||||
)
|
||||
all_chunks.extend(chapter_chunks)
|
||||
return all_chunks
|
||||
+13
-2474
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,379 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Sequence
|
||||
|
||||
|
||||
MARKER_PREFIX = "[[ABOGEN-DBG:"
|
||||
MARKER_SUFFIX = "]]"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DebugTTSSample:
|
||||
code: str
|
||||
label: str
|
||||
text: str
|
||||
|
||||
|
||||
DEBUG_TTS_SAMPLES: Sequence[DebugTTSSample] = (
|
||||
DebugTTSSample(
|
||||
code="APOS_001",
|
||||
label="Apostrophes & contractions (1)",
|
||||
text="It's a beautiful day, isn't it? Let's see what we'll do.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="APOS_002",
|
||||
label="Apostrophes & contractions (2)",
|
||||
text="I'm sure you're ready; we'd better go before it's too late.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="APOS_003",
|
||||
label="Apostrophes & contractions (3)",
|
||||
text="He'll say it's fine, but I can't promise it'll work.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="APOS_004",
|
||||
label="Apostrophes & contractions (4)",
|
||||
text="They've done it, and I'd agree they've earned it.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="APOS_005",
|
||||
label="Apostrophes & contractions (5)",
|
||||
text="She's here, we're late, they're waiting, and you're right.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="POS_001",
|
||||
label="Plural possessives (1)",
|
||||
text="The dogs' bowls were empty, but the boss's office was quiet.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="POS_002",
|
||||
label="Plural possessives (2)",
|
||||
text="The teachers' lounge was closed during the students' exams.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="POS_003",
|
||||
label="Plural possessives (3)",
|
||||
text="The actresses' roles changed, and the directors' notes piled up.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="POS_004",
|
||||
label="Plural possessives (4)",
|
||||
text="The Joneses' car was parked by the neighbors' fence.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="POS_005",
|
||||
label="Plural possessives (5)",
|
||||
text="The bosses' meeting ended before the witnesses' statements began.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="NUM_001",
|
||||
label="Grouped numbers (1)",
|
||||
text="There are 1,234 apples, 56 oranges, and 7.89 liters of juice.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="NUM_002",
|
||||
label="Grouped numbers (2)",
|
||||
text="The population is 10,000,000 and the area is 123.45 square miles.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="NUM_003",
|
||||
label="Grouped numbers (3)",
|
||||
text="Set the timer for 0.5 seconds, then wait 2.0 minutes.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="NUM_004",
|
||||
label="Grouped numbers (4)",
|
||||
text="We measured 3.1415 radians and wrote down 2,718.28 as well.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="NUM_005",
|
||||
label="Grouped numbers (5)",
|
||||
text="The sequence is 1, 2, 3, 4, 5, and then 13.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="YEAR_001",
|
||||
label="Years and decades (1)",
|
||||
text="In 1999, people said the '90s were over.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="YEAR_002",
|
||||
label="Years and decades (2)",
|
||||
text="In 2001, the show premiered; by 2010 it was everywhere.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="YEAR_003",
|
||||
label="Years and decades (3)",
|
||||
text="The 1980s were loud, and the 1970s were groovy.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="YEAR_004",
|
||||
label="Years and decades (4)",
|
||||
text="She loved the '80s, but he preferred the '60s.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="YEAR_005",
|
||||
label="Years and decades (5)",
|
||||
text="In 2024, we looked back at 2020 and planned for 2030.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="DATE_001",
|
||||
label="Dates (1)",
|
||||
text="On 2023-01-01, we celebrated the new year.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="DATE_002",
|
||||
label="Dates (2)",
|
||||
text="The deadline is 1999-12-31 at midnight.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="DATE_003",
|
||||
label="Dates (3)",
|
||||
text="Leap day happens on 2024-02-29.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="DATE_004",
|
||||
label="Dates (4)",
|
||||
text="Some formats look like 01/02/2003 and can be ambiguous.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="DATE_005",
|
||||
label="Dates (5)",
|
||||
text="We met on March 5, 2020 and again on Apr. 7, 2021.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="CUR_001",
|
||||
label="Currency symbols (1)",
|
||||
text="The price is $10.50, but it was £8.00 yesterday.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="CUR_002",
|
||||
label="Currency symbols (2)",
|
||||
text="Tickets cost €12, and the fine was $0.99.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="CUR_003",
|
||||
label="Currency symbols (3)",
|
||||
text="The bill was ¥500 and the refund was $-3.25.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="CUR_004",
|
||||
label="Currency symbols (4)",
|
||||
text="He paid £1,234.56 for the instrument.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="CUR_005",
|
||||
label="Currency symbols (5)",
|
||||
text="The subscription is $5 per month, or $50 per year.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="TITLE_001",
|
||||
label="Titles and abbreviations (1)",
|
||||
text="Dr. Smith lives on Elm St. near the U.S. border.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="TITLE_002",
|
||||
label="Titles and abbreviations (2)",
|
||||
text="Mr. and Mrs. Doe met Prof. Adams at 5 p.m.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="TITLE_003",
|
||||
label="Titles and abbreviations (3)",
|
||||
text="Gen. Smith spoke to Sgt. Rivera on Main St.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="TITLE_004",
|
||||
label="Titles and abbreviations (4)",
|
||||
text="The report came from the U.K. office, not the U.S.A. team.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="TITLE_005",
|
||||
label="Titles and abbreviations (5)",
|
||||
text="St. John's is different from St. Louis.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="PUNC_001",
|
||||
label="Terminal punctuation (1)",
|
||||
text="This sentence ends without punctuation",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="PUNC_002",
|
||||
label="Terminal punctuation (2)",
|
||||
text="An ellipsis is already present...",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="PUNC_003",
|
||||
label="Terminal punctuation (3)",
|
||||
text="A question without a mark",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="PUNC_004",
|
||||
label="Terminal punctuation (4)",
|
||||
text="An exclamation without a bang",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="PUNC_005",
|
||||
label="Terminal punctuation (5)",
|
||||
text='A quote ends here"',
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="QUOTE_001",
|
||||
label="ALL CAPS inside quotes (1)",
|
||||
text='He shouted, "THIS IS IMPORTANT!" and then whispered, "ok."',
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="QUOTE_002",
|
||||
label="ALL CAPS inside quotes (2)",
|
||||
text='She said, "NO WAY", but he replied, "maybe".',
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="QUOTE_003",
|
||||
label="ALL CAPS inside quotes (3)",
|
||||
text='The sign read "DO NOT ENTER" and the note read "pls knock".',
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="QUOTE_004",
|
||||
label="ALL CAPS inside quotes (4)",
|
||||
text='He muttered, "OK", then yelled, "STOP!"',
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="QUOTE_005",
|
||||
label="ALL CAPS inside quotes (5)",
|
||||
text='They chanted, "USA!" and someone wrote "idk".',
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="FOOT_001",
|
||||
label="Footnote indicators (1)",
|
||||
text="This is a sentence with a footnote[1] and another[12].",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="FOOT_002",
|
||||
label="Footnote indicators (2)",
|
||||
text="Some books use multiple footnotes like this[2][3] in a row.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="FOOT_003",
|
||||
label="Footnote indicators (3)",
|
||||
text="A footnote can appear mid-sentence[4] and continue afterward.",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="FOOT_004",
|
||||
label="Footnote indicators (4)",
|
||||
text="Edge cases include [0] or very large indices like [1234].",
|
||||
),
|
||||
DebugTTSSample(
|
||||
code="FOOT_005",
|
||||
label="Footnote indicators (5)",
|
||||
text="Sometimes a footnote follows punctuation.[5] Sometimes it doesn't[6]",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def marker_for(code: str) -> str:
|
||||
return f"{MARKER_PREFIX}{code}{MARKER_SUFFIX}"
|
||||
|
||||
|
||||
def build_debug_epub(dest_path: Path, *, title: str = "abogen debug samples") -> Path:
|
||||
"""Create a tiny EPUB containing all debug samples.
|
||||
|
||||
The text includes stable marker codes so developers can report failures
|
||||
precisely.
|
||||
"""
|
||||
|
||||
dest_path = Path(dest_path)
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
chapter_lines: List[str] = [
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
|
||||
"<!DOCTYPE html>",
|
||||
"<html xmlns=\"http://www.w3.org/1999/xhtml\">",
|
||||
"<head>",
|
||||
f" <title>{title}</title>",
|
||||
" <meta charset=\"utf-8\" />",
|
||||
"</head>",
|
||||
"<body>",
|
||||
f" <h1>{title}</h1>",
|
||||
" <p>Each paragraph begins with a stable debug code marker.</p>",
|
||||
]
|
||||
|
||||
for sample in DEBUG_TTS_SAMPLES:
|
||||
safe_label = sample.label.replace("&", "and")
|
||||
chapter_lines.append(f" <h2>{safe_label}</h2>")
|
||||
chapter_lines.append(
|
||||
" <p><strong>" + marker_for(sample.code) + "</strong> " + sample.text + "</p>"
|
||||
)
|
||||
|
||||
chapter_lines += ["</body>", "</html>"]
|
||||
chapter_xhtml = "\n".join(chapter_lines)
|
||||
|
||||
container_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
"""
|
||||
|
||||
content_opf = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="bookid">abogen-debug-samples</dc:identifier>
|
||||
<dc:title>abogen debug samples</dc:title>
|
||||
<dc:language>en</dc:language>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" />
|
||||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav" />
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="chapter" />
|
||||
</spine>
|
||||
</package>
|
||||
"""
|
||||
|
||||
nav_xhtml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>Navigation</title>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
<nav epub:type="toc" id="toc">
|
||||
<h2>Table of Contents</h2>
|
||||
<ol>
|
||||
<li><a href="chapter.xhtml">Debug samples</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
(tmp_path / "mimetype").write_text("application/epub+zip", encoding="utf-8")
|
||||
meta_inf = tmp_path / "META-INF"
|
||||
meta_inf.mkdir(parents=True, exist_ok=True)
|
||||
(meta_inf / "container.xml").write_text(container_xml, encoding="utf-8")
|
||||
oebps = tmp_path / "OEBPS"
|
||||
oebps.mkdir(parents=True, exist_ok=True)
|
||||
(oebps / "content.opf").write_text(content_opf, encoding="utf-8")
|
||||
(oebps / "chapter.xhtml").write_text(chapter_xhtml, encoding="utf-8")
|
||||
(oebps / "nav.xhtml").write_text(nav_xhtml, encoding="utf-8")
|
||||
|
||||
# Per EPUB spec: mimetype must be the first entry and stored (no compression).
|
||||
with zipfile.ZipFile(dest_path, "w") as zf:
|
||||
zf.write(tmp_path / "mimetype", "mimetype", compress_type=zipfile.ZIP_STORED)
|
||||
for source in (meta_inf / "container.xml", oebps / "content.opf", oebps / "chapter.xhtml", oebps / "nav.xhtml"):
|
||||
arcname = str(source.relative_to(tmp_path)).replace("\\", "/")
|
||||
zf.write(source, arcname, compress_type=zipfile.ZIP_DEFLATED)
|
||||
|
||||
return dest_path
|
||||
|
||||
|
||||
def iter_expected_codes() -> Iterable[str]:
|
||||
for sample in DEBUG_TTS_SAMPLES:
|
||||
yield sample.code
|
||||
@@ -0,0 +1,453 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
||||
|
||||
try: # pragma: no cover - fallback when spaCy not available during tests
|
||||
import spacy # type: ignore[import-not-found]
|
||||
except Exception: # pragma: no cover - spaCy optional during runtime bootstrap
|
||||
spacy = None
|
||||
|
||||
_Language = Any # type: ignore[misc,assignment]
|
||||
Doc = Any # type: ignore[misc,assignment]
|
||||
Span = Any # type: ignore[misc,assignment]
|
||||
|
||||
|
||||
_TITLE_PREFIXES = (
|
||||
"mr",
|
||||
"mrs",
|
||||
"ms",
|
||||
"miss",
|
||||
"dr",
|
||||
"prof",
|
||||
"sir",
|
||||
"madam",
|
||||
"lady",
|
||||
"lord",
|
||||
"capt",
|
||||
"captain",
|
||||
"col",
|
||||
"colonel",
|
||||
"maj",
|
||||
"major",
|
||||
"sgt",
|
||||
"sergeant",
|
||||
"rev",
|
||||
"father",
|
||||
"mother",
|
||||
"brother",
|
||||
"sister",
|
||||
)
|
||||
|
||||
_STOP_LABELS = {
|
||||
"the",
|
||||
"that",
|
||||
"this",
|
||||
"those",
|
||||
"these",
|
||||
"there",
|
||||
"here",
|
||||
"then",
|
||||
"and",
|
||||
"but",
|
||||
"or",
|
||||
"nor",
|
||||
"so",
|
||||
"yet",
|
||||
"dr",
|
||||
"mr",
|
||||
"mrs",
|
||||
"ms",
|
||||
"miss",
|
||||
"sir",
|
||||
"madam",
|
||||
"lady",
|
||||
"lord",
|
||||
}
|
||||
|
||||
_EXCLUDED_NER_LABELS = {
|
||||
"CARDINAL",
|
||||
"DATE",
|
||||
"ORDINAL",
|
||||
"PERCENT",
|
||||
"TIME",
|
||||
"LAW",
|
||||
"MONEY",
|
||||
"QUANTITY",
|
||||
}
|
||||
|
||||
_TITLE_PATTERN = re.compile(r"^(?:" + "|".join(re.escape(prefix) for prefix in _TITLE_PREFIXES) + r")\.?\s+", re.IGNORECASE)
|
||||
_POSSESSIVE_PATTERN = re.compile(r"(?:'s|’s|\u2019s)$", re.IGNORECASE)
|
||||
_NON_WORD_PATTERN = re.compile(r"[^\w\s'-]+")
|
||||
_MULTI_SPACE_PATTERN = re.compile(r"\s+")
|
||||
_SUFFIX_PATTERN = re.compile(
|
||||
r",?\s+(?:jr|sr|ii|iii|iv|v|vi|md|phd|esq|esquire|dds|dvm)\.?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EntityRecord:
|
||||
key: Tuple[str, str]
|
||||
label: str
|
||||
kind: str
|
||||
category: str
|
||||
count: int = 0
|
||||
samples: List[Dict[str, Any]] = field(default_factory=list)
|
||||
chapter_indices: set[int] = field(default_factory=set)
|
||||
forms: Counter = field(default_factory=Counter)
|
||||
first_position: Optional[Tuple[int, int]] = None
|
||||
|
||||
def register(self, *, chapter_index: int, position: int, text: str, sentence: Optional[str]) -> None:
|
||||
self.count += 1
|
||||
self.chapter_indices.add(chapter_index)
|
||||
self.forms[text] += 1
|
||||
if self.first_position is None:
|
||||
self.first_position = (chapter_index, position)
|
||||
if sentence and len(self.samples) < 5:
|
||||
payload = {
|
||||
"excerpt": sentence.strip(),
|
||||
"chapter_index": chapter_index,
|
||||
}
|
||||
if payload not in self.samples:
|
||||
self.samples.append(payload)
|
||||
|
||||
def as_dict(self, ordinal: int) -> Dict[str, Any]:
|
||||
chapter_indices = sorted(self.chapter_indices)
|
||||
first_chapter = chapter_indices[0] if chapter_indices else None
|
||||
return {
|
||||
"id": f"{self.category}_{ordinal}",
|
||||
"label": self.label,
|
||||
"normalized": self.key[1],
|
||||
"category": self.category,
|
||||
"kind": self.kind,
|
||||
"count": self.count,
|
||||
"samples": list(self.samples),
|
||||
"chapter_indices": chapter_indices,
|
||||
"first_chapter": first_chapter,
|
||||
"forms": self.forms.most_common(6),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EntityExtractionResult:
|
||||
summary: Dict[str, Any]
|
||||
cache_key: str
|
||||
elapsed: float
|
||||
errors: List[str]
|
||||
|
||||
|
||||
class EntityModelError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
_MODEL_CACHE: Dict[str, Any] = {}
|
||||
_MODEL_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _resolve_model_name(language: str) -> str:
|
||||
override = os.environ.get("ABOGEN_SPACY_MODEL")
|
||||
if override:
|
||||
return override.strip()
|
||||
lowered = language.strip().lower()
|
||||
if lowered.startswith("en"):
|
||||
return "en_core_web_sm"
|
||||
return "en_core_web_sm"
|
||||
|
||||
|
||||
def _load_model(language: str) -> Any:
|
||||
if spacy is None:
|
||||
raise EntityModelError("spaCy is not available. Install spaCy to enable entity extraction.")
|
||||
|
||||
model_name = _resolve_model_name(language)
|
||||
cache_key = model_name.lower()
|
||||
with _MODEL_LOCK:
|
||||
if cache_key in _MODEL_CACHE:
|
||||
return _MODEL_CACHE[cache_key]
|
||||
try:
|
||||
nlp = spacy.load(model_name) # type: ignore[arg-type]
|
||||
except OSError as exc: # pragma: no cover - external dependency failure
|
||||
raise EntityModelError(
|
||||
f"spaCy model '{model_name}' is not installed. Download it with "
|
||||
"`python -m spacy download en_core_web_sm`."
|
||||
) from exc
|
||||
nlp.max_length = max(nlp.max_length, 2_000_000)
|
||||
_MODEL_CACHE[cache_key] = nlp
|
||||
return nlp
|
||||
|
||||
|
||||
def _normalize_label(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
stripped = text.strip().strip("\"'`“”’")
|
||||
if not stripped:
|
||||
return ""
|
||||
stripped = _TITLE_PATTERN.sub("", stripped)
|
||||
stripped = _SUFFIX_PATTERN.sub("", stripped)
|
||||
stripped = _POSSESSIVE_PATTERN.sub("", stripped)
|
||||
stripped = _NON_WORD_PATTERN.sub(" ", stripped)
|
||||
stripped = _MULTI_SPACE_PATTERN.sub(" ", stripped)
|
||||
stripped = stripped.strip()
|
||||
if not stripped or stripped.lower() in _STOP_LABELS:
|
||||
return ""
|
||||
parts = stripped.split()
|
||||
if not parts:
|
||||
return ""
|
||||
if len(parts) == 1 and len(parts[0]) <= 1:
|
||||
return ""
|
||||
# Normalise casing: preserve uppercase abbreviations, otherwise title case.
|
||||
normalized_parts = []
|
||||
for index, part in enumerate(parts):
|
||||
if part.isupper():
|
||||
normalized_parts.append(part)
|
||||
elif part[:1].isupper():
|
||||
normalized_parts.append(part[:1].upper() + part[1:])
|
||||
elif index == 0:
|
||||
normalized_parts.append(part[:1].upper() + part[1:])
|
||||
else:
|
||||
normalized_parts.append(part)
|
||||
normalized = " ".join(normalized_parts).strip()
|
||||
if normalized.lower() in _STOP_LABELS:
|
||||
return ""
|
||||
return normalized
|
||||
|
||||
|
||||
def _token_key(value: str) -> str:
|
||||
return _MULTI_SPACE_PATTERN.sub(" ", value.lower().strip()).strip()
|
||||
|
||||
|
||||
def _iter_named_entities(doc: Any) -> Iterable[Any]: # type: ignore[override]
|
||||
for ent in getattr(doc, "ents", ()):
|
||||
if ent.label_ == "":
|
||||
continue
|
||||
yield ent
|
||||
|
||||
|
||||
def _extract_propn_tokens(doc: Any) -> Iterable[Any]: # type: ignore[override]
|
||||
seen: set[Tuple[int, int]] = set()
|
||||
for ent in getattr(doc, "ents", ()): # guard multi-token spans
|
||||
seen.add((ent.start, ent.end))
|
||||
for token in doc:
|
||||
if token.pos_ != "PROPN":
|
||||
continue
|
||||
span_key = (token.i, token.i + 1)
|
||||
if span_key in seen:
|
||||
continue
|
||||
if token.is_stop:
|
||||
continue
|
||||
text = token.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
if token.ent_type_:
|
||||
continue
|
||||
yield doc[token.i : token.i + 1]
|
||||
|
||||
|
||||
def _empty_result(cache_key: str, error: Optional[str] = None) -> EntityExtractionResult:
|
||||
payload = {
|
||||
"people": [],
|
||||
"entities": [],
|
||||
"index": {"tokens": []},
|
||||
"stats": {
|
||||
"tokens": 0,
|
||||
"chapters": 0,
|
||||
"processed": False,
|
||||
},
|
||||
"model": None,
|
||||
}
|
||||
errors = [error] if error else []
|
||||
return EntityExtractionResult(summary=payload, cache_key=cache_key, elapsed=0.0, errors=errors)
|
||||
|
||||
|
||||
def extract_entities(
|
||||
chapters: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
language: str = "en",
|
||||
) -> EntityExtractionResult:
|
||||
start = time.perf_counter()
|
||||
normalized_language = language or "en"
|
||||
combined_hasher = hashlib.sha1()
|
||||
chapter_texts: List[Tuple[int, str]] = []
|
||||
for idx, chapter in enumerate(chapters):
|
||||
text = chapter.get("text") if isinstance(chapter, Mapping) else None
|
||||
text_value = str(text or "")
|
||||
original_index = idx
|
||||
if isinstance(chapter, Mapping):
|
||||
try:
|
||||
original_index = int(chapter.get("index", idx))
|
||||
except (TypeError, ValueError):
|
||||
original_index = idx
|
||||
chapter_texts.append((original_index, text_value))
|
||||
if text_value:
|
||||
combined_hasher.update(text_value.encode("utf-8", "ignore"))
|
||||
combined_hasher.update(str(original_index).encode("utf-8", "ignore"))
|
||||
cache_key = combined_hasher.hexdigest()
|
||||
|
||||
if not chapter_texts:
|
||||
return _empty_result(cache_key)
|
||||
|
||||
try:
|
||||
nlp = _load_model(normalized_language)
|
||||
except EntityModelError as exc:
|
||||
return _empty_result(cache_key, str(exc))
|
||||
|
||||
records: Dict[Tuple[str, str], EntityRecord] = {}
|
||||
tokens_for_index: Dict[str, Dict[str, Any]] = {}
|
||||
processed_tokens = 0
|
||||
|
||||
for chapter_index, text in chapter_texts:
|
||||
trimmed = text.strip()
|
||||
if not trimmed:
|
||||
continue
|
||||
if len(trimmed) + 1024 > nlp.max_length:
|
||||
nlp.max_length = len(trimmed) + 1024
|
||||
doc = nlp(trimmed)
|
||||
|
||||
def _register_span(span: Any, category_hint: Optional[str] = None) -> None:
|
||||
nonlocal processed_tokens
|
||||
if category_hint is None and span.label_ in _EXCLUDED_NER_LABELS:
|
||||
return
|
||||
cleaned = _normalize_label(span.text)
|
||||
if not cleaned:
|
||||
return
|
||||
key = _token_key(cleaned)
|
||||
if not key:
|
||||
return
|
||||
category = category_hint or ("people" if span.label_ == "PERSON" else "entities")
|
||||
record_key = (category, key)
|
||||
record = records.get(record_key)
|
||||
if record is None:
|
||||
record = EntityRecord(
|
||||
key=record_key,
|
||||
label=cleaned,
|
||||
kind=span.label_ or ("PROPN" if category == "entities" else "PERSON"),
|
||||
category=category,
|
||||
)
|
||||
records[record_key] = record
|
||||
sentence = span.sent.text if hasattr(span, "sent") and span.sent is not None else None
|
||||
record.register(
|
||||
chapter_index=chapter_index,
|
||||
position=span.start,
|
||||
text=span.text,
|
||||
sentence=sentence,
|
||||
)
|
||||
processed_tokens += 1
|
||||
index_entry = tokens_for_index.get(key)
|
||||
if index_entry is None:
|
||||
index_entry = {
|
||||
"token": record.label,
|
||||
"normalized": key,
|
||||
"category": category,
|
||||
"count": 0,
|
||||
"samples": [],
|
||||
}
|
||||
tokens_for_index[key] = index_entry
|
||||
index_entry["count"] += 1
|
||||
if sentence and len(index_entry["samples"]) < 3:
|
||||
if sentence not in index_entry["samples"]:
|
||||
index_entry["samples"].append(sentence)
|
||||
|
||||
for ent in _iter_named_entities(doc):
|
||||
_register_span(ent)
|
||||
|
||||
for span in _extract_propn_tokens(doc):
|
||||
_register_span(span, category_hint="entities")
|
||||
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
people_records = [record for record in records.values() if record.category == "people"]
|
||||
people_keys = {record.key[1] for record in people_records}
|
||||
entity_records = [
|
||||
record
|
||||
for record in records.values()
|
||||
if record.category == "entities"
|
||||
and record.key[1] not in people_keys
|
||||
and record.kind != "PERSON"
|
||||
]
|
||||
|
||||
people_records.sort(key=lambda rec: (-rec.count, rec.label))
|
||||
entity_records.sort(key=lambda rec: (-rec.count, rec.label))
|
||||
|
||||
people_payload = [record.as_dict(index + 1) for index, record in enumerate(people_records)]
|
||||
entity_payload = [record.as_dict(index + 1) for index, record in enumerate(entity_records)]
|
||||
|
||||
index_payload = sorted(tokens_for_index.values(), key=lambda item: (-item["count"], item["token"]))
|
||||
|
||||
summary = {
|
||||
"people": people_payload,
|
||||
"entities": entity_payload,
|
||||
"index": {"tokens": index_payload},
|
||||
"stats": {
|
||||
"tokens": processed_tokens,
|
||||
"chapters": len(chapter_texts),
|
||||
"processed": True,
|
||||
"people": len(people_payload),
|
||||
"entities": len(entity_payload),
|
||||
},
|
||||
"model": {
|
||||
"name": getattr(nlp, "meta", {}).get("name", "unknown"),
|
||||
"version": getattr(nlp, "meta", {}).get("version", "unknown"),
|
||||
"lang": getattr(nlp, "meta", {}).get("lang", normalized_language),
|
||||
},
|
||||
}
|
||||
|
||||
return EntityExtractionResult(summary=summary, cache_key=cache_key, elapsed=elapsed, errors=[])
|
||||
|
||||
|
||||
def search_tokens(index: Mapping[str, Any], query: str, *, limit: int = 15) -> List[Dict[str, Any]]:
|
||||
tokens = index.get("tokens") if isinstance(index, Mapping) else None
|
||||
if not isinstance(tokens, list) or not query:
|
||||
return []
|
||||
normalized = query.strip().lower()
|
||||
if not normalized:
|
||||
return tokens[:limit]
|
||||
results: List[Dict[str, Any]] = []
|
||||
for entry in tokens:
|
||||
token_label = str(entry.get("token", ""))
|
||||
normalized_label = token_label.lower()
|
||||
if normalized in normalized_label or normalized in str(entry.get("normalized", "")):
|
||||
results.append(entry)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
def merge_override(summary: Mapping[str, Any], overrides: Mapping[str, Mapping[str, Any]]) -> Dict[str, Any]:
|
||||
if not isinstance(summary, Mapping):
|
||||
return {"people": [], "entities": []}
|
||||
merged_summary: Dict[str, Any] = dict(summary)
|
||||
for key in ("people", "entities"):
|
||||
items = summary.get(key)
|
||||
if not isinstance(items, list):
|
||||
continue
|
||||
merged_items: List[Dict[str, Any]] = []
|
||||
for entry in items:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
normalized = _token_key(str(entry.get("normalized") or entry.get("label") or ""))
|
||||
merged = dict(entry)
|
||||
if normalized and normalized in overrides:
|
||||
merged_override = dict(overrides[normalized])
|
||||
merged["override"] = merged_override
|
||||
merged_items.append(merged)
|
||||
merged_summary[key] = merged_items
|
||||
return merged_summary
|
||||
|
||||
|
||||
def normalize_token(token: str) -> str:
|
||||
return _token_key(_normalize_label(token))
|
||||
|
||||
|
||||
def normalize_manual_override_token(token: str) -> str:
|
||||
if not token:
|
||||
return ""
|
||||
stripped = token.strip().strip("\"'`“”’")
|
||||
if not stripped:
|
||||
return ""
|
||||
return _MULTI_SPACE_PATTERN.sub(" ", stripped.lower()).strip()
|
||||
@@ -0,0 +1,3 @@
|
||||
from .exporter import EPUB3PackageBuilder, build_epub3_package
|
||||
|
||||
__all__ = ["EPUB3PackageBuilder", "build_epub3_package"]
|
||||
@@ -0,0 +1,910 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
|
||||
import zipfile
|
||||
|
||||
from abogen.text_extractor import ExtractedChapter, ExtractionResult
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChunkOverlay:
|
||||
id: str
|
||||
text: str
|
||||
original_text: Optional[str]
|
||||
start: Optional[float]
|
||||
end: Optional[float]
|
||||
speaker_id: str
|
||||
voice: Optional[str]
|
||||
level: Optional[str] = None
|
||||
group_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChapterDocument:
|
||||
index: int # zero-based
|
||||
title: str
|
||||
xhtml_name: str
|
||||
smil_name: str
|
||||
chunks: List[ChunkOverlay]
|
||||
start: Optional[float]
|
||||
end: Optional[float]
|
||||
|
||||
|
||||
class EPUB3PackageBuilder:
|
||||
"""Constructs an EPUB 3 package with media overlays."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
output_path: Path,
|
||||
book_id: str,
|
||||
extraction: ExtractionResult,
|
||||
metadata_tags: Dict[str, Any],
|
||||
chapter_markers: Sequence[Dict[str, Any]],
|
||||
chunk_markers: Sequence[Dict[str, Any]],
|
||||
chunks: Iterable[Dict[str, Any]],
|
||||
audio_path: Path,
|
||||
speaker_mode: str = "single",
|
||||
cover_image_path: Optional[Path] = None,
|
||||
cover_image_mime: Optional[str] = None,
|
||||
) -> None:
|
||||
self.output_path = output_path
|
||||
self.book_id = book_id or str(uuid.uuid4())
|
||||
self.extraction = extraction
|
||||
self.metadata_tags = _normalize_metadata(metadata_tags)
|
||||
self.chapter_markers = list(chapter_markers or [])
|
||||
self.chunk_markers = list(chunk_markers or [])
|
||||
self.chunks = list(chunks or [])
|
||||
self.audio_path = audio_path
|
||||
self.speaker_mode = speaker_mode or "single"
|
||||
self.cover_image_path = cover_image_path if cover_image_path and cover_image_path.exists() else None
|
||||
self.cover_image_mime = cover_image_mime
|
||||
|
||||
self._combined_metadata = _combine_metadata(extraction.metadata, self.metadata_tags)
|
||||
self._title = self._combined_metadata.get("title") or self._fallback_title()
|
||||
self._authors = _split_authors(self._combined_metadata)
|
||||
self._language = self._determine_language()
|
||||
self._publisher = self._combined_metadata.get("publisher") or ""
|
||||
self._description = self._combined_metadata.get("comment")
|
||||
self._duration = _calculate_total_duration(self.chunk_markers, self.chapter_markers)
|
||||
self._modified = _utc_now_iso()
|
||||
|
||||
def build(self) -> Path:
|
||||
if not self.audio_path or not self.audio_path.exists():
|
||||
raise FileNotFoundError(f"Audio asset missing: {self.audio_path}")
|
||||
|
||||
chapter_documents = self._build_chapter_documents()
|
||||
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
oebps = root / "OEBPS"
|
||||
text_dir = oebps / "text"
|
||||
smil_dir = oebps / "smil"
|
||||
audio_dir = oebps / "audio"
|
||||
image_dir = oebps / "images"
|
||||
stylesheet_dir = oebps / "styles"
|
||||
|
||||
for directory in (oebps, text_dir, smil_dir, audio_dir, stylesheet_dir):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
if self.cover_image_path:
|
||||
image_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_write_mimetype(root)
|
||||
_write_container_xml(root)
|
||||
|
||||
audio_filename = self.audio_path.name
|
||||
embedded_audio = audio_dir / audio_filename
|
||||
shutil.copy2(self.audio_path, embedded_audio)
|
||||
|
||||
if self.cover_image_path:
|
||||
shutil.copy2(self.cover_image_path, image_dir / self.cover_image_path.name)
|
||||
|
||||
stylesheet_path = stylesheet_dir / "style.css"
|
||||
stylesheet_path.write_text(_DEFAULT_STYLESHEET, encoding="utf-8")
|
||||
|
||||
for chapter in chapter_documents:
|
||||
chapter_path = text_dir / chapter.xhtml_name
|
||||
chapter_path.write_text(
|
||||
self._render_chapter_xhtml(chapter),
|
||||
encoding="utf-8",
|
||||
)
|
||||
smil_path = smil_dir / chapter.smil_name
|
||||
smil_path.write_text(
|
||||
self._render_chapter_smil(chapter, f"audio/{audio_filename}"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
nav_path = oebps / "nav.xhtml"
|
||||
nav_path.write_text(self._render_nav(chapter_documents), encoding="utf-8")
|
||||
|
||||
opf_path = oebps / "content.opf"
|
||||
opf_path.write_text(
|
||||
self._render_opf(
|
||||
chapter_documents,
|
||||
audio_filename,
|
||||
has_cover=self.cover_image_path is not None,
|
||||
stylesheet_path=stylesheet_path.relative_to(oebps),
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self.output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(self.output_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
# Ensure mimetype is the first entry and stored without compression
|
||||
mimetype_path = root / "mimetype"
|
||||
info = zipfile.ZipInfo("mimetype")
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
archive.writestr(info, mimetype_path.read_bytes())
|
||||
|
||||
for file_path in sorted(root.rglob("*")):
|
||||
if file_path == mimetype_path or file_path.is_dir():
|
||||
continue
|
||||
archive.write(file_path, file_path.relative_to(root))
|
||||
|
||||
return self.output_path
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _build_chapter_documents(self) -> List[ChapterDocument]:
|
||||
chunk_lookup = _build_chunk_lookup(self.chunks)
|
||||
markers_by_chapter = _group_markers_by_chapter(self.chunk_markers)
|
||||
chapter_meta = {int(entry.get("index", idx + 1)) - 1: dict(entry) for idx, entry in enumerate(self.chapter_markers)}
|
||||
|
||||
documents: List[ChapterDocument] = []
|
||||
for chapter_index, chapter in enumerate(self.extraction.chapters):
|
||||
markers = markers_by_chapter.get(chapter_index, [])
|
||||
if not markers and chunk_lookup.by_chapter.get(chapter_index):
|
||||
markers = [
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"chapter_index": chapter_index,
|
||||
"chunk_index": item.get("chunk_index"),
|
||||
"start": None,
|
||||
"end": None,
|
||||
"speaker_id": item.get("speaker_id", "narrator"),
|
||||
"voice": item.get("voice"),
|
||||
}
|
||||
for item in chunk_lookup.by_chapter.get(chapter_index, [])
|
||||
]
|
||||
|
||||
if not markers:
|
||||
markers = [
|
||||
{
|
||||
"id": f"chap{chapter_index:04d}_auto0000",
|
||||
"chapter_index": chapter_index,
|
||||
"chunk_index": 0,
|
||||
"start": chapter_meta.get(chapter_index, {}).get("start"),
|
||||
"end": chapter_meta.get(chapter_index, {}).get("end"),
|
||||
"speaker_id": "narrator",
|
||||
"voice": None,
|
||||
}
|
||||
]
|
||||
|
||||
overlays = self._build_overlays_for_chapter(
|
||||
chapter_index,
|
||||
markers,
|
||||
chunk_lookup,
|
||||
)
|
||||
|
||||
xhtml_name = f"chapter_{chapter_index + 1:04d}.xhtml"
|
||||
smil_name = f"chapter_{chapter_index + 1:04d}.smil"
|
||||
|
||||
chapter_start = chapter_meta.get(chapter_index, {}).get("start")
|
||||
chapter_end = chapter_meta.get(chapter_index, {}).get("end")
|
||||
|
||||
documents.append(
|
||||
ChapterDocument(
|
||||
index=chapter_index,
|
||||
title=chapter.title or f"Chapter {chapter_index + 1}",
|
||||
xhtml_name=xhtml_name,
|
||||
smil_name=smil_name,
|
||||
chunks=overlays,
|
||||
start=chapter_start,
|
||||
end=chapter_end,
|
||||
)
|
||||
)
|
||||
|
||||
return documents
|
||||
|
||||
def _build_overlays_for_chapter(
|
||||
self,
|
||||
chapter_index: int,
|
||||
markers: Sequence[Dict[str, Any]],
|
||||
chunk_lookup: "ChunkLookup",
|
||||
) -> List[ChunkOverlay]:
|
||||
overlays: List[ChunkOverlay] = []
|
||||
used_ids: set[str] = set()
|
||||
|
||||
chapter_chunks = list(chunk_lookup.by_chapter.get(chapter_index, []))
|
||||
chapter_chunks.sort(key=lambda entry: _safe_int(entry.get("chunk_index")))
|
||||
|
||||
for position, marker in enumerate(markers):
|
||||
chunk_id = marker.get("id")
|
||||
chunk_entry = None
|
||||
if chunk_id and chunk_id in chunk_lookup.by_id:
|
||||
chunk_entry = chunk_lookup.by_id[chunk_id]
|
||||
else:
|
||||
candidate_index = _safe_int(marker.get("chunk_index"))
|
||||
chunk_entry = _find_chunk_by_index(chapter_chunks, candidate_index)
|
||||
if chunk_entry is None and chapter_chunks and position < len(chapter_chunks):
|
||||
chunk_entry = chapter_chunks[position]
|
||||
|
||||
level = None
|
||||
if chunk_entry is None:
|
||||
text = self.extraction.chapters[chapter_index].text
|
||||
speaker_id = str(marker.get("speaker_id") or "narrator")
|
||||
voice = marker.get("voice")
|
||||
else:
|
||||
display_text = chunk_entry.get("display_text")
|
||||
text = str(chunk_entry.get("text") or "")
|
||||
speaker_id = str(chunk_entry.get("speaker_id") or marker.get("speaker_id") or "narrator")
|
||||
voice = chunk_entry.get("voice") or chunk_entry.get("resolved_voice") or marker.get("voice")
|
||||
level = chunk_entry.get("level") or None
|
||||
if chunk_entry is None:
|
||||
level = None
|
||||
|
||||
normalized_id = _normalize_chunk_id(chunk_id) if chunk_id else None
|
||||
if not normalized_id:
|
||||
normalized_id = f"chap{chapter_index:04d}_chunk{position:04d}"
|
||||
while normalized_id in used_ids:
|
||||
normalized_id = f"{normalized_id}_dup"
|
||||
used_ids.add(normalized_id)
|
||||
|
||||
raw_group_key = chunk_entry.get("id") if chunk_entry else chunk_id
|
||||
group_id = _derive_group_id(raw_group_key, level)
|
||||
normalized_group_id = _normalize_chunk_id(group_id) if group_id else None
|
||||
|
||||
original_text = None
|
||||
if chunk_entry is not None:
|
||||
original_text = chunk_entry.get("original_text") or chunk_entry.get("display_text")
|
||||
|
||||
overlays.append(
|
||||
ChunkOverlay(
|
||||
id=normalized_id,
|
||||
text=text or self.extraction.chapters[chapter_index].text,
|
||||
original_text=str(original_text) if original_text is not None else None,
|
||||
start=_safe_float(marker.get("start")),
|
||||
end=_safe_float(marker.get("end")),
|
||||
speaker_id=speaker_id,
|
||||
voice=str(voice) if voice else None,
|
||||
level=str(level) if level else None,
|
||||
group_id=normalized_group_id,
|
||||
)
|
||||
)
|
||||
|
||||
chapter_text = ""
|
||||
if 0 <= chapter_index < len(self.extraction.chapters):
|
||||
chapter_entry = self.extraction.chapters[chapter_index]
|
||||
chapter_text = getattr(chapter_entry, "text", "") or ""
|
||||
|
||||
_restore_original_chunk_text(chapter_text, overlays)
|
||||
|
||||
return overlays
|
||||
|
||||
def _render_chapter_xhtml(self, chapter: ChapterDocument) -> str:
|
||||
language = html.escape(self._language or "en")
|
||||
title = html.escape(chapter.title)
|
||||
grouped_chunks = _group_chunks_for_render(chapter.chunks)
|
||||
chunk_html = "\n".join(
|
||||
_render_chunk_group_html(group_id, items) for group_id, items in grouped_chunks
|
||||
)
|
||||
if not chunk_html:
|
||||
chunk_html = "<p></p>"
|
||||
original_block = ""
|
||||
if chapter.chunks:
|
||||
original_text = "".join((chunk.original_text if chunk.original_text is not None else (chunk.text or "")) for chunk in chapter.chunks)
|
||||
if original_text:
|
||||
safe_original = html.escape(original_text)
|
||||
original_block = (
|
||||
" <pre class=\"chapter-original\" hidden=\"hidden\" aria-hidden=\"true\">\n"
|
||||
f"{safe_original}\n"
|
||||
" </pre>"
|
||||
)
|
||||
|
||||
return (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
|
||||
"<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\" lang=\"{lang}\">\n"
|
||||
" <head>\n"
|
||||
" <title>{title}</title>\n"
|
||||
" <meta charset=\"utf-8\"/>\n"
|
||||
" <link rel=\"stylesheet\" type=\"text/css\" href=\"styles/style.css\"/>\n"
|
||||
" </head>\n"
|
||||
" <body>\n"
|
||||
" <section epub:type=\"chapter\" id=\"chapter-{index:04d}\">\n"
|
||||
" <h1>{title}</h1>\n"
|
||||
" {chunks}\n"
|
||||
"{original_block}"
|
||||
" </section>\n"
|
||||
" </body>\n"
|
||||
"</html>\n"
|
||||
).format(
|
||||
lang=language,
|
||||
title=title,
|
||||
index=chapter.index + 1,
|
||||
chunks=chunk_html,
|
||||
original_block=("" if not original_block else f"{original_block}\n"),
|
||||
)
|
||||
|
||||
def _render_chapter_smil(self, chapter: ChapterDocument, audio_href: str) -> str:
|
||||
par_lines = []
|
||||
for chunk in chapter.chunks:
|
||||
par_lines.append(
|
||||
" <par id=\"par-{chunk_id}\">\n"
|
||||
" <text src=\"text/{xhtml}#{chunk_id}\"/>\n"
|
||||
" <audio src=\"{audio}\" clipBegin=\"{start}\" clipEnd=\"{end}\"/>\n"
|
||||
" </par>".format(
|
||||
chunk_id=html.escape(chunk.id),
|
||||
xhtml=html.escape(chapter.xhtml_name),
|
||||
audio=html.escape(audio_href),
|
||||
start=_format_smil_time(chunk.start),
|
||||
end=_format_smil_time(chunk.end),
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
|
||||
"<smil xmlns=\"http://www.w3.org/2001/SMIL20/Language\" xmlns:epub=\"http://www.idpf.org/2007/ops\">\n"
|
||||
" <head>\n"
|
||||
" <meta name=\"dc:title\" content=\"{title}\"/>\n"
|
||||
" <meta name=\"dtb:uid\" content=\"{book_id}\"/>\n"
|
||||
" <meta name=\"dtb:generator\" content=\"Abogen\"/>\n"
|
||||
" </head>\n"
|
||||
" <body>\n"
|
||||
" <seq id=\"seq-{index:04d}\" epub:textref=\"text/{xhtml}\">\n"
|
||||
"{pars}\n"
|
||||
" </seq>\n"
|
||||
" </body>\n"
|
||||
"</smil>\n"
|
||||
).format(
|
||||
title=html.escape(chapter.title),
|
||||
book_id=html.escape(self.book_id),
|
||||
index=chapter.index + 1,
|
||||
xhtml=html.escape(chapter.xhtml_name),
|
||||
pars="\n".join(par_lines) if par_lines else " <par/>",
|
||||
)
|
||||
|
||||
def _render_nav(self, chapters: Sequence[ChapterDocument]) -> str:
|
||||
items = []
|
||||
for chapter in chapters:
|
||||
href = f"text/{chapter.xhtml_name}"
|
||||
items.append(
|
||||
" <li><a href=\"{href}\">{title}</a></li>".format(
|
||||
href=html.escape(href),
|
||||
title=html.escape(chapter.title),
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
|
||||
"<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n"
|
||||
" <head>\n"
|
||||
" <title>Navigation</title>\n"
|
||||
" <meta charset=\"utf-8\"/>\n"
|
||||
" </head>\n"
|
||||
" <body>\n"
|
||||
" <nav epub:type=\"toc\" id=\"toc\">\n"
|
||||
" <h1>{title}</h1>\n"
|
||||
" <ol>\n"
|
||||
"{items}\n"
|
||||
" </ol>\n"
|
||||
" </nav>\n"
|
||||
" </body>\n"
|
||||
"</html>\n"
|
||||
).format(
|
||||
lang=html.escape(self._language or "en"),
|
||||
title=html.escape(self._title),
|
||||
items="\n".join(items) if items else " <li><a href=\"text/chapter_0001.xhtml\">Chapter 1</a></li>",
|
||||
)
|
||||
|
||||
def _render_opf(
|
||||
self,
|
||||
chapters: Sequence[ChapterDocument],
|
||||
audio_filename: str,
|
||||
*,
|
||||
has_cover: bool,
|
||||
stylesheet_path: Path,
|
||||
) -> str:
|
||||
manifest_items = []
|
||||
spine_refs = []
|
||||
for chapter in chapters:
|
||||
item_id = f"chap{chapter.index + 1:04d}"
|
||||
overlay_id = f"mo-{chapter.index + 1:04d}"
|
||||
manifest_items.append(
|
||||
" <item id=\"{item_id}\" href=\"text/{href}\" media-type=\"application/xhtml+xml\" media-overlay=\"{overlay_id}\"/>".format(
|
||||
item_id=item_id,
|
||||
href=html.escape(chapter.xhtml_name),
|
||||
overlay_id=overlay_id,
|
||||
)
|
||||
)
|
||||
manifest_items.append(
|
||||
" <item id=\"{overlay_id}\" href=\"smil/{smil}\" media-type=\"application/smil+xml\"/>".format(
|
||||
overlay_id=overlay_id,
|
||||
smil=html.escape(chapter.smil_name),
|
||||
)
|
||||
)
|
||||
spine_refs.append(f" <itemref idref=\"{item_id}\"/>")
|
||||
|
||||
audio_item_id = "primary-audio"
|
||||
manifest_items.append(
|
||||
" <item id=\"{item_id}\" href=\"audio/{href}\" media-type=\"{mime}\"/>".format(
|
||||
item_id=audio_item_id,
|
||||
href=html.escape(audio_filename),
|
||||
mime=_detect_audio_mime(audio_filename),
|
||||
)
|
||||
)
|
||||
|
||||
manifest_items.append(
|
||||
" <item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>"
|
||||
)
|
||||
|
||||
manifest_items.append(
|
||||
" <item id=\"style\" href=\"{href}\" media-type=\"text/css\"/>".format(
|
||||
href=html.escape(str(stylesheet_path).replace("\\", "/")),
|
||||
)
|
||||
)
|
||||
|
||||
if has_cover and self.cover_image_path:
|
||||
cover_id = "cover-image"
|
||||
manifest_items.append(
|
||||
" <item id=\"{item_id}\" href=\"images/{href}\" media-type=\"{mime}\" properties=\"cover-image\"/>".format(
|
||||
item_id=cover_id,
|
||||
href=html.escape(self.cover_image_path.name),
|
||||
mime=self.cover_image_mime or _detect_image_mime(self.cover_image_path.suffix),
|
||||
)
|
||||
)
|
||||
|
||||
metadata_elements = _render_metadata_xml(
|
||||
self._title,
|
||||
self._authors,
|
||||
self._language,
|
||||
self.book_id,
|
||||
duration=self._duration,
|
||||
publisher=self._publisher,
|
||||
description=self._description,
|
||||
speaker_mode=self.speaker_mode,
|
||||
modified=self._modified,
|
||||
)
|
||||
|
||||
return (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
|
||||
"<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"book-id\">\n"
|
||||
" <metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\" xmlns:media=\"http://www.idpf.org/epub/vocab/mediaoverlays/#\" xmlns:abogen=\"https://abogen.app/ns#\" xmlns:dcterms=\"http://purl.org/dc/terms/\">\n"
|
||||
"{metadata}\n"
|
||||
" </metadata>\n"
|
||||
" <manifest>\n"
|
||||
"{manifest}\n"
|
||||
" </manifest>\n"
|
||||
" <spine>\n"
|
||||
"{spine}\n"
|
||||
" </spine>\n"
|
||||
"</package>\n"
|
||||
).format(
|
||||
metadata="\n".join(metadata_elements),
|
||||
manifest="\n".join(manifest_items),
|
||||
spine="\n".join(spine_refs) if spine_refs else " <itemref idref=\"chap0001\"/>",
|
||||
)
|
||||
|
||||
def _fallback_title(self) -> str:
|
||||
if self.extraction.chapters:
|
||||
first_title = self.extraction.chapters[0].title
|
||||
if first_title:
|
||||
return first_title
|
||||
return "Generated Audiobook"
|
||||
|
||||
def _determine_language(self) -> str:
|
||||
language = self._combined_metadata.get("language")
|
||||
if language:
|
||||
return language
|
||||
return "en"
|
||||
|
||||
|
||||
def build_epub3_package(
|
||||
*,
|
||||
output_path: Path,
|
||||
book_id: str,
|
||||
extraction: ExtractionResult,
|
||||
metadata_tags: Dict[str, Any],
|
||||
chapter_markers: Sequence[Dict[str, Any]],
|
||||
chunk_markers: Sequence[Dict[str, Any]],
|
||||
chunks: Iterable[Dict[str, Any]],
|
||||
audio_path: Path,
|
||||
speaker_mode: str = "single",
|
||||
cover_image_path: Optional[Path] = None,
|
||||
cover_image_mime: Optional[str] = None,
|
||||
) -> Path:
|
||||
builder = EPUB3PackageBuilder(
|
||||
output_path=output_path,
|
||||
book_id=book_id,
|
||||
extraction=extraction,
|
||||
metadata_tags=metadata_tags,
|
||||
chapter_markers=chapter_markers,
|
||||
chunk_markers=chunk_markers,
|
||||
chunks=chunks,
|
||||
audio_path=audio_path,
|
||||
speaker_mode=speaker_mode,
|
||||
cover_image_path=cover_image_path,
|
||||
cover_image_mime=cover_image_mime,
|
||||
)
|
||||
return builder.build()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChunkLookup:
|
||||
by_id: Dict[str, Dict[str, Any]]
|
||||
by_chapter: Dict[int, List[Dict[str, Any]]]
|
||||
|
||||
|
||||
def _normalize_metadata(metadata: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
||||
normalized: Dict[str, str] = {}
|
||||
for key, value in (metadata or {}).items():
|
||||
if value is None:
|
||||
continue
|
||||
normalized[str(key).lower()] = str(value)
|
||||
return normalized
|
||||
|
||||
|
||||
def _combine_metadata(*sources: Dict[str, Any]) -> Dict[str, str]:
|
||||
combined: Dict[str, str] = {}
|
||||
for source in sources:
|
||||
for key, value in (source or {}).items():
|
||||
if value is None:
|
||||
continue
|
||||
combined[str(key).lower()] = str(value)
|
||||
return combined
|
||||
|
||||
|
||||
def _split_authors(metadata: Dict[str, str]) -> List[str]:
|
||||
candidates = []
|
||||
for key in ("artist", "author", "authors", "album_artist", "creator"):
|
||||
value = metadata.get(key)
|
||||
if value:
|
||||
candidates.extend(part.strip() for part in value.replace(";", ",").split(","))
|
||||
return [author for author in candidates if author]
|
||||
|
||||
|
||||
def _calculate_total_duration(
|
||||
chunk_markers: Sequence[Dict[str, Any]],
|
||||
chapter_markers: Sequence[Dict[str, Any]],
|
||||
) -> Optional[float]:
|
||||
candidates: List[float] = []
|
||||
for marker in chunk_markers or []:
|
||||
end_value = _safe_float(marker.get("end"))
|
||||
if end_value is not None:
|
||||
candidates.append(end_value)
|
||||
for marker in chapter_markers or []:
|
||||
end_value = _safe_float(marker.get("end"))
|
||||
if end_value is not None:
|
||||
candidates.append(end_value)
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates)
|
||||
|
||||
|
||||
def _write_mimetype(root: Path) -> None:
|
||||
(root / "mimetype").write_text("application/epub+zip", encoding="utf-8")
|
||||
|
||||
|
||||
def _write_container_xml(root: Path) -> None:
|
||||
meta_inf = root / "META-INF"
|
||||
meta_inf.mkdir(parents=True, exist_ok=True)
|
||||
container = meta_inf / "container.xml"
|
||||
container.write_text(
|
||||
(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
"<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\n"
|
||||
" <rootfiles>\n"
|
||||
" <rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/>\n"
|
||||
" </rootfiles>\n"
|
||||
"</container>\n"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _build_chunk_lookup(chunks: Iterable[Dict[str, Any]]) -> ChunkLookup:
|
||||
by_id: Dict[str, Dict[str, Any]] = {}
|
||||
by_chapter: Dict[int, List[Dict[str, Any]]] = {}
|
||||
for entry in chunks or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
chunk_id = entry.get("id")
|
||||
if chunk_id:
|
||||
by_id[str(chunk_id)] = dict(entry)
|
||||
chapter_index = _safe_int(entry.get("chapter_index"))
|
||||
by_chapter.setdefault(chapter_index, []).append(dict(entry))
|
||||
return ChunkLookup(by_id=by_id, by_chapter=by_chapter)
|
||||
|
||||
|
||||
def _group_markers_by_chapter(markers: Iterable[Dict[str, Any]]) -> Dict[int, List[Dict[str, Any]]]:
|
||||
grouped: Dict[int, List[Dict[str, Any]]] = {}
|
||||
for entry in markers or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
chapter_index = _safe_int(entry.get("chapter_index"))
|
||||
grouped.setdefault(chapter_index, []).append(dict(entry))
|
||||
for chapter_index, items in grouped.items():
|
||||
items.sort(key=lambda payload: (_safe_int(payload.get("chunk_index")), _safe_float(payload.get("start")) or 0.0))
|
||||
return grouped
|
||||
|
||||
|
||||
def _find_chunk_by_index(
|
||||
chapter_chunks: Sequence[Dict[str, Any]],
|
||||
chunk_index: Optional[int],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if chunk_index is None:
|
||||
return None
|
||||
for entry in chapter_chunks:
|
||||
if _safe_int(entry.get("chunk_index")) == chunk_index:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_chunk_id(chunk_id: Optional[Any]) -> Optional[str]:
|
||||
if chunk_id is None:
|
||||
return None
|
||||
text = str(chunk_id).strip()
|
||||
if not text:
|
||||
return None
|
||||
safe = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in text)
|
||||
return safe[:120]
|
||||
|
||||
|
||||
def _derive_group_id(chunk_id: Optional[Any], level: Optional[Any]) -> Optional[str]:
|
||||
if chunk_id is None:
|
||||
return None
|
||||
text = str(chunk_id).strip()
|
||||
if not text:
|
||||
return None
|
||||
if str(level or "").lower() == "sentence":
|
||||
match = re.match(r"(.+?)_s\d+(?:_.*)?$", text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return text
|
||||
|
||||
|
||||
def _group_chunks_for_render(chunks: Sequence[ChunkOverlay]) -> List[Tuple[Optional[str], List[ChunkOverlay]]]:
|
||||
groups: List[Tuple[Optional[str], List[ChunkOverlay]]] = []
|
||||
current_key: Optional[str] = None
|
||||
current_items: List[ChunkOverlay] = []
|
||||
|
||||
for chunk in chunks:
|
||||
key = chunk.group_id or chunk.id
|
||||
if current_items and key != current_key:
|
||||
groups.append((current_key, current_items))
|
||||
current_items = []
|
||||
if not current_items:
|
||||
current_key = key
|
||||
current_items.append(chunk)
|
||||
|
||||
if current_items:
|
||||
groups.append((current_key, current_items))
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def _render_chunk_inline(chunk: ChunkOverlay) -> str:
|
||||
escaped_id = html.escape(chunk.id)
|
||||
speaker_attr = f" data-speaker=\"{html.escape(chunk.speaker_id)}\"" if chunk.speaker_id else ""
|
||||
voice_attr = f" data-voice=\"{html.escape(chunk.voice)}\"" if chunk.voice else ""
|
||||
level_attr = f" data-level=\"{html.escape(chunk.level)}\"" if chunk.level else ""
|
||||
raw_text = chunk.text or ""
|
||||
escaped_text = html.escape(raw_text)
|
||||
if not escaped_text:
|
||||
escaped_text = " "
|
||||
return (
|
||||
f"<span class=\"chunk\" id=\"{escaped_id}\"{speaker_attr}{voice_attr}{level_attr}>"
|
||||
f"{escaped_text}"
|
||||
"</span>"
|
||||
)
|
||||
|
||||
|
||||
def _render_chunk_group_html(group_id: Optional[str], chunks: Sequence[ChunkOverlay]) -> str:
|
||||
if not chunks:
|
||||
return ""
|
||||
group_attr = f" data-group=\"{html.escape(group_id)}\"" if group_id else ""
|
||||
inline_html = "".join(_render_chunk_inline(chunk) for chunk in chunks)
|
||||
if not inline_html:
|
||||
inline_html = " "
|
||||
return f" <p class=\"chunk-group\"{group_attr}>{inline_html}</p>"
|
||||
|
||||
|
||||
def _format_smil_time(value: Optional[float]) -> str:
|
||||
if value is None or value < 0:
|
||||
value = 0.0
|
||||
total_ms = int(round(value * 1000))
|
||||
hours, remainder = divmod(total_ms, 3600_000)
|
||||
minutes, remainder = divmod(remainder, 60_000)
|
||||
seconds, milliseconds = divmod(remainder, 1000)
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}"
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _restore_original_chunk_text(chapter_text: str, overlays: List[ChunkOverlay]) -> None:
|
||||
if not chapter_text or not overlays:
|
||||
return
|
||||
|
||||
cursor = 0
|
||||
for chunk in overlays:
|
||||
if chunk.original_text is not None:
|
||||
prepared = _prepare_display_text(chunk.original_text)
|
||||
chunk.text = prepared
|
||||
continue
|
||||
candidate = chunk.text or ""
|
||||
if not candidate:
|
||||
continue
|
||||
match = _search_original_span(chapter_text, candidate, cursor)
|
||||
if match is None and cursor:
|
||||
match = _search_original_span(chapter_text, candidate, 0)
|
||||
if match is None:
|
||||
if chunk.original_text is None:
|
||||
chunk.original_text = chunk.text
|
||||
chunk.text = _prepare_display_text(chunk.text or "")
|
||||
continue
|
||||
start, end = match
|
||||
segment = chapter_text[start:end]
|
||||
chunk.original_text = segment
|
||||
chunk.text = _prepare_display_text(segment)
|
||||
cursor = end
|
||||
|
||||
|
||||
def _prepare_display_text(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
cleaned = re.sub(r"(?:[ \t]*\r?\n)+\Z", "", value)
|
||||
return cleaned if cleaned else ""
|
||||
|
||||
|
||||
def _search_original_span(source: str, normalized: str, start: int) -> Optional[Tuple[int, int]]:
|
||||
if not normalized:
|
||||
return None
|
||||
pattern = _build_chunk_pattern(normalized)
|
||||
match = pattern.search(source, start)
|
||||
if not match:
|
||||
return None
|
||||
return match.start(1), match.end(1)
|
||||
|
||||
|
||||
_CHUNK_REGEX_CACHE: Dict[str, Pattern[str]] = {}
|
||||
|
||||
|
||||
def _build_chunk_pattern(text: str) -> Pattern[str]:
|
||||
cached = _CHUNK_REGEX_CACHE.get(text)
|
||||
if cached is not None:
|
||||
return cached
|
||||
escaped = re.escape(text)
|
||||
escaped = escaped.replace(r"\ ", r"\s+")
|
||||
pattern = re.compile(r"(\s*" + escaped + r"\s*)", re.DOTALL)
|
||||
_CHUNK_REGEX_CACHE[text] = pattern
|
||||
return pattern
|
||||
|
||||
|
||||
def _render_metadata_xml(
|
||||
title: str,
|
||||
authors: Sequence[str],
|
||||
language: str,
|
||||
book_id: str,
|
||||
*,
|
||||
duration: Optional[float],
|
||||
publisher: Optional[str],
|
||||
description: Optional[str],
|
||||
speaker_mode: Optional[str],
|
||||
modified: Optional[str],
|
||||
) -> List[str]:
|
||||
elements = [
|
||||
f" <dc:identifier id=\"book-id\">{html.escape(book_id)}</dc:identifier>",
|
||||
f" <dc:title>{html.escape(title)}</dc:title>",
|
||||
f" <dc:language>{html.escape(language or 'en')}</dc:language>",
|
||||
]
|
||||
|
||||
for author in authors or ["Unknown"]:
|
||||
elements.append(f" <dc:creator>{html.escape(author)}</dc:creator>")
|
||||
|
||||
if publisher:
|
||||
elements.append(f" <dc:publisher>{html.escape(publisher)}</dc:publisher>")
|
||||
|
||||
if description:
|
||||
elements.append(f" <dc:description>{html.escape(description)}</dc:description>")
|
||||
|
||||
if duration is not None:
|
||||
elements.append(f" <meta property=\"media:duration\">{_format_iso_duration(duration)}</meta>")
|
||||
|
||||
if speaker_mode:
|
||||
elements.append(
|
||||
" <meta property=\"abogen:speakerMode\">{}</meta>".format(
|
||||
html.escape(str(speaker_mode))
|
||||
)
|
||||
)
|
||||
|
||||
if modified:
|
||||
elements.append(f" <meta property=\"dcterms:modified\">{html.escape(modified)}</meta>")
|
||||
return elements
|
||||
|
||||
|
||||
def _format_iso_duration(value: float) -> str:
|
||||
total_seconds = int(value)
|
||||
remainder = value - total_seconds
|
||||
hours, remainder_seconds = divmod(total_seconds, 3600)
|
||||
minutes, seconds = divmod(remainder_seconds, 60)
|
||||
seconds_with_fraction = seconds + remainder
|
||||
if seconds_with_fraction.is_integer():
|
||||
seconds_text = f"{int(seconds_with_fraction)}"
|
||||
else:
|
||||
seconds_text = f"{seconds_with_fraction:.3f}".rstrip("0").rstrip(".")
|
||||
return f"PT{hours}H{minutes}M{seconds_text}S"
|
||||
|
||||
|
||||
def _detect_audio_mime(audio_filename: str) -> str:
|
||||
suffix = Path(audio_filename).suffix.lower()
|
||||
return {
|
||||
".mp3": "audio/mpeg",
|
||||
".m4a": "audio/mp4",
|
||||
".m4b": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
".wav": "audio/wav",
|
||||
".flac": "audio/flac",
|
||||
".ogg": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
}.get(suffix, "audio/mpeg")
|
||||
|
||||
|
||||
def _detect_image_mime(suffix: str) -> str:
|
||||
normalized = suffix.lower()
|
||||
return {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}.get(normalized, "image/jpeg")
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
_DEFAULT_STYLESHEET = """
|
||||
body {
|
||||
font-family: 'Georgia', serif;
|
||||
line-height: 1.6;
|
||||
margin: 1.5em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.chunk-group {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.chunk-group .chunk {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
"""
|
||||
+6
-4044
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,298 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
try: # pragma: no cover - optional dependency
|
||||
import spacy # type: ignore
|
||||
except Exception: # pragma: no cover - spaCy may be unavailable in minimal environments
|
||||
spacy = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HeteronymVariant:
|
||||
key: str
|
||||
label: str
|
||||
replacement_token: str
|
||||
example_sentence: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HeteronymSpec:
|
||||
token: str
|
||||
variants: Tuple[HeteronymVariant, HeteronymVariant]
|
||||
|
||||
def default_choice_for_token(self, spacy_token: Any) -> str:
|
||||
"""Return the most likely variant key for this token."""
|
||||
pos = (getattr(spacy_token, "pos_", "") or "").upper()
|
||||
tag = (getattr(spacy_token, "tag_", "") or "").upper()
|
||||
|
||||
token_lower = self.token.casefold()
|
||||
if token_lower == "wind":
|
||||
# VERB => /waɪnd/, NOUN => /wɪnd/
|
||||
return "verb" if pos == "VERB" else "noun"
|
||||
if token_lower == "read":
|
||||
# VBD/VBN => /rɛd/
|
||||
return "past" if tag in {"VBD", "VBN"} else "present"
|
||||
if token_lower == "tear":
|
||||
return "verb" if pos == "VERB" else "noun"
|
||||
if token_lower == "close":
|
||||
return "verb" if pos == "VERB" else "adj"
|
||||
if token_lower == "lead":
|
||||
# Default to verb unless POS suggests noun.
|
||||
return "metal" if pos == "NOUN" else "verb"
|
||||
return self.variants[0].key
|
||||
|
||||
|
||||
# Minimal, high-confidence starter set.
|
||||
# NOTE: These replacements intentionally prioritize speech output.
|
||||
# Some replacements may not be appropriate for subtitles/text exports.
|
||||
_HETERONYM_SPECS: Dict[str, HeteronymSpec] = {
|
||||
"wind": HeteronymSpec(
|
||||
token="wind",
|
||||
variants=(
|
||||
HeteronymVariant(
|
||||
key="noun",
|
||||
label="Noun (the wind)",
|
||||
replacement_token="wind",
|
||||
example_sentence="Listen to the wind.",
|
||||
),
|
||||
HeteronymVariant(
|
||||
key="verb",
|
||||
label="Verb (to wind)",
|
||||
replacement_token="wynd",
|
||||
example_sentence="I need to wind the watch.",
|
||||
),
|
||||
),
|
||||
),
|
||||
"read": HeteronymSpec(
|
||||
token="read",
|
||||
variants=(
|
||||
HeteronymVariant(
|
||||
key="present",
|
||||
label="Present (I read every day)",
|
||||
replacement_token="read",
|
||||
example_sentence="I read every day.",
|
||||
),
|
||||
HeteronymVariant(
|
||||
key="past",
|
||||
label="Past (I read it yesterday)",
|
||||
replacement_token="red",
|
||||
example_sentence="I read it yesterday.",
|
||||
),
|
||||
),
|
||||
),
|
||||
"tear": HeteronymSpec(
|
||||
token="tear",
|
||||
variants=(
|
||||
HeteronymVariant(
|
||||
key="noun",
|
||||
label="Noun (a tear /crying/)",
|
||||
replacement_token="tier",
|
||||
example_sentence="A tear rolled down her cheek.",
|
||||
),
|
||||
HeteronymVariant(
|
||||
key="verb",
|
||||
label="Verb (to tear /rip/)",
|
||||
replacement_token="tear",
|
||||
example_sentence="Please don't tear the page.",
|
||||
),
|
||||
),
|
||||
),
|
||||
"close": HeteronymSpec(
|
||||
token="close",
|
||||
variants=(
|
||||
HeteronymVariant(
|
||||
key="adj",
|
||||
label="Adjective (close /near/)",
|
||||
replacement_token="close",
|
||||
example_sentence="We are close to the station.",
|
||||
),
|
||||
HeteronymVariant(
|
||||
key="verb",
|
||||
label="Verb (close /klohz/)",
|
||||
replacement_token="cloze",
|
||||
example_sentence="Please close the door.",
|
||||
),
|
||||
),
|
||||
),
|
||||
"lead": HeteronymSpec(
|
||||
token="lead",
|
||||
variants=(
|
||||
HeteronymVariant(
|
||||
key="verb",
|
||||
label="Verb (to lead)",
|
||||
replacement_token="lead",
|
||||
example_sentence="They will lead the way.",
|
||||
),
|
||||
HeteronymVariant(
|
||||
key="metal",
|
||||
label="Noun (lead /metal/)",
|
||||
replacement_token="led",
|
||||
example_sentence="The pipe was made of lead.",
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _hash_id(*parts: str) -> str:
|
||||
digest = hashlib.sha1("\n".join(parts).encode("utf-8")).hexdigest()
|
||||
return digest[:12]
|
||||
|
||||
|
||||
_WORD_BOUNDARY_CACHE: Dict[str, re.Pattern[str]] = {}
|
||||
|
||||
|
||||
def _word_boundary_pattern(token: str) -> re.Pattern[str]:
|
||||
key = token.casefold()
|
||||
cached = _WORD_BOUNDARY_CACHE.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
escaped = re.escape(token)
|
||||
pattern = re.compile(rf"(?i)(?<!\w){escaped}(?P<possessive>'s|\u2019s|\u2019)?(?!\w)")
|
||||
_WORD_BOUNDARY_CACHE[key] = pattern
|
||||
return pattern
|
||||
|
||||
|
||||
def _preserve_case(replacement: str, original: str) -> str:
|
||||
if not replacement:
|
||||
return replacement
|
||||
if original.isupper():
|
||||
return replacement.upper()
|
||||
if original[:1].isupper():
|
||||
return replacement[:1].upper() + replacement[1:]
|
||||
return replacement
|
||||
|
||||
|
||||
def _build_replacement_sentence(sentence: str, token: str, replacement_token: str) -> str:
|
||||
pattern = _word_boundary_pattern(token)
|
||||
|
||||
def _repl(match: re.Match[str]) -> str:
|
||||
matched = match.group(0) or ""
|
||||
suffix = match.group("possessive") or ""
|
||||
base = matched[: len(matched) - len(suffix)] if suffix else matched
|
||||
return _preserve_case(replacement_token, base) + suffix
|
||||
|
||||
return pattern.sub(_repl, sentence)
|
||||
|
||||
|
||||
def _load_spacy(language: str) -> Any:
|
||||
if spacy is None:
|
||||
return None
|
||||
|
||||
# English only for now.
|
||||
# Use installed small model; keep it simple.
|
||||
lang = (language or "en").lower()
|
||||
if lang.startswith("en"):
|
||||
try:
|
||||
return spacy.load("en_core_web_sm")
|
||||
except Exception:
|
||||
return spacy.blank("en")
|
||||
return spacy.blank("xx")
|
||||
|
||||
|
||||
def extract_heteronym_overrides(
|
||||
chapters: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
language: str,
|
||||
existing: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Extract distinct heteronym-containing sentences from chapters.
|
||||
|
||||
Returns entries shaped for persistence + UI.
|
||||
|
||||
Each entry contains:
|
||||
- id
|
||||
- token
|
||||
- sentence
|
||||
- options: [{key,label,replacement_token,replacement_sentence,example_sentence}]
|
||||
- default_choice
|
||||
- choice
|
||||
"""
|
||||
|
||||
lang = (language or "en").lower()
|
||||
if not lang.startswith("en"):
|
||||
return []
|
||||
|
||||
if spacy is None:
|
||||
return []
|
||||
|
||||
nlp = _load_spacy(lang)
|
||||
if nlp is None:
|
||||
return []
|
||||
|
||||
previous_choices: Dict[str, str] = {}
|
||||
if existing:
|
||||
for item in existing:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
entry_id = str(item.get("id") or "").strip()
|
||||
choice = str(item.get("choice") or "").strip()
|
||||
if entry_id and choice:
|
||||
previous_choices[entry_id] = choice
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
for chapter in chapters:
|
||||
if not isinstance(chapter, Mapping):
|
||||
continue
|
||||
text = str(chapter.get("text") or "")
|
||||
if not text.strip():
|
||||
continue
|
||||
|
||||
doc = nlp(text)
|
||||
for sent in getattr(doc, "sents", []):
|
||||
sentence = str(getattr(sent, "text", "") or "").strip()
|
||||
if not sentence:
|
||||
continue
|
||||
|
||||
for token in sent:
|
||||
token_text = str(getattr(token, "text", "") or "")
|
||||
if not token_text:
|
||||
continue
|
||||
token_key = token_text.casefold()
|
||||
spec = _HETERONYM_SPECS.get(token_key)
|
||||
if not spec:
|
||||
continue
|
||||
|
||||
dedupe_key = (token_key, sentence)
|
||||
if dedupe_key in seen:
|
||||
continue
|
||||
seen.add(dedupe_key)
|
||||
|
||||
entry_id = _hash_id(token_key, sentence)
|
||||
default_choice = spec.default_choice_for_token(token)
|
||||
choice = previous_choices.get(entry_id, default_choice)
|
||||
|
||||
options: List[Dict[str, Any]] = []
|
||||
for variant in spec.variants:
|
||||
replacement_sentence = _build_replacement_sentence(
|
||||
sentence, token=spec.token, replacement_token=variant.replacement_token
|
||||
)
|
||||
options.append(
|
||||
{
|
||||
"key": variant.key,
|
||||
"label": variant.label,
|
||||
"replacement_token": variant.replacement_token,
|
||||
"replacement_sentence": replacement_sentence,
|
||||
"example_sentence": variant.example_sentence,
|
||||
}
|
||||
)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": entry_id,
|
||||
"token": token_text,
|
||||
"token_lower": token_key,
|
||||
"sentence": sentence,
|
||||
"options": options,
|
||||
"default_choice": default_choice,
|
||||
"choice": choice,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration clients for external services."""
|
||||
@@ -0,0 +1,680 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import mimetypes
|
||||
import re
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AudiobookshelfUploadError(RuntimeError):
|
||||
"""Raised when an upload to Audiobookshelf fails."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudiobookshelfConfig:
|
||||
base_url: str
|
||||
api_token: str
|
||||
library_id: Optional[str] = None
|
||||
collection_id: Optional[str] = None
|
||||
folder_id: Optional[str] = None
|
||||
verify_ssl: bool = True
|
||||
send_cover: bool = True
|
||||
send_chapters: bool = True
|
||||
send_subtitles: bool = True
|
||||
timeout: float = 3600.0
|
||||
|
||||
def normalized_base_url(self) -> str:
|
||||
base = (self.base_url or "").strip()
|
||||
if not base:
|
||||
raise ValueError("Audiobookshelf base URL is required")
|
||||
normalized = base.rstrip("/")
|
||||
# The web UI historically suggested including '/api' in the base URL; trim
|
||||
# it here so we can safely append `/api/...` endpoints below.
|
||||
if normalized.lower().endswith("/api"):
|
||||
normalized = normalized[:-4]
|
||||
return normalized or base
|
||||
|
||||
|
||||
class AudiobookshelfClient:
|
||||
"""Client for the legacy Audiobookshelf multipart upload endpoint."""
|
||||
|
||||
def __init__(self, config: AudiobookshelfConfig) -> None:
|
||||
if not config.api_token:
|
||||
raise ValueError("Audiobookshelf API token is required")
|
||||
# library_id is now optional for discovery
|
||||
self._config = config
|
||||
normalized = config.normalized_base_url() or ""
|
||||
self._base_url = normalized.rstrip("/") or normalized
|
||||
self._client_base_url = f"{self._base_url}/"
|
||||
self._folder_cache: Optional[Tuple[str, str, str]] = None
|
||||
|
||||
def get_libraries(self) -> List[Dict[str, Any]]:
|
||||
"""Fetch all libraries from the Audiobookshelf server."""
|
||||
route = self._api_path("libraries")
|
||||
try:
|
||||
with self._open_client() as client:
|
||||
response = client.get(route)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
# data['libraries'] is a list of library objects
|
||||
return data.get("libraries", [])
|
||||
except httpx.HTTPError as exc:
|
||||
raise AudiobookshelfUploadError(f"Failed to fetch libraries: {exc}") from exc
|
||||
|
||||
def _api_path(self, suffix: str = "") -> str:
|
||||
"""Join the API prefix with the provided suffix without losing proxies."""
|
||||
clean_suffix = suffix.lstrip("/")
|
||||
return f"api/{clean_suffix}" if clean_suffix else "api"
|
||||
|
||||
def upload_audiobook(
|
||||
self,
|
||||
audio_path: Path,
|
||||
*,
|
||||
metadata: Dict[str, Any],
|
||||
cover_path: Optional[Path] = None,
|
||||
chapters: Optional[Iterable[Dict[str, Any]]] = None,
|
||||
subtitles: Optional[Iterable[Path]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if not audio_path.exists():
|
||||
raise AudiobookshelfUploadError(f"Audio path does not exist: {audio_path}")
|
||||
|
||||
form_fields = self._build_upload_fields(audio_path, metadata, chapters)
|
||||
file_entries = self._build_file_entries(audio_path, cover_path, subtitles)
|
||||
|
||||
route = self._api_path("upload")
|
||||
try:
|
||||
with self._open_client() as client, ExitStack() as stack:
|
||||
files_payload = self._open_file_handles(file_entries, stack)
|
||||
response = client.post(route, data=form_fields, files=files_payload)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
detail = (exc.response.text or "").strip()
|
||||
if detail:
|
||||
detail = detail[:200]
|
||||
message = f"Audiobookshelf upload failed with status {status}: {detail}"
|
||||
else:
|
||||
message = f"Audiobookshelf upload failed with status {status}"
|
||||
raise AudiobookshelfUploadError(
|
||||
message
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise AudiobookshelfUploadError(f"Audiobookshelf upload failed: {exc}") from exc
|
||||
|
||||
return {}
|
||||
|
||||
def _open_client(self) -> httpx.Client:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._config.api_token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
return httpx.Client(
|
||||
base_url=self._client_base_url,
|
||||
headers=headers,
|
||||
timeout=self._config.timeout,
|
||||
verify=self._config.verify_ssl,
|
||||
)
|
||||
|
||||
def _build_upload_fields(
|
||||
self,
|
||||
audio_path: Path,
|
||||
metadata: Dict[str, Any],
|
||||
chapters: Optional[Iterable[Dict[str, Any]]],
|
||||
) -> Dict[str, str]:
|
||||
folder_id, _, _ = self._ensure_folder()
|
||||
title = self._extract_title(metadata, audio_path)
|
||||
author = self._extract_author(metadata)
|
||||
series = self._extract_series(metadata)
|
||||
series_sequence = self._extract_series_sequence(metadata)
|
||||
|
||||
fields: Dict[str, str] = {
|
||||
"library": self._config.library_id,
|
||||
"folder": folder_id,
|
||||
"title": title,
|
||||
}
|
||||
if author:
|
||||
fields["author"] = author
|
||||
if series:
|
||||
fields["series"] = series
|
||||
if series_sequence:
|
||||
fields["seriesSequence"] = series_sequence
|
||||
if self._config.collection_id:
|
||||
fields["collectionId"] = self._config.collection_id
|
||||
|
||||
metadata_payload: Dict[str, Any] = metadata or {}
|
||||
if chapters and self._config.send_chapters:
|
||||
metadata_payload = dict(metadata_payload)
|
||||
metadata_payload["chapters"] = list(chapters)
|
||||
|
||||
if metadata_payload:
|
||||
# Ensure authors is a list of strings in the JSON payload if it exists
|
||||
if "authors" in metadata_payload:
|
||||
authors_val = metadata_payload["authors"]
|
||||
if isinstance(authors_val, str):
|
||||
metadata_payload["authors"] = [a.strip() for a in authors_val.split(",") if a.strip()]
|
||||
elif isinstance(authors_val, list):
|
||||
metadata_payload["authors"] = [str(a).strip() for a in authors_val if str(a).strip()]
|
||||
|
||||
try:
|
||||
fields["metadata"] = json.dumps(metadata_payload, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
logger.debug("Failed to serialize Audiobookshelf metadata payload")
|
||||
|
||||
return fields
|
||||
|
||||
def _build_file_entries(
|
||||
self,
|
||||
audio_path: Path,
|
||||
cover_path: Optional[Path],
|
||||
subtitles: Optional[Iterable[Path]],
|
||||
) -> List[Tuple[str, Path]]:
|
||||
entries: List[Tuple[str, Path]] = [("file0", audio_path)]
|
||||
index = 1
|
||||
|
||||
if cover_path and self._config.send_cover and cover_path.exists():
|
||||
entries.append((f"file{index}", cover_path))
|
||||
index += 1
|
||||
|
||||
if subtitles and self._config.send_subtitles:
|
||||
for subtitle in subtitles:
|
||||
if subtitle.exists():
|
||||
entries.append((f"file{index}", subtitle))
|
||||
index += 1
|
||||
|
||||
return entries
|
||||
|
||||
def _open_file_handles(
|
||||
self,
|
||||
entries: Sequence[Tuple[str, Path]],
|
||||
stack: ExitStack,
|
||||
) -> List[Tuple[str, Tuple[str, Any, str]]]:
|
||||
files: List[Tuple[str, Tuple[str, Any, str]]] = []
|
||||
for field_name, path in entries:
|
||||
mime_type, _ = mimetypes.guess_type(path.name)
|
||||
mime_type = mime_type or "application/octet-stream"
|
||||
handle = stack.enter_context(path.open("rb"))
|
||||
files.append((field_name, (path.name, handle, mime_type)))
|
||||
return files
|
||||
|
||||
def find_existing_items(
|
||||
self,
|
||||
title: str,
|
||||
*,
|
||||
folder_id: Optional[str] = None,
|
||||
) -> List[Mapping[str, Any]]:
|
||||
normalized_title = self._normalize_title_value(title)
|
||||
if not normalized_title:
|
||||
return []
|
||||
|
||||
folder_hint = folder_id or self._config.folder_id
|
||||
target_folders = set()
|
||||
if folder_hint:
|
||||
folder_token = str(folder_hint).strip().lower()
|
||||
if folder_token:
|
||||
target_folders.add(folder_token)
|
||||
|
||||
requests = self._candidate_search_requests(title, folder_hint)
|
||||
if not requests:
|
||||
return []
|
||||
|
||||
matches: List[Mapping[str, Any]] = []
|
||||
|
||||
try:
|
||||
with self._open_client() as client:
|
||||
for route, params in requests:
|
||||
try:
|
||||
response = client.get(route, params=params)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.debug("Audiobookshelf lookup failed for %s: %s", route, exc)
|
||||
continue
|
||||
|
||||
if response.status_code == 404:
|
||||
continue
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
if status in {401, 403}:
|
||||
raise AudiobookshelfUploadError(
|
||||
"Audiobookshelf authentication failed while checking for existing items."
|
||||
) from exc
|
||||
logger.debug("Audiobookshelf lookup error %s for %s", status, route)
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
candidates = self._extract_candidate_items(payload)
|
||||
for item in candidates:
|
||||
item_title = self._normalize_item_title(item)
|
||||
if not item_title or item_title != normalized_title:
|
||||
continue
|
||||
if target_folders:
|
||||
item_folder = self._normalize_folder_id(item)
|
||||
if item_folder and item_folder not in target_folders:
|
||||
continue
|
||||
matches.append(item)
|
||||
if matches:
|
||||
break
|
||||
except AudiobookshelfUploadError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Unexpected error while checking Audiobookshelf for existing items",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return matches
|
||||
|
||||
def delete_items(self, items: Iterable[Mapping[str, Any] | str]) -> None:
|
||||
to_delete: List[str] = []
|
||||
for entry in items:
|
||||
if isinstance(entry, Mapping):
|
||||
item_id = self._extract_item_id(entry)
|
||||
else:
|
||||
item_id = str(entry).strip()
|
||||
if item_id:
|
||||
to_delete.append(item_id)
|
||||
|
||||
if not to_delete:
|
||||
return
|
||||
|
||||
with self._open_client() as client:
|
||||
for item_id in to_delete:
|
||||
self._delete_single_item(client, item_id)
|
||||
|
||||
def _candidate_search_requests(
|
||||
self,
|
||||
title: str,
|
||||
folder_id: Optional[str],
|
||||
) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
query = (title or "").strip()
|
||||
if not query:
|
||||
return []
|
||||
|
||||
library_id = self._config.library_id
|
||||
folder_token = (folder_id or self._config.folder_id or "").strip()
|
||||
|
||||
requests: List[Tuple[str, Dict[str, Any]]] = []
|
||||
seen_routes: set[str] = set()
|
||||
|
||||
def _append(route: str, params: Dict[str, Any]) -> None:
|
||||
if route in seen_routes:
|
||||
return
|
||||
seen_routes.add(route)
|
||||
requests.append((route, params))
|
||||
|
||||
if folder_token:
|
||||
_append(
|
||||
self._api_path(f"folders/{folder_token}/items"),
|
||||
{"library": library_id, "search": query},
|
||||
)
|
||||
|
||||
_append(self._api_path(f"libraries/{library_id}/items"), {"search": query})
|
||||
_append(self._api_path("items"), {"library": library_id, "search": query})
|
||||
_append(
|
||||
self._api_path("search"),
|
||||
{"query": query, "library": library_id, "media": "audiobook"},
|
||||
)
|
||||
|
||||
return requests
|
||||
|
||||
def _delete_single_item(self, client: httpx.Client, item_id: str) -> None:
|
||||
routes = [
|
||||
self._api_path(f"items/{item_id}"),
|
||||
self._api_path(f"libraries/{self._config.library_id}/items/{item_id}"),
|
||||
]
|
||||
|
||||
for route in routes:
|
||||
try:
|
||||
response = client.delete(route)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.debug("Audiobookshelf delete failed for %s: %s", route, exc)
|
||||
continue
|
||||
|
||||
if response.status_code in (200, 202, 204):
|
||||
return
|
||||
if response.status_code == 404:
|
||||
continue
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise AudiobookshelfUploadError(
|
||||
f"Failed to delete Audiobookshelf item '{item_id}': {exc}"
|
||||
) from exc
|
||||
|
||||
logger.debug("Audiobookshelf item %s could not be confirmed deleted", item_id)
|
||||
|
||||
def resolve_folder(self) -> Tuple[str, str, str]:
|
||||
"""Return the resolved folder (id, name, library name)."""
|
||||
return self._ensure_folder()
|
||||
|
||||
def list_folders(self) -> List[Dict[str, str]]:
|
||||
"""Return all folders for the configured library."""
|
||||
library_name, folders = self._load_library_metadata()
|
||||
results: List[Dict[str, str]] = []
|
||||
for folder in folders:
|
||||
folder_id = str(folder.get("id") or "").strip()
|
||||
if not folder_id:
|
||||
continue
|
||||
name = self._folder_display_name(folder)
|
||||
path = self._select_folder_path(folder)
|
||||
results.append(
|
||||
{
|
||||
"id": folder_id,
|
||||
"name": name,
|
||||
"path": path,
|
||||
"library": library_name,
|
||||
}
|
||||
)
|
||||
results.sort(key=lambda entry: (entry.get("path") or entry.get("name") or entry.get("id") or "").lower())
|
||||
return results
|
||||
|
||||
def _ensure_folder(self) -> Tuple[str, str, str]:
|
||||
if self._folder_cache:
|
||||
return self._folder_cache
|
||||
|
||||
identifier = (self._config.folder_id or "").strip()
|
||||
if not identifier:
|
||||
raise AudiobookshelfUploadError(
|
||||
"Audiobookshelf folder is required; enter the folder name or ID in Settings."
|
||||
)
|
||||
|
||||
identifier_norm = self._normalize_identifier(identifier)
|
||||
library_name, folders = self._load_library_metadata()
|
||||
|
||||
# direct ID match
|
||||
for folder in folders:
|
||||
folder_id = str(folder.get("id") or "").strip()
|
||||
if folder_id and folder_id == identifier:
|
||||
folder_name = self._folder_display_name(folder) or folder_id
|
||||
self._folder_cache = (folder_id, folder_name, library_name)
|
||||
return self._folder_cache
|
||||
|
||||
has_path_component = "/" in identifier_norm
|
||||
|
||||
for folder in folders:
|
||||
folder_id = str(folder.get("id") or "").strip()
|
||||
if not folder_id:
|
||||
continue
|
||||
folder_name = self._folder_display_name(folder)
|
||||
name_norm = self._normalize_identifier(folder_name)
|
||||
if name_norm and name_norm == identifier_norm:
|
||||
self._folder_cache = (folder_id, folder_name or folder_id, library_name)
|
||||
return self._folder_cache
|
||||
|
||||
for candidate in self._folder_path_candidates(folder):
|
||||
candidate_norm = self._normalize_identifier(candidate)
|
||||
if not candidate_norm:
|
||||
continue
|
||||
if candidate_norm == identifier_norm:
|
||||
self._folder_cache = (folder_id, folder_name or folder_id, library_name)
|
||||
return self._folder_cache
|
||||
if has_path_component and candidate_norm.endswith(identifier_norm):
|
||||
self._folder_cache = (folder_id, folder_name or folder_id, library_name)
|
||||
return self._folder_cache
|
||||
if not has_path_component:
|
||||
tail = candidate_norm.split("/")[-1]
|
||||
if tail and tail == identifier_norm:
|
||||
self._folder_cache = (folder_id, folder_name or folder_id, library_name)
|
||||
return self._folder_cache
|
||||
|
||||
raise AudiobookshelfUploadError(
|
||||
f"Folder '{identifier}' was not found in library '{library_name}'. "
|
||||
"Enter the folder name exactly as it appears in Audiobookshelf, a trailing path segment, or paste the folder ID."
|
||||
)
|
||||
|
||||
def _load_library_metadata(self) -> Tuple[str, List[Mapping[str, Any]]]:
|
||||
try:
|
||||
with self._open_client() as client:
|
||||
response = client.get(self._api_path(f"libraries/{self._config.library_id}"))
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
if status == 404:
|
||||
message = f"Audiobookshelf library '{self._config.library_id}' not found."
|
||||
else:
|
||||
detail = (exc.response.text or "").strip()
|
||||
if detail:
|
||||
detail = detail[:200]
|
||||
message = (
|
||||
f"Failed to load Audiobookshelf library '{self._config.library_id}' "
|
||||
f"(status {status}): {detail}"
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"Failed to load Audiobookshelf library '{self._config.library_id}' "
|
||||
f"(status {status})."
|
||||
)
|
||||
raise AudiobookshelfUploadError(message) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise AudiobookshelfUploadError(
|
||||
f"Failed to reach Audiobookshelf library '{self._config.library_id}': {exc}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(payload, Mapping):
|
||||
return self._config.library_id, []
|
||||
|
||||
library_name = str(payload.get("name") or payload.get("label") or self._config.library_id)
|
||||
raw_folders = payload.get("libraryFolders") or payload.get("folders") or []
|
||||
folders = [entry for entry in raw_folders if isinstance(entry, Mapping)]
|
||||
return library_name, folders
|
||||
|
||||
@staticmethod
|
||||
def _folder_path_candidates(folder: Mapping[str, Any]) -> List[str]:
|
||||
candidates: List[str] = []
|
||||
for key in ("fullPath", "fullpath", "path", "folderPath", "virtualPath"):
|
||||
value = folder.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
candidates.append(value)
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _folder_display_name(folder: Mapping[str, Any]) -> str:
|
||||
name = str(folder.get("name") or folder.get("label") or "").strip()
|
||||
if name:
|
||||
return name
|
||||
path = AudiobookshelfClient._select_folder_path(folder)
|
||||
if path:
|
||||
tail = path.strip("/ ")
|
||||
tail = tail.split("/")[-1] if tail else ""
|
||||
if tail:
|
||||
return tail
|
||||
return str(folder.get("id") or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _select_folder_path(folder: Mapping[str, Any]) -> str:
|
||||
for candidate in AudiobookshelfClient._folder_path_candidates(folder):
|
||||
normalized = candidate.replace("\\", "/").strip()
|
||||
if normalized:
|
||||
return normalized
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_identifier(value: str) -> str:
|
||||
token = (value or "").strip()
|
||||
token = token.replace("\\", "/")
|
||||
if len(token) > 1 and token[1] == ":":
|
||||
token = token[2:]
|
||||
token = token.strip("/ ")
|
||||
return token.lower()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_title_value(value: Optional[str]) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
normalized = re.sub(r"\s+", " ", value).strip()
|
||||
return normalized.casefold() if normalized else ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_item_title(item: Mapping[str, Any]) -> str:
|
||||
if not isinstance(item, Mapping):
|
||||
return ""
|
||||
for key in ("title", "name", "label"):
|
||||
candidate = item.get(key)
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
return AudiobookshelfClient._normalize_title_value(candidate)
|
||||
library_item = item.get("libraryItem")
|
||||
if isinstance(library_item, Mapping):
|
||||
return AudiobookshelfClient._normalize_item_title(library_item)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_folder_id(item: Mapping[str, Any]) -> Optional[str]:
|
||||
if not isinstance(item, Mapping):
|
||||
return None
|
||||
for key in ("folderId", "libraryFolderId", "folder_id", "folder"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip().lower()
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value).strip().lower()
|
||||
library_item = item.get("libraryItem")
|
||||
if isinstance(library_item, Mapping):
|
||||
return AudiobookshelfClient._normalize_folder_id(library_item)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_item_id(item: Mapping[str, Any]) -> Optional[str]:
|
||||
if not isinstance(item, Mapping):
|
||||
return None
|
||||
for key in ("id", "libraryItemId", "itemId"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value).strip()
|
||||
library_item = item.get("libraryItem")
|
||||
if isinstance(library_item, Mapping):
|
||||
return AudiobookshelfClient._extract_item_id(library_item)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_candidate_items(payload: Any) -> List[Mapping[str, Any]]:
|
||||
items: List[Mapping[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
visited: set[int] = set()
|
||||
|
||||
def _visit(obj: Any) -> None:
|
||||
if isinstance(obj, Mapping):
|
||||
obj_id = id(obj)
|
||||
if obj_id in visited:
|
||||
return
|
||||
visited.add(obj_id)
|
||||
|
||||
title = AudiobookshelfClient._normalize_item_title(obj)
|
||||
item_id = AudiobookshelfClient._extract_item_id(obj)
|
||||
if title and item_id:
|
||||
key = item_id.strip().lower()
|
||||
if key not in seen_ids:
|
||||
seen_ids.add(key)
|
||||
items.append(obj)
|
||||
|
||||
for value in obj.values():
|
||||
_visit(value)
|
||||
|
||||
elif isinstance(obj, list):
|
||||
for entry in obj:
|
||||
_visit(entry)
|
||||
|
||||
_visit(payload)
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _extract_title(metadata: Mapping[str, Any], audio_path: Path) -> str:
|
||||
title = metadata.get("title") if isinstance(metadata, Mapping) else None
|
||||
candidate = str(title).strip() if isinstance(title, str) else ""
|
||||
if candidate:
|
||||
return candidate
|
||||
return audio_path.stem or audio_path.name
|
||||
|
||||
@staticmethod
|
||||
def _extract_author(metadata: Mapping[str, Any]) -> str:
|
||||
authors = metadata.get("authors") if isinstance(metadata, Mapping) else None
|
||||
if isinstance(authors, str):
|
||||
candidate = authors.strip()
|
||||
return candidate
|
||||
if isinstance(authors, Iterable) and not isinstance(authors, (str, Mapping)):
|
||||
names = [str(entry).strip() for entry in authors if isinstance(entry, str) and entry.strip()]
|
||||
if names:
|
||||
# ABS expects a comma-separated string for multiple authors.
|
||||
return ", ".join(names)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_series(metadata: Mapping[str, Any]) -> str:
|
||||
series_name = metadata.get("seriesName") if isinstance(metadata, Mapping) else None
|
||||
if isinstance(series_name, str) and series_name.strip():
|
||||
return series_name.strip()
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_series_sequence(metadata: Mapping[str, Any]) -> str:
|
||||
if not isinstance(metadata, Mapping):
|
||||
return ""
|
||||
|
||||
preferred_keys = (
|
||||
"seriesSequence",
|
||||
"series_sequence",
|
||||
"seriesIndex",
|
||||
"series_index",
|
||||
"seriesNumber",
|
||||
"series_number",
|
||||
"bookNumber",
|
||||
"book_number",
|
||||
)
|
||||
|
||||
for key in preferred_keys:
|
||||
if key not in metadata:
|
||||
continue
|
||||
normalized = AudiobookshelfClient._normalize_series_sequence(metadata.get(key))
|
||||
if normalized:
|
||||
return normalized
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_series_sequence(raw: Any) -> str:
|
||||
if raw is None:
|
||||
return ""
|
||||
|
||||
if isinstance(raw, (int, float)):
|
||||
if isinstance(raw, float) and (math.isnan(raw) or math.isinf(raw)):
|
||||
return ""
|
||||
text = str(raw)
|
||||
else:
|
||||
text = str(raw).strip()
|
||||
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
candidate = text.replace(",", ".")
|
||||
match = re.search(r"\d+(?:\.\d+)?", candidate)
|
||||
if not match:
|
||||
return ""
|
||||
|
||||
normalized = match.group(0)
|
||||
if "." in normalized:
|
||||
normalized = normalized.rstrip("0").rstrip(".")
|
||||
if not normalized:
|
||||
normalized = "0"
|
||||
return normalized
|
||||
|
||||
try:
|
||||
return str(int(normalized))
|
||||
except ValueError:
|
||||
cleaned = normalized.lstrip("0")
|
||||
return cleaned or "0"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
from urllib import error, parse, request
|
||||
|
||||
|
||||
class LLMClientError(RuntimeError):
|
||||
"""Raised when an LLM request fails."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMConfiguration:
|
||||
base_url: str
|
||||
api_key: str
|
||||
model: str
|
||||
timeout: float = 30.0
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.base_url.strip() and self.model.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMToolCall:
|
||||
name: str
|
||||
arguments: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMCompletion:
|
||||
content: Optional[str]
|
||||
tool_calls: Tuple[LLMToolCall, ...]
|
||||
|
||||
|
||||
_DEFAULT_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _normalized_base_url(base_url: str) -> str:
|
||||
trimmed = (base_url or "").strip()
|
||||
if not trimmed:
|
||||
raise LLMClientError("LLM base URL is required")
|
||||
if not trimmed.endswith("/"):
|
||||
trimmed += "/"
|
||||
return trimmed
|
||||
|
||||
|
||||
def _build_url(base_url: str, path: str) -> str:
|
||||
normalized = _normalized_base_url(base_url)
|
||||
trimmed_path = path.lstrip("/")
|
||||
parsed = parse.urlparse(normalized)
|
||||
if parsed.path.rstrip("/").lower().endswith("/v1") and trimmed_path.startswith("v1/"):
|
||||
trimmed_path = trimmed_path[len("v1/"):]
|
||||
return parse.urljoin(normalized, trimmed_path)
|
||||
|
||||
|
||||
def _build_headers(api_key: str) -> Dict[str, str]:
|
||||
headers = dict(_DEFAULT_HEADERS)
|
||||
token = (api_key or "").strip()
|
||||
if token and token.lower() != "ollama":
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def _perform_request(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
payload: Optional[Mapping[str, Any]] = None,
|
||||
timeout: float = 30.0,
|
||||
) -> Any:
|
||||
data_bytes: Optional[bytes] = None
|
||||
if payload is not None:
|
||||
data_bytes = json.dumps(payload).encode("utf-8")
|
||||
request_headers = dict(headers or {})
|
||||
req = request.Request(url, data=data_bytes, headers=request_headers, method=method.upper())
|
||||
try:
|
||||
with request.urlopen(req, timeout=timeout) as response:
|
||||
body = response.read()
|
||||
except error.HTTPError as exc: # pragma: no cover - defensive network guard
|
||||
message = exc.read().decode("utf-8", "ignore") if exc.fp else exc.reason
|
||||
raise LLMClientError(f"LLM request failed ({exc.code}): {message}") from exc
|
||||
except error.URLError as exc: # pragma: no cover - defensive network guard
|
||||
raise LLMClientError(f"LLM request failed: {exc.reason}") from exc
|
||||
except Exception as exc: # pragma: no cover - defensive network guard
|
||||
raise LLMClientError("LLM request failed") from exc
|
||||
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body.decode("utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise LLMClientError("LLM response was not valid JSON") from exc
|
||||
|
||||
|
||||
def list_models(configuration: LLMConfiguration) -> List[Dict[str, str]]:
|
||||
if not configuration.is_configured() and not configuration.base_url.strip():
|
||||
raise LLMClientError("LLM configuration is incomplete")
|
||||
url = _build_url(configuration.base_url, "v1/models")
|
||||
headers = _build_headers(configuration.api_key)
|
||||
payload = _perform_request("GET", url, headers=headers, timeout=configuration.timeout)
|
||||
if not isinstance(payload, Mapping):
|
||||
raise LLMClientError("Unexpected response when listing models")
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
models: List[Dict[str, str]] = []
|
||||
for entry in data:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
identifier = str(entry.get("id") or "").strip()
|
||||
if not identifier:
|
||||
continue
|
||||
description = str(entry.get("name") or entry.get("description") or identifier)
|
||||
models.append({"id": identifier, "label": description})
|
||||
return models
|
||||
|
||||
|
||||
def generate_completion(
|
||||
configuration: LLMConfiguration,
|
||||
*,
|
||||
system_message: str,
|
||||
user_message: str,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: Optional[int] = None,
|
||||
tools: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
tool_choice: Optional[Mapping[str, Any]] = None,
|
||||
response_format: Optional[Mapping[str, Any]] = None,
|
||||
) -> LLMCompletion:
|
||||
if not configuration.is_configured():
|
||||
raise LLMClientError("LLM configuration is incomplete")
|
||||
|
||||
url = _build_url(configuration.base_url, "v1/chat/completions")
|
||||
headers = _build_headers(configuration.api_key)
|
||||
payload: Dict[str, Any] = {
|
||||
"model": configuration.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_message},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens is not None:
|
||||
payload["max_tokens"] = max_tokens
|
||||
if tools:
|
||||
payload["tools"] = list(tools)
|
||||
if tool_choice:
|
||||
payload["tool_choice"] = dict(tool_choice)
|
||||
if response_format:
|
||||
payload["response_format"] = dict(response_format)
|
||||
|
||||
response = _perform_request("POST", url, headers=headers, payload=payload, timeout=configuration.timeout)
|
||||
if not isinstance(response, Mapping):
|
||||
raise LLMClientError("Unexpected response from LLM")
|
||||
choices = response.get("choices")
|
||||
if not isinstance(choices, list) or not choices:
|
||||
raise LLMClientError("LLM response did not include choices")
|
||||
first = choices[0]
|
||||
if not isinstance(first, Mapping):
|
||||
raise LLMClientError("LLM response choice was invalid")
|
||||
message = first.get("message")
|
||||
content: Optional[str] = None
|
||||
tool_calls: List[LLMToolCall] = []
|
||||
if isinstance(message, Mapping):
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
stripped = content.strip()
|
||||
if stripped:
|
||||
content = stripped
|
||||
else:
|
||||
content = None
|
||||
tool_call_entries = message.get("tool_calls")
|
||||
if isinstance(tool_call_entries, list):
|
||||
for entry in tool_call_entries:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
fn = entry.get("function")
|
||||
if not isinstance(fn, Mapping):
|
||||
continue
|
||||
name = str(fn.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
args = fn.get("arguments", "")
|
||||
if isinstance(args, (dict, list)):
|
||||
arguments = json.dumps(args)
|
||||
else:
|
||||
arguments = str(args)
|
||||
tool_calls.append(LLMToolCall(name=name, arguments=arguments))
|
||||
if content:
|
||||
return LLMCompletion(content=content, tool_calls=tuple(tool_calls))
|
||||
text = first.get("text")
|
||||
if isinstance(text, str):
|
||||
stripped = text.strip()
|
||||
if stripped:
|
||||
content = stripped
|
||||
if content or tool_calls:
|
||||
return LLMCompletion(content=content, tool_calls=tuple(tool_calls))
|
||||
raise LLMClientError("LLM response did not include text content")
|
||||
+27
-165
@@ -1,116 +1,36 @@
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
"""Backwards-compatible entry point that now launches the web UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
from abogen.utils import get_resource_path, load_config, prevent_sleep_end
|
||||
import sys
|
||||
|
||||
from abogen.utils import load_config, prevent_sleep_end
|
||||
from abogen.webui.app import main as _run_web_ui
|
||||
|
||||
# Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6
|
||||
if platform.system() == "Windows":
|
||||
import ctypes
|
||||
from importlib.util import find_spec
|
||||
|
||||
try:
|
||||
if (
|
||||
(spec := find_spec("torch"))
|
||||
and spec.origin
|
||||
and os.path.exists(
|
||||
dll_path := os.path.join(os.path.dirname(spec.origin), "lib", "c10.dll")
|
||||
)
|
||||
):
|
||||
ctypes.CDLL(os.path.normpath(dll_path))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Qt platform plugin detection (fixes #59)
|
||||
try:
|
||||
from PyQt6.QtCore import QLibraryInfo
|
||||
|
||||
# Get the path to the plugins directory
|
||||
plugins = QLibraryInfo.path(QLibraryInfo.LibraryPath.PluginsPath)
|
||||
|
||||
# Normalize path to use the OS-native separators and absolute path
|
||||
platform_dir = os.path.normpath(os.path.join(plugins, "platforms"))
|
||||
|
||||
# Ensure we work with an absolute path for clarity
|
||||
platform_dir = os.path.abspath(platform_dir)
|
||||
|
||||
if os.path.isdir(platform_dir):
|
||||
os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = platform_dir
|
||||
print("QT_QPA_PLATFORM_PLUGIN_PATH set to:", platform_dir)
|
||||
else:
|
||||
print("PyQt6 platform plugins not found at", platform_dir)
|
||||
except ImportError:
|
||||
print("PyQt6 not installed.")
|
||||
|
||||
|
||||
# Pre-load "libxcb-cursor" on Linux (fixes #101)
|
||||
if platform.system() == "Linux":
|
||||
arch = platform.machine().lower()
|
||||
lib_filename = {"x86_64": "libxcb-cursor-amd64.so.0", "amd64": "libxcb-cursor-amd64.so.0", "aarch64": "libxcb-cursor-arm64.so.0", "arm64": "libxcb-cursor-arm64.so.0"}.get(arch)
|
||||
if lib_filename:
|
||||
import ctypes
|
||||
try:
|
||||
# Try to load the system libxcb-cursor.so.0 first
|
||||
ctypes.CDLL('libxcb-cursor.so.0', mode=ctypes.RTLD_GLOBAL)
|
||||
except OSError:
|
||||
# System lib not available, load the bundled version
|
||||
lib_path = get_resource_path('abogen.libs', lib_filename)
|
||||
if lib_path:
|
||||
try:
|
||||
ctypes.CDLL(lib_path, mode=ctypes.RTLD_GLOBAL)
|
||||
except OSError:
|
||||
# If it fails (e.g. wrong glibc version on very old systems),
|
||||
# we simply ignore it and hope the system has the library.
|
||||
pass
|
||||
|
||||
|
||||
# Set application ID for Windows taskbar icon
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
from abogen.constants import PROGRAM_NAME, VERSION
|
||||
import ctypes
|
||||
|
||||
app_id = f"{PROGRAM_NAME}.{VERSION}"
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
|
||||
except Exception as e:
|
||||
print("Warning: failed to set AppUserModelID:", e)
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtGui import QIcon
|
||||
from PyQt6.QtCore import (
|
||||
QLibraryInfo,
|
||||
qInstallMessageHandler,
|
||||
QtMsgType,
|
||||
)
|
||||
|
||||
# Add the directory to Python path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
||||
|
||||
# Set Hugging Face Hub environment variables
|
||||
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry
|
||||
os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds)
|
||||
os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds)
|
||||
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning
|
||||
# Configure Hugging Face Hub behaviour (mirrors legacy GUI defaults).
|
||||
os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
|
||||
os.environ.setdefault("HF_HUB_ETAG_TIMEOUT", "10")
|
||||
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "10")
|
||||
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
|
||||
if load_config().get("disable_kokoro_internet", False):
|
||||
print("INFO: Kokoro's internet access is disabled.")
|
||||
os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
|
||||
from abogen.gui import abogen
|
||||
from abogen.constants import PROGRAM_NAME, VERSION
|
||||
# Prefer faster ROCm tuning defaults when available.
|
||||
os.environ.setdefault("MIOPEN_FIND_MODE", "FAST")
|
||||
os.environ.setdefault("MIOPEN_CONV_PRECISE_ROCM_TUNING", "0")
|
||||
|
||||
# Set environment variables for AMD ROCm
|
||||
os.environ["MIOPEN_FIND_MODE"] = "FAST"
|
||||
os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0"
|
||||
# Enable MPS GPU acceleration on Apple Silicon.
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
|
||||
|
||||
# Reset sleep states
|
||||
atexit.register(prevent_sleep_end)
|
||||
|
||||
|
||||
# Also handle signals (Ctrl+C, kill, etc.)
|
||||
def _cleanup_sleep(signum, frame):
|
||||
def _cleanup_sleep(signum, _frame):
|
||||
prevent_sleep_end()
|
||||
sys.exit(0)
|
||||
|
||||
@@ -118,70 +38,12 @@ def _cleanup_sleep(signum, frame):
|
||||
signal.signal(signal.SIGINT, _cleanup_sleep)
|
||||
signal.signal(signal.SIGTERM, _cleanup_sleep)
|
||||
|
||||
# Ensure sys.stdout and sys.stderr are valid in GUI mode
|
||||
if sys.stdout is None:
|
||||
sys.stdout = open(os.devnull, "w")
|
||||
if sys.stderr is None:
|
||||
sys.stderr = open(os.devnull, "w")
|
||||
|
||||
# Enable MPS GPU acceleration on Mac Apple Silicon
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
|
||||
def main() -> None:
|
||||
"""Launch the Flask-based web UI."""
|
||||
|
||||
_run_web_ui()
|
||||
|
||||
|
||||
# Custom message handler to filter out specific Qt warnings
|
||||
def qt_message_handler(mode, context, message):
|
||||
# In PyQt6, the mode is an enum, so we compare with the enum members
|
||||
if "Wayland does not support QWindow::requestActivate()" in message:
|
||||
return # Suppress this specific message
|
||||
if "setGrabPopup called with a parent, QtWaylandClient" in message:
|
||||
return
|
||||
|
||||
if mode == QtMsgType.QtWarningMsg:
|
||||
print(f"Qt Warning: {message}")
|
||||
elif mode == QtMsgType.QtCriticalMsg:
|
||||
print(f"Qt Critical: {message}")
|
||||
elif mode == QtMsgType.QtFatalMsg:
|
||||
print(f"Qt Fatal: {message}")
|
||||
elif mode == QtMsgType.QtInfoMsg:
|
||||
print(f"Qt Info: {message}")
|
||||
|
||||
|
||||
# Install the custom message handler
|
||||
qInstallMessageHandler(qt_message_handler)
|
||||
|
||||
# Handle Wayland on Linux GNOME
|
||||
if platform.system() == "Linux":
|
||||
xdg_session = os.environ.get("XDG_SESSION_TYPE", "").lower()
|
||||
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
||||
if (
|
||||
"gnome" in desktop
|
||||
and xdg_session == "wayland"
|
||||
and "QT_QPA_PLATFORM" not in os.environ
|
||||
):
|
||||
os.environ["QT_QPA_PLATFORM"] = "wayland"
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for console usage."""
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Set application icon using get_resource_path from utils
|
||||
icon_path = get_resource_path("abogen.assets", "icon.ico")
|
||||
if icon_path:
|
||||
app.setWindowIcon(QIcon(icon_path))
|
||||
|
||||
# Set the .desktop name on Linux
|
||||
if platform.system() == "Linux":
|
||||
try:
|
||||
app.setDesktopFileName("abogen")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
ex = abogen()
|
||||
ex.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == "__main__": # pragma: no cover - manual execution hook
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import replace
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Mapping, Optional
|
||||
|
||||
from abogen.kokoro_text_normalization import ApostropheConfig, CONTRACTION_CATEGORY_DEFAULTS
|
||||
from abogen.llm_client import LLMConfiguration
|
||||
from abogen.utils import load_config
|
||||
|
||||
DEFAULT_LLM_PROMPT = (
|
||||
"You are assisting with audiobook preparation. Analyze the sentence and identify any apostrophes or "
|
||||
"contractions that should be expanded for clarity. Call the apply_regex_replacements tool with precise "
|
||||
"regex substitutions for only the words that need adjustment. If no changes are required, return an empty list.\n"
|
||||
"Sentence: {{ sentence }}"
|
||||
)
|
||||
|
||||
_LEGACY_REWRITE_ONLY_PROMPT = (
|
||||
"You are assisting with audiobook preparation. Rewrite the provided sentence so apostrophes and "
|
||||
"contractions are unambiguous for text-to-speech. Respond with only the rewritten sentence.\n"
|
||||
"Sentence: {{ sentence }}\n"
|
||||
"Context: {{ paragraph }}"
|
||||
)
|
||||
|
||||
_SETTINGS_DEFAULTS: Dict[str, Any] = {
|
||||
"llm_base_url": "",
|
||||
"llm_api_key": "",
|
||||
"llm_model": "",
|
||||
"llm_timeout": 30.0,
|
||||
"llm_prompt": DEFAULT_LLM_PROMPT,
|
||||
"llm_context_mode": "sentence",
|
||||
"normalization_numbers": True,
|
||||
"normalization_numbers_year_style": "american",
|
||||
"normalization_currency": True,
|
||||
"normalization_footnotes": True,
|
||||
"normalization_titles": True,
|
||||
"normalization_terminal": True,
|
||||
"normalization_phoneme_hints": True,
|
||||
"normalization_caps_quotes": True,
|
||||
"normalization_internet_slang": False,
|
||||
"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",
|
||||
"normalization_contraction_aux_be": True,
|
||||
"normalization_contraction_aux_have": True,
|
||||
"normalization_contraction_modal_will": True,
|
||||
"normalization_contraction_modal_would": True,
|
||||
"normalization_contraction_negation_not": True,
|
||||
"normalization_contraction_let_us": True,
|
||||
}
|
||||
|
||||
_CONTRACTION_SETTING_MAP: Dict[str, str] = {
|
||||
"normalization_contraction_aux_be": "contraction_aux_be",
|
||||
"normalization_contraction_aux_have": "contraction_aux_have",
|
||||
"normalization_contraction_modal_will": "contraction_modal_will",
|
||||
"normalization_contraction_modal_would": "contraction_modal_would",
|
||||
"normalization_contraction_negation_not": "contraction_negation_not",
|
||||
"normalization_contraction_let_us": "contraction_let_us",
|
||||
}
|
||||
|
||||
_ENVIRONMENT_KEYS: Dict[str, str] = {
|
||||
"llm_base_url": "ABOGEN_LLM_BASE_URL",
|
||||
"llm_api_key": "ABOGEN_LLM_API_KEY",
|
||||
"llm_model": "ABOGEN_LLM_MODEL",
|
||||
"llm_timeout": "ABOGEN_LLM_TIMEOUT",
|
||||
"llm_prompt": "ABOGEN_LLM_PROMPT",
|
||||
"llm_context_mode": "ABOGEN_LLM_CONTEXT_MODE",
|
||||
}
|
||||
|
||||
NORMALIZATION_SAMPLE_TEXTS: Dict[str, str] = {
|
||||
"apostrophes": "I've heard the captain'll arrive by dusk, but they'd said the same yesterday.",
|
||||
"numbers": "The ledger listed 1,204 outstanding debts totaling $57,890.",
|
||||
"titles": "Dr. Smith met Mr. O'Leary outside St. John's Church on Jan. 4th.",
|
||||
"punctuation": "Meet me at the docks tonight We'll decide then", # missing punctuation
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _environment_defaults() -> Dict[str, Any]:
|
||||
overrides: Dict[str, Any] = {}
|
||||
for key, env_var in _ENVIRONMENT_KEYS.items():
|
||||
default = _SETTINGS_DEFAULTS.get(key)
|
||||
if default is None:
|
||||
continue
|
||||
value = os.environ.get(env_var)
|
||||
if value is None or value == "":
|
||||
continue
|
||||
if isinstance(default, bool):
|
||||
overrides[key] = _coerce_bool(value, default)
|
||||
elif isinstance(default, float):
|
||||
overrides[key] = _coerce_float(value, float(default))
|
||||
else:
|
||||
overrides[key] = value
|
||||
return overrides
|
||||
|
||||
|
||||
def environment_llm_defaults() -> Dict[str, Any]:
|
||||
defaults = dict(_environment_defaults())
|
||||
if defaults:
|
||||
_apply_llm_migrations(defaults)
|
||||
return defaults
|
||||
|
||||
|
||||
def _coerce_bool(value: Any, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_float(value: Any, default: float) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _apply_llm_migrations(settings: Dict[str, Any]) -> None:
|
||||
prompt_value = str(settings.get("llm_prompt") or "")
|
||||
if prompt_value.strip() == _LEGACY_REWRITE_ONLY_PROMPT.strip():
|
||||
settings["llm_prompt"] = DEFAULT_LLM_PROMPT
|
||||
|
||||
context_mode = str(settings.get("llm_context_mode") or "").strip().lower()
|
||||
if context_mode != "sentence":
|
||||
settings["llm_context_mode"] = "sentence"
|
||||
|
||||
|
||||
def _extract_settings(source: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
env_defaults = _environment_defaults()
|
||||
extracted: Dict[str, Any] = {}
|
||||
for key, default in _SETTINGS_DEFAULTS.items():
|
||||
if key in source:
|
||||
raw_value = source.get(key)
|
||||
elif key in env_defaults:
|
||||
raw_value = env_defaults[key]
|
||||
else:
|
||||
raw_value = default
|
||||
if isinstance(default, bool):
|
||||
extracted[key] = _coerce_bool(raw_value, default)
|
||||
elif isinstance(default, float):
|
||||
extracted[key] = _coerce_float(raw_value, default)
|
||||
else:
|
||||
extracted[key] = str(raw_value or "") if isinstance(default, str) else raw_value
|
||||
_apply_llm_migrations(extracted)
|
||||
return extracted
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _cached_settings() -> Dict[str, Any]:
|
||||
config = load_config() or {}
|
||||
return _extract_settings(config)
|
||||
|
||||
|
||||
def get_runtime_settings() -> Dict[str, Any]:
|
||||
return dict(_cached_settings())
|
||||
|
||||
|
||||
def clear_cached_settings() -> None:
|
||||
_cached_settings.cache_clear()
|
||||
|
||||
|
||||
def build_apostrophe_config(
|
||||
*,
|
||||
settings: Mapping[str, Any],
|
||||
base: Optional[ApostropheConfig] = None,
|
||||
) -> ApostropheConfig:
|
||||
config = replace(base or ApostropheConfig())
|
||||
config.convert_numbers = bool(settings.get("normalization_numbers", True))
|
||||
config.convert_currency = bool(settings.get("normalization_currency", True))
|
||||
config.remove_footnotes = bool(settings.get("normalization_footnotes", True))
|
||||
config.year_pronunciation_mode = str(settings.get("normalization_numbers_year_style", "american") or "").strip().lower()
|
||||
config.add_phoneme_hints = bool(settings.get("normalization_phoneme_hints", True))
|
||||
config.contraction_mode = "expand" if settings.get("normalization_apostrophes_contractions", True) else "keep"
|
||||
config.plural_possessive_mode = (
|
||||
"collapse" if settings.get("normalization_apostrophes_plural_possessives", True) else "keep"
|
||||
)
|
||||
config.sibilant_possessive_mode = (
|
||||
"mark" if settings.get("normalization_apostrophes_sibilant_possessives", True) else "keep"
|
||||
)
|
||||
config.decades_mode = "expand" if settings.get("normalization_apostrophes_decades", True) else "keep"
|
||||
config.leading_elision_mode = (
|
||||
"expand" if settings.get("normalization_apostrophes_leading_elisions", True) else "keep"
|
||||
)
|
||||
config.ambiguous_past_modal_mode = "contextual" if config.contraction_mode == "expand" else "keep"
|
||||
category_flags = dict(CONTRACTION_CATEGORY_DEFAULTS)
|
||||
for setting_key, category in _CONTRACTION_SETTING_MAP.items():
|
||||
default_value = bool(_SETTINGS_DEFAULTS.get(setting_key, True))
|
||||
raw_value = settings.get(setting_key, default_value)
|
||||
category_flags[category] = _coerce_bool(raw_value, default_value)
|
||||
config.contraction_categories = category_flags
|
||||
return config
|
||||
|
||||
|
||||
def build_llm_configuration(settings: Mapping[str, Any]) -> LLMConfiguration:
|
||||
return LLMConfiguration(
|
||||
base_url=str(settings.get("llm_base_url") or ""),
|
||||
api_key=str(settings.get("llm_api_key") or ""),
|
||||
model=str(settings.get("llm_model") or ""),
|
||||
timeout=_coerce_float(settings.get("llm_timeout"), float(_SETTINGS_DEFAULTS["llm_timeout"])),
|
||||
)
|
||||
|
||||
|
||||
def apply_overrides(base: Mapping[str, Any], overrides: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
merged: Dict[str, Any] = dict(base)
|
||||
for key, value in overrides.items():
|
||||
if key not in _SETTINGS_DEFAULTS:
|
||||
continue
|
||||
merged[key] = value
|
||||
_apply_llm_migrations(merged)
|
||||
return merged
|
||||
@@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from .entity_analysis import normalize_token
|
||||
from .utils import get_internal_cache_path, get_user_settings_dir
|
||||
|
||||
_DB_LOCK = threading.RLock()
|
||||
_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def _store_path() -> Path:
|
||||
try:
|
||||
base_dir = Path(get_user_settings_dir())
|
||||
except ModuleNotFoundError:
|
||||
base_dir = Path(get_internal_cache_path("pronunciations"))
|
||||
target = base_dir / "overrides.json"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
return target
|
||||
|
||||
|
||||
def _migrate_legacy_sqlite(target_json_path: Path) -> None:
|
||||
try:
|
||||
base_dir = Path(get_user_settings_dir())
|
||||
except ModuleNotFoundError:
|
||||
base_dir = Path(get_internal_cache_path("pronunciations"))
|
||||
|
||||
sqlite_path = base_dir / "pronunciations.db"
|
||||
if not sqlite_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(sqlite_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# Check if table exists
|
||||
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='overrides'")
|
||||
if not cursor.fetchone():
|
||||
conn.close()
|
||||
return
|
||||
|
||||
cursor = conn.execute("SELECT * FROM overrides")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
data = {"version": _SCHEMA_VERSION, "overrides": {}}
|
||||
|
||||
for row in rows:
|
||||
lang = row["language"]
|
||||
if lang not in data["overrides"]:
|
||||
data["overrides"][lang] = {}
|
||||
|
||||
entry = {
|
||||
"id": str(row["id"]),
|
||||
"normalized": row["normalized"],
|
||||
"token": row["token"],
|
||||
"language": row["language"],
|
||||
"pronunciation": row["pronunciation"],
|
||||
"voice": row["voice"],
|
||||
"notes": row["notes"],
|
||||
"context": row["context"],
|
||||
"usage_count": row["usage_count"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
data["overrides"][lang][row["normalized"]] = entry
|
||||
|
||||
conn.close()
|
||||
|
||||
# Save to JSON
|
||||
with open(target_json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Rename old DB
|
||||
sqlite_path.rename(sqlite_path.with_suffix(".db.bak"))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _load_db() -> Dict[str, Any]:
|
||||
path = _store_path()
|
||||
if not path.exists():
|
||||
_migrate_legacy_sqlite(path)
|
||||
if not path.exists():
|
||||
return {"version": _SCHEMA_VERSION, "overrides": {}}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {"version": _SCHEMA_VERSION, "overrides": {}}
|
||||
|
||||
|
||||
def _save_db(data: Dict[str, Any]) -> None:
|
||||
path = _store_path()
|
||||
# Atomic write
|
||||
temp_path = path.with_suffix(".tmp")
|
||||
with open(temp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
shutil.move(str(temp_path), str(path))
|
||||
|
||||
|
||||
def load_overrides(language: str, tokens: Iterable[str]) -> Dict[str, Dict[str, Any]]:
|
||||
normalized_tokens = {normalize_token(token) for token in tokens if token}
|
||||
if not normalized_tokens:
|
||||
return {}
|
||||
|
||||
with _DB_LOCK:
|
||||
db = _load_db()
|
||||
lang_overrides = db.get("overrides", {}).get(language, {})
|
||||
|
||||
results: Dict[str, Dict[str, Any]] = {}
|
||||
for normalized in normalized_tokens:
|
||||
if normalized in lang_overrides:
|
||||
results[normalized] = lang_overrides[normalized]
|
||||
return results
|
||||
|
||||
|
||||
def search_overrides(language: str, query: str, *, limit: int = 15) -> List[Dict[str, Any]]:
|
||||
if not query:
|
||||
return []
|
||||
|
||||
query = query.lower()
|
||||
with _DB_LOCK:
|
||||
db = _load_db()
|
||||
lang_overrides = db.get("overrides", {}).get(language, {})
|
||||
|
||||
matches = []
|
||||
for entry in lang_overrides.values():
|
||||
if query in entry["normalized"] or query in entry["token"].lower():
|
||||
matches.append(entry)
|
||||
|
||||
# Sort by usage count desc, then updated_at desc
|
||||
matches.sort(key=lambda x: (x.get("usage_count", 0), x.get("updated_at", 0)), reverse=True)
|
||||
return matches[:limit]
|
||||
|
||||
|
||||
def save_override(
|
||||
*,
|
||||
language: str,
|
||||
token: str,
|
||||
pronunciation: Optional[str] = None,
|
||||
voice: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
context: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
normalized = normalize_token(token)
|
||||
if not normalized:
|
||||
raise ValueError("Provide a token to override")
|
||||
|
||||
timestamp = time.time()
|
||||
with _DB_LOCK:
|
||||
db = _load_db()
|
||||
overrides = db.setdefault("overrides", {})
|
||||
lang_overrides = overrides.setdefault(language, {})
|
||||
|
||||
existing = lang_overrides.get(normalized)
|
||||
|
||||
if existing:
|
||||
entry = existing
|
||||
entry["token"] = token
|
||||
entry["pronunciation"] = pronunciation
|
||||
entry["voice"] = voice
|
||||
entry["notes"] = notes
|
||||
entry["context"] = context
|
||||
entry["updated_at"] = timestamp
|
||||
else:
|
||||
entry = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"normalized": normalized,
|
||||
"token": token,
|
||||
"language": language,
|
||||
"pronunciation": pronunciation,
|
||||
"voice": voice,
|
||||
"notes": notes,
|
||||
"context": context,
|
||||
"usage_count": 0,
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
lang_overrides[normalized] = entry
|
||||
|
||||
_save_db(db)
|
||||
return entry
|
||||
|
||||
|
||||
def delete_override(*, language: str, token: str) -> None:
|
||||
normalized = normalize_token(token)
|
||||
if not normalized:
|
||||
return
|
||||
|
||||
with _DB_LOCK:
|
||||
db = _load_db()
|
||||
lang_overrides = db.get("overrides", {}).get(language, {})
|
||||
|
||||
if normalized in lang_overrides:
|
||||
del lang_overrides[normalized]
|
||||
_save_db(db)
|
||||
|
||||
|
||||
def all_overrides(language: str) -> List[Dict[str, Any]]:
|
||||
with _DB_LOCK:
|
||||
db = _load_db()
|
||||
lang_overrides = db.get("overrides", {}).get(language, {})
|
||||
|
||||
results = list(lang_overrides.values())
|
||||
results.sort(key=lambda x: x.get("updated_at", 0), reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def increment_usage(*, language: str, token: str, amount: int = 1) -> None:
|
||||
normalized = normalize_token(token)
|
||||
if not normalized:
|
||||
return
|
||||
|
||||
with _DB_LOCK:
|
||||
db = _load_db()
|
||||
lang_overrides = db.get("overrides", {}).get(language, {})
|
||||
|
||||
if normalized in lang_overrides:
|
||||
entry = lang_overrides[normalized]
|
||||
entry["usage_count"] = entry.get("usage_count", 0) + amount
|
||||
entry["updated_at"] = time.time()
|
||||
_save_db(db)
|
||||
|
||||
|
||||
def get_override_stats(language: str) -> Dict[str, int]:
|
||||
with _DB_LOCK:
|
||||
db = _load_db()
|
||||
lang_overrides = db.get("overrides", {}).get(language, {})
|
||||
|
||||
total = len(lang_overrides)
|
||||
with_pronunciation = sum(1 for x in lang_overrides.values() if x.get("pronunciation"))
|
||||
with_voice = sum(1 for x in lang_overrides.values() if x.get("voice"))
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"filtered": total,
|
||||
"with_pronunciation": with_pronunciation,
|
||||
"with_voice": with_voice,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"""PyQt6 Desktop GUI for abogen.
|
||||
|
||||
This package contains the traditional PyQt6-based desktop interface.
|
||||
For the web-based interface, see abogen.webui.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4049
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import atexit
|
||||
import signal
|
||||
from abogen.utils import get_resource_path, load_config, prevent_sleep_end
|
||||
|
||||
|
||||
# Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6
|
||||
if platform.system() == "Windows":
|
||||
import ctypes
|
||||
from importlib.util import find_spec
|
||||
|
||||
try:
|
||||
if (
|
||||
(spec := find_spec("torch"))
|
||||
and spec.origin
|
||||
and os.path.exists(
|
||||
dll_path := os.path.join(os.path.dirname(spec.origin), "lib", "c10.dll")
|
||||
)
|
||||
):
|
||||
ctypes.CDLL(os.path.normpath(dll_path))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Qt platform plugin detection (fixes #59)
|
||||
try:
|
||||
from PyQt6.QtCore import QLibraryInfo
|
||||
|
||||
# Get the path to the plugins directory
|
||||
plugins = QLibraryInfo.path(QLibraryInfo.LibraryPath.PluginsPath)
|
||||
|
||||
# Normalize path to use the OS-native separators and absolute path
|
||||
platform_dir = os.path.normpath(os.path.join(plugins, "platforms"))
|
||||
|
||||
# Ensure we work with an absolute path for clarity
|
||||
platform_dir = os.path.abspath(platform_dir)
|
||||
|
||||
if os.path.isdir(platform_dir):
|
||||
os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = platform_dir
|
||||
print("QT_QPA_PLATFORM_PLUGIN_PATH set to:", platform_dir)
|
||||
else:
|
||||
print("PyQt6 platform plugins not found at", platform_dir)
|
||||
except ImportError:
|
||||
print("PyQt6 not installed.")
|
||||
|
||||
|
||||
# Pre-load "libxcb-cursor" on Linux (fixes #101)
|
||||
if platform.system() == "Linux":
|
||||
arch = platform.machine().lower()
|
||||
lib_filename = {"x86_64": "libxcb-cursor-amd64.so.0", "amd64": "libxcb-cursor-amd64.so.0", "aarch64": "libxcb-cursor-arm64.so.0", "arm64": "libxcb-cursor-arm64.so.0"}.get(arch)
|
||||
if lib_filename:
|
||||
import ctypes
|
||||
try:
|
||||
# Try to load the system libxcb-cursor.so.0 first
|
||||
ctypes.CDLL('libxcb-cursor.so.0', mode=ctypes.RTLD_GLOBAL)
|
||||
except OSError:
|
||||
# System lib not available, load the bundled version
|
||||
lib_path = get_resource_path('abogen.libs', lib_filename)
|
||||
if lib_path:
|
||||
try:
|
||||
ctypes.CDLL(lib_path, mode=ctypes.RTLD_GLOBAL)
|
||||
except OSError:
|
||||
# If it fails (e.g. wrong glibc version on very old systems),
|
||||
# we simply ignore it and hope the system has the library.
|
||||
pass
|
||||
|
||||
|
||||
# Set application ID for Windows taskbar icon
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
from abogen.constants import PROGRAM_NAME, VERSION
|
||||
import ctypes
|
||||
|
||||
app_id = f"{PROGRAM_NAME}.{VERSION}"
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
|
||||
except Exception as e:
|
||||
print("Warning: failed to set AppUserModelID:", e)
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtGui import QIcon
|
||||
from PyQt6.QtCore import (
|
||||
QLibraryInfo,
|
||||
qInstallMessageHandler,
|
||||
QtMsgType,
|
||||
)
|
||||
|
||||
# Add the directory to Python path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
||||
|
||||
# Set Hugging Face Hub environment variables
|
||||
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry
|
||||
os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds)
|
||||
os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds)
|
||||
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning
|
||||
if load_config().get("disable_kokoro_internet", False):
|
||||
print("INFO: Kokoro's internet access is disabled.")
|
||||
os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access
|
||||
|
||||
from abogen.pyqt.gui import abogen
|
||||
from abogen.constants import PROGRAM_NAME, VERSION
|
||||
|
||||
# Set environment variables for AMD ROCm
|
||||
os.environ["MIOPEN_FIND_MODE"] = "FAST"
|
||||
os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0"
|
||||
|
||||
# Reset sleep states
|
||||
atexit.register(prevent_sleep_end)
|
||||
|
||||
|
||||
# Also handle signals (Ctrl+C, kill, etc.)
|
||||
def _cleanup_sleep(signum, frame):
|
||||
prevent_sleep_end()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, _cleanup_sleep)
|
||||
signal.signal(signal.SIGTERM, _cleanup_sleep)
|
||||
|
||||
# Ensure sys.stdout and sys.stderr are valid in GUI mode
|
||||
if sys.stdout is None:
|
||||
sys.stdout = open(os.devnull, "w")
|
||||
if sys.stderr is None:
|
||||
sys.stderr = open(os.devnull, "w")
|
||||
|
||||
# Enable MPS GPU acceleration on Mac Apple Silicon
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
|
||||
|
||||
|
||||
# Custom message handler to filter out specific Qt warnings
|
||||
def qt_message_handler(mode, context, message):
|
||||
# In PyQt6, the mode is an enum, so we compare with the enum members
|
||||
if "Wayland does not support QWindow::requestActivate()" in message:
|
||||
return # Suppress this specific message
|
||||
if "setGrabPopup called with a parent, QtWaylandClient" in message:
|
||||
return
|
||||
|
||||
if mode == QtMsgType.QtWarningMsg:
|
||||
print(f"Qt Warning: {message}")
|
||||
elif mode == QtMsgType.QtCriticalMsg:
|
||||
print(f"Qt Critical: {message}")
|
||||
elif mode == QtMsgType.QtFatalMsg:
|
||||
print(f"Qt Fatal: {message}")
|
||||
elif mode == QtMsgType.QtInfoMsg:
|
||||
print(f"Qt Info: {message}")
|
||||
|
||||
|
||||
# Install the custom message handler
|
||||
qInstallMessageHandler(qt_message_handler)
|
||||
|
||||
# Handle Wayland on Linux GNOME
|
||||
if platform.system() == "Linux":
|
||||
xdg_session = os.environ.get("XDG_SESSION_TYPE", "").lower()
|
||||
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
||||
if (
|
||||
"gnome" in desktop
|
||||
and xdg_session == "wayland"
|
||||
and "QT_QPA_PLATFORM" not in os.environ
|
||||
):
|
||||
os.environ["QT_QPA_PLATFORM"] = "wayland"
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for console usage."""
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Set application icon using get_resource_path from utils
|
||||
icon_path = get_resource_path("abogen.assets", "icon.ico")
|
||||
if icon_path:
|
||||
app.setWindowIcon(QIcon(icon_path))
|
||||
|
||||
# Set the .desktop name on Linux
|
||||
if platform.system() == "Linux":
|
||||
try:
|
||||
app.setDesktopFileName("abogen")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
ex = abogen()
|
||||
ex.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,590 @@
|
||||
"""
|
||||
Pre-download dialog and worker for Abogen
|
||||
|
||||
This module consolidates pre-download logic for Kokoro voices and model
|
||||
and spaCy language models. The code favors clarity, avoids duplication,
|
||||
and handles optional dependencies gracefully.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
import importlib
|
||||
import importlib.util
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSpacerItem,
|
||||
QSizePolicy,
|
||||
)
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
from abogen.constants import COLORS, VOICES_INTERNAL
|
||||
from abogen.spacy_utils import SPACY_MODELS
|
||||
import abogen.hf_tracker
|
||||
|
||||
|
||||
# Helpers
|
||||
def _unique_sorted_models() -> List[str]:
|
||||
"""Return a sorted list of unique spaCy model package names."""
|
||||
return sorted(set(SPACY_MODELS.values()))
|
||||
|
||||
|
||||
def _is_package_installed(pkg_name: str) -> bool:
|
||||
"""Return True if a package with the given name can be imported (site-packages)."""
|
||||
try:
|
||||
return importlib.util.find_spec(pkg_name) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# NOTE: explicit HF cache helper removed; we use try_to_load_from_cache in-scope where needed
|
||||
|
||||
|
||||
class PreDownloadWorker(QThread):
|
||||
"""Worker thread to download required models/voices.
|
||||
|
||||
Emits human-readable messages via `progress`. Uses `category_done` to indicate
|
||||
a category (voices/model/spacy) finished successfully. Emits `error` on exception
|
||||
and `finished` after all work completes.
|
||||
"""
|
||||
|
||||
# Emit (category, status, message)
|
||||
progress = pyqtSignal(str, str, str)
|
||||
category_done = pyqtSignal(str)
|
||||
finished = pyqtSignal()
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._cancelled = False
|
||||
# repo and filenames used for Kokoro model
|
||||
self._repo_id = "hexgrad/Kokoro-82M"
|
||||
self._model_files = ["kokoro-v1_0.pth", "config.json"]
|
||||
# Track download success per category
|
||||
self._voices_success = False
|
||||
self._model_success = False
|
||||
self._spacy_success = False
|
||||
# Suppress HF tracker warnings during downloads
|
||||
self._original_emitter = abogen.hf_tracker.show_warning_signal_emitter
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancelled = True
|
||||
|
||||
def run(self) -> None:
|
||||
# Suppress HF tracker warnings during downloads
|
||||
abogen.hf_tracker.show_warning_signal_emitter = None
|
||||
try:
|
||||
self._download_kokoro_voices()
|
||||
if self._cancelled:
|
||||
return
|
||||
if self._voices_success:
|
||||
self.category_done.emit("voices")
|
||||
|
||||
self._download_kokoro_model()
|
||||
if self._cancelled:
|
||||
return
|
||||
if self._model_success:
|
||||
self.category_done.emit("model")
|
||||
|
||||
self._download_spacy_models()
|
||||
if self._cancelled:
|
||||
return
|
||||
if self._spacy_success:
|
||||
self.category_done.emit("spacy")
|
||||
|
||||
self.finished.emit()
|
||||
except Exception as exc: # pragma: no cover - best-effort reporting
|
||||
self.error.emit(str(exc))
|
||||
finally:
|
||||
# Restore original emitter
|
||||
abogen.hf_tracker.show_warning_signal_emitter = self._original_emitter
|
||||
|
||||
# Kokoro voices
|
||||
def _download_kokoro_voices(self) -> None:
|
||||
self._voices_success = True
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download, try_to_load_from_cache
|
||||
except Exception:
|
||||
self.progress.emit(
|
||||
"voice", "warning", "huggingface_hub not installed, skipping voices..."
|
||||
)
|
||||
self._voices_success = False
|
||||
return
|
||||
|
||||
voice_list = VOICES_INTERNAL
|
||||
for idx, voice in enumerate(voice_list, start=1):
|
||||
if self._cancelled:
|
||||
self._voices_success = False
|
||||
return
|
||||
filename = f"voices/{voice}.pt"
|
||||
if try_to_load_from_cache(repo_id=self._repo_id, filename=filename):
|
||||
self.progress.emit(
|
||||
"voice",
|
||||
"installed",
|
||||
f"{idx}/{len(voice_list)}: {voice} already present",
|
||||
)
|
||||
continue
|
||||
self.progress.emit(
|
||||
"voice", "downloading", f"{idx}/{len(voice_list)}: {voice}..."
|
||||
)
|
||||
try:
|
||||
hf_hub_download(repo_id=self._repo_id, filename=filename)
|
||||
self.progress.emit("voice", "downloaded", f"{voice} downloaded")
|
||||
except Exception as exc:
|
||||
self.progress.emit(
|
||||
"voice", "warning", f"could not download {voice}: {exc}"
|
||||
)
|
||||
self._voices_success = False
|
||||
|
||||
# Kokoro model
|
||||
def _download_kokoro_model(self) -> None:
|
||||
self._model_success = True
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download, try_to_load_from_cache
|
||||
except Exception:
|
||||
self.progress.emit(
|
||||
"model", "warning", "huggingface_hub not installed, skipping model..."
|
||||
)
|
||||
self._model_success = False
|
||||
return
|
||||
for fname in self._model_files:
|
||||
if self._cancelled:
|
||||
self._model_success = False
|
||||
return
|
||||
category = "config" if fname == "config.json" else "model"
|
||||
if try_to_load_from_cache(repo_id=self._repo_id, filename=fname):
|
||||
self.progress.emit(
|
||||
category, "installed", f"file {fname} already present"
|
||||
)
|
||||
continue
|
||||
self.progress.emit(category, "downloading", f"file {fname}...")
|
||||
try:
|
||||
hf_hub_download(repo_id=self._repo_id, filename=fname)
|
||||
self.progress.emit(category, "downloaded", f"file {fname} downloaded")
|
||||
except Exception as exc:
|
||||
self.progress.emit(
|
||||
category, "warning", f"could not download file {fname}: {exc}"
|
||||
)
|
||||
self._model_success = False
|
||||
|
||||
# spaCy models
|
||||
def _download_spacy_models(self) -> None:
|
||||
"""Download spaCy models. Prefer missing models provided by parent.
|
||||
|
||||
Parent dialog will populate _spacy_models_missing during checking.
|
||||
"""
|
||||
self._spacy_success = True
|
||||
# Determine which models to process: prefer parent-provided missing list to avoid
|
||||
# re-checking everything; otherwise use the full unique list.
|
||||
parent = self.parent()
|
||||
models_to_process: List[str] = _unique_sorted_models()
|
||||
try:
|
||||
if (
|
||||
parent is not None
|
||||
and hasattr(parent, "_spacy_models_missing")
|
||||
and parent._spacy_models_missing
|
||||
):
|
||||
models_to_process = list(dict.fromkeys(parent._spacy_models_missing))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If spaCy is not available to run the CLI, skip gracefully
|
||||
try:
|
||||
import spacy.cli as _spacy_cli
|
||||
except Exception:
|
||||
self.progress.emit(
|
||||
"spacy", "warning", "spaCy not available, skipping spaCy models..."
|
||||
)
|
||||
self._spacy_success = False
|
||||
return
|
||||
|
||||
for idx, model_name in enumerate(models_to_process, start=1):
|
||||
if self._cancelled:
|
||||
self._spacy_success = False
|
||||
return
|
||||
if _is_package_installed(model_name):
|
||||
self.progress.emit(
|
||||
"spacy",
|
||||
"installed",
|
||||
f"{idx}/{len(models_to_process)}: {model_name} already installed",
|
||||
)
|
||||
continue
|
||||
self.progress.emit(
|
||||
"spacy",
|
||||
"downloading",
|
||||
f"{idx}/{len(models_to_process)}: {model_name}...",
|
||||
)
|
||||
try:
|
||||
_spacy_cli.download(model_name)
|
||||
self.progress.emit("spacy", "downloaded", f"{model_name} downloaded")
|
||||
except Exception as exc:
|
||||
self.progress.emit(
|
||||
"spacy", "warning", f"could not download {model_name}: {exc}"
|
||||
)
|
||||
self._spacy_success = False
|
||||
|
||||
|
||||
class PreDownloadDialog(QDialog):
|
||||
"""Dialog to show and control pre-download process."""
|
||||
|
||||
VOICE_PREFIX = "Kokoro voices: "
|
||||
MODEL_PREFIX = "Kokoro model: "
|
||||
CONFIG_PREFIX = "Kokoro config: "
|
||||
SPACY_PREFIX = "spaCy models: "
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Pre-download Models and Voices")
|
||||
self.setMinimumWidth(500)
|
||||
self.worker: Optional[PreDownloadWorker] = None
|
||||
self.has_missing = False
|
||||
self._spacy_models_checked: List[tuple] = []
|
||||
self._spacy_models_missing: List[str] = []
|
||||
self._status_worker = None
|
||||
|
||||
# Map keywords to (label, prefix) - labels filled after UI creation
|
||||
self.status_map = {
|
||||
"voice": (None, self.VOICE_PREFIX),
|
||||
"spacy": (None, self.SPACY_PREFIX),
|
||||
"model": (None, self.MODEL_PREFIX),
|
||||
"config": (None, self.CONFIG_PREFIX),
|
||||
}
|
||||
|
||||
self.category_map = {
|
||||
"voices": ["voice"],
|
||||
"model": ["model", "config"],
|
||||
"spacy": ["spacy"],
|
||||
}
|
||||
|
||||
self._setup_ui()
|
||||
self._start_status_check()
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(0)
|
||||
layout.setContentsMargins(15, 0, 15, 15)
|
||||
|
||||
desc = QLabel(
|
||||
"You can pre-download all required models and voices for offline use.\n"
|
||||
"This includes Kokoro voices, Kokoro model (and config), and spaCy models."
|
||||
)
|
||||
desc.setWordWrap(True)
|
||||
layout.addWidget(desc)
|
||||
|
||||
# Status rows
|
||||
status_layout = QVBoxLayout()
|
||||
status_title = QLabel("<b>Current Status:</b>")
|
||||
status_layout.addWidget(status_title)
|
||||
|
||||
self.voices_status = QLabel(self.VOICE_PREFIX + "⏳ Checking...")
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.voices_status)
|
||||
row.addStretch()
|
||||
status_layout.addLayout(row)
|
||||
|
||||
self.model_status = QLabel(self.MODEL_PREFIX + "⏳ Checking...")
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.model_status)
|
||||
row.addStretch()
|
||||
status_layout.addLayout(row)
|
||||
|
||||
self.config_status = QLabel(self.CONFIG_PREFIX + "⏳ Checking...")
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.config_status)
|
||||
row.addStretch()
|
||||
status_layout.addLayout(row)
|
||||
|
||||
self.spacy_status = QLabel(self.SPACY_PREFIX + "⏳ Checking...")
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.spacy_status)
|
||||
row.addStretch()
|
||||
status_layout.addLayout(row)
|
||||
|
||||
# register labels
|
||||
self.status_map["voice"] = (self.voices_status, self.VOICE_PREFIX)
|
||||
self.status_map["model"] = (self.model_status, self.MODEL_PREFIX)
|
||||
self.status_map["config"] = (self.config_status, self.CONFIG_PREFIX)
|
||||
self.status_map["spacy"] = (self.spacy_status, self.SPACY_PREFIX)
|
||||
|
||||
layout.addLayout(status_layout)
|
||||
|
||||
layout.addItem(
|
||||
QSpacerItem(0, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
)
|
||||
|
||||
# Buttons
|
||||
button_row = QHBoxLayout()
|
||||
button_row.setSpacing(10)
|
||||
self.download_btn = QPushButton("Download all")
|
||||
self.download_btn.setMinimumWidth(100)
|
||||
self.download_btn.setMinimumHeight(35)
|
||||
self.download_btn.setEnabled(False)
|
||||
self.download_btn.clicked.connect(self._start_download)
|
||||
button_row.addWidget(self.download_btn)
|
||||
|
||||
self.close_btn = QPushButton("Close")
|
||||
self.close_btn.setMinimumWidth(100)
|
||||
self.close_btn.setMinimumHeight(35)
|
||||
self.close_btn.clicked.connect(self._handle_close)
|
||||
button_row.addWidget(self.close_btn)
|
||||
|
||||
layout.addLayout(button_row)
|
||||
self.adjustSize()
|
||||
|
||||
# Status checking worker
|
||||
class StatusCheckWorker(QThread):
|
||||
voices_checked = pyqtSignal(bool, list)
|
||||
model_checked = pyqtSignal(bool)
|
||||
config_checked = pyqtSignal(bool)
|
||||
spacy_model_checking = pyqtSignal(str)
|
||||
spacy_model_result = pyqtSignal(str, bool)
|
||||
spacy_checked = pyqtSignal(bool, list)
|
||||
|
||||
def run(self):
|
||||
parent = self.parent()
|
||||
if parent is None:
|
||||
return
|
||||
|
||||
voices_ok, missing_voices = parent._check_kokoro_voices()
|
||||
self.voices_checked.emit(voices_ok, missing_voices)
|
||||
|
||||
model_ok = parent._check_kokoro_model()
|
||||
self.model_checked.emit(model_ok)
|
||||
|
||||
config_ok = parent._check_kokoro_config()
|
||||
self.config_checked.emit(config_ok)
|
||||
|
||||
# Check spaCy models by package name to detect site-package installs
|
||||
unique = _unique_sorted_models()
|
||||
missing: List[str] = []
|
||||
for name in unique:
|
||||
self.spacy_model_checking.emit(name)
|
||||
ok = _is_package_installed(name)
|
||||
self.spacy_model_result.emit(name, ok)
|
||||
if not ok:
|
||||
missing.append(name)
|
||||
parent._spacy_models_missing = missing
|
||||
self.spacy_checked.emit(len(missing) == 0, missing)
|
||||
|
||||
def _start_status_check(self) -> None:
|
||||
self._status_worker = self.StatusCheckWorker(self)
|
||||
self._status_worker.voices_checked.connect(self._update_voices_status)
|
||||
self._status_worker.model_checked.connect(self._update_model_status)
|
||||
self._status_worker.config_checked.connect(self._update_config_status)
|
||||
self._status_worker.spacy_model_checking.connect(self._spacy_model_checking)
|
||||
self._status_worker.spacy_model_result.connect(self._spacy_model_result)
|
||||
self._status_worker.spacy_checked.connect(self._update_spacy_status)
|
||||
|
||||
# These are initialized in __init__ to keep consistent object state
|
||||
|
||||
# Set checking visual state
|
||||
for lbl in (
|
||||
self.voices_status,
|
||||
self.model_status,
|
||||
self.config_status,
|
||||
self.spacy_status,
|
||||
):
|
||||
lbl.setStyleSheet(f"color: {COLORS['ORANGE']};")
|
||||
|
||||
self.spacy_status.setText(self.SPACY_PREFIX + "⏳ Checking...")
|
||||
self._status_worker.start()
|
||||
|
||||
# UI update callbacks
|
||||
def _spacy_model_checking(self, name: str) -> None:
|
||||
self.spacy_status.setText(f"{self.SPACY_PREFIX}Checking {name}...")
|
||||
|
||||
def _spacy_model_result(self, name: str, ok: bool) -> None:
|
||||
self._spacy_models_checked.append((name, ok))
|
||||
if not ok and name not in self._spacy_models_missing:
|
||||
self._spacy_models_missing.append(name)
|
||||
checked = len(self._spacy_models_checked)
|
||||
missing_count = len(self._spacy_models_missing)
|
||||
if missing_count:
|
||||
self.spacy_status.setText(
|
||||
f"{self.SPACY_PREFIX}{checked} checked, {missing_count} missing..."
|
||||
)
|
||||
else:
|
||||
self.spacy_status.setText(f"{self.SPACY_PREFIX}{checked} checked...")
|
||||
|
||||
def _update_voices_status(self, ok: bool, missing: List[str]) -> None:
|
||||
if ok:
|
||||
self._set_status("voice", "✓ Downloaded", COLORS["GREEN"])
|
||||
else:
|
||||
self.has_missing = True
|
||||
if missing:
|
||||
self._set_status(
|
||||
"voice", f"✗ Missing {len(missing)} voices", COLORS["RED"]
|
||||
)
|
||||
else:
|
||||
self._set_status("voice", "✗ Not downloaded", COLORS["RED"])
|
||||
|
||||
def _update_model_status(self, ok: bool) -> None:
|
||||
if ok:
|
||||
self._set_status("model", "✓ Downloaded", COLORS["GREEN"])
|
||||
else:
|
||||
self.has_missing = True
|
||||
self._set_status("model", "✗ Not downloaded", COLORS["RED"])
|
||||
|
||||
def _update_config_status(self, ok: bool) -> None:
|
||||
if ok:
|
||||
self._set_status("config", "✓ Downloaded", COLORS["GREEN"])
|
||||
else:
|
||||
self.has_missing = True
|
||||
self._set_status("config", "✗ Not downloaded", COLORS["RED"])
|
||||
|
||||
def _update_spacy_status(self, ok: bool, missing: List[str]) -> None:
|
||||
if ok:
|
||||
self._set_status("spacy", "✓ Downloaded", COLORS["GREEN"])
|
||||
else:
|
||||
self.has_missing = True
|
||||
if missing:
|
||||
self._set_status(
|
||||
"spacy", f"✗ Missing {len(missing)} model(s)", COLORS["RED"]
|
||||
)
|
||||
else:
|
||||
self._set_status("spacy", "✗ Not downloaded", COLORS["RED"])
|
||||
self.download_btn.setEnabled(self.has_missing)
|
||||
|
||||
def _set_status(self, key: str, text: str, color: str) -> None:
|
||||
lbl, prefix = self.status_map.get(key, (None, ""))
|
||||
if not lbl:
|
||||
return
|
||||
lbl.setText(prefix + text)
|
||||
lbl.setStyleSheet(f"color: {color};")
|
||||
|
||||
# Helper checks
|
||||
def _check_kokoro_voices(self) -> Tuple[bool, List[str]]:
|
||||
"""Return (ok, missing_list) for Kokoro voices check."""
|
||||
missing = []
|
||||
try:
|
||||
from huggingface_hub import try_to_load_from_cache
|
||||
|
||||
for voice in VOICES_INTERNAL:
|
||||
if not try_to_load_from_cache(
|
||||
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
||||
):
|
||||
missing.append(voice)
|
||||
except Exception:
|
||||
# If HF missing, report all as missing
|
||||
return False, list(VOICES_INTERNAL)
|
||||
return (len(missing) == 0), missing
|
||||
|
||||
def _check_kokoro_model(self) -> bool:
|
||||
try:
|
||||
from huggingface_hub import try_to_load_from_cache
|
||||
|
||||
return (
|
||||
try_to_load_from_cache(
|
||||
repo_id="hexgrad/Kokoro-82M", filename="kokoro-v1_0.pth"
|
||||
)
|
||||
is not None
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _check_kokoro_config(self) -> bool:
|
||||
try:
|
||||
from huggingface_hub import try_to_load_from_cache
|
||||
|
||||
return (
|
||||
try_to_load_from_cache(
|
||||
repo_id="hexgrad/Kokoro-82M", filename="config.json"
|
||||
)
|
||||
is not None
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _check_spacy_models(self) -> bool:
|
||||
unique = _unique_sorted_models()
|
||||
missing = [m for m in unique if not _is_package_installed(m)]
|
||||
self._spacy_models_missing = missing
|
||||
return len(missing) == 0
|
||||
|
||||
# Download control
|
||||
def _start_download(self) -> None:
|
||||
self.download_btn.setEnabled(False)
|
||||
self.download_btn.setText("Downloading...")
|
||||
# mark the start of downloads; this triggers the labels
|
||||
self._on_progress("system", "starting", "Processing, please wait...")
|
||||
self.worker = PreDownloadWorker(self)
|
||||
self.worker.progress.connect(self._on_progress)
|
||||
self.worker.category_done.connect(self._on_category_done)
|
||||
self.worker.finished.connect(self._on_download_finished)
|
||||
self.worker.error.connect(self._on_download_error)
|
||||
self.worker.start()
|
||||
|
||||
def _on_progress(self, category: str, status: str, message: str) -> None:
|
||||
"""Map worker (category, status, message) to UI label updates.
|
||||
|
||||
Status is one of: 'downloading', 'installed', 'downloaded', 'warning', 'starting'.
|
||||
Category is one of: 'voice', 'model', 'spacy', 'config', or 'system'.
|
||||
"""
|
||||
try:
|
||||
# If the category targets a specific label, update directly
|
||||
if category in self.status_map:
|
||||
lbl, prefix = self.status_map[category]
|
||||
if not lbl:
|
||||
return
|
||||
# Compose message and set color based on status token
|
||||
full_text = prefix + message
|
||||
if len(full_text) > 60:
|
||||
display_text = full_text[:57] + "..."
|
||||
lbl.setText(display_text)
|
||||
lbl.setToolTip(full_text)
|
||||
else:
|
||||
lbl.setText(full_text)
|
||||
lbl.setToolTip("") # Clear tooltip if not needed
|
||||
if status == "downloading":
|
||||
lbl.setStyleSheet(f"color: {COLORS['ORANGE']};")
|
||||
elif status in ("installed", "downloaded"):
|
||||
lbl.setStyleSheet(f"color: {COLORS['GREEN']};")
|
||||
elif status == "warning":
|
||||
lbl.setStyleSheet(f"color: {COLORS['RED']};")
|
||||
elif status == "error":
|
||||
lbl.setStyleSheet(f"color: {COLORS['RED']};")
|
||||
return
|
||||
|
||||
# System-level messages
|
||||
if category == "system":
|
||||
if status == "starting":
|
||||
for k in self.status_map:
|
||||
lbl, prefix = self.status_map[k]
|
||||
if lbl:
|
||||
lbl.setText(prefix + "Processing, please wait...")
|
||||
lbl.setStyleSheet(f"color: {COLORS['ORANGE']};")
|
||||
# other system statuses don't require action
|
||||
return
|
||||
except Exception:
|
||||
# Do not let UI thread crash on unexpected worker message
|
||||
pass
|
||||
|
||||
def _on_category_done(self, category: str) -> None:
|
||||
for key in self.category_map.get(category, []):
|
||||
self._set_status(key, "✓ Downloaded", COLORS["GREEN"])
|
||||
|
||||
def _on_download_finished(self) -> None:
|
||||
self.has_missing = False
|
||||
self.download_btn.setText("Download all")
|
||||
self.download_btn.setEnabled(False)
|
||||
|
||||
def _on_download_error(self, error_msg: str) -> None:
|
||||
self.download_btn.setText("Download all")
|
||||
self.download_btn.setEnabled(True)
|
||||
for key in self.status_map:
|
||||
self._set_status(key, f"✗ Error - {error_msg}", COLORS["RED"])
|
||||
|
||||
def _handle_close(self) -> None:
|
||||
if self.worker and self.worker.isRunning():
|
||||
self.worker.cancel()
|
||||
self.worker.wait(2000)
|
||||
self.accept()
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
if self.worker and self.worker.isRunning():
|
||||
self.worker.cancel()
|
||||
self.worker.wait(2000)
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,860 @@
|
||||
# a simple window with a list of items in the queue, no checkboxes
|
||||
# button to remove an item from the queue
|
||||
# button to clear the queue
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QDialogButtonBox,
|
||||
QPushButton,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QFileIconProvider,
|
||||
QLabel,
|
||||
QWidget,
|
||||
QSizePolicy,
|
||||
QAbstractItemView,
|
||||
QCheckBox,
|
||||
)
|
||||
from PyQt6.QtCore import QFileInfo, Qt
|
||||
from abogen.constants import COLORS
|
||||
from copy import deepcopy
|
||||
from PyQt6.QtGui import QFontMetrics
|
||||
from abogen.utils import load_config, save_config
|
||||
|
||||
# Define attributes that are safe to override with global settings
|
||||
OVERRIDE_FIELDS = [
|
||||
"lang_code",
|
||||
"speed",
|
||||
"voice",
|
||||
"save_option",
|
||||
"output_folder",
|
||||
"subtitle_mode",
|
||||
"output_format",
|
||||
"replace_single_newlines",
|
||||
"use_silent_gaps",
|
||||
"subtitle_speed_method",
|
||||
]
|
||||
|
||||
|
||||
class ElidedLabel(QLabel):
|
||||
def __init__(self, text):
|
||||
super().__init__(text)
|
||||
self._full_text = text
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self.setTextFormat(Qt.TextFormat.PlainText)
|
||||
|
||||
def setText(self, text):
|
||||
self._full_text = text
|
||||
super().setText(text)
|
||||
self.update()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
metrics = QFontMetrics(self.font())
|
||||
elided = metrics.elidedText(
|
||||
self._full_text, Qt.TextElideMode.ElideRight, self.width()
|
||||
)
|
||||
super().setText(elided)
|
||||
super().resizeEvent(event)
|
||||
|
||||
def fullText(self):
|
||||
return self._full_text
|
||||
|
||||
|
||||
class QueueListItemWidget(QWidget):
|
||||
def __init__(self, file_name, char_count):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(12, 0, 6, 0)
|
||||
layout.setSpacing(0)
|
||||
import os
|
||||
|
||||
name_label = ElidedLabel(os.path.basename(file_name))
|
||||
char_label = QLabel(f"Chars: {char_count}")
|
||||
char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};")
|
||||
char_label.setAlignment(
|
||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
char_label.setSizePolicy(
|
||||
QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred
|
||||
)
|
||||
layout.addWidget(name_label, 1)
|
||||
layout.addWidget(char_label, 0)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
class DroppableQueueListWidget(QListWidget):
|
||||
def __init__(self, parent_dialog):
|
||||
super().__init__()
|
||||
self.parent_dialog = parent_dialog
|
||||
self.setAcceptDrops(True)
|
||||
# Overlay for drag hover
|
||||
self.drag_overlay = QLabel("", self)
|
||||
self.drag_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.drag_overlay.setStyleSheet(
|
||||
f"border:2px dashed {COLORS['BLUE_BORDER_HOVER']}; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG_HOVER']};"
|
||||
)
|
||||
self.drag_overlay.setVisible(False)
|
||||
self.drag_overlay.setAttribute(
|
||||
Qt.WidgetAttribute.WA_TransparentForMouseEvents, True
|
||||
)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
for url in event.mimeData().urls():
|
||||
file_path = url.toLocalFile().lower()
|
||||
if url.isLocalFile() and (
|
||||
file_path.endswith(".txt")
|
||||
or file_path.endswith((".srt", ".ass", ".vtt"))
|
||||
):
|
||||
self.drag_overlay.resize(self.size())
|
||||
self.drag_overlay.setVisible(True)
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
self.drag_overlay.setVisible(False)
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
for url in event.mimeData().urls():
|
||||
file_path = url.toLocalFile().lower()
|
||||
if url.isLocalFile() and (
|
||||
file_path.endswith(".txt")
|
||||
or file_path.endswith((".srt", ".ass", ".vtt"))
|
||||
):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
def dragLeaveEvent(self, event):
|
||||
self.drag_overlay.setVisible(False)
|
||||
event.accept()
|
||||
|
||||
def dropEvent(self, event):
|
||||
self.drag_overlay.setVisible(False)
|
||||
if event.mimeData().hasUrls():
|
||||
file_paths = [
|
||||
url.toLocalFile()
|
||||
for url in event.mimeData().urls()
|
||||
if url.isLocalFile()
|
||||
and (
|
||||
url.toLocalFile().lower().endswith(".txt")
|
||||
or url.toLocalFile().lower().endswith((".srt", ".ass", ".vtt"))
|
||||
)
|
||||
]
|
||||
if file_paths:
|
||||
self.parent_dialog.add_files_from_paths(file_paths)
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, "drag_overlay"):
|
||||
self.drag_overlay.resize(self.size())
|
||||
|
||||
|
||||
class QueueManager(QDialog):
|
||||
def __init__(self, parent, queue: list, title="Queue Manager", size=(600, 700)):
|
||||
super().__init__()
|
||||
self.queue = queue
|
||||
self._original_queue = deepcopy(
|
||||
queue
|
||||
) # Store a deep copy of the original queue
|
||||
self.parent = parent
|
||||
self.config = load_config() # Load config for persistence
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(15, 15, 15, 15) # set main layout margins
|
||||
layout.setSpacing(12) # set spacing between widgets in main layout
|
||||
# list of queued items
|
||||
self.listwidget = DroppableQueueListWidget(self)
|
||||
self.listwidget.setSelectionMode(
|
||||
QAbstractItemView.SelectionMode.ExtendedSelection
|
||||
)
|
||||
self.listwidget.setAlternatingRowColors(True)
|
||||
self.listwidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.listwidget.customContextMenuRequested.connect(self.show_context_menu)
|
||||
# Add informative instructions at the top
|
||||
instructions = QLabel(
|
||||
"<h2>How Queue Works?</h2>"
|
||||
"You can add text and subtitle files (.txt, .srt, .ass, .vtt) directly using the '<b>Add files</b>' button below. "
|
||||
"To add PDF, EPUB or markdown files, use the input box in the main window and click the <b>'Add to Queue'</b> button. "
|
||||
"By default, each file in the queue keeps the configuration settings active when they were added. "
|
||||
"Enabling the <b>'Override item settings with current selection'</b> option below will force all items to use the configuration currently selected in the main window. "
|
||||
"You can view each file's configuration by hovering over them."
|
||||
)
|
||||
instructions.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
instructions.setWordWrap(True)
|
||||
layout.addWidget(instructions)
|
||||
|
||||
# Override Checkbox
|
||||
self.override_chk = QCheckBox("Override item settings with current selection")
|
||||
self.override_chk.setToolTip(
|
||||
"If checked, all items in the queue will be processed using the \n"
|
||||
"settings currently selected in the main window, ignoring their saved state."
|
||||
)
|
||||
# Load saved state (default to False)
|
||||
self.override_chk.setChecked(self.config.get("queue_override_settings", False))
|
||||
# Trigger process_queue to update tooltips immediately when toggled
|
||||
self.override_chk.stateChanged.connect(self.process_queue)
|
||||
self.override_chk.setStyleSheet("margin-bottom: 8px;")
|
||||
layout.addWidget(self.override_chk)
|
||||
|
||||
# Overlay label for empty queue
|
||||
self.empty_overlay = QLabel(
|
||||
"Drag and drop your text or subtitle files here or use the 'Add files' button.",
|
||||
self.listwidget,
|
||||
)
|
||||
self.empty_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.empty_overlay.setStyleSheet(
|
||||
f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;"
|
||||
)
|
||||
self.empty_overlay.setWordWrap(True)
|
||||
self.empty_overlay.setAttribute(
|
||||
Qt.WidgetAttribute.WA_TransparentForMouseEvents, True
|
||||
)
|
||||
self.empty_overlay.hide()
|
||||
# add queue items to the list
|
||||
self.process_queue()
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
button_row.setContentsMargins(0, 0, 0, 0) # optional: no margins for button row
|
||||
button_row.setSpacing(7) # set spacing between buttons
|
||||
# Add files button
|
||||
add_files_button = QPushButton("Add files")
|
||||
add_files_button.setFixedHeight(40)
|
||||
add_files_button.clicked.connect(self.add_more_files)
|
||||
button_row.addWidget(add_files_button)
|
||||
|
||||
# Remove button
|
||||
self.remove_button = QPushButton("Remove selected")
|
||||
self.remove_button.setFixedHeight(40)
|
||||
self.remove_button.clicked.connect(self.remove_item)
|
||||
button_row.addWidget(self.remove_button)
|
||||
|
||||
# Clear button
|
||||
self.clear_button = QPushButton("Clear Queue")
|
||||
self.clear_button.setFixedHeight(40)
|
||||
self.clear_button.clicked.connect(self.clear_queue)
|
||||
button_row.addWidget(self.clear_button)
|
||||
|
||||
layout.addLayout(button_row)
|
||||
layout.addWidget(self.listwidget)
|
||||
|
||||
# Connect selection change to update button state
|
||||
self.listwidget.currentItemChanged.connect(self.update_button_states)
|
||||
self.listwidget.itemSelectionChanged.connect(self.update_button_states)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
self,
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
layout.addWidget(buttons)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
self.setWindowTitle(title)
|
||||
self.resize(*size)
|
||||
|
||||
self.update_button_states()
|
||||
|
||||
def process_queue(self):
|
||||
"""Process the queue items."""
|
||||
import os
|
||||
|
||||
self.listwidget.clear()
|
||||
if not self.queue:
|
||||
self.empty_overlay.show()
|
||||
self.update_button_states()
|
||||
return
|
||||
else:
|
||||
self.empty_overlay.hide()
|
||||
|
||||
# Get current global settings and checkbox state for overrides
|
||||
current_global_settings = self.get_current_attributes()
|
||||
is_override_active = self.override_chk.isChecked()
|
||||
|
||||
icon_provider = QFileIconProvider()
|
||||
for item in self.queue:
|
||||
# Dynamic Attribute Retrieval Helper
|
||||
def get_val(attr, default=""):
|
||||
# If override is ON and attr is overrideable, use global setting
|
||||
if is_override_active and attr in OVERRIDE_FIELDS:
|
||||
return current_global_settings.get(attr, default)
|
||||
# Otherwise return the item's saved attribute
|
||||
return getattr(item, attr, default)
|
||||
|
||||
# Determine display file path (prefer save_base_path for original file)
|
||||
display_file_path = getattr(item, "save_base_path", None) or item.file_name
|
||||
processing_file_path = item.file_name
|
||||
|
||||
# Normalize paths for consistent display (fixes Windows path separator issues)
|
||||
display_file_path = (
|
||||
os.path.normpath(display_file_path)
|
||||
if display_file_path
|
||||
else display_file_path
|
||||
)
|
||||
processing_file_path = (
|
||||
os.path.normpath(processing_file_path)
|
||||
if processing_file_path
|
||||
else processing_file_path
|
||||
)
|
||||
|
||||
# Only show the file name, not the full path
|
||||
display_name = display_file_path
|
||||
|
||||
if os.path.sep in display_file_path:
|
||||
display_name = os.path.basename(display_file_path)
|
||||
# Get icon for the display file
|
||||
icon = icon_provider.icon(QFileInfo(display_file_path))
|
||||
list_item = QListWidgetItem()
|
||||
|
||||
# Tooltip Generation
|
||||
tooltip = ""
|
||||
# If override is active, add the warning header on its own line
|
||||
if is_override_active:
|
||||
tooltip += "<b style='color: #ff9900;'>(Global Override Active)</b><br>"
|
||||
|
||||
output_folder = get_val("output_folder")
|
||||
# For plain .txt inputs we don't need to show a separate processing file
|
||||
show_processing = True
|
||||
try:
|
||||
if isinstance(
|
||||
display_file_path, str
|
||||
) and display_file_path.lower().endswith(".txt"):
|
||||
show_processing = False
|
||||
except Exception:
|
||||
show_processing = True
|
||||
|
||||
tooltip += f"<b>Input File:</b> {display_file_path}<br>"
|
||||
if (
|
||||
show_processing
|
||||
and processing_file_path
|
||||
and processing_file_path != display_file_path
|
||||
):
|
||||
tooltip += f"<b>Processing File:</b> {processing_file_path}<br>"
|
||||
|
||||
tooltip += (
|
||||
f"<b>Language:</b> {get_val('lang_code')}<br>"
|
||||
f"<b>Speed:</b> {get_val('speed')}<br>"
|
||||
f"<b>Voice:</b> {get_val('voice')}<br>"
|
||||
f"<b>Save Option:</b> {get_val('save_option')}<br>"
|
||||
)
|
||||
if output_folder not in (None, "", "None"):
|
||||
tooltip += f"<b>Output Folder:</b> {output_folder}<br>"
|
||||
tooltip += (
|
||||
f"<b>Subtitle Mode:</b> {get_val('subtitle_mode')}<br>"
|
||||
f"<b>Output Format:</b> {get_val('output_format')}<br>"
|
||||
f"<b>Characters:</b> {getattr(item, 'total_char_count', '')}<br>"
|
||||
f"<b>Replace Single Newlines:</b> {get_val('replace_single_newlines', True)}<br>"
|
||||
f"<b>Use Silent Gaps:</b> {get_val('use_silent_gaps', False)}<br>"
|
||||
f"<b>Speed Method:</b> {get_val('subtitle_speed_method', 'tts')}"
|
||||
)
|
||||
# Add book handler options if present (Preserve logic: specific to file structure)
|
||||
save_chapters_separately = getattr(item, "save_chapters_separately", None)
|
||||
merge_chapters_at_end = getattr(item, "merge_chapters_at_end", None)
|
||||
if save_chapters_separately is not None:
|
||||
tooltip += f"<br><b>Save chapters separately:</b> {'Yes' if save_chapters_separately else 'No'}"
|
||||
# Only show merge option if saving chapters separately
|
||||
if save_chapters_separately and merge_chapters_at_end is not None:
|
||||
tooltip += f"<br><b>Merge chapters at the end:</b> {'Yes' if merge_chapters_at_end else 'No'}"
|
||||
list_item.setToolTip(tooltip)
|
||||
list_item.setIcon(icon)
|
||||
# Store both paths for context menu
|
||||
list_item.setData(
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{
|
||||
"display_path": display_file_path,
|
||||
"processing_path": processing_file_path,
|
||||
},
|
||||
)
|
||||
# Use custom widget for display
|
||||
char_count = getattr(item, "total_char_count", 0)
|
||||
widget = QueueListItemWidget(display_file_path, char_count)
|
||||
self.listwidget.addItem(list_item)
|
||||
self.listwidget.setItemWidget(list_item, widget)
|
||||
self.update_button_states()
|
||||
|
||||
def remove_item(self):
|
||||
items = self.listwidget.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
# Remove by index to ensure correct mapping
|
||||
rows = sorted([self.listwidget.row(item) for item in items], reverse=True)
|
||||
# Warn user if removing multiple files
|
||||
if len(rows) > 1:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Remove",
|
||||
f"Are you sure you want to remove {len(rows)} selected items from the queue?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
for row in rows:
|
||||
if 0 <= row < len(self.queue):
|
||||
del self.queue[row]
|
||||
self.process_queue()
|
||||
self.update_button_states()
|
||||
|
||||
def clear_queue(self):
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
if len(self.queue) > 1:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Clear Queue",
|
||||
f"Are you sure you want to clear {len(self.queue)} items from the queue?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self.queue.clear()
|
||||
self.listwidget.clear()
|
||||
self.empty_overlay.resize(
|
||||
self.listwidget.size()
|
||||
) # Ensure overlay is sized correctly
|
||||
self.empty_overlay.show() # Show the overlay when queue is empty
|
||||
self.update_button_states()
|
||||
|
||||
def get_queue(self):
|
||||
return self.queue
|
||||
|
||||
def get_current_attributes(self):
|
||||
# Fetch current attribute values from the parent abogen GUI
|
||||
attrs = {}
|
||||
parent = self.parent
|
||||
if parent is not None:
|
||||
# lang_code: use parent's get_voice_formula and get_selected_lang
|
||||
if hasattr(parent, "get_voice_formula") and hasattr(
|
||||
parent, "get_selected_lang"
|
||||
):
|
||||
voice_formula = parent.get_voice_formula()
|
||||
attrs["lang_code"] = parent.get_selected_lang(voice_formula)
|
||||
attrs["voice"] = voice_formula
|
||||
else:
|
||||
attrs["lang_code"] = getattr(parent, "selected_lang", "")
|
||||
attrs["voice"] = getattr(parent, "selected_voice", "")
|
||||
# speed
|
||||
if hasattr(parent, "speed_slider"):
|
||||
attrs["speed"] = parent.speed_slider.value() / 100.0
|
||||
else:
|
||||
attrs["speed"] = getattr(parent, "speed", 1.0)
|
||||
# save_option
|
||||
attrs["save_option"] = getattr(parent, "save_option", "")
|
||||
# output_folder
|
||||
attrs["output_folder"] = getattr(parent, "selected_output_folder", "")
|
||||
# subtitle_mode
|
||||
if hasattr(parent, "get_actual_subtitle_mode"):
|
||||
attrs["subtitle_mode"] = parent.get_actual_subtitle_mode()
|
||||
else:
|
||||
attrs["subtitle_mode"] = getattr(parent, "subtitle_mode", "")
|
||||
# output_format
|
||||
attrs["output_format"] = getattr(parent, "selected_format", "")
|
||||
# total_char_count
|
||||
attrs["total_char_count"] = getattr(parent, "char_count", "")
|
||||
# replace_single_newlines
|
||||
attrs["replace_single_newlines"] = getattr(
|
||||
parent, "replace_single_newlines", True
|
||||
)
|
||||
# use_silent_gaps
|
||||
attrs["use_silent_gaps"] = getattr(parent, "use_silent_gaps", False)
|
||||
# subtitle_speed_method
|
||||
attrs["subtitle_speed_method"] = getattr(
|
||||
parent, "subtitle_speed_method", "tts"
|
||||
)
|
||||
# book handler options
|
||||
attrs["save_chapters_separately"] = getattr(
|
||||
parent, "save_chapters_separately", None
|
||||
)
|
||||
attrs["merge_chapters_at_end"] = getattr(
|
||||
parent, "merge_chapters_at_end", None
|
||||
)
|
||||
else:
|
||||
# fallback: empty values
|
||||
attrs = {
|
||||
k: ""
|
||||
for k in [
|
||||
"lang_code",
|
||||
"speed",
|
||||
"voice",
|
||||
"save_option",
|
||||
"output_folder",
|
||||
"subtitle_mode",
|
||||
"output_format",
|
||||
"total_char_count",
|
||||
"replace_single_newlines",
|
||||
]
|
||||
}
|
||||
attrs["save_chapters_separately"] = None
|
||||
attrs["merge_chapters_at_end"] = None
|
||||
return attrs
|
||||
|
||||
def add_files_from_paths(self, file_paths):
|
||||
from abogen.subtitle_utils import calculate_text_length
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
import os
|
||||
|
||||
current_attrs = self.get_current_attributes()
|
||||
duplicates = []
|
||||
for file_path in file_paths:
|
||||
|
||||
class QueueItem:
|
||||
pass
|
||||
|
||||
item = QueueItem()
|
||||
item.file_name = file_path
|
||||
item.save_base_path = (
|
||||
file_path # For .txt files, processing and save paths are the same
|
||||
)
|
||||
for attr, value in current_attrs.items():
|
||||
setattr(item, attr, value)
|
||||
# Override subtitle_mode to "Disabled" for subtitle files
|
||||
if file_path.lower().endswith((".srt", ".ass", ".vtt")):
|
||||
item.subtitle_mode = "Disabled"
|
||||
# Read file content and calculate total_char_count using calculate_text_length
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
file_content = f.read()
|
||||
item.total_char_count = calculate_text_length(file_content)
|
||||
except Exception:
|
||||
item.total_char_count = 0
|
||||
# Prevent adding duplicate items to the queue (check all attributes)
|
||||
is_duplicate = False
|
||||
for queued_item in self.queue:
|
||||
if (
|
||||
getattr(queued_item, "file_name", None)
|
||||
== getattr(item, "file_name", None)
|
||||
and getattr(queued_item, "lang_code", None)
|
||||
== getattr(item, "lang_code", None)
|
||||
and getattr(queued_item, "speed", None)
|
||||
== getattr(item, "speed", None)
|
||||
and getattr(queued_item, "voice", None)
|
||||
== getattr(item, "voice", None)
|
||||
and getattr(queued_item, "save_option", None)
|
||||
== getattr(item, "save_option", None)
|
||||
and getattr(queued_item, "output_folder", None)
|
||||
== getattr(item, "output_folder", None)
|
||||
and getattr(queued_item, "subtitle_mode", None)
|
||||
== getattr(item, "subtitle_mode", None)
|
||||
and getattr(queued_item, "output_format", None)
|
||||
== getattr(item, "output_format", None)
|
||||
and getattr(queued_item, "total_char_count", None)
|
||||
== getattr(item, "total_char_count", None)
|
||||
and getattr(queued_item, "replace_single_newlines", True)
|
||||
== getattr(item, "replace_single_newlines", True)
|
||||
and getattr(queued_item, "use_silent_gaps", False)
|
||||
== getattr(item, "use_silent_gaps", False)
|
||||
and getattr(queued_item, "subtitle_speed_method", "tts")
|
||||
== getattr(item, "subtitle_speed_method", "tts")
|
||||
and getattr(queued_item, "save_base_path", None)
|
||||
== getattr(item, "save_base_path", None)
|
||||
and getattr(queued_item, "save_chapters_separately", None)
|
||||
== getattr(item, "save_chapters_separately", None)
|
||||
and getattr(queued_item, "merge_chapters_at_end", None)
|
||||
== getattr(item, "merge_chapters_at_end", None)
|
||||
):
|
||||
is_duplicate = True
|
||||
break
|
||||
if is_duplicate:
|
||||
duplicates.append(os.path.basename(file_path))
|
||||
continue
|
||||
self.queue.append(item)
|
||||
if duplicates:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Duplicate Item(s)",
|
||||
f"Skipping {len(duplicates)} file(s) with the same attributes, already in the queue.",
|
||||
)
|
||||
self.process_queue()
|
||||
self.update_button_states()
|
||||
|
||||
def add_more_files(self):
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
|
||||
# Allow .txt, .srt, .ass, and .vtt files
|
||||
files, _ = QFileDialog.getOpenFileNames(
|
||||
self,
|
||||
"Select text or subtitle files",
|
||||
"",
|
||||
"Supported Files (*.txt *.srt *.ass *.vtt)",
|
||||
)
|
||||
if not files:
|
||||
return
|
||||
self.add_files_from_paths(files)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, "empty_overlay"):
|
||||
self.empty_overlay.resize(self.listwidget.size())
|
||||
|
||||
def update_button_states(self):
|
||||
# Enable Remove if at least one item is selected, else disable
|
||||
if hasattr(self, "remove_button"):
|
||||
selected_count = len(self.listwidget.selectedItems())
|
||||
self.remove_button.setEnabled(selected_count > 0)
|
||||
if selected_count > 1:
|
||||
self.remove_button.setText(f"Remove selected ({selected_count})")
|
||||
else:
|
||||
self.remove_button.setText("Remove selected")
|
||||
# Disable Clear if queue is empty
|
||||
if hasattr(self, "clear_button"):
|
||||
self.clear_button.setEnabled(bool(self.queue))
|
||||
|
||||
def show_context_menu(self, pos):
|
||||
from PyQt6.QtWidgets import QMenu
|
||||
from PyQt6.QtGui import QAction, QDesktopServices
|
||||
from PyQt6.QtCore import QUrl
|
||||
import os
|
||||
|
||||
global_pos = self.listwidget.viewport().mapToGlobal(pos)
|
||||
selected_items = self.listwidget.selectedItems()
|
||||
menu = QMenu(self)
|
||||
if len(selected_items) == 1:
|
||||
# Add Remove action
|
||||
remove_action = QAction("Remove this item", self)
|
||||
remove_action.triggered.connect(self.remove_item)
|
||||
menu.addAction(remove_action)
|
||||
|
||||
# Get paths for determining if it's a document input
|
||||
item = selected_items[0]
|
||||
paths = item.data(Qt.ItemDataRole.UserRole)
|
||||
if isinstance(paths, dict):
|
||||
display_path = paths.get("display_path", "")
|
||||
processing_path = paths.get("processing_path", "")
|
||||
else:
|
||||
display_path = paths
|
||||
processing_path = paths
|
||||
|
||||
doc_exts = (".md", ".markdown", ".pdf", ".epub")
|
||||
is_document_input = (
|
||||
isinstance(display_path, str)
|
||||
and display_path.lower().endswith(doc_exts)
|
||||
) or (
|
||||
isinstance(processing_path, str)
|
||||
and processing_path.lower().endswith(doc_exts)
|
||||
)
|
||||
|
||||
# Add Open file action(s)
|
||||
def open_file_by_path(path_label: str):
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
p = display_path if path_label == "display" else processing_path
|
||||
if not p:
|
||||
QMessageBox.warning(
|
||||
self, "File Not Found", "Path is not available."
|
||||
)
|
||||
return
|
||||
|
||||
# Find the queue item and resolve the target path
|
||||
target_path = None
|
||||
for q in self.queue:
|
||||
if (
|
||||
getattr(q, "save_base_path", None) == display_path
|
||||
or q.file_name == display_path
|
||||
):
|
||||
if path_label == "display":
|
||||
target_path = (
|
||||
getattr(q, "save_base_path", None) or q.file_name
|
||||
)
|
||||
else:
|
||||
target_path = q.file_name
|
||||
break
|
||||
if (
|
||||
getattr(q, "save_base_path", None) == processing_path
|
||||
or q.file_name == processing_path
|
||||
):
|
||||
if path_label == "display":
|
||||
target_path = (
|
||||
getattr(q, "save_base_path", None) or q.file_name
|
||||
)
|
||||
else:
|
||||
target_path = q.file_name
|
||||
break
|
||||
|
||||
# Fallback to the raw path if resolution failed
|
||||
if not target_path:
|
||||
target_path = p
|
||||
|
||||
if not os.path.exists(target_path):
|
||||
QMessageBox.warning(
|
||||
self, "File Not Found", f"The file does not exist."
|
||||
)
|
||||
return
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(target_path))
|
||||
|
||||
if is_document_input:
|
||||
# For documents, show two open options
|
||||
open_processed_action = QAction("Open processed file", self)
|
||||
open_processed_action.triggered.connect(
|
||||
lambda: open_file_by_path("processing")
|
||||
)
|
||||
menu.addAction(open_processed_action)
|
||||
|
||||
open_input_action = QAction("Open input file", self)
|
||||
open_input_action.triggered.connect(
|
||||
lambda: open_file_by_path("display")
|
||||
)
|
||||
menu.addAction(open_input_action)
|
||||
else:
|
||||
# For plain text files, show single open option
|
||||
open_file_action = QAction("Open file", self)
|
||||
open_file_action.triggered.connect(lambda: open_file_by_path("display"))
|
||||
menu.addAction(open_file_action)
|
||||
|
||||
# Add Go to folder action
|
||||
# If the queued item represents a converted document (markdown, pdf, epub)
|
||||
# show two actions: Go to processed file (the cached .txt) and Go to input file (original source)
|
||||
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
def open_folder_for(path_label: str):
|
||||
# path_label should be either 'display' or 'processing'
|
||||
p = display_path if path_label == "display" else processing_path
|
||||
if not p:
|
||||
QMessageBox.warning(
|
||||
self, "File Not Found", "Path is not available."
|
||||
)
|
||||
return
|
||||
# If the stored path is the display path (original) but the actual file may be
|
||||
# stored on the queue object differently, try to resolve via the queue entry.
|
||||
target_path = None
|
||||
for q in self.queue:
|
||||
if (
|
||||
getattr(q, "save_base_path", None) == display_path
|
||||
or q.file_name == display_path
|
||||
):
|
||||
if path_label == "display":
|
||||
target_path = (
|
||||
getattr(q, "save_base_path", None) or q.file_name
|
||||
)
|
||||
else:
|
||||
target_path = q.file_name
|
||||
break
|
||||
if (
|
||||
getattr(q, "save_base_path", None) == processing_path
|
||||
or q.file_name == processing_path
|
||||
):
|
||||
if path_label == "display":
|
||||
target_path = (
|
||||
getattr(q, "save_base_path", None) or q.file_name
|
||||
)
|
||||
else:
|
||||
target_path = q.file_name
|
||||
break
|
||||
# Fallback to the raw path if resolution failed
|
||||
if not target_path:
|
||||
target_path = p
|
||||
|
||||
if not os.path.exists(target_path):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"File Not Found",
|
||||
f"The file does not exist: {target_path}",
|
||||
)
|
||||
return
|
||||
folder = os.path.dirname(target_path)
|
||||
if os.path.exists(folder):
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
|
||||
|
||||
if is_document_input:
|
||||
processed_action = QAction("Go to processed file", self)
|
||||
processed_action.triggered.connect(
|
||||
lambda: open_folder_for("processing")
|
||||
)
|
||||
menu.addAction(processed_action)
|
||||
|
||||
input_action = QAction("Go to input file", self)
|
||||
input_action.triggered.connect(lambda: open_folder_for("display"))
|
||||
menu.addAction(input_action)
|
||||
else:
|
||||
# Default behavior for non-document inputs: single "Go to folder" action
|
||||
go_to_folder_action = QAction("Go to folder", self)
|
||||
|
||||
def go_to_folder():
|
||||
item = selected_items[0]
|
||||
paths = item.data(Qt.ItemDataRole.UserRole)
|
||||
if isinstance(paths, dict):
|
||||
file_path = paths.get(
|
||||
"display_path", paths.get("processing_path", "")
|
||||
)
|
||||
else:
|
||||
file_path = paths # Fallback for old format
|
||||
# Find the queue item
|
||||
for q in self.queue:
|
||||
if (
|
||||
getattr(q, "save_base_path", None) == file_path
|
||||
or q.file_name == file_path
|
||||
):
|
||||
target_path = (
|
||||
getattr(q, "save_base_path", None) or q.file_name
|
||||
)
|
||||
if not os.path.exists(target_path):
|
||||
QMessageBox.warning(
|
||||
self, "File Not Found", f"The file does not exist."
|
||||
)
|
||||
return
|
||||
folder = os.path.dirname(target_path)
|
||||
if os.path.exists(folder):
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
|
||||
break
|
||||
|
||||
go_to_folder_action.triggered.connect(go_to_folder)
|
||||
menu.addAction(go_to_folder_action)
|
||||
|
||||
elif len(selected_items) > 1:
|
||||
remove_action = QAction(f"Remove selected ({len(selected_items)})", self)
|
||||
remove_action.triggered.connect(self.remove_item)
|
||||
menu.addAction(remove_action)
|
||||
# Always add Clear Queue
|
||||
clear_action = QAction("Clear Queue", self)
|
||||
clear_action.triggered.connect(self.clear_queue)
|
||||
menu.addAction(clear_action)
|
||||
menu.exec(global_pos)
|
||||
|
||||
def accept(self):
|
||||
# Save the override state to config so it persists globally
|
||||
self.config["queue_override_settings"] = self.override_chk.isChecked()
|
||||
save_config(self.config)
|
||||
|
||||
super().accept()
|
||||
|
||||
def reject(self):
|
||||
# Cancel: restore original queue
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
# Warn if user changed a lot (e.g., more than 1 items difference)
|
||||
original_count = len(self._original_queue)
|
||||
current_count = len(self.queue)
|
||||
if abs(original_count - current_count) > 1:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Cancel",
|
||||
f"Are you sure you want to cancel and discard all changes?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self.queue.clear()
|
||||
self.queue.extend(deepcopy(self._original_queue))
|
||||
super().reject()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
if event.key() == Qt.Key.Key_Delete:
|
||||
self.remove_item()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
@@ -0,0 +1,21 @@
|
||||
# represents a queued item - book, chapters, voice, etc.
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedItem:
|
||||
file_name: str
|
||||
lang_code: str
|
||||
speed: float
|
||||
voice: str
|
||||
save_option: str
|
||||
output_folder: str
|
||||
subtitle_mode: str
|
||||
output_format: str
|
||||
total_char_count: int
|
||||
replace_single_newlines: bool = True
|
||||
use_silent_gaps: bool = False
|
||||
subtitle_speed_method: str = "tts"
|
||||
save_base_path: str = None
|
||||
save_chapters_separately: bool = None
|
||||
merge_chapters_at_end: bool = None
|
||||
File diff suppressed because it is too large
Load Diff
+7
-856
@@ -1,860 +1,11 @@
|
||||
# a simple window with a list of items in the queue, no checkboxes
|
||||
# button to remove an item from the queue
|
||||
# button to clear the queue
|
||||
"""Backwards-compatible re-export of the PyQt queue manager.
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QDialogButtonBox,
|
||||
QPushButton,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QFileIconProvider,
|
||||
QLabel,
|
||||
QWidget,
|
||||
QSizePolicy,
|
||||
QAbstractItemView,
|
||||
QCheckBox,
|
||||
)
|
||||
from PyQt6.QtCore import QFileInfo, Qt
|
||||
from abogen.constants import COLORS
|
||||
from copy import deepcopy
|
||||
from PyQt6.QtGui import QFontMetrics
|
||||
from abogen.utils import load_config, save_config
|
||||
The actual implementation lives in abogen.pyqt.queue_manager_gui.
|
||||
"""
|
||||
|
||||
# Define attributes that are safe to override with global settings
|
||||
OVERRIDE_FIELDS = [
|
||||
"lang_code",
|
||||
"speed",
|
||||
"voice",
|
||||
"save_option",
|
||||
"output_folder",
|
||||
"subtitle_mode",
|
||||
"output_format",
|
||||
"replace_single_newlines",
|
||||
"use_silent_gaps",
|
||||
"subtitle_speed_method",
|
||||
]
|
||||
from __future__ import annotations
|
||||
|
||||
from abogen.pyqt.queue_manager_gui import * # noqa: F401, F403
|
||||
from abogen.pyqt.queue_manager_gui import QueueManager
|
||||
|
||||
class ElidedLabel(QLabel):
|
||||
def __init__(self, text):
|
||||
super().__init__(text)
|
||||
self._full_text = text
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self.setTextFormat(Qt.TextFormat.PlainText)
|
||||
|
||||
def setText(self, text):
|
||||
self._full_text = text
|
||||
super().setText(text)
|
||||
self.update()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
metrics = QFontMetrics(self.font())
|
||||
elided = metrics.elidedText(
|
||||
self._full_text, Qt.TextElideMode.ElideRight, self.width()
|
||||
)
|
||||
super().setText(elided)
|
||||
super().resizeEvent(event)
|
||||
|
||||
def fullText(self):
|
||||
return self._full_text
|
||||
|
||||
|
||||
class QueueListItemWidget(QWidget):
|
||||
def __init__(self, file_name, char_count):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(12, 0, 6, 0)
|
||||
layout.setSpacing(0)
|
||||
import os
|
||||
|
||||
name_label = ElidedLabel(os.path.basename(file_name))
|
||||
char_label = QLabel(f"Chars: {char_count}")
|
||||
char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};")
|
||||
char_label.setAlignment(
|
||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
char_label.setSizePolicy(
|
||||
QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred
|
||||
)
|
||||
layout.addWidget(name_label, 1)
|
||||
layout.addWidget(char_label, 0)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
class DroppableQueueListWidget(QListWidget):
|
||||
def __init__(self, parent_dialog):
|
||||
super().__init__()
|
||||
self.parent_dialog = parent_dialog
|
||||
self.setAcceptDrops(True)
|
||||
# Overlay for drag hover
|
||||
self.drag_overlay = QLabel("", self)
|
||||
self.drag_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.drag_overlay.setStyleSheet(
|
||||
f"border:2px dashed {COLORS['BLUE_BORDER_HOVER']}; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG_HOVER']};"
|
||||
)
|
||||
self.drag_overlay.setVisible(False)
|
||||
self.drag_overlay.setAttribute(
|
||||
Qt.WidgetAttribute.WA_TransparentForMouseEvents, True
|
||||
)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
for url in event.mimeData().urls():
|
||||
file_path = url.toLocalFile().lower()
|
||||
if url.isLocalFile() and (
|
||||
file_path.endswith(".txt")
|
||||
or file_path.endswith((".srt", ".ass", ".vtt"))
|
||||
):
|
||||
self.drag_overlay.resize(self.size())
|
||||
self.drag_overlay.setVisible(True)
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
self.drag_overlay.setVisible(False)
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
for url in event.mimeData().urls():
|
||||
file_path = url.toLocalFile().lower()
|
||||
if url.isLocalFile() and (
|
||||
file_path.endswith(".txt")
|
||||
or file_path.endswith((".srt", ".ass", ".vtt"))
|
||||
):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
def dragLeaveEvent(self, event):
|
||||
self.drag_overlay.setVisible(False)
|
||||
event.accept()
|
||||
|
||||
def dropEvent(self, event):
|
||||
self.drag_overlay.setVisible(False)
|
||||
if event.mimeData().hasUrls():
|
||||
file_paths = [
|
||||
url.toLocalFile()
|
||||
for url in event.mimeData().urls()
|
||||
if url.isLocalFile()
|
||||
and (
|
||||
url.toLocalFile().lower().endswith(".txt")
|
||||
or url.toLocalFile().lower().endswith((".srt", ".ass", ".vtt"))
|
||||
)
|
||||
]
|
||||
if file_paths:
|
||||
self.parent_dialog.add_files_from_paths(file_paths)
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, "drag_overlay"):
|
||||
self.drag_overlay.resize(self.size())
|
||||
|
||||
|
||||
class QueueManager(QDialog):
|
||||
def __init__(self, parent, queue: list, title="Queue Manager", size=(600, 700)):
|
||||
super().__init__()
|
||||
self.queue = queue
|
||||
self._original_queue = deepcopy(
|
||||
queue
|
||||
) # Store a deep copy of the original queue
|
||||
self.parent = parent
|
||||
self.config = load_config() # Load config for persistence
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(15, 15, 15, 15) # set main layout margins
|
||||
layout.setSpacing(12) # set spacing between widgets in main layout
|
||||
# list of queued items
|
||||
self.listwidget = DroppableQueueListWidget(self)
|
||||
self.listwidget.setSelectionMode(
|
||||
QAbstractItemView.SelectionMode.ExtendedSelection
|
||||
)
|
||||
self.listwidget.setAlternatingRowColors(True)
|
||||
self.listwidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.listwidget.customContextMenuRequested.connect(self.show_context_menu)
|
||||
# Add informative instructions at the top
|
||||
instructions = QLabel(
|
||||
"<h2>How Queue Works?</h2>"
|
||||
"You can add text and subtitle files (.txt, .srt, .ass, .vtt) directly using the '<b>Add files</b>' button below. "
|
||||
"To add PDF, EPUB or markdown files, use the input box in the main window and click the <b>'Add to Queue'</b> button. "
|
||||
"By default, each file in the queue keeps the configuration settings active when they were added. "
|
||||
"Enabling the <b>'Override item settings with current selection'</b> option below will force all items to use the configuration currently selected in the main window. "
|
||||
"You can view each file's configuration by hovering over them."
|
||||
)
|
||||
instructions.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
instructions.setWordWrap(True)
|
||||
layout.addWidget(instructions)
|
||||
|
||||
# Override Checkbox
|
||||
self.override_chk = QCheckBox("Override item settings with current selection")
|
||||
self.override_chk.setToolTip(
|
||||
"If checked, all items in the queue will be processed using the \n"
|
||||
"settings currently selected in the main window, ignoring their saved state."
|
||||
)
|
||||
# Load saved state (default to False)
|
||||
self.override_chk.setChecked(self.config.get("queue_override_settings", False))
|
||||
# Trigger process_queue to update tooltips immediately when toggled
|
||||
self.override_chk.stateChanged.connect(self.process_queue)
|
||||
self.override_chk.setStyleSheet("margin-bottom: 8px;")
|
||||
layout.addWidget(self.override_chk)
|
||||
|
||||
# Overlay label for empty queue
|
||||
self.empty_overlay = QLabel(
|
||||
"Drag and drop your text or subtitle files here or use the 'Add files' button.",
|
||||
self.listwidget,
|
||||
)
|
||||
self.empty_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.empty_overlay.setStyleSheet(
|
||||
f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;"
|
||||
)
|
||||
self.empty_overlay.setWordWrap(True)
|
||||
self.empty_overlay.setAttribute(
|
||||
Qt.WidgetAttribute.WA_TransparentForMouseEvents, True
|
||||
)
|
||||
self.empty_overlay.hide()
|
||||
# add queue items to the list
|
||||
self.process_queue()
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
button_row.setContentsMargins(0, 0, 0, 0) # optional: no margins for button row
|
||||
button_row.setSpacing(7) # set spacing between buttons
|
||||
# Add files button
|
||||
add_files_button = QPushButton("Add files")
|
||||
add_files_button.setFixedHeight(40)
|
||||
add_files_button.clicked.connect(self.add_more_files)
|
||||
button_row.addWidget(add_files_button)
|
||||
|
||||
# Remove button
|
||||
self.remove_button = QPushButton("Remove selected")
|
||||
self.remove_button.setFixedHeight(40)
|
||||
self.remove_button.clicked.connect(self.remove_item)
|
||||
button_row.addWidget(self.remove_button)
|
||||
|
||||
# Clear button
|
||||
self.clear_button = QPushButton("Clear Queue")
|
||||
self.clear_button.setFixedHeight(40)
|
||||
self.clear_button.clicked.connect(self.clear_queue)
|
||||
button_row.addWidget(self.clear_button)
|
||||
|
||||
layout.addLayout(button_row)
|
||||
layout.addWidget(self.listwidget)
|
||||
|
||||
# Connect selection change to update button state
|
||||
self.listwidget.currentItemChanged.connect(self.update_button_states)
|
||||
self.listwidget.itemSelectionChanged.connect(self.update_button_states)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
self,
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
layout.addWidget(buttons)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
self.setWindowTitle(title)
|
||||
self.resize(*size)
|
||||
|
||||
self.update_button_states()
|
||||
|
||||
def process_queue(self):
|
||||
"""Process the queue items."""
|
||||
import os
|
||||
|
||||
self.listwidget.clear()
|
||||
if not self.queue:
|
||||
self.empty_overlay.show()
|
||||
self.update_button_states()
|
||||
return
|
||||
else:
|
||||
self.empty_overlay.hide()
|
||||
|
||||
# Get current global settings and checkbox state for overrides
|
||||
current_global_settings = self.get_current_attributes()
|
||||
is_override_active = self.override_chk.isChecked()
|
||||
|
||||
icon_provider = QFileIconProvider()
|
||||
for item in self.queue:
|
||||
# Dynamic Attribute Retrieval Helper
|
||||
def get_val(attr, default=""):
|
||||
# If override is ON and attr is overrideable, use global setting
|
||||
if is_override_active and attr in OVERRIDE_FIELDS:
|
||||
return current_global_settings.get(attr, default)
|
||||
# Otherwise return the item's saved attribute
|
||||
return getattr(item, attr, default)
|
||||
|
||||
# Determine display file path (prefer save_base_path for original file)
|
||||
display_file_path = getattr(item, "save_base_path", None) or item.file_name
|
||||
processing_file_path = item.file_name
|
||||
|
||||
# Normalize paths for consistent display (fixes Windows path separator issues)
|
||||
display_file_path = (
|
||||
os.path.normpath(display_file_path)
|
||||
if display_file_path
|
||||
else display_file_path
|
||||
)
|
||||
processing_file_path = (
|
||||
os.path.normpath(processing_file_path)
|
||||
if processing_file_path
|
||||
else processing_file_path
|
||||
)
|
||||
|
||||
# Only show the file name, not the full path
|
||||
display_name = display_file_path
|
||||
|
||||
if os.path.sep in display_file_path:
|
||||
display_name = os.path.basename(display_file_path)
|
||||
# Get icon for the display file
|
||||
icon = icon_provider.icon(QFileInfo(display_file_path))
|
||||
list_item = QListWidgetItem()
|
||||
|
||||
# Tooltip Generation
|
||||
tooltip = ""
|
||||
# If override is active, add the warning header on its own line
|
||||
if is_override_active:
|
||||
tooltip += "<b style='color: #ff9900;'>(Global Override Active)</b><br>"
|
||||
|
||||
output_folder = get_val("output_folder")
|
||||
# For plain .txt inputs we don't need to show a separate processing file
|
||||
show_processing = True
|
||||
try:
|
||||
if isinstance(
|
||||
display_file_path, str
|
||||
) and display_file_path.lower().endswith(".txt"):
|
||||
show_processing = False
|
||||
except Exception:
|
||||
show_processing = True
|
||||
|
||||
tooltip += f"<b>Input File:</b> {display_file_path}<br>"
|
||||
if (
|
||||
show_processing
|
||||
and processing_file_path
|
||||
and processing_file_path != display_file_path
|
||||
):
|
||||
tooltip += f"<b>Processing File:</b> {processing_file_path}<br>"
|
||||
|
||||
tooltip += (
|
||||
f"<b>Language:</b> {get_val('lang_code')}<br>"
|
||||
f"<b>Speed:</b> {get_val('speed')}<br>"
|
||||
f"<b>Voice:</b> {get_val('voice')}<br>"
|
||||
f"<b>Save Option:</b> {get_val('save_option')}<br>"
|
||||
)
|
||||
if output_folder not in (None, "", "None"):
|
||||
tooltip += f"<b>Output Folder:</b> {output_folder}<br>"
|
||||
tooltip += (
|
||||
f"<b>Subtitle Mode:</b> {get_val('subtitle_mode')}<br>"
|
||||
f"<b>Output Format:</b> {get_val('output_format')}<br>"
|
||||
f"<b>Characters:</b> {getattr(item, 'total_char_count', '')}<br>"
|
||||
f"<b>Replace Single Newlines:</b> {get_val('replace_single_newlines', True)}<br>"
|
||||
f"<b>Use Silent Gaps:</b> {get_val('use_silent_gaps', False)}<br>"
|
||||
f"<b>Speed Method:</b> {get_val('subtitle_speed_method', 'tts')}"
|
||||
)
|
||||
# Add book handler options if present (Preserve logic: specific to file structure)
|
||||
save_chapters_separately = getattr(item, "save_chapters_separately", None)
|
||||
merge_chapters_at_end = getattr(item, "merge_chapters_at_end", None)
|
||||
if save_chapters_separately is not None:
|
||||
tooltip += f"<br><b>Save chapters separately:</b> {'Yes' if save_chapters_separately else 'No'}"
|
||||
# Only show merge option if saving chapters separately
|
||||
if save_chapters_separately and merge_chapters_at_end is not None:
|
||||
tooltip += f"<br><b>Merge chapters at the end:</b> {'Yes' if merge_chapters_at_end else 'No'}"
|
||||
list_item.setToolTip(tooltip)
|
||||
list_item.setIcon(icon)
|
||||
# Store both paths for context menu
|
||||
list_item.setData(
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{
|
||||
"display_path": display_file_path,
|
||||
"processing_path": processing_file_path,
|
||||
},
|
||||
)
|
||||
# Use custom widget for display
|
||||
char_count = getattr(item, "total_char_count", 0)
|
||||
widget = QueueListItemWidget(display_file_path, char_count)
|
||||
self.listwidget.addItem(list_item)
|
||||
self.listwidget.setItemWidget(list_item, widget)
|
||||
self.update_button_states()
|
||||
|
||||
def remove_item(self):
|
||||
items = self.listwidget.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
# Remove by index to ensure correct mapping
|
||||
rows = sorted([self.listwidget.row(item) for item in items], reverse=True)
|
||||
# Warn user if removing multiple files
|
||||
if len(rows) > 1:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Remove",
|
||||
f"Are you sure you want to remove {len(rows)} selected items from the queue?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
for row in rows:
|
||||
if 0 <= row < len(self.queue):
|
||||
del self.queue[row]
|
||||
self.process_queue()
|
||||
self.update_button_states()
|
||||
|
||||
def clear_queue(self):
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
if len(self.queue) > 1:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Clear Queue",
|
||||
f"Are you sure you want to clear {len(self.queue)} items from the queue?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self.queue.clear()
|
||||
self.listwidget.clear()
|
||||
self.empty_overlay.resize(
|
||||
self.listwidget.size()
|
||||
) # Ensure overlay is sized correctly
|
||||
self.empty_overlay.show() # Show the overlay when queue is empty
|
||||
self.update_button_states()
|
||||
|
||||
def get_queue(self):
|
||||
return self.queue
|
||||
|
||||
def get_current_attributes(self):
|
||||
# Fetch current attribute values from the parent abogen GUI
|
||||
attrs = {}
|
||||
parent = self.parent
|
||||
if parent is not None:
|
||||
# lang_code: use parent's get_voice_formula and get_selected_lang
|
||||
if hasattr(parent, "get_voice_formula") and hasattr(
|
||||
parent, "get_selected_lang"
|
||||
):
|
||||
voice_formula = parent.get_voice_formula()
|
||||
attrs["lang_code"] = parent.get_selected_lang(voice_formula)
|
||||
attrs["voice"] = voice_formula
|
||||
else:
|
||||
attrs["lang_code"] = getattr(parent, "selected_lang", "")
|
||||
attrs["voice"] = getattr(parent, "selected_voice", "")
|
||||
# speed
|
||||
if hasattr(parent, "speed_slider"):
|
||||
attrs["speed"] = parent.speed_slider.value() / 100.0
|
||||
else:
|
||||
attrs["speed"] = getattr(parent, "speed", 1.0)
|
||||
# save_option
|
||||
attrs["save_option"] = getattr(parent, "save_option", "")
|
||||
# output_folder
|
||||
attrs["output_folder"] = getattr(parent, "selected_output_folder", "")
|
||||
# subtitle_mode
|
||||
if hasattr(parent, "get_actual_subtitle_mode"):
|
||||
attrs["subtitle_mode"] = parent.get_actual_subtitle_mode()
|
||||
else:
|
||||
attrs["subtitle_mode"] = getattr(parent, "subtitle_mode", "")
|
||||
# output_format
|
||||
attrs["output_format"] = getattr(parent, "selected_format", "")
|
||||
# total_char_count
|
||||
attrs["total_char_count"] = getattr(parent, "char_count", "")
|
||||
# replace_single_newlines
|
||||
attrs["replace_single_newlines"] = getattr(
|
||||
parent, "replace_single_newlines", True
|
||||
)
|
||||
# use_silent_gaps
|
||||
attrs["use_silent_gaps"] = getattr(parent, "use_silent_gaps", False)
|
||||
# subtitle_speed_method
|
||||
attrs["subtitle_speed_method"] = getattr(
|
||||
parent, "subtitle_speed_method", "tts"
|
||||
)
|
||||
# book handler options
|
||||
attrs["save_chapters_separately"] = getattr(
|
||||
parent, "save_chapters_separately", None
|
||||
)
|
||||
attrs["merge_chapters_at_end"] = getattr(
|
||||
parent, "merge_chapters_at_end", None
|
||||
)
|
||||
else:
|
||||
# fallback: empty values
|
||||
attrs = {
|
||||
k: ""
|
||||
for k in [
|
||||
"lang_code",
|
||||
"speed",
|
||||
"voice",
|
||||
"save_option",
|
||||
"output_folder",
|
||||
"subtitle_mode",
|
||||
"output_format",
|
||||
"total_char_count",
|
||||
"replace_single_newlines",
|
||||
]
|
||||
}
|
||||
attrs["save_chapters_separately"] = None
|
||||
attrs["merge_chapters_at_end"] = None
|
||||
return attrs
|
||||
|
||||
def add_files_from_paths(self, file_paths):
|
||||
from abogen.subtitle_utils import calculate_text_length
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
import os
|
||||
|
||||
current_attrs = self.get_current_attributes()
|
||||
duplicates = []
|
||||
for file_path in file_paths:
|
||||
|
||||
class QueueItem:
|
||||
pass
|
||||
|
||||
item = QueueItem()
|
||||
item.file_name = file_path
|
||||
item.save_base_path = (
|
||||
file_path # For .txt files, processing and save paths are the same
|
||||
)
|
||||
for attr, value in current_attrs.items():
|
||||
setattr(item, attr, value)
|
||||
# Override subtitle_mode to "Disabled" for subtitle files
|
||||
if file_path.lower().endswith((".srt", ".ass", ".vtt")):
|
||||
item.subtitle_mode = "Disabled"
|
||||
# Read file content and calculate total_char_count using calculate_text_length
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
file_content = f.read()
|
||||
item.total_char_count = calculate_text_length(file_content)
|
||||
except Exception:
|
||||
item.total_char_count = 0
|
||||
# Prevent adding duplicate items to the queue (check all attributes)
|
||||
is_duplicate = False
|
||||
for queued_item in self.queue:
|
||||
if (
|
||||
getattr(queued_item, "file_name", None)
|
||||
== getattr(item, "file_name", None)
|
||||
and getattr(queued_item, "lang_code", None)
|
||||
== getattr(item, "lang_code", None)
|
||||
and getattr(queued_item, "speed", None)
|
||||
== getattr(item, "speed", None)
|
||||
and getattr(queued_item, "voice", None)
|
||||
== getattr(item, "voice", None)
|
||||
and getattr(queued_item, "save_option", None)
|
||||
== getattr(item, "save_option", None)
|
||||
and getattr(queued_item, "output_folder", None)
|
||||
== getattr(item, "output_folder", None)
|
||||
and getattr(queued_item, "subtitle_mode", None)
|
||||
== getattr(item, "subtitle_mode", None)
|
||||
and getattr(queued_item, "output_format", None)
|
||||
== getattr(item, "output_format", None)
|
||||
and getattr(queued_item, "total_char_count", None)
|
||||
== getattr(item, "total_char_count", None)
|
||||
and getattr(queued_item, "replace_single_newlines", True)
|
||||
== getattr(item, "replace_single_newlines", True)
|
||||
and getattr(queued_item, "use_silent_gaps", False)
|
||||
== getattr(item, "use_silent_gaps", False)
|
||||
and getattr(queued_item, "subtitle_speed_method", "tts")
|
||||
== getattr(item, "subtitle_speed_method", "tts")
|
||||
and getattr(queued_item, "save_base_path", None)
|
||||
== getattr(item, "save_base_path", None)
|
||||
and getattr(queued_item, "save_chapters_separately", None)
|
||||
== getattr(item, "save_chapters_separately", None)
|
||||
and getattr(queued_item, "merge_chapters_at_end", None)
|
||||
== getattr(item, "merge_chapters_at_end", None)
|
||||
):
|
||||
is_duplicate = True
|
||||
break
|
||||
if is_duplicate:
|
||||
duplicates.append(os.path.basename(file_path))
|
||||
continue
|
||||
self.queue.append(item)
|
||||
if duplicates:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Duplicate Item(s)",
|
||||
f"Skipping {len(duplicates)} file(s) with the same attributes, already in the queue.",
|
||||
)
|
||||
self.process_queue()
|
||||
self.update_button_states()
|
||||
|
||||
def add_more_files(self):
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
|
||||
# Allow .txt, .srt, .ass, and .vtt files
|
||||
files, _ = QFileDialog.getOpenFileNames(
|
||||
self,
|
||||
"Select text or subtitle files",
|
||||
"",
|
||||
"Supported Files (*.txt *.srt *.ass *.vtt)",
|
||||
)
|
||||
if not files:
|
||||
return
|
||||
self.add_files_from_paths(files)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, "empty_overlay"):
|
||||
self.empty_overlay.resize(self.listwidget.size())
|
||||
|
||||
def update_button_states(self):
|
||||
# Enable Remove if at least one item is selected, else disable
|
||||
if hasattr(self, "remove_button"):
|
||||
selected_count = len(self.listwidget.selectedItems())
|
||||
self.remove_button.setEnabled(selected_count > 0)
|
||||
if selected_count > 1:
|
||||
self.remove_button.setText(f"Remove selected ({selected_count})")
|
||||
else:
|
||||
self.remove_button.setText("Remove selected")
|
||||
# Disable Clear if queue is empty
|
||||
if hasattr(self, "clear_button"):
|
||||
self.clear_button.setEnabled(bool(self.queue))
|
||||
|
||||
def show_context_menu(self, pos):
|
||||
from PyQt6.QtWidgets import QMenu
|
||||
from PyQt6.QtGui import QAction, QDesktopServices
|
||||
from PyQt6.QtCore import QUrl
|
||||
import os
|
||||
|
||||
global_pos = self.listwidget.viewport().mapToGlobal(pos)
|
||||
selected_items = self.listwidget.selectedItems()
|
||||
menu = QMenu(self)
|
||||
if len(selected_items) == 1:
|
||||
# Add Remove action
|
||||
remove_action = QAction("Remove this item", self)
|
||||
remove_action.triggered.connect(self.remove_item)
|
||||
menu.addAction(remove_action)
|
||||
|
||||
# Get paths for determining if it's a document input
|
||||
item = selected_items[0]
|
||||
paths = item.data(Qt.ItemDataRole.UserRole)
|
||||
if isinstance(paths, dict):
|
||||
display_path = paths.get("display_path", "")
|
||||
processing_path = paths.get("processing_path", "")
|
||||
else:
|
||||
display_path = paths
|
||||
processing_path = paths
|
||||
|
||||
doc_exts = (".md", ".markdown", ".pdf", ".epub")
|
||||
is_document_input = (
|
||||
isinstance(display_path, str)
|
||||
and display_path.lower().endswith(doc_exts)
|
||||
) or (
|
||||
isinstance(processing_path, str)
|
||||
and processing_path.lower().endswith(doc_exts)
|
||||
)
|
||||
|
||||
# Add Open file action(s)
|
||||
def open_file_by_path(path_label: str):
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
p = display_path if path_label == "display" else processing_path
|
||||
if not p:
|
||||
QMessageBox.warning(
|
||||
self, "File Not Found", "Path is not available."
|
||||
)
|
||||
return
|
||||
|
||||
# Find the queue item and resolve the target path
|
||||
target_path = None
|
||||
for q in self.queue:
|
||||
if (
|
||||
getattr(q, "save_base_path", None) == display_path
|
||||
or q.file_name == display_path
|
||||
):
|
||||
if path_label == "display":
|
||||
target_path = (
|
||||
getattr(q, "save_base_path", None) or q.file_name
|
||||
)
|
||||
else:
|
||||
target_path = q.file_name
|
||||
break
|
||||
if (
|
||||
getattr(q, "save_base_path", None) == processing_path
|
||||
or q.file_name == processing_path
|
||||
):
|
||||
if path_label == "display":
|
||||
target_path = (
|
||||
getattr(q, "save_base_path", None) or q.file_name
|
||||
)
|
||||
else:
|
||||
target_path = q.file_name
|
||||
break
|
||||
|
||||
# Fallback to the raw path if resolution failed
|
||||
if not target_path:
|
||||
target_path = p
|
||||
|
||||
if not os.path.exists(target_path):
|
||||
QMessageBox.warning(
|
||||
self, "File Not Found", f"The file does not exist."
|
||||
)
|
||||
return
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(target_path))
|
||||
|
||||
if is_document_input:
|
||||
# For documents, show two open options
|
||||
open_processed_action = QAction("Open processed file", self)
|
||||
open_processed_action.triggered.connect(
|
||||
lambda: open_file_by_path("processing")
|
||||
)
|
||||
menu.addAction(open_processed_action)
|
||||
|
||||
open_input_action = QAction("Open input file", self)
|
||||
open_input_action.triggered.connect(
|
||||
lambda: open_file_by_path("display")
|
||||
)
|
||||
menu.addAction(open_input_action)
|
||||
else:
|
||||
# For plain text files, show single open option
|
||||
open_file_action = QAction("Open file", self)
|
||||
open_file_action.triggered.connect(lambda: open_file_by_path("display"))
|
||||
menu.addAction(open_file_action)
|
||||
|
||||
# Add Go to folder action
|
||||
# If the queued item represents a converted document (markdown, pdf, epub)
|
||||
# show two actions: Go to processed file (the cached .txt) and Go to input file (original source)
|
||||
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
def open_folder_for(path_label: str):
|
||||
# path_label should be either 'display' or 'processing'
|
||||
p = display_path if path_label == "display" else processing_path
|
||||
if not p:
|
||||
QMessageBox.warning(
|
||||
self, "File Not Found", "Path is not available."
|
||||
)
|
||||
return
|
||||
# If the stored path is the display path (original) but the actual file may be
|
||||
# stored on the queue object differently, try to resolve via the queue entry.
|
||||
target_path = None
|
||||
for q in self.queue:
|
||||
if (
|
||||
getattr(q, "save_base_path", None) == display_path
|
||||
or q.file_name == display_path
|
||||
):
|
||||
if path_label == "display":
|
||||
target_path = (
|
||||
getattr(q, "save_base_path", None) or q.file_name
|
||||
)
|
||||
else:
|
||||
target_path = q.file_name
|
||||
break
|
||||
if (
|
||||
getattr(q, "save_base_path", None) == processing_path
|
||||
or q.file_name == processing_path
|
||||
):
|
||||
if path_label == "display":
|
||||
target_path = (
|
||||
getattr(q, "save_base_path", None) or q.file_name
|
||||
)
|
||||
else:
|
||||
target_path = q.file_name
|
||||
break
|
||||
# Fallback to the raw path if resolution failed
|
||||
if not target_path:
|
||||
target_path = p
|
||||
|
||||
if not os.path.exists(target_path):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"File Not Found",
|
||||
f"The file does not exist: {target_path}",
|
||||
)
|
||||
return
|
||||
folder = os.path.dirname(target_path)
|
||||
if os.path.exists(folder):
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
|
||||
|
||||
if is_document_input:
|
||||
processed_action = QAction("Go to processed file", self)
|
||||
processed_action.triggered.connect(
|
||||
lambda: open_folder_for("processing")
|
||||
)
|
||||
menu.addAction(processed_action)
|
||||
|
||||
input_action = QAction("Go to input file", self)
|
||||
input_action.triggered.connect(lambda: open_folder_for("display"))
|
||||
menu.addAction(input_action)
|
||||
else:
|
||||
# Default behavior for non-document inputs: single "Go to folder" action
|
||||
go_to_folder_action = QAction("Go to folder", self)
|
||||
|
||||
def go_to_folder():
|
||||
item = selected_items[0]
|
||||
paths = item.data(Qt.ItemDataRole.UserRole)
|
||||
if isinstance(paths, dict):
|
||||
file_path = paths.get(
|
||||
"display_path", paths.get("processing_path", "")
|
||||
)
|
||||
else:
|
||||
file_path = paths # Fallback for old format
|
||||
# Find the queue item
|
||||
for q in self.queue:
|
||||
if (
|
||||
getattr(q, "save_base_path", None) == file_path
|
||||
or q.file_name == file_path
|
||||
):
|
||||
target_path = (
|
||||
getattr(q, "save_base_path", None) or q.file_name
|
||||
)
|
||||
if not os.path.exists(target_path):
|
||||
QMessageBox.warning(
|
||||
self, "File Not Found", f"The file does not exist."
|
||||
)
|
||||
return
|
||||
folder = os.path.dirname(target_path)
|
||||
if os.path.exists(folder):
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
|
||||
break
|
||||
|
||||
go_to_folder_action.triggered.connect(go_to_folder)
|
||||
menu.addAction(go_to_folder_action)
|
||||
|
||||
elif len(selected_items) > 1:
|
||||
remove_action = QAction(f"Remove selected ({len(selected_items)})", self)
|
||||
remove_action.triggered.connect(self.remove_item)
|
||||
menu.addAction(remove_action)
|
||||
# Always add Clear Queue
|
||||
clear_action = QAction("Clear Queue", self)
|
||||
clear_action.triggered.connect(self.clear_queue)
|
||||
menu.addAction(clear_action)
|
||||
menu.exec(global_pos)
|
||||
|
||||
def accept(self):
|
||||
# Save the override state to config so it persists globally
|
||||
self.config["queue_override_settings"] = self.override_chk.isChecked()
|
||||
save_config(self.config)
|
||||
|
||||
super().accept()
|
||||
|
||||
def reject(self):
|
||||
# Cancel: restore original queue
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
# Warn if user changed a lot (e.g., more than 1 items difference)
|
||||
original_count = len(self._original_queue)
|
||||
current_count = len(self.queue)
|
||||
if abs(original_count - current_count) > 1:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Cancel",
|
||||
f"Are you sure you want to cancel and discard all changes?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self.queue.clear()
|
||||
self.queue.extend(deepcopy(self._original_queue))
|
||||
super().reject()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
if event.key() == Qt.Key.Key_Delete:
|
||||
self.remove_item()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
__all__ = ["QueueManager"]
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
try: # pragma: no cover - optional dependency
|
||||
import spacy
|
||||
except Exception: # pragma: no cover - spaCy unavailable at runtime
|
||||
spacy = None
|
||||
|
||||
# Lazy spaCy type hints to avoid a hard dependency at import time.
|
||||
Language = Any # type: ignore[assignment]
|
||||
Token = Any # type: ignore[assignment]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContractionResolution:
|
||||
start: int
|
||||
end: int
|
||||
surface: str
|
||||
expansion: str
|
||||
category: str
|
||||
lemma: str
|
||||
|
||||
@property
|
||||
def span(self) -> Tuple[int, int]:
|
||||
return self.start, self.end
|
||||
|
||||
|
||||
_DEFAULT_MODEL = os.environ.get("ABOGEN_SPACY_MODEL", "en_core_web_sm")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_spacy_model(model: str = _DEFAULT_MODEL) -> Optional[Language]:
|
||||
if spacy is None:
|
||||
logger.debug("spaCy is not installed; skipping contraction disambiguation")
|
||||
return None
|
||||
|
||||
try:
|
||||
nlp = spacy.load(model)
|
||||
except Exception as exc: # pragma: no cover - depends on environment
|
||||
logger.warning("Failed to load spaCy model '%s': %s", model, exc)
|
||||
return None
|
||||
return nlp
|
||||
|
||||
|
||||
def resolve_ambiguous_contractions(text: str, *, model: Optional[str] = None) -> Dict[Tuple[int, int], ContractionResolution]:
|
||||
"""Use spaCy to disambiguate ambiguous contractions in *text*.
|
||||
|
||||
Returns a mapping from (start, end) spans to their resolved expansion.
|
||||
Only ambiguous `'s` and `'d` contractions are considered.
|
||||
"""
|
||||
if not text:
|
||||
return {}
|
||||
|
||||
nlp = _load_spacy_model(model or _DEFAULT_MODEL)
|
||||
if nlp is None:
|
||||
return {}
|
||||
|
||||
doc = nlp(text)
|
||||
resolutions: Dict[Tuple[int, int], ContractionResolution] = {}
|
||||
for token in doc:
|
||||
if token.text == "'s":
|
||||
resolution = _resolve_apostrophe_s(token)
|
||||
elif token.text == "'d":
|
||||
resolution = _resolve_apostrophe_d(token)
|
||||
else:
|
||||
resolution = None
|
||||
|
||||
if resolution is None:
|
||||
continue
|
||||
|
||||
if resolution.span not in resolutions:
|
||||
resolutions[resolution.span] = resolution
|
||||
return resolutions
|
||||
|
||||
|
||||
def _resolution(prev: Token, token: Token, expansion_word: str, category: str, lemma_hint: str) -> Optional[ContractionResolution]:
|
||||
if token is None or prev is None:
|
||||
return None
|
||||
|
||||
if prev.idx + len(prev.text) != token.idx:
|
||||
# Not a contiguous contraction (whitespace or punctuation in between)
|
||||
return None
|
||||
|
||||
surface_start = prev.idx
|
||||
surface_end = token.idx + len(token.text)
|
||||
surface_text = token.doc.text[surface_start:surface_end]
|
||||
|
||||
expansion = _assemble_expansion(prev.text, surface_text, expansion_word)
|
||||
return ContractionResolution(
|
||||
start=surface_start,
|
||||
end=surface_end,
|
||||
surface=surface_text,
|
||||
expansion=expansion,
|
||||
category=category,
|
||||
lemma=lemma_hint,
|
||||
)
|
||||
|
||||
|
||||
def _assemble_expansion(base_text: str, surface_text: str, expansion_word: str) -> str:
|
||||
"""Combine *base_text* with *expansion_word*, preserving coarse casing."""
|
||||
if not expansion_word:
|
||||
return base_text
|
||||
|
||||
if surface_text.isupper() and expansion_word.isalpha():
|
||||
adjusted = expansion_word.upper()
|
||||
elif len(surface_text) > 2 and surface_text[:-2].istitle() and expansion_word:
|
||||
# Surface like "It's" -> keep appended word lowercase
|
||||
adjusted = expansion_word.lower()
|
||||
else:
|
||||
adjusted = expansion_word
|
||||
|
||||
return f"{base_text} {adjusted}".strip()
|
||||
|
||||
|
||||
def _resolve_apostrophe_s(token: Token) -> Optional[ContractionResolution]:
|
||||
prev = token.nbor(-1) if token.i > 0 else None
|
||||
if prev is None:
|
||||
return None
|
||||
|
||||
# Possessive marker e.g., dog's
|
||||
if token.tag_ == "POS" or token.lemma_ == "'s":
|
||||
return None
|
||||
|
||||
prev_lower = prev.lemma_.lower()
|
||||
surface = token.doc.text[prev.idx : token.idx + len(token.text)]
|
||||
|
||||
if prev_lower == "let":
|
||||
return _resolution(prev, token, "us", "contraction_let_us", "us")
|
||||
|
||||
# Special check for 's been -> has been, overriding lemma
|
||||
next_content = _next_content_token(token)
|
||||
if next_content and next_content.text.lower() == "been":
|
||||
return _resolution(prev, token, "has", "contraction_aux_have", "have")
|
||||
|
||||
lemma = token.lemma_.lower()
|
||||
if not lemma:
|
||||
lemma = "be" if _favors_be(token) else "have" if _favors_have(token) else "be"
|
||||
|
||||
if lemma == "be":
|
||||
return _resolution(prev, token, "is", "contraction_aux_be", "be")
|
||||
if lemma == "have":
|
||||
return _resolution(prev, token, "has", "contraction_aux_have", "have")
|
||||
|
||||
if _favors_have(token):
|
||||
return _resolution(prev, token, "has", "contraction_aux_have", "have")
|
||||
|
||||
if _favors_be(token):
|
||||
return _resolution(prev, token, "is", "contraction_aux_be", "be")
|
||||
|
||||
# Default to copula expansion.
|
||||
return _resolution(prev, token, "is", "contraction_aux_be", lemma or "be")
|
||||
|
||||
|
||||
def _resolve_apostrophe_d(token: Token) -> Optional[ContractionResolution]:
|
||||
prev = token.nbor(-1) if token.i > 0 else None
|
||||
if prev is None:
|
||||
return None
|
||||
|
||||
if token.morph.get("VerbForm") == ["Part"]:
|
||||
# spaCy sometimes tags possessives oddly; guard anyway
|
||||
return None
|
||||
|
||||
lemma = token.lemma_.lower()
|
||||
tense = set(token.morph.get("Tense"))
|
||||
next_content = _next_content_token(token)
|
||||
prefers_had = _context_prefers_had(token)
|
||||
|
||||
if prefers_had:
|
||||
return _resolution(prev, token, "had", "contraction_aux_have", "have")
|
||||
|
||||
if "Past" in tense and lemma in {"have", "had"}:
|
||||
return _resolution(prev, token, "had", "contraction_aux_have", "have")
|
||||
|
||||
if next_content is not None:
|
||||
next_tag = next_content.tag_
|
||||
next_lemma = next_content.lemma_.lower()
|
||||
else:
|
||||
next_tag = ""
|
||||
next_lemma = ""
|
||||
|
||||
if next_tag == "VB":
|
||||
return _resolution(prev, token, "would", "contraction_modal_would", lemma or "will")
|
||||
|
||||
if token.tag_ == "MD" or lemma in {"will", "would", "shall"}:
|
||||
return _resolution(prev, token, "would", "contraction_modal_would", lemma or "will")
|
||||
|
||||
if next_lemma in {"been", "gone", "had", "better"} or next_tag in {"VBN", "VBD"}:
|
||||
return _resolution(prev, token, "had", "contraction_aux_have", "have")
|
||||
|
||||
if lemma in {"have", "had"}:
|
||||
return _resolution(prev, token, "had", "contraction_aux_have", lemma)
|
||||
|
||||
return _resolution(prev, token, "would", "contraction_modal_would", lemma or "will")
|
||||
|
||||
|
||||
def _next_content_token(token: Token) -> Optional[Token]:
|
||||
doc = token.doc
|
||||
for candidate in doc[token.i + 1 :]:
|
||||
if candidate.is_space:
|
||||
continue
|
||||
if candidate.is_punct and candidate.text not in {"-"}:
|
||||
break
|
||||
if candidate.text in {"'", ""}:
|
||||
continue
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _favors_have(token: Token) -> bool:
|
||||
next_content = _next_content_token(token)
|
||||
if next_content is None:
|
||||
return False
|
||||
if next_content.tag_ in {"VBN"}:
|
||||
return True
|
||||
if next_content.lemma_.lower() in {"been", "gone", "had"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _favors_be(token: Token) -> bool:
|
||||
next_content = _next_content_token(token)
|
||||
if next_content is None:
|
||||
return True
|
||||
if next_content.tag_ in {"VBG", "JJ", "RB", "DT", "IN"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _context_prefers_had(token: Token) -> bool:
|
||||
head = token.head if token.head is not None else None
|
||||
if head is not None and head.i > token.i:
|
||||
head_tag = head.tag_
|
||||
head_lemma = head.lemma_.lower()
|
||||
if head_tag in {"VBN", "VBD"} or head_lemma in {"gone", "been", "had"}:
|
||||
return True
|
||||
if head_lemma == "better":
|
||||
return True
|
||||
|
||||
next_content = _next_content_token(token)
|
||||
if next_content is None:
|
||||
return False
|
||||
next_tag = next_content.tag_
|
||||
next_lemma = next_content.lemma_.lower()
|
||||
if next_tag in {"VBN", "VBD"}:
|
||||
return True
|
||||
if next_lemma in {"been", "gone", "had"}:
|
||||
return True
|
||||
if next_lemma == "better":
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import unicodedata
|
||||
|
||||
_DIALOGUE_VERBS = (
|
||||
"said",
|
||||
"asked",
|
||||
"replied",
|
||||
"whispered",
|
||||
"shouted",
|
||||
"cried",
|
||||
"muttered",
|
||||
"answered",
|
||||
"hissed",
|
||||
"called",
|
||||
"added",
|
||||
"continued",
|
||||
"insisted",
|
||||
"remarked",
|
||||
"yelled",
|
||||
"breathed",
|
||||
"murmured",
|
||||
"exclaimed",
|
||||
"explained",
|
||||
"noted",
|
||||
)
|
||||
|
||||
_VERB_PATTERN = "(?:" + "|".join(_DIALOGUE_VERBS) + ")"
|
||||
_NAME_FRAGMENT = r"[A-ZÀ-ÖØ-Þ][\w'’\-]*"
|
||||
_NAME_PATTERN = rf"{_NAME_FRAGMENT}(?:\s+{_NAME_FRAGMENT})*"
|
||||
|
||||
_COLON_PATTERN = re.compile(rf"^\s*({_NAME_PATTERN})\s*:\s*(.+)$")
|
||||
_NAME_BEFORE_VERB = re.compile(rf"({_NAME_PATTERN})\s+{_VERB_PATTERN}\b", re.IGNORECASE)
|
||||
_VERB_BEFORE_NAME = re.compile(rf"{_VERB_PATTERN}\s+({_NAME_PATTERN})", re.IGNORECASE)
|
||||
_PRONOUN_PATTERN = re.compile(r"\b(?:he|she|they)\b", re.IGNORECASE)
|
||||
_QUOTE_PATTERN = re.compile(r'["“”]([^"“”\\]*(?:\\.[^"“”\\]*)*)["”]')
|
||||
_MALE_PRONOUN_PATTERN = re.compile(r"\b(?:he|him|his|himself)\b", re.IGNORECASE)
|
||||
_FEMALE_PRONOUN_PATTERN = re.compile(r"\b(?:she|her|hers|herself)\b", re.IGNORECASE)
|
||||
_PRONOUN_LABELS = {
|
||||
"he",
|
||||
"she",
|
||||
"they",
|
||||
"them",
|
||||
"theirs",
|
||||
"their",
|
||||
"themselves",
|
||||
"him",
|
||||
"his",
|
||||
"himself",
|
||||
"her",
|
||||
"hers",
|
||||
"herself",
|
||||
"we",
|
||||
"us",
|
||||
"our",
|
||||
"ours",
|
||||
"ourselves",
|
||||
"i",
|
||||
"me",
|
||||
"my",
|
||||
"mine",
|
||||
"myself",
|
||||
"you",
|
||||
"your",
|
||||
"yours",
|
||||
"yourself",
|
||||
"yourselves",
|
||||
}
|
||||
|
||||
_CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3}
|
||||
|
||||
_FEMALE_TITLE_HINTS = (
|
||||
"madame",
|
||||
"mme",
|
||||
"madam",
|
||||
"mrs",
|
||||
"miss",
|
||||
"ms",
|
||||
"lady",
|
||||
"countess",
|
||||
"baroness",
|
||||
"princess",
|
||||
"queen",
|
||||
"mademoiselle",
|
||||
)
|
||||
|
||||
_MALE_TITLE_HINTS = (
|
||||
"monsieur",
|
||||
"m.",
|
||||
"mr",
|
||||
"sir",
|
||||
"lord",
|
||||
"count",
|
||||
"baron",
|
||||
"prince",
|
||||
"king",
|
||||
"abbé",
|
||||
"abbe",
|
||||
)
|
||||
|
||||
_MALE_TOKEN_WEIGHTS = {
|
||||
"he": 1.0,
|
||||
"him": 0.6,
|
||||
"his": 0.75,
|
||||
"himself": 1.0,
|
||||
}
|
||||
|
||||
_FEMALE_TOKEN_WEIGHTS = {
|
||||
"she": 1.0,
|
||||
"her": 0.4,
|
||||
"hers": 0.75,
|
||||
"herself": 1.0,
|
||||
}
|
||||
|
||||
_STOP_LABELS = {
|
||||
"and",
|
||||
"but",
|
||||
"then",
|
||||
"though",
|
||||
"meanwhile",
|
||||
"therefore",
|
||||
"after",
|
||||
"before",
|
||||
"when",
|
||||
"while",
|
||||
"because",
|
||||
"as",
|
||||
"yet",
|
||||
"nor",
|
||||
"so",
|
||||
"thus",
|
||||
"suddenly",
|
||||
"eventually",
|
||||
"finally",
|
||||
"until",
|
||||
"unless",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SpeakerGuess:
|
||||
speaker_id: str
|
||||
label: str
|
||||
count: int = 0
|
||||
confidence: str = "low"
|
||||
sample_quotes: List[Dict[str, str]] = field(default_factory=list)
|
||||
suppressed: bool = False
|
||||
gender: str = "unknown"
|
||||
detected_gender: str = "unknown"
|
||||
male_votes: int = 0
|
||||
female_votes: int = 0
|
||||
|
||||
def register_occurrence(
|
||||
self,
|
||||
confidence: str,
|
||||
text: str,
|
||||
quote: Optional[str],
|
||||
male_votes: int,
|
||||
female_votes: int,
|
||||
sample_excerpt: Optional[str] = None,
|
||||
) -> None:
|
||||
self.count += 1
|
||||
if _CONFIDENCE_RANK.get(confidence, 0) > _CONFIDENCE_RANK.get(self.confidence, 0):
|
||||
self.confidence = confidence
|
||||
|
||||
excerpt = sample_excerpt if sample_excerpt is not None else _build_excerpt(text, quote)
|
||||
gender_hint = _format_gender_hint(male_votes, female_votes)
|
||||
if excerpt:
|
||||
payload = {"excerpt": excerpt, "gender_hint": gender_hint}
|
||||
if payload not in self.sample_quotes:
|
||||
self.sample_quotes.append(payload)
|
||||
if len(self.sample_quotes) > 3:
|
||||
self.sample_quotes = self.sample_quotes[:3]
|
||||
|
||||
if male_votes:
|
||||
self.male_votes += male_votes
|
||||
if female_votes:
|
||||
self.female_votes += female_votes
|
||||
self.detected_gender = _derive_gender(self.male_votes, self.female_votes, self.detected_gender)
|
||||
if self.gender in {"unknown", "male", "female"}:
|
||||
self.gender = _derive_gender(self.male_votes, self.female_votes, self.gender)
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.speaker_id,
|
||||
"label": self.label,
|
||||
"count": self.count,
|
||||
"confidence": self.confidence,
|
||||
"sample_quotes": [dict(sample) for sample in self.sample_quotes],
|
||||
"suppressed": self.suppressed,
|
||||
"gender": self.gender,
|
||||
"detected_gender": self.detected_gender,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SpeakerAnalysis:
|
||||
assignments: Dict[str, str]
|
||||
speakers: Dict[str, SpeakerGuess]
|
||||
suppressed: List[str]
|
||||
narrator: str = "narrator"
|
||||
version: str = "1.0"
|
||||
stats: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"version": self.version,
|
||||
"narrator": self.narrator,
|
||||
"assignments": dict(self.assignments),
|
||||
"speakers": {speaker_id: guess.as_dict() for speaker_id, guess in self.speakers.items()},
|
||||
"suppressed": list(self.suppressed),
|
||||
"stats": dict(self.stats),
|
||||
}
|
||||
|
||||
|
||||
def analyze_speakers(
|
||||
chapters: Sequence[Dict[str, Any]] | Iterable[Dict[str, Any]],
|
||||
chunks: Sequence[Dict[str, Any]] | Iterable[Dict[str, Any]],
|
||||
*,
|
||||
threshold: int = 3,
|
||||
max_speakers: int = 8,
|
||||
) -> SpeakerAnalysis:
|
||||
narrator_id = "narrator"
|
||||
speaker_guesses: Dict[str, SpeakerGuess] = {
|
||||
narrator_id: SpeakerGuess(speaker_id=narrator_id, label="Narrator", confidence="low")
|
||||
}
|
||||
label_index: Dict[str, str] = {"Narrator": narrator_id}
|
||||
assignments: Dict[str, str] = {}
|
||||
suppressed: List[str] = []
|
||||
|
||||
ordered_chunks = sorted(
|
||||
(dict(chunk) for chunk in chunks),
|
||||
key=lambda entry: (
|
||||
_safe_int(entry.get("chapter_index")),
|
||||
_safe_int(entry.get("chunk_index")),
|
||||
),
|
||||
)
|
||||
last_explicit: Optional[str] = None
|
||||
explicit_assignments = 0
|
||||
unique_speakers: set[str] = set()
|
||||
|
||||
for index, chunk in enumerate(ordered_chunks):
|
||||
chunk_id = str(chunk.get("id") or "")
|
||||
text = _get_chunk_text(chunk)
|
||||
speaker_id, confidence, quote = _infer_chunk_speaker(text, last_explicit)
|
||||
if speaker_id is None:
|
||||
speaker_id = last_explicit or narrator_id
|
||||
confidence = "medium" if last_explicit else "low"
|
||||
quote = quote or _extract_quote(text)
|
||||
if speaker_id != narrator_id:
|
||||
last_explicit = speaker_id
|
||||
explicit_assignments += 1
|
||||
|
||||
if speaker_id in speaker_guesses:
|
||||
record_id = speaker_id
|
||||
guess = speaker_guesses[record_id]
|
||||
label = guess.label
|
||||
else:
|
||||
label = _normalize_label(speaker_id)
|
||||
record_id = label_index.get(label)
|
||||
if record_id is None:
|
||||
record_id = _dedupe_slug(_slugify(label), speaker_guesses)
|
||||
label_index[label] = record_id
|
||||
speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label)
|
||||
guess = speaker_guesses[record_id]
|
||||
assignments[chunk_id] = record_id
|
||||
unique_speakers.add(record_id)
|
||||
|
||||
if record_id != narrator_id and record_id != speaker_id and speaker_id == last_explicit:
|
||||
last_explicit = record_id
|
||||
|
||||
sample_excerpt = None
|
||||
if record_id != narrator_id:
|
||||
sample_excerpt = _select_sample_excerpt(ordered_chunks, index, guess.label, quote, confidence)
|
||||
|
||||
male_votes, female_votes = _count_gender_votes(text, guess.label)
|
||||
|
||||
guess.register_occurrence(confidence, text, quote, male_votes, female_votes, sample_excerpt)
|
||||
|
||||
active_speakers = [sid for sid in speaker_guesses if sid != narrator_id]
|
||||
# Apply minimum occurrence threshold.
|
||||
for speaker_id in list(active_speakers):
|
||||
guess = speaker_guesses[speaker_id]
|
||||
if guess.count < max(1, threshold):
|
||||
guess.suppressed = True
|
||||
suppressed.append(speaker_id)
|
||||
_reassign(assignments, speaker_id, narrator_id)
|
||||
active_speakers.remove(speaker_id)
|
||||
|
||||
# Apply maximum active speaker cap.
|
||||
if max_speakers and len(active_speakers) > max_speakers:
|
||||
active_speakers.sort(key=lambda sid: (-speaker_guesses[sid].count, sid))
|
||||
for speaker_id in active_speakers[max_speakers:]:
|
||||
guess = speaker_guesses[speaker_id]
|
||||
guess.suppressed = True
|
||||
suppressed.append(speaker_id)
|
||||
_reassign(assignments, speaker_id, narrator_id)
|
||||
active_speakers = active_speakers[:max_speakers]
|
||||
|
||||
narrator_guess = speaker_guesses[narrator_id]
|
||||
narrator_guess.count = sum(1 for value in assignments.values() if value == narrator_id)
|
||||
narrator_guess.confidence = "low"
|
||||
|
||||
stats = {
|
||||
"total_chunks": len(ordered_chunks),
|
||||
"explicit_chunks": explicit_assignments,
|
||||
"active_speakers": len(active_speakers),
|
||||
"unique_speakers": len(unique_speakers),
|
||||
"suppressed": len(suppressed),
|
||||
}
|
||||
|
||||
return SpeakerAnalysis(
|
||||
assignments=assignments,
|
||||
speakers=speaker_guesses,
|
||||
suppressed=suppressed,
|
||||
narrator=narrator_id,
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
|
||||
def _infer_chunk_speaker(text: str, last_explicit: Optional[str]) -> Tuple[Optional[str], str, Optional[str]]:
|
||||
normalized = text.strip()
|
||||
if not normalized:
|
||||
return None, "low", None
|
||||
|
||||
colon_match = _COLON_PATTERN.match(normalized)
|
||||
if colon_match:
|
||||
raw_label = colon_match.group(1)
|
||||
cleaned = _normalize_candidate_name(raw_label)
|
||||
if cleaned is None:
|
||||
return None, "low", colon_match.group(2).strip()
|
||||
quote = colon_match.group(2).strip()
|
||||
return cleaned, "high", quote
|
||||
|
||||
quote = _extract_quote(normalized)
|
||||
if not quote:
|
||||
return None, "low", None
|
||||
|
||||
before, after = _split_around_quote(normalized, quote)
|
||||
|
||||
candidate = _match_name_near_quote(before, after)
|
||||
if candidate:
|
||||
cleaned = _normalize_candidate_name(candidate)
|
||||
if cleaned:
|
||||
return cleaned, "high", quote
|
||||
|
||||
if last_explicit:
|
||||
pronoun_after = _PRONOUN_PATTERN.search(after)
|
||||
pronoun_before = _PRONOUN_PATTERN.search(before)
|
||||
if pronoun_after or pronoun_before:
|
||||
return last_explicit, "medium", quote
|
||||
|
||||
return None, "low", quote
|
||||
|
||||
|
||||
def _split_around_quote(text: str, quote: str) -> Tuple[str, str]:
|
||||
quote_index = text.find(quote)
|
||||
if quote_index == -1:
|
||||
return text, ""
|
||||
before = text[:quote_index]
|
||||
after = text[quote_index + len(quote) :]
|
||||
return before, after
|
||||
|
||||
|
||||
def _match_name_near_quote(before: str, after: str) -> Optional[str]:
|
||||
trailing = before[-120:]
|
||||
leading = after[:120]
|
||||
|
||||
match = _NAME_BEFORE_VERB.search(trailing)
|
||||
if match:
|
||||
name = match.group(1)
|
||||
if _looks_like_name(name):
|
||||
return name
|
||||
|
||||
match = re.search(rf"({_NAME_PATTERN})\s*,?\s*{_VERB_PATTERN}", leading, flags=re.IGNORECASE)
|
||||
if match:
|
||||
name = match.group(1)
|
||||
if _looks_like_name(name):
|
||||
return name
|
||||
|
||||
match = _VERB_BEFORE_NAME.search(leading)
|
||||
if match:
|
||||
name = match.group(1)
|
||||
if _looks_like_name(name):
|
||||
return name
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_name(value: str) -> bool:
|
||||
normalized = _normalize_candidate_name(value)
|
||||
if not normalized:
|
||||
return False
|
||||
parts = normalized.split()
|
||||
if not parts:
|
||||
return False
|
||||
return all(part and part[0].isupper() for part in parts)
|
||||
|
||||
|
||||
def _extract_quote(text: str) -> Optional[str]:
|
||||
match = _QUOTE_PATTERN.search(text)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def _slugify(label: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_")
|
||||
return slug or "speaker"
|
||||
|
||||
|
||||
def _dedupe_slug(slug: str, existing: Dict[str, SpeakerGuess]) -> str:
|
||||
candidate = slug
|
||||
index = 2
|
||||
while candidate in existing:
|
||||
candidate = f"{slug}_{index}"
|
||||
index += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _normalize_label(label: str) -> str:
|
||||
words = re.split(r"\s+", label.strip())
|
||||
return " ".join(word.capitalize() for word in words if word)
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _reassign(assignments: Dict[str, str], old: str, new: str) -> None:
|
||||
for key, value in list(assignments.items()):
|
||||
if value == old:
|
||||
assignments[key] = new
|
||||
|
||||
|
||||
def _strip_diacritics(value: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", value)
|
||||
return "".join(char for char in normalized if not unicodedata.combining(char))
|
||||
|
||||
|
||||
def _count_gender_votes(text: str, label: Optional[str]) -> Tuple[int, int]:
|
||||
if not text:
|
||||
return 0, 0
|
||||
|
||||
search_text = text
|
||||
windows: List[Tuple[int, int]] = []
|
||||
degrade_factor = 1.0
|
||||
|
||||
if label:
|
||||
pattern = re.compile(re.escape(label), re.IGNORECASE)
|
||||
matches = list(pattern.finditer(search_text))
|
||||
if not matches:
|
||||
alt_label = _strip_diacritics(label)
|
||||
if alt_label and alt_label != label:
|
||||
ascii_text = _strip_diacritics(search_text)
|
||||
pattern_alt = re.compile(re.escape(alt_label), re.IGNORECASE)
|
||||
windows = [match.span() for match in pattern_alt.finditer(ascii_text)]
|
||||
# Map spans back roughly using proportional index
|
||||
if windows:
|
||||
mapped: List[Tuple[int, int]] = []
|
||||
for start, end in windows:
|
||||
start_idx = min(len(search_text) - 1, int(start * len(search_text) / max(len(ascii_text), 1)))
|
||||
end_idx = min(len(search_text), int(end * len(search_text) / max(len(ascii_text), 1)))
|
||||
mapped.append((start_idx, end_idx))
|
||||
windows = mapped
|
||||
else:
|
||||
windows = [match.span() for match in matches]
|
||||
|
||||
if not windows:
|
||||
windows = [(0, len(search_text))]
|
||||
degrade_factor = 0.25
|
||||
|
||||
radius = 60
|
||||
quote_spans: List[Tuple[int, int, str]] = []
|
||||
for match in _QUOTE_PATTERN.finditer(search_text):
|
||||
try:
|
||||
content_start, content_end = match.span(1)
|
||||
except IndexError:
|
||||
content_start, content_end = match.span()
|
||||
if content_start < content_end:
|
||||
quote_spans.append((content_start, content_end, search_text[content_start:content_end]))
|
||||
|
||||
normalized_label = _normalize_candidate_name(label) if label else None
|
||||
normalized_label_lower = normalized_label.lower() if normalized_label else None
|
||||
|
||||
def _window_weight(position: int) -> float:
|
||||
for start, end in windows:
|
||||
if position < start - radius or position > end + radius:
|
||||
continue
|
||||
if position >= end:
|
||||
return 1.0
|
||||
if position <= start:
|
||||
return 0.2
|
||||
return 1.0
|
||||
return 0.0
|
||||
|
||||
def _quote_weight(position: int) -> float:
|
||||
for start, end, content in quote_spans:
|
||||
if position < start or position >= end:
|
||||
continue
|
||||
local_index = position - start
|
||||
prefix = content[:local_index]
|
||||
tail = prefix[-80:]
|
||||
name_matches = list(re.finditer(_NAME_PATTERN, tail))
|
||||
if name_matches:
|
||||
last_name = _normalize_candidate_name(name_matches[-1].group(0))
|
||||
if normalized_label_lower and last_name and last_name.lower() == normalized_label_lower:
|
||||
return 0.6
|
||||
return 0.05
|
||||
if re.search(r"[.!?]\s", prefix):
|
||||
return 0.2
|
||||
if prefix.strip():
|
||||
return 0.15
|
||||
return 0.1
|
||||
return 1.0
|
||||
|
||||
male_score = 0.0
|
||||
for match in _MALE_PRONOUN_PATTERN.finditer(search_text):
|
||||
base_weight = _window_weight(match.start())
|
||||
if not base_weight:
|
||||
continue
|
||||
quote_modifier = _quote_weight(match.start())
|
||||
weight = base_weight * quote_modifier
|
||||
if not weight:
|
||||
continue
|
||||
token = match.group(0).lower()
|
||||
male_score += _MALE_TOKEN_WEIGHTS.get(token, 0.6) * weight
|
||||
|
||||
female_score = 0.0
|
||||
for match in _FEMALE_PRONOUN_PATTERN.finditer(search_text):
|
||||
base_weight = _window_weight(match.start())
|
||||
if not base_weight:
|
||||
continue
|
||||
quote_modifier = _quote_weight(match.start())
|
||||
weight = base_weight * quote_modifier
|
||||
if not weight:
|
||||
continue
|
||||
if quote_modifier >= 0.95:
|
||||
weight = max(weight, 0.4)
|
||||
token = match.group(0).lower()
|
||||
female_score += _FEMALE_TOKEN_WEIGHTS.get(token, 0.4) * weight
|
||||
|
||||
for start, end in windows:
|
||||
span_start = max(0, start - 40)
|
||||
span_end = min(len(search_text), end + 40)
|
||||
span_text = search_text[span_start:span_end].lower()
|
||||
if any(title in span_text for title in _FEMALE_TITLE_HINTS):
|
||||
female_score += 2.5
|
||||
if any(title in span_text for title in _MALE_TITLE_HINTS):
|
||||
male_score += 2.5
|
||||
|
||||
male_votes = int(round(male_score * degrade_factor))
|
||||
female_votes = int(round(female_score * degrade_factor))
|
||||
return male_votes, female_votes
|
||||
|
||||
|
||||
def _derive_gender(male_votes: int, female_votes: int, current: str) -> str:
|
||||
if male_votes == 0 and female_votes == 0:
|
||||
return current if current != "unknown" else "unknown"
|
||||
|
||||
male_threshold = max(2, female_votes + 1)
|
||||
female_threshold = max(2, male_votes + 1)
|
||||
|
||||
if male_votes >= male_threshold:
|
||||
return "male"
|
||||
if female_votes >= female_threshold:
|
||||
return "female"
|
||||
|
||||
if current in {"male", "female"}:
|
||||
return current
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _get_chunk_text(chunk: Dict[str, Any]) -> str:
|
||||
if not isinstance(chunk, dict):
|
||||
return ""
|
||||
value = chunk.get("normalized_text") or chunk.get("text") or ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def _trim_paragraph(paragraph: str, limit: int = 600) -> str:
|
||||
normalized = (paragraph or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
if len(normalized) <= limit:
|
||||
return normalized
|
||||
return normalized[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _compose_context_excerpt(before: str, current: str, after: str) -> str:
|
||||
segments = []
|
||||
for value in (before, current, after):
|
||||
trimmed = _trim_paragraph(value)
|
||||
if trimmed:
|
||||
segments.append(trimmed)
|
||||
return "\n\n".join(segments)
|
||||
|
||||
|
||||
def _contains_dialogue_attribution(label: str, text: str, quote: Optional[str]) -> bool:
|
||||
if not label or not text:
|
||||
return False
|
||||
escaped_label = re.escape(label)
|
||||
direct_pattern = re.compile(rf"\b{escaped_label}\b\s+(?:{_VERB_PATTERN})\b", re.IGNORECASE)
|
||||
reverse_pattern = re.compile(rf"(?:{_VERB_PATTERN})\s+\b{escaped_label}\b", re.IGNORECASE)
|
||||
colon_pattern = re.compile(rf"^\s*{escaped_label}\s*:\s*", re.IGNORECASE)
|
||||
|
||||
if colon_pattern.search(text):
|
||||
return True
|
||||
if direct_pattern.search(text) or reverse_pattern.search(text):
|
||||
return True
|
||||
if quote:
|
||||
before, after = _split_around_quote(text, quote)
|
||||
if direct_pattern.search(before) or reverse_pattern.search(after):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _select_sample_excerpt(
|
||||
chunks: Sequence[Dict[str, Any]],
|
||||
index: int,
|
||||
label: str,
|
||||
quote: Optional[str],
|
||||
confidence: str,
|
||||
) -> Optional[str]:
|
||||
if confidence != "high" or not label:
|
||||
return None
|
||||
if index < 0 or index >= len(chunks):
|
||||
return None
|
||||
current = _get_chunk_text(chunks[index])
|
||||
if not current or not _contains_dialogue_attribution(label, current, quote):
|
||||
return None
|
||||
previous = _get_chunk_text(chunks[index - 1]) if index > 0 else ""
|
||||
following = _get_chunk_text(chunks[index + 1]) if index + 1 < len(chunks) else ""
|
||||
excerpt = _compose_context_excerpt(previous, current, following)
|
||||
return excerpt or None
|
||||
|
||||
|
||||
def _build_excerpt(text: str, quote: Optional[str]) -> str:
|
||||
normalized = (text or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
if quote:
|
||||
location = normalized.find(quote)
|
||||
if location != -1:
|
||||
start = max(0, location - 120)
|
||||
end = min(len(normalized), location + len(quote) + 120)
|
||||
snippet = normalized[start:end].strip()
|
||||
if start > 0:
|
||||
snippet = "…" + snippet
|
||||
if end < len(normalized):
|
||||
snippet = snippet + "…"
|
||||
return snippet
|
||||
if len(normalized) > 240:
|
||||
return normalized[:240].rstrip() + "…"
|
||||
return normalized
|
||||
|
||||
|
||||
def _format_gender_hint(male_votes: int, female_votes: int) -> str:
|
||||
if male_votes and female_votes:
|
||||
return "Context mentions both male and female pronouns."
|
||||
if male_votes:
|
||||
if male_votes >= 3:
|
||||
return "Multiple male pronouns detected nearby."
|
||||
return "Some male pronouns detected in the surrounding text."
|
||||
if female_votes:
|
||||
if female_votes >= 3:
|
||||
return "Multiple female pronouns detected nearby."
|
||||
return "Some female pronouns detected in the surrounding text."
|
||||
return "No clear pronoun signal detected."
|
||||
|
||||
|
||||
def _normalize_candidate_name(raw: str) -> Optional[str]:
|
||||
if not raw:
|
||||
return None
|
||||
cleaned = raw.strip().strip('"“”\'’.,:;!')
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
parts = cleaned.split()
|
||||
filtered: List[str] = []
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
if not filtered and part.lower() in _STOP_LABELS:
|
||||
continue
|
||||
filtered.append(part)
|
||||
while filtered and filtered[-1].lower() in _STOP_LABELS:
|
||||
filtered.pop()
|
||||
if not filtered:
|
||||
return None
|
||||
if all(part.lower() in _STOP_LABELS for part in filtered):
|
||||
return None
|
||||
contiguous: List[str] = []
|
||||
for part in filtered:
|
||||
if part and part[0].isupper():
|
||||
contiguous.append(part)
|
||||
else:
|
||||
break
|
||||
if contiguous:
|
||||
candidate = " ".join(contiguous)
|
||||
else:
|
||||
candidate = ""
|
||||
if not candidate:
|
||||
return None
|
||||
lowered = candidate.lower()
|
||||
if lowered in _PRONOUN_LABELS or lowered in _STOP_LABELS:
|
||||
return None
|
||||
return candidate
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from abogen.constants import LANGUAGE_DESCRIPTIONS
|
||||
from abogen.utils import get_user_config_path
|
||||
|
||||
_CONFIG_WRAPPER_KEY = "abogen_speaker_configs"
|
||||
|
||||
|
||||
def _config_path() -> str:
|
||||
config_path = get_user_config_path()
|
||||
config_dir = os.path.dirname(config_path)
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
return os.path.join(config_dir, "speaker_configs.json")
|
||||
|
||||
|
||||
def load_configs() -> Dict[str, Dict[str, Any]]:
|
||||
path = _config_path()
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except Exception:
|
||||
return {}
|
||||
if isinstance(payload, dict) and _CONFIG_WRAPPER_KEY in payload:
|
||||
payload = payload[_CONFIG_WRAPPER_KEY]
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
sanitized: Dict[str, Dict[str, Any]] = {}
|
||||
for name, entry in payload.items():
|
||||
if not isinstance(name, str) or not isinstance(entry, dict):
|
||||
continue
|
||||
sanitized[name] = _sanitize_config(entry)
|
||||
return sanitized
|
||||
|
||||
|
||||
def save_configs(configs: Dict[str, Dict[str, Any]]) -> None:
|
||||
path = _config_path()
|
||||
sanitized: Dict[str, Dict[str, Any]] = {}
|
||||
for name, entry in configs.items():
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
sanitized[name] = _sanitize_config(entry)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump({_CONFIG_WRAPPER_KEY: sanitized}, handle, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def get_config(name: str) -> Optional[Dict[str, Any]]:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
configs = load_configs()
|
||||
data = configs.get(name)
|
||||
return dict(data) if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def upsert_config(name: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Configuration name is required")
|
||||
configs = load_configs()
|
||||
configs[name] = _sanitize_config(payload or {})
|
||||
save_configs(configs)
|
||||
return configs[name]
|
||||
|
||||
|
||||
def delete_config(name: str) -> None:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return
|
||||
configs = load_configs()
|
||||
if name in configs:
|
||||
del configs[name]
|
||||
save_configs(configs)
|
||||
|
||||
|
||||
def _sanitize_config(entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
language = str(entry.get("language") or "a").strip() or "a"
|
||||
speakers_raw = entry.get("speakers")
|
||||
if not isinstance(speakers_raw, dict):
|
||||
speakers_raw = {}
|
||||
speakers: Dict[str, Any] = {}
|
||||
for speaker_id, payload in speakers_raw.items():
|
||||
if not isinstance(speaker_id, str) or not isinstance(payload, dict):
|
||||
continue
|
||||
record = _sanitize_speaker({"id": speaker_id, **payload})
|
||||
speakers[record["id"]] = record
|
||||
allowed_languages = entry.get("languages") or entry.get("allowed_languages") or []
|
||||
if not isinstance(allowed_languages, list):
|
||||
allowed_languages = []
|
||||
normalized_langs = []
|
||||
for code in allowed_languages:
|
||||
if isinstance(code, str) and code:
|
||||
normalized_langs.append(code.lower())
|
||||
default_voice = entry.get("default_voice")
|
||||
if not isinstance(default_voice, str):
|
||||
default_voice = ""
|
||||
return {
|
||||
"language": language.lower(),
|
||||
"languages": normalized_langs,
|
||||
"default_voice": default_voice,
|
||||
"speakers": speakers,
|
||||
"version": int(entry.get("version", 1)),
|
||||
"notes": entry.get("notes") if isinstance(entry.get("notes"), str) else "",
|
||||
}
|
||||
|
||||
|
||||
def slugify_label(label: str) -> str:
|
||||
normalized = (label or "").strip().lower()
|
||||
if not normalized:
|
||||
return "speaker"
|
||||
slug = "".join(ch if ch.isalnum() else "_" for ch in normalized)
|
||||
slug = "_".join(filter(None, slug.split("_")))
|
||||
return slug or "speaker"
|
||||
|
||||
|
||||
def _sanitize_speaker(entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
label = (entry.get("label") or entry.get("name") or "").strip()
|
||||
gender = (entry.get("gender") or "unknown").strip().lower()
|
||||
if gender not in {"male", "female", "unknown"}:
|
||||
gender = "unknown"
|
||||
voice = entry.get("voice")
|
||||
voice_profile = entry.get("voice_profile")
|
||||
voice_formula = entry.get("voice_formula")
|
||||
voice_languages = entry.get("languages") or []
|
||||
if not isinstance(voice_languages, list):
|
||||
voice_languages = []
|
||||
normalized_langs = []
|
||||
for code in voice_languages:
|
||||
if isinstance(code, str) and code:
|
||||
normalized_langs.append(code.lower())
|
||||
resolved_voice = entry.get("resolved_voice") or voice_formula or voice
|
||||
resolved_label = label or entry.get("id") or ""
|
||||
slug = entry.get("id") if isinstance(entry.get("id"), str) else slugify_label(resolved_label)
|
||||
return {
|
||||
"id": slug,
|
||||
"label": resolved_label,
|
||||
"gender": gender,
|
||||
"voice": voice if isinstance(voice, str) else "",
|
||||
"voice_profile": voice_profile if isinstance(voice_profile, str) else "",
|
||||
"voice_formula": voice_formula if isinstance(voice_formula, str) else "",
|
||||
"resolved_voice": resolved_voice if isinstance(resolved_voice, str) else "",
|
||||
"languages": normalized_langs,
|
||||
}
|
||||
|
||||
|
||||
def list_configs() -> List[Dict[str, Any]]:
|
||||
configs = load_configs()
|
||||
ordered = []
|
||||
for name in sorted(configs):
|
||||
entry = configs[name]
|
||||
ordered.append({"name": name, **entry})
|
||||
return ordered
|
||||
|
||||
|
||||
def describe_language(code: str) -> str:
|
||||
code = (code or "a").lower()
|
||||
return LANGUAGE_DESCRIPTIONS.get(code, code.upper())
|
||||
@@ -0,0 +1,977 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import textwrap
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple, cast
|
||||
|
||||
import ebooklib # type: ignore[import]
|
||||
import fitz # type: ignore[import]
|
||||
import markdown # type: ignore[import]
|
||||
from bs4 import BeautifulSoup, NavigableString # type: ignore[import]
|
||||
from ebooklib import epub # type: ignore[import]
|
||||
|
||||
from .utils import calculate_text_length, clean_text, detect_encoding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
METADATA_PATTERN = re.compile(r"<<METADATA_([A-Z_]+):(.*?)>>", re.DOTALL)
|
||||
CHAPTER_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>", re.IGNORECASE)
|
||||
METADATA_KEY_MAP: Dict[str, str] = {
|
||||
"TITLE": "title",
|
||||
"ARTIST": "artist",
|
||||
"ALBUM": "album",
|
||||
"YEAR": "year",
|
||||
"ALBUM_ARTIST": "album_artist",
|
||||
"ALBUMARTIST": "album_artist",
|
||||
"COMPOSER": "composer",
|
||||
"GENRE": "genre",
|
||||
"DATE": "date",
|
||||
"PUBLISHER": "publisher",
|
||||
"COMMENT": "comment",
|
||||
"LANGUAGE": "language",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractedChapter:
|
||||
title: str
|
||||
text: str
|
||||
|
||||
@property
|
||||
def characters(self) -> int:
|
||||
return calculate_text_length(self.text)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractionResult:
|
||||
chapters: List[ExtractedChapter]
|
||||
metadata: Dict[str, str] = field(default_factory=dict)
|
||||
cover_image: Optional[bytes] = None
|
||||
cover_mime: Optional[str] = None
|
||||
|
||||
@property
|
||||
def combined_text(self) -> str:
|
||||
return "\n\n".join(chapter.text for chapter in self.chapters)
|
||||
|
||||
@property
|
||||
def total_characters(self) -> int:
|
||||
return sum(chapter.characters for chapter in self.chapters)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetadataSource:
|
||||
title: Optional[str] = None
|
||||
authors: List[str] = field(default_factory=list)
|
||||
description: Optional[str] = None
|
||||
publisher: Optional[str] = None
|
||||
publication_year: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
series: Optional[str] = None
|
||||
series_index: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NavEntry:
|
||||
src: str
|
||||
title: str
|
||||
doc_href: str
|
||||
position: int
|
||||
doc_order: int
|
||||
|
||||
|
||||
def extract_from_path(path: Path) -> ExtractionResult:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".txt":
|
||||
return _extract_plaintext(path)
|
||||
if suffix == ".pdf":
|
||||
return _extract_pdf(path)
|
||||
if suffix in {".md", ".markdown"}:
|
||||
return _extract_markdown(path)
|
||||
if suffix == ".epub":
|
||||
return _extract_epub(path)
|
||||
raise ValueError(f"Unsupported input type: {suffix}")
|
||||
|
||||
|
||||
def _extract_plaintext(path: Path) -> ExtractionResult:
|
||||
encoding = detect_encoding(str(path))
|
||||
raw = path.read_text(encoding=encoding, errors="replace")
|
||||
return _extract_from_string(raw, default_title=path.stem)
|
||||
|
||||
|
||||
def _extract_from_string(raw: str, default_title: str) -> ExtractionResult:
|
||||
raw_metadata, body = _strip_metadata(raw)
|
||||
chapters = _split_chapters(body, default_title)
|
||||
normalized_tags = _normalize_metadata_keys(raw_metadata)
|
||||
chapter_count = len(chapters)
|
||||
artist_value = normalized_tags.get("artist")
|
||||
authors = [name.strip() for name in artist_value.split(",") if name.strip()] if artist_value else []
|
||||
metadata_source = MetadataSource(
|
||||
title=normalized_tags.get("title") or default_title,
|
||||
authors=authors,
|
||||
publication_year=normalized_tags.get("year"),
|
||||
)
|
||||
metadata = _build_metadata_payload(metadata_source, chapter_count, "text", default_title)
|
||||
metadata.update(normalized_tags)
|
||||
if not chapters:
|
||||
chapters = [ExtractedChapter(title=default_title, text="")]
|
||||
return ExtractionResult(chapters=chapters, metadata=metadata)
|
||||
|
||||
|
||||
def _strip_metadata(content: str) -> Tuple[Dict[str, str], str]:
|
||||
metadata: Dict[str, str] = {}
|
||||
|
||||
def _replacer(match: re.Match) -> str:
|
||||
key = match.group(1).strip().upper()
|
||||
value = match.group(2).strip()
|
||||
if value:
|
||||
metadata[key] = value
|
||||
return ""
|
||||
|
||||
stripped = METADATA_PATTERN.sub(_replacer, content)
|
||||
return metadata, stripped
|
||||
|
||||
|
||||
def _split_chapters(content: str, default_title: str) -> List[ExtractedChapter]:
|
||||
matches = list(CHAPTER_PATTERN.finditer(content))
|
||||
if not matches:
|
||||
cleaned = clean_text(content)
|
||||
return [ExtractedChapter(title=default_title, text=cleaned)]
|
||||
|
||||
chapters: List[ExtractedChapter] = []
|
||||
last_index = 0
|
||||
current_title = default_title
|
||||
|
||||
for match in matches:
|
||||
segment = content[last_index:match.start()]
|
||||
if segment.strip():
|
||||
chapters.append(ExtractedChapter(title=current_title, text=clean_text(segment)))
|
||||
current_title = match.group(1).strip() or default_title
|
||||
last_index = match.end()
|
||||
|
||||
tail = content[last_index:]
|
||||
if tail.strip():
|
||||
chapters.append(ExtractedChapter(title=current_title, text=clean_text(tail)))
|
||||
|
||||
return chapters
|
||||
|
||||
|
||||
def _normalize_metadata_keys(metadata: Dict[str, str]) -> Dict[str, str]:
|
||||
normalized: Dict[str, str] = {}
|
||||
for key, value in metadata.items():
|
||||
if not value:
|
||||
continue
|
||||
mapped = METADATA_KEY_MAP.get(key.upper(), key.lower())
|
||||
normalized[mapped] = value
|
||||
return normalized
|
||||
|
||||
|
||||
def _build_metadata_payload(
|
||||
metadata_source: MetadataSource,
|
||||
chapter_count: int,
|
||||
file_type: str,
|
||||
default_title: str,
|
||||
) -> Dict[str, str]:
|
||||
now_year = str(datetime.datetime.now().year)
|
||||
title = metadata_source.title.strip() if metadata_source.title else default_title
|
||||
if not title:
|
||||
title = default_title
|
||||
authors = [author for author in metadata_source.authors if author.strip()]
|
||||
if not authors:
|
||||
authors = ["Unknown"]
|
||||
authors_text = ", ".join(authors)
|
||||
if chapter_count <= 0:
|
||||
chapter_count = 1
|
||||
chapter_label = "Chapters" if file_type in {"epub", "markdown"} else "Pages"
|
||||
metadata = {
|
||||
"TITLE": title,
|
||||
"ARTIST": authors_text,
|
||||
"ALBUM": title,
|
||||
"YEAR": metadata_source.publication_year or now_year,
|
||||
"ALBUM_ARTIST": authors_text,
|
||||
"COMPOSER": authors_text,
|
||||
"GENRE": "Audiobook",
|
||||
"CHAPTER_COUNT": str(chapter_count),
|
||||
}
|
||||
if metadata_source.publisher:
|
||||
metadata["PUBLISHER"] = metadata_source.publisher
|
||||
if metadata_source.description:
|
||||
metadata["COMMENT"] = metadata_source.description
|
||||
if metadata_source.language:
|
||||
metadata["LANGUAGE"] = metadata_source.language
|
||||
normalized = _normalize_metadata_keys(metadata)
|
||||
# Ensure chapter_count survives normalization even if upstream metadata provided it
|
||||
normalized.setdefault("chapter_count", str(chapter_count))
|
||||
return normalized
|
||||
|
||||
|
||||
def _extract_pdf(path: Path) -> ExtractionResult:
|
||||
metadata_source = MetadataSource()
|
||||
chapters: List[ExtractedChapter] = []
|
||||
with fitz.open(str(path)) as document:
|
||||
metadata_source = _collect_pdf_metadata(document)
|
||||
pages = cast(Iterable[fitz.Page], document)
|
||||
for index, page in enumerate(pages):
|
||||
page_obj = cast(Any, page)
|
||||
text = _clean_pdf_text(page_obj.get_text())
|
||||
if not text:
|
||||
continue
|
||||
title = f"Page {index + 1}"
|
||||
chapters.append(ExtractedChapter(title=title, text=text))
|
||||
if not chapters:
|
||||
chapters.append(ExtractedChapter(title=path.stem, text=""))
|
||||
metadata = _build_metadata_payload(metadata_source, len(chapters), "pdf", path.stem)
|
||||
return ExtractionResult(chapters=chapters, metadata=metadata)
|
||||
|
||||
|
||||
def _collect_pdf_metadata(document: fitz.Document) -> MetadataSource:
|
||||
metadata = MetadataSource()
|
||||
info = document.metadata or {}
|
||||
if info.get("title"):
|
||||
metadata.title = info["title"]
|
||||
if info.get("author"):
|
||||
metadata.authors = [info["author"]]
|
||||
if info.get("subject"):
|
||||
metadata.description = info["subject"]
|
||||
if info.get("keywords"):
|
||||
keywords = info["keywords"]
|
||||
if metadata.description:
|
||||
metadata.description = f"{metadata.description}\n\nKeywords: {keywords}"
|
||||
else:
|
||||
metadata.description = f"Keywords: {keywords}"
|
||||
if info.get("creator"):
|
||||
metadata.publisher = info["creator"]
|
||||
for key in ("creationDate", "modDate"):
|
||||
value = info.get(key)
|
||||
if not value:
|
||||
continue
|
||||
match = re.search(r"D:(\d{4})", value)
|
||||
if match:
|
||||
metadata.publication_year = match.group(1)
|
||||
break
|
||||
return metadata
|
||||
|
||||
|
||||
def _clean_pdf_text(text: str) -> str:
|
||||
cleaned = clean_text(text)
|
||||
cleaned = re.sub(r"\[\s*\d+\s*\]", "", cleaned)
|
||||
cleaned = re.sub(r"^\s*\d+\s*$", "", cleaned, flags=re.MULTILINE)
|
||||
cleaned = re.sub(r"\s+\d+\s*$", "", cleaned, flags=re.MULTILINE)
|
||||
cleaned = re.sub(r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", "", cleaned, flags=re.MULTILINE)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def _extract_markdown(path: Path) -> ExtractionResult:
|
||||
encoding = detect_encoding(str(path))
|
||||
raw = path.read_text(encoding=encoding, errors="replace")
|
||||
metadata_source, chapters = _parse_markdown(raw, path.stem)
|
||||
if not chapters:
|
||||
chapters = [ExtractedChapter(title=metadata_source.title or path.stem, text=clean_text(raw))]
|
||||
metadata = _build_metadata_payload(metadata_source, len(chapters), "markdown", path.stem)
|
||||
return ExtractionResult(chapters=chapters, metadata=metadata)
|
||||
|
||||
|
||||
def _parse_markdown(raw: str, default_title: str) -> Tuple[MetadataSource, List[ExtractedChapter]]:
|
||||
metadata = MetadataSource()
|
||||
text = textwrap.dedent(raw)
|
||||
frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
|
||||
if frontmatter_match:
|
||||
frontmatter = frontmatter_match.group(1)
|
||||
_parse_markdown_frontmatter(frontmatter, metadata)
|
||||
text_body = text[frontmatter_match.end():]
|
||||
else:
|
||||
text_body = text
|
||||
|
||||
md = markdown.Markdown(extensions=["toc", "fenced_code"])
|
||||
html = md.convert(text_body)
|
||||
toc_tokens = getattr(md, "toc_tokens", None) or []
|
||||
|
||||
if not toc_tokens:
|
||||
cleaned = clean_text(text_body)
|
||||
title = metadata.title or default_title
|
||||
chapters = [ExtractedChapter(title=title, text=cleaned)] if cleaned else []
|
||||
return metadata, chapters
|
||||
|
||||
headers: List[dict] = []
|
||||
|
||||
def _flatten_tokens(tokens):
|
||||
for token in tokens:
|
||||
headers.append(token)
|
||||
if token.get("children"):
|
||||
_flatten_tokens(token["children"])
|
||||
|
||||
_flatten_tokens(toc_tokens)
|
||||
|
||||
header_positions: List[Tuple[str, int, str]] = []
|
||||
for header in headers:
|
||||
header_id = header.get("id")
|
||||
if not header_id:
|
||||
continue
|
||||
id_pattern = f'id="{header_id}"'
|
||||
pos = html.find(id_pattern)
|
||||
if pos == -1:
|
||||
continue
|
||||
tag_start = html.rfind("<", 0, pos)
|
||||
name = str(header.get("name", header_id))
|
||||
header_positions.append((header_id, tag_start, name))
|
||||
|
||||
header_positions.sort(key=lambda item: item[1])
|
||||
|
||||
chapters: List[ExtractedChapter] = []
|
||||
for index, (header_id, start, name) in enumerate(header_positions):
|
||||
end = header_positions[index + 1][1] if index + 1 < len(header_positions) else len(html)
|
||||
section_html = html[start:end]
|
||||
section_soup = BeautifulSoup(section_html, "html.parser")
|
||||
header_tag = section_soup.find(attrs={"id": header_id})
|
||||
if header_tag:
|
||||
header_tag.decompose()
|
||||
section_text = clean_text(section_soup.get_text()).strip()
|
||||
if not section_text:
|
||||
continue
|
||||
chapters.append(ExtractedChapter(title=name.strip(), text=section_text))
|
||||
|
||||
if not metadata.title:
|
||||
first_h1 = next((header for header in headers if header.get("level") == 1 and header.get("name")), None)
|
||||
if first_h1:
|
||||
metadata.title = str(first_h1["name"])
|
||||
|
||||
return metadata, chapters
|
||||
|
||||
|
||||
def _parse_markdown_frontmatter(frontmatter: str, metadata: MetadataSource) -> None:
|
||||
title_match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE)
|
||||
if title_match:
|
||||
metadata.title = title_match.group(1).strip().strip('"\'')
|
||||
|
||||
author_match = re.search(r"^author:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE)
|
||||
if author_match:
|
||||
metadata.authors = [author_match.group(1).strip().strip('"\'')]
|
||||
|
||||
desc_match = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE)
|
||||
if desc_match:
|
||||
metadata.description = desc_match.group(1).strip().strip('"\'')
|
||||
|
||||
date_match = re.search(r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE)
|
||||
if date_match:
|
||||
date_str = date_match.group(1).strip().strip('\"\'')
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
|
||||
if year_match:
|
||||
metadata.publication_year = year_match.group(0)
|
||||
|
||||
|
||||
def _extract_epub(path: Path) -> ExtractionResult:
|
||||
extractor = EpubExtractor(path)
|
||||
return extractor.extract()
|
||||
|
||||
|
||||
class EpubExtractor:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
self.book = epub.read_epub(str(path))
|
||||
self.doc_content: Dict[str, str] = {}
|
||||
self.spine_docs: List[str] = []
|
||||
|
||||
def extract(self) -> ExtractionResult:
|
||||
metadata_source = self._collect_metadata()
|
||||
try:
|
||||
chapters = self._process_nav()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"EPUB navigation processing failed for %s: %s. Falling back to spine order.",
|
||||
self.path.name,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
chapters = self._process_spine_fallback()
|
||||
if not chapters:
|
||||
chapters = [ExtractedChapter(title=self.path.stem, text="")]
|
||||
metadata = _build_metadata_payload(metadata_source, len(chapters), "epub", self.path.stem)
|
||||
metadata.setdefault("chapter_count", str(len(chapters)))
|
||||
if metadata_source.series:
|
||||
series_text = str(metadata_source.series).strip()
|
||||
if series_text:
|
||||
metadata.setdefault("series", series_text)
|
||||
metadata.setdefault("series_name", series_text)
|
||||
metadata.setdefault("seriesname", series_text)
|
||||
if metadata_source.series_index:
|
||||
idx_text = str(metadata_source.series_index).strip()
|
||||
if idx_text:
|
||||
metadata.setdefault("series_index", idx_text)
|
||||
metadata.setdefault("series_sequence", idx_text)
|
||||
metadata.setdefault("book_number", idx_text)
|
||||
cover_image, cover_mime = self._extract_cover()
|
||||
return ExtractionResult(
|
||||
chapters=chapters,
|
||||
metadata=metadata,
|
||||
cover_image=cover_image,
|
||||
cover_mime=cover_mime,
|
||||
)
|
||||
|
||||
def _collect_metadata(self) -> MetadataSource:
|
||||
metadata = MetadataSource()
|
||||
try:
|
||||
title_items = self.book.get_metadata("DC", "title")
|
||||
if title_items:
|
||||
metadata.title = title_items[0][0]
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to extract EPUB title metadata: %s", exc)
|
||||
|
||||
try:
|
||||
author_items = self.book.get_metadata("DC", "creator")
|
||||
if author_items:
|
||||
metadata.authors = [author[0] for author in author_items if author and author[0]]
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to extract EPUB author metadata: %s", exc)
|
||||
|
||||
try:
|
||||
desc_items = self.book.get_metadata("DC", "description")
|
||||
if desc_items:
|
||||
metadata.description = desc_items[0][0]
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to extract EPUB description metadata: %s", exc)
|
||||
|
||||
try:
|
||||
publisher_items = self.book.get_metadata("DC", "publisher")
|
||||
if publisher_items:
|
||||
metadata.publisher = publisher_items[0][0]
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to extract EPUB publisher metadata: %s", exc)
|
||||
|
||||
try:
|
||||
date_items = self.book.get_metadata("DC", "date")
|
||||
if date_items:
|
||||
date_str = date_items[0][0]
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
|
||||
metadata.publication_year = year_match.group(0) if year_match else date_str
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to extract EPUB publication year metadata: %s", exc)
|
||||
|
||||
try:
|
||||
language_items = self.book.get_metadata("DC", "language")
|
||||
if language_items:
|
||||
metadata.language = language_items[0][0]
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to extract EPUB language metadata: %s", exc)
|
||||
|
||||
# Series metadata (best-effort). Common sources:
|
||||
# - Calibre embeds OPF meta tags: <meta name="calibre:series" content="..." />
|
||||
# - EPUB3 collections via: <meta property="belongs-to-collection">...</meta>
|
||||
try:
|
||||
meta_items = self.book.get_metadata("OPF", "meta")
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to extract EPUB OPF meta tags: %s", exc)
|
||||
meta_items = []
|
||||
|
||||
series_name: Optional[str] = None
|
||||
series_index: Optional[str] = None
|
||||
for value, attrs in meta_items or []:
|
||||
attrs_dict = attrs or {}
|
||||
name = str(attrs_dict.get("name") or "").strip().casefold()
|
||||
prop = str(attrs_dict.get("property") or "").strip().casefold()
|
||||
content = attrs_dict.get("content")
|
||||
candidate = content if content is not None else value
|
||||
candidate_text = str(candidate or "").strip()
|
||||
if not candidate_text:
|
||||
continue
|
||||
|
||||
if name in {"calibre:series", "series"} and series_name is None:
|
||||
series_name = candidate_text
|
||||
continue
|
||||
if name in {"calibre:series_index", "calibre:seriesindex", "series_index", "seriesindex"} and series_index is None:
|
||||
series_index = candidate_text
|
||||
continue
|
||||
|
||||
if prop.endswith("belongs-to-collection") and series_name is None:
|
||||
series_name = candidate_text
|
||||
continue
|
||||
|
||||
metadata.series = series_name
|
||||
metadata.series_index = series_index
|
||||
|
||||
return metadata
|
||||
|
||||
def _extract_cover(self) -> Tuple[Optional[bytes], Optional[str]]:
|
||||
try:
|
||||
for item in self.book.get_items_of_type(ebooklib.ITEM_COVER):
|
||||
data = item.get_content()
|
||||
if data:
|
||||
media_type = getattr(item, "media_type", None)
|
||||
return data, media_type
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to read dedicated EPUB cover image: %s", exc)
|
||||
|
||||
try:
|
||||
for item in self.book.get_items_of_type(ebooklib.ITEM_IMAGE):
|
||||
name = item.get_name().lower()
|
||||
if "cover" not in name and "front" not in name:
|
||||
continue
|
||||
data = item.get_content()
|
||||
if not data:
|
||||
continue
|
||||
media_type = getattr(item, "media_type", None)
|
||||
if not media_type:
|
||||
media_type = mimetypes.guess_type(name)[0]
|
||||
return data, media_type
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to locate fallback EPUB cover image: %s", exc)
|
||||
|
||||
return None, None
|
||||
|
||||
def _process_nav(self) -> List[ExtractedChapter]:
|
||||
nav_item, nav_type = self._find_navigation_item()
|
||||
if not nav_item or not nav_type:
|
||||
raise ValueError("No navigation document found")
|
||||
|
||||
parser_type = "html.parser" if nav_type == "html" else "xml"
|
||||
nav_content = nav_item.get_content().decode("utf-8", errors="ignore")
|
||||
nav_soup = BeautifulSoup(nav_content, parser_type)
|
||||
|
||||
self.spine_docs = self._build_spine_docs()
|
||||
doc_order = {href: index for index, href in enumerate(self.spine_docs)}
|
||||
doc_order_decoded = {urllib.parse.unquote(href): index for href, index in doc_order.items()}
|
||||
|
||||
nav_targets = self._collect_nav_targets(nav_soup, nav_type)
|
||||
self._cache_relevant_documents(doc_order, nav_targets)
|
||||
|
||||
ordered_entries: List[NavEntry] = []
|
||||
if nav_type == "ncx":
|
||||
nav_map = nav_soup.find("navMap")
|
||||
if not nav_map:
|
||||
raise ValueError("NCX navigation missing <navMap>")
|
||||
for nav_point in nav_map.find_all("navPoint", recursive=False):
|
||||
self._parse_ncx_navpoint(nav_point, ordered_entries, doc_order, doc_order_decoded)
|
||||
else:
|
||||
toc_nav = nav_soup.find("nav", attrs={"epub:type": "toc"})
|
||||
if toc_nav is None:
|
||||
for nav in nav_soup.find_all("nav"):
|
||||
if nav.find("ol"):
|
||||
toc_nav = nav
|
||||
break
|
||||
if toc_nav is None:
|
||||
raise ValueError("NAV HTML missing TOC structure")
|
||||
top_ol = toc_nav.find("ol", recursive=False)
|
||||
if top_ol is None:
|
||||
raise ValueError("TOC navigation missing <ol>")
|
||||
for li in top_ol.find_all("li", recursive=False):
|
||||
self._parse_html_nav_li(li, ordered_entries, doc_order, doc_order_decoded)
|
||||
|
||||
if not ordered_entries:
|
||||
raise ValueError("No navigation entries found")
|
||||
|
||||
ordered_entries.sort(key=lambda entry: (entry.doc_order, entry.position))
|
||||
chapters = self._slice_entries(ordered_entries)
|
||||
self._append_prefix_content(ordered_entries, chapters)
|
||||
return chapters
|
||||
|
||||
def _process_spine_fallback(self) -> List[ExtractedChapter]:
|
||||
chapters: List[ExtractedChapter] = []
|
||||
self.spine_docs = self._build_spine_docs()
|
||||
self.doc_content = {}
|
||||
|
||||
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
||||
href = item.get_name()
|
||||
if href not in self.spine_docs:
|
||||
continue
|
||||
try:
|
||||
html_content = item.get_content().decode("utf-8", errors="ignore")
|
||||
except Exception as exc:
|
||||
logger.error("Error decoding EPUB document %s: %s", href, exc)
|
||||
html_content = ""
|
||||
self.doc_content[href] = html_content
|
||||
|
||||
for index, doc_href in enumerate(self.spine_docs):
|
||||
html_content = self.doc_content.get(doc_href, "")
|
||||
if not html_content:
|
||||
continue
|
||||
text = self._html_to_text(html_content)
|
||||
if not text:
|
||||
continue
|
||||
title = self._resolve_document_title(html_content, fallback=f"Untitled Chapter {index + 1}")
|
||||
chapters.append(ExtractedChapter(title=title, text=text))
|
||||
return chapters
|
||||
|
||||
def _find_navigation_item(self) -> Tuple[Optional[epub.EpubItem], Optional[str]]:
|
||||
nav_item: Optional[epub.EpubItem] = None
|
||||
nav_type: Optional[str] = None
|
||||
|
||||
nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION))
|
||||
if nav_items:
|
||||
preferred = next(
|
||||
(
|
||||
item
|
||||
for item in nav_items
|
||||
if "nav" in item.get_name().lower() and item.get_name().lower().endswith((".xhtml", ".html"))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if preferred:
|
||||
nav_item = preferred
|
||||
nav_type = "html"
|
||||
else:
|
||||
html_nav = next(
|
||||
(
|
||||
item
|
||||
for item in nav_items
|
||||
if item.get_name().lower().endswith((".xhtml", ".html"))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if html_nav:
|
||||
nav_item = html_nav
|
||||
nav_type = "html"
|
||||
|
||||
if not nav_item and nav_items:
|
||||
ncx_candidate = next(
|
||||
(item for item in nav_items if item.get_name().lower().endswith(".ncx")),
|
||||
None,
|
||||
)
|
||||
if ncx_candidate:
|
||||
nav_item = ncx_candidate
|
||||
nav_type = "ncx"
|
||||
|
||||
if not nav_item:
|
||||
ncx_constant = getattr(epub, "ITEM_NCX", None)
|
||||
if ncx_constant is not None:
|
||||
ncx_items = list(self.book.get_items_of_type(ncx_constant))
|
||||
if ncx_items:
|
||||
nav_item = ncx_items[0]
|
||||
nav_type = "ncx"
|
||||
|
||||
if not nav_item:
|
||||
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
||||
try:
|
||||
html_content = item.get_content().decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
continue
|
||||
if "<nav" in html_content and 'epub:type="toc"' in html_content:
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
if soup.find("nav", attrs={"epub:type": "toc"}):
|
||||
nav_item = item
|
||||
nav_type = "html"
|
||||
break
|
||||
|
||||
return nav_item, nav_type
|
||||
|
||||
def _build_spine_docs(self) -> List[str]:
|
||||
docs: List[str] = []
|
||||
for spine_entry in self.book.spine:
|
||||
item_id = spine_entry[0]
|
||||
item = self.book.get_item_with_id(item_id)
|
||||
if item:
|
||||
docs.append(item.get_name())
|
||||
return docs
|
||||
|
||||
def _collect_nav_targets(self, nav_soup: BeautifulSoup, nav_type: str) -> List[str]:
|
||||
targets: List[str] = []
|
||||
if nav_type == "ncx":
|
||||
for content_node in nav_soup.find_all("content"):
|
||||
src = content_node.get("src")
|
||||
if src:
|
||||
src_value = str(src)
|
||||
targets.append(src_value.split("#", 1)[0])
|
||||
else:
|
||||
for link in nav_soup.find_all("a"):
|
||||
href = link.get("href")
|
||||
if href:
|
||||
href_value = str(href)
|
||||
targets.append(href_value.split("#", 1)[0])
|
||||
return targets
|
||||
|
||||
def _cache_relevant_documents(self, doc_order: Dict[str, int], nav_targets: List[str]) -> None:
|
||||
needed: set[str] = set(doc_order.keys())
|
||||
for target in nav_targets:
|
||||
needed.add(target)
|
||||
needed.add(urllib.parse.unquote(target))
|
||||
|
||||
self.doc_content = {}
|
||||
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
||||
href = item.get_name()
|
||||
if href not in needed and urllib.parse.unquote(href) not in needed:
|
||||
continue
|
||||
try:
|
||||
html_content = item.get_content().decode("utf-8", errors="ignore")
|
||||
except Exception as exc:
|
||||
logger.error("Error decoding EPUB document %s: %s", href, exc)
|
||||
html_content = ""
|
||||
self.doc_content[href] = html_content
|
||||
|
||||
def _parse_ncx_navpoint(
|
||||
self,
|
||||
nav_point,
|
||||
ordered_entries: List[NavEntry],
|
||||
doc_order: Dict[str, int],
|
||||
doc_order_decoded: Dict[str, int],
|
||||
) -> None:
|
||||
nav_label = nav_point.find("navLabel")
|
||||
content = nav_point.find("content")
|
||||
title = (
|
||||
nav_label.find("text").get_text(strip=True)
|
||||
if nav_label and nav_label.find("text")
|
||||
else "Untitled Section"
|
||||
)
|
||||
src = content.get("src") if content and content.has_attr("src") else None
|
||||
|
||||
if src:
|
||||
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
|
||||
doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded)
|
||||
if doc_key is not None and doc_idx is not None:
|
||||
position = self._find_position_robust(doc_key, fragment)
|
||||
ordered_entries.append(
|
||||
NavEntry(
|
||||
src=src,
|
||||
title=title,
|
||||
doc_href=doc_key,
|
||||
position=position,
|
||||
doc_order=doc_idx,
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Navigation entry '%s' points to '%s', which is not in the spine.",
|
||||
title,
|
||||
base_href,
|
||||
)
|
||||
|
||||
for child_navpoint in nav_point.find_all("navPoint", recursive=False):
|
||||
self._parse_ncx_navpoint(child_navpoint, ordered_entries, doc_order, doc_order_decoded)
|
||||
|
||||
def _parse_html_nav_li(
|
||||
self,
|
||||
li_element,
|
||||
ordered_entries: List[NavEntry],
|
||||
doc_order: Dict[str, int],
|
||||
doc_order_decoded: Dict[str, int],
|
||||
) -> None:
|
||||
link = li_element.find("a", recursive=False)
|
||||
span_text = li_element.find("span", recursive=False)
|
||||
title = "Untitled Section"
|
||||
|
||||
if link and link.has_attr("href"):
|
||||
src = link["href"]
|
||||
title = link.get_text(strip=True) or title
|
||||
else:
|
||||
src = None
|
||||
if span_text:
|
||||
title = span_text.get_text(strip=True) or title
|
||||
else:
|
||||
text = "".join(t for t in li_element.stripped_strings)
|
||||
if text:
|
||||
title = text
|
||||
|
||||
title = title.strip() or "Untitled Section"
|
||||
|
||||
if src:
|
||||
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
|
||||
doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded)
|
||||
if doc_key is not None and doc_idx is not None:
|
||||
position = self._find_position_robust(doc_key, fragment)
|
||||
ordered_entries.append(
|
||||
NavEntry(
|
||||
src=src,
|
||||
title=title,
|
||||
doc_href=doc_key,
|
||||
position=position,
|
||||
doc_order=doc_idx,
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Navigation entry '%s' points to '%s', which is not in the spine.",
|
||||
title,
|
||||
base_href,
|
||||
)
|
||||
|
||||
for child_ol in li_element.find_all("ol", recursive=False):
|
||||
for child_li in child_ol.find_all("li", recursive=False):
|
||||
self._parse_html_nav_li(child_li, ordered_entries, doc_order, doc_order_decoded)
|
||||
|
||||
def _find_doc_key(
|
||||
self,
|
||||
base_href: str,
|
||||
doc_order: Dict[str, int],
|
||||
doc_order_decoded: Dict[str, int],
|
||||
) -> Tuple[Optional[str], Optional[int]]:
|
||||
candidates = {base_href, urllib.parse.unquote(base_href)}
|
||||
base_name = urllib.parse.unquote(base_href).split("/")[-1].lower()
|
||||
for key in list(doc_order.keys()) + list(doc_order_decoded.keys()):
|
||||
if key.split("/")[-1].lower() == base_name:
|
||||
candidates.add(key)
|
||||
for candidate in candidates:
|
||||
if candidate in doc_order:
|
||||
return candidate, doc_order[candidate]
|
||||
if candidate in doc_order_decoded:
|
||||
return candidate, doc_order_decoded[candidate]
|
||||
return None, None
|
||||
|
||||
def _find_position_robust(self, doc_href: str, fragment_id: Optional[str]) -> int:
|
||||
if doc_href not in self.doc_content:
|
||||
logger.warning("Document '%s' not found in cached EPUB content.", doc_href)
|
||||
return 0
|
||||
html_content = self.doc_content[doc_href]
|
||||
if not fragment_id:
|
||||
return 0
|
||||
|
||||
try:
|
||||
temp_soup = BeautifulSoup(f"<div>{html_content}</div>", "html.parser")
|
||||
target_element = temp_soup.find(id=fragment_id)
|
||||
if target_element:
|
||||
tag_str = str(target_element)
|
||||
pos = html_content.find(tag_str[: min(len(tag_str), 200)])
|
||||
if pos != -1:
|
||||
return pos
|
||||
except Exception:
|
||||
logger.debug("BeautifulSoup failed to locate id '%s' in %s", fragment_id, doc_href)
|
||||
|
||||
safe_fragment_id = re.escape(fragment_id)
|
||||
id_name_pattern = re.compile(
|
||||
f"<[^>]+(?:id|name)\\s*=\\s*[\"']{safe_fragment_id}[\"']",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
match = id_name_pattern.search(html_content)
|
||||
if match:
|
||||
return match.start()
|
||||
|
||||
id_pos = html_content.find(f'id="{fragment_id}"')
|
||||
name_pos = html_content.find(f'name="{fragment_id}"')
|
||||
candidates = [pos for pos in (id_pos, name_pos) if pos != -1]
|
||||
if candidates:
|
||||
pos = min(candidates)
|
||||
tag_start = html_content.rfind("<", 0, pos)
|
||||
return tag_start if tag_start != -1 else pos
|
||||
|
||||
logger.warning("Anchor '%s' not found in %s. Defaulting to start.", fragment_id, doc_href)
|
||||
return 0
|
||||
|
||||
def _slice_entries(self, ordered_entries: List[NavEntry]) -> List[ExtractedChapter]:
|
||||
chapters: List[ExtractedChapter] = []
|
||||
for index, entry in enumerate(ordered_entries):
|
||||
next_entry = ordered_entries[index + 1] if index + 1 < len(ordered_entries) else None
|
||||
slice_html = self._slice_entry(entry, next_entry)
|
||||
text = self._html_to_text(slice_html)
|
||||
if not text:
|
||||
continue
|
||||
title = entry.title or "Untitled Section"
|
||||
chapters.append(ExtractedChapter(title=title, text=text))
|
||||
return chapters
|
||||
|
||||
def _slice_entry(
|
||||
self,
|
||||
current_entry: NavEntry,
|
||||
next_entry: Optional[NavEntry],
|
||||
) -> str:
|
||||
current_doc = current_entry.doc_href
|
||||
current_pos = current_entry.position
|
||||
current_html = self.doc_content.get(current_doc, "")
|
||||
if not current_html:
|
||||
return ""
|
||||
|
||||
if next_entry and next_entry.doc_href == current_doc:
|
||||
return current_html[current_pos : next_entry.position]
|
||||
|
||||
slice_html = current_html[current_pos:]
|
||||
if next_entry:
|
||||
docs_between = self._docs_between(current_doc, next_entry.doc_href)
|
||||
for doc_href in docs_between:
|
||||
slice_html += self.doc_content.get(doc_href, "")
|
||||
next_doc_html = self.doc_content.get(next_entry.doc_href, "")
|
||||
slice_html += next_doc_html[: next_entry.position]
|
||||
else:
|
||||
for doc_href in self._docs_between(current_doc, None):
|
||||
slice_html += self.doc_content.get(doc_href, "")
|
||||
|
||||
if not slice_html.strip():
|
||||
logger.warning(
|
||||
"No content found for navigation source '%s'. Using full document fallback.",
|
||||
current_entry.src,
|
||||
)
|
||||
return current_html
|
||||
return slice_html
|
||||
|
||||
def _docs_between(self, current_doc: str, next_doc: Optional[str]) -> List[str]:
|
||||
docs: List[str] = []
|
||||
try:
|
||||
current_idx = self.spine_docs.index(current_doc)
|
||||
except ValueError:
|
||||
return docs
|
||||
|
||||
if next_doc is None:
|
||||
docs.extend(self.spine_docs[current_idx + 1 :])
|
||||
return docs
|
||||
|
||||
try:
|
||||
next_idx = self.spine_docs.index(next_doc)
|
||||
except ValueError:
|
||||
return docs
|
||||
|
||||
if current_idx < next_idx:
|
||||
docs.extend(self.spine_docs[current_idx + 1 : next_idx])
|
||||
elif current_idx > next_idx:
|
||||
docs.extend(self.spine_docs[current_idx + 1 :])
|
||||
docs.extend(self.spine_docs[:next_idx])
|
||||
return docs
|
||||
|
||||
def _append_prefix_content(
|
||||
self,
|
||||
ordered_entries: List[NavEntry],
|
||||
chapters: List[ExtractedChapter],
|
||||
) -> None:
|
||||
if not ordered_entries:
|
||||
return
|
||||
first_entry = ordered_entries[0]
|
||||
first_doc = first_entry.doc_href
|
||||
first_pos = first_entry.position
|
||||
if first_pos <= 0:
|
||||
return
|
||||
|
||||
prefix_html = ""
|
||||
try:
|
||||
first_idx = self.spine_docs.index(first_doc)
|
||||
except ValueError:
|
||||
first_idx = -1
|
||||
|
||||
if first_idx > 0:
|
||||
for doc_href in self.spine_docs[:first_idx]:
|
||||
prefix_html += self.doc_content.get(doc_href, "")
|
||||
prefix_html += self.doc_content.get(first_doc, "")[:first_pos]
|
||||
prefix_text = self._html_to_text(prefix_html)
|
||||
if prefix_text and (not chapters or prefix_text != chapters[0].text):
|
||||
chapters.insert(0, ExtractedChapter(title="Introduction", text=prefix_text))
|
||||
|
||||
def _html_to_text(self, html: str) -> str:
|
||||
if not html:
|
||||
return ""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup.find_all(["p", "div"]):
|
||||
tag.append("\n\n")
|
||||
for ol in soup.find_all("ol"):
|
||||
start_attr = ol.get("start")
|
||||
try:
|
||||
start = int(str(start_attr)) if start_attr is not None else 1
|
||||
except (TypeError, ValueError):
|
||||
start = 1
|
||||
for idx, li in enumerate(ol.find_all("li", recursive=False)):
|
||||
number_text = f"{start + idx}) "
|
||||
existing = li.string
|
||||
if isinstance(existing, NavigableString):
|
||||
existing.replace_with(NavigableString(number_text + str(existing)))
|
||||
else:
|
||||
li.insert(0, NavigableString(number_text))
|
||||
for tag in soup.find_all(["sup", "sub"]):
|
||||
tag.decompose()
|
||||
text = clean_text(soup.get_text())
|
||||
return text.strip()
|
||||
|
||||
def _resolve_document_title(self, html_content: str, fallback: str) -> str:
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
if soup.title and soup.title.string:
|
||||
return soup.title.string.strip()
|
||||
for heading_tag in ("h1", "h2", "h3"):
|
||||
heading = soup.find(heading_tag)
|
||||
if heading and heading.get_text(strip=True):
|
||||
return heading.get_text(strip=True)
|
||||
return fallback
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Iterable, Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupertonicSegment:
|
||||
graphemes: str
|
||||
audio: np.ndarray
|
||||
|
||||
|
||||
def _ensure_float32_mono(wav: Any) -> np.ndarray:
|
||||
arr = np.asarray(wav, dtype="float32")
|
||||
if arr.ndim == 2:
|
||||
# (n, 1) or (1, n) or (n, channels)
|
||||
if arr.shape[0] == 1 and arr.shape[1] > 1:
|
||||
arr = arr.reshape(-1)
|
||||
else:
|
||||
arr = arr[:, 0]
|
||||
return arr.reshape(-1)
|
||||
|
||||
|
||||
def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
|
||||
if src_rate == dst_rate:
|
||||
return audio
|
||||
if audio.size == 0:
|
||||
return audio
|
||||
ratio = dst_rate / float(src_rate)
|
||||
new_len = int(round(audio.size * ratio))
|
||||
if new_len <= 1:
|
||||
return np.zeros(0, dtype="float32")
|
||||
x_old = np.linspace(0.0, 1.0, num=audio.size, endpoint=False)
|
||||
x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False)
|
||||
return np.interp(x_new, x_old, audio).astype("float32", copy=False)
|
||||
|
||||
|
||||
def _split_text(text: str, *, split_pattern: Optional[str], max_chunk_length: int) -> list[str]:
|
||||
stripped = (text or "").strip()
|
||||
if not stripped:
|
||||
return []
|
||||
parts: list[str]
|
||||
if split_pattern:
|
||||
try:
|
||||
parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()]
|
||||
except re.error:
|
||||
parts = [stripped]
|
||||
else:
|
||||
parts = [stripped]
|
||||
|
||||
# Enforce max length by hard-splitting long parts.
|
||||
result: list[str] = []
|
||||
for part in parts:
|
||||
if len(part) <= max_chunk_length:
|
||||
result.append(part)
|
||||
continue
|
||||
start = 0
|
||||
while start < len(part):
|
||||
end = min(len(part), start + max_chunk_length)
|
||||
# Try to split at whitespace.
|
||||
if end < len(part):
|
||||
ws = part.rfind(" ", start, end)
|
||||
if ws > start + 40:
|
||||
end = ws
|
||||
chunk = part[start:end].strip()
|
||||
if chunk:
|
||||
result.append(chunk)
|
||||
start = end
|
||||
return result
|
||||
|
||||
|
||||
_UNSUPPORTED_CHARS_RE = re.compile(r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE)
|
||||
|
||||
|
||||
def _parse_unsupported_characters(error: BaseException) -> list[str]:
|
||||
"""Best-effort extraction of unsupported characters from SuperTonic errors."""
|
||||
|
||||
message = " ".join(str(part) for part in getattr(error, "args", ()) if part is not None) or str(error)
|
||||
match = _UNSUPPORTED_CHARS_RE.search(message)
|
||||
if not match:
|
||||
return []
|
||||
|
||||
raw = match.group(1)
|
||||
try:
|
||||
value = ast.literal_eval(raw)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if isinstance(value, (list, tuple)):
|
||||
out: list[str] = []
|
||||
for item in value:
|
||||
if item is None:
|
||||
continue
|
||||
s = str(item)
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
if isinstance(value, str) and value:
|
||||
return [value]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _remove_unsupported_characters(text: str, unsupported: Iterable[str]) -> str:
|
||||
result = text
|
||||
for item in unsupported:
|
||||
if not item:
|
||||
continue
|
||||
result = result.replace(item, "")
|
||||
return result
|
||||
|
||||
|
||||
def _configure_supertonic_gpu() -> None:
|
||||
"""Patch supertonic's config to enable GPU acceleration if available."""
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
available = ort.get_available_providers()
|
||||
|
||||
# Use CUDA if available, skip TensorRT (requires extra libs not always present)
|
||||
# TensorrtExecutionProvider may be listed as available but fail at runtime
|
||||
# if TensorRT libraries (libnvinfer.so) are not installed
|
||||
providers = []
|
||||
if "CUDAExecutionProvider" in available:
|
||||
providers.append("CUDAExecutionProvider")
|
||||
providers.append("CPUExecutionProvider")
|
||||
|
||||
# Patch supertonic's config and loader before TTS import
|
||||
# We must patch both because loader imports the value at module load time
|
||||
import supertonic.config as supertonic_config
|
||||
import supertonic.loader as supertonic_loader
|
||||
supertonic_config.DEFAULT_ONNX_PROVIDERS = providers
|
||||
supertonic_loader.DEFAULT_ONNX_PROVIDERS = providers
|
||||
logger.info("Supertonic ONNX providers configured: %s", providers)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not configure supertonic GPU providers: %s", exc)
|
||||
|
||||
|
||||
class SupertonicPipeline:
|
||||
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sample_rate: int,
|
||||
auto_download: bool = True,
|
||||
total_steps: int = 5,
|
||||
max_chunk_length: int = 300,
|
||||
) -> None:
|
||||
self.sample_rate = int(sample_rate)
|
||||
self.total_steps = int(total_steps)
|
||||
self.max_chunk_length = int(max_chunk_length)
|
||||
|
||||
# Configure GPU providers before importing TTS
|
||||
_configure_supertonic_gpu()
|
||||
|
||||
try:
|
||||
from supertonic import TTS # type: ignore[import-not-found]
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
"Supertonic is not installed. Install it with `pip install supertonic`."
|
||||
) from exc
|
||||
|
||||
self._tts = TTS(auto_download=auto_download)
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
voice: str,
|
||||
speed: float,
|
||||
split_pattern: Optional[str] = None,
|
||||
total_steps: Optional[int] = None,
|
||||
) -> Iterator[SupertonicSegment]:
|
||||
voice_name = (voice or "").strip() or "M1"
|
||||
steps = int(total_steps) if total_steps is not None else self.total_steps
|
||||
steps = max(2, min(15, steps))
|
||||
speed_value = float(speed) if speed is not None else 1.0
|
||||
speed_value = max(0.7, min(2.0, speed_value))
|
||||
|
||||
style = self._tts.get_voice_style(voice_name=voice_name)
|
||||
chunks = _split_text(text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length)
|
||||
for chunk in chunks:
|
||||
chunk_to_speak = chunk
|
||||
removed: set[str] = set()
|
||||
last_exc: Exception | None = None
|
||||
|
||||
# SuperTonic can raise ValueError for unsupported characters; strip and retry.
|
||||
for attempt in range(3):
|
||||
try:
|
||||
wav, duration = self._tts.synthesize(
|
||||
text=chunk_to_speak,
|
||||
voice_style=style,
|
||||
total_steps=steps,
|
||||
speed=speed_value,
|
||||
max_chunk_length=self.max_chunk_length,
|
||||
silence_duration=0.0,
|
||||
verbose=False,
|
||||
)
|
||||
break
|
||||
except ValueError as exc:
|
||||
last_exc = exc
|
||||
unsupported = _parse_unsupported_characters(exc)
|
||||
if not unsupported:
|
||||
raise
|
||||
|
||||
removed.update(unsupported)
|
||||
sanitized = _remove_unsupported_characters(chunk_to_speak, unsupported).strip()
|
||||
|
||||
# If we didn't change anything, don't loop forever.
|
||||
if sanitized == chunk_to_speak.strip():
|
||||
raise
|
||||
|
||||
chunk_to_speak = sanitized
|
||||
if not chunk_to_speak:
|
||||
logger.warning(
|
||||
"SuperTonic: dropped a chunk after removing unsupported characters: %s",
|
||||
sorted(removed),
|
||||
)
|
||||
break
|
||||
|
||||
if attempt == 0:
|
||||
logger.warning(
|
||||
"SuperTonic: removed unsupported characters %s and retried.",
|
||||
sorted(removed),
|
||||
)
|
||||
else:
|
||||
# Exhausted retries.
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
if not chunk_to_speak:
|
||||
continue
|
||||
|
||||
audio = _ensure_float32_mono(wav)
|
||||
|
||||
# If duration is present, infer the source sample rate and resample if needed.
|
||||
src_rate = self.sample_rate
|
||||
try:
|
||||
dur = float(duration)
|
||||
if dur > 0 and audio.size > 0:
|
||||
inferred = int(round(audio.size / dur))
|
||||
if 8000 <= inferred <= 96000:
|
||||
src_rate = inferred
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if src_rate != self.sample_rate:
|
||||
audio = _resample_linear(audio, src_rate, self.sample_rate)
|
||||
|
||||
yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio)
|
||||
+262
-58
@@ -1,23 +1,51 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import warnings
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
import warnings
|
||||
from threading import Thread
|
||||
from typing import Dict, Optional
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from dotenv import load_dotenv, find_dotenv
|
||||
|
||||
def _load_environment() -> None:
|
||||
explicit_path = os.environ.get("ABOGEN_ENV_FILE")
|
||||
if explicit_path:
|
||||
load_dotenv(explicit_path, override=False)
|
||||
return
|
||||
dotenv_path = find_dotenv(usecwd=True)
|
||||
if dotenv_path:
|
||||
load_dotenv(dotenv_path, override=False)
|
||||
|
||||
|
||||
_load_environment()
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def detect_encoding(file_path):
|
||||
import chardet
|
||||
import charset_normalizer
|
||||
try:
|
||||
import chardet # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
chardet = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
import charset_normalizer # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
charset_normalizer = None # type: ignore[assignment]
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
raw_data = f.read()
|
||||
detected_encoding = None
|
||||
for detectors in (charset_normalizer, chardet):
|
||||
if detectors is None:
|
||||
continue
|
||||
try:
|
||||
result = detectors.detect(raw_data)["encoding"]
|
||||
except Exception:
|
||||
@@ -28,7 +56,6 @@ def detect_encoding(file_path):
|
||||
encoding = detected_encoding if detected_encoding else "utf-8"
|
||||
return encoding.lower()
|
||||
|
||||
|
||||
def get_resource_path(package, resource):
|
||||
"""
|
||||
Get the path to a resource file, with fallback to local file system.
|
||||
@@ -76,53 +103,214 @@ def get_resource_path(package, resource):
|
||||
def get_version():
|
||||
"""Return the current version of the application."""
|
||||
try:
|
||||
with open(get_resource_path("/", "VERSION"), "r") as f:
|
||||
version_path = get_resource_path("/", "VERSION")
|
||||
if not version_path:
|
||||
raise FileNotFoundError("VERSION resource missing")
|
||||
with open(version_path, "r") as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
return "Unknown"
|
||||
|
||||
|
||||
# Define config path
|
||||
def get_user_config_path():
|
||||
def ensure_directory(path):
|
||||
resolved = os.path.abspath(os.path.expanduser(str(path)))
|
||||
os.makedirs(resolved, exist_ok=True)
|
||||
return resolved
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_user_settings_dir():
|
||||
override = os.environ.get("ABOGEN_SETTINGS_DIR")
|
||||
if override:
|
||||
return ensure_directory(override)
|
||||
|
||||
data_root = os.environ.get("ABOGEN_DATA") or os.environ.get("ABOGEN_DATA_DIR")
|
||||
if data_root:
|
||||
try:
|
||||
return ensure_directory(os.path.join(data_root, "settings"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
data_mount = "/data"
|
||||
if os.path.isdir(data_mount):
|
||||
try:
|
||||
return ensure_directory(os.path.join(data_mount, "settings"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
# TODO Config directory is changed for Linux and MacOS. But if old config exists, it will be used.
|
||||
# On non‑Windows, prefer ~/.config/abogen if it already exists
|
||||
if platform.system() != "Windows":
|
||||
custom_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen")
|
||||
if os.path.exists(custom_dir):
|
||||
config_dir = custom_dir
|
||||
else:
|
||||
config_dir = user_config_dir(
|
||||
"abogen", appauthor=False, roaming=True, ensure_exists=True
|
||||
)
|
||||
else:
|
||||
# Windows and fallback case
|
||||
config_dir = user_config_dir(
|
||||
"abogen", appauthor=False, roaming=True, ensure_exists=True
|
||||
)
|
||||
legacy_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen")
|
||||
if os.path.exists(legacy_dir):
|
||||
return ensure_directory(legacy_dir)
|
||||
|
||||
return os.path.join(config_dir, "config.json")
|
||||
config_dir = user_config_dir("abogen", appauthor=False, roaming=True, ensure_exists=True)
|
||||
return ensure_directory(config_dir)
|
||||
|
||||
|
||||
def get_user_config_path():
|
||||
return os.path.join(get_user_settings_dir(), "config.json")
|
||||
|
||||
|
||||
# Define cache path
|
||||
def get_user_cache_path(folder=None):
|
||||
from platformdirs import user_cache_dir
|
||||
@lru_cache(maxsize=1)
|
||||
def get_user_cache_root():
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
cache_dir = user_cache_dir(
|
||||
"abogen", appauthor=False, opinion=True, ensure_exists=True
|
||||
)
|
||||
def _try_paths(*paths):
|
||||
last_error = None
|
||||
for candidate in paths:
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
return ensure_directory(candidate)
|
||||
except OSError as exc:
|
||||
last_error = exc
|
||||
logger.debug("Unable to use cache directory %s: %s", candidate, exc)
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
|
||||
def _configure_cache_env(root: Optional[str]) -> None:
|
||||
temp_root = None
|
||||
if root:
|
||||
try:
|
||||
temp_root = ensure_directory(root)
|
||||
except OSError:
|
||||
temp_root = None
|
||||
|
||||
home_dir = os.environ.get("HOME")
|
||||
if not home_dir:
|
||||
home_dir = ensure_directory(os.path.join("/tmp", "abogen-home"))
|
||||
os.environ["HOME"] = home_dir
|
||||
else:
|
||||
home_dir = ensure_directory(home_dir)
|
||||
|
||||
cache_base = os.environ.get("XDG_CACHE_HOME")
|
||||
if cache_base:
|
||||
cache_base = ensure_directory(cache_base)
|
||||
elif temp_root:
|
||||
cache_base = temp_root
|
||||
os.environ["XDG_CACHE_HOME"] = cache_base
|
||||
else:
|
||||
cache_base = ensure_directory(os.path.join(home_dir, ".cache"))
|
||||
os.environ["XDG_CACHE_HOME"] = cache_base
|
||||
|
||||
hf_cache = os.environ.get("HF_HOME")
|
||||
if hf_cache:
|
||||
hf_cache = ensure_directory(hf_cache)
|
||||
elif temp_root:
|
||||
hf_cache = ensure_directory(os.path.join(temp_root, "huggingface"))
|
||||
os.environ["HF_HOME"] = hf_cache
|
||||
else:
|
||||
hf_cache = ensure_directory(os.path.join(cache_base, "huggingface"))
|
||||
os.environ["HF_HOME"] = hf_cache
|
||||
|
||||
for env_var in ("HUGGINGFACE_HUB_CACHE", "TRANSFORMERS_CACHE"):
|
||||
os.environ.setdefault(env_var, hf_cache)
|
||||
|
||||
os.environ.setdefault("ABOGEN_INTERNAL_CACHE_ROOT", cache_base)
|
||||
|
||||
cache_root: Optional[str] = None
|
||||
|
||||
override = os.environ.get("ABOGEN_TEMP_DIR")
|
||||
if override:
|
||||
try:
|
||||
cache_root = ensure_directory(override)
|
||||
except OSError as exc:
|
||||
logger.warning("ABOGEN_TEMP_DIR=%s is not writable: %s", override, exc)
|
||||
|
||||
if cache_root is None:
|
||||
from platformdirs import user_cache_dir
|
||||
|
||||
default_cache = user_cache_dir("abogen", appauthor=False, opinion=True)
|
||||
|
||||
data_root = os.environ.get("ABOGEN_DATA") or os.environ.get("ABOGEN_DATA_DIR")
|
||||
fallback_paths = [
|
||||
default_cache,
|
||||
os.path.join(data_root, "cache") if data_root else None,
|
||||
"/data/cache",
|
||||
"/tmp/abogen-cache",
|
||||
]
|
||||
|
||||
try:
|
||||
cache_root = _try_paths(*fallback_paths)
|
||||
except OSError:
|
||||
# Final safety net – attempt a tmp directory unique to this process.
|
||||
tmp_candidate = os.path.join("/tmp", f"abogen-cache-{os.getpid()}")
|
||||
logger.warning("Falling back to temp cache directory %s", tmp_candidate)
|
||||
cache_root = ensure_directory(tmp_candidate)
|
||||
|
||||
if cache_root is None:
|
||||
raise RuntimeError("Unable to determine cache directory")
|
||||
|
||||
_configure_cache_env(cache_root)
|
||||
return cache_root
|
||||
|
||||
|
||||
def get_internal_cache_root():
|
||||
root = os.environ.get("ABOGEN_INTERNAL_CACHE_ROOT") or os.environ.get("XDG_CACHE_HOME")
|
||||
if root:
|
||||
return ensure_directory(root)
|
||||
home_dir = os.environ.get("HOME") or os.path.join("/tmp", "abogen-home")
|
||||
home_dir = ensure_directory(home_dir)
|
||||
return ensure_directory(os.path.join(home_dir, ".cache"))
|
||||
|
||||
|
||||
def get_internal_cache_path(folder=None):
|
||||
base = get_internal_cache_root()
|
||||
if folder:
|
||||
cache_dir = os.path.join(cache_dir, folder)
|
||||
# Ensure the directory exists
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return cache_dir
|
||||
return ensure_directory(os.path.join(base, folder))
|
||||
return base
|
||||
|
||||
|
||||
_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes
|
||||
def get_user_cache_path(folder=None):
|
||||
base = get_user_cache_root()
|
||||
if folder:
|
||||
return ensure_directory(os.path.join(base, folder))
|
||||
return base
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_user_output_root():
|
||||
override = (
|
||||
os.environ.get("ABOGEN_OUTPUT_DIR")
|
||||
or os.environ.get("ABOGEN_OUTPUT_ROOT")
|
||||
)
|
||||
if override:
|
||||
return ensure_directory(override)
|
||||
return ensure_directory(os.path.join(get_user_cache_root(), "outputs"))
|
||||
|
||||
|
||||
def get_user_output_path(folder=None):
|
||||
base = get_user_output_root()
|
||||
if folder:
|
||||
return ensure_directory(os.path.join(base, folder))
|
||||
return base
|
||||
|
||||
|
||||
_sleep_procs: Dict[str, Optional[subprocess.Popen[str]]] = {"Darwin": None, "Linux": None} # Store sleep prevention processes
|
||||
|
||||
|
||||
def clean_text(text, *args, **kwargs):
|
||||
# Load replace_single_newlines from config
|
||||
cfg = load_config()
|
||||
replace_single_newlines = cfg.get("replace_single_newlines", False)
|
||||
# Collapse all whitespace (excluding newlines) into single spaces per line and trim edges
|
||||
lines = [re.sub(r"[^\S\n]+", " ", line).strip() for line in text.splitlines()]
|
||||
text = "\n".join(lines)
|
||||
# Standardize paragraph breaks (multiple newlines become exactly two) and trim overall whitespace
|
||||
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||
# Optionally replace single newlines with spaces, but preserve double newlines
|
||||
if replace_single_newlines:
|
||||
text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
|
||||
return text
|
||||
|
||||
|
||||
default_encoding = sys.getfilesystemencoding()
|
||||
|
||||
|
||||
def create_process(cmd, stdin=None, text=True, capture_output=False):
|
||||
import logging
|
||||
|
||||
@@ -139,6 +327,9 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
|
||||
|
||||
# Determine shell usage: use shell only for string commands
|
||||
use_shell = isinstance(cmd, str)
|
||||
if use_shell:
|
||||
logger.warning("Security Warning: create_process called with string command. Prefer using a list of arguments to avoid shell injection risks.")
|
||||
|
||||
kwargs = {
|
||||
"shell": use_shell,
|
||||
"stdout": subprocess.PIPE,
|
||||
@@ -161,11 +352,14 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
|
||||
kwargs["stdin"] = stdin
|
||||
|
||||
if platform.system() == "Windows":
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
startupinfo.wShowWindow = subprocess.SW_HIDE
|
||||
startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined]
|
||||
startupinfo.wShowWindow = subprocess.SW_HIDE # type: ignore[attr-defined]
|
||||
kwargs.update(
|
||||
{"startupinfo": startupinfo, "creationflags": subprocess.CREATE_NO_WINDOW}
|
||||
{
|
||||
"startupinfo": startupinfo,
|
||||
"creationflags": subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined]
|
||||
}
|
||||
)
|
||||
|
||||
# Print the command being executed
|
||||
@@ -224,32 +418,39 @@ def save_config(config):
|
||||
pass
|
||||
|
||||
|
||||
def calculate_text_length(text):
|
||||
# Ignore chapter markers
|
||||
text = re.sub(r"<<CHAPTER_MARKER:.*?>>", "", text)
|
||||
# Ignore metadata patterns
|
||||
text = re.sub(r"<<METADATA_[^:]+:[^>]*>>", "", text)
|
||||
# Ignore newlines
|
||||
text = text.replace("\n", "")
|
||||
# Ignore leading/trailing spaces
|
||||
text = text.strip()
|
||||
# Calculate character count
|
||||
char_count = len(text)
|
||||
return char_count
|
||||
|
||||
|
||||
def get_gpu_acceleration(enabled):
|
||||
"""
|
||||
Check GPU acceleration availability.
|
||||
|
||||
Note: On Windows, torch DLLs must be pre-loaded in main.py before PyQt6
|
||||
to avoid DLL initialization errors.
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
from torch.cuda import is_available as cuda_available
|
||||
import torch # type: ignore[import-not-found]
|
||||
from torch.cuda import is_available as cuda_available # type: ignore[import-not-found]
|
||||
|
||||
if not enabled:
|
||||
return "GPU available but using CPU.", False
|
||||
|
||||
|
||||
# Check for Apple Silicon MPS
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
if torch.backends.mps.is_available():
|
||||
return "MPS GPU available and enabled.", True
|
||||
else:
|
||||
return "MPS GPU not available on Apple Silicon. Using CPU.", False
|
||||
|
||||
|
||||
# Check for CUDA
|
||||
if cuda_available():
|
||||
return "CUDA GPU available and enabled.", True
|
||||
|
||||
|
||||
# Gather CUDA diagnostic info if not available
|
||||
try:
|
||||
cuda_devices = torch.cuda.device_count()
|
||||
@@ -272,7 +473,7 @@ def prevent_sleep_start():
|
||||
if system == "Windows":
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(
|
||||
ctypes.windll.kernel32.SetThreadExecutionState( # type: ignore[attr-defined]
|
||||
0x80000000 | 0x00000001 | 0x00000040
|
||||
)
|
||||
elif system == "Darwin":
|
||||
@@ -306,18 +507,21 @@ def prevent_sleep_end():
|
||||
if system == "Windows":
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS
|
||||
elif system in ("Darwin", "Linux") and _sleep_procs[system]:
|
||||
try:
|
||||
_sleep_procs[system].terminate()
|
||||
_sleep_procs[system] = None
|
||||
except Exception:
|
||||
pass
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # type: ignore[attr-defined]
|
||||
elif system in ("Darwin", "Linux"):
|
||||
proc = _sleep_procs.get(system)
|
||||
if proc:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_sleep_procs[system] = None
|
||||
|
||||
|
||||
def load_numpy_kpipeline():
|
||||
import numpy as np
|
||||
from kokoro import KPipeline
|
||||
from kokoro import KPipeline # type: ignore[import-not-found]
|
||||
|
||||
return np, KPipeline
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Callable, Dict, Iterable, Optional, Set, Tuple
|
||||
|
||||
try: # pragma: no cover - optional dependency guard
|
||||
from huggingface_hub import hf_hub_download # type: ignore
|
||||
from huggingface_hub.utils import LocalEntryNotFoundError # type: ignore
|
||||
except Exception: # pragma: no cover - import fallback
|
||||
hf_hub_download = None # type: ignore[assignment]
|
||||
LocalEntryNotFoundError = None # type: ignore[assignment]
|
||||
|
||||
if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
|
||||
class LocalEntryNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
|
||||
_CACHE_LOCK = threading.Lock()
|
||||
_CACHED_VOICES: Set[str] = set()
|
||||
_BOOTSTRAP_LOCK = threading.Lock()
|
||||
_BOOTSTRAPPED = False
|
||||
|
||||
|
||||
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
|
||||
if not voices:
|
||||
return set(VOICES_INTERNAL)
|
||||
normalized: Set[str] = set()
|
||||
for voice in voices:
|
||||
if not voice:
|
||||
continue
|
||||
voice_id = str(voice).strip()
|
||||
if not voice_id:
|
||||
continue
|
||||
if voice_id in VOICES_INTERNAL:
|
||||
normalized.add(voice_id)
|
||||
return normalized
|
||||
|
||||
|
||||
def ensure_voice_assets(
|
||||
voices: Optional[Iterable[str]] = None,
|
||||
*,
|
||||
repo_id: str = "hexgrad/Kokoro-82M",
|
||||
cache_dir: Optional[str] = None,
|
||||
on_progress: Optional[Callable[[str], None]] = None,
|
||||
) -> Tuple[Set[str], Dict[str, str]]:
|
||||
"""Ensure Kokoro voice weight files are present locally.
|
||||
|
||||
Returns a tuple of (downloaded voices, errors) where errors maps the
|
||||
voice id to the underlying exception message.
|
||||
"""
|
||||
|
||||
if hf_hub_download is None:
|
||||
raise RuntimeError("huggingface_hub is required to cache voices")
|
||||
|
||||
effective_cache_dir = cache_dir
|
||||
if effective_cache_dir is None:
|
||||
env_cache_dir = os.environ.get("ABOGEN_VOICE_CACHE_DIR", "").strip()
|
||||
effective_cache_dir = env_cache_dir or None
|
||||
|
||||
targets = _normalize_targets(voices)
|
||||
if not targets:
|
||||
return set(), {}
|
||||
|
||||
with _CACHE_LOCK:
|
||||
missing = [voice for voice in targets if voice not in _CACHED_VOICES]
|
||||
|
||||
downloaded: Set[str] = set()
|
||||
errors: Dict[str, str] = {}
|
||||
|
||||
for voice_id in missing:
|
||||
if on_progress:
|
||||
on_progress(f"Fetching voice asset '{voice_id}'")
|
||||
try:
|
||||
downloaded_flag = _ensure_single_voice_asset(
|
||||
voice_id,
|
||||
repo_id=repo_id,
|
||||
cache_dir=effective_cache_dir,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - network variance
|
||||
errors[voice_id] = str(exc)
|
||||
continue
|
||||
|
||||
if downloaded_flag:
|
||||
downloaded.add(voice_id)
|
||||
with _CACHE_LOCK:
|
||||
_CACHED_VOICES.add(voice_id)
|
||||
|
||||
return downloaded, errors
|
||||
|
||||
|
||||
def bootstrap_voice_cache(
|
||||
voices: Optional[Iterable[str]] = None,
|
||||
*,
|
||||
repo_id: str = "hexgrad/Kokoro-82M",
|
||||
cache_dir: Optional[str] = None,
|
||||
on_progress: Optional[Callable[[str], None]] = None,
|
||||
) -> Tuple[Set[str], Dict[str, str]]:
|
||||
"""Ensure voices are cached once per process.
|
||||
|
||||
Subsequent calls are no-ops and return empty structures.
|
||||
"""
|
||||
|
||||
global _BOOTSTRAPPED
|
||||
with _BOOTSTRAP_LOCK:
|
||||
if _BOOTSTRAPPED:
|
||||
return set(), {}
|
||||
downloaded, errors = ensure_voice_assets(
|
||||
voices,
|
||||
repo_id=repo_id,
|
||||
cache_dir=cache_dir,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
_BOOTSTRAPPED = True
|
||||
return downloaded, errors
|
||||
|
||||
|
||||
def _ensure_single_voice_asset(
|
||||
voice_id: str,
|
||||
*,
|
||||
repo_id: str,
|
||||
cache_dir: Optional[str],
|
||||
) -> bool:
|
||||
if hf_hub_download is None:
|
||||
raise RuntimeError("huggingface_hub is required to cache voices")
|
||||
|
||||
filename = f"voices/{voice_id}.pt"
|
||||
common_kwargs = {
|
||||
"repo_id": repo_id,
|
||||
"filename": filename,
|
||||
}
|
||||
if cache_dir is not None:
|
||||
common_kwargs["cache_dir"] = cache_dir
|
||||
|
||||
try:
|
||||
hf_hub_download(local_files_only=True, **common_kwargs)
|
||||
return False
|
||||
except LocalEntryNotFoundError:
|
||||
pass
|
||||
|
||||
hf_hub_download(resume_download=True, **common_kwargs)
|
||||
return True
|
||||
+7
-1517
File diff suppressed because it is too large
Load Diff
+46
-22
@@ -1,4 +1,6 @@
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
|
||||
|
||||
@@ -15,38 +17,56 @@ def get_new_voice(pipeline, formula, use_gpu):
|
||||
raise ValueError(f"Failed to create voice: {str(e)}")
|
||||
|
||||
|
||||
# Parse the formula and get the combined voice tensor
|
||||
def parse_voice_formula(pipeline, formula):
|
||||
if not formula.strip():
|
||||
def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
|
||||
if not formula or not formula.strip():
|
||||
raise ValueError("Empty voice formula")
|
||||
|
||||
# Initialize the weighted sum
|
||||
weighted_sum = None
|
||||
|
||||
total_weight = calculate_sum_from_formula(formula)
|
||||
|
||||
# Split the formula into terms
|
||||
voices = formula.split("+")
|
||||
|
||||
for term in voices:
|
||||
# Parse each term (format: "voice_name*0.333")
|
||||
voice_name, weight = term.strip().split("*")
|
||||
weight = float(weight.strip())
|
||||
# normalize the weight
|
||||
weight /= total_weight if total_weight > 0 else 1.0
|
||||
terms: List[Tuple[str, float]] = []
|
||||
for segment in formula.split("+"):
|
||||
part = segment.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "*" not in part:
|
||||
raise ValueError("Each component must be in the form voice*weight")
|
||||
voice_name, raw_weight = part.split("*", 1)
|
||||
voice_name = voice_name.strip()
|
||||
|
||||
# Get the voice tensor
|
||||
if voice_name not in VOICES_INTERNAL:
|
||||
raise ValueError(f"Unknown voice: {voice_name}")
|
||||
try:
|
||||
weight = float(raw_weight.strip())
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid weight for {voice_name}") from exc
|
||||
if weight <= 0:
|
||||
raise ValueError(f"Weight for {voice_name} must be positive")
|
||||
terms.append((voice_name, weight))
|
||||
|
||||
if not terms:
|
||||
raise ValueError("Voice weights must sum to a positive value")
|
||||
|
||||
return terms
|
||||
|
||||
|
||||
def parse_voice_formula(pipeline, formula):
|
||||
terms = parse_formula_terms(formula)
|
||||
|
||||
total_weight = sum(weight for _, weight in terms)
|
||||
if total_weight <= 0:
|
||||
raise ValueError("Voice weights must sum to a positive value")
|
||||
|
||||
weighted_sum = None
|
||||
|
||||
for voice_name, weight in terms:
|
||||
normalized_weight = weight / total_weight if total_weight > 0 else weight
|
||||
|
||||
voice_tensor = pipeline.load_single_voice(voice_name)
|
||||
|
||||
# Add to weighted sum
|
||||
if weighted_sum is None:
|
||||
weighted_sum = weight * voice_tensor
|
||||
weighted_sum = normalized_weight * voice_tensor
|
||||
else:
|
||||
weighted_sum += weight * voice_tensor
|
||||
weighted_sum += normalized_weight * voice_tensor
|
||||
|
||||
if weighted_sum is None:
|
||||
raise ValueError("Voice formula produced no components")
|
||||
|
||||
return weighted_sum
|
||||
|
||||
@@ -55,3 +75,7 @@ def calculate_sum_from_formula(formula):
|
||||
weights = re.findall(r"\* *([\d.]+)", formula)
|
||||
total_sum = sum(float(weight) for weight in weights)
|
||||
return total_sum
|
||||
|
||||
|
||||
def extract_voice_ids(formula: str) -> List[str]:
|
||||
return [voice for voice, _ in parse_formula_terms(formula)]
|
||||
|
||||
+163
-1
@@ -1,5 +1,9 @@
|
||||
import os
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
|
||||
from abogen.utils import get_user_config_path
|
||||
|
||||
|
||||
@@ -57,3 +61,161 @@ def export_profiles(export_path):
|
||||
profiles = load_profiles()
|
||||
with open(export_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
||||
|
||||
|
||||
def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
||||
"""Return profiles in canonical dictionary form."""
|
||||
return load_profiles()
|
||||
|
||||
|
||||
def _normalize_supertonic_voice(value: Any) -> str:
|
||||
raw = str(value or "").strip().upper()
|
||||
return raw if raw in DEFAULT_SUPERTONIC_VOICES else "M1"
|
||||
|
||||
|
||||
def _coerce_supertonic_steps(value: Any) -> int:
|
||||
try:
|
||||
steps = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 5
|
||||
return max(2, min(15, steps))
|
||||
|
||||
|
||||
def _coerce_supertonic_speed(value: Any) -> float:
|
||||
try:
|
||||
speed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
return max(0.7, min(2.0, speed))
|
||||
|
||||
|
||||
def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
||||
"""Normalize a stored profile entry.
|
||||
|
||||
Backwards compatible:
|
||||
- Legacy Kokoro-only entries: {language, voices}
|
||||
- New entries: include provider.
|
||||
"""
|
||||
|
||||
if not isinstance(entry, dict):
|
||||
return {}
|
||||
|
||||
provider = str(entry.get("provider") or "kokoro").strip().lower()
|
||||
if provider not in {"kokoro", "supertonic"}:
|
||||
provider = "kokoro"
|
||||
|
||||
language = str(entry.get("language") or "a").strip().lower() or "a"
|
||||
|
||||
if provider == "supertonic":
|
||||
return {
|
||||
"provider": "supertonic",
|
||||
"language": language,
|
||||
"voice": _normalize_supertonic_voice(entry.get("voice") or entry.get("voice_name") or entry.get("name")),
|
||||
"total_steps": _coerce_supertonic_steps(entry.get("total_steps") or entry.get("supertonic_total_steps") or entry.get("quality")),
|
||||
"speed": _coerce_supertonic_speed(entry.get("speed") or entry.get("supertonic_speed")),
|
||||
}
|
||||
|
||||
voices = _normalize_voice_entries(entry.get("voices", []))
|
||||
if not voices:
|
||||
return {}
|
||||
return {
|
||||
"provider": "kokoro",
|
||||
"language": language,
|
||||
"voices": voices,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||
normalized: List[Tuple[str, float]] = []
|
||||
for item in entries or []:
|
||||
if isinstance(item, dict):
|
||||
voice = item.get("id") or item.get("voice")
|
||||
weight = item.get("weight")
|
||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
voice, weight = item[0], item[1]
|
||||
else:
|
||||
continue
|
||||
if voice not in VOICES_INTERNAL:
|
||||
continue
|
||||
if weight is None:
|
||||
continue
|
||||
try:
|
||||
weight_val = float(weight)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if weight_val <= 0:
|
||||
continue
|
||||
normalized.append((voice, weight_val))
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||
"""Public helper to normalize voice-weight pairs from arbitrary payloads."""
|
||||
|
||||
return _normalize_voice_entries(entries)
|
||||
|
||||
|
||||
def save_profile(name: str, *, language: str, voices: Iterable) -> None:
|
||||
"""Persist a single profile after validating its data."""
|
||||
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Profile name is required")
|
||||
|
||||
normalized = _normalize_voice_entries(voices)
|
||||
if not normalized:
|
||||
raise ValueError("At least one voice with a weight above zero is required")
|
||||
|
||||
if not language:
|
||||
language = "a"
|
||||
|
||||
profiles = load_profiles()
|
||||
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
|
||||
save_profiles(profiles)
|
||||
|
||||
|
||||
def remove_profile(name: str) -> None:
|
||||
delete_profile(name)
|
||||
|
||||
|
||||
def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]:
|
||||
"""Merge profiles from a dictionary structure and persist them.
|
||||
|
||||
Returns the list of profile names that were added or updated.
|
||||
"""
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid profile payload")
|
||||
|
||||
if "abogen_voice_profiles" in data:
|
||||
data = data["abogen_voice_profiles"]
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid profile payload")
|
||||
|
||||
current = load_profiles()
|
||||
updated: List[str] = []
|
||||
for name, entry in data.items():
|
||||
normalized = normalize_profile_entry(entry)
|
||||
if not normalized:
|
||||
continue
|
||||
if name in current and not replace_existing:
|
||||
# skip duplicates unless explicit replacement is requested
|
||||
continue
|
||||
current[name] = normalized
|
||||
updated.append(name)
|
||||
|
||||
if updated:
|
||||
save_profiles(current)
|
||||
return updated
|
||||
|
||||
|
||||
def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]:
|
||||
"""Return profiles limited to the provided names for download/export."""
|
||||
|
||||
profiles = load_profiles()
|
||||
if names is None:
|
||||
subset = profiles
|
||||
else:
|
||||
subset = {name: profiles[name] for name in names if name in profiles}
|
||||
return {"abogen_voice_profiles": subset}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
VIRTUAL_ENV=/opt/venv \
|
||||
PATH=/opt/venv/bin:$PATH
|
||||
|
||||
ARG TORCH_INDEX_URL=https://download.pytorch.org/whl/cu126
|
||||
ARG TORCH_VERSION=
|
||||
ARG USE_GPU=true
|
||||
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
ffmpeg \
|
||||
libsndfile1 \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python3 -m venv "$VIRTUAL_ENV"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY abogen ./abogen
|
||||
|
||||
RUN pip install --upgrade pip \
|
||||
&& if [ -n "$TORCH_VERSION" ]; then \
|
||||
pip install torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
|
||||
else \
|
||||
pip install torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
|
||||
fi \
|
||||
&& pip install --no-cache-dir . \
|
||||
https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl \
|
||||
&& pip install --no-cache-dir "mutagen>=1.47.0"
|
||||
|
||||
# Install onnxruntime-gpu for CUDA acceleration (supertonic uses ONNX Runtime)
|
||||
# Set USE_GPU=false to skip this for CPU-only deployments
|
||||
RUN if [ "$USE_GPU" = "true" ]; then \
|
||||
pip install --no-cache-dir onnxruntime-gpu; \
|
||||
fi
|
||||
|
||||
ENV ABOGEN_HOST=0.0.0.0 \
|
||||
ABOGEN_PORT=8808
|
||||
|
||||
EXPOSE 8808
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENV ABOGEN_UPLOAD_ROOT=/data/uploads \
|
||||
ABOGEN_OUTPUT_ROOT=/data/outputs \
|
||||
ABOGEN_TEMP_DIR=/data/cache \
|
||||
ABOGEN_VOICE_CACHE_DIR=/data/voice-cache \
|
||||
HF_HOME=/data/huggingface \
|
||||
HUGGINGFACE_HUB_CACHE=/data/huggingface/hub
|
||||
|
||||
# Copy and setup entrypoint script
|
||||
COPY abogen/webui/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Create non-root user and setup permissions
|
||||
RUN useradd -m -u 1000 abogen \
|
||||
&& mkdir -p /data/uploads /data/outputs /data/cache /data/voice-cache /data/huggingface \
|
||||
&& chown -R abogen:abogen /data /app
|
||||
|
||||
USER abogen
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["abogen-web"]
|
||||
@@ -0,0 +1,9 @@
|
||||
__all__ = ["create_app"]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "create_app":
|
||||
from .app import create_app
|
||||
|
||||
return create_app
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from abogen.utils import get_user_cache_path, get_user_output_path, get_user_settings_dir
|
||||
|
||||
from .conversion_runner import run_conversion_job
|
||||
from .service import build_service
|
||||
|
||||
|
||||
class _SuppressSuccessfulAccessFilter(logging.Filter):
|
||||
"""Filter out successful (HTTP 200) werkzeug access logs."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover - small utility
|
||||
try:
|
||||
message = record.getMessage()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return True
|
||||
# Werkzeug access logs include the status code near the end, e.g.
|
||||
# "GET /path HTTP/1.1" 200 -
|
||||
# Treat any 2xx response as success to suppress.
|
||||
return " 200 " not in message and " 201 " not in message and " 204 " not in message
|
||||
|
||||
|
||||
_access_log_filter_attached = False
|
||||
|
||||
|
||||
def _default_dirs() -> tuple[Path, Path]:
|
||||
uploads_override = os.environ.get("ABOGEN_UPLOAD_ROOT")
|
||||
outputs_override = os.environ.get("ABOGEN_OUTPUT_ROOT")
|
||||
|
||||
if uploads_override:
|
||||
uploads = Path(os.path.expanduser(uploads_override)).resolve()
|
||||
else:
|
||||
uploads = Path(get_user_cache_path("web/uploads"))
|
||||
|
||||
if outputs_override:
|
||||
outputs = Path(os.path.expanduser(outputs_override)).resolve()
|
||||
else:
|
||||
outputs = Path(get_user_output_path("web"))
|
||||
|
||||
uploads.mkdir(parents=True, exist_ok=True)
|
||||
outputs.mkdir(parents=True, exist_ok=True)
|
||||
return uploads, outputs
|
||||
|
||||
|
||||
def _get_secret_key() -> str:
|
||||
env_key = os.environ.get("ABOGEN_SECRET_KEY")
|
||||
if env_key:
|
||||
return env_key
|
||||
|
||||
try:
|
||||
settings_dir = Path(get_user_settings_dir())
|
||||
settings_dir.mkdir(parents=True, exist_ok=True)
|
||||
secret_file = settings_dir / ".secret_key"
|
||||
if secret_file.exists():
|
||||
return secret_file.read_text(encoding="utf-8").strip()
|
||||
|
||||
key = os.urandom(24).hex()
|
||||
secret_file.write_text(key, encoding="utf-8")
|
||||
return key
|
||||
except Exception:
|
||||
# Fallback if we can't write to settings dir
|
||||
return os.urandom(24).hex()
|
||||
|
||||
|
||||
def create_app(config: Optional[dict[str, Any]] = None) -> Flask:
|
||||
uploads_dir, outputs_dir = _default_dirs()
|
||||
|
||||
app = Flask(
|
||||
__name__,
|
||||
static_folder="static",
|
||||
template_folder="templates",
|
||||
)
|
||||
base_config = {
|
||||
"SECRET_KEY": _get_secret_key(),
|
||||
"UPLOAD_FOLDER": str(uploads_dir),
|
||||
"OUTPUT_FOLDER": str(outputs_dir),
|
||||
"MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads
|
||||
}
|
||||
if config:
|
||||
base_config.update(config)
|
||||
app.config.update(base_config)
|
||||
|
||||
service = build_service(
|
||||
runner=run_conversion_job,
|
||||
output_root=Path(app.config["OUTPUT_FOLDER"]),
|
||||
uploads_root=Path(app.config["UPLOAD_FOLDER"]),
|
||||
)
|
||||
app.extensions["conversion_service"] = service
|
||||
|
||||
from abogen.webui.routes import (
|
||||
main_bp,
|
||||
jobs_bp,
|
||||
settings_bp,
|
||||
voices_bp,
|
||||
entities_bp,
|
||||
books_bp,
|
||||
api_bp,
|
||||
)
|
||||
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(jobs_bp, url_prefix="/jobs")
|
||||
app.register_blueprint(settings_bp, url_prefix="/settings")
|
||||
app.register_blueprint(voices_bp, url_prefix="/voices")
|
||||
app.register_blueprint(entities_bp, url_prefix="/overrides")
|
||||
app.register_blueprint(books_bp, url_prefix="/find-books")
|
||||
app.register_blueprint(api_bp, url_prefix="/api")
|
||||
|
||||
atexit.register(service.shutdown)
|
||||
|
||||
global _access_log_filter_attached
|
||||
if not _access_log_filter_attached:
|
||||
logging.getLogger("werkzeug").addFilter(_SuppressSuccessfulAccessFilter())
|
||||
_access_log_filter_attached = True
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app = create_app()
|
||||
host = os.environ.get("ABOGEN_HOST", "0.0.0.0")
|
||||
port = int(os.environ.get("ABOGEN_PORT", "8808"))
|
||||
debug = os.environ.get("ABOGEN_DEBUG", "false").lower() == "true"
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.debug_tts_samples import MARKER_PREFIX, MARKER_SUFFIX, build_debug_epub, iter_expected_codes
|
||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||
from abogen.normalization_settings import build_apostrophe_config
|
||||
from abogen.text_extractor import extract_from_path
|
||||
from abogen.voice_cache import ensure_voice_assets
|
||||
from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
|
||||
from abogen.utils import load_numpy_kpipeline
|
||||
|
||||
|
||||
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DebugWavArtifact:
|
||||
label: str
|
||||
filename: str
|
||||
code: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
|
||||
|
||||
def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]:
|
||||
"""Resolve settings voice strings into a pipeline-ready voice spec.
|
||||
|
||||
Supports "profile:<name>" by converting it into a concrete voice formula.
|
||||
Returns (resolved_voice_spec, profile_name, profile_language).
|
||||
"""
|
||||
|
||||
from abogen.webui.routes.utils.voice import resolve_voice_setting
|
||||
|
||||
return resolve_voice_setting(value)
|
||||
|
||||
|
||||
def _load_pipeline(language: str, use_gpu: bool) -> Any:
|
||||
device = "cpu"
|
||||
if use_gpu:
|
||||
device = _select_device()
|
||||
_np, KPipeline = load_numpy_kpipeline()
|
||||
return KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||
|
||||
|
||||
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
|
||||
raw = str(text or "")
|
||||
matches = list(_MARKER_RE.finditer(raw))
|
||||
cases: List[Tuple[str, str]] = []
|
||||
if not matches:
|
||||
return cases
|
||||
for idx, match in enumerate(matches):
|
||||
code = match.group("code")
|
||||
start = match.end()
|
||||
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(raw)
|
||||
snippet = raw[start:end]
|
||||
# Keep it small and predictable: collapse whitespace.
|
||||
snippet = " ".join(snippet.strip().split())
|
||||
cases.append((code, snippet))
|
||||
return cases
|
||||
|
||||
|
||||
def _spoken_id(code: str) -> str:
|
||||
# Make IDs pronounceable and stable (avoid reading as a word).
|
||||
out: List[str] = []
|
||||
for ch in str(code or ""):
|
||||
if ch == "_":
|
||||
out.append(" ")
|
||||
elif ch.isalnum():
|
||||
out.append(ch)
|
||||
else:
|
||||
out.append(" ")
|
||||
# Add spaces between alnum to encourage letter-by-letter reading.
|
||||
spaced = " ".join("".join(out).split())
|
||||
return spaced
|
||||
|
||||
|
||||
def run_debug_tts_wavs(
|
||||
*,
|
||||
output_root: Path,
|
||||
settings: Mapping[str, Any],
|
||||
epub_path: Optional[Path] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate WAV artifacts for the debug EPUB samples.
|
||||
|
||||
Writes:
|
||||
- overall.wav: concatenation of all samples
|
||||
- case_<CODE>.wav: each sample rendered separately
|
||||
- manifest.json: metadata + file list
|
||||
"""
|
||||
|
||||
output_root = Path(output_root)
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
run_id = uuid.uuid4().hex
|
||||
run_dir = output_root / "debug" / run_id
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if epub_path is None:
|
||||
epub_path = run_dir / "abogen_debug_samples.epub"
|
||||
build_debug_epub(epub_path)
|
||||
else:
|
||||
epub_path = Path(epub_path)
|
||||
|
||||
extraction = extract_from_path(epub_path)
|
||||
combined_text = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters)
|
||||
cases = _extract_cases_from_text(combined_text)
|
||||
|
||||
# Prefer the canonical sample catalog for text (EPUB extraction may include headings).
|
||||
try:
|
||||
from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES
|
||||
|
||||
sample_text_by_code = {sample.code: sample.text for sample in DEBUG_TTS_SAMPLES}
|
||||
except Exception:
|
||||
sample_text_by_code = {}
|
||||
|
||||
expected = list(iter_expected_codes())
|
||||
found_codes = {code for code, _ in cases}
|
||||
missing = [code for code in expected if code not in found_codes]
|
||||
if missing:
|
||||
raise RuntimeError(f"Debug EPUB missing expected codes: {', '.join(missing)}")
|
||||
|
||||
language = str(settings.get("language") or "a").strip() or "a"
|
||||
# Kokoro's KPipeline expects short language codes like "a" (American English),
|
||||
# but older settings may store ISO-like values such as "en".
|
||||
language_aliases = {
|
||||
"en": "a",
|
||||
"en-us": "a",
|
||||
"en_us": "a",
|
||||
"en-gb": "b",
|
||||
"en_gb": "b",
|
||||
"es": "e",
|
||||
"es-es": "e",
|
||||
"fr": "f",
|
||||
"fr-fr": "f",
|
||||
"hi": "h",
|
||||
"it": "i",
|
||||
"pt": "p",
|
||||
"pt-br": "p",
|
||||
"ja": "j",
|
||||
"jp": "j",
|
||||
"zh": "z",
|
||||
"zh-cn": "z",
|
||||
}
|
||||
language = language_aliases.get(language.lower(), language)
|
||||
voice_spec = str(settings.get("default_voice") or "").strip()
|
||||
use_gpu = bool(settings.get("use_gpu", False))
|
||||
speed = float(settings.get("default_speed", 1.0) or 1.0)
|
||||
|
||||
# Settings may store "profile:<name>" which is not a Kokoro voice ID.
|
||||
# Resolve it to a concrete voice formula (e.g. "af_heart*0.5+...") so Kokoro
|
||||
# doesn't attempt to download a non-existent "voices/profile:<name>.pt".
|
||||
try:
|
||||
resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec)
|
||||
if resolved_voice:
|
||||
voice_spec = resolved_voice
|
||||
if profile_language:
|
||||
language = str(profile_language).strip() or language
|
||||
except Exception:
|
||||
# Voice profile resolution is best-effort; fall back to raw voice_spec.
|
||||
pass
|
||||
|
||||
# Best-effort voice caching (only for known Kokoro internal voices).
|
||||
voice_ids = _spec_to_voice_ids(voice_spec)
|
||||
if voice_ids:
|
||||
try:
|
||||
ensure_voice_assets(voice_ids)
|
||||
except Exception:
|
||||
# Network / optional dependency variance; debug runner can still proceed.
|
||||
pass
|
||||
|
||||
pipeline = _load_pipeline(language, use_gpu)
|
||||
voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu)
|
||||
|
||||
apostrophe_config = build_apostrophe_config(settings=settings)
|
||||
normalization_settings = dict(settings)
|
||||
|
||||
artifacts: List[DebugWavArtifact] = []
|
||||
|
||||
overall_path = run_dir / "overall.wav"
|
||||
overall_audio: List[np.ndarray] = []
|
||||
|
||||
def synth(text: str, *, apply_normalization: bool = True) -> np.ndarray:
|
||||
normalized = (
|
||||
normalize_for_pipeline(
|
||||
text,
|
||||
config=apostrophe_config,
|
||||
settings=normalization_settings,
|
||||
)
|
||||
if apply_normalization
|
||||
else str(text or "")
|
||||
)
|
||||
parts: List[np.ndarray] = []
|
||||
for segment in pipeline(
|
||||
normalized,
|
||||
voice=voice_choice,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
):
|
||||
audio = _to_float32(getattr(segment, "audio", None))
|
||||
if audio.size:
|
||||
parts.append(audio)
|
||||
if not parts:
|
||||
return np.zeros(0, dtype="float32")
|
||||
return np.concatenate(parts).astype("float32", copy=False)
|
||||
|
||||
pause_1s = np.zeros(int(1.0 * SAMPLE_RATE), dtype="float32")
|
||||
between_cases = np.zeros(int(0.35 * SAMPLE_RATE), dtype="float32")
|
||||
|
||||
# Per sample
|
||||
for code, snippet in cases:
|
||||
snippet = sample_text_by_code.get(code, snippet)
|
||||
if not snippet:
|
||||
continue
|
||||
id_audio = synth(_spoken_id(code), apply_normalization=False)
|
||||
text_audio = synth(snippet, apply_normalization=True)
|
||||
audio = np.concatenate([id_audio, pause_1s, text_audio]).astype("float32", copy=False)
|
||||
filename = f"case_{code}.wav"
|
||||
path = run_dir / filename
|
||||
# Write float32 PCM WAV.
|
||||
import soundfile as sf
|
||||
|
||||
sf.write(path, audio, SAMPLE_RATE, subtype="FLOAT")
|
||||
artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code, text=snippet))
|
||||
overall_audio.append(audio)
|
||||
overall_audio.append(between_cases)
|
||||
|
||||
# Overall
|
||||
if overall_audio:
|
||||
combined = np.concatenate(overall_audio).astype("float32", copy=False)
|
||||
else:
|
||||
combined = np.zeros(0, dtype="float32")
|
||||
import soundfile as sf
|
||||
|
||||
sf.write(overall_path, combined, SAMPLE_RATE, subtype="FLOAT")
|
||||
artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None, text=None))
|
||||
|
||||
manifest = {
|
||||
"run_id": run_id,
|
||||
"epub": str(epub_path),
|
||||
"artifacts": [artifact.__dict__ for artifact in artifacts],
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
}
|
||||
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
return manifest
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Entrypoint script for abogen container
|
||||
# Performs CUDA diagnostics and starts the web server
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Abogen Container Starting ==="
|
||||
|
||||
# Check CUDA availability
|
||||
if command -v nvidia-smi &> /dev/null; then
|
||||
echo "NVIDIA Driver detected:"
|
||||
nvidia-smi --query-gpu=name,driver_version,memory.total,memory.free --format=csv,noheader 2>/dev/null || echo " (nvidia-smi query failed)"
|
||||
|
||||
# Check PyTorch CUDA support
|
||||
python3 -c "
|
||||
import torch
|
||||
print(f'PyTorch version: {torch.__version__}')
|
||||
print(f'CUDA available: {torch.cuda.is_available()}')
|
||||
if torch.cuda.is_available():
|
||||
print(f'CUDA version (PyTorch): {torch.version.cuda}')
|
||||
print(f'GPU count: {torch.cuda.device_count()}')
|
||||
for i in range(torch.cuda.device_count()):
|
||||
props = torch.cuda.get_device_properties(i)
|
||||
print(f' GPU {i}: {props.name} ({props.total_memory // 1024**2} MB)')
|
||||
else:
|
||||
print('WARNING: PyTorch cannot access CUDA. Running on CPU.')
|
||||
" 2>&1 || echo "PyTorch CUDA check failed"
|
||||
else
|
||||
echo "No NVIDIA driver detected. Running on CPU."
|
||||
fi
|
||||
|
||||
echo "================================="
|
||||
echo ""
|
||||
|
||||
# Start the application
|
||||
exec "$@"
|
||||
@@ -0,0 +1,18 @@
|
||||
from abogen.webui.routes.main import main_bp
|
||||
from abogen.webui.routes.jobs import jobs_bp
|
||||
from abogen.webui.routes.settings import settings_bp
|
||||
from abogen.webui.routes.voices import voices_bp
|
||||
from abogen.webui.routes.entities import entities_bp
|
||||
from abogen.webui.routes.books import books_bp
|
||||
from abogen.webui.routes.api import api_bp
|
||||
|
||||
__all__ = [
|
||||
"main_bp",
|
||||
"jobs_bp",
|
||||
"settings_bp",
|
||||
"voices_bp",
|
||||
"entities_bp",
|
||||
"books_bp",
|
||||
"api_bp",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
from typing import Any, Dict, Mapping, List, Optional
|
||||
import base64
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, request, jsonify, send_file, url_for, current_app
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.webui.routes.utils.settings import (
|
||||
load_settings,
|
||||
load_integration_settings,
|
||||
coerce_float,
|
||||
coerce_bool,
|
||||
audiobookshelf_settings_from_payload,
|
||||
calibre_settings_from_payload,
|
||||
)
|
||||
from abogen.voice_profiles import (
|
||||
load_profiles,
|
||||
save_profiles,
|
||||
delete_profile,
|
||||
duplicate_profile,
|
||||
serialize_profiles,
|
||||
import_profiles_data,
|
||||
export_profiles_payload,
|
||||
normalize_profile_entry,
|
||||
)
|
||||
from abogen.webui.routes.utils.common import split_profile_spec
|
||||
from abogen.webui.routes.utils.preview import synthesize_preview, generate_preview_audio
|
||||
from abogen.webui.routes.utils.voice import formula_from_profile
|
||||
from abogen.normalization_settings import (
|
||||
build_llm_configuration,
|
||||
build_apostrophe_config,
|
||||
apply_overrides,
|
||||
)
|
||||
from abogen.llm_client import list_models, LLMClientError
|
||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
|
||||
from abogen.integrations.calibre_opds import (
|
||||
CalibreOPDSClient,
|
||||
CalibreOPDSError,
|
||||
)
|
||||
from abogen.webui.routes.utils.service import get_service
|
||||
from abogen.webui.routes.utils.form import build_pending_job_from_extraction
|
||||
from abogen.text_extractor import extract_from_path
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
api_bp = Blueprint("api", __name__)
|
||||
|
||||
# --- Voice Profile Routes ---
|
||||
|
||||
@api_bp.get("/voice-profiles")
|
||||
def api_get_voice_profiles() -> ResponseReturnValue:
|
||||
profiles = load_profiles()
|
||||
return jsonify(profiles)
|
||||
|
||||
@api_bp.post("/voice-profiles")
|
||||
def api_save_voice_profile() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
name = str(payload.get("name") or "").strip()
|
||||
original_name = str(payload.get("originalName") or "").strip() or None
|
||||
|
||||
profile = payload.get("profile")
|
||||
if profile is None:
|
||||
# Speaker Studio payload format
|
||||
provider = str(payload.get("provider") or "kokoro").strip().lower()
|
||||
if provider not in {"kokoro", "supertonic"}:
|
||||
provider = "kokoro"
|
||||
if provider == "supertonic":
|
||||
profile = {
|
||||
"provider": "supertonic",
|
||||
"language": str(payload.get("language") or "a").strip().lower() or "a",
|
||||
"voice": payload.get("voice"),
|
||||
"total_steps": payload.get("total_steps") or payload.get("supertonic_total_steps"),
|
||||
"speed": payload.get("speed") or payload.get("supertonic_speed"),
|
||||
}
|
||||
else:
|
||||
profile = {
|
||||
"provider": "kokoro",
|
||||
"language": str(payload.get("language") or "a").strip().lower() or "a",
|
||||
"voices": payload.get("voices") or [],
|
||||
}
|
||||
|
||||
if not name or not profile:
|
||||
return jsonify({"error": "Name and profile are required"}), 400
|
||||
|
||||
profiles = load_profiles()
|
||||
|
||||
normalized = normalize_profile_entry(profile)
|
||||
if not normalized:
|
||||
return jsonify({"error": "Invalid profile payload"}), 400
|
||||
|
||||
if original_name and original_name in profiles and original_name != name:
|
||||
del profiles[original_name]
|
||||
|
||||
profiles[name] = normalized
|
||||
save_profiles(profiles)
|
||||
|
||||
return jsonify({"success": True, "profile": name, "profiles": serialize_profiles()})
|
||||
|
||||
@api_bp.delete("/voice-profiles/<path:name>")
|
||||
def api_delete_voice_profile(name: str) -> ResponseReturnValue:
|
||||
delete_profile(name)
|
||||
return jsonify({"success": True, "profiles": serialize_profiles()})
|
||||
|
||||
|
||||
@api_bp.post("/voice-profiles/<path:name>/duplicate")
|
||||
def api_duplicate_voice_profile(name: str) -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
new_name = str(payload.get("name") or "").strip()
|
||||
if not new_name:
|
||||
return jsonify({"error": "Name is required"}), 400
|
||||
duplicate_profile(name, new_name)
|
||||
return jsonify({"success": True, "profile": new_name, "profiles": serialize_profiles()})
|
||||
|
||||
|
||||
@api_bp.post("/voice-profiles/import")
|
||||
def api_import_voice_profiles() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
data = payload.get("data")
|
||||
replace_existing = bool(payload.get("replace_existing"))
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Invalid profile payload"}), 400
|
||||
try:
|
||||
imported = import_profiles_data(data, replace_existing=replace_existing)
|
||||
except Exception as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify({"success": True, "imported": imported, "profiles": serialize_profiles()})
|
||||
|
||||
|
||||
@api_bp.get("/voice-profiles/export")
|
||||
def api_export_voice_profiles() -> ResponseReturnValue:
|
||||
names_param = request.args.get("names")
|
||||
names = None
|
||||
if names_param:
|
||||
names = [item.strip() for item in names_param.split(",") if item.strip()]
|
||||
payload = export_profiles_payload(names)
|
||||
import io
|
||||
import json
|
||||
|
||||
data = json.dumps(payload, indent=2).encode("utf-8")
|
||||
filename = "voice_profiles.json" if not names else "voice_profiles_export.json"
|
||||
return send_file(
|
||||
io.BytesIO(data),
|
||||
mimetype="application/json",
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
)
|
||||
|
||||
|
||||
@api_bp.post("/voice-profiles/preview")
|
||||
def api_voice_profiles_preview() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
text = str(payload.get("text") or "").strip() or "Hello world"
|
||||
language = str(payload.get("language") or "a").strip().lower() or "a"
|
||||
speed = coerce_float(payload.get("speed"), 1.0)
|
||||
max_seconds = coerce_float(payload.get("max_seconds"), 8.0)
|
||||
|
||||
settings = load_settings()
|
||||
use_gpu = settings.get("use_gpu", False)
|
||||
|
||||
# Accept a direct formula string or a full profile entry.
|
||||
formula = str(payload.get("formula") or "").strip()
|
||||
profile_name = str(payload.get("profile") or "").strip()
|
||||
provider = str(payload.get("tts_provider") or payload.get("provider") or "").strip().lower() or None
|
||||
supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or settings.get("supertonic_total_steps") or 5)
|
||||
|
||||
voice_spec = ""
|
||||
resolved_provider = provider or "kokoro"
|
||||
|
||||
profiles = load_profiles()
|
||||
if resolved_provider == "supertonic" and not profile_name:
|
||||
voice_spec = str(payload.get("voice") or payload.get("supertonic_voice") or "M1").strip() or "M1"
|
||||
# Allow per-speaker overrides via payload.
|
||||
supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or supertonic_total_steps)
|
||||
speed = coerce_float(payload.get("supertonic_speed") or payload.get("speed"), speed)
|
||||
elif profile_name:
|
||||
entry = profiles.get(profile_name)
|
||||
normalized_entry = normalize_profile_entry(entry)
|
||||
if not normalized_entry:
|
||||
return jsonify({"error": "Unknown profile"}), 404
|
||||
resolved_provider = str(normalized_entry.get("provider") or "kokoro")
|
||||
if resolved_provider == "supertonic":
|
||||
voice_spec = str(normalized_entry.get("voice") or "M1")
|
||||
supertonic_total_steps = int(normalized_entry.get("total_steps") or supertonic_total_steps)
|
||||
speed = float(normalized_entry.get("speed") or speed)
|
||||
else:
|
||||
voice_spec = formula_from_profile(normalized_entry) or ""
|
||||
language = str(normalized_entry.get("language") or language)
|
||||
elif formula:
|
||||
voice_spec = formula
|
||||
resolved_provider = "kokoro"
|
||||
else:
|
||||
# Raw voices payload -> Kokoro mix.
|
||||
voices = payload.get("voices") or []
|
||||
pseudo = {"provider": "kokoro", "language": language, "voices": voices}
|
||||
normalized_entry = normalize_profile_entry(pseudo)
|
||||
voice_spec = formula_from_profile(normalized_entry) or ""
|
||||
resolved_provider = "kokoro"
|
||||
|
||||
if not voice_spec:
|
||||
return jsonify({"error": "Unable to resolve preview voice"}), 400
|
||||
|
||||
try:
|
||||
return synthesize_preview(
|
||||
text=text,
|
||||
voice_spec=voice_spec,
|
||||
language=language,
|
||||
speed=speed,
|
||||
use_gpu=use_gpu,
|
||||
tts_provider=resolved_provider,
|
||||
supertonic_total_steps=supertonic_total_steps,
|
||||
max_seconds=max_seconds,
|
||||
)
|
||||
except Exception as exc:
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
@api_bp.post("/speaker-preview")
|
||||
def api_speaker_preview() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
pending_id = str(payload.get("pending_id") or "").strip()
|
||||
text = payload.get("text", "Hello world")
|
||||
voice = payload.get("voice", "af_heart")
|
||||
language = payload.get("language", "a")
|
||||
speed_value = payload.get("speed")
|
||||
speed = coerce_float(speed_value, 1.0)
|
||||
tts_provider = str(payload.get("tts_provider") or "").strip().lower()
|
||||
supertonic_total_steps = int(payload.get("supertonic_total_steps") or 5)
|
||||
|
||||
settings = load_settings()
|
||||
use_gpu = settings.get("use_gpu", False)
|
||||
|
||||
base_spec, speaker_name = split_profile_spec(voice)
|
||||
resolved_provider = tts_provider if tts_provider in {"kokoro", "supertonic"} else ""
|
||||
|
||||
if speaker_name:
|
||||
entry = normalize_profile_entry(load_profiles().get(speaker_name))
|
||||
if entry:
|
||||
resolved_provider = str(entry.get("provider") or resolved_provider or "")
|
||||
if resolved_provider == "supertonic":
|
||||
voice = str(entry.get("voice") or "M1")
|
||||
supertonic_total_steps = int(entry.get("total_steps") or supertonic_total_steps)
|
||||
if speed_value is None:
|
||||
speed = coerce_float(entry.get("speed"), speed)
|
||||
elif resolved_provider == "kokoro":
|
||||
voice = formula_from_profile(entry) or (base_spec or voice)
|
||||
|
||||
if not resolved_provider:
|
||||
resolved_provider = "supertonic" if str(base_spec or "").strip() in {"M1","M2","M3","M4","M5","F1","F2","F3","F4","F5"} else "kokoro"
|
||||
|
||||
pronunciation_overrides = None
|
||||
manual_overrides = None
|
||||
speakers = None
|
||||
if pending_id:
|
||||
try:
|
||||
pending = get_service().get_pending_job(pending_id)
|
||||
except Exception:
|
||||
pending = None
|
||||
if pending is not None:
|
||||
manual_overrides = getattr(pending, "manual_overrides", None)
|
||||
pronunciation_overrides = getattr(pending, "pronunciation_overrides", None)
|
||||
speakers = getattr(pending, "speakers", None)
|
||||
|
||||
try:
|
||||
return synthesize_preview(
|
||||
text=text,
|
||||
voice_spec=voice,
|
||||
language=language,
|
||||
speed=speed,
|
||||
use_gpu=use_gpu
|
||||
,
|
||||
tts_provider=resolved_provider,
|
||||
supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 5),
|
||||
pronunciation_overrides=pronunciation_overrides,
|
||||
manual_overrides=manual_overrides,
|
||||
speakers=speakers,
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
# --- Integration Routes ---
|
||||
|
||||
|
||||
def _opds_metadata_overrides(metadata_payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
metadata_overrides: Dict[str, Any] = {}
|
||||
|
||||
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)
|
||||
return str(value).strip()
|
||||
|
||||
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)
|
||||
|
||||
subtitle_value = (
|
||||
metadata_payload.get("subtitle")
|
||||
or metadata_payload.get("sub_title")
|
||||
or metadata_payload.get("calibre_subtitle")
|
||||
)
|
||||
if subtitle_value:
|
||||
subtitle_text = _stringify_metadata_value(subtitle_value)
|
||||
if subtitle_text:
|
||||
metadata_overrides.setdefault("subtitle", subtitle_text)
|
||||
|
||||
publisher_value = metadata_payload.get("publisher")
|
||||
if publisher_value:
|
||||
publisher_text = _stringify_metadata_value(publisher_value)
|
||||
if publisher_text:
|
||||
metadata_overrides.setdefault("publisher", publisher_text)
|
||||
|
||||
# Author mapping: Abogen templates look for either 'authors' or 'author'.
|
||||
authors_value = (
|
||||
metadata_payload.get("authors")
|
||||
or metadata_payload.get("author")
|
||||
or metadata_payload.get("creator")
|
||||
or metadata_payload.get("dc_creator")
|
||||
)
|
||||
if authors_value:
|
||||
authors_text = _stringify_metadata_value(authors_value)
|
||||
if authors_text:
|
||||
metadata_overrides.setdefault("authors", authors_text)
|
||||
metadata_overrides.setdefault("author", authors_text)
|
||||
|
||||
return metadata_overrides
|
||||
|
||||
@api_bp.get("/integrations/calibre-opds/feed")
|
||||
def api_calibre_opds_feed() -> ResponseReturnValue:
|
||||
integrations = load_integration_settings()
|
||||
calibre_settings = integrations.get("calibre_opds", {})
|
||||
|
||||
payload = {
|
||||
"base_url": calibre_settings.get("base_url"),
|
||||
"username": calibre_settings.get("username"),
|
||||
"password": calibre_settings.get("password"),
|
||||
"verify_ssl": calibre_settings.get("verify_ssl", True),
|
||||
}
|
||||
|
||||
if not payload.get("base_url"):
|
||||
return jsonify({"error": "Calibre OPDS base URL is not configured."}), 400
|
||||
|
||||
try:
|
||||
client = CalibreOPDSClient(
|
||||
base_url=payload.get("base_url") or "",
|
||||
username=payload.get("username"),
|
||||
password=payload.get("password"),
|
||||
verify=bool(payload.get("verify_ssl", True)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
href = request.args.get("href", type=str)
|
||||
query = request.args.get("q", type=str)
|
||||
letter = request.args.get("letter", type=str)
|
||||
|
||||
try:
|
||||
if letter:
|
||||
feed = client.browse_letter(letter, start_href=href)
|
||||
elif query:
|
||||
feed = client.search(query, start_href=href)
|
||||
else:
|
||||
feed = client.fetch_feed(href)
|
||||
except CalibreOPDSError as exc:
|
||||
return jsonify({"error": str(exc)}), 502
|
||||
except Exception as exc:
|
||||
return jsonify({"error": f"Unexpected error: {str(exc)}"}), 500
|
||||
|
||||
return jsonify({
|
||||
"feed": feed.to_dict(),
|
||||
"href": href or "",
|
||||
"query": query or "",
|
||||
})
|
||||
|
||||
@api_bp.post("/integrations/audiobookshelf/folders")
|
||||
def api_abs_folders() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
# Use the helper to resolve saved tokens when use_saved_token is set
|
||||
settings = audiobookshelf_settings_from_payload(payload)
|
||||
host = settings.get("base_url")
|
||||
token = settings.get("api_token")
|
||||
library_id = settings.get("library_id")
|
||||
|
||||
if not host or not token:
|
||||
return jsonify({"error": "Base URL and API token are required"}), 400
|
||||
|
||||
if not library_id:
|
||||
return jsonify({"error": "Library ID is required to list folders"}), 400
|
||||
|
||||
try:
|
||||
config = AudiobookshelfConfig(base_url=host, api_token=token, library_id=library_id)
|
||||
client = AudiobookshelfClient(config)
|
||||
folders = client.list_folders()
|
||||
return jsonify({"folders": folders})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
@api_bp.post("/integrations/audiobookshelf/test")
|
||||
def api_abs_test() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
# Use the helper to resolve saved tokens when use_saved_token is set
|
||||
settings = audiobookshelf_settings_from_payload(payload)
|
||||
host = settings.get("base_url")
|
||||
token = settings.get("api_token")
|
||||
|
||||
if not host or not token:
|
||||
return jsonify({"error": "Base URL and API token are required"}), 400
|
||||
|
||||
try:
|
||||
config = AudiobookshelfConfig(base_url=host, api_token=token)
|
||||
client = AudiobookshelfClient(config)
|
||||
# Just getting libraries is a good enough test
|
||||
client.get_libraries()
|
||||
return jsonify({"success": True, "message": "Connection successful."})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
@api_bp.post("/integrations/calibre-opds/test")
|
||||
def api_calibre_opds_test() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
# Use the helper to resolve saved passwords when use_saved_password is set
|
||||
settings = calibre_settings_from_payload(payload)
|
||||
base_url = settings.get("base_url")
|
||||
username = settings.get("username")
|
||||
password = settings.get("password")
|
||||
verify_ssl = settings.get("verify_ssl", False)
|
||||
|
||||
if not base_url:
|
||||
return jsonify({"error": "Base URL is required"}), 400
|
||||
|
||||
try:
|
||||
client = CalibreOPDSClient(
|
||||
base_url=base_url,
|
||||
username=username,
|
||||
password=password,
|
||||
verify=verify_ssl,
|
||||
timeout=10.0
|
||||
)
|
||||
client.fetch_feed()
|
||||
return jsonify({"success": True, "message": "Connection successful."})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
@api_bp.post("/integrations/calibre-opds/import")
|
||||
def api_calibre_opds_import() -> ResponseReturnValue:
|
||||
if not request.is_json:
|
||||
return jsonify({"error": "Expected JSON payload."}), 400
|
||||
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
href = str(data.get("href") or "").strip()
|
||||
|
||||
if not href:
|
||||
return jsonify({"error": "Download URL (href) is required."}), 400
|
||||
|
||||
metadata_payload = data.get("metadata") if isinstance(data, Mapping) else None
|
||||
metadata_overrides: Dict[str, Any] = {}
|
||||
if isinstance(metadata_payload, Mapping):
|
||||
metadata_overrides = _opds_metadata_overrides(metadata_payload)
|
||||
|
||||
settings = load_settings()
|
||||
integrations = load_integration_settings()
|
||||
calibre_settings = integrations.get("calibre_opds", {})
|
||||
|
||||
try:
|
||||
client = CalibreOPDSClient(
|
||||
base_url=calibre_settings.get("base_url") or "",
|
||||
username=calibre_settings.get("username"),
|
||||
password=calibre_settings.get("password"),
|
||||
verify=bool(calibre_settings.get("verify_ssl", True)),
|
||||
)
|
||||
|
||||
temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads"))
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
resource = client.download(href)
|
||||
filename = resource.filename
|
||||
content = resource.content
|
||||
|
||||
if not filename:
|
||||
filename = f"{uuid.uuid4().hex}.epub"
|
||||
|
||||
file_path = temp_dir / f"{uuid.uuid4().hex}_{filename}"
|
||||
file_path.write_bytes(content)
|
||||
|
||||
extraction = extract_from_path(file_path)
|
||||
|
||||
if metadata_overrides:
|
||||
extraction.metadata.update(metadata_overrides)
|
||||
|
||||
result = build_pending_job_from_extraction(
|
||||
stored_path=file_path,
|
||||
original_name=filename,
|
||||
extraction=extraction,
|
||||
form={},
|
||||
settings=settings,
|
||||
profiles=serialize_profiles(),
|
||||
metadata_overrides=metadata_overrides,
|
||||
)
|
||||
|
||||
get_service().store_pending_job(result.pending)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"status": "imported",
|
||||
"pending_id": result.pending.id,
|
||||
"redirect_url": url_for("main.wizard_step", step="book", pending_id=result.pending.id)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
# --- LLM Routes ---
|
||||
|
||||
@api_bp.post("/llm/models")
|
||||
def api_llm_models() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=False) or {}
|
||||
current_settings = load_settings()
|
||||
|
||||
base_url = str(payload.get("base_url") or payload.get("llm_base_url") or current_settings.get("llm_base_url") or "").strip()
|
||||
if not base_url:
|
||||
return jsonify({"error": "LLM base URL is required."}), 400
|
||||
|
||||
api_key = str(payload.get("api_key") or payload.get("llm_api_key") or current_settings.get("llm_api_key") or "")
|
||||
timeout = coerce_float(payload.get("timeout"), current_settings.get("llm_timeout", 30.0))
|
||||
|
||||
overrides = {
|
||||
"llm_base_url": base_url,
|
||||
"llm_api_key": api_key,
|
||||
"llm_timeout": timeout,
|
||||
}
|
||||
|
||||
merged = apply_overrides(current_settings, overrides)
|
||||
configuration = build_llm_configuration(merged)
|
||||
try:
|
||||
models = list_models(configuration)
|
||||
except LLMClientError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify({"models": models})
|
||||
|
||||
@api_bp.post("/llm/preview")
|
||||
def api_llm_preview() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=False) or {}
|
||||
sample_text = str(payload.get("text") or "").strip()
|
||||
if not sample_text:
|
||||
return jsonify({"error": "Text is required."}), 400
|
||||
|
||||
base_settings = load_settings()
|
||||
overrides: Dict[str, Any] = {
|
||||
"llm_base_url": str(
|
||||
payload.get("base_url")
|
||||
or payload.get("llm_base_url")
|
||||
or base_settings.get("llm_base_url")
|
||||
or ""
|
||||
).strip(),
|
||||
"llm_api_key": str(
|
||||
payload.get("api_key")
|
||||
or payload.get("llm_api_key")
|
||||
or base_settings.get("llm_api_key")
|
||||
or ""
|
||||
),
|
||||
"llm_model": str(
|
||||
payload.get("model")
|
||||
or payload.get("llm_model")
|
||||
or base_settings.get("llm_model")
|
||||
or ""
|
||||
),
|
||||
"llm_prompt": payload.get("prompt") or payload.get("llm_prompt") or base_settings.get("llm_prompt"),
|
||||
"llm_context_mode": payload.get("context_mode") or base_settings.get("llm_context_mode"),
|
||||
"llm_timeout": coerce_float(payload.get("timeout"), base_settings.get("llm_timeout", 30.0)),
|
||||
"normalization_apostrophe_mode": "llm",
|
||||
}
|
||||
|
||||
merged = apply_overrides(base_settings, overrides)
|
||||
if not merged.get("llm_base_url"):
|
||||
return jsonify({"error": "LLM base URL is required."}), 400
|
||||
if not merged.get("llm_model"):
|
||||
return jsonify({"error": "Select an LLM model before previewing."}), 400
|
||||
|
||||
apostrophe_config = build_apostrophe_config(settings=merged)
|
||||
try:
|
||||
normalized_text = normalize_for_pipeline(sample_text, config=apostrophe_config, settings=merged)
|
||||
except LLMClientError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
context = {
|
||||
"text": sample_text,
|
||||
"normalized_text": normalized_text,
|
||||
}
|
||||
return jsonify(context)
|
||||
|
||||
# --- Normalization Routes ---
|
||||
|
||||
@api_bp.post("/normalization/preview")
|
||||
def api_normalization_preview() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=False) or {}
|
||||
sample_text = str(payload.get("text") or "").strip()
|
||||
if not sample_text:
|
||||
return jsonify({"error": "Sample text is required."}), 400
|
||||
|
||||
base_settings = load_settings()
|
||||
# We might want to apply overrides from payload if any normalization settings are passed
|
||||
# For now, just use base settings as in original code (presumably)
|
||||
|
||||
apostrophe_config = build_apostrophe_config(settings=base_settings)
|
||||
try:
|
||||
normalized_text = normalize_for_pipeline(sample_text, config=apostrophe_config, settings=base_settings)
|
||||
except Exception as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
return jsonify({
|
||||
"text": sample_text,
|
||||
"normalized_text": normalized_text,
|
||||
})
|
||||
|
||||
@api_bp.post("/entity-pronunciation/preview")
|
||||
def api_entity_pronunciation_preview() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
token = payload.get("token", "").strip()
|
||||
pronunciation = payload.get("pronunciation", "").strip()
|
||||
voice = payload.get("voice", "").strip()
|
||||
language = payload.get("language", "a").strip()
|
||||
|
||||
if not token and not pronunciation:
|
||||
return jsonify({"error": "Token or pronunciation required"}), 400
|
||||
|
||||
text_to_speak = pronunciation if pronunciation else token
|
||||
|
||||
if not voice:
|
||||
settings = load_settings()
|
||||
voice = settings.get("default_voice", "af_heart")
|
||||
|
||||
try:
|
||||
# Check GPU setting
|
||||
settings = load_settings()
|
||||
use_gpu = coerce_bool(settings.get("use_gpu"), False)
|
||||
|
||||
audio_bytes = generate_preview_audio(
|
||||
text=text_to_speak,
|
||||
voice_spec=voice,
|
||||
language=language,
|
||||
speed=1.0,
|
||||
use_gpu=use_gpu,
|
||||
)
|
||||
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
|
||||
return jsonify({"audio_base64": audio_base64})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from flask import Blueprint, render_template
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.webui.routes.utils.settings import (
|
||||
load_settings,
|
||||
load_integration_settings,
|
||||
)
|
||||
from abogen.webui.routes.utils.voice import template_options
|
||||
|
||||
books_bp = Blueprint("books", __name__)
|
||||
|
||||
def _calibre_integration_enabled(integrations: Dict[str, Any]) -> bool:
|
||||
calibre = integrations.get("calibre_opds", {})
|
||||
return bool(calibre.get("enabled") and calibre.get("base_url"))
|
||||
|
||||
@books_bp.get("/")
|
||||
def find_books_page() -> ResponseReturnValue:
|
||||
settings = load_settings()
|
||||
integrations = load_integration_settings()
|
||||
return render_template(
|
||||
"find_books.html",
|
||||
integrations=integrations,
|
||||
opds_available=_calibre_integration_enabled(integrations),
|
||||
options=template_options(),
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
@books_bp.get("/search")
|
||||
def search_books() -> ResponseReturnValue:
|
||||
return find_books_page()
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
from typing import Mapping
|
||||
from flask import Blueprint, request, jsonify, abort, render_template, redirect, url_for
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.webui.routes.utils.service import require_pending_job, get_service
|
||||
from abogen.webui.routes.utils.entity import (
|
||||
refresh_entity_summary,
|
||||
pending_entities_payload,
|
||||
upsert_manual_override,
|
||||
delete_manual_override,
|
||||
search_manual_override_candidates,
|
||||
)
|
||||
from abogen.webui.routes.utils.settings import coerce_int, load_settings
|
||||
from abogen.webui.routes.utils.voice import template_options
|
||||
from abogen.pronunciation_store import (
|
||||
delete_override as delete_pronunciation_override,
|
||||
save_override as save_pronunciation_override,
|
||||
get_override_stats,
|
||||
all_overrides,
|
||||
)
|
||||
|
||||
entities_bp = Blueprint("entities", __name__)
|
||||
|
||||
@entities_bp.post("/analyze")
|
||||
def analyze_entities() -> ResponseReturnValue:
|
||||
# This might be triggered via wizard update, but if there's a specific route:
|
||||
# In original routes.py, it was likely part of wizard logic or API.
|
||||
# I'll assume this is for the API endpoint /api/pending/<id>/entities/refresh
|
||||
pending_id = request.form.get("pending_id") or request.args.get("pending_id")
|
||||
if not pending_id:
|
||||
abort(400, "Pending ID required")
|
||||
|
||||
pending = require_pending_job(pending_id)
|
||||
refresh_entity_summary(pending, pending.chapters)
|
||||
get_service().store_pending_job(pending)
|
||||
return jsonify(pending_entities_payload(pending))
|
||||
|
||||
@entities_bp.get("/pending/<pending_id>")
|
||||
def get_entities(pending_id: str) -> ResponseReturnValue:
|
||||
pending = require_pending_job(pending_id)
|
||||
refresh_flag = (request.args.get("refresh") or "").strip().lower()
|
||||
expected_cache = (request.args.get("cache_key") or "").strip()
|
||||
refresh_requested = refresh_flag in {"1", "true", "yes", "force"}
|
||||
|
||||
if expected_cache and expected_cache != (pending.entity_cache_key or ""):
|
||||
refresh_requested = True
|
||||
|
||||
if refresh_requested or not pending.entity_summary:
|
||||
refresh_entity_summary(pending, pending.chapters)
|
||||
get_service().store_pending_job(pending)
|
||||
|
||||
return jsonify(pending_entities_payload(pending))
|
||||
|
||||
@entities_bp.post("/pending/<pending_id>/refresh")
|
||||
def refresh_entities(pending_id: str) -> ResponseReturnValue:
|
||||
pending = require_pending_job(pending_id)
|
||||
refresh_entity_summary(pending, pending.chapters)
|
||||
get_service().store_pending_job(pending)
|
||||
return jsonify(pending_entities_payload(pending))
|
||||
|
||||
@entities_bp.get("/pending/<pending_id>/overrides")
|
||||
def list_manual_overrides(pending_id: str) -> ResponseReturnValue:
|
||||
pending = require_pending_job(pending_id)
|
||||
return jsonify({
|
||||
"overrides": pending.manual_overrides or [],
|
||||
"pronunciation_overrides": pending.pronunciation_overrides or [],
|
||||
"heteronym_overrides": getattr(pending, "heteronym_overrides", None) or [],
|
||||
"language": pending.language or "en",
|
||||
})
|
||||
|
||||
@entities_bp.post("/pending/<pending_id>/overrides")
|
||||
def upsert_override(pending_id: str) -> ResponseReturnValue:
|
||||
pending = require_pending_job(pending_id)
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if not isinstance(payload, Mapping):
|
||||
abort(400, "Invalid override payload")
|
||||
|
||||
try:
|
||||
override = upsert_manual_override(pending, payload)
|
||||
except ValueError as exc:
|
||||
abort(400, str(exc))
|
||||
|
||||
get_service().store_pending_job(pending)
|
||||
return jsonify({"override": override, **pending_entities_payload(pending)})
|
||||
|
||||
@entities_bp.delete("/pending/<pending_id>/overrides/<override_id>")
|
||||
def delete_override(pending_id: str, override_id: str) -> ResponseReturnValue:
|
||||
pending = require_pending_job(pending_id)
|
||||
deleted = delete_manual_override(pending, override_id)
|
||||
if not deleted:
|
||||
abort(404)
|
||||
|
||||
get_service().store_pending_job(pending)
|
||||
return jsonify({"deleted": True, **pending_entities_payload(pending)})
|
||||
|
||||
@entities_bp.get("/pending/<pending_id>/overrides/search")
|
||||
def search_candidates(pending_id: str) -> ResponseReturnValue:
|
||||
pending = require_pending_job(pending_id)
|
||||
query = (request.args.get("q") or request.args.get("query") or "").strip()
|
||||
limit_param = request.args.get("limit")
|
||||
limit_value = coerce_int(limit_param, 15, minimum=1, maximum=50) if limit_param is not None else 15
|
||||
|
||||
results = search_manual_override_candidates(pending, query, limit=limit_value)
|
||||
return jsonify({"query": query, "limit": limit_value, "results": results})
|
||||
|
||||
@entities_bp.post("/overrides")
|
||||
def upsert_global_override() -> ResponseReturnValue:
|
||||
payload = request.form
|
||||
action = payload.get("action", "save")
|
||||
lang = payload.get("lang", "en")
|
||||
token = payload.get("token", "").strip()
|
||||
|
||||
if action == "delete":
|
||||
if token:
|
||||
delete_pronunciation_override(token=token, language=lang)
|
||||
else:
|
||||
pronunciation = payload.get("pronunciation", "").strip()
|
||||
voice = payload.get("voice", "").strip()
|
||||
if token:
|
||||
save_pronunciation_override(
|
||||
token=token,
|
||||
pronunciation=pronunciation,
|
||||
voice=voice or None,
|
||||
language=lang
|
||||
)
|
||||
|
||||
return redirect(url_for("entities.entities_page", lang=lang))
|
||||
|
||||
@entities_bp.get("/")
|
||||
def entities_page() -> str:
|
||||
settings = load_settings()
|
||||
lang = request.args.get("lang") or settings.get("language", "en")
|
||||
voice_filter = request.args.get("voice", "")
|
||||
pronunciation_filter = request.args.get("pronunciation", "")
|
||||
|
||||
options = template_options()
|
||||
stats = get_override_stats(lang)
|
||||
|
||||
overrides = all_overrides(lang)
|
||||
|
||||
if voice_filter == "assigned":
|
||||
overrides = [o for o in overrides if o.get("voice")]
|
||||
elif voice_filter == "unassigned":
|
||||
overrides = [o for o in overrides if not o.get("voice")]
|
||||
|
||||
if pronunciation_filter == "defined":
|
||||
overrides = [o for o in overrides if o.get("pronunciation")]
|
||||
elif pronunciation_filter == "undefined":
|
||||
overrides = [o for o in overrides if not o.get("pronunciation")]
|
||||
|
||||
voice_filter_options = [
|
||||
{"value": "", "label": "All voices"},
|
||||
{"value": "assigned", "label": "Assigned"},
|
||||
{"value": "unassigned", "label": "Unassigned"},
|
||||
]
|
||||
pronunciation_filter_options = [
|
||||
{"value": "", "label": "All pronunciations"},
|
||||
{"value": "defined", "label": "Defined"},
|
||||
{"value": "undefined", "label": "Undefined"},
|
||||
]
|
||||
|
||||
language_label = options["languages"].get(lang, lang)
|
||||
return render_template(
|
||||
"entities.html",
|
||||
language=lang,
|
||||
language_label=language_label,
|
||||
options=options,
|
||||
languages=options["languages"].items(),
|
||||
stats=stats,
|
||||
overrides=overrides,
|
||||
voice_filter=voice_filter,
|
||||
pronunciation_filter=pronunciation_filter,
|
||||
voice_filter_options=voice_filter_options,
|
||||
pronunciation_filter_options=pronunciation_filter_options,
|
||||
)
|
||||
@@ -0,0 +1,305 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from flask import Blueprint, Response, abort, redirect, render_template, request, url_for, send_file
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.webui.service import (
|
||||
JobStatus,
|
||||
load_audiobookshelf_chapters,
|
||||
build_audiobookshelf_metadata,
|
||||
)
|
||||
from abogen.webui.routes.utils.service import get_service
|
||||
from abogen.webui.routes.utils.form import render_jobs_panel
|
||||
from abogen.webui.routes.utils.voice import template_options
|
||||
from abogen.webui.routes.utils.epub import (
|
||||
job_download_flags,
|
||||
locate_job_epub,
|
||||
locate_job_audio,
|
||||
)
|
||||
from abogen.webui.routes.utils.settings import (
|
||||
stored_integration_config,
|
||||
build_audiobookshelf_config,
|
||||
coerce_bool,
|
||||
)
|
||||
from abogen.webui.routes.utils.common import existing_paths
|
||||
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfUploadError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
jobs_bp = Blueprint("jobs", __name__)
|
||||
|
||||
@jobs_bp.get("/<job_id>")
|
||||
def job_detail(job_id: str) -> ResponseReturnValue:
|
||||
job = get_service().get_job(job_id)
|
||||
if not job:
|
||||
# Return a friendly page instead of 404 to avoid confusion from stale browser tabs
|
||||
return render_template("job_not_found.html"), 200
|
||||
return render_template(
|
||||
"job_detail.html",
|
||||
job=job,
|
||||
options=template_options(),
|
||||
JobStatus=JobStatus,
|
||||
downloads=job_download_flags(job),
|
||||
)
|
||||
|
||||
@jobs_bp.post("/<job_id>/pause")
|
||||
def pause_job(job_id: str) -> ResponseReturnValue:
|
||||
get_service().pause(job_id)
|
||||
if request.headers.get("HX-Request"):
|
||||
return render_jobs_panel()
|
||||
return redirect(url_for("jobs.job_detail", job_id=job_id))
|
||||
|
||||
@jobs_bp.post("/<job_id>/resume")
|
||||
def resume_job(job_id: str) -> ResponseReturnValue:
|
||||
get_service().resume(job_id)
|
||||
if request.headers.get("HX-Request"):
|
||||
return render_jobs_panel()
|
||||
return redirect(url_for("jobs.job_detail", job_id=job_id))
|
||||
|
||||
@jobs_bp.post("/<job_id>/cancel")
|
||||
def cancel_job(job_id: str) -> ResponseReturnValue:
|
||||
get_service().cancel(job_id)
|
||||
if request.headers.get("HX-Request"):
|
||||
return render_jobs_panel()
|
||||
return redirect(url_for("jobs.job_detail", job_id=job_id))
|
||||
|
||||
@jobs_bp.post("/<job_id>/delete")
|
||||
def delete_job(job_id: str) -> ResponseReturnValue:
|
||||
get_service().delete(job_id)
|
||||
if request.headers.get("HX-Request"):
|
||||
return render_jobs_panel()
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
@jobs_bp.post("/<job_id>/retry")
|
||||
def retry_job(job_id: str) -> ResponseReturnValue:
|
||||
new_job = get_service().retry(job_id)
|
||||
if request.headers.get("HX-Request"):
|
||||
return render_jobs_panel()
|
||||
if new_job:
|
||||
return redirect(url_for("jobs.job_detail", job_id=new_job.id))
|
||||
return redirect(url_for("jobs.job_detail", job_id=job_id))
|
||||
|
||||
@jobs_bp.post("/<job_id>/audiobookshelf")
|
||||
def send_job_to_audiobookshelf(job_id: str) -> ResponseReturnValue:
|
||||
service = get_service()
|
||||
job = service.get_job(job_id)
|
||||
if job is None:
|
||||
abort(404)
|
||||
|
||||
def _panel_response() -> ResponseReturnValue:
|
||||
if request.headers.get("HX-Request"):
|
||||
return render_jobs_panel()
|
||||
return redirect(url_for("jobs.job_detail", job_id=job.id))
|
||||
|
||||
if job.status != JobStatus.COMPLETED:
|
||||
return _panel_response()
|
||||
|
||||
settings = stored_integration_config("audiobookshelf")
|
||||
if not settings or not coerce_bool(settings.get("enabled"), False):
|
||||
job.add_log("Audiobookshelf upload skipped: integration is disabled.", level="warning")
|
||||
service._persist_state()
|
||||
return _panel_response()
|
||||
|
||||
config = build_audiobookshelf_config(settings)
|
||||
if config is None:
|
||||
job.add_log(
|
||||
"Audiobookshelf upload skipped: configure base URL, API token, and library ID first.",
|
||||
level="warning",
|
||||
)
|
||||
service._persist_state()
|
||||
return _panel_response()
|
||||
if not config.folder_id:
|
||||
job.add_log(
|
||||
"Audiobookshelf upload skipped: enter the folder name or ID in the Audiobookshelf settings.",
|
||||
level="warning",
|
||||
)
|
||||
service._persist_state()
|
||||
return _panel_response()
|
||||
|
||||
audio_path = locate_job_audio(job)
|
||||
if not audio_path or not audio_path.exists():
|
||||
job.add_log("Audiobookshelf upload skipped: audio output not found.", level="warning")
|
||||
service._persist_state()
|
||||
return _panel_response()
|
||||
|
||||
cover_path = None
|
||||
if config.send_cover and job.cover_image_path:
|
||||
cover_candidate = job.cover_image_path
|
||||
if not isinstance(cover_candidate, Path):
|
||||
cover_candidate = Path(str(cover_candidate))
|
||||
if cover_candidate.exists():
|
||||
cover_path = cover_candidate
|
||||
|
||||
subtitles = existing_paths(job.result.subtitle_paths) if config.send_subtitles else None
|
||||
chapters = load_audiobookshelf_chapters(job) if config.send_chapters else None
|
||||
metadata = build_audiobookshelf_metadata(job)
|
||||
display_title = metadata.get("title") or audio_path.stem
|
||||
overwrite_requested = request.form.get("overwrite") == "true" or request.args.get("overwrite") == "true"
|
||||
|
||||
try:
|
||||
client = AudiobookshelfClient(config)
|
||||
except ValueError as exc:
|
||||
job.add_log(f"Audiobookshelf configuration error: {exc}", level="error")
|
||||
service._persist_state()
|
||||
return _panel_response()
|
||||
|
||||
try:
|
||||
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
|
||||
except AudiobookshelfUploadError as exc:
|
||||
job.add_log(f"Audiobookshelf lookup failed: {exc}", level="error")
|
||||
service._persist_state()
|
||||
return _panel_response()
|
||||
|
||||
if existing_items and not overwrite_requested:
|
||||
job.add_log(
|
||||
f"Audiobookshelf already contains '{display_title}'. Awaiting overwrite confirmation.",
|
||||
level="warning",
|
||||
)
|
||||
service._persist_state()
|
||||
if request.headers.get("HX-Request"):
|
||||
detail = {
|
||||
"jobId": job.id,
|
||||
"title": display_title,
|
||||
"url": url_for("jobs.send_job_to_audiobookshelf", job_id=job.id),
|
||||
"target": request.headers.get("HX-Target") or "#jobs-panel",
|
||||
"message": f'Audiobookshelf already contains "{display_title}". Overwrite?',
|
||||
}
|
||||
headers = {"HX-Trigger": json.dumps({"audiobookshelf-overwrite-prompt": detail})}
|
||||
return Response("", status=204, headers=headers)
|
||||
return _panel_response()
|
||||
|
||||
if existing_items and overwrite_requested:
|
||||
try:
|
||||
client.delete_items(existing_items)
|
||||
except AudiobookshelfUploadError as exc:
|
||||
job.add_log(f"Audiobookshelf overwrite aborted: {exc}", level="error")
|
||||
service._persist_state()
|
||||
return _panel_response()
|
||||
else:
|
||||
job.add_log(
|
||||
f"Removed {len(existing_items)} existing Audiobookshelf item(s) prior to overwrite.",
|
||||
level="info",
|
||||
)
|
||||
|
||||
job.add_log("Audiobookshelf upload triggered manually.", level="info")
|
||||
try:
|
||||
client.upload_audiobook(
|
||||
audio_path,
|
||||
metadata=metadata,
|
||||
cover_path=cover_path,
|
||||
chapters=chapters,
|
||||
subtitles=subtitles,
|
||||
)
|
||||
except AudiobookshelfUploadError as exc:
|
||||
job.add_log(f"Audiobookshelf upload failed: {exc}", level="error")
|
||||
except Exception as exc:
|
||||
job.add_log(f"Audiobookshelf integration error: {exc}", level="error")
|
||||
else:
|
||||
job.add_log("Audiobookshelf upload queued.", level="success")
|
||||
finally:
|
||||
service._persist_state()
|
||||
|
||||
return _panel_response()
|
||||
|
||||
@jobs_bp.post("/clear-finished")
|
||||
def clear_finished_jobs() -> ResponseReturnValue:
|
||||
get_service().clear_finished()
|
||||
if request.headers.get("HX-Request"):
|
||||
return render_jobs_panel()
|
||||
return redirect(url_for("main.index", _anchor="queue"))
|
||||
|
||||
@jobs_bp.get("/<job_id>/epub")
|
||||
def job_epub(job_id: str) -> ResponseReturnValue:
|
||||
job = get_service().get_job(job_id)
|
||||
if job is None or job.status != JobStatus.COMPLETED:
|
||||
abort(404)
|
||||
epub_path = locate_job_epub(job)
|
||||
if not epub_path:
|
||||
abort(404)
|
||||
return send_file(
|
||||
epub_path,
|
||||
as_attachment=True,
|
||||
download_name=epub_path.name,
|
||||
mimetype="application/epub+zip",
|
||||
)
|
||||
|
||||
@jobs_bp.get("/<job_id>/download/<file_type>")
|
||||
def download_file(job_id: str, file_type: str) -> ResponseReturnValue:
|
||||
job = get_service().get_job(job_id)
|
||||
if not job or job.status != JobStatus.COMPLETED:
|
||||
abort(404)
|
||||
|
||||
if file_type == "audio":
|
||||
path = locate_job_audio(job)
|
||||
if not path or not path.exists():
|
||||
abort(404)
|
||||
return send_file(
|
||||
path,
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
)
|
||||
|
||||
# Handle other file types if needed (subtitles, etc.)
|
||||
# For now, just audio and epub are explicitly handled
|
||||
abort(404)
|
||||
|
||||
@jobs_bp.get("/<job_id>/logs")
|
||||
def job_logs(job_id: str) -> ResponseReturnValue:
|
||||
job = get_service().get_job(job_id)
|
||||
if not job:
|
||||
# Return a simple page instead of 404 to avoid log spam from stale browser tabs
|
||||
return render_template("job_logs_missing.html"), 200
|
||||
return render_template("job_logs_static.html", job=job)
|
||||
|
||||
|
||||
@jobs_bp.get("/<job_id>/logs/partial")
|
||||
def job_logs_partial(job_id: str) -> ResponseReturnValue:
|
||||
job = get_service().get_job(job_id)
|
||||
if not job:
|
||||
# Return a non-polling section so HTMX stops retrying.
|
||||
return render_template("partials/logs_section_missing.html"), 200
|
||||
return render_template("partials/logs_section.html", job=job)
|
||||
|
||||
@jobs_bp.get("/<job_id>/logs/stream")
|
||||
def stream_logs(job_id: str) -> ResponseReturnValue:
|
||||
job = get_service().get_job(job_id)
|
||||
if not job:
|
||||
abort(404)
|
||||
|
||||
def generate():
|
||||
last_index = 0
|
||||
while True:
|
||||
current_logs = job.logs
|
||||
if len(current_logs) > last_index:
|
||||
for log in current_logs[last_index:]:
|
||||
yield f"data: {json.dumps({'timestamp': log.timestamp, 'level': log.level, 'message': log.message})}\n\n"
|
||||
last_index = len(current_logs)
|
||||
|
||||
if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
|
||||
break
|
||||
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
|
||||
return Response(generate(), mimetype="text/event-stream")
|
||||
|
||||
@jobs_bp.get("/<job_id>/reader")
|
||||
def job_reader(job_id: str) -> ResponseReturnValue:
|
||||
job = get_service().get_job(job_id)
|
||||
if not job:
|
||||
abort(404)
|
||||
return render_template("reader_embed.html", job=job)
|
||||
|
||||
@jobs_bp.get("/queue")
|
||||
def queue_page() -> str:
|
||||
return render_template(
|
||||
"queue.html",
|
||||
jobs_panel=render_jobs_panel(),
|
||||
)
|
||||
|
||||
@jobs_bp.get("/partial")
|
||||
def jobs_partial() -> str:
|
||||
return render_jobs_panel()
|
||||
@@ -0,0 +1,388 @@
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
from flask import Blueprint, redirect, render_template, request, url_for, jsonify, current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from abogen.webui.service import PendingJob, JobStatus
|
||||
from abogen.webui.routes.utils.service import get_service, remove_pending_job, submit_job
|
||||
from abogen.webui.routes.utils.settings import load_settings
|
||||
from abogen.webui.routes.utils.voice import template_options
|
||||
from abogen.webui.routes.utils.form import (
|
||||
normalize_wizard_step,
|
||||
wants_wizard_json,
|
||||
render_wizard_partial,
|
||||
wizard_json_response,
|
||||
build_pending_job_from_extraction,
|
||||
apply_book_step_form,
|
||||
apply_prepare_form,
|
||||
render_jobs_panel,
|
||||
)
|
||||
from abogen.text_extractor import extract_from_path
|
||||
from abogen.voice_profiles import serialize_profiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
|
||||
@main_bp.app_template_filter("datetimeformat")
|
||||
def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
if not value:
|
||||
return "—"
|
||||
from datetime import datetime
|
||||
return datetime.fromtimestamp(value).strftime(fmt)
|
||||
|
||||
@main_bp.app_template_filter("durationformat")
|
||||
def durationformat(value: Optional[float]) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
seconds = int(value)
|
||||
if seconds < 60:
|
||||
return f"{seconds}s"
|
||||
minutes = seconds // 60
|
||||
seconds = seconds % 60
|
||||
if minutes < 60:
|
||||
return f"{minutes}m {seconds}s"
|
||||
hours = minutes // 60
|
||||
minutes = minutes % 60
|
||||
return f"{hours}h {minutes}m"
|
||||
|
||||
@main_bp.route("/")
|
||||
def index():
|
||||
pending_id = request.args.get("pending_id")
|
||||
pending = get_service().get_pending_job(pending_id) if pending_id else None
|
||||
|
||||
# If we have a pending job, redirect to the wizard
|
||||
if pending:
|
||||
step_index = getattr(pending, "wizard_max_step_index", 0)
|
||||
# Map index to step name roughly
|
||||
steps = ["book", "chapters", "entities"]
|
||||
step_name = steps[min(step_index, len(steps)-1)]
|
||||
return redirect(url_for("main.wizard_step", step=step_name, pending_id=pending.id))
|
||||
|
||||
jobs = get_service().list_jobs()
|
||||
stats = {
|
||||
"total": len(jobs),
|
||||
"completed": sum(1 for j in jobs if j.status == JobStatus.COMPLETED),
|
||||
"running": sum(1 for j in jobs if j.status == JobStatus.RUNNING),
|
||||
"pending": sum(1 for j in jobs if j.status == JobStatus.PENDING),
|
||||
"failed": sum(1 for j in jobs if j.status == JobStatus.FAILED),
|
||||
}
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
options=template_options(),
|
||||
settings=load_settings(),
|
||||
jobs_panel=render_jobs_panel(),
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
@main_bp.route("/wizard")
|
||||
def wizard_start():
|
||||
pending_id = request.args.get("pending_id")
|
||||
step = request.args.get("step", "book")
|
||||
if pending_id:
|
||||
return redirect(url_for("main.wizard_step", step=step, pending_id=pending_id))
|
||||
return redirect(url_for("main.wizard_step", step=step))
|
||||
|
||||
@main_bp.route("/wizard/<step>")
|
||||
def wizard_step(step: str):
|
||||
pending_id = request.args.get("pending_id")
|
||||
pending = get_service().get_pending_job(pending_id) if pending_id else None
|
||||
|
||||
normalized_step = normalize_wizard_step(step, pending)
|
||||
if normalized_step != step:
|
||||
return redirect(url_for("main.wizard_step", step=normalized_step, pending_id=pending_id))
|
||||
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(pending, normalized_step)
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
options=template_options(),
|
||||
settings=load_settings(),
|
||||
jobs_panel=render_jobs_panel(),
|
||||
wizard_mode=True,
|
||||
wizard_step=normalized_step,
|
||||
wizard_partial=render_wizard_partial(pending, normalized_step),
|
||||
)
|
||||
|
||||
@main_bp.route("/wizard/upload", methods=["POST"])
|
||||
def wizard_upload():
|
||||
pending_id = request.form.get("pending_id")
|
||||
pending = get_service().get_pending_job(pending_id) if pending_id else None
|
||||
|
||||
file = request.files.get("file") or request.files.get("source_file")
|
||||
|
||||
settings = load_settings()
|
||||
profiles = serialize_profiles()
|
||||
|
||||
# Case 1: Updating existing job without new file
|
||||
if pending and (not file or not file.filename):
|
||||
try:
|
||||
apply_book_step_form(pending, request.form, settings=settings, profiles=profiles)
|
||||
get_service().store_pending_job(pending)
|
||||
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(pending, "chapters")
|
||||
return redirect(url_for("main.wizard_step", step="chapters", pending_id=pending.id))
|
||||
except Exception as e:
|
||||
logger.exception("Error updating job settings")
|
||||
error_msg = f"Failed to update settings: {str(e)}"
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(pending, "book", error=error_msg, status=500)
|
||||
return render_template(
|
||||
"index.html",
|
||||
options=template_options(),
|
||||
settings=settings,
|
||||
jobs_panel=render_jobs_panel(),
|
||||
wizard_mode=True,
|
||||
wizard_step="book",
|
||||
wizard_partial=render_wizard_partial(pending, "book", error=error_msg),
|
||||
)
|
||||
|
||||
# Case 2: New file upload (or replacing file on existing job)
|
||||
if not file or not file.filename:
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(None, "book", error="No file selected", status=400)
|
||||
return redirect(url_for("main.wizard_step", step="book"))
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads"))
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
file_path = temp_dir / f"{uuid.uuid4().hex}_{filename}"
|
||||
file.save(file_path)
|
||||
|
||||
try:
|
||||
extraction = extract_from_path(file_path)
|
||||
|
||||
result = build_pending_job_from_extraction(
|
||||
stored_path=file_path,
|
||||
original_name=filename,
|
||||
extraction=extraction,
|
||||
form=request.form,
|
||||
settings=settings,
|
||||
profiles=profiles,
|
||||
)
|
||||
|
||||
# If we had a pending job, we might want to preserve its ID or other properties,
|
||||
# but for a new file it's safer to start fresh with the new extraction.
|
||||
# The frontend will handle the ID change via the redirect.
|
||||
|
||||
get_service().store_pending_job(result.pending)
|
||||
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(result.pending, "chapters")
|
||||
|
||||
return redirect(url_for("main.wizard_step", step="chapters", pending_id=result.pending.id))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error processing upload")
|
||||
if file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
error_msg = f"Failed to process file: {str(e)}"
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(None, "book", error=error_msg, status=500)
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
options=template_options(),
|
||||
settings=settings,
|
||||
jobs_panel=render_jobs_panel(),
|
||||
wizard_mode=True,
|
||||
wizard_step="book",
|
||||
wizard_partial=render_wizard_partial(None, "book", error=error_msg),
|
||||
)
|
||||
|
||||
@main_bp.route("/wizard/text", methods=["POST"])
|
||||
def wizard_text():
|
||||
text = request.form.get("text", "").strip()
|
||||
title = request.form.get("title", "").strip() or "Pasted Text"
|
||||
|
||||
if not text:
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(None, "book", error="No text provided", status=400)
|
||||
return redirect(url_for("main.wizard_step", step="book"))
|
||||
|
||||
temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads"))
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
file_path = temp_dir / f"{uuid.uuid4().hex}.txt"
|
||||
file_path.write_text(text, encoding="utf-8")
|
||||
|
||||
settings = load_settings()
|
||||
profiles = serialize_profiles()
|
||||
|
||||
try:
|
||||
extraction = extract_from_path(file_path)
|
||||
# Override title since text extraction might not find one
|
||||
extraction.metadata["title"] = title
|
||||
|
||||
result = build_pending_job_from_extraction(
|
||||
stored_path=file_path,
|
||||
original_name=f"{title}.txt",
|
||||
extraction=extraction,
|
||||
form=request.form,
|
||||
settings=settings,
|
||||
profiles=profiles,
|
||||
)
|
||||
|
||||
get_service().store_pending_job(result.pending)
|
||||
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(result.pending, "chapters")
|
||||
|
||||
return redirect(url_for("main.wizard_step", step="chapters", pending_id=result.pending.id))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error processing text")
|
||||
if file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
error_msg = f"Failed to process text: {str(e)}"
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(None, "book", error=error_msg, status=500)
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
options=template_options(),
|
||||
settings=settings,
|
||||
jobs_panel=render_jobs_panel(),
|
||||
wizard_mode=True,
|
||||
wizard_step="book",
|
||||
wizard_partial=render_wizard_partial(None, "book", error=error_msg),
|
||||
)
|
||||
|
||||
@main_bp.route("/wizard/update", methods=["POST"])
|
||||
def wizard_update():
|
||||
pending_id = request.values.get("pending_id")
|
||||
if not pending_id:
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(None, "book", error="Missing job ID", status=400)
|
||||
return redirect(url_for("main.wizard_step", step="book"))
|
||||
|
||||
pending = get_service().get_pending_job(pending_id)
|
||||
if not pending:
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(None, "book", error="Job expired or not found", status=404)
|
||||
return redirect(url_for("main.wizard_step", step="book"))
|
||||
|
||||
current_step = request.form.get("step", "book")
|
||||
next_step = request.form.get("next_step")
|
||||
|
||||
settings = load_settings()
|
||||
profiles = serialize_profiles()
|
||||
|
||||
try:
|
||||
if current_step == "book":
|
||||
apply_book_step_form(pending, request.form, settings=settings, profiles=profiles)
|
||||
target_step = next_step or "chapters"
|
||||
|
||||
elif current_step == "chapters":
|
||||
# This step involves re-analyzing chunks if needed
|
||||
(
|
||||
chunk_level,
|
||||
overrides,
|
||||
enabled_overrides,
|
||||
errors,
|
||||
selected_total,
|
||||
selected_config,
|
||||
apply_config_requested,
|
||||
persist_config_requested,
|
||||
) = apply_prepare_form(pending, request.form)
|
||||
|
||||
if errors:
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(pending, current_step, error="\n".join(errors), status=400)
|
||||
return render_template(
|
||||
"index.html",
|
||||
options=template_options(),
|
||||
settings=settings,
|
||||
jobs_panel=render_jobs_panel(),
|
||||
wizard_mode=True,
|
||||
wizard_step=current_step,
|
||||
wizard_partial=render_wizard_partial(pending, current_step, error="\n".join(errors)),
|
||||
)
|
||||
|
||||
target_step = next_step or "entities"
|
||||
|
||||
elif current_step == "entities":
|
||||
# Just saving entity overrides
|
||||
apply_prepare_form(pending, request.form)
|
||||
target_step = next_step or "entities" # Stay or finish
|
||||
|
||||
else:
|
||||
target_step = "book"
|
||||
|
||||
get_service().store_pending_job(pending)
|
||||
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(pending, target_step)
|
||||
|
||||
return redirect(url_for("main.wizard_step", step=target_step, pending_id=pending.id))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error updating wizard step {current_step}")
|
||||
error_msg = f"Update failed: {str(e)}"
|
||||
if wants_wizard_json():
|
||||
return wizard_json_response(pending, current_step, error=error_msg, status=500)
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
options=template_options(),
|
||||
settings=settings,
|
||||
jobs_panel=render_jobs_panel(),
|
||||
wizard_mode=True,
|
||||
wizard_step=current_step,
|
||||
wizard_partial=render_wizard_partial(pending, current_step, error=error_msg),
|
||||
)
|
||||
|
||||
@main_bp.route("/wizard/cancel", methods=["POST"])
|
||||
def wizard_cancel():
|
||||
pending_id = request.values.get("pending_id")
|
||||
if pending_id:
|
||||
remove_pending_job(pending_id)
|
||||
|
||||
if wants_wizard_json():
|
||||
return jsonify({"status": "cancelled", "redirect_url": url_for("main.index")})
|
||||
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
@main_bp.route("/wizard/finish", methods=["POST"])
|
||||
def wizard_finish():
|
||||
pending_id = request.values.get("pending_id")
|
||||
if not pending_id:
|
||||
if wants_wizard_json():
|
||||
return jsonify({"error": "Missing job ID"}), 400
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
pending = get_service().get_pending_job(pending_id)
|
||||
if not pending:
|
||||
if wants_wizard_json():
|
||||
return jsonify({"error": "Job not found"}), 404
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
# Final update from form
|
||||
apply_prepare_form(pending, request.form)
|
||||
|
||||
# Submit job
|
||||
job_id = submit_job(pending)
|
||||
|
||||
if wants_wizard_json():
|
||||
return jsonify({
|
||||
"status": "submitted",
|
||||
"job_id": job_id,
|
||||
"redirect_url": url_for("main.index"),
|
||||
"jobs_panel": render_jobs_panel()
|
||||
})
|
||||
|
||||
return redirect(url_for("main.index"))
|
||||
@@ -0,0 +1,296 @@
|
||||
from pathlib import Path
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, current_app, render_template, request, redirect, url_for, flash, send_file, abort
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.webui.routes.utils.settings import (
|
||||
load_settings,
|
||||
load_integration_settings,
|
||||
save_settings,
|
||||
stored_integration_config,
|
||||
coerce_bool,
|
||||
coerce_int,
|
||||
SAVE_MODE_LABELS,
|
||||
llm_ready,
|
||||
_NORMALIZATION_BOOLEAN_KEYS,
|
||||
_NORMALIZATION_STRING_KEYS,
|
||||
_DEFAULT_ANALYSIS_THRESHOLD,
|
||||
)
|
||||
from abogen.webui.routes.utils.voice import template_options
|
||||
from abogen.webui.debug_tts_runner import run_debug_tts_wavs
|
||||
from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES
|
||||
from abogen.utils import get_user_output_path, load_config
|
||||
|
||||
settings_bp = Blueprint("settings", __name__)
|
||||
|
||||
_NORMALIZATION_SAMPLES = {
|
||||
"apostrophes": "It's a beautiful day, isn't it? 'Yes,' she said, 'it is.'",
|
||||
"currency": "The price is $10.50, but it was £8.00 yesterday.",
|
||||
"dates": "On 2023-01-01, we celebrated the new year.",
|
||||
"numbers": "There are 123 apples and 456 oranges.",
|
||||
"abbreviations": "Dr. Smith lives on Elm St. near the U.S. border.",
|
||||
}
|
||||
|
||||
@settings_bp.post("/update")
|
||||
def update_settings() -> ResponseReturnValue:
|
||||
current = load_settings()
|
||||
form = request.form
|
||||
|
||||
# General settings
|
||||
current["language"] = (form.get("language") or "en").strip()
|
||||
current["default_speaker"] = (form.get("default_speaker") or "").strip()
|
||||
current["default_voice"] = (form.get("default_voice") or "").strip()
|
||||
try:
|
||||
current["supertonic_total_steps"] = max(2, min(15, int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 5)))))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
current["supertonic_speed"] = max(0.7, min(2.0, float(form.get("supertonic_speed", current.get("supertonic_speed", 1.0)))))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
current["output_format"] = (form.get("output_format") or "mp3").strip()
|
||||
current["subtitle_mode"] = (form.get("subtitle_mode") or "Disabled").strip()
|
||||
current["subtitle_format"] = (form.get("subtitle_format") or "srt").strip()
|
||||
current["save_mode"] = (form.get("save_mode") or "save_next_to_input").strip()
|
||||
|
||||
current["replace_single_newlines"] = coerce_bool(form.get("replace_single_newlines"), False)
|
||||
current["use_gpu"] = coerce_bool(form.get("use_gpu"), False)
|
||||
current["save_chapters_separately"] = coerce_bool(form.get("save_chapters_separately"), False)
|
||||
current["merge_chapters_at_end"] = coerce_bool(form.get("merge_chapters_at_end"), True)
|
||||
current["save_as_project"] = coerce_bool(form.get("save_as_project"), False)
|
||||
current["separate_chapters_format"] = (form.get("separate_chapters_format") or "wav").strip()
|
||||
|
||||
try:
|
||||
current["silence_between_chapters"] = max(0.0, float(form.get("silence_between_chapters", 2.0)))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
current["chapter_intro_delay"] = max(0.0, float(form.get("chapter_intro_delay", 0.5)))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
current["read_title_intro"] = coerce_bool(form.get("read_title_intro"), False)
|
||||
current["read_closing_outro"] = coerce_bool(form.get("read_closing_outro"), True)
|
||||
current["normalize_chapter_opening_caps"] = coerce_bool(form.get("normalize_chapter_opening_caps"), True)
|
||||
current["auto_prefix_chapter_titles"] = coerce_bool(form.get("auto_prefix_chapter_titles"), True)
|
||||
|
||||
try:
|
||||
current["max_subtitle_words"] = max(1, int(form.get("max_subtitle_words", 50)))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
current["chunk_level"] = (form.get("chunk_level") or "paragraph").strip()
|
||||
current["generate_epub3"] = coerce_bool(form.get("generate_epub3"), False)
|
||||
|
||||
current["speaker_analysis_threshold"] = coerce_int(
|
||||
form.get("speaker_analysis_threshold"),
|
||||
_DEFAULT_ANALYSIS_THRESHOLD,
|
||||
minimum=1,
|
||||
maximum=25,
|
||||
)
|
||||
|
||||
def _extract_checkbox(name: str, default: bool) -> bool:
|
||||
values = form.getlist(name) if hasattr(form, "getlist") else []
|
||||
if values:
|
||||
return coerce_bool(values[-1], default)
|
||||
if hasattr(form, "__contains__") and name in form:
|
||||
return False
|
||||
return default
|
||||
|
||||
# Normalization settings
|
||||
for key in _NORMALIZATION_BOOLEAN_KEYS:
|
||||
current[key] = _extract_checkbox(key, bool(current.get(key, True)))
|
||||
for key in _NORMALIZATION_STRING_KEYS:
|
||||
if hasattr(form, "__contains__") and key in form:
|
||||
current[key] = (form.get(key) or "").strip()
|
||||
|
||||
# Integrations
|
||||
# `load_settings()` returns only the general settings subset and intentionally
|
||||
# does not include stored integrations. Seed them from the stored config so
|
||||
# saving unrelated settings cannot wipe credentials/tokens.
|
||||
current_integrations: dict[str, dict[str, Any]] = {}
|
||||
cfg = load_config() or {}
|
||||
stored_integrations = cfg.get("integrations")
|
||||
if isinstance(stored_integrations, Mapping):
|
||||
for name, payload in stored_integrations.items():
|
||||
if isinstance(name, str) and isinstance(payload, Mapping):
|
||||
current_integrations[name] = dict(payload)
|
||||
# Ensure known integrations are loaded even if the config is still in legacy format.
|
||||
for name in ("audiobookshelf", "calibre_opds"):
|
||||
stored = stored_integration_config(name)
|
||||
if stored and name not in current_integrations:
|
||||
current_integrations[name] = dict(stored)
|
||||
current["integrations"] = current_integrations
|
||||
|
||||
# Audiobookshelf
|
||||
abs_enabled = coerce_bool(form.get("audiobookshelf_enabled"), False)
|
||||
abs_url = (form.get("audiobookshelf_base_url") or "").strip()
|
||||
abs_token = (form.get("audiobookshelf_api_token") or "").strip()
|
||||
abs_library = (form.get("audiobookshelf_library_id") or "").strip()
|
||||
abs_folder = (form.get("audiobookshelf_folder_id") or "").strip()
|
||||
abs_verify = coerce_bool(form.get("audiobookshelf_verify_ssl"), True)
|
||||
abs_auto_send = coerce_bool(form.get("audiobookshelf_auto_send"), False)
|
||||
abs_cover = coerce_bool(form.get("audiobookshelf_send_cover"), True)
|
||||
abs_chapters = coerce_bool(form.get("audiobookshelf_send_chapters"), True)
|
||||
abs_subtitles = coerce_bool(form.get("audiobookshelf_send_subtitles"), False)
|
||||
|
||||
try:
|
||||
abs_timeout = max(1.0, float(form.get("audiobookshelf_timeout", 30.0)))
|
||||
except ValueError:
|
||||
abs_timeout = 30.0
|
||||
|
||||
# Preserve existing token if not provided and not cleared
|
||||
if not abs_token and not coerce_bool(form.get("audiobookshelf_api_token_clear"), False):
|
||||
existing_abs = current["integrations"].get("audiobookshelf", {})
|
||||
abs_token = existing_abs.get("api_token", "")
|
||||
|
||||
current["integrations"]["audiobookshelf"] = {
|
||||
"enabled": abs_enabled,
|
||||
"base_url": abs_url,
|
||||
"api_token": abs_token,
|
||||
"library_id": abs_library,
|
||||
"folder_id": abs_folder,
|
||||
"verify_ssl": abs_verify,
|
||||
"auto_send": abs_auto_send,
|
||||
"send_cover": abs_cover,
|
||||
"send_chapters": abs_chapters,
|
||||
"send_subtitles": abs_subtitles,
|
||||
"timeout": abs_timeout,
|
||||
}
|
||||
|
||||
# Calibre OPDS
|
||||
calibre_enabled = coerce_bool(form.get("calibre_opds_enabled"), False)
|
||||
calibre_url = (form.get("calibre_opds_base_url") or "").strip()
|
||||
calibre_user = (form.get("calibre_opds_username") or "").strip()
|
||||
calibre_pass = (form.get("calibre_opds_password") or "").strip()
|
||||
calibre_verify = coerce_bool(form.get("calibre_opds_verify_ssl"), True)
|
||||
|
||||
# Preserve existing password if not provided and not cleared
|
||||
if not calibre_pass and not coerce_bool(form.get("calibre_opds_password_clear"), False):
|
||||
existing_calibre = current["integrations"].get("calibre_opds", {})
|
||||
calibre_pass = existing_calibre.get("password", "")
|
||||
|
||||
current["integrations"]["calibre_opds"] = {
|
||||
"enabled": calibre_enabled,
|
||||
"base_url": calibre_url,
|
||||
"username": calibre_user,
|
||||
"password": calibre_pass,
|
||||
"verify_ssl": calibre_verify,
|
||||
}
|
||||
|
||||
save_settings(current)
|
||||
flash("Settings updated successfully.", "success")
|
||||
return redirect(url_for("settings.settings_page"))
|
||||
|
||||
@settings_bp.route("/", methods=["GET", "POST"])
|
||||
def settings_page() -> str | ResponseReturnValue:
|
||||
if request.method == "POST":
|
||||
return update_settings()
|
||||
|
||||
debug_run_id = (request.args.get("debug_run_id") or "").strip()
|
||||
debug_manifest = None
|
||||
if debug_run_id:
|
||||
run_dir = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web")) / "debug" / debug_run_id
|
||||
manifest_path = run_dir / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
import json
|
||||
|
||||
debug_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
debug_manifest = None
|
||||
|
||||
save_locations = [{"value": key, "label": label} for key, label in SAVE_MODE_LABELS.items()]
|
||||
default_output_dir = str(Path(get_user_output_path()).resolve())
|
||||
|
||||
return render_template(
|
||||
"settings.html",
|
||||
settings=load_settings(),
|
||||
integrations=load_integration_settings(),
|
||||
options=template_options(),
|
||||
normalization_samples=_NORMALIZATION_SAMPLES,
|
||||
save_locations=save_locations,
|
||||
default_output_dir=default_output_dir,
|
||||
llm_ready=llm_ready(load_settings()),
|
||||
debug_samples=DEBUG_TTS_SAMPLES,
|
||||
debug_manifest=debug_manifest,
|
||||
)
|
||||
|
||||
|
||||
@settings_bp.post("/debug/run")
|
||||
def run_debug_wavs() -> ResponseReturnValue:
|
||||
settings = load_settings()
|
||||
output_root = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web"))
|
||||
try:
|
||||
manifest = run_debug_tts_wavs(output_root=output_root, settings=settings)
|
||||
except Exception as exc:
|
||||
flash(f"Debug WAV generation failed: {exc}", "error")
|
||||
return redirect(url_for("settings.settings_page", _anchor="debug"))
|
||||
|
||||
flash("Debug WAV generation completed.", "success")
|
||||
return redirect(url_for("settings.debug_wavs_page", run_id=str(manifest.get("run_id") or "")))
|
||||
|
||||
|
||||
@settings_bp.get("/debug/<run_id>")
|
||||
def debug_wavs_page(run_id: str) -> ResponseReturnValue:
|
||||
safe_run = (run_id or "").strip()
|
||||
if not safe_run:
|
||||
abort(404)
|
||||
|
||||
root = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web"))
|
||||
run_dir = (root / "debug" / safe_run).resolve()
|
||||
manifest_path = run_dir / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
abort(404)
|
||||
|
||||
try:
|
||||
import json
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
abort(404)
|
||||
|
||||
artifacts = manifest.get("artifacts") or []
|
||||
# Precompute download URLs for each artifact.
|
||||
for item in artifacts:
|
||||
filename = str(item.get("filename") or "")
|
||||
item["url"] = url_for("settings.download_debug_wav", run_id=safe_run, filename=filename)
|
||||
|
||||
return render_template(
|
||||
"debug_wavs.html",
|
||||
run_id=safe_run,
|
||||
artifacts=artifacts,
|
||||
)
|
||||
|
||||
|
||||
@settings_bp.get("/debug/<run_id>/<filename>")
|
||||
def download_debug_wav(run_id: str, filename: str) -> ResponseReturnValue:
|
||||
safe_run = (run_id or "").strip()
|
||||
safe_name = (filename or "").strip()
|
||||
if not safe_run or not safe_name or "/" in safe_name or "\\" in safe_name:
|
||||
abort(404)
|
||||
is_wav = safe_name.lower().endswith(".wav")
|
||||
if not is_wav and safe_name != "manifest.json":
|
||||
abort(404)
|
||||
|
||||
root = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web"))
|
||||
path = (root / "debug" / safe_run / safe_name).resolve()
|
||||
if not path.exists() or not path.is_file():
|
||||
abort(404)
|
||||
# Ensure path is within root/debug/run_id
|
||||
expected_dir = (root / "debug" / safe_run).resolve()
|
||||
if expected_dir not in path.parents:
|
||||
abort(404)
|
||||
wants_download = str(request.args.get("download") or "").strip().lower() in {"1", "true", "yes"}
|
||||
mimetype = "audio/wav" if is_wav else "application/json"
|
||||
# Inline playback should work for WAVs; allow explicit downloads via ?download=1.
|
||||
return send_file(
|
||||
path,
|
||||
mimetype=mimetype,
|
||||
as_attachment=wants_download,
|
||||
download_name=path.name,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
from typing import Any, Optional, Tuple, Iterable, List
|
||||
from pathlib import Path
|
||||
|
||||
def split_profile_spec(value: Any) -> Tuple[str, Optional[str]]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return "", None
|
||||
lowered = text.lower()
|
||||
if lowered.startswith("profile:") or lowered.startswith("speaker:"):
|
||||
_, _, remainder = text.partition(":")
|
||||
name = remainder.strip()
|
||||
return "", name or None
|
||||
return text, None
|
||||
|
||||
|
||||
def split_speaker_spec(value: Any) -> Tuple[str, Optional[str]]:
|
||||
"""Preferred alias for split_profile_spec (supports 'speaker:' and legacy 'profile:')."""
|
||||
|
||||
return split_profile_spec(value)
|
||||
|
||||
def existing_paths(paths: Optional[Iterable[Path]]) -> List[Path]:
|
||||
if not paths:
|
||||
return []
|
||||
return [p for p in paths if p.exists()]
|
||||
@@ -0,0 +1,363 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from abogen.webui.service import PendingJob
|
||||
from abogen.entity_analysis import (
|
||||
extract_entities,
|
||||
merge_override,
|
||||
normalize_token as normalize_entity_token,
|
||||
normalize_manual_override_token,
|
||||
search_tokens as search_entity_tokens,
|
||||
)
|
||||
from abogen.pronunciation_store import (
|
||||
delete_override as delete_pronunciation_override,
|
||||
load_overrides as load_pronunciation_overrides,
|
||||
save_override as save_pronunciation_override,
|
||||
search_overrides as search_pronunciation_overrides,
|
||||
)
|
||||
from abogen.webui.routes.utils.settings import load_settings
|
||||
from abogen.heteronym_overrides import extract_heteronym_overrides
|
||||
|
||||
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_manual_override_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()
|
||||
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 []
|
||||
pending.heteronym_overrides = pending.heteronym_overrides or []
|
||||
return
|
||||
|
||||
enabled_only = [chapter for chapter in chapter_list if chapter.get("enabled")]
|
||||
target_chapters = enabled_only or chapter_list
|
||||
|
||||
# Always compute heteronym overrides (English only). Preserve any prior selections.
|
||||
try:
|
||||
pending.heteronym_overrides = extract_heteronym_overrides(
|
||||
target_chapters,
|
||||
language=language,
|
||||
existing=getattr(pending, "heteronym_overrides", None),
|
||||
)
|
||||
except Exception:
|
||||
pending.heteronym_overrides = getattr(pending, "heteronym_overrides", []) or []
|
||||
|
||||
if not bool(settings.get("enable_entity_recognition", True)):
|
||||
pending.entity_summary = {}
|
||||
pending.entity_cache_key = ""
|
||||
pending.pronunciation_overrides = pending.pronunciation_overrides or []
|
||||
return
|
||||
|
||||
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_manual_override_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 [],
|
||||
"heteronym_overrides": getattr(pending, "heteronym_overrides", None) or [],
|
||||
"cache_key": pending.entity_cache_key,
|
||||
"language": pending.language or "en",
|
||||
"recognition_enabled": recognition_enabled,
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import json
|
||||
import math
|
||||
import posixpath
|
||||
import zipfile
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from abogen.webui.service import Job, JobStatus
|
||||
|
||||
def _coerce_path(value: Any) -> Optional[Path]:
|
||||
if isinstance(value, Path):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
candidate = Path(value)
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def normalize_epub_path(base_dir: str, href: str) -> str:
|
||||
if not href:
|
||||
return ""
|
||||
sanitized = href.split("#", 1)[0].split("?", 1)[0].strip()
|
||||
sanitized = sanitized.replace("\\", "/")
|
||||
if not sanitized:
|
||||
return ""
|
||||
if sanitized.startswith("/"):
|
||||
sanitized = sanitized[1:]
|
||||
base_dir = ""
|
||||
normalized_base = base_dir.strip("/")
|
||||
sanitized_lower = sanitized.lower()
|
||||
if normalized_base:
|
||||
base_lower = normalized_base.lower()
|
||||
prefix = base_lower + "/"
|
||||
if sanitized_lower.startswith(prefix):
|
||||
remainder = sanitized[len(prefix):]
|
||||
if remainder.lower().startswith(prefix):
|
||||
sanitized = remainder
|
||||
sanitized_lower = sanitized.lower()
|
||||
base_dir = ""
|
||||
elif sanitized_lower == base_lower:
|
||||
base_dir = ""
|
||||
base = base_dir.strip("/")
|
||||
combined = posixpath.join(base, sanitized) if base else sanitized
|
||||
normalized = posixpath.normpath(combined)
|
||||
if normalized in {"", "."}:
|
||||
return ""
|
||||
normalized = normalized.replace("\\", "/")
|
||||
segments = [segment for segment in normalized.split("/") if segment and segment != "."]
|
||||
if not segments:
|
||||
return ""
|
||||
deduped: List[str] = []
|
||||
last_lower: Optional[str] = None
|
||||
for segment in segments:
|
||||
segment_lower = segment.lower()
|
||||
if last_lower == segment_lower:
|
||||
continue
|
||||
deduped.append(segment)
|
||||
last_lower = segment_lower
|
||||
normalized = "/".join(deduped)
|
||||
if normalized.startswith("../") or normalized == "..":
|
||||
return ""
|
||||
return normalized
|
||||
|
||||
|
||||
def decode_text(payload: bytes) -> str:
|
||||
for encoding in ("utf-8", "utf-16", "windows-1252"):
|
||||
try:
|
||||
return payload.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return payload.decode("utf-8", "ignore")
|
||||
|
||||
|
||||
def coerce_positive_time(value: Any) -> Optional[float]:
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(numeric) or numeric < 0:
|
||||
return None
|
||||
return numeric
|
||||
|
||||
|
||||
def load_job_metadata(job: Job) -> Dict[str, Any]:
|
||||
result = getattr(job, "result", None)
|
||||
artifacts = getattr(result, "artifacts", None)
|
||||
if not isinstance(artifacts, Mapping):
|
||||
return {}
|
||||
metadata_ref = artifacts.get("metadata")
|
||||
if isinstance(metadata_ref, Path):
|
||||
metadata_path = metadata_ref
|
||||
elif isinstance(metadata_ref, str):
|
||||
metadata_path = Path(metadata_ref)
|
||||
else:
|
||||
return {}
|
||||
if not metadata_path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def resolve_book_title(job: Job, *metadata_sources: Mapping[str, Any]) -> str:
|
||||
for source in metadata_sources:
|
||||
if not isinstance(source, Mapping):
|
||||
continue
|
||||
for key in ("title", "book_title", "name", "album", "album_title"):
|
||||
value = source.get(key)
|
||||
if isinstance(value, str):
|
||||
candidate = value.strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
filename = job.original_filename or ""
|
||||
stem = Path(filename).stem if filename else ""
|
||||
return stem or filename
|
||||
|
||||
|
||||
class _NavMapParser(HTMLParser):
|
||||
def __init__(self, base_dir: str) -> None:
|
||||
super().__init__()
|
||||
self._base_dir = base_dir
|
||||
self._in_nav = False
|
||||
self._nav_depth = 0
|
||||
self._current_href: Optional[str] = None
|
||||
self._buffer: List[str] = []
|
||||
self.links: Dict[str, str] = {}
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||
tag_lower = tag.lower()
|
||||
if tag_lower == "nav":
|
||||
attributes = dict(attrs)
|
||||
nav_type = (attributes.get("epub:type") or attributes.get("type") or "").strip().lower()
|
||||
nav_role = (attributes.get("role") or "").strip().lower()
|
||||
type_tokens = {token.strip() for token in nav_type.split() if token}
|
||||
role_tokens = {token.strip() for token in nav_role.split() if token}
|
||||
if "toc" in type_tokens or "doc-toc" in role_tokens:
|
||||
self._in_nav = True
|
||||
self._nav_depth = 1
|
||||
return
|
||||
if self._in_nav:
|
||||
self._nav_depth += 1
|
||||
return
|
||||
if not self._in_nav:
|
||||
return
|
||||
if tag_lower == "a":
|
||||
attributes = dict(attrs)
|
||||
href = attributes.get("href") or ""
|
||||
normalized = normalize_epub_path(self._base_dir, href)
|
||||
if normalized:
|
||||
self._current_href = normalized
|
||||
self._buffer = []
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
tag_lower = tag.lower()
|
||||
if tag_lower == "nav" and self._in_nav:
|
||||
self._nav_depth -= 1
|
||||
if self._nav_depth <= 0:
|
||||
self._in_nav = False
|
||||
return
|
||||
if not self._in_nav:
|
||||
return
|
||||
if tag_lower == "a" and self._current_href:
|
||||
text = "".join(self._buffer).strip()
|
||||
if text:
|
||||
self.links.setdefault(self._current_href, text)
|
||||
self._current_href = None
|
||||
self._buffer = []
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._in_nav and self._current_href and data:
|
||||
self._buffer.append(data)
|
||||
|
||||
|
||||
def parse_nav_document(payload: bytes, base_dir: str) -> Dict[str, str]:
|
||||
parser = _NavMapParser(base_dir)
|
||||
parser.feed(decode_text(payload))
|
||||
parser.close()
|
||||
return parser.links
|
||||
|
||||
|
||||
def parse_ncx_document(payload: bytes, base_dir: str) -> Dict[str, str]:
|
||||
try:
|
||||
root = ET.fromstring(payload)
|
||||
except ET.ParseError:
|
||||
return {}
|
||||
nav_map: Dict[str, str] = {}
|
||||
for nav_point in root.findall(".//{*}navPoint"):
|
||||
content = nav_point.find(".//{*}content")
|
||||
if content is None:
|
||||
continue
|
||||
src = content.attrib.get("src", "")
|
||||
normalized = normalize_epub_path(base_dir, src)
|
||||
if not normalized:
|
||||
continue
|
||||
label_el = nav_point.find(".//{*}text")
|
||||
label = (label_el.text or "").strip() if label_el is not None and label_el.text else ""
|
||||
if not label:
|
||||
label = posixpath.basename(normalized) or f"Section {len(nav_map) + 1}"
|
||||
nav_map.setdefault(normalized, label)
|
||||
return nav_map
|
||||
|
||||
|
||||
def extract_epub_chapters(epub_path: Path) -> List[Dict[str, str]]:
|
||||
chapters: List[Dict[str, str]] = []
|
||||
if not epub_path or not epub_path.exists():
|
||||
return chapters
|
||||
try:
|
||||
with zipfile.ZipFile(epub_path, "r") as archive:
|
||||
container_bytes = archive.read("META-INF/container.xml")
|
||||
container_root = ET.fromstring(container_bytes)
|
||||
rootfile = container_root.find(".//{*}rootfile")
|
||||
if rootfile is None:
|
||||
return chapters
|
||||
opf_path = (rootfile.attrib.get("full-path") or "").strip()
|
||||
if not opf_path:
|
||||
return chapters
|
||||
opf_dir = posixpath.dirname(opf_path)
|
||||
opf_bytes = archive.read(opf_path)
|
||||
opf_root = ET.fromstring(opf_bytes)
|
||||
|
||||
manifest: Dict[str, Dict[str, str]] = {}
|
||||
for item in opf_root.findall(".//{*}manifest/{*}item"):
|
||||
item_id = item.attrib.get("id")
|
||||
href = item.attrib.get("href")
|
||||
if not item_id or not href:
|
||||
continue
|
||||
manifest[item_id] = {
|
||||
"href": normalize_epub_path(opf_dir, href),
|
||||
"properties": item.attrib.get("properties", ""),
|
||||
"media_type": item.attrib.get("media-type", ""),
|
||||
}
|
||||
|
||||
spine_hrefs: List[str] = []
|
||||
nav_id: Optional[str] = None
|
||||
spine = opf_root.find(".//{*}spine")
|
||||
if spine is not None:
|
||||
nav_id = spine.attrib.get("toc")
|
||||
for itemref in spine.findall(".//{*}itemref"):
|
||||
idref = itemref.attrib.get("idref")
|
||||
if not idref:
|
||||
continue
|
||||
entry = manifest.get(idref)
|
||||
if not entry:
|
||||
continue
|
||||
href = entry["href"]
|
||||
if href and href not in spine_hrefs:
|
||||
spine_hrefs.append(href)
|
||||
|
||||
nav_href: Optional[str] = None
|
||||
for entry in manifest.values():
|
||||
properties = entry.get("properties") or ""
|
||||
if "nav" in {token.strip() for token in properties.split() if token}:
|
||||
nav_href = entry["href"]
|
||||
break
|
||||
if not nav_href and nav_id:
|
||||
toc_entry = manifest.get(nav_id)
|
||||
if toc_entry:
|
||||
nav_href = toc_entry["href"]
|
||||
|
||||
nav_titles: Dict[str, str] = {}
|
||||
if nav_href:
|
||||
nav_base = posixpath.dirname(nav_href)
|
||||
try:
|
||||
nav_bytes = archive.read(nav_href)
|
||||
except KeyError:
|
||||
nav_bytes = None
|
||||
if nav_bytes is not None:
|
||||
if nav_href.lower().endswith(".ncx"):
|
||||
nav_titles = parse_ncx_document(nav_bytes, nav_base)
|
||||
else:
|
||||
nav_titles = parse_nav_document(nav_bytes, nav_base)
|
||||
|
||||
if not nav_titles and nav_id and nav_id in manifest:
|
||||
toc_entry = manifest[nav_id]
|
||||
nav_base = posixpath.dirname(toc_entry["href"])
|
||||
try:
|
||||
nav_bytes = archive.read(toc_entry["href"])
|
||||
except KeyError:
|
||||
nav_bytes = None
|
||||
if nav_bytes is not None:
|
||||
nav_titles = parse_ncx_document(nav_bytes, nav_base)
|
||||
|
||||
for index, href in enumerate(spine_hrefs, start=1):
|
||||
normalized = href
|
||||
if not normalized:
|
||||
continue
|
||||
title = (
|
||||
nav_titles.get(normalized)
|
||||
or nav_titles.get(normalized.split("#", 1)[0])
|
||||
or posixpath.basename(normalized)
|
||||
or f"Chapter {index}"
|
||||
)
|
||||
chapters.append({"href": normalized, "title": title})
|
||||
|
||||
if not chapters and nav_titles:
|
||||
for index, (href, title) in enumerate(nav_titles.items(), start=1):
|
||||
normalized = href
|
||||
if not normalized:
|
||||
continue
|
||||
label = title or posixpath.basename(normalized) or f"Chapter {index}"
|
||||
chapters.append({"href": normalized, "title": label})
|
||||
|
||||
return chapters
|
||||
except (FileNotFoundError, zipfile.BadZipFile, KeyError, ET.ParseError, UnicodeDecodeError):
|
||||
return []
|
||||
return chapters
|
||||
|
||||
|
||||
def read_epub_bytes(epub_path: Path, raw_href: str) -> bytes:
|
||||
normalized = normalize_epub_path("", raw_href)
|
||||
if not normalized:
|
||||
raise ValueError("Invalid resource path")
|
||||
with zipfile.ZipFile(epub_path, "r") as archive:
|
||||
return archive.read(normalized)
|
||||
|
||||
|
||||
def iter_job_result_paths(job: Job) -> List[Path]:
|
||||
result = getattr(job, "result", None)
|
||||
if result is None:
|
||||
return []
|
||||
resolved_seen: Set[Path] = set()
|
||||
collected: List[Path] = []
|
||||
|
||||
def _remember(candidate: Optional[Path]) -> None:
|
||||
if not candidate:
|
||||
return
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
except OSError:
|
||||
return
|
||||
if resolved in resolved_seen:
|
||||
return
|
||||
resolved_seen.add(resolved)
|
||||
collected.append(candidate)
|
||||
|
||||
artifacts = getattr(result, "artifacts", None)
|
||||
if isinstance(artifacts, Mapping):
|
||||
for value in artifacts.values():
|
||||
candidate = _coerce_path(value)
|
||||
if candidate and candidate.exists() and candidate.is_file():
|
||||
_remember(candidate)
|
||||
|
||||
for attr in ("audio_path", "epub_path"):
|
||||
candidate = _coerce_path(getattr(result, attr, None))
|
||||
if candidate and candidate.exists() and candidate.is_file():
|
||||
_remember(candidate)
|
||||
|
||||
return collected
|
||||
|
||||
|
||||
def iter_job_artifact_dirs(job: Job) -> List[Path]:
|
||||
result = getattr(job, "result", None)
|
||||
if result is None:
|
||||
return []
|
||||
artifacts = getattr(result, "artifacts", None)
|
||||
directories: List[Path] = []
|
||||
if isinstance(artifacts, Mapping):
|
||||
for value in artifacts.values():
|
||||
candidate = _coerce_path(value)
|
||||
if candidate and candidate.exists() and candidate.is_dir():
|
||||
directories.append(candidate)
|
||||
return directories
|
||||
|
||||
|
||||
def normalize_suffixes(suffixes: Iterable[str]) -> List[str]:
|
||||
normalized: List[str] = []
|
||||
for suffix in suffixes:
|
||||
if not suffix:
|
||||
continue
|
||||
cleaned = suffix.lower().strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
if not cleaned.startswith("."):
|
||||
cleaned = f".{cleaned.lstrip('.')}"
|
||||
normalized.append(cleaned)
|
||||
return normalized
|
||||
|
||||
|
||||
def find_job_file(job: Job, suffixes: Iterable[str]) -> Optional[Path]:
|
||||
ordered_suffixes = normalize_suffixes(suffixes)
|
||||
if not ordered_suffixes:
|
||||
return None
|
||||
files = iter_job_result_paths(job)
|
||||
for suffix in ordered_suffixes:
|
||||
for candidate in files:
|
||||
if candidate.suffix.lower() == suffix:
|
||||
return candidate
|
||||
directories = iter_job_artifact_dirs(job)
|
||||
for suffix in ordered_suffixes:
|
||||
pattern = f"*{suffix}"
|
||||
for directory in directories:
|
||||
try:
|
||||
match = next((path for path in directory.rglob(pattern) if path.is_file()), None)
|
||||
except OSError:
|
||||
match = None
|
||||
if match:
|
||||
return match
|
||||
return None
|
||||
|
||||
|
||||
def locate_job_epub(job: Job) -> Optional[Path]:
|
||||
path = find_job_file(job, [".epub"])
|
||||
if path:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def locate_job_m4b(job: Job) -> Optional[Path]:
|
||||
return find_job_file(job, [".m4b"])
|
||||
|
||||
|
||||
def locate_job_audio(job: Job, preferred_suffixes: Optional[Iterable[str]] = None) -> Optional[Path]:
|
||||
suffix_order: List[str] = []
|
||||
if preferred_suffixes:
|
||||
suffix_order.extend(preferred_suffixes)
|
||||
suffix_order.extend([".m4b", ".mp3", ".flac", ".opus", ".ogg", ".m4a", ".wav"])
|
||||
path = find_job_file(job, suffix_order)
|
||||
if path:
|
||||
return path
|
||||
files = iter_job_result_paths(job)
|
||||
return files[0] if files else None
|
||||
|
||||
|
||||
def job_download_flags(job: Job) -> Dict[str, bool]:
|
||||
if job.status != JobStatus.COMPLETED:
|
||||
return {"audio": False, "m4b": False, "epub3": False}
|
||||
return {
|
||||
"audio": locate_job_audio(job) is not None,
|
||||
"m4b": locate_job_m4b(job) is not None,
|
||||
"epub3": locate_job_epub(job) is not None,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
import io
|
||||
import threading
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from flask import current_app, send_file
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
|
||||
SPLIT_PATTERN = r"\n+"
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||
_preview_pipeline_lock = threading.Lock()
|
||||
|
||||
|
||||
def _select_device() -> str:
|
||||
import platform
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin" and platform.processor() == "arm":
|
||||
return "mps"
|
||||
return "cuda"
|
||||
|
||||
|
||||
def _to_float32(audio_segment) -> np.ndarray:
|
||||
if audio_segment is None:
|
||||
return np.zeros(0, dtype="float32")
|
||||
|
||||
tensor = audio_segment
|
||||
if hasattr(tensor, "detach"):
|
||||
tensor = tensor.detach()
|
||||
if hasattr(tensor, "cpu"):
|
||||
try:
|
||||
tensor = tensor.cpu()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(tensor, "numpy"):
|
||||
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
|
||||
return np.asarray(tensor, dtype="float32").reshape(-1)
|
||||
|
||||
def get_preview_pipeline(language: str, device: str) -> Any:
|
||||
key = (language, device)
|
||||
with _preview_pipeline_lock:
|
||||
pipeline = _preview_pipelines.get(key)
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
from abogen.utils import load_numpy_kpipeline
|
||||
|
||||
_, KPipeline = load_numpy_kpipeline()
|
||||
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||
_preview_pipelines[key] = pipeline
|
||||
return pipeline
|
||||
|
||||
def generate_preview_audio(
|
||||
text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
tts_provider: str = "kokoro",
|
||||
supertonic_total_steps: int = 5,
|
||||
max_seconds: float = 8.0,
|
||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
speakers: Optional[Mapping[str, Any]] = None,
|
||||
) -> bytes:
|
||||
if not text.strip():
|
||||
raise ValueError("Preview text is required")
|
||||
|
||||
provider = (tts_provider or "kokoro").strip().lower()
|
||||
|
||||
# Apply pronunciation/manual overrides first so tokens like `Unfu*k` still match
|
||||
# before any downstream normalization potentially strips punctuation.
|
||||
source_text = text
|
||||
if pronunciation_overrides or manual_overrides or speakers:
|
||||
try:
|
||||
from abogen.webui import conversion_runner as runner
|
||||
|
||||
class _PreviewJob:
|
||||
def __init__(self):
|
||||
self.language = language
|
||||
self.voice = voice_spec
|
||||
self.speakers = speakers
|
||||
self.manual_overrides = list(manual_overrides or [])
|
||||
self.pronunciation_overrides = list(pronunciation_overrides or [])
|
||||
|
||||
job = _PreviewJob()
|
||||
merged = runner._merge_pronunciation_overrides(job)
|
||||
rules = runner._compile_pronunciation_rules(merged)
|
||||
source_text = runner._apply_pronunciation_rules(source_text, rules)
|
||||
except Exception:
|
||||
current_app.logger.exception("Preview override application failed; using raw text")
|
||||
source_text = text
|
||||
|
||||
normalized_text = source_text
|
||||
if provider != "supertonic":
|
||||
try:
|
||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||
|
||||
normalized_text = normalize_for_pipeline(source_text)
|
||||
except Exception:
|
||||
current_app.logger.exception("Preview normalization failed; using raw text")
|
||||
normalized_text = source_text
|
||||
|
||||
if provider == "supertonic":
|
||||
from abogen.tts_supertonic import SupertonicPipeline
|
||||
|
||||
pipeline = SupertonicPipeline(sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
voice=voice_spec,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
total_steps=supertonic_total_steps,
|
||||
)
|
||||
else:
|
||||
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:
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
|
||||
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")
|
||||
|
||||
audio_data = np.concatenate(audio_chunks)
|
||||
buffer = io.BytesIO()
|
||||
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
|
||||
return buffer.getvalue()
|
||||
|
||||
def synthesize_preview(
|
||||
text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
tts_provider: str = "kokoro",
|
||||
supertonic_total_steps: int = 5,
|
||||
max_seconds: float = 8.0,
|
||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
speakers: Optional[Mapping[str, Any]] = None,
|
||||
) -> ResponseReturnValue:
|
||||
try:
|
||||
audio_bytes = generate_preview_audio(
|
||||
text=text,
|
||||
voice_spec=voice_spec,
|
||||
language=language,
|
||||
speed=speed,
|
||||
use_gpu=use_gpu,
|
||||
tts_provider=tts_provider,
|
||||
supertonic_total_steps=supertonic_total_steps,
|
||||
max_seconds=max_seconds,
|
||||
pronunciation_overrides=pronunciation_overrides,
|
||||
manual_overrides=manual_overrides,
|
||||
speakers=speakers,
|
||||
)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
buffer = io.BytesIO(audio_bytes)
|
||||
response = send_file(
|
||||
buffer,
|
||||
mimetype="audio/wav",
|
||||
as_attachment=False,
|
||||
download_name="speaker_preview.wav",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
@@ -0,0 +1,67 @@
|
||||
from typing import cast
|
||||
from flask import current_app, abort
|
||||
from abogen.webui.service import ConversionService, PendingJob
|
||||
|
||||
def get_service() -> ConversionService:
|
||||
return current_app.extensions["conversion_service"]
|
||||
|
||||
def require_pending_job(pending_id: str) -> PendingJob:
|
||||
pending = get_service().get_pending_job(pending_id)
|
||||
if not pending:
|
||||
abort(404)
|
||||
return cast(PendingJob, pending)
|
||||
|
||||
def remove_pending_job(pending_id: str) -> None:
|
||||
get_service().pop_pending_job(pending_id)
|
||||
|
||||
def submit_job(pending: PendingJob) -> str:
|
||||
service = get_service()
|
||||
service.pop_pending_job(pending.id)
|
||||
|
||||
job = service.enqueue(
|
||||
original_filename=pending.original_filename,
|
||||
stored_path=pending.stored_path,
|
||||
language=pending.language,
|
||||
tts_provider=getattr(pending, "tts_provider", "kokoro"),
|
||||
voice=pending.voice,
|
||||
speed=pending.speed,
|
||||
supertonic_total_steps=getattr(pending, "supertonic_total_steps", 5),
|
||||
use_gpu=pending.use_gpu,
|
||||
subtitle_mode=pending.subtitle_mode,
|
||||
output_format=pending.output_format,
|
||||
save_mode=pending.save_mode,
|
||||
output_folder=pending.output_folder,
|
||||
replace_single_newlines=pending.replace_single_newlines,
|
||||
subtitle_format=pending.subtitle_format,
|
||||
total_characters=pending.total_characters,
|
||||
chapters=pending.chapters,
|
||||
save_chapters_separately=pending.save_chapters_separately,
|
||||
merge_chapters_at_end=pending.merge_chapters_at_end,
|
||||
separate_chapters_format=pending.separate_chapters_format,
|
||||
silence_between_chapters=pending.silence_between_chapters,
|
||||
save_as_project=pending.save_as_project,
|
||||
voice_profile=pending.voice_profile,
|
||||
max_subtitle_words=pending.max_subtitle_words,
|
||||
metadata_tags=pending.metadata_tags,
|
||||
cover_image_path=pending.cover_image_path,
|
||||
cover_image_mime=pending.cover_image_mime,
|
||||
chapter_intro_delay=pending.chapter_intro_delay,
|
||||
read_title_intro=pending.read_title_intro,
|
||||
read_closing_outro=pending.read_closing_outro,
|
||||
auto_prefix_chapter_titles=pending.auto_prefix_chapter_titles,
|
||||
normalize_chapter_opening_caps=pending.normalize_chapter_opening_caps,
|
||||
chunk_level=pending.chunk_level,
|
||||
chunks=pending.chunks,
|
||||
speakers=pending.speakers,
|
||||
speaker_mode=pending.speaker_mode,
|
||||
generate_epub3=pending.generate_epub3,
|
||||
speaker_analysis=pending.speaker_analysis,
|
||||
speaker_analysis_threshold=pending.speaker_analysis_threshold,
|
||||
analysis_requested=pending.analysis_requested,
|
||||
entity_summary=getattr(pending, "entity_summary", None),
|
||||
manual_overrides=getattr(pending, "manual_overrides", None),
|
||||
pronunciation_overrides=getattr(pending, "pronunciation_overrides", None),
|
||||
heteronym_overrides=getattr(pending, "heteronym_overrides", None),
|
||||
normalization_overrides=pending.normalization_overrides,
|
||||
)
|
||||
return job.id
|
||||
@@ -0,0 +1,752 @@
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, Mapping, Optional
|
||||
|
||||
from abogen.constants import (
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
SUBTITLE_FORMATS,
|
||||
SUPPORTED_SOUND_FORMATS,
|
||||
VOICES_INTERNAL,
|
||||
)
|
||||
from abogen.normalization_settings import (
|
||||
DEFAULT_LLM_PROMPT,
|
||||
environment_llm_defaults,
|
||||
)
|
||||
from abogen.utils import load_config, save_config
|
||||
from abogen.integrations.calibre_opds import CalibreOPDSClient
|
||||
from abogen.integrations.audiobookshelf import AudiobookshelfConfig
|
||||
from abogen.webui.routes.utils.common import split_profile_spec
|
||||
|
||||
SAVE_MODE_LABELS = {
|
||||
"save_next_to_input": "Save next to input file",
|
||||
"save_to_desktop": "Save to Desktop",
|
||||
"choose_output_folder": "Choose output folder",
|
||||
"default_output": "Use default save location",
|
||||
}
|
||||
|
||||
LEGACY_SAVE_MODE_MAP = {label: key for key, label in SAVE_MODE_LABELS.items()}
|
||||
|
||||
_CHUNK_LEVEL_OPTIONS = [
|
||||
{"value": "paragraph", "label": "Paragraphs"},
|
||||
{"value": "sentence", "label": "Sentences"},
|
||||
]
|
||||
|
||||
_CHUNK_LEVEL_VALUES = {option["value"] for option in _CHUNK_LEVEL_OPTIONS}
|
||||
|
||||
_DEFAULT_ANALYSIS_THRESHOLD = 3
|
||||
|
||||
_APOSTROPHE_MODE_OPTIONS = [
|
||||
{"value": "off", "label": "Off"},
|
||||
{"value": "spacy", "label": "spaCy (built-in)"},
|
||||
{"value": "llm", "label": "LLM assisted"},
|
||||
]
|
||||
|
||||
_NORMALIZATION_BOOLEAN_KEYS = {
|
||||
"normalization_numbers",
|
||||
"normalization_titles",
|
||||
"normalization_terminal",
|
||||
"normalization_phoneme_hints",
|
||||
"normalization_caps_quotes",
|
||||
"normalization_currency",
|
||||
"normalization_footnotes",
|
||||
"normalization_internet_slang",
|
||||
"normalization_apostrophes_contractions",
|
||||
"normalization_apostrophes_plural_possessives",
|
||||
"normalization_apostrophes_sibilant_possessives",
|
||||
"normalization_apostrophes_decades",
|
||||
"normalization_apostrophes_leading_elisions",
|
||||
"normalization_contraction_aux_be",
|
||||
"normalization_contraction_aux_have",
|
||||
"normalization_contraction_modal_will",
|
||||
"normalization_contraction_modal_would",
|
||||
"normalization_contraction_negation_not",
|
||||
"normalization_contraction_let_us",
|
||||
}
|
||||
|
||||
_NORMALIZATION_STRING_KEYS = {
|
||||
"normalization_numbers_year_style",
|
||||
"normalization_apostrophe_mode",
|
||||
}
|
||||
|
||||
BOOLEAN_SETTINGS = {
|
||||
"replace_single_newlines",
|
||||
"use_gpu",
|
||||
"save_chapters_separately",
|
||||
"merge_chapters_at_end",
|
||||
"save_as_project",
|
||||
"generate_epub3",
|
||||
"enable_entity_recognition",
|
||||
"read_title_intro",
|
||||
"read_closing_outro",
|
||||
"auto_prefix_chapter_titles",
|
||||
"normalize_chapter_opening_caps",
|
||||
"normalization_numbers",
|
||||
"normalization_titles",
|
||||
"normalization_terminal",
|
||||
"normalization_phoneme_hints",
|
||||
"normalization_caps_quotes",
|
||||
"normalization_currency",
|
||||
"normalization_footnotes",
|
||||
"normalization_internet_slang",
|
||||
"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"}
|
||||
|
||||
_NORMALIZATION_GROUPS = [
|
||||
{
|
||||
"label": "General Rules",
|
||||
"options": [
|
||||
{"key": "normalization_numbers", "label": "Convert grouped numbers to words"},
|
||||
{"key": "normalization_currency", "label": "Convert currency symbols ($10 → ten dollars)"},
|
||||
{"key": "normalization_titles", "label": "Expand titles and suffixes (Dr., St., Jr., …)"},
|
||||
{"key": "normalization_internet_slang", "label": "Expand internet slang (pls → please)"},
|
||||
{"key": "normalization_footnotes", "label": "Remove footnote indicators ([1], [2])"},
|
||||
{"key": "normalization_terminal", "label": "Ensure sentences end with terminal punctuation"},
|
||||
{"key": "normalization_caps_quotes", "label": "Convert ALL CAPS dialogue inside quotes"},
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Apostrophes & Contractions",
|
||||
"options": [
|
||||
{"key": "normalization_apostrophes_contractions", "label": "Expand contractions (it's → it is)"},
|
||||
{"key": "normalization_apostrophes_plural_possessives", "label": "Collapse plural possessives (dogs' → dogs)"},
|
||||
{"key": "normalization_apostrophes_sibilant_possessives", "label": "Mark sibilant possessives (boss's → boss + IZ marker)"},
|
||||
{"key": "normalization_apostrophes_decades", "label": "Expand decades ('90s → 1990s)"},
|
||||
{"key": "normalization_apostrophes_leading_elisions", "label": "Expand leading elisions ('tis → it is)"},
|
||||
{"key": "normalization_phoneme_hints", "label": "Add phoneme hints for possessives"},
|
||||
{"key": "normalization_contraction_aux_be", "label": "Expand auxiliary 'be' (I'm → I am)"},
|
||||
{"key": "normalization_contraction_aux_have", "label": "Expand auxiliary 'have' (I've → I have)"},
|
||||
{"key": "normalization_contraction_modal_will", "label": "Expand modal 'will' (I'll → I will)"},
|
||||
{"key": "normalization_contraction_modal_would", "label": "Expand modal 'would' (I'd → I would)"},
|
||||
{"key": "normalization_contraction_negation_not", "label": "Expand negation 'not' (don't → do not)"},
|
||||
{"key": "normalization_contraction_let_us", "label": "Expand 'let's' → 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_speaker": "",
|
||||
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
|
||||
"supertonic_total_steps": 5,
|
||||
"supertonic_speed": 1.0,
|
||||
"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_currency": True,
|
||||
"normalization_footnotes": True,
|
||||
"normalization_titles": True,
|
||||
"normalization_terminal": True,
|
||||
"normalization_phoneme_hints": True,
|
||||
"normalization_caps_quotes": True,
|
||||
"normalization_internet_slang": False,
|
||||
"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",
|
||||
"normalization_numbers_year_style": "american",
|
||||
"normalization_contraction_aux_be": True,
|
||||
"normalization_contraction_aux_have": True,
|
||||
"normalization_contraction_modal_will": True,
|
||||
"normalization_contraction_modal_would": True,
|
||||
"normalization_contraction_negation_not": True,
|
||||
"normalization_contraction_let_us": True,
|
||||
}
|
||||
|
||||
|
||||
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"speaker:{profile_name}"
|
||||
return spec
|
||||
return defaults[key]
|
||||
if key == "default_speaker":
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return ""
|
||||
spec, profile_name = split_profile_spec(text)
|
||||
if profile_name:
|
||||
return f"speaker:{profile_name}"
|
||||
return spec
|
||||
return ""
|
||||
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 == "normalization_numbers_year_style":
|
||||
if isinstance(value, str):
|
||||
normalized_style = value.strip().lower()
|
||||
if normalized_style in {"american", "off"}:
|
||||
return normalized_style
|
||||
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, [])
|
||||
if key == "supertonic_total_steps":
|
||||
try:
|
||||
steps = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return defaults.get(key, 5)
|
||||
return max(2, min(15, steps))
|
||||
if key == "supertonic_speed":
|
||||
try:
|
||||
speed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return defaults.get(key, 1.0)
|
||||
return max(0.7, min(2.0, speed))
|
||||
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 are stored under the "integrations" key in the config
|
||||
stored_integrations = cfg.get("integrations", {})
|
||||
if not isinstance(stored_integrations, Mapping):
|
||||
stored_integrations = {}
|
||||
|
||||
integrations: Dict[str, Dict[str, Any]] = {}
|
||||
for key, default in defaults.items():
|
||||
stored = stored_integrations.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"))
|
||||
# Do not clear the password here, let the template decide whether to show it or not
|
||||
# merged["password"] = ""
|
||||
elif key == "audiobookshelf":
|
||||
merged["has_api_token"] = bool(isinstance(stored, Mapping) and stored.get("api_token"))
|
||||
# Do not clear the token here
|
||||
# merged["api_token"] = ""
|
||||
integrations[key] = merged
|
||||
|
||||
# Environment variable fallbacks for Calibre OPDS
|
||||
calibre = integrations["calibre_opds"]
|
||||
if not calibre.get("base_url"):
|
||||
calibre["base_url"] = os.environ.get("CALIBRE_SERVER_HOST", "")
|
||||
if not calibre.get("username"):
|
||||
calibre["username"] = os.environ.get("OPDS_USERNAME", "")
|
||||
if not calibre.get("password"):
|
||||
calibre["password"] = os.environ.get("OPDS_PASSWORD", "")
|
||||
|
||||
# If we have a password (from storage or env), mark it as present for the UI
|
||||
if calibre.get("password"):
|
||||
calibre["has_password"] = True
|
||||
|
||||
# Auto-enable if configured via env but not explicitly disabled in config
|
||||
stored_calibre = stored_integrations.get("calibre_opds")
|
||||
if stored_calibre is None and calibre.get("base_url"):
|
||||
calibre["enabled"] = True
|
||||
|
||||
return integrations
|
||||
|
||||
|
||||
def stored_integration_config(name: str) -> Dict[str, Any]:
|
||||
cfg = load_config() or {}
|
||||
# Check under "integrations" first (new structure)
|
||||
integrations = cfg.get("integrations")
|
||||
if isinstance(integrations, Mapping):
|
||||
entry = integrations.get(name)
|
||||
if isinstance(entry, Mapping):
|
||||
return dict(entry)
|
||||
|
||||
# Fallback to top-level (legacy structure)
|
||||
entry = cfg.get(name)
|
||||
if isinstance(entry, Mapping):
|
||||
return dict(entry)
|
||||
return {}
|
||||
|
||||
|
||||
def calibre_settings_from_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
defaults = integration_defaults()["calibre_opds"]
|
||||
stored = stored_integration_config("calibre_opds")
|
||||
|
||||
base_url = str(
|
||||
payload.get("base_url")
|
||||
or payload.get("calibre_opds_base_url")
|
||||
or stored.get("base_url")
|
||||
or ""
|
||||
).strip()
|
||||
username = str(
|
||||
payload.get("username")
|
||||
or payload.get("calibre_opds_username")
|
||||
or stored.get("username")
|
||||
or ""
|
||||
).strip()
|
||||
password_input = str(
|
||||
payload.get("password")
|
||||
or payload.get("calibre_opds_password")
|
||||
or ""
|
||||
).strip()
|
||||
use_saved_password = coerce_bool(
|
||||
payload.get("use_saved_password")
|
||||
or payload.get("calibre_opds_use_saved_password"),
|
||||
False,
|
||||
)
|
||||
clear_saved_password = coerce_bool(
|
||||
payload.get("clear_saved_password")
|
||||
or payload.get("calibre_opds_password_clear"),
|
||||
False,
|
||||
)
|
||||
password = ""
|
||||
if password_input:
|
||||
password = password_input
|
||||
elif use_saved_password and not clear_saved_password:
|
||||
password = str(stored.get("password") or "")
|
||||
|
||||
verify_ssl = coerce_bool(
|
||||
payload.get("verify_ssl")
|
||||
or payload.get("calibre_opds_verify_ssl"),
|
||||
defaults["verify_ssl"],
|
||||
)
|
||||
enabled = coerce_bool(
|
||||
payload.get("enabled")
|
||||
or payload.get("calibre_opds_enabled"),
|
||||
coerce_bool(stored.get("enabled"), False),
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"base_url": base_url,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"verify_ssl": verify_ssl,
|
||||
}
|
||||
|
||||
|
||||
def audiobookshelf_settings_from_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
defaults = integration_defaults()["audiobookshelf"]
|
||||
stored = stored_integration_config("audiobookshelf")
|
||||
|
||||
base_url = str(
|
||||
payload.get("base_url")
|
||||
or payload.get("audiobookshelf_base_url")
|
||||
or stored.get("base_url")
|
||||
or ""
|
||||
).strip()
|
||||
library_id = str(
|
||||
payload.get("library_id")
|
||||
or payload.get("audiobookshelf_library_id")
|
||||
or stored.get("library_id")
|
||||
or ""
|
||||
).strip()
|
||||
collection_id = str(
|
||||
payload.get("collection_id")
|
||||
or payload.get("audiobookshelf_collection_id")
|
||||
or stored.get("collection_id")
|
||||
or ""
|
||||
).strip()
|
||||
folder_id = str(
|
||||
payload.get("folder_id")
|
||||
or payload.get("audiobookshelf_folder_id")
|
||||
or stored.get("folder_id")
|
||||
or ""
|
||||
).strip()
|
||||
token_input = str(
|
||||
payload.get("api_token")
|
||||
or payload.get("audiobookshelf_api_token")
|
||||
or ""
|
||||
).strip()
|
||||
use_saved_token = coerce_bool(
|
||||
payload.get("use_saved_token")
|
||||
or payload.get("audiobookshelf_use_saved_token"),
|
||||
False,
|
||||
)
|
||||
clear_saved_token = coerce_bool(
|
||||
payload.get("clear_saved_token")
|
||||
or payload.get("audiobookshelf_api_token_clear"),
|
||||
False,
|
||||
)
|
||||
if token_input:
|
||||
api_token = token_input
|
||||
elif use_saved_token and not clear_saved_token:
|
||||
api_token = str(stored.get("api_token") or "")
|
||||
else:
|
||||
api_token = ""
|
||||
|
||||
verify_ssl = coerce_bool(
|
||||
payload.get("verify_ssl")
|
||||
or payload.get("audiobookshelf_verify_ssl"),
|
||||
defaults["verify_ssl"],
|
||||
)
|
||||
send_cover = coerce_bool(
|
||||
payload.get("send_cover")
|
||||
or payload.get("audiobookshelf_send_cover"),
|
||||
defaults["send_cover"],
|
||||
)
|
||||
send_chapters = coerce_bool(
|
||||
payload.get("send_chapters")
|
||||
or payload.get("audiobookshelf_send_chapters"),
|
||||
defaults["send_chapters"],
|
||||
)
|
||||
send_subtitles = coerce_bool(
|
||||
payload.get("send_subtitles")
|
||||
or payload.get("audiobookshelf_send_subtitles"),
|
||||
defaults["send_subtitles"],
|
||||
)
|
||||
auto_send = coerce_bool(
|
||||
payload.get("auto_send")
|
||||
or payload.get("audiobookshelf_auto_send"),
|
||||
defaults["auto_send"],
|
||||
)
|
||||
timeout_raw = (
|
||||
payload.get("timeout")
|
||||
or payload.get("audiobookshelf_timeout")
|
||||
or stored.get("timeout")
|
||||
or defaults["timeout"]
|
||||
)
|
||||
try:
|
||||
timeout = float(timeout_raw)
|
||||
except (TypeError, ValueError):
|
||||
timeout = defaults["timeout"]
|
||||
|
||||
enabled = coerce_bool(
|
||||
payload.get("enabled")
|
||||
or payload.get("audiobookshelf_enabled"),
|
||||
coerce_bool(stored.get("enabled"), False),
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"base_url": base_url,
|
||||
"library_id": library_id,
|
||||
"collection_id": collection_id,
|
||||
"folder_id": folder_id,
|
||||
"api_token": api_token,
|
||||
"verify_ssl": verify_ssl,
|
||||
"send_cover": send_cover,
|
||||
"send_chapters": send_chapters,
|
||||
"send_subtitles": send_subtitles,
|
||||
"auto_send": auto_send,
|
||||
"timeout": timeout,
|
||||
}
|
||||
|
||||
|
||||
def build_audiobookshelf_config(settings: Mapping[str, Any]) -> Optional[AudiobookshelfConfig]:
|
||||
base_url = str(settings.get("base_url") or "").strip()
|
||||
api_token = str(settings.get("api_token") or "").strip()
|
||||
library_id = str(settings.get("library_id") or "").strip()
|
||||
if not (base_url and api_token and library_id):
|
||||
return None
|
||||
try:
|
||||
timeout = float(settings.get("timeout", 3600.0))
|
||||
except (TypeError, ValueError):
|
||||
timeout = 3600.0
|
||||
return AudiobookshelfConfig(
|
||||
base_url=base_url,
|
||||
api_token=api_token,
|
||||
library_id=library_id,
|
||||
collection_id=(str(settings.get("collection_id") or "").strip() or None),
|
||||
folder_id=(str(settings.get("folder_id") or "").strip() or None),
|
||||
verify_ssl=coerce_bool(settings.get("verify_ssl"), True),
|
||||
send_cover=coerce_bool(settings.get("send_cover"), True),
|
||||
send_chapters=coerce_bool(settings.get("send_chapters"), True),
|
||||
send_subtitles=coerce_bool(settings.get("send_subtitles"), False),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def calibre_integration_enabled(
|
||||
integrations: Optional[Mapping[str, Any]] = None,
|
||||
) -> bool:
|
||||
if integrations is None:
|
||||
integrations = load_integration_settings()
|
||||
payload = integrations.get("calibre_opds") if isinstance(integrations, Mapping) else None
|
||||
if not isinstance(payload, Mapping):
|
||||
return False
|
||||
base_url = str(payload.get("base_url") or "").strip()
|
||||
enabled_flag = coerce_bool(payload.get("enabled"), False)
|
||||
return bool(enabled_flag and base_url)
|
||||
|
||||
|
||||
def audiobookshelf_manual_available() -> bool:
|
||||
settings = stored_integration_config("audiobookshelf")
|
||||
if not settings:
|
||||
return False
|
||||
return coerce_bool(settings.get("enabled"), False)
|
||||
|
||||
|
||||
def build_calibre_client(settings: Mapping[str, Any]) -> CalibreOPDSClient:
|
||||
base_url = str(settings.get("base_url") or "").strip()
|
||||
if not base_url:
|
||||
raise ValueError("Calibre OPDS base URL is required")
|
||||
username = str(settings.get("username") or "").strip() or None
|
||||
password = str(settings.get("password") or "").strip() or None
|
||||
verify_ssl = coerce_bool(settings.get("verify_ssl"), True)
|
||||
timeout_raw = settings.get("timeout", 15.0)
|
||||
try:
|
||||
timeout = float(timeout_raw)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 15.0
|
||||
return CalibreOPDSClient(
|
||||
base_url,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=timeout,
|
||||
verify=verify_ssl,
|
||||
)
|
||||
|
||||
|
||||
def apply_integration_form(cfg: Dict[str, Any], form: Mapping[str, Any]) -> None:
|
||||
defaults = integration_defaults()
|
||||
|
||||
current_calibre = dict(cfg.get("calibre_opds") or {})
|
||||
calibre_enabled = coerce_bool(form.get("calibre_opds_enabled"), False)
|
||||
calibre_base = str(form.get("calibre_opds_base_url") or current_calibre.get("base_url") or "").strip()
|
||||
calibre_username = str(form.get("calibre_opds_username") or current_calibre.get("username") or "").strip()
|
||||
calibre_password_input = str(form.get("calibre_opds_password") or "")
|
||||
calibre_clear = coerce_bool(form.get("calibre_opds_password_clear"), False)
|
||||
if calibre_password_input:
|
||||
calibre_password = calibre_password_input
|
||||
elif calibre_clear:
|
||||
calibre_password = ""
|
||||
else:
|
||||
calibre_password = str(current_calibre.get("password") or "")
|
||||
calibre_verify = coerce_bool(form.get("calibre_opds_verify_ssl"), defaults["calibre_opds"]["verify_ssl"])
|
||||
cfg["calibre_opds"] = {
|
||||
"enabled": calibre_enabled,
|
||||
"base_url": calibre_base,
|
||||
"username": calibre_username,
|
||||
"password": calibre_password,
|
||||
"verify_ssl": calibre_verify,
|
||||
}
|
||||
|
||||
current_abs = dict(cfg.get("audiobookshelf") or {})
|
||||
abs_enabled = coerce_bool(form.get("audiobookshelf_enabled"), False)
|
||||
abs_base = str(form.get("audiobookshelf_base_url") or current_abs.get("base_url") or "").strip()
|
||||
abs_library = str(form.get("audiobookshelf_library_id") or current_abs.get("library_id") or "").strip()
|
||||
abs_collection = str(form.get("audiobookshelf_collection_id") or current_abs.get("collection_id") or "").strip()
|
||||
abs_folder = str(form.get("audiobookshelf_folder_id") or current_abs.get("folder_id") or "").strip()
|
||||
abs_token_input = str(form.get("audiobookshelf_api_token") or "")
|
||||
abs_token_clear = coerce_bool(form.get("audiobookshelf_api_token_clear"), False)
|
||||
if abs_token_input:
|
||||
abs_token = abs_token_input
|
||||
elif abs_token_clear:
|
||||
abs_token = ""
|
||||
else:
|
||||
abs_token = str(current_abs.get("api_token") or "")
|
||||
abs_verify = coerce_bool(form.get("audiobookshelf_verify_ssl"), defaults["audiobookshelf"]["verify_ssl"])
|
||||
abs_send_cover = coerce_bool(form.get("audiobookshelf_send_cover"), defaults["audiobookshelf"]["send_cover"])
|
||||
abs_send_chapters = coerce_bool(form.get("audiobookshelf_send_chapters"), defaults["audiobookshelf"]["send_chapters"])
|
||||
abs_send_subtitles = coerce_bool(form.get("audiobookshelf_send_subtitles"), defaults["audiobookshelf"]["send_subtitles"])
|
||||
abs_auto_send = coerce_bool(form.get("audiobookshelf_auto_send"), defaults["audiobookshelf"]["auto_send"])
|
||||
timeout_raw = form.get("audiobookshelf_timeout", current_abs.get("timeout", defaults["audiobookshelf"]["timeout"]))
|
||||
try:
|
||||
abs_timeout = float(timeout_raw)
|
||||
except (TypeError, ValueError):
|
||||
abs_timeout = defaults["audiobookshelf"]["timeout"]
|
||||
cfg["audiobookshelf"] = {
|
||||
"enabled": abs_enabled,
|
||||
"base_url": abs_base,
|
||||
"api_token": abs_token,
|
||||
"library_id": abs_library,
|
||||
"collection_id": abs_collection,
|
||||
"folder_id": abs_folder,
|
||||
"verify_ssl": abs_verify,
|
||||
"send_cover": abs_send_cover,
|
||||
"send_chapters": abs_send_chapters,
|
||||
"send_subtitles": abs_send_subtitles,
|
||||
"auto_send": abs_auto_send,
|
||||
"timeout": abs_timeout,
|
||||
}
|
||||
|
||||
|
||||
def save_settings(settings: Dict[str, Any]) -> None:
|
||||
save_config(settings)
|
||||
@@ -0,0 +1,809 @@
|
||||
import threading
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
|
||||
import numpy as np
|
||||
|
||||
from abogen.speaker_configs import slugify_label
|
||||
from abogen.speaker_analysis import analyze_speakers
|
||||
from abogen.webui.routes.utils.settings import load_settings, settings_defaults, _DEFAULT_ANALYSIS_THRESHOLD, _CHUNK_LEVEL_OPTIONS, _APOSTROPHE_MODE_OPTIONS, _NORMALIZATION_GROUPS
|
||||
from abogen.webui.routes.utils.common import split_profile_spec
|
||||
from abogen.voice_profiles import (
|
||||
load_profiles,
|
||||
serialize_profiles,
|
||||
)
|
||||
from abogen.voice_formulas import get_new_voice, parse_formula_terms
|
||||
from abogen.constants import (
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
SUBTITLE_FORMATS,
|
||||
SUPPORTED_SOUND_FORMATS,
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
SAMPLE_VOICE_TEXTS,
|
||||
VOICES_INTERNAL,
|
||||
)
|
||||
from abogen.speaker_configs import list_configs
|
||||
from abogen.utils import load_numpy_kpipeline
|
||||
from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
|
||||
|
||||
_preview_pipeline_lock = threading.RLock()
|
||||
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||
|
||||
def build_narrator_roster(
|
||||
voice: str,
|
||||
voice_profile: Optional[str],
|
||||
existing: Optional[Mapping[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
roster: Dict[str, Any] = {
|
||||
"narrator": {
|
||||
"id": "narrator",
|
||||
"label": "Narrator",
|
||||
"voice": voice,
|
||||
}
|
||||
}
|
||||
if voice_profile:
|
||||
roster["narrator"]["voice_profile"] = voice_profile
|
||||
existing_entry: Optional[Mapping[str, Any]] = None
|
||||
if existing is not None:
|
||||
existing_entry = existing.get("narrator") if isinstance(existing, Mapping) else None
|
||||
if isinstance(existing_entry, Mapping):
|
||||
roster_entry = roster["narrator"]
|
||||
for key in ("label", "voice", "voice_profile", "voice_formula", "pronunciation"):
|
||||
value = existing_entry.get(key)
|
||||
if value is not None and value != "":
|
||||
roster_entry[key] = value
|
||||
return roster
|
||||
|
||||
|
||||
def build_speaker_roster(
|
||||
analysis: Dict[str, Any],
|
||||
base_voice: str,
|
||||
voice_profile: Optional[str],
|
||||
existing: Optional[Mapping[str, Any]] = None,
|
||||
order: Optional[Iterable[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
roster = build_narrator_roster(base_voice, voice_profile, existing)
|
||||
existing_map: Dict[str, Any] = dict(existing) if isinstance(existing, Mapping) else {}
|
||||
speakers = analysis.get("speakers", {}) if isinstance(analysis, dict) else {}
|
||||
ordered_ids: Iterable[str]
|
||||
if order is not None:
|
||||
ordered_ids = [sid for sid in order if sid in speakers]
|
||||
else:
|
||||
ordered_ids = speakers.keys()
|
||||
|
||||
for speaker_id in ordered_ids:
|
||||
payload = speakers.get(speaker_id, {})
|
||||
if speaker_id == "narrator":
|
||||
continue
|
||||
if isinstance(payload, Mapping) and payload.get("suppressed"):
|
||||
continue
|
||||
previous = existing_map.get(speaker_id)
|
||||
roster[speaker_id] = {
|
||||
"id": speaker_id,
|
||||
"label": payload.get("label") or speaker_id.replace("_", " ").title(),
|
||||
"analysis_confidence": payload.get("confidence"),
|
||||
"analysis_count": payload.get("count"),
|
||||
"gender": payload.get("gender", "unknown"),
|
||||
}
|
||||
detected_gender = payload.get("detected_gender")
|
||||
if detected_gender:
|
||||
roster[speaker_id]["detected_gender"] = detected_gender
|
||||
samples = payload.get("sample_quotes")
|
||||
if isinstance(samples, list):
|
||||
roster[speaker_id]["sample_quotes"] = samples
|
||||
if isinstance(previous, Mapping):
|
||||
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"):
|
||||
value = previous.get(key)
|
||||
if value is not None and value != "":
|
||||
roster[speaker_id][key] = value
|
||||
if "sample_quotes" not in roster[speaker_id]:
|
||||
prev_samples = previous.get("sample_quotes")
|
||||
if isinstance(prev_samples, list):
|
||||
roster[speaker_id]["sample_quotes"] = prev_samples
|
||||
if "detected_gender" not in roster[speaker_id]:
|
||||
prev_detected = previous.get("detected_gender")
|
||||
if isinstance(prev_detected, str) and prev_detected:
|
||||
roster[speaker_id]["detected_gender"] = prev_detected
|
||||
return roster
|
||||
|
||||
|
||||
def match_configured_speaker(
|
||||
config_speakers: Mapping[str, Any],
|
||||
roster_id: str,
|
||||
roster_label: str,
|
||||
) -> Optional[Mapping[str, Any]]:
|
||||
if not config_speakers:
|
||||
return None
|
||||
entry = config_speakers.get(roster_id)
|
||||
if entry:
|
||||
return cast(Mapping[str, Any], entry)
|
||||
slug = slugify_label(roster_label)
|
||||
if slug != roster_id and slug in config_speakers:
|
||||
return cast(Mapping[str, Any], config_speakers[slug])
|
||||
lower_label = roster_label.strip().lower()
|
||||
for record in config_speakers.values():
|
||||
if not isinstance(record, Mapping):
|
||||
continue
|
||||
if str(record.get("label", "")).strip().lower() == lower_label:
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def apply_speaker_config_to_roster(
|
||||
roster: Mapping[str, Any],
|
||||
config: Optional[Mapping[str, Any]],
|
||||
*,
|
||||
persist_changes: bool = False,
|
||||
fallback_languages: Optional[Iterable[str]] = None,
|
||||
) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
||||
if not isinstance(roster, Mapping):
|
||||
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
||||
return {}, effective_languages, None
|
||||
updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)}
|
||||
if not config:
|
||||
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
||||
return updated_roster, effective_languages, None
|
||||
|
||||
speakers_map = config.get("speakers")
|
||||
if not isinstance(speakers_map, Mapping):
|
||||
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
||||
return updated_roster, effective_languages, None
|
||||
|
||||
config_languages = config.get("languages")
|
||||
if isinstance(config_languages, list):
|
||||
allowed_languages = [code for code in config_languages if isinstance(code, str) and code]
|
||||
else:
|
||||
allowed_languages = []
|
||||
if not allowed_languages and fallback_languages:
|
||||
allowed_languages = [code for code in fallback_languages if isinstance(code, str) and code]
|
||||
|
||||
default_voice = config.get("default_voice") if isinstance(config.get("default_voice"), str) else ""
|
||||
used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None}
|
||||
narrator_voice = ""
|
||||
narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None
|
||||
if isinstance(narrator_entry, Mapping):
|
||||
narrator_voice = str(
|
||||
narrator_entry.get("resolved_voice")
|
||||
or narrator_entry.get("default_voice")
|
||||
or ""
|
||||
).strip()
|
||||
if narrator_voice:
|
||||
used_voices.add(narrator_voice)
|
||||
|
||||
config_changed = False
|
||||
new_config_payload: Dict[str, Any] = {
|
||||
"language": config.get("language", "a"),
|
||||
"languages": allowed_languages,
|
||||
"default_voice": default_voice,
|
||||
"speakers": dict(speakers_map),
|
||||
"version": config.get("version", 1),
|
||||
"notes": config.get("notes", ""),
|
||||
}
|
||||
|
||||
speakers_payload = new_config_payload["speakers"]
|
||||
|
||||
for speaker_id, roster_entry in updated_roster.items():
|
||||
if speaker_id == "narrator":
|
||||
continue
|
||||
label = str(roster_entry.get("label") or speaker_id)
|
||||
config_entry = match_configured_speaker(speakers_map, speaker_id, label)
|
||||
if config_entry is None:
|
||||
continue
|
||||
voice_id = str(config_entry.get("voice") or "").strip()
|
||||
voice_profile = str(config_entry.get("voice_profile") or "").strip()
|
||||
voice_formula = str(config_entry.get("voice_formula") or "").strip()
|
||||
resolved_voice = str(config_entry.get("resolved_voice") or "").strip()
|
||||
languages = config_entry.get("languages") if isinstance(config_entry.get("languages"), list) else []
|
||||
chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
|
||||
usable_languages = languages or allowed_languages
|
||||
|
||||
if chosen_voice:
|
||||
roster_entry["resolved_voice"] = chosen_voice
|
||||
roster_entry["voice"] = chosen_voice if not voice_profile and not voice_formula else roster_entry.get("voice", chosen_voice)
|
||||
if voice_profile:
|
||||
roster_entry["voice_profile"] = voice_profile
|
||||
if voice_formula:
|
||||
roster_entry["voice_formula"] = voice_formula
|
||||
roster_entry["resolved_voice"] = voice_formula
|
||||
if not voice_formula and not voice_profile and resolved_voice:
|
||||
roster_entry["resolved_voice"] = resolved_voice
|
||||
roster_entry["config_languages"] = usable_languages or []
|
||||
|
||||
if chosen_voice:
|
||||
used_voices.add(chosen_voice)
|
||||
|
||||
# persist updates back to config payload if required
|
||||
if persist_changes:
|
||||
slug = config_entry.get("id") or slugify_label(label)
|
||||
speakers_payload[slug] = {
|
||||
"id": slug,
|
||||
"label": label,
|
||||
"gender": config_entry.get("gender", "unknown"),
|
||||
"voice": voice_id,
|
||||
"voice_profile": voice_profile,
|
||||
"voice_formula": voice_formula,
|
||||
"resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id),
|
||||
"languages": usable_languages,
|
||||
}
|
||||
|
||||
new_config = new_config_payload if (persist_changes and config_changed) else None
|
||||
return updated_roster, allowed_languages, new_config
|
||||
|
||||
|
||||
def filter_voice_catalog(
|
||||
catalog: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
gender: str,
|
||||
allowed_languages: Optional[Iterable[str]] = None,
|
||||
) -> List[str]:
|
||||
allowed_set = {code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code}
|
||||
gender_normalized = (gender or "unknown").lower()
|
||||
gender_code = ""
|
||||
if gender_normalized == "male":
|
||||
gender_code = "m"
|
||||
elif gender_normalized == "female":
|
||||
gender_code = "f"
|
||||
|
||||
matches: List[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _consider(entry: Mapping[str, Any]) -> None:
|
||||
voice_id = entry.get("id")
|
||||
if not isinstance(voice_id, str) or not voice_id:
|
||||
return
|
||||
if voice_id in seen:
|
||||
return
|
||||
seen.add(voice_id)
|
||||
matches.append(voice_id)
|
||||
|
||||
primary: List[Mapping[str, Any]] = []
|
||||
fallback: List[Mapping[str, Any]] = []
|
||||
for entry in catalog:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
voice_lang = str(entry.get("language", "")).lower()
|
||||
voice_gender_code = str(entry.get("gender_code", "")).lower()
|
||||
if allowed_set and voice_lang not in allowed_set:
|
||||
continue
|
||||
if gender_code and voice_gender_code != gender_code:
|
||||
fallback.append(entry)
|
||||
continue
|
||||
primary.append(entry)
|
||||
|
||||
for entry in primary:
|
||||
_consider(entry)
|
||||
|
||||
if not matches:
|
||||
for entry in fallback:
|
||||
_consider(entry)
|
||||
|
||||
if not matches:
|
||||
for entry in catalog:
|
||||
if isinstance(entry, Mapping):
|
||||
_consider(entry)
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def build_voice_catalog() -> List[Dict[str, str]]:
|
||||
catalog: List[Dict[str, str]] = []
|
||||
gender_map = {"f": "Female", "m": "Male"}
|
||||
for voice_id in VOICES_INTERNAL:
|
||||
prefix, _, rest = voice_id.partition("_")
|
||||
language_code = prefix[0] if prefix else "a"
|
||||
gender_code = prefix[1] if len(prefix) > 1 else ""
|
||||
catalog.append(
|
||||
{
|
||||
"id": voice_id,
|
||||
"language": language_code,
|
||||
"language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()),
|
||||
"gender": gender_map.get(gender_code, "Unknown"),
|
||||
"gender_code": gender_code,
|
||||
"display_name": rest.replace("_", " ").title() if rest else voice_id,
|
||||
}
|
||||
)
|
||||
return catalog
|
||||
|
||||
|
||||
def inject_recommended_voices(
|
||||
roster: Mapping[str, Any],
|
||||
*,
|
||||
fallback_languages: Optional[Iterable[str]] = None,
|
||||
) -> None:
|
||||
voice_catalog = build_voice_catalog()
|
||||
fallback_list = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
||||
for speaker_id, payload in roster.items():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
languages = payload.get("config_languages")
|
||||
if isinstance(languages, list) and languages:
|
||||
language_list = languages
|
||||
else:
|
||||
language_list = fallback_list
|
||||
gender = str(payload.get("gender", "unknown"))
|
||||
payload["recommended_voices"] = filter_voice_catalog(
|
||||
voice_catalog,
|
||||
gender=gender,
|
||||
allowed_languages=language_list,
|
||||
)
|
||||
|
||||
|
||||
def extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str, Any], List[str]]:
|
||||
getter = getattr(form, "getlist", None)
|
||||
|
||||
def _get_list(name: str) -> List[str]:
|
||||
if callable(getter):
|
||||
values = cast(Iterable[Any], getter(name))
|
||||
return [str(value).strip() for value in values if value]
|
||||
raw_value = form.get(name)
|
||||
if isinstance(raw_value, str):
|
||||
return [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||
return []
|
||||
|
||||
name = (form.get("config_name") or "").strip()
|
||||
language = str(form.get("config_language") or "a").strip() or "a"
|
||||
allowed_languages = []
|
||||
default_voice = (form.get("config_default_voice") or "").strip()
|
||||
notes = (form.get("config_notes") or "").strip()
|
||||
|
||||
try:
|
||||
parsed = int(form.get("config_version") or 1)
|
||||
version = max(1, min(parsed, 9999))
|
||||
except (TypeError, ValueError):
|
||||
version = 1
|
||||
|
||||
speaker_rows = _get_list("speaker_rows")
|
||||
speakers: Dict[str, Dict[str, Any]] = {}
|
||||
for row_key in speaker_rows:
|
||||
prefix = f"speaker-{row_key}-"
|
||||
label = (form.get(prefix + "label") or "").strip()
|
||||
if not label:
|
||||
continue
|
||||
raw_gender = (form.get(prefix + "gender") or "unknown").strip().lower()
|
||||
gender = raw_gender if raw_gender in {"male", "female", "unknown"} else "unknown"
|
||||
voice = (form.get(prefix + "voice") or "").strip()
|
||||
voice_profile = (form.get(prefix + "profile") or "").strip()
|
||||
voice_formula = (form.get(prefix + "formula") or "").strip()
|
||||
speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label)
|
||||
speakers[speaker_id] = {
|
||||
"id": speaker_id,
|
||||
"label": label,
|
||||
"gender": gender,
|
||||
"voice": voice,
|
||||
"voice_profile": voice_profile,
|
||||
"voice_formula": voice_formula,
|
||||
"resolved_voice": voice_formula or voice,
|
||||
"languages": [],
|
||||
}
|
||||
|
||||
payload = {
|
||||
"language": language,
|
||||
"languages": allowed_languages,
|
||||
"default_voice": default_voice,
|
||||
"speakers": speakers,
|
||||
"notes": notes,
|
||||
"version": version,
|
||||
}
|
||||
|
||||
errors: List[str] = []
|
||||
if not name:
|
||||
errors.append("Configuration name is required.")
|
||||
if not speakers:
|
||||
errors.append("Add at least one speaker to the configuration.")
|
||||
|
||||
return name, payload, errors
|
||||
|
||||
|
||||
def prepare_speaker_metadata(
|
||||
*,
|
||||
chapters: List[Dict[str, Any]],
|
||||
chunks: List[Dict[str, Any]],
|
||||
analysis_chunks: Optional[List[Dict[str, Any]]] = None,
|
||||
voice: str,
|
||||
voice_profile: Optional[str],
|
||||
threshold: int,
|
||||
existing_roster: Optional[Mapping[str, Any]] = None,
|
||||
run_analysis: bool = True,
|
||||
speaker_config: Optional[Mapping[str, Any]] = None,
|
||||
apply_config: bool = False,
|
||||
persist_config: bool = False,
|
||||
) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
||||
chunk_list = [dict(chunk) for chunk in chunks]
|
||||
analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)]
|
||||
threshold_value = max(1, int(threshold))
|
||||
analysis_enabled = run_analysis
|
||||
settings_state = load_settings()
|
||||
global_random_languages = [
|
||||
code
|
||||
for code in settings_state.get("speaker_random_languages", [])
|
||||
if isinstance(code, str) and code
|
||||
]
|
||||
|
||||
if not analysis_enabled:
|
||||
for chunk in chunk_list:
|
||||
chunk["speaker_id"] = "narrator"
|
||||
chunk["speaker_label"] = "Narrator"
|
||||
analysis_payload = {
|
||||
"version": "1.0",
|
||||
"narrator": "narrator",
|
||||
"assignments": {str(chunk.get("id")): "narrator" for chunk in chunk_list},
|
||||
"speakers": {
|
||||
"narrator": {
|
||||
"id": "narrator",
|
||||
"label": "Narrator",
|
||||
"count": len(chunk_list),
|
||||
"confidence": "low",
|
||||
"sample_quotes": [],
|
||||
"suppressed": False,
|
||||
}
|
||||
},
|
||||
"suppressed": [],
|
||||
"stats": {
|
||||
"total_chunks": len(chunk_list),
|
||||
"explicit_chunks": 0,
|
||||
"active_speakers": 0,
|
||||
"unique_speakers": 1,
|
||||
"suppressed": 0,
|
||||
},
|
||||
}
|
||||
roster = build_narrator_roster(voice, voice_profile, existing_roster)
|
||||
narrator_pron = roster["narrator"].get("pronunciation")
|
||||
if narrator_pron:
|
||||
analysis_payload["speakers"]["narrator"]["pronunciation"] = narrator_pron
|
||||
return chunk_list, roster, analysis_payload, [], None
|
||||
|
||||
analysis_result = analyze_speakers(
|
||||
chapters,
|
||||
analysis_source,
|
||||
threshold=threshold_value,
|
||||
max_speakers=0,
|
||||
)
|
||||
analysis_payload = analysis_result.to_dict()
|
||||
speakers_payload = analysis_payload.get("speakers", {})
|
||||
ordered_ids = [
|
||||
sid
|
||||
for sid, meta in sorted(
|
||||
(
|
||||
(sid, meta)
|
||||
for sid, meta in speakers_payload.items()
|
||||
if sid != "narrator" and isinstance(meta, Mapping) and not meta.get("suppressed")
|
||||
),
|
||||
key=lambda item: item[1].get("count", 0),
|
||||
reverse=True,
|
||||
)
|
||||
]
|
||||
analysis_payload["ordered_speakers"] = ordered_ids
|
||||
assignments = analysis_payload.get("assignments", {})
|
||||
suppressed_ids = analysis_payload.get("suppressed", [])
|
||||
suppressed_details: List[Dict[str, Any]] = []
|
||||
speakers_payload = analysis_payload.get("speakers", {})
|
||||
if isinstance(suppressed_ids, Iterable):
|
||||
for suppressed_id in suppressed_ids:
|
||||
speaker_meta = speakers_payload.get(suppressed_id) if isinstance(speakers_payload, dict) else None
|
||||
if isinstance(speaker_meta, dict):
|
||||
suppressed_details.append(
|
||||
{
|
||||
"id": suppressed_id,
|
||||
"label": speaker_meta.get("label")
|
||||
or str(suppressed_id).replace("_", " ").title(),
|
||||
"pronunciation": speaker_meta.get("pronunciation"),
|
||||
}
|
||||
)
|
||||
else:
|
||||
suppressed_details.append(
|
||||
{
|
||||
"id": suppressed_id,
|
||||
"label": str(suppressed_id).replace("_", " ").title(),
|
||||
"pronunciation": None,
|
||||
}
|
||||
)
|
||||
analysis_payload["suppressed_details"] = suppressed_details
|
||||
roster = build_speaker_roster(
|
||||
analysis_payload,
|
||||
voice,
|
||||
voice_profile,
|
||||
existing=existing_roster,
|
||||
order=analysis_payload.get("ordered_speakers"),
|
||||
)
|
||||
applied_languages: List[str] = []
|
||||
updated_config: Optional[Dict[str, Any]] = None
|
||||
if apply_config and speaker_config:
|
||||
roster, applied_languages, updated_config = apply_speaker_config_to_roster(
|
||||
roster,
|
||||
speaker_config,
|
||||
persist_changes=persist_config,
|
||||
fallback_languages=global_random_languages,
|
||||
)
|
||||
speakers_payload = analysis_payload.get("speakers")
|
||||
if isinstance(speakers_payload, dict):
|
||||
for roster_id, roster_payload in roster.items():
|
||||
speaker_meta = speakers_payload.get(roster_id)
|
||||
if isinstance(speaker_meta, dict):
|
||||
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice"):
|
||||
value = roster_payload.get(key)
|
||||
if value:
|
||||
speaker_meta[key] = value
|
||||
effective_languages: List[str] = []
|
||||
if applied_languages:
|
||||
effective_languages = applied_languages
|
||||
elif isinstance(analysis_payload.get("config_languages"), list):
|
||||
effective_languages = [
|
||||
code for code in analysis_payload.get("config_languages", []) if isinstance(code, str) and code
|
||||
]
|
||||
elif global_random_languages:
|
||||
effective_languages = list(global_random_languages)
|
||||
|
||||
if effective_languages:
|
||||
analysis_payload["config_languages"] = effective_languages
|
||||
speakers_payload = analysis_payload.get("speakers")
|
||||
if isinstance(speakers_payload, dict):
|
||||
for roster_id, roster_payload in roster.items():
|
||||
if roster_id in speakers_payload and isinstance(roster_payload, dict):
|
||||
pronunciation_value = roster_payload.get("pronunciation")
|
||||
if pronunciation_value:
|
||||
speakers_payload[roster_id]["pronunciation"] = pronunciation_value
|
||||
|
||||
fallback_languages = effective_languages or []
|
||||
inject_recommended_voices(roster, fallback_languages=fallback_languages)
|
||||
|
||||
for chunk in chunk_list:
|
||||
chunk_id = str(chunk.get("id"))
|
||||
speaker_id = assignments.get(chunk_id, "narrator")
|
||||
chunk["speaker_id"] = speaker_id
|
||||
speaker_meta = roster.get(speaker_id)
|
||||
chunk["speaker_label"] = speaker_meta.get("label") if isinstance(speaker_meta, dict) else speaker_id
|
||||
|
||||
return chunk_list, roster, analysis_payload, applied_languages, updated_config
|
||||
|
||||
|
||||
def formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
|
||||
voices = entry.get("voices") or []
|
||||
if not voices:
|
||||
return None
|
||||
total = sum(weight for _, weight in voices)
|
||||
if total <= 0:
|
||||
return None
|
||||
|
||||
def _format_weight(value: float) -> str:
|
||||
normalized = value / total if total else 0.0
|
||||
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
|
||||
|
||||
parts = [f"{name}*{_format_weight(weight)}" for name, weight in voices if weight > 0]
|
||||
return "+".join(parts) if parts else None
|
||||
|
||||
|
||||
def template_options() -> Dict[str, Any]:
|
||||
current_settings = load_settings()
|
||||
profiles = serialize_profiles()
|
||||
ordered_profiles = sorted(profiles.items())
|
||||
profile_options = []
|
||||
for name, entry in ordered_profiles:
|
||||
provider = str((entry or {}).get("provider") or "kokoro").strip().lower()
|
||||
profile_options.append(
|
||||
{
|
||||
"name": name,
|
||||
"language": (entry or {}).get("language", ""),
|
||||
"provider": provider,
|
||||
"formula": formula_from_profile(entry or {}) or "",
|
||||
"voice": (entry or {}).get("voice", ""),
|
||||
"total_steps": (entry or {}).get("total_steps"),
|
||||
"speed": (entry or {}).get("speed"),
|
||||
}
|
||||
)
|
||||
voice_catalog = build_voice_catalog()
|
||||
return {
|
||||
"languages": LANGUAGE_DESCRIPTIONS,
|
||||
"voices": VOICES_INTERNAL,
|
||||
"subtitle_formats": SUBTITLE_FORMATS,
|
||||
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
"output_formats": SUPPORTED_SOUND_FORMATS,
|
||||
"voice_profiles": ordered_profiles,
|
||||
"voice_profile_options": profile_options,
|
||||
"separate_formats": ["wav", "flac", "mp3", "opus"],
|
||||
"voice_catalog": voice_catalog,
|
||||
"voice_catalog_map": {entry["id"]: entry for entry in voice_catalog},
|
||||
"sample_voice_texts": SAMPLE_VOICE_TEXTS,
|
||||
"voice_profiles_data": profiles,
|
||||
"speaker_configs": list_configs(),
|
||||
"chunk_levels": _CHUNK_LEVEL_OPTIONS,
|
||||
"speaker_analysis_threshold": current_settings.get(
|
||||
"speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD
|
||||
),
|
||||
"speaker_pronunciation_sentence": current_settings.get(
|
||||
"speaker_pronunciation_sentence", settings_defaults()["speaker_pronunciation_sentence"]
|
||||
),
|
||||
"apostrophe_modes": _APOSTROPHE_MODE_OPTIONS,
|
||||
"normalization_groups": _NORMALIZATION_GROUPS,
|
||||
}
|
||||
|
||||
|
||||
def resolve_profile_voice(
|
||||
profile_name: Optional[str],
|
||||
*,
|
||||
profiles: Optional[Mapping[str, Any]] = None,
|
||||
) -> tuple[str, Optional[str]]:
|
||||
if not profile_name:
|
||||
return "", None
|
||||
source = profiles if isinstance(profiles, Mapping) else None
|
||||
if source is None:
|
||||
source = load_profiles()
|
||||
entry = source.get(profile_name) if isinstance(source, Mapping) else None
|
||||
if not isinstance(entry, Mapping):
|
||||
return "", None
|
||||
formula = formula_from_profile(dict(entry)) or ""
|
||||
language = entry.get("language") if isinstance(entry.get("language"), str) else None
|
||||
if isinstance(language, str):
|
||||
language = language.strip().lower() or None
|
||||
return formula, language
|
||||
|
||||
|
||||
def resolve_voice_setting(
|
||||
value: Any,
|
||||
*,
|
||||
profiles: Optional[Mapping[str, Any]] = None,
|
||||
) -> tuple[str, Optional[str], Optional[str]]:
|
||||
base_spec, profile_name = split_profile_spec(value)
|
||||
if profile_name:
|
||||
formula, language = resolve_profile_voice(profile_name, profiles=profiles)
|
||||
return formula or "", profile_name, language
|
||||
return base_spec, None, None
|
||||
|
||||
|
||||
def resolve_voice_choice(
|
||||
language: str,
|
||||
base_voice: str,
|
||||
profile_name: str,
|
||||
custom_formula: str,
|
||||
profiles: Dict[str, Any],
|
||||
) -> tuple[str, str, Optional[str]]:
|
||||
resolved_voice = base_voice
|
||||
resolved_language = language
|
||||
selected_profile = None
|
||||
|
||||
if profile_name:
|
||||
from abogen.voice_profiles import normalize_profile_entry
|
||||
|
||||
entry_raw = profiles.get(profile_name)
|
||||
entry = normalize_profile_entry(entry_raw)
|
||||
provider = str((entry or {}).get("provider") or "").strip().lower()
|
||||
|
||||
# Provider-aware behavior:
|
||||
# - Kokoro profiles typically represent mixes (formula strings).
|
||||
# - SuperTonic profiles represent a discrete voice id + settings.
|
||||
# In that case, we return a speaker reference so downstream can
|
||||
# resolve provider per-speaker and allow mixed-provider casting.
|
||||
if provider == "supertonic":
|
||||
resolved_voice = f"speaker:{profile_name}"
|
||||
selected_profile = profile_name
|
||||
profile_language = (entry or {}).get("language")
|
||||
if profile_language:
|
||||
resolved_language = str(profile_language)
|
||||
else:
|
||||
formula = formula_from_profile(entry or {}) if entry else None
|
||||
if formula:
|
||||
resolved_voice = formula
|
||||
selected_profile = profile_name
|
||||
profile_language = (entry or {}).get("language")
|
||||
if profile_language:
|
||||
resolved_language = profile_language
|
||||
|
||||
if custom_formula:
|
||||
resolved_voice = custom_formula
|
||||
selected_profile = None
|
||||
|
||||
return resolved_voice, resolved_language, selected_profile
|
||||
|
||||
|
||||
def parse_voice_formula(formula: str) -> List[tuple[str, float]]:
|
||||
voices = parse_formula_terms(formula)
|
||||
total = sum(weight for _, weight in voices)
|
||||
if total <= 0:
|
||||
raise ValueError("Voice weights must sum to a positive value")
|
||||
return voices
|
||||
|
||||
|
||||
def sanitize_voice_entries(entries: Iterable[Any]) -> List[Dict[str, Any]]:
|
||||
sanitized: List[Dict[str, Any]] = []
|
||||
for entry in entries or []:
|
||||
if isinstance(entry, dict):
|
||||
voice_id = entry.get("id") or entry.get("voice")
|
||||
if not voice_id:
|
||||
continue
|
||||
enabled = entry.get("enabled", True)
|
||||
if not enabled:
|
||||
continue
|
||||
sanitized.append({"voice": voice_id, "weight": entry.get("weight")})
|
||||
elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
|
||||
sanitized.append({"voice": entry[0], "weight": entry[1]})
|
||||
return sanitized
|
||||
|
||||
|
||||
def pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]:
|
||||
voices = [(voice, float(weight)) for voice, weight in pairs if float(weight) > 0]
|
||||
if not voices:
|
||||
return None
|
||||
total = sum(weight for _, weight in voices)
|
||||
if total <= 0:
|
||||
return None
|
||||
|
||||
def _format_value(value: float) -> str:
|
||||
normalized = value / total if total else 0.0
|
||||
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
|
||||
|
||||
parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices]
|
||||
return "+".join(parts)
|
||||
|
||||
|
||||
def profiles_payload() -> Dict[str, Any]:
|
||||
return {"profiles": serialize_profiles()}
|
||||
|
||||
|
||||
def get_preview_pipeline(language: str, device: str):
|
||||
key = (language, device)
|
||||
with _preview_pipeline_lock:
|
||||
pipeline = _preview_pipelines.get(key)
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
_, KPipeline = load_numpy_kpipeline()
|
||||
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||
_preview_pipelines[key] = pipeline
|
||||
return pipeline
|
||||
|
||||
|
||||
def synthesize_audio_from_normalized(
|
||||
*,
|
||||
normalized_text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
max_seconds: float,
|
||||
) -> np.ndarray:
|
||||
if not normalized_text.strip():
|
||||
raise ValueError("Preview text is required")
|
||||
|
||||
device = "cpu"
|
||||
if use_gpu:
|
||||
try:
|
||||
device = _select_device()
|
||||
except Exception:
|
||||
device = "cpu"
|
||||
use_gpu = False
|
||||
|
||||
pipeline = get_preview_pipeline(language, device)
|
||||
if pipeline is None:
|
||||
raise RuntimeError("Preview pipeline is unavailable")
|
||||
|
||||
voice_choice: Any = voice_spec
|
||||
if voice_spec and "*" in voice_spec:
|
||||
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
|
||||
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
voice=voice_choice,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
)
|
||||
|
||||
audio_chunks: List[np.ndarray] = []
|
||||
accumulated = 0
|
||||
max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
|
||||
|
||||
for segment in segments:
|
||||
graphemes = getattr(segment, "graphemes", "").strip()
|
||||
if not graphemes:
|
||||
continue
|
||||
audio = _to_float32(getattr(segment, "audio", None))
|
||||
if audio.size == 0:
|
||||
continue
|
||||
remaining = max_samples - accumulated
|
||||
if remaining <= 0:
|
||||
break
|
||||
if audio.shape[0] > remaining:
|
||||
audio = audio[:remaining]
|
||||
audio_chunks.append(audio)
|
||||
accumulated += audio.shape[0]
|
||||
if accumulated >= max_samples:
|
||||
break
|
||||
|
||||
if not audio_chunks:
|
||||
raise RuntimeError("Preview could not be generated")
|
||||
|
||||
return np.concatenate(audio_chunks)
|
||||
@@ -0,0 +1,140 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
from flask import Blueprint, render_template, request, jsonify, abort, flash, redirect, url_for
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.webui.routes.utils.voice import (
|
||||
template_options,
|
||||
resolve_voice_setting,
|
||||
resolve_voice_choice,
|
||||
parse_voice_formula,
|
||||
)
|
||||
from abogen.webui.routes.utils.settings import load_settings, coerce_bool
|
||||
from abogen.webui.routes.utils.preview import synthesize_preview
|
||||
from abogen.speaker_configs import (
|
||||
list_configs,
|
||||
get_config,
|
||||
load_configs,
|
||||
save_configs,
|
||||
delete_config,
|
||||
)
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
|
||||
voices_bp = Blueprint("voices", __name__)
|
||||
|
||||
@voices_bp.get("/")
|
||||
def voice_profiles() -> ResponseReturnValue:
|
||||
return render_template("voices.html", options=template_options())
|
||||
|
||||
@voices_bp.post("/test")
|
||||
def test_voice() -> ResponseReturnValue:
|
||||
text = (request.form.get("text") or "").strip()
|
||||
voice = (request.form.get("voice") or "").strip()
|
||||
speed = float(request.form.get("speed", 1.0))
|
||||
|
||||
# This seems to be the form-based preview
|
||||
settings = load_settings()
|
||||
use_gpu = coerce_bool(settings.get("use_gpu"), True)
|
||||
|
||||
try:
|
||||
return synthesize_preview(
|
||||
text=text,
|
||||
voice_spec=voice,
|
||||
language="a", # Default language
|
||||
speed=speed,
|
||||
use_gpu=use_gpu,
|
||||
)
|
||||
except Exception as e:
|
||||
abort(400, str(e))
|
||||
|
||||
@voices_bp.get("/configs")
|
||||
def speaker_configs() -> ResponseReturnValue:
|
||||
return jsonify({"configs": list_configs()})
|
||||
|
||||
@voices_bp.post("/configs/save")
|
||||
def save_speaker_config() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True)
|
||||
name = (payload.get("name") or "").strip()
|
||||
config = payload.get("config")
|
||||
|
||||
if not name:
|
||||
abort(400, "Config name is required")
|
||||
if not config:
|
||||
abort(400, "Config data is required")
|
||||
|
||||
configs = load_configs()
|
||||
configs[name] = config
|
||||
save_configs(configs)
|
||||
return jsonify({"status": "saved", "configs": list_configs()})
|
||||
|
||||
@voices_bp.post("/configs/delete")
|
||||
def delete_speaker_config() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True)
|
||||
name = (payload.get("name") or "").strip()
|
||||
|
||||
if not name:
|
||||
abort(400, "Config name is required")
|
||||
|
||||
delete_config(name)
|
||||
return jsonify({"status": "deleted", "configs": list_configs()})
|
||||
|
||||
@voices_bp.route("/presets", methods=["GET", "POST"])
|
||||
def speaker_configs_page() -> ResponseReturnValue:
|
||||
configs = load_configs()
|
||||
editing_name = request.args.get("config")
|
||||
message = None
|
||||
error = None
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
name = request.form.get("config_name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("Preset name is required")
|
||||
|
||||
language = request.form.get("config_language", "en")
|
||||
|
||||
speakers = []
|
||||
row_keys = request.form.getlist("speaker_rows")
|
||||
for key in row_keys:
|
||||
s_id = request.form.get(f"speaker-{key}-id", key)
|
||||
label = request.form.get(f"speaker-{key}-label", "")
|
||||
gender = request.form.get(f"speaker-{key}-gender", "unknown")
|
||||
voice = request.form.get(f"speaker-{key}-voice", "")
|
||||
|
||||
if label:
|
||||
speakers.append({
|
||||
"id": s_id,
|
||||
"label": label,
|
||||
"gender": gender,
|
||||
"voice": voice or None
|
||||
})
|
||||
|
||||
config = {
|
||||
"name": name,
|
||||
"language": language,
|
||||
"speakers": speakers,
|
||||
"version": 1
|
||||
}
|
||||
|
||||
configs[name] = config
|
||||
save_configs(configs)
|
||||
message = f"Preset '{name}' saved."
|
||||
editing_name = name
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
|
||||
editing = configs.get(editing_name, {}) if editing_name else {}
|
||||
|
||||
return render_template(
|
||||
"speakers.html",
|
||||
options=template_options(),
|
||||
configs=configs.values(),
|
||||
editing_name=editing_name,
|
||||
editing=editing,
|
||||
message=message,
|
||||
error=error
|
||||
)
|
||||
|
||||
@voices_bp.post("/presets/<name>/delete")
|
||||
def delete_speaker_config_named(name: str) -> ResponseReturnValue:
|
||||
delete_config(name)
|
||||
return redirect(url_for("voices.speaker_configs_page"))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,582 @@
|
||||
import { initReaderUI } from "./reader.js";
|
||||
import { initWizard } from "./wizard.js";
|
||||
|
||||
const dashboardState = (window.AbogenDashboardState = window.AbogenDashboardState || {
|
||||
boundKeydown: false,
|
||||
boundBeforeUnload: false,
|
||||
});
|
||||
|
||||
const initDashboard = () => {
|
||||
const uploadModal =
|
||||
document.querySelector('[data-role="new-job-modal"]') ||
|
||||
document.querySelector('[data-role="upload-modal"]');
|
||||
const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]');
|
||||
const scope = uploadModal || document;
|
||||
const sourceFileInput = scope.querySelector('#source_file');
|
||||
const dropzone = document.querySelector('[data-role="upload-dropzone"]');
|
||||
const dropzoneFilename = document.querySelector('[data-role="upload-dropzone-filename"]');
|
||||
|
||||
const parseJSONScript = (id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return null;
|
||||
try {
|
||||
const raw = element.textContent || "";
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse JSON script: ${id}`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const profileSelect = scope.querySelector('[data-role="voice-profile"]');
|
||||
const voiceField = scope.querySelector('[data-role="voice-field"]');
|
||||
const voiceSelect = scope.querySelector('[data-role="voice-select"]');
|
||||
const formulaField = scope.querySelector('[data-role="formula-field"]');
|
||||
const formulaInput = scope.querySelector('[data-role="voice-formula"]');
|
||||
const languageSelect = uploadModal?.querySelector("#language") || document.getElementById("language");
|
||||
const speedInput = uploadModal?.querySelector('#speed') || document.getElementById('speed');
|
||||
const previewButton = scope.querySelector('[data-role="voice-preview-button"]');
|
||||
const previewStatus = scope.querySelector('[data-role="voice-preview-status"]');
|
||||
const previewAudio = scope.querySelector('[data-role="voice-preview-audio"]');
|
||||
const sampleVoiceTexts = parseJSONScript('voice-sample-texts') || {};
|
||||
|
||||
const setDropzoneStatus = (message, state = "") => {
|
||||
if (!dropzoneFilename) return;
|
||||
if (!message) {
|
||||
dropzoneFilename.hidden = true;
|
||||
dropzoneFilename.textContent = "";
|
||||
dropzoneFilename.removeAttribute("data-state");
|
||||
return;
|
||||
}
|
||||
dropzoneFilename.hidden = false;
|
||||
dropzoneFilename.textContent = message;
|
||||
if (state) {
|
||||
dropzoneFilename.dataset.state = state;
|
||||
} else {
|
||||
dropzoneFilename.removeAttribute("data-state");
|
||||
}
|
||||
};
|
||||
|
||||
const updateDropzoneFilename = () => {
|
||||
if (!sourceFileInput) {
|
||||
setDropzoneStatus("");
|
||||
return;
|
||||
}
|
||||
const file = sourceFileInput.files && sourceFileInput.files[0];
|
||||
if (file) {
|
||||
setDropzoneStatus(`Selected: ${file.name}`);
|
||||
} else {
|
||||
setDropzoneStatus("");
|
||||
}
|
||||
};
|
||||
|
||||
const assignDroppedFile = (file) => {
|
||||
if (!sourceFileInput || !file) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (typeof DataTransfer === "undefined") {
|
||||
throw new Error("DataTransfer API unavailable");
|
||||
}
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(file);
|
||||
sourceFileInput.files = transfer.files;
|
||||
sourceFileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
try {
|
||||
sourceFileInput.focus({ preventScroll: true });
|
||||
} catch (error) {
|
||||
// Ignore focus errors
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("Unable to assign dropped file to input", error);
|
||||
setDropzoneStatus("Drag & drop isn't supported here. Click to choose a file instead.", "error");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const setDropzoneActive = (isActive) => {
|
||||
if (!dropzone) return;
|
||||
dropzone.classList.toggle("is-dragging", isActive);
|
||||
if (isActive) {
|
||||
dropzone.dataset.state = "drag";
|
||||
} else {
|
||||
delete dropzone.dataset.state;
|
||||
}
|
||||
};
|
||||
|
||||
let lastTrigger = null;
|
||||
let previewAbortController = null;
|
||||
let previewObjectUrl = null;
|
||||
let suppressPauseStatus = false;
|
||||
|
||||
const dispatchUploadModalEvent = (type, detail = {}) => {
|
||||
const eventName = `upload-modal:${type}`;
|
||||
if (uploadModal) {
|
||||
uploadModal.dispatchEvent(new CustomEvent(eventName, { detail, bubbles: true }));
|
||||
return;
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||||
};
|
||||
|
||||
const openUploadModal = (trigger) => {
|
||||
if (!uploadModal) return;
|
||||
lastTrigger = trigger || null;
|
||||
uploadModal.hidden = false;
|
||||
uploadModal.dataset.open = "true";
|
||||
document.body.classList.add("modal-open");
|
||||
const focusTarget = uploadModal.querySelector("#source_file") || uploadModal.querySelector("#source_text") || uploadModal;
|
||||
if (focusTarget instanceof HTMLElement) {
|
||||
focusTarget.focus({ preventScroll: true });
|
||||
}
|
||||
dispatchUploadModalEvent("open", { trigger: lastTrigger });
|
||||
};
|
||||
|
||||
const closeUploadModal = () => {
|
||||
if (!uploadModal || uploadModal.hidden) {
|
||||
return;
|
||||
}
|
||||
uploadModal.hidden = true;
|
||||
delete uploadModal.dataset.open;
|
||||
document.body.classList.remove("modal-open");
|
||||
if (lastTrigger && lastTrigger instanceof HTMLElement) {
|
||||
lastTrigger.focus({ preventScroll: true });
|
||||
}
|
||||
dispatchUploadModalEvent("close", { trigger: lastTrigger });
|
||||
};
|
||||
|
||||
openModalButtons.forEach((button) => {
|
||||
if (!button || button.dataset.dashboardBound === "true") {
|
||||
return;
|
||||
}
|
||||
button.dataset.dashboardBound = "true";
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
openUploadModal(button);
|
||||
});
|
||||
});
|
||||
|
||||
if (uploadModal && uploadModal.dataset.dashboardCloseBound !== "true") {
|
||||
uploadModal.dataset.dashboardCloseBound = "true";
|
||||
uploadModal.addEventListener("click", (event) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof Element &&
|
||||
(target.closest('[data-role="new-job-modal-close"]') ||
|
||||
target.closest('[data-role="upload-modal-close"]') ||
|
||||
target.closest('[data-role="wizard-close"]') ||
|
||||
target.closest('[data-role="wizard-cancel"]'))
|
||||
) {
|
||||
event.preventDefault();
|
||||
closeUploadModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!dashboardState.boundKeydown) {
|
||||
dashboardState.boundKeydown = true;
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
if (uploadModal && !uploadModal.hidden) {
|
||||
closeUploadModal();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initReaderUI({ onBeforeOpen: closeUploadModal });
|
||||
|
||||
if (sourceFileInput) {
|
||||
if (sourceFileInput.dataset.dashboardChangeBound !== "true") {
|
||||
sourceFileInput.dataset.dashboardChangeBound = "true";
|
||||
sourceFileInput.addEventListener("change", updateDropzoneFilename);
|
||||
}
|
||||
updateDropzoneFilename();
|
||||
} else {
|
||||
setDropzoneStatus("");
|
||||
}
|
||||
|
||||
const resolveSampleText = (language) => {
|
||||
const fallback = typeof sampleVoiceTexts === "object" && sampleVoiceTexts?.a
|
||||
? sampleVoiceTexts.a
|
||||
: "This is a sample of the selected voice.";
|
||||
if (!language || typeof sampleVoiceTexts !== "object" || !sampleVoiceTexts) {
|
||||
return fallback;
|
||||
}
|
||||
const normalizedKey = language.toLowerCase();
|
||||
if (typeof sampleVoiceTexts[normalizedKey] === "string" && sampleVoiceTexts[normalizedKey].trim()) {
|
||||
return sampleVoiceTexts[normalizedKey];
|
||||
}
|
||||
const baseKey = normalizedKey.split(/[_.-]/)[0];
|
||||
if (baseKey && typeof sampleVoiceTexts[baseKey] === "string" && sampleVoiceTexts[baseKey].trim()) {
|
||||
return sampleVoiceTexts[baseKey];
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const getSelectedLanguage = () => {
|
||||
const value = languageSelect?.value || "a";
|
||||
return (value || "a").trim() || "a";
|
||||
};
|
||||
|
||||
const getSelectedSpeed = () => {
|
||||
const raw = speedInput?.value || "1";
|
||||
const parsed = Number.parseFloat(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 1;
|
||||
};
|
||||
|
||||
const cancelPreviewRequest = () => {
|
||||
if (!previewAbortController) return;
|
||||
previewAbortController.abort();
|
||||
previewAbortController = null;
|
||||
};
|
||||
|
||||
const stopPreviewAudio = () => {
|
||||
if (previewAudio) {
|
||||
suppressPauseStatus = true;
|
||||
try {
|
||||
previewAudio.pause();
|
||||
} catch (error) {
|
||||
// Ignore pause errors
|
||||
}
|
||||
previewAudio.removeAttribute("src");
|
||||
previewAudio.load();
|
||||
previewAudio.hidden = true;
|
||||
suppressPauseStatus = false;
|
||||
}
|
||||
if (previewObjectUrl) {
|
||||
URL.revokeObjectURL(previewObjectUrl);
|
||||
previewObjectUrl = null;
|
||||
}
|
||||
};
|
||||
|
||||
const setPreviewStatus = (message, state = "") => {
|
||||
if (!previewStatus) return;
|
||||
if (!message) {
|
||||
previewStatus.textContent = "";
|
||||
previewStatus.hidden = true;
|
||||
previewStatus.removeAttribute("data-state");
|
||||
return;
|
||||
}
|
||||
previewStatus.textContent = message;
|
||||
previewStatus.hidden = false;
|
||||
if (state) {
|
||||
previewStatus.dataset.state = state;
|
||||
} else {
|
||||
previewStatus.removeAttribute("data-state");
|
||||
}
|
||||
};
|
||||
|
||||
const setPreviewLoading = (isLoading) => {
|
||||
if (!previewButton) return;
|
||||
previewButton.disabled = isLoading;
|
||||
if (isLoading) {
|
||||
previewButton.dataset.loading = "true";
|
||||
} else {
|
||||
previewButton.removeAttribute("data-loading");
|
||||
}
|
||||
};
|
||||
|
||||
const buildPreviewRequest = () => {
|
||||
const language = getSelectedLanguage();
|
||||
const speed = getSelectedSpeed();
|
||||
const basePayload = {
|
||||
language,
|
||||
speed,
|
||||
max_seconds: 8,
|
||||
text: resolveSampleText(language),
|
||||
};
|
||||
|
||||
const profileValue = profileSelect?.value || "__standard";
|
||||
|
||||
if (profileValue && profileValue !== "__standard") {
|
||||
if (profileValue === "__formula") {
|
||||
const formulaValue = (formulaInput?.value || "").trim();
|
||||
if (!formulaValue) {
|
||||
return { error: "Enter a custom voice formula to preview." };
|
||||
}
|
||||
return {
|
||||
endpoint: "/api/voice-profiles/preview",
|
||||
payload: { ...basePayload, formula: formulaValue },
|
||||
};
|
||||
}
|
||||
return {
|
||||
endpoint: "/api/voice-profiles/preview",
|
||||
payload: { ...basePayload, profile: profileValue },
|
||||
};
|
||||
}
|
||||
|
||||
const selectedVoice = (voiceSelect?.value || voiceSelect?.dataset.default || "").trim();
|
||||
if (!selectedVoice) {
|
||||
return { error: "Select a narrator voice to preview." };
|
||||
}
|
||||
return {
|
||||
endpoint: "/api/speaker-preview",
|
||||
payload: { ...basePayload, voice: selectedVoice },
|
||||
};
|
||||
};
|
||||
|
||||
const resetPreview = () => {
|
||||
cancelPreviewRequest();
|
||||
stopPreviewAudio();
|
||||
setPreviewStatus("", "");
|
||||
};
|
||||
|
||||
if (previewAudio) {
|
||||
previewAudio.addEventListener("ended", () => {
|
||||
setPreviewStatus("Preview finished", "info");
|
||||
});
|
||||
previewAudio.addEventListener("pause", () => {
|
||||
if (suppressPauseStatus || previewAudio.ended || previewAudio.currentTime === 0) {
|
||||
return;
|
||||
}
|
||||
setPreviewStatus("Preview paused", "info");
|
||||
});
|
||||
}
|
||||
|
||||
const handleVoicePreview = async () => {
|
||||
if (!previewButton) return;
|
||||
const request = buildPreviewRequest();
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
if (request.error) {
|
||||
setPreviewStatus(request.error, "error");
|
||||
cancelPreviewRequest();
|
||||
stopPreviewAudio();
|
||||
return;
|
||||
}
|
||||
|
||||
cancelPreviewRequest();
|
||||
stopPreviewAudio();
|
||||
previewAbortController = new AbortController();
|
||||
setPreviewLoading(true);
|
||||
setPreviewStatus("Generating preview…", "loading");
|
||||
|
||||
try {
|
||||
const response = await fetch(request.endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request.payload),
|
||||
signal: previewAbortController.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `Preview failed (status ${response.status})`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
previewObjectUrl = URL.createObjectURL(blob);
|
||||
if (previewAudio) {
|
||||
previewAudio.src = previewObjectUrl;
|
||||
previewAudio.hidden = false;
|
||||
try {
|
||||
await previewAudio.play();
|
||||
setPreviewStatus("Preview playing", "success");
|
||||
} catch (error) {
|
||||
setPreviewStatus("Preview ready. Press play to listen.", "success");
|
||||
}
|
||||
} else {
|
||||
setPreviewStatus("Preview ready.", "success");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
console.error("Voice preview failed", error);
|
||||
setPreviewStatus(error.message || "Preview failed", "error");
|
||||
stopPreviewAudio();
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (previewButton && previewButton.dataset.dashboardBound !== "true") {
|
||||
previewButton.dataset.dashboardBound = "true";
|
||||
previewButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
handleVoicePreview();
|
||||
});
|
||||
}
|
||||
|
||||
if (dropzone && dropzone.dataset.dashboardDragBound !== "true") {
|
||||
dropzone.dataset.dashboardDragBound = "true";
|
||||
let dragDepth = 0;
|
||||
|
||||
dropzone.addEventListener("dragenter", (event) => {
|
||||
event.preventDefault();
|
||||
dragDepth += 1;
|
||||
setDropzoneActive(true);
|
||||
});
|
||||
|
||||
dropzone.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
});
|
||||
|
||||
const handleDragLeave = (event) => {
|
||||
if (event && dropzone.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (dragDepth === 0) {
|
||||
setDropzoneActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
dropzone.addEventListener("dragleave", (event) => {
|
||||
handleDragLeave(event);
|
||||
});
|
||||
|
||||
dropzone.addEventListener("dragend", () => {
|
||||
dragDepth = 0;
|
||||
setDropzoneActive(false);
|
||||
});
|
||||
|
||||
dropzone.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
dragDepth = 0;
|
||||
setDropzoneActive(false);
|
||||
const files = event.dataTransfer && event.dataTransfer.files;
|
||||
if (!files || !files.length) {
|
||||
return;
|
||||
}
|
||||
openUploadModal(dropzone);
|
||||
assignDroppedFile(files[0]);
|
||||
});
|
||||
|
||||
dropzone.addEventListener("click", (event) => {
|
||||
if (event.target.closest('[data-role="open-upload-modal"]')) {
|
||||
return;
|
||||
}
|
||||
openUploadModal(dropzone);
|
||||
});
|
||||
|
||||
dropzone.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openUploadModal(dropzone);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[voiceSelect, profileSelect, formulaInput, languageSelect, speedInput].forEach((input) => {
|
||||
if (!input) return;
|
||||
const eventName = input === formulaInput ? "input" : "change";
|
||||
input.addEventListener(eventName, () => {
|
||||
resetPreview();
|
||||
});
|
||||
});
|
||||
|
||||
const hydrateDefaultVoice = () => {
|
||||
if (!voiceSelect) return;
|
||||
const defaultVoice = voiceSelect.dataset.default;
|
||||
if (!defaultVoice) return;
|
||||
const option = voiceSelect.querySelector(`option[value="${defaultVoice}"]`);
|
||||
if (option) {
|
||||
voiceSelect.value = defaultVoice;
|
||||
}
|
||||
};
|
||||
|
||||
const applySavedProfile = (option) => {
|
||||
if (!option) return;
|
||||
const presetFormula = option.dataset.formula || "";
|
||||
const profileLang = option.dataset.language || "";
|
||||
if (formulaInput) {
|
||||
formulaInput.value = presetFormula;
|
||||
formulaInput.readOnly = true;
|
||||
formulaInput.dataset.state = "locked";
|
||||
}
|
||||
if (profileLang && languageSelect) {
|
||||
languageSelect.value = profileLang;
|
||||
}
|
||||
};
|
||||
|
||||
const updateVoiceControls = () => {
|
||||
if (!profileSelect) {
|
||||
return;
|
||||
}
|
||||
const value = profileSelect.value || "__standard";
|
||||
const isStandard = value === "__standard";
|
||||
const isFormula = value === "__formula";
|
||||
const isSavedProfile = !isStandard && !isFormula;
|
||||
|
||||
const showVoiceField = isStandard;
|
||||
if (voiceField) {
|
||||
voiceField.hidden = !showVoiceField;
|
||||
voiceField.setAttribute("aria-hidden", showVoiceField ? "false" : "true");
|
||||
voiceField.dataset.state = showVoiceField ? "visible" : "hidden";
|
||||
}
|
||||
if (voiceSelect) {
|
||||
voiceSelect.disabled = !isStandard;
|
||||
voiceSelect.dataset.state = isStandard ? "editable" : "locked";
|
||||
if (isStandard) {
|
||||
hydrateDefaultVoice();
|
||||
}
|
||||
}
|
||||
|
||||
if (isSavedProfile) {
|
||||
applySavedProfile(profileSelect.selectedOptions[0] || null);
|
||||
} else if (!isFormula && formulaInput) {
|
||||
formulaInput.value = "";
|
||||
}
|
||||
|
||||
const showFormulaField = isFormula;
|
||||
if (formulaField) {
|
||||
const shouldShow = showFormulaField;
|
||||
formulaField.hidden = !shouldShow;
|
||||
formulaField.setAttribute("aria-hidden", shouldShow ? "false" : "true");
|
||||
formulaField.dataset.state = shouldShow ? "visible" : "hidden";
|
||||
}
|
||||
if (formulaInput) {
|
||||
if (isFormula) {
|
||||
formulaInput.disabled = false;
|
||||
formulaInput.readOnly = false;
|
||||
formulaInput.dataset.state = "editable";
|
||||
} else if (isSavedProfile) {
|
||||
formulaInput.disabled = false;
|
||||
formulaInput.readOnly = true;
|
||||
formulaInput.dataset.state = "locked";
|
||||
} else {
|
||||
formulaInput.disabled = true;
|
||||
formulaInput.readOnly = true;
|
||||
formulaInput.value = "";
|
||||
formulaInput.dataset.state = "editable";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (profileSelect) {
|
||||
if (profileSelect.dataset.dashboardBound !== "true") {
|
||||
profileSelect.dataset.dashboardBound = "true";
|
||||
profileSelect.addEventListener("change", updateVoiceControls);
|
||||
}
|
||||
updateVoiceControls();
|
||||
} else {
|
||||
hydrateDefaultVoice();
|
||||
}
|
||||
|
||||
if (!dashboardState.boundBeforeUnload) {
|
||||
dashboardState.boundBeforeUnload = true;
|
||||
window.addEventListener("beforeunload", () => {
|
||||
cancelPreviewRequest();
|
||||
stopPreviewAudio();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.AbogenDashboard = window.AbogenDashboard || {};
|
||||
window.AbogenDashboard.init = initDashboard;
|
||||
|
||||
const bootDashboard = () => {
|
||||
initDashboard();
|
||||
initWizard();
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", bootDashboard, { once: true });
|
||||
} else {
|
||||
bootDashboard();
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
(function () {
|
||||
const root = document.querySelector('[data-override-root]');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewUrl = root.dataset.previewUrl || "";
|
||||
const defaultLanguage = root.dataset.language || "a";
|
||||
const table = root.querySelector('[data-role="override-table"]');
|
||||
const rows = table ? Array.from(table.querySelectorAll('[data-role="override-row"]')) : [];
|
||||
const filterInput = root.querySelector('[data-role="override-filter"]');
|
||||
const filterClearButton = root.querySelector('[data-role="override-filter-clear"]');
|
||||
const filterEmptyMessage = root.querySelector('[data-role="filter-empty"]');
|
||||
|
||||
function base64ToBlob(base64, mimeType) {
|
||||
const binary = atob(base64);
|
||||
const length = binary.length;
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
}
|
||||
|
||||
function getControl(form, selector) {
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
const direct = form.querySelector(selector);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
if (!form.id) {
|
||||
return null;
|
||||
}
|
||||
return root.querySelector(`${selector}[form="${form.id}"]`) || document.querySelector(`${selector}[form="${form.id}"]`);
|
||||
}
|
||||
|
||||
function resetPreview(container) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const messageEl = container.querySelector('[data-role="preview-message"]');
|
||||
const audioEl = container.querySelector('[data-role="preview-audio"]');
|
||||
if (messageEl) {
|
||||
messageEl.textContent = "";
|
||||
messageEl.removeAttribute('data-state');
|
||||
}
|
||||
if (audioEl) {
|
||||
const priorUrl = audioEl.dataset.objectUrl;
|
||||
if (priorUrl) {
|
||||
URL.revokeObjectURL(priorUrl);
|
||||
delete audioEl.dataset.objectUrl;
|
||||
}
|
||||
audioEl.pause();
|
||||
audioEl.removeAttribute('src');
|
||||
audioEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewPayload(form) {
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
const tokenInput = getControl(form, 'input[name="token"]');
|
||||
const pronunciationInput = getControl(form, 'input[name="pronunciation"]');
|
||||
const voiceSelect = getControl(form, 'select[name="voice"]');
|
||||
const languageInput = getControl(form, 'input[name="lang"]');
|
||||
|
||||
const token = tokenInput && 'value' in tokenInput ? tokenInput.value.trim() : "";
|
||||
const pronunciation = pronunciationInput && 'value' in pronunciationInput ? pronunciationInput.value.trim() : "";
|
||||
const voice = voiceSelect && 'value' in voiceSelect ? voiceSelect.value.trim() : "";
|
||||
const language = languageInput && 'value' in languageInput ? languageInput.value.trim() : defaultLanguage;
|
||||
|
||||
if (!token && !pronunciation) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
token,
|
||||
pronunciation,
|
||||
voice,
|
||||
language,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestPreview(button) {
|
||||
if (!previewUrl) {
|
||||
return;
|
||||
}
|
||||
const formId = button.dataset.formId || "";
|
||||
const form = formId ? document.getElementById(formId) : button.closest('form');
|
||||
const container = button.closest('[data-role="preview-container"]');
|
||||
const messageEl = container ? container.querySelector('[data-role="preview-message"]') : null;
|
||||
const audioEl = container ? container.querySelector('[data-role="preview-audio"]') : null;
|
||||
|
||||
resetPreview(container);
|
||||
|
||||
const payload = buildPreviewPayload(form);
|
||||
if (!payload) {
|
||||
if (messageEl) {
|
||||
messageEl.textContent = "Enter a token or pronunciation first.";
|
||||
messageEl.dataset.state = "error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
button.setAttribute('data-loading', 'true');
|
||||
|
||||
try {
|
||||
const response = await fetch(previewUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
let data = null;
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (parseError) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Preview failed.');
|
||||
}
|
||||
throw parseError instanceof Error ? parseError : new Error('Preview failed.');
|
||||
}
|
||||
} else {
|
||||
if (!response.ok) {
|
||||
const fallback = await response.text().catch(() => '');
|
||||
throw new Error(fallback || 'Preview failed.');
|
||||
}
|
||||
throw new Error('Preview failed.');
|
||||
}
|
||||
|
||||
if (!response.ok || (data && data.error)) {
|
||||
throw new Error((data && data.error) || 'Preview failed.');
|
||||
}
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Preview failed.');
|
||||
}
|
||||
if (!data.audio_base64) {
|
||||
throw new Error('Preview did not return audio.');
|
||||
}
|
||||
|
||||
if (audioEl) {
|
||||
const blob = base64ToBlob(data.audio_base64, 'audio/wav');
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
audioEl.src = objectUrl;
|
||||
audioEl.dataset.objectUrl = objectUrl;
|
||||
audioEl.hidden = false;
|
||||
audioEl.load();
|
||||
audioEl.play().catch(() => {
|
||||
/* playback might require user interaction; ignore */
|
||||
});
|
||||
}
|
||||
|
||||
if (messageEl) {
|
||||
messageEl.textContent = data.normalized_text || data.text || 'Preview ready.';
|
||||
messageEl.dataset.state = "success";
|
||||
}
|
||||
} catch (error) {
|
||||
if (messageEl) {
|
||||
messageEl.textContent = error instanceof Error ? error.message : 'Preview failed.';
|
||||
messageEl.dataset.state = "error";
|
||||
}
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.removeAttribute('data-loading');
|
||||
}
|
||||
}
|
||||
|
||||
function attachPreviewHandlers() {
|
||||
const previewButtons = root.querySelectorAll('[data-role="preview-button"]');
|
||||
previewButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
requestPreview(button);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
if (!filterInput || rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const term = filterInput.value.trim().toLowerCase();
|
||||
let visibleCount = 0;
|
||||
rows.forEach((row) => {
|
||||
const token = row.dataset.token || "";
|
||||
const pronunciationInput = row.querySelector('input[name="pronunciation"]');
|
||||
const voiceSelect = row.querySelector('select[name="voice"]');
|
||||
|
||||
const pronunciationValue = pronunciationInput && 'value' in pronunciationInput
|
||||
? pronunciationInput.value.trim().toLowerCase()
|
||||
: "";
|
||||
const voiceOption = voiceSelect && 'selectedIndex' in voiceSelect && voiceSelect.selectedIndex >= 0
|
||||
? voiceSelect.options[voiceSelect.selectedIndex]
|
||||
: null;
|
||||
const voiceValue = voiceOption && voiceOption.textContent
|
||||
? voiceOption.textContent.trim().toLowerCase()
|
||||
: "";
|
||||
|
||||
if (!term || token.includes(term) || pronunciationValue.includes(term) || voiceValue.includes(term)) {
|
||||
row.hidden = false;
|
||||
visibleCount += 1;
|
||||
} else {
|
||||
row.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (filterEmptyMessage) {
|
||||
filterEmptyMessage.hidden = visibleCount !== 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (filterInput) {
|
||||
filterInput.addEventListener('input', applyFilter);
|
||||
}
|
||||
|
||||
if (filterClearButton && filterInput) {
|
||||
filterClearButton.addEventListener('click', () => {
|
||||
filterInput.value = "";
|
||||
applyFilter();
|
||||
filterInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (table) {
|
||||
table.addEventListener('input', (event) => {
|
||||
const target = event.target;
|
||||
if (target && (target.matches('input[name="pronunciation"]') || target.matches('select[name="voice"]'))) {
|
||||
applyFilter();
|
||||
}
|
||||
});
|
||||
table.addEventListener('change', (event) => {
|
||||
const target = event.target;
|
||||
if (target && target.matches('select[name="voice"]')) {
|
||||
applyFilter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
attachPreviewHandlers();
|
||||
applyFilter();
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
import { initReaderUI } from "./reader.js";
|
||||
|
||||
const queueState = (window.AbogenQueueState = window.AbogenQueueState || {
|
||||
boundOverwritePrompt: false,
|
||||
});
|
||||
|
||||
const handleOverwritePrompt = (event) => {
|
||||
const detail = event?.detail || {};
|
||||
const title = detail.title || "this item";
|
||||
const message = detail.message || `Audiobookshelf already has "${title}". Overwrite?`;
|
||||
if (!window.confirm(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = detail.url;
|
||||
if (!url || typeof htmx === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = detail.target || "#jobs-panel";
|
||||
const values = { overwrite: "true" };
|
||||
if (detail.values && typeof detail.values === "object") {
|
||||
Object.assign(values, detail.values);
|
||||
}
|
||||
|
||||
htmx.ajax("POST", url, {
|
||||
target,
|
||||
swap: "innerHTML",
|
||||
values,
|
||||
});
|
||||
};
|
||||
|
||||
const initQueuePage = () => {
|
||||
initReaderUI();
|
||||
if (!queueState.boundOverwritePrompt) {
|
||||
queueState.boundOverwritePrompt = true;
|
||||
document.addEventListener("audiobookshelf-overwrite-prompt", handleOverwritePrompt);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initQueuePage, { once: true });
|
||||
} else {
|
||||
initQueuePage();
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
const readerButtonRegistry = new WeakSet();
|
||||
let initialized = false;
|
||||
let readerModal = null;
|
||||
let readerFrame = null;
|
||||
let readerHint = null;
|
||||
let readerTitle = null;
|
||||
let readerTrigger = null;
|
||||
let defaultReaderHint = "";
|
||||
|
||||
const resolveEventMatch = (event, selector) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Element) {
|
||||
const match = target.closest(selector);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
||||
for (const node of path) {
|
||||
if (node instanceof Element) {
|
||||
if (node.matches(selector)) {
|
||||
return node;
|
||||
}
|
||||
const match = node.closest(selector);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const closeReaderModal = () => {
|
||||
if (!readerModal) {
|
||||
return;
|
||||
}
|
||||
if (readerModal.hidden) {
|
||||
return;
|
||||
}
|
||||
readerModal.hidden = true;
|
||||
readerModal.removeAttribute("data-open");
|
||||
document.body.classList.remove("modal-open");
|
||||
if (readerFrame) {
|
||||
const frameWindow = readerFrame.contentWindow;
|
||||
if (frameWindow) {
|
||||
try {
|
||||
frameWindow.postMessage({ type: "abogen:reader:pause", currentTime: 0 }, window.location.origin);
|
||||
} catch (error) {
|
||||
// Ignore cross-origin messaging errors.
|
||||
}
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
readerFrame.src = "about:blank";
|
||||
}, 75);
|
||||
}
|
||||
if (readerHint && defaultReaderHint) {
|
||||
readerHint.textContent = defaultReaderHint;
|
||||
}
|
||||
if (readerTitle) {
|
||||
readerTitle.textContent = "Read & listen";
|
||||
}
|
||||
if (readerTrigger instanceof HTMLElement) {
|
||||
try {
|
||||
readerTrigger.focus({ preventScroll: true });
|
||||
} catch (error) {
|
||||
// Ignore focus errors.
|
||||
}
|
||||
}
|
||||
readerTrigger = null;
|
||||
};
|
||||
|
||||
const createBindReaderButtons = (openReaderModal) => {
|
||||
return (root) => {
|
||||
const context = root instanceof Element ? root : document;
|
||||
const buttons = context.querySelectorAll('[data-role="open-reader"]');
|
||||
buttons.forEach((button) => {
|
||||
if (!(button instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (readerButtonRegistry.has(button)) {
|
||||
return;
|
||||
}
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
openReaderModal(button);
|
||||
});
|
||||
readerButtonRegistry.add(button);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const initReaderUI = (options = {}) => {
|
||||
if (initialized) {
|
||||
return {
|
||||
bindReaderButtons: createBindReaderButtons((trigger) => {
|
||||
if (typeof options.onBeforeOpen === "function") {
|
||||
options.onBeforeOpen();
|
||||
}
|
||||
openReader(trigger, options);
|
||||
}),
|
||||
closeReaderModal,
|
||||
};
|
||||
}
|
||||
|
||||
readerModal = document.querySelector('[data-role="reader-modal"]');
|
||||
readerFrame = readerModal?.querySelector('[data-role="reader-frame"]') || null;
|
||||
readerHint = readerModal?.querySelector('[data-role="reader-modal-hint"]') || null;
|
||||
readerTitle = readerModal?.querySelector('#reader-modal-title') || null;
|
||||
defaultReaderHint = readerHint?.textContent || "";
|
||||
|
||||
const openReaderModal = (trigger) => {
|
||||
if (typeof options.onBeforeOpen === "function") {
|
||||
options.onBeforeOpen();
|
||||
}
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const url = trigger.dataset.readerUrl || "";
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
if (!readerModal || !readerFrame) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
readerTrigger = trigger;
|
||||
const bookTitle = trigger.dataset.bookTitle || "";
|
||||
if (readerTitle) {
|
||||
readerTitle.textContent = bookTitle ? `${bookTitle} · reader` : "Read & listen";
|
||||
}
|
||||
if (readerHint) {
|
||||
readerHint.textContent = bookTitle
|
||||
? `Preview ${bookTitle} directly in your browser.`
|
||||
: defaultReaderHint;
|
||||
}
|
||||
readerModal.hidden = false;
|
||||
readerModal.dataset.open = "true";
|
||||
document.body.classList.add("modal-open");
|
||||
readerFrame.src = url;
|
||||
try {
|
||||
readerFrame.focus({ preventScroll: true });
|
||||
} catch (error) {
|
||||
// Ignore focus errors.
|
||||
}
|
||||
};
|
||||
|
||||
const bindReaderButtons = createBindReaderButtons(openReaderModal);
|
||||
bindReaderButtons();
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const closeButton = resolveEventMatch(event, '[data-role="reader-modal-close"]');
|
||||
if (closeButton) {
|
||||
event.preventDefault();
|
||||
closeReaderModal();
|
||||
return;
|
||||
}
|
||||
const trigger = resolveEventMatch(event, '[data-role="open-reader"]');
|
||||
if (trigger instanceof HTMLElement) {
|
||||
event.preventDefault();
|
||||
openReaderModal(trigger);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
closeReaderModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:afterSwap", (event) => {
|
||||
const fragment = event?.detail?.target;
|
||||
if (fragment instanceof Element) {
|
||||
bindReaderButtons(fragment);
|
||||
} else {
|
||||
bindReaderButtons();
|
||||
}
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
|
||||
return {
|
||||
bindReaderButtons,
|
||||
closeReaderModal,
|
||||
};
|
||||
};
|
||||
|
||||
const openReader = (trigger, options) => {
|
||||
if (typeof options.onBeforeOpen === "function") {
|
||||
options.onBeforeOpen();
|
||||
}
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const url = trigger.dataset.readerUrl || "";
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
if (!readerModal || !readerFrame) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
readerTrigger = trigger;
|
||||
const bookTitle = trigger.dataset.bookTitle || "";
|
||||
if (readerTitle) {
|
||||
readerTitle.textContent = bookTitle ? `${bookTitle} · reader` : "Read & listen";
|
||||
}
|
||||
if (readerHint) {
|
||||
readerHint.textContent = bookTitle
|
||||
? `Preview ${bookTitle} directly in your browser.`
|
||||
: defaultReaderHint;
|
||||
}
|
||||
readerModal.hidden = false;
|
||||
readerModal.dataset.open = "true";
|
||||
document.body.classList.add("modal-open");
|
||||
readerFrame.src = url;
|
||||
try {
|
||||
readerFrame.focus({ preventScroll: true });
|
||||
} catch (error) {
|
||||
// Ignore focus errors.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,882 @@
|
||||
const form = document.querySelector('.settings__form');
|
||||
const navButtons = Array.from(document.querySelectorAll('.settings-nav__item'));
|
||||
const panels = Array.from(document.querySelectorAll('.settings-panel'));
|
||||
const llmNavButton = navButtons.find((button) => button.dataset.section === 'llm');
|
||||
|
||||
const statusSelectors = {
|
||||
llm: document.querySelector('[data-role="llm-preview-status"]'),
|
||||
normalization: document.querySelector('[data-role="normalization-preview-status"]'),
|
||||
calibre: document.querySelector('[data-role="calibre-test-status"]'),
|
||||
audiobookshelf: document.querySelector('[data-role="audiobookshelf-test-status"]'),
|
||||
};
|
||||
|
||||
const outputAreas = {
|
||||
llm: document.querySelector('[data-role="llm-preview-output"]'),
|
||||
normalization: document.querySelector('[data-role="normalization-preview-output"]'),
|
||||
};
|
||||
|
||||
const normalizationAudio = document.querySelector('[data-role="normalization-preview-audio"]');
|
||||
|
||||
const folderModal = document.querySelector('[data-role="audiobookshelf-folder-modal"]');
|
||||
const folderModalOverlay = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-overlay"]') : null;
|
||||
const folderList = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-list"]') : null;
|
||||
const folderStatusMessage = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-status"]') : null;
|
||||
const folderFilter = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-filter"]') : null;
|
||||
const folderEmptyState = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-empty"]') : null;
|
||||
const defaultFolderEmptyMessage = folderEmptyState ? folderEmptyState.textContent : 'No folders match your filter.';
|
||||
let folderModalOpener = null;
|
||||
let folderModalPreviousFocus = null;
|
||||
let audiobookshelfFolderSource = [];
|
||||
|
||||
const contractionModal = document.querySelector('[data-role="contraction-modal"]');
|
||||
const contractionModalOverlay = contractionModal ? contractionModal.querySelector('[data-role="contraction-modal-overlay"]') : null;
|
||||
let contractionModalOpener = null;
|
||||
let contractionModalPreviousFocus = null;
|
||||
|
||||
function setStatus(target, message, state) {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.textContent = message || '';
|
||||
if (state) {
|
||||
target.dataset.state = state;
|
||||
} else {
|
||||
delete target.dataset.state;
|
||||
}
|
||||
}
|
||||
|
||||
function clearStatus(target) {
|
||||
setStatus(target, '', null);
|
||||
}
|
||||
|
||||
function activatePanel(section) {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
navButtons.forEach((button) => {
|
||||
const isActive = button.dataset.section === section;
|
||||
button.classList.toggle('is-active', isActive);
|
||||
});
|
||||
let activePanel = null;
|
||||
panels.forEach((panel) => {
|
||||
const isActive = panel.dataset.section === section;
|
||||
panel.classList.toggle('is-active', isActive);
|
||||
if (isActive) {
|
||||
activePanel = panel;
|
||||
}
|
||||
});
|
||||
if (activePanel) {
|
||||
const focusable = activePanel.querySelector('input, select, textarea');
|
||||
if (focusable) {
|
||||
window.requestAnimationFrame(() => {
|
||||
focusable.focus({ preventScroll: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initNavigation() {
|
||||
if (!navButtons.length || !panels.length) {
|
||||
return;
|
||||
}
|
||||
navButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
activatePanel(button.dataset.section);
|
||||
if (button.dataset.section) {
|
||||
window.history.replaceState(null, '', `#${button.dataset.section}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
if (hash && panels.some((panel) => panel.dataset.section === hash)) {
|
||||
activatePanel(hash);
|
||||
} else {
|
||||
const current = navButtons.find((button) => button.classList.contains('is-active'));
|
||||
if (current) {
|
||||
activatePanel(current.dataset.section);
|
||||
}
|
||||
}
|
||||
window.addEventListener('hashchange', () => {
|
||||
const section = window.location.hash.replace('#', '');
|
||||
if (section) {
|
||||
activatePanel(section);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseNumber(value, fallback) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeFolderToken(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function setFolderModalStatus(message, state) {
|
||||
if (!folderStatusMessage) {
|
||||
return;
|
||||
}
|
||||
folderStatusMessage.textContent = message || '';
|
||||
if (state) {
|
||||
folderStatusMessage.dataset.state = state;
|
||||
folderStatusMessage.hidden = false;
|
||||
} else {
|
||||
delete folderStatusMessage.dataset.state;
|
||||
folderStatusMessage.hidden = !message;
|
||||
}
|
||||
}
|
||||
|
||||
function clearFolderModalContents() {
|
||||
if (folderList) {
|
||||
folderList.innerHTML = '';
|
||||
}
|
||||
if (folderEmptyState) {
|
||||
folderEmptyState.textContent = defaultFolderEmptyMessage;
|
||||
folderEmptyState.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function openFolderModal(opener) {
|
||||
if (!folderModal) {
|
||||
return;
|
||||
}
|
||||
folderModalOpener = opener || null;
|
||||
folderModalPreviousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
folderModal.hidden = false;
|
||||
folderModal.dataset.open = 'true';
|
||||
document.body.classList.add('modal-open');
|
||||
if (folderFilter) {
|
||||
folderFilter.value = '';
|
||||
folderFilter.disabled = true;
|
||||
}
|
||||
clearFolderModalContents();
|
||||
setFolderModalStatus('Loading folders...', 'loading');
|
||||
}
|
||||
|
||||
function closeFolderModal(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (!folderModal || folderModal.hidden) {
|
||||
return;
|
||||
}
|
||||
folderModal.dataset.open = 'false';
|
||||
folderModal.hidden = true;
|
||||
document.body.classList.remove('modal-open');
|
||||
audiobookshelfFolderSource = [];
|
||||
if (folderFilter) {
|
||||
folderFilter.value = '';
|
||||
folderFilter.disabled = false;
|
||||
}
|
||||
clearFolderModalContents();
|
||||
setFolderModalStatus('', null);
|
||||
const focusTarget = folderModalPreviousFocus && typeof folderModalPreviousFocus.focus === 'function'
|
||||
? folderModalPreviousFocus
|
||||
: folderModalOpener;
|
||||
if (focusTarget && typeof focusTarget.focus === 'function') {
|
||||
focusTarget.focus({ preventScroll: false });
|
||||
}
|
||||
folderModalPreviousFocus = null;
|
||||
folderModalOpener = null;
|
||||
}
|
||||
|
||||
function openContractionModal(opener) {
|
||||
if (!contractionModal) {
|
||||
return;
|
||||
}
|
||||
contractionModalOpener = opener || null;
|
||||
contractionModalPreviousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
contractionModal.hidden = false;
|
||||
contractionModal.dataset.open = 'true';
|
||||
document.body.classList.add('modal-open');
|
||||
const focusTarget = contractionModal.querySelector('input, button, select, textarea');
|
||||
if (focusTarget instanceof HTMLElement) {
|
||||
focusTarget.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function closeContractionModal(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (!contractionModal || contractionModal.hidden) {
|
||||
return;
|
||||
}
|
||||
contractionModal.dataset.open = 'false';
|
||||
contractionModal.hidden = true;
|
||||
document.body.classList.remove('modal-open');
|
||||
const focusTarget =
|
||||
(contractionModalPreviousFocus && typeof contractionModalPreviousFocus.focus === 'function'
|
||||
? contractionModalPreviousFocus
|
||||
: contractionModalOpener) || null;
|
||||
if (focusTarget && typeof focusTarget.focus === 'function') {
|
||||
focusTarget.focus({ preventScroll: true });
|
||||
}
|
||||
contractionModalPreviousFocus = null;
|
||||
contractionModalOpener = null;
|
||||
}
|
||||
|
||||
function initContractionModal() {
|
||||
if (!contractionModal) {
|
||||
return;
|
||||
}
|
||||
const openButton = document.querySelector('[data-action="contraction-modal-open"]');
|
||||
if (openButton) {
|
||||
openButton.addEventListener('click', () => openContractionModal(openButton));
|
||||
}
|
||||
const closeButtons = contractionModal.querySelectorAll('[data-action="contraction-modal-close"]');
|
||||
closeButtons.forEach((button) => {
|
||||
button.addEventListener('click', closeContractionModal);
|
||||
});
|
||||
if (contractionModalOverlay) {
|
||||
contractionModalOverlay.addEventListener('click', closeContractionModal);
|
||||
}
|
||||
contractionModal.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeContractionModal(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderFolderList(query) {
|
||||
if (!folderList) {
|
||||
return;
|
||||
}
|
||||
folderList.innerHTML = '';
|
||||
const normalizedQuery = normalizeFolderToken(query);
|
||||
const matches = audiobookshelfFolderSource.filter((entry) => {
|
||||
const tokens = [
|
||||
normalizeFolderToken(entry.name),
|
||||
normalizeFolderToken(entry.path),
|
||||
normalizeFolderToken(entry.id),
|
||||
];
|
||||
return !normalizedQuery || tokens.some((token) => token.includes(normalizedQuery));
|
||||
});
|
||||
if (!matches.length) {
|
||||
if (folderEmptyState) {
|
||||
folderEmptyState.textContent = normalizedQuery ? defaultFolderEmptyMessage : 'No folders found for this library.';
|
||||
folderEmptyState.hidden = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (folderEmptyState) {
|
||||
folderEmptyState.textContent = defaultFolderEmptyMessage;
|
||||
folderEmptyState.hidden = true;
|
||||
}
|
||||
matches.forEach((entry) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'folder-picker__item';
|
||||
button.setAttribute('role', 'option');
|
||||
if (entry.id) {
|
||||
button.dataset.folderId = entry.id;
|
||||
}
|
||||
const displayName = entry.name || entry.path || entry.id || 'Unnamed folder';
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'folder-picker__item-name';
|
||||
nameEl.textContent = displayName;
|
||||
button.appendChild(nameEl);
|
||||
if (entry.path && (!entry.name || entry.path.toLowerCase() !== entry.name.toLowerCase())) {
|
||||
const pathEl = document.createElement('span');
|
||||
pathEl.className = 'folder-picker__item-path';
|
||||
pathEl.textContent = entry.path;
|
||||
button.appendChild(pathEl);
|
||||
}
|
||||
if (entry.id) {
|
||||
const idEl = document.createElement('span');
|
||||
idEl.className = 'folder-picker__item-id';
|
||||
idEl.textContent = entry.id;
|
||||
button.appendChild(idEl);
|
||||
}
|
||||
button.addEventListener('click', () => handleFolderSelection(entry));
|
||||
folderList.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
function populateFolderPicker(entries) {
|
||||
audiobookshelfFolderSource = Array.isArray(entries) ? entries : [];
|
||||
if (!audiobookshelfFolderSource.length) {
|
||||
if (folderFilter) {
|
||||
folderFilter.value = '';
|
||||
folderFilter.disabled = true;
|
||||
}
|
||||
setFolderModalStatus('No folders found for this library.', 'info');
|
||||
if (folderEmptyState) {
|
||||
folderEmptyState.textContent = 'No folders found for this library.';
|
||||
folderEmptyState.hidden = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (folderFilter) {
|
||||
folderFilter.disabled = false;
|
||||
folderFilter.value = '';
|
||||
folderFilter.focus({ preventScroll: true });
|
||||
}
|
||||
setFolderModalStatus('', null);
|
||||
if (folderEmptyState) {
|
||||
folderEmptyState.textContent = defaultFolderEmptyMessage;
|
||||
folderEmptyState.hidden = true;
|
||||
}
|
||||
renderFolderList('');
|
||||
}
|
||||
|
||||
function handleFolderSelection(entry) {
|
||||
const folderInput = form ? form.querySelector('#audiobookshelf_folder_id') : null;
|
||||
if (folderInput) {
|
||||
folderInput.value = entry.id || '';
|
||||
folderInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
closeFolderModal();
|
||||
const status = statusSelectors.audiobookshelf;
|
||||
if (status) {
|
||||
const label = entry.name || entry.path || entry.id || 'selected folder';
|
||||
setStatus(status, `Selected folder '${label}'.`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function initFolderPicker() {
|
||||
if (!folderModal) {
|
||||
return;
|
||||
}
|
||||
const closeButtons = folderModal.querySelectorAll('[data-action="audiobookshelf-folder-close"]');
|
||||
closeButtons.forEach((button) => {
|
||||
button.addEventListener('click', closeFolderModal);
|
||||
});
|
||||
if (folderModalOverlay) {
|
||||
folderModalOverlay.addEventListener('click', closeFolderModal);
|
||||
}
|
||||
if (folderFilter) {
|
||||
folderFilter.addEventListener('input', () => renderFolderList(folderFilter.value));
|
||||
}
|
||||
folderModal.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeFolderModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function collectLLMFields() {
|
||||
const baseUrl = form.querySelector('#llm_base_url');
|
||||
const apiKey = form.querySelector('#llm_api_key');
|
||||
const model = form.querySelector('#llm_model');
|
||||
const prompt = form.querySelector('#llm_prompt');
|
||||
const timeout = form.querySelector('#llm_timeout');
|
||||
const context = form.querySelector('input[name="llm_context_mode"]:checked');
|
||||
return {
|
||||
base_url: baseUrl ? baseUrl.value.trim() : '',
|
||||
api_key: apiKey ? apiKey.value.trim() : '',
|
||||
model: model ? model.value.trim() : '',
|
||||
prompt: prompt ? prompt.value : '',
|
||||
context_mode: context ? context.value : 'sentence',
|
||||
timeout: timeout ? parseNumber(timeout.value, 30) : 30,
|
||||
};
|
||||
}
|
||||
|
||||
function updateModelOptions(models) {
|
||||
const select = form.querySelector('#llm_model');
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
const current = select.dataset.currentModel || select.value;
|
||||
select.innerHTML = '';
|
||||
if (!Array.isArray(models) || !models.length) {
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = 'No models found';
|
||||
select.appendChild(option);
|
||||
select.dataset.currentModel = '';
|
||||
select.disabled = true;
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
let matchedCurrent = false;
|
||||
models.forEach((entry) => {
|
||||
let identifier = '';
|
||||
let label = '';
|
||||
if (typeof entry === 'string') {
|
||||
identifier = entry;
|
||||
label = entry;
|
||||
} else if (entry && typeof entry === 'object') {
|
||||
identifier = String(entry.id || entry.name || entry.label || '').trim();
|
||||
label = String(entry.label || entry.name || identifier || '').trim();
|
||||
}
|
||||
if (!identifier) {
|
||||
return;
|
||||
}
|
||||
if (!label) {
|
||||
label = identifier;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = identifier;
|
||||
option.textContent = label;
|
||||
if (identifier === current) {
|
||||
option.selected = true;
|
||||
matchedCurrent = true;
|
||||
}
|
||||
fragment.appendChild(option);
|
||||
});
|
||||
select.appendChild(fragment);
|
||||
if (!matchedCurrent && select.options.length) {
|
||||
select.selectedIndex = 0;
|
||||
}
|
||||
select.dataset.currentModel = select.value || '';
|
||||
select.disabled = false;
|
||||
}
|
||||
|
||||
async function refreshModels(button) {
|
||||
const status = statusSelectors.llm;
|
||||
const llmFields = collectLLMFields();
|
||||
if (!llmFields.base_url) {
|
||||
setStatus(status, 'Enter a base URL before refreshing models.', 'error');
|
||||
return;
|
||||
}
|
||||
clearStatus(status);
|
||||
setStatus(status, 'Fetching models…');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/llm/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
base_url: llmFields.base_url,
|
||||
api_key: llmFields.api_key,
|
||||
timeout: llmFields.timeout,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Unable to load models.');
|
||||
}
|
||||
updateModelOptions(payload.models || []);
|
||||
const count = Array.isArray(payload.models) ? payload.models.length : 0;
|
||||
if (count) {
|
||||
setStatus(status, `Loaded ${count} model${count === 1 ? '' : 's'}.`, 'success');
|
||||
} else {
|
||||
setStatus(status, 'No models were returned.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(status, error instanceof Error ? error.message : 'Failed to load models.', 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function previewLLM(button) {
|
||||
const status = statusSelectors.llm;
|
||||
const output = outputAreas.llm;
|
||||
const previewText = document.querySelector('#llm_preview_text');
|
||||
if (!previewText) {
|
||||
return;
|
||||
}
|
||||
const llmFields = collectLLMFields();
|
||||
if (!llmFields.base_url) {
|
||||
setStatus(status, 'Enter a base URL to preview.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!llmFields.model) {
|
||||
setStatus(status, 'Select a model to preview.', 'error');
|
||||
return;
|
||||
}
|
||||
const sample = previewText.value.trim();
|
||||
if (!sample) {
|
||||
setStatus(status, 'Add some sample text first.', 'error');
|
||||
return;
|
||||
}
|
||||
clearStatus(status);
|
||||
if (output) {
|
||||
output.textContent = '';
|
||||
}
|
||||
setStatus(status, 'Generating preview…');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/llm/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: sample,
|
||||
base_url: llmFields.base_url,
|
||||
api_key: llmFields.api_key,
|
||||
model: llmFields.model,
|
||||
prompt: llmFields.prompt,
|
||||
context_mode: llmFields.context_mode,
|
||||
timeout: llmFields.timeout,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Preview failed.');
|
||||
}
|
||||
if (output) {
|
||||
output.textContent = payload.normalized_text || '';
|
||||
}
|
||||
setStatus(status, 'Preview ready.', 'success');
|
||||
} catch (error) {
|
||||
if (output) {
|
||||
output.textContent = '';
|
||||
}
|
||||
setStatus(status, error instanceof Error ? error.message : 'Preview failed.', 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function collectNormalizationSettings() {
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
const normalization = {
|
||||
normalization_numbers: Boolean(form.querySelector('input[name="normalization_numbers"]')?.checked),
|
||||
normalization_currency: Boolean(form.querySelector('input[name="normalization_currency"]')?.checked),
|
||||
normalization_titles: Boolean(form.querySelector('input[name="normalization_titles"]')?.checked),
|
||||
normalization_footnotes: Boolean(form.querySelector('input[name="normalization_footnotes"]')?.checked),
|
||||
normalization_terminal: Boolean(form.querySelector('input[name="normalization_terminal"]')?.checked),
|
||||
normalization_caps_quotes: Boolean(form.querySelector('input[name="normalization_caps_quotes"]')?.checked),
|
||||
normalization_phoneme_hints: Boolean(form.querySelector('input[name="normalization_phoneme_hints"]')?.checked),
|
||||
normalization_apostrophes_contractions: Boolean(form.querySelector('input[name="normalization_apostrophes_contractions"]')?.checked),
|
||||
normalization_apostrophes_plural_possessives: Boolean(form.querySelector('input[name="normalization_apostrophes_plural_possessives"]')?.checked),
|
||||
normalization_apostrophes_sibilant_possessives: Boolean(form.querySelector('input[name="normalization_apostrophes_sibilant_possessives"]')?.checked),
|
||||
normalization_apostrophes_decades: Boolean(form.querySelector('input[name="normalization_apostrophes_decades"]')?.checked),
|
||||
normalization_apostrophes_leading_elisions: Boolean(form.querySelector('input[name="normalization_apostrophes_leading_elisions"]')?.checked),
|
||||
normalization_contraction_aux_be: Boolean(form.querySelector('input[name="normalization_contraction_aux_be"]')?.checked),
|
||||
normalization_contraction_aux_have: Boolean(form.querySelector('input[name="normalization_contraction_aux_have"]')?.checked),
|
||||
normalization_contraction_modal_will: Boolean(form.querySelector('input[name="normalization_contraction_modal_will"]')?.checked),
|
||||
normalization_contraction_modal_would: Boolean(form.querySelector('input[name="normalization_contraction_modal_would"]')?.checked),
|
||||
normalization_contraction_negation_not: Boolean(form.querySelector('input[name="normalization_contraction_negation_not"]')?.checked),
|
||||
normalization_contraction_let_us: Boolean(form.querySelector('input[name="normalization_contraction_let_us"]')?.checked),
|
||||
normalization_apostrophe_mode: form.querySelector('input[name="normalization_apostrophe_mode"]:checked')?.value || 'spacy',
|
||||
};
|
||||
return normalization;
|
||||
}
|
||||
|
||||
function collectCalibreFields() {
|
||||
if (!form) {
|
||||
return {};
|
||||
}
|
||||
const enabled = Boolean(form.querySelector('input[name="calibre_opds_enabled"]')?.checked);
|
||||
const baseUrl = form.querySelector('#calibre_opds_base_url')?.value?.trim() || '';
|
||||
const username = form.querySelector('#calibre_opds_username')?.value?.trim() || '';
|
||||
const passwordInput = form.querySelector('#calibre_opds_password');
|
||||
const password = passwordInput ? passwordInput.value : '';
|
||||
const hasSecret = passwordInput?.dataset.hasSecret === 'true';
|
||||
const clearSaved = Boolean(form.querySelector('input[name="calibre_opds_password_clear"]')?.checked);
|
||||
const useSavedPassword = !password && hasSecret && !clearSaved;
|
||||
const verify = Boolean(form.querySelector('input[name="calibre_opds_verify_ssl"]')?.checked);
|
||||
return {
|
||||
enabled,
|
||||
base_url: baseUrl,
|
||||
username,
|
||||
password,
|
||||
verify_ssl: verify,
|
||||
use_saved_password: useSavedPassword,
|
||||
clear_saved_password: clearSaved,
|
||||
};
|
||||
}
|
||||
|
||||
function collectAudiobookshelfFields() {
|
||||
if (!form) {
|
||||
return {};
|
||||
}
|
||||
const baseUrl = form.querySelector('#audiobookshelf_base_url')?.value?.trim() || '';
|
||||
const libraryId = form.querySelector('#audiobookshelf_library_id')?.value?.trim() || '';
|
||||
const collectionId = form.querySelector('#audiobookshelf_collection_id')?.value?.trim() || '';
|
||||
const folderId = form.querySelector('#audiobookshelf_folder_id')?.value?.trim() || '';
|
||||
const tokenInput = form.querySelector('#audiobookshelf_api_token');
|
||||
const apiToken = tokenInput?.value?.trim() || '';
|
||||
const hasSecret = tokenInput?.dataset.hasSecret === 'true';
|
||||
const clearToken = Boolean(form.querySelector('input[name="audiobookshelf_api_token_clear"]')?.checked);
|
||||
const useSavedToken = !apiToken && hasSecret && !clearToken;
|
||||
const timeoutInput = form.querySelector('#audiobookshelf_timeout');
|
||||
const timeout = parseNumber(timeoutInput?.value, 30);
|
||||
return {
|
||||
enabled: Boolean(form.querySelector('input[name="audiobookshelf_enabled"]')?.checked),
|
||||
auto_send: Boolean(form.querySelector('input[name="audiobookshelf_auto_send"]')?.checked),
|
||||
verify_ssl: Boolean(form.querySelector('input[name="audiobookshelf_verify_ssl"]')?.checked),
|
||||
base_url: baseUrl,
|
||||
library_id: libraryId,
|
||||
collection_id: collectionId,
|
||||
folder_id: folderId,
|
||||
api_token: apiToken,
|
||||
use_saved_token: useSavedToken,
|
||||
clear_saved_token: clearToken,
|
||||
timeout,
|
||||
send_cover: Boolean(form.querySelector('input[name="audiobookshelf_send_cover"]')?.checked),
|
||||
send_chapters: Boolean(form.querySelector('input[name="audiobookshelf_send_chapters"]')?.checked),
|
||||
send_subtitles: Boolean(form.querySelector('input[name="audiobookshelf_send_subtitles"]')?.checked),
|
||||
};
|
||||
}
|
||||
|
||||
function updateLLMNavState() {
|
||||
if (!llmNavButton) {
|
||||
return;
|
||||
}
|
||||
const fields = collectLLMFields();
|
||||
if (fields.base_url && fields.api_key) {
|
||||
llmNavButton.classList.remove('is-disabled');
|
||||
} else {
|
||||
llmNavButton.classList.add('is-disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async function testCalibre(button) {
|
||||
const status = statusSelectors.calibre;
|
||||
const fields = collectCalibreFields();
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
if (!fields.base_url) {
|
||||
setStatus(status, 'Enter a Calibre OPDS base URL to test.', 'error');
|
||||
return;
|
||||
}
|
||||
clearStatus(status);
|
||||
setStatus(status, 'Testing connection…');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/integrations/calibre-opds/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Calibre test failed.');
|
||||
}
|
||||
setStatus(status, payload.message || 'Connection successful.', 'success');
|
||||
} catch (error) {
|
||||
setStatus(status, error instanceof Error ? error.message : 'Calibre test failed.', 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testAudiobookshelf(button) {
|
||||
const status = statusSelectors.audiobookshelf;
|
||||
const fields = collectAudiobookshelfFields();
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
const hasToken = Boolean(fields.api_token) || Boolean(fields.use_saved_token);
|
||||
if (!fields.base_url || !hasToken || !fields.library_id || !fields.folder_id) {
|
||||
setStatus(status, 'Enter the base URL, API token, library ID, and folder name or ID to test.', 'error');
|
||||
return;
|
||||
}
|
||||
clearStatus(status);
|
||||
setStatus(status, 'Testing connection…');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/integrations/audiobookshelf/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Audiobookshelf test failed.');
|
||||
}
|
||||
setStatus(status, payload.message || 'Connection successful.', 'success');
|
||||
} catch (error) {
|
||||
setStatus(status, error instanceof Error ? error.message : 'Audiobookshelf test failed.', 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function browseAudiobookshelfFolders(button) {
|
||||
const status = statusSelectors.audiobookshelf;
|
||||
const fields = collectAudiobookshelfFields();
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
const hasToken = Boolean(fields.api_token) || Boolean(fields.use_saved_token);
|
||||
if (!fields.base_url || !hasToken || !fields.library_id) {
|
||||
setStatus(status, 'Enter the base URL, API token, and library ID before browsing folders.', 'error');
|
||||
return;
|
||||
}
|
||||
clearStatus(status);
|
||||
openFolderModal(button);
|
||||
if (!folderModal) {
|
||||
setStatus(status, 'Folder picker is unavailable in this view.', 'error');
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/integrations/audiobookshelf/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Folder lookup failed.');
|
||||
}
|
||||
const folders = Array.isArray(payload.folders) ? payload.folders : [];
|
||||
const modalActive = folderModal && !folderModal.hidden;
|
||||
if (!folders.length) {
|
||||
const message = payload.message || 'No folders found for this library.';
|
||||
setStatus(status, message, 'info');
|
||||
if (modalActive) {
|
||||
clearFolderModalContents();
|
||||
setFolderModalStatus(message, 'info');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!modalActive) {
|
||||
setStatus(status, 'Folders loaded.', 'info');
|
||||
return;
|
||||
}
|
||||
populateFolderPicker(folders);
|
||||
setStatus(status, 'Choose a folder below.', 'info');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Folder lookup failed.';
|
||||
setStatus(status, message, 'error');
|
||||
if (folderModal && !folderModal.hidden) {
|
||||
clearFolderModalContents();
|
||||
setFolderModalStatus(message, 'error');
|
||||
}
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function previewNormalization(button) {
|
||||
const status = statusSelectors.normalization;
|
||||
const output = outputAreas.normalization;
|
||||
const textArea = document.querySelector('#normalization_sample_text');
|
||||
const voiceSelect = document.querySelector('#normalization_sample_voice');
|
||||
if (!textArea) {
|
||||
return;
|
||||
}
|
||||
const sample = textArea.value.trim();
|
||||
if (!sample) {
|
||||
setStatus(status, 'Enter some text to preview.', 'error');
|
||||
return;
|
||||
}
|
||||
clearStatus(status);
|
||||
if (output) {
|
||||
output.textContent = '';
|
||||
}
|
||||
if (normalizationAudio) {
|
||||
normalizationAudio.hidden = true;
|
||||
normalizationAudio.removeAttribute('src');
|
||||
}
|
||||
setStatus(status, 'Building preview…');
|
||||
const normalization = collectNormalizationSettings();
|
||||
if (!normalization) {
|
||||
setStatus(status, 'Unable to gather normalization settings.', 'error');
|
||||
return;
|
||||
}
|
||||
const llmFields = collectLLMFields();
|
||||
try {
|
||||
const response = await fetch('/api/normalization/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: sample,
|
||||
voice: voiceSelect ? voiceSelect.value : undefined,
|
||||
normalization,
|
||||
llm: {
|
||||
llm_base_url: llmFields.base_url,
|
||||
llm_api_key: llmFields.api_key,
|
||||
llm_model: llmFields.model,
|
||||
llm_prompt: llmFields.prompt,
|
||||
llm_context_mode: llmFields.context_mode,
|
||||
llm_timeout: llmFields.timeout,
|
||||
},
|
||||
max_seconds: 8,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Preview failed.');
|
||||
}
|
||||
if (output) {
|
||||
output.textContent = payload.normalized_text || '';
|
||||
}
|
||||
if (payload.audio_base64 && normalizationAudio) {
|
||||
normalizationAudio.src = `data:audio/wav;base64,${payload.audio_base64}`;
|
||||
normalizationAudio.hidden = false;
|
||||
normalizationAudio.load();
|
||||
normalizationAudio.play().catch(() => {
|
||||
/* autoplay can fail; ignore */
|
||||
});
|
||||
}
|
||||
setStatus(status, 'Preview updated.', 'success');
|
||||
} catch (error) {
|
||||
if (output) {
|
||||
output.textContent = '';
|
||||
}
|
||||
if (normalizationAudio) {
|
||||
normalizationAudio.hidden = true;
|
||||
normalizationAudio.removeAttribute('src');
|
||||
}
|
||||
setStatus(status, error instanceof Error ? error.message : 'Preview failed.', 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function initSampleSelector() {
|
||||
const select = document.querySelector('#normalization_sample_select');
|
||||
const textArea = document.querySelector('#normalization_sample_text');
|
||||
if (!select || !textArea) {
|
||||
return;
|
||||
}
|
||||
select.addEventListener('change', () => {
|
||||
const option = select.selectedOptions[0];
|
||||
if (option) {
|
||||
textArea.value = option.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initActions() {
|
||||
const refreshButton = document.querySelector('[data-action="llm-refresh-models"]');
|
||||
if (refreshButton) {
|
||||
refreshButton.addEventListener('click', () => refreshModels(refreshButton));
|
||||
}
|
||||
const llmPreviewButton = document.querySelector('[data-action="llm-preview"]');
|
||||
if (llmPreviewButton) {
|
||||
llmPreviewButton.addEventListener('click', () => previewLLM(llmPreviewButton));
|
||||
}
|
||||
const normalizationButton = document.querySelector('[data-action="normalization-preview"]');
|
||||
if (normalizationButton) {
|
||||
normalizationButton.addEventListener('click', () => previewNormalization(normalizationButton));
|
||||
}
|
||||
const calibreTestButton = document.querySelector('[data-action="calibre-test"]');
|
||||
if (calibreTestButton) {
|
||||
calibreTestButton.addEventListener('click', () => testCalibre(calibreTestButton));
|
||||
}
|
||||
const audiobookshelfTestButton = document.querySelector('[data-action="audiobookshelf-test"]');
|
||||
if (audiobookshelfTestButton) {
|
||||
audiobookshelfTestButton.addEventListener('click', () => testAudiobookshelf(audiobookshelfTestButton));
|
||||
}
|
||||
const audiobookshelfBrowseButton = document.querySelector('[data-action="audiobookshelf-list-folders"]');
|
||||
if (audiobookshelfBrowseButton) {
|
||||
audiobookshelfBrowseButton.addEventListener('click', () => browseAudiobookshelfFolders(audiobookshelfBrowseButton));
|
||||
}
|
||||
}
|
||||
|
||||
function initLLMStateWatchers() {
|
||||
const baseUrlInput = form.querySelector('#llm_base_url');
|
||||
const apiKeyInput = form.querySelector('#llm_api_key');
|
||||
if (!baseUrlInput || !apiKeyInput) {
|
||||
return;
|
||||
}
|
||||
const handler = () => updateLLMNavState();
|
||||
baseUrlInput.addEventListener('input', handler);
|
||||
apiKeyInput.addEventListener('input', handler);
|
||||
updateLLMNavState();
|
||||
}
|
||||
|
||||
if (form) {
|
||||
initNavigation();
|
||||
initSampleSelector();
|
||||
initActions();
|
||||
initFolderPicker();
|
||||
initContractionModal();
|
||||
initLLMStateWatchers();
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
const initSpeakerConfigsPage = () => {
|
||||
const form = document.getElementById("speaker-config-form");
|
||||
if (!form) return;
|
||||
|
||||
const rowsContainer = form.querySelector('[data-role="speaker-rows"]');
|
||||
const template = document.getElementById("speaker-row-template");
|
||||
const addButtons = form.querySelectorAll('[data-action="add-speaker"]');
|
||||
|
||||
const ensureEmptyState = () => {
|
||||
if (!rowsContainer) return;
|
||||
const hasRows = rowsContainer.querySelector('[data-role="speaker-row"]');
|
||||
let emptyState = rowsContainer.querySelector('[data-role="empty-state"]');
|
||||
if (hasRows && emptyState) {
|
||||
emptyState.remove();
|
||||
emptyState = null;
|
||||
}
|
||||
if (!hasRows && !emptyState) {
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "speaker-config-rows__empty";
|
||||
placeholder.dataset.role = "empty-state";
|
||||
placeholder.textContent = "No speakers yet. Add your first character.";
|
||||
rowsContainer.appendChild(placeholder);
|
||||
}
|
||||
};
|
||||
|
||||
const hydrateRow = (fragment, key) => {
|
||||
const elements = fragment.querySelectorAll("[name], [id], label[for], [data-row-id]");
|
||||
elements.forEach((el) => {
|
||||
if (el.name) {
|
||||
el.name = el.name.replace(/__ROW__/g, key);
|
||||
}
|
||||
if (el.id) {
|
||||
el.id = el.id.replace(/__ROW__/g, key);
|
||||
}
|
||||
if (el.tagName === "LABEL") {
|
||||
const forValue = el.getAttribute("for");
|
||||
if (forValue) {
|
||||
el.setAttribute("for", forValue.replace(/__ROW__/g, key));
|
||||
}
|
||||
}
|
||||
if (el.dataset && el.dataset.rowId) {
|
||||
el.dataset.rowId = key;
|
||||
}
|
||||
});
|
||||
|
||||
const hiddenId = fragment.querySelector(`input[name="speaker-${key}-id"]`);
|
||||
if (hiddenId && !hiddenId.value) {
|
||||
hiddenId.value = key;
|
||||
}
|
||||
const rowMarkers = fragment.querySelectorAll('input[name="speaker_rows"]');
|
||||
rowMarkers.forEach((marker) => {
|
||||
marker.value = key;
|
||||
});
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
if (!template || !rowsContainer) return;
|
||||
const key = `row-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
const fragment = template.content.cloneNode(true);
|
||||
hydrateRow(fragment, key);
|
||||
rowsContainer.appendChild(fragment);
|
||||
ensureEmptyState();
|
||||
const newRow = rowsContainer.querySelector(`[data-row-id="${key}"]`);
|
||||
if (newRow) {
|
||||
const input = newRow.querySelector("input[type=text]");
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
addButtons.forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
addRow();
|
||||
});
|
||||
});
|
||||
|
||||
rowsContainer?.addEventListener("click", (event) => {
|
||||
const removeButton = event.target.closest('[data-action="remove-speaker"]');
|
||||
if (!removeButton) return;
|
||||
event.preventDefault();
|
||||
const row = removeButton.closest('[data-role="speaker-row"]');
|
||||
if (row) {
|
||||
row.remove();
|
||||
ensureEmptyState();
|
||||
}
|
||||
});
|
||||
|
||||
ensureEmptyState();
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initSpeakerConfigsPage, { once: true });
|
||||
} else {
|
||||
initSpeakerConfigsPage();
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
const audioElement = new Audio();
|
||||
let activeButton = null;
|
||||
let activeUrl = null;
|
||||
|
||||
const setLoadingState = (button, isLoading) => {
|
||||
if (!button) return;
|
||||
button.disabled = isLoading;
|
||||
if (isLoading) {
|
||||
button.setAttribute("data-loading", "true");
|
||||
} else {
|
||||
button.removeAttribute("data-loading");
|
||||
}
|
||||
};
|
||||
|
||||
const stopCurrentPlayback = () => {
|
||||
if (audioElement && !audioElement.paused) {
|
||||
audioElement.pause();
|
||||
}
|
||||
if (activeUrl) {
|
||||
URL.revokeObjectURL(activeUrl);
|
||||
activeUrl = null;
|
||||
}
|
||||
if (activeButton) {
|
||||
setLoadingState(activeButton, false);
|
||||
activeButton = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvePreviewText = (button) => {
|
||||
const source = (button.dataset.previewSource || "").toLowerCase();
|
||||
if (source === "pronunciation") {
|
||||
const container = button.closest(".speaker-list__item");
|
||||
if (container) {
|
||||
const input = container.querySelector('[data-role="speaker-pronunciation"]');
|
||||
const fallback = (container.dataset.defaultPronunciation || "").trim();
|
||||
const value = (input?.value || "").trim() || fallback;
|
||||
button.dataset.previewText = value;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return (button.dataset.previewText || "").trim();
|
||||
};
|
||||
|
||||
audioElement.addEventListener("ended", () => {
|
||||
stopCurrentPlayback();
|
||||
});
|
||||
|
||||
audioElement.addEventListener("pause", () => {
|
||||
if (audioElement.currentTime === 0 || audioElement.currentTime >= audioElement.duration) {
|
||||
stopCurrentPlayback();
|
||||
}
|
||||
});
|
||||
|
||||
const playPreview = async (button) => {
|
||||
const text = resolvePreviewText(button);
|
||||
const voice = (button.dataset.voice || "").trim();
|
||||
const language = (button.dataset.language || "a").trim() || "a";
|
||||
const speedRaw = button.dataset.speed || "1";
|
||||
const useGpu = (button.dataset.useGpu || "true") !== "false";
|
||||
const speed = Number.parseFloat(speedRaw);
|
||||
|
||||
if (!text) {
|
||||
console.warn("Skipping speaker preview: no text provided");
|
||||
return;
|
||||
}
|
||||
if (!voice) {
|
||||
console.warn("Skipping speaker preview: no voice provided");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
text,
|
||||
voice,
|
||||
language,
|
||||
speed: Number.isFinite(speed) ? speed : 1.0,
|
||||
use_gpu: useGpu,
|
||||
max_seconds: 8,
|
||||
};
|
||||
|
||||
const pendingId =
|
||||
button.dataset.pendingId ||
|
||||
button.closest("[data-pending-id]")?.dataset.pendingId ||
|
||||
document.querySelector('[data-role="prepare-form"]')?.dataset.pendingId ||
|
||||
"";
|
||||
if (pendingId) {
|
||||
payload.pending_id = pendingId;
|
||||
}
|
||||
|
||||
stopCurrentPlayback();
|
||||
activeButton = button;
|
||||
setLoadingState(button, true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/speaker-preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `Preview failed with status ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
activeUrl = URL.createObjectURL(blob);
|
||||
audioElement.src = activeUrl;
|
||||
await audioElement.play();
|
||||
} catch (error) {
|
||||
console.error("Failed to play speaker preview", error);
|
||||
stopCurrentPlayback();
|
||||
} finally {
|
||||
setLoadingState(button, false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const trigger = event.target.closest('[data-role="speaker-preview"]');
|
||||
if (!trigger) return;
|
||||
event.preventDefault();
|
||||
if (trigger.disabled) return;
|
||||
playPreview(trigger);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,514 @@
|
||||
const STEP_ORDER = ["book", "chapters", "entities"];
|
||||
const 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.",
|
||||
},
|
||||
};
|
||||
|
||||
const wizardState = (window.AbogenWizardState = window.AbogenWizardState || {
|
||||
initialized: false,
|
||||
modal: null,
|
||||
stage: null,
|
||||
submitting: false,
|
||||
initialStep: "book",
|
||||
initialStageMarkup: "",
|
||||
});
|
||||
|
||||
const normalizeStep = (step) => {
|
||||
let value = (step || "book").toLowerCase();
|
||||
if (value === "speakers") {
|
||||
value = "entities";
|
||||
}
|
||||
if (value === "settings" || value === "upload" || value === "") {
|
||||
value = "book";
|
||||
}
|
||||
if (!STEP_ORDER.includes(value)) {
|
||||
return "book";
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const setButtonLoading = (button, isLoading) => {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
if (isLoading) {
|
||||
if (!button.dataset.originalDisabled) {
|
||||
button.dataset.originalDisabled = button.disabled ? "true" : "false";
|
||||
}
|
||||
button.disabled = true;
|
||||
button.dataset.loading = "true";
|
||||
button.setAttribute("aria-busy", "true");
|
||||
} else {
|
||||
if (button.dataset.loading) {
|
||||
delete button.dataset.loading;
|
||||
}
|
||||
button.removeAttribute("aria-busy");
|
||||
const original = button.dataset.originalDisabled;
|
||||
if (original !== undefined) {
|
||||
button.disabled = original === "true";
|
||||
delete button.dataset.originalDisabled;
|
||||
} else {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setSubmitting = (modal, isSubmitting, button) => {
|
||||
if (!modal) return;
|
||||
wizardState.submitting = isSubmitting;
|
||||
if (isSubmitting) {
|
||||
modal.dataset.submitting = "true";
|
||||
modal.setAttribute("aria-busy", "true");
|
||||
} else {
|
||||
delete modal.dataset.submitting;
|
||||
modal.removeAttribute("aria-busy");
|
||||
}
|
||||
setButtonLoading(button, isSubmitting);
|
||||
};
|
||||
|
||||
const resetWizardToInitial = () => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
wizardState.submitting = false;
|
||||
delete modal.dataset.submitting;
|
||||
modal.removeAttribute("aria-busy");
|
||||
modal.dataset.pendingId = "";
|
||||
const step = normalizeStep(wizardState.initialStep || modal.dataset.step || "book");
|
||||
modal.dataset.step = step;
|
||||
updateHeaderCopy(modal, step);
|
||||
updateFilenameLabel(modal, "");
|
||||
const stage = modal.querySelector('[data-role="wizard-stage"]');
|
||||
if (stage) {
|
||||
wizardState.stage = stage;
|
||||
destroyTransientAlerts(stage);
|
||||
if (typeof wizardState.initialStageMarkup === "string" && wizardState.initialStageMarkup) {
|
||||
stage.innerHTML = wizardState.initialStageMarkup;
|
||||
reinitializeStageModules(stage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const findModal = () => document.querySelector('[data-role="new-job-modal"]');
|
||||
|
||||
const ensureModalRef = () => {
|
||||
if (wizardState.modal && wizardState.modal.isConnected) {
|
||||
return wizardState.modal;
|
||||
}
|
||||
wizardState.modal = findModal();
|
||||
return wizardState.modal;
|
||||
};
|
||||
|
||||
const dispatchWizardEvent = (modal, type, detail = {}) => {
|
||||
if (!modal) return;
|
||||
const event = new CustomEvent(`wizard:${type}`, { bubbles: true, detail });
|
||||
modal.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const destroyTransientAlerts = (stage) => {
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
const alerts = stage.querySelectorAll('[data-role="wizard-error"]');
|
||||
alerts.forEach((alert) => alert.remove());
|
||||
};
|
||||
|
||||
const displayTransientError = (modal, message) => {
|
||||
if (!modal) return;
|
||||
const stage = modal.querySelector('[data-role="wizard-stage"]');
|
||||
if (!stage) return;
|
||||
const existing = stage.querySelector('[data-role="wizard-error"]');
|
||||
if (existing) {
|
||||
existing.textContent = message;
|
||||
return;
|
||||
}
|
||||
const alert = document.createElement("div");
|
||||
alert.className = "alert alert--error";
|
||||
alert.dataset.role = "wizard-error";
|
||||
alert.textContent = message;
|
||||
stage.prepend(alert);
|
||||
};
|
||||
|
||||
const updateStepIndicators = (modal, activeStep, payload) => {
|
||||
const indicators = modal.querySelectorAll('[data-role="wizard-step-indicator"]');
|
||||
const activeIndex = STEP_ORDER.indexOf(activeStep);
|
||||
const completedList = Array.isArray(payload?.completed_steps) ? payload.completed_steps : [];
|
||||
const completedSet = new Set(completedList.map((step) => normalizeStep(step)));
|
||||
indicators.forEach((indicator) => {
|
||||
const step = normalizeStep(indicator.dataset.step || "book");
|
||||
indicator.classList.remove("is-active", "is-complete");
|
||||
const index = STEP_ORDER.indexOf(step);
|
||||
const isActive = index === activeIndex;
|
||||
const visited = completedSet.has(step);
|
||||
const isComplete = !isActive && (visited || (index > -1 && index < activeIndex));
|
||||
indicator.classList.toggle("is-complete", isComplete);
|
||||
indicator.classList.toggle("is-active", isActive);
|
||||
if (indicator instanceof HTMLButtonElement) {
|
||||
const clickable = isComplete && !isActive;
|
||||
indicator.disabled = !clickable;
|
||||
indicator.setAttribute("aria-disabled", clickable ? "false" : "true");
|
||||
indicator.setAttribute("aria-current", isActive ? "step" : "false");
|
||||
if (clickable) {
|
||||
indicator.dataset.state = "clickable";
|
||||
} else {
|
||||
delete indicator.dataset.state;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateHeaderCopy = (modal, step, payload) => {
|
||||
const meta = STEP_META[step];
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
const titleEl = modal.querySelector("#new-job-modal-title");
|
||||
const hintEl = modal.querySelector('[data-role="wizard-hint"]');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = payload?.title || meta.title;
|
||||
}
|
||||
if (hintEl) {
|
||||
hintEl.textContent = payload?.hint || meta.hint;
|
||||
}
|
||||
updateStepIndicators(modal, step, payload);
|
||||
};
|
||||
|
||||
const updateFilenameLabel = (modal, filename) => {
|
||||
const label = modal.querySelector(".wizard-card__filename");
|
||||
if (!label) return;
|
||||
if (filename) {
|
||||
label.hidden = false;
|
||||
label.textContent = filename;
|
||||
label.setAttribute("title", filename);
|
||||
} else {
|
||||
label.hidden = true;
|
||||
label.textContent = "";
|
||||
label.removeAttribute("title");
|
||||
}
|
||||
};
|
||||
|
||||
const reinitializeStageModules = (stage) => {
|
||||
if (!stage) return;
|
||||
if (window.AbogenDashboard?.init) {
|
||||
window.AbogenDashboard.init();
|
||||
}
|
||||
if (window.AbogenPrepare?.init) {
|
||||
window.AbogenPrepare.init(stage);
|
||||
}
|
||||
};
|
||||
|
||||
const focusFirstInteractive = (stage) => {
|
||||
if (!stage) return;
|
||||
const focusable = stage.querySelector(
|
||||
'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])'
|
||||
);
|
||||
if (focusable instanceof HTMLElement) {
|
||||
try {
|
||||
focusable.focus({ preventScroll: true });
|
||||
} catch (error) {
|
||||
// Ignore focus errors, browser may block programmatic focus
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyWizardPayload = (payload) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
if (payload.pending_id !== undefined) {
|
||||
modal.dataset.pendingId = payload.pending_id || "";
|
||||
}
|
||||
const step = normalizeStep(payload.step || modal.dataset.step || "book");
|
||||
modal.dataset.step = step;
|
||||
modal.hidden = false;
|
||||
modal.dataset.open = "true";
|
||||
document.body.classList.add("modal-open");
|
||||
updateHeaderCopy(modal, step, payload);
|
||||
updateFilenameLabel(modal, payload.filename);
|
||||
|
||||
const stage = modal.querySelector('[data-role="wizard-stage"]');
|
||||
if (stage) {
|
||||
destroyTransientAlerts(stage);
|
||||
stage.innerHTML = payload.html || "";
|
||||
wizardState.stage = stage;
|
||||
reinitializeStageModules(stage);
|
||||
focusFirstInteractive(stage);
|
||||
}
|
||||
|
||||
const stepDetail = {
|
||||
step,
|
||||
index: STEP_META[step]?.index || STEP_ORDER.indexOf(step) + 1,
|
||||
total: STEP_ORDER.length,
|
||||
pendingId: modal.dataset.pendingId || "",
|
||||
notice: payload.notice || "",
|
||||
error: payload.error || "",
|
||||
};
|
||||
dispatchWizardEvent(modal, "step", stepDetail);
|
||||
};
|
||||
|
||||
const handleWizardRedirect = (payload) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
modal.hidden = true;
|
||||
delete modal.dataset.open;
|
||||
document.body.classList.remove("modal-open");
|
||||
resetWizardToInitial();
|
||||
dispatchWizardEvent(modal, "done", { redirectUrl: payload.redirect_url });
|
||||
if (payload.redirect_url) {
|
||||
window.location.assign(payload.redirect_url);
|
||||
}
|
||||
};
|
||||
|
||||
const processResponsePayload = (payload, responseOk) => {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
if (payload.redirect_url) {
|
||||
handleWizardRedirect(payload);
|
||||
return;
|
||||
}
|
||||
if (!payload.html && !responseOk) {
|
||||
const modal = ensureModalRef();
|
||||
displayTransientError(modal, payload.error || "Something went wrong. Try again.");
|
||||
return;
|
||||
}
|
||||
applyWizardPayload(payload);
|
||||
};
|
||||
|
||||
const requestWizardStep = async (url, { method = "GET", body = undefined } = {}) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
body,
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = text ? JSON.parse(text) : null;
|
||||
processResponsePayload(payload, response.ok);
|
||||
} catch (error) {
|
||||
console.error("Wizard request failed", error);
|
||||
displayTransientError(modal, error?.message || "Unable to update the wizard. Try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const submitWizardForm = async (form, submitter) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
if (wizardState.submitting) {
|
||||
return;
|
||||
}
|
||||
const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || window.location.href;
|
||||
const method = (submitter?.getAttribute("formmethod") || form.getAttribute("method") || "GET").toUpperCase();
|
||||
const stepTarget = submitter?.dataset?.stepTarget || "";
|
||||
const normalizedStepTarget = stepTarget ? stepTarget.toLowerCase() : "";
|
||||
if (normalizedStepTarget) {
|
||||
const activeInput = form.querySelector('[data-role="active-step-input"]');
|
||||
if (activeInput) {
|
||||
activeInput.value = normalizedStepTarget;
|
||||
}
|
||||
}
|
||||
const formData = new FormData(form);
|
||||
if (normalizedStepTarget) {
|
||||
formData.set("active_step", normalizedStepTarget);
|
||||
formData.set("next_step", normalizedStepTarget);
|
||||
}
|
||||
if (submitter && submitter.name && !formData.has(submitter.name)) {
|
||||
formData.append(submitter.name, submitter.value ?? "");
|
||||
}
|
||||
|
||||
// Ensure pending_id is included if available in modal state but missing from form
|
||||
if (!formData.get("pending_id") && modal && modal.dataset.pendingId) {
|
||||
formData.set("pending_id", modal.dataset.pendingId);
|
||||
}
|
||||
|
||||
const allowValidation = !submitter?.hasAttribute("formnovalidate") && !form.noValidate;
|
||||
if (allowValidation && typeof form.reportValidity === "function" && !form.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
destroyTransientAlerts(modal.querySelector('[data-role="wizard-stage"]'));
|
||||
setSubmitting(modal, true, submitter);
|
||||
try {
|
||||
const response = await fetch(action, {
|
||||
method,
|
||||
body: formData,
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch (parseError) {
|
||||
console.error("Failed to parse wizard response", parseError);
|
||||
displayTransientError(modal, "Received an invalid response. Try again.");
|
||||
return;
|
||||
}
|
||||
processResponsePayload(payload, response.ok);
|
||||
if (!response.ok && (!payload || !payload.html)) {
|
||||
displayTransientError(modal, payload?.error || `Request failed (${response.status})`);
|
||||
}
|
||||
} catch (networkError) {
|
||||
console.error("Wizard submission failed", networkError);
|
||||
displayTransientError(modal, networkError?.message || "Unable to submit form. Check your connection and try again.");
|
||||
} finally {
|
||||
setSubmitting(modal, false, submitter);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (button) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
const pendingId = button?.dataset.pendingId || modal.dataset.pendingId;
|
||||
const template = modal.dataset.cancelUrlTemplate || "";
|
||||
if (!pendingId || !template) {
|
||||
modal.hidden = true;
|
||||
delete modal.dataset.open;
|
||||
document.body.classList.remove("modal-open");
|
||||
dispatchWizardEvent(modal, "cancel", { pendingId });
|
||||
return;
|
||||
}
|
||||
const url = template.replace("__pending__", encodeURIComponent(pendingId));
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
const payload = text ? JSON.parse(text) : null;
|
||||
if (payload?.redirect_url) {
|
||||
handleWizardRedirect(payload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Cancel request failed", error);
|
||||
}
|
||||
modal.hidden = true;
|
||||
delete modal.dataset.open;
|
||||
document.body.classList.remove("modal-open");
|
||||
dispatchWizardEvent(modal, "cancel", { pendingId });
|
||||
resetWizardToInitial();
|
||||
};
|
||||
|
||||
const navigateToWizardStep = (targetStep, pendingOverride) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal || wizardState.submitting) {
|
||||
return;
|
||||
}
|
||||
const normalizedTarget = normalizeStep(targetStep || "book");
|
||||
const currentStep = modal.dataset.step ? normalizeStep(modal.dataset.step) : "book";
|
||||
if (normalizedTarget === currentStep) {
|
||||
return;
|
||||
}
|
||||
const pendingId = pendingOverride || modal.dataset.pendingId || "";
|
||||
const template = modal.dataset.prepareUrlTemplate || "";
|
||||
if (!pendingId || !template) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(template.replace("__pending__", encodeURIComponent(pendingId)), window.location.origin);
|
||||
url.searchParams.set("step", normalizedTarget);
|
||||
url.searchParams.set("format", "json");
|
||||
requestWizardStep(url.toString(), { method: "GET" });
|
||||
};
|
||||
|
||||
const handleBackToStep = (button) => {
|
||||
const targetStep = normalizeStep(button.dataset.targetStep || "book");
|
||||
navigateToWizardStep(targetStep, button.dataset.pendingId);
|
||||
};
|
||||
|
||||
const handleWizardClick = (event) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
const closeTarget = event.target.closest('[data-role="new-job-modal-close"]');
|
||||
if (closeTarget) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleCancel(closeTarget);
|
||||
return;
|
||||
}
|
||||
const cancelButton = event.target.closest('[data-role="wizard-cancel"]');
|
||||
if (cancelButton) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleCancel(cancelButton);
|
||||
return;
|
||||
}
|
||||
const backButton = event.target.closest('[data-role="wizard-back"]');
|
||||
if (backButton) {
|
||||
const targetStep = normalizeStep(backButton.dataset.targetStep || "book");
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleBackToStep(backButton);
|
||||
return;
|
||||
}
|
||||
const indicator = event.target.closest('[data-role="wizard-step-indicator"]');
|
||||
if (indicator instanceof HTMLButtonElement) {
|
||||
if (indicator.classList.contains("is-complete")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigateToWizardStep(indicator.dataset.step || "book");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleWizardSubmit = (event) => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
return;
|
||||
}
|
||||
if (form.dataset.wizardForm !== "true") {
|
||||
return;
|
||||
}
|
||||
const submitter = event.submitter || form.querySelector('button[type="submit"]');
|
||||
if (!submitter) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
submitWizardForm(form, submitter);
|
||||
};
|
||||
|
||||
const initWizard = () => {
|
||||
if (wizardState.initialized) {
|
||||
return;
|
||||
}
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
wizardState.initialized = true;
|
||||
wizardState.modal = modal;
|
||||
wizardState.stage = modal.querySelector('[data-role="wizard-stage"]');
|
||||
const initialStep = normalizeStep(modal.dataset.step || "book");
|
||||
if (!wizardState.initialStageMarkup && wizardState.stage) {
|
||||
wizardState.initialStageMarkup = wizardState.stage.innerHTML;
|
||||
wizardState.initialStep = initialStep;
|
||||
}
|
||||
modal.addEventListener("submit", handleWizardSubmit, true);
|
||||
modal.addEventListener("click", handleWizardClick);
|
||||
};
|
||||
|
||||
window.AbogenWizard = window.AbogenWizard || {};
|
||||
window.AbogenWizard.init = initWizard;
|
||||
window.AbogenWizard.requestStep = requestWizardStep;
|
||||
window.AbogenWizard.applyPayload = applyWizardPayload;
|
||||
|
||||
export { initWizard };
|
||||
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}abogen{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-bar">
|
||||
<div class="brand">
|
||||
<span class="brand__mark">🔊</span>
|
||||
<span class="brand__name">abogen</span>
|
||||
<span class="brand__tagline">Verba audiuntur</span>
|
||||
</div>
|
||||
<nav class="top-actions">
|
||||
{% set endpoint = request.endpoint or '' %}
|
||||
<a href="{{ url_for('main.index') }}" class="btn{% if endpoint == 'main.index' %} is-active{% endif %}">Dashboard</a>
|
||||
<a href="{{ url_for('voices.voice_profiles') }}" class="btn{% if endpoint == 'voices.voice_profiles' %} is-active{% endif %}">Speaker Studio</a>
|
||||
<a href="{{ url_for('entities.entities_page') }}" class="btn{% if endpoint == 'entities.entities_page' %} is-active{% endif %}">TTS Overrides</a>
|
||||
<a href="{{ url_for('books.find_books_page') }}" class="btn{% if endpoint == 'books.find_books_page' %} is-active{% endif %}">Find Books</a>
|
||||
<a href="{{ url_for('jobs.queue_page') }}" class="btn{% if endpoint in ['jobs.queue_page', 'jobs.job_detail'] %} is-active{% endif %}">Queue</a>
|
||||
<a href="{{ url_for('settings.settings_page') }}" class="btn{% if endpoint == 'settings.settings_page' %} is-active{% endif %}">Settings</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<span>Need help?</span>
|
||||
<a href="https://github.com/denizsafak/abogen" target="_blank" rel="noopener">Read the docs</a>
|
||||
</footer>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · Debug WAVs{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1 class="card__title">Debug WAVs</h1>
|
||||
<p class="tag">Run ID: <code>{{ run_id }}</code></p>
|
||||
|
||||
<p class="hint">Each clip reads: ID, one second pause, then the reference text.</p>
|
||||
|
||||
{% if artifacts %}
|
||||
<div class="field field--wide">
|
||||
<label>Samples</label>
|
||||
<ul>
|
||||
{% for item in artifacts %}
|
||||
<li>
|
||||
<div class="field field--stack" style="margin: 0;">
|
||||
<div>
|
||||
<strong>{{ item.label }}</strong>
|
||||
{% if item.text %}
|
||||
— <span class="muted">{{ item.text }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<audio controls preload="none" src="{{ item.url }}"></audio>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="field field--inline">
|
||||
<a href="{{ url_for('settings.settings_page', _anchor='debug') }}" class="button button--ghost">Back to Settings</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,275 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · TTS Overrides{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section
|
||||
class="card card--panel"
|
||||
data-override-root
|
||||
data-preview-url="{{ url_for('api.api_entity_pronunciation_preview') }}"
|
||||
data-language="{{ language }}"
|
||||
>
|
||||
<h1 class="card__title">TTS Overrides & Pronunciation</h1>
|
||||
<p class="card__subtitle">Review and refine stored pronunciations so recurring names sound right in every project.</p>
|
||||
<form class="entity-filter" method="get">
|
||||
<div class="entity-filter__row">
|
||||
<label class="field">
|
||||
<span>Language</span>
|
||||
<select name="lang">
|
||||
{% for code, label in languages %}
|
||||
<option value="{{ code }}" {% if code == language %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Voice filter</span>
|
||||
<select name="voice">
|
||||
{% for option in voice_filter_options %}
|
||||
<option value="{{ option.value }}" {% if option.value == voice_filter %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Pronunciations</span>
|
||||
<select name="pronunciation">
|
||||
{% for option in pronunciation_filter_options %}
|
||||
<option value="{{ option.value }}" {% if option.value == pronunciation_filter %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Limit</span>
|
||||
<input type="number" name="limit" min="10" max="500" value="{{ limit }}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="entity-filter__row entity-filter__row--actions">
|
||||
<label class="field field--grow">
|
||||
<span>Search overrides</span>
|
||||
<input type="search" name="q" value="{{ query }}" placeholder="Search by token, pronunciation, or notes">
|
||||
</label>
|
||||
<div class="entity-filter__actions">
|
||||
<button type="submit" class="button">Apply filters</button>
|
||||
<a href="{{ url_for('entities.entities_page') }}" class="button button--ghost">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<p class="hint">Looking for saved speaker rosters instead? <a href="{{ url_for('voices.speaker_configs_page') }}">Manage speaker presets</a>.</p>
|
||||
</section>
|
||||
|
||||
<section class="card card--panel">
|
||||
<header class="entity-summary">
|
||||
<h2 class="card__title">Overrides for {{ language_label }}</h2>
|
||||
<p class="card__subtitle">
|
||||
Showing {{ stats.filtered }} of {{ stats.total }} stored overrides ·
|
||||
{{ stats.with_pronunciation }} with pronunciations · {{ stats.with_voice }} with assigned voices
|
||||
</p>
|
||||
</header>
|
||||
{% if status_message or status_error %}
|
||||
<div class="notice {% if status_error %}notice--error{% else %}notice--success{% endif %}">
|
||||
{{ status_error or status_message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="entity-table-tools">
|
||||
<label class="field entity-table-tools__filter">
|
||||
<span>Filter visible overrides</span>
|
||||
<input
|
||||
type="search"
|
||||
data-role="override-filter"
|
||||
placeholder="Type a name or voice to filter"
|
||||
autocomplete="off"
|
||||
>
|
||||
</label>
|
||||
<button type="button" class="button button--ghost" data-role="override-filter-clear">Clear</button>
|
||||
</div>
|
||||
<form
|
||||
id="override-create-form"
|
||||
class="form-section entity-create"
|
||||
method="post"
|
||||
action="{{ url_for('entities.upsert_global_override') }}"
|
||||
>
|
||||
<h3 class="form-section__title">Add manual override</h3>
|
||||
<p class="entity-create__description">Create pronunciations or assign default voices without preparing a job.</p>
|
||||
<input type="hidden" name="lang" value="{{ language }}">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="state_voice" value="{{ voice_filter }}">
|
||||
<input type="hidden" name="state_pronunciation" value="{{ pronunciation_filter }}">
|
||||
<input type="hidden" name="state_limit" value="{{ limit }}">
|
||||
<input type="hidden" name="state_query" value="{{ query }}">
|
||||
<div class="field-grid field-grid--compact">
|
||||
<label class="field">
|
||||
<span>Token</span>
|
||||
<input type="text" name="token" placeholder="e.g. Arrakis" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Pronunciation</span>
|
||||
<input type="text" name="pronunciation" placeholder="Optional phonetic spelling">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Voice override</span>
|
||||
<select name="voice">
|
||||
<option value="" selected>No override</option>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Saved mixes">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
{% set profile_value = 'profile:' ~ profile.name %}
|
||||
<option value="{{ profile_value }}">{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<optgroup label="Stock voices">
|
||||
{% for voice in options.voice_catalog %}
|
||||
<option value="{{ voice.id }}">{{ voice.display_name }} - {{ voice.language_label }} - {{ voice.gender }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="entity-create__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button button--ghost"
|
||||
data-role="preview-button"
|
||||
data-form-id="override-create-form"
|
||||
>Preview</button>
|
||||
<button type="submit" class="button">Add override</button>
|
||||
</div>
|
||||
<div class="entity-preview" data-role="preview-container">
|
||||
<div class="entity-preview__status" data-role="preview-message"></div>
|
||||
<audio
|
||||
class="entity-preview__audio"
|
||||
data-role="preview-audio"
|
||||
controls
|
||||
hidden
|
||||
></audio>
|
||||
</div>
|
||||
</form>
|
||||
{% if overrides %}
|
||||
<div class="table-wrapper">
|
||||
<table class="jobs-table overrides-table" data-role="override-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Token</th>
|
||||
<th scope="col">Pronunciation</th>
|
||||
<th scope="col">Voice</th>
|
||||
<th scope="col">Usage</th>
|
||||
<th scope="col">Last updated</th>
|
||||
<th scope="col">Preview</th>
|
||||
<th scope="col" class="overrides-table__actions-heading">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for override in overrides %}
|
||||
{% set form_id = "override-form-" ~ loop.index %}
|
||||
<tr
|
||||
data-role="override-row"
|
||||
data-token="{{ override.token | lower }}"
|
||||
>
|
||||
<td data-label="Token"><span class="overrides-table__token">{{ override.token }}</span></td>
|
||||
<td data-label="Pronunciation">
|
||||
<input
|
||||
class="overrides-table__input"
|
||||
type="text"
|
||||
name="pronunciation"
|
||||
form="{{ form_id }}"
|
||||
value="{{ override.pronunciation or '' }}"
|
||||
placeholder="Add pronunciation"
|
||||
>
|
||||
</td>
|
||||
<td data-label="Voice">
|
||||
{% set current_voice = override.voice or '' %}
|
||||
{% set known_voice = namespace(value=False) %}
|
||||
{% if current_voice %}
|
||||
{% if current_voice in options.voice_catalog_map %}
|
||||
{% set known_voice.value = True %}
|
||||
{% else %}
|
||||
{% for profile in options.voice_profile_options %}
|
||||
{% if current_voice == 'profile:' ~ profile.name %}
|
||||
{% set known_voice.value = True %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<select
|
||||
class="overrides-table__select"
|
||||
name="voice"
|
||||
form="{{ form_id }}"
|
||||
>
|
||||
<option value="" {% if not current_voice %}selected{% endif %}>No override</option>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Saved mixes">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
{% set profile_value = 'profile:' ~ profile.name %}
|
||||
<option value="{{ profile_value }}" {% if current_voice == profile_value %}selected{% endif %}>
|
||||
{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<optgroup label="Stock voices">
|
||||
{% for voice in options.voice_catalog %}
|
||||
<option value="{{ voice.id }}" {% if current_voice == voice.id %}selected{% endif %}>
|
||||
{{ voice.display_name }} - {{ voice.language_label }} - {{ voice.gender }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% if current_voice and not known_voice.value %}
|
||||
<option value="{{ current_voice }}" selected>{{ current_voice }}</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</td>
|
||||
<td data-label="Usage">{{ override.usage_count }}</td>
|
||||
<td data-label="Last updated">{{ override.updated_at_label }}</td>
|
||||
<td data-label="Preview">
|
||||
<div class="entity-preview" data-role="preview-container">
|
||||
<div class="entity-preview__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="preview-button"
|
||||
data-form-id="{{ form_id }}"
|
||||
>Preview</button>
|
||||
<span class="entity-preview__status" data-role="preview-message"></span>
|
||||
</div>
|
||||
<audio
|
||||
class="entity-preview__audio"
|
||||
data-role="preview-audio"
|
||||
controls
|
||||
hidden
|
||||
></audio>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<form
|
||||
id="{{ form_id }}"
|
||||
class="overrides-table__form"
|
||||
method="post"
|
||||
action="{{ url_for('entities.upsert_global_override') }}"
|
||||
>
|
||||
<input type="hidden" name="lang" value="{{ language }}">
|
||||
<input type="hidden" name="token" value="{{ override.token }}">
|
||||
<input type="hidden" name="state_voice" value="{{ voice_filter }}">
|
||||
<input type="hidden" name="state_pronunciation" value="{{ pronunciation_filter }}">
|
||||
<input type="hidden" name="state_limit" value="{{ limit }}">
|
||||
<input type="hidden" name="state_query" value="{{ query }}">
|
||||
<div class="overrides-table__actions">
|
||||
<button type="submit" class="button button--small" name="action" value="save">Save</button>
|
||||
<button type="submit" class="button button--ghost button--small button--danger" name="action" value="delete">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint" data-role="filter-empty" hidden>No overrides match your current filter.</p>
|
||||
{% else %}
|
||||
<p class="hint">No overrides matched your filters. Try adjusting the search or create overrides from the Entities step while preparing a job.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script src="{{ url_for('static', filename='entities.js') }}" defer></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · Find Books{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="card__title">Find Books</div>
|
||||
<p class="card__subtitle">Browse trusted public-domain libraries or drill into your Calibre catalog without leaving abogen.</p>
|
||||
<div class="card__body find-books__body">
|
||||
<div class="find-books__resources">
|
||||
<article class="resource-tile">
|
||||
<h2>Standard Ebooks</h2>
|
||||
<p>Discover meticulously produced public-domain ebooks with consistent typography, metadata, and clean EPUB sources.</p>
|
||||
<p>
|
||||
<a class="button" href="https://standardebooks.org/ebooks" target="_blank" rel="noopener">
|
||||
Visit standardebooks.org
|
||||
</a>
|
||||
</p>
|
||||
<p class="hint">Opens in a new tab so you can grab EPUB downloads and drag them into abogen.</p>
|
||||
</article>
|
||||
|
||||
<article class="resource-tile">
|
||||
<h2>Project Gutenberg</h2>
|
||||
<p>The world’s largest public-domain ebook library, offering thousands of EPUB and plain-text editions sourced from volunteer transcriptions.</p>
|
||||
<p>
|
||||
<a class="button" href="https://gutenberg.org/ebooks/" target="_blank" rel="noopener">
|
||||
Visit gutenberg.org
|
||||
</a>
|
||||
</p>
|
||||
<p class="hint">Download the EPUB version for best results, then import it into abogen.</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="find-books__opds">
|
||||
<h2>Calibre catalog</h2>
|
||||
<p class="hint">Wire abogen to your Calibre OPDS server to browse, search, and import titles without leaving the app.</p>
|
||||
{% if not opds_available %}
|
||||
<div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div>
|
||||
{% else %}
|
||||
<button type="button" class="button" data-action="open-opds-modal">Browse Calibre catalog</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if opds_available %}
|
||||
<div class="modal" data-role="opds-modal" hidden>
|
||||
<div class="modal__overlay" data-role="opds-modal-close" tabindex="-1"></div>
|
||||
<div class="modal__content card card--modal opds-modal" role="dialog" aria-modal="true" aria-labelledby="opds-modal-title">
|
||||
<header class="modal__header opds-modal__header">
|
||||
<div class="modal__heading">
|
||||
<p class="modal__eyebrow">Calibre OPDS</p>
|
||||
<h2 class="modal__title" id="opds-modal-title">Browse your catalog</h2>
|
||||
</div>
|
||||
<form class="opds-modal__search" data-role="opds-search">
|
||||
<label class="visually-hidden" for="opds-search-input">Search your Calibre catalog</label>
|
||||
<div class="opds-search__field">
|
||||
<svg class="opds-search__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M15.5 14h-.79l-.28-.27A6.5 6.5 0 0016 9.5 6.5 6.5 0 109.5 16a6.5 6.5 0 004.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1114 9.5 4.5 4.5 0 019.5 14z" />
|
||||
</svg>
|
||||
<input type="search" id="opds-search-input" name="q" placeholder="Search by title, author, or keyword" autocomplete="off">
|
||||
</div>
|
||||
<div class="opds-search__actions">
|
||||
<button type="submit" class="button">Search</button>
|
||||
<button type="button" class="button button--ghost" data-action="opds-refresh">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
<button type="button" class="button button--ghost opds-modal__close" data-role="opds-modal-close">Close</button>
|
||||
</header>
|
||||
<div class="opds-modal__tabs" data-role="opds-tabs"></div>
|
||||
<div class="modal__body opds-modal__body">
|
||||
<div class="opds-browser" data-role="opds-browser">
|
||||
<div class="opds-modal__status" data-role="opds-status"></div>
|
||||
<div class="opds-modal__nav" data-role="opds-nav"></div>
|
||||
<div class="opds-modal__alpha" data-role="opds-alpha-picker"></div>
|
||||
<div class="opds-modal__results-wrap">
|
||||
<ul class="opds-modal__results" data-role="opds-results"></ul>
|
||||
</div>
|
||||
<div class="opds-modal__nav opds-modal__nav--bottom" data-role="opds-nav-bottom"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% with pending=None, readonly=False, active_step='settings' %}
|
||||
{% include "partials/upload_modal.html" %}
|
||||
{% endwith %}
|
||||
{% include "partials/reader_modal.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script>
|
||||
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
|
||||
{% if opds_available %}
|
||||
<script type="module" src="{{ url_for('static', filename='find_books.js') }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.stats-overview {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--border-subtle, #ddd);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0,0,0,0.05));
|
||||
}
|
||||
.stat-card__value {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main, #333);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-card__label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<section class="stats-overview">
|
||||
<div class="stat-card">
|
||||
<span class="stat-card__value">{{ stats.total }}</span>
|
||||
<span class="stat-card__label">Total Jobs</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card__value">{{ stats.completed }}</span>
|
||||
<span class="stat-card__label">Converted</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card__value">{{ stats.running }}</span>
|
||||
<span class="stat-card__label">Converting</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card__value">{{ stats.pending }}</span>
|
||||
<span class="stat-card__label">Pending</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card__value">{{ stats.failed }}</span>
|
||||
<span class="stat-card__label">Failed</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card card--workflow">
|
||||
<h1 class="card__title">Create a New Audiobook</h1>
|
||||
<p class="card__subtitle">Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and entities in the next steps.</p>
|
||||
<div class="upload-card__dropzone" data-role="upload-dropzone" tabindex="0" role="button" aria-label="Open upload settings or drop a manuscript file">
|
||||
<div class="upload-card__dropzone-content">
|
||||
<span class="upload-card__icon" aria-hidden="true">↑</span>
|
||||
<p class="upload-card__headline">Drop your manuscript to begin</p>
|
||||
<p class="upload-card__hint">Drag & drop a supported file here, or click to choose one in the next step.</p>
|
||||
<p class="upload-card__filename" data-role="upload-dropzone-filename" hidden></p>
|
||||
<button type="button" class="button" data-role="open-upload-modal">Open upload & settings</button>
|
||||
{% if opds_available %}
|
||||
<div class="wizard-card__body">
|
||||
<p>Upload an EPUB or text file to begin.</p>
|
||||
<a class="button button--ghost" href="{{ url_for('books.find_books_page') }}">Browse Calibre library</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% with pending=None, readonly=False, active_step='settings' %}
|
||||
{% include "partials/upload_modal.html" %}
|
||||
{% endwith %}
|
||||
|
||||
{% include "partials/reader_modal.html" %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script>
|
||||
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,158 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Job · {{ job.original_filename }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="card__title">{{ job.original_filename }}</div>
|
||||
<div class="grid grid--two">
|
||||
<article>
|
||||
<h2>Settings</h2>
|
||||
<ul>
|
||||
<li><strong>Voice:</strong> {{ job.voice }}</li>
|
||||
<li><strong>Language:</strong> {{ options.languages.get(job.language, job.language) }}</li>
|
||||
{% if job.voice_profile %}
|
||||
<li><strong>Voice profile:</strong> {{ job.voice_profile }}</li>
|
||||
{% endif %}
|
||||
<li><strong>Speed:</strong> {{ '%.2f' | format(job.speed) }}×</li>
|
||||
<li><strong>Subtitle mode:</strong> {{ job.subtitle_mode }}</li>
|
||||
<li><strong>Audio format:</strong> {{ job.output_format }}</li>
|
||||
<li><strong>Subtitle format:</strong> {{ job.subtitle_format }}</li>
|
||||
<li><strong>GPU:</strong> {{ 'Yes' if job.use_gpu else 'No' }}</li>
|
||||
<li><strong>Save chapters separately:</strong> {{ 'Yes' if job.save_chapters_separately else 'No' }}</li>
|
||||
<li><strong>Merge at end:</strong> {{ 'Yes' if job.merge_chapters_at_end else 'No' }}</li>
|
||||
<li><strong>Separate chapter format:</strong> {{ job.separate_chapters_format|upper }}</li>
|
||||
<li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li>
|
||||
<li><strong>Chapter intro delay:</strong> {{ '%.1f'|format(job.chapter_intro_delay) }}s</li>
|
||||
<li><strong>Title intro:</strong> {{ 'Yes' if job.read_title_intro else 'No' }}</li>
|
||||
<li><strong>Closing outro:</strong> {{ 'Yes' if job.read_closing_outro else 'No' }}</li>
|
||||
<li><strong>Normalize chapter openings:</strong> {{ 'Yes' if job.normalize_chapter_opening_caps else 'No' }}</li>
|
||||
<li><strong>Prefix chapter titles:</strong> {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}</li>
|
||||
<li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li>
|
||||
<li><strong>Project folder:</strong> {{ 'Yes' if job.save_as_project else 'No' }}</li>
|
||||
<li><strong>Chunk granularity:</strong> {{ job.chunk_level|replace('_', ' ')|title }}</li>
|
||||
<li><strong>Speaker analysis threshold:</strong> {{ job.speaker_analysis_threshold }}</li>
|
||||
<li><strong>Generate EPUB 3:</strong> {{ 'Yes' if job.generate_epub3 else 'No' }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Status</h2>
|
||||
<p><span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span></p>
|
||||
<p>Created: {{ job.created_at|datetimeformat }}</p>
|
||||
<p>Started: {{ job.started_at|datetimeformat }}</p>
|
||||
<p>Finished: {{ job.finished_at|datetimeformat }}</p>
|
||||
<p>Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}</p>
|
||||
{% set flags = downloads or {} %}
|
||||
{% if flags.get('m4b') %}
|
||||
<p><a class="button" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download M4B</a></p>
|
||||
{% elif flags.get('audio') %}
|
||||
<p><a class="button" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download audio</a></p>
|
||||
{% endif %}
|
||||
{% if flags.get('epub3') %}
|
||||
<p><a class="button button--ghost" href="{{ url_for('jobs.job_epub', job_id=job.id) }}">Download EPUB 3</a></p>
|
||||
{% endif %}
|
||||
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %}
|
||||
<form action="{{ url_for('jobs.cancel_job', job_id=job.id) }}" method="post">
|
||||
<button type="submit" class="button button--ghost">Cancel job</button>
|
||||
</form>
|
||||
{% elif job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED] %}
|
||||
<form action="{{ url_for('jobs.retry_job', job_id=job.id) }}" method="post">
|
||||
<button type="submit" class="button">Retry job</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% set analysis = job.speaker_analysis or {} %}
|
||||
{% if analysis %}
|
||||
{% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
|
||||
<section class="card">
|
||||
<div class="card__title">Speaker analysis</div>
|
||||
<div class="grid grid--two">
|
||||
<article>
|
||||
<h2>Summary</h2>
|
||||
{% set stats = analysis.get('stats', {}) %}
|
||||
<ul>
|
||||
<li><strong>Total chunks:</strong> {{ stats.get('total_chunks', '—') }}</li>
|
||||
<li><strong>Explicit dialogue chunks:</strong> {{ stats.get('explicit_chunks', '—') }}</li>
|
||||
<li><strong>Active speakers:</strong> {{ stats.get('active_speakers', '—') }}</li>
|
||||
<li><strong>Unique speakers observed:</strong> {{ stats.get('unique_speakers', '—') }}</li>
|
||||
<li><strong>Suppressed speakers:</strong> {{ stats.get('suppressed', 0) }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Detected speakers</h2>
|
||||
{% set speakers = analysis.get('speakers', {}) %}
|
||||
{% set narrator_id = analysis.get('narrator', 'narrator') %}
|
||||
{% if speakers %}
|
||||
<ul>
|
||||
{% for speaker_id, payload in speakers.items() if speaker_id != narrator_id and not payload.get('suppressed') %}
|
||||
{% set spoken_name = payload.get('pronunciation') or payload.get('label') or speaker_id|replace('_', ' ')|title %}
|
||||
{% set preview_text = preview_template | replace("{{name}}", spoken_name) %}
|
||||
<li>
|
||||
<div class="speaker-line">
|
||||
<strong>{{ payload.get('label', speaker_id|replace('_', ' ')|title) }}</strong>
|
||||
<button type="button"
|
||||
class="icon-button speaker-list__preview"
|
||||
data-role="speaker-preview"
|
||||
data-job-id="{{ job.id }}"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ preview_text|e }}"
|
||||
data-language="{{ job.language }}"
|
||||
data-voice="{{ payload.get('resolved_voice') or payload.get('voice_formula') or payload.get('voice') or job.voice }}"
|
||||
data-speed="{{ '%.2f'|format(job.speed) }}"
|
||||
data-use-gpu="{{ 'true' if job.use_gpu else 'false' }}"
|
||||
aria-label="Preview pronunciation for {{ payload.get('label', speaker_id|replace('_', ' ')|title) }}"
|
||||
title="Preview pronunciation">
|
||||
<span class="icon-button__glyph" aria-hidden="true">🔊</span>
|
||||
<span class="spinner spinner--sm spinner--muted" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span>{{ payload.get('count', 0) }} chunks</span>
|
||||
<span>Confidence: {{ payload.get('confidence', 'low')|title }}</span>
|
||||
{% if payload.get('pronunciation') %}
|
||||
<span>Pronunciation: {{ payload.get('pronunciation') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% set quotes = payload.get('sample_quotes', []) %}
|
||||
{% if quotes %}
|
||||
<details>
|
||||
<summary>Sample quotes</summary>
|
||||
<ul>
|
||||
{% for quote in quotes %}
|
||||
<li>{{ quote }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No additional speakers detected.</p>
|
||||
{% endif %}
|
||||
{% set suppressed = analysis.get('suppressed_details') or analysis.get('suppressed', []) %}
|
||||
{% if suppressed %}
|
||||
<p class="muted">
|
||||
Suppressed speakers:
|
||||
{% if suppressed[0] is string %}
|
||||
{{ suppressed | join(', ') }}
|
||||
{% else %}
|
||||
{{ suppressed | map(attribute='label') | join(', ') }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% include "partials/logs_section.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Job Not Found{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card logs-static">
|
||||
<div class="card__title-row">
|
||||
<div class="card__title">Job Not Found</div>
|
||||
</div>
|
||||
<p>This job no longer exists. It may have been deleted or the server was restarted.</p>
|
||||
<p><a href="{{ url_for('main.index') }}" class="button button--primary">Return to Dashboard</a></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Logs · {{ job.original_filename }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card logs-static">
|
||||
{% include "partials/logs.html" %}
|
||||
{% if job.logs %}
|
||||
<details class="log-copy">
|
||||
<summary>Copy raw log output</summary>
|
||||
<textarea class="log-copy__textarea" readonly>{{- log_text -}}</textarea>
|
||||
</details>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Job Not Found{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="card__title-row">
|
||||
<div class="card__title">Job Not Found</div>
|
||||
</div>
|
||||
<p>This job no longer exists. It may have been deleted or the server was restarted.</p>
|
||||
<p><a href="{{ url_for('main.index') }}" class="button button--primary">Return to Dashboard</a></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,134 @@
|
||||
<div class="card__title">Queue</div>
|
||||
|
||||
<section class="queue-section">
|
||||
<header class="queue-section__header">
|
||||
<h3>Active jobs</h3>
|
||||
</header>
|
||||
{% if active_jobs %}
|
||||
<ul class="job-cards">
|
||||
{% for job in active_jobs %}
|
||||
{% set progress_value = ((job.progress or 0) * 100)|round(1) %}
|
||||
<li class="job-card" data-status="{{ job.status.value }}">
|
||||
<div class="job-card__header">
|
||||
<div>
|
||||
<a class="job-card__title" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">{{ job.original_filename }}</a>
|
||||
<div class="job-card__meta">
|
||||
{% if job.queue_position %}Position #{{ job.queue_position }} · {% endif %}
|
||||
{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}
|
||||
</div>
|
||||
{% if job.pause_requested and job.status == JobStatus.RUNNING %}
|
||||
<div class="job-card__meta job-card__meta--warning">Pause requested — waiting for a safe point…</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
|
||||
</div>
|
||||
<div class="job-card__progress">
|
||||
<div class="progress-bar" style="--progress: {{ progress_value }}%">
|
||||
<div class="progress-bar__fill"></div>
|
||||
</div>
|
||||
<div class="job-card__progress-meta">
|
||||
<small>{{ progress_value }}% · {{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
|
||||
{% if job.estimated_time_remaining %}
|
||||
<small class="job-card__eta">~{{ job.estimated_time_remaining | durationformat }} remaining</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="job-card__footer">
|
||||
<a class="button button--ghost" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">Details</a>
|
||||
{% if job.status == JobStatus.RUNNING %}
|
||||
<button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.pause_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Pause</button>
|
||||
{% elif job.status == JobStatus.PAUSED %}
|
||||
<button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.resume_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Resume</button>
|
||||
{% elif job.status == JobStatus.PENDING %}
|
||||
<button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.pause_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Pause</button>
|
||||
{% endif %}
|
||||
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %}
|
||||
<button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.cancel_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Cancel this conversion?">Cancel</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="queue-empty">No active jobs. Drop a file or paste text to get started.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="queue-section">
|
||||
<header class="queue-section__header">
|
||||
<h3>Recent results</h3>
|
||||
{% if total_finished > 0 %}
|
||||
<button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.clear_finished_jobs') }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove completed jobs from this list?">Clear finished</button>
|
||||
{% endif %}
|
||||
</header>
|
||||
{% if finished_jobs %}
|
||||
<ul class="job-cards job-cards--compact">
|
||||
{% for job in finished_jobs %}
|
||||
<li class="job-card">
|
||||
<div class="job-card__header">
|
||||
<div>
|
||||
<a class="job-card__title" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">{{ job.original_filename }}</a>
|
||||
<div class="job-card__meta">Finished {{ job.finished_at|datetimeformat }}</div>
|
||||
</div>
|
||||
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
|
||||
</div>
|
||||
<div class="job-card__footer">
|
||||
<a class="button button--ghost" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">Inspect</a>
|
||||
{% if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED] %}
|
||||
<button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.retry_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Retry</button>
|
||||
{% endif %}
|
||||
{% set flags = download_flags.get(job.id, {}) %}
|
||||
{% if job.status == JobStatus.COMPLETED %}
|
||||
{% if flags.get('m4b') %}
|
||||
<a class="button button--ghost" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download M4B</a>
|
||||
{% elif flags.get('audio') %}
|
||||
<a class="button button--ghost" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download Audio</a>
|
||||
{% endif %}
|
||||
{% if flags.get('epub3') %}
|
||||
<a class="button button--ghost" href="{{ url_for('jobs.job_epub', job_id=job.id) }}">Download EPUB 3</a>
|
||||
{% endif %}
|
||||
{% if audiobookshelf_manual_available %}
|
||||
<button type="button"
|
||||
class="button button--ghost"
|
||||
hx-post="{{ url_for('jobs.send_job_to_audiobookshelf', job_id=job.id) }}"
|
||||
hx-target="#jobs-panel"
|
||||
hx-swap="innerHTML">
|
||||
Send to Audiobookshelf
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set reader_source = None %}
|
||||
{% if job.status == JobStatus.COMPLETED and job.result %}
|
||||
{% if job.result.epub_path %}
|
||||
{% set reader_source = job.result.epub_path %}
|
||||
{% elif job.result.artifacts and job.result.artifacts.get('epub3') %}
|
||||
{% set reader_source = job.result.artifacts.get('epub3') %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if reader_source %}
|
||||
{% set metadata = job.metadata_tags if job.metadata_tags is mapping else {} %}
|
||||
{% set job_display_title = metadata.get('title') or metadata.get('book_title') or metadata.get('name') or job.original_filename %}
|
||||
<button type="button"
|
||||
class="icon-button"
|
||||
data-role="open-reader"
|
||||
data-reader-url="{{ url_for('jobs.job_reader', job_id=job.id) }}"
|
||||
data-book-title="{{ job_display_title }}"
|
||||
title="Open reader"
|
||||
aria-label="Open EPUB reader for {{ job_display_title }}">
|
||||
📖
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.delete_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove this job from the list?">Remove</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if total_finished > finished_jobs|length %}
|
||||
<li class="job-card job-card--info">
|
||||
<div class="job-card__meta">{{ total_finished - finished_jobs|length }} more finished job{{ 's' if (total_finished - finished_jobs|length) != 1 else '' }} hidden. Clear finished to remove them.</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="queue-empty">Completed jobs will appear here.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -0,0 +1,21 @@
|
||||
{% set is_static = static_view if static_view is defined and static_view else False %}
|
||||
<div class="card__title-row">
|
||||
<div class="card__title">Live log</div>
|
||||
{% if not is_static %}
|
||||
<a class="button button--ghost button--small" href="{{ url_for('jobs.job_logs', job_id=job.id) }}" target="_blank" rel="noopener">Open static view</a>
|
||||
{% else %}
|
||||
<a class="button button--ghost button--small" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">Back to job</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if job.logs %}
|
||||
<ul class="log-list">
|
||||
{% for entry in job.logs|reverse %}
|
||||
<li class="log-item log-item--{{ entry.level }}">
|
||||
<small>{{ entry.timestamp|datetimeformat('%H:%M:%S') }}</small>
|
||||
<div>{{ entry.message }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No log entries yet. Check back soon!</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,7 @@
|
||||
<section class="card" id="logs"
|
||||
hx-get="{{ url_for('jobs.job_logs_partial', job_id=job.id) }}"
|
||||
hx-trigger="load, every 2s"
|
||||
hx-target="#logs"
|
||||
hx-swap="outerHTML">
|
||||
{% include "partials/logs.html" %}
|
||||
</section>
|
||||
@@ -0,0 +1,6 @@
|
||||
<section class="card" id="logs">
|
||||
<div class="card__title-row">
|
||||
<div class="card__title">Live log</div>
|
||||
</div>
|
||||
<p>Job not found (it may have completed, been removed, or the server restarted). Refresh the page to load an active job.</p>
|
||||
</section>
|
||||
@@ -0,0 +1,425 @@
|
||||
{% set pending = pending if pending is defined else None %}
|
||||
{% set metadata = pending.metadata_tags if pending else {} %}
|
||||
{% set readonly = readonly if readonly is defined else False %}
|
||||
{% set settings_dict = settings if settings is defined else {} %}
|
||||
{% set options = options if options is defined else {} %}
|
||||
{% set form_values = form_values if form_values is defined and form_values else {} %}
|
||||
{% set language_value = form_values.get('language') if form_values else None %}
|
||||
{% if not language_value %}
|
||||
{% if pending and pending.language %}
|
||||
{% set language_value = pending.language %}
|
||||
{% else %}
|
||||
{% set language_value = settings_dict.get('language', '') %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not language_value and options.languages %}
|
||||
{% set sorted_languages = options.languages|dictsort %}
|
||||
{% if sorted_languages %}
|
||||
{% set language_value = sorted_languages[0][0] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set subtitle_value = form_values.get('subtitle_mode') if form_values else None %}
|
||||
{% if not subtitle_value %}
|
||||
{% if pending and pending.subtitle_mode %}
|
||||
{% set subtitle_value = pending.subtitle_mode %}
|
||||
{% else %}
|
||||
{% set subtitle_value = settings_dict.get('subtitle_mode', 'Disabled') %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set generate_flag = form_values.get('generate_epub3') if form_values else None %}
|
||||
{% if generate_flag is not none %}
|
||||
{% set generate_epub3 = True %}
|
||||
{% else %}
|
||||
{% set generate_epub3 = pending.generate_epub3 if pending else settings_dict.get('generate_epub3', False) %}
|
||||
{% endif %}
|
||||
{% set chunk_level_value = form_values.get('chunk_level') if form_values else None %}
|
||||
{% if not chunk_level_value %}
|
||||
{% if pending and pending.chunk_level %}
|
||||
{% set chunk_level_value = pending.chunk_level %}
|
||||
{% else %}
|
||||
{% set chunk_level_value = settings_dict.get('chunk_level', 'paragraph') %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set analysis_threshold_value = form_values.get('speaker_analysis_threshold') if form_values else None %}
|
||||
{% if not analysis_threshold_value %}
|
||||
{% if pending and pending.speaker_analysis_threshold %}
|
||||
{% set analysis_threshold_value = pending.speaker_analysis_threshold %}
|
||||
{% else %}
|
||||
{% set analysis_threshold_value = settings_dict.get('speaker_analysis_threshold', 3) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set chapter_delay_value = form_values.get('chapter_intro_delay') if form_values else None %}
|
||||
{% if not chapter_delay_value %}
|
||||
{% if pending and pending.chapter_intro_delay is not none %}
|
||||
{% set chapter_delay_value = pending.chapter_intro_delay %}
|
||||
{% else %}
|
||||
{% set chapter_delay_value = settings_dict.get('chapter_intro_delay', 0.5) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set read_intro_value = form_values.get('read_title_intro') if form_values else None %}
|
||||
{% if read_intro_value is not none %}
|
||||
{% set read_title_intro = ((read_intro_value|string)|lower) in ['true', '1', 'yes', 'on'] %}
|
||||
{% else %}
|
||||
{% if pending is not none %}
|
||||
{% set read_title_intro = pending.read_title_intro %}
|
||||
{% else %}
|
||||
{% set read_title_intro = settings_dict.get('read_title_intro', False) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set read_outro_value = form_values.get('read_closing_outro') if form_values else None %}
|
||||
{% if read_outro_value is not none %}
|
||||
{% set read_closing_outro = ((read_outro_value|string)|lower) in ['true', '1', 'yes', 'on'] %}
|
||||
{% else %}
|
||||
{% if pending is not none %}
|
||||
{% set read_closing_outro = pending.read_closing_outro %}
|
||||
{% else %}
|
||||
{% set read_closing_outro = settings_dict.get('read_closing_outro', True) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set normalize_caps_value = form_values.get('normalize_chapter_opening_caps') if form_values else None %}
|
||||
{% if normalize_caps_value is not none %}
|
||||
{% set normalize_chapter_opening_caps = ((normalize_caps_value|string)|lower) in ['true', '1', 'yes', 'on'] %}
|
||||
{% else %}
|
||||
{% if pending is not none %}
|
||||
{% set normalize_chapter_opening_caps = pending.normalize_chapter_opening_caps %}
|
||||
{% else %}
|
||||
{% set normalize_chapter_opening_caps = settings_dict.get('normalize_chapter_opening_caps', True) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set selected_config = form_values.get('speaker_config') if form_values else None %}
|
||||
{% if selected_config is none %}
|
||||
{% if pending and pending.applied_speaker_config %}
|
||||
{% set selected_config = pending.applied_speaker_config %}
|
||||
{% else %}
|
||||
{% set selected_config = '' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set narrator_speed_value = form_values.get('speed') if form_values else None %}
|
||||
{% if narrator_speed_value is none %}
|
||||
{% if pending and pending.speed %}
|
||||
{% set narrator_speed_value = pending.speed %}
|
||||
{% else %}
|
||||
{% set narrator_speed_value = settings_dict.get('default_speed', 1.0) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set narrator_speed = narrator_speed_value|float if narrator_speed_value is not none else 1.0 %}
|
||||
{% set speed_display = '%.2f'|format(narrator_speed if narrator_speed else 1.0) %}
|
||||
{% set form_profile = form_values.get('voice_profile') if form_values else None %}
|
||||
{% set form_voice = form_values.get('voice') if form_values else None %}
|
||||
{% set form_formula = form_values.get('voice_formula') if form_values else None %}
|
||||
{% set narrator_profile = None %}
|
||||
{% if form_profile is not none and form_profile != '' %}
|
||||
{% set narrator_profile = form_profile %}
|
||||
{% elif pending and pending.voice_profile %}
|
||||
{% set narrator_profile = pending.voice_profile %}
|
||||
{% else %}
|
||||
{% set narrator_profile = '' %}
|
||||
{% endif %}
|
||||
{% set narrator_voice = None %}
|
||||
{% if form_voice %}
|
||||
{% set narrator_voice = form_voice %}
|
||||
{% elif pending and pending.voice %}
|
||||
{% set narrator_voice = pending.voice %}
|
||||
{% else %}
|
||||
{% set narrator_voice = settings_dict.get('default_voice', options.voices[0] if options.voices else '') %}
|
||||
{% endif %}
|
||||
{% if (not narrator_profile) and narrator_voice and narrator_voice[:8]|lower == 'profile:' %}
|
||||
{% set narrator_profile = narrator_voice[8:]|trim %}
|
||||
{% set narrator_voice = '' %}
|
||||
{% endif %}
|
||||
{% set normalization_overrides = pending.normalization_overrides if pending and pending.normalization_overrides else {} %}
|
||||
{% set voice_formula_value = '' %}
|
||||
{% set profile_value = narrator_profile if narrator_profile else '__standard' %}
|
||||
{% if profile_value == '__formula' %}
|
||||
{% if form_formula %}
|
||||
{% set voice_formula_value = form_formula %}
|
||||
{% elif pending and pending.voice %}
|
||||
{% set voice_formula_value = pending.voice %}
|
||||
{% endif %}
|
||||
{% elif profile_value not in ['__standard', '', None] %}
|
||||
{% set voice_formula_value = '' %}
|
||||
{% else %}
|
||||
{% if form_formula %}
|
||||
{% set profile_value = '__formula' %}
|
||||
{% set voice_formula_value = form_formula %}
|
||||
{% elif narrator_voice and ('+' in narrator_voice or '*' in narrator_voice) %}
|
||||
{% set profile_value = '__formula' %}
|
||||
{% set voice_formula_value = narrator_voice %}
|
||||
{% else %}
|
||||
{% set profile_value = '__standard' %}
|
||||
{% set voice_formula_value = '' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if profile_value == '__formula' and not voice_formula_value %}
|
||||
{% if pending and pending.voice %}
|
||||
{% set voice_formula_value = pending.voice %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if profile_value != '__standard' and profile_value != '__formula' %}
|
||||
{% set narrator_voice = '' %}
|
||||
{% endif %}
|
||||
{% if not narrator_voice and options.voices %}
|
||||
{% set narrator_voice = options.voices[0] %}
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="alert alert--info">{{ notice }}</div>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('main.wizard_upload') if not readonly else '#' }}"
|
||||
method="post"
|
||||
id="new-job-book-form"
|
||||
class="upload-form"
|
||||
data-role="wizard-form"
|
||||
data-wizard-form="true"
|
||||
data-step="book"
|
||||
data-pending-id="{{ pending.id if pending else '' }}"
|
||||
{% if not readonly %}enctype="multipart/form-data"{% endif %}>
|
||||
<input type="hidden" name="pending_id" value="{{ pending.id if pending else '' }}">
|
||||
<div class="modal__body wizard-card__body upload-form__sections">
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Manuscript & subtitles</h3>
|
||||
<div class="field-grid field-grid--compact">
|
||||
<div class="field field--file field--span-2">
|
||||
<label for="source_file">Source file</label>
|
||||
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown" {{ 'disabled' if readonly else '' }}>
|
||||
{% if pending %}
|
||||
<p class="hint">Current file: {{ pending.original_filename }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="language">Language</label>
|
||||
<select id="language" name="language" {{ 'disabled' if readonly else '' }}>
|
||||
{% for key, label in options.languages.items() %}
|
||||
<option value="{{ key }}" {% if key == language_value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="subtitle_mode">Subtitle mode</label>
|
||||
<select id="subtitle_mode" name="subtitle_mode" {{ 'disabled' if readonly else '' }}>
|
||||
{% set subtitle_options = ['Disabled', 'Sentence', 'Sentence + Comma', 'Sentence + Highlighting'] %}
|
||||
{% for option in subtitle_options %}
|
||||
<option value="{{ option }}" {% if subtitle_value == option %}selected{% endif %}>{{ option }}</option>
|
||||
{% endfor %}
|
||||
{% for i in range(1, 11) %}
|
||||
{% set label = i ~ ' ' ~ ('word' if i == 1 else 'words') %}
|
||||
<option value="{{ label }}" {% if subtitle_value == label %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field field--stack field--span-2">
|
||||
<span class="field__caption">Additional outputs</span>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="generate_epub3" value="true" {% if generate_epub3 %}checked{% endif %} {{ 'disabled' if readonly else '' }}>
|
||||
<span>Generate EPUB 3 (experimental)</span>
|
||||
</label>
|
||||
<p class="hint">Creates a synchronized EPUB alongside audio output.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Book Metadata</h3>
|
||||
<div class="field-grid field-grid--compact">
|
||||
<div class="field field--span-2">
|
||||
<label for="meta_title">Title</label>
|
||||
<input type="text" id="meta_title" name="meta_title" value="{{ metadata.get('title', '') }}" {{ 'disabled' if readonly else '' }}>
|
||||
</div>
|
||||
<div class="field field--span-2">
|
||||
<label for="meta_subtitle">Subtitle</label>
|
||||
<input type="text" id="meta_subtitle" name="meta_subtitle" value="{{ metadata.get('subtitle', '') }}" {{ 'disabled' if readonly else '' }}>
|
||||
</div>
|
||||
<div class="field field--span-2">
|
||||
<label for="meta_author">Author(s)</label>
|
||||
<input type="text" id="meta_author" name="meta_author" value="{{ metadata.get('authors', '') or metadata.get('author', '') }}" {{ 'disabled' if readonly else '' }}>
|
||||
<p class="hint">Comma separated for multiple authors</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="meta_series">Series</label>
|
||||
<input type="text" id="meta_series" name="meta_series" value="{{ metadata.get('series', '') or metadata.get('series_name', '') }}" {{ 'disabled' if readonly else '' }}>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="meta_series_index">Series Index</label>
|
||||
<input type="text" id="meta_series_index" name="meta_series_index" value="{{ metadata.get('series_index', '') or metadata.get('series_sequence', '') }}" {{ 'disabled' if readonly else '' }}>
|
||||
</div>
|
||||
<div class="field field--span-2">
|
||||
<label for="meta_publisher">Publisher</label>
|
||||
<input type="text" id="meta_publisher" name="meta_publisher" value="{{ metadata.get('publisher', '') }}" {{ 'disabled' if readonly else '' }}>
|
||||
</div>
|
||||
<div class="field field--span-2">
|
||||
<label for="meta_description">Description</label>
|
||||
<textarea id="meta_description" name="meta_description" rows="3" {{ 'disabled' if readonly else '' }}>{{ metadata.get('description', '') or metadata.get('summary', '') }}</textarea>
|
||||
</div>
|
||||
<div class="field field--stack field--span-2">
|
||||
<label class="toggle-pill">
|
||||
<input type="hidden" name="remove_cover" value="false">
|
||||
<input type="checkbox" name="remove_cover" value="true" {{ 'disabled' if readonly else '' }}>
|
||||
<span>Remove Cover Image</span>
|
||||
</label>
|
||||
{% if pending and pending.cover_image_path %}
|
||||
<p class="hint">Cover image currently exists.</p>
|
||||
{% else %}
|
||||
<p class="hint">No cover image found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Narrator defaults</h3>
|
||||
<div class="form-section__layout form-section__layout--split">
|
||||
<div class="form-section__group">
|
||||
<div class="field">
|
||||
<label for="voice_profile">Voice profile</label>
|
||||
<select id="voice_profile" name="voice_profile" data-role="voice-profile" {{ 'disabled' if readonly else '' }}>
|
||||
<option value="__standard" {% if profile_value == '__standard' %}selected{% endif %}>Standard voice</option>
|
||||
<option value="__formula" {% if profile_value == '__formula' %}selected{% endif %}>Custom voice formula</option>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Saved mixes">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
<option value="{{ profile.name }}" data-language="{{ profile.language }}" data-formula="{{ profile.formula|e }}" {% if profile_value == profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} ({{ profile.language|upper }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" data-role="voice-field" {% if profile_value != '__standard' %}hidden aria-hidden="true"{% endif %}>
|
||||
<label for="voice">Voice</label>
|
||||
<select id="voice" name="voice" data-role="voice-select" data-default="{{ narrator_voice or settings_dict.get('default_voice', '') }}" {{ 'disabled' if readonly else '' }}>
|
||||
{% for voice in options.voices %}
|
||||
<option value="{{ voice }}" {% if narrator_voice == voice and profile_value == '__standard' %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" data-conditional="formula" data-role="formula-field" {% if profile_value != '__formula' %}hidden aria-hidden="true"{% endif %}>
|
||||
<label for="voice_formula">Custom voice formula</label>
|
||||
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula" value="{{ voice_formula_value }}" {{ 'disabled' if readonly else '' }}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-section__group">
|
||||
<div class="field field--slider">
|
||||
<label for="speed">Speed <span class="tag" id="speed_value">{{ speed_display }}×</span></label>
|
||||
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="{{ speed_display }}" {{ 'disabled' if readonly else '' }} oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
|
||||
</div>
|
||||
<div class="field field--with-action field--preview" data-role="voice-preview">
|
||||
<div class="field__label-row">
|
||||
<span class="field__label">Preview</span>
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-preview-button" {{ 'disabled' if readonly else '' }}>Preview voice</button>
|
||||
</div>
|
||||
<p class="hint field__status" data-role="voice-preview-status" aria-live="polite" hidden></p>
|
||||
<audio class="voice-preview__audio" data-role="voice-preview-audio" controls preload="none" hidden></audio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Text normalization</h3>
|
||||
<div class="field-grid field-grid--compact">
|
||||
<div class="field field--stack field--span-2">
|
||||
<span class="field__label">Apostrophe strategy</span>
|
||||
<div class="choices choices--inline">
|
||||
{% for option in options.apostrophe_modes %}
|
||||
<label class="radio-pill">
|
||||
<input type="radio" name="normalization_apostrophe_mode" value="{{ option.value }}" {% if normalization_overrides.get('normalization_apostrophe_mode', settings_dict.get('normalization_apostrophe_mode', 'spacy')) == option.value %}checked{% endif %} {{ 'disabled' if readonly else '' }}>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field field--stack field--span-2">
|
||||
<label for="normalization_numbers_year_style">Year pronunciation style</label>
|
||||
<select id="normalization_numbers_year_style" name="normalization_numbers_year_style" {{ 'disabled' if readonly else '' }}>
|
||||
<option value="american" {% if normalization_overrides.get('normalization_numbers_year_style', settings_dict.get('normalization_numbers_year_style', 'american')) == 'american' %}selected{% endif %}>American (1990 -> nineteen ninety)</option>
|
||||
<option value="off" {% if normalization_overrides.get('normalization_numbers_year_style', settings_dict.get('normalization_numbers_year_style', 'american')) == 'off' %}selected{% endif %}>Off (read as number)</option>
|
||||
</select>
|
||||
</div>
|
||||
{% for group in options.normalization_groups %}
|
||||
{% for option in group.options %}
|
||||
<div class="field field--stack">
|
||||
<label class="toggle-pill">
|
||||
<input type="hidden" name="{{ option.key }}" value="false">
|
||||
<input type="checkbox" name="{{ option.key }}" value="true" {% if normalization_overrides.get(option.key, settings_dict.get(option.key, True)) %}checked{% endif %} {{ 'disabled' if readonly else '' }}>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Entities & casting</h3>
|
||||
<div class="field-grid field-grid--compact">
|
||||
<div class="field field--stack field--span-2">
|
||||
<label for="speaker_config">Speaker preset</label>
|
||||
<select id="speaker_config" name="speaker_config" {{ 'disabled' if readonly else '' }}>
|
||||
<option value="">None</option>
|
||||
{% for config in options.speaker_configs %}
|
||||
<option value="{{ config.name }}" {% if selected_config == config.name %}selected{% endif %}>{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Reuse a saved roster to keep character voices consistent.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="chunk_level">Chunk granularity</label>
|
||||
<select id="chunk_level" name="chunk_level" {{ 'disabled' if readonly else '' }}>
|
||||
{% for option in options.chunk_levels %}
|
||||
<option value="{{ option.value }}" {% if chunk_level_value == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Paragraphs work well for long-form narration; sentences give finer subtitle sync.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speaker_analysis_threshold">Minimum mentions</label>
|
||||
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ analysis_threshold_value }}" {{ 'disabled' if readonly else '' }}>
|
||||
<p class="hint">Entities appearing less often fall back to the narrator voice.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="chapter_intro_delay">Pause after chapter titles (seconds)</label>
|
||||
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(chapter_delay_value|float if chapter_delay_value is not none else 0.5) }}" {{ 'disabled' if readonly else '' }}>
|
||||
<p class="hint">Set to 0 to disable the pause after speaking each chapter title.</p>
|
||||
</div>
|
||||
<div class="field field--stack">
|
||||
<label class="toggle-pill">
|
||||
<input type="hidden" name="read_title_intro" value="false">
|
||||
<input type="checkbox" name="read_title_intro" value="true" {% if read_title_intro %}checked{% endif %} {{ 'disabled' if readonly else '' }}>
|
||||
<span>Read title and authors before narration</span>
|
||||
</label>
|
||||
<p class="hint">When enabled, the narrator speaks the book title and author list before chapter one begins.</p>
|
||||
</div>
|
||||
<div class="field field--stack">
|
||||
<label class="toggle-pill">
|
||||
<input type="hidden" name="read_closing_outro" value="false">
|
||||
<input type="checkbox" name="read_closing_outro" value="true" {% if read_closing_outro %}checked{% endif %} {{ 'disabled' if readonly else '' }}>
|
||||
<span>Read closing outro after narration</span>
|
||||
</label>
|
||||
<p class="hint">Adds a short "The end" statement after the final chapter, including series details when available.</p>
|
||||
</div>
|
||||
<div class="field field--stack">
|
||||
<label class="toggle-pill">
|
||||
<input type="hidden" name="normalize_chapter_opening_caps" value="false">
|
||||
<input type="checkbox" name="normalize_chapter_opening_caps" value="true" {% if normalize_chapter_opening_caps %}checked{% endif %} {{ 'disabled' if readonly else '' }}>
|
||||
<span>Normalize ALL CAPS chapter openings</span>
|
||||
</label>
|
||||
<p class="hint">Converts opening sentences written in uppercase to sentence case while keeping acronyms like AI intact.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="modal__footer wizard-card__footer">
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="button" class="button button--ghost" data-role="wizard-cancel" data-pending-id="{{ pending.id if pending else '' }}">Cancel</button>
|
||||
</div>
|
||||
<div class="wizard-card__footer-actions">
|
||||
{% if readonly %}
|
||||
<button type="button" class="button" data-role="wizard-back" data-target-step="chapters" data-pending-id="{{ pending.id if pending else '' }}">Return to chapters</button>
|
||||
{% else %}
|
||||
<button type="submit" class="button" data-step-target="chapters">Chapter selection</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
@@ -0,0 +1,126 @@
|
||||
<form method="post"
|
||||
action="{{ url_for('main.wizard_update', pending_id=pending.id) }}"
|
||||
class="prepare-form"
|
||||
id="prepare-form"
|
||||
data-role="prepare-form"
|
||||
data-wizard-form="true"
|
||||
data-step="chapters"
|
||||
data-pending-id="{{ pending.id }}"
|
||||
data-analyze-url="{{ url_for('main.wizard_update', pending_id=pending.id) }}">
|
||||
<input type="hidden" name="step" value="chapters">
|
||||
<input type="hidden" name="active_step" value="chapters" data-role="active-step-input">
|
||||
<div class="wizard-hidden-inputs" aria-hidden="true">
|
||||
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
|
||||
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
|
||||
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
||||
<input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}">
|
||||
<input type="hidden" name="read_closing_outro" value="{{ 'true' if pending.read_closing_outro else 'false' }}">
|
||||
<input type="hidden" name="normalize_chapter_opening_caps" value="{{ 'true' if pending.normalize_chapter_opening_caps else 'false' }}">
|
||||
{% if pending.generate_epub3 %}
|
||||
<input type="hidden" name="generate_epub3" value="true">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal__body wizard-card__body">
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="alert alert--info">{{ notice }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="form-section">
|
||||
<div class="form-section__title-row">
|
||||
<h3 class="form-section__title">Detected chapters</h3>
|
||||
<p class="hint">Toggle chapters on or off, rename them, and override the voice per chapter if needed.</p>
|
||||
</div>
|
||||
<div class="chapter-grid">
|
||||
{% for chapter in pending.chapters %}
|
||||
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
|
||||
{% set selected_option = '__default' %}
|
||||
{% if chapter.voice_profile %}
|
||||
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
|
||||
{% elif chapter.voice %}
|
||||
{% set selected_option = 'voice:' ~ chapter.voice %}
|
||||
{% elif chapter.voice_formula %}
|
||||
{% set selected_option = 'formula' %}
|
||||
{% endif %}
|
||||
<article class="chapter-card"
|
||||
data-role="chapter-row"
|
||||
data-disabled="{{ 'false' if is_enabled else 'true' }}"
|
||||
data-expanded="false">
|
||||
<header class="chapter-card__summary" data-role="chapter-summary">
|
||||
<label class="chapter-card__checkbox">
|
||||
<input type="checkbox"
|
||||
name="chapter-{{ loop.index0 }}-enabled"
|
||||
data-role="chapter-enabled"
|
||||
{% if is_enabled %}checked{% endif %}>
|
||||
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
|
||||
</label>
|
||||
<button type="button"
|
||||
class="chapter-card__toggle"
|
||||
data-role="chapter-toggle"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle chapter details">
|
||||
<span class="chapter-card__toggle-icon" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="chapter-card__details"
|
||||
data-role="chapter-details">
|
||||
<div class="chapter-card__field">
|
||||
<label for="chapter-{{ loop.index0 }}-title">Title</label>
|
||||
<input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
|
||||
</div>
|
||||
<div class="chapter-card__preview">
|
||||
<details>
|
||||
<summary>Preview full text</summary>
|
||||
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<div class="chapter-card__field">
|
||||
<label for="chapter-{{ loop.index0 }}-voice">Voice override</label>
|
||||
<select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select">
|
||||
<option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option>
|
||||
<optgroup label="Voices">
|
||||
{% for voice in options.voices %}
|
||||
<option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Profiles">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
<option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option>
|
||||
</select>
|
||||
<input type="text"
|
||||
name="chapter-{{ loop.index0 }}-formula"
|
||||
class="chapter-card__formula"
|
||||
data-role="formula-input"
|
||||
placeholder="af_nova*0.4+am_liam*0.6"
|
||||
value="{{ chapter.voice_formula or '' }}"
|
||||
{% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="modal__footer wizard-card__footer">
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="button" class="button button--ghost" data-role="wizard-back" data-target-step="book" data-pending-id="{{ pending.id }}">Previous</button>
|
||||
<button type="button" class="button button--ghost" data-role="wizard-cancel" data-pending-id="{{ pending.id }}">Cancel</button>
|
||||
</div>
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="submit"
|
||||
class="button"
|
||||
data-role="submit-speaker-analysis"
|
||||
data-step-target="entities">
|
||||
Continue to entities
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
@@ -0,0 +1,610 @@
|
||||
{% set embed_scripts = embed_scripts if embed_scripts is defined else True %}
|
||||
{% set recognition_enabled = settings.enable_entity_recognition if settings is defined and settings.enable_entity_recognition is not none else True %}
|
||||
<form method="post"
|
||||
action="{{ url_for('main.wizard_finish', pending_id=pending.id) }}"
|
||||
class="prepare-form"
|
||||
id="prepare-form"
|
||||
data-role="prepare-form"
|
||||
data-wizard-form="true"
|
||||
data-step="entities"
|
||||
data-pending-id="{{ pending.id }}"
|
||||
data-analyze-url="{{ url_for('entities.analyze_entities', pending_id=pending.id) }}"
|
||||
data-entities-url="{{ url_for('entities.get_entities', pending_id=pending.id) }}"
|
||||
data-manual-list-url="{{ url_for('entities.list_manual_overrides', pending_id=pending.id) }}"
|
||||
data-manual-upsert-url="{{ url_for('entities.upsert_override', pending_id=pending.id) }}"
|
||||
data-manual-delete-url-template="{{ url_for('entities.delete_override', pending_id=pending.id, override_id='__OVERRIDE_ID__') }}"
|
||||
data-manual-search-url="{{ url_for('entities.search_candidates', pending_id=pending.id) }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-base-voice="{{ pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
data-entities-enabled="{{ 'true' if recognition_enabled else 'false' }}">
|
||||
<input type="hidden" name="active_step" value="entities" data-role="active-step-input">
|
||||
|
||||
<div class="wizard-hidden-inputs" aria-hidden="true">
|
||||
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
|
||||
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
|
||||
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
||||
<input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}">
|
||||
<input type="hidden" name="normalize_chapter_opening_caps" value="{{ 'true' if pending.normalize_chapter_opening_caps else 'false' }}">
|
||||
{% if pending.generate_epub3 %}
|
||||
<input type="hidden" name="generate_epub3" value="true">
|
||||
{% endif %}
|
||||
{% for chapter in pending.chapters %}
|
||||
{% set idx = loop.index0 %}
|
||||
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
|
||||
{% set selected_option = '__default' %}
|
||||
{% if chapter.voice_profile %}
|
||||
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
|
||||
{% elif chapter.voice %}
|
||||
{% set selected_option = 'voice:' ~ chapter.voice %}
|
||||
{% elif chapter.voice_formula %}
|
||||
{% set selected_option = 'formula' %}
|
||||
{% endif %}
|
||||
{% if is_enabled %}
|
||||
<input type="hidden" name="chapter-{{ idx }}-enabled" value="on">
|
||||
{% endif %}
|
||||
<input type="hidden" name="chapter-{{ idx }}-title" value="{{ chapter.title }}">
|
||||
<input type="hidden" name="chapter-{{ idx }}-voice" value="{{ selected_option }}">
|
||||
<input type="hidden" name="chapter-{{ idx }}-formula" value="{{ chapter.voice_formula or '' }}">
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="modal__body wizard-card__body">
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="alert alert--info">{{ notice }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="entity-tabs" data-role="entity-tabs">
|
||||
{% if not recognition_enabled %}
|
||||
<div class="alert alert--info entity-tabs__notice">
|
||||
Entity recognition is disabled in Settings. Enable it to populate detected people and entities automatically.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="entity-tabs__nav" role="tablist" aria-label="Entity categories">
|
||||
<button type="button"
|
||||
class="entity-tabs__tab is-active"
|
||||
data-role="entity-tab"
|
||||
data-panel="people"
|
||||
id="entity-tab-people"
|
||||
aria-controls="entity-panel-people"
|
||||
aria-selected="true"
|
||||
role="tab">
|
||||
People
|
||||
</button>
|
||||
<button type="button"
|
||||
class="entity-tabs__tab"
|
||||
data-role="entity-tab"
|
||||
data-panel="entities"
|
||||
id="entity-tab-entities"
|
||||
aria-controls="entity-panel-entities"
|
||||
aria-selected="false"
|
||||
role="tab">
|
||||
Entities
|
||||
</button>
|
||||
<button type="button"
|
||||
class="entity-tabs__tab"
|
||||
data-role="entity-tab"
|
||||
data-panel="manual"
|
||||
id="entity-tab-manual"
|
||||
aria-controls="entity-panel-manual"
|
||||
aria-selected="false"
|
||||
role="tab">
|
||||
Manual Overrides
|
||||
</button>
|
||||
<div class="entity-tabs__status" data-role="global-entity-spinner" hidden>
|
||||
<span class="spinner spinner--sm"></span>
|
||||
<span class="entity-tabs__status-text">Scanning text...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-tabs__panels">
|
||||
<section class="entity-tabs__panel is-active"
|
||||
data-role="entity-panel"
|
||||
data-panel="people"
|
||||
id="entity-panel-people"
|
||||
role="tabpanel"
|
||||
aria-labelledby="entity-tab-people">
|
||||
<section class="prepare-speaker-config">
|
||||
<div class="prepare-speaker-config__header">
|
||||
<h2>Speaker configuration</h2>
|
||||
<p class="hint">Reuse saved presets to keep character voices consistent between projects.</p>
|
||||
</div>
|
||||
<div class="prepare-speaker-config__grid">
|
||||
<label class="field prepare-speaker-config__field" for="applied_speaker_config">
|
||||
<span>Saved preset</span>
|
||||
<select id="applied_speaker_config" name="applied_speaker_config">
|
||||
<option value="">None</option>
|
||||
{% for config in options.speaker_configs %}
|
||||
<option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}>
|
||||
{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Presets are saved from previous jobs.</p>
|
||||
</label>
|
||||
<div class="prepare-speaker-config__actions">
|
||||
<button type="submit"
|
||||
class="button button--ghost"
|
||||
name="apply_speaker_config"
|
||||
data-role="submit-speaker-analysis"
|
||||
value="1"
|
||||
formaction="{{ url_for('entities.analyze_entities', pending_id=pending.id) }}"
|
||||
formmethod="post"
|
||||
formnovalidate
|
||||
{% if not options.speaker_configs %}disabled{% endif %}>
|
||||
Apply preset
|
||||
</button>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="save_speaker_config" value="1">
|
||||
<span>Save roster updates back to preset</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if pending.speakers %}
|
||||
<div class="prepare-speakers">
|
||||
<h2>Speaker settings</h2>
|
||||
<p class="hint">Set pronunciations, lock specific voices, and audition sample paragraphs to hear casting choices.</p>
|
||||
<ul class="speaker-list">
|
||||
{% for speaker_id, speaker in pending.speakers.items() %}
|
||||
{% set pronunciation_text = speaker.pronunciation or speaker.label %}
|
||||
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
|
||||
{% set seen = namespace(values=[]) %}
|
||||
{% set sample_quotes = speaker.sample_quotes or [] %}
|
||||
{% set detected_gender = speaker.detected_gender or speaker.gender or 'unknown' %}
|
||||
{% set current_gender = speaker.gender or detected_gender %}
|
||||
{% set gender_label = 'Either' if current_gender == 'either' else (current_gender|title if current_gender != 'unknown' else 'Unknown') %}
|
||||
{% set detected_label = 'Either' if detected_gender == 'either' else (detected_gender|title if detected_gender != 'unknown' else 'Unknown') %}
|
||||
<li class="speaker-list__item"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-default-pronunciation="{{ pronunciation_text }}">
|
||||
<div class="speaker-line speaker-list__header">
|
||||
<span class="speaker-list__name">{{ speaker.label }}</span>
|
||||
<button type="button"
|
||||
class="icon-button speaker-list__preview"
|
||||
data-role="speaker-preview"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-source="pronunciation"
|
||||
data-preview-text="{{ pronunciation_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
aria-label="Preview pronunciation for {{ speaker.label }}"
|
||||
title="Preview pronunciation">
|
||||
🔊
|
||||
</button>
|
||||
</div>
|
||||
<template data-role="speaker-samples">{{ sample_quotes | tojson }}</template>
|
||||
<div class="speaker-list__meta">
|
||||
<div class="speaker-gender" data-role="speaker-gender" data-speaker-id="{{ speaker_id }}">
|
||||
<button type="button"
|
||||
class="chip speaker-gender__pill"
|
||||
data-role="gender-pill"
|
||||
data-current="{{ current_gender }}">
|
||||
{{ gender_label }} voice
|
||||
</button>
|
||||
<div class="speaker-gender__menu" data-role="gender-menu" hidden>
|
||||
<p class="hint">Detected: {{ detected_label }}</p>
|
||||
<div class="speaker-gender__options">
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="female">Female</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="male">Male</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="either">Either</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="{{ detected_gender }}" {% if detected_gender == current_gender %}data-state="active"{% endif %}>
|
||||
Use detected ({{ detected_label }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-gender" value="{{ current_gender }}" data-role="gender-input">
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-detected-gender" value="{{ detected_gender }}">
|
||||
</div>
|
||||
{% if speaker.get('analysis_count') %}
|
||||
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
|
||||
<span>Pronunciation</span>
|
||||
<input type="text"
|
||||
id="speaker-{{ speaker_id }}-pronunciation"
|
||||
name="speaker-{{ speaker_id }}-pronunciation"
|
||||
value="{{ pronunciation_text }}"
|
||||
data-role="speaker-pronunciation"
|
||||
placeholder="{{ speaker.label }}">
|
||||
</label>
|
||||
<div class="speaker-list__controls">
|
||||
<div class="speaker-list__selection">
|
||||
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice">
|
||||
<span>Assigned voice</span>
|
||||
<select id="speaker-{{ speaker_id }}-voice"
|
||||
name="speaker-{{ speaker_id }}-voice"
|
||||
data-role="speaker-voice"
|
||||
data-default-voice="{{ pending.voice }}">
|
||||
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
|
||||
<option value="__custom_mix" data-role="custom-mix-option" {% if speaker.voice_formula %}selected{% else %}hidden disabled{% endif %}>
|
||||
Custom mix
|
||||
</option>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Saved speakers">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
{% set profile_value = 'speaker:' ~ profile.name %}
|
||||
{% set provider_label = 'Supertonic' if profile.provider == 'supertonic' else 'Kokoro' %}
|
||||
<option value="{{ profile_value }}" {% if selected_voice == profile_value %}selected{% endif %}>
|
||||
{{ profile.name }} · {{ provider_label }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% if speaker.recommended_voices %}
|
||||
<optgroup label="Recommended">
|
||||
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||
{% if voice_id not in seen.values %}
|
||||
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
|
||||
<option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}>
|
||||
{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}
|
||||
</option>
|
||||
{% set _ = seen.values.append(voice_id) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<optgroup label="All voices">
|
||||
{% for voice in options.voice_catalog %}
|
||||
{% if voice.id not in seen.values %}
|
||||
<option value="{{ voice.id }}"
|
||||
{% if selected_voice == voice.id %}selected{% endif %}>
|
||||
{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="open-voice-browser"
|
||||
data-speaker-id="{{ speaker_id }}">
|
||||
Browse voices
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="generate-voice"
|
||||
data-speaker-id="{{ speaker_id }}">
|
||||
Generate voice
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-kind="generated"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-source="generated"
|
||||
data-preview-text="{{ pronunciation_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
{% if not speaker.voice_formula %}hidden{% endif %}>
|
||||
Preview generated
|
||||
</button>
|
||||
</div>
|
||||
<div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}>
|
||||
<span class="tag">Custom mix</span>
|
||||
<span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span>
|
||||
<div class="speaker-list__mix-actions">
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-source="mix"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ pronunciation_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
|
||||
Preview mix
|
||||
</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula">
|
||||
</div>
|
||||
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
|
||||
<summary>Sample paragraphs</summary>
|
||||
{% if sample_quotes %}
|
||||
{% set first_sample = sample_quotes[0] if sample_quotes|length > 0 else None %}
|
||||
{% set first_excerpt = first_sample.excerpt if first_sample is mapping else first_sample %}
|
||||
{% set first_hint = first_sample.gender_hint if first_sample is mapping else '' %}
|
||||
<article class="speaker-sample" data-role="speaker-sample">
|
||||
<p data-role="sample-text">{{ first_excerpt }}</p>
|
||||
<p class="hint" data-role="sample-hint" {% if not first_hint %}hidden{% endif %}>{{ first_hint }}</p>
|
||||
<div class="speaker-sample__actions">
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-source="sample"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ first_excerpt }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
|
||||
Preview with assigned voice
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="open-voice-browser"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-sample-index="0">
|
||||
Preview in voice browser
|
||||
</button>
|
||||
{% if sample_quotes|length > 1 %}
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-next-sample">
|
||||
Show another example
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</p>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% if speaker.recommended_voices %}
|
||||
<div class="speaker-list__recommendations">
|
||||
<span class="hint">Suggested:</span>
|
||||
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
|
||||
<button type="button"
|
||||
class="chip"
|
||||
data-role="recommended-voice"
|
||||
data-voice="{{ voice_id }}"
|
||||
title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}">
|
||||
{{ voice_meta.display_name or voice_id }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p>
|
||||
{% endif %}
|
||||
|
||||
<section class="entity-summary entity-summary--people" data-role="people-summary">
|
||||
<header class="entity-summary__header">
|
||||
<div class="entity-summary__titles">
|
||||
<h2>Detected people</h2>
|
||||
<p class="hint">Characters surfaced by entity recognition. Filter by mention count to focus on recurring names.</p>
|
||||
</div>
|
||||
<div class="entity-summary__filters">
|
||||
<label class="field field--inline" for="people-mention-filter">
|
||||
<span>Minimum mentions</span>
|
||||
<select id="people-mention-filter" data-role="entity-filter-people">
|
||||
<option value="0">All</option>
|
||||
<option value="1">1+</option>
|
||||
<option value="2">2+</option>
|
||||
<option value="5">5+</option>
|
||||
<option value="10">10+</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
<dl class="entity-summary__stats" data-role="people-stats"></dl>
|
||||
<ol class="entity-summary__list" data-role="entity-list-people"></ol>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="entity-tabs__panel"
|
||||
data-role="entity-panel"
|
||||
data-panel="entities"
|
||||
id="entity-panel-entities"
|
||||
role="tabpanel"
|
||||
aria-labelledby="entity-tab-entities"
|
||||
hidden>
|
||||
<div class="entity-summary" data-role="entities-summary">
|
||||
<header class="entity-summary__header">
|
||||
<div class="entity-summary__titles">
|
||||
<h2>Detected entities</h2>
|
||||
<p class="hint">Assign pronunciations and voices for recurring names and terminology across the manuscript.</p>
|
||||
</div>
|
||||
<div class="entity-summary__filters">
|
||||
<label class="field field--inline" for="entities-mention-filter">
|
||||
<span>Minimum mentions</span>
|
||||
<select id="entities-mention-filter" data-role="entity-filter-entities">
|
||||
<option value="0">All</option>
|
||||
<option value="1">1+</option>
|
||||
<option value="2">2+</option>
|
||||
<option value="5">5+</option>
|
||||
<option value="10">10+</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field field--inline" for="entities-kind-filter">
|
||||
<span>Entity type</span>
|
||||
<select id="entities-kind-filter" data-role="entity-filter-kind">
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="entity-summary__refresh-group">
|
||||
<span class="spinner spinner--sm spinner--muted" data-role="entities-spinner" aria-hidden="true" hidden></span>
|
||||
<button type="button" class="button button--ghost button--small" data-role="entities-refresh" {% if not recognition_enabled %}disabled aria-disabled="true"{% endif %}>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<dl class="entity-summary__stats" data-role="entity-stats"></dl>
|
||||
<ol class="entity-summary__list" data-role="entity-list-entities"></ol>
|
||||
<template data-role="entity-row-template">
|
||||
<li class="entity-summary__item" data-role="entity-row">
|
||||
<header class="entity-summary__item-header">
|
||||
<div>
|
||||
<h3 class="entity-summary__label" data-role="entity-label"></h3>
|
||||
<p class="entity-summary__kind" data-role="entity-kind"></p>
|
||||
</div>
|
||||
<div class="entity-summary__actions">
|
||||
<span class="badge badge--muted" data-role="entity-count"></span>
|
||||
<button type="button" class="button button--ghost button--small" data-role="speaker-preview" data-entity-preview="true" hidden>Preview</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="entity-add-override">Add manual override</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="entity-inline-override" data-role="inline-override" hidden>
|
||||
<div class="entity-inline-override__fields">
|
||||
<label class="field" data-role="inline-override-pronunciation-label">
|
||||
<span>Pronunciation</span>
|
||||
<input type="text" data-role="manual-override-pronunciation" value="">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Assigned voice</span>
|
||||
<select data-role="manual-override-voice" data-default-voice="{{ pending.voice }}">
|
||||
<option value="">Use narrator voice ({{ pending.voice }})</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="entity-inline-override__actions">
|
||||
<div class="entity-inline-override__buttons">
|
||||
<button type="button" class="button button--ghost button--small" data-role="speaker-preview" data-preview-source="manual">Preview</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="inline-override-save">Save</button>
|
||||
</div>
|
||||
<button type="button" class="button button--ghost button--small" data-role="inline-override-remove">Remove override</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entity-summary__samples" data-role="entity-samples"></div>
|
||||
</li>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="entity-tabs__panel"
|
||||
data-role="entity-panel"
|
||||
data-panel="manual"
|
||||
id="entity-panel-manual"
|
||||
role="tabpanel"
|
||||
aria-labelledby="entity-tab-manual"
|
||||
hidden>
|
||||
<div class="manual-overrides" data-role="manual-overrides">
|
||||
<header class="manual-overrides__header">
|
||||
<div class="manual-overrides__copy">
|
||||
<h2>Manual overrides</h2>
|
||||
<p class="hint">Search tokens from the book or add custom entries. Set pronunciations and assign voices to ensure previews and conversions use your preferred delivery.</p>
|
||||
</div>
|
||||
<div class="manual-overrides__header-actions">
|
||||
<span class="manual-overrides__status" data-role="manual-override-status" aria-live="polite"></span>
|
||||
<button type="button" class="button button--ghost button--small" data-role="manual-override-save-all">Save overrides</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="manual-overrides__search">
|
||||
<label class="field" for="manual-override-query">
|
||||
<span>Search manuscript tokens</span>
|
||||
<input type="search" id="manual-override-query" data-role="manual-override-query" placeholder="Search by name or phrase">
|
||||
</label>
|
||||
<div class="manual-overrides__search-actions">
|
||||
<button type="button" class="button button--ghost" data-role="manual-override-search">Search</button>
|
||||
<button type="button" class="button button--ghost" data-role="manual-override-add-custom">Add custom token</button>
|
||||
</div>
|
||||
<ul class="manual-overrides__results" data-role="manual-override-results"></ul>
|
||||
</div>
|
||||
<div class="manual-overrides__list" data-role="manual-override-list"></div>
|
||||
<template data-role="manual-override-template">
|
||||
<article class="manual-override" data-override-id="">
|
||||
<header class="manual-override__header">
|
||||
<div>
|
||||
<h3 class="manual-override__label" data-role="override-label"></h3>
|
||||
<p class="manual-override__notes" data-role="override-notes"></p>
|
||||
</div>
|
||||
<div class="manual-override__actions">
|
||||
<button type="button" class="button button--ghost button--small" data-role="speaker-preview" data-preview-source="manual">Preview</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="manual-override-delete">Remove</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="manual-override__body">
|
||||
<label class="field" data-role="manual-override-pronunciation-label">
|
||||
<span>Pronunciation</span>
|
||||
<input type="text" data-role="manual-override-pronunciation" value="">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Assigned voice</span>
|
||||
<select data-role="manual-override-voice" data-default-voice="{{ pending.voice }}">
|
||||
<option value="">Use narrator voice ({{ pending.voice }})</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="manual-override__meta" data-role="manual-override-meta"></div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<p class="manual-overrides__empty" data-role="manual-overrides-empty" hidden>No overrides yet. Use search or add a custom entry to begin.</p>
|
||||
|
||||
<section class="manual-overrides__section" data-role="heteronym-overrides">
|
||||
<header class="manual-overrides__header">
|
||||
<div class="manual-overrides__copy">
|
||||
<h2>Heteronyms</h2>
|
||||
<p class="hint">Review sentences that contain a word with multiple pronunciations. The suggested option is selected by default. Hover the highlighted word for examples and use Preview to audition each pronunciation.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="manual-overrides__list" data-role="heteronym-override-list"></div>
|
||||
<template data-role="heteronym-override-template">
|
||||
<article class="manual-override" data-entry-id="">
|
||||
<header class="manual-override__header">
|
||||
<div>
|
||||
<h3 class="manual-override__label" data-role="heteronym-sentence"></h3>
|
||||
<p class="manual-override__notes" data-role="heteronym-notes"></p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="manual-override__body" data-role="heteronym-options"></div>
|
||||
</article>
|
||||
</template>
|
||||
<p class="manual-overrides__empty" data-role="heteronym-overrides-empty" hidden>No heteronyms found in the current selection.</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="modal__footer wizard-card__footer">
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="button" class="button button--ghost" data-role="wizard-back" data-target-step="chapters" data-pending-id="{{ pending.id }}">Previous</button>
|
||||
<button type="button" class="button button--ghost" data-role="wizard-cancel" data-pending-id="{{ pending.id }}">Cancel</button>
|
||||
</div>
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="submit" class="button" data-step-target="finalize">Queue conversion</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
<div class="modal" data-role="voice-modal" hidden>
|
||||
<div class="modal__overlay" data-role="voice-modal-close" tabindex="-1"></div>
|
||||
<div class="modal__content voice-browser" role="dialog" aria-modal="true" aria-labelledby="voice-modal-title">
|
||||
<header class="voice-browser__header">
|
||||
<h2 id="voice-modal-title">Choose a voice</h2>
|
||||
<button type="button" class="icon-button" data-role="voice-modal-close" aria-label="Close voice browser">✕</button>
|
||||
</header>
|
||||
<div class="voice-browser__body">
|
||||
<aside class="voice-browser__filters">
|
||||
<label class="field">
|
||||
<span>Search</span>
|
||||
<input type="search" data-role="voice-filter" placeholder="Search by name, language, or tag">
|
||||
</label>
|
||||
<div class="voice-browser__chips" data-role="voice-filter-chips"></div>
|
||||
</aside>
|
||||
<section class="voice-browser__results" data-role="voice-results"></section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="entity-summary-data" type="application/json">{{ pending.entity_summary or {} | tojson }}</script>
|
||||
<script id="entity-cache-key" type="application/json">{{ pending.entity_cache_key or '' | tojson }}</script>
|
||||
<script id="manual-overrides-data" type="application/json">{{ pending.manual_overrides or [] | tojson }}</script>
|
||||
<script id="pronunciation-overrides-data" type="application/json">{{ pending.pronunciation_overrides or [] | tojson }}</script>
|
||||
<script id="heteronym-overrides-data" type="application/json">{{ pending.heteronym_overrides or [] | tojson }}</script>
|
||||
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
|
||||
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
|
||||
{% if embed_scripts %}
|
||||
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,19 @@
|
||||
<div class="modal" data-role="reader-modal" hidden>
|
||||
<div class="modal__overlay" data-role="reader-modal-close" tabindex="-1"></div>
|
||||
<div class="modal__content card card--modal reader-modal" role="dialog" aria-modal="true" aria-labelledby="reader-modal-title">
|
||||
<header class="modal__header">
|
||||
<div class="modal__heading">
|
||||
<h2 class="modal__title" id="reader-modal-title">Read & listen</h2>
|
||||
<p class="hint" data-role="reader-modal-hint">Preview synchronized narration directly in your browser.</p>
|
||||
</div>
|
||||
<button type="button" class="icon-button" data-role="reader-modal-close" aria-label="Close reader">✕</button>
|
||||
</header>
|
||||
<div class="modal__body reader-modal__body">
|
||||
<iframe title="EPUB reader"
|
||||
class="reader-modal__frame"
|
||||
data-role="reader-frame"
|
||||
allow="autoplay"
|
||||
loading="lazy"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
{% set pending = pending if pending is defined else None %}
|
||||
{% set readonly = readonly if readonly is defined else False %}
|
||||
{% set settings_dict = settings if settings is defined else {} %}
|
||||
{% set provided_step = step if step is defined else (active_step if active_step is defined else 'book') %}
|
||||
{% set current_step = provided_step %}
|
||||
{% if current_step in ['settings', 'upload', ''] %}{% set current_step = 'book' %}{% endif %}
|
||||
{% if current_step not in ['book', 'chapters', 'entities'] %}{% set current_step = 'book' %}{% endif %}
|
||||
{% set step_number = {'book': 1, 'chapters': 2, 'entities': 3} %}
|
||||
{% set step_titles = {
|
||||
'book': 'Book parameters',
|
||||
'chapters': 'Select chapters',
|
||||
'entities': 'Review entities'
|
||||
} %}
|
||||
{% set step_hints = {
|
||||
'book': 'Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.',
|
||||
'chapters': "Choose which chapters to convert. We'll analyse entities automatically when you continue.",
|
||||
'entities': 'Assign pronunciations, voices, and manual overrides before queueing the conversion.'
|
||||
} %}
|
||||
{% set navigation_labels = {
|
||||
'book': 'Book parameters',
|
||||
'chapters': 'Chapters',
|
||||
'entities': 'Entities'
|
||||
} %}
|
||||
{% set total_steps = 3 %}
|
||||
{% set current_index = step_number[current_step] %}
|
||||
{% set is_open = open if open is defined else False %}
|
||||
{% set prepare_url_template = url_for('main.wizard_start', pending_id='__pending__') %}
|
||||
{% set cancel_url_template = url_for('main.wizard_cancel', pending_id='__pending__') %}
|
||||
{% set analyze_url_template = url_for('main.wizard_update', pending_id='__pending__') %}
|
||||
<div class="modal"
|
||||
data-role="new-job-modal"
|
||||
data-step="{{ current_step }}"
|
||||
data-pending-id="{{ pending.id if pending else '' }}"
|
||||
data-prepare-url-template="{{ prepare_url_template }}"
|
||||
data-cancel-url-template="{{ cancel_url_template }}"
|
||||
data-analyze-url-template="{{ analyze_url_template }}"
|
||||
{% if is_open %}data-open="true"{% else %}hidden{% endif %}>
|
||||
<div class="modal__overlay" data-role="new-job-modal-close" tabindex="-1"></div>
|
||||
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="new-job-modal-title">
|
||||
<header class="modal__header wizard-card__header">
|
||||
<div class="wizard-card__headline">
|
||||
<nav class="step-indicator" aria-label="New job workflow">
|
||||
{% for slug, label in navigation_labels.items() %}
|
||||
{% set item_index = step_number[slug] %}
|
||||
{% set state = 'is-active' if slug == current_step else ('is-complete' if item_index < current_index else '') %}
|
||||
<button type="button"
|
||||
class="step-indicator__item {{ state }}"
|
||||
data-role="wizard-step-indicator"
|
||||
data-step="{{ slug }}"
|
||||
aria-current="{{ 'step' if slug == current_step else 'false' }}">
|
||||
<span class="step-indicator__index">{{ item_index }}</span>
|
||||
<span class="step-indicator__label">{{ label }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<h2 class="modal__title" id="new-job-modal-title">{{ step_titles[current_step] }}</h2>
|
||||
<p class="hint" data-role="wizard-hint">{{ step_hints[current_step] }}</p>
|
||||
</div>
|
||||
<div class="wizard-card__aside">
|
||||
<button type="button" class="icon-button wizard-card__close" data-role="new-job-modal-close" aria-label="Close new job wizard">✕</button>
|
||||
{% if pending %}
|
||||
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<div class="wizard-card__stage" data-role="wizard-stage">
|
||||
{% if current_step == 'book' %}
|
||||
{% include 'partials/new_job_step_book.html' %}
|
||||
{% elif current_step == 'chapters' %}
|
||||
{% include 'partials/new_job_step_chapters.html' %}
|
||||
{% else %}
|
||||
{% include 'partials/new_job_step_entities.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,190 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
|
||||
|
||||
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
|
||||
{% set total_steps = 3 if is_multi_speaker else 2 %}
|
||||
|
||||
{% block content %}
|
||||
<section class="wizard-page wizard-page--modal" data-step="chapters">
|
||||
<div class="modal modal--wizard" data-role="wizard-modal" data-open="true">
|
||||
<div class="modal__overlay" aria-hidden="true"></div>
|
||||
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="prepare-chapters-title">
|
||||
<header class="modal__header wizard-card__header">
|
||||
<div class="wizard-card__headline">
|
||||
<nav class="step-indicator" aria-label="Audiobook workflow">
|
||||
<button type="button"
|
||||
class="step-indicator__item is-complete"
|
||||
data-role="open-upload-modal">
|
||||
<span class="step-indicator__index">1</span>
|
||||
<span class="step-indicator__label">Upload & settings</span>
|
||||
</button>
|
||||
<a class="step-indicator__item is-active"
|
||||
aria-current="step"
|
||||
href="{{ url_for('main.wizard_step', pending_id=pending.id, step='chapters') }}">
|
||||
<span class="step-indicator__index">2</span>
|
||||
<span class="step-indicator__label">Chapters</span>
|
||||
</a>
|
||||
{% if is_multi_speaker %}
|
||||
<a class="step-indicator__item"
|
||||
href="{{ url_for('main.wizard_step', pending_id=pending.id, step='entities') }}">
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Entities</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<h2 class="modal__title" id="prepare-chapters-title">Select chapters</h2>
|
||||
<p class="hint">Choose which chapters to convert. We'll analyse entities automatically when you continue.</p>
|
||||
</div>
|
||||
<div class="wizard-card__aside">
|
||||
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<form method="post"
|
||||
action="{{ url_for('main.wizard_finish', pending_id=pending.id) }}"
|
||||
class="prepare-form"
|
||||
id="prepare-form"
|
||||
data-speaker-mode="{{ pending.speaker_mode }}"
|
||||
data-analyze-url="{{ url_for('main.wizard_update', pending_id=pending.id) }}">
|
||||
<input type="hidden" name="active_step" value="chapters" data-role="active-step-input">
|
||||
<div class="wizard-hidden-inputs" aria-hidden="true">
|
||||
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
|
||||
<input type="hidden" name="speaker_mode" value="{{ pending.speaker_mode }}">
|
||||
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
|
||||
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
||||
<input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}">
|
||||
<input type="hidden" name="read_closing_outro" value="{{ 'true' if pending.read_closing_outro else 'false' }}">
|
||||
<input type="hidden" name="normalize_chapter_opening_caps" value="{{ 'true' if pending.normalize_chapter_opening_caps else 'false' }}">
|
||||
{% if pending.generate_epub3 %}
|
||||
<input type="hidden" name="generate_epub3" value="true">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal__body wizard-card__body">
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="alert alert--info">{{ notice }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="form-section">
|
||||
<div class="form-section__title-row">
|
||||
<h3 class="form-section__title">Detected chapters</h3>
|
||||
<p class="hint">Toggle chapters on or off, rename them, and override the voice per chapter if needed.</p>
|
||||
</div>
|
||||
<div class="chapter-grid">
|
||||
{% for chapter in pending.chapters %}
|
||||
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
|
||||
{% set selected_option = '__default' %}
|
||||
{% if chapter.voice_profile %}
|
||||
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
|
||||
{% elif chapter.voice %}
|
||||
{% set selected_option = 'voice:' ~ chapter.voice %}
|
||||
{% elif chapter.voice_formula %}
|
||||
{% set selected_option = 'formula' %}
|
||||
{% endif %}
|
||||
<article class="chapter-card"
|
||||
data-role="chapter-row"
|
||||
data-disabled="{{ 'false' if is_enabled else 'true' }}"
|
||||
data-expanded="false">
|
||||
<header class="chapter-card__summary" data-role="chapter-summary">
|
||||
<label class="chapter-card__checkbox">
|
||||
<input type="checkbox"
|
||||
name="chapter-{{ loop.index0 }}-enabled"
|
||||
data-role="chapter-enabled"
|
||||
{% if is_enabled %}checked{% endif %}>
|
||||
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
|
||||
</label>
|
||||
<button type="button"
|
||||
class="chapter-card__toggle"
|
||||
data-role="chapter-toggle"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle chapter details">
|
||||
<span class="chapter-card__toggle-icon" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="chapter-card__details"
|
||||
data-role="chapter-details">
|
||||
<div class="chapter-card__field">
|
||||
<label for="chapter-{{ loop.index0 }}-title">Title</label>
|
||||
<input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
|
||||
</div>
|
||||
<div class="chapter-card__preview">
|
||||
<details>
|
||||
<summary>Preview full text</summary>
|
||||
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<div class="chapter-card__field">
|
||||
<label for="chapter-{{ loop.index0 }}-voice">Voice override</label>
|
||||
<select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select">
|
||||
<option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option>
|
||||
<optgroup label="Voices">
|
||||
{% for voice in options.voices %}
|
||||
<option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Profiles">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
<option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option>
|
||||
</select>
|
||||
<input type="text"
|
||||
name="chapter-{{ loop.index0 }}-formula"
|
||||
class="chapter-card__formula"
|
||||
data-role="formula-input"
|
||||
placeholder="af_nova*0.4+am_liam*0.6"
|
||||
value="{{ chapter.voice_formula or '' }}"
|
||||
{% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="modal__footer wizard-card__footer">
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="button" class="button button--ghost" data-role="wizard-previous">Previous</button>
|
||||
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
|
||||
</div>
|
||||
<div class="wizard-card__footer-actions">
|
||||
{% if is_multi_speaker %}
|
||||
<button type="submit"
|
||||
class="button"
|
||||
data-role="submit-speaker-analysis"
|
||||
data-step-toggle="analysis"
|
||||
formaction="{{ url_for('main.wizard_update', pending_id=pending.id) }}"
|
||||
formmethod="post">
|
||||
Continue to entities
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="button" data-step-toggle="finalize"{% if is_multi_speaker %} hidden aria-hidden="true"{% endif %}>
|
||||
Queue conversion
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('main.wizard_cancel', pending_id=pending.id) }}" id="cancel-form"></form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% with pending=pending, readonly=True, active_step='chapters' %}
|
||||
{% include "partials/upload_modal.html" %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script>
|
||||
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
|
||||
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
|
||||
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{# This template is intentionally left empty.
|
||||
Step-specific templates now live in `prepare_chapters.html` and `prepare_entities.html`.
|
||||
The file is kept as a placeholder to avoid breaking documentation references. #}
|
||||
@@ -0,0 +1,172 @@
|
||||
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
{% if not speaker.voice_formula %}hidden{% endif %}>
|
||||
Preview generated
|
||||
</button>
|
||||
</div>
|
||||
<div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}>
|
||||
<span class="tag">Custom mix</span>
|
||||
<span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span>
|
||||
<div class="speaker-list__mix-actions">
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-source="mix"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ pronunciation_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
|
||||
Preview mix
|
||||
</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula">
|
||||
</div>
|
||||
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
|
||||
<summary>Sample paragraphs</summary>
|
||||
{% if sample_quotes %}
|
||||
{% set first_sample = sample_quotes[0] if sample_quotes|length > 0 else None %}
|
||||
{% set first_excerpt = first_sample.excerpt if first_sample is mapping else first_sample %}
|
||||
{% set first_hint = first_sample.gender_hint if first_sample is mapping else '' %}
|
||||
<article class="speaker-sample" data-role="speaker-sample">
|
||||
<p data-role="sample-text">{{ first_excerpt }}</p>
|
||||
<p class="hint" data-role="sample-hint" {% if not first_hint %}hidden{% endif %}>{{ first_hint }}</p>
|
||||
<div class="speaker-sample__actions">
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-source="sample"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ first_excerpt }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
|
||||
Preview with assigned voice
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="open-voice-browser"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-sample-index="0">
|
||||
Preview in voice browser
|
||||
</button>
|
||||
{% if sample_quotes|length > 1 %}
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-next-sample">
|
||||
Show another example
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</p>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% if speaker.recommended_voices %}
|
||||
<div class="speaker-list__recommendations">
|
||||
<span class="hint">Suggested:</span>
|
||||
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
|
||||
<button type="button"
|
||||
class="chip"
|
||||
data-role="recommended-voice"
|
||||
data-voice="{{ voice_id }}"
|
||||
title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}">
|
||||
{{ voice_meta.display_name or voice_id }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<footer class="modal__footer wizard-card__footer">
|
||||
<div class="wizard-card__footer-actions">
|
||||
<a class="button button--ghost" href="{{ url_for('main.wizard_step', pending_id=pending.id, step='chapters') }}">Previous</a>
|
||||
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
|
||||
</div>
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="submit" class="button" form="prepare-form">Next: Review Entities</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('main.wizard_cancel', pending_id=pending.id) }}" id="cancel-form"></form>
|
||||
|
||||
<div class="modal" data-role="voice-modal" hidden>
|
||||
<div class="modal__overlay" data-role="voice-modal-close" tabindex="-1"></div>
|
||||
<div class="modal__content voice-browser" role="dialog" aria-modal="true" aria-labelledby="voice-modal-title">
|
||||
<header class="voice-browser__header">
|
||||
<h2 id="voice-modal-title">Choose a voice</h2>
|
||||
<button type="button" class="icon-button" data-role="voice-modal-close" aria-label="Close voice browser">✕</button>
|
||||
</header>
|
||||
<div class="voice-browser__body">
|
||||
<aside class="voice-browser__filters">
|
||||
<label class="field">
|
||||
<span>Search</span>
|
||||
<input type="search" data-role="voice-modal-search" placeholder="Search by name or language">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Language</span>
|
||||
<select data-role="voice-modal-language">
|
||||
<option value="">All languages</option>
|
||||
{% for code, label in options.languages.items() %}
|
||||
<option value="{{ code }}">{{ label }} ({{ code|upper }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div class="voice-browser__gender" role="group" aria-label="Filter by gender">
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="">All</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="f">Female</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button>
|
||||
</div>
|
||||
<button type="button" class="button button--ghost" data-role="voice-modal-clear">Clear selection</button>
|
||||
</aside>
|
||||
<section class="voice-browser__catalog" aria-label="Voice catalog">
|
||||
<ul class="voice-browser__list" data-role="voice-modal-list"></ul>
|
||||
</section>
|
||||
<section class="voice-browser__mix" data-role="voice-modal-mix">
|
||||
<header class="voice-browser__mix-header">
|
||||
<h3>Selected voices</h3>
|
||||
<p class="tag" data-role="voice-modal-mix-total">Total weight: 0.00</p>
|
||||
</header>
|
||||
<div class="voice-browser__mix-list" data-role="voice-modal-mix-list"></div>
|
||||
<section class="voice-browser__preview" data-role="voice-modal-preview">
|
||||
<h3 data-role="voice-modal-selected-name">Select a voice to preview</h3>
|
||||
<p class="tag" data-role="voice-modal-selected-meta"></p>
|
||||
<div class="voice-browser__samples" data-role="voice-modal-samples"></div>
|
||||
<div class="voice-browser__actions">
|
||||
<button type="button" class="button" data-role="voice-modal-apply" disabled>Apply mix</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% with pending=pending, readonly=True, active_step='speakers' %}
|
||||
{% include "partials/upload_modal.html" %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script>
|
||||
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
|
||||
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
|
||||
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · Queue{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div id="jobs-panel"
|
||||
hx-get="{{ url_for('jobs.jobs_partial') }}"
|
||||
hx-trigger="load, every 3s"
|
||||
hx-target="#jobs-panel"
|
||||
hx-swap="innerHTML">
|
||||
{{ jobs_panel|safe }}
|
||||
</div>
|
||||
</section>
|
||||
{% include "partials/reader_modal.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script type="module" src="{{ url_for('static', filename='queue.js') }}"></script>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,646 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · Settings{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card settings">
|
||||
<h1 class="card__title">Application Settings</h1>
|
||||
<p class="tag">Settings apply to new jobs you queue from the dashboard.</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">Settings saved successfully.</div>
|
||||
{% endif %}
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=True) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert {% if category == 'error' %}alert--error{% else %}alert--success{% endif %}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="settings-layout">
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
<button type="button" class="settings-nav__item is-active" data-section="narration">Narration</button>
|
||||
<button type="button" class="settings-nav__item" data-section="audio">Audio & Delivery</button>
|
||||
<button type="button" class="settings-nav__item" data-section="subtitles">Subtitles & Text</button>
|
||||
<button type="button" class="settings-nav__item" data-section="performance">Performance</button>
|
||||
<button type="button" class="settings-nav__item{% if not llm_ready %} is-disabled{% endif %}" data-section="llm">LLM</button>
|
||||
<button type="button" class="settings-nav__item" data-section="normalization">Text Normalization</button>
|
||||
<button type="button" class="settings-nav__item" data-section="integrations">Integrations</button>
|
||||
<button type="button" class="settings-nav__item" data-section="debug">Debug</button>
|
||||
</nav>
|
||||
<form action="{{ url_for('settings.update_settings') }}" method="post" class="settings__form">
|
||||
<div class="settings-panels">
|
||||
<section class="settings-panel is-active" data-section="narration">
|
||||
<fieldset class="settings__section">
|
||||
<legend>Narration Defaults</legend>
|
||||
<div class="field">
|
||||
<label for="default_speaker">Default Speaker</label>
|
||||
<select id="default_speaker" name="default_speaker">
|
||||
<option value="" {% if not settings.default_speaker %}selected{% endif %}>Use fallback voice</option>
|
||||
{% if options.voice_profile_options %}
|
||||
{% for profile in options.voice_profile_options %}
|
||||
{% set profile_value = 'speaker:' ~ profile.name %}
|
||||
<option value="{{ profile_value }}" {% if settings.default_speaker == profile_value %}selected{% endif %}>{{ profile.name }}{% if profile.provider %} · {{ profile.provider|capitalize }}{% endif %}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set current_default = settings.default_speaker %}
|
||||
{% if current_default %}
|
||||
{% set known_default = namespace(value=False) %}
|
||||
{% for profile in options.voice_profile_options %}
|
||||
{% if current_default == 'speaker:' ~ profile.name or current_default == 'profile:' ~ profile.name %}
|
||||
{% set known_default.value = True %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not known_default.value %}
|
||||
<option value="{{ current_default }}" selected>{{ current_default }}</option>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<p class="hint">Pick a saved speaker from Speaker Studio to use by default for new jobs.</p>
|
||||
</div>
|
||||
|
||||
<div class="field field--wide">
|
||||
<p class="tag">Kokoro settings</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="default_voice">Fallback Kokoro Voice</label>
|
||||
<select id="default_voice" name="default_voice">
|
||||
<optgroup label="Standard voices">
|
||||
{% for voice in options.voices %}
|
||||
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Saved speakers">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
{% set profile_value = 'speaker:' ~ profile.name %}
|
||||
<option value="{{ profile_value }}" {% if settings.default_voice == profile_value %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% set current_default = settings.default_voice %}
|
||||
{% if current_default %}
|
||||
{% set known_default = namespace(value=False) %}
|
||||
{% if current_default in options.voices %}
|
||||
{% set known_default.value = True %}
|
||||
{% else %}
|
||||
{% for profile in options.voice_profile_options %}
|
||||
{% if current_default == 'profile:' ~ profile.name or current_default == 'speaker:' ~ profile.name %}
|
||||
{% set known_default.value = True %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if not known_default.value %}
|
||||
<option value="{{ current_default }}" selected>{{ current_default }}</option>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<p class="hint">Used when no default speaker is selected, and as a fallback when speaker analysis cannot resolve a speaker.</p>
|
||||
</div>
|
||||
|
||||
<div class="field field--wide">
|
||||
<p class="tag">Supertonic settings</p>
|
||||
<p class="hint">These defaults apply when a Supertonic speaker does not override them.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="supertonic_total_steps">Supertonic Quality (total steps)</label>
|
||||
<input type="number" id="supertonic_total_steps" name="supertonic_total_steps" min="2" max="15" value="{{ settings.supertonic_total_steps }}">
|
||||
<p class="hint">2 = fastest/lowest quality, 15 = slowest/highest quality.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="supertonic_speed">Supertonic Speed</label>
|
||||
<input type="number" id="supertonic_speed" name="supertonic_speed" min="0.7" max="2.0" step="0.05" value="{{ '%.2f'|format(settings.supertonic_speed) }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="chunk_level_default">Chunk Granularity</label>
|
||||
<select id="chunk_level_default" name="chunk_level">
|
||||
{% for option in options.chunk_levels %}
|
||||
<option value="{{ option.value }}" {% if settings.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speaker_analysis_threshold">Speaker Analysis Minimum Mentions</label>
|
||||
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ settings.speaker_analysis_threshold }}">
|
||||
<p class="hint">Speakers detected fewer times fall back to the narrator voice.</p>
|
||||
</div>
|
||||
<div class="field field--wide">
|
||||
<label for="speaker_pronunciation_sentence">Speaker Pronunciation Preview</label>
|
||||
<input type="text" id="speaker_pronunciation_sentence" name="speaker_pronunciation_sentence" value="{{ settings.speaker_pronunciation_sentence }}" placeholder="This is {{ '{{name}}' }} speaking.">
|
||||
<p class="hint">Include <code>{{ '{{name}}' }}</code> where the speaker name should be inserted.</p>
|
||||
</div>
|
||||
<div class="field field--stack">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="enable_entity_recognition" value="true" {% if settings.enable_entity_recognition %}checked{% endif %}>
|
||||
<span>Automatically detect entities for new jobs</span>
|
||||
</label>
|
||||
<p class="hint">Disable if you prefer to skip entity extraction in the job wizard.</p>
|
||||
</div>
|
||||
<div class="field field--wide">
|
||||
<label for="speaker_random_languages">Randomizer Languages</label>
|
||||
{% set selected_languages = settings.speaker_random_languages or [] %}
|
||||
<select id="speaker_random_languages" name="speaker_random_languages" multiple size="6">
|
||||
{% for code, label in options.languages.items() %}
|
||||
<option value="{{ code }}" {% if code in selected_languages %}selected{% endif %}>{{ label }} ({{ code|upper }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Limits random voice selection for speakers marked as random. Leave empty to allow any language.</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel" data-section="audio">
|
||||
<fieldset class="settings__section">
|
||||
<legend>Audio & Delivery</legend>
|
||||
<div class="field">
|
||||
<label for="output_format">Audio Format</label>
|
||||
<select id="output_format" name="output_format">
|
||||
{% for fmt in options.output_formats %}
|
||||
<option value="{{ fmt }}" {% if settings.output_format == fmt %}selected{% endif %}>{{ fmt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="save_mode">Save Location</label>
|
||||
<select id="save_mode" name="save_mode">
|
||||
{% for location in save_locations %}
|
||||
<option value="{{ location.value }}" {% if settings.save_mode == location.value %}selected{% endif %}>{{ location.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="tag">Default output: <code>{{ default_output_dir }}</code></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="separate_chapters_format">Separate Chapter Format</label>
|
||||
<select id="separate_chapters_format" name="separate_chapters_format">
|
||||
{% for fmt in options.separate_formats %}
|
||||
<option value="{{ fmt }}" {% if settings.separate_chapters_format == fmt %}selected{% endif %}>{{ fmt|upper }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field field--choices">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="save_chapters_separately" value="true" {% if settings.save_chapters_separately %}checked{% endif %}>
|
||||
<span>Save Each Chapter Separately</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="merge_chapters_at_end" value="true" {% if settings.merge_chapters_at_end %}checked{% endif %}>
|
||||
<span>Also Create Merged Audiobook</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="save_as_project" value="true" {% if settings.save_as_project %}checked{% endif %}>
|
||||
<span>Save as Project With Metadata</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="silence_between_chapters">Silence Between Chapters (Seconds)</label>
|
||||
<input type="number" step="0.5" min="0" id="silence_between_chapters" name="silence_between_chapters" value="{{ settings.silence_between_chapters }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="chapter_intro_delay">Pause After Chapter Titles (Seconds)</label>
|
||||
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(settings.chapter_intro_delay) }}">
|
||||
<p class="hint">Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.</p>
|
||||
</div>
|
||||
<div class="field field--stack">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="read_title_intro" value="true" {% if settings.read_title_intro %}checked{% endif %}>
|
||||
<span>Read Book Title Before Narration</span>
|
||||
</label>
|
||||
<p class="hint">When enabled, the narrator speaks the title, optional subtitle, and author names before chapter one.</p>
|
||||
</div>
|
||||
<div class="field field--stack">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="read_closing_outro" value="true" {% if settings.read_closing_outro %}checked{% endif %}>
|
||||
<span>Read Closing Outro After Narration</span>
|
||||
</label>
|
||||
<p class="hint">Adds a brief "The end" line after the final chapter, optionally including series information.</p>
|
||||
</div>
|
||||
<div class="field field--stack">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="normalize_chapter_opening_caps" value="true" {% if settings.normalize_chapter_opening_caps %}checked{% endif %}>
|
||||
<span>Normalize ALL CAPS Chapter Openings</span>
|
||||
</label>
|
||||
<p class="hint">Converts screaming uppercase openings to sentence case while preserving acronyms.</p>
|
||||
</div>
|
||||
<div class="field field--stack">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="auto_prefix_chapter_titles" value="true" {% if settings.auto_prefix_chapter_titles %}checked{% endif %}>
|
||||
<span>Add "Chapter" before numeric chapter titles</span>
|
||||
</label>
|
||||
<p class="hint">Ensures the spoken chapter heading starts with "Chapter" when source titles begin with only a number or numeral.</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel" data-section="subtitles">
|
||||
<fieldset class="settings__section">
|
||||
<legend>Subtitles & Text</legend>
|
||||
<div class="field">
|
||||
<label for="subtitle_format">Subtitle File Format</label>
|
||||
<select id="subtitle_format" name="subtitle_format">
|
||||
{% for value, text in options.subtitle_formats %}
|
||||
<option value="{{ value }}" {% if settings.subtitle_format == value %}selected{% endif %}>{{ text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="max_subtitle_words">Max Words Per Subtitle Entry</label>
|
||||
<input type="number" min="1" max="200" id="max_subtitle_words" name="max_subtitle_words" value="{{ settings.max_subtitle_words }}">
|
||||
</div>
|
||||
<div class="field field--choices">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="replace_single_newlines" value="true" {% if settings.replace_single_newlines %}checked{% endif %}>
|
||||
<span>Replace Single Newlines</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}>
|
||||
<span>Generate EPUB 3 (experimental)</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel" data-section="performance">
|
||||
<fieldset class="settings__section">
|
||||
<legend>Performance</legend>
|
||||
<div class="field field--choices">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="use_gpu" value="true" {% if settings.use_gpu %}checked{% endif %}>
|
||||
<span>Use GPU (when available)</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel" data-section="llm">
|
||||
<fieldset class="settings__section">
|
||||
<legend>Endpoint</legend>
|
||||
<div class="field">
|
||||
<label for="llm_base_url">Base URL</label>
|
||||
<input type="url" id="llm_base_url" name="llm_base_url" value="{{ settings.llm_base_url }}" placeholder="https://localhost:11434/v1">
|
||||
<p class="hint">Point to an OpenAI-compatible endpoint such as Ollama or a proxy.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="llm_api_key">API Key</label>
|
||||
<input type="text" id="llm_api_key" name="llm_api_key" value="{{ settings.llm_api_key }}" autocomplete="off" placeholder="ollama">
|
||||
<p class="hint">Leave blank or use <code>ollama</code> for local servers that do not require keys.</p>
|
||||
</div>
|
||||
<div class="field field--inline">
|
||||
<div class="field__group">
|
||||
<label for="llm_model">Default Model</label>
|
||||
<select id="llm_model" name="llm_model" data-current-model="{{ settings.llm_model }}">
|
||||
{% if settings.llm_model %}
|
||||
<option value="{{ settings.llm_model }}" selected>{{ settings.llm_model }}</option>
|
||||
{% else %}
|
||||
<option value="" selected disabled>Select a model</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field__group">
|
||||
<label for="llm_timeout">Timeout (seconds)</label>
|
||||
<input type="number" step="1" min="1" id="llm_timeout" name="llm_timeout" value="{{ settings.llm_timeout }}">
|
||||
</div>
|
||||
<button type="button" class="button button--ghost" data-action="llm-refresh-models">Refresh models</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="settings__section">
|
||||
<legend>Normalization Prompt</legend>
|
||||
<div class="field">
|
||||
<label for="llm_prompt">Prompt Template</label>
|
||||
<textarea id="llm_prompt" name="llm_prompt" rows="6">{{ settings.llm_prompt }}</textarea>
|
||||
<p class="hint">Use <code>{{ '{{sentence}}' }}</code> for the active sentence. <code>{{ '{{paragraph}}' }}</code> remains available for legacy prompts.</p>
|
||||
</div>
|
||||
<div class="field field--choices">
|
||||
<span class="field__label">Context Mode</span>
|
||||
<div class="choices choices--inline">
|
||||
{% for option in llm_context_options %}
|
||||
<label class="radio-pill">
|
||||
<input type="radio" name="llm_context_mode" value="{{ option.value }}" {% if settings.llm_context_mode == option.value %}checked{% endif %}>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-card" data-preview="llm">
|
||||
<label for="llm_preview_text">Try the prompt</label>
|
||||
<textarea id="llm_preview_text" rows="3">I've been waiting all day.</textarea>
|
||||
<div class="preview-card__actions">
|
||||
<button type="button" class="button" data-action="llm-preview">Preview</button>
|
||||
<span class="preview-card__status" data-role="llm-preview-status"></span>
|
||||
</div>
|
||||
<div class="preview-card__output" data-role="llm-preview-output"></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel" data-section="normalization">
|
||||
{% for group in options.normalization_groups %}
|
||||
<fieldset class="settings__section">
|
||||
<legend>{{ group.label }}</legend>
|
||||
{% if group.label == "Apostrophes & Contractions" %}
|
||||
<div class="field">
|
||||
<span class="field__label">Strategy</span>
|
||||
<div class="choices choices--inline">
|
||||
{% for option in apostrophe_modes %}
|
||||
<label class="radio-pill">
|
||||
<input type="radio" name="normalization_apostrophe_mode" value="{{ option.value }}" {% if settings.normalization_apostrophe_mode == option.value %}checked{% endif %}>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if settings.normalization_apostrophe_mode == 'llm' and not llm_ready %}
|
||||
<p class="hint hint--warning">Configure the LLM connection before using it for audiobook runs.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="field field--choices">
|
||||
{% for option in group.options %}
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="{{ option.key }}" value="true" {% if settings[option.key] %}checked{% endif %}>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if group.label == "Apostrophes & Contractions" %}
|
||||
<div class="field field--inline field--actions">
|
||||
<button type="button" class="button button--ghost button--small" data-action="contraction-modal-open">Advanced Contraction Settings…</button>
|
||||
<p class="hint">Choose which contraction families are expanded.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
|
||||
<fieldset class="settings__section">
|
||||
<legend>Sample & Preview</legend>
|
||||
<div class="field field--inline">
|
||||
<div class="field__group">
|
||||
<label for="normalization_sample_select">Sample</label>
|
||||
<select id="normalization_sample_select">
|
||||
{% for key, text in normalization_samples.items() %}
|
||||
<option value="{{ text }}" {% if loop.first %}selected{% endif %}>{{ key|capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field__group">
|
||||
<label for="normalization_sample_voice">Voice</label>
|
||||
<select id="normalization_sample_voice">
|
||||
{% for voice in options.voices %}
|
||||
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="normalization_sample_text">Sample text</label>
|
||||
<textarea id="normalization_sample_text" rows="4">{{ normalization_samples['apostrophes'] }}</textarea>
|
||||
</div>
|
||||
<div class="preview-card" data-preview="normalization">
|
||||
<div class="preview-card__actions">
|
||||
<button type="button" class="button" data-action="normalization-preview">Preview with current settings</button>
|
||||
<span class="preview-card__status" data-role="normalization-preview-status"></span>
|
||||
</div>
|
||||
<pre class="preview-card__output" data-role="normalization-preview-output"></pre>
|
||||
<audio controls class="preview-card__audio" data-role="normalization-preview-audio" hidden></audio>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel" data-section="integrations">
|
||||
<fieldset class="settings__section">
|
||||
<legend>Calibre OPDS</legend>
|
||||
<div class="field field--choices">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="calibre_opds_enabled" value="true" {% if integrations.calibre_opds.enabled %}checked{% endif %}>
|
||||
<span>Enable Calibre integration</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="calibre_opds_verify_ssl" value="true" {% if integrations.calibre_opds.verify_ssl %}checked{% endif %}>
|
||||
<span>Verify TLS certificates</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="calibre_opds_base_url">Base URL</label>
|
||||
<input type="url" id="calibre_opds_base_url" name="calibre_opds_base_url" value="{{ integrations.calibre_opds.base_url }}" placeholder="https://calibre.example.com/opds">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="calibre_opds_username">Username</label>
|
||||
<input type="text" id="calibre_opds_username" name="calibre_opds_username" value="{{ integrations.calibre_opds.username }}">
|
||||
</div>
|
||||
<div class="field field--inline">
|
||||
<div class="field__group">
|
||||
<label for="calibre_opds_password">Password</label>
|
||||
<input type="password" id="calibre_opds_password" name="calibre_opds_password" value="" placeholder="{% if integrations.calibre_opds.has_password %}••••••••{% endif %}" data-has-secret="{{ 'true' if integrations.calibre_opds.has_password else 'false' }}">
|
||||
<p class="hint">Leave blank to keep the stored password.</p>
|
||||
</div>
|
||||
<div class="field__group field__group--checkbox">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="calibre_opds_password_clear" value="true">
|
||||
<span>Clear saved password</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field field--inline">
|
||||
<button type="button" class="button button--ghost" data-action="calibre-test">Test connection</button>
|
||||
<span class="field__status" data-role="calibre-test-status" aria-live="polite"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings__section">
|
||||
<legend>Audiobookshelf</legend>
|
||||
<div class="field field--choices">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="audiobookshelf_enabled" value="true" {% if integrations.audiobookshelf.enabled %}checked{% endif %}>
|
||||
<span>Enable Audiobookshelf uploads</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="audiobookshelf_auto_send" value="true" {% if integrations.audiobookshelf.auto_send %}checked{% endif %}>
|
||||
<span>Upload finished jobs automatically</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="audiobookshelf_verify_ssl" value="true" {% if integrations.audiobookshelf.verify_ssl %}checked{% endif %}>
|
||||
<span>Verify TLS certificates</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="audiobookshelf_base_url">Base URL</label>
|
||||
<input type="url" id="audiobookshelf_base_url" name="audiobookshelf_base_url" value="{{ integrations.audiobookshelf.base_url }}" placeholder="https://abs.local:13378">
|
||||
<p class="hint">Use the server root (no trailing <code>/api</code>); the upload requests add it automatically.</p>
|
||||
</div>
|
||||
<div class="field field--inline">
|
||||
<div class="field__group">
|
||||
<label for="audiobookshelf_library_id">Library ID</label>
|
||||
<input type="text" id="audiobookshelf_library_id" name="audiobookshelf_library_id" value="{{ integrations.audiobookshelf.library_id }}">
|
||||
</div>
|
||||
<div class="field__group">
|
||||
<label for="audiobookshelf_collection_id">Collection ID (optional)</label>
|
||||
<input type="text" id="audiobookshelf_collection_id" name="audiobookshelf_collection_id" value="{{ integrations.audiobookshelf.collection_id }}">
|
||||
</div>
|
||||
<div class="field__group">
|
||||
<label for="audiobookshelf_folder_id">Folder (name or ID)</label>
|
||||
<div class="field__input-with-button">
|
||||
<input type="text" id="audiobookshelf_folder_id" name="audiobookshelf_folder_id" value="{{ integrations.audiobookshelf.folder_id }}">
|
||||
<button type="button" class="button button--ghost button--small" data-action="audiobookshelf-list-folders">Browse folders</button>
|
||||
</div>
|
||||
<p class="hint">Enter the folder exactly as it appears in Audiobookshelf, paste the folder ID, or browse the available folders.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field field--inline">
|
||||
<div class="field__group">
|
||||
<label for="audiobookshelf_api_token">API token</label>
|
||||
<input type="password" id="audiobookshelf_api_token" name="audiobookshelf_api_token" value="" placeholder="{% if integrations.audiobookshelf.has_api_token %}••••••••{% endif %}" data-has-secret="{{ 'true' if integrations.audiobookshelf.has_api_token else 'false' }}">
|
||||
<p class="hint">Leave blank to keep the stored token.</p>
|
||||
</div>
|
||||
<div class="field__group field__group--checkbox">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="audiobookshelf_api_token_clear" value="true">
|
||||
<span>Clear saved token</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field field--inline">
|
||||
<div class="field__group">
|
||||
<label for="audiobookshelf_timeout">Request timeout (seconds)</label>
|
||||
<input type="number" step="1" min="5" id="audiobookshelf_timeout" name="audiobookshelf_timeout" value="{{ integrations.audiobookshelf.timeout }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field field--choices">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="audiobookshelf_send_cover" value="true" {% if integrations.audiobookshelf.send_cover %}checked{% endif %}>
|
||||
<span>Include cover artwork</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="audiobookshelf_send_chapters" value="true" {% if integrations.audiobookshelf.send_chapters %}checked{% endif %}>
|
||||
<span>Send chapter markers</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="audiobookshelf_send_subtitles" value="true" {% if integrations.audiobookshelf.send_subtitles %}checked{% endif %}>
|
||||
<span>Send subtitle files</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field field--inline">
|
||||
<button type="button" class="button button--ghost" data-action="audiobookshelf-test">Test connection</button>
|
||||
<span class="field__status" data-role="audiobookshelf-test-status" aria-live="polite"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel" data-section="debug">
|
||||
<fieldset class="settings__section">
|
||||
<legend>Debug · TTS transformations</legend>
|
||||
<p class="hint">Generate a set of WAV files from a purpose-built EPUB containing code-tagged examples. When something sounds wrong, report the code (e.g. <code>NUM_001</code>) to pinpoint the failing transformation.</p>
|
||||
|
||||
<div class="field field--stack">
|
||||
<button type="submit" class="button" form="debug-tts-form">Generate debug WAVs</button>
|
||||
<p class="hint">Uses your current Settings defaults (voice, language, speed, GPU). If generation fails, an error will appear at the top of this page.</p>
|
||||
</div>
|
||||
|
||||
{% if debug_manifest and debug_manifest.artifacts %}
|
||||
<div class="field field--wide">
|
||||
<label>Latest debug WAVs</label>
|
||||
<ul>
|
||||
{% for item in debug_manifest.artifacts %}
|
||||
<li>
|
||||
<a href="{{ url_for('settings.download_debug_wav', run_id=debug_manifest.run_id, filename=item.filename) }}">{{ item.label }} · {{ item.filename }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if debug_samples %}
|
||||
<div class="field field--wide">
|
||||
<label>Included examples</label>
|
||||
<ul>
|
||||
{% for sample in debug_samples %}
|
||||
<li><strong>{{ sample.code }}</strong> — {{ sample.label }}: <span class="muted">{{ sample.text }}</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal" data-role="contraction-modal" hidden>
|
||||
<div class="modal__overlay" data-role="contraction-modal-overlay" tabindex="-1"></div>
|
||||
<div class="modal__content card card--modal" role="dialog" aria-modal="true" aria-labelledby="contraction-modal-title">
|
||||
<header class="modal__header">
|
||||
<p class="modal__eyebrow">Normalization</p>
|
||||
<h2 class="modal__title" id="contraction-modal-title">Contraction Options</h2>
|
||||
<button type="button" class="button button--ghost button--small" data-action="contraction-modal-close" aria-label="Close contraction options">Close</button>
|
||||
</header>
|
||||
<div class="modal__body">
|
||||
<p class="hint">Enable or disable specific contraction families. These controls apply when contraction expansion is enabled.</p>
|
||||
<div class="field field--choices">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="normalization_contraction_aux_be" value="true" {% if settings.normalization_contraction_aux_be %}checked{% endif %}>
|
||||
<span>Be verbs ('m, 're, 's -> am/is/are)</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="normalization_contraction_aux_have" value="true" {% if settings.normalization_contraction_aux_have %}checked{% endif %}>
|
||||
<span>Have auxiliaries ('ve, 'd -> have/had)</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="normalization_contraction_modal_will" value="true" {% if settings.normalization_contraction_modal_will %}checked{% endif %}>
|
||||
<span>Will modals ('ll -> will)</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="normalization_contraction_modal_would" value="true" {% if settings.normalization_contraction_modal_would %}checked{% endif %}>
|
||||
<span>Would conditionals ('d -> would)</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="normalization_contraction_negation_not" value="true" {% if settings.normalization_contraction_negation_not %}checked{% endif %}>
|
||||
<span>Negative forms (can't -> can not)</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="normalization_contraction_let_us" value="true" {% if settings.normalization_contraction_let_us %}checked{% endif %}>
|
||||
<span>Let us (let's -> let us)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="modal__footer">
|
||||
<button type="button" class="button button--ghost" data-action="contraction-modal-close">Done</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" data-role="audiobookshelf-folder-modal" hidden>
|
||||
<div class="modal__overlay" data-role="audiobookshelf-folder-overlay" tabindex="-1"></div>
|
||||
<div class="modal__content card card--modal folder-picker-modal" role="dialog" aria-modal="true" aria-labelledby="audiobookshelf-folder-picker-title">
|
||||
<header class="modal__header">
|
||||
<p class="modal__eyebrow">Audiobookshelf</p>
|
||||
<h2 class="modal__title" id="audiobookshelf-folder-picker-title">Select folder</h2>
|
||||
<button type="button" class="button button--ghost button--small" data-action="audiobookshelf-folder-close" aria-label="Close folder picker">Close</button>
|
||||
</header>
|
||||
<div class="modal__body">
|
||||
<div class="folder-picker" data-role="audiobookshelf-folder-picker">
|
||||
<div class="folder-picker__controls">
|
||||
<input type="search" data-role="audiobookshelf-folder-filter" placeholder="Filter folders" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<p class="folder-picker__status" data-role="audiobookshelf-folder-status"></p>
|
||||
<div class="folder-picker__list" data-role="audiobookshelf-folder-list" role="listbox"></div>
|
||||
<p class="folder-picker__empty" data-role="audiobookshelf-folder-empty" hidden>No folders match your filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="modal__footer">
|
||||
<button type="button" class="button button--ghost" data-action="audiobookshelf-folder-close">Cancel</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings__actions">
|
||||
<button type="submit" class="button">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="debug-tts-form" action="{{ url_for('settings.run_debug_wavs') }}" method="post"></form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script type="module" src="{{ url_for('static', filename='settings.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% macro speaker_row(row_key, speaker, options) %}
|
||||
{% set gender = (speaker.gender or 'unknown') %}
|
||||
<div class="speaker-config-row" data-role="speaker-row" data-row-id="{{ row_key }}">
|
||||
<input type="hidden" name="speaker_rows" value="{{ row_key }}">
|
||||
<input type="hidden" name="speaker-{{ row_key }}-id" value="{{ speaker.id or row_key }}">
|
||||
<div class="speaker-config-row__grid">
|
||||
<label class="field" for="speaker-{{ row_key }}-label">
|
||||
<span>Label</span>
|
||||
<input type="text" id="speaker-{{ row_key }}-label" name="speaker-{{ row_key }}-label" value="{{ speaker.label or '' }}" placeholder="Character name" required>
|
||||
</label>
|
||||
<label class="field" for="speaker-{{ row_key }}-gender">
|
||||
<span>Gender</span>
|
||||
<select id="speaker-{{ row_key }}-gender" name="speaker-{{ row_key }}-gender">
|
||||
<option value="unknown" {% if gender == 'unknown' %}selected{% endif %}>Unknown</option>
|
||||
<option value="female" {% if gender == 'female' %}selected{% endif %}>Female</option>
|
||||
<option value="male" {% if gender == 'male' %}selected{% endif %}>Male</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field" for="speaker-{{ row_key }}-voice">
|
||||
<span>Preferred voice</span>
|
||||
<select id="speaker-{{ row_key }}-voice" name="speaker-{{ row_key }}-voice">
|
||||
<option value="" {% if not speaker.voice %}selected{% endif %}>Random compatible voice</option>
|
||||
{% for voice in options.voice_catalog %}
|
||||
<option value="{{ voice.id }}" {% if speaker.voice == voice.id %}selected{% endif %}>{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="speaker-config-row__footer">
|
||||
<button type="button" class="button button--ghost button--small" data-action="remove-speaker">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block title %}Speaker presets{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card speaker-configs">
|
||||
<div class="step-indicator" aria-label="Audiobook workflow">
|
||||
<span class="step-indicator__item is-complete">
|
||||
<span class="step-indicator__index">1</span>
|
||||
<span class="step-indicator__label">Upload</span>
|
||||
</span>
|
||||
<span class="step-indicator__item is-active">
|
||||
<span class="step-indicator__index">2</span>
|
||||
<span class="step-indicator__label">Speakers</span>
|
||||
</span>
|
||||
<span class="step-indicator__item">
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Queue</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card__title">Speaker presets</div>
|
||||
<p class="card__subtitle">Store recurring casts and keep voices consistent between books.</p>
|
||||
|
||||
{% if message %}
|
||||
<div class="alert alert--success">{{ message }}</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="speaker-configs__layout">
|
||||
<aside class="speaker-configs__sidebar">
|
||||
<div class="speaker-configs__sidebar-header">
|
||||
<h2>Saved presets</h2>
|
||||
<a class="button button--ghost button--small" href="{{ url_for('voices.speaker_configs_page') }}">New preset</a>
|
||||
</div>
|
||||
<ul class="speaker-configs__list">
|
||||
{% for config in configs %}
|
||||
<li class="speaker-configs__entry {% if editing_name == config.name %}is-active{% endif %}">
|
||||
<a href="{{ url_for('voices.speaker_configs_page', config=config.name) }}">{{ config.name }}</a>
|
||||
<span class="speaker-configs__meta">{{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</span>
|
||||
<form method="post" action="{{ url_for('voices.delete_speaker_config_named', name=config.name) }}" class="speaker-configs__delete" onsubmit="return confirm('Delete preset {{ config.name }}?');">
|
||||
<button type="submit" class="link-button">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="speaker-configs__empty">No presets yet. Create your first configuration on the right.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</aside>
|
||||
<div class="speaker-configs__editor">
|
||||
<form method="post" class="speaker-config-form" id="speaker-config-form">
|
||||
<input type="hidden" name="config_version" value="{{ editing.version or 1 }}">
|
||||
<div class="speaker-config-form__grid">
|
||||
<label class="field" for="config_name">
|
||||
<span>Preset name</span>
|
||||
<input type="text" id="config_name" name="config_name" value="{{ editing_name }}" placeholder="Mystery duo" required>
|
||||
</label>
|
||||
<label class="field" for="config_language">
|
||||
<span>Primary language</span>
|
||||
<select id="config_language" name="config_language">
|
||||
{% for code, label in options.languages.items() %}
|
||||
<option value="{{ code }}" {% if editing.language == code %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field" for="config_default_voice">
|
||||
<span>Fallback voice</span>
|
||||
<select id="config_default_voice" name="config_default_voice">
|
||||
<option value="" {% if not editing.default_voice %}selected{% endif %}>Use narrator voice</option>
|
||||
{% for voice in options.voice_catalog %}
|
||||
<option value="{{ voice.id }}" {% if editing.default_voice == voice.id %}selected{% endif %}>{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field field--full" for="config_notes">
|
||||
<span>Notes</span>
|
||||
<textarea id="config_notes" name="config_notes" rows="3" placeholder="Pronunciation reminders, references, or character context...">{{ editing.notes or '' }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section class="speaker-config-rows">
|
||||
<div class="speaker-config-rows__header">
|
||||
<h3>Speakers</h3>
|
||||
<button type="button" class="button button--ghost button--small" data-action="add-speaker">Add speaker</button>
|
||||
</div>
|
||||
<p class="hint">Add each recurring character, set their gender, and choose a preferred voice.</p>
|
||||
<div class="speaker-config-rows__list" data-role="speaker-rows">
|
||||
{% set speakers_map = editing.speakers or {} %}
|
||||
{% if speakers_map %}
|
||||
{% for speaker_id, speaker in speakers_map|dictsort(attribute='1.label') %}
|
||||
{{ speaker_row(speaker_id, speaker, options) }}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="speaker-config-rows__empty" data-role="empty-state">No speakers yet. Add your first character.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="speaker-configs__actions">
|
||||
<button type="submit" class="button">Save configuration</button>
|
||||
<div class="speaker-configs__actions-right">
|
||||
<a class="button button--ghost" href="{{ url_for('main.index') }}">Back to dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="speaker-row-template">
|
||||
{{ speaker_row('__ROW__', {'id': '', 'label': '', 'gender': 'unknown', 'voice': ''}, options) | safe }}
|
||||
</template>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script type="module" src="{{ url_for('static', filename='speaker-configs.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,177 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · Speaker Studio{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card voice-mixer">
|
||||
<div class="voice-mixer__header">
|
||||
<div>
|
||||
<h1 class="card__title">Speaker Studio</h1>
|
||||
<p class="tag">Create and manage speakers for Kokoro and Supertonic.</p>
|
||||
</div>
|
||||
<div class="voice-mixer__header-actions">
|
||||
<button type="button" class="button button--ghost" data-action="new-profile">New speaker</button>
|
||||
<button type="button" class="button button--ghost" data-action="import-profiles">Import</button>
|
||||
<button type="button" class="button button--ghost" data-action="export-profiles">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="voice-mixer-app" class="voice-mixer__layout" data-state="loading">
|
||||
<aside class="voice-mixer__profiles" data-role="profile-list">
|
||||
<p class="tag">Loading profiles…</p>
|
||||
</aside>
|
||||
<section class="voice-mixer__editor" data-role="editor">
|
||||
<noscript>
|
||||
<p class="tag">JavaScript is required for the studio. Please enable it to edit speakers.</p>
|
||||
</noscript>
|
||||
<form id="voice-profile-form" class="voice-editor" autocomplete="off">
|
||||
<div class="voice-status" data-role="status"></div>
|
||||
<div class="voice-editor__meta">
|
||||
<div class="voice-editor__identity">
|
||||
<div class="field voice-editor__name-field">
|
||||
<label for="profile-name">Speaker name</label>
|
||||
<input id="profile-name" name="name" type="text" placeholder="Narrator" required>
|
||||
</div>
|
||||
<div class="voice-editor__toolbar" role="group" aria-label="Profile actions">
|
||||
<button type="submit" class="icon-button icon-button--primary" data-role="save-profile" disabled aria-label="Save profile" title="Save profile">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M5 3h11l5 5v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7 4a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-5 11h10v-5H7v5z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="icon-button" data-role="duplicate-profile" aria-label="Duplicate profile" title="Duplicate profile" disabled>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M8 4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2V4zm-3 6a2 2 0 0 1 2-2h1v8a3 3 0 0 0 3 3h8v1a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="icon-button icon-button--danger" data-role="delete-profile" aria-label="Delete profile" title="Delete profile" disabled>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M9 3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1h5v2H4V3h5zm1 5h2v10h-2V8zm4 0h2v10h-2V8zM6 8h2v10H6V8z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field voice-editor__provider">
|
||||
<label for="profile-provider">TTS provider</label>
|
||||
<select id="profile-provider" name="provider" data-role="provider">
|
||||
<option value="kokoro">Kokoro</option>
|
||||
<option value="supertonic">Supertonic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field voice-editor__language">
|
||||
<label for="profile-language">Language</label>
|
||||
<select id="profile-language" name="language">
|
||||
{% for key, label in options.languages.items() %}
|
||||
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="voice-editor__provider-panel" data-role="supertonic-panel" hidden>
|
||||
<div class="field">
|
||||
<label for="supertonic-voice">Supertonic voice</label>
|
||||
<select id="supertonic-voice" data-role="supertonic-voice">
|
||||
{% for voice in ['M1','M2','M3','M4','M5','F1','F2','F3','F4','F5'] %}
|
||||
<option value="{{ voice }}">{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field field--slider">
|
||||
<label for="supertonic-quality">Supertonic quality (total steps) <span class="tag" data-role="supertonic-steps-display">5</span></label>
|
||||
<input type="range" id="supertonic-quality" data-role="supertonic-steps" min="2" max="15" step="1" value="5">
|
||||
<p class="hint">2 = fastest/lowest quality, 15 = slowest/highest quality.</p>
|
||||
</div>
|
||||
<div class="field field--slider">
|
||||
<label for="supertonic-speaker-speed">Supertonic speed <span class="tag" data-role="supertonic-speed-display">1.00×</span></label>
|
||||
<input type="range" id="supertonic-speaker-speed" data-role="supertonic-speed" min="0.7" max="2.0" step="0.05" value="1.0">
|
||||
</div>
|
||||
<p class="hint">Supertonic voice mixing is not implemented yet. Stub target: <a href="https://github.com/Topping1/Supertonic-Voice-Mixer" target="_blank" rel="noreferrer">Supertonic-Voice-Mixer</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="voice-editor__summary">
|
||||
<span data-role="profile-summary">Select or create a profile to begin.</span>
|
||||
<span class="tag" data-role="mix-total">Total weight: 0.00</span>
|
||||
</div>
|
||||
<div class="voice-editor__canvas" data-role="kokoro-mixer">
|
||||
<section class="voice-available">
|
||||
<header class="voice-available__header">
|
||||
<div class="voice-available__title">
|
||||
<h2>Available voices</h2>
|
||||
<p class="tag">Drag into the mixer or click Add.</p>
|
||||
</div>
|
||||
<div class="voice-filter">
|
||||
<label class="voice-filter__label" for="voice-language-filter">Language</label>
|
||||
<div class="voice-filter__controls">
|
||||
<select id="voice-language-filter" data-role="voice-filter">
|
||||
<option value="">All languages</option>
|
||||
{% for key, label in options.languages.items() %}
|
||||
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="voice-gender-filter" data-role="gender-filter" aria-label="Filter by gender">
|
||||
<button type="button" class="voice-gender-filter__button" data-value="f" aria-pressed="false" title="Show female voices">♀</button>
|
||||
<button type="button" class="voice-gender-filter__button" data-value="m" aria-pressed="false" title="Show male voices">♂</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="voice-available__list" data-role="available-voices"></div>
|
||||
</section>
|
||||
<section class="voice-mix">
|
||||
<header class="voice-mix__header">
|
||||
<h2>Your mix</h2>
|
||||
<p class="tag">Drop voices below to set levels.</p>
|
||||
</header>
|
||||
<div class="voice-mix__dropzone" data-role="dropzone">
|
||||
<p class="voice-mix__empty" data-role="mix-empty">Drag voices here or tap “Add”.</p>
|
||||
<div class="voice-mix__list" data-role="selected-voices"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="voice-editor__actions">
|
||||
<div class="voice-preview">
|
||||
<div class="field">
|
||||
<label for="preview-text">Preview text</label>
|
||||
<textarea id="preview-text" rows="3" data-role="preview-text" placeholder="Paste a short line to audition your mix."></textarea>
|
||||
</div>
|
||||
<div class="field field--slider">
|
||||
<label for="preview-speed">Preview speed <span class="tag" data-role="preview-speed-display">1.00×</span></label>
|
||||
<input id="preview-speed" type="range" min="0.7" max="2.0" step="0.05" value="1.0" data-role="preview-speed">
|
||||
</div>
|
||||
<div class="voice-preview__controls">
|
||||
<button type="button" class="button" data-role="preview-button">Preview speaker</button>
|
||||
<button type="button" class="button button--ghost" data-role="load-sample">Use sample text</button>
|
||||
</div>
|
||||
<audio data-role="preview-audio" controls preload="none"></audio>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<input id="voice-import-input" type="file" accept="application/json" hidden>
|
||||
|
||||
<div class="modal" data-role="provider-picker-modal" hidden>
|
||||
<div class="modal__overlay" data-role="provider-picker-overlay" tabindex="-1"></div>
|
||||
<div class="modal__content card card--modal" role="dialog" aria-modal="true" aria-labelledby="provider-picker-title">
|
||||
<header class="modal__header">
|
||||
<p class="modal__eyebrow">Speaker Studio</p>
|
||||
<h2 class="modal__title" id="provider-picker-title">Choose a TTS provider</h2>
|
||||
<button type="button" class="button button--ghost button--small" data-role="provider-picker-close" aria-label="Close provider picker">Close</button>
|
||||
</header>
|
||||
<div class="modal__body">
|
||||
<p class="hint">Select which text-to-speech provider this speaker will use. You can create separate speakers per provider.</p>
|
||||
<div class="field field--choices" data-role="provider-picker-options"></div>
|
||||
</div>
|
||||
<footer class="modal__footer">
|
||||
<button type="button" class="button button--ghost" data-role="provider-picker-cancel">Cancel</button>
|
||||
<button type="button" class="button" data-role="provider-picker-confirm">Continue</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script id="voice-mixer-options" type="application/json">{{ options | tojson | safe }}</script>
|
||||
<script>
|
||||
window.ABOGEN_VOICE_MIXER_DATA = JSON.parse(document.getElementById('voice-mixer-options').textContent);
|
||||
</script>
|
||||
<script type="module" src="{{ url_for('static', filename='voices.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user