diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go
index 6bf8fc9..fbd1d1b 100644
--- a/internal/services/media_scanner.go
+++ b/internal/services/media_scanner.go
@@ -1161,7 +1161,7 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
ext := strings.ToLower(filepath.Ext(path))
// For EPUB files
- if ext == ".epub" {
+ if ext == ".epub" || ext == ".kepub" {
book, err := epub.ReadBook(path)
if err == nil {
genreTags := extractGenreTagsFromEPUB(book)
@@ -1177,6 +1177,48 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
metadata.PageCount = int32(pageCount)
}
}
+
+ // Gap-fill: sidecar-sourced metadata wins, but blanks are filled from
+ // the book's own OPF so a sparse metadata.opf/metadata.json doesn't
+ // hide data the file carries. Never overwrites sidecar values.
+ if embedded, err := s.extractEPUBMetadata(path); err == nil && embedded != nil {
+ if metadata.Title == "" && embedded.Title != "" {
+ metadata.Title = embedded.Title
+ }
+ if metadata.Author == "" && embedded.Author != "" {
+ metadata.Author = embedded.Author
+ }
+ if metadata.Description == "" && embedded.Description != "" {
+ metadata.Description = embedded.Description
+ }
+ if metadata.Publisher == "" && embedded.Publisher != "" {
+ metadata.Publisher = embedded.Publisher
+ }
+ if metadata.Language == "" && embedded.Language != "" {
+ metadata.Language = embedded.Language
+ }
+ if metadata.ISBN == "" && embedded.ISBN != "" {
+ metadata.ISBN = embedded.ISBN
+ }
+ if metadata.ASIN == "" && embedded.ASIN != "" {
+ metadata.ASIN = embedded.ASIN
+ }
+ if metadata.Series == "" && embedded.Series != "" {
+ metadata.Series = embedded.Series
+ }
+ if metadata.SeriesNumber == 0 && embedded.SeriesNumber != 0 {
+ metadata.SeriesNumber = embedded.SeriesNumber
+ }
+ if metadata.PublishDate.IsZero() && !embedded.PublishDate.IsZero() {
+ metadata.PublishDate = embedded.PublishDate
+ }
+ if len(metadata.Tags) == 0 && len(embedded.Tags) > 0 {
+ metadata.Tags = embedded.Tags
+ }
+ if len(metadata.Contributors) == 0 && len(embedded.Contributors) > 0 {
+ metadata.Contributors = embedded.Contributors
+ }
+ }
}
// For comic archives, try to extract ComicInfo.xml
diff --git a/internal/services/media_scanner_metadata_test.go b/internal/services/media_scanner_metadata_test.go
index b045162..2e2744a 100644
--- a/internal/services/media_scanner_metadata_test.go
+++ b/internal/services/media_scanner_metadata_test.go
@@ -302,3 +302,64 @@ func TestSidecarCoverFallback(t *testing.T) {
t.Errorf("extracted cover not on disk: %v", err)
}
}
+
+// TestMergeMetadataEPUBGapFill verifies that a sparse sidecar (metadata.opf
+// with only a title) gets its blanks filled from the book's own embedded OPF
+// while sidecar-set fields are never overwritten.
+func TestMergeMetadataEPUBGapFill(t *testing.T) {
+ dir := t.TempDir()
+ epubPath := filepath.Join(dir, "gappy.epub")
+ f, err := os.Create(epubPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ zipWriter := zip.NewWriter(f)
+ mimetype, _ := zipWriter.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
+ mimetype.Write([]byte("application/epub+zip"))
+ epubFiles := map[string]string{
+ "META-INF/container.xml": ``,
+ "OEBPS/content.opf": `
+
+
+ Embedded Title
+ Embedded Author
+ Embedded description from the book.
+ Embedded Publisher
+ en
+ 2021-06-01
+
+
+
+`,
+ }
+ for name, content := range epubFiles {
+ w, err := zipWriter.Create(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ w.Write([]byte(content))
+ }
+ if err := zipWriter.Close(); err != nil {
+ t.Fatal(err)
+ }
+ f.Close()
+
+ sparse := &MediaMetadata{Title: "Sidecar Title", Author: "Sidecar Author"}
+ s := NewMediaScanner(nil)
+ merged, err := s.mergeMetadata(epubPath, sparse)
+ if err != nil {
+ t.Fatalf("mergeMetadata() error: %v", err)
+ }
+ if merged.Title != "Sidecar Title" || merged.Author != "Sidecar Author" {
+ t.Errorf("sidecar values overwritten: title=%q author=%q", merged.Title, merged.Author)
+ }
+ if merged.Description != "Embedded description from the book." {
+ t.Errorf("Description = %q, want embedded fill", merged.Description)
+ }
+ if merged.Publisher != "Embedded Publisher" {
+ t.Errorf("Publisher = %q, want embedded fill", merged.Publisher)
+ }
+ if merged.Language != "en" {
+ t.Errorf("Language = %q, want embedded fill", merged.Language)
+ }
+}