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.
282 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|