Same gap-fill rule as the EPUB path, against the PDF's embedded Info dictionary via a readPDFInfoDict helper reusing extractPDFMetadata's field conventions (creator falls back to author, subject maps to description, producer to publisher, keywords to tags, plus page count). Sidecar values always win; unopenable PDFs skip silently. TestMergeMetadataPDFGapFill uses an Info-bearing hand-built PDF fixture (shared with the sidecar-cover test) and asserts the sidecar title is kept while author/description/publisher/tags/page count fill in.
411 lines
14 KiB
Go
411 lines
14 KiB
Go
package services
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
"image/jpeg"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// createTestJPEGBytes returns the bytes of a minimal valid JPEG.
|
|
func createTestJPEGBytes() string {
|
|
var buf bytes.Buffer
|
|
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
|
|
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
|
return ""
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// createPragmaticStyleEPUB builds an EPUB modeled on Pragmatic Bookshelf
|
|
// output: the dc namespace declared on the <metadata> element (not on
|
|
// <package>), a scheme-less ISBN identifier, an OPF-declared cover, and a
|
|
// deliberately malformed chapter body. The malformed chapter is the
|
|
// regression trigger: the previous go-epub-based extractor failed the whole
|
|
// book when any chapter was unparseable and wrote blank metadata.
|
|
func createPragmaticStyleEPUB(epubPath string) error {
|
|
file, err := os.Create(epubPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
zipWriter := zip.NewWriter(file)
|
|
defer zipWriter.Close()
|
|
|
|
mimetypeW, err := zipWriter.CreateHeader(&zip.FileHeader{
|
|
Name: "mimetype",
|
|
Method: zip.Store,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mimetypeW.Write([]byte("application/epub+zip"))
|
|
|
|
files := 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>`,
|
|
// dc namespace declared on <metadata>; identifiers carry no scheme attr
|
|
"OEBPS/content.opf": `<?xml version="1.0" encoding="UTF-8"?>
|
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="PubID">
|
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
<dc:language>en</dc:language>
|
|
<dc:title>A Common-Sense Guide</dc:title>
|
|
<dc:creator>Jay Wengrow</dc:creator>
|
|
<dc:publisher>The Pragmatic Bookshelf, LLC</dc:publisher>
|
|
<dc:description>Content that makes you a better programmer.</dc:description>
|
|
<dc:subject>Programming</dc:subject>
|
|
<dc:identifier id="PubID">978-1-68050-722-8</dc:identifier>
|
|
<meta name="cover" content="cover-image"/>
|
|
</metadata>
|
|
<manifest>
|
|
<item id="cover-image" href="images/cover.jpg" media-type="image/jpeg"/>
|
|
<item id="ch1" href="ch1.xhtml" media-type="application/xhtml+xml"/>
|
|
</manifest>
|
|
<spine><itemref idref="ch1"/></spine>
|
|
</package>`,
|
|
// Malformed on purpose: unclosed tags
|
|
"OEBPS/ch1.xhtml": `<html><body><p>unclosed paragraph`,
|
|
"OEBPS/images/cover.jpg": createTestJPEGBytes(),
|
|
}
|
|
|
|
for name, content := range files {
|
|
w, err := zipWriter.Create(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := w.Write([]byte(content)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return zipWriter.Close()
|
|
}
|
|
|
|
// TestExtractEPUBMetadataBrokenChapter guards the regression where one
|
|
// unparseable chapter made the extractor return nothing at all: metadata must
|
|
// come from the OPF regardless of chapter-body damage.
|
|
func TestExtractEPUBMetadataBrokenChapter(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
epubPath := filepath.Join(tmpDir, "book.epub")
|
|
if err := createPragmaticStyleEPUB(epubPath); err != nil {
|
|
t.Fatalf("failed to create test EPUB: %v", err)
|
|
}
|
|
|
|
s := NewMediaScanner(nil)
|
|
metadata, err := s.extractEPUBMetadata(epubPath)
|
|
if err != nil {
|
|
t.Fatalf("extractEPUBMetadata() error: %v", err)
|
|
}
|
|
if metadata.Title != "A Common-Sense Guide" {
|
|
t.Errorf("Title = %q, want %q", metadata.Title, "A Common-Sense Guide")
|
|
}
|
|
if metadata.Author != "Jay Wengrow" {
|
|
t.Errorf("Author = %q, want %q", metadata.Author, "Jay Wengrow")
|
|
}
|
|
if metadata.Publisher != "The Pragmatic Bookshelf, LLC" {
|
|
t.Errorf("Publisher = %q, want %q", metadata.Publisher, "The Pragmatic Bookshelf, LLC")
|
|
}
|
|
if metadata.Description == "" {
|
|
t.Error("Description missing")
|
|
}
|
|
if metadata.Language != "en" {
|
|
t.Errorf("Language = %q, want %q", metadata.Language, "en")
|
|
}
|
|
// Scheme-less identifier that normalizes to a valid ISBN must be picked up
|
|
if metadata.ISBN == "" {
|
|
t.Error("ISBN missing (scheme-less dc:identifier fallback failed)")
|
|
}
|
|
}
|
|
|
|
func TestParseOPFContentCalibreSeries(t *testing.T) {
|
|
opf := `<?xml version="1.0"?>
|
|
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="id">
|
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
|
<dc:title>Test Book</dc:title>
|
|
<dc:creator>Some Author</dc:creator>
|
|
<dc:date>2020-03-15</dc:date>
|
|
<dc:subject>Fiction</dc:subject>
|
|
<dc:subject>Classic</dc:subject>
|
|
<dc:identifier opf:scheme="ISBN">978-3-16-148410-0</dc:identifier>
|
|
<meta name="calibre:series" content="Great Series"/>
|
|
<meta name="calibre:series_index" content="2.5"/>
|
|
</metadata>
|
|
</package>`
|
|
metadata, err := parseOPFContent([]byte(opf))
|
|
if err != nil {
|
|
t.Fatalf("parseOPFContent() error: %v", err)
|
|
}
|
|
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2 {
|
|
t.Errorf("Series = %q/%d, want Great Series/2", metadata.Series, metadata.SeriesNumber)
|
|
}
|
|
if metadata.ISBN == "" {
|
|
t.Error("schemed ISBN not extracted")
|
|
}
|
|
wantDate := time.Date(2020, 3, 15, 0, 0, 0, 0, time.UTC)
|
|
if !metadata.PublishDate.Equal(wantDate) {
|
|
t.Errorf("PublishDate = %v, want %v", metadata.PublishDate, wantDate)
|
|
}
|
|
if len(metadata.Tags) != 2 {
|
|
t.Errorf("Tags = %v, want 2 subjects", metadata.Tags)
|
|
}
|
|
}
|
|
|
|
func TestExtractAudiobookshelfSidecar(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
validate func(t *testing.T, m *MediaMetadata)
|
|
}{
|
|
{
|
|
name: "full sidecar",
|
|
json: `{
|
|
"title": "An Book",
|
|
"authors": ["Author One", "Author Two"],
|
|
"series": [{"series": "The Series", "sequence": "4.5"}],
|
|
"genres": ["Fantasy"],
|
|
"tags": ["tag1"],
|
|
"publishedYear": 2019,
|
|
"publisher": "ACME Books",
|
|
"description": "A very good book.",
|
|
"isbn": "978-3-16-148410-0",
|
|
"asin": "B08XYZ",
|
|
"language": "en"
|
|
}`,
|
|
validate: func(t *testing.T, m *MediaMetadata) {
|
|
if m.Title != "An Book" || m.Author != "Author One" {
|
|
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
|
}
|
|
if m.Series != "The Series" || m.SeriesNumber != 4 {
|
|
t.Errorf("Series = %q/%d, want The Series/4", m.Series, m.SeriesNumber)
|
|
}
|
|
if len(m.Tags) != 2 {
|
|
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
|
|
}
|
|
if m.PublishDate.Year() != 2019 {
|
|
t.Errorf("PublishDate year = %d, want 2019", m.PublishDate.Year())
|
|
}
|
|
if m.Publisher != "ACME Books" || m.Description != "A very good book." {
|
|
t.Errorf("Publisher/Description = %q/%q", m.Publisher, m.Description)
|
|
}
|
|
if m.ISBN == "" || m.ASIN != "B08XYZ" || m.Language != "en" {
|
|
t.Errorf("ISBN/ASIN/Language = %q/%q/%q", m.ISBN, m.ASIN, m.Language)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "sparse sidecar (real-world Audiobookshelf export)",
|
|
json: `{"title": "Sparse (1234)", "authors": ["X"], "tags": [], "description": null}`,
|
|
validate: func(t *testing.T, m *MediaMetadata) {
|
|
if m.Title != "Sparse (1234)" || m.Author != "X" {
|
|
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
|
}
|
|
if m.Description != "" || m.Tags != nil {
|
|
t.Error("null/empty sidecar fields must stay unset")
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(tt.json), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub"))
|
|
if m == nil {
|
|
t.Fatal("extractAudiobookshelfSidecar() = nil, want metadata")
|
|
}
|
|
tt.validate(t, m)
|
|
})
|
|
}
|
|
|
|
t.Run("no sidecar returns nil", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub")); m != nil {
|
|
t.Errorf("extractAudiobookshelfSidecar() = %v, want nil", m)
|
|
}
|
|
})
|
|
}
|
|
|
|
// createTestPDFWithImage builds a minimal one-page PDF embedding a JPEG
|
|
// XObject, so pdfcpu-based cover extraction has an image to find.
|
|
func createTestPDFWithImage() []byte {
|
|
jpegData := []byte(createTestJPEGBytes())
|
|
content := "q 100 0 0 100 0 0 cm /Im0 Do Q"
|
|
|
|
var buf bytes.Buffer
|
|
offsets := []int{0} // object numbers are 1-based
|
|
buf.WriteString("%PDF-1.4\n")
|
|
|
|
writeObj := func(n int, body func(w *bytes.Buffer)) {
|
|
offsets = append(offsets, buf.Len())
|
|
fmt.Fprintf(&buf, "%d 0 obj\n", n)
|
|
body(&buf)
|
|
buf.WriteString("endobj\n")
|
|
}
|
|
|
|
writeObj(1, func(w *bytes.Buffer) { w.WriteString("<< /Type /Catalog /Pages 2 0 R >>\n") })
|
|
writeObj(2, func(w *bytes.Buffer) { w.WriteString("<< /Type /Pages /Kids [3 0 R] /Count 1 >>\n") })
|
|
writeObj(3, func(w *bytes.Buffer) {
|
|
w.WriteString("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\n")
|
|
})
|
|
writeObj(4, func(w *bytes.Buffer) {
|
|
fmt.Fprintf(w, "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n", len(jpegData))
|
|
w.Write(jpegData)
|
|
w.WriteString("\nendstream\n")
|
|
})
|
|
writeObj(5, func(w *bytes.Buffer) {
|
|
fmt.Fprintf(w, "<< /Length %d >>\nstream\n%s\nendstream\n", len(content), content)
|
|
})
|
|
writeObj(6, func(w *bytes.Buffer) {
|
|
// Info dictionary deliberately richer than the sidecars in gap-fill
|
|
// tests: gap-fill must use these, never overwrite with them.
|
|
w.WriteString("<< /Title (Embedded Title) /Author (Embedded Author) /Subject (Embedded Subject) /Producer (Embedded Producer) /Keywords (embedded-kw) >>\n")
|
|
})
|
|
|
|
xrefStart := buf.Len()
|
|
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
|
|
for _, off := range offsets[1:] {
|
|
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
|
}
|
|
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R /Info 6 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xrefStart)
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// TestSidecarCoverFallback guards the regression where a metadata.json
|
|
// sidecar (no cover.jpg next to the book) made extractMetadata return an
|
|
// empty CoverPath for PDFs - mergeMetadata has no PDF/EPUB cover logic, so a
|
|
// full rescan wiped cover_image_path for sidecar-managed books.
|
|
func TestSidecarCoverFallback(t *testing.T) {
|
|
dir := t.TempDir()
|
|
pdfPath := filepath.Join(dir, "book.pdf")
|
|
if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(`{"title":"Sidecar Book","authors":["A"]}`), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s := NewMediaScanner(nil)
|
|
s.folders = []string{dir} // SetFolders requires a live DB; tests only need path relativization
|
|
metadata, err := s.extractMetadata(pdfPath)
|
|
if err != nil {
|
|
t.Fatalf("extractMetadata() error: %v", err)
|
|
}
|
|
if metadata.Title != "Sidecar Book" {
|
|
t.Errorf("Title = %q, want sidecar title", metadata.Title)
|
|
}
|
|
if metadata.CoverPath == "" {
|
|
t.Fatal("CoverPath empty - sidecar branch skipped embedded cover extraction")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, metadata.CoverPath)); err != nil {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestMergeMetadataPDFGapFill verifies the same for PDFs: a sparse sidecar
|
|
// keeps its title while author/description/publisher/tags/pagecount fill in
|
|
// from the embedded Info dictionary.
|
|
func TestMergeMetadataPDFGapFill(t *testing.T) {
|
|
dir := t.TempDir()
|
|
pdfPath := filepath.Join(dir, "gappy.pdf")
|
|
if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
sparse := &MediaMetadata{Title: "Kept Title"}
|
|
s := NewMediaScanner(nil)
|
|
merged, err := s.mergeMetadata(pdfPath, sparse)
|
|
if err != nil {
|
|
t.Fatalf("mergeMetadata() error: %v", err)
|
|
}
|
|
|
|
checks := []struct {
|
|
name string
|
|
got string
|
|
want string
|
|
}{
|
|
{"Title (sidecar wins)", merged.Title, "Kept Title"},
|
|
{"Author", merged.Author, "Embedded Author"},
|
|
{"Description", merged.Description, "Embedded Subject"},
|
|
{"Publisher", merged.Publisher, "Embedded Producer"},
|
|
}
|
|
for _, c := range checks {
|
|
if c.got != c.want {
|
|
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
|
|
}
|
|
}
|
|
if len(merged.Tags) == 0 {
|
|
t.Error("Tags empty, want keywords from Info dict")
|
|
}
|
|
if merged.PageCount != 1 {
|
|
t.Errorf("PageCount = %d, want 1 from Info dict", merged.PageCount)
|
|
}
|
|
}
|