feat: Add support for series metadata and closing outro in audio conversion

This commit is contained in:
JB
2025-10-30 12:43:24 -07:00
parent 688d550f13
commit fe5419565d
13 changed files with 495 additions and 65 deletions
+155 -62
View File
@@ -310,6 +310,82 @@ def _ensure_sentence(text: str) -> str:
return f"{cleaned}."
_SERIES_NAME_KEYS = (
"series",
"series_name",
"series_title",
)
_SERIES_NUMBER_KEYS = (
"series_index",
"series_position",
"series_sequence",
"book_number",
"series_number",
)
_SERIES_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?")
def _normalize_series_number(value: Any) -> Optional[str]:
text = str(value or "").strip()
if not text:
return None
candidate = text.replace(",", ".")
if candidate.replace(".", "", 1).isdigit():
if "." in candidate:
normalized = candidate.rstrip("0").rstrip(".")
return normalized or "0"
try:
return str(int(candidate))
except ValueError:
pass
match = _SERIES_NUMBER_RE.search(candidate)
if not match:
return None
normalized = match.group(0)
if "." in normalized:
normalized = normalized.rstrip("0").rstrip(".")
return normalized or "0"
try:
return str(int(normalized))
except ValueError:
return normalized
def _extract_series_metadata(values: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]:
series_name: Optional[str] = None
for key in _SERIES_NAME_KEYS:
raw = values.get(key)
if raw:
cleaned = str(raw).strip()
if cleaned:
series_name = cleaned
break
series_number: Optional[str] = None
for key in _SERIES_NUMBER_KEYS:
raw = values.get(key)
if raw is None:
continue
normalized = _normalize_series_number(raw)
if normalized:
series_number = normalized
break
return series_name, series_number
def _format_series_sentence(series_name: Optional[str], series_number: Optional[str]) -> str:
if not series_name or not series_number:
return ""
name = series_name.strip()
number = series_number.strip()
if not name or not number:
return ""
article = "the " if not name.lower().startswith("the ") else ""
phrase = f"Book {number} of {article}{name}"
return re.sub(r"\s+", " ", phrase).strip()
def _build_title_intro_text(
metadata: Optional[Mapping[str, Any]],
fallback_basename: str,
@@ -329,7 +405,12 @@ def _build_title_intro_text(
author_value = value
break
series_name, series_number = _extract_series_metadata(normalized)
series_sentence = _format_series_sentence(series_name, series_number)
sentences: List[str] = []
if series_sentence:
sentences.append(_ensure_sentence(series_sentence))
if title:
sentences.append(_ensure_sentence(title))
if subtitle:
@@ -360,13 +441,24 @@ def _build_outro_text(
break
author_sentence = _format_author_sentence(author_value)
authors_fragment = author_sentence[3:].strip() if author_sentence.lower().startswith("by ") else author_sentence.strip()
if title and authors_fragment:
return f"The end of {title} from {authors_fragment}."
if title:
return f"The end of {title}."
if authors_fragment:
return f"The end from {authors_fragment}."
return "The end."
closing_line = f"The end of {title} from {authors_fragment}"
elif title:
closing_line = f"The end of {title}"
elif authors_fragment:
closing_line = f"The end from {authors_fragment}"
else:
closing_line = "The end"
series_name, series_number = _extract_series_metadata(normalized)
series_sentence = _format_series_sentence(series_name, series_number)
sentences: List[str] = [_ensure_sentence(closing_line)]
if series_sentence:
sentences.append(_ensure_sentence(series_sentence))
return " ".join(sentence for sentence in sentences if sentence).strip()
def _spec_to_voice_ids(spec: Any) -> Set[str]:
@@ -1661,66 +1753,67 @@ def run_conversion_job(job: Job) -> None:
marker["original_title"] = raw_title
chapter_markers.append(marker)
outro_text = _build_outro_text(job.metadata_tags, job.original_filename)
outro_voice_spec = base_voice_spec or job.voice
if outro_voice_spec == "__custom_mix":
outro_voice_spec = base_voice_spec or ""
if not outro_voice_spec:
fallback_voice = next(iter(voice_cache.keys()), "")
if fallback_voice and fallback_voice != "__custom_mix":
outro_voice_spec = fallback_voice
if not outro_voice_spec and VOICES_INTERNAL:
outro_voice_spec = VOICES_INTERNAL[0]
if getattr(job, "read_closing_outro", True):
outro_text = _build_outro_text(job.metadata_tags, job.original_filename)
outro_voice_spec = base_voice_spec or job.voice
if outro_voice_spec == "__custom_mix":
outro_voice_spec = base_voice_spec or ""
if not outro_voice_spec:
fallback_voice = next(iter(voice_cache.keys()), "")
if fallback_voice and fallback_voice != "__custom_mix":
outro_voice_spec = fallback_voice
if not outro_voice_spec and VOICES_INTERNAL:
outro_voice_spec = VOICES_INTERNAL[0]
if outro_text and outro_voice_spec:
outro_start_time = current_time
outro_audio_path: Optional[Path] = None
outro_segments = 0
outro_index = total_chapters + 1
outro_voice_choice = voice_cache.get(outro_voice_spec)
if outro_voice_choice is None:
outro_voice_choice = _resolve_voice(pipeline, outro_voice_spec, job.use_gpu)
voice_cache[outro_voice_spec] = outro_voice_choice
if outro_text and outro_voice_spec:
outro_start_time = current_time
outro_audio_path: Optional[Path] = None
outro_segments = 0
outro_index = total_chapters + 1
outro_voice_choice = voice_cache.get(outro_voice_spec)
if outro_voice_choice is None:
outro_voice_choice = _resolve_voice(pipeline, outro_voice_spec, job.use_gpu)
voice_cache[outro_voice_spec] = outro_voice_choice
with ExitStack() as outro_sink_stack:
chapter_sink: Optional[AudioSink] = None
if chapter_dir is not None:
outro_audio_path = _build_output_path(
chapter_dir,
f"{Path(job.original_filename).stem}_outro",
job.separate_chapters_format,
with ExitStack() as outro_sink_stack:
chapter_sink: Optional[AudioSink] = None
if chapter_dir is not None:
outro_audio_path = _build_output_path(
chapter_dir,
f"{Path(job.original_filename).stem}_outro",
job.separate_chapters_format,
)
chapter_sink = _open_audio_sink(
outro_audio_path,
job,
outro_sink_stack,
fmt=job.separate_chapters_format,
)
outro_segments = emit_text(
outro_text,
voice_choice=outro_voice_choice,
chapter_sink=chapter_sink,
preview_prefix="Outro",
)
chapter_sink = _open_audio_sink(
outro_audio_path,
job,
outro_sink_stack,
fmt=job.separate_chapters_format,
outro_end_time = current_time
if outro_segments > 0:
job.add_log(f"Appended outro sequence: {outro_text}")
if outro_audio_path is not None:
job.result.artifacts[f"chapter_{outro_index:02d}"] = outro_audio_path
chapter_paths.append(outro_audio_path)
chapter_markers.append(
{
"index": outro_index,
"title": "Outro",
"start": outro_start_time,
"end": outro_end_time,
"voice": outro_voice_spec,
}
)
outro_segments = emit_text(
outro_text,
voice_choice=outro_voice_choice,
chapter_sink=chapter_sink,
preview_prefix="Outro",
)
outro_end_time = current_time
if outro_segments > 0:
job.add_log(f"Appended outro sequence: {outro_text}")
if outro_audio_path is not None:
job.result.artifacts[f"chapter_{outro_index:02d}"] = outro_audio_path
chapter_paths.append(outro_audio_path)
chapter_markers.append(
{
"index": outro_index,
"title": "Outro",
"start": outro_start_time,
"end": outro_end_time,
"voice": outro_voice_spec,
}
)
else:
job.add_log("No audio generated for outro sequence.", level="warning")
else:
job.add_log("No audio generated for outro sequence.", level="warning")
if not audio_path and chapter_paths:
job.result.audio_path = chapter_paths[0]
+83 -1
View File
@@ -1559,6 +1559,22 @@ def _apply_prepare_form(
elif hasattr(form, "__contains__") and "read_title_intro" in form:
pending.read_title_intro = False
outro_values: List[str] = []
if callable(getter):
raw_outro_values = getter("read_closing_outro")
if raw_outro_values:
outro_values = list(cast(Iterable[str], raw_outro_values))
else:
raw_outro = form.get("read_closing_outro")
if raw_outro is not None:
outro_values = [raw_outro]
if outro_values:
pending.read_closing_outro = _coerce_bool(
outro_values[-1], getattr(pending, "read_closing_outro", True)
)
elif hasattr(form, "__contains__") and "read_closing_outro" in form:
pending.read_closing_outro = False
caps_values: List[str] = []
if callable(getter):
raw_caps_values = getter("normalize_chapter_opening_caps")
@@ -1701,6 +1717,27 @@ def _apply_book_step_form(
else:
pending.read_title_intro = intro_default
outro_default = (
pending.read_closing_outro
if isinstance(getattr(pending, "read_closing_outro", None), bool)
else bool(settings.get("read_closing_outro", True))
)
outro_values: List[str] = []
if callable(getter):
raw_outro_values = getter("read_closing_outro")
if raw_outro_values:
outro_values = list(cast(Iterable[str], raw_outro_values))
else:
raw_outro_flag = form.get("read_closing_outro")
if raw_outro_flag is not None:
outro_values = [raw_outro_flag]
if outro_values:
pending.read_closing_outro = _coerce_bool(outro_values[-1], outro_default)
elif hasattr(form, "__contains__") and "read_closing_outro" in form:
pending.read_closing_outro = False
else:
pending.read_closing_outro = outro_default
caps_default = (
pending.normalize_chapter_opening_caps
if isinstance(getattr(pending, "normalize_chapter_opening_caps", None), bool)
@@ -2051,6 +2088,7 @@ BOOLEAN_SETTINGS = {
"generate_epub3",
"enable_entity_recognition",
"read_title_intro",
"read_closing_outro",
"auto_prefix_chapter_titles",
"normalize_chapter_opening_caps",
"normalization_numbers",
@@ -2121,6 +2159,7 @@ def _settings_defaults() -> Dict[str, Any]:
"silence_between_chapters": 2.0,
"chapter_intro_delay": 0.5,
"read_title_intro": False,
"read_closing_outro": True,
"normalize_chapter_opening_caps": True,
"max_subtitle_words": 50,
"chunk_level": "paragraph",
@@ -2869,6 +2908,28 @@ def calibre_opds_import() -> ResponseReturnValue:
if not href:
return jsonify({"error": "Download link missing."}), 400
metadata_payload = data.get("metadata") if isinstance(data, Mapping) else None
metadata_overrides: Dict[str, Any] = {}
if isinstance(metadata_payload, Mapping):
raw_series = metadata_payload.get("series") or metadata_payload.get("series_name")
series_name = str(raw_series or "").strip()
if series_name:
metadata_overrides["series"] = series_name
metadata_overrides.setdefault("series_name", series_name)
series_index_value = (
metadata_payload.get("series_index")
or metadata_payload.get("series_position")
or metadata_payload.get("series_sequence")
or metadata_payload.get("book_number")
)
if series_index_value is not None:
series_index_text = str(series_index_value).strip()
if series_index_text:
metadata_overrides.setdefault("series_index", series_index_text)
metadata_overrides.setdefault("series_position", series_index_text)
metadata_overrides.setdefault("series_sequence", series_index_text)
metadata_overrides.setdefault("book_number", series_index_text)
stored_settings = _stored_integration_config("calibre_opds")
if not stored_settings or not _coerce_bool(stored_settings.get("enabled"), False):
return jsonify({"error": "Calibre OPDS integration is not enabled."}), 400
@@ -2915,6 +2976,7 @@ def calibre_opds_import() -> ResponseReturnValue:
form=MultiDict(),
settings=settings,
profiles=profiles,
metadata_overrides=metadata_overrides or None,
)
pending = build_result.pending
@@ -4059,10 +4121,27 @@ def _build_pending_job_from_extraction(
metadata_tags = dict(getattr(extraction, "metadata", {}) or {})
if metadata_overrides:
normalized_keys = {str(existing_key).casefold(): str(existing_key) for existing_key in metadata_tags.keys()}
for key, value in metadata_overrides.items():
if value is None:
continue
metadata_tags[str(key)] = str(value)
key_text = str(key or "").strip()
if not key_text:
continue
value_text = str(value).strip()
if not value_text:
continue
lookup = key_text.casefold()
existing_key = normalized_keys.get(lookup)
if existing_key:
existing_value = str(metadata_tags.get(existing_key) or "").strip()
if existing_value:
continue
target_key = existing_key
else:
target_key = key_text
normalized_keys[lookup] = target_key
metadata_tags[target_key] = value_text
total_chars = getattr(extraction, "total_characters", None) or calculate_text_length(
getattr(extraction, "combined_text", "")
@@ -4154,6 +4233,7 @@ def _build_pending_job_from_extraction(
silence_between_chapters = settings["silence_between_chapters"]
chapter_intro_delay = settings["chapter_intro_delay"]
read_title_intro = settings["read_title_intro"]
read_closing_outro = settings.get("read_closing_outro", True)
normalize_chapter_opening_caps = settings["normalize_chapter_opening_caps"]
max_subtitle_words = settings["max_subtitle_words"]
auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"]
@@ -4232,6 +4312,7 @@ def _build_pending_job_from_extraction(
cover_image_mime=cover_mime,
chapter_intro_delay=chapter_intro_delay,
read_title_intro=bool(read_title_intro),
read_closing_outro=bool(read_closing_outro),
normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps),
auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
chunk_level=chunk_level_value,
@@ -4593,6 +4674,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
cover_image_mime=pending.cover_image_mime,
chapter_intro_delay=pending.chapter_intro_delay,
read_title_intro=pending.read_title_intro,
read_closing_outro=getattr(pending, "read_closing_outro", True),
normalize_chapter_opening_caps=getattr(pending, "normalize_chapter_opening_caps", True),
auto_prefix_chapter_titles=getattr(pending, "auto_prefix_chapter_titles", True),
chunk_level=pending.chunk_level,
+5
View File
@@ -115,6 +115,7 @@ class Job:
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
@@ -185,6 +186,7 @@ class Job:
"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),
},
@@ -439,6 +441,7 @@ class PendingJob:
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"
@@ -521,6 +524,7 @@ class ConversionService:
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",
@@ -571,6 +575,7 @@ class ConversionService:
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,
+19 -1
View File
@@ -364,10 +364,28 @@ if (modal && browser) {
}
setStatus('Downloading book from Calibre. This can take a minute…', 'loading');
try {
const requestPayload = {
href: entry.download.href,
title: entry.title || '',
};
const metadata = {};
if (entry.series) {
metadata.series = entry.series;
metadata.series_name = entry.series;
}
const seriesIndex = entry.series_index ?? entry.seriesIndex ?? null;
if (seriesIndex !== null && seriesIndex !== undefined && seriesIndex !== '') {
metadata.series_index = seriesIndex;
metadata.series_position = seriesIndex;
metadata.book_number = seriesIndex;
}
if (Object.keys(metadata).length > 0) {
requestPayload.metadata = metadata;
}
const response = await fetch('/api/integrations/calibre-opds/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ href: entry.download.href, title: entry.title || '' }),
body: JSON.stringify(requestPayload),
});
const payload = await response.json();
if (!response.ok) {
+1
View File
@@ -25,6 +25,7 @@
<li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li>
<li><strong>Chapter intro delay:</strong> {{ '%.1f'|format(job.chapter_intro_delay) }}s</li>
<li><strong>Title intro:</strong> {{ 'Yes' if job.read_title_intro else 'No' }}</li>
<li><strong>Closing outro:</strong> {{ 'Yes' if job.read_closing_outro else 'No' }}</li>
<li><strong>Normalize chapter openings:</strong> {{ 'Yes' if job.normalize_chapter_opening_caps else 'No' }}</li>
<li><strong>Prefix chapter titles:</strong> {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}</li>
<li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li>
@@ -65,6 +65,16 @@
{% set read_title_intro = settings_dict.get('read_title_intro', False) %}
{% endif %}
{% endif %}
{% set read_outro_value = form_values.get('read_closing_outro') if form_values else None %}
{% if read_outro_value is not none %}
{% set read_closing_outro = ((read_outro_value|string)|lower) in ['true', '1', 'yes', 'on'] %}
{% else %}
{% if pending is not none %}
{% set read_closing_outro = pending.read_closing_outro %}
{% else %}
{% set read_closing_outro = settings_dict.get('read_closing_outro', True) %}
{% endif %}
{% endif %}
{% set normalize_caps_value = form_values.get('normalize_chapter_opening_caps') if form_values else None %}
{% if normalize_caps_value is not none %}
{% set normalize_chapter_opening_caps = ((normalize_caps_value|string)|lower) in ['true', '1', 'yes', 'on'] %}
@@ -340,6 +350,14 @@
</label>
<p class="hint">When enabled, the narrator speaks the book title and author list before chapter one begins.</p>
</div>
<div class="field field--stack">
<label class="toggle-pill">
<input type="hidden" name="read_closing_outro" value="false">
<input type="checkbox" name="read_closing_outro" value="true" {% if read_closing_outro %}checked{% endif %} {{ 'disabled' if readonly else '' }}>
<span>Read closing outro after narration</span>
</label>
<p class="hint">Adds a short "The end" statement after the final chapter, including series details when available.</p>
</div>
<div class="field field--stack">
<label class="toggle-pill">
<input type="hidden" name="normalize_chapter_opening_caps" value="false">
@@ -13,6 +13,7 @@
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
<input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}">
<input type="hidden" name="read_closing_outro" value="{{ 'true' if pending.read_closing_outro else 'false' }}">
<input type="hidden" name="normalize_chapter_opening_caps" value="{{ 'true' if pending.normalize_chapter_opening_caps else 'false' }}">
{% if pending.generate_epub3 %}
<input type="hidden" name="generate_epub3" value="true">
@@ -53,6 +53,7 @@
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
<input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}">
<input type="hidden" name="read_closing_outro" value="{{ 'true' if pending.read_closing_outro else 'false' }}">
<input type="hidden" name="normalize_chapter_opening_caps" value="{{ 'true' if pending.normalize_chapter_opening_caps else 'false' }}">
{% if pending.generate_epub3 %}
<input type="hidden" name="generate_epub3" value="true">
+7
View File
@@ -157,6 +157,13 @@
</label>
<p class="hint">When enabled, the narrator speaks the title, optional subtitle, and author names before chapter one.</p>
</div>
<div class="field field--stack">
<label class="toggle-pill">
<input type="checkbox" name="read_closing_outro" value="true" {% if settings.read_closing_outro %}checked{% endif %}>
<span>Read Closing Outro After Narration</span>
</label>
<p class="hint">Adds a brief "The end" line after the final chapter, optionally including series information.</p>
</div>
<div class="field field--stack">
<label class="toggle-pill">
<input type="checkbox" name="normalize_chapter_opening_caps" value="true" {% if settings.normalize_chapter_opening_caps %}checked{% endif %}>