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
}
// 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
// metadata.json sidecar next to the media file. Only fields with a matching
// media_items column are mapped; narrators, subtitle, explicit, abridged and
@@ -1414,6 +1435,7 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
if coverPath != "" {
calibreMetadata.CoverPath = s.getRelativePath(coverPath)
}
s.applyEmbeddedCoverFallback(path, calibreMetadata)
return s.mergeMetadata(path, calibreMetadata)
}
@@ -1428,6 +1450,7 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
if coverPath != "" {
abMetadata.CoverPath = s.getRelativePath(coverPath)
}
s.applyEmbeddedCoverFallback(path, abMetadata)
return s.mergeMetadata(path, abMetadata)
}