From 03120bac32d39efa2080cd4650191009f216b528 Mon Sep 17 00:00:00 2001 From: Terry Raimondo Date: Sun, 18 Jan 2026 23:42:42 +0100 Subject: [PATCH] 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 * 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 * 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 * 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 --- conf/configuration.go | 2 + .../20260117201522_add_avg_rating_column.sql | 23 ++++ model/annotation.go | 13 +- persistence/album_repository_test.go | 83 ++++++++++++ persistence/mediafile_repository_test.go | 68 ++++++++++ persistence/persistence_suite_test.go | 3 +- persistence/sql_annotations.go | 21 ++- server/subsonic/browsing.go | 6 + server/subsonic/helpers.go | 15 +++ server/subsonic/helpers_test.go | 127 ++++++++++++++++++ server/subsonic/responses/responses.go | 33 ++--- 11 files changed, 366 insertions(+), 28 deletions(-) create mode 100644 db/migrations/20260117201522_add_avg_rating_column.sql diff --git a/conf/configuration.go b/conf/configuration.go index 29e6582e3..9bc07e639 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -152,6 +152,7 @@ type subsonicOptions struct { AppendSubtitle bool ArtistParticipations bool DefaultReportRealPath bool + EnableAverageRating bool LegacyClients string MinimalClients string } @@ -605,6 +606,7 @@ func setViperDefaults() { viper.SetDefault("subsonic.appendsubtitle", true) viper.SetDefault("subsonic.artistparticipations", false) viper.SetDefault("subsonic.defaultreportrealpath", false) + viper.SetDefault("subsonic.enableaveragerating", true) viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic") viper.SetDefault("agents", "lastfm,spotify,deezer") viper.SetDefault("lastfm.enabled", true) diff --git a/db/migrations/20260117201522_add_avg_rating_column.sql b/db/migrations/20260117201522_add_avg_rating_column.sql new file mode 100644 index 000000000..f5c8d4522 --- /dev/null +++ b/db/migrations/20260117201522_add_avg_rating_column.sql @@ -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; diff --git a/model/annotation.go b/model/annotation.go index fbff5f178..5228028a6 100644 --- a/model/annotation.go +++ b/model/annotation.go @@ -3,12 +3,13 @@ package model import "time" type Annotations struct { - PlayCount int64 `structs:"play_count" json:"playCount,omitempty"` - PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" ` - Rating int `structs:"rating" json:"rating,omitempty" ` - RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" ` - Starred bool `structs:"starred" json:"starred,omitempty" ` - StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"` + PlayCount int64 `structs:"play_count" json:"playCount,omitempty"` + PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" ` + Rating int `structs:"rating" json:"rating,omitempty" ` + RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" ` + Starred bool `structs:"starred" json:"starred,omitempty" ` + StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"` + AverageRating float64 `structs:"average_rating" json:"averageRating,omitempty"` } type AnnotatedRepository interface { diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 284d4dc5e..612e459f0 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -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 diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 35fda0873..e33639721 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -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()) diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index f3cb4f3d0..559ca3d4c 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -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 { diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 108e9be94..fac519829 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -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 { diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index ba3fb058a..30779e420 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -410,6 +410,9 @@ func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artis } dir.AlbumCount = getArtistAlbumCount(artist) dir.UserRating = int32(artist.Rating) + if conf.Server.Subsonic.EnableAverageRating { + dir.AverageRating = artist.AverageRating + } if artist.Starred { dir.Starred = artist.StarredAt } @@ -447,6 +450,9 @@ func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) dir.Played = album.PlayDate } dir.UserRating = int32(album.Rating) + if conf.Server.Subsonic.EnableAverageRating { + dir.AverageRating = album.AverageRating + } dir.SongCount = int32(album.SongCount) dir.CoverArt = album.CoverArtID().String() if album.Starred { diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 2303914d6..dfeb4c6dd 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -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} }) diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 2a5f43765..8d86b0010 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -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)) + }) + }) + }) }) diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index de47c54e2..c5e07395e 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -95,11 +95,9 @@ type Artist struct { Name string `xml:"name,attr" json:"name"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,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"` ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"` - /* TODO: - - */ } type Index struct { @@ -160,13 +158,11 @@ type Child struct { ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"` Type string `xml:"type,attr,omitempty" json:"type,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"` IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"` BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"` - /* - - */ - *OpenSubsonicChild `xml:",omitempty" json:",omitempty"` + *OpenSubsonicChild `xml:",omitempty" json:",omitempty"` } type OpenSubsonicChild struct { @@ -198,14 +194,15 @@ type Songs struct { } type Directory struct { - Child []Child `xml:"child" json:"child,omitempty"` - Id string `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` - Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"` - Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` - PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` - Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"` - UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` + Child []Child `xml:"child" json:"child,omitempty"` + Id string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"` + Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` + PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` + Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"` + UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` + AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"` // ID3 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"` Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"` Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` - - /* - - */ } // 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"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,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"` *OpenSubsonicArtistID3 `xml:",omitempty" json:",omitempty"` } @@ -268,6 +262,7 @@ type OpenSubsonicAlbumID3 struct { // OpenSubsonic extensions Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"` 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"` MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"` IsCompilation bool `xml:"isCompilation,attr,omitempty" json:"isCompilation"`