diff --git a/db/db.go b/db/db.go index 24285b7..e458114 100644 --- a/db/db.go +++ b/db/db.go @@ -41,6 +41,7 @@ func New(path string) (*DB, error) { &migrationCreateInitUser, &migrationMergePlaylist, &migrationCreateTranscode, + &migrationAddGenre, }) if err = migr.Migrate(); err != nil { return nil, errors.Wrap(err, "migrating to latest version") diff --git a/db/migrations.go b/db/migrations.go index 4f72ad6..394c4a0 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -79,3 +79,15 @@ var migrationCreateTranscode = gormigrate.Migration{ Error }, } + +var migrationAddGenre = gormigrate.Migration{ + ID: "202003121330", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate( + Genre{}, + Album{}, + Track{}, + ). + Error + }, +} diff --git a/db/model.go b/db/model.go index 46d9c7b..73754bd 100644 --- a/db/model.go +++ b/db/model.go @@ -49,6 +49,15 @@ func (a *Artist) IndexName() string { return a.Name } +type Genre struct { + ID int `gorm:"primary_ket"` + Name string `gorm:"not null; unique_index"` + Albums []*Album `gorm:"foreignkey:TagGenreID"` + AlbumCount int `sql:"-"` + Tracks []*Track `gorm:"foreignkey:TagGenreID"` + TrackCount int `sql:"-"` +} + type Track struct { ID int `gorm:"primary_key"` CreatedAt time.Time @@ -67,6 +76,8 @@ type Track struct { TagTrackArtist string `sql:"default: null"` TagTrackNumber int `sql:"default: null"` TagDiscNumber int `sql:"default: null"` + TagGenre *Genre + TagGenreID int `sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"` TagBrainzID string `sql:"default: null"` } @@ -129,7 +140,9 @@ type Album struct { ParentID int `sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` Cover string `sql:"default: null"` TagArtist *Artist - TagArtistID int `sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` + TagArtistID int `sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"` + TagGenre *Genre + TagGenreID int `sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"` TagTitle string `sql:"default: null"` TagTitleUDec string `sql:"default: null"` TagBrainzID string `sql:"default: null"` diff --git a/scanner/scanner.go b/scanner/scanner.go index b5bec06..06e760c 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -362,6 +362,27 @@ func (s *Scanner) handleTrack(it *item) error { s.trTx.Save(artist) } track.ArtistID = artist.ID + // + // set genre + genreName := func() string { + if r := trTags.Genre(); r != "" { + return r + } + return "Unknown Genre" + }() + genre := &db.Genre{} + err = s.trTx. + Select("id"). + Where("name=?", genreName). + First(genre). + Error + if gorm.IsRecordNotFoundError(err) { + genre.Name = genreName + s.trTx.Save(genre) + } + track.TagGenreID = genre.ID + // + // save the track s.trTx.Save(track) s.seenTracks[track.ID] = struct{}{} s.seenTracksNew++ @@ -377,6 +398,7 @@ func (s *Scanner) handleTrack(it *item) error { folder.TagBrainzID = trTags.AlbumBrainzID() folder.TagYear = trTags.Year() folder.TagArtistID = artist.ID + folder.TagGenreID = genre.ID folder.ReceivedTags = true return nil } diff --git a/scanner/tags/tags.go b/scanner/tags/tags.go index 04059fb..21b703e 100644 --- a/scanner/tags/tags.go +++ b/scanner/tags/tags.go @@ -39,6 +39,7 @@ func (t *Tags) Artist() string { return t.firstTag("artist") } func (t *Tags) Album() string { return t.firstTag("album") } func (t *Tags) AlbumArtist() string { return t.firstTag("albumartist", "album artist") } func (t *Tags) AlbumBrainzID() string { return t.firstTag("musicbrainz_albumid") } +func (t *Tags) Genre() string { return t.firstTag("genre") } func (t *Tags) Year() int { return intSep(t.firstTag("date", "year"), "-") } // eg. 2019-6-11 func (t *Tags) TrackNumber() int { return intSep(t.firstTag("tracknumber"), "/") } // eg. 5/12 func (t *Tags) DiscNumber() int { return intSep(t.firstTag("discnumber"), "/") } // eg. 1/2 diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index 548ccf9..c2d9e90 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -115,6 +115,9 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { params.GetIntOr("fromYear", 1800), params.GetIntOr("toYear", 2200)) q = q.Order("tag_year") + case "byGenre": + q = q.Joins("JOIN genres ON albums.tag_genre_id=genres.id AND genres.name=?", + params.GetOr("genre", "Unknown 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=?", @@ -275,3 +278,51 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response { } return sub } + +func (c *Controller) ServeGetGenres(r *http.Request) *spec.Response { + var genres []*db.Genre + c.DB. + Select(`*, + (SELECT count(id) FROM albums WHERE tag_genre_id=genres.id) album_count, + (SELECT count(id) FROM tracks WHERE tag_genre_id=genres.id) track_count`). + Group("genres.id"). + Find(&genres) + + sub := spec.NewResponse() + sub.Genres = &spec.Genres{ + List: make([]*spec.Genre, len(genres)), + } + for i, genre := range genres { + sub.Genres.List[i] = spec.NewGenre(genre) + } + return sub +} + +func (c *Controller) ServeGetSongsByGenre(r *http.Request) *spec.Response { + params := r.Context().Value(CtxParams).(params.Params) + genre := params.Get("genre") + if genre == "" { + return spec.NewError(10, "please provide an `genre` parameter") + } + + // TODO: add musicFolderId parameter: + // (Since 1.12.0) Only return albums in the music folder with the given ID. + + var tracks []*db.Track + c.DB. + Joins("JOIN albums ON tracks.album_id=albums.id"). + Joins("JOIN genres ON tracks.tag_genre_id=genres.id AND genres.name=?", genre). + Preload("Album"). + Offset(params.GetIntOr("offset", 0)). + Limit(params.GetIntOr("count", 10)). + Find(&tracks) + + sub := spec.NewResponse() + sub.TracksByGenre = &spec.TracksByGenre{ + List: make([]*spec.TrackChild, len(tracks)), + } + for i, track := range tracks { + sub.TracksByGenre.List[i] = spec.NewTrackByTags(track, track.Album) + } + return sub +} diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index 799d5eb..d8a1c27 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -286,18 +286,28 @@ 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) - // TODO: add genre restraint here var tracks []*db.Track - c.DB.DB. - Limit(params.GetIntOr("size", 10)). - Where( - "albums.tag_year BETWEEN ? AND ?", - params.GetIntOr("fromYear", 1800), - params.GetIntOr("toYear", 2200)). + + q := c.DB.DB. Joins("JOIN albums ON tracks.album_id=albums.id"). + Limit(params.GetIntOr("size", 10)). Preload("Album"). - Order(gorm.Expr("random()")). - Find(&tracks) + Order(gorm.Expr("random()")) + + if year, err := params.GetInt("fromYear"); err == nil { + q = q.Where("albums.tag_year >= ?", year) + } + if year, err := params.GetInt("toYear"); err == nil { + q = q.Where("albums.tag_year <= ?", year) + } + if genre := params.Get("genre"); genre != "" { + q = q.Joins( + "JOIN genres ON tracks.tag_genre_id=genres.id AND genres.name=?", + genre, + ) + } + q.Find(&tracks) + sub := spec.NewResponse() sub.RandomTracks = &spec.RandomTracks{} sub.RandomTracks.List = make([]*spec.TrackChild, len(tracks)) diff --git a/server/ctrlsubsonic/handlers_unimplemented.go b/server/ctrlsubsonic/handlers_unimplemented.go index 0767ede..e518df3 100644 --- a/server/ctrlsubsonic/handlers_unimplemented.go +++ b/server/ctrlsubsonic/handlers_unimplemented.go @@ -1,17 +1,4 @@ package ctrlsubsonic -import ( - "net/http" - - "senan.xyz/g/gonic/server/ctrlsubsonic/spec" -) - // NOTE: when these are implemented, they should be moved to their // respective _by_folder or _by_tag file - -func (c *Controller) ServeGetGenres(r *http.Request) *spec.Response { - sub := spec.NewResponse() - sub.Genres = &spec.Genres{} - sub.Genres.List = []*spec.Genre{} - return sub -} diff --git a/server/ctrlsubsonic/spec/construct_by_tags.go b/server/ctrlsubsonic/spec/construct_by_tags.go index b42c27b..3314412 100644 --- a/server/ctrlsubsonic/spec/construct_by_tags.go +++ b/server/ctrlsubsonic/spec/construct_by_tags.go @@ -72,3 +72,11 @@ func NewArtistByTags(a *db.Artist) *Artist { AlbumCount: a.AlbumCount, } } + +func NewGenre(g *db.Genre) *Genre { + return &Genre{ + Name: g.Name, + AlbumCount: g.AlbumCount, + SongCount: g.TrackCount, + } +} diff --git a/server/ctrlsubsonic/spec/spec.go b/server/ctrlsubsonic/spec/spec.go index 9f979ef..5a78f40 100644 --- a/server/ctrlsubsonic/spec/spec.go +++ b/server/ctrlsubsonic/spec/spec.go @@ -28,6 +28,7 @@ type Response struct { 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"` @@ -109,6 +110,10 @@ type RandomTracks struct { List []*TrackChild `xml:"song" json:"song"` } +type TracksByGenre struct { + List []*TrackChild `xml:"song" json:"song"` +} + type TrackChild struct { Album string `xml:"album,attr,omitempty" json:"album,omitempty"` AlbumID int `xml:"albumId,attr,omitempty" json:"albumId,omitempty,string"` @@ -250,8 +255,9 @@ type Genres struct { } type Genre struct { - SongCount string `xml:"songCount,attr"` - AlbumCount string `xml:"albumCount,attr"` + Name string `xml:",chardata",json:"value"` + SongCount int `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` + AlbumCount int `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"` } type PlayQueue struct { diff --git a/server/server.go b/server/server.go index 7595347..ecce4a5 100644 --- a/server/server.go +++ b/server/server.go @@ -150,6 +150,7 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) { r.Handle("/getPlayQueue{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetPlayQueue)) r.Handle("/getSong{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSong)) r.Handle("/getRandomSongs{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetRandomSongs)) + r.Handle("/getSongsByGenre{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSongsByGenre)) // ** begin raw r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeDownload)) r.Handle("/getCoverArt{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetCoverArt)) @@ -166,8 +167,8 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) { r.Handle("/getMusicDirectory{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetMusicDirectory)) r.Handle("/getAlbumList{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetAlbumList)) r.Handle("/search2{_:(?:\\.view)?}", ctrl.H(ctrl.ServeSearchTwo)) - // ** begin unimplemented r.Handle("/getGenres{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetGenres)) + // ** begin unimplemented // middlewares should be run for not found handler // https://github.com/gorilla/mux/issues/416 notFoundHandler := ctrl.H(ctrl.ServeNotFound)