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,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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user