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 <deluan@navidrome.org> * 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 <deluan@navidrome.org> * 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 <deluan@navidrome.org> * 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 <deluan@navidrome.org> * 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 <deluan@navidrome.org> * 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 <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -155,6 +155,7 @@ type scannerOptions struct {
|
|||||||
|
|
||||||
type subsonicOptions struct {
|
type subsonicOptions struct {
|
||||||
AppendSubtitle bool
|
AppendSubtitle bool
|
||||||
|
AppendAlbumVersion bool
|
||||||
ArtistParticipations bool
|
ArtistParticipations bool
|
||||||
DefaultReportRealPath bool
|
DefaultReportRealPath bool
|
||||||
EnableAverageRating bool
|
EnableAverageRating bool
|
||||||
@@ -689,6 +690,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("scanner.followsymlinks", true)
|
viper.SetDefault("scanner.followsymlinks", true)
|
||||||
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
||||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||||
|
viper.SetDefault("subsonic.appendalbumversion", true)
|
||||||
viper.SetDefault("subsonic.artistparticipations", false)
|
viper.SetDefault("subsonic.artistparticipations", false)
|
||||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||||
viper.SetDefault("subsonic.enableaveragerating", true)
|
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"iter"
|
"iter"
|
||||||
"math"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
|
||||||
"github.com/gohugoio/hashstructure"
|
"github.com/gohugoio/hashstructure"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,6 +73,13 @@ func (a Album) CoverArtID() ArtworkID {
|
|||||||
return artworkIDFromAlbum(a)
|
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
|
// Equals compares two Album structs, ignoring calculated fields
|
||||||
func (a Album) Equals(other Album) bool {
|
func (a Album) Equals(other Album) bool {
|
||||||
// Normalize float32 values to avoid false negatives
|
// Normalize float32 values to avoid false negatives
|
||||||
|
|||||||
@@ -3,11 +3,30 @@ package model_test
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
. "github.com/navidrome/navidrome/model"
|
. "github.com/navidrome/navidrome/model"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "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 _ = Describe("Albums", func() {
|
||||||
var albums Albums
|
var albums Albums
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -95,12 +95,19 @@ type MediaFile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (mf MediaFile) FullTitle() string {
|
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 fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0])
|
||||||
}
|
}
|
||||||
return mf.Title
|
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 {
|
func (mf MediaFile) ContentType() string {
|
||||||
return mime.TypeByExtension("." + mf.Suffix)
|
return mime.TypeByExtension("." + mf.Suffix)
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -475,7 +475,29 @@ var _ = Describe("MediaFile", func() {
|
|||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
conf.Server.EnableMediaFileCoverArt = true
|
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() {
|
It("returns its own id if it HasCoverArt", func() {
|
||||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||||
id := mf.CoverArtID()
|
id := mf.CoverArtID()
|
||||||
|
|||||||
@@ -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) {
|
func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) (*responses.Directory, error) {
|
||||||
dir := &responses.Directory{}
|
dir := &responses.Directory{}
|
||||||
dir.Id = album.ID
|
dir.Id = album.ID
|
||||||
dir.Name = album.Name
|
dir.Name = album.FullName()
|
||||||
dir.Parent = album.AlbumArtistID
|
dir.Parent = album.AlbumArtistID
|
||||||
dir.PlayCount = album.PlayCount
|
dir.PlayCount = album.PlayCount
|
||||||
if album.PlayCount > 0 {
|
if album.PlayCount > 0 {
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
|||||||
}
|
}
|
||||||
|
|
||||||
child.Parent = mf.AlbumID
|
child.Parent = mf.AlbumID
|
||||||
child.Album = mf.Album
|
child.Album = mf.FullAlbumName()
|
||||||
child.Year = int32(mf.Year)
|
child.Year = int32(mf.Year)
|
||||||
child.Artist = mf.Artist
|
child.Artist = mf.Artist
|
||||||
child.Genre = mf.Genre
|
child.Genre = mf.Genre
|
||||||
@@ -302,7 +302,7 @@ func artistRefs(participants model.ParticipantList) []responses.ArtistID3Ref {
|
|||||||
func fakePath(mf model.MediaFile) string {
|
func fakePath(mf model.MediaFile) string {
|
||||||
builder := strings.Builder{}
|
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 {
|
if mf.DiscNumber != 0 {
|
||||||
builder.WriteString(fmt.Sprintf("%02d-", mf.DiscNumber))
|
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 := responses.Child{}
|
||||||
child.Id = al.ID
|
child.Id = al.ID
|
||||||
child.IsDir = true
|
child.IsDir = true
|
||||||
child.Title = al.Name
|
fullName := al.FullName()
|
||||||
child.Name = al.Name
|
child.Title = fullName
|
||||||
child.Album = al.Name
|
child.Name = fullName
|
||||||
|
child.Album = fullName
|
||||||
child.Artist = al.AlbumArtist
|
child.Artist = al.AlbumArtist
|
||||||
child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
|
child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
|
||||||
child.Genre = al.Genre
|
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 {
|
func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
|
||||||
dir := responses.AlbumID3{}
|
dir := responses.AlbumID3{}
|
||||||
dir.Id = album.ID
|
dir.Id = album.ID
|
||||||
dir.Name = album.Name
|
dir.Name = album.FullName()
|
||||||
dir.Artist = album.AlbumArtist
|
dir.Artist = album.AlbumArtist
|
||||||
dir.ArtistId = album.AlbumArtistID
|
dir.ArtistId = album.AlbumArtistID
|
||||||
dir.CoverArt = album.CoverArtID().String()
|
dir.CoverArt = album.CoverArtID().String()
|
||||||
|
|||||||
Reference in New Issue
Block a user