diff --git a/db/migrations/20260719005427_add_album_replaygain.go b/db/migrations/20260719005427_add_album_replaygain.go new file mode 100644 index 000000000..79c8b8b3e --- /dev/null +++ b/db/migrations/20260719005427_add_album_replaygain.go @@ -0,0 +1,44 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddAlbumReplaygain, downAddAlbumReplaygain) +} + +func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { + // Backfill the most-frequent non-null value per album, matching MediaFiles.ToAlbum. A plain + // max() would pick a minority outlier, and GetTouchedAlbums never re-derives an unchanged album, + // so a wrong value would persist. The CTEs group media_file once instead of scanning it per album. + _, err := tx.ExecContext(ctx, ` +ALTER TABLE album ADD COLUMN rg_album_gain real; +ALTER TABLE album ADD COLUMN rg_album_peak real; + +WITH gain_mode AS ( + SELECT album_id, rg_album_gain AS val, + row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_gain) AS rn + FROM media_file WHERE rg_album_gain IS NOT NULL + GROUP BY album_id, rg_album_gain +), +peak_mode AS ( + SELECT album_id, rg_album_peak AS val, + row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_peak) AS rn + FROM media_file WHERE rg_album_peak IS NOT NULL + GROUP BY album_id, rg_album_peak +) +UPDATE album SET + rg_album_gain = (SELECT val FROM gain_mode WHERE gain_mode.album_id = album.id AND gain_mode.rn = 1), + rg_album_peak = (SELECT val FROM peak_mode WHERE peak_mode.album_id = album.id AND peak_mode.rn = 1); + `) + return err +} + +func downAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/model/album.go b/model/album.go index ade7f6ee0..16727ea39 100644 --- a/model/album.go +++ b/model/album.go @@ -49,6 +49,8 @@ type Album struct { MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` + RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"` + RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"` // External metadata fields Description string `structs:"description" json:"description,omitempty" hash:"ignore"` diff --git a/model/mediafile.go b/model/mediafile.go index 90db80f71..910cd998d 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -314,6 +314,8 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears := make([]int, 0, len(mfs)) originalDates := make([]string, 0, len(mfs)) releaseDates := make([]string, 0, len(mfs)) + rgAlbumGains := make([]*float64, 0, len(mfs)) + rgAlbumPeaks := make([]*float64, 0, len(mfs)) tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs)) a.Missing = true @@ -344,6 +346,8 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears = append(originalYears, m.OriginalYear) originalDates = append(originalDates, m.OriginalDate) releaseDates = append(releaseDates, m.ReleaseDate) + rgAlbumGains = append(rgAlbumGains, m.RGAlbumGain) + rgAlbumPeaks = append(rgAlbumPeaks, m.RGAlbumPeak) comments = append(comments, m.Comment) mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID) mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID) @@ -378,6 +382,8 @@ func (mfs MediaFiles) ToAlbum() Album { a.Comment, _ = allOrNothing(comments) a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds) a.MbzReleaseGroupID = slice.MostFrequent(mbzReleaseGroupIds) + a.RGAlbumGain = mostFrequentPtr(rgAlbumGains) + a.RGAlbumPeak = mostFrequentPtr(rgAlbumPeaks) fixAlbumArtist(&a) return a @@ -407,6 +413,32 @@ func minMax(items []int) (int, int) { return mn, mx } +// mostFrequentPtr returns a pointer to the most common non-nil value, or nil if +// none. It counts by dereferenced value so a genuine 0.0 is a real candidate +// (slice.MostFrequent skips the zero value and compares pointers by identity). +func mostFrequentPtr(items []*float64) *float64 { + var counts map[float64]int + var best float64 + var bestCount int + for _, it := range items { + if it == nil { + continue + } + if counts == nil { + counts = map[float64]int{} + } + counts[*it]++ + if counts[*it] > bestCount { + bestCount = counts[*it] + best = *it + } + } + if bestCount == 0 { + return nil + } + return &best +} + func newer(t1, t2 time.Time) time.Time { if t1.After(t2) { return t1 diff --git a/model/mediafile_test.go b/model/mediafile_test.go index c94653530..6085ccf25 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -268,6 +268,35 @@ var _ = Describe("MediaFiles", func() { }) }) }) + Context("ReplayGain", func() { + It("picks the most frequent non-nil album gain and peak", func() { + mfs := MediaFiles{ + {Path: "a", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, + {Path: "b", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, + {Path: "c", RGAlbumGain: new(-5.0), RGAlbumPeak: new(1.0)}, + } + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).ToNot(BeNil()) + Expect(*album.RGAlbumGain).To(Equal(-8.0)) + Expect(album.RGAlbumPeak).ToNot(BeNil()) + Expect(*album.RGAlbumPeak).To(Equal(0.9)) + }) + It("keeps a genuine 0.0 gain instead of dropping it", func() { + mfs := MediaFiles{ + {Path: "a", RGAlbumGain: new(0.0)}, + {Path: "b", RGAlbumGain: new(0.0)}, + } + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).ToNot(BeNil()) + Expect(*album.RGAlbumGain).To(Equal(0.0)) + }) + It("leaves gain and peak nil when no track has a value", func() { + mfs := MediaFiles{{Path: "a"}, {Path: "b"}} + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).To(BeNil()) + Expect(album.RGAlbumPeak).To(BeNil()) + }) + }) Context("Participants", func() { var album Album BeforeEach(func() { diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6ebbd9202..53e8efe32 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -31,6 +31,10 @@ type dbAlbum struct { Participants string `structs:"-" json:"-"` Tags string `structs:"-" json:"-"` FolderIDs string `structs:"-" json:"-"` + // dbx maps columns to fields by name; RGAlbumGain doesn't convert to + // rg_album_gain, so shim fields carry the read and PostScan copies them over. + RgAlbumGain *float64 `structs:"-" json:"-"` + RgAlbumPeak *float64 `structs:"-" json:"-"` } func (a *dbAlbum) PostScan() error { @@ -58,6 +62,8 @@ func (a *dbAlbum) PostScan() error { } a.Album.FolderIDs = ids } + a.Album.RGAlbumGain = a.RgAlbumGain + a.Album.RGAlbumPeak = a.RgAlbumPeak return nil } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 64ff0095e..761255b7d 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -890,6 +890,33 @@ var _ = Describe("AlbumRepository", func() { Expect(albums[0].ID).To(Equal("a1")) }) }) + + Describe("ReplayGain", func() { + BeforeEach(func() { + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"rg-1", "rg-2"}})) + }) + }) + It("round-trips album ReplayGain gain and peak", func() { + Expect(albumRepo.Put(&model.Album{ + ID: "rg-1", Name: "rg", LibraryID: 1, + RGAlbumGain: new(-7.5), RGAlbumPeak: new(0.98), + })).To(Succeed()) + got, err := albumRepo.Get("rg-1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.RGAlbumGain).ToNot(BeNil()) + Expect(*got.RGAlbumGain).To(Equal(-7.5)) + Expect(got.RGAlbumPeak).ToNot(BeNil()) + Expect(*got.RGAlbumPeak).To(Equal(0.98)) + }) + It("reads nil when ReplayGain is unset", func() { + Expect(albumRepo.Put(&model.Album{ID: "rg-2", Name: "rg2", LibraryID: 1})).To(Succeed()) + got, err := albumRepo.Get("rg-2") + Expect(err).ToNot(HaveOccurred()) + Expect(got.RGAlbumGain).To(BeNil()) + Expect(got.RGAlbumPeak).To(BeNil()) + }) + }) }) func _p(id, name string, sortName ...string) model.Participant { diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index bdb0a1e37..036dfbd91 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -232,6 +232,9 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } + // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same + // convention as tracks; clients read it off the album item as NormalizationGain. + item.NormalizationGain = al.RGAlbumGain return item } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 8867a5e73..c986e7f81 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -260,6 +260,21 @@ var _ = Describe("mappers", func() { Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) + It("sets NormalizationGain on the album from its ReplayGain", func() { + al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)} + b, err := json.Marshal(AlbumToBaseItem(al)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) + // Real Jellyfin never sets AlbumNormalizationGain on an album item. + Expect(string(b)).ToNot(ContainSubstring("AlbumNormalizationGain")) + }) + + It("omits NormalizationGain when the album has no ReplayGain", func() { + b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"})) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) + }) + It("maps an artist to a MusicArtist folder item", func() { ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20} item := ArtistToBaseItem(ar)