From 885d614092f5b29cbc4fdebe48b1604393e8ce75 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 27 Oct 2026 13:21:39 -0400 Subject: [PATCH] fix(scanner): route the audio-tag genre through the smart-genre pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/services/media_scanner_audio.go | 21 +++++++- .../media_scanner_audio_metadata_test.go | 51 ++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/internal/services/media_scanner_audio.go b/internal/services/media_scanner_audio.go index 52e568b5..344109b5 100644 --- a/internal/services/media_scanner_audio.go +++ b/internal/services/media_scanner_audio.go @@ -30,6 +30,8 @@ import ( "strconv" "strings" "time" + + "bookhoard/internal/utils" ) // AudioChapter is the uniform chapter entry for every audiobook file model. @@ -394,7 +396,8 @@ func marshalAudioMetadata(payload *AudioMetadataPayload) []byte { // ----:com.apple.iTunes:SUBTITLE → joined into the title // ("Title: Subtitle"), mirroring the ABS sidecar path // ----:com.apple.iTunes:ASIN / ABRIDGED → asin / abridged flag -// date → publish date; genre → genre +// date → publish date; genre → smart-genre (shelf-or-empty column, +// split candidates to tags — see pickSmartGenre) // // Abridged is the one field where an explicit sidecar false must not be // overridden by a tag — the payload pointer marks sidecar presence. @@ -472,7 +475,21 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) { } } if metadata.Genre == "" { - metadata.Genre = report.tag("genre") + // Same doctrine as the ABS sidecar path (ab2a5568): the raw file + // atom is a comma-joined category blob ("Politicians, Presidents & + // Heads of State, United States, ..." — Audible ladders, often past + // 100 chars). Shelf-or-empty wins the genre column; every split + // candidate lands in tags. No shelf match → the column stays empty + // and the blob is tags-only, never a VARCHAR-overflowing INSERT. + if genreTag := report.tag("genre"); genreTag != "" { + genre, all := pickSmartGenre([]string{genreTag}) + if genre != "" { + metadata.Genre = genre + } + if len(all) > 0 { + metadata.Tags = utils.NormalizeTags(append(metadata.Tags, all...)) + } + } } } diff --git a/internal/services/media_scanner_audio_metadata_test.go b/internal/services/media_scanner_audio_metadata_test.go index adc41486..87de57ea 100644 --- a/internal/services/media_scanner_audio_metadata_test.go +++ b/internal/services/media_scanner_audio_metadata_test.go @@ -5,6 +5,7 @@ package services // and tag-based track ordering. import ( + "slices" "os" "path/filepath" "strings" @@ -178,8 +179,14 @@ func TestApplyAudioTagFallbacks(t *testing.T) { if meta.PublishDate.Year() != 2020 { t.Fatalf("publish date = %v", meta.PublishDate) } - if meta.Genre != "Audiobook" { - t.Fatalf("genre = %q", meta.Genre) + // 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) } }) @@ -214,6 +221,46 @@ func TestApplyAudioTagFallbacks(t *testing.T) { 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) {