Add Calibre metadata.opf sidecar file support to media scanner
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
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user