Files
silo-server/internal/scanner/ebook_test.go
T
8b70357703 feat(ebooks): first-class ebook libraries, scanner, and reader (#124)
* docs: define ebook architecture matching audiobooks

* docs: plan ebook audiobook-parity implementation

* feat: add ebook scanner parser foundation

* fix: harden ebook scanner foundation

* fix: handle ebook isbn labels

* fix: guard ebook subtree scans

* feat: scan ebook libraries in core

* fix: preserve ebook scan people credits

* fix: refresh ebook scan metadata safely

* feat: persist ebook series membership

* test: cover ebook series persistence decisions

* fix: address ebook scanner PR review

* docs: clarify ebook foundation PR scope

* feat: add ebook metadata enricher

* fix: harden ebook poster cache

* feat: wire ebook metadata sync task

* feat: expose ebook library metadata setup

* feat: add ebook catalog scope support

* feat: add ebook detail view

* feat: label ebook file versions by format

* feat: use file-size copy for downloads

* feat: use file language in download dialog

* test: cover ebook detail authors and downloads

* fix: drop narrator credits from ebook scanner merges

* fix: align ebook collection filters with book media

* fix: drop asin provider ids from ebook enrichment

* fix: force ebook people refresh for stale narrators

* chore: omit ebook planning docs from branch

* feat: add ebook detail related content

* feat: add ebook reader file entrypoint

* feat: render ebooks with foliate reader

* feat: persist ebook reader progress

* feat: add ebook reader controls

* feat: extract ebook pdf metadata

* feat: favor scanner isbn during ebook enrichment

* feat: extract fbz ebook metadata

* feat: count cbz ebook pages

* feat: show ebook file page counts

* feat: show ebook download summaries

* feat: switch ebook reader files

* feat: prefer epub for ebook read action

* feat: surface ebook reader progress

* feat: sync ebook reader progress cache

* feat: hide ebook read action for unsupported files

* feat: filter ebook reader file selector

* fix: serve fbz ebook archives with reader mime type

* fix: detect fbz ebooks from compound filename

* fix: authorize fbz ebooks from compound filename

* fix: scope ebook catalog facets

* fix: reject narrator queries for ebooks

* fix: build ebook recommendation text from authors

* fix: include ebooks in embedding eligibility

* fix: include ebooks in recommendation media mix

* fix: include ebooks in recently added recommendations

* feat: include ebook progress in recommendation signals

* feat: include ebooks in continue watching sections

* feat: include ebooks in catalog progress metrics

* fix: read ebook isbn from epub metadata

* fix: filter ebook asin provider aliases

* fix: fall back from unsupported ebook reader files

* fix: sort ebook catalogs by reader progress

* fix: filter ebook catalogs by reader progress

* fix: include ebooks in last watched catalog filters

* feat: reflect ebook reader progress in item user state

* feat: share ebook progress state across item surfaces

* feat: report ebook scan progress

* fix: include ebook activity in recommendations

* fix: expose ebook reader progress on item detail

* fix: support ebook subtree scans

* fix: honor profile header for ebook item progress

* fix: add ebook library default sections

* fix: route ebook continue cards to reader

* fix: hide watched toggle for ebooks

* fix: route ebook watch tonight cards to reader

* fix: route ebook hero actions to reader

* fix: detect archive ebook reader formats by filename

* feat: cache embedded ebook covers during scan

* fix: encode ebook hero reader links

* fix: persist non-epub ebook reader progress

* fix: scope narrator catalog badges to audiobooks

* fix: merge ebook reader progress during item repair

* fix: label ebook progress filters as read

* fix: show ebook related rails as book covers

* fix: remove txt ebook reader support

* fix: reject txt ebook reader files

* fix: label ebook advanced filters as read

* fix: label ebook personalized sorts as read

* fix: remove plain text reader loader path

* test: cover ebook unread catalog rules

* fix: preserve ebook reader library context

* fix: link ebook genres with library scope

* fix: encode related rail item links

* fix: encode catalog card item links

* fix: encode hero and continue item links

* fix: encode watch tonight item links

* fix: encode recommendation and search item links

* test: cover ebook scan format set

* fix: label ebook search results clearly

* fix: make global search prompt media neutral

* fix: encode catalog read API ids

* fix: encode item API ids

* fix: include ebook reader vendor in docker build

* fix: make ebook reader build clean

* fix: clean ebook embedded descriptions

* docs: plan ebook reader shell parity

* feat: add ebook reader shell controls

* fix: widen ebook scrolled reader flow

* fix: remove scrolled reader content width cap

* docs: plan ebook reader full parity

* feat: persist ebook reader config

* feat: add ebook annotations and bookmarks

* feat: add ebook reader tools and aids

* feat: add ebook advanced reader settings

* fix: keep ebook reader panel in viewport

* fix: use foliate sizing units for ebook scroll flow

* fix: keep ebook settings controls readable

* fix: simplify ebook reader settings controls

* feat(ebooks): extract local covers during scan (#98)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* fix(ebooks): harden local cover extraction and format grouping

Address review findings on the local cover scan:

- Restrict generic sidecar covers (cover.jpg, folder.png, ...) to
  single-book directories, always accept images named after the book
  file, and apply exactly one cover per reconcile with sidecar taking
  precedence over the embedded cover.
- Replace the read-then-write poster update with an atomic conditional
  UPDATE (ItemRepository.SetLocalPoster) so provider/admin artwork is
  never clobbered by concurrent writers, and refresh locally owned
  posters when the extracted cover bytes change (thumbhash compare).
- Preserve UTF-8 PDF Info strings (including a UTF-8 BOM) instead of
  forcing everything through Windows-1252; the cp1252 fallback now only
  applies to non-UTF-8 bytes.
- Select EPUB covers by manifest media-type with properties="cover-image"
  outranking the EPUB2 meta name="cover" id, so XHTML cover pages no
  longer shadow the real image.
- Order CBZ pages naturally (2.jpg before 10.jpg, ch2/ before ch10/)
  when picking the cover page, via a single O(n) min-scan.
- Bump the ebook content group key scheme to version 2 and reprocess
  rows written under older versions so pre-existing libraries gain
  sibling-format grouping instead of accumulating duplicates.
- Group different formats only (a same-format sibling with colliding
  sparse metadata stays a separate item) and stop a joining sibling's
  embedded metadata from overwriting a provider-matched item.
- Decode any IANA-labelled OPF/FB2 XML charset (windows-1251, koi8-r,
  shift_jis, ...) via x/net/html/charset, and wire the charset reader
  into FB2 parsing which previously had none.
- Strip the full .fb2.zip double extension from filename-derived titles
  and group keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): add reader profiles and ruler (#99)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* feat(ebooks): add reader profiles and ruler

* fix(ebooks): address reader ruler and profile review findings

- skip renderer setStyles/render when computed styles and attributes are
  unchanged, so ruler position updates no longer re-style the book view
- drag the ruler via a local draft that commits on release, with the
  surface rect cached at pointer-down
- migrate font values persisted before the generic stacks (Inter,
  Georgia, Merriweather, legacy serif) so the font select never renders
  blank, with a Custom fallback option for unknown values
- make the ruler band click-through and move dragging to a dedicated
  keyboard-accessible slider handle so links and text selection keep
  working under the band
- share font stacks between options and profiles via READER_FONT_STACKS
- surface the active reading profile, move presets to the top of the
  settings panel, and drop the redundant profile button aria-labels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): resolve prefer-const lint error in readest document lib

`pnpm run lint` failed on the branch because `direction` is never
reassigned in getDirection; split the destructure so only the
reassigned `writingMode` stays mutable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Merge branch 'main' into work/ebooks-reader-base

Brings the ebook integration branch up to date with main (audiobook
library redesign, continue-watching rework and card affordances,
quic-go bump, jellycompat fixes). Conflict resolutions favor main's
generalized mechanisms and register ebooks with them:

- media scope validation goes through IsValidMediaScope (now including
  "ebook" alongside main's "video" group scope), in Go and in the web
  filter/search types
- continue-watching uses main's typed rails; reading-type sections pull
  resume points from ebook_reader_progress and the ebook library default
  section is wired to ContinueTypeConfig(ContinueTypeReading)
- item_repo keeps main's derived select-list machinery (itemColumnExpr)
  and both poster accessors (GetPoster/SetLocalPoster for ebook covers,
  GetPosterPath for audiobook covers)
- web cards/hero/watch-tonight adopt main's buildMediaPlayHref helpers,
  which now route ebooks to /reader/ebook and encode content ids;
  ebook affordances (BookOpen icon, Read verb, percent-read subtitle)
  carry over onto main's reworked components
- LibraryForm ebook support ported into main's refactored
  useLibraryForm/libraryTypes modules

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy foliate-js vendor into Dockerfile.dev frontend stage

foliate-js is a file:vendor/foliate-js dependency, so pnpm install needs
the vendor directory before the lockfile install layer. The production
Dockerfile already copies it; the dev image was missed, breaking
make dev-deploy with ENOENT on /app/web/vendor/foliate-js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): render Continue Reading sections as upright poster cards

All-ebook continue sections previously fell through to the horizontal
16:9 wide card; include ebooks in the poster-variant check so book
covers render in their natural 2:3 framing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): stop related-rail highlight ring clipping on detail pages

