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