refactor: extract book metadata logic from PyQt to domain

- domain/metadata_extraction.py: add format_metadata_tags(),
  extract_book_metadata_epub(), extract_book_metadata_pdf(),
  extract_book_metadata_markdown(), _save_cover_to_cache()
- pyqt/book_handler.py: _extract_book_metadata() reduced from ~165 lines
  to ~10 lines by delegating to domain; _format_metadata_tags() reduced
  from ~55 lines to ~15 lines; ebooklib/fitz imports moved to domain
- +16 tests (format_metadata_tags, save_cover, markdown extraction)
- 1169 tests pass
This commit is contained in:
Artem Akymenko
2026-07-19 09:51:50 +00:00
parent 0e216f3786
commit fe62b6b44c
3 changed files with 428 additions and 210 deletions
+294 -3
View File
@@ -1,16 +1,20 @@
"""Metadata extraction and processing utilities. """Metadata extraction and processing utilities.
This module provides functions for extracting metadata from text content This module provides functions for extracting metadata from text content,
and generating ffmpeg metadata arguments. formatting metadata tags for TTS embedding, and generating ffmpeg metadata arguments.
""" """
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import logging
import os import os
import re import re
import uuid
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
def extract_metadata_from_text(text: str) -> Dict[str, Optional[str]]: def extract_metadata_from_text(text: str) -> Dict[str, Optional[str]]:
@@ -189,3 +193,290 @@ def read_text_for_metadata(
return f.read() return f.read()
except Exception: except Exception:
return "" return ""
def format_metadata_tags(
metadata: Dict[str, Any],
filename: str,
chapter_count: int,
file_type: str,
cover_bytes: Optional[bytes] = None,
cache_dir: Optional[str] = None,
) -> str:
"""Format metadata tags for insertion into TTS text.
Builds <<METADATA_KEY:value>> tags that are later parsed by
extract_metadata_from_text() and fed to ffmpeg.
Args:
metadata: Dict with keys like 'title', 'authors' (list),
'publication_year', 'description', 'cover_image' (bytes).
filename: Fallback filename (without extension) for title/album.
chapter_count: Number of chapters/pages.
file_type: 'epub', 'pdf', or 'markdown'.
cover_bytes: Optional cover image bytes to save to cache.
cache_dir: Directory for cover cache (uses default if None).
Returns:
Newline-joined string of <<METADATA_KEY:value>> tags.
"""
title = metadata.get("title") or filename
authors = metadata.get("authors") or ["Unknown"]
authors_text = ", ".join(authors) if isinstance(authors, list) else str(authors)
year = metadata.get("publication_year") or str(datetime.datetime.now().year)
chapter_label = "Chapters" if file_type in ("epub", "markdown") else "Pages"
chapter_text = f"{chapter_count} {chapter_label}"
tags = [
f"<<METADATA_TITLE:{title}>>",
f"<<METADATA_ARTIST:{authors_text}>>",
f"<<METADATA_ALBUM:{title} ({chapter_text})>>",
f"<<METADATA_YEAR:{year}>>",
f"<<METADATA_ALBUM_ARTIST:{authors_text}>>",
f"<<METADATA_COMPOSER:Narrator>>",
f"<<METADATA_GENRE:Audiobook>>",
]
cover_path = _save_cover_to_cache(cover_bytes, cache_dir)
if cover_path:
tags.append(f"<<METADATA_COVER_PATH:{cover_path}>>")
return "\n".join(tags)
def _save_cover_to_cache(
cover_bytes: Optional[bytes],
cache_dir: Optional[str] = None,
) -> Optional[str]:
"""Save cover image bytes to cache directory.
Args:
cover_bytes: Raw image bytes (e.g. JPEG/PNG).
cache_dir: Directory to save to. If None, returns None.
Returns:
Normalized path to saved cover file, or None on failure.
"""
if not cover_bytes:
return None
if cache_dir is None:
return None
try:
cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg")
cover_path = os.path.normpath(cover_path)
with open(cover_path, "wb") as f:
f.write(cover_bytes)
return cover_path
except Exception as e:
logger.warning("Failed to save cover image: %s", e)
return None
def extract_book_metadata_epub(book: Any) -> Dict[str, Any]:
"""Extract metadata from an opened ebooklib EPUB book.
Args:
book: An opened ebooklib EPUB book object.
Returns:
Dict with keys: title, authors, description, publisher,
publication_year, cover_image (bytes or None).
"""
import ebooklib
metadata: Dict[str, Any] = {
"title": None,
"authors": [],
"description": None,
"cover_image": None,
"publisher": None,
"publication_year": None,
}
try:
title_items = book.get_metadata("DC", "title")
if title_items and len(title_items) > 0:
metadata["title"] = title_items[0][0]
except Exception as e:
logger.warning("Error extracting title metadata: %s", e)
try:
author_items = book.get_metadata("DC", "creator")
if author_items:
metadata["authors"] = [
author[0] for author in author_items if len(author) > 0
]
except Exception as e:
logger.warning("Error extracting author metadata: %s", e)
try:
desc_items = book.get_metadata("DC", "description")
if desc_items and len(desc_items) > 0:
metadata["description"] = desc_items[0][0]
except Exception as e:
logger.warning("Error extracting description metadata: %s", e)
try:
publisher_items = book.get_metadata("DC", "publisher")
if publisher_items and len(publisher_items) > 0:
metadata["publisher"] = publisher_items[0][0]
except Exception as e:
logger.warning("Error extracting publisher metadata: %s", e)
try:
date_items = book.get_metadata("DC", "date")
if date_items and len(date_items) > 0:
date_str = date_items[0][0]
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
if year_match:
metadata["publication_year"] = year_match.group(0)
else:
metadata["publication_year"] = date_str
except Exception as e:
logger.warning("Error extracting publication date metadata: %s", e)
for item in book.get_items_of_type(ebooklib.ITEM_COVER):
metadata["cover_image"] = item.get_content()
break
if not metadata["cover_image"]:
for item in book.get_items_of_type(ebooklib.ITEM_IMAGE):
if "cover" in item.get_name().lower():
metadata["cover_image"] = item.get_content()
break
return metadata
def extract_book_metadata_pdf(pdf_doc: Any) -> Dict[str, Any]:
"""Extract metadata from an opened PyMuPDF document.
Args:
pdf_doc: An opened fitz.Document object.
Returns:
Dict with keys: title, authors, description, publisher,
publication_year, cover_image (bytes or None).
"""
metadata: Dict[str, Any] = {
"title": None,
"authors": [],
"description": None,
"cover_image": None,
"publisher": None,
"publication_year": None,
}
pdf_info = pdf_doc.metadata
if pdf_info:
metadata["title"] = pdf_info.get("title", None)
author = pdf_info.get("author", None)
if author:
metadata["authors"] = [author]
metadata["description"] = pdf_info.get("subject", None)
keywords = pdf_info.get("keywords", None)
if keywords:
if metadata["description"]:
metadata["description"] += f"\n\nKeywords: {keywords}"
else:
metadata["description"] = f"Keywords: {keywords}"
metadata["publisher"] = pdf_info.get("creator", None)
if "creationDate" in pdf_info:
date_str = pdf_info["creationDate"]
year_match = re.search(r"D:(\d{4})", date_str)
if year_match:
metadata["publication_year"] = year_match.group(1)
elif "modDate" in pdf_info:
date_str = pdf_info["modDate"]
year_match = re.search(r"D:(\d{4})", date_str)
if year_match:
metadata["publication_year"] = year_match.group(1)
if len(pdf_doc) > 0:
try:
import fitz
pix = pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2))
metadata["cover_image"] = pix.tobytes("png")
except Exception:
pass
return metadata
def extract_book_metadata_markdown(
markdown_text: str,
markdown_toc: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""Extract metadata from markdown frontmatter and first heading.
Args:
markdown_text: Raw markdown text content.
markdown_toc: Optional table of contents list (each item has
'level' and 'name' keys).
Returns:
Dict with keys: title, authors, description, publication_year.
cover_image is always None for markdown.
"""
metadata: Dict[str, Any] = {
"title": None,
"authors": [],
"description": None,
"cover_image": None,
"publisher": None,
"publication_year": None,
}
if not markdown_text:
return metadata
frontmatter_match = re.match(
r"^---\s*\n(.*?)\n---\s*\n", markdown_text, re.DOTALL
)
if frontmatter_match:
try:
frontmatter = frontmatter_match.group(1)
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)
except Exception as e:
logger.warning("Error parsing markdown frontmatter: %s", e)
if not metadata["title"] and markdown_toc:
first_h1 = next(
(h for h in markdown_toc if h.get("level") == 1), None
)
if first_h1:
metadata["title"] = first_h1.get("name")
return metadata
+20 -207
View File
@@ -29,6 +29,12 @@ from abogen.utils import (
get_resource_path, get_resource_path,
) )
from abogen.book_parser import get_book_parser from abogen.book_parser import get_book_parser
from abogen.domain.metadata_extraction import (
extract_book_metadata_epub,
extract_book_metadata_pdf,
extract_book_metadata_markdown,
format_metadata_tags,
)
from abogen.subtitle_utils import ( from abogen.subtitle_utils import (
clean_text, clean_text,
@@ -948,169 +954,14 @@ class HandlerDialog(QDialog):
self.previewEdit.setHtml(html_content) self.previewEdit.setHtml(html_content)
def _extract_book_metadata(self): def _extract_book_metadata(self):
metadata = {
"title": None,
"authors": [],
"description": None,
"cover_image": None,
"publisher": None,
"publication_year": None,
}
if self.parser.file_type == "epub": if self.parser.file_type == "epub":
try: return extract_book_metadata_epub(self.book)
title_items = self.book.get_metadata("DC", "title")
if title_items and len(title_items) > 0:
metadata["title"] = title_items[0][0]
except Exception as e:
logging.warning(f"Error extracting title metadata: {e}")
try:
author_items = self.book.get_metadata("DC", "creator")
if author_items:
metadata["authors"] = [
author[0] for author in author_items if len(author) > 0
]
except Exception as e:
logging.warning(f"Error extracting author metadata: {e}")
try:
desc_items = self.book.get_metadata("DC", "description")
if desc_items and len(desc_items) > 0:
metadata["description"] = desc_items[0][0]
except Exception as e:
logging.warning(f"Error extracting description metadata: {e}")
try:
publisher_items = self.book.get_metadata("DC", "publisher")
if publisher_items and len(publisher_items) > 0:
metadata["publisher"] = publisher_items[0][0]
except Exception as e:
logging.warning(f"Error extracting publisher metadata: {e}")
# Try to extract publication year
try:
date_items = self.book.get_metadata("DC", "date")
if date_items and len(date_items) > 0:
date_str = date_items[0][0]
# Try to extract just the year from the date string
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
if year_match:
metadata["publication_year"] = year_match.group(0)
else:
metadata["publication_year"] = date_str
except Exception as e:
logging.warning(f"Error extracting publication date metadata: {e}")
for item in self.book.get_items_of_type(ebooklib.ITEM_COVER):
metadata["cover_image"] = item.get_content()
break
if not metadata["cover_image"]:
for item in self.book.get_items_of_type(ebooklib.ITEM_IMAGE):
if "cover" in item.get_name().lower():
metadata["cover_image"] = item.get_content()
break
elif self.parser.file_type == "markdown": elif self.parser.file_type == "markdown":
# Extract metadata from markdown frontmatter or first heading return extract_book_metadata_markdown(
if self.markdown_text: self.markdown_text, self.markdown_toc
# Try to extract YAML frontmatter )
frontmatter_match = re.match(
r"^---\s*\n(.*?)\n---\s*\n", self.markdown_text, re.DOTALL
)
if frontmatter_match:
try:
frontmatter = frontmatter_match.group(1)
# Simple YAML-like parsing for common fields
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)
except Exception as e:
logging.warning(f"Error parsing markdown frontmatter: {e}")
# Fallback: use first H1 header as title if no frontmatter title
if not metadata["title"] and self.markdown_toc:
# Find the first level 1 header
first_h1 = next(
(h for h in self.markdown_toc if h["level"] == 1), None
)
if first_h1:
metadata["title"] = first_h1["name"]
else: else:
pdf_info = self.pdf_doc.metadata return extract_book_metadata_pdf(self.pdf_doc)
if pdf_info:
metadata["title"] = pdf_info.get("title", None)
author = pdf_info.get("author", None)
if author:
metadata["authors"] = [author]
metadata["description"] = pdf_info.get("subject", None)
keywords = pdf_info.get("keywords", None)
if keywords:
if metadata["description"]:
metadata["description"] += f"\n\nKeywords: {keywords}"
else:
metadata["description"] = f"Keywords: {keywords}"
metadata["publisher"] = pdf_info.get("creator", None)
# Try to extract publication date from PDF metadata
if "creationDate" in pdf_info:
date_str = pdf_info["creationDate"]
year_match = re.search(r"D:(\d{4})", date_str)
if year_match:
metadata["publication_year"] = year_match.group(1)
elif "modDate" in pdf_info:
date_str = pdf_info["modDate"]
year_match = re.search(r"D:(\d{4})", date_str)
if year_match:
metadata["publication_year"] = year_match.group(1)
if len(self.pdf_doc) > 0:
try:
pix = self.pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2))
metadata["cover_image"] = pix.tobytes("png")
except Exception:
pass
return metadata
def get_selected_text(self): def get_selected_text(self):
# If a background loader thread is running, wait for it to finish to # If a background loader thread is running, wait for it to finish to
@@ -1136,59 +987,21 @@ class HandlerDialog(QDialog):
def _format_metadata_tags(self): def _format_metadata_tags(self):
"""Format metadata tags for insertion at the beginning of the text""" """Format metadata tags for insertion at the beginning of the text"""
import datetime
from abogen.utils import get_user_cache_path from abogen.utils import get_user_cache_path
metadata = self.book_metadata
filename = os.path.splitext(os.path.basename(self.book_path))[0] filename = os.path.splitext(os.path.basename(self.book_path))[0]
current_year = str(datetime.datetime.now().year) chapter_count = len(self.checked_chapters)
cache_dir = get_user_cache_path()
# Get values with fallbacks return format_metadata_tags(
title = metadata.get("title") or filename self.book_metadata,
authors = metadata.get("authors") or ["Unknown"] filename,
authors_text = ", ".join(authors) chapter_count,
album_artist = authors_text or "Unknown" self.parser.file_type,
year = ( cover_bytes=self.book_metadata.get("cover_image"),
metadata.get("publication_year") or current_year cache_dir=cache_dir,
) # Use publication year if available
# Count chapters/pages
total_chapters = len(self.checked_chapters)
chapter_text = (
f"{total_chapters} {'Chapters' if self.parser.file_type == 'epub' else 'Pages'}"
) )
# Handle cover image
cover_tag = ""
if metadata.get("cover_image"):
try:
import uuid
cache_dir = get_user_cache_path()
cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg")
cover_path = os.path.normpath(cover_path)
with open(cover_path, "wb") as f:
f.write(metadata["cover_image"])
cover_tag = f"<<METADATA_COVER_PATH:{cover_path}>>"
except Exception as e:
logging.warning(f"Failed to save cover image: {e}")
# Format metadata tags
metadata_tags = [
f"<<METADATA_TITLE:{title}>>",
f"<<METADATA_ARTIST:{authors_text}>>",
f"<<METADATA_ALBUM:{title} ({chapter_text})>>",
f"<<METADATA_YEAR:{year}>>",
f"<<METADATA_ALBUM_ARTIST:{album_artist}>>",
f"<<METADATA_COMPOSER:Narrator>>",
f"<<METADATA_GENRE:Audiobook>>",
]
if cover_tag:
metadata_tags.append(cover_tag)
return "\n".join(metadata_tags)
def _get_markdown_selected_text(self): def _get_markdown_selected_text(self):
"""Get selected text from markdown chapters""" """Get selected text from markdown chapters"""
all_checked_identifiers = set() all_checked_identifiers = set()
+114
View File
@@ -0,0 +1,114 @@
"""Tests for domain/metadata_extraction.py — format_metadata_tags, extract_book_metadata_*."""
import os
import pytest
from abogen.domain.metadata_extraction import (
extract_book_metadata_markdown,
format_metadata_tags,
_save_cover_to_cache,
)
class TestFormatMetadataTags:
def test_basic_epub(self):
metadata = {
"title": "My Book",
"authors": ["Author One", "Author Two"],
"publication_year": "2023",
}
result = format_metadata_tags(metadata, "fallback", 10, "epub")
assert "<<METADATA_TITLE:My Book>>" in result
assert "<<METADATA_ARTIST:Author One, Author Two>>" in result
assert "<<METADATA_ALBUM:My Book (10 Chapters)>>" in result
assert "<<METADATA_YEAR:2023>>" in result
assert "<<METADATA_GENRE:Audiobook>>" in result
def test_pdf_uses_pages(self):
metadata = {"title": "PDF Doc", "authors": ["Writer"]}
result = format_metadata_tags(metadata, "doc", 50, "pdf")
assert "50 Pages" in result
def test_markdown_uses_chapters(self):
metadata = {"title": "MD Doc"}
result = format_metadata_tags(metadata, "doc", 3, "markdown")
assert "3 Chapters" in result
def test_fallback_title(self):
metadata = {}
result = format_metadata_tags(metadata, "fallback_name", 1, "epub")
assert "<<METADATA_TITLE:fallback_name>>" in result
def test_unknown_authors(self):
metadata = {"authors": []}
result = format_metadata_tags(metadata, "file", 1, "epub")
assert "<<METADATA_ARTIST:Unknown>>" in result
def test_cover_bytes_saved(self, tmp_path):
metadata = {"title": "With Cover"}
cover = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 # Fake image bytes
result = format_metadata_tags(
metadata, "file", 1, "epub",
cover_bytes=cover, cache_dir=str(tmp_path),
)
assert "<<METADATA_COVER_PATH:" in result
# Verify file was created
cover_files = list(tmp_path.glob("cover_*.jpg"))
assert len(cover_files) == 1
assert cover_files[0].read_bytes() == cover
def test_no_cover_bytes(self):
metadata = {"title": "No Cover"}
result = format_metadata_tags(metadata, "file", 1, "epub")
assert "METADATA_COVER_PATH" not in result
def test_authors_as_string(self):
metadata = {"authors": "Single Author"}
result = format_metadata_tags(metadata, "file", 1, "epub")
assert "<<METADATA_ARTIST:Single Author>>" in result
class TestSaveCoverToCache:
def test_saves_file(self, tmp_path):
data = b"\x89PNG" + b"\x00" * 50
result = _save_cover_to_cache(data, str(tmp_path))
assert result is not None
assert os.path.exists(result)
assert open(result, "rb").read() == data
def test_none_bytes(self, tmp_path):
assert _save_cover_to_cache(None, str(tmp_path)) is None
def test_none_cache_dir(self):
assert _save_cover_to_cache(b"data", None) is None
def test_returns_normalized_path(self, tmp_path):
result = _save_cover_to_cache(b"data", str(tmp_path))
assert result == os.path.normpath(result)
class TestExtractBookMetadataMarkdown:
def test_frontmatter(self):
text = "---\ntitle: Test Title\nauthor: Test Author\ndate: 2024\n---\n\nBody"
result = extract_book_metadata_markdown(text)
assert result["title"] == "Test Title"
assert result["authors"] == ["Test Author"]
assert result["publication_year"] == "2024"
def test_fallback_to_h1(self, ):
text = "# My Heading\n\nSome content"
toc = [{"level": 1, "name": "My Heading"}]
result = extract_book_metadata_markdown(text, toc)
assert result["title"] == "My Heading"
def test_empty_text(self):
result = extract_book_metadata_markdown("")
assert result["title"] is None
assert result["authors"] == []
def test_frontmatter_with_quotes(self):
text = '---\ntitle: "Quoted Title"\nauthor: \'Quoted Author\'\n---\n\nBody'
result = extract_book_metadata_markdown(text)
assert result["title"] == "Quoted Title"
assert result["authors"] == ["Quoted Author"]