mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Enhance series metadata handling in Calibre OPDS integration and Epub extraction
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user