From 0ac77d169e9727838309f1cb73df8bd922170e00 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Tue, 17 Sep 2024 14:20:23 -0500 Subject: [PATCH 1/5] Implements https://github.com/spezifisch/stmps/issues/26, global search --- README.md | 11 ++++++++- gui.go | 8 +++++++ gui_handlers.go | 3 +++ help_text.go | 12 ++++++++++ page_playlist.go | 6 ++--- subsonic/api.go | 58 +++++++++++++++++++++++++++++++----------------- widget_help.go | 3 +++ widget_menu.go | 10 ++++++++- 8 files changed, 86 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 885c009..894efd5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Dev Branch: - Browse by folder - Queue songs and albums - Create and play playlists +- Search music library - Mark favorites - Volume control - Server-side scrobbling (e.g., on Navidrome, gonic) @@ -81,7 +82,8 @@ spinner = '▁▂▃▄▅▆▇█▇▆▅▄▃▂▁' - `1`: Folder view - `2`: Queue view - `3`: Playlist view -- `4`: Log (errors, etc.) view +- `4`: Search view +- `5`: Log (errors, etc.) view - `Escape`/`Return`: Close modal if open ### Playback Controls @@ -133,6 +135,13 @@ spinner = '⣾⣽⣻⢿⡿⣟⣯⣷' The default is `▉▊▋▌▍▎▏▎▍▌▋▊▉`. Set only one of these at a time, and the glyphs must exist in the font that the terminal running stmps is using. +### Search Controls + +- `/`: Focus search field +- `Enter`: In the search field, initiates a server-side search in all attributes for the text +- `Enter` / `a`: In one of the columns (artist, album, song), adds that item (recursively) to the queue +- Arrow keys navigate between the columns and search field + ## Advanced Configuration and Features ### MPRIS2 Integration diff --git a/gui.go b/gui.go index 0c00275..934fd47 100644 --- a/gui.go +++ b/gui.go @@ -35,6 +35,9 @@ type Ui struct { // playlist page playlistPage *PlaylistPage + // search page + searchPage *SearchPage + // log page logPage *LogPage @@ -61,6 +64,7 @@ const ( PageBrowser = "browser" PageQueue = "queue" PagePlaylists = "playlists" + PageSearch = "search" PageLog = "log" PageDeletePlaylist = "deletePlaylist" @@ -149,12 +153,16 @@ func InitGui(indexes *[]subsonic.SubsonicIndex, // playlist page ui.playlistPage = ui.createPlaylistPage() + // search page + ui.searchPage = ui.createSearchPage() + // log page ui.logPage = ui.createLogPage() ui.pages.AddPage(PageBrowser, ui.browserPage.Root, true, true). AddPage(PageQueue, ui.queuePage.Root, true, false). AddPage(PagePlaylists, ui.playlistPage.Root, true, false). + AddPage(PageSearch, ui.searchPage.Root, true, false). AddPage(PageDeletePlaylist, ui.playlistPage.DeletePlaylistModal, true, false). AddPage(PageNewPlaylist, ui.playlistPage.NewPlaylistModal, true, false). AddPage(PageAddToPlaylist, ui.browserPage.AddToPlaylistModal, true, false). diff --git a/gui_handlers.go b/gui_handlers.go index 87aff90..8a0069b 100644 --- a/gui_handlers.go +++ b/gui_handlers.go @@ -27,6 +27,9 @@ func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { ui.ShowPage(PagePlaylists) case '4': + ui.ShowPage(PageSearch) + + case '5': ui.ShowPage(PageLog) case '?': diff --git a/help_text.go b/help_text.go index 412674f..517c845 100644 --- a/help_text.go +++ b/help_text.go @@ -39,3 +39,15 @@ n new playlist d delete playlist a add playlist or song to queue ` + +const helpSearchPage = ` +artist, album, or song tab + Down focus search field + Left previous column + Right next column + Enter recursively add item to quue + a recursively add item to quue + / start search +search field + Enter search for text +` diff --git a/page_playlist.go b/page_playlist.go index bbde492..95cb187 100644 --- a/page_playlist.go +++ b/page_playlist.go @@ -203,7 +203,7 @@ func (p *PlaylistPage) UpdatePlaylists() { spinnerText = []rune("▉▊▋▌▍▎▏▎▍▌▋▊▉") } spinnerMax := len(spinnerText) - 1 - playlistsButton := buttonOrder[2] + playlistsButton := buttonOrder[PAGE_PLAYLISTS] stop := make(chan bool) go func() { var idx int @@ -219,7 +219,7 @@ func (p *PlaylistPage) UpdatePlaylists() { } else { format = "%d: [red]%c[white]%s" } - label := fmt.Sprintf(format, 3, spinnerText[idx], playlistsButton) + label := fmt.Sprintf(format, PAGE_PLAYLISTS+1, spinnerText[idx], playlistsButton) p.ui.menuWidget.buttons[playlistsButton].SetLabel(label) idx++ if idx > spinnerMax { @@ -234,7 +234,7 @@ func (p *PlaylistPage) UpdatePlaylists() { } else { format = "%d: %s" } - label := fmt.Sprintf(format, 3, playlistsButton) + label := fmt.Sprintf(format, PAGE_PLAYLISTS+1, playlistsButton) p.ui.menuWidget.buttons[playlistsButton].SetLabel(label) }) close(stop) diff --git a/subsonic/api.go b/subsonic/api.go index 585d091..b3b2e9b 100644 --- a/subsonic/api.go +++ b/subsonic/api.go @@ -93,22 +93,28 @@ type SubsonicSongs struct { Song SubsonicEntities `json:"song"` } -type SubsonicStarred struct { +type SubsonicResults struct { Artist SubsonicEntities `json:"artist"` Album SubsonicEntities `json:"album"` Song SubsonicEntities `json:"song"` } +type Artist struct { + Id string `json:"id"` + Name string `json:"name"` +} + type SubsonicEntity struct { - Id string `json:"id"` - IsDirectory bool `json:"isDir"` - Parent string `json:"parent"` - Title string `json:"title"` - Artist string `json:"artist"` - Duration int `json:"duration"` - Track int `json:"track"` - DiskNumber int `json:"diskNumber"` - Path string `json:"path"` + Id string `json:"id"` + IsDirectory bool `json:"isDir"` + Parent string `json:"parent"` + Title string `json:"title"` + Artist string `json:"artist"` + Artists []Artist `json:"artists"` + Duration int `json:"duration"` + Track int `json:"track"` + DiskNumber int `json:"diskNumber"` + Path string `json:"path"` } // Return the title if present, otherwise fallback to the file path @@ -177,16 +183,17 @@ type SubsonicPlaylist struct { } type SubsonicResponse struct { - Status string `json:"status"` - Version string `json:"version"` - Indexes SubsonicIndexes `json:"indexes"` - Directory SubsonicDirectory `json:"directory"` - RandomSongs SubsonicSongs `json:"randomSongs"` - SimilarSongs SubsonicSongs `json:"similarSongs"` - Starred SubsonicStarred `json:"starred"` - Playlists SubsonicPlaylists `json:"playlists"` - Playlist SubsonicPlaylist `json:"playlist"` - Error SubsonicError `json:"error"` + Status string `json:"status"` + Version string `json:"version"` + Indexes SubsonicIndexes `json:"indexes"` + Directory SubsonicDirectory `json:"directory"` + RandomSongs SubsonicSongs `json:"randomSongs"` + SimilarSongs SubsonicSongs `json:"similarSongs"` + Starred SubsonicResults `json:"starred"` + Playlists SubsonicPlaylists `json:"playlists"` + Playlist SubsonicPlaylist `json:"playlist"` + Error SubsonicError `json:"error"` + SearchResults SubsonicResults `json:"searchResult3"` } type responseWrapper struct { @@ -426,3 +433,14 @@ func (connection *SubsonicConnection) GetPlayUrl(entity *SubsonicEntity) string query.Set("id", entity.Id) return connection.Host + "/rest/stream" + "?" + query.Encode() } + +// Search uses the Subsonic search3 API to query a server for all songs that have +// ID3 tags that match the query. The query is global, in that it matches in any +// ID3 field. +// https://www.subsonic.org/pages/api.jsp#search3 +func (connection *SubsonicConnection) Search(searchTerm string) (*SubsonicResponse, error) { + query := defaultQuery(connection) + query.Set("query", searchTerm) + requestUrl := connection.Host + "/rest/search3" + "?" + query.Encode() + return connection.getResponse("Search", requestUrl) +} diff --git a/widget_help.go b/widget_help.go index a3d00ae..caccc17 100644 --- a/widget_help.go +++ b/widget_help.go @@ -61,6 +61,9 @@ func (h *HelpWidget) RenderHelp(context string) { case PagePlaylists: rightText = "[::b]Playlists[::-]\n" + tview.Escape(strings.TrimSpace(helpPagePlaylists)) + case PageSearch: + rightText = "[::b]Search[::-]\n" + tview.Escape(strings.TrimSpace(helpSearchPage)) + case PageLog: fallthrough default: diff --git a/widget_menu.go b/widget_menu.go index 1b87deb..256e707 100644 --- a/widget_menu.go +++ b/widget_menu.go @@ -26,7 +26,15 @@ type MenuWidget struct { ui *Ui } -var buttonOrder = []string{PageBrowser, PageQueue, PagePlaylists, PageLog} +const ( + PAGE_BROWSER = iota + PAGE_QUEUE + PAGE_PLAYLISTS + PAGE_SEARCH + PAGE_LOG +) + +var buttonOrder = []string{PageBrowser, PageQueue, PagePlaylists, PageSearch, PageLog} func (ui *Ui) createMenuWidget() (m *MenuWidget) { m = &MenuWidget{ From a7f9c9a9a23659d3e3dc02d6db6729944ba0fce6 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Tue, 17 Sep 2024 14:43:47 -0500 Subject: [PATCH 2/5] Fixes input capture trickle-down and also prevents global hotkeys from being stolen from the global search input. --- gui_handlers.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/gui_handlers.go b/gui_handlers.go index 8a0069b..879502c 100644 --- a/gui_handlers.go +++ b/gui_handlers.go @@ -12,7 +12,7 @@ import ( func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { // we don't want any of these firing if we're trying to add a new playlist focused := ui.app.GetFocus() - if ui.playlistPage.IsNewPlaylistInputFocused(focused) || ui.browserPage.IsSearchFocused(focused) { + if ui.playlistPage.IsNewPlaylistInputFocused(focused) || ui.browserPage.IsSearchFocused(focused) || focused == ui.searchPage.queryInput { return event } @@ -53,7 +53,6 @@ func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { if err != nil { ui.logger.PrintError("handlePageInput: Pause", err) } - return nil case 'P': // stop playing without changes to queue @@ -62,42 +61,36 @@ func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { if err != nil { ui.logger.PrintError("handlePageInput: Stop", err) } - return nil case 'X': // debug stuff ui.logger.Print("test") //ui.player.Test() ui.showMessageBox("foo bar") - return nil case '-': // volume- if err := ui.player.AdjustVolume(-5); err != nil { ui.logger.PrintError("handlePageInput: AdjustVolume-", err) } - return nil case '+', '=': // volume+ if err := ui.player.AdjustVolume(5); err != nil { ui.logger.PrintError("handlePageInput: AdjustVolume+", err) } - return nil case '.': // << if err := ui.player.Seek(10); err != nil { ui.logger.PrintError("handlePageInput: Seek+", err) } - return nil case ',': // >> if err := ui.player.Seek(-10); err != nil { ui.logger.PrintError("handlePageInput: Seek-", err) } - return nil case '>': // skip to next track @@ -105,9 +98,12 @@ func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { ui.logger.PrintError("handlePageInput: Next", err) } ui.queuePage.UpdateQueue() + + default: + return event } - return event + return nil } func (ui *Ui) ShowPage(name string) { From 49068266fa418e20be0bb71f6c248a473c7744d6 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Tue, 17 Sep 2024 14:43:47 -0500 Subject: [PATCH 3/5] Fixes input capture trickle-down and also prevents global hotkeys from being stolen from the global search input. Also addresses a number of other bugs, and implements load-more. --- README.md | 20 +++- event_loop.go | 2 +- gui_handlers.go | 2 +- help_text.go | 1 + page_browser.go | 4 +- page_search.go | 280 ++++++++++++++++++++++++++++++++++++++++++++++++ subsonic/api.go | 112 +++++++++++++++++-- 7 files changed, 404 insertions(+), 17 deletions(-) create mode 100644 page_search.go diff --git a/README.md b/README.md index 894efd5..707e908 100644 --- a/README.md +++ b/README.md @@ -137,10 +137,22 @@ The default is `▉▊▋▌▍▎▏▎▍▌▋▊▉`. Set only one of these ### Search Controls -- `/`: Focus search field -- `Enter`: In the search field, initiates a server-side search in all attributes for the text -- `Enter` / `a`: In one of the columns (artist, album, song), adds that item (recursively) to the queue -- Arrow keys navigate between the columns and search field +The search tab performs a server-side search for text in metadata name fields. +The search results are filtered into three columns: artist, album, and song. 20 +results (in each column) are fetched at a time; use `n` to load more results. + +In any of the columns: + +- `/`: Focus search field. +- `Enter` / `a`: Adds the selected item recursively to the queue. +- `n`: Load more search results. +- Left/right arrow keys (`←`, `→`) navigate between the columns +- Up/down arrow keys (`↓`, `↑`) navigate the selected column list + +In the search field: + +- `Enter`: Perform the query. +- `Escape`: Escapes into the columns, where the global key bindings work. ## Advanced Configuration and Features diff --git a/event_loop.go b/event_loop.go index d17e6e2..ded9789 100644 --- a/event_loop.go +++ b/event_loop.go @@ -45,7 +45,7 @@ func (ui *Ui) guiEventLoop() { select { case <-fpsTimer.C: fpsTimer.Reset(10 * time.Second) - ui.logger.Printf("guiEventLoop: %f events per second", events/10.0) + // ui.logger.Printf("guiEventLoop: %f events per second", events/10.0) events = 0 case msg := <-ui.logger.Prints: diff --git a/gui_handlers.go b/gui_handlers.go index 879502c..0690c9d 100644 --- a/gui_handlers.go +++ b/gui_handlers.go @@ -12,7 +12,7 @@ import ( func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { // we don't want any of these firing if we're trying to add a new playlist focused := ui.app.GetFocus() - if ui.playlistPage.IsNewPlaylistInputFocused(focused) || ui.browserPage.IsSearchFocused(focused) || focused == ui.searchPage.queryInput { + if ui.playlistPage.IsNewPlaylistInputFocused(focused) || ui.browserPage.IsSearchFocused(focused) || focused == ui.searchPage.searchField { return event } diff --git a/help_text.go b/help_text.go index 517c845..f453502 100644 --- a/help_text.go +++ b/help_text.go @@ -48,6 +48,7 @@ artist, album, or song tab Enter recursively add item to quue a recursively add item to quue / start search + n load more results search field Enter search for text ` diff --git a/page_browser.go b/page_browser.go index 93d1877..6c40bd5 100644 --- a/page_browser.go +++ b/page_browser.go @@ -82,7 +82,6 @@ func (ui *Ui) createBrowserPage(indexes *[]subsonic.SubsonicIndex) *BrowserPage AddItem(browserPage.artistList, 0, 1, true). AddItem(browserPage.entityList, 0, 1, false) - // TODO (A) add search-for-song, if feasible. Might be able to do server-side then drill-down, but we might also have all entities cached on the client already. To investigate. browserPage.Root = tview.NewFlex().SetDirection(tview.FlexRow) browserPage.showSearchField(false) // add artist/search items @@ -254,8 +253,7 @@ func (b *BrowserPage) UpdateStars() { } func (b *BrowserPage) handleAddArtistToQueue() { - currentIndex := b.artistList.GetCurrentItem() - if currentIndex < 0 { + if b.artistList.GetCurrentItem() < 0 { return } diff --git a/page_search.go b/page_search.go new file mode 100644 index 0000000..96d0aff --- /dev/null +++ b/page_search.go @@ -0,0 +1,280 @@ +// Copyright 2023 The STMPS Authors +// SPDX-License-Identifier: GPL-3.0-only + +package main + +import ( + "sort" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/spezifisch/stmps/logger" + "github.com/spezifisch/stmps/subsonic" +) + +type SearchPage struct { + Root *tview.Flex + AddToPlaylistModal tview.Primitive + + columnsFlex *tview.Flex + + artistList *tview.List + albumList *tview.List + songList *tview.List + searchField *tview.InputField + + artists []*subsonic.Artist + albums []*subsonic.Album + songs []*subsonic.SubsonicEntity + + artistOffset int + albumOffset int + songOffset int + + // external refs + ui *Ui + logger logger.LoggerInterface +} + +func (ui *Ui) createSearchPage() *SearchPage { + searchPage := SearchPage{ + ui: ui, + logger: ui.logger, + } + + // artist list + searchPage.artistList = tview.NewList(). + ShowSecondaryText(false) + searchPage.artistList.Box. + SetTitle(" artist "). + SetTitleAlign(tview.AlignLeft). + SetBorder(true) + + // album list + searchPage.albumList = tview.NewList(). + ShowSecondaryText(false) + searchPage.albumList.Box. + SetTitle(" album "). + SetTitleAlign(tview.AlignLeft). + SetBorder(true) + + // song list + searchPage.songList = tview.NewList(). + ShowSecondaryText(false) + searchPage.songList.Box. + SetTitle(" song "). + SetTitleAlign(tview.AlignLeft). + SetBorder(true) + + // search bar + searchPage.searchField = tview.NewInputField(). + SetLabel("search:"). + SetFieldBackgroundColor(tcell.ColorBlack) + + searchPage.columnsFlex = tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(searchPage.artistList, 0, 1, false). + AddItem(searchPage.albumList, 0, 1, false). + AddItem(searchPage.songList, 0, 1, false) + + searchPage.Root = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(searchPage.columnsFlex, 0, 1, false). + AddItem(searchPage.searchField, 1, 1, true) + + searchPage.artistList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyLeft: + ui.app.SetFocus(searchPage.songList) + return nil + case tcell.KeyRight: + ui.app.SetFocus(searchPage.albumList) + return nil + case tcell.KeyEnter: + idx := searchPage.artistList.GetCurrentItem() + searchPage.addArtistToQueue(searchPage.artists[idx]) + return nil + } + + switch event.Rune() { + case 'a': + idx := searchPage.artistList.GetCurrentItem() + searchPage.logger.Printf("artistList adding (%d) %s", idx, searchPage.artists[idx].Name) + searchPage.addArtistToQueue(searchPage.artists[idx]) + return nil + case '/': + searchPage.ui.app.SetFocus(searchPage.searchField) + return nil + case 'n': + searchPage.search() + return nil + } + + return event + }) + searchPage.albumList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyLeft: + ui.app.SetFocus(searchPage.artistList) + return nil + case tcell.KeyRight: + ui.app.SetFocus(searchPage.songList) + return nil + case tcell.KeyEnter: + idx := searchPage.albumList.GetCurrentItem() + searchPage.addAlbumToQueue(searchPage.albums[idx]) + return nil + } + + switch event.Rune() { + case 'a': + idx := searchPage.albumList.GetCurrentItem() + searchPage.logger.Printf("albumList adding (%d) %s", idx, searchPage.albums[idx].Name) + searchPage.addAlbumToQueue(searchPage.albums[idx]) + return nil + case '/': + searchPage.ui.app.SetFocus(searchPage.searchField) + return nil + case 'n': + searchPage.search() + return nil + } + + return event + }) + searchPage.songList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyLeft: + ui.app.SetFocus(searchPage.albumList) + return nil + case tcell.KeyRight: + ui.app.SetFocus(searchPage.artistList) + return nil + case tcell.KeyEnter: + idx := searchPage.songList.GetCurrentItem() + ui.addSongToQueue(searchPage.songs[idx]) + ui.queuePage.UpdateQueue() + return nil + } + + switch event.Rune() { + case 'a': + idx := searchPage.songList.GetCurrentItem() + ui.addSongToQueue(searchPage.songs[idx]) + ui.queuePage.updateQueue() + return nil + case '/': + searchPage.ui.app.SetFocus(searchPage.searchField) + return nil + case 'n': + searchPage.search() + return nil + } + + return event + }) + searchPage.searchField.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyUp, tcell.KeyESC: + if len(searchPage.artists) != 0 { + ui.app.SetFocus(searchPage.artistList) + } else if len(searchPage.albums) != 0 { + ui.app.SetFocus(searchPage.albumList) + } else if len(searchPage.songs) != 0 { + ui.app.SetFocus(searchPage.songList) + } else { + ui.app.SetFocus(searchPage.artistList) + } + case tcell.KeyEnter: + searchPage.artistList.Clear() + searchPage.artists = make([]*subsonic.Artist, 0) + searchPage.albumList.Clear() + searchPage.albums = make([]*subsonic.Album, 0) + searchPage.songList.Clear() + searchPage.songs = make([]*subsonic.SubsonicEntity, 0) + + searchPage.artistOffset = 0 + searchPage.albumOffset = 0 + searchPage.songOffset = 0 + searchPage.search() + if len(searchPage.artists) > 0 { + ui.app.SetFocus(searchPage.artistList) + } else if len(searchPage.albums) > 0 { + ui.app.SetFocus(searchPage.albumList) + } else if len(searchPage.songs) > 0 { + ui.app.SetFocus(searchPage.songList) + } + default: + return event + } + return nil + }) + + return &searchPage +} + +func (s *SearchPage) search() { + if len(s.searchField.GetText()) == 0 { + return + } + query := s.searchField.GetText() + + res, q, err := s.ui.connection.Search(query, s.artistOffset, s.albumOffset, s.songOffset) + s.logger.Printf("query was %s", q) + if err != nil { + s.logger.PrintError("SearchPage.search", err) + return + } + + for _, artist := range res.SearchResults.Artist { + s.artistList.AddItem(tview.Escape(artist.Name), "", 0, nil) + s.artists = append(s.artists, &artist) + } + for _, album := range res.SearchResults.Album { + s.albumList.AddItem(tview.Escape(album.Album), "", 0, nil) + s.albums = append(s.albums, &album) + } + for _, song := range res.SearchResults.Song { + s.songList.AddItem(tview.Escape(song.Title), "", 0, nil) + s.songs = append(s.songs, &song) + } + + s.artistOffset += len(res.SearchResults.Artist) + s.albumOffset += len(res.SearchResults.Album) + s.songOffset += len(res.SearchResults.Song) +} + +func (s *SearchPage) addArtistToQueue(entity subsonic.Ider) { + response, err := s.ui.connection.GetArtist(entity.ID()) + if err != nil { + s.logger.Printf("addArtistToQueue: GetArtist %s -- %s", entity.ID(), err.Error()) + return + } + artistName := response.Artist.Name + + for _, album := range response.Artist.Album { + response, err = s.ui.connection.GetAlbum(album.Id) + sort.Sort(response.Album.Song) + for _, e := range response.Album.Song { + for _, art := range e.Artists { + if art.Name == artistName { + s.ui.addSongToQueue(&e) + break + } + } + } + } + + s.ui.queuePage.UpdateQueue() +} + +func (s *SearchPage) addAlbumToQueue(entity subsonic.Ider) { + response, err := s.ui.connection.GetAlbum(entity.ID()) + if err != nil { + s.logger.Printf("addToQueue: GetMusicDirectory %s -- %s", entity.ID(), err.Error()) + return + } + sort.Sort(response.Album.Song) + for _, e := range response.Album.Song { + s.ui.addSongToQueue(&e) + } + s.ui.queuePage.UpdateQueue() +} diff --git a/subsonic/api.go b/subsonic/api.go index b3b2e9b..2cca5b6 100644 --- a/subsonic/api.go +++ b/subsonic/api.go @@ -70,6 +70,10 @@ func defaultQuery(connection *SubsonicConnection) url.Values { return query } +type Ider interface { + ID() string +} + // response structs type SubsonicError struct { Code int `json:"code"` @@ -77,9 +81,13 @@ type SubsonicError struct { } type SubsonicArtist struct { - Id string - Name string - AlbumCount int + Id string `json:"id"` + Name string `json:"name"` + AlbumCount int `json:"albumCount"` +} + +func (s SubsonicArtist) ID() string { + return s.Id } type SubsonicDirectory struct { @@ -89,18 +97,54 @@ type SubsonicDirectory struct { Entities SubsonicEntities `json:"child"` } +func (s SubsonicDirectory) ID() string { + return s.Id +} + type SubsonicSongs struct { Song SubsonicEntities `json:"song"` } type SubsonicResults struct { - Artist SubsonicEntities `json:"artist"` - Album SubsonicEntities `json:"album"` + Artist []Artist `json:"artist"` + Album []Album `json:"album"` Song SubsonicEntities `json:"song"` } type Artist struct { - Id string `json:"id"` + Id string `json:"id"` + Name string `json:"name"` + AlbumCount int `json:"albumCount"` + Album []Album `json:"album"` +} + +func (s Artist) ID() string { + return s.Id +} + +type Album struct { + Id string `json:"id"` + Created string `json:"created"` + Artist string `json:"artist"` + Artists []Artist `json:"artists"` + DisplayArtist string `json:"displayArtist"` + Title string `json:"title"` + Album string `json:"album"` + Name string `json:"name"` + SongCount int `json:"songCount"` + Duration int `json:"duration"` + PlayCount int `json:"playCount"` + Genre string `json:"genre"` + Genres []Genre `json:"genres"` + Year int `json:"year"` + Song SubsonicEntities `json:"song"` +} + +func (s Album) ID() string { + return s.Id +} + +type Genre struct { Name string `json:"name"` } @@ -117,6 +161,10 @@ type SubsonicEntity struct { Path string `json:"path"` } +func (s SubsonicEntity) ID() string { + return s.Id +} + // Return the title if present, otherwise fallback to the file path func (e SubsonicEntity) GetSongTitle() string { if e.Title != "" { @@ -193,6 +241,8 @@ type SubsonicResponse struct { Playlists SubsonicPlaylists `json:"playlists"` Playlist SubsonicPlaylist `json:"playlist"` Error SubsonicError `json:"error"` + Artist Artist `json:"artist"` + Album Album `json:"album"` SearchResults SubsonicResults `json:"searchResult3"` } @@ -228,6 +278,48 @@ func (connection *SubsonicConnection) GetIndexes() (*SubsonicResponse, error) { return connection.getResponse("GetIndexes", requestUrl) } +func (connection *SubsonicConnection) GetArtist(id string) (*SubsonicResponse, error) { + if cachedResponse, present := connection.directoryCache[id]; present { + return &cachedResponse, nil + } + + query := defaultQuery(connection) + query.Set("id", id) + requestUrl := connection.Host + "/rest/getArtist" + "?" + query.Encode() + resp, err := connection.getResponse("GetMusicDirectory", requestUrl) + if err != nil { + return resp, err + } + + // on a sucessful request, cache the response + if resp.Status == "ok" { + connection.directoryCache[id] = *resp + } + + return resp, nil +} + +func (connection *SubsonicConnection) GetAlbum(id string) (*SubsonicResponse, error) { + if cachedResponse, present := connection.directoryCache[id]; present { + return &cachedResponse, nil + } + + query := defaultQuery(connection) + query.Set("id", id) + requestUrl := connection.Host + "/rest/getAlbum" + "?" + query.Encode() + resp, err := connection.getResponse("GetAlbum", requestUrl) + if err != nil { + return resp, err + } + + // on a sucessful request, cache the response + if resp.Status == "ok" { + connection.directoryCache[id] = *resp + } + + return resp, nil +} + func (connection *SubsonicConnection) GetMusicDirectory(id string) (*SubsonicResponse, error) { if cachedResponse, present := connection.directoryCache[id]; present { return &cachedResponse, nil @@ -438,9 +530,13 @@ func (connection *SubsonicConnection) GetPlayUrl(entity *SubsonicEntity) string // ID3 tags that match the query. The query is global, in that it matches in any // ID3 field. // https://www.subsonic.org/pages/api.jsp#search3 -func (connection *SubsonicConnection) Search(searchTerm string) (*SubsonicResponse, error) { +func (connection *SubsonicConnection) Search(searchTerm string, artistOffset, albumOffset, songOffset int) (*SubsonicResponse, string, error) { query := defaultQuery(connection) query.Set("query", searchTerm) + query.Set("artistOffset", strconv.Itoa(artistOffset)) + query.Set("albumOffset", strconv.Itoa(albumOffset)) + query.Set("songOffset", strconv.Itoa(songOffset)) requestUrl := connection.Host + "/rest/search3" + "?" + query.Encode() - return connection.getResponse("Search", requestUrl) + res, err := connection.getResponse("Search", requestUrl) + return res, requestUrl, err } From 17a8dde39411917b755908eed771e85667599a58 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Wed, 18 Sep 2024 10:24:45 -0500 Subject: [PATCH 4/5] Removes some debugging code. --- page_search.go | 3 +-- subsonic/api.go | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/page_search.go b/page_search.go index 96d0aff..e6e7908 100644 --- a/page_search.go +++ b/page_search.go @@ -217,8 +217,7 @@ func (s *SearchPage) search() { } query := s.searchField.GetText() - res, q, err := s.ui.connection.Search(query, s.artistOffset, s.albumOffset, s.songOffset) - s.logger.Printf("query was %s", q) + res, err := s.ui.connection.Search(query, s.artistOffset, s.albumOffset, s.songOffset) if err != nil { s.logger.PrintError("SearchPage.search", err) return diff --git a/subsonic/api.go b/subsonic/api.go index 2cca5b6..db8691d 100644 --- a/subsonic/api.go +++ b/subsonic/api.go @@ -530,7 +530,7 @@ func (connection *SubsonicConnection) GetPlayUrl(entity *SubsonicEntity) string // ID3 tags that match the query. The query is global, in that it matches in any // ID3 field. // https://www.subsonic.org/pages/api.jsp#search3 -func (connection *SubsonicConnection) Search(searchTerm string, artistOffset, albumOffset, songOffset int) (*SubsonicResponse, string, error) { +func (connection *SubsonicConnection) Search(searchTerm string, artistOffset, albumOffset, songOffset int) (*SubsonicResponse, error) { query := defaultQuery(connection) query.Set("query", searchTerm) query.Set("artistOffset", strconv.Itoa(artistOffset)) @@ -538,5 +538,5 @@ func (connection *SubsonicConnection) Search(searchTerm string, artistOffset, al query.Set("songOffset", strconv.Itoa(songOffset)) requestUrl := connection.Host + "/rest/search3" + "?" + query.Encode() res, err := connection.getResponse("Search", requestUrl) - return res, requestUrl, err + return res, err } From f6d7cbb97d0c6f0cc12733344c41ff9660eb440c Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Wed, 18 Sep 2024 10:44:40 -0500 Subject: [PATCH 5/5] Finishes de-hardcoding the buttonOrder references --- widget_menu.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/widget_menu.go b/widget_menu.go index 256e707..17f64cb 100644 --- a/widget_menu.go +++ b/widget_menu.go @@ -38,7 +38,7 @@ var buttonOrder = []string{PageBrowser, PageQueue, PagePlaylists, PageSearch, Pa func (ui *Ui) createMenuWidget() (m *MenuWidget) { m = &MenuWidget{ - activeButton: buttonOrder[0], + activeButton: buttonOrder[PAGE_BROWSER], buttons: make(map[string]*tview.Button), buttonStyle: tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite),