From e3aa8629d3ca2ee0812eef9dbfcc25aea6d759a5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 13:54:05 -0400 Subject: [PATCH] feat(api): deliver loudness gains to every client (M464 #4997) 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 --- internal/api/api.go | 3 + internal/api/library_sync.go | 13 +- internal/api/library_sync_views.go | 19 ++- internal/api/library_sync_views_test.go | 40 +++++- internal/api/replay_gain.go | 54 ++++++++ internal/api/replay_gain_test.go | 70 ++++++++++ internal/db/dbq/loudness.sql.go | 149 +++++++++++++++++++-- internal/db/queries/loudness.sql | 32 ++++- internal/library/album_loudness.go | 58 +++++++- internal/library/album_loudness_test.go | 32 ++++- internal/library/loudness.go | 16 ++- internal/library/loudness_backfill.go | 2 +- internal/library/loudness_backfill_test.go | 13 ++ internal/library/replaygain.go | 102 ++++++++++++++ internal/library/replaygain_test.go | 122 +++++++++++++++++ internal/subsonic/browse.go | 8 +- internal/subsonic/replaygain_test.go | 58 ++++++++ internal/subsonic/star.go | 3 +- internal/subsonic/types.go | 41 +++++- 19 files changed, 797 insertions(+), 38 deletions(-) create mode 100644 internal/api/replay_gain.go create mode 100644 internal/api/replay_gain_test.go create mode 100644 internal/library/replaygain.go create mode 100644 internal/library/replaygain_test.go create mode 100644 internal/subsonic/replaygain_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 65d4240b..a495c2fa 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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. diff --git a/internal/api/library_sync.go b/internal/api/library_sync.go index 96363ad1..932d7b93 100644 --- a/internal/api/library_sync.go +++ b/internal/api/library_sync.go @@ -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) } } diff --git a/internal/api/library_sync_views.go b/internal/api/library_sync_views.go index 1e8520f9..c9038c3c 100644 --- a/internal/api/library_sync_views.go +++ b/internal/api/library_sync_views.go @@ -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, } } diff --git a/internal/api/library_sync_views_test.go b/internal/api/library_sync_views_test.go index bd322d23..fe627d45 100644 --- a/internal/api/library_sync_views_test.go +++ b/internal/api/library_sync_views_test.go @@ -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{ diff --git a/internal/api/replay_gain.go b/internal/api/replay_gain.go new file mode 100644 index 00000000..38009db1 --- /dev/null +++ b/internal/api/replay_gain.go @@ -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}) +} diff --git a/internal/api/replay_gain_test.go b/internal/api/replay_gain_test.go new file mode 100644 index 00000000..150997bf --- /dev/null +++ b/internal/api/replay_gain_test.go @@ -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) + } +} diff --git a/internal/db/dbq/loudness.sql.go b/internal/db/dbq/loudness.sql.go index e4aa5f71..59d3c143 100644 --- a/internal/db/dbq/loudness.sql.go +++ b/internal/db/dbq/loudness.sql.go @@ -11,22 +11,13 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const deleteOrphanAlbumLoudness = `-- name: DeleteOrphanAlbumLoudness :execrows -DELETE FROM album_loudness a - WHERE NOT EXISTS ( - SELECT 1 FROM tracks t WHERE t.album_id = a.album_id AND t.missing_since IS NULL - ) +const deleteAlbumLoudness = `-- name: DeleteAlbumLoudness :exec +DELETE FROM album_loudness WHERE album_id = ANY($1::uuid[]) ` -// Album rows whose album has no present track left (every file missing). The -// album row itself survives a missing file, so its loudness would otherwise -// stay behind describing tracks that are gone; it is recomputed if they return. -func (q *Queries) DeleteOrphanAlbumLoudness(ctx context.Context) (int64, error) { - result, err := q.db.Exec(ctx, deleteOrphanAlbumLoudness) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil +func (q *Queries) DeleteAlbumLoudness(ctx context.Context, albumIds []pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteAlbumLoudness, albumIds) + return err } const deleteTrackLoudness = `-- name: DeleteTrackLoudness :exec @@ -40,6 +31,56 @@ func (q *Queries) DeleteTrackLoudness(ctx context.Context, trackID pgtype.UUID) return err } +const getAlbumLoudness = `-- name: GetAlbumLoudness :one +SELECT integrated_lufs, true_peak_dbtp FROM album_loudness WHERE album_id = $1 +` + +type GetAlbumLoudnessRow struct { + IntegratedLufs *float32 + TruePeakDbtp *float32 +} + +// The stored album values, read before a recompute so a sync change is +// logged only when they actually move. +func (q *Queries) GetAlbumLoudness(ctx context.Context, albumID pgtype.UUID) (GetAlbumLoudnessRow, error) { + row := q.db.QueryRow(ctx, getAlbumLoudness, albumID) + var i GetAlbumLoudnessRow + err := row.Scan(&i.IntegratedLufs, &i.TruePeakDbtp) + return i, err +} + +const getAlbumLoudnessByIDs = `-- name: GetAlbumLoudnessByIDs :many +SELECT album_id, integrated_lufs, true_peak_dbtp + FROM album_loudness + WHERE album_id = ANY($1::uuid[]) +` + +type GetAlbumLoudnessByIDsRow struct { + AlbumID pgtype.UUID + IntegratedLufs *float32 + TruePeakDbtp *float32 +} + +func (q *Queries) GetAlbumLoudnessByIDs(ctx context.Context, ids []pgtype.UUID) ([]GetAlbumLoudnessByIDsRow, error) { + rows, err := q.db.Query(ctx, getAlbumLoudnessByIDs, ids) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAlbumLoudnessByIDsRow + for rows.Next() { + var i GetAlbumLoudnessByIDsRow + if err := rows.Scan(&i.AlbumID, &i.IntegratedLufs, &i.TruePeakDbtp); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getLoudnessCoverage = `-- name: GetLoudnessCoverage :one SELECT count(*)::bigint AS total, count(*) FILTER ( @@ -101,6 +142,55 @@ func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, err return i, err } +const getReplayGainByTrackIDs = `-- name: GetReplayGainByTrackIDs :many +SELECT t.id, + tl.integrated_lufs AS track_lufs, + tl.true_peak_dbtp AS track_peak_dbtp, + al.integrated_lufs AS album_lufs, + al.true_peak_dbtp AS album_peak_dbtp + FROM tracks t + LEFT JOIN track_loudness tl ON tl.track_id = t.id + LEFT JOIN album_loudness al ON al.album_id = t.album_id + WHERE t.id = ANY($1::uuid[]) +` + +type GetReplayGainByTrackIDsRow struct { + ID pgtype.UUID + TrackLufs *float32 + TrackPeakDbtp *float32 + AlbumLufs *float32 + AlbumPeakDbtp *float32 +} + +// Track and album loudness for a set of tracks, for every surface that hands +// gains to a client (#4997). A measurement from an older analysis version is +// still delivered: it is a better gain than none until the backfill redoes it. +func (q *Queries) GetReplayGainByTrackIDs(ctx context.Context, ids []pgtype.UUID) ([]GetReplayGainByTrackIDsRow, error) { + rows, err := q.db.Query(ctx, getReplayGainByTrackIDs, ids) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetReplayGainByTrackIDsRow + for rows.Next() { + var i GetReplayGainByTrackIDsRow + if err := rows.Scan( + &i.ID, + &i.TrackLufs, + &i.TrackPeakDbtp, + &i.AlbumLufs, + &i.AlbumPeakDbtp, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAlbumLoudnessInputs = `-- name: ListAlbumLoudnessInputs :many SELECT t.id, (l.track_id IS NOT NULL)::boolean AS settled, @@ -215,6 +305,37 @@ func (q *Queries) ListAlbumsNeedingLoudness(ctx context.Context, arg ListAlbumsN return items, nil } +const listOrphanAlbumLoudness = `-- name: ListOrphanAlbumLoudness :many +SELECT a.album_id + FROM album_loudness a + WHERE NOT EXISTS ( + SELECT 1 FROM tracks t WHERE t.album_id = a.album_id AND t.missing_since IS NULL + ) +` + +// Album rows whose album has no present track left (every file missing). The +// album row itself survives a missing file, so its loudness would otherwise +// stay behind describing tracks that are gone; it is recomputed if they return. +func (q *Queries) ListOrphanAlbumLoudness(ctx context.Context) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listOrphanAlbumLoudness) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var album_id pgtype.UUID + if err := rows.Scan(&album_id); err != nil { + return nil, err + } + items = append(items, album_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listTracksNeedingLoudness = `-- name: ListTracksNeedingLoudness :many SELECT t.id, t.file_path, t.duration_ms FROM tracks t diff --git a/internal/db/queries/loudness.sql b/internal/db/queries/loudness.sql index 94aefb65..af33fb1b 100644 --- a/internal/db/queries/loudness.sql +++ b/internal/db/queries/loudness.sql @@ -133,11 +133,39 @@ ON CONFLICT (album_id) DO UPDATE SET inputs_digest = EXCLUDED.inputs_digest, computed_at = now(); --- name: DeleteOrphanAlbumLoudness :execrows +-- name: ListOrphanAlbumLoudness :many -- Album rows whose album has no present track left (every file missing). The -- album row itself survives a missing file, so its loudness would otherwise -- stay behind describing tracks that are gone; it is recomputed if they return. -DELETE FROM album_loudness a +SELECT a.album_id + FROM album_loudness a WHERE NOT EXISTS ( SELECT 1 FROM tracks t WHERE t.album_id = a.album_id AND t.missing_since IS NULL ); + +-- name: DeleteAlbumLoudness :exec +DELETE FROM album_loudness WHERE album_id = ANY(sqlc.arg(album_ids)::uuid[]); + +-- name: GetAlbumLoudness :one +-- The stored album values, read before a recompute so a sync change is +-- logged only when they actually move. +SELECT integrated_lufs, true_peak_dbtp FROM album_loudness WHERE album_id = $1; + +-- name: GetReplayGainByTrackIDs :many +-- Track and album loudness for a set of tracks, for every surface that hands +-- gains to a client (#4997). A measurement from an older analysis version is +-- still delivered: it is a better gain than none until the backfill redoes it. +SELECT t.id, + tl.integrated_lufs AS track_lufs, + tl.true_peak_dbtp AS track_peak_dbtp, + al.integrated_lufs AS album_lufs, + al.true_peak_dbtp AS album_peak_dbtp + FROM tracks t + LEFT JOIN track_loudness tl ON tl.track_id = t.id + LEFT JOIN album_loudness al ON al.album_id = t.album_id + WHERE t.id = ANY(sqlc.arg(ids)::uuid[]); + +-- name: GetAlbumLoudnessByIDs :many +SELECT album_id, integrated_lufs, true_peak_dbtp + FROM album_loudness + WHERE album_id = ANY(sqlc.arg(ids)::uuid[]); diff --git a/internal/library/album_loudness.go b/internal/library/album_loudness.go index 19ee5df4..7d029e0c 100644 --- a/internal/library/album_loudness.go +++ b/internal/library/album_loudness.go @@ -2,11 +2,14 @@ package library import ( "context" + "errors" "fmt" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) // Album loudness (M464 #4996). @@ -117,24 +120,46 @@ func (w *LoudnessBackfillWorker) albumPass(ctx context.Context) (AlbumLoudnessRe } for _, row := range rows { res.Recomputed++ - if err := storeAlbumLoudness(ctx, q, row.AlbumID, row.Digest, &res); err != nil { + if err := w.storeAlbumLoudness(ctx, q, row.AlbumID, row.Digest, &res); err != nil { res.Failed++ w.logger.Warn("album loudness: recompute failed", "album_id", row.AlbumID, "err", err) } } after = rows[len(rows)-1].AlbumID } - n, err := q.DeleteOrphanAlbumLoudness(ctx) + // Listed, logged, then deleted: the same log-first order as above. + orphans, err := q.ListOrphanAlbumLoudness(ctx) if err != nil { + return res, fmt.Errorf("list orphan album loudness: %w", err) + } + if len(orphans) == 0 { + return res, nil + } + ids := make([]string, len(orphans)) + for i, id := range orphans { + ids[i] = syncpkg.FormatUUID(id) + } + if err := syncpkg.LogChanges(ctx, w.pool, syncpkg.EntityAlbum, ids, syncpkg.OpUpsert); err != nil { + return res, fmt.Errorf("log orphan album changes: %w", err) + } + if err := q.DeleteAlbumLoudness(ctx, orphans); err != nil { return res, fmt.Errorf("drop orphan album loudness: %w", err) } - res.Orphans = n + res.Orphans = int64(len(orphans)) return res, nil } -func storeAlbumLoudness( +// storeAlbumLoudness recomputes one album. The sync feed hears about it only +// when the values clients see move: during the backfill an album's digest +// changes with every track measured, and most of those recomputes still end +// with no album value. +func (w *LoudnessBackfillWorker) storeAlbumLoudness( ctx context.Context, q *dbq.Queries, albumID pgtype.UUID, digest string, res *AlbumLoudnessResult, ) error { + before, err := q.GetAlbumLoudness(ctx, albumID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("read stored values: %w", err) + } inputs, err := q.ListAlbumLoudnessInputs(ctx, dbq.ListAlbumLoudnessInputsParams{ CurrentVersion: loudnessVersion, AlbumID: albumID, @@ -143,6 +168,14 @@ func storeAlbumLoudness( return fmt.Errorf("read inputs: %w", err) } a := computeAlbumLoudness(inputs) + // Logged before the write, for the reason storeLoudness gives (#2704). A + // failed log stores nothing, so the digest still differs and the next + // pass tries again. + if albumVisibleChange(before, a) { + if err := syncpkg.LogChange(ctx, w.pool, syncpkg.EntityAlbum, syncpkg.FormatUUID(albumID), syncpkg.OpUpsert); err != nil { + return fmt.Errorf("log album change: %w", err) + } + } if err := q.UpsertAlbumLoudness(ctx, dbq.UpsertAlbumLoudnessParams{ AlbumID: albumID, IntegratedLufs: a.integratedLUFS, @@ -160,3 +193,20 @@ func storeAlbumLoudness( } return nil } + +// albumVisibleChange reports whether clients would see different album gains. +// The peak is only delivered beside a loudness, so a waiting album whose peak +// moves as tracks are measured has nothing new to tell anyone. +func albumVisibleChange(before dbq.GetAlbumLoudnessRow, after albumLoudness) bool { + if !sameFloat(before.IntegratedLufs, after.integratedLUFS) { + return true + } + return after.integratedLUFS != nil && !sameFloat(before.TruePeakDbtp, after.truePeakDBTP) +} + +func sameFloat(a, b *float32) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} diff --git a/internal/library/album_loudness_test.go b/internal/library/album_loudness_test.go index 50badf13..a943e024 100644 --- a/internal/library/album_loudness_test.go +++ b/internal/library/album_loudness_test.go @@ -9,6 +9,7 @@ import ( "testing" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) // histAt builds a histogram of n blocks all at one loudness. @@ -162,6 +163,17 @@ func TestAlbumLoudness_Integration(t *testing.T) { return res } + albumChanges := func() int { + t.Helper() + var n int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_changes + WHERE entity_type = 'album' AND entity_id = $1`, + syncpkg.FormatUUID(album.ID)).Scan(&n); err != nil { + t.Fatalf("count album changes: %v", err) + } + return n + } + second := addTrack("b", album) measure(first, -10, -1) @@ -173,6 +185,11 @@ func TestAlbumLoudness_Integration(t *testing.T) { t.Fatalf("waiting album = lufs %v, %d/%d settled; want nil, 1/2", lufs, settled, total) } + // Still no album value: clients have nothing new to read (#4997). + if n := albumChanges(); n != 0 { + t.Fatalf("a waiting album logged %d sync changes, want 0", n) + } + // 2. Measuring the second track changes the digest; the album is leveled. measure(second, -10, -0.5) if res := pass(); res.Recomputed != 1 || res.Leveled != 1 { @@ -183,10 +200,18 @@ func TestAlbumLoudness_Integration(t *testing.T) { t.Fatalf("leveled album = lufs %v peak %v, want -10 and -0.5", lufs, peak) } - // 3. Nothing changed: nothing recomputed. + // Leveled: clients are told, once. + if n := albumChanges(); n != 1 { + t.Fatalf("leveling the album logged %d sync changes, want 1", n) + } + + // 3. Nothing changed: nothing recomputed, nothing logged. if res := pass(); res.Recomputed != 0 { t.Fatalf("idle pass = %+v, want nothing recomputed", res) } + if n := albumChanges(); n != 1 { + t.Fatalf("an idle pass logged album changes (now %d), want still 1", n) + } // 4. A track joins (here, retagged onto this album): recomputed. other, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Other", SortTitle: "Other", ArtistID: artist.ID}) @@ -232,9 +257,14 @@ func TestAlbumLoudness_Integration(t *testing.T) { if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", first.ID); err != nil { t.Fatalf("mark missing: %v", err) } + before := albumChanges() if res := pass(); res.Orphans != 1 { t.Fatalf("pass with every track missing = %+v, want 1 orphan dropped", res) } + // Dropping the row takes the album gain away, so clients are told. + if n := albumChanges(); n != before+1 { + t.Fatalf("dropping the orphan logged %d album changes, want 1", n-before) + } var n int if err := pool.QueryRow(ctx, "SELECT count(*) FROM album_loudness WHERE album_id = $1", album.ID).Scan(&n); err != nil { t.Fatalf("count: %v", err) diff --git a/internal/library/loudness.go b/internal/library/loudness.go index 2fd50032..78893553 100644 --- a/internal/library/loudness.go +++ b/internal/library/loudness.go @@ -17,6 +17,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) // Loudness analysis (M464 #4995). @@ -351,10 +352,15 @@ const ( // An inconclusive attempt leaves any existing row alone. The scan deletes a // row when its file changes, so a row still here describes these bytes, and a // value from an older method is a better gain than none until it is redone. +// +// A stored measurement is logged to the sync feed as a track upsert, so +// clients that cache the library (Android) pick up the new gain (#4997). If +// the log fails nothing is stored, and the backfill tries the track again. func storeLoudness( - ctx context.Context, q *dbq.Queries, logger *slog.Logger, + ctx context.Context, db dbq.DBTX, logger *slog.Logger, trackID pgtype.UUID, path string, r loudnessResult, ) loudnessOutcome { + q := dbq.New(db) if r.err != nil { logger.Warn("loudness: analysis failed", "path", path, "err", r.err) if r.inconclusive() { @@ -388,6 +394,14 @@ func storeLoudness( } } } + // Logged before the write, as reconcile does (#2704): a log that then + // finds the write failed costs clients one wasted re-read, but a write + // that lands with no log is never retried, since the track is settled, and + // clients would never learn its gain. + if err := syncpkg.LogChange(ctx, db, syncpkg.EntityTrack, syncpkg.FormatUUID(trackID), syncpkg.OpUpsert); err != nil { + logger.Warn("loudness: LogChange track upsert failed; not storing", "path", path, "err", err) + return loudnessStoreFailed + } if err := q.UpsertTrackLoudness(ctx, params); err != nil { logger.Warn("loudness: storing measurement failed", "path", path, "err", err) return loudnessStoreFailed diff --git a/internal/library/loudness_backfill.go b/internal/library/loudness_backfill.go index 9bf05025..3379c973 100644 --- a/internal/library/loudness_backfill.go +++ b/internal/library/loudness_backfill.go @@ -187,7 +187,7 @@ func (w *LoudnessBackfillWorker) pass(ctx context.Context) (BackfillLoudnessResu w.logger.Error("loudness backfill: track panicked", "path", row.FilePath, "panic", r) } }() - outcome := storeLoudness(ctx, q, w.logger, row.ID, row.FilePath, + outcome := storeLoudness(ctx, w.pool, w.logger, row.ID, row.FilePath, w.analyzeFile(ctx, row.FilePath, row.DurationMs)) mu.Lock() res.add(outcome) diff --git a/internal/library/loudness_backfill_test.go b/internal/library/loudness_backfill_test.go index 4a052bec..88451475 100644 --- a/internal/library/loudness_backfill_test.go +++ b/internal/library/loudness_backfill_test.go @@ -11,6 +11,7 @@ import ( "testing" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) // TestLoudnessBackfill_Integration pins which tracks a pass measures, what it @@ -130,6 +131,18 @@ func TestLoudnessBackfill_Integration(t *testing.T) { gotLUFS, gotPeak, gotHist, gotStart, gotVersion) } + // Each stored measurement told the sync feed, so caching clients re-read + // the track and pick up its gain (#4997). + var logged int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_changes + WHERE entity_type = 'track' AND op = 'upsert' AND entity_id = $1`, + syncpkg.FormatUUID(stale.ID)).Scan(&logged); err != nil { + t.Fatalf("count sync changes: %v", err) + } + if logged != 1 { + t.Errorf("re-measuring stale.mp3 logged %d track changes, want 1", logged) + } + // 2. A pass after a complete one is a no-op. res, err = w.pass(ctx) if err != nil { diff --git a/internal/library/replaygain.go b/internal/library/replaygain.go new file mode 100644 index 00000000..8c98ae51 --- /dev/null +++ b/internal/library/replaygain.go @@ -0,0 +1,102 @@ +package library + +import ( + "context" + "math" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// ReplayGain delivery (M464 #4997). +// +// Clients are handed loudness in the ReplayGain 2.0 form every player already +// understands: a gain in dB that brings the track (or album) to the RG2 +// reference of -18 LUFS, and a linear peak. A client aiming at another target +// adds (target - ReplayGainReferenceLUFS) to the gain. The same numbers go to +// the native API, the sync feed and OpenSubsonic's replayGain, so every client +// levels a track identically. + +// ReplayGainReferenceLUFS is the RG2 reference loudness gains are relative to. +const ReplayGainReferenceLUFS = -18.0 + +// ReplayGain is one track's gains. Each field is nil when there is nothing to +// say: not yet measured, silent, or (for the album pair) an album still +// waiting on a track. AlbumPeak travels only with AlbumGain. +type ReplayGain struct { + TrackGain *float32 `json:"track_gain,omitempty"` + TrackPeak *float32 `json:"track_peak,omitempty"` + AlbumGain *float32 `json:"album_gain,omitempty"` + AlbumPeak *float32 `json:"album_peak,omitempty"` +} + +// Empty reports whether no gain is known at all. +func (g ReplayGain) Empty() bool { + return g.TrackGain == nil && g.AlbumGain == nil +} + +// GainDB converts an integrated loudness to its RG2 gain, to 0.01 dB. +func GainDB(lufs *float32) *float32 { + if lufs == nil { + return nil + } + v := float32(math.Round((ReplayGainReferenceLUFS-float64(*lufs))*100) / 100) + return &v +} + +// LinearPeak converts a true peak in dBTP to the linear amplitude ReplayGain +// peaks are written in (1.0 is full scale), to 4 decimals. +func LinearPeak(dbtp *float32) *float32 { + if dbtp == nil { + return nil + } + v := float32(math.Round(math.Pow(10, float64(*dbtp)/20)*10000) / 10000) + return &v +} + +// ReplayGainForTracks returns the gains for each of ids that has any. Tracks +// with none are absent from the map, so a lookup of a missing key yields the +// zero ReplayGain: no adjustment. +func ReplayGainForTracks(ctx context.Context, q *dbq.Queries, ids []pgtype.UUID) (map[pgtype.UUID]ReplayGain, error) { + out := make(map[pgtype.UUID]ReplayGain, len(ids)) + if len(ids) == 0 { + return out, nil + } + rows, err := q.GetReplayGainByTrackIDs(ctx, ids) + if err != nil { + return nil, err + } + for _, r := range rows { + g := ReplayGain{TrackGain: GainDB(r.TrackLufs)} + if g.TrackGain != nil { + g.TrackPeak = LinearPeak(r.TrackPeakDbtp) + } + if g.AlbumGain = GainDB(r.AlbumLufs); g.AlbumGain != nil { + g.AlbumPeak = LinearPeak(r.AlbumPeakDbtp) + } + if !g.Empty() { + out[r.ID] = g + } + } + return out, nil +} + +// ReplayGainForAlbums returns the album pair for each of albumIDs that has an +// album loudness. The track fields are left nil. +func ReplayGainForAlbums(ctx context.Context, q *dbq.Queries, albumIDs []pgtype.UUID) (map[pgtype.UUID]ReplayGain, error) { + out := make(map[pgtype.UUID]ReplayGain, len(albumIDs)) + if len(albumIDs) == 0 { + return out, nil + } + rows, err := q.GetAlbumLoudnessByIDs(ctx, albumIDs) + if err != nil { + return nil, err + } + for _, r := range rows { + if g := GainDB(r.IntegratedLufs); g != nil { + out[r.AlbumID] = ReplayGain{AlbumGain: g, AlbumPeak: LinearPeak(r.TruePeakDbtp)} + } + } + return out, nil +} diff --git a/internal/library/replaygain_test.go b/internal/library/replaygain_test.go new file mode 100644 index 00000000..ae18d735 --- /dev/null +++ b/internal/library/replaygain_test.go @@ -0,0 +1,122 @@ +package library + +import ( + "context" + "math" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func TestGainDBAndLinearPeak(t *testing.T) { + f := func(v float32) *float32 { return &v } + for _, c := range []struct { + lufs, want float32 + }{ + {-18, 0}, // at the reference: no change + {-9.5, -8.5}, // a loud master is turned down + {-23.456, 5.46}, // a quiet one up, to 0.01 dB + } { + if got := GainDB(f(c.lufs)); got == nil || *got != c.want { + t.Errorf("GainDB(%v) = %v, want %v", c.lufs, got, c.want) + } + } + if GainDB(nil) != nil { + t.Errorf("GainDB(nil) is not nil") + } + for _, c := range []struct { + dbtp, want float32 + }{ + {0, 1}, // full scale + {-6.0206, 0.5}, // half amplitude + {1.2, 1.1482}, // an inter-sample over, above 1.0 + } { + got := LinearPeak(f(c.dbtp)) + if got == nil || math.Abs(float64(*got-c.want)) > 0.0001 { + t.Errorf("LinearPeak(%v) = %v, want %v", c.dbtp, got, c.want) + } + } + if LinearPeak(nil) != nil { + t.Errorf("LinearPeak(nil) is not nil") + } +} + +func TestReplayGainForTracks_Integration(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + q := dbq.New(pool) + dir := t.TempDir() + + measured, album, artist := seedTrack(t, pool, filepath.Join(dir, "measured.mp3")) + add := func(name string) dbq.Track { + t.Helper() + tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: name, AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("track %s: %v", name, err) + } + return tr + } + older := add("older") + unmeasured := add("unmeasured") + f := func(v float32) *float32 { return &v } + for _, m := range []struct { + tr dbq.Track + lufs float32 + version int16 + }{ + {measured, -12, loudnessVersion}, + // A measurement by an older method is still a better gain than none. + {older, -20, loudnessVersion - 1}, + } { + if err := q.UpsertTrackLoudness(ctx, dbq.UpsertTrackLoudnessParams{ + TrackID: m.tr.ID, IntegratedLufs: f(m.lufs), TruePeakDbtp: f(-1), AnalysisVersion: m.version, + }); err != nil { + t.Fatalf("seed: %v", err) + } + } + + gains, err := ReplayGainForTracks(ctx, q, []pgtype.UUID{measured.ID, older.ID, unmeasured.ID}) + if err != nil { + t.Fatalf("lookup: %v", err) + } + if g := gains[measured.ID]; g.TrackGain == nil || *g.TrackGain != -6 || g.TrackPeak == nil { + t.Errorf("measured track gains = %+v, want track gain -6 with a peak", g) + } + if g := gains[older.ID]; g.TrackGain == nil || *g.TrackGain != 2 { + t.Errorf("older-version track gains = %+v, want track gain 2", g) + } + if _, ok := gains[unmeasured.ID]; ok { + t.Errorf("unmeasured track has an entry; absent means no adjustment") + } + // No album value yet (none computed): the album pair is absent. + if g := gains[measured.ID]; g.AlbumGain != nil || g.AlbumPeak != nil { + t.Errorf("album pair present before album loudness exists: %+v", g) + } + + if err := q.UpsertAlbumLoudness(ctx, dbq.UpsertAlbumLoudnessParams{ + AlbumID: album.ID, IntegratedLufs: f(-14), TruePeakDbtp: f(-0.5), + TracksTotal: 3, TracksSettled: 3, InputsDigest: "x", + }); err != nil { + t.Fatalf("seed album: %v", err) + } + gains, err = ReplayGainForTracks(ctx, q, []pgtype.UUID{unmeasured.ID}) + if err != nil { + t.Fatalf("lookup: %v", err) + } + if g := gains[unmeasured.ID]; g.TrackGain != nil || g.AlbumGain == nil || *g.AlbumGain != -4 || g.AlbumPeak == nil { + t.Errorf("unmeasured track on a leveled album = %+v, want only the album pair (gain -4)", g) + } + byAlbum, err := ReplayGainForAlbums(ctx, q, []pgtype.UUID{album.ID}) + if err != nil { + t.Fatalf("album lookup: %v", err) + } + if g := byAlbum[album.ID]; g.AlbumGain == nil || *g.AlbumGain != -4 || g.TrackGain != nil { + t.Errorf("album gains = %+v, want album gain -4 and no track pair", g) + } +} diff --git a/internal/subsonic/browse.go b/internal/subsonic/browse.go index 70225084..2afb0ed8 100644 --- a/internal/subsonic/browse.go +++ b/internal/subsonic/browse.go @@ -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])) } } diff --git a/internal/subsonic/replaygain_test.go b/internal/subsonic/replaygain_test.go new file mode 100644 index 00000000..774093c5 --- /dev/null +++ b/internal/subsonic/replaygain_test.go @@ -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), ``) { + 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) + } +} diff --git a/internal/subsonic/star.go b/internal/subsonic/star.go index b2b333f5..7d717a3a 100644 --- a/internal/subsonic/star.go +++ b/internal/subsonic/star.go @@ -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{ diff --git a/internal/subsonic/types.go b/internal/subsonic/types.go index 19ffb63d..2414fe60 100644 --- a/internal/subsonic/types.go +++ b/internal/subsonic/types.go @@ -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