Files
silo-server/internal/scanner/ebook_test.go
QuickandGitHub 10394b0a05 fix(scanner): stop hiding media when a library root is offline (#472)
* fix(scanner): stop hiding media when a library root is offline

A scan that cannot read a library root found no files there, so every
cataloged file under it was marked missing. Catalog reads all filter on
missing_since IS NULL, so marking is equivalent to deletion from a user's
point of view: the title leaves browse, search and next-up, and playback
answers "Source media file is missing" for media that is intact on disk.

The dead-root protection already existed but only guarded the destructive
operations. protectedConfiguredRoots was computed *after* the marking loop
in scanPaths, and applyScopedScan received the protected set but applied it
only to its force-delete branch. So an unreachable root could not lose its
rows, but could still have its entire catalog hidden until the next
successful scan.

On a CephFS deployment whose per-library subvolume mounts flap, this marked
190 present files missing in a single day — 15% of all missing-flagged rows
were files sitting untouched on disk, some flagged more than 20 hours after
their last write.

Hoist the probe above the marking loop and skip files under an unreachable
or suspect-empty root in both the folder and scoped paths. Pass the
unreachable set to the walked-scope call too: a nested child mount can die
under a healthy parent, and its rows are inside the parent's scope.

An offline root tells us nothing about whether its files exist. The only
safe reading is to leave them alone and let the next good scan decide.

Genuine deletions under a reachable root are unaffected and still marked
and swept on the same schedule as before.

Report the count as ScanResult.MissingSkippedProtected and log it, so an
operator can tell "my library shrank" from "my mount dropped".

Known gap: a suspect-empty *nested child* root under a healthy parent is
still marked missing, because the suspect set is not resolved until after
the walk loop. Unreachable roots — the case observed in production — are
covered.

* fix(scanner): protect suspect-empty and partially-walked roots too

Addresses review findings on #472. The original change guarded missing-marking
against probe-unreachable roots, but left three ways for a storage fault to
still hide a healthy library.

Suspect-empty detection was reactive. suspectEmptyRoots asked
ListRootsWithOnlyMissingFiles, which returns a root only once it has NO live
rows left. On the first scan after a mount drops — the moment that matters —
the rows are still live, so the root was not classified suspect and the scan
marked everything missing. The protection then engaged on the next scan, in
time to protect the wreckage. Ask ListRootsWithCatalogedFiles instead: any
cataloged row under an empty-but-reachable root is the lost-mount signature.
Intentional emptying is still reachable through the operator's one-time
cleanup allowance, which is the deliberate path for it.

Nested suspect-empty children were unprotected. Root compaction sends only the
populated parent through the walked-scope branch, which received only
unreachableRoots, so an empty child mountpoint had its rows marked missing on
its parent scanning cleanly. Pass the suspect set as well.

Partial walks were treated as authoritative. walkLogicalTree deliberately
swallows per-entry Lstat/ReadDir failures so one bad file cannot abort a scan
of a million, and collectLogicalFilePaths passed nil for the failure counter —
so the video path had no signal at all. A mount dying partway through
traversal produced a short file list indistinguishable from a large deletion.
Thread the counter through, and exclude a scope whose walk came back
incomplete from missing reconciliation, mirroring what the ebook scanner
already does via ebookRootScan.failed.

Also extract the duplicated mark-missing loop into markMissingExcludingProtected
so the folder and scoped paths cannot drift, and correct two comments that
still described the pre-fix "files are marked missing" behaviour — the exact
text a future reader would have trusted when reintroducing this bug.

TestScanFolderNestedSuspectEmptyChildRootProtection asserted the old
behaviour and is updated accordingly.

* fix(scanner): scope walk-failure protection and stop pruning on partial walks

Addresses the second Codex review round on #472. The previous commit's
incomplete-walk protection was too blunt in one direction and applied too late
in another.

Walk failures were counted, not located, and any non-zero count protected the
whole library root. A dangling symlink is both common and permanent, so that
would have suppressed missing-file reconciliation for its entire root on every
future scan — genuinely deleted titles would stay live indefinitely. That is
the same class of bug as the one this PR fixes, pointing the other way.
recordWalkFailure now records the logical path of each unreadable entry, and
only those paths are protected. Per-entry failures record the child path, so a
dangling symlink protects itself and nothing else, while a directory that
cannot be read protects its subtree.

Snapshot and group pruning ran before the protection. reconcileScannedRoots
and reconcileScannedGroups delete whatever the walk did not see, and both run
ahead of the missing-file guard, so a partial walk still dropped root
snapshots, observed locations and group locations for the unread portion —
corrupting later metadata matching even though the media_files rows survived.
Upserting what was seen is always safe; pruning now waits for a scan that read
the whole tree.

The confirmed-cleanup allowance was consumed to no effect for nested suspect
children. The walked-parent branch protected them unconditionally and runs
before the allowance is consumed, and an already-reconciled scope cannot be
revisited — so arming the allowance burned the confirmation while the child's
rows stayed live forever. Read the allowance without consuming it before the
walk loop, and honour it there. Unreachable roots stay protected either way:
an outage is never a confirmation to erase a catalog.

Two new regression tests, plus signature updates in the ebook pipeline, which
already tracked walk failures and now shares the path-based representation.

* fix(scanner): re-probe nested roots and gate group pruning on walk completeness

Third Codex review round on #472; both findings confirmed.

Group pruning ignored walk completeness in the subtree path. scanPaths passed
the completeness decision to reconcileScannedRoots but left
reconcileScannedGroups on !allowEmptyRootGuard, which is always true for
ScanSubtree — so a subtree scan that hit an unreadable directory still replaced
group snapshots and locations from a partial inventory. Same rule now applies
to both.

Nested roots were not re-probed before their parent was reconciled. Root
compaction folds a child mount into its parent for traversal, so a child that
is healthy at the initial probe but drops before the parent is walked leaves no
scope of its own, and the post-walk re-probe only revisits scopes that walked
empty. The parent walks files, looks healthy, and the child's rows are marked
missing on its success. reprobeNestedRoots re-checks this root's configured
children immediately before reconciling, protecting any that have since become
unreachable — or suspect-empty, unless the operator has confirmed cleanup.

Also guard suspectEmptyRoots against a nil file repository, matching
emptyCleanupArmed: without a catalog there is nothing to protect.

* fix(scanner): keep re-probed outages protected through folder-wide cleanup

Fourth Codex review round on #472; both findings confirmed. The first could
destroy data.

reprobeNestedRoots protected a root it found offline only for the scope being
reconciled, then discarded the result. The folder-wide membership reconcile and
the trash sweep afterwards rebuilt their protected set from the initial probe
alone, so rows under a child that dropped mid-scan — already marked missing and
past the removal grace — were hard-deleted by the very scan that noticed the
outage. Accumulate those roots in reprobedRoots, fold them into
protectedScanRoots, and reuse that set for the membership reconcile and sweep
instead of rebuilding. They now also land in ScanResult.UnreachableRoots so the
folder warning reflects the outage rather than presenting a partial scan as
clean.

Snapshot and group pruning was enabled for scopes that were never walked. The
gate was len(walkFailures) == 0, but an unreachable root gets nil walkRoots, so
it has no walk and therefore no failures — and pruning then deleted its
snapshots, observed locations and group locations even though its media rows
were protected. The same held for a suspect-empty child compacted into a
populated parent. Pruning now additionally requires that the scope was actually
walked and contains no protected path.

The new test pins that the sweep honours the protected set it is given. It does
not reproduce the mid-scan race itself: staging that needs the drop to land
between the probe and the walk, which a test cannot reach without hooks. That
path is covered by inspection, and the test comment says so rather than
implying coverage it does not have.

* fix(scanner): route every protection source through one folder-wide set

Fifth Codex review round on #472. Two P1s, one of them the second data-loss
path in this area — and the direct sibling of the one fixed in 35326adc, which
is the reason this commit changes the structure rather than patching another
edge.

Rows beneath a directory the walk could not read were protected only inside
applyScopedScan. The folder-wide protected set was rebuilt from the probe
results alone, so DeleteMissingByFolder could permanently delete rows past the
removal grace under a subtree this scan never managed to read — deleting on the
strength of an observation that was never made.

The recurring defect is structural: protection is discovered in several places
(initial probe, mid-loop re-probe, per-scope walk failures) and consumed in
several more (scoped reconcile, membership reconcile, trash sweep), and each
fix so far has wired up one edge and missed another. Every source now
accumulates folder-wide and every consumer reads the combined set, so a new
source has one place to register instead of several to remember.

reprobeNestedRoots classified from two probe batches. It called
probeUnreachableRoots, then suspectEmptyRoots probed the same paths again; a
child dropping between the samples was reachable to the first and discarded by
the second, which only returns reachable-and-empty roots. It now classifies
both states from one batch, so the disconnect it exists to catch cannot fall
between its own probes.

Re-probed roots kept their classification instead of being collapsed into
unreachableRoots, which had been reporting a suspect-empty child as
unreachable and giving operators contradictory failure information.

The new regression test is verified to fail with the propagation disabled and
pass with it, rather than assumed to cover the path.

Not addressed: the cleanup allowance is read without being reserved, so two
overlapping full scans of one folder can both observe it armed. Narrow, needs
a transactional reserve in the scan-claim query, and is left for follow-up
rather than bundled here.

* fix(scanner): resolve root protection before scoped metadata pruning

Sixth Codex review round on #472.

scanPaths pruned before it knew what was protected. reconcileScannedRoots and
reconcileScannedGroups ran roughly 160 lines ahead of protectedConfiguredRoots,
so a ScanSubtree of a mount that dropped but left a reachable empty mountpoint
walked clean, reported no failures, and pruned root snapshots and observed and
group locations against that empty inventory — preserving the media rows while
deleting the metadata describing them. Protection is now resolved before any
reconciliation, and both prunes share one decision, matching applyScopedScan.

Pending empty scopes never re-probed their nested children. A parent whose only
media lives in a child walks empty when that child drops, so it lands in
pendingEmptyScopes rather than the populated-scope branch where
reprobeNestedRoots ran. Probing the parent alone proves nothing: it still holds
the child's bare mountpoint directory, so it reads present and non-empty. With
a healthy sibling keeping the folder-wide empty guard quiet, nothing protected
the child. Both branches now re-probe.

MissingSkippedProtected never left the scanner. Both ingest-to-result
conversions copied every other cleanup count but not this one, and
events.ScanRunResult had no field, so scan history, completion events and API
responses reported an all-zero no-op for a scan that skipped files because
storage was offline. Added as a new field, which is additive under the v1 API
rules.

Test honesty: the new test does NOT exercise the pending-scope re-probe. It
empties the child before the scan, so the initial probe classifies it and
protection arrives by that path — verified by confirming the test still passes
with the re-probe disabled. It is named and commented for what it does cover.
The mid-scan race behind both re-probe fixes needs the drop to land between the
probe and the walk, which is not reachable from a test without hooks; those
fixes rest on inspection.
2026-07-25 14:47:12 -04:00

2206 lines
72 KiB
Go

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, `&lt;div&gt;&lt;p&gt;&lt;strong&gt;Shanghai Dreams ...&lt;/strong&gt; Magnus is stationed at Shanghai.&lt;br&gt;Published by The Electronic Book Company&lt;/p&gt;&lt;/div&gt;`)
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, ` <meta name="calibre:isbn" content="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 from calibre meta tag", got.ISBN)
}
}
func TestParseEbookEPUBMetadataHandlesLatin1OPF(t *testing.T) {
path := writeTestEPUBWithOPFBytes(t, []byte("<?xml version=\"1.0\" encoding=\"iso-8859-1\"?>\n"+
"<package xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><metadata>"+
"<dc:title>Caf\xe9 Society</dc:title>"+
"<dc:creator>Fran\xe7ois Author</dc:creator>"+
"<dc:publisher>Cr\xe8me Press</dc:publisher>"+
"</metadata></package>"))
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("<?xml version=\"1.0\" encoding=\"windows-1251\"?>\n"+
"<package xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><metadata>"+
"<dc:title>\xc2\xee\xe9\xed\xe0 \xe8 \xec\xe8\xf0</dc:title>"+
"<dc:creator>\xd2\xee\xeb\xf1\xf2\xee\xe9</dc:creator>"+
"</metadata></package>"))
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 := `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata>
<dc:title>Dune</dc:title>
<dc:creator>Frank Herbert</dc:creator>
<dc:description>A desert planet and a dangerous inheritance.</dc:description>
<dc:identifier>9780441172719</dc:identifier>
<dc:subject>Science Fiction</dc:subject>
</metadata>
</package>`
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 := `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata><dc:title>Smuggled</dc:title></metadata>
</package>`
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(`<?xml version="1.1" encoding="UTF-8"?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata>
<dc:title>Versioned Ebook</dc:title>
<dc:creator>Ada Writer</dc:creator>
</metadata></package>`))
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", `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`)
// EPUB2-style meta cover pointing at the XHTML cover *page*, with the
// real image declared later via properties="cover-image".
add("OPS/content.opf", `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<metadata>
<dc:title>The Test Ebook</dc:title>
<meta name="cover" content="cover"/>
</metadata>
<manifest>
<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>
<item id="cover-img" href="Images/cover.jpg" media-type="image/jpeg" properties="cover-image"/>
</manifest>
</package>`)
add("OPS/cover.xhtml", "<html><body><img src='Images/cover.jpg'/></body></html>")
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(`<?xml version="1.0" encoding="UTF-8"?>
<FictionBook>
<description>
<title-info>
<genre>science fiction</genre>
<author><first-name>Ada</first-name><last-name>Writer</last-name></author>
<book-title>FB2 Test Ebook</book-title>
<date value="2022-04-05">5 April 2022</date>
<lang>en</lang>
<sequence name="FB2 Series" number="3"/>
</title-info>
<publish-info>
<publisher>FB2 Press</publisher>
<year>2022</year>
<isbn>978-0-306-40615-7</isbn>
</publish-info>
</description>
</FictionBook>`), 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(`<?xml version="1.0" encoding="UTF-8"?>
<FictionBook>
<description>
<title-info>
<genre>fantasy</genre>
<author><first-name>Ada</first-name><last-name>Writer</last-name></author>
<book-title>FBZ Test Ebook</book-title>
<date value="2021-02-03"/>
<lang>en</lang>
<sequence name="Archive Series" number="4"/>
</title-info>
<publish-info>
<publisher>Archive Press</publisher>
<year>2021</year>
<isbn>978-0-306-40615-7</isbn>
</publish-info>
</description>
</FictionBook>`)); 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 <FEFF00480065007800200042006F006F006B>
/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: " "}, &currentName, 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"}, &currentName, &currentIdx, 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"}, &currentName, &currentIdx, 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: ""}, &currentName, &currentIdx, 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: "<div><p>Raw overview</p></div>",
}}}
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", `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`)
var identifierXML strings.Builder
for _, identifier := range identifiers {
identifierXML.WriteString(" <dc:identifier>")
identifierXML.WriteString(identifier)
identifierXML.WriteString("</dc:identifier>\n")
}
add("OPS/content.opf", `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<metadata>
<dc:title>The Test Ebook</dc:title>
<dc:creator>Ada Writer</dc:creator>
<dc:creator>Ben Author</dc:creator>
`+identifierXML.String()+`
<dc:publisher>Silo Press</dc:publisher>
<dc:date>2024-03-10</dc:date>
<dc:language>en</dc:language>
<dc:subject>Fiction</dc:subject>
<dc:subject>Adventure</dc:subject>
<dc:description>`+description+`</dc:description>
<meta name="calibre:series" content="Test Series"/>
<meta name="calibre:series_index" content="2"/>
`+extraMetaXML+`
</metadata>
</package>`)
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(`<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`))
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(`<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`))
add("OPS/content.opf", []byte(`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<metadata>
<dc:title>The Test Ebook</dc:title>
<meta name="cover" content="cover-image"/>
</metadata>
<manifest>
<item id="cover-image" href="`+coverHref+`" media-type="`+coverType+`"/>
</manifest>
</package>`))
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)
}
})
}
}