Files
minstrel/internal/api/library_sync_views.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

153 lines
5.4 KiB
Go

package api
// Wire shapes for /api/library/sync upserts. The sqlc-generated row
// structs (dbq.Artist, dbq.Album, dbq.Track, dbq.Playlist) have no
// JSON tags (sqlc.yaml: emit_json_tags=false), so a raw json.Marshal
// of them produces PascalCase field names that the Flutter sync
// controller (which reads snake_case keys) can't parse.
//
// These view structs add the JSON tag layer + flatten pgtype.UUID and
// pgtype.Timestamptz / Date into strings, mirroring the pattern
// playlistRowView already established for /api/playlists.
//
// Field names match what the Android client deserialises in
// models/wire/SyncResponseWire.kt (Sync{Artist,Album,Track}Wire) exactly.
// Adding a field server-side requires a matching @SerialName there or it is
// silently dropped — kotlinx.serialization ignores unknown keys, so the
// failure is a missing value at runtime, not an error at parse time.
import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
type artistSyncView struct {
ID string `json:"id"`
Name string `json:"name"`
SortName string `json:"sort_name"`
Mbid *string `json:"mbid"`
ArtistThumbPath *string `json:"artist_thumb_path"`
ArtistFanartPath *string `json:"artist_fanart_path"`
}
func toArtistSyncView(a dbq.Artist) artistSyncView {
return artistSyncView{
ID: syncpkg.FormatUUID(a.ID),
Name: a.Name,
SortName: a.SortName,
Mbid: a.Mbid,
ArtistThumbPath: a.ArtistThumbPath,
ArtistFanartPath: a.ArtistFanartPath,
}
}
type albumSyncView struct {
ID string `json:"id"`
ArtistID string `json:"artist_id"`
Title string `json:"title"`
SortTitle string `json:"sort_title"`
ReleaseDate *string `json:"release_date"`
CoverArtPath *string `json:"cover_art_path"`
Mbid *string `json:"mbid"`
// AlbumGain and AlbumPeak are the album's ReplayGain 2.0 values (#4997):
// dB to the -18 LUFS reference, and a linear peak. Null until every track
// on the album is measured.
AlbumGain *float32 `json:"album_gain"`
AlbumPeak *float32 `json:"album_peak"`
}
func toAlbumSyncView(a dbq.Album, g library.ReplayGain) albumSyncView {
var releaseDate *string
if a.ReleaseDate.Valid {
s := a.ReleaseDate.Time.Format("2006-01-02")
releaseDate = &s
}
return albumSyncView{
ID: syncpkg.FormatUUID(a.ID),
ArtistID: syncpkg.FormatUUID(a.ArtistID),
Title: a.Title,
SortTitle: a.SortTitle,
ReleaseDate: releaseDate,
CoverArtPath: a.CoverArtPath,
Mbid: a.Mbid,
AlbumGain: g.AlbumGain,
AlbumPeak: g.AlbumPeak,
}
}
type trackSyncView struct {
ID string `json:"id"`
AlbumID string `json:"album_id"`
ArtistID string `json:"artist_id"`
Title string `json:"title"`
DurationMs int32 `json:"duration_ms"`
TrackNumber *int32 `json:"track_number"`
DiscNumber *int32 `json:"disc_number"`
FilePath string `json:"file_path"`
FileFormat string `json:"file_format"`
Genre *string `json:"genre"`
// Missing reports that the file is currently absent from disk (#2704).
//
// Shipped as state rather than filtered out of the feed, because a
// missing file is expected to come back: the scanner clears the mark
// when it does, and adopts the row if it returns under a new name
// (#2528). Dropping the row instead would mean a delete-and-recreate on
// every client for what is often a transient unmount, churning caches
// and discarding the identity #2528 works to preserve.
//
// A bool rather than the timestamp: clients need it to decide whether a
// track is playable, which is a yes/no. The "gone since" clock is an
// operator concern and lives on the admin surface.
Missing bool `json:"missing"`
// TrackGain and TrackPeak are the track's ReplayGain 2.0 values (#4997).
// Null until the track is measured. The album's pair rides on the album
// view, since it changes when the album does, not when this track does.
TrackGain *float32 `json:"track_gain"`
TrackPeak *float32 `json:"track_peak"`
}
func toTrackSyncView(t dbq.Track, g library.ReplayGain) trackSyncView {
return trackSyncView{
ID: syncpkg.FormatUUID(t.ID),
AlbumID: syncpkg.FormatUUID(t.AlbumID),
ArtistID: syncpkg.FormatUUID(t.ArtistID),
Title: t.Title,
DurationMs: t.DurationMs,
TrackNumber: t.TrackNumber,
DiscNumber: t.DiscNumber,
FilePath: t.FilePath,
FileFormat: t.FileFormat,
Genre: t.Genre,
Missing: t.MissingSince.Valid,
TrackGain: g.TrackGain,
TrackPeak: g.TrackPeak,
}
}
type playlistSyncView struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
CoverPath *string `json:"cover_path"`
TrackCount int32 `json:"track_count"`
DurationSec int32 `json:"duration_sec"`
SystemVariant *string `json:"system_variant"`
}
func toPlaylistSyncView(p dbq.Playlist) playlistSyncView {
return playlistSyncView{
ID: syncpkg.FormatUUID(p.ID),
UserID: syncpkg.FormatUUID(p.UserID),
Name: p.Name,
Description: p.Description,
IsPublic: p.IsPublic,
CoverPath: p.CoverPath,
TrackCount: p.TrackCount,
DurationSec: p.DurationSec,
SystemVariant: p.SystemVariant,
}
}