From 62257527d7f24fdf90b717f12827e708f6223033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 20 Jul 2026 21:49:14 -0400 Subject: [PATCH] fix(subsonic): avoid double brackets when appending subtitle or version (#5832) When AppendSubtitle or AppendAlbumVersion is enabled, the subtitle/version tag was always wrapped in parentheses and appended to the title/album name. If the tag value already came wrapped in brackets (e.g. "(non-explicit version)"), the result was doubled: "Title ((non-explicit version))". Append the tag as-is when it is already wrapped in a matching bracket pair - (), [], {} or <> - and trim surrounding whitespace first. The shared appendSuffix helper is used by MediaFile.FullTitle, MediaFile.FullAlbumName and Album.FullName so all consumers behave consistently. --- model/album.go | 3 +-- model/album_test.go | 2 ++ model/mediafile.go | 14 ++++++++++++-- model/mediafile_test.go | 9 +++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/model/album.go b/model/album.go index 0afeb2760..55df8c63c 100644 --- a/model/album.go +++ b/model/album.go @@ -1,7 +1,6 @@ package model import ( - "fmt" "iter" "math" "sync" @@ -77,7 +76,7 @@ func (a Album) CoverArtID() ArtworkID { func (a Album) FullName() string { if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 { - return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0]) + return appendSuffix(a.Name, a.Tags[TagAlbumVersion][0]) } return a.Name } diff --git a/model/album_test.go b/model/album_test.go index 0f4c912cd..ad2ca1cb6 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -24,6 +24,8 @@ var _ = Describe("Album", func() { Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"), Entry("returns just name when tag is absent", true, Tags{}, "Album"), Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), + Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Remastered)"}}, "Album (Remastered)"), + Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Remastered]"}}, "Album [Remastered]"), ) }) diff --git a/model/mediafile.go b/model/mediafile.go index 910cd998d..22ab7fbbe 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -100,18 +100,28 @@ type MediaFile struct { func (mf MediaFile) FullTitle() string { if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 { - return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0]) + return appendSuffix(mf.Title, mf.Tags[TagSubtitle][0]) } return mf.Title } func (mf MediaFile) FullAlbumName() string { if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 { - return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0]) + return appendSuffix(mf.Album, mf.Tags[TagAlbumVersion][0]) } return mf.Album } +var bracketPairs = map[byte]byte{'(': ')', '[': ']', '{': '}', '<': '>'} + +func appendSuffix(base, suffix string) string { + suffix = strings.TrimSpace(suffix) + if len(suffix) >= 2 && bracketPairs[suffix[0]] == suffix[len(suffix)-1] { + return base + " " + suffix + } + return base + " (" + suffix + ")" +} + func (mf MediaFile) ContentType() string { return mime.TypeByExtension("." + mf.Suffix) } diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 6085ccf25..3f306f1a7 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -533,6 +533,13 @@ var _ = Describe("MediaFile", func() { Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"), Entry("returns just title when tag is absent", true, Tags{}, "Song"), Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"), + Entry("does not double parentheses when subtitle is already parenthesized", true, Tags{TagSubtitle: []string{"(non-explicit version)"}}, "Song (non-explicit version)"), + Entry("does not add parentheses when subtitle is wrapped in square brackets", true, Tags{TagSubtitle: []string{"[Live]"}}, "Song [Live]"), + Entry("does not add parentheses when subtitle is wrapped in curly braces", true, Tags{TagSubtitle: []string{"{Remix}"}}, "Song {Remix}"), + Entry("does not add parentheses when subtitle is wrapped in angle brackets", true, Tags{TagSubtitle: []string{""}}, "Song "), + Entry("adds parentheses when brackets do not match", true, Tags{TagSubtitle: []string{"[Live)"}}, "Song ([Live))"), + Entry("trims surrounding whitespace before wrapping", true, Tags{TagSubtitle: []string{" Live "}}, "Song (Live)"), + Entry("trims whitespace around an already-bracketed subtitle", true, Tags{TagSubtitle: []string{" (Live) "}}, "Song (Live)"), ) DescribeTable("FullAlbumName", func(enabled bool, tags Tags, expected string) { @@ -544,6 +551,8 @@ var _ = Describe("MediaFile", func() { Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"), Entry("returns just album name when tag is absent", true, Tags{}, "Album"), Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), + Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Deluxe Edition)"}}, "Album (Deluxe Edition)"), + Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Deluxe Edition]"}}, "Album [Deluxe Edition]"), ) Describe("CoverArtId", func() { It("returns its own id if it HasCoverArt", func() {