- Remove duplicate extractPDFCover function documentation - Fixes issue identified during plan review where Step 3.2 appeared twice with identical content
1018 lines
29 KiB
Markdown
1018 lines
29 KiB
Markdown
# 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:
|
|
```bash
|
|
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:**
|
|
|
|
```go
|
|
// 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:**
|
|
|
|
```go
|
|
// 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):**
|
|
```go
|
|
switch ext {
|
|
case ".epub":
|
|
return s.extractEPUBMetadata(path)
|
|
```
|
|
|
|
**REPLACE WITH:**
|
|
```go
|
|
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):**
|
|
```go
|
|
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:**
|
|
```go
|
|
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:**
|
|
|
|
```go
|
|
// 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):**
|
|
```go
|
|
"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:**
|
|
```go
|
|
"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:**
|
|
|
|
```go
|
|
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:**
|
|
|
|
```go
|
|
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:**
|
|
```yaml
|
|
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:**
|
|
|
|
```markdown
|
|
## 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
|
|
|
|
```bash
|
|
# 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
|
|
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
|