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:
2026-03-26 14:38:20 -04:00
parent 902e878341
commit a900c78faf
3 changed files with 496 additions and 0 deletions
+161
View File
@@ -98,6 +98,25 @@ type MediaScanner struct {
job *Job
}
// CalibreOPFMetadata represents intermediate parsed metadata from Calibre metadata.opf files
type CalibreOPFMetadata struct {
Title string
Authors []string
Tags []string
Description string
Publisher string
PublishDate *time.Time
Language string
ISBN string
ASIN string
UUID string
Contributors []string
Series string
SeriesIndex *float64
Rating *int32
Timestamp *time.Time
}
// NewMediaScanner creates a new media scanner instance
func NewMediaScanner(db *database.Queries) *MediaScanner {
watcher, err := fsnotify.NewWatcher()
@@ -709,7 +728,42 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
return true, nil
}
// extractCalibreSidecar checks for and parses a Calibre metadata.opf sidecar file
func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
// Get directory of media file
dir := filepath.Dir(path)
opfPath := filepath.Join(dir, "metadata.opf")
// Check if sidecar exists
if _, err := os.Stat(opfPath); os.IsNotExist(err) {
return nil // No sidecar, not an error
}
// Parse sidecar
metadata, err := s.parseCalibreMetadataOPF(opfPath)
if err != nil {
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
return nil // Parsing failed, fall back to embedded
}
return metadata
}
func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
// NEW: Try Calibre sidecar first
if metadata := s.extractCalibreSidecar(path); metadata != nil {
fmt.Printf("Using Calibre metadata.opf for %s\n", path)
// Try to find cover image for sidecar metadata
coverPath := findSidecarCover(path)
if coverPath != "" {
metadata.CoverPath = s.getRelativePath(coverPath)
}
return metadata, nil
}
// EXISTING: Fallback to embedded metadata
ext := strings.ToLower(filepath.Ext(path))
switch ext {
@@ -829,6 +883,113 @@ func (s *MediaScanner) extractEPUBMetadata(path string) (*MediaMetadata, error)
return metadata, nil
}
// parseCalibreMetadataOPF parses a Calibre metadata.opf file and extracts metadata
func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata, error) {
// Open file
file, err := os.Open(opfPath)
if err != nil {
return nil, fmt.Errorf("failed to open metadata.opf: %v", err)
}
defer file.Close()
// Define XML structure for parsing with full Dublin Core namespace URLs
var opf struct {
XMLName xml.Name `xml:"package"`
Metadata struct {
XMLName xml.Name `xml:"metadata"`
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
Subjects []string `xml:"http://purl.org/dc/elements/1.1/ subject"`
Desc []string `xml:"http://purl.org/dc/elements/1.1/ description"`
Publisher []string `xml:"http://purl.org/dc/elements/1.1/ publisher"`
Dates []string `xml:"http://purl.org/dc/elements/1.1/ date"`
Language []string `xml:"http://purl.org/dc/elements/1.1/ language"`
Identifiers []struct {
Scheme string `xml:"http://www.idpf.org/2007/opf scheme,attr"`
Value string `xml:",chardata"`
} `xml:"http://purl.org/dc/elements/1.1/ identifier"`
Contributors []string `xml:"http://purl.org/dc/elements/1.1/ contributor"`
// Calibre-specific meta tags - capture all, filter later
MetaTags []struct {
Name string `xml:"name,attr"`
Value string `xml:"content,attr"`
} `xml:"meta"`
} `xml:"metadata"`
}
// Parse XML
if err := xml.NewDecoder(file).Decode(&opf); err != nil {
return nil, fmt.Errorf("failed to parse metadata.opf XML: %v", err)
}
// Map to MediaMetadata struct
metadata := &MediaMetadata{}
// Title (required)
if len(opf.Metadata.Titles) > 0 {
metadata.Title = opf.Metadata.Titles[0]
}
// Author (first creator)
if len(opf.Metadata.Creators) > 0 {
metadata.Author = opf.Metadata.Creators[0]
}
// Tags (all subjects)
if len(opf.Metadata.Subjects) > 0 {
metadata.Tags = utils.NormalizeTags(opf.Metadata.Subjects)
}
// Description
if len(opf.Metadata.Desc) > 0 {
metadata.Description = opf.Metadata.Desc[0]
}
// Publisher
if len(opf.Metadata.Publisher) > 0 {
metadata.Publisher = opf.Metadata.Publisher[0]
}
// Publish date
if len(opf.Metadata.Dates) > 0 {
if date, err := time.Parse("2006-01-02T15:04:05Z07:00", opf.Metadata.Dates[0]); err == nil {
metadata.PublishDate = date
} else if date, err := time.Parse("2006-01-02", opf.Metadata.Dates[0]); err == nil {
metadata.PublishDate = date
} else {
// Try alternative date formats
if date, err := time.Parse("2006", opf.Metadata.Dates[0]); err == nil {
metadata.PublishDate = date
}
}
}
// Identifiers (ISBN, ASIN)
for _, id := range opf.Metadata.Identifiers {
switch strings.ToUpper(id.Scheme) {
case "ISBN":
metadata.ISBN = utils.NormalizeISBNSafe(id.Value)
case "ASIN":
metadata.ASIN = id.Value
case "UUID", "CALIBRE":
// Store UUID in hash info, not metadata
// Will be extracted by extractHashInfo()
}
}
// Contributors
if len(opf.Metadata.Contributors) > 0 {
metadata.Contributors = utils.NormalizeContributors(opf.Metadata.Contributors)
}
// Calibre-specific meta tags (filter by name attribute)
for _, meta := range opf.Metadata.MetaTags {
switch meta.Name {
case "calibre:series":
metadata.Series = meta.Value
case "calibre:series_index":
if index, err := strconv.ParseFloat(meta.Value, 32); err == nil {
metadata.SeriesNumber = int32(index)
}
case "calibre:rating":
// Not imported (ratings are per-user in Bookhoard)
case "calibre:title_sort":
// Not imported (Bookhoard has its own sorting logic)
case "calibre:timestamp":
// Could be used for created_at, but skipping for now
}
}
return metadata, nil
}
// extractEPUBCover extracts the cover image from an EPUB file.
// It looks for:
// 1. An item with properties="cover-image" in the manifest
@@ -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")
}
})
}