Libraries managed by Audiobookshelf keep a metadata.json next to each
book (title, authors, series+sequence, genres/tags, publisher,
description, isbn/asin, language, published year/date) - and no
metadata.opf. The scanner silently ignored those files: deleting them
changed nothing, and their data never reached the database.
Parse them as a first-class sidecar in extractMetadata, priority
metadata.opf -> metadata.json -> embedded media. Only fields with a
matching media_items column are mapped; narrators, subtitle, explicit,
abridged, and chapters are deliberately skipped.
Cover handling is unchanged: the existing findSidecarCover priority
(cover.jpg / folder.jpg / {basename}.jpg) applies to the sidecar branch
exactly as it does for Calibre.
233 lines
7.7 KiB
Go
233 lines
7.7 KiB
Go
package services
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"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)
|
|
}
|
|
})
|
|
}
|