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:
+2
-2
@@ -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 \
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -2078,17 +2079,10 @@ 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 {
|
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
|
// Check for extracted images in the temp directory
|
||||||
entries, err := os.ReadDir(tmpDir)
|
entries, err := os.ReadDir(tmpDir)
|
||||||
if err != nil || len(entries) == 0 {
|
if err == nil && len(entries) > 0 {
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the largest image (likely the cover)
|
// Find the largest image (likely the cover)
|
||||||
var largestImage string
|
var largestImage string
|
||||||
var largestSize int64
|
var largestSize int64
|
||||||
@@ -2111,16 +2105,10 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if largestImage == "" {
|
if largestImage != "" {
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the image
|
// Read the image
|
||||||
imageData, err := os.ReadFile(largestImage)
|
imageData, err := os.ReadFile(largestImage)
|
||||||
if err != nil || len(imageData) == 0 {
|
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 {
|
||||||
@@ -2128,6 +2116,57 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return coverPath, nil
|
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 {
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
fmt.Printf("Warning: failed to write rendered PDF cover for %s: %v\n", pdfPath, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
|||||||
Reference in New Issue
Block a user