From 213e7b9a9df0ac48bf4d102e6291c57717856a62 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 25 Feb 2026 20:53:18 -0500 Subject: [PATCH] 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 --- internal/services/media_scanner.go | 414 ++++++++++++++++++++++++++++- 1 file changed, 406 insertions(+), 8 deletions(-) diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 6370a82..1d6bcd0 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -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("]*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(`]*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(`]*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(`]*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