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
+5 -3
View File
@@ -185,9 +185,10 @@ func (b *browseHandlers) getAlbum(w http.ResponseWriter, r *http.Request) {
WriteFail(w, r, ErrGeneric, "Failed to load tracks")
return
}
gains := replayGains(r.Context(), q, tracks)
songs := make([]SongRef, 0, len(tracks))
for _, t := range tracks {
songs = append(songs, songRef(t, album.Title, artist.Name))
songs = append(songs, songRef(t, album.Title, artist.Name, gains[t.ID]))
}
Write(w, r, AlbumResponse{
Envelope: NewEnvelope("ok"),
@@ -230,7 +231,7 @@ func (b *browseHandlers) getSong(w http.ResponseWriter, r *http.Request) {
}
Write(w, r, SongResponse{
Envelope: NewEnvelope("ok"),
Song: songRef(track, album.Title, artist.Name),
Song: songRef(track, album.Title, artist.Name, replayGains(r.Context(), q, []dbq.Track{track})[track.ID]),
})
}
@@ -386,6 +387,7 @@ func (b *browseHandlers) search3(w http.ResponseWriter, r *http.Request) {
WriteFail(w, r, ErrGeneric, "Song search failed")
return
}
gains := replayGains(r.Context(), q, tracks)
for _, t := range tracks {
album, aerr := q.GetAlbumByID(r.Context(), t.AlbumID)
if aerr != nil {
@@ -397,7 +399,7 @@ func (b *browseHandlers) search3(w http.ResponseWriter, r *http.Request) {
WriteFail(w, r, ErrGeneric, "Song search failed")
return
}
result.Songs = append(result.Songs, songRef(t, album.Title, artist.Name))
result.Songs = append(result.Songs, songRef(t, album.Title, artist.Name, gains[t.ID]))
}
}
+58
View File
@@ -0,0 +1,58 @@
package subsonic
import (
"encoding/json"
"encoding/xml"
"strings"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// OpenSubsonic's replayGain is an object in JSON and an element with
// attributes in XML. Clients parse both, so both are pinned (#4997).
func TestSongRef_ReplayGainEncodesForBothFormats(t *testing.T) {
gain, peak := float32(-4.5), float32(0.8913)
tr := dbq.Track{Title: "Song", FilePath: "/m/a.flac", FileFormat: "flac"}
s := songRef(tr, "Album", "Artist", library.ReplayGain{TrackGain: &gain, TrackPeak: &peak})
b, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
rg, ok := m["replayGain"].(map[string]any)
if !ok {
t.Fatalf("JSON has no replayGain object: %s", b)
}
if rg["trackGain"] != -4.5 || rg["trackPeak"] == nil {
t.Errorf("JSON replayGain = %v, want trackGain -4.5 and a trackPeak", rg)
}
if _, ok := rg["albumGain"]; ok {
t.Errorf("JSON replayGain carries albumGain with no album value: %v", rg)
}
x, err := xml.Marshal(s)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(x), `<replayGain trackGain="-4.5" trackPeak="0.8913"></replayGain>`) {
t.Errorf("XML lacks the replayGain element: %s", x)
}
}
func TestSongRef_NoReplayGainWhenUnmeasured(t *testing.T) {
s := songRef(dbq.Track{Title: "Song", FilePath: "/m/a.mp3"}, "Album", "Artist", library.ReplayGain{})
if s.ReplayGain != nil {
t.Fatalf("unmeasured song carries replayGain %+v", s.ReplayGain)
}
b, _ := json.Marshal(s)
x, _ := xml.Marshal(s)
if strings.Contains(string(b), "replayGain") || strings.Contains(string(x), "replayGain") {
t.Errorf("unmeasured song encodes replayGain: %s / %s", b, x)
}
}
+2 -1
View File
@@ -191,6 +191,7 @@ func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]Son
if err != nil {
return nil, nil, nil, err
}
gains := replayGains(ctx, q, trackRows)
for _, t := range trackRows {
album, err := q.GetAlbumByID(ctx, t.AlbumID)
if err != nil {
@@ -200,7 +201,7 @@ func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]Son
if err != nil {
return nil, nil, nil, err
}
songs = append(songs, songRef(t, album.Title, artist.Name))
songs = append(songs, songRef(t, album.Title, artist.Name, gains[t.ID]))
}
albumRows, err := q.ListLikedAlbumRows(ctx, dbq.ListLikedAlbumRowsParams{
+40 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// IDs on the wire are the bare UUID string. Subsonic endpoints know from
@@ -180,6 +181,20 @@ type SongRef struct {
BitRate int `json:"bitRate,omitempty" xml:"bitRate,attr,omitempty"`
IsDir bool `json:"isDir" xml:"isDir,attr"`
Type string `json:"type" xml:"type,attr"`
// ReplayGain is OpenSubsonic's replayGain (M464 #4997): Minstrel's own
// measurements, so third-party clients that level by ReplayGain do it
// from the same numbers the Minstrel apps use. Omitted until measured.
ReplayGain *ReplayGain `json:"replayGain,omitempty" xml:"replayGain,omitempty"`
}
// ReplayGain is the OpenSubsonic replayGain object: gains in dB to the
// -18 LUFS ReplayGain 2.0 reference, peaks as linear amplitude. A field with
// no value is omitted.
type ReplayGain struct {
TrackGain *float32 `json:"trackGain,omitempty" xml:"trackGain,attr,omitempty"`
AlbumGain *float32 `json:"albumGain,omitempty" xml:"albumGain,attr,omitempty"`
TrackPeak *float32 `json:"trackPeak,omitempty" xml:"trackPeak,attr,omitempty"`
AlbumPeak *float32 `json:"albumPeak,omitempty" xml:"albumPeak,attr,omitempty"`
}
type SongResponse struct {
@@ -284,7 +299,10 @@ func albumDetail(a dbq.Album, artistName string, songs []SongRef) AlbumDetail {
}
}
func songRef(t dbq.Track, albumTitle, artistName string) SongRef {
// songRef takes the track's gains as an argument rather than looking them up,
// so no response that lists songs can leave them out by forgetting a step:
// each caller fetches them once for its whole list (replayGains).
func songRef(t dbq.Track, albumTitle, artistName string, g library.ReplayGain) SongRef {
s := SongRef{
ID: uuidToID(t.ID),
Parent: uuidToID(t.AlbumID),
@@ -313,9 +331,30 @@ func songRef(t dbq.Track, albumTitle, artistName string) SongRef {
if t.Genre != nil {
s.Genre = *t.Genre
}
if !g.Empty() {
s.ReplayGain = &ReplayGain{
TrackGain: g.TrackGain, AlbumGain: g.AlbumGain,
TrackPeak: g.TrackPeak, AlbumPeak: g.AlbumPeak,
}
}
return s
}
// replayGains fetches the gains for a list of tracks in one query. A failed
// lookup yields no gains rather than failing the response: the songs still
// play, just unleveled, which is how they played before gains existed.
func replayGains(ctx context.Context, q *dbq.Queries, tracks []dbq.Track) map[pgtype.UUID]library.ReplayGain {
ids := make([]pgtype.UUID, len(tracks))
for i, t := range tracks {
ids[i] = t.ID
}
gains, err := library.ReplayGainForTracks(ctx, q, ids)
if err != nil {
return map[pgtype.UUID]library.ReplayGain{}
}
return gains
}
// coverArtID returns the album UUID as the cover-art key. getCoverArt uses
// the album row to find art either in cover_art_path (when the scanner sets
// it) or via sidecar lookup in the album directory, so emitting the id