feat(scanner): extract EPUB metadata from the embedded OPF directly

go-epub's ReadBook parses every spine chapter and fails the entire call
if any single chapter (or the TOC) is malformed, discarding already-parsed
OPF metadata. For books like Pragmatic's 'A Common-Sense Guide' the OPF
holds good title/author/publisher/ISBN metadata that rescans then wrote
as blanks - success toast, no (visible) change.

Refactor to parse the EPUB's own OPF document with the same Dublin Core
machinery used for Calibre sidecars: parseCalibreMetadataOPF is now a thin
file wrapper around a reusable parseOPFContent([]byte), and the OPF lookup
previously inline in extractEPUBCover is shared via findOPFPathInZip. Since
metadata never touches chapter bodies, chapter damage cannot blank it.

Also picked up along the way: dc:language mapping and scheme-less
dc:identifier values that normalize to a valid ISBN (EPUB3 style).

Tests cover a Pragmatic-style EPUB (dc namespace on <metadata>, no
identifier scheme, deliberately malformed chapter) that must still yield
full metadata, plus series/date/subject OPF parsing.
This commit is contained in:
John O'Keefe
2026-09-12 17:49:30 -04:00
parent 7a9a66fcc5
commit 61681aac23
2 changed files with 248 additions and 117 deletions
+94 -117
View File
@@ -1347,89 +1347,38 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
}
}
// extractEPUBMetadata extracts metadata from an EPUB by parsing its embedded
// OPF document directly (container.xml → OPF → Dublin Core elements).
//
// It deliberately does NOT use a full-book parser: the previous go-epub
// implementation parsed every spine chapter and failed the whole call when any
// single chapter (or the TOC) was malformed, discarding perfectly good OPF
// metadata and leaving rescans writing blanks. The OPF holds all the metadata
// we need; chapter damage can no longer affect it.
func (s *MediaScanner) extractEPUBMetadata(path string) (*MediaMetadata, error) {
book, err := epub.ReadBook(path)
r, err := zip.OpenReader(path)
if err != nil {
return nil, fmt.Errorf("failed to open EPUB: %v", err)
}
metadata := &MediaMetadata{}
// Title
if title, err := book.Title(); err == nil && title != "" {
metadata.Title = title
}
// Author
if authors, err := book.MetadataByKey("creator"); err == nil && len(authors) > 0 {
metadata.Author = authors[0]
}
// Description
if descriptions, err := book.MetadataByKey("description"); err == nil && len(descriptions) > 0 {
metadata.Description = descriptions[0]
}
// Publisher
if publishers, err := book.MetadataByKey("publisher"); err == nil && len(publishers) > 0 {
metadata.Publisher = publishers[0]
}
// Series and series number (Calibre specific metadata)
if series, err := book.MetadataByKey("calibre:series"); err == nil && len(series) > 0 {
metadata.Series = series[0]
}
if seriesIndex, err := book.MetadataByKey("calibre:series_index"); err == nil && len(seriesIndex) > 0 {
if index, err := strconv.ParseFloat(seriesIndex[0], 32); err == nil {
metadata.SeriesNumber = int32(index)
defer func() {
if err := r.Close(); err != nil {
fmt.Printf("Warning: failed to close EPUB zip reader for %s: %v\n", path, err)
}
}()
opfPath := findOPFPathInZip(r.File)
if opfPath == "" {
return nil, fmt.Errorf("no OPF document found in EPUB %s", path)
}
opfContent, err := readFileFromZip(r.File, opfPath)
if err != nil {
return nil, fmt.Errorf("failed to read OPF %s from EPUB: %v", opfPath, err)
}
// Publish date
if dates, err := book.MetadataByKey("date"); err == nil && len(dates) > 0 {
if date, err := time.Parse("2006-01-02", dates[0]); err == nil {
metadata.PublishDate = date
} else {
// Try alternative date formats
if date, err := time.Parse("2006", dates[0]); err == nil {
metadata.PublishDate = date
}
}
metadata, err := parseOPFContent(opfContent)
if err != nil {
return nil, fmt.Errorf("failed to parse OPF in EPUB %s: %v", path, err)
}
// Contributors
if contributors, err := book.MetadataByKey("contributor"); err == nil && len(contributors) > 0 {
// Normalize contributors for display
metadata.Contributors = utils.NormalizeContributors(contributors)
}
// ISBN
if isbns, err := book.MetadataByKey("identifier"); err == nil && len(isbns) > 0 {
for _, isbn := range isbns {
if strings.Contains(strings.ToLower(isbn), "isbn") {
// Extract ISBN number from identifier like "isbn:978-3-16-148410-0"
isbnParts := strings.SplitN(isbn, ":", 2)
if len(isbnParts) == 2 {
metadata.ISBN = isbnParts[1]
break
}
}
if strings.Contains(strings.ToLower(isbn), "asin") {
// Extract ASIN from identifier like "asin:B08XXXXX"
asinParts := strings.SplitN(isbn, ":", 2)
if len(asinParts) == 2 {
metadata.ASIN = asinParts[1]
}
}
}
}
// Tags
if tags, err := book.MetadataByKey("subject"); err == nil && len(tags) > 0 {
// Normalize tags for display
metadata.Tags = utils.NormalizeTags(tags)
}
return metadata, nil
}
@@ -1604,7 +1553,7 @@ func (s *MediaScanner) LogProcessingIssue(
return err
}
// parseCalibreMetadataOPF parses a Calibre metadata.opf file and extracts metadata
// parseCalibreMetadataOPF parses a Calibre metadata.opf sidecar file.
func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata, error) {
// Open file
file, err := os.Open(opfPath)
@@ -1616,6 +1565,18 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
fmt.Printf("Warning: failed to close metadata.opf: %v\n", err)
}
}()
content, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read metadata.opf: %v", err)
}
return parseOPFContent(content)
}
// parseOPFContent parses an OPF document (Dublin Core metadata) into
// MediaMetadata. Used for both Calibre metadata.opf sidecars and the OPF
// embedded inside an EPUB - the dc:* vocabulary is identical. Namespace-aware
// parsing means it tolerates wherever the xmlns:dc declaration lives.
func parseOPFContent(content []byte) (*MediaMetadata, error) {
// Define XML structure for parsing with full Dublin Core namespace URLs
var opf struct {
XMLName xml.Name `xml:"package"`
@@ -1641,8 +1602,8 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
} `xml:"metadata"`
}
// Parse XML
if err := xml.NewDecoder(file).Decode(&opf); err != nil {
return nil, fmt.Errorf("failed to parse metadata.opf XML: %v", err)
if err := xml.NewDecoder(bytes.NewReader(content)).Decode(&opf); err != nil {
return nil, fmt.Errorf("failed to parse OPF XML: %v", err)
}
// Map to MediaMetadata struct
metadata := &MediaMetadata{}
@@ -1666,6 +1627,10 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
if len(opf.Metadata.Publisher) > 0 {
metadata.Publisher = opf.Metadata.Publisher[0]
}
// Language
if len(opf.Metadata.Language) > 0 && opf.Metadata.Language[0] != "" {
metadata.Language = opf.Metadata.Language[0]
}
// Publish date
if len(opf.Metadata.Dates) > 0 {
if date, err := time.Parse("2006-01-02T15:04:05Z07:00", opf.Metadata.Dates[0]); err == nil {
@@ -1681,14 +1646,23 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
}
// Identifiers (ISBN, ASIN)
for _, id := range opf.Metadata.Identifiers {
value := strings.TrimSpace(id.Value)
switch strings.ToUpper(id.Scheme) {
case "ISBN":
metadata.ISBN = utils.NormalizeISBNSafe(id.Value)
metadata.ISBN = utils.NormalizeISBNSafe(value)
case "ASIN":
metadata.ASIN = id.Value
metadata.ASIN = value
case "UUID", "CALIBRE":
// Store UUID in hash info, not metadata
// Will be extracted by extractHashInfo()
default:
// EPUB3 identifiers often carry no opf:scheme attribute;
// accept a bare value that normalizes to a valid ISBN.
if metadata.ISBN == "" && id.Scheme == "" {
if normalized := utils.NormalizeISBNSafe(value); normalized != "" {
metadata.ISBN = normalized
}
}
}
}
// Contributors
@@ -1715,6 +1689,45 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
return metadata, nil
}
// findOPFPathInZip locates the OPF document inside an EPUB by reading
// META-INF/container.xml (string-scraped; we only need the rootfile
// full-path attribute). Returns "" when absent.
func findOPFPathInZip(files []*zip.File) string {
for _, f := range files {
if f.Name != "META-INF/container.xml" {
continue
}
rc, err := f.Open()
if err != nil {
return ""
}
content, readErr := io.ReadAll(rc)
if closeErr := rc.Close(); closeErr != nil {
fmt.Printf("Warning: failed to close META-INF/container.xml reader: %v\n", closeErr)
}
if readErr != nil {
return ""
}
opfStart := bytes.Index(content, []byte("<rootfile "))
if opfStart == -1 {
return ""
}
opfStartAttr := bytes.Index(content[opfStart:], []byte("full-path="))
if opfStartAttr == -1 {
return ""
}
opfStartAttr += len("full-path=")
quote := content[opfStart+opfStartAttr]
opfStartQuote := opfStart + opfStartAttr + 1
opfEndQuote := bytes.Index(content[opfStartQuote:], []byte{quote})
if opfEndQuote == -1 {
return ""
}
return string(content[opfStartQuote : opfStartQuote+opfEndQuote])
}
return ""
}
// extractEPUBCover extracts the cover image from an EPUB file.
// It looks for:
// 1. An item with properties="cover-image" in the manifest
@@ -1736,43 +1749,7 @@ func (s *MediaScanner) extractEPUBCover(epubPath string) (string, error) {
// Try to find cover image from OPF metadata
coverImageName := ""
// Attempt to read the OPF file to find cover reference
// First, find container.xml to locate the OPF
var opfPath string
for _, f := range r.File {
if f.Name == "META-INF/container.xml" {
rc, err := f.Open()
if err != nil {
continue
}
content, readErr := io.ReadAll(rc)
if closeErr := rc.Close(); closeErr != nil {
fmt.Printf("Warning: failed to close META-INF/container.xml reader in %s: %v\n", epubPath, closeErr)
}
if readErr != nil {
continue
}
// Parse container.xml to find OPF path
// Simple string search since we just need the path
opfStart := bytes.Index(content, []byte("<rootfile "))
if opfStart == -1 {
continue
}
opfStartAttr := bytes.Index(content[opfStart:], []byte("full-path="))
if opfStartAttr == -1 {
continue
}
opfStartAttr += len("full-path=")
quote := content[opfStart+opfStartAttr]
opfStartQuote := opfStart + opfStartAttr + 1
opfEndQuote := bytes.Index(content[opfStartQuote:], []byte{quote})
if opfEndQuote == -1 {
continue
}
opfPath = string(content[opfStartQuote : opfStartQuote+opfEndQuote])
break
}
}
opfPath := findOPFPathInZip(r.File)
if opfPath == "" {
// No OPF found, try common cover image paths