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