Files
bookhoard/IMPLEMENTATION_PLAN_COVER_PDF.md
T
john-okeefe f36e88c0ee Clean up implementation plan: remove duplicate Step 3.2
- Remove duplicate extractPDFCover function documentation
- Fixes issue identified during plan review where Step 3.2
  appeared twice with identical content
2026-02-25 20:53:46 -05:00

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:

  • pdfcpuapi for high-level functions: PDFInfoFile, ExtractImagesFile
  • pdfcpu for 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