Add EPUB and PDF cover extraction with metadata support

- Add extractEPUBCover() to extract embedded covers from EPUB files
  - Parse OPF manifest for cover-image properties
  - Support meta name="cover" tags
  - Fall back to common cover paths (cover.jpg, images/cover.jpg)

- Add findSidecarCover() for sidecar cover detection
  - Check cover.jpg, cover.png, cover.webp
  - Check folder.jpg, folder.png
  - Check {basename}.jpg (same name as media file)

- Add extractPDFCover() to extract first page images from PDFs
  - Use pdfcpu API to extract images from page 1
  - Save largest image as cover

- Update extractPDFMetadata() to use pdfcpu API
  - Extract Title, Author, Subject, Creator, Producer
  - Call extractPDFCover for embedded covers
  - Fall back to sidecar covers

- Update extractMetadata() for EPUB to call extractEPUBCover
  - Try embedded cover first, then sidecar
This commit is contained in:
2026-02-25 20:53:18 -05:00
parent 570048c96c
commit 213e7b9a9d
+406 -8
View File
@@ -31,6 +31,7 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/nwaples/rardecode" "github.com/nwaples/rardecode"
pdfcpuapi "github.com/pdfcpu/pdfcpu/pkg/api"
) )
// MediaMetadata contains extracted metadata for media files (ebooks, comics, manga) // MediaMetadata contains extracted metadata for media files (ebooks, comics, manga)
@@ -555,7 +556,25 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
switch ext { switch ext {
case ".epub": case ".epub":
return s.extractEPUBMetadata(path) 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
case ".pdf": case ".pdf":
return s.extractPDFMetadata(path) return s.extractPDFMetadata(path)
default: default:
@@ -652,14 +671,393 @@ func (s *MediaScanner) extractEPUBMetadata(path string) (*MediaMetadata, error)
return metadata, nil return metadata, nil
} }
func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) { // extractEPUBCover extracts the cover image from an EPUB file.
// For now, return basic metadata since PDF extraction requires additional libraries // It looks for:
// In a future enhancement, you could use libraries like github.com/ledongthuc/pdf // 1. An item with properties="cover-image" in the manifest
filename := strings.TrimSuffix(filepath.Base(path), ".pdf") // 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()
return &MediaMetadata{ // Try to find cover image from OPF metadata
Title: filename, coverImageName := ""
}, nil
// 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 {
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)
}
// 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 ""
}
func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
metadata := &MediaMetadata{}
// Open PDF file for reading metadata
f, err := os.Open(path)
if err != nil {
fmt.Printf("Warning: failed to open PDF file %s: %v\n", path, err)
metadata.Title = strings.TrimSuffix(filepath.Base(path), ".pdf")
return metadata, nil
}
defer f.Close()
// Use pdfcpu API to read PDF metadata
// Configuration: nil = default (lenient mode)
pdfInfo, err := pdfcpuapi.PDFInfo(f, filepath.Base(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
}
// 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
} }
// ComicInfo represents metadata from ComicInfo.xml // ComicInfo represents metadata from ComicInfo.xml