From 7640c474cf1c5cb4d869d1bf42196c9e29ac24a2 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Tue, 17 Jun 2025 16:02:25 +0000 Subject: [PATCH] fix: Allow nullable ReplayGain and support 0.0 (#4239) * fix(ui,scanner,subsonic): Allow nullable replaygain and support 0.0 Resolves #4236. Makes the replaygain columns (track/album gain/peak) nullable. Converts the type to a pointer, allowing for 0.0 (a valid value) to be returned from Subsonic. Updates tests for this behavior. * small refactor Signed-off-by: Deluan --------- Signed-off-by: Deluan Co-authored-by: Deluan --- adapters/taglib/end_to_end_test.go | 24 +++++ ...1010104_make_replaygain_fields_nullable.go | 49 +++++++++ model/mediafile.go | 94 +++++++++--------- model/metadata/map_mediafile.go | 17 ++-- model/metadata/metadata.go | 24 +++-- model/metadata/metadata_test.go | 38 +++---- persistence/mediafile_repository.go | 8 +- persistence/persistence_suite_test.go | 3 +- server/subsonic/api_test.go | 3 +- ...mWithSongsID3 with data should match .JSON | 46 +++++++++ ...umWithSongsID3 with data should match .XML | 3 + ...sponses Child with data should match .JSON | 31 ++++++ ...esponses Child with data should match .XML | 3 + server/subsonic/responses/responses.go | 14 +-- server/subsonic/responses/responses_test.go | 18 +++- tests/fixtures/no_replaygain.mp3 | Bin 0 -> 8585 bytes tests/fixtures/zero_replaygain.mp3 | Bin 0 -> 9919 bytes 17 files changed, 279 insertions(+), 96 deletions(-) create mode 100644 db/migrations/20250701010104_make_replaygain_fields_nullable.go create mode 100644 tests/fixtures/no_replaygain.mp3 create mode 100644 tests/fixtures/zero_replaygain.mp3 diff --git a/adapters/taglib/end_to_end_test.go b/adapters/taglib/end_to_end_test.go index 08fc1a506..0b5126542 100644 --- a/adapters/taglib/end_to_end_test.go +++ b/adapters/taglib/end_to_end_test.go @@ -8,6 +8,7 @@ import ( "github.com/djherbis/times" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -82,6 +83,29 @@ var _ = Describe("Extractor", func() { e = &extractor{} }) + Describe("ReplayGain", func() { + DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) { + path := "tests/fixtures/" + file + mds, err := e.Parse(path) + Expect(err).ToNot(HaveOccurred()) + + info := mds[path] + fileInfo, _ := os.Stat(path) + info.FileInfo = testFileInfo{FileInfo: fileInfo} + + metadata := metadata.New(path, info) + mf := metadata.ToMediaFile(1, "folderID") + + Expect(mf.RGTrackGain).To(Equal(trackGain)) + Expect(mf.RGTrackPeak).To(Equal(trackPeak)) + Expect(mf.RGAlbumGain).To(Equal(albumGain)) + Expect(mf.RGAlbumPeak).To(Equal(albumPeak)) + }, + Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil), + Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)), + ) + }) + Describe("Participants", func() { DescribeTable("test tags consistent across formats", func(format string) { path := "tests/fixtures/test." + format diff --git a/db/migrations/20250701010104_make_replaygain_fields_nullable.go b/db/migrations/20250701010104_make_replaygain_fields_nullable.go new file mode 100644 index 000000000..163608d32 --- /dev/null +++ b/db/migrations/20250701010104_make_replaygain_fields_nullable.go @@ -0,0 +1,49 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upMakeReplaygainFieldsNullable, downMakeReplaygainFieldsNullable) +} + +func upMakeReplaygainFieldsNullable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` +ALTER TABLE media_file ADD COLUMN rg_album_gain_new real; +ALTER TABLE media_file ADD COLUMN rg_album_peak_new real; +ALTER TABLE media_file ADD COLUMN rg_track_gain_new real; +ALTER TABLE media_file ADD COLUMN rg_track_peak_new real; + +UPDATE media_file SET + rg_album_gain_new = rg_album_gain, + rg_album_peak_new = rg_album_peak, + rg_track_gain_new = rg_track_gain, + rg_track_peak_new = rg_track_peak; + +ALTER TABLE media_file DROP COLUMN rg_album_gain; +ALTER TABLE media_file DROP COLUMN rg_album_peak; +ALTER TABLE media_file DROP COLUMN rg_track_gain; +ALTER TABLE media_file DROP COLUMN rg_track_peak; + +ALTER TABLE media_file RENAME COLUMN rg_album_gain_new TO rg_album_gain; +ALTER TABLE media_file RENAME COLUMN rg_album_peak_new TO rg_album_peak; +ALTER TABLE media_file RENAME COLUMN rg_track_gain_new TO rg_track_gain; +ALTER TABLE media_file RENAME COLUMN rg_track_peak_new TO rg_track_peak; + `) + + if err != nil { + return err + } + + notice(tx, "Fetching replaygain fields properly will require a full scan") + return nil +} + +func downMakeReplaygainFieldsNullable(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/model/mediafile.go b/model/mediafile.go index 5068e5d04..d29a2a509 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -36,53 +36,53 @@ type MediaFile struct { Artist string `structs:"artist" json:"artist"` AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead // AlbumArtist is the display name used for the album artist. - AlbumArtist string `structs:"album_artist" json:"albumArtist"` - AlbumID string `structs:"album_id" json:"albumId"` - HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"` - TrackNumber int `structs:"track_number" json:"trackNumber"` - DiscNumber int `structs:"disc_number" json:"discNumber"` - DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"` - Year int `structs:"year" json:"year"` - Date string `structs:"date" json:"date,omitempty"` - OriginalYear int `structs:"original_year" json:"originalYear"` - OriginalDate string `structs:"original_date" json:"originalDate,omitempty"` - ReleaseYear int `structs:"release_year" json:"releaseYear"` - ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"` - Size int64 `structs:"size" json:"size"` - Suffix string `structs:"suffix" json:"suffix"` - Duration float32 `structs:"duration" json:"duration"` - BitRate int `structs:"bit_rate" json:"bitRate"` - SampleRate int `structs:"sample_rate" json:"sampleRate"` - BitDepth int `structs:"bit_depth" json:"bitDepth"` - Channels int `structs:"channels" json:"channels"` - Genre string `structs:"genre" json:"genre"` - Genres Genres `structs:"-" json:"genres,omitempty"` - SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"` - SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` - SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` // Deprecated: Use Participants instead - SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` // Deprecated: Use Participants instead - OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"` - OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` - OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"` // Deprecated: Use Participants instead - OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` // Deprecated: Use Participants instead - Compilation bool `structs:"compilation" json:"compilation"` - Comment string `structs:"comment" json:"comment,omitempty"` - Lyrics string `structs:"lyrics" json:"lyrics"` - BPM int `structs:"bpm" json:"bpm,omitempty"` - ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` - CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` - MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"` - MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty"` - MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"` - MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` - MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty"` // Deprecated: Use Participants instead - MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"` // Deprecated: Use Participants instead - MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` - MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` - RGAlbumGain float64 `structs:"rg_album_gain" json:"rgAlbumGain"` - RGAlbumPeak float64 `structs:"rg_album_peak" json:"rgAlbumPeak"` - RGTrackGain float64 `structs:"rg_track_gain" json:"rgTrackGain"` - RGTrackPeak float64 `structs:"rg_track_peak" json:"rgTrackPeak"` + AlbumArtist string `structs:"album_artist" json:"albumArtist"` + AlbumID string `structs:"album_id" json:"albumId"` + HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"` + TrackNumber int `structs:"track_number" json:"trackNumber"` + DiscNumber int `structs:"disc_number" json:"discNumber"` + DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"` + Year int `structs:"year" json:"year"` + Date string `structs:"date" json:"date,omitempty"` + OriginalYear int `structs:"original_year" json:"originalYear"` + OriginalDate string `structs:"original_date" json:"originalDate,omitempty"` + ReleaseYear int `structs:"release_year" json:"releaseYear"` + ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"` + Size int64 `structs:"size" json:"size"` + Suffix string `structs:"suffix" json:"suffix"` + Duration float32 `structs:"duration" json:"duration"` + BitRate int `structs:"bit_rate" json:"bitRate"` + SampleRate int `structs:"sample_rate" json:"sampleRate"` + BitDepth int `structs:"bit_depth" json:"bitDepth"` + Channels int `structs:"channels" json:"channels"` + Genre string `structs:"genre" json:"genre"` + Genres Genres `structs:"-" json:"genres,omitempty"` + SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"` + SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` + SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` // Deprecated: Use Participants instead + SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` // Deprecated: Use Participants instead + OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"` + OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` + OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"` // Deprecated: Use Participants instead + OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` // Deprecated: Use Participants instead + Compilation bool `structs:"compilation" json:"compilation"` + Comment string `structs:"comment" json:"comment,omitempty"` + Lyrics string `structs:"lyrics" json:"lyrics"` + BPM int `structs:"bpm" json:"bpm,omitempty"` + ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` + CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` + MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"` + MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty"` + MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"` + MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` + MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty"` // Deprecated: Use Participants instead + MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"` // Deprecated: Use Participants instead + MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` + RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"` + RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"` + RGTrackGain *float64 `structs:"rg_track_gain" json:"rgTrackGain"` + RGTrackPeak *float64 `structs:"rg_track_peak" json:"rgTrackPeak"` Tags Tags `structs:"tags" json:"tags,omitempty" hash:"ignore"` // All imported tags from the original file Participants Participants `structs:"participants" json:"participants" hash:"ignore"` // All artists that participated in this track diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index b4857df85..591b618a3 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -53,9 +53,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.MbzAlbumType = md.String(model.TagReleaseType) // ReplayGain - mf.RGAlbumPeak = md.Float(model.TagReplayGainAlbumPeak, 1) + mf.RGAlbumPeak = md.NullableFloat(model.TagReplayGainAlbumPeak) mf.RGAlbumGain = md.mapGain(model.TagReplayGainAlbumGain, model.TagR128AlbumGain) - mf.RGTrackPeak = md.Float(model.TagReplayGainTrackPeak, 1) + mf.RGTrackPeak = md.NullableFloat(model.TagReplayGainTrackPeak) mf.RGTrackGain = md.mapGain(model.TagReplayGainTrackGain, model.TagR128TrackGain) // General properties @@ -108,23 +108,24 @@ func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string { return getPID(mf, md, pidConf) } -func (md Metadata) mapGain(rg, r128 model.TagName) float64 { +func (md Metadata) mapGain(rg, r128 model.TagName) *float64 { v := md.Gain(rg) - if v != 0 { + if v != nil { return v } r128value := md.String(r128) if r128value != "" { var v, err = strconv.Atoi(r128value) if err != nil { - return 0 + return nil } // Convert Q7.8 to float - var value = float64(v) / 256.0 + value := float64(v) / 256.0 // Adding 5 dB to normalize with ReplayGain level - return value + 5 + value += 5 + return &value } - return 0 + return nil } func (md Metadata) mapLyrics() string { diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 471c2434c..aea4238a4 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -103,9 +103,11 @@ func (md Metadata) NumAndTotal(key model.TagName) (int, int) { return md.tuple(k func (md Metadata) Float(key model.TagName, def ...float64) float64 { return float(md.first(key), def...) } -func (md Metadata) Gain(key model.TagName) float64 { +func (md Metadata) NullableFloat(key model.TagName) *float64 { return nullableFloat(md.first(key)) } + +func (md Metadata) Gain(key model.TagName) *float64 { v := strings.TrimSpace(strings.Replace(md.first(key), "dB", "", 1)) - return float(v) + return nullableFloat(v) } func (md Metadata) Pairs(key model.TagName) []Pair { values := md.tags[key] @@ -119,14 +121,22 @@ func (md Metadata) first(key model.TagName) string { } func float(value string, def ...float64) float64 { + v := nullableFloat(value) + if v != nil { + return *v + } + if len(def) > 0 { + return def[0] + } + return 0 +} + +func nullableFloat(value string) *float64 { v, err := strconv.ParseFloat(value, 64) if err != nil || v == math.Inf(-1) || math.IsInf(v, 1) || math.IsNaN(v) { - if len(def) > 0 { - return def[0] - } - return 0 + return nil } - return v + return &v } // Used for tracks and discs diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index d7473afa7..82afd8657 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -257,38 +258,39 @@ var _ = Describe("Metadata", func() { } DescribeTable("Gain", - func(tagValue string, expected float64) { + func(tagValue string, expected *float64) { mf := createMF("replaygain_track_gain", tagValue) Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", 0.0), - Entry("1.2dB", "1.2dB", 1.2), - Entry("Infinity", "Infinity", 0.0), - Entry("Invalid value", "INVALID VALUE", 0.0), - Entry("NaN", "NaN", 0.0), + Entry("0", "0", gg.P(0.0)), + Entry("1.2dB", "1.2dB", gg.P(1.2)), + Entry("Infinity", "Infinity", nil), + Entry("Invalid value", "INVALID VALUE", nil), + Entry("NaN", "NaN", nil), ) DescribeTable("Peak", - func(tagValue string, expected float64) { + func(tagValue string, expected *float64) { mf := createMF("replaygain_track_peak", tagValue) Expect(mf.RGTrackPeak).To(Equal(expected)) }, - Entry("0", "0", 0.0), - Entry("0.5", "0.5", 0.5), - Entry("Invalid dB suffix", "0.7dB", 1.0), - Entry("Infinity", "Infinity", 1.0), - Entry("Invalid value", "INVALID VALUE", 1.0), - Entry("NaN", "NaN", 1.0), + Entry("0", "0", gg.P(0.0)), + Entry("1.0", "1.0", gg.P(1.0)), + Entry("0.5", "0.5", gg.P(0.5)), + Entry("Invalid dB suffix", "0.7dB", nil), + Entry("Infinity", "Infinity", nil), + Entry("Invalid value", "INVALID VALUE", nil), + Entry("NaN", "NaN", nil), ) DescribeTable("getR128GainValue", - func(tagValue string, expected float64) { + func(tagValue string, expected *float64) { mf := createMF("r128_track_gain", tagValue) Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", 5.0), - Entry("-3776", "-3776", -9.75), - Entry("Infinity", "Infinity", 0.0), - Entry("Invalid value", "INVALID VALUE", 0.0), + Entry("0", "0", gg.P(5.0)), + Entry("-3776", "-3776", gg.P(-9.75)), + Entry("Infinity", "Infinity", nil), + Entry("Invalid value", "INVALID VALUE", nil), ) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index b0ed637c1..eee6444c1 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -25,10 +25,10 @@ type dbMediaFile struct { Tags string `structs:"-" json:"-"` // These are necessary to map the correct names (rg_*) to the correct fields (RG*) // without using `db` struct tags in the model.MediaFile struct - RgAlbumGain float64 `structs:"-" json:"-"` - RgAlbumPeak float64 `structs:"-" json:"-"` - RgTrackGain float64 `structs:"-" json:"-"` - RgTrackPeak float64 `structs:"-" json:"-"` + RgAlbumGain *float64 `structs:"-" json:"-"` + RgAlbumPeak *float64 `structs:"-" json:"-"` + RgTrackGain *float64 `structs:"-" json:"-"` + RgTrackPeak *float64 `structs:"-" json:"-"` } func (m *dbMediaFile) PostScan() error { diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index fc4519135..7edfeee1f 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -79,7 +80,7 @@ var ( songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Path: p("/kraft/radio/antenna.mp3"), - RGAlbumGain: 1.0, RGAlbumPeak: 2.0, RGTrackGain: 3.0, RGTrackPeak: 4.0, + RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0), }) songAntennaWithLyrics = mf(model.MediaFile{ ID: "1005", diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index 5d248c464..1658f0945 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -91,7 +92,7 @@ var _ = Describe("sendResponse", func() { It("should return a fail response", func() { payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}} // An +Inf value will cause an error when marshalling to JSON - payload.Song.ReplayGain = responses.ReplayGain{TrackGain: math.Inf(1)} + payload.Song.ReplayGain = responses.ReplayGain{TrackGain: gg.P(math.Inf(1))} q := r.URL.Query() q.Add("f", "json") r.URL.RawQuery = q.Encode() diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index 78b5c6e7a..c2a29b22a 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -166,6 +166,52 @@ ], "displayComposer": "composer 1 \u0026 composer 2", "explicitStatus": "clean" + }, + { + "id": "2", + "isDir": true, + "title": "title", + "album": "album", + "artist": "artist", + "track": 1, + "year": 1985, + "genre": "Rock", + "coverArt": "1", + "size": 8421341, + "contentType": "audio/flac", + "suffix": "flac", + "starred": "2016-03-02T20:30:00Z", + "transcodedContentType": "audio/mpeg", + "transcodedSuffix": "mp3", + "duration": 146, + "bitRate": 320, + "isVideo": false, + "bpm": 0, + "comment": "", + "sortName": "", + "mediaType": "", + "musicBrainzId": "", + "isrc": [], + "genres": [], + "replayGain": { + "trackGain": 0, + "albumGain": 0, + "trackPeak": 0, + "albumPeak": 0, + "baseGain": 0, + "fallbackGain": 0 + }, + "channelCount": 0, + "samplingRate": 0, + "bitDepth": 0, + "moods": [], + "artists": [], + "displayArtist": "", + "albumArtists": [], + "displayAlbumArtist": "", + "contributors": [], + "displayComposer": "", + "explicitStatus": "" } ] } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML index f3281d9ee..1ad3e600c 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -33,5 +33,8 @@ + + + diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON index d64ae9e7f..fde40646a 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON @@ -112,6 +112,37 @@ ], "displayComposer": "composer 1 \u0026 composer 2", "explicitStatus": "clean" + }, + { + "id": "", + "isDir": false, + "isVideo": false, + "bpm": 0, + "comment": "", + "sortName": "", + "mediaType": "", + "musicBrainzId": "", + "isrc": [], + "genres": [], + "replayGain": { + "trackGain": 0, + "albumGain": 0, + "trackPeak": 0, + "albumPeak": 0, + "baseGain": 0, + "fallbackGain": 0 + }, + "channelCount": 0, + "samplingRate": 0, + "bitDepth": 0, + "moods": [], + "artists": [], + "displayArtist": "", + "albumArtists": [], + "displayAlbumArtist": "", + "contributors": [], + "displayComposer": "", + "explicitStatus": "" } ], "id": "1", diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML index 639fd3f60..faea8ee93 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML @@ -25,5 +25,8 @@ + + + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 4a7ebbe83..ffda2aa43 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -546,16 +546,16 @@ type ItemGenre struct { } type ReplayGain struct { - TrackGain float64 `xml:"trackGain,omitempty,attr" json:"trackGain,omitempty"` - AlbumGain float64 `xml:"albumGain,omitempty,attr" json:"albumGain,omitempty"` - TrackPeak float64 `xml:"trackPeak,omitempty,attr" json:"trackPeak,omitempty"` - AlbumPeak float64 `xml:"albumPeak,omitempty,attr" json:"albumPeak,omitempty"` - BaseGain float64 `xml:"baseGain,omitempty,attr" json:"baseGain,omitempty"` - FallbackGain float64 `xml:"fallbackGain,omitempty,attr" json:"fallbackGain,omitempty"` + TrackGain *float64 `xml:"trackGain,omitempty,attr" json:"trackGain,omitempty"` + AlbumGain *float64 `xml:"albumGain,omitempty,attr" json:"albumGain,omitempty"` + TrackPeak *float64 `xml:"trackPeak,omitempty,attr" json:"trackPeak,omitempty"` + AlbumPeak *float64 `xml:"albumPeak,omitempty,attr" json:"albumPeak,omitempty"` + BaseGain *float64 `xml:"baseGain,omitempty,attr" json:"baseGain,omitempty"` + FallbackGain *float64 `xml:"fallbackGain,omitempty,attr" json:"fallbackGain,omitempty"` } func (r ReplayGain) MarshalXML(e *xml.Encoder, start xml.StartElement) error { - if r.TrackGain == 0 && r.AlbumGain == 0 && r.TrackPeak == 0 && r.AlbumPeak == 0 && r.BaseGain == 0 && r.FallbackGain == 0 { + if r.TrackGain == nil && r.AlbumGain == nil && r.TrackPeak == nil && r.AlbumPeak == nil && r.BaseGain == nil && r.FallbackGain == nil { return nil } type replayGain ReplayGain diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 9fcd6078e..7238665cf 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/consts" . "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -213,7 +214,7 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { response.Directory = &Directory{Id: "1", Name: "N"} - child := make([]Child, 1) + child := make([]Child, 2) t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) child[0] = Child{ Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, @@ -227,7 +228,7 @@ var _ = Describe("Responses", func() { Isrc: []string{"ISRC-1", "ISRC-2"}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, Moods: []string{"happy", "sad"}, - ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6}, + ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, DisplayArtist: "artist 1 & artist 2", Artists: []ArtistID3Ref{ {Id: "1", Name: "artist1"}, @@ -247,6 +248,9 @@ var _ = Describe("Responses", func() { }, ExplicitStatus: "clean", } + child[1].OpenSubsonicChild = &OpenSubsonicChild{ + ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + } response.Directory.Child = child }) @@ -309,13 +313,18 @@ var _ = Describe("Responses", func() { Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac", Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", Duration: 146, BitRate: 320, Starred: &t, + }, { + Id: "2", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, + Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac", + Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", + Duration: 146, BitRate: 320, Starred: &t, }} songs[0].OpenSubsonicChild = &OpenSubsonicChild{ Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song", Isrc: []string{"ISRC-1"}, Moods: []string{"happy", "sad"}, - ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6}, + ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, DisplayArtist: "artist1 & artist2", Artists: []ArtistID3Ref{ @@ -334,6 +343,9 @@ var _ = Describe("Responses", func() { DisplayComposer: "composer 1 & composer 2", ExplicitStatus: "clean", } + songs[1].OpenSubsonicChild = &OpenSubsonicChild{ + ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + } response.AlbumWithSongsID3.AlbumID3 = album response.AlbumWithSongsID3.Song = songs }) diff --git a/tests/fixtures/no_replaygain.mp3 b/tests/fixtures/no_replaygain.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..45c2176e353611a841a0ab9c3decd8c4e8b2554c GIT binary patch literal 8585 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