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/genres", h.handleListGenres)
authed.Get("/library/years", h.handleListAlbumYears) authed.Get("/library/years", h.handleListAlbumYears)
authed.Get("/library/sync", h.handleLibrarySync) 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) authed.Get("/tracks/{id}", h.handleGetTrack)
// /tracks/{id}/stream is mounted above with OptionalUser so // /tracks/{id}/stream is mounted above with OptionalUser so
// it can accept either a session or a signed token. // 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/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
) )
@@ -151,8 +152,12 @@ func (h *handlers) hydrateUpserts(
if err != nil { if err != nil {
return nil, err return nil, err
} }
gains, err := library.ReplayGainForAlbums(ctx, q, uuids)
if err != nil {
return nil, err
}
for _, a := range albums { for _, a := range albums {
b, _ := json.Marshal(toAlbumSyncView(a)) b, _ := json.Marshal(toAlbumSyncView(a, gains[a.ID]))
out["album"] = append(out["album"], b) out["album"] = append(out["album"], b)
} }
} }
@@ -162,8 +167,12 @@ func (h *handlers) hydrateUpserts(
if err != nil { if err != nil {
return nil, err return nil, err
} }
gains, err := library.ReplayGainForTracks(ctx, q, uuids)
if err != nil {
return nil, err
}
for _, t := range tracks { for _, t := range tracks {
b, _ := json.Marshal(toTrackSyncView(t)) b, _ := json.Marshal(toTrackSyncView(t, gains[t.ID]))
out["track"] = append(out["track"], b) out["track"] = append(out["track"], b)
} }
} }
+17 -2
View File
@@ -18,6 +18,7 @@ package api
import ( import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
) )
@@ -49,9 +50,14 @@ type albumSyncView struct {
ReleaseDate *string `json:"release_date"` ReleaseDate *string `json:"release_date"`
CoverArtPath *string `json:"cover_art_path"` CoverArtPath *string `json:"cover_art_path"`
Mbid *string `json:"mbid"` 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 var releaseDate *string
if a.ReleaseDate.Valid { if a.ReleaseDate.Valid {
s := a.ReleaseDate.Time.Format("2006-01-02") s := a.ReleaseDate.Time.Format("2006-01-02")
@@ -65,6 +71,8 @@ func toAlbumSyncView(a dbq.Album) albumSyncView {
ReleaseDate: releaseDate, ReleaseDate: releaseDate,
CoverArtPath: a.CoverArtPath, CoverArtPath: a.CoverArtPath,
Mbid: a.Mbid, 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 // track is playable, which is a yes/no. The "gone since" clock is an
// operator concern and lives on the admin surface. // operator concern and lives on the admin surface.
Missing bool `json:"missing"` 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{ return trackSyncView{
ID: syncpkg.FormatUUID(t.ID), ID: syncpkg.FormatUUID(t.ID),
AlbumID: syncpkg.FormatUUID(t.AlbumID), AlbumID: syncpkg.FormatUUID(t.AlbumID),
@@ -107,6 +120,8 @@ func toTrackSyncView(t dbq.Track) trackSyncView {
FileFormat: t.FileFormat, FileFormat: t.FileFormat,
Genre: t.Genre, Genre: t.Genre,
Missing: t.MissingSince.Valid, 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" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "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. // 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) { func TestAlbumSyncView_WireKeys(t *testing.T) {
a := dbq.Album{ID: validUUID, ArtistID: validUUID, Title: "Drukqs", SortTitle: "Drukqs"} 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assertJSONKeys(t, "album", b, []string{ assertJSONKeys(t, "album", b, []string{
"id", "artist_id", "title", "sort_title", "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, Title: "Avril 14th", DurationMs: 121_000,
FilePath: "x", FileFormat: "flac", FilePath: "x", FileFormat: "flac",
} }
b, err := json.Marshal(toTrackSyncView(tr)) b, err := json.Marshal(toTrackSyncView(tr, library.ReplayGain{}))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assertJSONKeys(t, "track", b, []string{ assertJSONKeys(t, "track", b, []string{
"id", "album_id", "artist_id", "title", "duration_ms", "id", "album_id", "artist_id", "title", "duration_ms",
"track_number", "disc_number", "file_path", "file_format", "genre", "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. // client's library, a missing one stays playable and fails at the speaker.
func TestTrackSyncView_MissingReflectsTheMark(t *testing.T) { func TestTrackSyncView_MissingReflectsTheMark(t *testing.T) {
present := dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID} 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") t.Error("a track with no missing_since must not be marked missing")
} }
gone := present gone := present
gone.MissingSince = pgtype.Timestamptz{Time: time.Now(), Valid: true} 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") 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) { func TestPlaylistSyncView_WireKeys(t *testing.T) {
variant := "discover" variant := "discover"
p := dbq.Playlist{ 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)
}
}
+135 -14
View File
@@ -11,22 +11,13 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
const deleteOrphanAlbumLoudness = `-- name: DeleteOrphanAlbumLoudness :execrows const deleteAlbumLoudness = `-- name: DeleteAlbumLoudness :exec
DELETE FROM album_loudness a DELETE FROM album_loudness WHERE album_id = ANY($1::uuid[])
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 func (q *Queries) DeleteAlbumLoudness(ctx context.Context, albumIds []pgtype.UUID) error {
// album row itself survives a missing file, so its loudness would otherwise _, err := q.db.Exec(ctx, deleteAlbumLoudness, albumIds)
// stay behind describing tracks that are gone; it is recomputed if they return. return err
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
} }
const deleteTrackLoudness = `-- name: DeleteTrackLoudness :exec const deleteTrackLoudness = `-- name: DeleteTrackLoudness :exec
@@ -40,6 +31,56 @@ func (q *Queries) DeleteTrackLoudness(ctx context.Context, trackID pgtype.UUID)
return err 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 const getLoudnessCoverage = `-- name: GetLoudnessCoverage :one
SELECT count(*)::bigint AS total, SELECT count(*)::bigint AS total,
count(*) FILTER ( count(*) FILTER (
@@ -101,6 +142,55 @@ func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, err
return i, 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 const listAlbumLoudnessInputs = `-- name: ListAlbumLoudnessInputs :many
SELECT t.id, SELECT t.id,
(l.track_id IS NOT NULL)::boolean AS settled, (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 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 const listTracksNeedingLoudness = `-- name: ListTracksNeedingLoudness :many
SELECT t.id, t.file_path, t.duration_ms SELECT t.id, t.file_path, t.duration_ms
FROM tracks t FROM tracks t
+30 -2
View File
@@ -133,11 +133,39 @@ ON CONFLICT (album_id) DO UPDATE SET
inputs_digest = EXCLUDED.inputs_digest, inputs_digest = EXCLUDED.inputs_digest,
computed_at = now(); computed_at = now();
-- name: DeleteOrphanAlbumLoudness :execrows -- name: ListOrphanAlbumLoudness :many
-- Album rows whose album has no present track left (every file missing). The -- 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 -- 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. -- 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 ( WHERE NOT EXISTS (
SELECT 1 FROM tracks t WHERE t.album_id = a.album_id AND t.missing_since IS NULL 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[]);
+54 -4
View File
@@ -2,11 +2,14 @@ package library
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
) )
// Album loudness (M464 #4996). // Album loudness (M464 #4996).
@@ -117,24 +120,46 @@ func (w *LoudnessBackfillWorker) albumPass(ctx context.Context) (AlbumLoudnessRe
} }
for _, row := range rows { for _, row := range rows {
res.Recomputed++ 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++ res.Failed++
w.logger.Warn("album loudness: recompute failed", "album_id", row.AlbumID, "err", err) w.logger.Warn("album loudness: recompute failed", "album_id", row.AlbumID, "err", err)
} }
} }
after = rows[len(rows)-1].AlbumID 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 { 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) return res, fmt.Errorf("drop orphan album loudness: %w", err)
} }
res.Orphans = n res.Orphans = int64(len(orphans))
return res, nil 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, ctx context.Context, q *dbq.Queries, albumID pgtype.UUID, digest string, res *AlbumLoudnessResult,
) error { ) 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{ inputs, err := q.ListAlbumLoudnessInputs(ctx, dbq.ListAlbumLoudnessInputsParams{
CurrentVersion: loudnessVersion, CurrentVersion: loudnessVersion,
AlbumID: albumID, AlbumID: albumID,
@@ -143,6 +168,14 @@ func storeAlbumLoudness(
return fmt.Errorf("read inputs: %w", err) return fmt.Errorf("read inputs: %w", err)
} }
a := computeAlbumLoudness(inputs) 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{ if err := q.UpsertAlbumLoudness(ctx, dbq.UpsertAlbumLoudnessParams{
AlbumID: albumID, AlbumID: albumID,
IntegratedLufs: a.integratedLUFS, IntegratedLufs: a.integratedLUFS,
@@ -160,3 +193,20 @@ func storeAlbumLoudness(
} }
return nil 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
}
+31 -1
View File
@@ -9,6 +9,7 @@ import (
"testing" "testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "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. // histAt builds a histogram of n blocks all at one loudness.
@@ -162,6 +163,17 @@ func TestAlbumLoudness_Integration(t *testing.T) {
return res 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) second := addTrack("b", album)
measure(first, -10, -1) 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) 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. // 2. Measuring the second track changes the digest; the album is leveled.
measure(second, -10, -0.5) measure(second, -10, -0.5)
if res := pass(); res.Recomputed != 1 || res.Leveled != 1 { 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) 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 { if res := pass(); res.Recomputed != 0 {
t.Fatalf("idle pass = %+v, want nothing recomputed", res) 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. // 4. A track joins (here, retagged onto this album): recomputed.
other, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Other", SortTitle: "Other", ArtistID: artist.ID}) 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 { if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", first.ID); err != nil {
t.Fatalf("mark missing: %v", err) t.Fatalf("mark missing: %v", err)
} }
before := albumChanges()
if res := pass(); res.Orphans != 1 { if res := pass(); res.Orphans != 1 {
t.Fatalf("pass with every track missing = %+v, want 1 orphan dropped", res) 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 var n int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM album_loudness WHERE album_id = $1", album.ID).Scan(&n); err != nil { 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) t.Fatalf("count: %v", err)
+15 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
) )
// Loudness analysis (M464 #4995). // Loudness analysis (M464 #4995).
@@ -351,10 +352,15 @@ const (
// An inconclusive attempt leaves any existing row alone. The scan deletes a // 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 // 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. // 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( 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, trackID pgtype.UUID, path string, r loudnessResult,
) loudnessOutcome { ) loudnessOutcome {
q := dbq.New(db)
if r.err != nil { if r.err != nil {
logger.Warn("loudness: analysis failed", "path", path, "err", r.err) logger.Warn("loudness: analysis failed", "path", path, "err", r.err)
if r.inconclusive() { 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 { if err := q.UpsertTrackLoudness(ctx, params); err != nil {
logger.Warn("loudness: storing measurement failed", "path", path, "err", err) logger.Warn("loudness: storing measurement failed", "path", path, "err", err)
return loudnessStoreFailed return loudnessStoreFailed
+1 -1
View File
@@ -187,7 +187,7 @@ func (w *LoudnessBackfillWorker) pass(ctx context.Context) (BackfillLoudnessResu
w.logger.Error("loudness backfill: track panicked", "path", row.FilePath, "panic", r) 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)) w.analyzeFile(ctx, row.FilePath, row.DurationMs))
mu.Lock() mu.Lock()
res.add(outcome) res.add(outcome)
@@ -11,6 +11,7 @@ import (
"testing" "testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "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 // 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) 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. // 2. A pass after a complete one is a no-op.
res, err = w.pass(ctx) res, err = w.pass(ctx)
if err != nil { if err != nil {
+102
View File
@@ -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
}
+122
View File
@@ -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)
}
}
+5 -3
View File
@@ -185,9 +185,10 @@ func (b *browseHandlers) getAlbum(w http.ResponseWriter, r *http.Request) {
WriteFail(w, r, ErrGeneric, "Failed to load tracks") WriteFail(w, r, ErrGeneric, "Failed to load tracks")
return return
} }
gains := replayGains(r.Context(), q, tracks)
songs := make([]SongRef, 0, len(tracks)) songs := make([]SongRef, 0, len(tracks))
for _, t := range 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{ Write(w, r, AlbumResponse{
Envelope: NewEnvelope("ok"), Envelope: NewEnvelope("ok"),
@@ -230,7 +231,7 @@ func (b *browseHandlers) getSong(w http.ResponseWriter, r *http.Request) {
} }
Write(w, r, SongResponse{ Write(w, r, SongResponse{
Envelope: NewEnvelope("ok"), 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") WriteFail(w, r, ErrGeneric, "Song search failed")
return return
} }
gains := replayGains(r.Context(), q, tracks)
for _, t := range tracks { for _, t := range tracks {
album, aerr := q.GetAlbumByID(r.Context(), t.AlbumID) album, aerr := q.GetAlbumByID(r.Context(), t.AlbumID)
if aerr != nil { if aerr != nil {
@@ -397,7 +399,7 @@ func (b *browseHandlers) search3(w http.ResponseWriter, r *http.Request) {
WriteFail(w, r, ErrGeneric, "Song search failed") WriteFail(w, r, ErrGeneric, "Song search failed")
return 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]))
} }
} }
+58
View File
@@ -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)
}
}
+2 -1
View File
@@ -191,6 +191,7 @@ func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]Son
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
gains := replayGains(ctx, q, trackRows)
for _, t := range trackRows { for _, t := range trackRows {
album, err := q.GetAlbumByID(ctx, t.AlbumID) album, err := q.GetAlbumByID(ctx, t.AlbumID)
if err != nil { if err != nil {
@@ -200,7 +201,7 @@ func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]Son
if err != nil { if err != nil {
return nil, nil, nil, err 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{ albumRows, err := q.ListLikedAlbumRows(ctx, dbq.ListLikedAlbumRowsParams{
+40 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "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 // 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"` BitRate int `json:"bitRate,omitempty" xml:"bitRate,attr,omitempty"`
IsDir bool `json:"isDir" xml:"isDir,attr"` IsDir bool `json:"isDir" xml:"isDir,attr"`
Type string `json:"type" xml:"type,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 { 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{ s := SongRef{
ID: uuidToID(t.ID), ID: uuidToID(t.ID),
Parent: uuidToID(t.AlbumID), Parent: uuidToID(t.AlbumID),
@@ -313,9 +331,30 @@ func songRef(t dbq.Track, albumTitle, artistName string) SongRef {
if t.Genre != nil { if t.Genre != nil {
s.Genre = *t.Genre s.Genre = *t.Genre
} }
if !g.Empty() {
s.ReplayGain = &ReplayGain{
TrackGain: g.TrackGain, AlbumGain: g.AlbumGain,
TrackPeak: g.TrackPeak, AlbumPeak: g.AlbumPeak,
}
}
return s 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 // 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 // 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 // it) or via sidecar lookup in the album directory, so emitting the id