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