From 708598687e9d97cc27b5fe26f0ca3797cab9ab30 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 13:05:37 -0400 Subject: [PATCH] feat(scanner): fill sparse sidecars from embedded EPUB metadata A sparse metadata.opf/metadata.json (title only, no description) left books thin even when the file itself carried rich data: the embedded extractors only ran when no sidecar existed at all. Now mergeMetadata fills blanks from the book's own OPF - title, author, description, publisher, language, ISBN, ASIN, series/number, publish date, tags, contributors - while sidecar values always win and unparseable files skip silently. Also covers .kepub, which the merge previously ignored while the extractor already supported it. Adds TestMergeMetadataEPUBGapFill asserting both directions: sidecar title/author survive, embedded description/publisher/language fill in. --- internal/services/media_scanner.go | 44 ++++++++++++- .../services/media_scanner_metadata_test.go | 61 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) 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) + } +}