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.
This commit is contained in:
2026-09-11 23:08:20 -04:00
parent 6aa958c78f
commit 0614795ca2
2 changed files with 126 additions and 42 deletions
+2 -2
View File
@@ -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 # This stage is ONLY used for running tests, never deployed to production
FROM golang:1.26-alpine AS test-runner 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 WORKDIR /app
@@ -65,7 +65,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1",
# Final stage # Final stage
FROM alpine:latest 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 # Install kepubify for EPUB→KEPUB conversion
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \ RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
+124 -40
View File
@@ -23,6 +23,7 @@ import (
"io" "io"
"io/fs" "io/fs"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
@@ -2078,56 +2079,94 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
// Use pdfcpu API to extract images from first page // Use pdfcpu API to extract images from first page
// ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error // ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error
err = pdfcpuapi.ExtractImagesFile(pdfPath, tmpDir, []string{"1"}, nil) 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 { if err != nil {
// No images found or extraction failed - this is OK, just return empty fmt.Printf("Warning: failed to create temp dir for PDF cover render %s: %v\n", pdfPath, err)
return "", nil 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 imageData, err := os.ReadFile(outPrefix + ".jpg")
entries, err := os.ReadDir(tmpDir) if err != nil || len(imageData) < 1000 {
if err != nil || len(entries) == 0 { fmt.Printf("Warning: PDF cover render produced no usable image for %s\n", pdfPath)
return "", nil return ""
}
// 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) // Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
coverPath := pdfPath + ".cover.jpg" coverPath := pdfPath + ".cover.jpg"
if err := os.WriteFile(coverPath, imageData, 0644); err != nil { 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 // ComicInfo represents metadata from ComicInfo.xml
@@ -2618,6 +2657,51 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
return err 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) { func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string, libraryID pgtype.UUID) (database.MediaItems, error) {
return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{ return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
FilePath: s.getRelativePath(filePath), FilePath: s.getRelativePath(filePath),