From ab2a5568ae9940ac1e637cff0d130135ef088d83 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 25 Oct 2026 12:05:14 -0400 Subject: [PATCH] fix(scanner): split comma-joined ABS sidecar genres into separate tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audiobookshelf-style exports join genres into one comma-string (["Epic, Action & Adventure, Military, Sword & Sorcery"]). The smart-genre picker already split candidates for the genre column, but the tags assembly used the raw unsplit array — so the whole string landed as ONE element of media_items.tags and rendered as a single tag everywhere. Use pickSmartGenre's split candidates for tags too, exactly as the function's own comment documents ('split parts included stay in tags'). Commas inside a single subject heading remain legal within one tag (EPUB dc:subject path unchanged). --- internal/services/media_scanner.go | 12 ++++++--- .../services/media_scanner_metadata_test.go | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index fc7f0991..d68eda82 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -1884,11 +1884,17 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata { } // Smart genre (A2c §2.5): most-specific canonical genre wins the genre - // column; every candidate (split parts included) stays in tags. - if genre, _ := pickSmartGenre(sidecar.Genres); genre != "" { + // column; every candidate (split parts included) stays in tags. The + // split is the point: ABS-style exports join genres into one + // comma-string ("Epic, Action & Adventure, Military") which must land + // as separate tags, while commas inside a single subject heading stay + // legal within one tag. + genre, all := pickSmartGenre(sidecar.Genres) + if genre != "" { metadata.Genre = genre } - if tags := append(append([]string{}, sidecar.Genres...), sidecar.Tags...); len(tags) > 0 { + tags := append(append([]string{}, all...), sidecar.Tags...) + if len(tags) > 0 { metadata.Tags = utils.NormalizeTags(tags) } if sidecar.Language != nil { diff --git a/internal/services/media_scanner_metadata_test.go b/internal/services/media_scanner_metadata_test.go index aea9c8db..baeffc8a 100644 --- a/internal/services/media_scanner_metadata_test.go +++ b/internal/services/media_scanner_metadata_test.go @@ -8,6 +8,7 @@ import ( "image/jpeg" "os" "path/filepath" + "strings" "testing" "time" ) @@ -196,6 +197,30 @@ func TestExtractAudiobookshelfSidecar(t *testing.T) { } }, }, + { + name: "joined genre string splits into separate tags (ABS export)", + json: `{"title": "Joined Genres", "genres": ["Epic, Action & Adventure, Military, Sword & Sorcery"]}`, + validate: func(t *testing.T, m *MediaMetadata) { + want := []string{"Epic", "Action & Adventure", "Military", "Sword & Sorcery"} + if len(m.Tags) != len(want) { + t.Fatalf("Tags = %v, want %v", m.Tags, want) + } + for i, w := range want { + if m.Tags[i] != w { + t.Errorf("Tags[%d] = %q, want %q", i, m.Tags[i], w) + } + } + inGenre := false + for _, w := range want { + if strings.EqualFold(m.Genre, w) { + inGenre = true + } + } + if !inGenre || m.Genre == "" { + t.Errorf("Genre = %q, want a case-insensitive match of %v", m.Genre, want) + } + }, + }, { name: "sparse sidecar (real-world Audiobookshelf export)", json: `{"title": "Sparse (1234)", "authors": ["X"], "tags": [], "description": null}`,