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 floor: alone it resolves, against a real shelf it loses. if genre, _ := pickSmartGenre([]string{"Fiction"}); genre != "fiction" { t.Errorf("plain Fiction should still resolve, got %q", genre) } // Genre model v2: Epic Fantasy is a SUBGENRE — the genre column gets // its parent shelf and the specific label lives in tags. (Intended // change from the old longest-match behavior, which returned // "epic fantasy" here.) if genre, _ := pickSmartGenre([]string{"Fiction", "Epic Fantasy"}); genre != "fantasy" { t.Errorf("subgenre must resolve to its shelf, got %q", genre) } // User-verified traces (Batch B decision): if genre, _ := pickSmartGenre([]string{"Pirates", "Adventure"}); genre != "adventure" { t.Errorf("direct shelf beats mapped subgenre, got %q", genre) } if genre, _ := pickSmartGenre([]string{"Pirates"}); genre != "adventure" { t.Errorf("mapped subgenre alone resolves to its shelf, got %q", genre) } if genre, _ := pickSmartGenre([]string{"Sci-Fi", "Science Fiction", "Fiction"}); genre != "science fiction" { t.Errorf("direct shelf beats floor, got %q", genre) } // Type-words describe the library type, not a shelf. if genre, _ := pickSmartGenre([]string{"Manga"}); genre != "" { t.Errorf("manga is a type-word, got %q", genre) } // Unknown-only input: no shelf is known — tags carry it, never guess. if genre, all := pickSmartGenre([]string{"Completely Made-Up Genre"}); genre != "" { t.Errorf("unknown-only input must leave genre empty, got %q (tags=%v)", genre, all) } } 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") } }