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
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user