diff --git a/README.md b/README.md index bb84615..02990e3 100644 --- a/README.md +++ b/README.md @@ -177,13 +177,16 @@ 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. -Note that the Search page is *not* a browser like the Browser page: it displays the search results returned by the server. Selecting a different artist will not change the album or song search results. OpenSubsonic servers implement the search function differently; in gonic, if you search for "black", you will get artists with "black" in their names in the artists column; albums with "black" in their titles in the albums column; and songs with "black" in their titles in the songs column. Navidrome appears to include all results with "black" anywhere in their IDv3 metadata. Since the API search results filteres these matches into sections -- artists, albums, and songs -- this means that, with Navidrome, you may see albums that don't have "black" in their names; maybe "black" is in their artist title. +Note that the Search page is *not* a browser like the Browser page: it displays the search results returned by the server. Selecting a different artist will not change the album or song search results. + +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 diff --git a/gui_handlers.go b/gui_handlers.go index e0294d2..1033c06 100644 --- a/gui_handlers.go +++ b/gui_handlers.go @@ -191,6 +191,7 @@ func (ui *Ui) addSongToQueue(entity *subsonic.SubsonicEntity) { TrackNumber: entity.Track, CoverArtId: entity.CoverArtId, DiscNumber: entity.DiscNumber, + Year: entity.Year, } ui.player.AddToQueue(queueItem) } @@ -206,6 +207,7 @@ func makeSongHandler(entity *subsonic.SubsonicEntity, ui *Ui, fallbackArtist str track := entity.Track coverArtId := entity.CoverArtId disc := entity.DiscNumber + year := entity.Year response, err := ui.connection.GetAlbum(entity.Parent) album := "" @@ -223,7 +225,7 @@ func makeSongHandler(entity *subsonic.SubsonicEntity, ui *Ui, fallbackArtist str } return func() { - if err := ui.player.PlayUri(id, uri, title, artist, album, duration, track, disc, coverArtId); err != nil { + if err := ui.player.PlayUri(id, uri, title, artist, album, duration, track, disc, coverArtId, year); err != nil { ui.logger.PrintError("SongHandler Play", err) return } diff --git a/help_text.go b/help_text.go index c958127..2ffbc41 100644 --- a/help_text.go +++ b/help_text.go @@ -47,12 +47,17 @@ a add playlist or song to queue ` const helpSearchPage = ` -artist, album, or song column +artist, album/genre, or song column Down/Up navigate within the column Left previous column Right next column Enter/a recursively add item to quue + g toggle genre search / start search +In album tab + Enter recursively add item to quue +In genre tab + Enter shows songs with genre search field Enter search for text Esc cancel search diff --git a/mpvplayer/player.go b/mpvplayer/player.go index febe14e..d71d3c5 100644 --- a/mpvplayer/player.go +++ b/mpvplayer/player.go @@ -125,8 +125,8 @@ func (p *Player) PlayNextTrack() error { return nil } -func (p *Player) PlayUri(id, uri, title, artist, album string, duration, track, disc int, coverArtId string) error { - p.queue = []QueueItem{{id, uri, title, artist, duration, album, track, coverArtId, disc}} +func (p *Player) PlayUri(id, uri, title, artist, album string, duration, track, disc int, coverArtId string, year int) error { + p.queue = []QueueItem{{id, uri, title, artist, duration, album, track, coverArtId, disc, year}} p.replaceInProgress = true if ip, e := p.IsPaused(); ip && e == nil { if err := p.Pause(); err != nil { diff --git a/mpvplayer/queue_item.go b/mpvplayer/queue_item.go index 2b147e4..04fb54c 100644 --- a/mpvplayer/queue_item.go +++ b/mpvplayer/queue_item.go @@ -17,6 +17,7 @@ type QueueItem struct { TrackNumber int CoverArtId string DiscNumber int + Year int } var _ remote.TrackInterface = (*QueueItem)(nil) @@ -60,3 +61,7 @@ func (q QueueItem) GetTrackNumber() int { func (q QueueItem) GetDiscNumber() int { return q.DiscNumber } + +func (q QueueItem) GetYear() int { + return q.Year +} diff --git a/page_playlist.go b/page_playlist.go index 4ece28b..e59d123 100644 --- a/page_playlist.go +++ b/page_playlist.go @@ -251,11 +251,6 @@ func (p *PlaylistPage) UpdatePlaylists() { stop <- true return } - if response == nil { - p.logger.Printf("no error from GetPlaylists, but also no response!") - stop <- true - return - } p.updatingMutex.Lock() defer p.updatingMutex.Unlock() p.ui.playlists = response.Playlists.Playlists diff --git a/page_queue.go b/page_queue.go index 500dcea..6810edf 100644 --- a/page_queue.go +++ b/page_queue.go @@ -268,6 +268,7 @@ func (q *QueuePage) updateQueue() { q.queueList.ScrollToBeginning() } + q.queueList.Box.SetTitle(fmt.Sprintf(" queue (%d) ", q.queueList.GetRowCount())) r, c := q.queueList.GetSelection() q.changeSelection(r, c) } diff --git a/page_search.go b/page_search.go index ac106fa..b2cde8d 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 @@ -108,12 +110,27 @@ func (ui *Ui) createSearchPage() *SearchPage { } return event case '/': + searchPage.searchField.SetLabel("search:") searchPage.ui.app.SetFocus(searchPage.searchField) return nil + case 'g': + searchPage.albumList.Clear() + searchPage.artistList.Clear() + searchPage.songList.Clear() + if searchPage.queryGenre { + searchPage.albumList.SetTitle(" album matches ") + } else { + searchPage.populateGenres() + searchPage.albumList.SetTitle(fmt.Sprintf(" genres (%d) ", searchPage.albumList.GetItemCount())) + 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: @@ -123,19 +140,40 @@ func (ui *Ui) createSearchPage() *SearchPage { ui.app.SetFocus(searchPage.songList) return nil case tcell.KeyEnter: - if len(searchPage.albums) != 0 { + if !searchPage.queryGenre { idx := searchPage.albumList.GetCurrentItem() - searchPage.addAlbumToQueue(searchPage.albums[idx]) + if idx >= 0 && idx < len(searchPage.albums) { + searchPage.addAlbumToQueue(searchPage.albums[idx]) + return nil + } + return event + } 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 } - return event } switch event.Rune() { case 'a': - if len(searchPage.albums) != 0 { + if searchPage.queryGenre { idx := searchPage.albumList.GetCurrentItem() - searchPage.logger.Printf("albumList adding (%d) %s", idx, searchPage.albums[idx].Name) + if idx < searchPage.albumList.GetItemCount() { + genre, _ := searchPage.albumList.GetItemText(idx) + searchPage.addGenreToQueue(genre) + } + return nil + } + idx := searchPage.albumList.GetCurrentItem() + if idx >= 0 && idx < len(searchPage.albums) { searchPage.addAlbumToQueue(searchPage.albums[idx]) return nil } @@ -143,6 +181,19 @@ func (ui *Ui) createSearchPage() *SearchPage { case '/': searchPage.ui.app.SetFocus(searchPage.searchField) return nil + case 'g': + searchPage.albumList.Clear() + searchPage.artistList.Clear() + searchPage.songList.Clear() + if searchPage.queryGenre { + searchPage.albumList.SetTitle(" album matches ") + } else { + searchPage.populateGenres() + searchPage.albumList.SetTitle(fmt.Sprintf(" genres (%d) ", searchPage.albumList.GetItemCount())) + searchPage.ui.app.SetFocus(searchPage.albumList) + } + searchPage.queryGenre = !searchPage.queryGenre + return nil } return event @@ -177,11 +228,23 @@ func (ui *Ui) createSearchPage() *SearchPage { case '/': searchPage.ui.app.SetFocus(searchPage.searchField) return nil + case 'g': + searchPage.albumList.Clear() + searchPage.artistList.Clear() + searchPage.songList.Clear() + if searchPage.queryGenre { + searchPage.albumList.SetTitle(" album matches ") + } else { + searchPage.populateGenres() + searchPage.albumList.SetTitle(fmt.Sprintf(" genres (%d) ", searchPage.albumList.GetItemCount())) + 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: @@ -190,13 +253,16 @@ 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) queryStr := searchPage.searchField.GetText() search <- queryStr + searchPage.aproposFocus() default: return event } @@ -211,6 +277,8 @@ func (s *SearchPage) search(search chan string) { var query string var artOff, albOff, songOff int more := make(chan bool, 5) + var res *subsonic.SubsonicResponse + var err error for { // quit searching if we receive an interrupt select { @@ -218,7 +286,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 } @@ -226,50 +293,103 @@ 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) + if s.queryGenre { + s.logger.Printf("genre %q %d", query, songOff) + res, err = s.ui.connection.GetSongsByGenre(query, songOff, "") + if len(res.SongsByGenre.Song) == 0 { + s.logger.Printf("found a total of %d songs", songOff) + 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.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))) + songOff += len(res.SongsByGenre.Song) + } else { + query = strings.ToLower(query) + 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))) + artOff += len(res.SearchResults.Artist) + albOff += len(res.SearchResults.Album) + songOff += len(res.SearchResults.Song) } - s.songList.Box.SetTitle(fmt.Sprintf(" song matches (%d) ", len(s.songs))) + more <- true }) - artOff += len(res.SearchResults.Artist) - albOff += len(res.SearchResults.Album) - songOff += len(res.SearchResults.Song) - more <- true + // Only do this the one time, to prevent loops from stealing the user's focus + if artOff == 0 && albOff == 0 && songOff == 0 { + s.aproposFocus() + } + + 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() + } +} + +func (s *SearchPage) addGenreToQueue(query string) { + var songOff int + for { + res, err := s.ui.connection.GetSongsByGenre(query, songOff, "") + if err != nil { + s.logger.PrintError("SearchPage.addGenreToQueue", err) + return + } + if len(res.SongsByGenre.Song) == 0 { + break + } + for _, song := range res.SongsByGenre.Song { + s.ui.addSongToQueue(&song) + } + songOff += len(res.SongsByGenre.Song) } + s.logger.Printf("added a total of %d songs to the queue for %q", songOff, query) + s.ui.queuePage.UpdateQueue() } func (s *SearchPage) addArtistToQueue(entity subsonic.Ider) { @@ -336,3 +456,17 @@ func (s *SearchPage) aproposFocus() { s.ui.app.SetFocus(s.artistList) } } + +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 9c64041..638864a 100644 --- a/subsonic/api.go +++ b/subsonic/api.go @@ -131,6 +131,16 @@ type PlayQueue struct { Entries SubsonicEntities `json:"entry"` } +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"` @@ -183,6 +193,7 @@ type SubsonicEntity struct { DiscNumber int `json:"discNumber"` Path string `json:"path"` CoverArtId string `json:"coverArt"` + Year int `json:"year"` } func (s SubsonicEntity) ID() string { @@ -278,6 +289,8 @@ type SubsonicResponse struct { SearchResults SubsonicResults `json:"searchResult3"` ScanStatus ScanStatus `json:"scanStatus"` PlayQueue PlayQueue `json:"playQueue"` + Genres GenreEntries `json:"genres"` + SongsByGenre SubsonicSongs `json:"songsByGenre"` } type responseWrapper struct { @@ -688,3 +701,30 @@ func (connection *SubsonicConnection) LoadPlayQueue() (*SubsonicResponse, error) requestUrl := fmt.Sprintf("%s/rest/getPlayQueue?%s", connection.Host, query.Encode()) return connection.getResponse("GetPlayQueue", requestUrl) } + +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 +}