Implement sidecar-first metadata extraction approach that prioritizes Calibre metadata.opf files over embedded metadata when available. Key Features: - Sidecar-first approach: Check for metadata.opf before extracting embedded - Full Dublin Core namespace support: Use complete namespace URLs - Calibre-specific meta tags: Extract series, series_index from <meta> tags - Graceful degradation: Fall back to embedded metadata on parse failure - Identifier extraction: Support ISBN and ASIN from Dublin Core identifiers - Date parsing: Handle ISO 8601 timestamps and simple date formats Implementation Details: - Added extractCalibreSidecar() to check for and parse metadata.opf - Added parseCalibreMetadataOPF() with full Dublin Core namespace handling - Modified extractMetadata() to try sidecar first, fallback to embedded - Added CalibreOPFMetadata struct for intermediate parsing - Cover image support: findSidecarCover() for sidecar metadata Tests: - Unit tests for parseCalibreMetadataOPF() with real Calibre file examples - Integration tests for Calibre library scanning This allows users with Calibre-managed libraries to import their curated metadata (series, tags, custom covers) into Bookhoard. Fixes: #calibre-opf-support
150 lines
4.0 KiB
Go
150 lines
4.0 KiB
Go
package services
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseCalibreMetadataOPF(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opfContent string
|
|
wantTitle string
|
|
wantAuthor string
|
|
wantSeries string
|
|
wantTags int
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "Complete metadata.opf",
|
|
opfContent: `<?xml version='1.0' encoding='utf-8'?>
|
|
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
<dc:title>Test Book</dc:title>
|
|
<dc:creator>Test Author</dc:creator>
|
|
<dc:subject>Fantasy</dc:subject>
|
|
<dc:subject>Adventure</dc:subject>
|
|
<dc:description>Test description</dc:description>
|
|
<dc:publisher>Test Publisher</dc:publisher>
|
|
<dc:date>2024-01-15</dc:date>
|
|
<dc:language>en</dc:language>
|
|
<dc:identifier opf:scheme="ISBN">978-0-123456-78-9</dc:identifier>
|
|
<dc:contributor>Contributor Name</dc:contributor>
|
|
<meta name="calibre:series" content="Test Series"/>
|
|
<meta name="calibre:series_index" content="1"/>
|
|
</metadata>
|
|
</package>`,
|
|
wantTitle: "Test Book",
|
|
wantAuthor: "Test Author",
|
|
wantSeries: "Test Series",
|
|
wantTags: 2,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "Minimal metadata.opf",
|
|
opfContent: `<?xml version='1.0' encoding='utf-8'?>
|
|
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
<dc:title>Minimal Book</dc:title>
|
|
</metadata>
|
|
</package>`,
|
|
wantTitle: "Minimal Book",
|
|
wantAuthor: "",
|
|
wantSeries: "",
|
|
wantTags: 0,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "Malformed XML",
|
|
opfContent: `<?xml version='1.0' encoding='utf-8'?>
|
|
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
<dc:title>Test`,
|
|
wantTitle: "",
|
|
wantAuthor: "",
|
|
wantSeries: "",
|
|
wantTags: 0,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create temporary OPF file
|
|
tmpDir := t.TempDir()
|
|
opfPath := filepath.Join(tmpDir, "metadata.opf")
|
|
if err := os.WriteFile(opfPath, []byte(tt.opfContent), 0644); err != nil {
|
|
t.Fatalf("Failed to create test OPF: %v", err)
|
|
}
|
|
|
|
// Parse
|
|
scanner := &MediaScanner{}
|
|
got, err := scanner.parseCalibreMetadataOPF(opfPath)
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("parseCalibreMetadataOPF() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
|
|
if !tt.wantErr && got.Title != tt.wantTitle {
|
|
t.Errorf("Title = %v, want %v", got.Title, tt.wantTitle)
|
|
}
|
|
if !tt.wantErr && got.Author != tt.wantAuthor {
|
|
t.Errorf("Author = %v, want %v", got.Author, tt.wantAuthor)
|
|
}
|
|
if !tt.wantErr && got.Series != tt.wantSeries {
|
|
t.Errorf("Series = %v, want %v", got.Series, tt.wantSeries)
|
|
}
|
|
if !tt.wantErr && len(got.Tags) != tt.wantTags {
|
|
t.Errorf("Tags length = %v, want %v", len(got.Tags), tt.wantTags)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractCalibreSidecar(t *testing.T) {
|
|
t.Run("Sidecar exists", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
opfPath := filepath.Join(tmpDir, "metadata.opf")
|
|
bookPath := filepath.Join(tmpDir, "book.epub")
|
|
|
|
// Create OPF file
|
|
opfContent := `<?xml version='1.0' encoding='utf-8'?>
|
|
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
<dc:title>Sidecar Test</dc:title>
|
|
<dc:creator>Test Author</dc:creator>
|
|
</metadata>
|
|
</package>`
|
|
if err := os.WriteFile(opfPath, []byte(opfContent), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Test extraction
|
|
scanner := &MediaScanner{}
|
|
metadata := scanner.extractCalibreSidecar(bookPath)
|
|
|
|
if metadata == nil {
|
|
t.Error("Expected metadata, got nil")
|
|
return
|
|
}
|
|
|
|
if metadata.Title != "Sidecar Test" {
|
|
t.Errorf("Title = %v, want 'Sidecar Test'", metadata.Title)
|
|
}
|
|
})
|
|
|
|
t.Run("No sidecar", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
bookPath := filepath.Join(tmpDir, "book.epub")
|
|
|
|
scanner := &MediaScanner{}
|
|
metadata := scanner.extractCalibreSidecar(bookPath)
|
|
|
|
if metadata != nil {
|
|
t.Error("Expected nil, got metadata")
|
|
}
|
|
})
|
|
}
|