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:
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/nwaples/rardecode"
|
||||
pdfcpuapi "github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
)
|
||||
|
||||
// MediaMetadata contains extracted metadata for media files (ebooks, comics, manga)
|
||||
@@ -555,7 +556,25 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
|
||||
|
||||
switch ext {
|
||||
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":
|
||||
return s.extractPDFMetadata(path)
|
||||
default:
|
||||
@@ -652,14 +671,393 @@ func (s *MediaScanner) extractEPUBMetadata(path string) (*MediaMetadata, error)
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
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")
|
||||
// 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()
|
||||
|
||||
return &MediaMetadata{
|
||||
Title: filename,
|
||||
}, nil
|
||||
// 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 {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user