Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
feat(subsonic): add support for track/album/artist ratings/stars
fixes #171
fixes #31

* Initial code. Compiles and passes unit tests.

* Moved average rating calculation from rating fetch to set rating function. Still only compiled and unit tested.

* Bug fixes

* Fixed bug in savePlayQueue. Removed unique_index for star / rating entries because it's not valid.

* Changed time format on stars to RFC3339Nano to match created date format.

* Lint fixes.

* More lint fixes.

* Removed add* functions and replaced with Preload.

* Fixed several bugs in handlers for getStarred and getStarred2.

* Fixed bug when using music folder ID.

Co-authored-by: Brian Doherty <brian@hplaptop.dohertyfamily.me>
  • Loading branch information
2 people authored and sentriz committed Oct 26, 2022
1 parent 25b3908 commit e8759cb
Show file tree
Hide file tree
Showing 10 changed files with 666 additions and 133 deletions.
16 changes: 16 additions & 0 deletions db/migrations.go
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
58 changes: 52 additions & 6 deletions 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

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
Expand Down
109 changes: 95 additions & 14 deletions server/ctrlsubsonic/handlers_by_folder.go
Expand Up @@ -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{}).
Expand All @@ -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").
Expand All @@ -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{
Expand All @@ -65,28 +67,36 @@ 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
c.DB.
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"
Expand All @@ -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":
Expand All @@ -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=?`,
Expand Down Expand Up @@ -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{
Expand All @@ -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")
Expand All @@ -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 {
Expand All @@ -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 != "" {
Expand All @@ -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 != "" {
Expand All @@ -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
}

0 comments on commit e8759cb

Please sign in to comment.