Files
abogen/abogen/webui/service.py
T

1619 lines
63 KiB
Python

from __future__ import annotations
import json
import logging
import math
import os
import re
import shutil
import sys
import threading
import time
import uuid
import traceback
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping, Tuple
from abogen.utils import get_internal_cache_path, get_user_settings_dir, load_config
from abogen.voice_cache import bootstrap_voice_cache
from abogen.integrations.audiobookshelf import (
AudiobookshelfClient,
AudiobookshelfConfig,
AudiobookshelfUploadError,
)
def _create_set_event() -> threading.Event:
event = threading.Event()
event.set()
return event
STATE_VERSION = 8
_JOB_LOGGER = logging.getLogger("abogen.jobs")
if not _JOB_LOGGER.handlers:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S"))
_JOB_LOGGER.addHandler(handler)
_JOB_LOGGER.propagate = False
_JOB_LOGGER.setLevel(logging.DEBUG)
_JOB_LEVEL_MAP: Dict[str, int] = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"info": logging.INFO,
"success": logging.INFO,
"debug": logging.DEBUG,
"trace": logging.DEBUG,
}
_PEOPLE_SPLIT_RE = re.compile(r"[;,/&]|\band\b", re.IGNORECASE)
def _emit_job_log(job_id: str, level: str, message: str) -> None:
normalized = (level or "info").lower()
log_level = _JOB_LEVEL_MAP.get(normalized, logging.INFO)
try:
_JOB_LOGGER.log(log_level, "[job %s] %s", job_id, message)
except Exception:
# Logging failures should never disrupt job processing, but we should know about them.
try:
sys.stderr.write(f"Logging failed for job {job_id}: {message}\n")
traceback.print_exc(file=sys.stderr)
except Exception:
pass
class JobStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
@dataclass
class JobLog:
timestamp: float
message: str
level: str = "info"
@dataclass
class JobResult:
audio_path: Optional[Path] = None
subtitle_paths: List[Path] = field(default_factory=list)
artifacts: Dict[str, Path] = field(default_factory=dict)
epub_path: Optional[Path] = None
@dataclass
class Job:
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
created_at: float
tts_provider: str = "kokoro"
supertonic_total_steps: int = 8
save_chapters_separately: bool = False
merge_chapters_at_end: bool = True
separate_chapters_format: str = "wav"
silence_between_chapters: float = 2.0
save_as_project: bool = False
voice_profile: Optional[str] = None
metadata_tags: Dict[str, str] = field(default_factory=dict)
max_subtitle_words: int = 50
chapter_intro_delay: float = 0.5
read_title_intro: bool = False
read_closing_outro: bool = True
auto_prefix_chapter_titles: bool = True
normalize_chapter_opening_caps: bool = True
status: JobStatus = JobStatus.PENDING
started_at: Optional[float] = None
finished_at: Optional[float] = None
progress: float = 0.0
total_characters: int = 0
processed_characters: int = 0
logs: List[JobLog] = field(default_factory=list)
error: Optional[str] = None
result: JobResult = field(default_factory=JobResult)
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
chunk_level: str = "paragraph"
chunks: List[Dict[str, Any]] = field(default_factory=list)
speakers: Dict[str, Any] = field(default_factory=dict)
speaker_mode: str = "single"
generate_epub3: bool = False
speaker_analysis: Dict[str, Any] = field(default_factory=dict)
speaker_analysis_threshold: int = 3
analysis_requested: bool = False
entity_summary: Dict[str, Any] = field(default_factory=dict)
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
normalization_overrides: Dict[str, Any] = field(default_factory=dict)
speaker_voice_languages: List[str] = field(default_factory=list)
applied_speaker_config: Optional[str] = None
@property
def estimated_time_remaining(self) -> Optional[float]:
"""
Returns the estimated seconds remaining based on current progress and elapsed time.
Returns None if the job hasn't started, is finished, or progress is 0.
"""
if self.status != JobStatus.RUNNING or not self.started_at or self.progress <= 0:
return None
elapsed = time.time() - self.started_at
if elapsed <= 0:
return None
# Estimate total time based on current progress
total_estimated = elapsed / self.progress
remaining = total_estimated - elapsed
return max(0.0, remaining)
def add_log(self, message: str, level: str = "info") -> None:
entry = JobLog(timestamp=time.time(), message=message, level=level)
self.logs.append(entry)
_emit_job_log(self.id, level, message)
def as_dict(self) -> Dict[str, object]:
return {
"id": self.id,
"original_filename": self.original_filename,
"status": self.status.value,
"use_gpu": self.use_gpu,
"created_at": self.created_at,
"started_at": self.started_at,
"finished_at": self.finished_at,
"progress": self.progress,
"total_characters": self.total_characters,
"processed_characters": self.processed_characters,
"error": self.error,
"logs": [log.__dict__ for log in self.logs],
"result": {
"audio": str(self.result.audio_path) if self.result.audio_path else None,
"subtitles": [str(path) for path in self.result.subtitle_paths],
"artifacts": {key: str(path) for key, path in self.result.artifacts.items()},
},
"queue_position": self.queue_position,
"options": {
"tts_provider": getattr(self, "tts_provider", "kokoro"),
"supertonic_total_steps": getattr(self, "supertonic_total_steps", 8,
"save_chapters_separately": self.save_chapters_separately,
"merge_chapters_at_end": self.merge_chapters_at_end,
"separate_chapters_format": self.separate_chapters_format,
"silence_between_chapters": self.silence_between_chapters,
"save_as_project": self.save_as_project,
"voice_profile": self.voice_profile,
"max_subtitle_words": self.max_subtitle_words,
"chapter_intro_delay": self.chapter_intro_delay,
"read_title_intro": getattr(self, "read_title_intro", False),
"read_closing_outro": getattr(self, "read_closing_outro", True),
"auto_prefix_chapter_titles": getattr(self, "auto_prefix_chapter_titles", True),
"normalize_chapter_opening_caps": getattr(self, "normalize_chapter_opening_caps", True),
},
"metadata_tags": dict(self.metadata_tags),
"chapters": [
{
"id": entry.get("id"),
"index": entry.get("index"),
"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
],
"chunk_level": self.chunk_level,
"chunks": [dict(chunk) for chunk in self.chunks],
"speakers": dict(self.speakers),
"speaker_mode": self.speaker_mode,
"generate_epub3": self.generate_epub3,
"speaker_analysis": dict(self.speaker_analysis),
"speaker_analysis_threshold": self.speaker_analysis_threshold,
"analysis_requested": self.analysis_requested,
"speaker_voice_languages": list(self.speaker_voice_languages),
"applied_speaker_config": self.applied_speaker_config,
"entity_summary": dict(self.entity_summary),
"manual_overrides": [dict(entry) for entry in self.manual_overrides],
"pronunciation_overrides": [dict(entry) for entry in self.pronunciation_overrides],
"heteronym_overrides": [dict(entry) for entry in self.heteronym_overrides],
"normalization_overrides": dict(self.normalization_overrides),
}
def _normalize_metadata_casefold(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
normalized: Dict[str, Any] = {}
if not values:
return normalized
for key, value in values.items():
if value is None:
continue
key_text = str(key).strip().lower()
if not key_text:
continue
if isinstance(value, (list, tuple, set)):
normalized[key_text] = value
else:
text = str(value).strip()
if text:
normalized[key_text] = text
return normalized
def _split_people_field(raw: Any) -> List[str]:
if raw is None:
return []
if isinstance(raw, (list, tuple, set)):
results: List[str] = []
for item in raw:
results.extend(_split_people_field(item))
return results
text = str(raw or "").strip()
if not text:
return []
tokens = [_token.strip() for _token in _PEOPLE_SPLIT_RE.split(text) if _token.strip()]
seen: set[str] = set()
ordered: List[str] = []
for token in tokens:
key = token.casefold()
if key in seen:
continue
seen.add(key)
ordered.append(token)
return ordered
_LIST_SPLIT_RE = re.compile(r"[;,\n]")
_SERIES_SEQUENCE_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?")
_SERIES_SEQUENCE_TAG_KEYS: Tuple[str, ...] = (
"series_index",
"series_position",
"series_sequence",
"series_number",
"seriesnumber",
"book_number",
"booknumber",
)
def _split_simple_list(raw: Any) -> List[str]:
if raw is None:
return []
if isinstance(raw, (list, tuple, set)):
results: List[str] = []
for item in raw:
results.extend(_split_simple_list(item))
return results
text = str(raw or "").strip()
if not text:
return []
tokens = [_token.strip() for _token in _LIST_SPLIT_RE.split(text) if _token.strip()]
seen: set[str] = set()
ordered: List[str] = []
for token in tokens:
key = token.casefold()
if key in seen:
continue
seen.add(key)
ordered.append(token)
return ordered
def _first_nonempty(*values: Any) -> Optional[str]:
for value in values:
if value is None:
continue
if isinstance(value, (list, tuple, set)):
items = list(value)
if not items:
continue
value = items[0]
text = str(value).strip()
if text:
return text
return None
def _extract_year(raw: Optional[str]) -> Optional[int]:
if not raw:
return None
text = str(raw).strip()
if not text:
return None
match = re.search(r"(19|20)\d{2}", text)
if match:
try:
return int(match.group(0))
except ValueError:
return None
try:
parsed = int(text)
except ValueError:
return None
if 0 < parsed < 3000:
return parsed
return None
def build_audiobookshelf_metadata(job: Job) -> Dict[str, Any]:
tags = _normalize_metadata_casefold(job.metadata_tags)
filename = Path(job.original_filename or "").stem or job.original_filename or "Audiobook"
title = _first_nonempty(
tags.get("title"),
tags.get("book_title"),
tags.get("name"),
tags.get("album"),
filename,
)
authors = _split_people_field(
tags.get("authors")
or tags.get("author")
or tags.get("album_artist")
or tags.get("artist")
)
narrators = _split_people_field(tags.get("narrators") or tags.get("narrator"))
description = _first_nonempty(tags.get("description"), tags.get("summary"), tags.get("comment"))
genres = _split_simple_list(tags.get("genre"))
keywords = _split_simple_list(tags.get("tags") or tags.get("keywords"))
language = _first_nonempty(tags.get("language"), tags.get("lang")) or job.language or ""
series_name = _first_nonempty(
tags.get("series"),
tags.get("series_name"),
tags.get("seriesname"),
tags.get("series_title"),
tags.get("seriestitle"),
)
series_sequence = None
for key in _SERIES_SEQUENCE_TAG_KEYS:
raw_value = tags.get(key)
normalized_sequence = _normalize_series_sequence(raw_value)
if normalized_sequence:
series_sequence = normalized_sequence
break
if not series_name:
series_sequence = None
data: Dict[str, Any] = {
"title": title,
"subtitle": tags.get("subtitle"),
"authors": authors,
"narrators": narrators,
"description": description,
"publisher": tags.get("publisher"),
"genres": genres,
"tags": keywords,
"language": language,
"publishedYear": _extract_year(tags.get("published") or tags.get("publication_year") or tags.get("date") or tags.get("year")),
"seriesName": series_name,
"seriesSequence": series_sequence,
"isbn": _first_nonempty(tags.get("isbn"), tags.get("asin")),
}
published_date = _first_nonempty(tags.get("published"), tags.get("publication_date"), tags.get("date"))
if published_date:
data["publishedDate"] = published_date
rating_text = _first_nonempty(tags.get("rating"), tags.get("my_rating"))
if rating_text:
try:
data["rating"] = float(str(rating_text).strip())
except ValueError:
pass
rating_max_text = _first_nonempty(tags.get("rating_max"), tags.get("rating_scale"))
if rating_max_text:
try:
data["ratingMax"] = float(str(rating_max_text).strip())
except ValueError:
pass
# Remove empty values
cleaned: Dict[str, Any] = {}
for key, value in data.items():
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
if isinstance(value, (list, tuple)) and not value:
continue
cleaned[key] = value
return cleaned
def _normalize_series_sequence(raw: Any) -> Optional[str]:
if raw is None:
return None
if isinstance(raw, (int, float)):
if isinstance(raw, float) and (math.isnan(raw) or math.isinf(raw)):
return None
text = str(raw)
else:
text = str(raw).strip()
if not text:
return None
candidate = text.replace(",", ".")
match = _SERIES_SEQUENCE_NUMBER_RE.search(candidate)
if not match:
return None
normalized = match.group(0)
if "." in normalized:
normalized = normalized.rstrip("0").rstrip(".")
if not normalized:
normalized = "0"
return normalized
try:
return str(int(normalized))
except ValueError:
cleaned = normalized.lstrip("0")
return cleaned or "0"
def load_audiobookshelf_chapters(job: Job) -> Optional[List[Dict[str, Any]]]:
metadata_ref = job.result.artifacts.get("metadata")
if not metadata_ref:
return None
metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref))
if not metadata_path.exists():
return None
try:
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
chapters = payload.get("chapters")
if not isinstance(chapters, list):
return None
cleaned: List[Dict[str, Any]] = []
for entry in chapters:
if not isinstance(entry, Mapping):
continue
title = _first_nonempty(entry.get("title"), entry.get("original_title"))
start = entry.get("start")
end = entry.get("end")
if title is None or not isinstance(start, (int, float)):
continue
chapter_payload: Dict[str, Any] = {
"title": title,
"start": float(start),
}
if isinstance(end, (int, float)):
chapter_payload["end"] = float(end)
cleaned.append(chapter_payload)
return cleaned or None
def _existing_paths(paths: Iterable[Any]) -> List[Path]:
resolved: List[Path] = []
for item in paths:
candidate = item if isinstance(item, Path) else Path(str(item))
if candidate.exists():
resolved.append(candidate)
return resolved
@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]]
normalization_overrides: Dict[str, Any]
created_at: float
tts_provider: str = "kokoro"
supertonic_total_steps: int = 8
cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None
chapter_intro_delay: float = 0.5
read_title_intro: bool = False
read_closing_outro: bool = True
auto_prefix_chapter_titles: bool = True
normalize_chapter_opening_caps: bool = True
chunk_level: str = "paragraph"
chunks: List[Dict[str, Any]] = field(default_factory=list)
speakers: Dict[str, Any] = field(default_factory=dict)
speaker_mode: str = "single"
generate_epub3: bool = False
speaker_analysis: Dict[str, Any] = field(default_factory=dict)
speaker_analysis_threshold: int = 3
analysis_requested: bool = False
speaker_voice_languages: List[str] = field(default_factory=list)
applied_speaker_config: Optional[str] = None
entity_summary: Dict[str, Any] = field(default_factory=dict)
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
entity_cache_key: Optional[str] = None
wizard_max_step_index: int = 0
class ConversionService:
def __init__(
self,
output_root: Path,
runner: Callable[[Job], None],
*,
uploads_root: Optional[Path] = None,
poll_interval: float = 0.5,
) -> None:
self._jobs: Dict[str, Job] = {}
self._queue: List[str] = []
self._lock = threading.RLock()
self._worker_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._wake_event = threading.Event()
self._output_root = output_root
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 = self._determine_state_path()
self._ensure_directories()
self._bootstrap_voice_cache()
self._load_state()
# Public API ---------------------------------------------------------
def list_jobs(self) -> List[Job]:
with self._lock:
return sorted(self._jobs.values(), key=lambda job: job.created_at, reverse=True)
def get_job(self, job_id: str) -> Optional[Job]:
with self._lock:
return self._jobs.get(job_id)
def enqueue(
self,
*,
original_filename: str,
stored_path: Path,
language: str,
voice: str,
speed: float,
tts_provider: str = "kokoro",
supertonic_total_steps: int = 8,
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,
chapters: Optional[Iterable[Any]] = None,
save_chapters_separately: bool = False,
merge_chapters_at_end: bool = True,
separate_chapters_format: str = "wav",
silence_between_chapters: float = 2.0,
save_as_project: bool = False,
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,
chapter_intro_delay: float = 0.5,
read_title_intro: bool = False,
read_closing_outro: bool = True,
auto_prefix_chapter_titles: bool = True,
normalize_chapter_opening_caps: bool = True,
chunk_level: str = "paragraph",
chunks: Optional[Iterable[Any]] = None,
speakers: Optional[Mapping[str, Any]] = None,
speaker_mode: str = "single",
generate_epub3: bool = False,
speaker_analysis: Optional[Mapping[str, Any]] = None,
speaker_analysis_threshold: int = 3,
analysis_requested: bool = False,
entity_summary: Optional[Mapping[str, Any]] = None,
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
heteronym_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
normalization_overrides: Optional[Mapping[str, Any]] = None,
) -> Job:
job_id = uuid.uuid4().hex
normalized_metadata = self._normalize_metadata_tags(metadata_tags)
normalized_chapters = self._normalize_chapters(chapters)
normalized_chunks = self._normalize_chunks(chunks)
if total_characters <= 0 and normalized_chapters:
total_characters = sum(len(str(entry.get("text", ""))) for entry in normalized_chapters)
job = Job(
id=job_id,
original_filename=original_filename,
stored_path=stored_path,
language=language,
voice=voice,
speed=speed,
tts_provider=tts_provider,
supertonic_total_steps=int(supertonic_total_steps or 8),
use_gpu=use_gpu,
subtitle_mode=subtitle_mode,
output_format=output_format,
save_mode=save_mode,
output_folder=output_folder,
replace_single_newlines=replace_single_newlines,
subtitle_format=subtitle_format,
save_chapters_separately=save_chapters_separately,
merge_chapters_at_end=merge_chapters_at_end,
separate_chapters_format=separate_chapters_format,
silence_between_chapters=silence_between_chapters,
save_as_project=save_as_project,
voice_profile=voice_profile,
max_subtitle_words=max_subtitle_words,
metadata_tags=normalized_metadata,
created_at=time.time(),
total_characters=total_characters,
chapters=normalized_chapters,
cover_image_path=cover_image_path,
cover_image_mime=cover_image_mime,
chapter_intro_delay=chapter_intro_delay,
read_title_intro=bool(read_title_intro),
read_closing_outro=bool(read_closing_outro),
auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps),
chunk_level=chunk_level,
chunks=normalized_chunks,
speakers=dict(speakers or {}),
speaker_mode=speaker_mode,
generate_epub3=bool(generate_epub3),
speaker_analysis=dict(speaker_analysis or {}),
speaker_analysis_threshold=int(speaker_analysis_threshold or 3),
analysis_requested=bool(analysis_requested),
entity_summary=dict(entity_summary or {}),
manual_overrides=[dict(entry) for entry in manual_overrides] if manual_overrides else [],
pronunciation_overrides=[dict(entry) for entry in pronunciation_overrides] if pronunciation_overrides else [],
heteronym_overrides=[dict(entry) for entry in heteronym_overrides] if heteronym_overrides else [],
normalization_overrides=dict(normalization_overrides or {}),
)
with self._lock:
self._jobs[job_id] = job
self._queue.append(job_id)
self._update_queue_positions_locked()
self._wake_event.set()
self._ensure_worker()
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)
if job is None:
return False
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 retry(self, job_id: str) -> Optional[Job]:
with self._lock:
job = self._jobs.get(job_id)
if job is None:
return None
if job.status not in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
job.add_log(
"Retry requested while job still active; ignoring.",
level="warning",
)
return None
stored_path = job.stored_path
if not isinstance(stored_path, Path):
stored_path = Path(str(stored_path))
if not stored_path.exists():
job.add_log(
f"Retry requested but source file is missing: {stored_path}",
level="error",
)
return None
new_job = self.enqueue(
original_filename=job.original_filename,
stored_path=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=job.output_folder,
replace_single_newlines=job.replace_single_newlines,
subtitle_format=job.subtitle_format,
total_characters=job.total_characters,
chapters=job.chapters,
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,
max_subtitle_words=job.max_subtitle_words,
metadata_tags=job.metadata_tags,
cover_image_path=job.cover_image_path,
cover_image_mime=job.cover_image_mime,
chapter_intro_delay=job.chapter_intro_delay,
read_title_intro=job.read_title_intro,
auto_prefix_chapter_titles=job.auto_prefix_chapter_titles,
normalize_chapter_opening_caps=job.normalize_chapter_opening_caps,
chunk_level=job.chunk_level,
chunks=job.chunks,
speakers=job.speakers,
speaker_mode=job.speaker_mode,
generate_epub3=job.generate_epub3,
speaker_analysis=job.speaker_analysis,
speaker_analysis_threshold=job.speaker_analysis_threshold,
analysis_requested=job.analysis_requested,
entity_summary=job.entity_summary,
manual_overrides=job.manual_overrides,
pronunciation_overrides=job.pronunciation_overrides,
normalization_overrides=job.normalization_overrides,
)
new_job.speaker_voice_languages = list(job.speaker_voice_languages)
new_job.applied_speaker_config = job.applied_speaker_config
new_job.add_log(f"Retry created from job {job.id}", level="info")
job.add_log(f"Retry scheduled as job {new_job.id}", level="info")
self._remove_job_locked(job_id)
return new_job
def delete(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.RUNNING}:
return False
self._jobs.pop(job_id)
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:
finished_statuses = set(statuses or {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED})
removed = 0
with self._lock:
# Remove any queued entries first to avoid stale references
filtered_queue: List[str] = []
for job_id in self._queue:
job = self._jobs.get(job_id)
if job and job.status in finished_statuses:
continue
filtered_queue.append(job_id)
self._queue = filtered_queue
for job_id, job in list(self._jobs.items()):
if job.status in finished_statuses:
self._jobs.pop(job_id)
removed += 1
if removed:
self._update_queue_positions_locked()
self._persist_state()
return removed
def shutdown(self) -> None:
self._stop_event.set()
self._wake_event.set()
if self._worker_thread and self._worker_thread.is_alive():
self._worker_thread.join(timeout=5)
self._worker_thread = None
# Internal -----------------------------------------------------------
def _ensure_directories(self) -> None:
self._output_root.mkdir(parents=True, exist_ok=True)
self._uploads_root.mkdir(parents=True, exist_ok=True)
self._state_path.parent.mkdir(parents=True, exist_ok=True)
def _bootstrap_voice_cache(self) -> None:
try:
downloaded, errors = bootstrap_voice_cache(
on_progress=lambda msg: _JOB_LOGGER.debug("[voice cache] %s", msg)
)
except RuntimeError as exc:
_JOB_LOGGER.warning("Voice cache bootstrap skipped: %s", exc)
return
if downloaded:
count = len(downloaded)
suffix = "s" if count != 1 else ""
_JOB_LOGGER.info("Voice cache ready: downloaded %d new asset%s.", count, suffix)
if errors:
for voice_id, message in errors.items():
_JOB_LOGGER.warning("Voice cache failed for %s: %s", voice_id, message)
def _ensure_worker(self) -> None:
with self._lock:
if self._worker_thread and self._worker_thread.is_alive():
return
self._stop_event.clear()
self._worker_thread = threading.Thread(
target=self._worker_loop,
name="abogen-conversion-worker",
daemon=True,
)
self._worker_thread.start()
def _worker_loop(self) -> None:
while not self._stop_event.is_set():
job = None
with self._lock:
self._wake_event.clear()
while self._queue and self._jobs[self._queue[0]].status in {
JobStatus.CANCELLED,
JobStatus.COMPLETED,
JobStatus.FAILED,
}:
self._queue.pop(0)
if self._queue:
job = self._jobs[self._queue.pop(0)]
else:
self._update_queue_positions_locked()
if job is None:
self._wake_event.wait(timeout=self._poll_interval)
continue
if job.cancel_requested:
job.add_log("Job cancelled before start", level="warning")
job.status = JobStatus.CANCELLED
job.finished_at = time.time()
continue
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
job.error = str(exc)
job.status = JobStatus.FAILED
job.finished_at = time.time()
exc_type = exc.__class__.__name__
job.add_log(f"Job failed ({exc_type}): {exc}", level="error")
tb_lines = traceback.format_exception(exc.__class__, exc, exc.__traceback__)
for line in tb_lines[:20]:
trimmed = line.rstrip()
if trimmed:
for snippet in trimmed.splitlines():
job.add_log(f"TRACE: {snippet}", level="debug")
else:
if job.cancel_requested:
job.status = JobStatus.CANCELLED
job.add_log("Job cancelled", level="warning")
elif job.status != JobStatus.FAILED:
job.status = JobStatus.COMPLETED
job.add_log("Job completed", level="success")
self._post_completion_hooks(job)
job.finished_at = time.time()
finally:
job.pause_event.set()
self._persist_state()
with self._lock:
self._update_queue_positions_locked()
def _update_queue_positions_locked(self) -> None:
for index, job_id in enumerate(self._queue, start=1):
job = self._jobs.get(job_id)
if job:
job.queue_position = index
self._persist_state()
def _remove_job_locked(self, job_id: str) -> None:
self._jobs.pop(job_id, None)
if job_id in self._queue:
self._queue.remove(job_id)
self._update_queue_positions_locked()
def _post_completion_hooks(self, job: Job) -> None:
try:
self._maybe_send_to_audiobookshelf(job)
except AudiobookshelfUploadError as exc:
job.add_log(f"Audiobookshelf upload failed: {exc}", level="error")
except Exception as exc: # pragma: no cover - defensive guard
job.add_log(f"Audiobookshelf integration error: {exc}", level="error")
def _maybe_send_to_audiobookshelf(self, job: Job) -> None:
cfg = load_config() or {}
integration_cfg = cfg.get("audiobookshelf")
if not isinstance(integration_cfg, Mapping):
return
enabled = self._coerce_bool(integration_cfg.get("enabled"), False)
auto_send = self._coerce_bool(integration_cfg.get("auto_send"), False)
if not (enabled and auto_send):
return
base_url = str(integration_cfg.get("base_url") or "").strip()
api_token = str(integration_cfg.get("api_token") or "").strip()
library_id = str(integration_cfg.get("library_id") or "").strip()
folder_id = str(integration_cfg.get("folder_id") or "").strip()
if not base_url or not api_token or not library_id:
job.add_log(
"Audiobookshelf upload skipped: configure base URL, API token, and library ID first.",
level="warning",
)
return
if not folder_id:
job.add_log(
"Audiobookshelf upload skipped: enter the folder name or ID in the Audiobookshelf settings.",
level="warning",
)
return
audio_ref = job.result.audio_path
audio_path = audio_ref if isinstance(audio_ref, Path) else Path(str(audio_ref)) if audio_ref else None
if not audio_path or not audio_path.exists():
job.add_log("Audiobookshelf upload skipped: audio output not found.", level="warning")
return
timeout_raw = integration_cfg.get("timeout", 3600.0)
try:
timeout_value = float(timeout_raw)
except (TypeError, ValueError):
timeout_value = 3600.0
config = AudiobookshelfConfig(
base_url=base_url,
api_token=api_token,
library_id=library_id,
collection_id=(str(integration_cfg.get("collection_id") or "").strip() or None),
folder_id=folder_id,
verify_ssl=self._coerce_bool(integration_cfg.get("verify_ssl"), True),
send_cover=self._coerce_bool(integration_cfg.get("send_cover"), True),
send_chapters=self._coerce_bool(integration_cfg.get("send_chapters"), True),
send_subtitles=self._coerce_bool(integration_cfg.get("send_subtitles"), False),
timeout=timeout_value,
)
cover_ref = job.cover_image_path
cover_path = None
if config.send_cover and cover_ref:
cover_candidate = cover_ref if isinstance(cover_ref, Path) else Path(str(cover_ref))
if cover_candidate.exists():
cover_path = cover_candidate
subtitles = _existing_paths(job.result.subtitle_paths) if config.send_subtitles else None
chapters = load_audiobookshelf_chapters(job) if config.send_chapters else None
metadata = build_audiobookshelf_metadata(job)
client = AudiobookshelfClient(config)
display_title = metadata.get("title") or audio_path.stem
try:
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
except AudiobookshelfUploadError as exc:
job.add_log(f"Audiobookshelf lookup failed: {exc}", level="error")
return
if existing_items:
job.add_log(
f"Removing existing Audiobookshelf item(s) for '{display_title}' before upload.",
level="info",
)
try:
client.delete_items(existing_items)
except Exception as exc:
job.add_log(f"Failed to remove existing item(s): {exc}", level="warning")
client.upload_audiobook(
audio_path,
metadata=metadata,
cover_path=cover_path,
chapters=chapters,
subtitles=subtitles,
)
job.add_log("Audiobookshelf upload queued.", level="info")
# 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()}
result_epub = str(job.result.epub_path) if job.result.epub_path else None
return {
"id": job.id,
"original_filename": job.original_filename,
"stored_path": str(job.stored_path),
"language": job.language,
"tts_provider": getattr(job, "tts_provider", "kokoro"),
"voice": job.voice,
"speed": job.speed,
"supertonic_total_steps": getattr(job, "supertonic_total_steps", 8,
"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,
"epub_path": result_epub,
},
"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,
"chapter_intro_delay": job.chapter_intro_delay,
"read_title_intro": job.read_title_intro,
"auto_prefix_chapter_titles": job.auto_prefix_chapter_titles,
"normalize_chapter_opening_caps": job.normalize_chapter_opening_caps,
"chunk_level": job.chunk_level,
"chunks": [dict(entry) for entry in job.chunks],
"speakers": dict(job.speakers),
"speaker_mode": job.speaker_mode,
"generate_epub3": job.generate_epub3,
"speaker_analysis": dict(job.speaker_analysis),
"speaker_analysis_threshold": job.speaker_analysis_threshold,
"analysis_requested": job.analysis_requested,
"entity_summary": dict(job.entity_summary),
"manual_overrides": [dict(entry) for entry in job.manual_overrides],
"pronunciation_overrides": [dict(entry) for entry in job.pronunciation_overrides],
"heteronym_overrides": [dict(entry) for entry in job.heteronym_overrides],
"normalization_overrides": dict(job.normalization_overrides),
}
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 _determine_state_path(self) -> Path:
override_file = os.environ.get("ABOGEN_QUEUE_STATE_PATH")
if override_file:
target_path = Path(override_file).expanduser()
target_path.parent.mkdir(parents=True, exist_ok=True)
return target_path
override_dir = os.environ.get("ABOGEN_QUEUE_STATE_DIR")
if override_dir:
base_dir = Path(override_dir).expanduser()
else:
settings_override = os.environ.get("ABOGEN_SETTINGS_DIR")
if settings_override:
base_dir = Path(settings_override).expanduser() / "queue"
else:
try:
base_dir = Path(get_user_settings_dir()) / "queue"
except ModuleNotFoundError:
base_dir = Path(get_internal_cache_path("jobs"))
base_dir.mkdir(parents=True, exist_ok=True)
target_path = base_dir / "queue_state.json"
legacy_path = Path(get_internal_cache_path("jobs")) / "queue_state.json"
if legacy_path.exists() and not target_path.exists():
try:
shutil.move(str(legacy_path), target_path)
except Exception:
try:
shutil.copy2(str(legacy_path), target_path)
except Exception:
pass
return target_path
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"),
tts_provider=str(payload.get("tts_provider") or "kokoro"),
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())),
supertonic_total_steps=int(payload.get("supertonic_total_steps", 8),
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)),
chapter_intro_delay=float(payload.get("chapter_intro_delay", 0.5)),
read_title_intro=bool(payload.get("read_title_intro", False)),
auto_prefix_chapter_titles=bool(payload.get("auto_prefix_chapter_titles", True)),
normalize_chapter_opening_caps=bool(payload.get("normalize_chapter_opening_caps", True)),
)
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()
}
epub_path_raw = result_payload.get("epub_path")
job.result.epub_path = Path(epub_path_raw) if epub_path_raw else None
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.chunk_level = str(payload.get("chunk_level", job.chunk_level or "paragraph"))
job.chunks = self._normalize_chunks(payload.get("chunks"))
job.speakers = dict(payload.get("speakers", {}))
job.speaker_mode = str(payload.get("speaker_mode", job.speaker_mode or "single"))
job.generate_epub3 = bool(payload.get("generate_epub3", job.generate_epub3))
job.speaker_analysis = payload.get("speaker_analysis", {})
job.speaker_analysis_threshold = int(
payload.get("speaker_analysis_threshold", job.speaker_analysis_threshold or 3)
)
job.analysis_requested = bool(payload.get("analysis_requested", job.analysis_requested))
job.entity_summary = payload.get("entity_summary", {})
job.manual_overrides = [dict(entry) for entry in payload.get("manual_overrides", []) if isinstance(entry, Mapping)]
job.pronunciation_overrides = [
dict(entry) for entry in payload.get("pronunciation_overrides", []) if isinstance(entry, Mapping)
]
job.heteronym_overrides = [
dict(entry) for entry in payload.get("heteronym_overrides", []) if isinstance(entry, Mapping)
]
job.normalization_overrides = dict(payload.get("normalization_overrides", {}) or {})
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
version = int(payload.get("version", 0) or 0)
if version not in {STATE_VERSION, STATE_VERSION - 1}:
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:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"true", "1", "yes", "on"}:
return True
if lowered in {"false", "0", "no", "off"}:
return False
return default
if value is None:
return default
return bool(value)
@staticmethod
def _coerce_optional_int(value: Any) -> Optional[int]:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _normalize_metadata_tags(values: Optional[Mapping[str, Any]]) -> Dict[str, str]:
if not values:
return {}
normalized: Dict[str, str] = {}
for key, raw_value in values.items():
if raw_value is None:
continue
key_str = str(key).strip()
if not key_str:
continue
normalized[key_str] = str(raw_value)
return normalized
@classmethod
def _normalize_chapters(cls, chapters: Optional[Iterable[Any]]) -> List[Dict[str, Any]]:
if not chapters:
return []
normalized: List[Dict[str, Any]] = []
for order, raw in enumerate(chapters):
if raw is None:
continue
if isinstance(raw, str):
raw_dict: Dict[str, Any] = {"title": raw}
elif isinstance(raw, dict):
raw_dict = dict(raw)
else:
continue
entry: Dict[str, Any] = {}
id_value = raw_dict.get("id") or raw_dict.get("chapter_id") or raw_dict.get("key")
if id_value is not None:
entry["id"] = str(id_value)
index_value = (
cls._coerce_optional_int(raw_dict.get("index"))
or cls._coerce_optional_int(raw_dict.get("original_index"))
or cls._coerce_optional_int(raw_dict.get("source_index"))
or cls._coerce_optional_int(raw_dict.get("chapter_index"))
)
if index_value is not None:
entry["index"] = index_value
order_value = (
cls._coerce_optional_int(raw_dict.get("order"))
or cls._coerce_optional_int(raw_dict.get("position"))
or cls._coerce_optional_int(raw_dict.get("sort"))
or cls._coerce_optional_int(raw_dict.get("sort_order"))
)
entry["order"] = order_value if order_value is not None else order
source_title = (
raw_dict.get("source_title")
or raw_dict.get("original_title")
or raw_dict.get("base_title")
)
if source_title:
entry["source_title"] = str(source_title)
title_value = (
raw_dict.get("title")
or raw_dict.get("name")
or raw_dict.get("label")
or raw_dict.get("chapter")
)
if title_value is not None:
entry["title"] = str(title_value)
elif source_title:
entry["title"] = str(source_title)
else:
entry["title"] = f"Chapter {order + 1}"
text_value = raw_dict.get("text")
if text_value is None:
text_value = raw_dict.get("content") or raw_dict.get("body") or raw_dict.get("value")
if text_value is not None:
entry["text"] = str(text_value)
enabled = cls._coerce_bool(
raw_dict.get("enabled", raw_dict.get("include", raw_dict.get("selected", True))),
True,
)
if "disabled" in raw_dict and cls._coerce_bool(raw_dict.get("disabled"), False):
enabled = False
entry["enabled"] = enabled
metadata_payload = raw_dict.get("metadata") or raw_dict.get("metadata_tags")
normalized_metadata = cls._normalize_metadata_tags(metadata_payload)
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
@classmethod
def _normalize_chunks(cls, chunks: Optional[Iterable[Any]]) -> List[Dict[str, Any]]:
if not chunks:
return []
normalized: List[Dict[str, Any]] = []
for order, raw in enumerate(chunks):
if raw is None:
continue
if isinstance(raw, dict):
entry = dict(raw)
else:
continue
chunk: Dict[str, Any] = {}
identifier = entry.get("id") or entry.get("chunk_id")
if identifier is not None:
chunk["id"] = str(identifier)
try:
chunk_index = int(entry.get("chunk_index", order))
except (TypeError, ValueError):
chunk_index = order
chunk["chunk_index"] = chunk_index
try:
chapter_index = int(entry.get("chapter_index", 0))
except (TypeError, ValueError):
chapter_index = 0
chunk["chapter_index"] = chapter_index
level_raw = str(entry.get("level", "paragraph")).lower()
if level_raw not in {"paragraph", "sentence"}:
level_raw = "paragraph"
chunk["level"] = level_raw
text_value = entry.get("text")
if text_value is not None:
chunk["text"] = str(text_value)
else:
chunk["text"] = ""
normalized_value = entry.get("normalized_text")
if normalized_value is not None:
chunk["normalized_text"] = str(normalized_value)
for text_key in ("display_text", "original_text"):
if text_key in entry and entry[text_key] is not None:
chunk[text_key] = str(entry[text_key])
speaker_value = entry.get("speaker_id", entry.get("speaker"))
chunk["speaker_id"] = str(speaker_value) if speaker_value else "narrator"
for key in ("voice", "voice_profile", "voice_formula", "audio_path", "start", "end"):
if key in entry and entry[key] is not None:
chunk[key] = entry[key]
normalized.append(chunk)
return normalized
def default_storage_root() -> Path:
base = Path.cwd()
uploads = base / "var" / "uploads"
outputs = base / "var" / "outputs"
outputs.mkdir(parents=True, exist_ok=True)
uploads.mkdir(parents=True, exist_ok=True)
return outputs
def build_service(
runner: Callable[[Job], None],
*,
output_root: Optional[Path] = None,
uploads_root: Optional[Path] = None,
) -> ConversionService:
output_root = output_root or default_storage_root()
service = ConversionService(
output_root=output_root,
uploads_root=uploads_root,
runner=runner,
)
return service