feat(scanner): Calibre-aligned OPF metadata extraction
Release / build-and-push (push) Successful in 3m5s

Adopt Calibre's reading conventions for the Dublin Core metadata that
parseOPFContent now pulls from the structured OPF parse:

- Titles: EPUB3 title-type selection (prefer 'main', join a distinct
  subtitle with ': ' exactly as Calibre stores it). There is no separate
  subtitle column by design - Calibre-sidecar books arrive pre-joined,
  so a column would stay empty for most libraries and force every client
  to reimplement concatenation.
- Genre: first dc:subject, mirroring the existing processGenresAndTags
  behavior of the Calibre-sidecar path; the embedded path never
  populated Genre before. Subjects stay one-element-one-tag - Library
  of Congress headings legitimately contain commas ("Holmes, Sherlock
  (Fictitious character) -- Fiction") and must not be split.
- Identifiers: urn:isbn:/urn:asin: prefixed values parse in addition to
  opf:scheme attributes, and the scheme-less fallback now requires an
  ISBN-shaped value (10/13 digits, optional separators/trailing X) so
  URIs like the Gutenberg identifiers cannot masquerade as ISBNs -
  observed live on 'A Study in Scarlet'.
- Series: EPUB3 belongs-to-collection with collection-type=series and
  group-position refines, ahead of the classic calibre:series metas.
- Audiobookshelf metadata.json sidecars join their subtitle field into
  the title the same way.

Tests cover title-type main+subtitle joining, belongs-to-collection
series with fractional group-position, urn:isbn extraction, genre/tag
parity, and comma preservation inside subject headings.
This commit is contained in:
John O'Keefe
2026-09-12 23:45:18 -04:00
parent 72d167005f
commit 1fa8ee3a59
3 changed files with 255 additions and 65 deletions
@@ -145,6 +145,65 @@ func TestFindCoverInOPFImageFirstSpine(t *testing.T) {
}
}
// TestParseOPFContentTitleTypeAndSeries covers EPUB3 refines-based title
// selection (main + subtitle joined Calibre-style) and belongs-to-collection
// series with collection-type and group-position refines.
func TestParseOPFContentTitleTypeAndSeries(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title id="t1">The Main Title</dc:title>
<dc:title id="t2">The Subtitle</dc:title>
<meta refines="#t1" property="title-type">main</meta>
<meta refines="#t2" property="title-type">subtitle</meta>
<dc:subject>Programming</dc:subject>
<dc:subject>Algorithms</dc:subject>
<dc:identifier>urn:isbn:978-3-16-148410-0</dc:identifier>
<meta id="coll1" property="belongs-to-collection">Great Series</meta>
<meta refines="#coll1" property="collection-type">series</meta>
<meta refines="#coll1" property="group-position">4.5</meta>
</metadata>
</package>`
metadata, err := parseOPFContent([]byte(opf))
if err != nil {
t.Fatalf("parseOPFContent() error: %v", err)
}
if want := "The Main Title: The Subtitle"; metadata.Title != want {
t.Errorf("Title = %q, want %q", metadata.Title, want)
}
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4 {
t.Errorf("Series = %q/%d, want Great Series/4", metadata.Series, metadata.SeriesNumber)
}
if metadata.ISBN == "" {
t.Error("urn:isbn: identifier not extracted")
}
if metadata.Genre != "Programming" {
t.Errorf("Genre = %q, want first subject %q", metadata.Genre, "Programming")
}
if len(metadata.Tags) != 2 {
t.Errorf("Tags = %v, want both subjects", metadata.Tags)
}
}
// TestParseOPFContentSubjectsWithCommas verifies subject headings keep their
// embedded commas as single tags (Library of Congress style headings).
func TestParseOPFContentSubjectsWithCommas(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>A Study in Scarlet</dc:title>
<dc:subject>Holmes, Sherlock (Fictitious character) -- Fiction</dc:subject>
</metadata>
</package>`
metadata, err := parseOPFContent([]byte(opf))
if err != nil {
t.Fatalf("parseOPFContent() error: %v", err)
}
if len(metadata.Tags) != 1 {
t.Errorf("Tags = %v, want exactly 1 unsplit subject heading", metadata.Tags)
}
}
// TestResolveOPFPath checks URL decoding and posix normalization of
// OPF-relative hrefs.
func TestResolveOPFPath(t *testing.T) {