fix(scanner): extract embedded covers for sidecar-managed books

A full rescan wiped cover_image_path for every PDF/EPUB living in a
metadata.json (or metadata.opf-less) folder with no cover.jpg next to
the book: the sidecar branches returned early after findSidecarCover
missed, and mergeMetadata has no PDF/EPUB cover logic of its own. Four
books lost their thumbnails while their {file}.cover.jpg files still sat
on disk - most visibly the Audiobookshelf-managed No Starch titles.

Both sidecar branches now fall through to an embedded-cover fallback
(PDF via extractPDFCover, EPUB/KEPUB via extractEPUBCover) whenever no
sidecar cover file exists. Comics are untouched: mergeMetadata already
extracts their covers from the archive.

Adds TestSidecarCoverFallback with a hand-built one-page PDF carrying a
JPEG XObject plus a metadata.json sidecar, asserting the sidecar title
wins while the cover still comes from the file. Verified live: rescans
restored all four dereferenced covers with no leftover rows.
This commit is contained in:
John O'Keefe
2026-09-13 11:59:24 -04:00
parent 1eb9c92d6a
commit 841db91a29
2 changed files with 95 additions and 0 deletions
+23
View File
@@ -1007,6 +1007,27 @@ func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
return metadata return metadata
} }
// applyEmbeddedCoverFallback extracts a cover from the media file itself when
// a metadata sidecar (metadata.opf / metadata.json) supplied the metadata but
// no sidecar cover (cover.jpg etc.) exists. Without this, a full rescan would
// clear cover_image_path for sidecar-managed PDFs and EPUBs - mergeMetadata
// has no PDF/EPUB cover logic of its own.
func (s *MediaScanner) applyEmbeddedCoverFallback(path string, metadata *MediaMetadata) {
if metadata.CoverPath != "" {
return
}
switch strings.ToLower(filepath.Ext(path)) {
case ".pdf":
if coverPath, err := s.extractPDFCover(path); err == nil && coverPath != "" {
metadata.CoverPath = s.getRelativePath(coverPath)
}
case ".epub", ".kepub":
if coverPath, err := s.extractEPUBCover(path); err == nil && coverPath != "" {
metadata.CoverPath = s.getRelativePath(coverPath)
}
}
}
// extractAudiobookshelfSidecar checks for and parses an Audiobookshelf-style // extractAudiobookshelfSidecar checks for and parses an Audiobookshelf-style
// metadata.json sidecar next to the media file. Only fields with a matching // metadata.json sidecar next to the media file. Only fields with a matching
// media_items column are mapped; narrators, subtitle, explicit, abridged and // media_items column are mapped; narrators, subtitle, explicit, abridged and
@@ -1414,6 +1435,7 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
if coverPath != "" { if coverPath != "" {
calibreMetadata.CoverPath = s.getRelativePath(coverPath) calibreMetadata.CoverPath = s.getRelativePath(coverPath)
} }
s.applyEmbeddedCoverFallback(path, calibreMetadata)
return s.mergeMetadata(path, calibreMetadata) return s.mergeMetadata(path, calibreMetadata)
} }
@@ -1428,6 +1450,7 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
if coverPath != "" { if coverPath != "" {
abMetadata.CoverPath = s.getRelativePath(coverPath) abMetadata.CoverPath = s.getRelativePath(coverPath)
} }
s.applyEmbeddedCoverFallback(path, abMetadata)
return s.mergeMetadata(path, abMetadata) return s.mergeMetadata(path, abMetadata)
} }
@@ -3,6 +3,7 @@ package services
import ( import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"fmt"
"image" "image"
"image/jpeg" "image/jpeg"
"os" "os"
@@ -230,3 +231,74 @@ func TestExtractAudiobookshelfSidecar(t *testing.T) {
} }
}) })
} }
// createTestPDFWithImage builds a minimal one-page PDF embedding a JPEG
// XObject, so pdfcpu-based cover extraction has an image to find.
func createTestPDFWithImage() []byte {
jpegData := []byte(createTestJPEGBytes())
content := "q 100 0 0 100 0 0 cm /Im0 Do Q"
var buf bytes.Buffer
offsets := []int{0} // object numbers are 1-based
buf.WriteString("%PDF-1.4\n")
writeObj := func(n int, body func(w *bytes.Buffer)) {
offsets = append(offsets, buf.Len())
fmt.Fprintf(&buf, "%d 0 obj\n", n)
body(&buf)
buf.WriteString("endobj\n")
}
writeObj(1, func(w *bytes.Buffer) { w.WriteString("<< /Type /Catalog /Pages 2 0 R >>\n") })
writeObj(2, func(w *bytes.Buffer) { w.WriteString("<< /Type /Pages /Kids [3 0 R] /Count 1 >>\n") })
writeObj(3, func(w *bytes.Buffer) {
w.WriteString("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\n")
})
writeObj(4, func(w *bytes.Buffer) {
fmt.Fprintf(w, "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n", len(jpegData))
w.Write(jpegData)
w.WriteString("\nendstream\n")
})
writeObj(5, func(w *bytes.Buffer) {
fmt.Fprintf(w, "<< /Length %d >>\nstream\n%s\nendstream\n", len(content), content)
})
xrefStart := buf.Len()
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
for _, off := range offsets[1:] {
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xrefStart)
return buf.Bytes()
}
// TestSidecarCoverFallback guards the regression where a metadata.json
// sidecar (no cover.jpg next to the book) made extractMetadata return an
// empty CoverPath for PDFs - mergeMetadata has no PDF/EPUB cover logic, so a
// full rescan wiped cover_image_path for sidecar-managed books.
func TestSidecarCoverFallback(t *testing.T) {
dir := t.TempDir()
pdfPath := filepath.Join(dir, "book.pdf")
if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(`{"title":"Sidecar Book","authors":["A"]}`), 0644); err != nil {
t.Fatal(err)
}
s := NewMediaScanner(nil)
s.folders = []string{dir} // SetFolders requires a live DB; tests only need path relativization
metadata, err := s.extractMetadata(pdfPath)
if err != nil {
t.Fatalf("extractMetadata() error: %v", err)
}
if metadata.Title != "Sidecar Book" {
t.Errorf("Title = %q, want sidecar title", metadata.Title)
}
if metadata.CoverPath == "" {
t.Fatal("CoverPath empty - sidecar branch skipped embedded cover extraction")
}
if _, err := os.Stat(filepath.Join(dir, metadata.CoverPath)); err != nil {
t.Errorf("extracted cover not on disk: %v", err)
}
}