Phase 2a of the metadata overhaul (plan §2.1-2.3, §2.5-2.9). The headline bug: ABS writes sidecar series as an array of STRINGS (["The Witcher #3", ...]); the parser only accepted {series, sequence} OBJECT arrays, so every ABS-matched book silently lost its series (found live: 9 sidecars, zero series ingested; only folder-structure Malazan survived). parseSeriesCandidates now normalizes string arrays, object arrays and Libation plain strings. Three-way series assignment (plan §2.2): - primary bibliographic series -> series/series_number columns - additional real series (Drizzt: Icewind Dale #1 = Legend of Drizzt #4) -> alternate_info JSONB, comic-established shape - ordering constructs ('The Witcher (Reading Order)') -> PlaylistSeries candidates; NEVER the series column. Detection is explicit-marker-only ((reading order|chronological|publication order|in order|timeline) as parenthetical/suffix) — precision over recall; a series merely containing 'timeline' mid-name stays a series. Sequence policy: leading-number extraction ('Book 2' -> 2, '2, Dramatized Adaptation' -> 2, '1a' -> 1); lossy extractions preserve the raw text in alternate_info.series_sequence_raw. Smart genre (deterministic lexicon): compound Audible blobs split ('Epic, Action & Adventure, Military, Sword & Sorcery' -> 4 candidates), alias-normalized, format noise demoted ('Audiobook' never wins), most-specific canonical genre takes the genre column, all candidates stay in tags. Language: display names normalize to ISO 639-1 on ingest; ROOT-CAUSED the update-path drop — CreateMediaItem call site never set Language (explicit NULL over the column default), the update path did; create now carries it. Title-derived series fallback (§2.7): 'Baptism of Fire: The Witcher, Book 3' 2-level folders split conservatively (only when the post-colon tail matches ', Book N'), subtitle parsed for series+number, blanks-only precedence, matching-name number fill, zero display mutation. Works with no sidecar and no network. Ingest fixes: subtitle/explicit land in their columns (audio tag subtitle stops joining into title); full authors[] array (was [0]- only); sidecar descriptions sanitized via bluemonday UGC policy (vendored). metadata_overrides: verified scan-update path already applies ApplyMetadataOverrides; subtitle/explicit/authors covered by the Phase 1 override adaptation. Tests: new series_parsing_test.go (string-array sidecar e2e, Drizzt alternate_info, lossy raw preservation, playlist classification, title-derived fallback incl. no-mutation + blanks-only, genre lexicon, language map); audio-tag subtitle pin updated to column behavior; calibre/ABS 2.5-series_index pins widened per float change (changelog'd in phase 1). Deferred to 2b: auto-created playlist collections (§2.4) — settings toggle, collection upsert, ownership.
301 lines
11 KiB
Go
301 lines
11 KiB
Go
package services
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
func TestSplitSeriesString(t *testing.T) {
|
|
cases := []struct{ in, name, seq string }{
|
|
{"The Witcher #3", "The Witcher", "3"},
|
|
{"Name #1a", "Name", "1a"},
|
|
{"No Sequence", "No Sequence", ""},
|
|
{"Name #1#a", "Name #1#a", ""}, // embedded # is not a sequence
|
|
}
|
|
for _, c := range cases {
|
|
name, seq := splitSeriesString(c.in)
|
|
if name != c.name || seq != c.seq {
|
|
t.Errorf("splitSeriesString(%q) = (%q, %q), want (%q, %q)", c.in, name, seq, c.name, c.seq)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSeriesSequenceNumber(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
num float64
|
|
found bool
|
|
lossy bool
|
|
}{
|
|
{"3", 3, true, false},
|
|
{"1.5", 1.5, true, false},
|
|
{"1a", 1, true, true},
|
|
{"Book 2", 2, true, true},
|
|
{"2, Dramatized Adaptation", 2, true, true},
|
|
{"", 0, false, false},
|
|
{"novella", 0, false, false},
|
|
}
|
|
for _, c := range cases {
|
|
num, found, lossy := seriesSequenceNumber(c.in)
|
|
if num != c.num || found != c.found || lossy != c.lossy {
|
|
t.Errorf("seriesSequenceNumber(%q) = (%v,%v,%v), want (%v,%v,%v)",
|
|
c.in, num, found, lossy, c.num, c.found, c.lossy)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The A2c headline bug: ABS abmetadata writes series as an array of
|
|
// "Name #seq" STRINGS. The old parser only accepted object arrays and
|
|
// silently dropped every ABS-matched series.
|
|
func TestParseSeriesCandidatesStringArray(t *testing.T) {
|
|
raw := []byte(`["The Witcher #3", "The Witcher (Reading Order) #5"]`)
|
|
candidates := parseSeriesCandidates(raw)
|
|
if len(candidates) != 2 {
|
|
t.Fatalf("got %d candidates, want 2", len(candidates))
|
|
}
|
|
if candidates[0].Name != "The Witcher" || candidates[0].Number != 3 || !candidates[0].HasNum {
|
|
t.Errorf("candidate[0] = %+v", candidates[0])
|
|
}
|
|
if candidates[1].Name != "The Witcher (Reading Order)" || candidates[1].Number != 5 {
|
|
t.Errorf("candidate[1] = %+v", candidates[1])
|
|
}
|
|
if !isPlaylistSeriesName(candidates[1].Name) {
|
|
t.Errorf("reading-order entry must classify as playlist-ish")
|
|
}
|
|
if isPlaylistSeriesName(candidates[0].Name) {
|
|
t.Errorf("publication series must not classify as playlist-ish")
|
|
}
|
|
}
|
|
|
|
func TestParseSeriesCandidatesOtherShapes(t *testing.T) {
|
|
objs := parseSeriesCandidates([]byte(`[{"series":"Great Series","sequence":"2.5"}]`))
|
|
if len(objs) != 1 || objs[0].Name != "Great Series" || objs[0].Number != 2.5 {
|
|
t.Errorf("object form: %+v", objs)
|
|
}
|
|
libation := parseSeriesCandidates([]byte(`"Libation Series #4"`))
|
|
if len(libation) != 1 || libation[0].Name != "Libation Series" || libation[0].Number != 4 {
|
|
t.Errorf("plain string form: %+v", libation)
|
|
}
|
|
if parseSeriesCandidates([]byte(`null`)) != nil {
|
|
t.Errorf("null must yield no candidates")
|
|
}
|
|
}
|
|
|
|
func TestIsPlaylistSeriesName(t *testing.T) {
|
|
positives := []string{
|
|
"The Witcher (Reading Order)",
|
|
"Discworld Reading Order",
|
|
"Narnia Chronological Order",
|
|
"Robin Hobb in order",
|
|
"Series Publication Order",
|
|
"Middle Earth Timeline",
|
|
}
|
|
for _, p := range positives {
|
|
if !isPlaylistSeriesName(p) {
|
|
t.Errorf("%q should be playlist-ish", p)
|
|
}
|
|
}
|
|
negatives := []string{"The Witcher", "Witcher Saga", "The Timeline of..."}
|
|
for _, n := range negatives {
|
|
if isPlaylistSeriesName(n) {
|
|
t.Errorf("%q must not be playlist-ish", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeriveSeriesFromText(t *testing.T) {
|
|
name, num, hasNum, ok := deriveSeriesFromText("The Witcher, Book 3")
|
|
if !ok || name != "The Witcher" || num != 3 || !hasNum {
|
|
t.Errorf("got (%q,%v,%v,%v)", name, num, hasNum, ok)
|
|
}
|
|
name, num, hasNum, ok = deriveSeriesFromText("The Witcher, Book 1.5")
|
|
if !ok || name != "The Witcher" || num != 1.5 || !hasNum {
|
|
t.Errorf("decimal: got (%q,%v,%v,%v)", name, num, hasNum, ok)
|
|
}
|
|
// Not obviously a series tail → must refuse rather than guess.
|
|
for _, negative := range []string{"1984", "The Fellowship", "Book Ends"} {
|
|
if _, _, _, ok := deriveSeriesFromText(negative); ok {
|
|
t.Errorf("%q must not derive a series", negative)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSplitTitleOnSubtitle(t *testing.T) {
|
|
title, subtitle, split := splitTitleOnSubtitle("Baptism of Fire: The Witcher, Book 3")
|
|
if !split || title != "Baptism of Fire" || subtitle != "The Witcher, Book 3" {
|
|
t.Errorf("got (%q,%q,%v)", title, subtitle, split)
|
|
}
|
|
// Arbitrary colons never split.
|
|
_, _, split = splitTitleOnSubtitle("Star Wars: A New Hope")
|
|
if split {
|
|
t.Errorf("non-series colon must not split")
|
|
}
|
|
}
|
|
|
|
func TestApplyTitleDerivedSeries(t *testing.T) {
|
|
// The user's actual naming, two-level folder, no sidecar anywhere.
|
|
meta := &MediaMetadata{Title: "Baptism of Fire: The Witcher, Book 3"}
|
|
applyTitleDerivedSeries(meta)
|
|
if meta.Series != "The Witcher" || meta.SeriesNumber != 3 {
|
|
t.Errorf("series = %q #%v, want The Witcher #3", meta.Series, meta.SeriesNumber)
|
|
}
|
|
if meta.Title != "Baptism of Fire: The Witcher, Book 3" {
|
|
t.Errorf("fallback must never mutate the display title, got %q", meta.Title)
|
|
}
|
|
|
|
// Subtitle already split by the folder parser: number + name both land.
|
|
meta = &MediaMetadata{Title: "Blood of Elves", Subtitle: "The Witcher, Book 1"}
|
|
applyTitleDerivedSeries(meta)
|
|
if meta.Series != "The Witcher" || meta.SeriesNumber != 1 {
|
|
t.Errorf("series = %q #%v, want The Witcher #1", meta.Series, meta.SeriesNumber)
|
|
}
|
|
|
|
// Existing series data is never overwritten (blanks-only fallback)...
|
|
meta = &MediaMetadata{Title: "X", Subtitle: "Derived, Book 9", Series: "Real Series"}
|
|
applyTitleDerivedSeries(meta)
|
|
if meta.Series != "Real Series" || meta.SeriesNumber != 0 {
|
|
t.Errorf("a number from a different series name must not apply, got %q #%v", meta.Series, meta.SeriesNumber)
|
|
}
|
|
// ...but a number whose derived name MATCHES the existing series fills it.
|
|
meta = &MediaMetadata{Title: "X", Subtitle: "Real Series, Book 9", Series: "Real Series"}
|
|
applyTitleDerivedSeries(meta)
|
|
if meta.Series != "Real Series" || meta.SeriesNumber != 9 {
|
|
t.Errorf("matching-name number must fill, got %q #%v", meta.Series, meta.SeriesNumber)
|
|
}
|
|
}
|
|
|
|
func TestPickSmartGenre(t *testing.T) {
|
|
// The real Malazan Audible blob: one string, four genres.
|
|
genre, all := pickSmartGenre([]string{"Epic, Action & Adventure, Military, Sword & Sorcery"})
|
|
if genre != "action & adventure" {
|
|
t.Errorf("genre = %q, want action & adventure", genre)
|
|
}
|
|
if len(all) != 4 {
|
|
t.Errorf("candidates = %v, want the 4 split parts", all)
|
|
}
|
|
// Format noise never wins.
|
|
if genre, _ := pickSmartGenre([]string{"Audiobook"}); genre != "" {
|
|
t.Errorf("audiobook is not a genre, got %q", genre)
|
|
}
|
|
// Fiction is the last resort, not the winner.
|
|
if genre, _ := pickSmartGenre([]string{"Fiction"}); genre != "fiction" {
|
|
t.Errorf("plain Fiction should still resolve, got %q", genre)
|
|
}
|
|
if genre, _ := pickSmartGenre([]string{"Fiction", "Epic Fantasy"}); genre != "epic fantasy" {
|
|
t.Errorf("specificity must beat generic, got %q", genre)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLanguageCode(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"English", "en"},
|
|
{"en", "en"},
|
|
{"German", "de"},
|
|
{"", ""},
|
|
{"Norwegian (Bokmål)", ""}, // too long for VARCHAR(10); dropped, never truncated
|
|
{"Pig Latin", "Pig Latin"}, // unknown display name that fits: kept
|
|
}
|
|
for _, c := range cases {
|
|
if got := normalizeLanguageCode(c.in); got != c.want {
|
|
t.Errorf("normalizeLanguageCode(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// End-to-end: a REAL ABS-shaped sidecar (string-array series, the shape
|
|
// every ABS-matched book writes) must land series, language, explicit and
|
|
// the full author array.
|
|
func TestExtractAudiobookshelfSidecarStringArraySeries(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sidecar := `{
|
|
"title": "Blood of Elves",
|
|
"authors": ["Andrzej Sapkowski"],
|
|
"narrators": ["Peter Kenny"],
|
|
"series": ["The Witcher #1", "The Witcher (Reading Order) #3"],
|
|
"genres": ["Fiction"],
|
|
"language": "English",
|
|
"explicit": true,
|
|
"publisher": "Orbit"
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(sidecar), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta := extractAudiobookshelfSidecar(filepath.Join(dir, "book.m4b"))
|
|
if meta == nil {
|
|
t.Fatal("sidecar not extracted")
|
|
}
|
|
if meta.Series != "The Witcher" || meta.SeriesNumber != 1 {
|
|
t.Errorf("series = %q #%v", meta.Series, meta.SeriesNumber)
|
|
}
|
|
if len(meta.Authors) != 1 || meta.Authors[0] != "Andrzej Sapkowski" {
|
|
t.Errorf("authors array = %v", meta.Authors)
|
|
}
|
|
if meta.Language != "en" {
|
|
t.Errorf("language = %q, want en", meta.Language)
|
|
}
|
|
if !meta.Explicit {
|
|
t.Errorf("explicit flag lost")
|
|
}
|
|
if len(meta.PlaylistSeries) != 1 || meta.PlaylistSeries[0].Name != "The Witcher (Reading Order)" {
|
|
t.Errorf("playlist series = %+v", meta.PlaylistSeries)
|
|
}
|
|
if meta.AlternateInfo != "" {
|
|
t.Errorf("playlist entry must not leak into alternate_info, got %q", meta.AlternateInfo)
|
|
}
|
|
}
|
|
|
|
// Additional real series (the Drizzt case: Icewind Dale #1 = Legend of
|
|
// Drizzt #4) land in alternate_info with the comic-established shape.
|
|
func TestExtractAudiobookshelfSidecarExtraSeriesToAlternateInfo(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sidecar := `{
|
|
"title": "The Crystal Shard",
|
|
"authors": ["R.A. Salvatore"],
|
|
"series": [
|
|
{"series": "The Icewind Dale Trilogy", "sequence": "1"},
|
|
{"series": "The Legend of Drizzt", "sequence": "4"}
|
|
]
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(sidecar), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta := extractAudiobookshelfSidecar(filepath.Join(dir, "book.m4b"))
|
|
if meta.Series != "The Icewind Dale Trilogy" || meta.SeriesNumber != 1 {
|
|
t.Errorf("series = %q #%v", meta.Series, meta.SeriesNumber)
|
|
}
|
|
if meta.AlternateInfo == "" {
|
|
t.Fatal("second series must land in alternate_info")
|
|
}
|
|
// Lossy sequence text is preserved, never silently dropped.
|
|
meta2 := extractAudiobookshelfSidecarPath(t, dir, `{"title":"X","series":["Saga #1a"]}`)
|
|
if meta2 == nil || meta2.Series != "Saga" || meta2.SeriesNumber != 1 {
|
|
t.Fatalf("lossy series = %+v", meta2)
|
|
}
|
|
if meta2.AlternateInfo == "" || !strings.Contains(meta2.AlternateInfo, "series_sequence_raw") || !strings.Contains(meta2.AlternateInfo, "1a") {
|
|
t.Errorf("raw sequence not preserved: %q", meta2.AlternateInfo)
|
|
}
|
|
}
|
|
|
|
func extractAudiobookshelfSidecarPath(t *testing.T, dir, json string) *MediaMetadata {
|
|
t.Helper()
|
|
// extractAudiobookshelfSidecar looks for exactly "metadata.json"; use a
|
|
// fresh dir per call instead.
|
|
fresh := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(fresh, "metadata.json"), []byte(json), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return extractAudiobookshelfSidecar(filepath.Join(fresh, "book.m4b"))
|
|
}
|
|
|
|
// The series page tiebreaker rides on the same ordering contract the
|
|
// scanner feeds: numbers sort numerically, NULLs last (§3.6).
|
|
func TestSeriesNumberFloatOrderingValues(t *testing.T) {
|
|
if !(pgtype.Float8{Float64: 1}.Float64 < pgtype.Float8{Float64: 1.5}.Float64) {
|
|
t.Fatal("1 must sort before 1.5")
|
|
}
|
|
}
|