feat(server): expose album-level ReplayGain in albums (#5816)

* feat(model): aggregate album ReplayGain in ToAlbum

* feat(db): add nullable album ReplayGain columns with backfill

* feat(persistence): persist album ReplayGain fields

* feat(jellyfin): expose album NormalizationGain from ReplayGain

* refactor(model): lazy-init mostFrequentPtr map; clean up RG test rows

* fix(db): backfill album ReplayGain with most-frequent value, not max

A plain max() picked a minority outlier that diverged from MediaFiles.ToAlbum
(which uses the most-frequent value), and GetTouchedAlbums never re-derives an
unchanged album, so the wrong value would persist. Reproduce the modal
aggregation via grouped CTEs, which also groups media_file once instead of a
per-album correlated scan.

* refactor(model): return an owned pointer from mostFrequentPtr

Avoid aliasing a MediaFile field so the resulting Album is independent of the
source slice.
This commit is contained in:
Deluan Quintão 2026-07-18 22:19:29 -04:00 • committed by GitHub
commit 4efd92cf83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 158 additions and 0 deletions

View file

@ -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
}

View file

@ -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"`

View file

@ -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

View file

@ -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() {

View file

@ -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
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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)