feat: Implement chapter overrides and metadata merging in conversion process

- Added `_coerce_truthy` function to handle truthy value coercion.
- Introduced `_apply_chapter_overrides` to apply chapter modifications based on provided overrides.
- Implemented `_merge_metadata` to combine extracted metadata with overrides, ensuring proper handling of None values.
- Updated `run_conversion_job` to utilize new chapter override and metadata merging functionalities.
- Modified `Job` class to store chapters as dictionaries for better flexibility.
- Enhanced `ConversionService` to normalize chapter input and metadata tags.
- Added comprehensive tests for chapter overrides and metadata merging to ensure functionality and correctness.
This commit is contained in:
JB
2025-10-06 18:05:12 -07:00
parent c8e9eb6fd2
commit 85310ad916
4 changed files with 1217 additions and 83 deletions
+746 -73
View File
@@ -1,18 +1,38 @@
from __future__ import annotations from __future__ import annotations
import datetime
import logging
import re
import textwrap
import urllib.parse
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
import re from typing import Dict, List, Optional, Tuple
from typing import List, Sequence
import ebooklib import ebooklib # type: ignore[import]
import fitz import fitz # type: ignore[import]
import markdown import markdown # type: ignore[import]
from bs4 import BeautifulSoup from bs4 import BeautifulSoup, NavigableString # type: ignore[import]
from ebooklib import epub from ebooklib import epub # type: ignore[import]
from .utils import clean_text, detect_encoding from .utils import clean_text, detect_encoding
logger = logging.getLogger(__name__)
METADATA_PATTERN = re.compile(r"<<METADATA_([A-Z_]+):(.*?)>>", re.DOTALL)
CHAPTER_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>", re.IGNORECASE)
METADATA_KEY_MAP: Dict[str, str] = {
"TITLE": "title",
"ARTIST": "artist",
"ALBUM": "album",
"YEAR": "year",
"ALBUM_ARTIST": "album_artist",
"ALBUMARTIST": "album_artist",
"COMPOSER": "composer",
"GENRE": "genre",
"DATE": "date",
}
@dataclass @dataclass
class ExtractedChapter: class ExtractedChapter:
@@ -27,7 +47,7 @@ class ExtractedChapter:
@dataclass @dataclass
class ExtractionResult: class ExtractionResult:
chapters: List[ExtractedChapter] chapters: List[ExtractedChapter]
metadata: dict[str, str] = field(default_factory=dict) metadata: Dict[str, str] = field(default_factory=dict)
@property @property
def combined_text(self) -> str: def combined_text(self) -> str:
@@ -38,6 +58,24 @@ class ExtractionResult:
return sum(chapter.characters for chapter in self.chapters) return sum(chapter.characters for chapter in self.chapters)
@dataclass
class MetadataSource:
title: Optional[str] = None
authors: List[str] = field(default_factory=list)
description: Optional[str] = None
publisher: Optional[str] = None
publication_year: Optional[str] = None
@dataclass
class NavEntry:
src: str
title: str
doc_href: str
position: int
doc_order: int
def extract_from_path(path: Path) -> ExtractionResult: def extract_from_path(path: Path) -> ExtractionResult:
suffix = path.suffix.lower() suffix = path.suffix.lower()
if suffix == ".txt": if suffix == ".txt":
@@ -57,20 +95,27 @@ def _extract_plaintext(path: Path) -> ExtractionResult:
return _extract_from_string(raw, default_title=path.stem) return _extract_from_string(raw, default_title=path.stem)
METADATA_PATTERN = re.compile(r"<<METADATA_([A-Z_]+):(.*?)>>", re.DOTALL)
CHAPTER_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>", re.IGNORECASE)
def _extract_from_string(raw: str, default_title: str) -> ExtractionResult: def _extract_from_string(raw: str, default_title: str) -> ExtractionResult:
metadata, body = _strip_metadata(raw) raw_metadata, body = _strip_metadata(raw)
chapters = _split_chapters(body, default_title) chapters = _split_chapters(body, default_title)
normalized_tags = _normalize_metadata_keys(raw_metadata)
chapter_count = len(chapters)
artist_value = normalized_tags.get("artist")
authors = [name.strip() for name in artist_value.split(",") if name.strip()] if artist_value else []
metadata_source = MetadataSource(
title=normalized_tags.get("title") or default_title,
authors=authors,
publication_year=normalized_tags.get("year"),
)
metadata = _build_metadata_payload(metadata_source, chapter_count, "text", default_title)
metadata.update(normalized_tags)
if not chapters: if not chapters:
chapters = [ExtractedChapter(title=default_title, text="")] chapters = [ExtractedChapter(title=default_title, text="")]
return ExtractionResult(chapters=chapters, metadata=metadata) return ExtractionResult(chapters=chapters, metadata=metadata)
def _strip_metadata(content: str) -> tuple[dict[str, str], str]: def _strip_metadata(content: str) -> Tuple[Dict[str, str], str]:
metadata: dict[str, str] = {} metadata: Dict[str, str] = {}
def _replacer(match: re.Match) -> str: def _replacer(match: re.Match) -> str:
key = match.group(1).strip().upper() key = match.group(1).strip().upper()
@@ -107,82 +152,710 @@ def _split_chapters(content: str, default_title: str) -> List[ExtractedChapter]:
return chapters return chapters
def _extract_pdf(path: Path) -> ExtractionResult: def _normalize_metadata_keys(metadata: Dict[str, str]) -> Dict[str, str]:
document = fitz.open(str(path)) normalized: Dict[str, str] = {}
chapters: List[ExtractedChapter] = [] for key, value in metadata.items():
for index, page in enumerate(document): if not value:
text = clean_text(page.get_text())
if not text:
continue continue
title = f"Page {index + 1}" mapped = METADATA_KEY_MAP.get(key.upper(), key.lower())
chapters.append(ExtractedChapter(title=title, text=text)) normalized[mapped] = value
return normalized
def _build_metadata_payload(
metadata_source: MetadataSource,
chapter_count: int,
file_type: str,
default_title: str,
) -> Dict[str, str]:
now_year = str(datetime.datetime.now().year)
title = metadata_source.title.strip() if metadata_source.title else default_title
if not title:
title = default_title
authors = [author for author in metadata_source.authors if author.strip()]
if not authors:
authors = ["Unknown"]
authors_text = ", ".join(authors)
if chapter_count <= 0:
chapter_count = 1
chapter_label = "Chapters" if file_type in {"epub", "markdown"} else "Pages"
metadata = {
"TITLE": title,
"ARTIST": authors_text,
"ALBUM": f"{title} ({chapter_count} {chapter_label})",
"YEAR": metadata_source.publication_year or now_year,
"ALBUM_ARTIST": authors_text,
"COMPOSER": "Narrator",
"GENRE": "Audiobook",
}
return _normalize_metadata_keys(metadata)
def _extract_pdf(path: Path) -> ExtractionResult:
metadata_source = MetadataSource()
chapters: List[ExtractedChapter] = []
with fitz.open(str(path)) as document:
metadata_source = _collect_pdf_metadata(document)
for index, page in enumerate(document):
text = _clean_pdf_text(page.get_text())
if not text:
continue
title = f"Page {index + 1}"
chapters.append(ExtractedChapter(title=title, text=text))
if not chapters: if not chapters:
chapters.append(ExtractedChapter(title=path.stem, text="")) chapters.append(ExtractedChapter(title=path.stem, text=""))
return ExtractionResult(chapters) metadata = _build_metadata_payload(metadata_source, len(chapters), "pdf", path.stem)
return ExtractionResult(chapters=chapters, metadata=metadata)
def _collect_pdf_metadata(document: fitz.Document) -> MetadataSource:
metadata = MetadataSource()
info = document.metadata or {}
if info.get("title"):
metadata.title = info["title"]
if info.get("author"):
metadata.authors = [info["author"]]
if info.get("subject"):
metadata.description = info["subject"]
if info.get("keywords"):
keywords = info["keywords"]
if metadata.description:
metadata.description = f"{metadata.description}\n\nKeywords: {keywords}"
else:
metadata.description = f"Keywords: {keywords}"
if info.get("creator"):
metadata.publisher = info["creator"]
for key in ("creationDate", "modDate"):
value = info.get(key)
if not value:
continue
match = re.search(r"D:(\d{4})", value)
if match:
metadata.publication_year = match.group(1)
break
return metadata
def _clean_pdf_text(text: str) -> str:
cleaned = clean_text(text)
cleaned = re.sub(r"\[\s*\d+\s*\]", "", cleaned)
cleaned = re.sub(r"^\s*\d+\s*$", "", cleaned, flags=re.MULTILINE)
cleaned = re.sub(r"\s+\d+\s*$", "", cleaned, flags=re.MULTILINE)
cleaned = re.sub(r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", "", cleaned, flags=re.MULTILINE)
return cleaned.strip()
def _extract_markdown(path: Path) -> ExtractionResult: def _extract_markdown(path: Path) -> ExtractionResult:
encoding = detect_encoding(str(path)) encoding = detect_encoding(str(path))
raw = path.read_text(encoding=encoding, errors="replace") raw = path.read_text(encoding=encoding, errors="replace")
html = markdown.markdown(raw, extensions=["toc", "fenced_code"]) metadata_source, chapters = _parse_markdown(raw, path.stem)
soup = BeautifulSoup(html, "html.parser")
headings = soup.find_all([f"h{i}" for i in range(1, 7)])
chapters: List[ExtractedChapter] = []
if headings:
for heading in headings:
sibling_text = _collect_heading_text(heading)
text = clean_text(sibling_text)
if text:
chapters.append(ExtractedChapter(title=heading.get_text(strip=True), text=text))
if not chapters: if not chapters:
chapters.append(ExtractedChapter(title=path.stem, text=clean_text(raw))) chapters = [ExtractedChapter(title=metadata_source.title or path.stem, text=clean_text(raw))]
return ExtractionResult(chapters) metadata = _build_metadata_payload(metadata_source, len(chapters), "markdown", path.stem)
return ExtractionResult(chapters=chapters, metadata=metadata)
def _collect_heading_text(node) -> str: def _parse_markdown(raw: str, default_title: str) -> Tuple[MetadataSource, List[ExtractedChapter]]:
texts: List[str] = [] metadata = MetadataSource()
for sibling in node.next_siblings: text = textwrap.dedent(raw)
if getattr(sibling, "name", None) and sibling.name.startswith("h"): frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
break if frontmatter_match:
text = getattr(sibling, "get_text", lambda **_: "")() frontmatter = frontmatter_match.group(1)
if text: _parse_markdown_frontmatter(frontmatter, metadata)
texts.append(text) text_body = text[frontmatter_match.end():]
return "\n".join(texts) else:
text_body = text
md = markdown.Markdown(extensions=["toc", "fenced_code"])
html = md.convert(text_body)
toc_tokens = getattr(md, "toc_tokens", None) or []
if not toc_tokens:
cleaned = clean_text(text_body)
title = metadata.title or default_title
chapters = [ExtractedChapter(title=title, text=cleaned)] if cleaned else []
return metadata, chapters
headers: List[dict] = []
def _flatten_tokens(tokens):
for token in tokens:
headers.append(token)
if token.get("children"):
_flatten_tokens(token["children"])
_flatten_tokens(toc_tokens)
header_positions: List[Tuple[str, int, str]] = []
for header in headers:
header_id = header.get("id")
if not header_id:
continue
id_pattern = f'id="{header_id}"'
pos = html.find(id_pattern)
if pos == -1:
continue
tag_start = html.rfind("<", 0, pos)
name = str(header.get("name", header_id))
header_positions.append((header_id, tag_start, name))
header_positions.sort(key=lambda item: item[1])
chapters: List[ExtractedChapter] = []
for index, (header_id, start, name) in enumerate(header_positions):
end = header_positions[index + 1][1] if index + 1 < len(header_positions) else len(html)
section_html = html[start:end]
section_soup = BeautifulSoup(section_html, "html.parser")
header_tag = section_soup.find(attrs={"id": header_id})
if header_tag:
header_tag.decompose()
section_text = clean_text(section_soup.get_text()).strip()
if not section_text:
continue
chapters.append(ExtractedChapter(title=name.strip(), text=section_text))
if not metadata.title:
first_h1 = next((header for header in headers if header.get("level") == 1 and header.get("name")), None)
if first_h1:
metadata.title = str(first_h1["name"])
return metadata, chapters
def _parse_markdown_frontmatter(frontmatter: str, metadata: MetadataSource) -> None:
title_match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE)
if title_match:
metadata.title = title_match.group(1).strip().strip('"\'')
author_match = re.search(r"^author:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE)
if author_match:
metadata.authors = [author_match.group(1).strip().strip('"\'')]
desc_match = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE)
if desc_match:
metadata.description = desc_match.group(1).strip().strip('"\'')
date_match = re.search(r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE)
if date_match:
date_str = date_match.group(1).strip().strip('\"\'')
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
if year_match:
metadata.publication_year = year_match.group(0)
def _extract_epub(path: Path) -> ExtractionResult: def _extract_epub(path: Path) -> ExtractionResult:
book = epub.read_epub(str(path)) extractor = EpubExtractor(path)
chapters: List[ExtractedChapter] = [] return extractor.extract()
spine_docs: Sequence[str] = [item[0] for item in book.spine]
for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
name = item.get_name() class EpubExtractor:
if name not in spine_docs: def __init__(self, path: Path) -> None:
continue self.path = path
html_bytes = item.get_content() self.book = epub.read_epub(str(path))
soup = BeautifulSoup(html_bytes, "html.parser") self.doc_content: Dict[str, str] = {}
self.spine_docs: List[str] = []
def extract(self) -> ExtractionResult:
metadata_source = self._collect_metadata()
try:
chapters = self._process_nav()
except Exception as exc:
logger.warning(
"EPUB navigation processing failed for %s: %s. Falling back to spine order.",
self.path.name,
exc,
exc_info=True,
)
chapters = self._process_spine_fallback()
if not chapters:
chapters = [ExtractedChapter(title=self.path.stem, text="")]
metadata = _build_metadata_payload(metadata_source, len(chapters), "epub", self.path.stem)
return ExtractionResult(chapters=chapters, metadata=metadata)
def _collect_metadata(self) -> MetadataSource:
metadata = MetadataSource()
try:
title_items = self.book.get_metadata("DC", "title")
if title_items:
metadata.title = title_items[0][0]
except Exception as exc:
logger.debug("Failed to extract EPUB title metadata: %s", exc)
try:
author_items = self.book.get_metadata("DC", "creator")
if author_items:
metadata.authors = [author[0] for author in author_items if author and author[0]]
except Exception as exc:
logger.debug("Failed to extract EPUB author metadata: %s", exc)
try:
desc_items = self.book.get_metadata("DC", "description")
if desc_items:
metadata.description = desc_items[0][0]
except Exception as exc:
logger.debug("Failed to extract EPUB description metadata: %s", exc)
try:
publisher_items = self.book.get_metadata("DC", "publisher")
if publisher_items:
metadata.publisher = publisher_items[0][0]
except Exception as exc:
logger.debug("Failed to extract EPUB publisher metadata: %s", exc)
try:
date_items = self.book.get_metadata("DC", "date")
if date_items:
date_str = date_items[0][0]
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
metadata.publication_year = year_match.group(0) if year_match else date_str
except Exception as exc:
logger.debug("Failed to extract EPUB publication year metadata: %s", exc)
return metadata
def _process_nav(self) -> List[ExtractedChapter]:
nav_item, nav_type = self._find_navigation_item()
if not nav_item or not nav_type:
raise ValueError("No navigation document found")
parser_type = "html.parser" if nav_type == "html" else "xml"
nav_content = nav_item.get_content().decode("utf-8", errors="ignore")
nav_soup = BeautifulSoup(nav_content, parser_type)
self.spine_docs = self._build_spine_docs()
doc_order = {href: index for index, href in enumerate(self.spine_docs)}
doc_order_decoded = {urllib.parse.unquote(href): index for href, index in doc_order.items()}
nav_targets = self._collect_nav_targets(nav_soup, nav_type)
self._cache_relevant_documents(doc_order, nav_targets)
ordered_entries: List[NavEntry] = []
if nav_type == "ncx":
nav_map = nav_soup.find("navMap")
if not nav_map:
raise ValueError("NCX navigation missing <navMap>")
for nav_point in nav_map.find_all("navPoint", recursive=False):
self._parse_ncx_navpoint(nav_point, ordered_entries, doc_order, doc_order_decoded)
else:
toc_nav = nav_soup.find("nav", attrs={"epub:type": "toc"})
if toc_nav is None:
for nav in nav_soup.find_all("nav"):
if nav.find("ol"):
toc_nav = nav
break
if toc_nav is None:
raise ValueError("NAV HTML missing TOC structure")
top_ol = toc_nav.find("ol", recursive=False)
if top_ol is None:
raise ValueError("TOC navigation missing <ol>")
for li in top_ol.find_all("li", recursive=False):
self._parse_html_nav_li(li, ordered_entries, doc_order, doc_order_decoded)
if not ordered_entries:
raise ValueError("No navigation entries found")
ordered_entries.sort(key=lambda entry: (entry.doc_order, entry.position))
chapters = self._slice_entries(ordered_entries)
self._append_prefix_content(ordered_entries, chapters)
return chapters
def _process_spine_fallback(self) -> List[ExtractedChapter]:
chapters: List[ExtractedChapter] = []
self.spine_docs = self._build_spine_docs()
self.doc_content = {}
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
href = item.get_name()
if href not in self.spine_docs:
continue
try:
html_content = item.get_content().decode("utf-8", errors="ignore")
except Exception as exc:
logger.error("Error decoding EPUB document %s: %s", href, exc)
html_content = ""
self.doc_content[href] = html_content
for index, doc_href in enumerate(self.spine_docs):
html_content = self.doc_content.get(doc_href, "")
if not html_content:
continue
text = self._html_to_text(html_content)
if not text:
continue
title = self._resolve_document_title(html_content, fallback=f"Untitled Chapter {index + 1}")
chapters.append(ExtractedChapter(title=title, text=text))
return chapters
def _find_navigation_item(self) -> Tuple[Optional[ebooklib.epub.EpubItem], Optional[str]]:
nav_item: Optional[ebooklib.epub.EpubItem] = None
nav_type: Optional[str] = None
nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION))
if nav_items:
preferred = next(
(
item
for item in nav_items
if "nav" in item.get_name().lower() and item.get_name().lower().endswith((".xhtml", ".html"))
),
None,
)
if preferred:
nav_item = preferred
nav_type = "html"
else:
html_nav = next(
(
item
for item in nav_items
if item.get_name().lower().endswith((".xhtml", ".html"))
),
None,
)
if html_nav:
nav_item = html_nav
nav_type = "html"
if not nav_item and nav_items:
ncx_candidate = next(
(item for item in nav_items if item.get_name().lower().endswith(".ncx")),
None,
)
if ncx_candidate:
nav_item = ncx_candidate
nav_type = "ncx"
if not nav_item:
ncx_constant = getattr(epub, "ITEM_NCX", None)
if ncx_constant is not None:
ncx_items = list(self.book.get_items_of_type(ncx_constant))
if ncx_items:
nav_item = ncx_items[0]
nav_type = "ncx"
if not nav_item:
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
try:
html_content = item.get_content().decode("utf-8", errors="ignore")
except Exception:
continue
if "<nav" in html_content and 'epub:type="toc"' in html_content:
soup = BeautifulSoup(html_content, "html.parser")
if soup.find("nav", attrs={"epub:type": "toc"}):
nav_item = item
nav_type = "html"
break
return nav_item, nav_type
def _build_spine_docs(self) -> List[str]:
docs: List[str] = []
for spine_entry in self.book.spine:
item_id = spine_entry[0]
item = self.book.get_item_with_id(item_id)
if item:
docs.append(item.get_name())
return docs
def _collect_nav_targets(self, nav_soup: BeautifulSoup, nav_type: str) -> List[str]:
targets: List[str] = []
if nav_type == "ncx":
for content_node in nav_soup.find_all("content"):
src = content_node.get("src")
if src:
targets.append(src.split("#", 1)[0])
else:
for link in nav_soup.find_all("a"):
href = link.get("href")
if href:
targets.append(href.split("#", 1)[0])
return targets
def _cache_relevant_documents(self, doc_order: Dict[str, int], nav_targets: List[str]) -> None:
needed: set[str] = set(doc_order.keys())
for target in nav_targets:
needed.add(target)
needed.add(urllib.parse.unquote(target))
self.doc_content = {}
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
href = item.get_name()
if href not in needed and urllib.parse.unquote(href) not in needed:
continue
try:
html_content = item.get_content().decode("utf-8", errors="ignore")
except Exception as exc:
logger.error("Error decoding EPUB document %s: %s", href, exc)
html_content = ""
self.doc_content[href] = html_content
def _parse_ncx_navpoint(
self,
nav_point,
ordered_entries: List[NavEntry],
doc_order: Dict[str, int],
doc_order_decoded: Dict[str, int],
) -> None:
nav_label = nav_point.find("navLabel")
content = nav_point.find("content")
title = (
nav_label.find("text").get_text(strip=True)
if nav_label and nav_label.find("text")
else "Untitled Section"
)
src = content.get("src") if content and content.has_attr("src") else None
if src:
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded)
if doc_key is not None and doc_idx is not None:
position = self._find_position_robust(doc_key, fragment)
ordered_entries.append(
NavEntry(
src=src,
title=title,
doc_href=doc_key,
position=position,
doc_order=doc_idx,
)
)
else:
logger.warning(
"Navigation entry '%s' points to '%s', which is not in the spine.",
title,
base_href,
)
for child_navpoint in nav_point.find_all("navPoint", recursive=False):
self._parse_ncx_navpoint(child_navpoint, ordered_entries, doc_order, doc_order_decoded)
def _parse_html_nav_li(
self,
li_element,
ordered_entries: List[NavEntry],
doc_order: Dict[str, int],
doc_order_decoded: Dict[str, int],
) -> None:
link = li_element.find("a", recursive=False)
span_text = li_element.find("span", recursive=False)
title = "Untitled Section"
if link and link.has_attr("href"):
src = link["href"]
title = link.get_text(strip=True) or title
else:
src = None
if span_text:
title = span_text.get_text(strip=True) or title
else:
text = "".join(t for t in li_element.stripped_strings)
if text:
title = text
title = title.strip() or "Untitled Section"
if src:
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded)
if doc_key is not None and doc_idx is not None:
position = self._find_position_robust(doc_key, fragment)
ordered_entries.append(
NavEntry(
src=src,
title=title,
doc_href=doc_key,
position=position,
doc_order=doc_idx,
)
)
else:
logger.warning(
"Navigation entry '%s' points to '%s', which is not in the spine.",
title,
base_href,
)
for child_ol in li_element.find_all("ol", recursive=False):
for child_li in child_ol.find_all("li", recursive=False):
self._parse_html_nav_li(child_li, ordered_entries, doc_order, doc_order_decoded)
def _find_doc_key(
self,
base_href: str,
doc_order: Dict[str, int],
doc_order_decoded: Dict[str, int],
) -> Tuple[Optional[str], Optional[int]]:
candidates = {base_href, urllib.parse.unquote(base_href)}
base_name = urllib.parse.unquote(base_href).split("/")[-1].lower()
for key in list(doc_order.keys()) + list(doc_order_decoded.keys()):
if key.split("/")[-1].lower() == base_name:
candidates.add(key)
for candidate in candidates:
if candidate in doc_order:
return candidate, doc_order[candidate]
if candidate in doc_order_decoded:
return candidate, doc_order_decoded[candidate]
return None, None
def _find_position_robust(self, doc_href: str, fragment_id: Optional[str]) -> int:
if doc_href not in self.doc_content:
logger.warning("Document '%s' not found in cached EPUB content.", doc_href)
return 0
html_content = self.doc_content[doc_href]
if not fragment_id:
return 0
try:
temp_soup = BeautifulSoup(f"<div>{html_content}</div>", "html.parser")
target_element = temp_soup.find(id=fragment_id)
if target_element:
tag_str = str(target_element)
pos = html_content.find(tag_str[: min(len(tag_str), 200)])
if pos != -1:
return pos
except Exception:
logger.debug("BeautifulSoup failed to locate id '%s' in %s", fragment_id, doc_href)
safe_fragment_id = re.escape(fragment_id)
id_name_pattern = re.compile(
f"<[^>]+(?:id|name)\\s*=\\s*[\"']{safe_fragment_id}[\"']",
re.IGNORECASE,
)
match = id_name_pattern.search(html_content)
if match:
return match.start()
id_pos = html_content.find(f'id="{fragment_id}"')
name_pos = html_content.find(f'name="{fragment_id}"')
candidates = [pos for pos in (id_pos, name_pos) if pos != -1]
if candidates:
pos = min(candidates)
tag_start = html_content.rfind("<", 0, pos)
return tag_start if tag_start != -1 else pos
logger.warning("Anchor '%s' not found in %s. Defaulting to start.", fragment_id, doc_href)
return 0
def _slice_entries(self, ordered_entries: List[NavEntry]) -> List[ExtractedChapter]:
chapters: List[ExtractedChapter] = []
for index, entry in enumerate(ordered_entries):
next_entry = ordered_entries[index + 1] if index + 1 < len(ordered_entries) else None
slice_html = self._slice_entry(entry, next_entry)
text = self._html_to_text(slice_html)
if not text:
continue
title = entry.title or "Untitled Section"
chapters.append(ExtractedChapter(title=title, text=text))
return chapters
def _slice_entry(
self,
current_entry: NavEntry,
next_entry: Optional[NavEntry],
) -> str:
current_doc = current_entry.doc_href
current_pos = current_entry.position
current_html = self.doc_content.get(current_doc, "")
if not current_html:
return ""
if next_entry and next_entry.doc_href == current_doc:
return current_html[current_pos : next_entry.position]
slice_html = current_html[current_pos:]
if next_entry:
docs_between = self._docs_between(current_doc, next_entry.doc_href)
for doc_href in docs_between:
slice_html += self.doc_content.get(doc_href, "")
next_doc_html = self.doc_content.get(next_entry.doc_href, "")
slice_html += next_doc_html[: next_entry.position]
else:
for doc_href in self._docs_between(current_doc, None):
slice_html += self.doc_content.get(doc_href, "")
if not slice_html.strip():
logger.warning(
"No content found for navigation source '%s'. Using full document fallback.",
current_entry.src,
)
return current_html
return slice_html
def _docs_between(self, current_doc: str, next_doc: Optional[str]) -> List[str]:
docs: List[str] = []
try:
current_idx = self.spine_docs.index(current_doc)
except ValueError:
return docs
if next_doc is None:
docs.extend(self.spine_docs[current_idx + 1 :])
return docs
try:
next_idx = self.spine_docs.index(next_doc)
except ValueError:
return docs
if current_idx < next_idx:
docs.extend(self.spine_docs[current_idx + 1 : next_idx])
elif current_idx > next_idx:
docs.extend(self.spine_docs[current_idx + 1 :])
docs.extend(self.spine_docs[:next_idx])
return docs
def _append_prefix_content(
self,
ordered_entries: List[NavEntry],
chapters: List[ExtractedChapter],
) -> None:
if not ordered_entries:
return
first_entry = ordered_entries[0]
first_doc = first_entry.doc_href
first_pos = first_entry.position
if first_pos <= 0:
return
prefix_html = ""
try:
first_idx = self.spine_docs.index(first_doc)
except ValueError:
first_idx = -1
if first_idx > 0:
for doc_href in self.spine_docs[:first_idx]:
prefix_html += self.doc_content.get(doc_href, "")
prefix_html += self.doc_content.get(first_doc, "")[:first_pos]
prefix_text = self._html_to_text(prefix_html)
if prefix_text and (not chapters or prefix_text != chapters[0].text):
chapters.insert(0, ExtractedChapter(title="Introduction", text=prefix_text))
def _html_to_text(self, html: str) -> str:
if not html:
return ""
soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all(["p", "div"]):
tag.append("\n\n")
for ol in soup.find_all("ol"): for ol in soup.find_all("ol"):
start = int(ol.get("start", 1)) start = int(ol.get("start", 1))
for idx, li in enumerate(ol.find_all("li", recursive=False)): for idx, li in enumerate(ol.find_all("li", recursive=False)):
number = f"{start + idx}. " number_text = f"{start + idx}) "
if li.string: if li.string:
li.string.replace_with(number + li.string) li.string.replace_with(number_text + li.string)
else: else:
li.insert(0, number) li.insert(0, NavigableString(number_text))
for tag in soup.find_all(["sup", "sub"]):
tag.decompose()
text = clean_text(soup.get_text()) text = clean_text(soup.get_text())
if not text: return text.strip()
continue
title = _resolve_epub_title(soup, name)
chapters.append(ExtractedChapter(title=title, text=text))
if not chapters:
chapters.append(ExtractedChapter(title=path.stem, text=""))
return ExtractionResult(chapters)
def _resolve_document_title(self, html_content: str, fallback: str) -> str:
def _resolve_epub_title(soup: BeautifulSoup, fallback: str) -> str: soup = BeautifulSoup(html_content, "html.parser")
if soup.title and soup.title.string: if soup.title and soup.title.string:
return soup.title.string.strip() return soup.title.string.strip()
for heading_tag in ("h1", "h2", "h3"): for heading_tag in ("h1", "h2", "h3"):
heading = soup.find(heading_tag) heading = soup.find(heading_tag)
if heading and heading.get_text(strip=True): if heading and heading.get_text(strip=True):
return heading.get_text(strip=True) return heading.get_text(strip=True)
return fallback return fallback
+144 -6
View File
@@ -9,7 +9,7 @@ import sys
from contextlib import ExitStack from contextlib import ExitStack
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
import numpy as np import numpy as np
import soundfile as sf import soundfile as sf
@@ -43,6 +43,126 @@ class AudioSink:
write: Callable[[np.ndarray], None] write: Callable[[np.ndarray], None]
def _coerce_truthy(value: Any, default: bool = True) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"true", "1", "yes", "on"}:
return True
if lowered in {"false", "0", "no", "off"}:
return False
return default
if value is None:
return default
return bool(value)
def _apply_chapter_overrides(
extracted: List[ExtractedChapter],
overrides: List[Dict[str, Any]],
) -> tuple[List[ExtractedChapter], Dict[str, str], List[str]]:
if not overrides:
return [], {}, []
selected: List[ExtractedChapter] = []
metadata_updates: Dict[str, str] = {}
diagnostics: List[str] = []
for position, payload in enumerate(overrides):
if not isinstance(payload, dict):
diagnostics.append(
f"Skipped chapter override at position {position + 1}: unsupported payload type {type(payload).__name__}."
)
continue
enabled = _coerce_truthy(payload.get("enabled", True))
payload["enabled"] = enabled
if not enabled:
continue
metadata_payload = payload.get("metadata") or {}
if isinstance(metadata_payload, dict):
for key, value in metadata_payload.items():
if value is None:
continue
metadata_updates[str(key)] = str(value)
base: Optional[ExtractedChapter] = None
idx_candidate = payload.get("index")
idx_normalized: Optional[int] = None
if isinstance(idx_candidate, int):
idx_normalized = idx_candidate
elif isinstance(idx_candidate, str):
try:
idx_normalized = int(idx_candidate)
except ValueError:
idx_normalized = None
if idx_normalized is not None and 0 <= idx_normalized < len(extracted):
base = extracted[idx_normalized]
payload["index"] = idx_normalized
if base is None:
source_title = payload.get("source_title")
if isinstance(source_title, str):
base = next((chapter for chapter in extracted if chapter.title == source_title), None)
if base is None:
candidate_title = payload.get("title")
if isinstance(candidate_title, str):
base = next((chapter for chapter in extracted if chapter.title == candidate_title), None)
text_override = payload.get("text")
if text_override is not None:
text_value = str(text_override)
elif base is not None:
text_value = base.text
else:
diagnostics.append(
f"Skipped chapter override at position {position + 1}: no text provided and no matching source chapter found."
)
continue
title_override = payload.get("title")
if title_override is not None:
title_value = str(title_override)
elif base is not None:
title_value = base.title
else:
title_value = f"Chapter {position + 1}"
if base and not payload.get("source_title"):
payload["source_title"] = base.title
payload["title"] = title_value
payload["text"] = text_value
payload["characters"] = len(text_value)
payload.setdefault("order", payload.get("order", position))
selected.append(ExtractedChapter(title=title_value, text=text_value))
return selected, metadata_updates, diagnostics
def _merge_metadata(
extracted: Optional[Dict[str, str]],
overrides: Dict[str, Any],
) -> Dict[str, str]:
merged: Dict[str, str] = {}
if extracted:
for key, value in extracted.items():
if value is None:
continue
merged[str(key)] = str(value)
for key, value in (overrides or {}).items():
key_str = str(key)
if value is None:
merged.pop(key_str, None)
else:
merged[key_str] = str(value)
return merged
def run_conversion_job(job: Job) -> None: def run_conversion_job(job: Job) -> None:
job.add_log("Preparing conversion pipeline") job.add_log("Preparing conversion pipeline")
canceller = _make_canceller(job) canceller = _make_canceller(job)
@@ -53,11 +173,29 @@ def run_conversion_job(job: Job) -> None:
try: try:
pipeline = _load_pipeline(job) pipeline = _load_pipeline(job)
extraction = extract_from_path(job.stored_path) extraction = extract_from_path(job.stored_path)
job.metadata_tags = extraction.metadata or {}
metadata_overrides: Dict[str, Any] = dict(job.metadata_tags or {})
if job.chapters:
selected_chapters, chapter_metadata, diagnostics = _apply_chapter_overrides(
extraction.chapters,
job.chapters,
)
for message in diagnostics:
job.add_log(message, level="warning")
if selected_chapters:
extraction.chapters = selected_chapters
metadata_overrides.update(chapter_metadata)
job.add_log(
f"Chapter overrides applied: {len(selected_chapters)} selected.",
level="info",
)
else:
raise ValueError("No chapters were enabled in the requested job.")
job.metadata_tags = _merge_metadata(extraction.metadata, metadata_overrides)
total_characters = extraction.total_characters or calculate_text_length(extraction.combined_text) total_characters = extraction.total_characters or calculate_text_length(extraction.combined_text)
if job.total_characters == 0: job.total_characters = total_characters
job.total_characters = total_characters
job.add_log(f"Total characters: {job.total_characters:,}") job.add_log(f"Total characters: {job.total_characters:,}")
_apply_newline_policy(extraction.chapters, job.replace_single_newlines) _apply_newline_policy(extraction.chapters, job.replace_single_newlines)
@@ -237,9 +375,9 @@ def _select_device() -> str:
def _prepare_output_dir(job: Job) -> Path: def _prepare_output_dir(job: Job) -> Path:
from platformdirs import user_desktop_dir from platformdirs import user_desktop_dir # type: ignore[import-not-found]
default_output = Path(get_user_cache_path("outputs")) default_output = Path(str(get_user_cache_path("outputs")))
if job.save_mode == "Save to Desktop": if job.save_mode == "Save to Desktop":
directory = Path(user_desktop_dir()) directory = Path(user_desktop_dir())
elif job.save_mode == "Save next to input file": elif job.save_mode == "Save next to input file":
+144 -4
View File
@@ -6,7 +6,7 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Callable, Dict, Iterable, List, Optional from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping
class JobStatus(str, Enum): class JobStatus(str, Enum):
@@ -64,7 +64,7 @@ class Job:
logs: List[JobLog] = field(default_factory=list) logs: List[JobLog] = field(default_factory=list)
error: Optional[str] = None error: Optional[str] = None
result: JobResult = field(default_factory=JobResult) result: JobResult = field(default_factory=JobResult)
chapters: List[str] = field(default_factory=list) chapters: List[Dict[str, Any]] = field(default_factory=list)
queue_position: Optional[int] = None queue_position: Optional[int] = None
cancel_requested: bool = False cancel_requested: bool = False
@@ -100,6 +100,18 @@ class Job:
"voice_profile": self.voice_profile, "voice_profile": self.voice_profile,
"max_subtitle_words": self.max_subtitle_words, "max_subtitle_words": self.max_subtitle_words,
}, },
"metadata_tags": dict(self.metadata_tags),
"chapters": [
{
"id": entry.get("id"),
"index": entry.get("index"),
"order": entry.get("order"),
"title": entry.get("title"),
"enabled": bool(entry.get("enabled", True)),
"characters": len(str(entry.get("text", ""))),
}
for entry in self.chapters
],
} }
@@ -149,7 +161,7 @@ class ConversionService:
replace_single_newlines: bool, replace_single_newlines: bool,
subtitle_format: str, subtitle_format: str,
total_characters: int, total_characters: int,
chapters: Optional[Iterable[str]] = None, chapters: Optional[Iterable[Any]] = None,
save_chapters_separately: bool = False, save_chapters_separately: bool = False,
merge_chapters_at_end: bool = True, merge_chapters_at_end: bool = True,
separate_chapters_format: str = "wav", separate_chapters_format: str = "wav",
@@ -157,8 +169,13 @@ class ConversionService:
save_as_project: bool = False, save_as_project: bool = False,
voice_profile: Optional[str] = None, voice_profile: Optional[str] = None,
max_subtitle_words: int = 50, max_subtitle_words: int = 50,
metadata_tags: Optional[Mapping[str, Any]] = None,
) -> Job: ) -> Job:
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
normalized_metadata = self._normalize_metadata_tags(metadata_tags)
normalized_chapters = self._normalize_chapters(chapters)
if total_characters <= 0 and normalized_chapters:
total_characters = sum(len(str(entry.get("text", ""))) for entry in normalized_chapters)
job = Job( job = Job(
id=job_id, id=job_id,
original_filename=original_filename, original_filename=original_filename,
@@ -180,9 +197,10 @@ class ConversionService:
save_as_project=save_as_project, save_as_project=save_as_project,
voice_profile=voice_profile, voice_profile=voice_profile,
max_subtitle_words=max_subtitle_words, max_subtitle_words=max_subtitle_words,
metadata_tags=normalized_metadata,
created_at=time.time(), created_at=time.time(),
total_characters=total_characters, total_characters=total_characters,
chapters=list(chapters or []), chapters=normalized_chapters,
) )
with self._lock: with self._lock:
self._jobs[job_id] = job self._jobs[job_id] = job
@@ -322,6 +340,128 @@ class ConversionService:
if job: if job:
job.queue_position = index job.queue_position = index
@staticmethod
def _coerce_bool(value: Any, default: bool = True) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"true", "1", "yes", "on"}:
return True
if lowered in {"false", "0", "no", "off"}:
return False
return default
if value is None:
return default
return bool(value)
@staticmethod
def _coerce_optional_int(value: Any) -> Optional[int]:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _normalize_metadata_tags(values: Optional[Mapping[str, Any]]) -> Dict[str, str]:
if not values:
return {}
normalized: Dict[str, str] = {}
for key, raw_value in values.items():
if raw_value is None:
continue
key_str = str(key).strip()
if not key_str:
continue
normalized[key_str] = str(raw_value)
return normalized
@classmethod
def _normalize_chapters(cls, chapters: Optional[Iterable[Any]]) -> List[Dict[str, Any]]:
if not chapters:
return []
normalized: List[Dict[str, Any]] = []
for order, raw in enumerate(chapters):
if raw is None:
continue
if isinstance(raw, str):
raw_dict: Dict[str, Any] = {"title": raw}
elif isinstance(raw, dict):
raw_dict = dict(raw)
else:
continue
entry: Dict[str, Any] = {}
id_value = raw_dict.get("id") or raw_dict.get("chapter_id") or raw_dict.get("key")
if id_value is not None:
entry["id"] = str(id_value)
index_value = (
cls._coerce_optional_int(raw_dict.get("index"))
or cls._coerce_optional_int(raw_dict.get("original_index"))
or cls._coerce_optional_int(raw_dict.get("source_index"))
or cls._coerce_optional_int(raw_dict.get("chapter_index"))
)
if index_value is not None:
entry["index"] = index_value
order_value = (
cls._coerce_optional_int(raw_dict.get("order"))
or cls._coerce_optional_int(raw_dict.get("position"))
or cls._coerce_optional_int(raw_dict.get("sort"))
or cls._coerce_optional_int(raw_dict.get("sort_order"))
)
entry["order"] = order_value if order_value is not None else order
source_title = (
raw_dict.get("source_title")
or raw_dict.get("original_title")
or raw_dict.get("base_title")
)
if source_title:
entry["source_title"] = str(source_title)
title_value = (
raw_dict.get("title")
or raw_dict.get("name")
or raw_dict.get("label")
or raw_dict.get("chapter")
)
if title_value is not None:
entry["title"] = str(title_value)
elif source_title:
entry["title"] = str(source_title)
else:
entry["title"] = f"Chapter {order + 1}"
text_value = raw_dict.get("text")
if text_value is None:
text_value = raw_dict.get("content") or raw_dict.get("body") or raw_dict.get("value")
if text_value is not None:
entry["text"] = str(text_value)
enabled = cls._coerce_bool(
raw_dict.get("enabled", raw_dict.get("include", raw_dict.get("selected", True))),
True,
)
if "disabled" in raw_dict and cls._coerce_bool(raw_dict.get("disabled"), False):
enabled = False
entry["enabled"] = enabled
metadata_payload = raw_dict.get("metadata") or raw_dict.get("metadata_tags")
normalized_metadata = cls._normalize_metadata_tags(metadata_payload)
if normalized_metadata:
entry["metadata"] = normalized_metadata
normalized.append(entry)
return normalized
def default_storage_root() -> Path: def default_storage_root() -> Path:
base = Path.cwd() base = Path.cwd()
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
import sys
import types
def _install_dependency_stubs() -> None:
if "ebooklib" not in sys.modules:
ebooklib_stub = types.ModuleType("ebooklib")
epub_stub = types.ModuleType("ebooklib.epub")
setattr(ebooklib_stub, "epub", epub_stub)
sys.modules["ebooklib"] = ebooklib_stub
sys.modules["ebooklib.epub"] = epub_stub
if "dotenv" not in sys.modules:
dotenv_stub = types.ModuleType("dotenv")
def _noop(*_, **__):
return None
setattr(dotenv_stub, "load_dotenv", _noop)
setattr(dotenv_stub, "find_dotenv", lambda *_, **__: "")
sys.modules["dotenv"] = dotenv_stub
if "numpy" not in sys.modules:
numpy_stub = types.ModuleType("numpy")
class _DummyArray(list):
pass
def _zeros(shape, dtype=None):
size = 1
if isinstance(shape, int):
size = shape
elif shape:
size = 1
for dimension in shape:
size *= int(dimension)
return [0.0] * size
setattr(numpy_stub, "ndarray", _DummyArray)
setattr(numpy_stub, "zeros", _zeros)
setattr(numpy_stub, "float32", "float32")
setattr(numpy_stub, "array", lambda data, dtype=None: data)
setattr(numpy_stub, "asarray", lambda data, dtype=None: data)
setattr(numpy_stub, "concatenate", lambda seq, axis=0: sum((list(item) for item in seq), []))
sys.modules["numpy"] = numpy_stub
if "soundfile" not in sys.modules:
soundfile_stub = types.ModuleType("soundfile")
class _DummySoundFile:
def __init__(self, *_, **__):
pass
def write(self, *_args, **_kwargs):
return None
def close(self):
return None
setattr(soundfile_stub, "SoundFile", _DummySoundFile)
setattr(soundfile_stub, "write", lambda *_args, **_kwargs: None)
sys.modules["soundfile"] = soundfile_stub
if "fitz" not in sys.modules:
sys.modules["fitz"] = types.ModuleType("fitz")
if "markdown" not in sys.modules:
markdown_stub = types.ModuleType("markdown")
class _DummyMarkdown:
def __init__(self, *_, **__):
pass
def convert(self, text: str) -> str:
return text
setattr(markdown_stub, "Markdown", _DummyMarkdown)
sys.modules["markdown"] = markdown_stub
if "bs4" not in sys.modules:
bs4_stub = types.ModuleType("bs4")
class _DummySoup:
def __init__(self, *_, **__):
pass
def select(self, *_, **__):
return []
def find_all(self, *_, **__):
return []
setattr(bs4_stub, "BeautifulSoup", _DummySoup)
setattr(bs4_stub, "NavigableString", str)
sys.modules["bs4"] = bs4_stub
_install_dependency_stubs()
from abogen.text_extractor import ExtractedChapter
from abogen.web.conversion_runner import _apply_chapter_overrides, _merge_metadata
def _sample_chapters() -> list[ExtractedChapter]:
return [
ExtractedChapter(title="Chapter 1", text="Original one"),
ExtractedChapter(title="Chapter 2", text="Original two"),
ExtractedChapter(title="Chapter 3", text="Original three"),
]
def test_apply_chapter_overrides_with_custom_text() -> None:
overrides = [
{"index": 0, "enabled": True, "title": "Intro", "text": "Hello world"},
{"index": 1, "enabled": False},
]
selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides)
assert len(selected) == 1
assert selected[0].title == "Intro"
assert selected[0].text == "Hello world"
assert overrides[0]["characters"] == len("Hello world")
assert metadata == {}
assert diagnostics == []
def test_apply_chapter_overrides_uses_original_content_when_text_missing() -> None:
overrides = [
{"index": 1, "enabled": True},
]
selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides)
assert len(selected) == 1
assert selected[0].title == "Chapter 2"
assert selected[0].text == "Original two"
assert overrides[0]["text"] == "Original two"
assert overrides[0]["characters"] == len("Original two")
assert metadata == {}
assert diagnostics == []
def test_apply_chapter_overrides_collects_metadata_updates() -> None:
overrides = [
{
"index": 2,
"enabled": True,
"metadata": {"artist": "Test Author", "year": 2024},
}
]
selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides)
assert len(selected) == 1
assert metadata == {"artist": "Test Author", "year": "2024"}
assert diagnostics == []
def test_apply_chapter_overrides_reports_diagnostics_for_invalid_payload() -> None:
overrides = [
{"enabled": True, "title": "Missing"},
]
selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides)
assert selected == []
assert metadata == {}
assert diagnostics and "Skipped chapter override" in diagnostics[0]
def test_merge_metadata_prefers_overrides_and_drops_none_values() -> None:
extracted = {"title": "Original", "artist": "Someone"}
overrides = {"artist": "Another", "genre": "Fiction", "year": None}
merged = _merge_metadata(extracted, overrides)
assert merged["title"] == "Original"
assert merged["artist"] == "Another"
assert merged["genre"] == "Fiction"
assert "year" not in merged