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:
@@ -2,11 +2,14 @@ package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
// Album loudness (M464 #4996).
|
||||
@@ -117,24 +120,46 @@ func (w *LoudnessBackfillWorker) albumPass(ctx context.Context) (AlbumLoudnessRe
|
||||
}
|
||||
for _, row := range rows {
|
||||
res.Recomputed++
|
||||
if err := storeAlbumLoudness(ctx, q, row.AlbumID, row.Digest, &res); err != nil {
|
||||
if err := w.storeAlbumLoudness(ctx, q, row.AlbumID, row.Digest, &res); err != nil {
|
||||
res.Failed++
|
||||
w.logger.Warn("album loudness: recompute failed", "album_id", row.AlbumID, "err", err)
|
||||
}
|
||||
}
|
||||
after = rows[len(rows)-1].AlbumID
|
||||
}
|
||||
n, err := q.DeleteOrphanAlbumLoudness(ctx)
|
||||
// Listed, logged, then deleted: the same log-first order as above.
|
||||
orphans, err := q.ListOrphanAlbumLoudness(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("list orphan album loudness: %w", err)
|
||||
}
|
||||
if len(orphans) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
ids := make([]string, len(orphans))
|
||||
for i, id := range orphans {
|
||||
ids[i] = syncpkg.FormatUUID(id)
|
||||
}
|
||||
if err := syncpkg.LogChanges(ctx, w.pool, syncpkg.EntityAlbum, ids, syncpkg.OpUpsert); err != nil {
|
||||
return res, fmt.Errorf("log orphan album changes: %w", err)
|
||||
}
|
||||
if err := q.DeleteAlbumLoudness(ctx, orphans); err != nil {
|
||||
return res, fmt.Errorf("drop orphan album loudness: %w", err)
|
||||
}
|
||||
res.Orphans = n
|
||||
res.Orphans = int64(len(orphans))
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func storeAlbumLoudness(
|
||||
// storeAlbumLoudness recomputes one album. The sync feed hears about it only
|
||||
// when the values clients see move: during the backfill an album's digest
|
||||
// changes with every track measured, and most of those recomputes still end
|
||||
// with no album value.
|
||||
func (w *LoudnessBackfillWorker) storeAlbumLoudness(
|
||||
ctx context.Context, q *dbq.Queries, albumID pgtype.UUID, digest string, res *AlbumLoudnessResult,
|
||||
) error {
|
||||
before, err := q.GetAlbumLoudness(ctx, albumID)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("read stored values: %w", err)
|
||||
}
|
||||
inputs, err := q.ListAlbumLoudnessInputs(ctx, dbq.ListAlbumLoudnessInputsParams{
|
||||
CurrentVersion: loudnessVersion,
|
||||
AlbumID: albumID,
|
||||
@@ -143,6 +168,14 @@ func storeAlbumLoudness(
|
||||
return fmt.Errorf("read inputs: %w", err)
|
||||
}
|
||||
a := computeAlbumLoudness(inputs)
|
||||
// Logged before the write, for the reason storeLoudness gives (#2704). A
|
||||
// failed log stores nothing, so the digest still differs and the next
|
||||
// pass tries again.
|
||||
if albumVisibleChange(before, a) {
|
||||
if err := syncpkg.LogChange(ctx, w.pool, syncpkg.EntityAlbum, syncpkg.FormatUUID(albumID), syncpkg.OpUpsert); err != nil {
|
||||
return fmt.Errorf("log album change: %w", err)
|
||||
}
|
||||
}
|
||||
if err := q.UpsertAlbumLoudness(ctx, dbq.UpsertAlbumLoudnessParams{
|
||||
AlbumID: albumID,
|
||||
IntegratedLufs: a.integratedLUFS,
|
||||
@@ -160,3 +193,20 @@ func storeAlbumLoudness(
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// albumVisibleChange reports whether clients would see different album gains.
|
||||
// The peak is only delivered beside a loudness, so a waiting album whose peak
|
||||
// moves as tracks are measured has nothing new to tell anyone.
|
||||
func albumVisibleChange(before dbq.GetAlbumLoudnessRow, after albumLoudness) bool {
|
||||
if !sameFloat(before.IntegratedLufs, after.integratedLUFS) {
|
||||
return true
|
||||
}
|
||||
return after.integratedLUFS != nil && !sameFloat(before.TruePeakDbtp, after.truePeakDBTP)
|
||||
}
|
||||
|
||||
func sameFloat(a, b *float32) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user