mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
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:
parent
7234ea23b7
commit
4efd92cf83
8 changed files with 158 additions and 0 deletions
44
db/migrations/20260719005427_add_album_replaygain.go
Normal file
44
db/migrations/20260719005427_add_album_replaygain.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue