feat: Enhance series metadata handling in Calibre OPDS integration and Epub extraction

This commit is contained in:
JB
2025-12-20 20:22:05 -08:00
parent 0fe8ee0ad4
commit b47952b857
5 changed files with 169 additions and 7 deletions
+30 -3
View File
@@ -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:
+49
View File
@@ -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: <meta name="calibre:series" content="..." />
# - EPUB3 collections via: <meta property="belongs-to-collection">...</meta>
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]]:
+27 -4
View File
@@ -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:
+34
View File
@@ -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 = """<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<feed xmlns=\"http://www.w3.org/2005/Atom\"
xmlns:dc=\"http://purl.org/dc/terms/\"
xmlns:calibre=\"http://calibre.kovidgoyal.net/2009/catalog\">
<id>catalog</id>
<title>Example Catalog</title>
<entry>
<id>book-author-series-bug</id>
<title>Sample Book</title>
<author>
<name>Alexandre Dumas</name>
</author>
<category
scheme=\"http://calibre.kovidgoyal.net/2009/series\"
term=\"Books: Alexandre Dumas\"
label=\"Books: Alexandre Dumas\" />
<link rel=\"http://opds-spec.org/acquisition\"
href=\"books/sample.epub\"
type=\"application/epub+zip\" />
</entry>
</feed>
"""
feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog")
assert feed.entries
entry = feed.entries[0]
assert entry.authors == ["Alexandre Dumas"]
assert entry.series is None
assert entry.series_index is None
def test_calibre_opds_extracts_tags_and_rating_from_summary() -> None:
client = CalibreOPDSClient("http://example.com/catalog")
xml_payload = """<?xml version=\"1.0\" encoding=\"UTF-8\"?>
+29
View File
@@ -1,5 +1,7 @@
from pathlib import Path
from ebooklib import epub
from abogen.text_extractor import extract_from_path
from abogen.utils import calculate_text_length
@@ -25,3 +27,30 @@ def test_epub_metadata_composer_matches_artist():
assert composer
assert composer == artist
assert composer != "Narrator"
def test_epub_series_metadata_extracted_from_opf_meta(tmp_path):
book = epub.EpubBook()
book.set_identifier("id")
book.set_title("Example Title")
book.set_language("en")
book.add_author("Example Author")
# Calibre-style series metadata
book.add_metadata("OPF", "meta", "", {"name": "calibre:series", "content": "Example Saga"})
book.add_metadata("OPF", "meta", "", {"name": "calibre:series_index", "content": "2"})
chapter = epub.EpubHtml(title="Chapter 1", file_name="chap_01.xhtml", lang="en")
chapter.content = "<h1>Chapter 1</h1><p>Hello</p>"
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"