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:
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user