Files
minstrel/internal/api/library_sync_views_test.go
T
bvandeusenandClaude Opus 5.5 e3aa8629d3
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
feat(api): deliver loudness gains to every client (M464 #4997)
ReplayGain 2.0 values (gain to -18 LUFS, linear peak) derived from the
stored track and album loudness:

- Web: GET /api/tracks/replay-gain?ids=... (up to 200), a lookup the
  player calls for its queue, rather than a field on every TrackRef
  surface.
- Android: track_gain/track_peak and album_gain/album_peak on the sync
  views, so cached tracks level offline. Storing a measurement logs a
  track change, and an album's values moving logs an album change, both
  before the write (#2704), so caches pick the gains up.
- OpenSubsonic: replayGain on every song (album, getSong, search3,
  starred), as a JSON object and an XML element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:54:05 -04:00

150 lines
5.2 KiB
Go

package api
import (
"encoding/json"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// These tests pin the wire-format keys for /api/library/sync upserts.
// Without them, sqlc model field-name drift or accidental
// `json.Marshal(rawStruct)` regressions would silently break the Android
// client, which reads these snake_case keys via @SerialName in
// models/wire/SyncResponseWire.kt. "Silently" is the operative word:
// kotlinx.serialization skips unknown keys, so a renamed field arrives as
// a default value rather than an error.
// validUUID is a deterministic test UUID — not a real value, just
// something that pgtype.UUID.Valid will accept.
var validUUID = pgtype.UUID{
Bytes: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
Valid: true,
}
func assertJSONKeys(t *testing.T, label string, b []byte, want []string) {
t.Helper()
var m map[string]json.RawMessage
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("%s: invalid json: %v\nbody: %s", label, err, b)
}
for _, k := range want {
if _, ok := m[k]; !ok {
t.Errorf("%s: missing key %q in %s", label, k, b)
}
}
}
func TestArtistSyncView_WireKeys(t *testing.T) {
a := dbq.Artist{ID: validUUID, Name: "Aphex Twin", SortName: "Aphex Twin"}
b, err := json.Marshal(toArtistSyncView(a))
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, "artist", b, []string{
"id", "name", "sort_name", "mbid",
"artist_thumb_path", "artist_fanart_path",
})
}
func TestAlbumSyncView_WireKeys(t *testing.T) {
a := dbq.Album{ID: validUUID, ArtistID: validUUID, Title: "Drukqs", SortTitle: "Drukqs"}
b, err := json.Marshal(toAlbumSyncView(a, library.ReplayGain{}))
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, "album", b, []string{
"id", "artist_id", "title", "sort_title",
"release_date", "cover_art_path", "mbid", "album_gain", "album_peak",
})
}
func TestTrackSyncView_WireKeys(t *testing.T) {
tr := dbq.Track{
ID: validUUID, AlbumID: validUUID, ArtistID: validUUID,
Title: "Avril 14th", DurationMs: 121_000,
FilePath: "x", FileFormat: "flac",
}
b, err := json.Marshal(toTrackSyncView(tr, library.ReplayGain{}))
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, "track", b, []string{
"id", "album_id", "artist_id", "title", "duration_ms",
"track_number", "disc_number", "file_path", "file_format", "genre",
"missing", "track_gain", "track_peak",
})
}
// The mark is what the client filters on, so a wrong value here silently
// re-introduces #2704: a present track marked missing vanishes from the
// client's library, a missing one stays playable and fails at the speaker.
func TestTrackSyncView_MissingReflectsTheMark(t *testing.T) {
present := dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}
if toTrackSyncView(present, library.ReplayGain{}).Missing {
t.Error("a track with no missing_since must not be marked missing")
}
gone := present
gone.MissingSince = pgtype.Timestamptz{Time: time.Now(), Valid: true}
if !toTrackSyncView(gone, library.ReplayGain{}).Missing {
t.Error("a track with missing_since must be marked missing")
}
}
// The gains are what Android levels playback by, offline included (#4997):
// the track's pair rides on the track view and the album's on the album view.
func TestSyncViews_CarryReplayGain(t *testing.T) {
gain, peak := float32(-3.25), float32(0.9441)
g := library.ReplayGain{TrackGain: &gain, TrackPeak: &peak, AlbumGain: &gain, AlbumPeak: &peak}
tv := toTrackSyncView(dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}, g)
if tv.TrackGain == nil || *tv.TrackGain != gain || tv.TrackPeak == nil || *tv.TrackPeak != peak {
t.Errorf("track view gains = %v/%v, want %v/%v", tv.TrackGain, tv.TrackPeak, gain, peak)
}
av := toAlbumSyncView(dbq.Album{ID: validUUID, ArtistID: validUUID}, g)
if av.AlbumGain == nil || *av.AlbumGain != gain || av.AlbumPeak == nil || *av.AlbumPeak != peak {
t.Errorf("album view gains = %v/%v, want %v/%v", av.AlbumGain, av.AlbumPeak, gain, peak)
}
// Unmeasured: explicit nulls, like every other optional sync field.
b, err := json.Marshal(toTrackSyncView(dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}, library.ReplayGain{}))
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
if v, ok := m["track_gain"]; !ok || v != nil {
t.Errorf("unmeasured track_gain = %v (present %v), want an explicit null", v, ok)
}
}
func TestPlaylistSyncView_WireKeys(t *testing.T) {
variant := "discover"
p := dbq.Playlist{
ID: validUUID, UserID: validUUID, Name: "Test",
IsPublic: true, TrackCount: 5, DurationSec: 600,
SystemVariant: &variant,
}
b, err := json.Marshal(toPlaylistSyncView(p))
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, "playlist", b, []string{
"id", "user_id", "name", "description",
"is_public", "cover_path", "track_count",
"duration_sec", "system_variant",
})
// Sanity: system_variant carries the value, not just present.
var m map[string]any
_ = json.Unmarshal(b, &m)
if got := m["system_variant"]; got != "discover" {
t.Errorf("system_variant: want 'discover', got %v", got)
}
}