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