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