From 718a3fa1c0f9e42b19e28c84be4a8a56c1be2de3 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 05:34:53 -0700 Subject: [PATCH] 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. --- abogen/kokoro_text_normalization.py | 334 ++++++++++++++++++++++++ abogen/text_extractor.py | 56 +++- abogen/web/conversion_runner.py | 86 +++++- abogen/web/routes.py | 241 ++++++++++++++++- abogen/web/service.py | 333 +++++++++++++++++++++++ abogen/web/static/prepare.js | 64 +++++ abogen/web/static/styles.css | 188 +++++++++++++ abogen/web/templates/partials/jobs.html | 30 ++- abogen/web/templates/prepare_job.html | 120 +++++++++ 9 files changed, 1426 insertions(+), 26 deletions(-) create mode 100644 abogen/kokoro_text_normalization.py create mode 100644 abogen/web/static/prepare.js create mode 100644 abogen/web/templates/prepare_job.html diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py new file mode 100644 index 0000000..3cc784e --- /dev/null +++ b/abogen/kokoro_text_normalization.py @@ -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}]") \ No newline at end of file diff --git a/abogen/text_extractor.py b/abogen/text_extractor.py index e175051..079bc98 100644 --- a/abogen/text_extractor.py +++ b/abogen/text_extractor.py @@ -2,6 +2,7 @@ from __future__ import annotations import datetime import logging +import mimetypes import re import textwrap import urllib.parse @@ -31,6 +32,9 @@ METADATA_KEY_MAP: Dict[str, str] = { "COMPOSER": "composer", "GENRE": "genre", "DATE": "date", + "PUBLISHER": "publisher", + "COMMENT": "comment", + "LANGUAGE": "language", } @@ -48,6 +52,8 @@ class ExtractedChapter: 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: @@ -65,6 +71,7 @@ class MetadataSource: description: Optional[str] = None publisher: Optional[str] = None publication_year: Optional[str] = None + language: Optional[str] = None @dataclass @@ -188,6 +195,12 @@ def _build_metadata_payload( "COMPOSER": "Narrator", "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) @@ -370,7 +383,14 @@ class EpubExtractor: if not chapters: chapters = [ExtractedChapter(title=self.path.stem, text="")] 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: metadata = MetadataSource() @@ -411,8 +431,42 @@ class EpubExtractor: 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) + 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: diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index e8a4cad..b794b52 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -15,6 +15,11 @@ import numpy as np import soundfile as sf 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.utils import ( calculate_text_length, @@ -163,6 +168,35 @@ def _merge_metadata( 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: job.add_log("Preparing conversion pipeline") canceller = _make_canceller(job) @@ -175,6 +209,7 @@ def run_conversion_job(job: Job) -> None: extraction = extract_from_path(job.stored_path) metadata_overrides: Dict[str, Any] = dict(job.metadata_tags or {}) + active_chapter_configs: List[Dict[str, Any]] = [] if job.chapters: selected_chapters, chapter_metadata, diagnostics = _apply_chapter_overrides( extraction.chapters, @@ -189,6 +224,9 @@ def run_conversion_job(job: Job) -> None: f"Chapter overrides applied: {len(selected_chapters)} selected.", level="info", ) + active_chapter_configs = [ + entry for entry in job.chapters if _coerce_truthy(entry.get("enabled", True)) + ][: len(selected_chapters)] else: 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.mkdir(parents=True, exist_ok=True) - voice_spec = job.voice or "" - cached_voice = None - if "*" not in voice_spec: - cached_voice = _resolve_voice(pipeline, voice_spec, job.use_gpu) + base_voice_spec = (job.voice or "").strip() + voice_cache: Dict[str, Any] = {} + if base_voice_spec and "*" not in base_voice_spec: + voice_cache[base_voice_spec] = _resolve_voice(pipeline, base_voice_spec, job.use_gpu) processed_chars = 0 subtitle_index = 1 current_time = 0.0 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 ''}") for idx, chapter in enumerate(extraction.chapters, start=1): canceller() 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: Optional[AudioSink] = None chapter_audio_path: Optional[Path] = None @@ -251,14 +303,11 @@ def run_conversion_job(job: Job) -> None: 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 + tts_input = _normalize_for_pipeline(chapter.text) for segment in pipeline( - chapter.text, + tts_input, voice=voice_choice, speed=job.speed, split_pattern=SPLIT_PATTERN, @@ -303,6 +352,8 @@ def run_conversion_job(job: Job) -> None: if chapter_sink: chapter_sink_stack.close() + chapter_end_time = current_time + if chapter_audio_path is not None: job.result.artifacts[f"chapter_{idx:02d}"] = 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") audio_sink.write(silence) 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: job.result.audio_path = chapter_paths[0] @@ -333,7 +395,11 @@ def run_conversion_job(job: Job) -> None: if metadata_dir: metadata_dir.mkdir(parents=True, exist_ok=True) 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 if job.save_as_project: diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 618877b..38e7446 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -5,6 +5,7 @@ import json import mimetypes import os import threading +import time import uuid from pathlib import Path 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.text_extractor import extract_from_path 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__) api_bp = Blueprint("api", __name__) @@ -272,6 +274,28 @@ def _resolve_voice_choice( 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]]: parts = [segment.strip() for segment in formula.split("+") if segment.strip()] voices: List[tuple[str, float]] = [] @@ -690,12 +714,54 @@ def enqueue_job() -> Response: stored_path = uploads_dir / f"{uuid.uuid4().hex}_{filename}" file.save(stored_path) original_name = filename - total_chars = 0 else: original_name = "direct_text.txt" stored_path = uploads_dir / f"{uuid.uuid4().hex}_{original_name}" stored_path.write_text(text_input, encoding="utf-8") - 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() settings = _load_settings() @@ -737,7 +803,8 @@ def enqueue_job() -> Response: silence_between_chapters = settings["silence_between_chapters"] max_subtitle_words = settings["max_subtitle_words"] - job = service.enqueue( + pending = PendingJob( + id=uuid.uuid4().hex, original_filename=original_name, stored_path=stored_path, language=language, @@ -758,14 +825,149 @@ def enqueue_job() -> Response: save_as_project=save_as_project, voice_profile=selected_profile, 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/") +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/") +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)) +@web_bp.post("/jobs/prepare//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: jobs = _service().list_jobs() - active_jobs = [job for job in jobs if job.status in {JobStatus.PENDING, JobStatus.RUNNING}] - finished_jobs = [job for job in jobs if job.status not in {JobStatus.PENDING, JobStatus.RUNNING}] + active_statuses = {JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED} + active_jobs = [job for job in jobs if job.status in active_statuses] + active_jobs.sort(key=lambda job: ((job.queue_position or 10_000), -job.created_at)) + finished_jobs = [job for job in jobs if job.status not in active_statuses] return render_template( "partials/jobs.html", 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/") def job_detail(job_id: str) -> str: job = _service().get_job(job_id) @@ -787,6 +999,22 @@ def job_detail(job_id: str) -> str: ) +@web_bp.post("/jobs//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//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//cancel") def cancel_job(job_id: str) -> Response: _service().cancel(job_id) @@ -838,7 +1066,6 @@ def download_job(job_id: str) -> Response: def jobs_partial() -> str: return _render_jobs_panel() - @web_bp.get("/partials/jobs//logs") def job_logs_partial(job_id: str) -> str: job = _service().get_job(job_id) diff --git a/abogen/web/service.py b/abogen/web/service.py index ac06705..33da8d8 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +import os import threading import time import uuid @@ -8,10 +10,22 @@ from enum import Enum from pathlib import Path 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): PENDING = "pending" RUNNING = "running" + PAUSED = "paused" COMPLETED = "completed" FAILED = "failed" CANCELLED = "cancelled" @@ -67,6 +81,12 @@ class Job: chapters: List[Dict[str, Any]] = field(default_factory=list) queue_position: Optional[int] = None 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: self.logs.append(JobLog(timestamp=time.time(), message=message, level=level)) @@ -108,6 +128,10 @@ class Job: "order": entry.get("order"), "title": entry.get("title"), "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", ""))), } 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: def __init__( self, @@ -134,7 +188,11 @@ class ConversionService: self._uploads_root = uploads_root or output_root / "uploads" self._runner = runner 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._load_state() # Public API --------------------------------------------------------- def list_jobs(self) -> List[Job]: @@ -170,6 +228,8 @@ class ConversionService: voice_profile: Optional[str] = None, max_subtitle_words: int = 50, metadata_tags: Optional[Mapping[str, Any]] = None, + cover_image_path: Optional[Path] = None, + cover_image_mime: Optional[str] = None, ) -> Job: job_id = uuid.uuid4().hex normalized_metadata = self._normalize_metadata_tags(metadata_tags) @@ -201,6 +261,8 @@ class ConversionService: created_at=time.time(), total_characters=total_characters, chapters=normalized_chapters, + cover_image_path=cover_image_path, + cover_image_mime=cover_image_mime, ) with self._lock: self._jobs[job_id] = job @@ -211,6 +273,18 @@ class ConversionService: job.add_log("Job queued") 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: with self._lock: job = self._jobs.get(job_id) @@ -219,12 +293,68 @@ class ConversionService: if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}: return False job.cancel_requested = True + job.pause_requested = False + job.paused = False job.add_log("Cancellation requested", level="warning") + job.pause_event.set() if job.status == JobStatus.PENDING: job.status = JobStatus.CANCELLED self._queue.remove(job_id) job.finished_at = time.time() 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 def delete(self, job_id: str) -> bool: @@ -238,6 +368,7 @@ class ConversionService: if job_id in self._queue: self._queue.remove(job_id) self._update_queue_positions_locked() + self._persist_state() return True def clear_finished(self, *, statuses: Optional[Iterable[JobStatus]] = None) -> int: @@ -260,6 +391,7 @@ class ConversionService: if removed: self._update_queue_positions_locked() + self._persist_state() return removed def shutdown(self) -> None: @@ -312,9 +444,13 @@ class ConversionService: self._run_job(job) def _run_job(self, job: Job) -> None: + job.pause_event.set() + job.pause_requested = False + job.paused = False job.status = JobStatus.RUNNING job.started_at = time.time() job.add_log("Job started", level="info") + self._persist_state() try: self._runner(job) except Exception as exc: # pragma: no cover - defensive @@ -331,6 +467,8 @@ class ConversionService: job.add_log("Job completed", level="success") job.finished_at = time.time() finally: + job.pause_event.set() + self._persist_state() with self._lock: self._update_queue_positions_locked() @@ -339,6 +477,177 @@ class ConversionService: job = self._jobs.get(job_id) if job: 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 def _coerce_bool(value: Any, default: bool = True) -> bool: @@ -458,6 +767,30 @@ class ConversionService: if 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) return normalized diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js new file mode 100644 index 0000000..b50f3a3 --- /dev/null +++ b/abogen/web/static/prepare.js @@ -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); + } + }); +}); diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index d966bfb..b9e2f61 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -441,6 +441,26 @@ body { 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 { display: flex; justify-content: space-between; @@ -454,6 +474,10 @@ body { margin-top: 0.35rem; } +.job-card__meta--warning { + color: var(--warning); +} + .job-card__progress { display: grid; gap: 0.4rem; @@ -549,6 +573,12 @@ body { 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 { background: linear-gradient(135deg, var(--danger), #ef4444); 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); } +.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 { background: rgba(56, 189, 248, 0.15); border-color: rgba(56, 189, 248, 0.4); diff --git a/abogen/web/templates/partials/jobs.html b/abogen/web/templates/partials/jobs.html index 756fd5f..67abc47 100644 --- a/abogen/web/templates/partials/jobs.html +++ b/abogen/web/templates/partials/jobs.html @@ -7,23 +7,37 @@ {% if active_jobs %}
    {% for job in active_jobs %} -
  • + {% set progress_value = ((job.progress or 0) * 100)|round(1) %} +
  • - {{ job.original_filename }} -
    {% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}
    + {{ job.original_filename }} +
    + {% if job.queue_position %}Position #{{ job.queue_position }} · {% endif %} + {% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }} +
    + {% if job.pause_requested and job.status == JobStatus.RUNNING %} +
    Pause requested — waiting for a safe point…
    + {% endif %}
    {{ job.status.value|title }}
    -
    +
    - {{ job.processed_characters }} / {{ job.total_characters or '—' }} + {{ progress_value }}% · {{ job.processed_characters }} / {{ job.total_characters or '—' }}
    @@ -48,7 +62,7 @@
  • - {{ job.original_filename }} + {{ job.original_filename }}
    Finished {{ job.finished_at|datetimeformat }}
    {{ job.status.value|title }} diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html new file mode 100644 index 0000000..40d261a --- /dev/null +++ b/abogen/web/templates/prepare_job.html @@ -0,0 +1,120 @@ +{% extends "base.html" %} + +{% block title %}Prepare · {{ pending.original_filename }}{% endblock %} + +{% block content %} +
    +
    Prepare audiobook
    +

    Review the detected chapters, choose which ones to render, and optionally override the voice per chapter before sending the job to the queue.

    + +
    +
    +
    +
    Source
    +
    {{ pending.original_filename }}
    +
    +
    +
    Language
    +
    {{ options.languages.get(pending.language, pending.language|upper) }}
    +
    +
    +
    Default voice
    +
    {% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}
    +
    +
    +
    Total chapters
    +
    {{ pending.chapters|length }}
    +
    +
    +
    Characters
    +
    {{ pending.total_characters|default(0) }}
    +
    +
    + {% if pending.metadata_tags %} + + {% endif %} +
    + + {% if error %} +
    {{ error }}
    + {% endif %} + +
    +
    + {% 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 %} +
    +
    +
    + +
    +
    + {{ chapter.characters }} characters +
    +
    +
    +
    + + +
    +
    +
    + Preview text +
    {{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}
    +
    +
    +
    + + + +
    +
    +
    + {% endfor %} +
    +
    + + +
    +
    +
    +
    +{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %}