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:
1 parent
d42c78f58c
commit
885d614092
2 files changed
+68
-4
No files matched your search
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user