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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user