feat(api): deliver loudness gains to every client (M464 #4997)
release / web (push) Successful in 1m44s
release / go (push) Successful in 2m12s
release / govulncheck (push) Successful in 40s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 4m42s
release / integration (push) Successful in 15m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m21s
release / Verify release artifacts (tag releases only) (push) Skipped
release / web (push) Successful in 1m44s
release / go (push) Successful in 2m12s
release / govulncheck (push) Successful in 40s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 4m42s
release / integration (push) Successful in 15m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m21s
release / Verify release artifacts (tag releases only) (push) Skipped
ReplayGain 2.0 values (gain to -18 LUFS, linear peak) derived from the stored track and album loudness: - Web: GET /api/tracks/replay-gain?ids=... (up to 200), a lookup the player calls for its queue, rather than a field on every TrackRef surface. - Android: track_gain/track_peak and album_gain/album_peak on the sync views, so cached tracks level offline. Storing a measurement logs a track change, and an album's values moving logs an album change, both before the write (#2704), so caches pick the gains up. - OpenSubsonic: replayGain on every song (album, getSong, search3, starred), as a JSON object and an XML element. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/library/genres", h.handleListGenres)
|
authed.Get("/library/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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxReplayGainIDs caps one lookup. A player asks for the tracks around the
|
||||||
|
// one it is about to play, never a whole library.
|
||||||
|
const maxReplayGainIDs = 200
|
||||||
|
|
||||||
|
// replayGainResp is the wire shape for GET /api/tracks/replay-gain. Tracks
|
||||||
|
// with no gain known are absent from items: no adjustment.
|
||||||
|
type replayGainResp struct {
|
||||||
|
Items map[string]library.ReplayGain `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetReplayGain implements GET /api/tracks/replay-gain?ids=a,b,c (M464
|
||||||
|
// #4997): ReplayGain 2.0 values (dB to -18 LUFS, linear peaks) for each track,
|
||||||
|
// and for its album when the whole album is measured.
|
||||||
|
//
|
||||||
|
// A lookup rather than fields on TrackRef. Tracks reach the player through
|
||||||
|
// about fifteen surfaces in two wire shapes (TrackRef and playlist entries),
|
||||||
|
// and only the player uses the gain. One call from the player, made for the
|
||||||
|
// tracks it is about to play, cannot be forgotten by a surface added later,
|
||||||
|
// and costs nothing on the pages that never play anything. Android takes the
|
||||||
|
// same values from the sync feed instead, so they are there offline.
|
||||||
|
func (h *handlers) handleGetReplayGain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
raw := strings.TrimSpace(r.URL.Query().Get("ids"))
|
||||||
|
if raw == "" {
|
||||||
|
writeJSON(w, http.StatusOK, replayGainResp{Items: map[string]library.ReplayGain{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
if len(parts) > maxReplayGainIDs {
|
||||||
|
writeErr(w, apierror.BadRequest("too_many_ids", "at most 200 ids per request"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := stringsToUUIDs(parts)
|
||||||
|
gains, err := library.ReplayGainForTracks(r.Context(), dbq.New(h.pool), ids)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "replay gain: lookup", apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make(map[string]library.ReplayGain, len(gains))
|
||||||
|
for id, g := range gains {
|
||||||
|
items[uuidToString(id)] = g
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, replayGainResp{Items: items})
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetReplayGain(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
user := seedUser(t, pool, "rg1", "pw", false)
|
||||||
|
measured, _ := seedTrackForRemoveTest(t, h, "rg-measured", "", "")
|
||||||
|
unmeasured, _ := seedTrackForRemoveTest(t, h, "rg-unmeasured", "", "")
|
||||||
|
lufs, peak := float32(-12.5), float32(-1)
|
||||||
|
if err := dbq.New(pool).UpsertTrackLoudness(context.Background(), dbq.UpsertTrackLoudnessParams{
|
||||||
|
TrackID: measured.ID, IntegratedLufs: &lufs, TruePeakDbtp: &peak, AnalysisVersion: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed loudness: %v", err)
|
||||||
|
}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/tracks/replay-gain", h.handleGetReplayGain)
|
||||||
|
get := func(query string) *httptest.ResponseRecorder {
|
||||||
|
req := withUser(httptest.NewRequest(http.MethodGet, "/api/tracks/replay-gain"+query, nil), user)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := get("?ids=" + uuidToString(measured.ID) + "," + uuidToString(unmeasured.ID) + ",not-a-uuid")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Items map[string]struct {
|
||||||
|
TrackGain *float32 `json:"track_gain"`
|
||||||
|
TrackPeak *float32 `json:"track_peak"`
|
||||||
|
AlbumGain *float32 `json:"album_gain"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
g, ok := resp.Items[uuidToString(measured.ID)]
|
||||||
|
if !ok || g.TrackGain == nil || *g.TrackGain != -5.5 || g.TrackPeak == nil {
|
||||||
|
t.Errorf("measured track = %+v (present %v), want track_gain -5.5 with a peak", g, ok)
|
||||||
|
}
|
||||||
|
if g.AlbumGain != nil {
|
||||||
|
t.Errorf("album_gain present with no album loudness: %v", *g.AlbumGain)
|
||||||
|
}
|
||||||
|
if _, ok := resp.Items[uuidToString(unmeasured.ID)]; ok {
|
||||||
|
t.Errorf("unmeasured track listed; absent means no adjustment")
|
||||||
|
}
|
||||||
|
if len(resp.Items) != 1 {
|
||||||
|
t.Errorf("items = %v, want only the measured track", resp.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec := get(""); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"items":{}`) {
|
||||||
|
t.Errorf("no ids: %d %s, want 200 with empty items", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := get("?ids=" + strings.Repeat("x,", maxReplayGainIDs)); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("%d ids: status %d, want 400", maxReplayGainIDs+1, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
-14
@@ -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
|
||||||
|
|||||||
@@ -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[]);
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenSubsonic's replayGain is an object in JSON and an element with
|
||||||
|
// attributes in XML. Clients parse both, so both are pinned (#4997).
|
||||||
|
func TestSongRef_ReplayGainEncodesForBothFormats(t *testing.T) {
|
||||||
|
gain, peak := float32(-4.5), float32(0.8913)
|
||||||
|
tr := dbq.Track{Title: "Song", FilePath: "/m/a.flac", FileFormat: "flac"}
|
||||||
|
s := songRef(tr, "Album", "Artist", library.ReplayGain{TrackGain: &gain, TrackPeak: &peak})
|
||||||
|
|
||||||
|
b, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rg, ok := m["replayGain"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("JSON has no replayGain object: %s", b)
|
||||||
|
}
|
||||||
|
if rg["trackGain"] != -4.5 || rg["trackPeak"] == nil {
|
||||||
|
t.Errorf("JSON replayGain = %v, want trackGain -4.5 and a trackPeak", rg)
|
||||||
|
}
|
||||||
|
if _, ok := rg["albumGain"]; ok {
|
||||||
|
t.Errorf("JSON replayGain carries albumGain with no album value: %v", rg)
|
||||||
|
}
|
||||||
|
|
||||||
|
x, err := xml.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(x), `<replayGain trackGain="-4.5" trackPeak="0.8913"></replayGain>`) {
|
||||||
|
t.Errorf("XML lacks the replayGain element: %s", x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSongRef_NoReplayGainWhenUnmeasured(t *testing.T) {
|
||||||
|
s := songRef(dbq.Track{Title: "Song", FilePath: "/m/a.mp3"}, "Album", "Artist", library.ReplayGain{})
|
||||||
|
if s.ReplayGain != nil {
|
||||||
|
t.Fatalf("unmeasured song carries replayGain %+v", s.ReplayGain)
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
x, _ := xml.Marshal(s)
|
||||||
|
if strings.Contains(string(b), "replayGain") || strings.Contains(string(x), "replayGain") {
|
||||||
|
t.Errorf("unmeasured song encodes replayGain: %s / %s", b, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -191,6 +191,7 @@ func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]Son
|
|||||||
if err != nil {
|
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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user