From 0614795ca2c7d6cc0fa0a36ee4d020b2f2dd556e Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 11 Sep 2026 23:08:04 -0400 Subject: [PATCH] feat(scanner): render PDF first page as cover fallback via pdftoppm extractPDFCover previously only saved embedded raster images from page 1, so vector/text-first-page PDFs (e.g. InDesign exports like Data Structures the Fun Way) ended up with no cover and a dashboard placeholder. When no embedded image is found it now falls back to rendering page 1 with pdftoppm (poppler-utils), saving the same {pdf}.cover.jpg sidecar. Also adds MediaScanner.RescanMediaItem, which re-extracts metadata for a single media item (resolving its on-disk path from library folders) so previously imported books can backfill covers without a full force rescan. Dockerfile installs poppler-utils in the final and test-runner stages. --- Dockerfile | 4 +- internal/services/media_scanner.go | 164 ++++++++++++++++++++++------- 2 files changed, 126 insertions(+), 42 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3a9c6a0..272cde6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \ # This stage is ONLY used for running tests, never deployed to production FROM golang:1.26-alpine AS test-runner -RUN apk --no-cache add ca-certificates curl +RUN apk --no-cache add ca-certificates curl poppler-utils WORKDIR /app @@ -65,7 +65,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1", # Final stage FROM alpine:latest -RUN apk --no-cache add ca-certificates curl +RUN apk --no-cache add ca-certificates curl poppler-utils # Install kepubify for EPUB→KEPUB conversion RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \ diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 5fd2215..f32677f 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -23,6 +23,7 @@ import ( "io" "io/fs" "os" + "os/exec" "path/filepath" "regexp" "strconv" @@ -2078,56 +2079,94 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) { // 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 { + // Check for extracted images in the temp directory + entries, err := os.ReadDir(tmpDir) + if err == nil && len(entries) > 0 { + // 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 != "" { + // Read the image + imageData, err := os.ReadFile(largestImage) + if err == nil && len(imageData) > 0 { + // 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 + } + } + } + } + + // No embedded raster cover found (e.g. vector/text first page) - fall back + // to rendering the first page with pdftoppm (poppler-utils). + return s.renderPDFCoverPage(pdfPath), nil +} + +// renderPDFCoverPage renders the first page of a PDF file to a JPEG image +// using pdftoppm. It saves the cover next to the PDF ({pdf_path}.cover.jpg). +// Returns the path to the saved cover, or empty string if rendering failed. +func (s *MediaScanner) renderPDFCoverPage(pdfPath string) string { + if _, err := exec.LookPath("pdftoppm"); err != nil { + fmt.Printf("Warning: pdftoppm not available, skipping PDF cover render for %s\n", pdfPath) + return "" + } + + tmpDir, err := os.MkdirTemp("", "pdf-render-") if err != nil { - // No images found or extraction failed - this is OK, just return empty - return "", nil + fmt.Printf("Warning: failed to create temp dir for PDF cover render %s: %v\n", pdfPath, err) + return "" + } + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + fmt.Printf("Warning: failed to remove temp directory %s: %v\n", tmpDir, err) + } + }() + + outPrefix := filepath.Join(tmpDir, "cover") + cmd := exec.Command("pdftoppm", "-jpeg", "-f", "1", "-l", "1", "-singlefile", "-r", "150", pdfPath, outPrefix) + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Printf("Warning: failed to render PDF cover from %s: %v, output: %s\n", pdfPath, err, string(output)) + return "" } - // 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 + imageData, err := os.ReadFile(outPrefix + ".jpg") + if err != nil || len(imageData) < 1000 { + fmt.Printf("Warning: PDF cover render produced no usable image for %s\n", pdfPath) + return "" } // 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) + fmt.Printf("Warning: failed to write rendered PDF cover for %s: %v\n", pdfPath, err) + return "" } - return coverPath, nil + return coverPath } // ComicInfo represents metadata from ComicInfo.xml @@ -2618,6 +2657,51 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U return err } +// RescanMediaItem re-extracts metadata for a single media item and updates it. +// It is the per-book rescan used by the Edit Metadata dialog and backfills +// covers for items imported before the PDF render fallback existed. +func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.UUID) error { + item, err := s.db.GetMediaItem(ctx, mediaItemID) + if err != nil { + return fmt.Errorf("media item not found: %w", err) + } + + folders, err := s.db.GetLibraryFolders(ctx, item.LibraryID) + if err != nil || len(folders) == 0 { + return fmt.Errorf("no library folders found for library") + } + + folderPaths := make([]string, 0, len(folders)) + for _, folder := range folders { + folderPaths = append(folderPaths, folder.FolderPath) + } + s.folders = folderPaths + + var fullPath string + for _, folder := range folders { + candidate := filepath.Join(folder.FolderPath, item.FilePath) + if _, err := os.Stat(candidate); err == nil { + fullPath = candidate + break + } + } + if fullPath == "" { + return fmt.Errorf("media file not found on disk: %s", item.FilePath) + } + + info, err := os.Stat(fullPath) + if err != nil { + return fmt.Errorf("failed to stat media file: %w", err) + } + + if err := s.updateMediaItem(ctx, mediaItemID, fullPath, info); err != nil { + return fmt.Errorf("failed to update media item: %w", err) + } + s.recomputeHashInfo(ctx, mediaItemID, item.LibraryID, fullPath) + + return nil +} + func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string, libraryID pgtype.UUID) (database.MediaItems, error) { return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{ FilePath: s.getRelativePath(filePath),