Move the current-item ring onto the cover artwork with a themed
ring-offset color (matching the sidebar profile highlight) and give
the scroll container top headroom so the ring is not cut off by
overflow-x-auto. Applies to both ebook and audiobook detail rails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scanner): harden ebook scanning against data loss and bad metadata

- Reconcile missing ebook files like video/audio, with real per-root walk
  failure tracking (failed/unmounted roots are excluded from deletion),
  symlinked-root support via the shared logical walker, and the empty-root
  cleanup allowance before any destructive reconciliation.
- Create ebook items as 'pending' so enrichment can promote them to
  'matched' (backfill migration included), and protect matched items from
  re-scan clobbering: title/year skipped, people/series fill-empty only.
- PDF metadata: scan head + tail windows (non-linearized PDFs keep the Info
  dict at the end), require proper key delimiters, head values win.
- Cap plain .fb2 reads like .fbz entries; drop .md as an ebook format.
- gofmt internal/scanner/audiobook.go (pre-existing drift).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): make enrichment failures non-terminal with dedicated backoff state

- Provider errors now record a failure (capped retries) instead of stamping
  last_refreshed, which permanently excluded items after transient outages.
- Unconfigured metadata chains and the scan-window membership race skip the
  item without stamping or burning a retry.
- Failure tracking moves to a new ebook_enrichment_state table, decoupling
  it from media_items.refresh_failures (shared with metadata refresh debt).
- Preserve non-author people credits when persisting enrichment results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): gate ebook progress on hidden history and centralize threshold

- Apply user_history_hidden_items gating (video semantics) to the ebook
  watched/in-progress filters, progress sort plan, and Continue Reading.
- Continue Reading pages past dismissed items via the shared collector and
  dedupes items across pages (also fixes the video path's latent exposure).
- Centralize the 0.9 finished threshold as models.EbookFinishedProgressThreshold
  with a single SQL-interpolated mirror in catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(recommendations): correct watcher counting and wire ebook taste signals

- itemWatchersQuery dedupes to distinct (watcher, item) rows so one
  binge-watcher can no longer satisfy minWatchers; the eligibility floor
  now counts distinct accounts rather than profiles.
- Hidden-history gating on GetEbookReaderProgressForUser (signal reader).
- Ebook reading produces canonical implicit taste signals (weighted like
  the equivalent movie progress ratio); ebooks join taste-seed candidates.
- Stale GetRecentlyAddedItems doc comment corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): harden ebook reader endpoints and serve a Content-Security-Policy

- Serve a CSP on all SPA HTML responses: blob/srcdoc book iframes inherit
  it, so script-src 'self' 'wasm-unsafe-eval' blocks script execution from
  malicious book content (sandbox alone is defeated by the WebKit
  allow-scripts requirement). Threat model documented on the constant.
- X-Content-Type-Options: nosniff on frontend, jellycompat, and ebook file
  responses; MIME resolution can no longer fall through to octet-stream
  for an admitted ebook file.
- Annotation PATCH: presence-aware field semantics (absent keeps, present
  sets/clears), invariant re-validation on the merged row, and an atomic
  SELECT ... FOR UPDATE read-merge-write.
- Request size caps (413) on progress/config/annotation writes;
  Content-Disposition via mime.FormatMediaType; hidden-history gating in
  the shared ebook progress lister; FK-cascade indexes for reader tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(api): native read-state endpoints for ebooks

- POST/DELETE /watched/{id} accepts ebook content IDs: mark read upserts
  progress 1.0 preserving the reader's file/location (or picks the
  preferred reader file for never-opened books); mark unread mirrors video
  unwatch semantics and deletes the progress row.
- /history/remove accepts ebooks: hides via user_history_hidden_items
  without touching the reading position (hidden != unread; next reading
  activity resurfaces the book, mirroring video re-watch).
- Access-filter checks match the video branch; shared logic lives in
  ebook_read_state.go. Sort metrics/user-state thresholds use the shared
  constant; profile-header fallback deduplicated.

Clients: response is {type: "ebook", affected_count: 1, played: bool};
the existing watched SSE event fires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): harden the ebook reader UI

- Open-flow race: cancellation checked after every await with full stale-run
  teardown (no wrong-file progress saves, no leaked views/blob URLs);
  book.destroy() on cleanup.
- Progress: monotonic stale-response guard; visibilitychange flush uses the
  refresh-capable client, pagehide uses keepalive; per-book cross-format
  progress documented as deliberate.
- Settings: side effects out of the setState updater; local edits no longer
  clobbered by late server config; pending saves flushed on unmount/pagehide.
- TTS: generation token so Stop actually stops (Chromium/Firefox synthetic
  events); Media Session uninstalled on unmount.
- External book links: http(s) only, opened with noopener,noreferrer.
- apiBlob 512 MiB guard with a user-facing error; fraction bookmarks
  navigable; search-result key collisions fixed; dead e-ink code removed;
  getLibrarySortRelevanceScope deduplicated; md format dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): mark read/unread affordances for ebooks

- Item detail gets a Mark Read/Unread button; card menus drop the ebook
  gate and share type-aware labels/toasts (also dedupes audiobook wording).
- Watched-state invalidation includes the reader progress query key so the
  Continue button and percent refresh after toggling.
- Continue Reading dismiss copy for ebooks; dismissal path now URL-encodes
  item IDs (ebook content IDs can contain reserved characters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record the PR #124 review and hardening pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:18:35 -04:00

1897 lines
62 KiB
Go

package scanner
import (
"archive/zip"
"context"
"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"
)
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, 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", ".webp", "thumb", 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 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.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.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 TestCollectEbookRootScansTreatsNonDirectoryRootAsFailed(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() {
t.Fatalf("scans = %+v, want one failed scan for a non-directory root", 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 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 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)
}
}