Phase 1 of the series/collections/metadata overhaul
(~/.opencode/plan/series-collections-metadata-overhaul.md).
Schema (in place, idempotent):
- media_items.series_number INTEGER -> DOUBLE PRECISION: real-world
ordinals are decimals (0.5, 1.5 novellas); INTEGER silently truncated
them (calibre 2.5 pinned as 2 in tests).
- + subtitle TEXT, explicit BOOLEAN (sidecar ingest; ABS explicit /
Audible isAdult).
- + authors/authors_search/publishers/publishers_search TEXT[] NOT NULL
(narrators A2a precedent). Arrays are write truth; author/publisher
varchars become trigger-regenerated display strings
(sync_author_publisher_display) so every varchar consumer (trgm
search, facets, API, OPDS/Kobo) keeps working unchanged.
- Row-level NULL normalization for the new arrays in
set_library_type_name (A2 lesson: Go nil -> explicit NULL -> 23502).
- Backfills ARRAY[author]/ARRAY[publisher] — deliberately no
comma-splitting ('Tolkien, J.R.R.' would corrupt; scanner kept only
authors[0] historically).
- collection_items.position INTEGER + (collection_id, position) index
for playlist ordering.
Go: sqlc regen; MediaMetadata/handler params gain Subtitle/Explicit/
Authors/Publishers (legacy singles still accepted, arrays win);
SeriesNumber int32 -> float64 end to end; metadata_overrides adapted
with legacy override keys ('author'/'publisher') preserved so existing
user overrides survive; bulk author edit writes the array.
Pins widened (docs/contract_pins.md): raw sqlc row key set + the six
new columns; changelog entry added.
Verified: unit suite green; integration sweep (bulk/smart collections/
scanner/stats/sync/queue/calibre/pins/audiobooks) green against the
shared dev DB after in-place migration. Pre-existing unrelated red:
TestOwnershipAndAdminTestSuite/TestAdminCreateUser timezone inheritance
fails identically at the pre-change commit (live shared state).
187 lines
7.2 KiB
Go
187 lines
7.2 KiB
Go
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}, false)
|
|
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, float64(1), book.SeriesNumber.Float64, "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}, false)
|
|
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")
|
|
}
|