From e944f11415ba042c7ca57975d851ca9e9b3ba70b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 13:06:48 -0400 Subject: [PATCH] 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. --- internal/services/media_scanner.go | 70 +++++++++++++++++++ .../services/media_scanner_metadata_test.go | 47 ++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index fbd1d1b..e2e6e56 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -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{} diff --git a/internal/services/media_scanner_metadata_test.go b/internal/services/media_scanner_metadata_test.go index 2e2744a..5902131 100644 --- a/internal/services/media_scanner_metadata_test.go +++ b/internal/services/media_scanner_metadata_test.go @@ -262,13 +262,18 @@ func createTestPDFWithImage() []byte { writeObj(5, func(w *bytes.Buffer) { fmt.Fprintf(w, "<< /Length %d >>\nstream\n%s\nendstream\n", len(content), content) }) + writeObj(6, func(w *bytes.Buffer) { + // Info dictionary deliberately richer than the sidecars in gap-fill + // tests: gap-fill must use these, never overwrite with them. + w.WriteString("<< /Title (Embedded Title) /Author (Embedded Author) /Subject (Embedded Subject) /Producer (Embedded Producer) /Keywords (embedded-kw) >>\n") + }) 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) + fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R /Info 6 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xrefStart) return buf.Bytes() } @@ -363,3 +368,43 @@ func TestMergeMetadataEPUBGapFill(t *testing.T) { t.Errorf("Language = %q, want embedded fill", merged.Language) } } + +// TestMergeMetadataPDFGapFill verifies the same for PDFs: a sparse sidecar +// keeps its title while author/description/publisher/tags/pagecount fill in +// from the embedded Info dictionary. +func TestMergeMetadataPDFGapFill(t *testing.T) { + dir := t.TempDir() + pdfPath := filepath.Join(dir, "gappy.pdf") + if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil { + t.Fatal(err) + } + + sparse := &MediaMetadata{Title: "Kept Title"} + s := NewMediaScanner(nil) + merged, err := s.mergeMetadata(pdfPath, sparse) + if err != nil { + t.Fatalf("mergeMetadata() error: %v", err) + } + + checks := []struct { + name string + got string + want string + }{ + {"Title (sidecar wins)", merged.Title, "Kept Title"}, + {"Author", merged.Author, "Embedded Author"}, + {"Description", merged.Description, "Embedded Subject"}, + {"Publisher", merged.Publisher, "Embedded Producer"}, + } + for _, c := range checks { + if c.got != c.want { + t.Errorf("%s = %q, want %q", c.name, c.got, c.want) + } + } + if len(merged.Tags) == 0 { + t.Error("Tags empty, want keywords from Info dict") + } + if merged.PageCount != 1 { + t.Errorf("PageCount = %d, want 1 from Info dict", merged.PageCount) + } +}