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
+83
View File
@@ -126,6 +126,89 @@ var _ = Describe("AlbumRepository", func() {
)
})
Describe("Album.AverageRating", func() {
It("returns 0 when no ratings exist", func() {
newID := id.NewRandom()
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "no ratings album"})).To(Succeed())
album, err := albumRepo.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(album.AverageRating).To(Equal(0.0))
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
})
It("returns the user's rating as average when only one user rated", func() {
newID := id.NewRandom()
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "single rating album"})).To(Succeed())
Expect(albumRepo.SetRating(4, newID)).To(Succeed())
album, err := albumRepo.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(album.AverageRating).To(Equal(4.0))
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
})
It("calculates average across multiple users", func() {
newID := id.NewRandom()
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "multi rating album"})).To(Succeed())
Expect(albumRepo.SetRating(4, newID)).To(Succeed())
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
Expect(user2Repo.SetRating(5, newID)).To(Succeed())
album, err := albumRepo.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(album.AverageRating).To(Equal(4.5))
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
})
It("excludes zero ratings from average calculation", func() {
newID := id.NewRandom()
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "zero rating excluded album"})).To(Succeed())
Expect(albumRepo.SetRating(3, newID)).To(Succeed())
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
Expect(user2Repo.SetRating(0, newID)).To(Succeed())
album, err := albumRepo.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(album.AverageRating).To(Equal(3.0))
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
})
It("rounds to 2 decimal places", func() {
newID := id.NewRandom()
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "rounding test album"})).To(Succeed())
Expect(albumRepo.SetRating(5, newID)).To(Succeed())
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
Expect(user2Repo.SetRating(4, newID)).To(Succeed())
user3Ctx := request.WithUser(GinkgoT().Context(), thirdUser)
user3Repo := NewAlbumRepository(user3Ctx, GetDBXBuilder()).(*albumRepository)
Expect(user3Repo.SetRating(4, newID)).To(Succeed())
album, err := albumRepo.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(album.AverageRating).To(Equal(4.33)) // (5 + 4 + 4) / 3 = 4.333...
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
})
})
Describe("dbAlbum mapping", func() {
var (
a model.Album
+68
View File
@@ -157,6 +157,74 @@ var _ = Describe("MediaRepository", func() {
Expect(mf.PlayCount).To(Equal(int64(1)))
})
Describe("AverageRating", func() {
var raw *mediaFileRepository
BeforeEach(func() {
raw = mr.(*mediaFileRepository)
})
It("returns 0 when no ratings exist", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/no-rating.mp3"})).To(Succeed())
mf, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(mf.AverageRating).To(Equal(0.0))
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
})
It("returns the user's rating as average when only one user rated", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/single-rating.mp3"})).To(Succeed())
Expect(mr.SetRating(5, newID)).To(Succeed())
mf, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(mf.AverageRating).To(Equal(5.0))
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
})
It("calculates average across multiple users", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/multi-rating.mp3"})).To(Succeed())
Expect(mr.SetRating(3, newID)).To(Succeed())
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
Expect(user2Repo.SetRating(5, newID)).To(Succeed())
mf, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(mf.AverageRating).To(Equal(4.0))
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
})
It("excludes zero ratings from average calculation", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/zero-excluded.mp3"})).To(Succeed())
Expect(mr.SetRating(4, newID)).To(Succeed())
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
Expect(user2Repo.SetRating(0, newID)).To(Succeed())
mf, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(mf.AverageRating).To(Equal(4.0))
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
})
})
It("preserves play date if and only if provided date is older", func() {
id := "incplay.playdate"
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
+2 -1
View File
@@ -130,7 +130,8 @@ var (
var (
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
testUsers = model.Users{adminUser, regularUser}
thirdUser = model.User{ID: "3333", UserName: "third-user", Name: "Third User", Email: "third@example.com"}
testUsers = model.Users{adminUser, regularUser, thirdUser}
)
func p(path string) string {
+19 -2
View File
@@ -17,7 +17,7 @@ const annotationTable = "annotation"
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
userID := loggedUser(r.ctx).ID
if userID == invalidUserId {
return query
return query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
}
query = query.
LeftJoin("annotation on ("+
@@ -38,6 +38,8 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
query = query.Columns("coalesce(play_count, 0) as play_count")
}
query = query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
return query
}
@@ -79,7 +81,22 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
func (r sqlRepository) SetRating(rating int, itemID string) error {
ratedAt := time.Now()
return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
err := r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
if err != nil {
return err
}
return r.updateAvgRating(itemID)
}
func (r sqlRepository) updateAvgRating(itemID string) error {
upd := Update(r.tableName).
Where(Eq{"id": itemID}).
Set("average_rating", Expr(
"coalesce((select round(avg(rating), 2) from annotation where item_id = ? and item_type = ? and rating > 0), 0)",
itemID, r.tableName,
))
_, err := r.executeSQL(upd)
return err
}
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {