fix(scanner): route the audio-tag genre through the smart-genre pipeline

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.
This commit is contained in:
John O'Keefe committed 2026-10-27 13:21:39 -04:00
1 parent d42c78f58c
commit 885d614092
2 files changed
+68 -4

No files matched your search

+19 -2
View File
@@ -30,6 +30,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"bookhoard/internal/utils"
) )
// AudioChapter is the uniform chapter entry for every audiobook file model. // 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 // ----:com.apple.iTunes:SUBTITLE → joined into the title
// ("Title: Subtitle"), mirroring the ABS sidecar path // ("Title: Subtitle"), mirroring the ABS sidecar path
// ----:com.apple.iTunes:ASIN / ABRIDGED → asin / abridged flag // ----: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 // Abridged is the one field where an explicit sidecar false must not be
// overridden by a tag — the payload pointer marks sidecar presence. // overridden by a tag — the payload pointer marks sidecar presence.
@@ -472,7 +475,21 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
} }
} }
if metadata.Genre == "" { 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...))
}
}
} }
} }
@@ -5,6 +5,7 @@ package services
// and tag-based track ordering. // and tag-based track ordering.
import ( import (
"slices"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -178,8 +179,14 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
if meta.PublishDate.Year() != 2020 { if meta.PublishDate.Year() != 2020 {
t.Fatalf("publish date = %v", meta.PublishDate) t.Fatalf("publish date = %v", meta.PublishDate)
} }
if meta.Genre != "Audiobook" { // Smart-genre routing (2026-10 Crusader fix): "Audiobook" is a
t.Fatalf("genre = %q", meta.Genre) // 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.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) { func TestParseTagOrdinal(t *testing.T) {