applyAudioTagFallbacks captured the ffprobe genre atom verbatim into
media_items.genre — no splitting, no shelf resolution, no cap. That raw
capture is what fed 100+-char Audible category blobs into the column
(the 2026-10 Crusader import/update failures).
The ABS sidecar path already has the correct doctrine (ab2a5568): a known
lexicon shelf wins the genre column, everything else is tags-only. Give
the tag fallback the same treatment — splitGenreCandidates +
pickSmartGenre for the column, split candidates appended to tags
(NormalizeTags dedups). Demoted genres like "Audiobook" stop masquerading
as a shelf, and an unknown blob can no longer manufacture an overflowing
value at all.
Fixtures pin the new behavior: the real 111-char Crusader blob stays out
of the column entirely and lands as six separate tags; a shelf-carrying
blob still wins the column; the previously-pinned verbatim "Audiobook"
expectation is updated to the demoted (tags-only) contract.
329 lines
11 KiB
Go
329 lines
11 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 (
|
|
"slices"
|
|
"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)
|
|
}
|
|
// Smart-genre routing (2026-10 Crusader fix): "Audiobook" is a
|
|
// DEMOTED genre (genreDemoted) — it is tags-only and never wins
|
|
// the column, mirroring the sidecar path's pickSmartGenre.
|
|
if meta.Genre != "" {
|
|
t.Fatalf("demoted genre must not win the column, got %q", meta.Genre)
|
|
}
|
|
if !slices.Contains(meta.Tags, "Audiobook") {
|
|
t.Fatalf("demoted genre must stay in tags, got %v", meta.Tags)
|
|
}
|
|
})
|
|
|
|
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")
|
|
}
|
|
})
|
|
|
|
t.Run("audible genre blob splits into tags, column stays empty when no shelf matches", func(t *testing.T) {
|
|
// The 2026-10 Crusader regression: Libation writes the Audible
|
|
// category ladder as one comma-joined atom — 111 chars here — and
|
|
// the fallback used to capture it VERBATIM into media_items.genre,
|
|
// failing the import/update with SQLSTATE 22001 (VARCHAR(100)).
|
|
// The genre column takes a known shelf or stays empty; the split
|
|
// candidates are tags-only, exactly like the sidecar path.
|
|
crusader := "Politicians, Presidents & Heads of State, United States, Russia, Communism & Socialism, International Relations"
|
|
if len(crusader) <= 100 {
|
|
t.Fatalf("fixture must exceed the old VARCHAR(100) limit, len=%d", len(crusader))
|
|
}
|
|
meta := &MediaMetadata{}
|
|
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{"genre": crusader}))
|
|
if len(meta.Genre) > 100 {
|
|
t.Fatalf("genre column value must never exceed 100 chars, got %d: %q", len(meta.Genre), meta.Genre)
|
|
}
|
|
if meta.Genre != "" {
|
|
t.Fatalf("no candidate is a known shelf — column must stay empty, got %q", meta.Genre)
|
|
}
|
|
// NormalizeTags titlecases: "of" → "Of" is the shipped display form.
|
|
for _, want := range []string{"Politicians", "Presidents & Heads Of State", "United States", "Russia", "Communism & Socialism", "International Relations"} {
|
|
if !slices.Contains(meta.Tags, want) {
|
|
t.Fatalf("tags must contain split candidate %q, got %v", want, meta.Tags)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("known shelf wins the column from a genre blob", func(t *testing.T) {
|
|
meta := &MediaMetadata{}
|
|
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
|
|
"genre": "Epic, Action & Adventure, Military, Sword & Sorcery",
|
|
}))
|
|
if meta.Genre == "" {
|
|
t.Fatalf("expected a lexicon shelf from the blob, tags=%v", meta.Tags)
|
|
}
|
|
if !slices.Contains(meta.Tags, "Epic") || !slices.Contains(meta.Tags, "Military") {
|
|
t.Fatalf("split candidates must land in tags, got %v", meta.Tags)
|
|
}
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|