feat(scanner): fill sparse sidecars from embedded EPUB metadata

A sparse metadata.opf/metadata.json (title only, no description) left
books thin even when the file itself carried rich data: the embedded
extractors only ran when no sidecar existed at all. Now mergeMetadata
fills blanks from the book's own OPF - title, author, description,
publisher, language, ISBN, ASIN, series/number, publish date, tags,
contributors - while sidecar values always win and unparseable files
skip silently. Also covers .kepub, which the merge previously ignored
while the extractor already supported it.

Adds TestMergeMetadataEPUBGapFill asserting both directions: sidecar
title/author survive, embedded description/publisher/language fill in.
This commit is contained in:
John O'Keefe
2026-09-13 13:05:37 -04:00
parent aac7c72900
commit 708598687e
2 changed files with 104 additions and 1 deletions
@@ -302,3 +302,64 @@ func TestSidecarCoverFallback(t *testing.T) {
t.Errorf("extracted cover not on disk: %v", err)
}
}
// TestMergeMetadataEPUBGapFill verifies that a sparse sidecar (metadata.opf
// with only a title) gets its blanks filled from the book's own embedded OPF
// while sidecar-set fields are never overwritten.
func TestMergeMetadataEPUBGapFill(t *testing.T) {
dir := t.TempDir()
epubPath := filepath.Join(dir, "gappy.epub")
f, err := os.Create(epubPath)
if err != nil {
t.Fatal(err)
}
zipWriter := zip.NewWriter(f)
mimetype, _ := zipWriter.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
mimetype.Write([]byte("application/epub+zip"))
epubFiles := map[string]string{
"META-INF/container.xml": `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`,
"OEBPS/content.opf": `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="u">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Embedded Title</dc:title>
<dc:creator>Embedded Author</dc:creator>
<dc:description>Embedded description from the book.</dc:description>
<dc:publisher>Embedded Publisher</dc:publisher>
<dc:language>en</dc:language>
<dc:date>2021-06-01</dc:date>
</metadata>
<manifest/>
<spine/>
</package>`,
}
for name, content := range epubFiles {
w, err := zipWriter.Create(name)
if err != nil {
t.Fatal(err)
}
w.Write([]byte(content))
}
if err := zipWriter.Close(); err != nil {
t.Fatal(err)
}
f.Close()
sparse := &MediaMetadata{Title: "Sidecar Title", Author: "Sidecar Author"}
s := NewMediaScanner(nil)
merged, err := s.mergeMetadata(epubPath, sparse)
if err != nil {
t.Fatalf("mergeMetadata() error: %v", err)
}
if merged.Title != "Sidecar Title" || merged.Author != "Sidecar Author" {
t.Errorf("sidecar values overwritten: title=%q author=%q", merged.Title, merged.Author)
}
if merged.Description != "Embedded description from the book." {
t.Errorf("Description = %q, want embedded fill", merged.Description)
}
if merged.Publisher != "Embedded Publisher" {
t.Errorf("Publisher = %q, want embedded fill", merged.Publisher)
}
if merged.Language != "en" {
t.Errorf("Language = %q, want embedded fill", merged.Language)
}
}