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
+127
View File
@@ -456,4 +456,131 @@ var _ = Describe("helpers", func() {
})
})
})
Describe("AverageRating in responses", func() {
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
conf.Server.Subsonic.EnableAverageRating = true
})
Describe("childFromMediaFile", func() {
It("includes averageRating when set", func() {
mf := model.MediaFile{
ID: "mf-avg-1",
Title: "Test Song",
Annotations: model.Annotations{
AverageRating: 4.5,
},
}
child := childFromMediaFile(ctx, mf)
Expect(child.AverageRating).To(Equal(4.5))
})
It("returns 0 for averageRating when not set", func() {
mf := model.MediaFile{
ID: "mf-avg-2",
Title: "Test Song No Rating",
}
child := childFromMediaFile(ctx, mf)
Expect(child.AverageRating).To(Equal(0.0))
})
})
Describe("childFromAlbum", func() {
It("includes averageRating when set", func() {
al := model.Album{
ID: "al-avg-1",
Name: "Test Album",
Annotations: model.Annotations{
AverageRating: 3.75,
},
}
child := childFromAlbum(ctx, al)
Expect(child.AverageRating).To(Equal(3.75))
})
It("returns 0 for averageRating when not set", func() {
al := model.Album{
ID: "al-avg-2",
Name: "Test Album No Rating",
}
child := childFromAlbum(ctx, al)
Expect(child.AverageRating).To(Equal(0.0))
})
})
Describe("toArtist", func() {
It("includes averageRating when set", func() {
conf.Server.Subsonic.EnableAverageRating = true
r := httptest.NewRequest("GET", "/test", nil)
a := model.Artist{
ID: "ar-avg-1",
Name: "Test Artist",
Annotations: model.Annotations{
AverageRating: 5.0,
},
}
artist := toArtist(r, a)
Expect(artist.AverageRating).To(Equal(5.0))
})
})
Describe("toArtistID3", func() {
It("includes averageRating when set", func() {
conf.Server.Subsonic.EnableAverageRating = true
r := httptest.NewRequest("GET", "/test", nil)
a := model.Artist{
ID: "ar-avg-2",
Name: "Test Artist ID3",
Annotations: model.Annotations{
AverageRating: 2.5,
},
}
artist := toArtistID3(r, a)
Expect(artist.AverageRating).To(Equal(2.5))
})
})
Describe("EnableAverageRating config", func() {
It("excludes averageRating when disabled", func() {
conf.Server.Subsonic.EnableAverageRating = false
mf := model.MediaFile{
ID: "mf-cfg-1",
Title: "Test Song",
Annotations: model.Annotations{
AverageRating: 4.5,
},
}
child := childFromMediaFile(ctx, mf)
Expect(child.AverageRating).To(Equal(0.0))
al := model.Album{
ID: "al-cfg-1",
Name: "Test Album",
Annotations: model.Annotations{
AverageRating: 3.75,
},
}
albumChild := childFromAlbum(ctx, al)
Expect(albumChild.AverageRating).To(Equal(0.0))
r := httptest.NewRequest("GET", "/test", nil)
a := model.Artist{
ID: "ar-cfg-1",
Name: "Test Artist",
Annotations: model.Annotations{
AverageRating: 5.0,
},
}
artist := toArtist(r, a)
Expect(artist.AverageRating).To(Equal(0.0))
artistID3 := toArtistID3(r, a)
Expect(artistID3.AverageRating).To(Equal(0.0))
})
})
})
})