diff --git a/README.md b/README.md index 984f51e..ed02f17 100644 --- a/README.md +++ b/README.md @@ -154,12 +154,15 @@ In any of the columns: - `Enter` / `a`: Adds the selected item recursively to the queue. - Left/right arrow keys (`←`, `→`) navigate between the columns - Up/down arrow keys (`↓`, `↑`) navigate the selected column list +- `g`: toggle genre search In the search field: - `Enter`: Perform the query. - `Escape`: Escapes into the columns, where the global key bindings work. +In Genre Search mode, the genres known by the server are displayed in the middle column. Pressing `Enter` on one of these will load all of the songs with that genre in the third column. Searching with the search field will fill the third column with songs whose genres match the search. Searching for a genre by typing it in should return the same songs as selecting it in the middle column. Note that genre searches may (depending on your Subsonic server's search implementation) be case sensitive. + ## Advanced Configuration and Features ### MPRIS2 Integration diff --git a/help_text.go b/help_text.go index 0bc1e1d..7762e43 100644 --- a/help_text.go +++ b/help_text.go @@ -52,6 +52,7 @@ artist, album, or song tab Right next column Enter recursively add item to quue a recursively add item to quue + g toggle genre search / start search search field Enter search for text diff --git a/page_playlist.go b/page_playlist.go index 358012f..e59d123 100644 --- a/page_playlist.go +++ b/page_playlist.go @@ -247,6 +247,9 @@ func (p *PlaylistPage) UpdatePlaylists() { response, err := p.ui.connection.GetPlaylists() if err != nil { p.logger.PrintError("GetPlaylists", err) + p.isUpdating = false + stop <- true + return } p.updatingMutex.Lock() defer p.updatingMutex.Unlock() diff --git a/page_search.go b/page_search.go index 2e79fa4..c0d6012 100644 --- a/page_search.go +++ b/page_search.go @@ -5,6 +5,7 @@ package main import ( "fmt" + "slices" "sort" "strings" @@ -24,6 +25,7 @@ type SearchPage struct { albumList *tview.List songList *tview.List searchField *tview.InputField + queryGenre bool artists []*subsonic.Artist albums []*subsonic.Album @@ -99,12 +101,24 @@ func (ui *Ui) createSearchPage() *SearchPage { searchPage.addArtistToQueue(searchPage.artists[idx]) return nil case '/': + searchPage.searchField.SetLabel("search:") searchPage.ui.app.SetFocus(searchPage.searchField) return nil + case 'g': + if searchPage.queryGenre { + searchPage.albumList.SetTitle(" album matches ") + } else { + searchPage.albumList.SetTitle(" genres ") + searchPage.populateGenres() + searchPage.ui.app.SetFocus(searchPage.albumList) + } + searchPage.queryGenre = !searchPage.queryGenre + return nil } return event }) + search := make(chan string, 5) searchPage.albumList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyLeft: @@ -114,13 +128,30 @@ func (ui *Ui) createSearchPage() *SearchPage { ui.app.SetFocus(searchPage.songList) return nil case tcell.KeyEnter: - idx := searchPage.albumList.GetCurrentItem() - searchPage.addAlbumToQueue(searchPage.albums[idx]) - return nil + if !searchPage.queryGenre { + idx := searchPage.albumList.GetCurrentItem() + searchPage.addAlbumToQueue(searchPage.albums[idx]) + return nil + } else { + search <- "" + searchPage.artistList.Clear() + searchPage.artists = make([]*subsonic.Artist, 0) + searchPage.songList.Clear() + searchPage.songs = make([]*subsonic.SubsonicEntity, 0) + + idx := searchPage.albumList.GetCurrentItem() + // searchPage.logger.Printf("current item index = %d; albumList len = %d", idx, searchPage.albumList.GetItemCount()) + queryStr, _ := searchPage.albumList.GetItemText(idx) + search <- queryStr + return nil + } } switch event.Rune() { case 'a': + if searchPage.queryGenre { + return event + } idx := searchPage.albumList.GetCurrentItem() searchPage.logger.Printf("albumList adding (%d) %s", idx, searchPage.albums[idx].Name) searchPage.addAlbumToQueue(searchPage.albums[idx]) @@ -128,6 +159,16 @@ func (ui *Ui) createSearchPage() *SearchPage { case '/': searchPage.ui.app.SetFocus(searchPage.searchField) return nil + case 'g': + if searchPage.queryGenre { + searchPage.albumList.SetTitle(" album matches ") + } else { + searchPage.albumList.SetTitle(" genres ") + searchPage.populateGenres() + searchPage.ui.app.SetFocus(searchPage.albumList) + } + searchPage.queryGenre = !searchPage.queryGenre + return nil } return event @@ -156,11 +197,20 @@ func (ui *Ui) createSearchPage() *SearchPage { case '/': searchPage.ui.app.SetFocus(searchPage.searchField) return nil + case 'g': + if searchPage.queryGenre { + searchPage.albumList.SetTitle(" album matches ") + } else { + searchPage.albumList.SetTitle(" genres ") + searchPage.populateGenres() + searchPage.ui.app.SetFocus(searchPage.albumList) + } + searchPage.queryGenre = !searchPage.queryGenre + return nil } return event }) - search := make(chan string, 5) searchPage.searchField.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyUp, tcell.KeyESC: @@ -177,8 +227,10 @@ func (ui *Ui) createSearchPage() *SearchPage { search <- "" searchPage.artistList.Clear() searchPage.artists = make([]*subsonic.Artist, 0) - searchPage.albumList.Clear() - searchPage.albums = make([]*subsonic.Album, 0) + if !searchPage.queryGenre { + searchPage.albumList.Clear() + searchPage.albums = make([]*subsonic.Album, 0) + } searchPage.songList.Clear() searchPage.songs = make([]*subsonic.SubsonicEntity, 0) @@ -205,7 +257,6 @@ func (s *SearchPage) search(search chan string) { artOff = 0 albOff = 0 songOff = 0 - s.logger.Printf("searching for %q [%d, %d, %d]", query, artOff, albOff, songOff) for len(more) > 0 { <-more } @@ -213,48 +264,73 @@ func (s *SearchPage) search(search chan string) { continue } case <-more: - s.logger.Printf("fetching more %q [%d, %d, %d]", query, artOff, albOff, songOff) } - res, err := s.ui.connection.Search(query, artOff, albOff, songOff) + var res *subsonic.SubsonicResponse + var err error + if s.queryGenre { + res, err = s.ui.connection.GetSongsByGenre(query, songOff, "") + if len(res.SongsByGenre.Song) == 0 { + continue + } + } else { + res, err = s.ui.connection.Search(query, artOff, albOff, songOff) + // Quit searching if there are no more results + if len(res.SearchResults.Artist) == 0 && + len(res.SearchResults.Album) == 0 && + len(res.SearchResults.Song) == 0 { + continue + } + } if err != nil { s.logger.PrintError("SearchPage.search", err) return } - // Quit searching if there are no more results - if len(res.SearchResults.Artist) == 0 && - len(res.SearchResults.Album) == 0 && - len(res.SearchResults.Song) == 0 { - continue - } query = strings.ToLower(query) s.ui.app.QueueUpdate(func() { - for _, artist := range res.SearchResults.Artist { - if strings.Contains(strings.ToLower(artist.Name), query) { - s.artistList.AddItem(tview.Escape(artist.Name), "", 0, nil) - s.artists = append(s.artists, &artist) + if s.queryGenre { + if songOff == 0 { + s.artistList.Box.SetTitle(" artist matches ") + s.albumList.Box.SetTitle(" genres ") } - } - s.artistList.Box.SetTitle(fmt.Sprintf(" artist matches (%d) ", len(s.artists))) - for _, album := range res.SearchResults.Album { - if strings.Contains(strings.ToLower(album.Name), query) { - s.albumList.AddItem(tview.Escape(album.Name), "", 0, nil) - s.albums = append(s.albums, &album) - } - } - s.albumList.Box.SetTitle(fmt.Sprintf(" album matches (%d) ", len(s.albums))) - for _, song := range res.SearchResults.Song { - if strings.Contains(strings.ToLower(song.Title), query) { + for _, song := range res.SongsByGenre.Song { s.songList.AddItem(tview.Escape(song.Title), "", 0, nil) s.songs = append(s.songs, &song) } + s.songList.Box.SetTitle(fmt.Sprintf(" genre song matches (%d) ", len(s.songs))) + } else { + for _, artist := range res.SearchResults.Artist { + if strings.Contains(strings.ToLower(artist.Name), query) { + s.artistList.AddItem(tview.Escape(artist.Name), "", 0, nil) + s.artists = append(s.artists, &artist) + } + } + s.artistList.Box.SetTitle(fmt.Sprintf(" artist matches (%d) ", len(s.artists))) + for _, album := range res.SearchResults.Album { + if strings.Contains(strings.ToLower(album.Name), query) { + s.albumList.AddItem(tview.Escape(album.Name), "", 0, nil) + s.albums = append(s.albums, &album) + } + } + s.albumList.Box.SetTitle(fmt.Sprintf(" album matches (%d) ", len(s.albums))) + for _, song := range res.SearchResults.Song { + if strings.Contains(strings.ToLower(song.Title), query) { + s.songList.AddItem(tview.Escape(song.Title), "", 0, nil) + s.songs = append(s.songs, &song) + } + } + s.songList.Box.SetTitle(fmt.Sprintf(" song matches (%d) ", len(s.songs))) } - s.songList.Box.SetTitle(fmt.Sprintf(" song matches (%d) ", len(s.songs))) }) - artOff += len(res.SearchResults.Artist) - albOff += len(res.SearchResults.Album) - songOff += len(res.SearchResults.Song) + if !s.queryGenre { + artOff += len(res.SearchResults.Artist) + albOff += len(res.SearchResults.Album) + songOff += len(res.SearchResults.Song) + } else { + songOff += len(res.SongsByGenre.Song) + } + s.ui.app.Draw() more <- true } } @@ -311,3 +387,17 @@ func (s *SearchPage) addAlbumToQueue(entity subsonic.Ider) { } s.ui.queuePage.UpdateQueue() } + +func (s *SearchPage) populateGenres() { + resp, err := s.ui.connection.GetGenres() + if err != nil { + s.logger.PrintError("populateGenres", err) + return + } + slices.SortFunc(resp.Genres.Genres, func(a, b subsonic.GenreEntry) int { + return strings.Compare(a.Name, b.Name) + }) + for _, entry := range resp.Genres.Genres { + s.albumList.AddItem(tview.Escape(entry.Name), "", 0, nil) + } +} diff --git a/subsonic/api.go b/subsonic/api.go index 95ae7c3..20d5482 100644 --- a/subsonic/api.go +++ b/subsonic/api.go @@ -125,6 +125,16 @@ type ScanStatus struct { Count int `json:"count"` } +type GenreEntries struct { + Genres []GenreEntry `json:"genre"` +} + +type GenreEntry struct { + SongCount int `json:"songCount"` + AlbumCount int `json:"albumCount"` + Name string `json:"value"` +} + type Artist struct { Id string `json:"id"` Name string `json:"name"` @@ -271,6 +281,8 @@ type SubsonicResponse struct { Album Album `json:"album"` SearchResults SubsonicResults `json:"searchResult3"` ScanStatus ScanStatus `json:"scanStatus"` + Genres GenreEntries `json:"genres"` + SongsByGenre SubsonicSongs `json:"songsByGenre"` } type responseWrapper struct { @@ -663,3 +675,30 @@ func (connection *SubsonicConnection) StartScan() error { } return nil } + +func (connection *SubsonicConnection) GetGenres() (*SubsonicResponse, error) { + query := defaultQuery(connection) + requestUrl := connection.Host + "/rest/getGenres" + "?" + query.Encode() + resp, err := connection.getResponse("GetGenres", requestUrl) + if err != nil { + return resp, err + } + return resp, nil +} + +func (connection *SubsonicConnection) GetSongsByGenre(genre string, offset int, musicFolderID string) (*SubsonicResponse, error) { + query := defaultQuery(connection) + query.Add("genre", genre) + if offset != 0 { + query.Add("offset", strconv.Itoa(offset)) + } + if musicFolderID != "" { + query.Add("musicFolderId", musicFolderID) + } + requestUrl := connection.Host + "/rest/getSongsByGenre" + "?" + query.Encode() + resp, err := connection.getResponse("GetPlaylists", requestUrl) + if err != nil { + return resp, err + } + return resp, nil +}