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:
@@ -152,6 +152,7 @@ type subsonicOptions struct {
|
|||||||
AppendSubtitle bool
|
AppendSubtitle bool
|
||||||
ArtistParticipations bool
|
ArtistParticipations bool
|
||||||
DefaultReportRealPath bool
|
DefaultReportRealPath bool
|
||||||
|
EnableAverageRating bool
|
||||||
LegacyClients string
|
LegacyClients string
|
||||||
MinimalClients string
|
MinimalClients string
|
||||||
}
|
}
|
||||||
@@ -605,6 +606,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||||
viper.SetDefault("subsonic.artistparticipations", false)
|
viper.SetDefault("subsonic.artistparticipations", false)
|
||||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||||
|
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||||
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
|
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
|
||||||
viper.SetDefault("agents", "lastfm,spotify,deezer")
|
viper.SetDefault("agents", "lastfm,spotify,deezer")
|
||||||
viper.SetDefault("lastfm.enabled", true)
|
viper.SetDefault("lastfm.enabled", true)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE album ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE media_file ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE artist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Populate average_rating from existing ratings
|
||||||
|
UPDATE album SET average_rating = coalesce(
|
||||||
|
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = album.id AND item_type = 'album' AND rating > 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
UPDATE media_file SET average_rating = coalesce(
|
||||||
|
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = media_file.id AND item_type = 'media_file' AND rating > 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
UPDATE artist SET average_rating = coalesce(
|
||||||
|
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = artist.id AND item_type = 'artist' AND rating > 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE artist DROP COLUMN average_rating;
|
||||||
|
ALTER TABLE media_file DROP COLUMN average_rating;
|
||||||
|
ALTER TABLE album DROP COLUMN average_rating;
|
||||||
+7
-6
@@ -3,12 +3,13 @@ package model
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Annotations struct {
|
type Annotations struct {
|
||||||
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
|
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
|
||||||
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
|
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
|
||||||
Rating int `structs:"rating" json:"rating,omitempty" `
|
Rating int `structs:"rating" json:"rating,omitempty" `
|
||||||
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
|
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
|
||||||
Starred bool `structs:"starred" json:"starred,omitempty" `
|
Starred bool `structs:"starred" json:"starred,omitempty" `
|
||||||
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
|
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
|
||||||
|
AverageRating float64 `structs:"average_rating" json:"averageRating,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnnotatedRepository interface {
|
type AnnotatedRepository interface {
|
||||||
|
|||||||
@@ -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() {
|
Describe("dbAlbum mapping", func() {
|
||||||
var (
|
var (
|
||||||
a model.Album
|
a model.Album
|
||||||
|
|||||||
@@ -157,6 +157,74 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
Expect(mf.PlayCount).To(Equal(int64(1)))
|
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() {
|
It("preserves play date if and only if provided date is older", func() {
|
||||||
id := "incplay.playdate"
|
id := "incplay.playdate"
|
||||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ var (
|
|||||||
var (
|
var (
|
||||||
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
|
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"}
|
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 {
|
func p(path string) string {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const annotationTable = "annotation"
|
|||||||
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
|
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
|
||||||
userID := loggedUser(r.ctx).ID
|
userID := loggedUser(r.ctx).ID
|
||||||
if userID == invalidUserId {
|
if userID == invalidUserId {
|
||||||
return query
|
return query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
|
||||||
}
|
}
|
||||||
query = query.
|
query = query.
|
||||||
LeftJoin("annotation on ("+
|
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("coalesce(play_count, 0) as play_count")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query = query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
|
||||||
|
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +81,22 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
|
|||||||
|
|
||||||
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
||||||
ratedAt := time.Now()
|
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 {
|
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||||
|
|||||||
@@ -410,6 +410,9 @@ func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artis
|
|||||||
}
|
}
|
||||||
dir.AlbumCount = getArtistAlbumCount(artist)
|
dir.AlbumCount = getArtistAlbumCount(artist)
|
||||||
dir.UserRating = int32(artist.Rating)
|
dir.UserRating = int32(artist.Rating)
|
||||||
|
if conf.Server.Subsonic.EnableAverageRating {
|
||||||
|
dir.AverageRating = artist.AverageRating
|
||||||
|
}
|
||||||
if artist.Starred {
|
if artist.Starred {
|
||||||
dir.Starred = artist.StarredAt
|
dir.Starred = artist.StarredAt
|
||||||
}
|
}
|
||||||
@@ -447,6 +450,9 @@ func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album)
|
|||||||
dir.Played = album.PlayDate
|
dir.Played = album.PlayDate
|
||||||
}
|
}
|
||||||
dir.UserRating = int32(album.Rating)
|
dir.UserRating = int32(album.Rating)
|
||||||
|
if conf.Server.Subsonic.EnableAverageRating {
|
||||||
|
dir.AverageRating = album.AverageRating
|
||||||
|
}
|
||||||
dir.SongCount = int32(album.SongCount)
|
dir.SongCount = int32(album.SongCount)
|
||||||
dir.CoverArt = album.CoverArtID().String()
|
dir.CoverArt = album.CoverArtID().String()
|
||||||
if album.Starred {
|
if album.Starred {
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist {
|
|||||||
CoverArt: a.CoverArtID().String(),
|
CoverArt: a.CoverArtID().String(),
|
||||||
ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600),
|
ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600),
|
||||||
}
|
}
|
||||||
|
if conf.Server.Subsonic.EnableAverageRating {
|
||||||
|
artist.AverageRating = a.AverageRating
|
||||||
|
}
|
||||||
if a.Starred {
|
if a.Starred {
|
||||||
artist.Starred = a.StarredAt
|
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),
|
ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600),
|
||||||
UserRating: int32(a.Rating),
|
UserRating: int32(a.Rating),
|
||||||
}
|
}
|
||||||
|
if conf.Server.Subsonic.EnableAverageRating {
|
||||||
|
artist.AverageRating = a.AverageRating
|
||||||
|
}
|
||||||
if a.Starred {
|
if a.Starred {
|
||||||
artist.Starred = a.StarredAt
|
artist.Starred = a.StarredAt
|
||||||
}
|
}
|
||||||
@@ -218,6 +224,9 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
|||||||
child.Starred = mf.StarredAt
|
child.Starred = mf.StarredAt
|
||||||
}
|
}
|
||||||
child.UserRating = int32(mf.Rating)
|
child.UserRating = int32(mf.Rating)
|
||||||
|
if conf.Server.Subsonic.EnableAverageRating {
|
||||||
|
child.AverageRating = mf.AverageRating
|
||||||
|
}
|
||||||
|
|
||||||
format, _ := getTranscoding(ctx)
|
format, _ := getTranscoding(ctx)
|
||||||
if mf.Suffix != "" && format != "" && mf.Suffix != format {
|
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.PlayCount = al.PlayCount
|
||||||
child.UserRating = int32(al.Rating)
|
child.UserRating = int32(al.Rating)
|
||||||
|
if conf.Server.Subsonic.EnableAverageRating {
|
||||||
|
child.AverageRating = al.AverageRating
|
||||||
|
}
|
||||||
child.OpenSubsonicChild = osChildFromAlbum(ctx, al)
|
child.OpenSubsonicChild = osChildFromAlbum(ctx, al)
|
||||||
return child
|
return child
|
||||||
}
|
}
|
||||||
@@ -422,6 +434,9 @@ func buildOSAlbumID3(ctx context.Context, album model.Album) *responses.OpenSubs
|
|||||||
dir.Played = album.PlayDate
|
dir.Played = album.PlayDate
|
||||||
}
|
}
|
||||||
dir.UserRating = int32(album.Rating)
|
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 {
|
dir.RecordLabels = slice.Map(album.Tags.Values(model.TagRecordLabel), func(s string) responses.RecordLabel {
|
||||||
return responses.RecordLabel{Name: s}
|
return responses.RecordLabel{Name: s}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -95,11 +95,9 @@ type Artist struct {
|
|||||||
Name string `xml:"name,attr" json:"name"`
|
Name string `xml:"name,attr" json:"name"`
|
||||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||||
|
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
|
||||||
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
|
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
|
||||||
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
|
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
|
||||||
/* TODO:
|
|
||||||
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 -->
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Index struct {
|
type Index struct {
|
||||||
@@ -160,13 +158,11 @@ type Child struct {
|
|||||||
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
|
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
|
||||||
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
|
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
|
||||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||||
|
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
|
||||||
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
|
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
|
||||||
IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"`
|
IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"`
|
||||||
BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"`
|
BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"`
|
||||||
/*
|
*OpenSubsonicChild `xml:",omitempty" json:",omitempty"`
|
||||||
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
|
|
||||||
*/
|
|
||||||
*OpenSubsonicChild `xml:",omitempty" json:",omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenSubsonicChild struct {
|
type OpenSubsonicChild struct {
|
||||||
@@ -198,14 +194,15 @@ type Songs struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Directory struct {
|
type Directory struct {
|
||||||
Child []Child `xml:"child" json:"child,omitempty"`
|
Child []Child `xml:"child" json:"child,omitempty"`
|
||||||
Id string `xml:"id,attr" json:"id"`
|
Id string `xml:"id,attr" json:"id"`
|
||||||
Name string `xml:"name,attr" json:"name"`
|
Name string `xml:"name,attr" json:"name"`
|
||||||
Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"`
|
Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"`
|
||||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||||
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
|
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
|
||||||
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
|
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
|
||||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||||
|
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
|
||||||
|
|
||||||
// ID3
|
// ID3
|
||||||
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
|
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
|
||||||
@@ -217,10 +214,6 @@ type Directory struct {
|
|||||||
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
|
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
|
||||||
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
|
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
|
||||||
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
|
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
|
||||||
|
|
||||||
/*
|
|
||||||
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 -->
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtistID3Ref is a reference to an artist, a simplified version of ArtistID3. This is used to resolve the
|
// ArtistID3Ref is a reference to an artist, a simplified version of ArtistID3. This is used to resolve the
|
||||||
@@ -237,6 +230,7 @@ type ArtistID3 struct {
|
|||||||
AlbumCount int32 `xml:"albumCount,attr" json:"albumCount"`
|
AlbumCount int32 `xml:"albumCount,attr" json:"albumCount"`
|
||||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||||
|
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
|
||||||
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
|
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
|
||||||
*OpenSubsonicArtistID3 `xml:",omitempty" json:",omitempty"`
|
*OpenSubsonicArtistID3 `xml:",omitempty" json:",omitempty"`
|
||||||
}
|
}
|
||||||
@@ -268,6 +262,7 @@ type OpenSubsonicAlbumID3 struct {
|
|||||||
// OpenSubsonic extensions
|
// OpenSubsonic extensions
|
||||||
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
|
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
|
||||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating"`
|
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating"`
|
||||||
|
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
|
||||||
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
|
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
|
||||||
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
|
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
|
||||||
IsCompilation bool `xml:"isCompilation,attr,omitempty" json:"isCompilation"`
|
IsCompilation bool `xml:"isCompilation,attr,omitempty" json:"isCompilation"`
|
||||||
|
|||||||
Reference in New Issue
Block a user