From 5bc2bbb70e6236d7361b1e0e68d657a74847fd1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 26 Feb 2026 10:50:12 -0500 Subject: [PATCH] feat(subsonic): append album version to names in Subsonic API (#5111) * feat(subsonic): append album version to album names in Subsonic API responses Add AppendAlbumVersion config option (default: true) that appends the album version tag to album names in Subsonic API responses, similar to how AppendSubtitle works for track titles. This affects album names in childFromAlbum and buildAlbumID3 responses. Signed-off-by: Deluan * feat(subsonic): append album version to media file album names in Subsonic API Add FullAlbumName() to MediaFile that appends the album version tag, mirroring the Album.FullName() behavior. Use it in childFromMediaFile and fakePath to ensure media file responses also show the album version. Signed-off-by: Deluan * fix(subsonic): use len() check for album version tag to prevent panic on empty slice Use len(tags) > 0 instead of != nil to safely guard against empty slices when accessing the first element of the album version tag. Signed-off-by: Deluan * fix(subsonic): use FullName in buildAlbumDirectory and deduplicate FullName calls Apply album.FullName() in buildAlbumDirectory (getMusicDirectory) so album names are consistent across all Subsonic endpoints. Also compute al.FullName() once in childFromAlbum to avoid redundant calls. Signed-off-by: Deluan * fix: use len() check in MediaFile.FullTitle() to prevent panic on empty slice Apply the same safety improvement as FullAlbumName() and Album.FullName() for consistency. Signed-off-by: Deluan * test: add tests for Album.FullName, MediaFile.FullTitle, and MediaFile.FullAlbumName Cover all cases: config enabled/disabled, tag present, tag absent, and empty tag slice. Signed-off-by: Deluan --------- Signed-off-by: Deluan --- conf/configuration.go | 2 ++ model/album.go | 10 ++++++++++ model/album_test.go | 19 +++++++++++++++++++ model/mediafile.go | 9 ++++++++- model/mediafile_test.go | 24 +++++++++++++++++++++++- server/subsonic/browsing.go | 2 +- server/subsonic/helpers.go | 13 +++++++------ 7 files changed, 70 insertions(+), 9 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 555d8f587..61448d315 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -155,6 +155,7 @@ type scannerOptions struct { type subsonicOptions struct { AppendSubtitle bool + AppendAlbumVersion bool ArtistParticipations bool DefaultReportRealPath bool EnableAverageRating bool @@ -689,6 +690,7 @@ func setViperDefaults() { viper.SetDefault("scanner.followsymlinks", true) viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever) viper.SetDefault("subsonic.appendsubtitle", true) + viper.SetDefault("subsonic.appendalbumversion", true) viper.SetDefault("subsonic.artistparticipations", false) viper.SetDefault("subsonic.defaultreportrealpath", false) viper.SetDefault("subsonic.enableaveragerating", true) diff --git a/model/album.go b/model/album.go index a8dcfe682..667f4695b 100644 --- a/model/album.go +++ b/model/album.go @@ -1,11 +1,14 @@ package model import ( + "fmt" "iter" "math" "sync" "time" + "github.com/navidrome/navidrome/conf" + "github.com/gohugoio/hashstructure" ) @@ -70,6 +73,13 @@ func (a Album) CoverArtID() ArtworkID { return artworkIDFromAlbum(a) } +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 a.Name +} + // Equals compares two Album structs, ignoring calculated fields func (a Album) Equals(other Album) bool { // Normalize float32 values to avoid false negatives diff --git a/model/album_test.go b/model/album_test.go index a45d16dd5..0f4c912cd 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -3,11 +3,30 @@ package model_test import ( "encoding/json" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" . "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("Album", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + DescribeTable("FullName", + func(enabled bool, tags Tags, expected string) { + conf.Server.Subsonic.AppendAlbumVersion = enabled + a := Album{Name: "Album", Tags: tags} + Expect(a.FullName()).To(Equal(expected)) + }, + Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album (Remastered)"), + 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"), + ) +}) + var _ = Describe("Albums", func() { var albums Albums diff --git a/model/mediafile.go b/model/mediafile.go index 831f006bf..103b02639 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -95,12 +95,19 @@ type MediaFile struct { } func (mf MediaFile) FullTitle() string { - if conf.Server.Subsonic.AppendSubtitle && mf.Tags[TagSubtitle] != nil { + if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 { return fmt.Sprintf("%s (%s)", 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 mf.Album +} + func (mf MediaFile) ContentType() string { return mime.TypeByExtension("." + mf.Suffix) } diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 635a61d30..0b9191fe5 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -475,7 +475,29 @@ var _ = Describe("MediaFile", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.EnableMediaFileCoverArt = true }) - Describe(".CoverArtId()", func() { + DescribeTable("FullTitle", + func(enabled bool, tags Tags, expected string) { + conf.Server.Subsonic.AppendSubtitle = enabled + mf := MediaFile{Title: "Song", Tags: tags} + Expect(mf.FullTitle()).To(Equal(expected)) + }, + Entry("appends subtitle when enabled and tag is present", true, Tags{TagSubtitle: []string{"Live"}}, "Song (Live)"), + 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"), + ) + DescribeTable("FullAlbumName", + func(enabled bool, tags Tags, expected string) { + conf.Server.Subsonic.AppendAlbumVersion = enabled + mf := MediaFile{Album: "Album", Tags: tags} + Expect(mf.FullAlbumName()).To(Equal(expected)) + }, + Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album (Deluxe Edition)"), + 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"), + ) + Describe("CoverArtId()", func() { It("returns its own id if it HasCoverArt", func() { mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true} id := mf.CoverArtID() diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 63939f6f4..5b9c4f3c9 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -443,7 +443,7 @@ func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*response func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) (*responses.Directory, error) { dir := &responses.Directory{} dir.Id = album.ID - dir.Name = album.Name + dir.Name = album.FullName() dir.Parent = album.AlbumArtistID dir.PlayCount = album.PlayCount if album.PlayCount > 0 { diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 107e23133..598346901 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -197,7 +197,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child } child.Parent = mf.AlbumID - child.Album = mf.Album + child.Album = mf.FullAlbumName() child.Year = int32(mf.Year) child.Artist = mf.Artist child.Genre = mf.Genre @@ -302,7 +302,7 @@ func artistRefs(participants model.ParticipantList) []responses.ArtistID3Ref { func fakePath(mf model.MediaFile) string { builder := strings.Builder{} - builder.WriteString(fmt.Sprintf("%s/%s/", sanitizeSlashes(mf.AlbumArtist), sanitizeSlashes(mf.Album))) + builder.WriteString(fmt.Sprintf("%s/%s/", sanitizeSlashes(mf.AlbumArtist), sanitizeSlashes(mf.FullAlbumName()))) if mf.DiscNumber != 0 { builder.WriteString(fmt.Sprintf("%02d-", mf.DiscNumber)) } @@ -321,9 +321,10 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child := responses.Child{} child.Id = al.ID child.IsDir = true - child.Title = al.Name - child.Name = al.Name - child.Album = al.Name + fullName := al.FullName() + child.Title = fullName + child.Name = fullName + child.Album = fullName child.Artist = al.AlbumArtist child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre @@ -405,7 +406,7 @@ func buildDiscSubtitles(a model.Album) []responses.DiscTitle { func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir := responses.AlbumID3{} dir.Id = album.ID - dir.Name = album.Name + dir.Name = album.FullName() dir.Artist = album.AlbumArtist dir.ArtistId = album.AlbumArtistID dir.CoverArt = album.CoverArtID().String()