- Remove duplicate extractPDFCover function documentation - Fixes issue identified during plan review where Step 3.2 appeared twice with identical content
29 KiB
Detailed Implementation Plan: Cover Image Extraction & PDF Metadata
CRITICAL PRE-IMPLEMENTATION CHECKLIST
- Read this entire document before starting
- Understand each step before executing
- Verify after each edit (go build ./...)
- Do NOT proceed if compilation fails - STOP and fix
- Follow PROJECT_GUIDELINES.md exactly
PHASE 1: Add pdfcpu Dependency
Step 1.1: Add pdfcpu to go.mod
File: go.mod
Action: Add this line to the dependencies section (alphabetically near other pdf libraries if any):
github.com/pdfcpu/pdfcpu v0.9.1
Verification: After adding, run inside container:
cd /home/nymusicman/Code/bookhoard && podman run --rm -v .:/app -w /app golang:1.25 go mod tidy
PHASE 2: Add EPUB Cover Extraction Function
Step 2.1: Add extractEPUBCover Function
File: internal/services/media_scanner.go
Location: AFTER line 652 (after the closing } of extractEPUBMetadata), BEFORE line 655 (the extractPDFMetadata function)
Exact Code to INSERT:
// extractEPUBCover extracts the cover image from an EPUB file.
// It looks for:
// 1. An item with properties="cover-image" in the manifest
// 2. A meta tag with name="cover" pointing to an image
// 3. Common cover image paths like cover.jpg, cover.jpeg, cover.png
// Returns the path to the saved cover image, or empty string if no cover found.
func (s *MediaScanner) extractEPUBCover(epubPath string) (string, error) {
// Open the EPUB as a zip file to extract images
r, err := zip.OpenReader(epubPath)
if err != nil {
return "", fmt.Errorf("failed to open EPUB as zip: %v", err)
}
defer r.Close()
// Try to find cover image from OPF metadata
coverImageName := ""
// Attempt to read the OPF file to find cover reference
// First, find container.xml to locate the OPF
var opfPath string
for _, f := range r.File {
if f.Name == "META-INF/container.xml" {
rc, err := f.Open()
if err != nil {
continue
}
content, err := io.ReadAll(rc)
rc.Close()
if err != nil {
continue
}
// Parse container.xml to find OPF path
// Simple string search since we just need the path
opfStart := bytes.Index(content, []byte("<rootfile "))
if opfStart == -1 {
continue
}
opfStartAttr := bytes.Index(content[opfStart:], []byte("full-path="))
if opfStartAttr == -1 {
continue
}
opfStartAttr += len("full-path=")
quote := content[opfStart+opfStartAttr]
opfStartQuote := opfStart + opfStartAttr + 1
opfEndQuote := bytes.Index(content[opfStartQuote:], []byte{byte(quote)})
if opfEndQuote == -1 {
continue
}
opfPath = string(content[opfStartQuote : opfStartQuote+opfEndQuote])
break
}
}
if opfPath == "" {
// No OPF found, try common cover image paths
coverImageName = findCoverImageInZip(r.File)
} else {
// Read OPF to find cover reference
opfContent, err := readFileFromZip(r.File, opfPath)
if err != nil {
// Fall back to common paths
coverImageName = findCoverImageInZip(r.File)
} else {
coverImageName = findCoverInOPF(opfContent, r.File, opfPath)
}
}
if coverImageName == "" {
return "", nil // No cover found
}
// Extract the cover image
coverImage, err := extractImageFromZip(r.File, coverImageName, opfPath)
if err != nil {
return "", fmt.Errorf("failed to extract cover image: %v", err)
}
if len(coverImage) == 0 {
return "", nil
}
// Save cover to disk (same pattern as comics: {book_path}.cover.jpg)
coverPath := epubPath + ".cover.jpg"
if err := os.WriteFile(coverPath, coverImage, 0644); err != nil {
return "", fmt.Errorf("failed to write cover file: %v", err)
}
return coverPath, nil
}
// findCoverImageInZip searches for common cover image filenames in the zip
func findCoverImageInZip(files []*zip.File) string {
coverNames := []string{"cover.jpg", "cover.jpeg", "cover.png", "cover.webp",
"Cover.jpg", "Cover.jpeg", "Cover.png", "Cover.webp",
"images/cover.jpg", "Images/cover.jpg", "OEBPS/images/cover.jpg"}
for _, name := range coverNames {
for _, f := range files {
if strings.ToLower(f.Name) == strings.ToLower(name) {
return f.Name
}
}
}
return ""
}
// findCoverInOPF parses OPF content to find cover image reference
func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string {
contentStr := string(opfContent)
// Look for item with properties="cover-image"
coverImageRE := regexp.MustCompile(`<item[^>]*properties="[^"]*cover-image[^"]*"[^>]*id="([^"]+)"`)
matches := coverImageRE.FindStringSubmatch(contentStr)
if len(matches) > 1 {
coverID := matches[1]
// Find the href for this ID
hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID))
hrefMatches := hrefRE.FindStringSubmatch(contentStr)
if len(hrefMatches) > 1 {
return resolveOPFPath(opfDir, hrefMatches[1])
}
}
// Look for meta name="cover"
metaCoverRE := regexp.MustCompile(`<meta[^>]*name="cover"[^>]*content="([^"]+)"`)
metaMatches := metaCoverRE.FindStringSubmatch(contentStr)
if len(metaMatches) > 1 {
coverContent := metaMatches[1]
// Could be "image-id" format
if strings.HasPrefix(coverContent, "image-") {
coverID := strings.TrimPrefix(coverContent, "image-")
hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID))
hrefMatches := hrefRE.FindStringSubmatch(contentStr)
if len(hrefMatches) > 1 {
return resolveOPFPath(opfDir, hrefMatches[1])
}
}
}
// Fall back to searching common paths
return findCoverImageInZip(files)
}
// resolveOPFPath resolves a relative path against the OPF directory
func resolveOPFPath(opfDir, href string) string {
if opfDir == "" {
return href
}
// Handle ../ in href
if strings.HasPrefix(href, "../") {
// Simple case: just use the href as-is for now
return href
}
// Join the directory with the href
return filepath.Join(filepath.Dir(opfDir), href)
}
// readFileFromZip reads a file from the zip by name
func readFileFromZip(files []*zip.File, name string) ([]byte, error) {
// Normalize the name for comparison
name = filepath.ToSlash(name)
for _, f := range files {
fName := filepath.ToSlash(f.Name)
if fName == name || fName == name {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
}
return nil, fmt.Errorf("file not found: %s", name)
}
// extractImageFromZip extracts an image file and returns its contents
func extractImageFromZip(files []*zip.File, imagePath, opfDir string) ([]byte, error) {
// Try direct match first
for _, f := range files {
if strings.ToLower(f.Name) == strings.ToLower(imagePath) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
}
// Try resolved path
resolvedPath := resolveOPFPath(opfDir, imagePath)
for _, f := range files {
if strings.ToLower(f.Name) == strings.ToLower(resolvedPath) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
}
return nil, fmt.Errorf("image not found: %s", imagePath)
}
Verification: Run go build ./internal/services/... - must compile without errors
Step 2.2: Add Sidecar Cover Detection Function
File: internal/services/media_scanner.go
Location: AFTER the extractEPUBCover function (which ends around line 820ish), BEFORE extractPDFMetadata function
Exact Code to INSERT:
// findSidecarCover looks for cover images in the same directory as the media file.
// It checks for common cover filename patterns in priority order:
// 1. cover.jpg, cover.jpeg, cover.png, cover.webp
// 2. folder.jpg, folder.jpeg, folder.png, folder.webp
// 3. {basename}.jpg, {basename}.jpeg, etc. (same name as media file)
// 4. .folder.jpg (hidden file)
// Returns the full path to the cover file, or empty string if not found.
func findSidecarCover(mediaPath string) string {
dir := filepath.Dir(mediaPath)
baseName := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
// Priority order for cover filenames
coverPatterns := []string{
"cover.jpg",
"cover.jpeg",
"cover.png",
"cover.webp",
"folder.jpg",
"folder.jpeg",
"folder.png",
"folder.webp",
".folder.jpg",
".folder.jpeg",
".folder.png",
}
// First, check exact match cover/folder names
for _, coverName := range coverPatterns {
coverPath := filepath.Join(dir, coverName)
if _, err := os.Stat(coverPath); err == nil {
return coverPath
}
}
// Second, check for {basename}.{ext} pattern
extensions := []string{".jpg", ".jpeg", ".png", ".webp"}
for _, ext := range extensions {
coverPath := filepath.Join(dir, baseName+ext)
if _, err := os.Stat(coverPath); err == nil {
return coverPath
}
// Also check uppercase extension
coverPathUpper := filepath.Join(dir, baseName+strings.ToUpper(ext))
if _, err := os.Stat(coverPathUpper); err == nil {
return coverPathUpper
}
}
return ""
}
Verification: Run go build ./internal/services/... - must compile without errors
Step 2.3: Update extractMetadata to Extract EPUB Cover
File: internal/services/media_scanner.go
Location: In the .epub case around line 558
EXISTING CODE (lines 556-558):
switch ext {
case ".epub":
return s.extractEPUBMetadata(path)
REPLACE WITH:
switch ext {
case ".epub":
metadata, err := s.extractEPUBMetadata(path)
if err != nil {
return metadata, err
}
// Try to extract embedded cover
coverPath, err := s.extractEPUBCover(path)
if err != nil {
fmt.Printf("Warning: failed to extract EPUB cover from %s: %v\n", path, err)
} else if coverPath != "" {
metadata.CoverPath = coverPath
}
// If no embedded cover, try sidecar
if metadata.CoverPath == "" {
sidecarCover := findSidecarCover(path)
if sidecarCover != "" {
metadata.CoverPath = sidecarCover
}
}
return metadata, nil
Verification: Run go build ./internal/services/... - must compile without errors
PHASE 3: PDF Metadata & Cover Extraction
Step 3.1: Update extractPDFMetadata Function
File: internal/services/media_scanner.go
Location: Lines 655-663 (current stub function)
EXISTING CODE (lines 655-663):
func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
// For now, return basic metadata since PDF extraction requires additional libraries
// In a future enhancement, you could use libraries like github.com/ledongthuc/pdf
filename := strings.TrimSuffix(filepath.Base(path), ".pdf")
return &MediaMetadata{
Title: filename,
}, nil
}
REPLACE WITH:
func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
metadata := &MediaMetadata{}
// Use pdfcpu API to read PDF metadata
// Configuration: nil = default (lenient mode)
pdfInfo, err := pdfcpuapi.PDFInfoFile(path, nil, nil)
if err != nil {
fmt.Printf("Warning: failed to read PDF info from %s: %v\n", path, err)
// Fall back to filename as title
metadata.Title = strings.TrimSuffix(filepath.Base(path), ".pdf")
return metadata, nil
}
// Extract title
if pdfInfo.Title != "" {
metadata.Title = pdfInfo.Title
} else {
metadata.Title = strings.TrimSuffix(filepath.Base(path), ".pdf")
}
// Extract author
if pdfInfo.Author != "" {
metadata.Author = pdfInfo.Author
}
// Extract subject (use as description)
if pdfInfo.Subject != "" {
metadata.Description = pdfInfo.Subject
}
// Extract creator (use as author fallback)
if pdfInfo.Creator != "" && metadata.Author == "" {
metadata.Author = pdfInfo.Creator
}
// Extract producer (use as publisher)
if pdfInfo.Producer != "" {
metadata.Publisher = pdfInfo.Producer
}
// Try to extract cover image
coverPath, err := s.extractPDFCover(path)
if err != nil {
fmt.Printf("Warning: failed to extract PDF cover from %s: %v\n", path, err)
} else if coverPath != "" {
metadata.CoverPath = coverPath
}
// If no embedded cover, try sidecar
if metadata.CoverPath == "" {
sidecarCover := findSidecarCover(path)
if sidecarCover != "" {
metadata.CoverPath = sidecarCover
}
}
return metadata, nil
}
Step 3.2: Add extractPDFCover Helper Function
File: internal/services/media_scanner.go
Location: AFTER the extractPDFMetadata function, BEFORE the ComicInfo struct
Exact Code to INSERT:
// extractPDFCover extracts a cover image from a PDF file.
// It uses pdfcpu API to extract images from the first page.
// Returns the path to the saved cover, or empty string if no cover found.
func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
// Create a temporary directory for extracted images
tmpDir, err := os.MkdirTemp("", "pdf-cover-")
if err != nil {
return "", fmt.Errorf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Use pdfcpu API to extract images from first page
// ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error
err = pdfcpuapi.ExtractImagesFile(pdfPath, tmpDir, []string{"1"}, nil)
if err != nil {
// No images found or extraction failed - this is OK, just return empty
return "", nil
}
// Check for extracted images in the temp directory
entries, err := os.ReadDir(tmpDir)
if err != nil || len(entries) == 0 {
return "", nil
}
// Find the largest image (likely the cover)
var largestImage string
var largestSize int64
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
// Skip very small files (likely thumbnails or icons)
if info.Size() < 1000 {
continue
}
if info.Size() > largestSize {
largestImage = filepath.Join(tmpDir, entry.Name())
largestSize = info.Size()
}
}
if largestImage == "" {
return "", nil
}
// Read the image
imageData, err := os.ReadFile(largestImage)
if err != nil || len(imageData) == 0 {
return "", nil
}
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
coverPath := pdfPath + ".cover.jpg"
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover file: %v", err)
}
return coverPath, nil
}
PHASE 4: Add Required Import
Step 4.1: Add pdfcpu imports
File: internal/services/media_scanner.go
Location: In the import block (lines 1-34)
EXISTING CODE (lines 28-34):
"bookhoard/internal/sevenzip"
epub "github.com/ArcadiaLin/go-epub"
"github.com/fsnotify/fsnotify"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/nwaples/rardecode"
)
REPLACE WITH:
"bookhoard/internal/sevenzip"
epub "github.com/ArcadiaLin/go-epub"
"github.com/fsnotify/fsnotify"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/nwaples/rardecode"
pdfcpuapi "github.com/pdfcpu/pdfcpu/pkg/api"
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu"
)
NOTE: We import both:
pdfcpuapifor high-level functions:PDFInfoFile,ExtractImagesFilepdfcpufor types and configuration (if needed)
Verification: Run go build ./internal/services/... - must compile without errors
PHASE 5: Unit Tests
Step 5.1: Create EPUB Cover Unit Test File
File: internal/services/media_scanner_epub_cover_test.go (NEW FILE)
Exact Code:
package services
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"testing"
)
// TestExtractEPUBCover tests EPUB cover extraction
func TestExtractEPUBCover(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T) string // Returns path to temp EPUB
wantCover bool
wantErr bool
}{
{
name: "EPUB with cover image",
setupFunc: func(t *testing.T) string {
tmpDir := t.TempDir()
epubPath := filepath.Join(tmpDir, "test.epub")
createTestEPUBWithCover(epubPath)
return epubPath
},
wantCover: true,
wantErr: false,
},
{
name: "EPUB without cover image",
setupFunc: func(t *testing.T) string {
tmpDir := t.TempDir()
epubPath := filepath.Join(tmpDir, "test.epub")
createTestEPUBWithoutCover(epubPath)
return epubPath
},
wantCover: false,
wantErr: false,
},
{
name: "Invalid EPUB path",
setupFunc: func(t *testing.T) string {
return "/nonexistent/path.epub"
},
wantCover: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
epubPath := tt.setupFunc(t)
if tt.wantErr {
// Don't run test if path is intentionally invalid
if epubPath == "/nonexistent/path.epub" {
return
}
}
scanner := &MediaScanner{}
coverPath, err := scanner.extractEPUBCover(epubPath)
if tt.wantErr && err == nil {
t.Error("Expected error but got none")
}
if !tt.wantErr && err != nil {
t.Errorf("Unexpected error: %v", err)
}
if tt.wantCover && coverPath == "" {
t.Error("Expected cover path but got empty string")
}
if !tt.wantCover && coverPath != "" {
t.Errorf("Did not expect cover but got: %s", coverPath)
}
// Cleanup cover file if created
if coverPath != "" {
os.Remove(coverPath)
}
})
}
}
// createTestEPUBWithCover creates a test EPUB with a cover image
func createTestEPUBWithCover(epubPath string) error {
file, err := os.Create(epubPath)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
defer zipWriter.Close()
// Create minimal EPUB structure with cover
files := map[string]string{
"mimetype": "application/epub+zip",
"META-INF/container.xml": `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`,
"OEBPS/content.opf": `<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" version="2.0"><manifest><item id="cover-image" href="cover.jpg" media-type="image/jpeg"/><item id="cover-page" href="cover.xhtml" media-type="application/xhtml+xml"/></manifest><metadata><meta name="cover" content="cover-image"/></metadata><spine><itemref idref="cover-page"/></spine></package>`,
"OEBPS/cover.xhtml": `<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>Cover</title></head><body><img src="cover.jpg" alt="Cover"/></body></html>`,
"OEBPS/cover.jpg": createPlaceholderJPEG(),
}
for name, content := range files {
w, err := zipWriter.Create(name)
if err != nil {
return err
}
if name == "mimetype" {
w.Write([]byte(content))
} else {
w.Write([]byte(content))
}
}
return nil
}
// createTestEPUBWithoutCover creates a test EPUB without a cover image
func createTestEPUBWithoutCover(epubPath string) error {
file, err := os.Create(epubPath)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
defer zipWriter.Close()
files := map[string]string{
"mimetype": "application/epub+zip",
"META-INF/container.xml": `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`,
"OEBPS/content.opf": `<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" version="2.0"><manifest><item id="chapter1" href="chapter.xhtml" media-type="application/xhtml+xml"/></manifest><metadata><dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Test Book</dc:title></metadata><spine><itemref idref="chapter1"/></spine></package>`,
"OEBPS/chapter.xhtml": `<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter 1</title></head><body><p>Chapter 1 content</p></body></html>`,
}
for name, content := range files {
w, err := zipWriter.Create(name)
if err != nil {
return err
}
w.Write([]byte(content))
}
return nil
}
// createPlaceholderJPEG creates a minimal valid JPEG for testing
func createPlaceholderJPEG() string {
// Minimal 1x1 red JPEG
return "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x01\x00\x00\xFF\xDB\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c\x1c $.\' \",#\x1c\x1c(7),01444\x1f\'9=82<.342\xFF\xC0\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00\xFF\xC4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\xFF\xC4\x00\xb5\x10\x00\x02\x01\x03\x03\x02\x04\x03\x05\x05\x04\x04\x00\x00\x01}\x01\x02\x03\x00\x04\x11\x05\x12!1A\x06\x13Qa\x07\"q\x142\x81\x91\xa1\x08#B\xb1\xc1\x15R\xd1\xf0$3br\x82\t\n\x16\x17\x18\x19\x1a%&\'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz\x83\x84\x85\x86\x87\x88\x89\x8a\x92\x93\x94\x95\x96\x97\x98\x99\x9a\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xFF\xDA\x00\x08\x01\x01\x00\x00?\x00\xFB\xD5\xFF\xD9"
}
// TestFindSidecarCover tests sidecar cover detection
func TestFindSidecarCover(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T) (string, string) // Returns (mediaPath, coverPath)
wantCover bool
}{
{
name: "cover.jpg exists",
setupFunc: func(t *testing.T) (string, string) {
tmpDir := t.TempDir()
mediaPath := filepath.Join(tmpDir, "book.epub")
coverPath := filepath.Join(tmpDir, "cover.jpg")
os.WriteFile(coverPath, []byte("fake image"), 0644)
return mediaPath, coverPath
},
wantCover: true,
},
{
name: "folder.jpg exists",
setupFunc: func(t *testing.T) (string, string) {
tmpDir := t.TempDir()
mediaPath := filepath.Join(tmpDir, "book.epub")
coverPath := filepath.Join(tmpDir, "folder.jpg")
os.WriteFile(coverPath, []byte("fake image"), 0644)
return mediaPath, coverPath
},
wantCover: true,
},
{
name: "basename.jpg exists",
setupFunc: func(t *testing.T) (string, string) {
tmpDir := t.TempDir()
mediaPath := filepath.Join(tmpDir, "mystory.epub")
coverPath := filepath.Join(tmpDir, "mystory.jpg")
os.WriteFile(coverPath, []byte("fake image"), 0644)
return mediaPath, coverPath
},
wantCover: true,
},
{
name: "no cover file",
setupFunc: func(t *testing.T) (string, string) {
tmpDir := t.TempDir()
mediaPath := filepath.Join(tmpDir, "book.epub")
return mediaPath, ""
},
wantCover: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mediaPath, _ := tt.setupFunc(t)
result := findSidecarCover(mediaPath)
if tt.wantCover && result == "" {
t.Error("Expected cover path but got empty string")
}
if !tt.wantCover && result != "" {
t.Errorf("Did not expect cover but got: %s", result)
}
})
}
}
Verification: Run go test ./internal/services/... -run "EPUB|Cover" -v - tests should compile and run
PHASE 6: Integration Tests
Step 6.1: Add Scanner Integration Test for Covers
File: cmd/server/tests/scanner_integration_test.go
Location: Add as a new test function in the file
Exact Code:
func (s *ScannerIntegrationTestSuite) TestScanMediaItems_CoverExtraction() {
token := s.setup.Token
// Create test library
createLibReq := map[string]interface{}{
"name": "Cover Test Library",
"description": "Test library for cover extraction",
"type": "ebooks",
}
createLibBody, _ := json.Marshal(createLibReq)
createLibURL := s.setup.Server.URL + "/api/libraries"
createLibReqHTTP, _ := http.NewRequest("POST", createLibURL, bytes.NewBuffer(createLibBody))
createLibReqHTTP.Header.Set("Content-Type", "application/json")
createLibReqHTTP.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
createLibResp, err := client.Do(createLibReqHTTP)
require.NoError(s.T(), err)
require.Equal(s.T(), http.StatusCreated, createLibResp.StatusCode)
var createLibResponse map[string]interface{}
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
createLibResp.Body.Close()
libraryID, ok := createLibResponse["id"].(string)
require.True(s.T(), ok, "library_id should be string")
// Add test folder
folderURL := fmt.Sprintf("%s/api/libraries/%s/folders", s.setup.Server.URL, libraryID)
folderReq := map[string]interface{}{
"folder_path": "/app/uploads/cover-test",
}
folderBody, _ := json.Marshal(folderReq)
folderReqHTTP, _ := http.NewRequest("POST", folderURL, bytes.NewBuffer(folderBody))
folderReqHTTP.Header.Set("Content-Type", "application/json")
folderReqHTTP.Header.Set("Authorization", "Bearer "+token)
folderResp, err := client.Do(folderReqHTTP)
require.NoError(s.T(), err)
folderResp.Body.Close()
require.Equal(s.T(), http.StatusCreated, folderResp.StatusCode)
// Note: In real test, you'd create actual EPUB/PDF files with covers
// For now, we test that the scan completes without error
// Run scan
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
scanReq, _ := http.NewRequest("POST", scanURL, nil)
scanReq.Header.Set("Authorization", "Bearer "+token)
scanResp, err := client.Do(scanReq)
require.NoError(s.T(), err)
require.Equal(s.T(), http.StatusAccepted, scanResp.StatusCode)
var scanResponse map[string]interface{}
json.NewDecoder(scanResp.Body).Decode(&scanResponse)
scanResp.Body.Close()
jobID, ok := scanResponse["job_id"].(string)
require.True(s.T(), ok, "job_id should be string")
// Wait for scan to complete
time.Sleep(5 * time.Second)
// Poll for completion
var completed bool
for i := 0; i < 30; i++ {
statusURL := fmt.Sprintf("%s/api/scanner/status/%s", s.setup.Server.URL, jobID)
statusReq, _ := http.NewRequest("GET", statusURL, nil)
statusReq.Header.Set("Authorization", "Bearer "+token)
statusResp, err := client.Do(statusReq)
require.NoError(s.T(), err)
var status map[string]interface{}
json.NewDecoder(statusResp.Body).Decode(&status)
statusResp.Body.Close()
if status["status"] == "completed" || status["status"] == "failed" {
completed = true
break
}
time.Sleep(1 * time.Second)
}
require.True(s.T(), completed, "Scan should complete")
}
NOTE: Full integration test requires actual test EPUB/PDF files with covers. This test verifies the scan flow works.
PHASE 7: Bruno Tests
Step 7.1: Update Scan Media Items Documentation
File: bruno/scanner/Scan Media Items.yml
Add to docs section:
docs: |-
## Scan Media Items (Background)
Triggers an asynchronous media items scanning operation. The scan runs in the background and can be monitored using the job ID.
**Cover Extraction:**
- EPUB: Extracts embedded cover from OPF manifest, falls back to sidecar cover.jpg/folder.jpg
- PDF: Attempts to extract first page as cover image, falls back to sidecar
- Comics (CBZ/CBR): Already extracts cover from archive
**Metadata Extraction:**
- Title, Author, Description, Publisher (all formats)
- Series, Series Number (EPUB with Calibre metadata)
- Tags/Subjects (EPUB)
PHASE 8: Documentation Updates
Step 8.1: Update User Admin Guide
File: docs/user/admin-guide.md
Add section:
## Cover Image Extraction
Bookhoard automatically extracts cover images from your media files during scanning.
### Supported Formats
| Format | Embedded Cover | Sidecar Cover |
|--------|---------------|---------------|
| EPUB | ✓ | ✓ |
| PDF | ✓ (first page) | ✓ |
| CBZ/CBR| ✓ | - |
### Cover Priority
1. **Embedded cover** - Extracted from the file's internal metadata
2. **Sidecar cover** - If no embedded cover, looks for:
- `cover.jpg`, `cover.jpeg`, `cover.png`, `cover.webp`
- `folder.jpg`, `folder.jpeg`, `folder.png`, `folder.webp`
- `{filename}.jpg` (same name as media file)
### Storage
Cover images are saved next to the original file with `.cover.jpg` extension:
- `/path/to/book.epub` → `/path/to/book.epub.cover.jpg`
PHASE 9: Verification
Step 9.1: Build Verification
# Build the application
go build ./...
# Run unit tests
go test ./internal/services/... -v -run "EPUB|Cover"
# Run integration tests (requires running container)
go test ./cmd/server/tests/... -v -run "Scanner"
Step 9.2: Run Verification Script
bash scripts/verify-guidelines.sh
SUMMARY OF CHANGES
Files Modified
| File | Change Type | Lines |
|---|---|---|
go.mod |
Add dependency | +1 |
internal/services/media_scanner.go |
Add functions + modify | +200-250 |
Files Created
| File | Purpose |
|---|---|
internal/services/media_scanner_epub_cover_test.go |
Unit tests |
docs/user/admin-guide.md |
Update user docs |
Files Updated
| File | Purpose |
|---|---|
bruno/scanner/Scan Media Items.yml |
Update API docs |
POST-IMPLEMENTATION CHECKLIST
- All code compiles without errors
- Unit tests pass
- Integration tests pass (manual verification with real files)
- Verification script passes (0 errors)
- Documentation is complete and accurate
- No secrets or credentials in changes