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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 366 additions and 28 deletions

View File

@ -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)

View File

@ -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;

View File

@ -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 {

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

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())

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 {

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 {

View File

@ -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 {

View File

@ -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}
})

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))
})
})
})
})

View File

@ -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:
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 -->
*/
}
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"`
/*
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
*/
*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"`
/*
<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
@ -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"`