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
@@ -0,0 +1,186 @@
package main
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"context"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/require"
)
// TestCalibreLibraryScan tests importing books from a Calibre library with metadata.opf sidecar files
func TestCalibreLibraryScan(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
ctx := context.Background()
// Get admin user ID from database
adminUser, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
require.NoError(t, err, "Failed to get admin user")
adminUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:])
require.NoError(t, err, "Failed to parse admin UUID")
adminID := pgtype.UUID{Bytes: adminUUID, Valid: true}
// Create test Calibre library structure
tmpDir := t.TempDir()
// Create author directory
authorDir := filepath.Join(tmpDir, "Test Author")
require.NoError(t, os.Mkdir(authorDir, 0755), "Failed to create author directory")
// Create book directory
bookDir := filepath.Join(authorDir, "Test Book")
require.NoError(t, os.Mkdir(bookDir, 0755), "Failed to create book directory")
// Create metadata.opf with Calibre metadata
opfPath := filepath.Join(bookDir, "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>`
require.NoError(t, os.WriteFile(opfPath, []byte(opfContent), 0644), "Failed to create metadata.opf")
// Create dummy EPUB file
epubPath := filepath.Join(bookDir, "Test Book.epub")
require.NoError(t, os.WriteFile(epubPath, []byte("dummy epub content"), 0644), "Failed to create EPUB file")
// Create library via API
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Calibre Test Library", true)
// Update the folder path to our temp directory
libUUID, err := uuid.Parse(libID)
require.NoError(t, err, "Failed to parse library UUID")
// Get and delete the default folder, then add our temp directory
folders, err := setup.DB.GetLibraryFolders(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
require.NoError(t, err, "Failed to list library folders")
if len(folders) > 0 {
_, err = setup.DB.DeleteLibraryFolder(ctx, database.DeleteLibraryFolderParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
FolderPath: folders[0].FolderPath,
})
require.NoError(t, err, "Failed to delete default folder")
}
_, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
FolderPath: tmpDir,
})
require.NoError(t, err, "Failed to add folder to library")
// Create scanner and configure it
scanner := services.NewMediaScanner(setup.DB)
scanner.SetAdminID(adminID)
err = scanner.SetFolders([]string{tmpDir})
require.NoError(t, err, "Failed to set scanner folders")
// Scan library
err = scanner.ScanFolders(ctx)
require.NoError(t, err, "ScanFolders should succeed")
// Verify imported book
books, err := setup.DB.ListMediaItemsByLibrary(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
require.NoError(t, err, "ListMediaItemsByLibrary should succeed")
require.Len(t, books, 1, "Should have imported 1 book")
book := books[0]
// Verify metadata from sidecar
require.Equal(t, "Test Book", book.Title, "Title should match sidecar")
require.Equal(t, "Test Author", book.Author.String, "Author should match sidecar")
require.Equal(t, "Test Series", book.Series.String, "Series should match sidecar")
require.Equal(t, int32(1), book.SeriesNumber.Int32, "Series number should match sidecar")
require.Equal(t, "Test Publisher", book.Publisher.String, "Publisher should match sidecar")
require.Len(t, book.Tags, 2, "Should have 2 tags from sidecar")
require.Contains(t, book.Tags, "Fantasy", "Should have Fantasy tag")
require.Contains(t, book.Tags, "Adventure", "Should have Adventure tag")
}
// TestCalibreLibraryScanWithoutSidecar tests that books without metadata.opf still work (backward compatibility)
func TestCalibreLibraryScanWithoutSidecar(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
ctx := context.Background()
// Get admin user ID from database
adminUser, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
require.NoError(t, err, "Failed to get admin user")
adminUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:])
require.NoError(t, err, "Failed to parse admin UUID")
adminID := pgtype.UUID{Bytes: adminUUID, Valid: true}
// Create test directory structure (non-Calibre)
tmpDir := t.TempDir()
// Create book directory
bookDir := filepath.Join(tmpDir, "Plain Book")
require.NoError(t, os.Mkdir(bookDir, 0755), "Failed to create book directory")
// Create EPUB file WITHOUT metadata.opf sidecar
epubPath := filepath.Join(bookDir, "Plain Book.epub")
require.NoError(t, os.WriteFile(epubPath, []byte("dummy epub content"), 0644), "Failed to create EPUB file")
// Create library via API
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Non-Calibre Test Library", true)
// Update the folder path to our temp directory
libUUID, err := uuid.Parse(libID)
require.NoError(t, err, "Failed to parse library UUID")
// Get and delete the default folder, then add our temp directory
folders, err := setup.DB.GetLibraryFolders(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
require.NoError(t, err, "Failed to list library folders")
if len(folders) > 0 {
_, err = setup.DB.DeleteLibraryFolder(ctx, database.DeleteLibraryFolderParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
FolderPath: folders[0].FolderPath,
})
require.NoError(t, err, "Failed to delete default folder")
}
_, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
FolderPath: tmpDir,
})
require.NoError(t, err, "Failed to add folder to library")
// Create scanner and configure it
scanner := services.NewMediaScanner(setup.DB)
scanner.SetAdminID(adminID)
err = scanner.SetFolders([]string{tmpDir})
require.NoError(t, err, "Failed to set scanner folders")
// Scan library
err = scanner.ScanFolders(ctx)
require.NoError(t, err, "ScanFolders should succeed")
// Verify imported book (using fallback metadata)
books, err := setup.DB.ListMediaItemsByLibrary(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
require.NoError(t, err, "ListMediaItemsByLibrary should succeed")
require.Len(t, books, 1, "Should have imported 1 book")
book := books[0]
// Verify fallback metadata (from filename)
require.Equal(t, "Plain Book", book.Title, "Title should fallback to filename")
}
+161
View File
@@ -98,6 +98,25 @@ type MediaScanner struct {
job *Job 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 // NewMediaScanner creates a new media scanner instance
func NewMediaScanner(db *database.Queries) *MediaScanner { func NewMediaScanner(db *database.Queries) *MediaScanner {
watcher, err := fsnotify.NewWatcher() watcher, err := fsnotify.NewWatcher()
@@ -709,7 +728,42 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
return true, nil 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) { 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)) ext := strings.ToLower(filepath.Ext(path))
switch ext { switch ext {
@@ -829,6 +883,113 @@ func (s *MediaScanner) extractEPUBMetadata(path string) (*MediaMetadata, error)
return metadata, nil 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. // extractEPUBCover extracts the cover image from an EPUB file.
// It looks for: // It looks for:
// 1. An item with properties="cover-image" in the manifest // 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")
}
})
}