package scanner
import (
"archive/zip"
"context"
"encoding/binary"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
const ebookCoverTestThumbhash = "thumb"
type fakeEbookEnrichmentQueue struct {
contentID string
priority int
err error
reconcileCalls int
reconcileLimit int
reconcileErr error
inspected int
wrapped bool
}
func (f *fakeEbookEnrichmentQueue) Enqueue(_ context.Context, contentID string, priority int) error {
f.contentID = contentID
f.priority = priority
return f.err
}
func (f *fakeEbookEnrichmentQueue) ReconcileMissing(_ context.Context, _ int, _ int, limit int) (int, int, bool, error) {
f.reconcileCalls++
f.reconcileLimit = limit
return 0, f.inspected, f.wrapped, f.reconcileErr
}
func TestScannerEbookEnrichmentHookEnqueuesHighPriorityWork(t *testing.T) {
queue := &fakeEbookEnrichmentQueue{}
s := &Scanner{}
s.SetEbookEnrichmentQueue(queue)
if err := s.enqueueEbookEnrichment(context.Background(), "ebook-1"); err != nil {
t.Fatalf("enqueueEbookEnrichment() error = %v", err)
}
if queue.contentID != "ebook-1" || queue.priority != 100 {
t.Fatalf("enqueue = (%q, %d), want (ebook-1, 100)", queue.contentID, queue.priority)
}
}
func TestScannerEbookEnrichmentHookDoesNotFailScanOnQueueFailure(t *testing.T) {
queueErr := errors.New("queue unavailable")
s := &Scanner{}
s.SetEbookEnrichmentQueue(&fakeEbookEnrichmentQueue{err: queueErr})
err := s.enqueueEbookEnrichment(context.Background(), "ebook-1")
if err != nil {
t.Fatalf("enqueueEbookEnrichment() error = %v, want nil", err)
}
}
func TestScannerEbookEnrichmentReconciliationIsBoundedAndNonFatal(t *testing.T) {
queue := &fakeEbookEnrichmentQueue{reconcileErr: errors.New("queue unavailable")}
s := &Scanner{}
s.SetEbookEnrichmentQueue(queue)
if err := s.reconcileEbookScan(
context.Background(),
&models.MediaFolder{ID: 17},
nil,
nil,
true,
); err != nil {
t.Fatalf("reconcileEbookScan() error = %v", err)
}
if queue.reconcileCalls != 1 {
t.Fatalf("reconcile calls = %d, want 1", queue.reconcileCalls)
}
if queue.reconcileLimit <= 0 || queue.reconcileLimit > 1000 {
t.Fatalf("reconcile limit = %d, want bounded positive limit", queue.reconcileLimit)
}
}
type recordingEbookExecutor struct {
queries []string
args [][]any
}
func (r *recordingEbookExecutor) Exec(_ context.Context, query string, args ...any) (pgconn.CommandTag, error) {
r.queries = append(r.queries, query)
r.args = append(r.args, append([]any(nil), args...))
return pgconn.CommandTag{}, nil
}
type fakeFilesystemItemReader struct {
items []*models.MediaItem
err error
}
func (f *fakeFilesystemItemReader) GetByIDs(_ context.Context, _ []string) ([]*models.MediaItem, error) {
return f.items, f.err
}
type fakeEbookCoverCacher struct {
calls int
data []byte
contentID string
err error
}
func (f *fakeEbookCoverCacher) CacheEbookCover(_ context.Context, data []byte, contentID string) (string, string, error) {
f.calls++
f.data = append([]byte(nil), data...)
f.contentID = contentID
if f.err != nil {
return "", "", f.err
}
return "local/ebooks/" + contentID + "/poster/original.test-revision.webp", ebookCoverTestThumbhash, nil
}
type fakeEbookMetadataUpdater struct {
posterPath string
posterThumbhash string
getErr error
contentID string
setPosterPath string
setThumbhash string
setPrefix string
setCalls int
err error
}
func (f *fakeEbookMetadataUpdater) GetPoster(_ context.Context, _ string) (string, string, error) {
return f.posterPath, f.posterThumbhash, f.getErr
}
func (f *fakeEbookMetadataUpdater) SetLocalPoster(_ context.Context, contentID, posterPath, thumbhash, localPrefix string) (bool, error) {
f.setCalls++
f.contentID = contentID
f.setPosterPath = posterPath
f.setThumbhash = thumbhash
f.setPrefix = localPrefix
return f.err == nil, f.err
}
func TestSupportsEbookFile(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"book.epub", true},
{"book.PDF", true},
{"book.mobi", true},
{"book.azw", true},
{"book.azw3", true},
{"book.FB2", true},
{"book.fb2.zip", true},
{"book.fbz", true},
{"book.cbz", true},
{"book.cbr", true},
{"book.txt", false},
{"book.md", false},
{"README.md", false},
{"book.mp3", false},
{"movie.mkv", false},
}
for _, tc := range cases {
if got := SupportsEbookFile(tc.path); got != tc.want {
t.Errorf("SupportsEbookFile(%q) = %v, want %v", tc.path, got, tc.want)
}
}
}
func TestParseEbookFileSupportsReaderFormatsWithoutEmbeddedMetadata(t *testing.T) {
for _, tc := range []struct {
name string
wantFormat string
}{
{"book.pdf", "pdf"},
{"book.mobi", "mobi"},
{"book.azw", "azw"},
{"book.azw3", "azw3"},
{"book.cbr", "cbr"},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), tc.name)
if err := os.WriteFile(path, []byte("placeholder"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Format != tc.wantFormat {
t.Fatalf("Format = %q, want %q", got.Format, tc.wantFormat)
}
})
}
}
func TestNormalizeEbookISBNHandlesCommonLabels(t *testing.T) {
cases := []struct {
in string
want string
}{
{"ISBN-13: 978-0-306-40615-7", "9780306406157"},
{"ISBN-10: 0-9752298-0-x", "097522980X"},
}
for _, tc := range cases {
if got := normalizeEbookISBN(tc.in); got != tc.want {
t.Errorf("normalizeEbookISBN(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestParseEbookEPUBMetadata(t *testing.T) {
path := writeTestEPUB(t, []string{"ISBN: 978-0-306-40615-7"})
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Format != "epub" {
t.Fatalf("Format = %q, want epub", got.Format)
}
if got.Title != "The Test Ebook" {
t.Fatalf("Title = %q, want The Test Ebook", got.Title)
}
if strings.Join(got.Authors, ", ") != "Ada Writer, Ben Author" {
t.Fatalf("Authors = %v, want Ada Writer and Ben Author", got.Authors)
}
if got.ISBN != "9780306406157" {
t.Fatalf("ISBN = %q, want normalized ISBN", got.ISBN)
}
if got.Year != 2024 {
t.Fatalf("Year = %d, want 2024", got.Year)
}
if got.Publisher != "Silo Press" {
t.Fatalf("Publisher = %q, want Silo Press", got.Publisher)
}
wantPublishedAt := time.Date(2024, 3, 10, 0, 0, 0, 0, time.UTC)
if !got.PublishedAt.Equal(wantPublishedAt) {
t.Fatalf("PublishedAt = %v, want %v", got.PublishedAt, wantPublishedAt)
}
if got.Language != "en" {
t.Fatalf("Language = %q, want en", got.Language)
}
if strings.Join(got.Genres, ", ") != "Fiction, Adventure" {
t.Fatalf("Genres = %v, want Fiction and Adventure", got.Genres)
}
if got.Series != "Test Series" || got.SeriesIndex != "2" {
t.Fatalf("Series = %q/%q, want Test Series/2", got.Series, got.SeriesIndex)
}
}
func TestParseEbookEPUBMetadataStripsHTMLDescription(t *testing.T) {
path := writeTestEPUBWithDescription(t, `<div><p><strong>Shanghai Dreams ...</strong> Magnus is stationed at Shanghai.<br>Published by The Electronic Book Company</p></div>`)
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
want := "Shanghai Dreams ... Magnus is stationed at Shanghai. Published by The Electronic Book Company"
if got.Description != want {
t.Fatalf("Description = %q, want %q", got.Description, want)
}
}
func TestParseEbookEPUBSkipsUUIDIdentifierBeforeISBN(t *testing.T) {
path := writeTestEPUB(t, []string{
"550e8400-e29b-41d4-a716-446655440000",
"ISBN: 978-0-306-40615-7",
})
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.ISBN != "9780306406157" {
t.Fatalf("ISBN = %q, want normalized ISBN after skipping UUID", got.ISBN)
}
}
func TestParseEbookEPUBMetadataReadsISBNFromMetaTags(t *testing.T) {
path := writeTestEPUBWithMeta(t, nil, ` `)
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.ISBN != "9780306406157" {
t.Fatalf("ISBN = %q, want normalized ISBN from calibre meta tag", got.ISBN)
}
}
func TestParseEbookEPUBMetadataHandlesLatin1OPF(t *testing.T) {
path := writeTestEPUBWithOPFBytes(t, []byte("\n"+
""+
"Caf\xe9 Society"+
"Fran\xe7ois Author"+
"Cr\xe8me Press"+
""))
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Title != "Café Society" || strings.Join(got.Authors, ", ") != "François Author" || got.Publisher != "Crème Press" {
t.Fatalf("parsed latin1 metadata = title %q authors %v publisher %q", got.Title, got.Authors, got.Publisher)
}
}
func TestParseEbookEPUBMetadataHandlesWindows1251OPF(t *testing.T) {
path := writeTestEPUBWithOPFBytes(t, []byte("\n"+
""+
"\xc2\xee\xe9\xed\xe0 \xe8 \xec\xe8\xf0"+
"\xd2\xee\xeb\xf1\xf2\xee\xe9"+
""))
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Title != "Война и мир" || strings.Join(got.Authors, ", ") != "Толстой" {
t.Fatalf("parsed windows-1251 metadata = title %q authors %v", got.Title, got.Authors)
}
}
func TestParseEbookFileAppliesExternalOPFSidecar(t *testing.T) {
dir := t.TempDir()
bookPath := filepath.Join(dir, "Dune.cbr")
if err := os.WriteFile(bookPath, nil, 0o644); err != nil {
t.Fatalf("write book: %v", err)
}
sidecar := `
Dune
Frank Herbert
A desert planet and a dangerous inheritance.
9780441172719
Science Fiction
`
if err := os.WriteFile(filepath.Join(dir, "Dune.opf"), []byte(sidecar), 0o644); err != nil {
t.Fatalf("write sidecar: %v", err)
}
got, err := parseEbookFile(bookPath)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Title != "Dune" || len(got.Authors) != 1 || got.Authors[0] != "Frank Herbert" {
t.Fatalf("sidecar identity = title %q authors %v", got.Title, got.Authors)
}
if got.Description == "" || got.ISBN != "9780441172719" {
t.Fatalf("sidecar metadata = description %q ISBN %q", got.Description, got.ISBN)
}
if got.Format != "cbr" {
t.Fatalf("Format = %q, want original file format cbr", got.Format)
}
}
func TestParseEbookOPFSidecarRejectsSymlinkedSidecar(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "outside-library.opf")
sidecar := `
Smuggled
`
if err := os.WriteFile(target, []byte(sidecar), 0o644); err != nil {
t.Fatalf("write target: %v", err)
}
link := filepath.Join(dir, "Dune.opf")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
if _, err := parseEbookOPFSidecar(link); err == nil {
t.Fatal("parseEbookOPFSidecar accepted a symlinked sidecar; a leaf swapped to a symlink after the Lstat gate would be followed to its target")
}
}
func TestInsertEbookISBNProviderIDReplacesStaleISBN(t *testing.T) {
exec := &recordingEbookExecutor{}
if err := insertEbookISBNProviderID(context.Background(), exec, "book-1", "9780441172719"); err != nil {
t.Fatalf("insertEbookISBNProviderID: %v", err)
}
if len(exec.queries) != 1 {
t.Fatalf("queries = %d, want 1", len(exec.queries))
}
query := strings.Join(strings.Fields(exec.queries[0]), " ")
if !strings.Contains(query, "ON CONFLICT (content_id, provider) DO UPDATE") ||
!strings.Contains(query, "provider_id = EXCLUDED.provider_id") {
t.Fatalf("ISBN insert does not replace a stale value on rescan:\n%s", query)
}
}
func TestInsertEbookISBNProviderIDToleratesISBNOwnedByAnotherItem(t *testing.T) {
exec := &erroringEbookExecutor{err: &pgconn.PgError{Code: "23505"}}
if err := insertEbookISBNProviderID(context.Background(), exec, "book-1", "9780441172719"); err != nil {
t.Fatalf("unique-violation on the (provider, provider_id) constraint must not fail the scan, got %v", err)
}
boom := errors.New("connection lost")
exec = &erroringEbookExecutor{err: boom}
if err := insertEbookISBNProviderID(context.Background(), exec, "book-1", "9780441172719"); !errors.Is(err, boom) {
t.Fatalf("non-unique-violation error = %v, want %v", err, boom)
}
}
type erroringEbookExecutor struct {
err error
}
func (e *erroringEbookExecutor) Exec(_ context.Context, _ string, _ ...any) (pgconn.CommandTag, error) {
return pgconn.CommandTag{}, e.err
}
func TestParseEbookEPUBMetadataAllowsXMLVersion11(t *testing.T) {
path := writeTestEPUBWithOPFBytes(t, []byte(`
Versioned Ebook
Ada Writer
`))
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Title != "Versioned Ebook" || strings.Join(got.Authors, ", ") != "Ada Writer" {
t.Fatalf("parsed XML 1.1 metadata = title %q authors %v", got.Title, got.Authors)
}
}
func TestParseEbookEPUBExtractsManifestCover(t *testing.T) {
path := writeTestEPUBWithCover(t, "Images/cover.jpg", "image/jpeg", []byte("epub-cover-bytes"))
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Cover == nil {
t.Fatal("Cover = nil, want embedded EPUB cover")
}
if got.Cover.ContentType != "image/jpeg" {
t.Fatalf("Cover.ContentType = %q, want image/jpeg", got.Cover.ContentType)
}
if string(got.Cover.Bytes) != "epub-cover-bytes" {
t.Fatalf("Cover.Bytes = %q, want EPUB cover bytes", string(got.Cover.Bytes))
}
}
func TestParseEbookEPUBCoverSkipsXHTMLCoverPage(t *testing.T) {
path := filepath.Join(t.TempDir(), "book.epub")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create epub: %v", err)
}
zw := zip.NewWriter(file)
add := func(name, body string) {
t.Helper()
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create zip entry %s: %v", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("write zip entry %s: %v", name, err)
}
}
add("META-INF/container.xml", `
`)
// EPUB2-style meta cover pointing at the XHTML cover *page*, with the
// real image declared later via properties="cover-image".
add("OPS/content.opf", `
The Test Ebook
`)
add("OPS/cover.xhtml", "
")
add("OPS/Images/cover.jpg", "real-cover-bytes")
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close epub: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Cover == nil {
t.Fatal("Cover = nil, want cover-image manifest item")
}
if got.Cover.ContentType != "image/jpeg" || string(got.Cover.Bytes) != "real-cover-bytes" {
t.Fatalf("Cover = %q/%q, want the image item, not the XHTML cover page", got.Cover.ContentType, string(got.Cover.Bytes))
}
}
func TestReadEPUBZipEntryRejectsOversizedEntry(t *testing.T) {
path := filepath.Join(t.TempDir(), "large.epub")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create epub: %v", err)
}
zw := zip.NewWriter(file)
w, err := zw.Create("OPS/content.opf")
if err != nil {
t.Fatalf("create zip entry: %v", err)
}
if _, err := w.Write([]byte(strings.Repeat("x", maxEPUBMetadataEntrySize+1))); err != nil {
t.Fatalf("write zip entry: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close file: %v", err)
}
reader, err := zip.OpenReader(path)
if err != nil {
t.Fatalf("open zip: %v", err)
}
defer reader.Close()
_, err = readEPUBZipEntry(&reader.Reader, "OPS/content.opf")
if err == nil || !strings.Contains(err.Error(), "epub entry too large") {
t.Fatalf("readEPUBZipEntry error = %v, want size limit error", err)
}
}
func TestParseEbookFB2Metadata(t *testing.T) {
path := filepath.Join(t.TempDir(), "book.fb2")
if err := os.WriteFile(path, []byte(`
science fiction
AdaWriter
FB2 Test Ebook
5 April 2022
en
FB2 Press
2022
978-0-306-40615-7
`), 0o644); err != nil {
t.Fatalf("write fb2: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Format != "fb2" || got.Title != "FB2 Test Ebook" {
t.Fatalf("Format/Title = %q/%q, want fb2/FB2 Test Ebook", got.Format, got.Title)
}
if strings.Join(got.Authors, ", ") != "Ada Writer" {
t.Fatalf("Authors = %v, want Ada Writer", got.Authors)
}
if got.ISBN != "9780306406157" {
t.Fatalf("ISBN = %q, want normalized ISBN", got.ISBN)
}
if got.Publisher != "FB2 Press" {
t.Fatalf("Publisher = %q, want FB2 Press", got.Publisher)
}
if got.Year != 2022 {
t.Fatalf("Year = %d, want 2022", got.Year)
}
wantPublishedAt := time.Date(2022, 4, 5, 0, 0, 0, 0, time.UTC)
if !got.PublishedAt.Equal(wantPublishedAt) {
t.Fatalf("PublishedAt = %v, want %v", got.PublishedAt, wantPublishedAt)
}
if got.Language != "en" {
t.Fatalf("Language = %q, want en", got.Language)
}
if strings.Join(got.Genres, ", ") != "science fiction" {
t.Fatalf("Genres = %v, want science fiction", got.Genres)
}
if got.Series != "FB2 Series" || got.SeriesIndex != "3" {
t.Fatalf("Series = %q/%q, want FB2 Series/3", got.Series, got.SeriesIndex)
}
}
func TestParseEbookFBZMetadata(t *testing.T) {
path := filepath.Join(t.TempDir(), "book.fb2.zip")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create fbz: %v", err)
}
zw := zip.NewWriter(file)
w, err := zw.Create("nested/book.fb2")
if err != nil {
t.Fatalf("create fb2 entry: %v", err)
}
if _, err := w.Write([]byte(`
fantasy
AdaWriter
FBZ Test Ebook
en
Archive Press
2021
978-0-306-40615-7
`)); err != nil {
t.Fatalf("write fb2 entry: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close fbz: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Format != "fbz" || got.Title != "FBZ Test Ebook" {
t.Fatalf("Format/Title = %q/%q, want fbz/FBZ Test Ebook", got.Format, got.Title)
}
if strings.Join(got.Authors, ", ") != "Ada Writer" {
t.Fatalf("Authors = %v, want Ada Writer", got.Authors)
}
if got.ISBN != "9780306406157" {
t.Fatalf("ISBN = %q, want normalized ISBN", got.ISBN)
}
if got.Publisher != "Archive Press" || got.Year != 2021 {
t.Fatalf("Publisher/Year = %q/%d, want Archive Press/2021", got.Publisher, got.Year)
}
if got.Series != "Archive Series" || got.SeriesIndex != "4" {
t.Fatalf("Series = %q/%q, want Archive Series/4", got.Series, got.SeriesIndex)
}
}
func TestParseEbookPDFInfoMetadata(t *testing.T) {
path := filepath.Join(t.TempDir(), "book.pdf")
if err := os.WriteFile(path, []byte(`%PDF-1.7
1 0 obj
<< /Title (PDF Test Ebook)
/Author (Ada Writer; Ben Author)
/Subject (ISBN 978-0-306-40615-7)
/Keywords (science fiction, adventure)
/CreationDate (D:20240102030405Z)
>>
endobj
trailer
<< /Info 1 0 R >>
%%EOF`), 0o644); err != nil {
t.Fatalf("write pdf: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Format != "pdf" || got.Title != "PDF Test Ebook" {
t.Fatalf("Format/Title = %q/%q, want pdf/PDF Test Ebook", got.Format, got.Title)
}
if strings.Join(got.Authors, ", ") != "Ada Writer, Ben Author" {
t.Fatalf("Authors = %v, want Ada Writer and Ben Author", got.Authors)
}
if got.ISBN != "9780306406157" {
t.Fatalf("ISBN = %q, want normalized ISBN", got.ISBN)
}
if got.Year != 2024 {
t.Fatalf("Year = %d, want 2024", got.Year)
}
if strings.Join(got.Genres, ", ") != "science fiction, adventure" {
t.Fatalf("Genres = %v, want science fiction and adventure", got.Genres)
}
}
func TestParseEbookPDFInfoMetadataDecodesUTF16BELiterals(t *testing.T) {
path := filepath.Join(t.TempDir(), "book.pdf")
if err := os.WriteFile(path, []byte("%PDF-1.7\n"+
"1 0 obj\n"+
"<< /Title (\xfe\xff\x00C\x00a\x00f\x00e)\n"+
" /Author (\xfe\xff\x00T\x00h\x00o\x00m\x00a\x00s\x00 \x00D\x00 \x00S\x00e\x00e\x00l\x00e\x00y)\n"+
">>\nendobj\n%%EOF"), 0o644); err != nil {
t.Fatalf("write pdf: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Title != "Cafe" || strings.Join(got.Authors, ", ") != "Thomas D Seeley" {
t.Fatalf("PDF metadata = title %q authors %v, want decoded UTF-16BE", got.Title, got.Authors)
}
}
func TestParseEbookPDFInfoMetadataPreservesUTF8Literals(t *testing.T) {
path := filepath.Join(t.TempDir(), "book.pdf")
if err := os.WriteFile(path, []byte("%PDF-1.7\n"+
"1 0 obj\n"+
"<< /Title (Caf\xc3\xa9 Society)\n"+
" /Author (\xef\xbb\xbfFran\xc3\xa7ois Author)\n"+
">>\nendobj\n%%EOF"), 0o644); err != nil {
t.Fatalf("write pdf: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Title != "Café Society" {
t.Fatalf("Title = %q, want UTF-8 literal preserved without Windows-1252 mojibake", got.Title)
}
if strings.Join(got.Authors, ", ") != "François Author" {
t.Fatalf("Authors = %v, want UTF-8 BOM stripped and bytes preserved", got.Authors)
}
}
func TestParseEbookPDFInfoMetadataFallsBackToWindows1252(t *testing.T) {
path := filepath.Join(t.TempDir(), "book.pdf")
if err := os.WriteFile(path, []byte("%PDF-1.7\n"+
"1 0 obj\n"+
"<< /Title (Caf\xe9 Society)\n"+
">>\nendobj\n%%EOF"), 0o644); err != nil {
t.Fatalf("write pdf: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Title != "Café Society" {
t.Fatalf("Title = %q, want Windows-1252 fallback for non-UTF-8 literal", got.Title)
}
}
func TestParseEbookPDFInfoMetadataDecodesHexStrings(t *testing.T) {
path := filepath.Join(t.TempDir(), "book.pdf")
if err := os.WriteFile(path, []byte(`%PDF-1.7
1 0 obj
<< /Title
/Author <41646120577269746572>
/CreationDate (0000-01-01)
>>
endobj
%%EOF`), 0o644); err != nil {
t.Fatalf("write pdf: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Title != "Hex Book" || strings.Join(got.Authors, ", ") != "Ada Writer" {
t.Fatalf("PDF hex metadata = title %q authors %v", got.Title, got.Authors)
}
if got.Year != 0 || !got.PublishedAt.IsZero() {
t.Fatalf("bad PDF date produced year/date = %d/%v, want ignored", got.Year, got.PublishedAt)
}
}
func TestParseEbookCBZPageCount(t *testing.T) {
path := filepath.Join(t.TempDir(), "comic.cbz")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create cbz: %v", err)
}
zw := zip.NewWriter(file)
for _, name := range []string{
"Comic/001.jpg",
"Comic/002.PNG",
"Comic/003.webp",
"Comic/notes.txt",
"__MACOSX/._001.jpg",
} {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create zip entry %s: %v", name, err)
}
if _, err := w.Write([]byte("placeholder")); err != nil {
t.Fatalf("write zip entry %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close cbz: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Format != "cbz" {
t.Fatalf("Format = %q, want cbz", got.Format)
}
if got.PageCount != 3 {
t.Fatalf("PageCount = %d, want 3", got.PageCount)
}
}
func TestParseEbookCBZExtractsFirstReadablePageAsCover(t *testing.T) {
path := filepath.Join(t.TempDir(), "comic.cbz")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create cbz: %v", err)
}
zw := zip.NewWriter(file)
for _, entry := range []struct {
name string
body []byte
}{
{name: "Comic/002.png", body: []byte("second-page")},
{name: "Comic/001.jpg", body: []byte("first-page")},
{name: "Comic/notes.txt", body: []byte("ignored")},
} {
w, err := zw.Create(entry.name)
if err != nil {
t.Fatalf("create zip entry %s: %v", entry.name, err)
}
if _, err := w.Write(entry.body); err != nil {
t.Fatalf("write zip entry %s: %v", entry.name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close cbz: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Cover == nil {
t.Fatal("Cover = nil, want first CBZ page as cover")
}
if got.Cover.ContentType != "image/jpeg" {
t.Fatalf("Cover.ContentType = %q, want image/jpeg", got.Cover.ContentType)
}
if string(got.Cover.Bytes) != "first-page" {
t.Fatalf("Cover.Bytes = %q, want first page bytes", string(got.Cover.Bytes))
}
}
func TestParseEbookCBZPicksNaturallyFirstPageAsCover(t *testing.T) {
path := filepath.Join(t.TempDir(), "comic.cbz")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create cbz: %v", err)
}
zw := zip.NewWriter(file)
for _, entry := range []struct {
name string
body string
}{
{name: "ch10/page10.jpg", body: "chapter-ten"},
{name: "ch2/page2.jpg", body: "chapter-two"},
} {
w, err := zw.Create(entry.name)
if err != nil {
t.Fatalf("create zip entry %s: %v", entry.name, err)
}
if _, err := w.Write([]byte(entry.body)); err != nil {
t.Fatalf("write zip entry %s: %v", entry.name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close cbz: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Cover == nil {
t.Fatal("Cover = nil, want first page in natural order")
}
if string(got.Cover.Bytes) != "chapter-two" {
t.Fatalf("Cover.Bytes = %q, want ch2 before ch10 (natural order, not byte order)", string(got.Cover.Bytes))
}
}
func TestEbookTitleFromPathStripsCompoundFB2Extension(t *testing.T) {
cases := map[string]string{
"/lib/Anna Karenina.fb2.zip": "Anna Karenina",
"/lib/Anna Karenina.FB2.ZIP": "Anna Karenina",
"/lib/Book.epub": "Book",
"/lib/vol.1.pdf": "vol.1",
}
for path, want := range cases {
if got := ebookTitleFromPath(path); got != want {
t.Errorf("ebookTitleFromPath(%q) = %q, want %q", path, got, want)
}
}
}
func TestEbookSeriesDesiredParsesIndex(t *testing.T) {
name, idx := ebookSeriesDesired(&parsedEbook{
Series: " The Expanse ",
SeriesIndex: "2 of 9",
})
if name != "The Expanse" {
t.Fatalf("series name = %q, want The Expanse", name)
}
if idx == nil || *idx != 2 {
t.Fatalf("series index = %v, want 2", idx)
}
}
func TestUpsertEbookSeriesNilScannerReturnsError(t *testing.T) {
var s *Scanner
if err := s.upsertEbookSeries(context.Background(), "content-1", &parsedEbook{Series: "Series"}, false); err == nil {
t.Fatal("upsertEbookSeries nil scanner error = nil, want error")
}
}
func TestUpsertEbookSeriesNilFileRepoReturnsError(t *testing.T) {
s := &Scanner{}
if err := s.upsertEbookSeries(context.Background(), "content-1", &parsedEbook{Series: "Series"}, false); err == nil {
t.Fatal("upsertEbookSeries nil fileRepo error = nil, want error")
}
}
func TestPlanEbookSeriesWriteInsertsWhenRowAbsent(t *testing.T) {
plan, err := planEbookSeriesWrite(&parsedEbook{Series: " Series ", SeriesIndex: "2 of 9"}, nil, nil, pgx.ErrNoRows, false)
if err != nil {
t.Fatalf("planEbookSeriesWrite: %v", err)
}
if plan.Kind != ebookSeriesWriteUpsert || plan.Name != "Series" {
t.Fatalf("plan = %+v, want upsert Series", plan)
}
if plan.Index == nil || *plan.Index != 2 {
t.Fatalf("index = %v, want 2", plan.Index)
}
}
func TestPlanEbookSeriesWriteBlankSeriesDeletesExistingAndSkipsAbsent(t *testing.T) {
currentName := "Series"
plan, err := planEbookSeriesWrite(&parsedEbook{Series: " "}, ¤tName, nil, nil, false)
if err != nil {
t.Fatalf("planEbookSeriesWrite delete: %v", err)
}
if plan.Kind != ebookSeriesWriteDelete {
t.Fatalf("blank existing plan = %+v, want delete", plan)
}
plan, err = planEbookSeriesWrite(&parsedEbook{Series: ""}, nil, nil, pgx.ErrNoRows, false)
if err != nil {
t.Fatalf("planEbookSeriesWrite absent: %v", err)
}
if plan.Kind != ebookSeriesWriteNone {
t.Fatalf("blank absent plan = %+v, want none", plan)
}
}
func TestPlanEbookSeriesWriteSkipsIdenticalRow(t *testing.T) {
currentName := "Series"
currentIdx := 3.5
plan, err := planEbookSeriesWrite(&parsedEbook{Series: "Series", SeriesIndex: "3.5"}, ¤tName, ¤tIdx, nil, false)
if err != nil {
t.Fatalf("planEbookSeriesWrite: %v", err)
}
if plan.Kind != ebookSeriesWriteNone {
t.Fatalf("identical plan = %+v, want none", plan)
}
}
func TestPlanEbookSeriesWriteAllowsNullNumericIndex(t *testing.T) {
plan, err := planEbookSeriesWrite(&parsedEbook{Series: "Series", SeriesIndex: "appendix"}, nil, nil, pgx.ErrNoRows, false)
if err != nil {
t.Fatalf("planEbookSeriesWrite: %v", err)
}
if plan.Kind != ebookSeriesWriteUpsert || plan.Index != nil {
t.Fatalf("plan = %+v, want upsert with nil index", plan)
}
}
func TestPlanEbookSeriesWriteFillOnlyNeverReplacesOrDeletesExistingRow(t *testing.T) {
currentName := "Provider Series"
currentIdx := 1.0
// A curated item with an existing (provider-enriched) row must not be
// replaced by a differing file-embedded series...
plan, err := planEbookSeriesWrite(&parsedEbook{Series: "File Series", SeriesIndex: "4"}, ¤tName, ¤tIdx, nil, true)
if err != nil {
t.Fatalf("planEbookSeriesWrite replace: %v", err)
}
if plan.Kind != ebookSeriesWriteNone {
t.Fatalf("fill-only replace plan = %+v, want none", plan)
}
// ...nor deleted when the file carries no series at all.
plan, err = planEbookSeriesWrite(&parsedEbook{Series: ""}, ¤tName, ¤tIdx, nil, true)
if err != nil {
t.Fatalf("planEbookSeriesWrite delete: %v", err)
}
if plan.Kind != ebookSeriesWriteNone {
t.Fatalf("fill-only delete plan = %+v, want none", plan)
}
}
func TestPlanEbookSeriesWriteFillOnlyInsertsWhenRowAbsent(t *testing.T) {
plan, err := planEbookSeriesWrite(&parsedEbook{Series: "File Series", SeriesIndex: "2"}, nil, nil, pgx.ErrNoRows, true)
if err != nil {
t.Fatalf("planEbookSeriesWrite: %v", err)
}
if plan.Kind != ebookSeriesWriteUpsert || plan.Name != "File Series" {
t.Fatalf("plan = %+v, want fill-empty upsert", plan)
}
}
func TestEbookPeopleWriteAllowedFillsEmptyOnlyWhenCurated(t *testing.T) {
providerAuthor := []models.ItemPerson{{Person: models.Person{Name: "Provider Author"}, Kind: models.PersonKindAuthor}}
nonAuthorOnly := []models.ItemPerson{{Person: models.Person{Name: "Manual Writer"}, Kind: models.PersonKindWriter}}
cases := []struct {
name string
curated bool
existing []models.ItemPerson
want bool
}{
{"pending item always writes", false, providerAuthor, true},
{"curated with provider authors is read-only", true, providerAuthor, false},
{"curated without author credits fills empty", true, nil, true},
{"curated with only non-author credits fills empty", true, nonAuthorOnly, true},
}
for _, tc := range cases {
if got := ebookPeopleWriteAllowed(tc.curated, tc.existing); got != tc.want {
t.Errorf("%s: ebookPeopleWriteAllowed = %v, want %v", tc.name, got, tc.want)
}
}
}
func TestPlanEbookSeriesWriteReturnsQueryError(t *testing.T) {
queryErr := errors.New("query failed")
if _, err := planEbookSeriesWrite(&parsedEbook{Series: "Series"}, nil, nil, queryErr, false); !errors.Is(err, queryErr) {
t.Fatalf("planEbookSeriesWrite error = %v, want query error", err)
}
}
func TestEbookIdentityConfidenceReflectsMetadataCompleteness(t *testing.T) {
book := &parsedEbook{Title: "Tagged Ebook", Authors: []string{"Author"}, Year: 2024, ISBN: "9780306406157"}
if got := ebookIdentityConfidence(book); got != "high" {
t.Fatalf("complete metadata confidence = %q, want high", got)
}
book = &parsedEbook{Title: "Tagged Ebook", Authors: []string{"Author"}, Year: 2024}
if got := ebookIdentityConfidence(book); got != "medium" {
t.Fatalf("partial metadata confidence = %q, want medium", got)
}
book = &parsedEbook{}
if got := ebookIdentityConfidence(book); got != "low" {
t.Fatalf("empty metadata confidence = %q, want low", got)
}
}
func TestEbookContentGroupKeyGroupsMultipleSiblingFormatsByTitleAuthor(t *testing.T) {
want := ebookContentGroupKey(&parsedEbook{
Format: "epub",
Title: " The Test Ebook ",
Authors: []string{"Ada Writer", "Ben Author"},
}, "/library/Ada Writer/The Test Ebook.epub")
if want == "" {
t.Fatal("ebookContentGroupKey returned blank")
}
for _, tc := range []struct {
format string
path string
}{
{format: "mobi", path: "/library/Ada Writer/The Test Ebook.mobi"},
{format: "azw3", path: "/library/Ada Writer/The Test Ebook.azw3"},
{format: "pdf", path: "/library/Ada Writer/The Test Ebook.pdf"},
{format: "fb2", path: "/library/Ada Writer/The Test Ebook.fb2"},
} {
got := ebookContentGroupKey(&parsedEbook{
Format: tc.format,
Title: "The Test Ebook",
Authors: []string{"ben author", "ada writer"},
}, tc.path)
if got != want {
t.Fatalf("%s content group key = %q, want %q", tc.format, got, want)
}
if strings.Contains(got, tc.format) {
t.Fatalf("content group key should not include file format %q: %q", tc.format, got)
}
}
}
func TestEbookContentGroupKeyPrefersISBNAcrossTitleVariants(t *testing.T) {
first := ebookContentGroupKey(&parsedEbook{
Format: "epub",
Title: "The Test Ebook",
Authors: []string{"Ada Writer"},
ISBN: "9780306406157",
}, "/library/Ada Writer/The Test Ebook.epub")
second := ebookContentGroupKey(&parsedEbook{
Format: "pdf",
Title: "The Test Ebook: Revised Edition",
Authors: []string{"Someone Else"},
ISBN: "9780306406157",
}, "/library/Other Name.pdf")
if first != second {
t.Fatalf("ISBN-backed content group keys differ: %q != %q", first, second)
}
}
func TestResolveEbookMediaItemCreatesNewWhenRootHasNoClaim(t *testing.T) {
finder := &fakeRootContentFinder{}
writer := &fakeFilesystemItemWriter{}
got, err := resolveEbookMediaItem(context.Background(), finder, writer, 7, "/library/Author/Book.epub", &parsedEbook{
Title: "Book", Year: 2024, Authors: []string{"Author"}, Description: "Overview", Publisher: "Publisher", Genres: []string{"Fiction"}, Language: "en",
})
if err != nil {
t.Fatalf("resolveEbookMediaItem: %v", err)
}
if got == "" || len(writer.upserts) != 1 {
t.Fatalf("contentID/upserts = %q/%d, want id and one upsert", got, len(writer.upserts))
}
item := writer.upserts[0]
if item.Type != "ebook" || item.Title != "Book" || item.Year != 2024 || item.Overview != "Overview" || item.OriginalLanguage != "en" {
t.Fatalf("upserted ebook item = %+v", item)
}
if item.Status != "pending" {
t.Fatalf("Status = %q, want pending so enrichment can promote it to matched", item.Status)
}
}
func TestResolveEbookMediaItemReusesRootScopedContentID(t *testing.T) {
finder := &fakeRootContentFinder{contentID: "ebook-root-id"}
writer := &fakeFilesystemItemWriter{}
got, err := resolveEbookMediaItem(context.Background(), finder, writer, 7, "/library/Author/Book.epub", &parsedEbook{Title: "Book"})
if err != nil {
t.Fatalf("resolveEbookMediaItem: %v", err)
}
if got != "ebook-root-id" {
t.Fatalf("contentID = %q, want root-scoped id", got)
}
if len(writer.upserts) != 0 {
t.Fatalf("unexpected item upsert for existing root: %d", len(writer.upserts))
}
}
func TestResolveEbookExistingRootAppliesParsedMetadata(t *testing.T) {
reader := &fakeFilesystemItemReader{items: []*models.MediaItem{{
ContentID: "ebook-root-id",
Type: "ebook",
Title: "Old Title",
Year: 2020,
OriginalLanguage: "",
}}}
writer := &fakeFilesystemItemWriter{}
curated, err := updateExistingEbookMediaItem(context.Background(), reader, writer, "ebook-root-id", &parsedEbook{
Title: "New Title",
Year: 2026,
Publisher: "New Press",
Genres: []string{"Fiction"},
Language: "en",
})
if err != nil {
t.Fatalf("updateExistingEbookMediaItem: %v", err)
}
if curated {
t.Fatal("curated = true, want false for an unmatched item")
}
if len(writer.upserts) != 1 {
t.Fatalf("upserts = %d, want 1", len(writer.upserts))
}
item := writer.upserts[0]
if item.ContentID != "ebook-root-id" || item.Type != "ebook" || item.Title != "New Title" || item.Year != 2026 || item.OriginalLanguage != "en" {
t.Fatalf("updated item = %+v", item)
}
if strings.Join(item.Studios, ",") != "New Press" || strings.Join(item.Genres, ",") != "Fiction" {
t.Fatalf("updated item studios/genres = %+v/%+v", item.Studios, item.Genres)
}
}
func TestResolveEbookExistingRootReplacesHTMLOverview(t *testing.T) {
reader := &fakeFilesystemItemReader{items: []*models.MediaItem{{
ContentID: "ebook-root-id",
Type: "ebook",
Title: "Old Title",
Overview: "",
}}}
writer := &fakeFilesystemItemWriter{}
_, err := updateExistingEbookMediaItem(context.Background(), reader, writer, "ebook-root-id", &parsedEbook{
Title: "New Title",
Description: "Clean overview",
})
if err != nil {
t.Fatalf("updateExistingEbookMediaItem: %v", err)
}
if len(writer.upserts) != 1 {
t.Fatalf("upserts = %d, want 1", len(writer.upserts))
}
if writer.upserts[0].Overview != "Clean overview" {
t.Fatalf("Overview = %q, want Clean overview", writer.upserts[0].Overview)
}
}
func TestApplyEbookToMediaItemPreservesExistingYearWhenParsedYearMissing(t *testing.T) {
item := &models.MediaItem{
Type: "ebook",
Title: "Existing",
Year: 1999,
}
applyEbookToMediaItem(item, &parsedEbook{Title: "Updated", Year: 0})
if item.Title != "Updated" {
t.Fatalf("Title = %q, want Updated", item.Title)
}
if item.Year != 1999 {
t.Fatalf("Year = %d, want preserved 1999", item.Year)
}
}
func TestEbookPeopleCreditsEqualAuthorsOnly(t *testing.T) {
existing := []models.ItemPerson{
{Person: models.Person{Name: "Ada Writer"}, Kind: models.PersonKindAuthor, SortOrder: 0},
{Person: models.Person{Name: "Ben Author"}, Kind: models.PersonKindAuthor, SortOrder: 1},
{Person: models.Person{Name: "Manual Contributor"}, Kind: models.PersonKindWriter, SortOrder: 2},
}
desired := []ebookCredit{
{Name: "Ada Writer", Kind: models.PersonKindAuthor},
{Name: "Ben Author", Kind: models.PersonKindAuthor},
}
if !ebookPeopleCreditsEqual(existing, desired) {
t.Fatal("expected matching author credits to compare equal while ignoring non-authors")
}
existing[1] = models.ItemPerson{Person: models.Person{Name: "Other Author"}, Kind: models.PersonKindAuthor, SortOrder: 1}
if ebookPeopleCreditsEqual(existing, desired) {
t.Fatal("expected different author credit to make ebook author set differ")
}
}
func TestEbookPeopleCreditsEqualRejectsStaleNarratorCredit(t *testing.T) {
existing := []models.ItemPerson{
{Person: models.Person{Name: "Ada Writer"}, Kind: models.PersonKindAuthor, SortOrder: 0},
{Person: models.Person{Name: "Legacy Narrator"}, Kind: models.PersonKindNarrator, SortOrder: 1},
}
desired := []ebookCredit{
{Name: "Ada Writer", Kind: models.PersonKindAuthor},
}
if ebookPeopleCreditsEqual(existing, desired) {
t.Fatal("expected stale narrator credit to force ebook people replacement")
}
}
func TestEbookPeopleMergePreservesExistingNonAuthorCreditsExceptNarrators(t *testing.T) {
existing := []models.ItemPerson{
{Person: models.Person{ID: 10, Name: "Old Author"}, Kind: models.PersonKindAuthor, SortOrder: 0},
{Person: models.Person{ID: 20, Name: "Manual Writer"}, Kind: models.PersonKindWriter, SortOrder: 1, Character: "essay"},
{Person: models.Person{ID: 30, Name: "Manual Narrator"}, Kind: models.PersonKindNarrator, SortOrder: 2},
}
authors := []ebookResolvedAuthor{
{ID: 40, Name: "New Author"},
}
got := mergeEbookPeople(existing, authors)
if len(got) != 2 {
t.Fatalf("merged people len = %d, want 2: %+v", len(got), got)
}
if got[0].Person.ID != 20 || got[0].Kind != models.PersonKindWriter || got[0].Character != "essay" {
t.Fatalf("first preserved non-author = %+v", got[0])
}
if got[1].Person.ID != 40 || got[1].Kind != models.PersonKindAuthor || got[1].SortOrder != 1 {
t.Fatalf("new author credit = %+v", got[1])
}
}
func TestEbookPeopleReplacePlanReturnsGetPeopleError(t *testing.T) {
wantErr := errors.New("get people failed")
_, err := ebookPeopleForReplace(nil, wantErr, []ebookResolvedAuthor{{ID: 40, Name: "New Author"}})
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
}
func TestScanEbookBuildMediaFileSetsCorePersistenceFields(t *testing.T) {
modifiedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
book := &parsedEbook{Format: "epub", Title: "Book", Authors: []string{"Author"}, Year: 2024, ISBN: "9780306406157", PageCount: 321}
got := buildEbookMediaFile(&models.MediaFolder{ID: 44}, "content-1", "/library/Book.epub", 1234, modifiedAt, book, ebookContentGroupKey(book, "/library/Book.epub"))
if got.ContentID != "content-1" || got.MediaFolderID != 44 {
t.Fatalf("ids = %q/%d, want content-1/44", got.ContentID, got.MediaFolderID)
}
if got.BaseType != "ebook" || got.BaseTitle != "Book" || got.BaseYear != 2024 || got.Container != "epub" || got.ProbeSource != "local" {
t.Fatalf("ebook file metadata = %+v", got)
}
if got.Duration != 321 {
t.Fatalf("Duration = %d, want ebook page count", got.Duration)
}
if got.CanonicalRootPath != "/library/Book.epub" || got.ObservedRootPath != "/library/Book.epub" || got.FilePath != "/library/Book.epub" {
t.Fatalf("ebook paths = canonical %q observed %q file %q", got.CanonicalRootPath, got.ObservedRootPath, got.FilePath)
}
if got.ContentGroupKey != "ebook:isbn:9780306406157" {
t.Fatalf("ContentGroupKey = %q, want ISBN-backed ebook group", got.ContentGroupKey)
}
if got.GroupKeyVersion != ebookGroupKeyVersion {
t.Fatalf("GroupKeyVersion = %d, want %d", got.GroupKeyVersion, ebookGroupKeyVersion)
}
if got.FileSize != 1234 || got.FileModifiedAt == nil || !got.FileModifiedAt.Equal(modifiedAt) {
t.Fatalf("file facts = size %d modified %v", got.FileSize, got.FileModifiedAt)
}
}
func TestApplyEbookLocalCoverCachesEmbeddedAndSetsPoster(t *testing.T) {
cacher := &fakeEbookCoverCacher{}
updater := &fakeEbookMetadataUpdater{}
err := applyEbookLocalCover(context.Background(), updater, cacher, "content-1", filepath.Join(t.TempDir(), "book.epub"), &parsedEbook{
Cover: &parsedEbookCover{
ContentType: "image/jpeg",
Bytes: []byte("cover-bytes"),
},
})
if err != nil {
t.Fatalf("applyEbookLocalCover: %v", err)
}
if cacher.calls != 1 || cacher.contentID != "content-1" || string(cacher.data) != "cover-bytes" {
t.Fatalf("cache call = calls %d content %q data %q", cacher.calls, cacher.contentID, string(cacher.data))
}
if updater.setCalls != 1 || updater.contentID != "content-1" {
t.Fatalf("set call = calls %d content %q", updater.setCalls, updater.contentID)
}
if updater.setPosterPath != "local/ebooks/content-1/poster/original.test-revision.webp" {
t.Fatalf("poster path = %q", updater.setPosterPath)
}
if updater.setThumbhash != "thumb" {
t.Fatalf("poster thumbhash = %q", updater.setThumbhash)
}
if updater.setPrefix != localEbookPosterPrefix {
t.Fatalf("local prefix = %q, want %q", updater.setPrefix, localEbookPosterPrefix)
}
}
func TestApplyEbookLocalCoverPreservesProviderPoster(t *testing.T) {
cacher := &fakeEbookCoverCacher{}
updater := &fakeEbookMetadataUpdater{posterPath: "ebook-metadata/ebooks/content-1/poster/original.webp"}
err := applyEbookLocalCover(context.Background(), updater, cacher, "content-1", filepath.Join(t.TempDir(), "book.epub"), &parsedEbook{
Cover: &parsedEbookCover{Bytes: []byte("cover-bytes")},
})
if err != nil {
t.Fatalf("applyEbookLocalCover: %v", err)
}
if cacher.calls != 0 {
t.Fatalf("cache calls = %d, want 0", cacher.calls)
}
if updater.setCalls != 0 {
t.Fatalf("set calls = %d, want 0", updater.setCalls)
}
}
func TestApplyEbookLocalCoverRefreshesStaleLocalPoster(t *testing.T) {
cacher := &fakeEbookCoverCacher{}
updater := &fakeEbookMetadataUpdater{
posterPath: "local/ebooks/content-1/poster/original.webp",
posterThumbhash: "stale-thumb",
}
err := applyEbookLocalCover(context.Background(), updater, cacher, "content-1", filepath.Join(t.TempDir(), "book.epub"), &parsedEbook{
Cover: &parsedEbookCover{Bytes: []byte("replacement-cover-bytes")},
})
if err != nil {
t.Fatalf("applyEbookLocalCover: %v", err)
}
if cacher.calls != 1 || string(cacher.data) != "replacement-cover-bytes" {
t.Fatalf("cache call = calls %d data %q, want refresh of locally owned poster", cacher.calls, string(cacher.data))
}
if updater.setCalls != 1 {
t.Fatalf("set calls = %d, want 1", updater.setCalls)
}
}
func TestApplyEbookLocalCoverPrefersSidecarOverEmbedded(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "book.epub"), []byte("book"), 0o644); err != nil {
t.Fatalf("write ebook: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("ebook-sidecar-cover"), 0o644); err != nil {
t.Fatalf("write cover: %v", err)
}
cacher := &fakeEbookCoverCacher{}
updater := &fakeEbookMetadataUpdater{}
err := applyEbookLocalCover(context.Background(), updater, cacher, "content-1", filepath.Join(dir, "book.epub"), &parsedEbook{
Cover: &parsedEbookCover{Bytes: []byte("embedded-cover")},
})
if err != nil {
t.Fatalf("applyEbookLocalCover: %v", err)
}
if cacher.calls != 1 || string(cacher.data) != "ebook-sidecar-cover" {
t.Fatalf("cache call = calls %d data %q, want sidecar bytes", cacher.calls, string(cacher.data))
}
if updater.setCalls != 1 || updater.setPosterPath != "local/ebooks/content-1/poster/original.test-revision.webp" {
t.Fatalf("poster update = calls %d path %q", updater.setCalls, updater.setPosterPath)
}
}
func TestFindSidecarBookCoverIgnoresGenericNamesInMultiBookDirectories(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"alpha.epub", "beta.epub"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("book"), 0o644); err != nil {
t.Fatalf("write ebook %s: %v", name, err)
}
}
if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("shared-cover"), 0o644); err != nil {
t.Fatalf("write cover: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "beta.jpg"), []byte("beta-cover"), 0o644); err != nil {
t.Fatalf("write cover: %v", err)
}
cover, _, err := findSidecarBookCover(filepath.Join(dir, "alpha.epub"))
if err != nil {
t.Fatalf("findSidecarBookCover(alpha): %v", err)
}
if cover != nil {
t.Fatalf("alpha cover = %q, want none (generic cover.jpg must not claim every book)", string(cover.Bytes))
}
cover, _, err = findSidecarBookCover(filepath.Join(dir, "beta.epub"))
if err != nil {
t.Fatalf("findSidecarBookCover(beta): %v", err)
}
if cover == nil || string(cover.Bytes) != "beta-cover" {
t.Fatalf("beta cover = %v, want filename-matched beta.jpg", cover)
}
}
func TestScanEbookPersistenceSQLWritesLibraryMembershipAndISBNOnly(t *testing.T) {
ctx := context.Background()
exec := &recordingEbookExecutor{}
if err := insertEbookLibraryMembership(ctx, exec, "content-1", 44); err != nil {
t.Fatalf("insertEbookLibraryMembership: %v", err)
}
if err := insertEbookISBNProviderID(ctx, exec, "content-1", "9780306406157"); err != nil {
t.Fatalf("insertEbookISBNProviderID: %v", err)
}
if len(exec.queries) != 2 {
t.Fatalf("queries = %d, want 2", len(exec.queries))
}
if !strings.Contains(exec.queries[0], "media_item_libraries") || exec.args[0][0] != "content-1" || exec.args[0][1] != 44 {
t.Fatalf("library membership write = query %q args %+v", exec.queries[0], exec.args[0])
}
if !strings.Contains(exec.queries[1], "media_item_provider_ids") || !strings.Contains(exec.queries[1], "'isbn'") || !strings.Contains(exec.queries[1], "'ebook'") {
t.Fatalf("ISBN provider write query = %q", exec.queries[1])
}
if strings.Contains(exec.queries[1], "asin") || exec.args[1][0] != "content-1" || exec.args[1][1] != "9780306406157" {
t.Fatalf("ISBN provider write args/query = query %q args %+v", exec.queries[1], exec.args[1])
}
}
func TestCollectEbookRootScansExcludesUnmountedRootFromReconciliation(t *testing.T) {
healthy := t.TempDir()
if err := os.WriteFile(filepath.Join(healthy, "book.epub"), []byte("x"), 0o644); err != nil {
t.Fatalf("write ebook: %v", err)
}
unmounted := filepath.Join(t.TempDir(), "gone")
scans, err := collectEbookRootScans(context.Background(), 44, []string{unmounted, healthy})
if err != nil {
t.Fatalf("collectEbookRootScans: %v", err)
}
if len(scans) != 2 {
t.Fatalf("scans = %d, want 2", len(scans))
}
if !scans[0].failed() || scans[0].rootErr == nil {
t.Fatalf("unmounted root scan = %+v, want failed with rootErr", scans[0])
}
if scans[1].failed() || len(scans[1].files) != 1 {
t.Fatalf("healthy root scan = %+v, want 1 file and no failure", scans[1])
}
reconcileRoots, sawFiles := splitEbookReconcileRoots(scans)
if !sawFiles {
t.Fatal("sawFiles = false, want true for the healthy root")
}
if len(reconcileRoots) != 1 || reconcileRoots[0] != healthy {
t.Fatalf("reconcileRoots = %v, want only the healthy root: an unmounted root must never be reconciled (no mass-missing, no deletion)", reconcileRoots)
}
}
func TestCollectEbookRootScansTreatsSingleFileRootAsNonReconciling(t *testing.T) {
dir := t.TempDir()
fileRoot := filepath.Join(dir, "book.epub")
if err := os.WriteFile(fileRoot, []byte("x"), 0o644); err != nil {
t.Fatalf("write ebook: %v", err)
}
scans, err := collectEbookRootScans(context.Background(), 44, []string{fileRoot})
if err != nil {
t.Fatalf("collectEbookRootScans: %v", err)
}
if len(scans) != 1 || scans[0].failed() || !scans[0].fileOnly {
t.Fatalf("scans = %+v, want one healthy file-only scan", scans)
}
// The file itself is still indexed; only reconciliation is withheld.
if len(scans[0].files) != 1 {
t.Fatalf("files = %v, want the ebook file root indexed", scans[0].files)
}
if roots, _ := splitEbookReconcileRoots(scans); len(roots) != 0 {
t.Fatalf("reconcileRoots = %v, want none", roots)
}
}
func TestCollectEbookRootScansIncludesCompoundFB2ZipFile(t *testing.T) {
fileRoot := filepath.Join(t.TempDir(), "book.fb2.zip")
if err := os.WriteFile(fileRoot, []byte("x"), 0o644); err != nil {
t.Fatalf("write ebook: %v", err)
}
scans, err := collectEbookRootScans(context.Background(), 44, []string{fileRoot})
if err != nil {
t.Fatalf("collectEbookRootScans: %v", err)
}
if len(scans) != 1 || len(scans[0].files) != 1 || scans[0].files[0] != fileRoot {
t.Fatalf("scans = %+v, want compound-extension file indexed", scans)
}
}
func TestCollectEbookRootScansMidWalkSubtreeErrorExcludesRoot(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("permission-based subtree failure cannot be simulated as root")
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "readable.epub"), []byte("x"), 0o644); err != nil {
t.Fatalf("write ebook: %v", err)
}
locked := filepath.Join(root, "zz-locked")
if err := os.Mkdir(locked, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(locked, "hidden.epub"), []byte("x"), 0o644); err != nil {
t.Fatalf("write hidden ebook: %v", err)
}
if err := os.Chmod(locked, 0o000); err != nil {
t.Fatalf("chmod: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
scans, err := collectEbookRootScans(context.Background(), 44, []string{root})
if err != nil {
t.Fatalf("collectEbookRootScans: %v", err)
}
if len(scans) != 1 {
t.Fatalf("scans = %d, want 1", len(scans))
}
if len(scans[0].walkFailures) == 0 || !scans[0].failed() {
t.Fatalf("scan = %+v, want walk failure recorded for unreadable subtree", scans[0])
}
if len(scans[0].files) != 1 {
t.Fatalf("files = %v, want the readable ebook still indexed", scans[0].files)
}
// The unseen hidden.epub must not be marked missing: the whole root sits
// out of reconciliation.
if roots, _ := splitEbookReconcileRoots(scans); len(roots) != 0 {
t.Fatalf("reconcileRoots = %v, want none after a mid-walk subtree error", roots)
}
}
func TestCollectEbookRootScansFollowsSymlinkedRoot(t *testing.T) {
target := t.TempDir()
if err := os.WriteFile(filepath.Join(target, "book.epub"), []byte("x"), 0o644); err != nil {
t.Fatalf("write ebook: %v", err)
}
link := filepath.Join(t.TempDir(), "library")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
scans, err := collectEbookRootScans(context.Background(), 44, []string{link})
if err != nil {
t.Fatalf("collectEbookRootScans: %v", err)
}
if len(scans) != 1 || scans[0].failed() {
t.Fatalf("scans = %+v, want one clean scan through the symlinked root", scans)
}
want := filepath.Join(link, "book.epub")
if len(scans[0].files) != 1 || scans[0].files[0] != want {
t.Fatalf("files = %v, want %q recorded under the logical (symlink) path", scans[0].files, want)
}
if roots, sawFiles := splitEbookReconcileRoots(scans); !sawFiles || len(roots) != 1 {
t.Fatalf("reconcileRoots/sawFiles = %v/%v, want symlinked root reconcilable", roots, sawFiles)
}
}
type fakeEbookCleanupGuard struct {
allowance bool
consumeCalls int
warnings []string
consumeErr error
}
func (f *fakeEbookCleanupGuard) ConsumeEmptyCleanupAllowance(_ context.Context, _ int) (bool, error) {
f.consumeCalls++
return f.allowance, f.consumeErr
}
func (f *fakeEbookCleanupGuard) SetScanWarning(_ context.Context, _ int, code, _ string, _ time.Time) error {
f.warnings = append(f.warnings, code)
return nil
}
func TestEbookEmptyCleanupAllowedRequiresExplicitAllowance(t *testing.T) {
guard := &fakeEbookCleanupGuard{allowance: false}
allowed, err := ebookEmptyCleanupAllowed(context.Background(), guard, 44, true)
if err != nil {
t.Fatalf("ebookEmptyCleanupAllowed: %v", err)
}
if allowed {
t.Fatal("allowed = true, want false: an empty root must not reconcile without confirmation")
}
if guard.consumeCalls != 1 {
t.Fatalf("consumeCalls = %d, want 1", guard.consumeCalls)
}
if len(guard.warnings) != 1 || guard.warnings[0] != "empty_root" {
t.Fatalf("warnings = %v, want one empty_root scan warning", guard.warnings)
}
}
func TestEbookEmptyCleanupAllowedConsumesAllowanceOnce(t *testing.T) {
guard := &fakeEbookCleanupGuard{allowance: true}
allowed, err := ebookEmptyCleanupAllowed(context.Background(), guard, 44, true)
if err != nil {
t.Fatalf("ebookEmptyCleanupAllowed: %v", err)
}
if !allowed {
t.Fatal("allowed = false, want true once the operator confirmed cleanup")
}
if len(guard.warnings) != 0 {
t.Fatalf("warnings = %v, want none for a confirmed cleanup", guard.warnings)
}
}
func TestEbookEmptyCleanupAllowedNeverConsumesForSubtreeScans(t *testing.T) {
guard := &fakeEbookCleanupGuard{allowance: true}
allowed, err := ebookEmptyCleanupAllowed(context.Background(), guard, 44, false)
if err != nil {
t.Fatalf("ebookEmptyCleanupAllowed: %v", err)
}
if allowed || guard.consumeCalls != 0 {
t.Fatalf("allowed/consumeCalls = %v/%d, want subtree scans to skip cleanup without touching the folder allowance", allowed, guard.consumeCalls)
}
}
func TestEbookEmptyCleanupAllowedNilGuardDeniesCleanup(t *testing.T) {
allowed, err := ebookEmptyCleanupAllowed(context.Background(), nil, 44, true)
if err != nil || allowed {
t.Fatalf("allowed/err = %v/%v, want denial when no guard repository is wired", allowed, err)
}
}
func TestScanEbookFolderReturnsErrorWhenEveryReconcileFails(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "bad.epub"), []byte("not a real epub"), 0o644); err != nil {
t.Fatalf("write fake ebook: %v", err)
}
s := &Scanner{}
err := s.ScanEbookFolder(context.Background(), &models.MediaFolder{ID: 44, Paths: []string{root}})
if err == nil {
t.Fatal("ScanEbookFolder returned nil, want aggregate failure")
}
if !strings.Contains(err.Error(), "folder_id=44") {
t.Fatalf("error = %q, want folder id", err)
}
}
func TestScanEbookFolderReturnsCanceledContext(t *testing.T) {
root := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
cancel()
s := &Scanner{}
err := s.ScanEbookFolder(ctx, &models.MediaFolder{ID: 44, Paths: []string{root}})
if !errors.Is(err, context.Canceled) {
t.Fatalf("ScanEbookFolder error = %v, want context.Canceled", err)
}
}
func TestScanEbookFolderReportsProgress(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "broken.epub"), []byte("not a zip"), 0o644); err != nil {
t.Fatalf("write ebook fixture: %v", err)
}
var updates []ProgressUpdate
ctx := WithProgressReporter(context.Background(), func(update ProgressUpdate) {
updates = append(updates, update)
})
err := (&Scanner{}).ScanEbookFolder(ctx, &models.MediaFolder{ID: 44, Paths: []string{root}})
if err == nil {
t.Fatal("ScanEbookFolder returned nil, want aggregate failure")
}
if len(updates) == 0 {
t.Fatal("expected ebook scanner progress updates")
}
if updates[0].Phase != "ebook_scan" || updates[0].TotalFiles != 1 {
t.Fatalf("first progress update = %+v, want ebook_scan discovery total", updates[0])
}
last := updates[len(updates)-1]
if last.FilesProcessed != 1 || last.Errors != 1 {
t.Fatalf("last progress update = %+v, want processed/error counts", last)
}
}
func writeTestEPUB(t *testing.T, identifiers []string) string {
return writeTestEPUBWithMeta(t, identifiers, "")
}
func writeTestEPUBWithMeta(t *testing.T, identifiers []string, extraMetaXML string) string {
return writeTestEPUBWithDescriptionAndMeta(t, identifiers, "Back cover copy", extraMetaXML)
}
func writeTestEPUBWithDescription(t *testing.T, description string) string {
return writeTestEPUBWithDescriptionAndMeta(t, []string{"ISBN: 978-0-306-40615-7"}, description, "")
}
func writeTestEPUBWithDescriptionAndMeta(t *testing.T, identifiers []string, description string, extraMetaXML string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "book.epub")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create epub: %v", err)
}
zw := zip.NewWriter(file)
add := func(name, body string) {
t.Helper()
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create zip entry %s: %v", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("write zip entry %s: %v", name, err)
}
}
add("META-INF/container.xml", `
`)
var identifierXML strings.Builder
for _, identifier := range identifiers {
identifierXML.WriteString(" ")
identifierXML.WriteString(identifier)
identifierXML.WriteString("\n")
}
add("OPS/content.opf", `
The Test Ebook
Ada Writer
Ben Author
`+identifierXML.String()+`
Silo Press
2024-03-10
en
Fiction
Adventure
`+description+`
`+extraMetaXML+`
`)
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close epub: %v", err)
}
return path
}
func writeTestEPUBWithOPFBytes(t *testing.T, opf []byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), "book.epub")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create epub: %v", err)
}
zw := zip.NewWriter(file)
add := func(name string, body []byte) {
t.Helper()
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create zip entry %s: %v", name, err)
}
if _, err := w.Write(body); err != nil {
t.Fatalf("write zip entry %s: %v", name, err)
}
}
add("META-INF/container.xml", []byte(`
`))
add("OPS/content.opf", opf)
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close epub: %v", err)
}
return path
}
func writeTestEPUBWithCover(t *testing.T, coverHref string, coverType string, coverBytes []byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), "book.epub")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create epub: %v", err)
}
zw := zip.NewWriter(file)
add := func(name string, body []byte) {
t.Helper()
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create zip entry %s: %v", name, err)
}
if _, err := w.Write(body); err != nil {
t.Fatalf("write zip entry %s: %v", name, err)
}
}
add("META-INF/container.xml", []byte(`
`))
add("OPS/content.opf", []byte(`
The Test Ebook
`))
add("OPS/"+coverHref, coverBytes)
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close epub: %v", err)
}
return path
}
func TestUpdateExistingEbookMediaItemPreservesCuratedMatchedItem(t *testing.T) {
reader := &fakeFilesystemItemReader{items: []*models.MediaItem{{
ContentID: "ebook-root-id",
Type: "ebook",
Title: "Curated Title",
Year: 2020,
Status: "matched",
}}}
writer := &fakeFilesystemItemWriter{}
curated, err := updateExistingEbookMediaItem(context.Background(), reader, writer, "ebook-root-id", &parsedEbook{
Title: "Embedded File Title",
Year: 2026,
})
if err != nil {
t.Fatalf("updateExistingEbookMediaItem: %v", err)
}
if !curated {
t.Fatal("curated = false, want true for a matched item")
}
if len(writer.upserts) != 0 {
t.Fatalf("upserts = %d, want 0: matched items must not be overwritten by file metadata", len(writer.upserts))
}
}
func TestEbookItemHasCuratedMetadata(t *testing.T) {
cases := []struct {
item *models.MediaItem
want bool
}{
{nil, false},
{&models.MediaItem{Status: "matched"}, true},
{&models.MediaItem{Status: " Matched "}, true},
{&models.MediaItem{Status: "pending"}, false},
{&models.MediaItem{Status: "unmatched"}, false},
{&models.MediaItem{}, false},
}
for _, tc := range cases {
if got := ebookItemHasCuratedMetadata(tc.item); got != tc.want {
t.Errorf("ebookItemHasCuratedMetadata(%+v) = %v, want %v", tc.item, got, tc.want)
}
}
}
func TestParseEbookPDFReadsInfoDictionaryFromFileTail(t *testing.T) {
// Non-linearized PDFs put the Info dictionary at the end of the file and
// typically have no Info keys in the head at all; the parser must find the
// trailing dictionary past the head scan window and use it to fill every
// key the head did not produce.
head := "%PDF-1.7\n" +
"1 0 obj\n" +
"<< /Author (Ada Writer)\n" +
">>\nendobj\n"
tail := "2 0 obj\n" +
"<< /Title (Tail Info Title)\n" +
" /CreationDate (D:20240102030405Z)\n" +
">>\nendobj\n" +
"trailer\n<< /Info 2 0 R >>\n%%EOF"
padding := strings.Repeat("x", maxPDFMetadataScanSize)
path := filepath.Join(t.TempDir(), "book.pdf")
if err := os.WriteFile(path, []byte(head+padding+tail), 0o644); err != nil {
t.Fatalf("write pdf: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Title != "Tail Info Title" {
t.Fatalf("Title = %q, want tail Info dictionary value", got.Title)
}
if strings.Join(got.Authors, ", ") != "Ada Writer" {
t.Fatalf("Authors = %v, want head-only value preserved", got.Authors)
}
if got.Year != 2024 {
t.Fatalf("Year = %d, want 2024 from tail CreationDate", got.Year)
}
}
func TestParseEbookPDFHeadInfoWinsOverTailStreamGarbage(t *testing.T) {
// Linearized PDFs carry the real Info dictionary in the head; a
// "/Title"-shaped byte pattern inside a trailing compressed stream must
// not replace it.
head := "%PDF-1.7\n" +
"1 0 obj\n" +
"<< /Linearized 1 >>\nendobj\n" +
"2 0 obj\n" +
"<< /Title (Real Head Title)\n" +
" /Author (Ada Writer)\n" +
">>\nendobj\n"
tail := "3 0 obj\n" +
"<< /Length 64 >>\nstream\n" +
"\x01\x02/Title (\xfe\xba\xad binary stream garbage)\x03\x04\n" +
"endstream\nendobj\n" +
"trailer\n<< /Info 2 0 R >>\n%%EOF"
padding := strings.Repeat("x", maxPDFMetadataScanSize)
path := filepath.Join(t.TempDir(), "book.pdf")
if err := os.WriteFile(path, []byte(head+padding+tail), 0o644); err != nil {
t.Fatalf("write pdf: %v", err)
}
got, err := parseEbookFile(path)
if err != nil {
t.Fatalf("parseEbookFile: %v", err)
}
if got.Title != "Real Head Title" {
t.Fatalf("Title = %q, want authoritative head value over tail stream garbage", got.Title)
}
}
func TestFindPDFInfoValueRequiresProperKeyDelimiter(t *testing.T) {
// "/TitleSort" must not satisfy a "/Title" lookup, and the real key later
// in the window must still be found.
data := []byte(`<< /TitleSort (Adventures of, The) /Title (The Adventures) >>`)
value, ok := findPDFInfoValue(data, "Title")
if !ok || value != "The Adventures" {
t.Fatalf("findPDFInfoValue = %q, %v; want the properly delimited /Title", value, ok)
}
}
func TestFindPDFInfoValueRetriesPastUnparseableOccurrences(t *testing.T) {
// A key match inside binary stream data whose "value" is not a PDF string
// must not end the search; the real key/value pair later in the window
// still wins.
data := []byte("stream\x00/Title \x07\x08garbage\x00endstream\n" +
"<< /Title (Recovered Title) >>")
value, ok := findPDFInfoValue(data, "Title")
if !ok || value != "Recovered Title" {
t.Fatalf("findPDFInfoValue = %q, %v; want recovery past unparseable match", value, ok)
}
if _, ok := findPDFInfoValue([]byte("/Titled (Nope)"), "Title"); ok {
t.Fatal("findPDFInfoValue matched a longer key sharing the prefix")
}
}
func TestParseEbookFB2RejectsOversizedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "book.fb2")
if err := os.WriteFile(path, make([]byte, maxEPUBMetadataEntrySize+1), 0o644); err != nil {
t.Fatalf("write fb2: %v", err)
}
_, err := parseEbookFile(path)
if err == nil || !strings.Contains(err.Error(), "too large") {
t.Fatalf("parseEbookFile error = %v, want size-cap rejection", err)
}
}
// buildTestMOBI assembles a minimal but valid MOBI/AZW container: a PDB header,
// a single record-info entry pointing at record 0, and record 0 holding a
// PalmDOC header, a MOBI header, an EXTH block, and the full-title string.
func buildTestMOBI(t *testing.T) string {
t.Helper()
exthRec := func(typ uint32, val string) []byte {
b := make([]byte, 8+len(val))
binary.BigEndian.PutUint32(b[0:4], typ)
binary.BigEndian.PutUint32(b[4:8], uint32(8+len(val)))
copy(b[8:], val)
return b
}
var recs []byte
recs = append(recs, exthRec(100, "A H Lee")...) // author
recs = append(recs, exthRec(503, "The Sea: A Novel")...) // updated title
recs = append(recs, exthRec(104, "9780306406157")...) // ISBN
exth := make([]byte, 12)
copy(exth[0:4], "EXTH")
binary.BigEndian.PutUint32(exth[4:8], uint32(12+len(recs)))
binary.BigEndian.PutUint32(exth[8:12], 3)
exth = append(exth, recs...)
const mobiHeaderLen = 232
mobi := make([]byte, mobiHeaderLen)
copy(mobi[0:4], "MOBI")
binary.BigEndian.PutUint32(mobi[4:8], mobiHeaderLen)
binary.BigEndian.PutUint32(mobi[12:16], 65001) // text encoding: UTF-8
fullName := []byte("The Sea")
nameOff := 16 + mobiHeaderLen + len(exth) // relative to record 0 start
binary.BigEndian.PutUint32(mobi[0x44:0x48], uint32(nameOff))
binary.BigEndian.PutUint32(mobi[0x48:0x4C], uint32(len(fullName)))
var rec0 []byte
rec0 = append(rec0, make([]byte, 16)...) // PalmDOC header (zeroed)
rec0 = append(rec0, mobi...)
rec0 = append(rec0, exth...)
rec0 = append(rec0, fullName...)
const rec0Off = 86 // 78-byte PDB header + one 8-byte record-info entry
pdb := make([]byte, rec0Off)
copy(pdb[0:32], "The Sea") // PDB database name (last-resort title)
copy(pdb[60:64], "BOOK")
copy(pdb[64:68], "MOBI")
binary.BigEndian.PutUint16(pdb[76:78], 1) // record count
binary.BigEndian.PutUint32(pdb[78:82], rec0Off) // record 0 offset
path := filepath.Join(t.TempDir(), "book.mobi")
if err := os.WriteFile(path, append(pdb, rec0...), 0o644); err != nil {
t.Fatalf("write test mobi: %v", err)
}
return path
}
func TestParseEbookMOBIEXTH(t *testing.T) {
got, err := parseEbookFile(buildTestMOBI(t))
if err != nil {
t.Fatalf("parseEbookFile error = %v", err)
}
if got.Format != "mobi" {
t.Fatalf("Format = %q, want mobi", got.Format)
}
if got.Title != "The Sea: A Novel" {
t.Fatalf("Title = %q, want EXTH updated title", got.Title)
}
if len(got.Authors) != 1 || got.Authors[0] != "A H Lee" {
t.Fatalf("Authors = %v, want [A H Lee]", got.Authors)
}
if got.ISBN != "9780306406157" {
t.Fatalf("ISBN = %q, want 9780306406157", got.ISBN)
}
}
func TestEbookAuthorFromPath(t *testing.T) {
cases := []struct {
name string
path string
want string
}{
{
name: "corroborated grandparent and filename suffix",
path: "/books/Books_English/Lisa Jewell/The House We Grew Up In (135563)/The House We Grew Up In - Lisa Jewell.azw3",
want: "Lisa Jewell",
},
{
name: "case and spacing differences still match",
path: "/books/Books_English/A. F. Carter/All of Us (57890)/All of Us - a. f. carter.pdf",
want: "A. F. Carter",
},
{
name: "no dash in filename",
path: "/books/Books_German/Schweizer Familie 11.04.2019.pdf",
want: "",
},
{
name: "suffix does not match grandparent dir",
path: "/books/Books_English/Stephen King/Salem's Lot (8507)/Salem's Lot - Some Other Name.pdf",
want: "",
},
{
name: "empty suffix",
path: "/books/X/Author/Title/Title - .pdf",
want: "",
},
{
name: "inverted series folder rejected (grandparent is title)",
path: "/books/Books_Dutch/De legenden van de Alfen/Heinz, Markus (2118)/Heinz, Markus - De legenden van de Alfen.epub",
want: "",
},
{
name: "comma surname form accepted",
path: "/books/Books_Dutch/Mersbergen, Jan van/De laatste ontsnapping (8702)/De laatste ontsnapping - Mersbergen, Jan van.epub",
want: "Mersbergen, Jan van",
},
{
name: "particle in name accepted",
path: "/books/Books_English/Dean R. Koontz/Midnight (7408)/Midnight - Dean R. Koontz.epub",
want: "Dean R. Koontz",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ebookAuthorFromPath(tc.path); got != tc.want {
t.Fatalf("ebookAuthorFromPath(%q) = %q, want %q", tc.path, got, tc.want)
}
})
}
}