feat: Enhance job management UI and add apostrophe normalization

- Updated styles for job cards to include a paused state and improved title styling.
- Modified job card template to display job status and progress more clearly, including pause/resume functionality.
- Introduced a new script for managing chapter row states in the prepare job form, allowing for dynamic enabling/disabling of inputs.
- Created a new template for preparing jobs, featuring a summary of metadata and chapter details.
- Added a comprehensive apostrophe normalization module to handle various cases of apostrophe usage in text.
This commit is contained in:
JB
2025-10-07 05:34:53 -07:00
parent 85310ad916
commit 718a3fa1c0
9 changed files with 1426 additions and 26 deletions
+334
View File
@@ -0,0 +1,334 @@
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass
from typing import List, Tuple, Iterable, Callable
# ---------- Configuration Dataclass ----------
@dataclass
class ApostropheConfig:
contraction_mode: str = "expand" # expand|collapse|keep
possessive_mode: str = "keep" # keep|collapse
plural_possessive_mode: str = "collapse" # keep|collapse
irregular_possessive_mode: str = "keep" # keep|expand (expand just means keep or add hints; modify if needed)
sibilant_possessive_mode: str = "mark" # keep|mark|approx
fantasy_mode: str = "keep" # keep|mark|collapse_internal
acronym_possessive_mode: str = "keep" # keep|collapse_add_s
decades_mode: str = "expand" # keep|expand
leading_elision_mode: str = "expand" # keep|expand
ambiguous_past_modal_mode: str = "keep" # keep|expand_prefer_would|expand_prefer_had
add_phoneme_hints: bool = True # Whether to emit markers like IZ
fantasy_marker: str = "FAP" # Marker inserted if fantasy_mode == mark
sibilant_iz_marker: str = "IZ" # Marker for /ɪz/ insertion
joiner: str = "" # Replacement used when collapsing internal apostrophes
lowercase_for_matching: bool = True # Normalize to lower for rule matching (not output)
protect_cultural_names: bool = True # Always keep O'Brien, D'Angelo, etc.
# ---------- Dictionaries / Patterns ----------
# Common contraction expansions (straightforward unambiguous)
CONTRACTIONS_EXACT = {
"it's": "it is",
"that's": "that is",
"what's": "what is",
"where's": "where is",
"who's": "who is",
"when's": "when is",
"how's": "how is",
"there's": "there is",
"here's": "here is",
"let's": "let us",
"i'm": "i am",
"you're": "you are",
"we're": "we are",
"they're": "they are",
"i've": "i have",
"you've": "you have",
"we've": "we have",
"they've": "they have",
"i'll": "i will",
"you'll": "you will",
"he'll": "he will",
"she'll": "she will",
"we'll": "we will",
"they'll": "they will",
"i'd": "i would", # ambiguous (had/would), treat default
"you'd": "you would",
"he'd": "he would",
"she'd": "she would",
"we'd": "we would",
"they'd": "they would",
"can't": "can not", # or "cannot"
"won't": "will not",
"don't": "do not",
"doesn't": "does not",
"didn't": "did not",
"isn't": "is not",
"aren't": "are not",
"wasn't": "was not",
"weren't": "were not",
"haven't": "have not",
"hasn't": "has not",
"hadn't": "had not",
"couldn't": "could not",
"shouldn't": "should not",
"wouldn't": "would not",
"mustn't": "must not",
"mightn't": "might not",
"shan't": "shall not",
}
# For ambiguous 'd and 's we handle separately
AMBIGUOUS_D_BASES = {"i","you","he","she","we","they"}
AMBIGUOUS_S_BASES = {"it","that","what","where","who","when","how","there","here"}
# Irregular possessives that are not formed by simple + 's logic
IRREGULAR_POSSESSIVES = {
"children's": "children's",
"men's": "men's",
"women's": "women's",
"people's": "people's",
"geese's": "geese's",
"mouse's": "mouse's", # singular irregular
}
SIBILANT_END_RE = re.compile(r"(?:[sxz]|(?:ch|sh))$", re.IGNORECASE)
DECADE_RE = re.compile(r"^'\d0s$", re.IGNORECASE) # '90s, '80s
LEADING_ELISION = {
"'tis": "it is",
"'twas": "it was",
"'cause": "because",
"'em": "them",
"'round": "around",
"'til": "until",
}
CULTURAL_NAME_PATTERNS = [
re.compile(r"^O'[A-Z][a-z]+$"),
re.compile(r"^D'[A-Z][a-z]+$"),
re.compile(r"^L'[A-Za-z].*$"),
re.compile(r"^Mc[A-Z].*$"), # not apostrophe, but often relevant (kept anyway)
]
ACRONYM_POSSESSIVE_RE = re.compile(r"^[A-Z]{2,}'s$")
INTERNAL_APOSTROPHE_RE = re.compile(r"[A-Za-z]'.+[A-Za-z]") # apostrophe not at edge
WORD_TOKEN_RE = re.compile(r"[A-Za-z0-9']+|[^A-Za-z0-9\s]")
APOSTROPHE_CHARS = "`´ꞌʼ"
# ---------- Utility Functions ----------
def normalize_unicode_apostrophes(text: str) -> str:
text = unicodedata.normalize("NFKC", text)
for ch in APOSTROPHE_CHARS:
text = text.replace(ch, "'")
return text
def tokenize(text: str) -> List[str]:
# Simple tokenization preserving punctuation tokens
return WORD_TOKEN_RE.findall(text)
def is_cultural_name(token: str, cfg: ApostropheConfig) -> bool:
if not cfg.protect_cultural_names:
return False
for pat in CULTURAL_NAME_PATTERNS:
if pat.match(token):
return True
return False
def classify_token(token: str, cfg: ApostropheConfig) -> Tuple[str, str]:
"""
Classify apostrophe usage and propose normalized form.
Returns (category, normalized_token_or_same).
Categories: contraction, ambiguous_contraction_s, ambiguous_contraction_d,
plural_possessive, irregular_possessive, sibilant_possessive,
singular_possessive, acronym_possessive, decade, leading_elision,
fantasy_internal, other
"""
if "'" not in token:
return "other", token
raw = token
low = token.lower()
# 1. Decades
if DECADE_RE.match(token):
if cfg.decades_mode == "expand":
# '90s -> 1990s (you could also choose 90s)
return "decade", f"19{token[2:4]}s"
return "decade", token
# 2. Leading elision
if low in LEADING_ELISION:
if cfg.leading_elision_mode == "expand":
return "leading_elision", LEADING_ELISION[low]
return "leading_elision", token
# 3. Exact contraction
if low in CONTRACTIONS_EXACT:
if cfg.contraction_mode == "expand":
return "contraction", CONTRACTIONS_EXACT[low]
elif cfg.contraction_mode == "collapse":
# collapse: remove apostrophe only (it's -> its)
return "contraction", low.replace("'", "")
else:
return "contraction", token
# 4. Ambiguous 'd
if low.endswith("'d"):
base = low[:-2]
if base in AMBIGUOUS_D_BASES:
if cfg.ambiguous_past_modal_mode == "expand_prefer_would":
return "ambiguous_contraction_d", base + " would"
elif cfg.ambiguous_past_modal_mode == "expand_prefer_had":
return "ambiguous_contraction_d", base + " had"
elif cfg.contraction_mode == "collapse":
return "ambiguous_contraction_d", base + "d"
return "ambiguous_contraction_d", token
# 5. Ambiguous 's
if low.endswith("'s"):
base = low[:-2]
if base in AMBIGUOUS_S_BASES:
# treat as contraction 'is' under chosen mode
if cfg.contraction_mode == "expand":
return "ambiguous_contraction_s", base + " is"
elif cfg.contraction_mode == "collapse":
return "ambiguous_contraction_s", base + "s"
else:
return "ambiguous_contraction_s", token
# 6. Irregular possessives (keep or expand logic)
if low in IRREGULAR_POSSESSIVES:
if cfg.irregular_possessive_mode == "keep":
return "irregular_possessive", token
else:
# 'expand': we might keep same or optionally add marker
return "irregular_possessive", token
# 7. Plural possessive pattern dogs'
if re.match(r"^[A-Za-z0-9]+s'$", token):
if cfg.plural_possessive_mode == "collapse":
return "plural_possessive", token[:-1] # remove trailing apostrophe
return "plural_possessive", token
# 8. Acronym possessive NASA's
if ACRONYM_POSSESSIVE_RE.match(token):
if cfg.acronym_possessive_mode == "collapse_add_s":
return "acronym_possessive", token.replace("'", "")
return "acronym_possessive", token
# 9. Sibilant singular possessive boss's, church's
if low.endswith("'s"):
base = token[:-2]
if SIBILANT_END_RE.search(base):
if cfg.sibilant_possessive_mode == "keep":
return "sibilant_possessive", token
elif cfg.sibilant_possessive_mode == "approx":
# convert to base + "es" (boss's -> bosses)
# risk: loses possessive semantics visually
return "sibilant_possessive", base + "es"
elif cfg.sibilant_possessive_mode == "mark":
# remove apostrophe, add IZ marker
normalized = base
if cfg.add_phoneme_hints:
normalized += cfg.sibilant_iz_marker
else:
normalized += "es"
return "sibilant_possessive", normalized
# 10. Generic singular possessive (\w+'s)
if re.match(r"^[A-Za-z0-9]+'s$", token):
if cfg.possessive_mode == "collapse":
# Just remove apostrophe
return "singular_possessive", token.replace("'", "")
return "singular_possessive", token
# 11. Cultural names or fantasy internal
if is_cultural_name(token, cfg):
return "cultural_name", token
# 12. Fantasy internal apostrophes
if INTERNAL_APOSTROPHE_RE.search(token):
if cfg.fantasy_mode == "keep":
return "fantasy_internal", token
elif cfg.fantasy_mode == "mark":
out = token + (cfg.fantasy_marker if cfg.add_phoneme_hints else "")
return "fantasy_internal", out
elif cfg.fantasy_mode == "collapse_internal":
# Remove internal apostrophes only
inner = re.sub(r"(?<=\w)'+(?=\w)", cfg.joiner, token)
return "fantasy_internal", inner
# 13. Fallback: treat as other (maybe stray apostrophe)
if cfg.fantasy_mode == "collapse_internal":
# Remove any internal apostrophes
return "other", token.replace("'", cfg.joiner)
return "other", token
def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tuple[str, List[Tuple[str,str,str]]]:
"""
Normalize apostrophes per config.
Returns normalized text AND a list of (original_token, category, normalized_token)
so you can debug or post-process (e.g., apply phoneme replacement for IZ).
"""
if cfg is None:
cfg = ApostropheConfig()
text = normalize_unicode_apostrophes(text)
tokens = tokenize(text)
results = []
normalized_tokens: List[str] = []
for tok in tokens:
category, norm = classify_token(tok, cfg)
results.append((tok, category, norm))
normalized_tokens.append(norm)
# Simple rejoin heuristic:
# If token is purely punctuation, attach without extra space.
out_parts = []
for i, (orig, cat, norm) in enumerate(results):
if i == 0:
out_parts.append(norm)
continue
prev = results[i-1][2]
if re.match(r"^[.,;:!?)]$", norm):
# Attach to previous
out_parts[-1] = out_parts[-1] + norm
elif re.match(r"^[(]$", norm):
out_parts.append(norm)
else:
# Normal separation
if not (re.match(r"^[.,;:!?)]$", prev) or prev.endswith("")):
out_parts.append(" " + norm)
else:
out_parts.append(norm)
normalized_text = "".join(out_parts)
return normalized_text, results
# ---------- Optional phoneme hint post-processing ----------
def apply_phoneme_hints(text: str, iz_marker="IZ") -> str:
"""
Replace markers with an orthographic sequence that
your phonemizer will reliably convert to /ɪz/.
"""
return text.replace(iz_marker, " iz")
# ---------- Example Usage ----------
if __name__ == "__main__":
sample = "Bob's boss's chair. The dogs' collars. It's cold. Ta'veren and Sha'hal. O'Brien's code in the '90s. Boss's orders."
config = ApostropheConfig()
norm_text, details = normalize_apostrophes(sample, config)
norm_text = apply_phoneme_hints(norm_text)
print("Original:", sample)
print("Normalized:", norm_text)
for orig, cat, norm in details:
print(f"{orig:15} -> {norm:15} [{cat}]")
+55 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import datetime import datetime
import logging import logging
import mimetypes
import re import re
import textwrap import textwrap
import urllib.parse import urllib.parse
@@ -31,6 +32,9 @@ METADATA_KEY_MAP: Dict[str, str] = {
"COMPOSER": "composer", "COMPOSER": "composer",
"GENRE": "genre", "GENRE": "genre",
"DATE": "date", "DATE": "date",
"PUBLISHER": "publisher",
"COMMENT": "comment",
"LANGUAGE": "language",
} }
@@ -48,6 +52,8 @@ class ExtractedChapter:
class ExtractionResult: class ExtractionResult:
chapters: List[ExtractedChapter] chapters: List[ExtractedChapter]
metadata: Dict[str, str] = field(default_factory=dict) metadata: Dict[str, str] = field(default_factory=dict)
cover_image: Optional[bytes] = None
cover_mime: Optional[str] = None
@property @property
def combined_text(self) -> str: def combined_text(self) -> str:
@@ -65,6 +71,7 @@ class MetadataSource:
description: Optional[str] = None description: Optional[str] = None
publisher: Optional[str] = None publisher: Optional[str] = None
publication_year: Optional[str] = None publication_year: Optional[str] = None
language: Optional[str] = None
@dataclass @dataclass
@@ -188,6 +195,12 @@ def _build_metadata_payload(
"COMPOSER": "Narrator", "COMPOSER": "Narrator",
"GENRE": "Audiobook", "GENRE": "Audiobook",
} }
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
return _normalize_metadata_keys(metadata) return _normalize_metadata_keys(metadata)
@@ -370,7 +383,14 @@ class EpubExtractor:
if not chapters: if not chapters:
chapters = [ExtractedChapter(title=self.path.stem, text="")] chapters = [ExtractedChapter(title=self.path.stem, text="")]
metadata = _build_metadata_payload(metadata_source, len(chapters), "epub", self.path.stem) metadata = _build_metadata_payload(metadata_source, len(chapters), "epub", self.path.stem)
return ExtractionResult(chapters=chapters, metadata=metadata) metadata.setdefault("chapter_count", str(len(chapters)))
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: def _collect_metadata(self) -> MetadataSource:
metadata = MetadataSource() metadata = MetadataSource()
@@ -411,8 +431,42 @@ class EpubExtractor:
except Exception as exc: except Exception as exc:
logger.debug("Failed to extract EPUB publication year metadata: %s", 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)
return metadata 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]: def _process_nav(self) -> List[ExtractedChapter]:
nav_item, nav_type = self._find_navigation_item() nav_item, nav_type = self._find_navigation_item()
if not nav_item or not nav_type: if not nav_item or not nav_type:
+76 -10
View File
@@ -15,6 +15,11 @@ import numpy as np
import soundfile as sf import soundfile as sf
import static_ffmpeg import static_ffmpeg
from abogen.kokoro_text_normalization import (
ApostropheConfig,
apply_phoneme_hints,
normalize_apostrophes,
)
from abogen.text_extractor import ExtractedChapter, extract_from_path from abogen.text_extractor import ExtractedChapter, extract_from_path
from abogen.utils import ( from abogen.utils import (
calculate_text_length, calculate_text_length,
@@ -163,6 +168,35 @@ def _merge_metadata(
return merged return merged
_APOSTROPHE_CONFIG = ApostropheConfig()
def _normalize_for_pipeline(text: str) -> str:
normalized, _details = normalize_apostrophes(text, _APOSTROPHE_CONFIG)
if _APOSTROPHE_CONFIG.add_phoneme_hints:
return apply_phoneme_hints(normalized, iz_marker=_APOSTROPHE_CONFIG.sibilant_iz_marker)
return normalized
def _chapter_voice_spec(job: Job, override: Optional[Dict[str, Any]]) -> str:
if not override:
return job.voice or ""
resolved = str(override.get("resolved_voice", "")).strip()
if resolved:
return resolved
formula = str(override.get("voice_formula", "")).strip()
if formula:
return formula
voice = str(override.get("voice", "")).strip()
if voice:
return voice
return job.voice or ""
def run_conversion_job(job: Job) -> None: def run_conversion_job(job: Job) -> None:
job.add_log("Preparing conversion pipeline") job.add_log("Preparing conversion pipeline")
canceller = _make_canceller(job) canceller = _make_canceller(job)
@@ -175,6 +209,7 @@ def run_conversion_job(job: Job) -> None:
extraction = extract_from_path(job.stored_path) extraction = extract_from_path(job.stored_path)
metadata_overrides: Dict[str, Any] = dict(job.metadata_tags or {}) metadata_overrides: Dict[str, Any] = dict(job.metadata_tags or {})
active_chapter_configs: List[Dict[str, Any]] = []
if job.chapters: if job.chapters:
selected_chapters, chapter_metadata, diagnostics = _apply_chapter_overrides( selected_chapters, chapter_metadata, diagnostics = _apply_chapter_overrides(
extraction.chapters, extraction.chapters,
@@ -189,6 +224,9 @@ def run_conversion_job(job: Job) -> None:
f"Chapter overrides applied: {len(selected_chapters)} selected.", f"Chapter overrides applied: {len(selected_chapters)} selected.",
level="info", level="info",
) )
active_chapter_configs = [
entry for entry in job.chapters if _coerce_truthy(entry.get("enabled", True))
][: len(selected_chapters)]
else: else:
raise ValueError("No chapters were enabled in the requested job.") raise ValueError("No chapters were enabled in the requested job.")
@@ -220,20 +258,34 @@ def run_conversion_job(job: Job) -> None:
chapter_dir = audio_dir / "chapters" chapter_dir = audio_dir / "chapters"
chapter_dir.mkdir(parents=True, exist_ok=True) chapter_dir.mkdir(parents=True, exist_ok=True)
voice_spec = job.voice or "" base_voice_spec = (job.voice or "").strip()
cached_voice = None voice_cache: Dict[str, Any] = {}
if "*" not in voice_spec: if base_voice_spec and "*" not in base_voice_spec:
cached_voice = _resolve_voice(pipeline, voice_spec, job.use_gpu) voice_cache[base_voice_spec] = _resolve_voice(pipeline, base_voice_spec, job.use_gpu)
processed_chars = 0 processed_chars = 0
subtitle_index = 1 subtitle_index = 1
current_time = 0.0 current_time = 0.0
total_chapters = len(extraction.chapters) total_chapters = len(extraction.chapters)
chapter_markers: List[Dict[str, Any]] = []
job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}") job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}")
for idx, chapter in enumerate(extraction.chapters, start=1): for idx, chapter in enumerate(extraction.chapters, start=1):
canceller() canceller()
job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter.title}") job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter.title}")
chapter_start_time = current_time
chapter_override = (
active_chapter_configs[idx - 1] if idx - 1 < len(active_chapter_configs) else None
)
chapter_voice_spec = _chapter_voice_spec(job, chapter_override)
if not chapter_voice_spec:
chapter_voice_spec = base_voice_spec
voice_choice = voice_cache.get(chapter_voice_spec)
if voice_choice is None:
voice_choice = _resolve_voice(pipeline, chapter_voice_spec, job.use_gpu)
voice_cache[chapter_voice_spec] = voice_choice
chapter_sink_stack = ExitStack() chapter_sink_stack = ExitStack()
chapter_sink: Optional[AudioSink] = None chapter_sink: Optional[AudioSink] = None
chapter_audio_path: Optional[Path] = None chapter_audio_path: Optional[Path] = None
@@ -251,14 +303,11 @@ def run_conversion_job(job: Job) -> None:
fmt=job.separate_chapters_format, fmt=job.separate_chapters_format,
) )
voice_choice = cached_voice if cached_voice is not None else _resolve_voice(
pipeline, voice_spec, job.use_gpu
)
segments_emitted = 0 segments_emitted = 0
tts_input = _normalize_for_pipeline(chapter.text)
for segment in pipeline( for segment in pipeline(
chapter.text, tts_input,
voice=voice_choice, voice=voice_choice,
speed=job.speed, speed=job.speed,
split_pattern=SPLIT_PATTERN, split_pattern=SPLIT_PATTERN,
@@ -303,6 +352,8 @@ def run_conversion_job(job: Job) -> None:
if chapter_sink: if chapter_sink:
chapter_sink_stack.close() chapter_sink_stack.close()
chapter_end_time = current_time
if chapter_audio_path is not None: if chapter_audio_path is not None:
job.result.artifacts[f"chapter_{idx:02d}"] = chapter_audio_path job.result.artifacts[f"chapter_{idx:02d}"] = chapter_audio_path
chapter_paths.append(chapter_audio_path) chapter_paths.append(chapter_audio_path)
@@ -326,6 +377,17 @@ def run_conversion_job(job: Job) -> None:
silence = np.zeros(silence_samples, dtype="float32") silence = np.zeros(silence_samples, dtype="float32")
audio_sink.write(silence) audio_sink.write(silence)
current_time += job.silence_between_chapters current_time += job.silence_between_chapters
chapter_end_time = current_time
chapter_markers.append(
{
"index": idx,
"title": chapter.title,
"start": chapter_start_time,
"end": chapter_end_time,
"voice": chapter_voice_spec,
}
)
if not audio_path and chapter_paths: if not audio_path and chapter_paths:
job.result.audio_path = chapter_paths[0] job.result.audio_path = chapter_paths[0]
@@ -333,7 +395,11 @@ def run_conversion_job(job: Job) -> None:
if metadata_dir: if metadata_dir:
metadata_dir.mkdir(parents=True, exist_ok=True) metadata_dir.mkdir(parents=True, exist_ok=True)
metadata_file = metadata_dir / "metadata.json" metadata_file = metadata_dir / "metadata.json"
metadata_file.write_text(json.dumps({"metadata": job.metadata_tags}, indent=2), encoding="utf-8") metadata_payload = {
"metadata": job.metadata_tags,
"chapters": chapter_markers,
}
metadata_file.write_text(json.dumps(metadata_payload, indent=2), encoding="utf-8")
job.result.artifacts["metadata"] = metadata_file job.result.artifacts["metadata"] = metadata_file
if job.save_as_project: if job.save_as_project:
+234 -7
View File
@@ -5,6 +5,7 @@ import json
import mimetypes import mimetypes
import os import os
import threading import threading
import time
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple, cast from typing import Any, Dict, Iterable, List, Optional, Tuple, cast
@@ -55,8 +56,9 @@ from abogen.voice_profiles import (
) )
from abogen.voice_formulas import get_new_voice from abogen.voice_formulas import get_new_voice
from abogen.text_extractor import extract_from_path
from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32 from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
from .service import ConversionService, Job, JobStatus from .service import ConversionService, Job, JobStatus, PendingJob
web_bp = Blueprint("web", __name__) web_bp = Blueprint("web", __name__)
api_bp = Blueprint("api", __name__) api_bp = Blueprint("api", __name__)
@@ -272,6 +274,28 @@ def _resolve_voice_choice(
return resolved_voice, resolved_language, selected_profile return resolved_voice, resolved_language, selected_profile
def _persist_cover_image(extraction_result: Any, stored_path: Path) -> tuple[Optional[Path], Optional[str]]:
cover_bytes = getattr(extraction_result, "cover_image", None)
if not cover_bytes:
return None, None
mime = getattr(extraction_result, "cover_mime", None)
extension = mimetypes.guess_extension(mime or "") or ".png"
base_stem = Path(stored_path).stem or "cover"
candidate = stored_path.parent / f"{base_stem}_cover{extension}"
counter = 1
while candidate.exists():
candidate = stored_path.parent / f"{base_stem}_cover_{counter}{extension}"
counter += 1
try:
candidate.write_bytes(cover_bytes)
except OSError:
return None, None
return candidate, mime
def _parse_voice_formula(formula: str) -> List[tuple[str, float]]: def _parse_voice_formula(formula: str) -> List[tuple[str, float]]:
parts = [segment.strip() for segment in formula.split("+") if segment.strip()] parts = [segment.strip() for segment in formula.split("+") if segment.strip()]
voices: List[tuple[str, float]] = [] voices: List[tuple[str, float]] = []
@@ -690,12 +714,54 @@ def enqueue_job() -> Response:
stored_path = uploads_dir / f"{uuid.uuid4().hex}_{filename}" stored_path = uploads_dir / f"{uuid.uuid4().hex}_{filename}"
file.save(stored_path) file.save(stored_path)
original_name = filename original_name = filename
total_chars = 0
else: else:
original_name = "direct_text.txt" original_name = "direct_text.txt"
stored_path = uploads_dir / f"{uuid.uuid4().hex}_{original_name}" stored_path = uploads_dir / f"{uuid.uuid4().hex}_{original_name}"
stored_path.write_text(text_input, encoding="utf-8") stored_path.write_text(text_input, encoding="utf-8")
total_chars = calculate_text_length(clean_text(text_input))
extraction = None
try:
extraction = extract_from_path(stored_path)
except Exception as exc: # pragma: no cover - defensive
try:
stored_path.unlink(missing_ok=True)
except Exception:
pass
abort(400, f"Unable to read the supplied content: {exc}")
if extraction is None: # pragma: no cover - defensive
abort(400, "Unable to read the supplied content")
assert extraction is not None
cover_path, cover_mime = _persist_cover_image(extraction, stored_path)
metadata_tags = extraction.metadata or {}
total_chars = extraction.total_characters or calculate_text_length(extraction.combined_text)
chapters_payload: List[Dict[str, Any]] = []
for index, chapter in enumerate(extraction.chapters):
chapters_payload.append(
{
"id": f"{index:04d}",
"index": index,
"title": chapter.title,
"text": chapter.text,
"characters": len(chapter.text),
"enabled": True,
}
)
if not chapters_payload:
chapters_payload.append(
{
"id": "0000",
"index": 0,
"title": original_name,
"text": "",
"characters": 0,
"enabled": True,
}
)
profiles = load_profiles() profiles = load_profiles()
settings = _load_settings() settings = _load_settings()
@@ -737,7 +803,8 @@ def enqueue_job() -> Response:
silence_between_chapters = settings["silence_between_chapters"] silence_between_chapters = settings["silence_between_chapters"]
max_subtitle_words = settings["max_subtitle_words"] max_subtitle_words = settings["max_subtitle_words"]
job = service.enqueue( pending = PendingJob(
id=uuid.uuid4().hex,
original_filename=original_name, original_filename=original_name,
stored_path=stored_path, stored_path=stored_path,
language=language, language=language,
@@ -758,14 +825,149 @@ def enqueue_job() -> Response:
save_as_project=save_as_project, save_as_project=save_as_project,
voice_profile=selected_profile, voice_profile=selected_profile,
max_subtitle_words=max_subtitle_words, max_subtitle_words=max_subtitle_words,
metadata_tags=metadata_tags,
chapters=chapters_payload,
created_at=time.time(),
cover_image_path=cover_path,
cover_image_mime=cover_mime,
) )
service.store_pending_job(pending)
return redirect(url_for("web.prepare_job", pending_id=pending.id))
@web_bp.get("/jobs/prepare/<pending_id>")
def prepare_job(pending_id: str) -> str:
pending = _service().get_pending_job(pending_id)
if not pending:
abort(404)
pending = cast(PendingJob, pending)
return _render_prepare_page(pending)
@web_bp.post("/jobs/prepare/<pending_id>")
def finalize_job(pending_id: str) -> Response:
service = _service()
pending = service.get_pending_job(pending_id)
if not pending:
abort(404)
pending = cast(PendingJob, pending)
profiles = serialize_profiles()
overrides: List[Dict[str, Any]] = []
selected_total = 0
errors: List[str] = []
for index, chapter in enumerate(pending.chapters):
enabled = request.form.get(f"chapter-{index}-enabled") == "on"
title_input = (request.form.get(f"chapter-{index}-title") or "").strip()
title = title_input or chapter.get("title") or f"Chapter {index + 1}"
voice_selection = request.form.get(f"chapter-{index}-voice", "__default")
formula_input = (request.form.get(f"chapter-{index}-formula") or "").strip()
entry: Dict[str, Any] = {
"id": chapter.get("id") or f"{index:04d}",
"index": index,
"order": index,
"source_title": chapter.get("title") or title,
"title": title,
"text": chapter.get("text", ""),
"enabled": enabled,
}
entry["characters"] = len(entry["text"])
if enabled:
if voice_selection.startswith("voice:"):
entry["voice"] = voice_selection.split(":", 1)[1]
entry["resolved_voice"] = entry["voice"]
elif voice_selection.startswith("profile:"):
profile_name = voice_selection.split(":", 1)[1]
entry["voice_profile"] = profile_name
profile_entry = profiles.get(profile_name) or {}
formula_value = _formula_from_profile(profile_entry)
if formula_value:
entry["voice_formula"] = formula_value
entry["resolved_voice"] = formula_value
else:
errors.append(f"Profile '{profile_name}' has no configured voices.")
elif voice_selection == "formula":
if not formula_input:
errors.append(f"Provide a custom formula for chapter {index + 1}.")
else:
try:
_parse_voice_formula(formula_input)
except ValueError as exc:
errors.append(str(exc))
else:
entry["voice_formula"] = formula_input
entry["resolved_voice"] = formula_input
selected_total += len(entry["text"] or "")
overrides.append(entry)
pending.chapters[index] = dict(entry)
if not any(item.get("enabled") for item in overrides):
return _render_prepare_page(pending, error="Select at least one chapter to convert.")
if errors:
return _render_prepare_page(pending, error=" ".join(errors))
total_characters = selected_total or pending.total_characters
service.pop_pending_job(pending_id)
job = service.enqueue(
original_filename=pending.original_filename,
stored_path=pending.stored_path,
language=pending.language,
voice=pending.voice,
speed=pending.speed,
use_gpu=pending.use_gpu,
subtitle_mode=pending.subtitle_mode,
output_format=pending.output_format,
save_mode=pending.save_mode,
output_folder=pending.output_folder,
replace_single_newlines=pending.replace_single_newlines,
subtitle_format=pending.subtitle_format,
total_characters=total_characters,
chapters=overrides,
metadata_tags=pending.metadata_tags,
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,
cover_image_path=pending.cover_image_path,
cover_image_mime=pending.cover_image_mime,
)
return redirect(url_for("web.job_detail", job_id=job.id)) return redirect(url_for("web.job_detail", job_id=job.id))
@web_bp.post("/jobs/prepare/<pending_id>/cancel")
def cancel_pending_job(pending_id: str) -> Response:
pending = _service().pop_pending_job(pending_id)
if pending and pending.stored_path.exists():
try:
pending.stored_path.unlink()
except OSError:
pass
if pending and pending.cover_image_path and pending.cover_image_path.exists():
try:
pending.cover_image_path.unlink()
except OSError:
pass
return redirect(url_for("web.index"))
def _render_jobs_panel() -> str: def _render_jobs_panel() -> str:
jobs = _service().list_jobs() jobs = _service().list_jobs()
active_jobs = [job for job in jobs if job.status in {JobStatus.PENDING, JobStatus.RUNNING}] active_statuses = {JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED}
finished_jobs = [job for job in jobs if job.status not in {JobStatus.PENDING, JobStatus.RUNNING}] active_jobs = [job for job in jobs if job.status in active_statuses]
active_jobs.sort(key=lambda job: ((job.queue_position or 10_000), -job.created_at))
finished_jobs = [job for job in jobs if job.status not in active_statuses]
return render_template( return render_template(
"partials/jobs.html", "partials/jobs.html",
active_jobs=active_jobs, active_jobs=active_jobs,
@@ -775,6 +977,16 @@ def _render_jobs_panel() -> str:
) )
def _render_prepare_page(pending: PendingJob, *, error: Optional[str] = None) -> str:
return render_template(
"prepare_job.html",
pending=pending,
options=_template_options(),
settings=_load_settings(),
error=error,
)
@web_bp.get("/jobs/<job_id>") @web_bp.get("/jobs/<job_id>")
def job_detail(job_id: str) -> str: def job_detail(job_id: str) -> str:
job = _service().get_job(job_id) job = _service().get_job(job_id)
@@ -787,6 +999,22 @@ def job_detail(job_id: str) -> str:
) )
@web_bp.post("/jobs/<job_id>/pause")
def pause_job(job_id: str) -> Response:
_service().pause(job_id)
if request.headers.get("HX-Request"):
return _render_jobs_panel()
return redirect(url_for("web.job_detail", job_id=job_id))
@web_bp.post("/jobs/<job_id>/resume")
def resume_job(job_id: str) -> Response:
_service().resume(job_id)
if request.headers.get("HX-Request"):
return _render_jobs_panel()
return redirect(url_for("web.job_detail", job_id=job_id))
@web_bp.post("/jobs/<job_id>/cancel") @web_bp.post("/jobs/<job_id>/cancel")
def cancel_job(job_id: str) -> Response: def cancel_job(job_id: str) -> Response:
_service().cancel(job_id) _service().cancel(job_id)
@@ -838,7 +1066,6 @@ def download_job(job_id: str) -> Response:
def jobs_partial() -> str: def jobs_partial() -> str:
return _render_jobs_panel() return _render_jobs_panel()
@web_bp.get("/partials/jobs/<job_id>/logs") @web_bp.get("/partials/jobs/<job_id>/logs")
def job_logs_partial(job_id: str) -> str: def job_logs_partial(job_id: str) -> str:
job = _service().get_job(job_id) job = _service().get_job(job_id)
+333
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json
import os
import threading import threading
import time import time
import uuid import uuid
@@ -8,10 +10,22 @@ from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping
from abogen.utils import get_internal_cache_path
def _create_set_event() -> threading.Event:
event = threading.Event()
event.set()
return event
STATE_VERSION = 2
class JobStatus(str, Enum): class JobStatus(str, Enum):
PENDING = "pending" PENDING = "pending"
RUNNING = "running" RUNNING = "running"
PAUSED = "paused"
COMPLETED = "completed" COMPLETED = "completed"
FAILED = "failed" FAILED = "failed"
CANCELLED = "cancelled" CANCELLED = "cancelled"
@@ -67,6 +81,12 @@ class Job:
chapters: List[Dict[str, Any]] = field(default_factory=list) chapters: List[Dict[str, Any]] = field(default_factory=list)
queue_position: Optional[int] = None queue_position: Optional[int] = None
cancel_requested: bool = False cancel_requested: bool = False
pause_requested: bool = False
paused: bool = False
resume_token: Optional[str] = None
pause_event: threading.Event = field(default_factory=_create_set_event, repr=False, compare=False)
cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None
def add_log(self, message: str, level: str = "info") -> None: def add_log(self, message: str, level: str = "info") -> None:
self.logs.append(JobLog(timestamp=time.time(), message=message, level=level)) self.logs.append(JobLog(timestamp=time.time(), message=message, level=level))
@@ -108,6 +128,10 @@ class Job:
"order": entry.get("order"), "order": entry.get("order"),
"title": entry.get("title"), "title": entry.get("title"),
"enabled": bool(entry.get("enabled", True)), "enabled": bool(entry.get("enabled", True)),
"voice": entry.get("voice"),
"voice_profile": entry.get("voice_profile"),
"voice_formula": entry.get("voice_formula"),
"resolved_voice": entry.get("resolved_voice"),
"characters": len(str(entry.get("text", ""))), "characters": len(str(entry.get("text", ""))),
} }
for entry in self.chapters for entry in self.chapters
@@ -115,6 +139,36 @@ class Job:
} }
@dataclass
class PendingJob:
id: str
original_filename: str
stored_path: Path
language: str
voice: str
speed: float
use_gpu: bool
subtitle_mode: str
output_format: str
save_mode: str
output_folder: Optional[Path]
replace_single_newlines: bool
subtitle_format: str
total_characters: int
save_chapters_separately: bool
merge_chapters_at_end: bool
separate_chapters_format: str
silence_between_chapters: float
save_as_project: bool
voice_profile: Optional[str]
max_subtitle_words: int
metadata_tags: Dict[str, Any]
chapters: List[Dict[str, Any]]
created_at: float
cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None
class ConversionService: class ConversionService:
def __init__( def __init__(
self, self,
@@ -134,7 +188,11 @@ class ConversionService:
self._uploads_root = uploads_root or output_root / "uploads" self._uploads_root = uploads_root or output_root / "uploads"
self._runner = runner self._runner = runner
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._pending_jobs: Dict[str, PendingJob] = {}
self._state_path = Path(get_internal_cache_path("jobs")) / "queue_state.json"
self._state_path.parent.mkdir(parents=True, exist_ok=True)
self._ensure_directories() self._ensure_directories()
self._load_state()
# Public API --------------------------------------------------------- # Public API ---------------------------------------------------------
def list_jobs(self) -> List[Job]: def list_jobs(self) -> List[Job]:
@@ -170,6 +228,8 @@ class ConversionService:
voice_profile: Optional[str] = None, voice_profile: Optional[str] = None,
max_subtitle_words: int = 50, max_subtitle_words: int = 50,
metadata_tags: Optional[Mapping[str, Any]] = None, metadata_tags: Optional[Mapping[str, Any]] = None,
cover_image_path: Optional[Path] = None,
cover_image_mime: Optional[str] = None,
) -> Job: ) -> Job:
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
normalized_metadata = self._normalize_metadata_tags(metadata_tags) normalized_metadata = self._normalize_metadata_tags(metadata_tags)
@@ -201,6 +261,8 @@ class ConversionService:
created_at=time.time(), created_at=time.time(),
total_characters=total_characters, total_characters=total_characters,
chapters=normalized_chapters, chapters=normalized_chapters,
cover_image_path=cover_image_path,
cover_image_mime=cover_image_mime,
) )
with self._lock: with self._lock:
self._jobs[job_id] = job self._jobs[job_id] = job
@@ -211,6 +273,18 @@ class ConversionService:
job.add_log("Job queued") job.add_log("Job queued")
return job return job
def store_pending_job(self, pending: PendingJob) -> None:
with self._lock:
self._pending_jobs[pending.id] = pending
def get_pending_job(self, pending_id: str) -> Optional[PendingJob]:
with self._lock:
return self._pending_jobs.get(pending_id)
def pop_pending_job(self, pending_id: str) -> Optional[PendingJob]:
with self._lock:
return self._pending_jobs.pop(pending_id, None)
def cancel(self, job_id: str) -> bool: def cancel(self, job_id: str) -> bool:
with self._lock: with self._lock:
job = self._jobs.get(job_id) job = self._jobs.get(job_id)
@@ -219,12 +293,68 @@ class ConversionService:
if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}: if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
return False return False
job.cancel_requested = True job.cancel_requested = True
job.pause_requested = False
job.paused = False
job.add_log("Cancellation requested", level="warning") job.add_log("Cancellation requested", level="warning")
job.pause_event.set()
if job.status == JobStatus.PENDING: if job.status == JobStatus.PENDING:
job.status = JobStatus.CANCELLED job.status = JobStatus.CANCELLED
self._queue.remove(job_id) self._queue.remove(job_id)
job.finished_at = time.time() job.finished_at = time.time()
self._update_queue_positions_locked() self._update_queue_positions_locked()
self._persist_state()
return True
def pause(self, job_id: str) -> bool:
with self._lock:
job = self._jobs.get(job_id)
if job is None:
return False
if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
return False
if job.pause_requested or job.paused:
return True
job.pause_requested = True
job.add_log("Pause requested; finishing current chunk before stopping.", level="warning")
if job.status == JobStatus.PENDING:
if job_id in self._queue:
self._queue.remove(job_id)
self._update_queue_positions_locked()
job.status = JobStatus.PAUSED
job.paused = True
job.pause_event.clear()
self._persist_state()
return True
def resume(self, job_id: str) -> bool:
with self._lock:
job = self._jobs.get(job_id)
if job is None:
return False
if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
return False
job.pause_requested = False
if job.status == JobStatus.PAUSED and job.started_at is None:
job.status = JobStatus.PENDING
job.paused = False
job.pause_event.set()
if job_id not in self._queue:
self._queue.insert(0, job_id)
self._update_queue_positions_locked()
self._wake_event.set()
job.add_log("Resume requested; returning job to queue.", level="info")
else:
job.paused = False
job.pause_event.set()
if job.status == JobStatus.PAUSED:
job.status = JobStatus.RUNNING
job.add_log("Resume requested", level="info")
self._persist_state()
return True return True
def delete(self, job_id: str) -> bool: def delete(self, job_id: str) -> bool:
@@ -238,6 +368,7 @@ class ConversionService:
if job_id in self._queue: if job_id in self._queue:
self._queue.remove(job_id) self._queue.remove(job_id)
self._update_queue_positions_locked() self._update_queue_positions_locked()
self._persist_state()
return True return True
def clear_finished(self, *, statuses: Optional[Iterable[JobStatus]] = None) -> int: def clear_finished(self, *, statuses: Optional[Iterable[JobStatus]] = None) -> int:
@@ -260,6 +391,7 @@ class ConversionService:
if removed: if removed:
self._update_queue_positions_locked() self._update_queue_positions_locked()
self._persist_state()
return removed return removed
def shutdown(self) -> None: def shutdown(self) -> None:
@@ -312,9 +444,13 @@ class ConversionService:
self._run_job(job) self._run_job(job)
def _run_job(self, job: Job) -> None: def _run_job(self, job: Job) -> None:
job.pause_event.set()
job.pause_requested = False
job.paused = False
job.status = JobStatus.RUNNING job.status = JobStatus.RUNNING
job.started_at = time.time() job.started_at = time.time()
job.add_log("Job started", level="info") job.add_log("Job started", level="info")
self._persist_state()
try: try:
self._runner(job) self._runner(job)
except Exception as exc: # pragma: no cover - defensive except Exception as exc: # pragma: no cover - defensive
@@ -331,6 +467,8 @@ class ConversionService:
job.add_log("Job completed", level="success") job.add_log("Job completed", level="success")
job.finished_at = time.time() job.finished_at = time.time()
finally: finally:
job.pause_event.set()
self._persist_state()
with self._lock: with self._lock:
self._update_queue_positions_locked() self._update_queue_positions_locked()
@@ -339,6 +477,177 @@ class ConversionService:
job = self._jobs.get(job_id) job = self._jobs.get(job_id)
if job: if job:
job.queue_position = index job.queue_position = index
self._persist_state()
# Persistence ------------------------------------------------------
def _serialize_job(self, job: Job) -> Dict[str, Any]:
result_audio = str(job.result.audio_path) if job.result.audio_path else None
result_subtitles = [str(path) for path in job.result.subtitle_paths]
result_artifacts = {key: str(path) for key, path in job.result.artifacts.items()}
return {
"id": job.id,
"original_filename": job.original_filename,
"stored_path": str(job.stored_path),
"language": job.language,
"voice": job.voice,
"speed": job.speed,
"use_gpu": job.use_gpu,
"subtitle_mode": job.subtitle_mode,
"output_format": job.output_format,
"save_mode": job.save_mode,
"output_folder": str(job.output_folder) if job.output_folder else None,
"replace_single_newlines": job.replace_single_newlines,
"subtitle_format": job.subtitle_format,
"created_at": job.created_at,
"save_chapters_separately": job.save_chapters_separately,
"merge_chapters_at_end": job.merge_chapters_at_end,
"separate_chapters_format": job.separate_chapters_format,
"silence_between_chapters": job.silence_between_chapters,
"save_as_project": job.save_as_project,
"voice_profile": job.voice_profile,
"metadata_tags": job.metadata_tags,
"max_subtitle_words": job.max_subtitle_words,
"status": job.status.value,
"started_at": job.started_at,
"finished_at": job.finished_at,
"progress": job.progress,
"total_characters": job.total_characters,
"processed_characters": job.processed_characters,
"error": job.error,
"logs": [log.__dict__ for log in job.logs][-500:],
"result": {
"audio_path": result_audio,
"subtitle_paths": result_subtitles,
"artifacts": result_artifacts,
},
"chapters": [dict(entry) for entry in job.chapters],
"queue_position": job.queue_position,
"cancel_requested": job.cancel_requested,
"pause_requested": job.pause_requested,
"paused": job.paused,
"resume_token": job.resume_token,
"cover_image_path": str(job.cover_image_path) if job.cover_image_path else None,
"cover_image_mime": job.cover_image_mime,
}
def _persist_state(self) -> None:
try:
with self._lock:
snapshot = {
"version": STATE_VERSION,
"jobs": [self._serialize_job(job) for job in self._jobs.values()],
"queue": list(self._queue),
}
tmp_path = self._state_path.with_suffix(".tmp")
with tmp_path.open("w", encoding="utf-8") as handle:
json.dump(snapshot, handle, indent=2)
os.replace(tmp_path, self._state_path)
except Exception:
# Persistence failures should not disrupt runtime; ignore.
pass
def _deserialize_job(self, payload: Dict[str, Any]) -> Job:
stored_path = Path(payload["stored_path"])
output_folder_raw = payload.get("output_folder")
output_folder = Path(output_folder_raw) if output_folder_raw else None
job = Job(
id=payload["id"],
original_filename=payload["original_filename"],
stored_path=stored_path,
language=payload.get("language", "a"),
voice=payload.get("voice", ""),
speed=float(payload.get("speed", 1.0)),
use_gpu=bool(payload.get("use_gpu", True)),
subtitle_mode=payload.get("subtitle_mode", "Disabled"),
output_format=payload.get("output_format", "wav"),
save_mode=payload.get("save_mode", "Save next to input file"),
output_folder=output_folder,
replace_single_newlines=bool(payload.get("replace_single_newlines", False)),
subtitle_format=payload.get("subtitle_format", "srt"),
created_at=float(payload.get("created_at", time.time())),
save_chapters_separately=bool(payload.get("save_chapters_separately", False)),
merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)),
separate_chapters_format=payload.get("separate_chapters_format", "wav"),
silence_between_chapters=float(payload.get("silence_between_chapters", 2.0)),
save_as_project=bool(payload.get("save_as_project", False)),
voice_profile=payload.get("voice_profile"),
metadata_tags=payload.get("metadata_tags", {}),
max_subtitle_words=int(payload.get("max_subtitle_words", 50)),
)
job.status = JobStatus(payload.get("status", job.status.value))
job.started_at = payload.get("started_at")
job.finished_at = payload.get("finished_at")
job.progress = float(payload.get("progress", 0.0))
job.total_characters = int(payload.get("total_characters", 0))
job.processed_characters = int(payload.get("processed_characters", 0))
job.error = payload.get("error")
job.logs = [JobLog(**entry) for entry in payload.get("logs", [])]
result_payload = payload.get("result", {})
audio_path_raw = result_payload.get("audio_path")
job.result.audio_path = Path(audio_path_raw) if audio_path_raw else None
job.result.subtitle_paths = [Path(item) for item in result_payload.get("subtitle_paths", [])]
job.result.artifacts = {
key: Path(value) for key, value in result_payload.get("artifacts", {}).items()
}
job.chapters = payload.get("chapters", [])
job.queue_position = payload.get("queue_position")
job.cancel_requested = bool(payload.get("cancel_requested", False))
job.pause_requested = bool(payload.get("pause_requested", False))
job.paused = bool(payload.get("paused", False))
job.resume_token = payload.get("resume_token")
cover_path_raw = payload.get("cover_image_path")
job.cover_image_path = Path(cover_path_raw) if cover_path_raw else None
job.cover_image_mime = payload.get("cover_image_mime")
job.pause_event.set()
return job
def _load_state(self) -> None:
if not self._state_path.exists():
return
try:
with self._state_path.open("r", encoding="utf-8") as handle:
payload = json.load(handle)
except Exception:
return
if payload.get("version") != STATE_VERSION:
return
jobs_payload = payload.get("jobs", [])
queue_payload = payload.get("queue", [])
loaded_jobs: Dict[str, Job] = {}
requeue: List[str] = []
for entry in jobs_payload:
try:
job = self._deserialize_job(entry)
except Exception:
continue
if job.status in {JobStatus.RUNNING, JobStatus.PAUSED}:
job.status = JobStatus.PENDING
job.add_log("Job restored after restart: resetting to pending queue.", level="warning")
job.progress = 0.0
job.processed_characters = 0
job.pause_requested = False
job.paused = False
job.pause_event.set()
requeue.append(job.id)
elif job.status == JobStatus.PENDING:
requeue.append(job.id)
loaded_jobs[job.id] = job
with self._lock:
self._jobs = loaded_jobs
self._queue = [job_id for job_id in queue_payload if job_id in loaded_jobs]
for job_id in requeue:
if job_id not in self._queue:
self._queue.append(job_id)
self._update_queue_positions_locked()
if self._queue:
self._ensure_worker()
@staticmethod @staticmethod
def _coerce_bool(value: Any, default: bool = True) -> bool: def _coerce_bool(value: Any, default: bool = True) -> bool:
@@ -458,6 +767,30 @@ class ConversionService:
if normalized_metadata: if normalized_metadata:
entry["metadata"] = normalized_metadata entry["metadata"] = normalized_metadata
voice_value = raw_dict.get("voice")
if voice_value:
entry["voice"] = str(voice_value)
profile_value = raw_dict.get("voice_profile")
if profile_value:
entry["voice_profile"] = str(profile_value)
formula_value = raw_dict.get("voice_formula") or raw_dict.get("formula")
if formula_value:
entry["voice_formula"] = str(formula_value)
resolved_value = raw_dict.get("resolved_voice")
if resolved_value:
entry["resolved_voice"] = str(resolved_value)
if "characters" in raw_dict:
try:
entry["characters"] = int(raw_dict.get("characters", 0))
except (TypeError, ValueError):
entry["characters"] = len(str(entry.get("text", "")))
else:
entry["characters"] = len(str(entry.get("text", "")))
normalized.append(entry) normalized.append(entry)
return normalized return normalized
+64
View File
@@ -0,0 +1,64 @@
document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector(".prepare-form");
if (!form) return;
const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]"));
const updateRowState = (row) => {
const enabled = row.querySelector('[data-role=chapter-enabled]');
const inputs = Array.from(row.querySelectorAll("input[type=text], select, textarea"));
const isChecked = enabled ? enabled.checked : true;
row.dataset.disabled = isChecked ? "false" : "true";
inputs.forEach((input) => {
if (input === enabled) return;
input.disabled = !isChecked;
if (!isChecked) {
if (input.tagName === "SELECT") {
input.dataset.prevValue = input.value;
input.value = "__default";
}
if (input.dataset.role === "formula-input") {
input.value = "";
input.hidden = true;
input.setAttribute("aria-hidden", "true");
}
} else if (input.tagName === "SELECT" && input.dataset.prevValue) {
input.value = input.dataset.prevValue;
}
});
const select = row.querySelector("select[data-role=voice-select]");
toggleFormula(select);
};
const toggleFormula = (select) => {
if (!select) return;
const container = select.closest("[data-role=chapter-row]");
const formulaInput = container.querySelector('[data-role=formula-input]');
const isFormula = select.value === "formula";
formulaInput.hidden = !isFormula;
formulaInput.setAttribute("aria-hidden", isFormula ? "false" : "true");
if (!isFormula) {
formulaInput.value = "";
}
if (isFormula) {
formulaInput.required = true;
} else {
formulaInput.required = false;
}
};
chapterRows.forEach((row) => {
const enabled = row.querySelector('[data-role=chapter-enabled]');
if (enabled) {
enabled.addEventListener("change", () => updateRowState(row));
updateRowState(row);
}
const select = row.querySelector("select[data-role=voice-select]");
if (select) {
select.addEventListener("change", () => toggleFormula(select));
toggleFormula(select);
}
});
});
+188
View File
@@ -441,6 +441,26 @@ body {
gap: 0.75rem; gap: 0.75rem;
} }
.job-card[data-status="paused"] {
border-color: rgba(251, 191, 36, 0.45);
background: rgba(251, 191, 36, 0.08);
}
.job-card__title {
font-size: 1.05rem;
font-weight: 600;
margin: 0;
}
.job-card__title a {
color: inherit;
text-decoration: none;
}
.job-card__title a:hover {
color: var(--accent);
}
.job-card__header { .job-card__header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -454,6 +474,10 @@ body {
margin-top: 0.35rem; margin-top: 0.35rem;
} }
.job-card__meta--warning {
color: var(--warning);
}
.job-card__progress { .job-card__progress {
display: grid; display: grid;
gap: 0.4rem; gap: 0.4rem;
@@ -549,6 +573,12 @@ body {
color: var(--success); color: var(--success);
} }
.alert--error {
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.35);
color: var(--danger);
}
.button--danger { .button--danger {
background: linear-gradient(135deg, var(--danger), #ef4444); background: linear-gradient(135deg, var(--danger), #ef4444);
box-shadow: 0 12px 30px rgba(239, 68, 68, 0.2); box-shadow: 0 12px 30px rgba(239, 68, 68, 0.2);
@@ -618,6 +648,164 @@ body {
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18); box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18);
} }
.prepare-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.prepare-summary dl {
margin: 0;
display: grid;
gap: 0.6rem;
}
.prepare-summary dt {
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.prepare-summary dd {
margin: 0;
font-weight: 500;
}
.prepare-metadata h2 {
font-size: 1rem;
margin: 0 0 0.6rem;
color: var(--muted);
}
.prepare-metadata ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.35rem;
font-size: 0.95rem;
}
.prepare-form {
display: grid;
gap: 1.5rem;
}
.chapter-grid {
display: grid;
gap: 1rem;
}
.chapter-card {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 20px;
padding: 1rem 1.25rem;
background: rgba(15, 23, 42, 0.32);
display: grid;
gap: 0.85rem;
}
.chapter-card[data-disabled="true"] {
opacity: 0.5;
}
.chapter-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.chapter-card__title {
font-weight: 600;
display: flex;
align-items: center;
gap: 0.6rem;
}
.chapter-card__checkbox {
display: inline-flex;
align-items: center;
gap: 0.6rem;
}
.chapter-card__checkbox input {
width: 1rem;
height: 1rem;
accent-color: var(--accent);
}
.chapter-card__metrics {
color: var(--muted);
font-size: 0.85rem;
}
.chapter-card__body {
display: grid;
gap: 1rem;
}
.chapter-card__field {
display: grid;
gap: 0.45rem;
}
.chapter-card__field label {
font-size: 0.85rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.chapter-card__field input,
.chapter-card__field select {
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(15, 23, 42, 0.45);
padding: 0.7rem 0.9rem;
color: var(--text);
font-size: 0.95rem;
}
.chapter-card__preview summary {
cursor: pointer;
color: var(--accent);
font-weight: 500;
}
.chapter-card__preview pre {
margin: 0.35rem 0 0;
padding: 0.75rem 0.9rem;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.55);
max-height: 200px;
overflow-y: auto;
font-size: 0.85rem;
line-height: 1.45;
white-space: pre-wrap;
}
.chapter-card__formula {
max-width: 420px;
}
.prepare-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.prepare-actions .button {
min-width: 190px;
}
.prepare-actions .button--ghost {
border-color: rgba(148, 163, 184, 0.25);
}
.toggle-pill input:checked + span { .toggle-pill input:checked + span {
background: rgba(56, 189, 248, 0.15); background: rgba(56, 189, 248, 0.15);
border-color: rgba(56, 189, 248, 0.4); border-color: rgba(56, 189, 248, 0.4);
+22 -8
View File
@@ -7,23 +7,37 @@
{% if active_jobs %} {% if active_jobs %}
<ul class="job-cards"> <ul class="job-cards">
{% for job in active_jobs %} {% for job in active_jobs %}
<li class="job-card"> {% 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 class="job-card__header">
<div> <div>
<strong>{{ job.original_filename }}</strong> <a class="job-card__title" href="{{ url_for('web.job_detail', job_id=job.id) }}">{{ job.original_filename }}</a>
<div class="job-card__meta">{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}</div> <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> </div>
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span> <span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
</div> </div>
<div class="job-card__progress"> <div class="job-card__progress">
<div class="progress-bar" style="--progress: {{ ((job.progress or 0) * 100)|round(1) }}%"> <div class="progress-bar" style="--progress: {{ progress_value }}%">
<div class="progress-bar__fill"></div> <div class="progress-bar__fill"></div>
</div> </div>
<small>{{ job.processed_characters }} / {{ job.total_characters or '—' }}</small> <small>{{ progress_value }}% · {{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
</div> </div>
<div class="job-card__footer"> <div class="job-card__footer">
<a class="button button--ghost" href="{{ url_for('web.job_detail', job_id=job.id) }}">Inspect</a> <a class="button button--ghost" href="{{ url_for('web.job_detail', job_id=job.id) }}">Details</a>
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING] %} {% if job.status == JobStatus.RUNNING %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.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('web.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('web.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('web.cancel_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Cancel this conversion?">Cancel</button> <button type="button" class="button button--ghost" hx-post="{{ url_for('web.cancel_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Cancel this conversion?">Cancel</button>
{% endif %} {% endif %}
</div> </div>
@@ -48,7 +62,7 @@
<li class="job-card"> <li class="job-card">
<div class="job-card__header"> <div class="job-card__header">
<div> <div>
<strong>{{ job.original_filename }}</strong> <a class="job-card__title" href="{{ url_for('web.job_detail', job_id=job.id) }}">{{ job.original_filename }}</a>
<div class="job-card__meta">Finished {{ job.finished_at|datetimeformat }}</div> <div class="job-card__meta">Finished {{ job.finished_at|datetimeformat }}</div>
</div> </div>
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span> <span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
+120
View File
@@ -0,0 +1,120 @@
{% extends "base.html" %}
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
{% block content %}
<section class="card">
<div class="card__title">Prepare audiobook</div>
<p class="card__subtitle">Review the detected chapters, choose which ones to render, and optionally override the voice per chapter before sending the job to the queue.</p>
<div class="prepare-summary">
<dl>
<div>
<dt>Source</dt>
<dd>{{ pending.original_filename }}</dd>
</div>
<div>
<dt>Language</dt>
<dd>{{ options.languages.get(pending.language, pending.language|upper) }}</dd>
</div>
<div>
<dt>Default voice</dt>
<dd>{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}</dd>
</div>
<div>
<dt>Total chapters</dt>
<dd>{{ pending.chapters|length }}</dd>
</div>
<div>
<dt>Characters</dt>
<dd>{{ pending.total_characters|default(0) }}</dd>
</div>
</dl>
{% if pending.metadata_tags %}
<div class="prepare-metadata">
<h2>Metadata</h2>
<ul>
{% for key, value in pending.metadata_tags.items() %}
<li><strong>{{ key|replace('_', ' ')|title }}:</strong> {{ value }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if error %}
<div class="alert alert--error">{{ error }}</div>
{% endif %}
<form method="post" action="{{ url_for('web.finalize_job', pending_id=pending.id) }}" class="prepare-form" id="prepare-form">
<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">
<header class="chapter-card__header">
<div class="chapter-card__title">
<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>
</div>
<div class="chapter-card__metrics">
{{ chapter.characters }} characters
</div>
</header>
<div class="chapter-card__body">
<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 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>
<div class="prepare-actions">
<button type="submit" class="button">Queue conversion</button>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div>
</form>
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
</section>
{% endblock %}
{% block scripts %}
{{ super() }}
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
{% endblock %}