feat(scanner): fill sparse sidecars from the PDF Info dictionary

Same gap-fill rule as the EPUB path, against the PDF's embedded Info
dictionary via a readPDFInfoDict helper reusing extractPDFMetadata's
field conventions (creator falls back to author, subject maps to
description, producer to publisher, keywords to tags, plus page count).
Sidecar values always win; unopenable PDFs skip silently.

TestMergeMetadataPDFGapFill uses an Info-bearing hand-built PDF fixture
(shared with the sidecar-cover test) and asserts the sidecar title is
kept while author/description/publisher/tags/page count fill in.
This commit is contained in:
John O'Keefe
2026-09-13 13:06:48 -04:00
parent 708598687e
commit e944f11415
2 changed files with 116 additions and 1 deletions
+70
View File
@@ -1221,6 +1221,31 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
}
}
// For PDFs: fill blanks from the embedded Info dictionary. Same rule as
// EPUBs - sidecar values win, the file only fills gaps.
if ext == ".pdf" {
if embedded, err := s.readPDFInfoDict(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 len(metadata.Tags) == 0 && len(embedded.Tags) > 0 {
metadata.Tags = embedded.Tags
}
if metadata.PageCount == 0 && embedded.PageCount > 0 {
metadata.PageCount = embedded.PageCount
}
}
}
// For comic archives, try to extract ComicInfo.xml
if ext == ".cbz" || ext == ".cbr" || ext == ".cb7" || ext == ".cbt" {
comicInfo, cover, err := extractComicMetadata(path)
@@ -2268,6 +2293,51 @@ func findSidecarCover(mediaPath string) string {
return ""
}
// readPDFInfoDict reads a PDF's embedded Info dictionary into MediaMetadata,
// using the same field conventions as extractPDFMetadata (creator falls back
// to author, subject maps to description, producer to publisher, keywords to
// tags). No cover or filename logic - purely the document's own metadata, for
// filling sidecar gaps in mergeMetadata.
func (s *MediaScanner) readPDFInfoDict(path string) (*MediaMetadata, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() {
if err := f.Close(); err != nil {
fmt.Printf("Warning: failed to close PDF file %s: %v\n", path, err)
}
}()
pdfInfo, err := pdfcpuapi.PDFInfo(f, filepath.Base(path), nil, false, nil)
if err != nil {
return nil, err
}
metadata := &MediaMetadata{}
if pdfInfo.Title != "" {
metadata.Title = pdfInfo.Title
}
if pdfInfo.Author != "" {
metadata.Author = pdfInfo.Author
} else if pdfInfo.Creator != "" {
metadata.Author = pdfInfo.Creator
}
if pdfInfo.Subject != "" {
metadata.Description = pdfInfo.Subject
}
if pdfInfo.Producer != "" {
metadata.Publisher = pdfInfo.Producer
}
if len(pdfInfo.Keywords) > 0 {
metadata.Tags = utils.NormalizeTags(pdfInfo.Keywords)
}
if pdfInfo.PageCount > 0 {
metadata.PageCount = int32(pdfInfo.PageCount)
}
return metadata, nil
}
func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
metadata := &MediaMetadata{}