feat(subsonic): Add avgRating from subsonic spec (#4900)

* feat(subsonic): add averageRating to API responses

Add averageRating attribute to Subsonic API responses for artists,
albums, and songs. The average is calculated across all user ratings.

* perf(db): add index for average rating queries

Add composite index on (item_id, item_type, rating) to optimize
the correlated subquery used for calculating average ratings.

Signed-off-by: Terry Raimondo <terry.raimondo@gmail.com>

* test: add tests for averageRating feature

Add tests for:
- Album.AverageRating calculation in persistence layer
- MediaFile.AverageRating calculation in persistence layer
- AverageRating mapping in subsonic response helpers

Signed-off-by: Terry Raimondo <terry.raimondo@gmail.com>

* test: improve averageRating rounding test with 3 users

Add third test user to fixtures and update rounding test to use
3 ratings (5 + 4 + 4) / 3 = 4.33 for proper decimal rounding coverage.

Signed-off-by: Terry Raimondo <terry.raimondo@gmail.com>

* perf: store avg_rating on entity tables instead of using subquery

- Add avg_rating column to album, media_file, and artist tables
- Update SetRating() to recalculate and store average when ratings change
- Read avg_rating directly from entity table in withAnnotation()
- Remove old annotation index migration (no longer needed)

This trades write-time computation for read-time performance by
pre-computing the average rating instead of using a correlated
subquery on every read.

* feat: add Subsonic.EnableAverageRating config option (default true)

Allow administrators to disable exposing averageRating in Subsonic API
responses if they don't want to expose other users' rating data.

The avg_rating column is still updated internally when users rate items,
but the value is only included in API responses when this option is enabled.

* address PR comments

- Use structs:"avg_rating" with db:"avg_rating" tag instead of SQL alias
- Remove avg_rating indexes (not needed)
- Populate avg_rating columns from existing ratings in migration

* Woops

* rename avg_rating column to average_rating

---------

Signed-off-by: Terry Raimondo <terry.raimondo@gmail.com>
This commit is contained in:
Terry Raimondo
2026-01-18 23:42:42 +01:00
committed by GitHub
parent 0473c50b49
commit 03120bac32
11 changed files with 366 additions and 28 deletions
+15
View File
@@ -101,6 +101,9 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist {
CoverArt: a.CoverArtID().String(),
ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600),
}
if conf.Server.Subsonic.EnableAverageRating {
artist.AverageRating = a.AverageRating
}
if a.Starred {
artist.Starred = a.StarredAt
}
@@ -116,6 +119,9 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 {
ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600),
UserRating: int32(a.Rating),
}
if conf.Server.Subsonic.EnableAverageRating {
artist.AverageRating = a.AverageRating
}
if a.Starred {
artist.Starred = a.StarredAt
}
@@ -218,6 +224,9 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
child.Starred = mf.StarredAt
}
child.UserRating = int32(mf.Rating)
if conf.Server.Subsonic.EnableAverageRating {
child.AverageRating = mf.AverageRating
}
format, _ := getTranscoding(ctx)
if mf.Suffix != "" && format != "" && mf.Suffix != format {
@@ -329,6 +338,9 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
}
child.PlayCount = al.PlayCount
child.UserRating = int32(al.Rating)
if conf.Server.Subsonic.EnableAverageRating {
child.AverageRating = al.AverageRating
}
child.OpenSubsonicChild = osChildFromAlbum(ctx, al)
return child
}
@@ -422,6 +434,9 @@ func buildOSAlbumID3(ctx context.Context, album model.Album) *responses.OpenSubs
dir.Played = album.PlayDate
}
dir.UserRating = int32(album.Rating)
if conf.Server.Subsonic.EnableAverageRating {
dir.AverageRating = album.AverageRating
}
dir.RecordLabels = slice.Map(album.Tags.Values(model.TagRecordLabel), func(s string) responses.RecordLabel {
return responses.RecordLabel{Name: s}
})