feat(api): deliver loudness gains to every client (M464 #4997)
release / web (push) Successful in 1m44s
release / go (push) Successful in 2m12s
release / govulncheck (push) Successful in 40s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 4m42s
release / integration (push) Successful in 15m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m21s
release / Verify release artifacts (tag releases only) (push) Skipped

ReplayGain 2.0 values (gain to -18 LUFS, linear peak) derived from the
stored track and album loudness:

- Web: GET /api/tracks/replay-gain?ids=... (up to 200), a lookup the
  player calls for its queue, rather than a field on every TrackRef
  surface.
- Android: track_gain/track_peak and album_gain/album_peak on the sync
  views, so cached tracks level offline. Storing a measurement logs a
  track change, and an album's values moving logs an album change, both
  before the write (#2704), so caches pick the gains up.
- OpenSubsonic: replayGain on every song (album, getSong, search3,
  starred), as a JSON object and an XML element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 13:54:05 -04:00
co-authored by Claude Opus 5.5
parent c2f81bf8df
commit e3aa8629d3
19 changed files with 797 additions and 38 deletions
+31 -1
View File
@@ -9,6 +9,7 @@ import (
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// histAt builds a histogram of n blocks all at one loudness.
@@ -162,6 +163,17 @@ func TestAlbumLoudness_Integration(t *testing.T) {
return res
}
albumChanges := func() int {
t.Helper()
var n int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_changes
WHERE entity_type = 'album' AND entity_id = $1`,
syncpkg.FormatUUID(album.ID)).Scan(&n); err != nil {
t.Fatalf("count album changes: %v", err)
}
return n
}
second := addTrack("b", album)
measure(first, -10, -1)
@@ -173,6 +185,11 @@ func TestAlbumLoudness_Integration(t *testing.T) {
t.Fatalf("waiting album = lufs %v, %d/%d settled; want nil, 1/2", lufs, settled, total)
}
// Still no album value: clients have nothing new to read (#4997).
if n := albumChanges(); n != 0 {
t.Fatalf("a waiting album logged %d sync changes, want 0", n)
}
// 2. Measuring the second track changes the digest; the album is leveled.
measure(second, -10, -0.5)
if res := pass(); res.Recomputed != 1 || res.Leveled != 1 {
@@ -183,10 +200,18 @@ func TestAlbumLoudness_Integration(t *testing.T) {
t.Fatalf("leveled album = lufs %v peak %v, want -10 and -0.5", lufs, peak)
}
// 3. Nothing changed: nothing recomputed.
// Leveled: clients are told, once.
if n := albumChanges(); n != 1 {
t.Fatalf("leveling the album logged %d sync changes, want 1", n)
}
// 3. Nothing changed: nothing recomputed, nothing logged.
if res := pass(); res.Recomputed != 0 {
t.Fatalf("idle pass = %+v, want nothing recomputed", res)
}
if n := albumChanges(); n != 1 {
t.Fatalf("an idle pass logged album changes (now %d), want still 1", n)
}
// 4. A track joins (here, retagged onto this album): recomputed.
other, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Other", SortTitle: "Other", ArtistID: artist.ID})
@@ -232,9 +257,14 @@ func TestAlbumLoudness_Integration(t *testing.T) {
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", first.ID); err != nil {
t.Fatalf("mark missing: %v", err)
}
before := albumChanges()
if res := pass(); res.Orphans != 1 {
t.Fatalf("pass with every track missing = %+v, want 1 orphan dropped", res)
}
// Dropping the row takes the album gain away, so clients are told.
if n := albumChanges(); n != before+1 {
t.Fatalf("dropping the orphan logged %d album changes, want 1", n-before)
}
var n int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM album_loudness WHERE album_id = $1", album.ID).Scan(&n); err != nil {
t.Fatalf("count: %v", err)