Files
bookhoard/internal/services/media_scanner_audio_metadata_test.go
T
John O'Keefe 7e4c97f639 feat(scanner): fix ABS sidecar series parsing; three-way series mapping; smart genre; language codes; title-derived series
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.
2026-10-19 14:34:42 -04:00

282 lines
8.5 KiB
Go

package services
// Unit tests for the exhaustive audiobook metadata ingestion: CUE
// sheets, m3u playlists, tag fallbacks (incl. Audible freeform atoms)
// and tag-based track ordering.
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestCueTimeToSeconds(t *testing.T) {
cases := map[string]float64{
"00:00:00": 0,
"01:00:00": 60,
"03:25:12": 3*60 + 25 + 12.0/75.0,
"75:00:37": 75*60 + 37.0/75.0, // minutes exceed 59 per spec
"not-a-time": 0,
"1:2": 0,
}
for in, want := range cases {
if got := cueTimeToSeconds(in); (got-want) > 0.001 || (want-got) > 0.001 {
t.Errorf("cueTimeToSeconds(%q) = %v, want %v", in, got, want)
}
}
}
func TestParseCueSheet(t *testing.T) {
dir := t.TempDir()
cuePath := filepath.Join(dir, "book.cue")
cue := `TITLE "Cue Book"
FILE "book.flac" WAVE
TRACK 01 AUDIO
TITLE "Opening"
INDEX 01 00:00:00
TRACK 02 AUDIO
TITLE "The Middle Bit"
INDEX 01 00:01:30
TRACK 03 AUDIO
TITLE "Closing"
INDEX 01 00:03:00
FILE "other.flac" WAVE
TRACK 01 AUDIO
TITLE "Foreign Track"
INDEX 01 00:00:00
`
if err := os.WriteFile(cuePath, []byte(cue), 0o644); err != nil {
t.Fatal(err)
}
chapters := parseCueSheet(cuePath, "book.flac")
if len(chapters) != 3 {
t.Fatalf("chapters = %d (%+v), want 3 (foreign FILE block excluded)", len(chapters), chapters)
}
if chapters[0].Title != "Opening" || chapters[0].Start != 0 {
t.Fatalf("first = %+v", chapters[0])
}
// MM:SS:FF: "00:01:30" = 1 second + 30/75 frames = 1.4s
if chapters[1].Start != 1.4 || chapters[1].End != 3.0 {
t.Fatalf("second = %+v", chapters[1])
}
if chapters[2].End != 0 {
t.Fatalf("last end must stay 0 (unknown): %+v", chapters[2])
}
}
func TestReadCueChaptersMatched(t *testing.T) {
dir := t.TempDir()
cue := "FILE \"audio.mp3\" WAVE\n TRACK 01 AUDIO\n TITLE \"One\"\n INDEX 01 00:00:00\n TRACK 02 AUDIO\n TITLE \"Two\"\n INDEX 01 00:00:10\n"
if err := os.WriteFile(filepath.Join(dir, "audio.cue"), []byte(cue), 0o644); err != nil {
t.Fatal(err)
}
chapters := readCueChapters(filepath.Join(dir, "audio.mp3"))
if len(chapters) != 2 {
t.Fatalf("chapters = %d", len(chapters))
}
// No cue at all → nil, never an error.
if chapters := readCueChapters(filepath.Join(dir, "other.mp3")); chapters != nil {
t.Fatalf("expected nil for missing cue, got %+v", chapters)
}
}
func TestFindAndApplyPlaylistHint(t *testing.T) {
dir := t.TempDir()
playlist := "#EXTM3U\n#EXTINF:120,Chapter Zero\n02_Second.mp3\n#EXTINF:60,Chapter One\n./01_First.mp3\nmissing.mp3\n"
if err := os.WriteFile(filepath.Join(dir, "book.m3u"), []byte(playlist), 0o644); err != nil {
t.Fatal(err)
}
// Exactly one playlist → hint; two → none.
hint := findPlaylistHint(dir)
if hint == nil {
t.Fatal("expected hint")
}
if err := os.WriteFile(filepath.Join(dir, "other.m3u8"), []byte("#EXTM3U\n"), 0o644); err != nil {
t.Fatal(err)
}
if findPlaylistHint(dir) != nil {
t.Fatal("multiple playlists must produce no hint")
}
abs := []string{
filepath.Join(dir, "01_First.mp3"),
filepath.Join(dir, "02_Second.mp3"),
filepath.Join(dir, "10_Later.mp3"), // not in playlist → appended last
}
ordered := hint.reorder(abs)
if len(ordered) != 3 || ordered[0] != abs[1] || ordered[1] != abs[0] || ordered[2] != abs[2] {
t.Fatalf("playlist order not applied: %v", ordered)
}
if got := hint.titleFor(abs[0]); got != "Chapter One" {
t.Fatalf("titleFor(./01_First.mp3) = %q", got)
}
if got := hint.titleFor(abs[1]); got != "Chapter Zero" {
t.Fatalf("titleFor(02_Second.mp3) = %q", got)
}
if got := hint.titleFor(abs[2]); got != "" {
t.Fatalf("unlisted track must have no title, got %q", got)
}
if h := (*playlistHint)(nil); h.titleFor(abs[0]) != "" {
t.Fatal("nil hint must be safe")
}
}
func syntheticReport(tags map[string]string) *ffprobeReport {
r := &ffprobeReport{}
r.Format.Tags = tags
return r
}
func TestApplyAudioTagFallbacks(t *testing.T) {
t.Run("full fill", func(t *testing.T) {
meta := &MediaMetadata{}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
"album": "The Tagged Album",
"album_artist": "Tag Author",
"artist": "Wrong Author",
"composer": "Tag Narrator",
"grouping": "Tag Series",
"genre": "Audiobook",
"date": "2020",
"----:com.apple.iTunes:SUBTITLE": "Or, A Life",
"----:com.apple.iTunes:SERIES": "Freeform Series",
"----:com.apple.iTunes:SERIES-PART": "4",
"----:com.apple.iTunes:ASIN": "B0ASIN1234",
"----:com.apple.iTunes:ABRIDGED": "1",
}))
// A2b: subtitle is a first-class column — no title joining.
if meta.Title != "The Tagged Album" {
t.Fatalf("title = %q", meta.Title)
}
if meta.Subtitle != "Or, A Life" {
t.Fatalf("subtitle = %q", meta.Subtitle)
}
if meta.Author != "Tag Author" {
t.Fatalf("author = %q", meta.Author)
}
if len(meta.Narrators) != 1 || meta.Narrators[0] != "Tag Narrator" {
t.Fatalf("narrators = %v", meta.Narrators)
}
// grouping outranks freeform SERIES for the series name only
// because it is checked first; both are tag-level fallbacks.
if meta.Series == "" {
t.Fatal("series not filled from tags")
}
if meta.SeriesNumber != 4 {
t.Fatalf("series number = %v", meta.SeriesNumber)
}
if meta.ASIN != "B0ASIN1234" {
t.Fatalf("asin = %q", meta.ASIN)
}
if !meta.Abridged {
t.Fatal("abridged not filled")
}
if meta.PublishDate.Year() != 2020 {
t.Fatalf("publish date = %v", meta.PublishDate)
}
if meta.Genre != "Audiobook" {
t.Fatalf("genre = %q", meta.Genre)
}
})
t.Run("fills only empty fields", func(t *testing.T) {
meta := &MediaMetadata{Title: "Sidecar Title", Author: "Sidecar Author", Narrators: []string{"Sidecar Narrator"}}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
"album": "Tag Album", "artist": "Tag Artist", "composer": "Tag Narrator",
}))
if meta.Title != "Sidecar Title" || meta.Author != "Sidecar Author" {
t.Fatalf("tags must not override earlier sources: %+v", meta)
}
if len(meta.Narrators) != 1 || meta.Narrators[0] != "Sidecar Narrator" {
t.Fatalf("narrators = %v", meta.Narrators)
}
})
t.Run("explicit sidecar false survives abridged tag", func(t *testing.T) {
abridgedFalse := false
meta := &MediaMetadata{AudioMeta: &AudioMetadataPayload{Abridged: &abridgedFalse}}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
"----:com.apple.iTunes:ABRIDGED": "1",
}))
if meta.Abridged {
t.Fatal("explicit sidecar false must not be overridden")
}
})
t.Run("nil report is a no-op", func(t *testing.T) {
meta := &MediaMetadata{}
applyAudioTagFallbacks(meta, nil)
if meta.Title != "" {
t.Fatal("nil report must change nothing")
}
})
}
func TestParseTagOrdinal(t *testing.T) {
cases := map[string]int{
"2": 2,
"3/9": 3,
" 12 ": 12,
"": 0,
"x": 0,
"0": 0,
"2/": 2,
"-1": 0,
}
for in, want := range cases {
if got := parseTagOrdinal(in); got != want {
t.Errorf("parseTagOrdinal(%q) = %d, want %d", in, got, want)
}
}
}
func TestOrderTracksByTags(t *testing.T) {
mk := func(disc, track int, rel string) audioTrackInfo {
return audioTrackInfo{disc: disc, trackNum: track, relPath: rel}
}
t.Run("tag order wins when complete", func(t *testing.T) {
probed := []audioTrackInfo{
mk(2, 1, "b/01.mp3"), mk(1, 2, "a/02.mp3"), mk(1, 10, "a/10.mp3"),
}
orderTracksByTags(probed)
want := []string{"a/02.mp3", "a/10.mp3", "b/01.mp3"}
for i, tr := range probed {
if tr.relPath != want[i] {
t.Fatalf("order = %v", probed)
}
}
})
t.Run("incomplete coverage keeps natural order", func(t *testing.T) {
probed := []audioTrackInfo{mk(1, 1, "a/01.mp3"), mk(0, 0, "a/02.mp3")}
orderTracksByTags(probed)
if probed[0].relPath != "a/01.mp3" || probed[1].relPath != "a/02.mp3" {
t.Fatalf("order = %v", probed)
}
})
}
func TestTagLookupCaseInsensitive(t *testing.T) {
r := syntheticReport(map[string]string{
"ALBUM": "Cased Album",
"----:com.apple.iTunes:ASIN": "B0CASE",
})
if got := r.tag("album"); got != "Cased Album" {
t.Fatalf("tag(album) = %q", got)
}
if got := r.tag("album", "title"); got != "Cased Album" {
t.Fatalf("multi-key lookup = %q", got)
}
if got := r.tag("----:COM.APPLE.ITUNES:asin"); !strings.EqualFold(got, "B0CASE") {
t.Fatalf("freeform case-insensitive lookup = %q", got)
}
if got := r.tag("missing"); got != "" {
t.Fatalf("missing key = %q", got)
}
}