mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
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:
@@ -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}]")
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+234
-7
@@ -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/<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))
|
||||
|
||||
|
||||
@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:
|
||||
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/<job_id>")
|
||||
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/<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")
|
||||
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/<job_id>/logs")
|
||||
def job_logs_partial(job_id: str) -> str:
|
||||
job = _service().get_job(job_id)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -7,23 +7,37 @@
|
||||
{% if active_jobs %}
|
||||
<ul class="job-cards">
|
||||
{% 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>
|
||||
<strong>{{ job.original_filename }}</strong>
|
||||
<div class="job-card__meta">{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}</div>
|
||||
<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.queue_position %}Position #{{ job.queue_position }} · {% endif %}
|
||||
{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}
|
||||
</div>
|
||||
{% if job.pause_requested and job.status == JobStatus.RUNNING %}
|
||||
<div class="job-card__meta job-card__meta--warning">Pause requested — waiting for a safe point…</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
|
||||
</div>
|
||||
<div class="job-card__progress">
|
||||
<div class="progress-bar" style="--progress: {{ ((job.progress or 0) * 100)|round(1) }}%">
|
||||
<div class="progress-bar" style="--progress: {{ progress_value }}%">
|
||||
<div class="progress-bar__fill"></div>
|
||||
</div>
|
||||
<small>{{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
|
||||
<small>{{ progress_value }}% · {{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
|
||||
</div>
|
||||
<div class="job-card__footer">
|
||||
<a class="button button--ghost" href="{{ url_for('web.job_detail', job_id=job.id) }}">Inspect</a>
|
||||
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING] %}
|
||||
<a class="button button--ghost" href="{{ url_for('web.job_detail', job_id=job.id) }}">Details</a>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -48,7 +62,7 @@
|
||||
<li class="job-card">
|
||||
<div class="job-card__header">
|
||||
<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>
|
||||
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user