diff --git a/db/migrations.go b/db/migrations.go index 6e8a485b..4e6d6592 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -44,6 +44,7 @@ func (db *DB) Migrate(ctx MigrationContext) error { construct(ctx, "202204270903", migratePodcastDropUserID), construct(ctx, "202206011628", migrateInternetRadioStations), construct(ctx, "202206101425", migrateUser), + construct(ctx, "202207251148", migrateStarRating), } return gormigrate. @@ -371,3 +372,18 @@ func migrateUser(tx *gorm.DB, _ MigrationContext) error { ). Error } + +func migrateStarRating(tx *gorm.DB, _ MigrationContext) error { + return tx.AutoMigrate( + Album{}, + AlbumStar{}, + AlbumRating{}, + Artist{}, + ArtistStar{}, + ArtistRating{}, + Track{}, + TrackStar{}, + TrackRating{}, + ). + Error +} diff --git a/db/model.go b/db/model.go index ed0d2fb3..e742a4a6 100644 --- a/db/model.go +++ b/db/model.go @@ -1,4 +1,5 @@ // Package db provides database helpers and models +// //nolint:lll // struct tags get very long and can't be split package db @@ -42,12 +43,15 @@ func joinInt(in []int, sep string) string { } type Artist struct { - ID int `gorm:"primary_key"` - Name string `gorm:"not null; unique_index"` - NameUDec string `sql:"default: null"` - Albums []*Album `gorm:"foreignkey:TagArtistID"` - AlbumCount int `sql:"-"` - Cover string `sql:"default: null"` + ID int `gorm:"primary_key"` + Name string `gorm:"not null; unique_index"` + NameUDec string `sql:"default: null"` + Albums []*Album `gorm:"foreignkey:TagArtistID"` + AlbumCount int `sql:"-"` + Cover string `sql:"default: null"` + ArtistStar *ArtistStar + ArtistRating *ArtistRating + AverageRating float64 `sql:"default: null"` } func (a *Artist) SID() *specid.ID { @@ -98,6 +102,9 @@ type Track struct { TagTrackNumber int `sql:"default: null"` TagDiscNumber int `sql:"default: null"` TagBrainzID string `sql:"default: null"` + TrackStar *TrackStar + TrackRating *TrackRating + AverageRating float64 `sql:"default: null"` } func (t *Track) AudioLength() int { return t.Length } @@ -212,6 +219,9 @@ type Album struct { Tracks []*Track ChildCount int `sql:"-"` Duration int `sql:"-"` + AlbumStar *AlbumStar + AlbumRating *AlbumRating + AverageRating float64 `sql:"default: null"` } func (a *Album) SID() *specid.ID { @@ -304,6 +314,42 @@ type AlbumGenre struct { GenreID int `gorm:"not null; unique_index:idx_album_id_genre_id" sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"` } +type AlbumStar struct { + UserID int `gorm:"primary_key; not null" sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"` + AlbumID int `gorm:"primary_key; not null" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` + StarDate time.Time +} + +type AlbumRating struct { + UserID int `gorm:"primary_key; not null" sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"` + AlbumID int `gorm:"primary_key; not null" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` + Rating int `gorm:"not null; check:(rating >= 1 AND rating <= 5)"` +} + +type ArtistStar struct { + UserID int `gorm:"primary_key; not null" sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"` + ArtistID int `gorm:"primary_key; not null" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` + StarDate time.Time +} + +type ArtistRating struct { + UserID int `gorm:"primary_key; not null" sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"` + ArtistID int `gorm:"primary_key; not null" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` + Rating int `gorm:"not null; check:(rating >= 1 AND rating <= 5)"` +} + +type TrackStar struct { + UserID int `gorm:"primary_key; not null" sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"` + TrackID int `gorm:"primary_key; not null" sql:"default: null; type:int REFERENCES tracks(id) ON DELETE CASCADE"` + StarDate time.Time +} + +type TrackRating struct { + UserID int `gorm:"primary_key; not null" sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"` + TrackID int `gorm:"primary_key; not null" sql:"default: null; type:int REFERENCES tracks(id) ON DELETE CASCADE"` + Rating int `gorm:"not null; check:(rating >= 1 AND rating <= 5)"` +} + type PodcastAutoDownload string const ( diff --git a/server/ctrlsubsonic/handlers_by_folder.go b/server/ctrlsubsonic/handlers_by_folder.go index cd522f50..07d205fa 100644 --- a/server/ctrlsubsonic/handlers_by_folder.go +++ b/server/ctrlsubsonic/handlers_by_folder.go @@ -20,6 +20,7 @@ import ( func (c *Controller) ServeGetIndexes(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) rootQ := c.DB. Select("id"). Model(&db.Album{}). @@ -31,6 +32,8 @@ func (c *Controller) ServeGetIndexes(r *http.Request) *spec.Response { var folders []*db.Album c.DB. Select("*, count(sub.id) child_count"). + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID). Joins("LEFT JOIN albums sub ON albums.id=sub.parent_id"). Where("albums.parent_id IN ?", rootQ.SubQuery()). Group("albums.id"). @@ -48,8 +51,7 @@ func (c *Controller) ServeGetIndexes(r *http.Request) *spec.Response { } resp = append(resp, indexMap[key]) } - indexMap[key].Artists = append(indexMap[key].Artists, - spec.NewArtistByFolder(folder)) + indexMap[key].Artists = append(indexMap[key].Artists, spec.NewArtistByFolder(folder)) } sub := spec.NewResponse() sub.Indexes = &spec.Indexes{ @@ -65,17 +67,23 @@ func (c *Controller) ServeGetMusicDirectory(r *http.Request) *spec.Response { if err != nil { return spec.NewError(10, "please provide an `id` parameter") } + user := r.Context().Value(CtxUser).(*db.User) childrenObj := []*spec.TrackChild{} folder := &db.Album{} - c.DB.First(folder, id.Value) + c.DB. + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID). + First(folder, id.Value) // start looking for child childFolders in the current dir var childFolders []*db.Album c.DB. Where("parent_id=?", id.Value). + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID). Order("albums.right_path COLLATE NOCASE"). Find(&childFolders) - for _, c := range childFolders { - childrenObj = append(childrenObj, spec.NewTCAlbumByFolder(c)) + for _, ch := range childFolders { + childrenObj = append(childrenObj, spec.NewTCAlbumByFolder(ch)) } // start looking for child childTracks in the current dir var childTracks []*db.Track @@ -83,10 +91,12 @@ func (c *Controller) ServeGetMusicDirectory(r *http.Request) *spec.Response { Where("album_id=?", id.Value). Preload("Album"). Preload("Album.TagArtist"). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID). Order("filename"). Find(&childTracks) - for _, c := range childTracks { - toAppend := spec.NewTCTrackByFolder(c, folder) + for _, ch := range childTracks { + toAppend := spec.NewTCTrackByFolder(ch, folder) if v, _ := params.Get("c"); v == "Jamstash" { // jamstash thinks it can't play flacs toAppend.ContentType = "audio/mpeg" @@ -105,6 +115,7 @@ func (c *Controller) ServeGetMusicDirectory(r *http.Request) *spec.Response { // getAlbumListTwo() function func (c *Controller) ServeGetAlbumList(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) q := c.DB.DB switch v, _ := params.Get("type"); v { case "alphabeticalByArtist": @@ -120,14 +131,13 @@ func (c *Controller) ServeGetAlbumList(r *http.Request) *spec.Response { if fromYear > toYear { toYear, fromYear = fromYear, toYear } - q = q.Where("tag_year BETWEEN ? AND ?", fromYear, toYear) + q = q.Where("tag_year BETWEEN ? AND ?", fromYear, toYear) q = q.Order("tag_year") case "byGenre": genre, _ := params.Get("genre") q = q.Joins("JOIN album_genres ON album_genres.album_id=albums.id") q = q.Joins("JOIN genres ON genres.id=album_genres.genre_id AND genres.name=?", genre) case "frequent": - user := r.Context().Value(CtxUser).(*db.User) q = q.Joins(` JOIN plays ON albums.id=plays.album_id AND plays.user_id=?`, @@ -162,6 +172,8 @@ func (c *Controller) ServeGetAlbumList(r *http.Request) *spec.Response { Offset(params.GetOrInt("offset", 0)). Limit(params.GetOrInt("size", 10)). Preload("Parent"). + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID). Find(&folders) sub := spec.NewResponse() sub.Albums = &spec.Albums{ @@ -175,6 +187,7 @@ func (c *Controller) ServeGetAlbumList(r *http.Request) *spec.Response { func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) query, err := params.Get("query") if err != nil { return spec.NewError(10, "please provide a `query` parameter") @@ -195,6 +208,8 @@ func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response { var artists []*db.Album q := c.DB. Where(`parent_id IN ? AND (right_path LIKE ? OR right_path_u_dec LIKE ?)`, rootQ.SubQuery(), query, query). + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID). Offset(params.GetOrInt("artistOffset", 0)). Limit(params.GetOrInt("artistCount", 20)) if err := q.Find(&artists).Error; err != nil { @@ -208,6 +223,8 @@ func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response { var albums []*db.Album q = c.DB. Where(`tag_artist_id IS NOT NULL AND (right_path LIKE ? OR right_path_u_dec LIKE ?)`, query, query). + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID). Offset(params.GetOrInt("albumOffset", 0)). Limit(params.GetOrInt("albumCount", 20)) if m := c.getMusicFolder(params); m != "" { @@ -225,6 +242,8 @@ func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response { q = c.DB. Preload("Album"). Where("filename LIKE ? OR filename_u_dec LIKE ?", query, query). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID). Offset(params.GetOrInt("songOffset", 0)). Limit(params.GetOrInt("songCount", 20)) if m := c.getMusicFolder(params); m != "" { @@ -249,11 +268,73 @@ func (c *Controller) ServeGetArtistInfo(r *http.Request) *spec.Response { } func (c *Controller) ServeGetStarred(r *http.Request) *spec.Response { - sub := spec.NewResponse() - sub.Starred = &spec.Starred{ - Artists: []*spec.Directory{}, - Albums: []*spec.TrackChild{}, - Tracks: []*spec.TrackChild{}, + params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) + + results := &spec.Starred{} + + // "artists" + rootQ := c.DB. + Select("id"). + Model(&db.Album{}). + Where("parent_id IS NULL") + if m := c.getMusicFolder(params); m != "" { + rootQ = rootQ.Where("root_dir=?", m) + } + + var artists []*db.Album + q := c.DB. + Where(`parent_id IN ?`, rootQ.SubQuery()). + Joins("JOIN album_stars ON albums.id=album_stars.album_id"). + Where("album_stars.user_id=?", user.ID). + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID) + if err := q.Find(&artists).Error; err != nil { + return spec.NewError(0, "find artists: %v", err) + } + for _, a := range artists { + results.Artists = append(results.Artists, spec.NewDirectoryByFolder(a, nil)) + } + + // "albums" + var albums []*db.Album + q = c.DB. + Where("tag_artist_id IS NOT NULL"). + Joins("JOIN album_stars ON albums.id=album_stars.album_id"). + Where("album_stars.user_id=?", user.ID). + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID) + if m := c.getMusicFolder(params); m != "" { + q = q.Where("root_dir=?", m) } + if err := q.Find(&albums).Error; err != nil { + return spec.NewError(0, "find albums: %v", err) + } + for _, a := range albums { + results.Albums = append(results.Albums, spec.NewTCAlbumByFolder(a)) + } + + // tracks + var tracks []*db.Track + q = c.DB. + Preload("Album"). + Joins("JOIN track_stars ON tracks.id=track_stars.track_id"). + Where("track_stars.user_id=?", user.ID). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID) + if m := c.getMusicFolder(params); m != "" { + q = q. + Joins("JOIN albums ON albums.id=tracks.album_id"). + Where("albums.root_dir=?", m) + } + if err := q.Find(&tracks).Error; err != nil { + return spec.NewError(0, "find tracks: %v", err) + } + for _, t := range tracks { + results.Tracks = append(results.Tracks, spec.NewTCTrackByFolder(t, t.Album)) + } + + sub := spec.NewResponse() + sub.Starred = results return sub } diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index 1d312037..6deab631 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -3,10 +3,12 @@ package ctrlsubsonic import ( "errors" "fmt" + "math" "net/http" "net/url" "strconv" "strings" + "time" "github.com/jinzhu/gorm" @@ -19,10 +21,13 @@ import ( func (c *Controller) ServeGetArtists(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) var artists []*db.Artist q := c.DB. Select("*, count(sub.id) album_count"). Joins("LEFT JOIN albums sub ON artists.id=sub.tag_artist_id"). + Preload("ArtistStar", "user_id=?", user.ID). + Preload("ArtistRating", "user_id=?", user.ID). Group("artists.id"). Order("artists.name COLLATE NOCASE") if m := c.getMusicFolder(params); m != "" { @@ -43,8 +48,7 @@ func (c *Controller) ServeGetArtists(r *http.Request) *spec.Response { } resp = append(resp, indexMap[key]) } - indexMap[key].Artists = append(indexMap[key].Artists, - spec.NewArtistByTags(artist)) + indexMap[key].Artists = append(indexMap[key].Artists, spec.NewArtistByTags(artist)) } sub := spec.NewResponse() sub.Artists = &spec.Artists{ @@ -55,6 +59,7 @@ func (c *Controller) ServeGetArtists(r *http.Request) *spec.Response { func (c *Controller) ServeGetArtist(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) id, err := params.GetID("id") if err != nil { return spec.NewError(10, "please provide an `id` parameter") @@ -65,9 +70,13 @@ func (c *Controller) ServeGetArtist(r *http.Request) *spec.Response { return db. Select("*, count(sub.id) child_count, sum(sub.length) duration"). Joins("LEFT JOIN tracks sub ON albums.id=sub.album_id"). + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID). Order("albums.right_path"). Group("albums.id") }). + Preload("ArtistStar", "user_id=?", user.ID). + Preload("ArtistRating", "user_id=?", user.ID). First(artist, id.Value) sub := spec.NewResponse() sub.Artist = spec.NewArtistByTags(artist) @@ -81,6 +90,7 @@ func (c *Controller) ServeGetArtist(r *http.Request) *spec.Response { func (c *Controller) ServeGetAlbum(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) id, err := params.GetID("id") if err != nil { return spec.NewError(10, "please provide an `id` parameter") @@ -92,8 +102,13 @@ func (c *Controller) ServeGetAlbum(r *http.Request) *spec.Response { Preload("TagArtist"). Preload("Genres"). Preload("Tracks", func(db *gorm.DB) *gorm.DB { - return db.Order("tracks.tag_disc_number, tracks.tag_track_number") + return db. + Order("tracks.tag_disc_number, tracks.tag_track_number"). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID) }). + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID). First(album, id.Value). Error if errors.Is(err, gorm.ErrRecordNotFound) { @@ -113,6 +128,7 @@ func (c *Controller) ServeGetAlbum(r *http.Request) *spec.Response { // getAlbumList() function func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) listType, err := params.Get("type") if err != nil { return spec.NewError(10, "please provide a `type` parameter") @@ -130,7 +146,7 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { if fromYear > toYear { toYear, fromYear = fromYear, toYear } - q = q.Where("tag_year BETWEEN ? AND ?", fromYear, toYear) + q = q.Where("tag_year BETWEEN ? AND ?", fromYear, toYear) q = q.Order("tag_year") case "byGenre": genre, _ := params.Get("genre") @@ -138,8 +154,7 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { q = q.Joins("JOIN genres ON genres.id=album_genres.genre_id AND genres.name=?", genre) case "frequent": user := r.Context().Value(CtxUser).(*db.User) - q = q.Joins("JOIN plays ON albums.id=plays.album_id AND plays.user_id=?", - user.ID) + q = q.Joins("JOIN plays ON albums.id=plays.album_id AND plays.user_id=?", user.ID) q = q.Order("plays.count DESC") case "newest": q = q.Order("created_at DESC") @@ -147,8 +162,7 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { q = q.Order(gorm.Expr("random()")) case "recent": user := r.Context().Value(CtxUser).(*db.User) - q = q.Joins("JOIN plays ON albums.id=plays.album_id AND plays.user_id=?", - user.ID) + q = q.Joins("JOIN plays ON albums.id=plays.album_id AND plays.user_id=?", user.ID) q = q.Order("plays.time DESC") default: return spec.NewError(10, "unknown value `%s` for parameter 'type'", listType) @@ -167,6 +181,8 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { Offset(params.GetOrInt("offset", 0)). Limit(params.GetOrInt("size", 10)). Preload("TagArtist"). + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID). Find(&albums) sub := spec.NewResponse() sub.AlbumsTwo = &spec.Albums{ @@ -180,20 +196,24 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) query, err := params.Get("query") if err != nil { return spec.NewError(10, "please provide a `query` parameter") } query = fmt.Sprintf("%%%s%%", strings.Trim(query, `*"'`)) + results := &spec.SearchResultThree{} - // search "artists" + // search artists var artists []*db.Artist q := c.DB. Select("*, count(albums.id) album_count"). Group("artists.id"). Where("name LIKE ? OR name_u_dec LIKE ?", query, query). Joins("JOIN albums ON albums.tag_artist_id=artists.id"). + Preload("ArtistStar", "user_id=?", user.ID). + Preload("ArtistRating", "user_id=?", user.ID). Offset(params.GetOrInt("artistOffset", 0)). Limit(params.GetOrInt("artistCount", 20)) if m := c.getMusicFolder(params); m != "" { @@ -206,11 +226,13 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response { results.Artists = append(results.Artists, spec.NewArtistByTags(a)) } - // search "albums" + // search albums var albums []*db.Album q = c.DB. Preload("TagArtist"). Preload("Genres"). + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID). Where("tag_title LIKE ? OR tag_title_u_dec LIKE ?", query, query). Offset(params.GetOrInt("albumOffset", 0)). Limit(params.GetOrInt("albumCount", 20)) @@ -230,6 +252,8 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response { Preload("Album"). Preload("Album.TagArtist"). Preload("Genres"). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID). Where("tag_title LIKE ? OR tag_title_u_dec LIKE ?", query, query). Offset(params.GetOrInt("songOffset", 0)). Limit(params.GetOrInt("songCount", 20)) @@ -356,6 +380,7 @@ func (c *Controller) ServeGetGenres(r *http.Request) *spec.Response { func (c *Controller) ServeGetSongsByGenre(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) genre, err := params.Get("genre") if err != nil { return spec.NewError(10, "please provide an `genre` parameter") @@ -367,6 +392,8 @@ func (c *Controller) ServeGetSongsByGenre(r *http.Request) *spec.Response { Joins("JOIN genres ON track_genres.genre_id=genres.id AND genres.name=?", genre). Preload("Album"). Preload("Album.TagArtist"). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID). Offset(params.GetOrInt("offset", 0)). Limit(params.GetOrInt("count", 10)) if m := c.getMusicFolder(params); m != "" { @@ -386,12 +413,70 @@ func (c *Controller) ServeGetSongsByGenre(r *http.Request) *spec.Response { } func (c *Controller) ServeGetStarredTwo(r *http.Request) *spec.Response { - sub := spec.NewResponse() - sub.StarredTwo = &spec.StarredTwo{ - Artists: []*spec.Artist{}, - Albums: []*spec.Album{}, - Tracks: []*spec.TrackChild{}, + user := r.Context().Value(CtxUser).(*db.User) + params := r.Context().Value(CtxParams).(params.Params) + + results := &spec.StarredTwo{} + + // artists + var artists []*db.Artist + q := c.DB. + Select("*, count(albums.id) album_count"). + Group("artists.id"). + Joins("JOIN artist_stars ON artist_stars.artist_id=artists.id"). + Where("artist_stars.user_id=?", user.ID). + Preload("ArtistStar", "user_id=?", user.ID). + Preload("ArtistRating", "user_id=?", user.ID) + if m := c.getMusicFolder(params); m != "" { + q = q.Where("albums.root_dir=?", m) + } + if err := q.Find(&artists).Error; err != nil { + return spec.NewError(0, "find artists: %v", err) } + for _, a := range artists { + results.Artists = append(results.Artists, spec.NewArtistByTags(a)) + } + + // albums + var albums []*db.Album + q = c.DB. + Joins("JOIN album_stars ON album_stars.album_id=albums.id"). + Where("album_stars.user_id=?", user.ID). + Preload("TagArtist"). + Preload("AlbumStar", "user_id=?", user.ID). + Preload("AlbumRating", "user_id=?", user.ID) + if m := c.getMusicFolder(params); m != "" { + q = q.Where("albums.root_dir=?", m) + } + if err := q.Find(&albums).Error; err != nil { + return spec.NewError(0, "find albums: %v", err) + } + for _, a := range albums { + results.Albums = append(results.Albums, spec.NewAlbumByTags(a, a.TagArtist)) + } + + // tracks + var tracks []*db.Track + q = c.DB. + Joins("JOIN track_stars ON tracks.id=track_stars.track_id"). + Where("track_stars.user_id=?", user.ID). + Preload("Album"). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID) + if m := c.getMusicFolder(params); m != "" { + q = q. + Joins("JOIN albums ON albums.id=tracks.album_id"). + Where("albums.root_dir=?", m) + } + if err := q.Find(&tracks).Error; err != nil { + return spec.NewError(0, "find tracks: %v", err) + } + for _, t := range tracks { + results.Tracks = append(results.Tracks, spec.NewTrackByTags(t, t.Album)) + } + + sub := spec.NewResponse() + sub.StarredTwo = results return sub } @@ -409,6 +494,7 @@ func (c *Controller) genArtistCoverURL(r *http.Request, artist *db.Artist, size func (c *Controller) ServeGetTopSongs(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) count := params.GetOrInt("count", 10) artistName, err := params.Get("artist") if err != nil { @@ -441,6 +527,8 @@ func (c *Controller) ServeGetTopSongs(r *http.Request) *spec.Response { Preload("Album"). Where("artist_id=? AND tracks.tag_title IN (?)", artist.ID, topTrackNames). Limit(count). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID). Find(&tracks). Error if err != nil { @@ -462,6 +550,7 @@ func (c *Controller) ServeGetTopSongs(r *http.Request) *spec.Response { func (c *Controller) ServeGetSimilarSongs(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) count := params.GetOrInt("count", 10) id, err := params.GetID("id") if err != nil || id.Type != specid.Track { @@ -500,6 +589,8 @@ func (c *Controller) ServeGetSimilarSongs(r *http.Request) *spec.Response { err = c.DB. Preload("Artist"). Preload("Album"). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID). Select("tracks.*"). Where("tracks.tag_title IN (?)", similarTrackNames). Order(gorm.Expr("random()")). @@ -525,6 +616,7 @@ func (c *Controller) ServeGetSimilarSongs(r *http.Request) *spec.Response { func (c *Controller) ServeGetSimilarSongsTwo(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) count := params.GetOrInt("count", 10) id, err := params.GetID("id") if err != nil || id.Type != specid.Artist { @@ -561,6 +653,8 @@ func (c *Controller) ServeGetSimilarSongsTwo(r *http.Request) *spec.Response { var tracks []*db.Track err = c.DB. Preload("Album"). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID). Joins("JOIN artists on tracks.artist_id=artists.id"). Where("artists.name IN (?)", artistNames). Order(gorm.Expr("random()")). @@ -583,3 +677,200 @@ func (c *Controller) ServeGetSimilarSongsTwo(r *http.Request) *spec.Response { } return sub } + +func starIDsOfType(p params.Params, typ specid.IDT) []int { + var ids []specid.ID + ids = append(ids, p.GetOrIDList("id", nil)...) + ids = append(ids, p.GetOrIDList("albumId", nil)...) + ids = append(ids, p.GetOrIDList("artistId", nil)...) + + var out []int + for _, id := range ids { + if id.Type != typ { + continue + } + out = append(out, id.Value) + } + return out +} + +func (c *Controller) ServeStar(r *http.Request) *spec.Response { + params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) + + stardate := time.Now() + for _, id := range starIDsOfType(params, specid.Album) { + var albumstar db.AlbumStar + _ = c.DB.Where("user_id=? AND album_id=?", user.ID, id).First(&albumstar).Error + albumstar.UserID = user.ID + albumstar.AlbumID = id + albumstar.StarDate = stardate + if err := c.DB.Save(&albumstar).Error; err != nil { + return spec.NewError(0, "save album star: %v", err) + } + } + + for _, id := range starIDsOfType(params, specid.Artist) { + var artiststar db.ArtistStar + _ = c.DB.Where("user_id=? AND artist_id=?", user.ID, id).First(&artiststar).Error + artiststar.UserID = user.ID + artiststar.ArtistID = id + artiststar.StarDate = stardate + if err := c.DB.Save(&artiststar).Error; err != nil { + return spec.NewError(0, "save artist star: %v", err) + } + } + + for _, id := range starIDsOfType(params, specid.Track) { + var trackstar db.TrackStar + _ = c.DB.Where("user_id=? AND track_id=?", user.ID, id).First(&trackstar).Error + trackstar.UserID = user.ID + trackstar.TrackID = id + trackstar.StarDate = stardate + if err := c.DB.Save(&trackstar).Error; err != nil { + return spec.NewError(0, "save track star: %v", err) + } + } + + return spec.NewResponse() +} + +func (c *Controller) ServeUnstar(r *http.Request) *spec.Response { + params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) + + for _, id := range starIDsOfType(params, specid.Album) { + if err := c.DB.Where("user_id=? AND album_id=?", user.ID, id).Delete(db.AlbumStar{}).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return spec.NewError(0, "delete album star: %v", err) + } + } + + for _, id := range starIDsOfType(params, specid.Artist) { + if err := c.DB.Where("user_id=? AND artist_id=?", user.ID, id).Delete(db.ArtistStar{}).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return spec.NewError(0, "delete artist star: %v", err) + } + } + + for _, id := range starIDsOfType(params, specid.Track) { + if err := c.DB.Where("user_id=? AND track_id=?", user.ID, id).Delete(db.TrackStar{}).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return spec.NewError(0, "delete track star: %v", err) + } + } + + return spec.NewResponse() +} + +//nolint:gocyclo // we could probably simplify this with some interfaces or generics. but it's fine for now +func (c *Controller) ServeSetRating(r *http.Request) *spec.Response { + params := r.Context().Value(CtxParams).(params.Params) + id, err := params.GetID("id") + if err != nil { + return spec.NewError(10, "please provide a valid id") + } + rating, err := params.GetInt("rating") + if err != nil || rating < 0 || rating > 5 { + return spec.NewError(10, "please provide a valid rating") + } + + user := r.Context().Value(CtxUser).(*db.User) + + switch id.Type { + case specid.Album: + var album db.Album + err := c.DB.Where("id=?", id.Value).First(&album).Error + if err != nil { + return spec.NewError(0, "fetch album: %v", err) + } + var albumRating db.AlbumRating + if err := c.DB.Where("user_id=? AND album_id=?", user.ID, id.Value).First(&albumRating).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return spec.NewError(0, "fetch album rating: %v", err) + } + switch { + case rating == 0 && albumRating.AlbumID == album.ID: + if err := c.DB.Delete(&albumRating).Error; err != nil { + return spec.NewError(0, "delete album rating: %v", err) + } + case rating > 0: + albumRating.UserID = user.ID + albumRating.AlbumID = id.Value + albumRating.Rating = rating + if err := c.DB.Save(&albumRating).Error; err != nil { + return spec.NewError(0, "save album rating: %v", err) + } + } + var averageRating float64 + if err := c.DB.Model(db.AlbumRating{}).Select("coalesce(avg(rating), 0)").Where("album_id=?", id.Value).Row().Scan(&averageRating); err != nil { + return spec.NewError(0, "find average album rating: %v", err) + } + album.AverageRating = math.Trunc(averageRating*100) / 100 + if err := c.DB.Save(&album).Error; err != nil { + return spec.NewError(0, "save album: %v", err) + } + case specid.Artist: + var artist db.Artist + err := c.DB.Where("id=?", id.Value).First(&artist).Error + if err != nil { + return spec.NewError(0, "fetch artist: %v", err) + } + var artistRating db.ArtistRating + if err := c.DB.Where("user_id=? AND artist_id=?", user.ID, id.Value).First(&artistRating).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return spec.NewError(0, "fetch artist rating: %v", err) + } + switch { + case rating == 0 && artistRating.ArtistID == artist.ID: + if err := c.DB.Delete(&artistRating).Error; err != nil { + return spec.NewError(0, "delete artist rating: %v", err) + } + case rating > 0: + artistRating.UserID = user.ID + artistRating.ArtistID = id.Value + artistRating.Rating = rating + if err := c.DB.Save(&artistRating).Error; err != nil { + return spec.NewError(0, "save artist rating: %v", err) + } + } + var averageRating float64 + if err := c.DB.Model(db.ArtistRating{}).Select("coalesce(avg(rating), 0)").Where("artist_id=?", id.Value).Row().Scan(&averageRating); err != nil { + return spec.NewError(0, "find average artist rating: %v", err) + } + artist.AverageRating = math.Trunc(averageRating*100) / 100 + if err := c.DB.Save(&artist).Error; err != nil { + return spec.NewError(0, "save artist: %v", err) + } + case specid.Track: + var track db.Track + err := c.DB.Where("id=?", id.Value).First(&track).Error + if err != nil { + return spec.NewError(0, "fetch track: %v", err) + } + var trackRating db.TrackRating + if err := c.DB.Where("user_id=? AND track_id=?", user.ID, id.Value).First(&trackRating).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return spec.NewError(0, "fetch track rating: %v", err) + } + switch { + case rating == 0 && trackRating.TrackID == track.ID: + if err := c.DB.Delete(&trackRating).Error; err != nil { + return spec.NewError(0, "delete track rating: %v", err) + } + case rating > 0: + trackRating.UserID = user.ID + trackRating.TrackID = id.Value + trackRating.Rating = rating + if err := c.DB.Save(&trackRating).Error; err != nil { + return spec.NewError(0, "save track rating: %v", err) + } + } + var averageRating float64 + if err := c.DB.Model(db.TrackRating{}).Select("coalesce(avg(rating), 0)").Where("track_id=?", id.Value).Row().Scan(&averageRating); err != nil { + return spec.NewError(0, "find average track rating: %v", err) + } + track.AverageRating = math.Trunc(averageRating*100) / 100 + if err := c.DB.Save(&track).Error; err != nil { + return spec.NewError(0, "save track: %v", err) + } + default: + return spec.NewError(0, "non-album non-artist non-track id cannot be rated") + } + + return spec.NewResponse() +} diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index a2dded46..602f35f8 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -150,6 +150,8 @@ func (c *Controller) ServeGetPlayQueue(r *http.Request) *spec.Response { c.DB. Where("id=?", id). Preload("Album"). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID). Find(&track) sub.PlayQueue.List[i] = spec.NewTCTrackByFolder(&track, track.Album) } @@ -180,12 +182,13 @@ func (c *Controller) ServeSavePlayQueue(r *http.Request) *spec.Response { queue.Position = params.GetOrInt("position", 0) queue.ChangedBy = params.GetOr("c", "") // must exist, middleware checks queue.SetItems(trackIDs) - c.DB.Save(queue) + c.DB.Save(&queue) return spec.NewResponse() } func (c *Controller) ServeGetSong(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) id, err := params.GetID("id") if err != nil { return spec.NewError(10, "provide an `id` parameter") @@ -195,6 +198,8 @@ func (c *Controller) ServeGetSong(r *http.Request) *spec.Response { Where("id=?", id.Value). Preload("Album"). Preload("Album.TagArtist"). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID). First(&track). Error if errors.Is(err, gorm.ErrRecordNotFound) { @@ -207,11 +212,14 @@ func (c *Controller) ServeGetSong(r *http.Request) *spec.Response { func (c *Controller) ServeGetRandomSongs(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) var tracks []*db.Track q := c.DB.DB. Limit(params.GetOrInt("size", 10)). Preload("Album"). Preload("Album.TagArtist"). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID). Joins("JOIN albums ON tracks.album_id=albums.id"). Order(gorm.Expr("random()")) if year, err := params.GetInt("fromYear"); err == nil { @@ -241,6 +249,7 @@ func (c *Controller) ServeGetRandomSongs(r *http.Request) *spec.Response { func (c *Controller) ServeJukebox(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) + user := r.Context().Value(CtxUser).(*db.User) getTracks := func() []*db.Track { var tracks []*db.Track ids, err := params.GetIDList("id") @@ -249,7 +258,11 @@ func (c *Controller) ServeJukebox(r *http.Request) *spec.Response { } for _, id := range ids { track := &db.Track{} - c.DB.Preload("Album").First(track, id.Value) + c.DB. + Preload("Album"). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID). + First(track, id.Value) if track.ID != 0 { tracks = append(tracks, track) } diff --git a/server/ctrlsubsonic/handlers_playlist.go b/server/ctrlsubsonic/handlers_playlist.go index b29793b7..343ea97b 100644 --- a/server/ctrlsubsonic/handlers_playlist.go +++ b/server/ctrlsubsonic/handlers_playlist.go @@ -8,9 +8,9 @@ import ( "github.com/jinzhu/gorm" + "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/server/ctrlsubsonic/params" "go.senan.xyz/gonic/server/ctrlsubsonic/spec" - "go.senan.xyz/gonic/db" ) func playlistRender(c *Controller, playlist *db.Playlist) *spec.Playlist { @@ -35,6 +35,8 @@ func playlistRender(c *Controller, playlist *db.Playlist) *spec.Playlist { Where("id=?", id). Preload("Album"). Preload("Album.TagArtist"). + Preload("TrackStar", "user_id=?", user.ID). + Preload("TrackRating", "user_id=?", user.ID). Find(&track). Error if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/server/ctrlsubsonic/spec/construct_by_folder.go b/server/ctrlsubsonic/spec/construct_by_folder.go index fb585516..1b5a2ec2 100644 --- a/server/ctrlsubsonic/spec/construct_by_folder.go +++ b/server/ctrlsubsonic/spec/construct_by_folder.go @@ -9,14 +9,21 @@ import ( func NewAlbumByFolder(f *db.Album) *Album { a := &Album{ - Artist: f.Parent.RightPath, - ID: f.SID(), - IsDir: true, - ParentID: f.ParentSID(), - Title: f.RightPath, - TrackCount: f.ChildCount, - Duration: f.Duration, - Created: f.CreatedAt, + Artist: f.Parent.RightPath, + ID: f.SID(), + IsDir: true, + ParentID: f.ParentSID(), + Title: f.RightPath, + TrackCount: f.ChildCount, + Duration: f.Duration, + Created: f.CreatedAt, + AverageRating: formatRating(f.AverageRating), + } + if f.AlbumStar != nil { + a.Starred = &f.AlbumStar.StarDate + } + if f.AlbumRating != nil { + a.UserRating = f.AlbumRating.Rating } if f.Cover != "" { a.CoverID = f.SID() @@ -26,11 +33,18 @@ func NewAlbumByFolder(f *db.Album) *Album { func NewTCAlbumByFolder(f *db.Album) *TrackChild { trCh := &TrackChild{ - ID: f.SID(), - IsDir: true, - Title: f.RightPath, - ParentID: f.ParentSID(), - CreatedAt: f.CreatedAt, + ID: f.SID(), + IsDir: true, + Title: f.RightPath, + ParentID: f.ParentSID(), + CreatedAt: f.CreatedAt, + AverageRating: formatRating(f.AverageRating), + } + if f.AlbumStar != nil { + trCh.Starred = &f.AlbumStar.StarDate + } + if f.AlbumRating != nil { + trCh.UserRating = f.AlbumRating.Rating } if f.Cover != "" { trCh.CoverID = f.SID() @@ -53,14 +67,15 @@ func NewTCTrackByFolder(t *db.Track, parent *db.Album) *TrackChild { parent.RightPath, t.Filename, ), - ParentID: parent.SID(), - Duration: t.Length, - Genre: strings.Join(t.GenreStrings(), ", "), - Year: parent.TagYear, - Bitrate: t.Bitrate, - IsDir: false, - Type: "music", - CreatedAt: t.CreatedAt, + ParentID: parent.SID(), + Duration: t.Length, + Genre: strings.Join(t.GenreStrings(), ", "), + Year: parent.TagYear, + Bitrate: t.Bitrate, + IsDir: false, + Type: "music", + CreatedAt: t.CreatedAt, + AverageRating: formatRating(t.AverageRating), } if trCh.Title == "" { trCh.Title = t.Filename @@ -71,6 +86,12 @@ func NewTCTrackByFolder(t *db.Track, parent *db.Album) *TrackChild { if t.Album != nil { trCh.Album = t.Album.RightPath } + if t.TrackStar != nil { + trCh.Starred = &t.TrackStar.StarDate + } + if t.TrackRating != nil { + trCh.UserRating = t.TrackRating.Rating + } return trCh } @@ -80,9 +101,16 @@ func NewArtistByFolder(f *db.Album) *Artist { // from an "album" where // maybe TODO: rename the Album model to Folder a := &Artist{ - ID: f.SID(), - Name: f.RightPath, - AlbumCount: f.ChildCount, + ID: f.SID(), + Name: f.RightPath, + AlbumCount: f.ChildCount, + AverageRating: formatRating(f.AverageRating), + } + if f.AlbumStar != nil { + a.Starred = &f.AlbumStar.StarDate + } + if f.AlbumRating != nil { + a.UserRating = f.AlbumRating.Rating } if f.Cover != "" { a.CoverID = f.SID() @@ -91,10 +119,18 @@ func NewArtistByFolder(f *db.Album) *Artist { } func NewDirectoryByFolder(f *db.Album, children []*TrackChild) *Directory { - return &Directory{ - ID: f.SID(), - Name: f.RightPath, - Children: children, - ParentID: f.ParentSID(), + d := &Directory{ + ID: f.SID(), + Name: f.RightPath, + Children: children, + ParentID: f.ParentSID(), + AverageRating: formatRating(f.AverageRating), + } + if f.AlbumStar != nil { + d.Starred = &f.AlbumStar.StarDate + } + if f.AlbumRating != nil { + d.UserRating = f.AlbumRating.Rating } + return d } diff --git a/server/ctrlsubsonic/spec/construct_by_tags.go b/server/ctrlsubsonic/spec/construct_by_tags.go index 5551caec..51d5860d 100644 --- a/server/ctrlsubsonic/spec/construct_by_tags.go +++ b/server/ctrlsubsonic/spec/construct_by_tags.go @@ -9,17 +9,24 @@ import ( func NewAlbumByTags(a *db.Album, artist *db.Artist) *Album { ret := &Album{ - Created: a.CreatedAt, - ID: a.SID(), - Name: a.TagTitle, - Year: a.TagYear, - TrackCount: a.ChildCount, - Genre: strings.Join(a.GenreStrings(), ", "), - Duration: a.Duration, + Created: a.CreatedAt, + ID: a.SID(), + Name: a.TagTitle, + Year: a.TagYear, + TrackCount: a.ChildCount, + Genre: strings.Join(a.GenreStrings(), ", "), + Duration: a.Duration, + AverageRating: formatRating(a.AverageRating), } if a.Cover != "" { ret.CoverID = a.SID() } + if a.AlbumStar != nil { + ret.Starred = &a.AlbumStar.StarDate + } + if a.AlbumRating != nil { + ret.UserRating = a.AlbumRating.Rating + } if artist != nil { ret.Artist = artist.Name ret.ArtistID = artist.SID() @@ -44,17 +51,24 @@ func NewTrackByTags(t *db.Track, album *db.Album) *TrackChild { album.RightPath, t.Filename, ), - Album: album.TagTitle, - AlbumID: album.SID(), - Genre: strings.Join(t.GenreStrings(), ", "), - Duration: t.Length, - Bitrate: t.Bitrate, - Type: "music", - Year: album.TagYear, + Album: album.TagTitle, + AlbumID: album.SID(), + Genre: strings.Join(t.GenreStrings(), ", "), + Duration: t.Length, + Bitrate: t.Bitrate, + Type: "music", + Year: album.TagYear, + AverageRating: formatRating(t.AverageRating), } if album.Cover != "" { ret.CoverID = album.SID() } + if t.TrackStar != nil { + ret.Starred = &t.TrackStar.StarDate + } + if t.TrackRating != nil { + ret.UserRating = t.TrackRating.Rating + } if album.TagArtist != nil { ret.ArtistID = album.TagArtist.SID() } @@ -73,13 +87,20 @@ func NewTrackByTags(t *db.Track, album *db.Album) *TrackChild { func NewArtistByTags(a *db.Artist) *Artist { r := &Artist{ - ID: a.SID(), - Name: a.Name, - AlbumCount: a.AlbumCount, + ID: a.SID(), + Name: a.Name, + AlbumCount: a.AlbumCount, + AverageRating: formatRating(a.AverageRating), } if a.Cover != "" { r.CoverID = a.SID() } + if a.ArtistStar != nil { + r.Starred = &a.ArtistStar.StarDate + } + if a.ArtistRating != nil { + r.UserRating = a.ArtistRating.Rating + } return r } diff --git a/server/ctrlsubsonic/spec/spec.go b/server/ctrlsubsonic/spec/spec.go index 06c1fb6e..e9936120 100644 --- a/server/ctrlsubsonic/spec/spec.go +++ b/server/ctrlsubsonic/spec/spec.go @@ -14,48 +14,48 @@ const ( ) type SubsonicResponse struct { - Response Response `xml:"subsonic-response" json:"subsonic-response"` + Response Response `xml:"subsonic-response" json:"subsonic-response"` } type Response struct { - Status string `xml:"status,attr" json:"status"` - Version string `xml:"version,attr" json:"version"` - XMLNS string `xml:"xmlns,attr" json:"-"` - Type string `xml:"type,attr" json:"type"` - Error *Error `xml:"error" json:"error,omitempty"` - Albums *Albums `xml:"albumList" json:"albumList,omitempty"` - AlbumsTwo *Albums `xml:"albumList2" json:"albumList2,omitempty"` - Album *Album `xml:"album" json:"album,omitempty"` - Track *TrackChild `xml:"song" json:"song,omitempty"` - Indexes *Indexes `xml:"indexes" json:"indexes,omitempty"` - Artists *Artists `xml:"artists" json:"artists,omitempty"` - Artist *Artist `xml:"artist" json:"artist,omitempty"` - Directory *Directory `xml:"directory" json:"directory,omitempty"` - RandomTracks *RandomTracks `xml:"randomSongs" json:"randomSongs,omitempty"` - TracksByGenre *TracksByGenre `xml:"songsByGenre" json:"songsByGenre,omitempty"` - MusicFolders *MusicFolders `xml:"musicFolders" json:"musicFolders,omitempty"` - ScanStatus *ScanStatus `xml:"scanStatus" json:"scanStatus,omitempty"` - Licence *Licence `xml:"license" json:"license,omitempty"` - SearchResultTwo *SearchResultTwo `xml:"searchResult2" json:"searchResult2,omitempty"` - SearchResultThree *SearchResultThree `xml:"searchResult3" json:"searchResult3,omitempty"` - User *User `xml:"user" json:"user,omitempty"` - Playlists *Playlists `xml:"playlists" json:"playlists,omitempty"` - Playlist *Playlist `xml:"playlist" json:"playlist,omitempty"` - ArtistInfo *ArtistInfo `xml:"artistInfo" json:"artistInfo,omitempty"` - ArtistInfoTwo *ArtistInfo `xml:"artistInfo2" json:"artistInfo2,omitempty"` - Genres *Genres `xml:"genres" json:"genres,omitempty"` - PlayQueue *PlayQueue `xml:"playQueue" json:"playQueue,omitempty"` - JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus" json:"jukeboxStatus,omitempty"` - JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist" json:"jukeboxPlaylist,omitempty"` - Podcasts *Podcasts `xml:"podcasts" json:"podcasts,omitempty"` - NewestPodcasts *NewestPodcasts `xml:"newestPodcasts" json:"newestPodcasts,omitempty"` - Bookmarks *Bookmarks `xml:"bookmarks" json:"bookmarks,omitempty"` - Starred *Starred `xml:"starred" json:"starred,omitempty"` - StarredTwo *StarredTwo `xml:"starred2" json:"starred2,omitempty"` - TopSongs *TopSongs `xml:"topSongs" json:"topSongs,omitempty"` - SimilarSongs *SimilarSongs `xml:"similarSongs" json:"similarSongs,omitempty"` - SimilarSongsTwo *SimilarSongsTwo `xml:"similarSongs2" json:"similarSongs2,omitempty"` - InternetRadioStations *InternetRadioStations `xml:"internetRadioStations" json:"internetRadioStations,omitempty"` + Status string `xml:"status,attr" json:"status"` + Version string `xml:"version,attr" json:"version"` + XMLNS string `xml:"xmlns,attr" json:"-"` + Type string `xml:"type,attr" json:"type"` + Error *Error `xml:"error" json:"error,omitempty"` + Albums *Albums `xml:"albumList" json:"albumList,omitempty"` + AlbumsTwo *Albums `xml:"albumList2" json:"albumList2,omitempty"` + Album *Album `xml:"album" json:"album,omitempty"` + Track *TrackChild `xml:"song" json:"song,omitempty"` + Indexes *Indexes `xml:"indexes" json:"indexes,omitempty"` + Artists *Artists `xml:"artists" json:"artists,omitempty"` + Artist *Artist `xml:"artist" json:"artist,omitempty"` + Directory *Directory `xml:"directory" json:"directory,omitempty"` + RandomTracks *RandomTracks `xml:"randomSongs" json:"randomSongs,omitempty"` + TracksByGenre *TracksByGenre `xml:"songsByGenre" json:"songsByGenre,omitempty"` + MusicFolders *MusicFolders `xml:"musicFolders" json:"musicFolders,omitempty"` + ScanStatus *ScanStatus `xml:"scanStatus" json:"scanStatus,omitempty"` + Licence *Licence `xml:"license" json:"license,omitempty"` + SearchResultTwo *SearchResultTwo `xml:"searchResult2" json:"searchResult2,omitempty"` + SearchResultThree *SearchResultThree `xml:"searchResult3" json:"searchResult3,omitempty"` + User *User `xml:"user" json:"user,omitempty"` + Playlists *Playlists `xml:"playlists" json:"playlists,omitempty"` + Playlist *Playlist `xml:"playlist" json:"playlist,omitempty"` + ArtistInfo *ArtistInfo `xml:"artistInfo" json:"artistInfo,omitempty"` + ArtistInfoTwo *ArtistInfo `xml:"artistInfo2" json:"artistInfo2,omitempty"` + Genres *Genres `xml:"genres" json:"genres,omitempty"` + PlayQueue *PlayQueue `xml:"playQueue" json:"playQueue,omitempty"` + JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus" json:"jukeboxStatus,omitempty"` + JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist" json:"jukeboxPlaylist,omitempty"` + Podcasts *Podcasts `xml:"podcasts" json:"podcasts,omitempty"` + NewestPodcasts *NewestPodcasts `xml:"newestPodcasts" json:"newestPodcasts,omitempty"` + Bookmarks *Bookmarks `xml:"bookmarks" json:"bookmarks,omitempty"` + Starred *Starred `xml:"starred" json:"starred,omitempty"` + StarredTwo *StarredTwo `xml:"starred2" json:"starred2,omitempty"` + TopSongs *TopSongs `xml:"topSongs" json:"topSongs,omitempty"` + SimilarSongs *SimilarSongs `xml:"similarSongs" json:"similarSongs,omitempty"` + SimilarSongsTwo *SimilarSongsTwo `xml:"similarSongs2" json:"similarSongs2,omitempty"` + InternetRadioStations *InternetRadioStations `xml:"internetRadioStations" json:"internetRadioStations,omitempty"` } func NewResponse() *Response { @@ -68,7 +68,9 @@ func NewResponse() *Response { } // Error represents a typed error -// 0 a generic error +// +// 0 a generic error +// // 10 required parameter is missing // 20 incompatible subsonic rest protocol version. client must upgrade // 30 incompatible subsonic rest protocol version. server must upgrade @@ -118,6 +120,10 @@ type Album struct { Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` Year int `xml:"year,attr,omitempty" json:"year,omitempty"` Tracks []*TrackChild `xml:"song,omitempty" json:"song,omitempty"` + // star / rating + Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` + UserRating int `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` + AverageRating string `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"` } type RandomTracks struct { @@ -151,6 +157,10 @@ type TrackChild struct { DiscNumber int `xml:"discNumber,attr,omitempty" json:"discNumber,omitempty"` Type string `xml:"type,attr,omitempty" json:"type,omitempty"` Year int `xml:"year,attr,omitempty" json:"year,omitempty"` + // star / rating + Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` + UserRating int `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` + AverageRating string `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"` } type Artists struct { @@ -164,6 +174,10 @@ type Artist struct { CoverID *specid.ID `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` AlbumCount int `xml:"albumCount,attr" json:"albumCount"` Albums []*Album `xml:"album,omitempty" json:"album,omitempty"` + // star / rating + Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` + UserRating int `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` + AverageRating string `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"` } type Indexes struct { @@ -178,11 +192,13 @@ type Index struct { } type Directory struct { - ID *specid.ID `xml:"id,attr,omitempty" json:"id"` - ParentID *specid.ID `xml:"parent,attr,omitempty" json:"parent,omitempty"` - Name string `xml:"name,attr,omitempty" json:"name"` - Starred string `xml:"starred,attr,omitempty" json:"starred,omitempty"` - Children []*TrackChild `xml:"child,omitempty" json:"child,omitempty"` + ID *specid.ID `xml:"id,attr,omitempty" json:"id"` + ParentID *specid.ID `xml:"parent,attr,omitempty" json:"parent,omitempty"` + Name string `xml:"name,attr,omitempty" json:"name"` + Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` + UserRating int `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` + AverageRating string `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"` + Children []*TrackChild `xml:"child,omitempty" json:"child,omitempty"` } type MusicFolders struct { @@ -383,9 +399,15 @@ type InternetRadioStations struct { } type InternetRadioStation struct { - ID *specid.ID `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` - StreamURL string `xml:"streamUrl,attr" json:"streamUrl"` - HomepageURL string `xml:"homepageUrl,attr" json:"homepageUrl"` + ID *specid.ID `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + StreamURL string `xml:"streamUrl,attr" json:"streamUrl"` + HomepageURL string `xml:"homepageUrl,attr" json:"homepageUrl"` } +func formatRating(rating float64) string { + if rating == 0 { + return "" + } + return fmt.Sprintf("%.2f", rating) +} diff --git a/server/server.go b/server/server.go index dd716922..ed6d6691 100644 --- a/server/server.go +++ b/server/server.go @@ -264,6 +264,11 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) { r.Handle("/getArtistInfo{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetArtistInfo)) r.Handle("/getStarred{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetStarred)) + // star / rating + r.Handle("/star{_:(?:\\.view)?}", ctrl.H(ctrl.ServeStar)) + r.Handle("/unstar{_:(?:\\.view)?}", ctrl.H(ctrl.ServeUnstar)) + r.Handle("/setRating{_:(?:\\.view)?}", ctrl.H(ctrl.ServeSetRating)) + // podcasts r.Handle("/getPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetPodcasts)) r.Handle("/getNewestPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetNewestPodcasts))