diff --git a/cmd/server/tests/calibre_integration_test.go b/cmd/server/tests/calibre_integration_test.go
new file mode 100644
index 0000000..9c06ccd
--- /dev/null
+++ b/cmd/server/tests/calibre_integration_test.go
@@ -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 := `
+
+
+ Test Book
+ Test Author
+ Fantasy
+ Adventure
+ Test description
+ Test Publisher
+ 2024-01-15
+ en
+ 978-0-123456-78-9
+ Contributor Name
+
+
+
+`
+ 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")
+}
diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go
index 4f4ddf2..ed6cdc5 100644
--- a/internal/services/media_scanner.go
+++ b/internal/services/media_scanner.go
@@ -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
diff --git a/internal/services/media_scanner_calibre_test.go b/internal/services/media_scanner_calibre_test.go
new file mode 100644
index 0000000..81a2b76
--- /dev/null
+++ b/internal/services/media_scanner_calibre_test.go
@@ -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: `
+
+
+ Test Book
+ Test Author
+ Fantasy
+ Adventure
+ Test description
+ Test Publisher
+ 2024-01-15
+ en
+ 978-0-123456-78-9
+ Contributor Name
+
+
+
+`,
+ wantTitle: "Test Book",
+ wantAuthor: "Test Author",
+ wantSeries: "Test Series",
+ wantTags: 2,
+ wantErr: false,
+ },
+ {
+ name: "Minimal metadata.opf",
+ opfContent: `
+
+
+ Minimal Book
+
+`,
+ wantTitle: "Minimal Book",
+ wantAuthor: "",
+ wantSeries: "",
+ wantTags: 0,
+ wantErr: false,
+ },
+ {
+ name: "Malformed XML",
+ opfContent: `
+
+
+ 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 := `
+
+
+ Sidecar Test
+ Test Author
+
+`
+ 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")
+ }
+ })
+}