diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 39a9fb3..de60791 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -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) } diff --git a/internal/services/media_scanner_metadata_test.go b/internal/services/media_scanner_metadata_test.go index c044538..b045162 100644 --- a/internal/services/media_scanner_metadata_test.go +++ b/internal/services/media_scanner_metadata_test.go @@ -3,6 +3,7 @@ package services import ( "archive/zip" "bytes" + "fmt" "image" "image/jpeg" "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) + } +}