diff --git a/abogen/integrations/calibre_opds.py b/abogen/integrations/calibre_opds.py index 0d10d76..741fcc9 100644 --- a/abogen/integrations/calibre_opds.py +++ b/abogen/integrations/calibre_opds.py @@ -14,7 +14,15 @@ import httpx ATOM_NS = "http://www.w3.org/2005/Atom" OPDS_NS = "http://opds-spec.org/2010/catalog" DC_NS = "http://purl.org/dc/terms/" -NS = {"atom": ATOM_NS, "opds": OPDS_NS, "dc": DC_NS} +CALIBRE_CATALOG_NS = "http://calibre.kovidgoyal.net/2009/catalog" +CALIBRE_METADATA_NS = "http://calibre.kovidgoyal.net/2009/metadata" +NS = { + "atom": ATOM_NS, + "opds": OPDS_NS, + "dc": DC_NS, + "calibre": CALIBRE_CATALOG_NS, + "calibre_md": CALIBRE_METADATA_NS, +} _TAG_STRIP_RE = re.compile(r"<[^>]+>") @@ -58,6 +66,8 @@ class OPDSEntry: alternate: Optional[OPDSLink] = None thumbnail: Optional[OPDSLink] = None links: List[OPDSLink] = field(default_factory=list) + series: Optional[str] = None + series_index: Optional[float] = None def to_dict(self) -> Dict[str, Any]: return { @@ -71,6 +81,8 @@ class OPDSEntry: "alternate": self.alternate.to_dict() if self.alternate else None, "thumbnail": self.thumbnail.to_dict() if self.thumbnail else None, "links": [link.to_dict() for link in self.links], + "series": self.series, + "series_index": self.series_index, } @@ -272,6 +284,31 @@ class CalibreOPDSClient: thumb_link = parsed_links.get("http://opds-spec.org/image/thumbnail") or parsed_links.get( "thumbnail" ) + + series_name = ( + node.findtext("calibre:series", default=None, namespaces=NS) + or node.findtext("calibre_md:series", default=None, namespaces=NS) + ) + if series_name: + series_name = series_name.strip() or None + + series_index_raw = ( + node.findtext("calibre:series_index", default=None, namespaces=NS) + or node.findtext("calibre_md:series_index", default=None, namespaces=NS) + ) + series_index: Optional[float] = None + if series_index_raw is not None: + text = str(series_index_raw).strip() + if text: + try: + series_index = float(text) + except ValueError: + match = re.search(r"\d+(?:\.\d+)?", text.replace(",", ".")) + if match: + try: + series_index = float(match.group(0)) + except ValueError: + series_index = None return OPDSEntry( id=entry_id or title, title=title, @@ -283,6 +320,8 @@ class CalibreOPDSClient: alternate=alternate_link, thumbnail=thumb_link, links=list(parsed_links.values()), + series=series_name, + series_index=series_index, ) def _extract_position(self, node: ET.Element) -> Optional[int]: diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 5a27431..157b468 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -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] diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 915586e..387a5c0 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -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, diff --git a/abogen/web/service.py b/abogen/web/service.py index 20c0773..294f891 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -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, diff --git a/abogen/web/static/find_books.js b/abogen/web/static/find_books.js index bfff53a..7e95c81 100644 --- a/abogen/web/static/find_books.js +++ b/abogen/web/static/find_books.js @@ -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) { diff --git a/abogen/web/templates/job_detail.html b/abogen/web/templates/job_detail.html index 9245bc2..2674102 100644 --- a/abogen/web/templates/job_detail.html +++ b/abogen/web/templates/job_detail.html @@ -25,6 +25,7 @@
  • Silence between chapters: {{ '%.1f'|format(job.silence_between_chapters) }}s
  • Chapter intro delay: {{ '%.1f'|format(job.chapter_intro_delay) }}s
  • Title intro: {{ 'Yes' if job.read_title_intro else 'No' }}
  • +
  • Closing outro: {{ 'Yes' if job.read_closing_outro else 'No' }}
  • Normalize chapter openings: {{ 'Yes' if job.normalize_chapter_opening_caps else 'No' }}
  • Prefix chapter titles: {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}
  • Max words per subtitle: {{ job.max_subtitle_words }}
  • diff --git a/abogen/web/templates/partials/new_job_step_book.html b/abogen/web/templates/partials/new_job_step_book.html index 81144ec..03f97ba 100644 --- a/abogen/web/templates/partials/new_job_step_book.html +++ b/abogen/web/templates/partials/new_job_step_book.html @@ -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 @@

    When enabled, the narrator speaks the book title and author list before chapter one begins.

    +
    + +

    Adds a short "The end" statement after the final chapter, including series details when available.

    +

    When enabled, the narrator speaks the title, optional subtitle, and author names before chapter one.

    +
    + +

    Adds a brief "The end" line after the final chapter, optionally including series information.

    +