Features: - Complete dashboard redesign with improved UI components and layout - Implement custom section builder for personalized book organization - Add new events tracking system for user interactions - Enhance search functionality with better static search.js - Update TypeScript type definitions for API responses Backend: - Update Go dependencies in go.mod - Add new frontend routes in router Templates: - Update admin and dashboard templates with new components Frontend: - Refactor analytics, collections, conflicts, and queue modules - Add new documentation features in docs.ts - Implement linking between books and collections - Add toast notifications for user feedback - Include placeholder book SVG asset This commit consolidates multiple feature additions and improvements across the entire stack including backend, templates, and frontend.
1096 lines
31 KiB
Markdown
1096 lines
31 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 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
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 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
|