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"
|
||||||
"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
|
||||||
|
|||||||
Reference in New Issue
Block a user