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
+3
View File
@@ -132,6 +132,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/library/genres", h.handleListGenres)
authed.Get("/library/years", h.handleListAlbumYears)
authed.Get("/library/sync", h.handleLibrarySync)
// Before /tracks/{id} for readability; chi prefers the static
// segment either way.
authed.Get("/tracks/replay-gain", h.handleGetReplayGain)
authed.Get("/tracks/{id}", h.handleGetTrack)
// /tracks/{id}/stream is mounted above with OptionalUser so
// it can accept either a session or a signed token.
+11 -2
View File
@@ -10,6 +10,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
@@ -151,8 +152,12 @@ func (h *handlers) hydrateUpserts(
if err != nil {
return nil, err
}
gains, err := library.ReplayGainForAlbums(ctx, q, uuids)
if err != nil {
return nil, err
}
for _, a := range albums {
b, _ := json.Marshal(toAlbumSyncView(a))
b, _ := json.Marshal(toAlbumSyncView(a, gains[a.ID]))
out["album"] = append(out["album"], b)
}
}
@@ -162,8 +167,12 @@ func (h *handlers) hydrateUpserts(
if err != nil {
return nil, err
}
gains, err := library.ReplayGainForTracks(ctx, q, uuids)
if err != nil {
return nil, err
}
for _, t := range tracks {
b, _ := json.Marshal(toTrackSyncView(t))
b, _ := json.Marshal(toTrackSyncView(t, gains[t.ID]))
out["track"] = append(out["track"], b)
}
}
+17 -2
View File
@@ -18,6 +18,7 @@ package api
import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
@@ -49,9 +50,14 @@ type albumSyncView struct {
ReleaseDate *string `json:"release_date"`
CoverArtPath *string `json:"cover_art_path"`
Mbid *string `json:"mbid"`
// AlbumGain and AlbumPeak are the album's ReplayGain 2.0 values (#4997):
// dB to the -18 LUFS reference, and a linear peak. Null until every track
// on the album is measured.
AlbumGain *float32 `json:"album_gain"`
AlbumPeak *float32 `json:"album_peak"`
}
func toAlbumSyncView(a dbq.Album) albumSyncView {
func toAlbumSyncView(a dbq.Album, g library.ReplayGain) albumSyncView {
var releaseDate *string
if a.ReleaseDate.Valid {
s := a.ReleaseDate.Time.Format("2006-01-02")
@@ -65,6 +71,8 @@ func toAlbumSyncView(a dbq.Album) albumSyncView {
ReleaseDate: releaseDate,
CoverArtPath: a.CoverArtPath,
Mbid: a.Mbid,
AlbumGain: g.AlbumGain,
AlbumPeak: g.AlbumPeak,
}
}
@@ -92,9 +100,14 @@ type trackSyncView struct {
// track is playable, which is a yes/no. The "gone since" clock is an
// operator concern and lives on the admin surface.
Missing bool `json:"missing"`
// TrackGain and TrackPeak are the track's ReplayGain 2.0 values (#4997).
// Null until the track is measured. The album's pair rides on the album
// view, since it changes when the album does, not when this track does.
TrackGain *float32 `json:"track_gain"`
TrackPeak *float32 `json:"track_peak"`
}
func toTrackSyncView(t dbq.Track) trackSyncView {
func toTrackSyncView(t dbq.Track, g library.ReplayGain) trackSyncView {
return trackSyncView{
ID: syncpkg.FormatUUID(t.ID),
AlbumID: syncpkg.FormatUUID(t.AlbumID),
@@ -107,6 +120,8 @@ func toTrackSyncView(t dbq.Track) trackSyncView {
FileFormat: t.FileFormat,
Genre: t.Genre,
Missing: t.MissingSince.Valid,
TrackGain: g.TrackGain,
TrackPeak: g.TrackPeak,
}
}
+34 -6
View File
@@ -8,6 +8,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// These tests pin the wire-format keys for /api/library/sync upserts.
@@ -53,13 +54,13 @@ func TestArtistSyncView_WireKeys(t *testing.T) {
func TestAlbumSyncView_WireKeys(t *testing.T) {
a := dbq.Album{ID: validUUID, ArtistID: validUUID, Title: "Drukqs", SortTitle: "Drukqs"}
b, err := json.Marshal(toAlbumSyncView(a))
b, err := json.Marshal(toAlbumSyncView(a, library.ReplayGain{}))
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, "album", b, []string{
"id", "artist_id", "title", "sort_title",
"release_date", "cover_art_path", "mbid",
"release_date", "cover_art_path", "mbid", "album_gain", "album_peak",
})
}
@@ -69,14 +70,14 @@ func TestTrackSyncView_WireKeys(t *testing.T) {
Title: "Avril 14th", DurationMs: 121_000,
FilePath: "x", FileFormat: "flac",
}
b, err := json.Marshal(toTrackSyncView(tr))
b, err := json.Marshal(toTrackSyncView(tr, library.ReplayGain{}))
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, "track", b, []string{
"id", "album_id", "artist_id", "title", "duration_ms",
"track_number", "disc_number", "file_path", "file_format", "genre",
"missing",
"missing", "track_gain", "track_peak",
})
}
@@ -85,17 +86,44 @@ func TestTrackSyncView_WireKeys(t *testing.T) {
// client's library, a missing one stays playable and fails at the speaker.
func TestTrackSyncView_MissingReflectsTheMark(t *testing.T) {
present := dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}
if toTrackSyncView(present).Missing {
if toTrackSyncView(present, library.ReplayGain{}).Missing {
t.Error("a track with no missing_since must not be marked missing")
}
gone := present
gone.MissingSince = pgtype.Timestamptz{Time: time.Now(), Valid: true}
if !toTrackSyncView(gone).Missing {
if !toTrackSyncView(gone, library.ReplayGain{}).Missing {
t.Error("a track with missing_since must be marked missing")
}
}
// The gains are what Android levels playback by, offline included (#4997):
// the track's pair rides on the track view and the album's on the album view.
func TestSyncViews_CarryReplayGain(t *testing.T) {
gain, peak := float32(-3.25), float32(0.9441)
g := library.ReplayGain{TrackGain: &gain, TrackPeak: &peak, AlbumGain: &gain, AlbumPeak: &peak}
tv := toTrackSyncView(dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}, g)
if tv.TrackGain == nil || *tv.TrackGain != gain || tv.TrackPeak == nil || *tv.TrackPeak != peak {
t.Errorf("track view gains = %v/%v, want %v/%v", tv.TrackGain, tv.TrackPeak, gain, peak)
}
av := toAlbumSyncView(dbq.Album{ID: validUUID, ArtistID: validUUID}, g)
if av.AlbumGain == nil || *av.AlbumGain != gain || av.AlbumPeak == nil || *av.AlbumPeak != peak {
t.Errorf("album view gains = %v/%v, want %v/%v", av.AlbumGain, av.AlbumPeak, gain, peak)
}
// Unmeasured: explicit nulls, like every other optional sync field.
b, err := json.Marshal(toTrackSyncView(dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}, library.ReplayGain{}))
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
if v, ok := m["track_gain"]; !ok || v != nil {
t.Errorf("unmeasured track_gain = %v (present %v), want an explicit null", v, ok)
}
}
func TestPlaylistSyncView_WireKeys(t *testing.T) {
variant := "discover"
p := dbq.Playlist{
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"net/http"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// maxReplayGainIDs caps one lookup. A player asks for the tracks around the
// one it is about to play, never a whole library.
const maxReplayGainIDs = 200
// replayGainResp is the wire shape for GET /api/tracks/replay-gain. Tracks
// with no gain known are absent from items: no adjustment.
type replayGainResp struct {
Items map[string]library.ReplayGain `json:"items"`
}
// handleGetReplayGain implements GET /api/tracks/replay-gain?ids=a,b,c (M464
// #4997): ReplayGain 2.0 values (dB to -18 LUFS, linear peaks) for each track,
// and for its album when the whole album is measured.
//
// A lookup rather than fields on TrackRef. Tracks reach the player through
// about fifteen surfaces in two wire shapes (TrackRef and playlist entries),
// and only the player uses the gain. One call from the player, made for the
// tracks it is about to play, cannot be forgotten by a surface added later,
// and costs nothing on the pages that never play anything. Android takes the
// same values from the sync feed instead, so they are there offline.
func (h *handlers) handleGetReplayGain(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimSpace(r.URL.Query().Get("ids"))
if raw == "" {
writeJSON(w, http.StatusOK, replayGainResp{Items: map[string]library.ReplayGain{}})
return
}
parts := strings.Split(raw, ",")
if len(parts) > maxReplayGainIDs {
writeErr(w, apierror.BadRequest("too_many_ids", "at most 200 ids per request"))
return
}
ids := stringsToUUIDs(parts)
gains, err := library.ReplayGainForTracks(r.Context(), dbq.New(h.pool), ids)
if err != nil {
writeErrWithLog(w, h.logger, "replay gain: lookup", apierror.InternalMsg("lookup failed", err))
return
}
items := make(map[string]library.ReplayGain, len(gains))
for id, g := range gains {
items[uuidToString(id)] = g
}
writeJSON(w, http.StatusOK, replayGainResp{Items: items})
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func TestGetReplayGain(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "rg1", "pw", false)
measured, _ := seedTrackForRemoveTest(t, h, "rg-measured", "", "")
unmeasured, _ := seedTrackForRemoveTest(t, h, "rg-unmeasured", "", "")
lufs, peak := float32(-12.5), float32(-1)
if err := dbq.New(pool).UpsertTrackLoudness(context.Background(), dbq.UpsertTrackLoudnessParams{
TrackID: measured.ID, IntegratedLufs: &lufs, TruePeakDbtp: &peak, AnalysisVersion: 1,
}); err != nil {
t.Fatalf("seed loudness: %v", err)
}
r := chi.NewRouter()
r.Get("/api/tracks/replay-gain", h.handleGetReplayGain)
get := func(query string) *httptest.ResponseRecorder {
req := withUser(httptest.NewRequest(http.MethodGet, "/api/tracks/replay-gain"+query, nil), user)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec
}
rec := get("?ids=" + uuidToString(measured.ID) + "," + uuidToString(unmeasured.ID) + ",not-a-uuid")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Items map[string]struct {
TrackGain *float32 `json:"track_gain"`
TrackPeak *float32 `json:"track_peak"`
AlbumGain *float32 `json:"album_gain"`
} `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
g, ok := resp.Items[uuidToString(measured.ID)]
if !ok || g.TrackGain == nil || *g.TrackGain != -5.5 || g.TrackPeak == nil {
t.Errorf("measured track = %+v (present %v), want track_gain -5.5 with a peak", g, ok)
}
if g.AlbumGain != nil {
t.Errorf("album_gain present with no album loudness: %v", *g.AlbumGain)
}
if _, ok := resp.Items[uuidToString(unmeasured.ID)]; ok {
t.Errorf("unmeasured track listed; absent means no adjustment")
}
if len(resp.Items) != 1 {
t.Errorf("items = %v, want only the measured track", resp.Items)
}
if rec := get(""); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"items":{}`) {
t.Errorf("no ids: %d %s, want 200 with empty items", rec.Code, rec.Body.String())
}
if rec := get("?ids=" + strings.Repeat("x,", maxReplayGainIDs)); rec.Code != http.StatusBadRequest {
t.Errorf("%d ids: status %d, want 400", maxReplayGainIDs+1, rec.Code)
}
}