package services import ( "archive/zip" "bytes" "fmt" "image" "image/jpeg" "os" "path/filepath" "testing" "time" ) // createTestJPEGBytes returns the bytes of a minimal valid JPEG. func createTestJPEGBytes() string { var buf bytes.Buffer img := image.NewRGBA(image.Rect(0, 0, 1, 1)) if err := jpeg.Encode(&buf, img, nil); err != nil { return "" } return buf.String() } // createPragmaticStyleEPUB builds an EPUB modeled on Pragmatic Bookshelf // output: the dc namespace declared on the element (not on // ), a scheme-less ISBN identifier, an OPF-declared cover, and a // deliberately malformed chapter body. The malformed chapter is the // regression trigger: the previous go-epub-based extractor failed the whole // book when any chapter was unparseable and wrote blank metadata. func createPragmaticStyleEPUB(epubPath string) error { file, err := os.Create(epubPath) if err != nil { return err } defer file.Close() zipWriter := zip.NewWriter(file) defer zipWriter.Close() mimetypeW, err := zipWriter.CreateHeader(&zip.FileHeader{ Name: "mimetype", Method: zip.Store, }) if err != nil { return err } mimetypeW.Write([]byte("application/epub+zip")) files := map[string]string{ "META-INF/container.xml": ``, // dc namespace declared on ; identifiers carry no scheme attr "OEBPS/content.opf": ` en A Common-Sense Guide Jay Wengrow The Pragmatic Bookshelf, LLC Content that makes you a better programmer. Programming 978-1-68050-722-8 `, // Malformed on purpose: unclosed tags "OEBPS/ch1.xhtml": `

unclosed paragraph`, "OEBPS/images/cover.jpg": createTestJPEGBytes(), } for name, content := range files { w, err := zipWriter.Create(name) if err != nil { return err } if _, err := w.Write([]byte(content)); err != nil { return err } } return zipWriter.Close() } // TestExtractEPUBMetadataBrokenChapter guards the regression where one // unparseable chapter made the extractor return nothing at all: metadata must // come from the OPF regardless of chapter-body damage. func TestExtractEPUBMetadataBrokenChapter(t *testing.T) { tmpDir := t.TempDir() epubPath := filepath.Join(tmpDir, "book.epub") if err := createPragmaticStyleEPUB(epubPath); err != nil { t.Fatalf("failed to create test EPUB: %v", err) } s := NewMediaScanner(nil) metadata, err := s.extractEPUBMetadata(epubPath) if err != nil { t.Fatalf("extractEPUBMetadata() error: %v", err) } if metadata.Title != "A Common-Sense Guide" { t.Errorf("Title = %q, want %q", metadata.Title, "A Common-Sense Guide") } if metadata.Author != "Jay Wengrow" { t.Errorf("Author = %q, want %q", metadata.Author, "Jay Wengrow") } if metadata.Publisher != "The Pragmatic Bookshelf, LLC" { t.Errorf("Publisher = %q, want %q", metadata.Publisher, "The Pragmatic Bookshelf, LLC") } if metadata.Description == "" { t.Error("Description missing") } if metadata.Language != "en" { t.Errorf("Language = %q, want %q", metadata.Language, "en") } // Scheme-less identifier that normalizes to a valid ISBN must be picked up if metadata.ISBN == "" { t.Error("ISBN missing (scheme-less dc:identifier fallback failed)") } } func TestParseOPFContentCalibreSeries(t *testing.T) { opf := ` Test Book Some Author 2020-03-15 Fiction Classic 978-3-16-148410-0 ` metadata, err := parseOPFContent([]byte(opf)) if err != nil { t.Fatalf("parseOPFContent() error: %v", err) } if metadata.Series != "Great Series" || metadata.SeriesNumber != 2 { t.Errorf("Series = %q/%d, want Great Series/2", metadata.Series, metadata.SeriesNumber) } if metadata.ISBN == "" { t.Error("schemed ISBN not extracted") } wantDate := time.Date(2020, 3, 15, 0, 0, 0, 0, time.UTC) if !metadata.PublishDate.Equal(wantDate) { t.Errorf("PublishDate = %v, want %v", metadata.PublishDate, wantDate) } if len(metadata.Tags) != 2 { t.Errorf("Tags = %v, want 2 subjects", metadata.Tags) } } func TestExtractAudiobookshelfSidecar(t *testing.T) { tests := []struct { name string json string validate func(t *testing.T, m *MediaMetadata) }{ { name: "full sidecar", json: `{ "title": "An Book", "authors": ["Author One", "Author Two"], "series": [{"series": "The Series", "sequence": "4.5"}], "genres": ["Fantasy"], "tags": ["tag1"], "publishedYear": 2019, "publisher": "ACME Books", "description": "A very good book.", "isbn": "978-3-16-148410-0", "asin": "B08XYZ", "language": "en" }`, validate: func(t *testing.T, m *MediaMetadata) { if m.Title != "An Book" || m.Author != "Author One" { t.Errorf("Title/Author = %q/%q", m.Title, m.Author) } if m.Series != "The Series" || m.SeriesNumber != 4 { t.Errorf("Series = %q/%d, want The Series/4", m.Series, m.SeriesNumber) } if len(m.Tags) != 2 { t.Errorf("Tags = %v, want genres+tags merged", m.Tags) } if m.PublishDate.Year() != 2019 { t.Errorf("PublishDate year = %d, want 2019", m.PublishDate.Year()) } if m.Publisher != "ACME Books" || m.Description != "A very good book." { t.Errorf("Publisher/Description = %q/%q", m.Publisher, m.Description) } if m.ISBN == "" || m.ASIN != "B08XYZ" || m.Language != "en" { t.Errorf("ISBN/ASIN/Language = %q/%q/%q", m.ISBN, m.ASIN, m.Language) } }, }, { name: "sparse sidecar (real-world Audiobookshelf export)", json: `{"title": "Sparse (1234)", "authors": ["X"], "tags": [], "description": null}`, validate: func(t *testing.T, m *MediaMetadata) { if m.Title != "Sparse (1234)" || m.Author != "X" { t.Errorf("Title/Author = %q/%q", m.Title, m.Author) } if m.Description != "" || m.Tags != nil { t.Error("null/empty sidecar fields must stay unset") } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(tt.json), 0644); err != nil { t.Fatal(err) } m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub")) if m == nil { t.Fatal("extractAudiobookshelfSidecar() = nil, want metadata") } tt.validate(t, m) }) } t.Run("no sidecar returns nil", func(t *testing.T) { dir := t.TempDir() if m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub")); m != nil { t.Errorf("extractAudiobookshelfSidecar() = %v, want nil", m) } }) } // 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) } }