diff --git a/abogen/integrations/calibre_opds.py b/abogen/integrations/calibre_opds.py
index eec4f86..a643930 100644
--- a/abogen/integrations/calibre_opds.py
+++ b/abogen/integrations/calibre_opds.py
@@ -129,6 +129,10 @@ class OPDSFeed:
}
+def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]:
+ return feed.to_dict()
+
+
@dataclass
class DownloadedResource:
filename: str
@@ -677,7 +681,8 @@ class CalibreOPDSClient:
if series_name is None or series_index is None:
category_series_name, category_series_index = self._extract_series_from_categories(
- node.findall("atom:category", NS)
+ node.findall("atom:category", NS),
+ authors=authors,
)
if series_name is None and category_series_name:
series_name = category_series_name
@@ -716,9 +721,15 @@ class CalibreOPDSClient:
rating_max=rating_max,
)
- def _extract_series_from_categories(self, category_nodes: List[ET.Element]) -> tuple[Optional[str], Optional[float]]:
+ def _extract_series_from_categories(
+ self,
+ category_nodes: List[ET.Element],
+ *,
+ authors: Optional[List[str]] = None,
+ ) -> tuple[Optional[str], Optional[float]]:
name: Optional[str] = None
index: Optional[float] = None
+ author_set = {str(author).strip().casefold() for author in (authors or []) if str(author).strip()}
for category in category_nodes:
scheme = (category.attrib.get("scheme") or "").strip().lower()
label = (category.attrib.get("label") or "").strip()
@@ -729,7 +740,9 @@ class CalibreOPDSClient:
if term and term not in values:
values.append(term)
- is_series_hint = "series" in scheme or any("series" in value.lower() for value in values if value)
+ # Be conservative: category schemes are often URLs and can contain unrelated substrings.
+ # Also, some catalog feeds incorrectly include author names in series-like categories.
+ is_series_hint = self._is_series_scheme(scheme) or any("series" in value.lower() for value in values if value)
if not is_series_hint:
continue
@@ -737,6 +750,9 @@ class CalibreOPDSClient:
if not value:
continue
candidate_name, candidate_index = self._parse_series_value(value)
+ if candidate_name and candidate_name.casefold() in author_set:
+ # Guardrail: avoid mapping the author name into series.
+ continue
if candidate_name and not name:
name = candidate_name
if candidate_index is not None and index is None:
@@ -745,6 +761,15 @@ class CalibreOPDSClient:
return name, index
return name, index
+ @staticmethod
+ def _is_series_scheme(scheme: str) -> bool:
+ cleaned = (scheme or "").strip().lower()
+ if not cleaned:
+ return False
+ if "author" in cleaned:
+ return False
+ return bool(re.search(r"(^|[/#:\-])series([/#:\-]|$)", cleaned))
+
def _parse_series_value(self, value: str) -> tuple[Optional[str], Optional[float]]:
cleaned = re.sub(r"\s+", " ", value or "").strip()
if not cleaned:
@@ -1111,6 +1136,8 @@ class CalibreOPDSClient:
scored_entries = []
for entry in feed.entries:
+ if not self._entry_matches_query(entry, tokens):
+ continue
score = self._calculate_match_score(entry, tokens)
# Require a minimum score to avoid weak matches (e.g. single word in summary)
if score >= 10:
diff --git a/abogen/text_extractor.py b/abogen/text_extractor.py
index 9938942..b6e2ee9 100644
--- a/abogen/text_extractor.py
+++ b/abogen/text_extractor.py
@@ -72,6 +72,8 @@ class MetadataSource:
publisher: Optional[str] = None
publication_year: Optional[str] = None
language: Optional[str] = None
+ series: Optional[str] = None
+ series_index: Optional[str] = None
@dataclass
@@ -390,6 +392,18 @@ class EpubExtractor:
chapters = [ExtractedChapter(title=self.path.stem, text="")]
metadata = _build_metadata_payload(metadata_source, len(chapters), "epub", self.path.stem)
metadata.setdefault("chapter_count", str(len(chapters)))
+ if metadata_source.series:
+ series_text = str(metadata_source.series).strip()
+ if series_text:
+ metadata.setdefault("series", series_text)
+ metadata.setdefault("series_name", series_text)
+ metadata.setdefault("seriesname", series_text)
+ if metadata_source.series_index:
+ idx_text = str(metadata_source.series_index).strip()
+ if idx_text:
+ metadata.setdefault("series_index", idx_text)
+ metadata.setdefault("series_sequence", idx_text)
+ metadata.setdefault("book_number", idx_text)
cover_image, cover_mime = self._extract_cover()
return ExtractionResult(
chapters=chapters,
@@ -444,6 +458,41 @@ class EpubExtractor:
except Exception as exc:
logger.debug("Failed to extract EPUB language metadata: %s", exc)
+ # Series metadata (best-effort). Common sources:
+ # - Calibre embeds OPF meta tags:
+ # - EPUB3 collections via: ...
+ try:
+ meta_items = self.book.get_metadata("OPF", "meta")
+ except Exception as exc:
+ logger.debug("Failed to extract EPUB OPF meta tags: %s", exc)
+ meta_items = []
+
+ series_name: Optional[str] = None
+ series_index: Optional[str] = None
+ for value, attrs in meta_items or []:
+ attrs_dict = attrs or {}
+ name = str(attrs_dict.get("name") or "").strip().casefold()
+ prop = str(attrs_dict.get("property") or "").strip().casefold()
+ content = attrs_dict.get("content")
+ candidate = content if content is not None else value
+ candidate_text = str(candidate or "").strip()
+ if not candidate_text:
+ continue
+
+ if name in {"calibre:series", "series"} and series_name is None:
+ series_name = candidate_text
+ continue
+ if name in {"calibre:series_index", "calibre:seriesindex", "series_index", "seriesindex"} and series_index is None:
+ series_index = candidate_text
+ continue
+
+ if prop.endswith("belongs-to-collection") and series_name is None:
+ series_name = candidate_text
+ continue
+
+ metadata.series = series_name
+ metadata.series_index = series_index
+
return metadata
def _extract_cover(self) -> Tuple[Optional[bytes], Optional[str]]:
diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py
index 9c732ae..0b996ea 100644
--- a/abogen/web/conversion_runner.py
+++ b/abogen/web/conversion_runner.py
@@ -1780,11 +1780,30 @@ def run_conversion_job(job: Job) -> None:
auto_prefix_titles = getattr(job, "auto_prefix_chapter_titles", True)
read_title_intro = getattr(job, "read_title_intro", False)
book_intro_text = ""
+ intro_provider: Optional[str] = None
+ intro_voice_choice: Any = None
+ intro_speed: Optional[float] = None
+ intro_steps: Optional[int] = None
if read_title_intro:
book_intro_text = _build_title_intro_text(job.metadata_tags, job.original_filename)
if book_intro_text:
preview = book_intro_text if len(book_intro_text) <= 120 else f"{book_intro_text[:117]}…"
job.add_log(f"Title intro enabled: {preview}", level="debug")
+
+ intro_voice_spec = base_voice_spec or job.voice
+ if intro_voice_spec == "__custom_mix":
+ intro_voice_spec = base_voice_spec or ""
+ if not intro_voice_spec:
+ fallback_key = next(iter(voice_cache.keys()), "")
+ if fallback_key and fallback_key != "__custom_mix":
+ intro_voice_spec = fallback_key.split(":", 1)[-1]
+ if not intro_voice_spec and VOICES_INTERNAL:
+ intro_voice_spec = VOICES_INTERNAL[0]
+
+ if intro_voice_spec:
+ intro_provider, _, intro_voice_choice, intro_speed, intro_steps = resolve_voice_choice(
+ intro_voice_spec
+ )
else:
job.add_log("Title intro enabled but no usable metadata was found.", level="debug")
intro_emitted = False
@@ -1958,14 +1977,18 @@ def run_conversion_job(job: Job) -> None:
remove_heading_from_body = True
if not intro_emitted and book_intro_text:
+ intro_use_provider = intro_provider or chapter_provider
+ intro_use_voice_choice = intro_voice_choice if intro_voice_choice is not None else voice_choice
+ intro_use_speed = intro_speed if intro_speed is not None else chapter_speed
+ intro_use_steps = intro_steps if intro_steps is not None else chapter_steps
intro_segments = emit_text(
book_intro_text,
- voice_choice=voice_choice,
+ voice_choice=intro_use_voice_choice,
chapter_sink=chapter_sink,
preview_prefix="Book intro",
- tts_provider=chapter_provider,
- speed_override=chapter_speed,
- supertonic_steps_override=chapter_steps,
+ tts_provider=intro_use_provider,
+ speed_override=intro_use_speed,
+ supertonic_steps_override=intro_use_steps,
)
intro_emitted = True
if intro_segments > 0 and job.chapter_intro_delay > 0:
diff --git a/tests/test_calibre_opds.py b/tests/test_calibre_opds.py
index f2e001e..d7f3d1e 100644
--- a/tests/test_calibre_opds.py
+++ b/tests/test_calibre_opds.py
@@ -89,6 +89,40 @@ def test_calibre_opds_feed_extracts_series_from_categories() -> None:
assert entry.series_index == 5.0
+def test_calibre_opds_does_not_map_author_into_series_from_categories() -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ xml_payload = """
+
Hello
" + book.add_item(chapter) + book.spine = ["nav", chapter] + book.add_item(epub.EpubNcx()) + book.add_item(epub.EpubNav()) + + path = tmp_path / "example.epub" + epub.write_epub(str(path), book) + + result = extract_from_path(path) + + assert result.metadata.get("series") == "Example Saga" + assert result.metadata.get("series_index") == "2"