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") + } + }) +}