From 55fd05e0c3e48955e09bc64229fa32484f9c587b Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Fri, 11 Oct 2024 13:51:57 -0500 Subject: [PATCH 01/20] Updates README about the development fork. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 0315606..8aa2894 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ *Stamps* is a terminal client for *sonic music servers, inspired by ncmpcpp and musickube. +xxxserxxx's main branch: +Forked from spezifisch's project, with all of my (outstanding) PRs applied. I do not plan on hard-forking stmps, so I would not recommend creating distro packages. If you want one of the implemented features that @spezifisch hasn't yet merged, by all means, use this. Just remember it's a development fork. +[![Build+Test Linux](https://github.com/xxxserxxx/stmps/actions/workflows/build-linux.yml/badge.svg?branch=main)](https://github.com/xxxserxxx/stmps/actions/workflows/build-linux.yml) +[![Build+Test macOS](https://github.com/xxxserxxx/stmps/actions/workflows/build-macos.yml/badge.svg?branch=main)](https://github.com/xxxserxxx/stmps/actions/workflows/build-macos.yml) + Main Branch: [![Build+Test Linux](https://github.com/spezifisch/stmps/actions/workflows/build-linux.yml/badge.svg?branch=main)](https://github.com/spezifisch/stmps/actions/workflows/build-linux.yml) [![Build+Test macOS](https://github.com/spezifisch/stmps/actions/workflows/build-macos.yml/badge.svg?branch=main)](https://github.com/spezifisch/stmps/actions/workflows/build-macos.yml) From 97524e1cfe7adf9037f0a38334863e57e5f8fd35 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Mon, 14 Oct 2024 10:05:13 -0500 Subject: [PATCH 02/20] Add a more complex caching mechanism which loads assets concurrently, to handle slow asset servers. Abstracted enough to be able to handle caching to disk without having to change users of the Cache. --- cache.go | 122 ++++++++++++++++++++++++++++ cache_test.go | 208 ++++++++++++++++++++++++++++++++++++++++++++++++ page_queue.go | 54 ++++++++++--- subsonic/api.go | 18 +---- 4 files changed, 379 insertions(+), 23 deletions(-) create mode 100644 cache.go create mode 100644 cache_test.go diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..075ec7e --- /dev/null +++ b/cache.go @@ -0,0 +1,122 @@ +package main + +import ( + "time" + + "github.com/spezifisch/stmps/logger" +) + +// Cache fetches assets and holds a copy, returning them on request. +// A Cache is composed of four mechanisms: +// +// 1. a zero object +// 2. a function for fetching assets +// 3. a function for invalidating assets +// 4. a call-back function for when an asset is fetched +// +// When an asset is requested, Cache returns the asset if it is cached. +// Otherwise, it returns the zero object, and queues up a fetch for the object +// in the background. When the fetch is complete, the callback function is +// called, allowing the caller to get the real asset. An invalidation function +// allows Cache to manage the cache size by removing cached invalid objects. +// +// Caches are indexed by strings, because. They don't have to be, but +// stmps doesn't need them to be anything different. +type Cache[T any] struct { + zero T + cache map[string]T + pipeline chan string + quit func() +} + +// NewCache sets up a new cache, given +// +// - a zero value, returned immediately on cache misses +// - a fetcher, which can be a long-running function that loads assets. +// fetcher should take a key ID and return an asset, or an error. +// - a call-back, which will be called when a requested asset is available. It +// will be called with the asset ID, and the loaded asset. +// - an invalidation function, returning true if a cached object stored under a +// key can be removed from the cache. It will be called with an asset ID to +// check. +// - an invalidation frequency; the invalidation function will be called for +// every cached object this frequently. +// - a logger, used for reporting errors returned by the fetching function +// +// The invalidation should be reasonably efficient. +func NewCache[T any]( + zeroValue T, + fetcher func(string) (T, error), + fetchedItem func(string, T), + isInvalid func(string) bool, + invalidateFrequency time.Duration, + logger *logger.Logger, +) Cache[T] { + + cache := make(map[string]T) + getPipe := make(chan string, 1000) + + go func() { + for i := range getPipe { + asset, err := fetcher(i) + if err != nil { + logger.Printf("error fetching asset %s: %s", i, err) + continue + } + cache[i] = asset + fetchedItem(i, asset) + } + }() + + timer := time.NewTicker(invalidateFrequency) + done := make(chan bool) + go func() { + for { + select { + case <-timer.C: + for k := range cache { + if isInvalid(k) { + delete(cache, k) + } + } + case <-done: + return + } + } + }() + + return Cache[T]{ + zero: zeroValue, + cache: cache, + pipeline: getPipe, + quit: func() { + close(getPipe) + done <- true + }, + } +} + +// Get returns a cached asset, or the zero asset on a cache miss. +// On a cache miss, the requested asset is queued for fetching. +func (c *Cache[T]) Get(key string) T { + if v, ok := c.cache[key]; ok { + return v + } + c.pipeline <- key + return c.zero +} + +// Close releases resources used by the cache, clearing the cache +// and shutting down goroutines. It should be called when the +// Cache is no longer used, and before program exit. +// +// Note: since the current iteration of Cache is a memory cache, it isn't +// strictly necessary to call this on program exit; however, as the caching +// mechanism may change and use other system resources, it's good practice to +// call this on exit. +func (c Cache[T]) Close() { + for k := range c.cache { + delete(c.cache, k) + } + c.quit() +} diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..872fe77 --- /dev/null +++ b/cache_test.go @@ -0,0 +1,208 @@ +package main + +import ( + "testing" + "time" + + "github.com/spezifisch/stmps/logger" +) + +func TestNewCache(t *testing.T) { + logger := logger.Logger{} + + t.Run("basic string cache creation", func(t *testing.T) { + zero := "empty" + c := NewCache( + zero, + func(k string) (string, error) { return zero, nil }, + func(k, v string) {}, + func(k string) bool { return false }, + time.Second, + &logger, + ) + defer c.Close() + if c.zero != zero { + t.Errorf("expected %q, got %q", zero, c.zero) + } + if c.cache == nil || len(c.cache) != 0 { + t.Errorf("expected non-nil, empty map; got %#v", c.cache) + } + if c.pipeline == nil { + t.Errorf("expected non-nil chan; got %#v", c.pipeline) + } + }) + + t.Run("different data type cache creation", func(t *testing.T) { + zero := -1 + c := NewCache( + zero, + func(k string) (int, error) { return zero, nil }, + func(k string, v int) {}, + func(k string) bool { return false }, + time.Second, + &logger, + ) + defer c.Close() + if c.zero != zero { + t.Errorf("expected %d, got %d", zero, c.zero) + } + if c.cache == nil || len(c.cache) != 0 { + t.Errorf("expected non-nil, empty map; got %#v", c.cache) + } + if c.pipeline == nil { + t.Errorf("expected non-nil chan; got %#v", c.pipeline) + } + }) +} + +func TestGet(t *testing.T) { + logger := logger.Logger{} + zero := "zero" + items := map[string]string{"a": "1", "b": "2", "c": "3"} + c := NewCache( + zero, + func(k string) (string, error) { + return items[k], nil + }, + func(k, v string) {}, + func(k string) bool { return false }, + time.Second, + &logger, + ) + defer c.Close() + t.Run("empty cache get returns zero", func(t *testing.T) { + got := c.Get("a") + if got != zero { + t.Errorf("expected %q, got %q", zero, got) + } + }) + // Give the fetcher a chance to populate the cache + time.Sleep(time.Millisecond) + t.Run("non-empty cache get returns value", func(t *testing.T) { + got := c.Get("a") + expected := "1" + if got != expected { + t.Errorf("expected %q, got %q", expected, got) + } + }) +} + +func TestCallback(t *testing.T) { + logger := logger.Logger{} + zero := "zero" + var gotK, gotV string + expectedK := "a" + expectedV := "1" + c := NewCache( + zero, + func(k string) (string, error) { + return expectedV, nil + }, + func(k, v string) { + gotK = k + gotV = v + }, + func(k string) bool { return false }, + time.Second, + &logger, + ) + defer c.Close() + t.Run("callback gets called back", func(t *testing.T) { + c.Get(expectedK) + // Give the callback goroutine a chance to do its thing + time.Sleep(time.Millisecond) + if gotK != expectedK { + t.Errorf("expected key %q, got %q", expectedV, gotV) + } + if gotV != expectedV { + t.Errorf("expected value %q, got %q", expectedV, gotV) + } + }) +} + +func TestClose(t *testing.T) { + logger := logger.Logger{} + t.Run("pipeline is closed", func(t *testing.T) { + c0 := NewCache( + "", + func(k string) (string, error) { return "A", nil }, + func(k, v string) {}, + func(k string) bool { return false }, + time.Second, + &logger, + ) + // Put something in the cache + c0.Get("") + // Give the cache time to populate the cache + time.Sleep(time.Millisecond) + // Make sure the cache isn't empty + if len(c0.cache) == 0 { + t.Fatalf("expected the cache to be non-empty, but it was. Probably a threading issue with the test, and we need a longer timeout.") + } + defer func() { + if r := recover(); r == nil { + t.Error("expected panic on pipeline use; got none") + } + }() + c0.Close() + if len(c0.cache) > 0 { + t.Errorf("expected empty cache; was %d", len(c0.cache)) + } + c0.Get("") + }) + + t.Run("callback gets called back", func(t *testing.T) { + c0 := NewCache( + "", + func(k string) (string, error) { return "", nil }, + func(k, v string) {}, + func(k string) bool { return false }, + time.Second, + &logger, + ) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic on pipeline use; got none") + } + }() + c0.Close() + c0.Get("") + }) +} + +func TestInvalidate(t *testing.T) { + logger := logger.Logger{} + zero := "zero" + var gotV string + expected := "1" + c := NewCache( + zero, + func(k string) (string, error) { + return expected, nil + }, + func(k, v string) { + gotV = v + }, + func(k string) bool { + return true + }, + 500*time.Millisecond, + &logger, + ) + defer c.Close() + t.Run("basic invalidation", func(t *testing.T) { + if c.Get("a") != zero { + t.Errorf("expected %q, got %q", zero, gotV) + } + // Give the callback goroutine a chance to do its thing + time.Sleep(time.Millisecond) + if c.Get("a") != expected { + t.Errorf("expected %q, got %q", expected, gotV) + } + // Give the invalidation time to be called + time.Sleep(600 * time.Millisecond) + if c.Get("a") != zero { + t.Errorf("expected %q, got %q", zero, gotV) + } + }) +} diff --git a/page_queue.go b/page_queue.go index a7b12e7..f5078a4 100644 --- a/page_queue.go +++ b/page_queue.go @@ -53,6 +53,8 @@ type QueuePage struct { logger logger.LoggerInterface songInfoTemplate *template.Template + + coverArtCache Cache[image.Image] } var STMPS_LOGO image.Image @@ -143,6 +145,48 @@ func (ui *Ui) createQueuePage() *QueuePage { starIdList: ui.starIdList, } + queuePage.coverArtCache = NewCache( + // zero value + STMPS_LOGO, + // function that loads assets; can be slow + ui.connection.GetCoverArt, + // function that gets called when the actual asset is loaded + func(imgId string, img image.Image) { + row, _ := queuePage.queueList.GetSelection() + // If nothing is selected, set the image to the logo + if row >= len(queuePage.queueData.playerQueue) || row < 0 { + ui.app.QueueUpdate(func() { + queuePage.coverArt.SetImage(STMPS_LOGO) + }) + return + } + // If the fetched asset isn't the asset for the current song, + // just skip it. + currentSong := queuePage.queueData.playerQueue[row] + if currentSong.CoverArtId != imgId { + return + } + // Otherwise, the asset is for the current song, so update it + ui.app.QueueUpdate(func() { + queuePage.coverArt.SetImage(img) + }) + }, + // function called to check if asset is invalid: + // true if it can be purged from the cache, false if it's still needed + func(assetId string) bool { + for _, song := range queuePage.queueData.playerQueue { + if song.CoverArtId == assetId { + return false + } + } + // Didn't find a song that needs the asset; purge it. + return true + }, + // How frequently we check for invalid assets + time.Minute, + ui.logger, + ) + return &queuePage } @@ -155,15 +199,7 @@ func (q *QueuePage) changeSelection(row, column int) { currentSong := q.queueData.playerQueue[row] art := STMPS_LOGO if currentSong.CoverArtId != "" { - if nart, err := q.ui.connection.GetCoverArt(currentSong.CoverArtId); err == nil { - if nart != nil { - art = nart - } else { - q.logger.Printf("%q cover art %s was unexpectedly nil", currentSong.Title, currentSong.CoverArtId) - } - } else { - q.logger.Printf("error fetching cover art for %s: %v", currentSong.Title, err) - } + art = q.coverArtCache.Get(currentSong.CoverArtId) } q.coverArt.SetImage(art) _ = q.songInfoTemplate.Execute(q.songInfo, currentSong) diff --git a/subsonic/api.go b/subsonic/api.go index 95ae7c3..42eef41 100644 --- a/subsonic/api.go +++ b/subsonic/api.go @@ -35,7 +35,6 @@ type SubsonicConnection struct { logger logger.LoggerInterface directoryCache map[string]SubsonicResponse - coverArts map[string]image.Image } func Init(logger logger.LoggerInterface) *SubsonicConnection { @@ -45,7 +44,6 @@ func Init(logger logger.LoggerInterface) *SubsonicConnection { logger: logger, directoryCache: make(map[string]SubsonicResponse), - coverArts: make(map[string]image.Image), } } @@ -377,18 +375,14 @@ func (connection *SubsonicConnection) GetMusicDirectory(id string) (*SubsonicRes return resp, nil } -// GetCoverArt fetches album art from the server, by ID. The results are cached, -// so it is safe to call this function repeatedly. If id is empty, an error -// is returned. If, for some reason, the server response can't be parsed into -// an image, an error is returned. This function can parse GIF, JPEG, and PNG -// images. +// GetCoverArt fetches album art from the server, by ID. If id is empty, an +// error is returned. If, for some reason, the server response can't be parsed +// into an image, an error is returned. This function can parse GIF, JPEG, and +// PNG images. func (connection *SubsonicConnection) GetCoverArt(id string) (image.Image, error) { if id == "" { return nil, fmt.Errorf("GetCoverArt: no ID provided") } - if rv, ok := connection.coverArts[id]; ok { - return rv, nil - } query := defaultQuery(connection) query.Set("id", id) query.Set("f", "image/png") @@ -426,10 +420,6 @@ func (connection *SubsonicConnection) GetCoverArt(id string) (image.Image, error default: return nil, fmt.Errorf("[%s] unhandled image type %s: %v", caller, res.Header["Content-Type"][0], err) } - if art != nil { - // FIXME connection.coverArts shouldn't grow indefinitely. Add some LRU cleanup after loading a few hundred cover arts. - connection.coverArts[id] = art - } return art, err } From baf1670e95a0f9f6951c3eea0d846d4fb05b874f Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Mon, 14 Oct 2024 10:29:43 -0500 Subject: [PATCH 03/20] Changes program exit function to properly close the asset queue. Closing the queue is not currently _required_, but if we replace the queue with an on-disk queue we may need to clean up resources; this commit ensures that doesn't get missed in the future. --- gui_handlers.go | 1 + 1 file changed, 1 insertion(+) diff --git a/gui_handlers.go b/gui_handlers.go index 14ff0cb..43da1a0 100644 --- a/gui_handlers.go +++ b/gui_handlers.go @@ -118,6 +118,7 @@ func (ui *Ui) ShowPage(name string) { func (ui *Ui) Quit() { // TODO savePlayQueue/getPlayQueue + ui.queuePage.coverArtCache.Close() ui.player.Quit() ui.app.Stop() } From 9dd42eb83eb4d5580be6f4486efee19cf28a597b Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Tue, 15 Oct 2024 14:13:54 -0500 Subject: [PATCH 04/20] fix: second edge case of #33 --- gui_handlers.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gui_handlers.go b/gui_handlers.go index 14ff0cb..2b4a351 100644 --- a/gui_handlers.go +++ b/gui_handlers.go @@ -114,6 +114,8 @@ func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { func (ui *Ui) ShowPage(name string) { ui.pages.SwitchToPage(name) ui.menuWidget.SetActivePage(name) + _, prim := ui.pages.GetFrontPage() + ui.app.SetFocus(prim) } func (ui *Ui) Quit() { From 97a7ca0f2f49f424f67f3140bd5186dd6c3acfba Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Tue, 15 Oct 2024 14:19:10 -0500 Subject: [PATCH 05/20] Redoing the fix --- page_playlist.go | 3 +++ 1 file changed, 3 insertions(+) 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() From 6d2d4fd9918cbf91480990f971badf83f4bc0070 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Tue, 15 Oct 2024 15:03:22 -0500 Subject: [PATCH 06/20] feature: #76, clarify search results --- page_search.go | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/page_search.go b/page_search.go index 3981b01..1eab345 100644 --- a/page_search.go +++ b/page_search.go @@ -5,6 +5,7 @@ package main import ( "sort" + "strings" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -46,7 +47,7 @@ func (ui *Ui) createSearchPage() *SearchPage { searchPage.artistList = tview.NewList(). ShowSecondaryText(false) searchPage.artistList.Box. - SetTitle(" artist "). + SetTitle(" artist matches "). SetTitleAlign(tview.AlignLeft). SetBorder(true) @@ -54,7 +55,7 @@ func (ui *Ui) createSearchPage() *SearchPage { searchPage.albumList = tview.NewList(). ShowSecondaryText(false) searchPage.albumList.Box. - SetTitle(" album "). + SetTitle(" album matches "). SetTitleAlign(tview.AlignLeft). SetBorder(true) @@ -62,7 +63,7 @@ func (ui *Ui) createSearchPage() *SearchPage { searchPage.songList = tview.NewList(). ShowSecondaryText(false) searchPage.songList.Box. - SetTitle(" song "). + SetTitle(" song matches "). SetTitleAlign(tview.AlignLeft). SetBorder(true) @@ -211,6 +212,7 @@ func (ui *Ui) createSearchPage() *SearchPage { return &searchPage } +// TODO fork this off and search until there are no more results func (s *SearchPage) search() { if len(s.searchField.GetText()) == 0 { return @@ -223,17 +225,24 @@ func (s *SearchPage) search() { return } + query = strings.ToLower(query) for _, artist := range res.SearchResults.Artist { - s.artistList.AddItem(tview.Escape(artist.Name), "", 0, nil) - s.artists = append(s.artists, &artist) + if strings.Contains(strings.ToLower(artist.Name), query) { + 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.Name), "", 0, nil) - s.albums = append(s.albums, &album) + if strings.Contains(strings.ToLower(album.Name), query) { + s.albumList.AddItem(tview.Escape(album.Name), "", 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) + if strings.Contains(strings.ToLower(song.Title), query) { + s.songList.AddItem(tview.Escape(song.Title), "", 0, nil) + s.songs = append(s.songs, &song) + } } s.artistOffset += len(res.SearchResults.Artist) From c927ac0da03d86435a7761a5ec420b8ac3266bc9 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Tue, 15 Oct 2024 15:58:39 -0500 Subject: [PATCH 07/20] add: search now pro-actively searches until no more results are returned. It still queries in batches of 20, and updates the list(s) as results are available. --- README.md | 5 +-- help_text.go | 1 - page_search.go | 118 +++++++++++++++++++++++++------------------------ 3 files changed, 62 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 6edf6b2..984f51e 100644 --- a/README.md +++ b/README.md @@ -146,15 +146,12 @@ The default is `▉▊▋▌▍▎▏▎▍▌▋▊▉`. Set only one of these ### Search Controls -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. +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, where each entry matches the query in name or title. 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 diff --git a/help_text.go b/help_text.go index bea4e2e..0bc1e1d 100644 --- a/help_text.go +++ b/help_text.go @@ -53,7 +53,6 @@ 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_search.go b/page_search.go index 1eab345..4609a6c 100644 --- a/page_search.go +++ b/page_search.go @@ -28,10 +28,6 @@ type SearchPage struct { albums []*subsonic.Album songs []*subsonic.SubsonicEntity - artistOffset int - albumOffset int - songOffset int - // external refs ui *Ui logger logger.LoggerInterface @@ -104,9 +100,6 @@ func (ui *Ui) createSearchPage() *SearchPage { case '/': searchPage.ui.app.SetFocus(searchPage.searchField) return nil - case 'n': - searchPage.search() - return nil } return event @@ -134,9 +127,6 @@ func (ui *Ui) createSearchPage() *SearchPage { case '/': searchPage.ui.app.SetFocus(searchPage.searchField) return nil - case 'n': - searchPage.search() - return nil } return event @@ -165,13 +155,11 @@ func (ui *Ui) createSearchPage() *SearchPage { case '/': searchPage.ui.app.SetFocus(searchPage.searchField) return nil - case 'n': - searchPage.search() - 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: @@ -185,69 +173,85 @@ func (ui *Ui) createSearchPage() *SearchPage { ui.app.SetFocus(searchPage.artistList) } case tcell.KeyEnter: - searchPage.artistList.Clear() + search <- "" 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) - } + queryStr := searchPage.searchField.GetText() + search <- queryStr default: return event } return nil }) + go searchPage.search(search) return &searchPage } -// TODO fork this off and search until there are no more results -func (s *SearchPage) search() { - if len(s.searchField.GetText()) == 0 { - return - } - query := s.searchField.GetText() - - res, err := s.ui.connection.Search(query, s.artistOffset, s.albumOffset, s.songOffset) - if err != nil { - s.logger.PrintError("SearchPage.search", err) - return - } - - 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) +func (s *SearchPage) search(search chan string) { + var query string + var artOff, albOff, songOff int + more := make(chan bool, 5) + for { + // quit searching if we receive an interrupt + select { + case query = <-search: + artOff = 0 + albOff = 0 + songOff = 0 + s.logger.Printf("searching for %q [%d, %d, %d]", query, artOff, albOff, songOff) + for len(more) > 0 { + <-more + } + if query == "" { + continue + } + case <-more: + s.logger.Printf("fetching more %q [%d, %d, %d]", query, artOff, albOff, songOff) } - } - 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) + res, err := s.ui.connection.Search(query, artOff, albOff, songOff) + if err != nil { + s.logger.PrintError("SearchPage.search", err) + return } - } - 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) + // 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 } - } - s.artistOffset += len(res.SearchResults.Artist) - s.albumOffset += len(res.SearchResults.Album) - s.songOffset += len(res.SearchResults.Song) + 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) + } + } + 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) + } + } + 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) + } + } + }) + + artOff += len(res.SearchResults.Artist) + albOff += len(res.SearchResults.Album) + songOff += len(res.SearchResults.Song) + more <- true + } } func (s *SearchPage) addArtistToQueue(entity subsonic.Ider) { From 5ffbcba617def449dd6380fc63f29054a5b8604c Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Tue, 15 Oct 2024 16:16:38 -0500 Subject: [PATCH 08/20] add: search result counts in the column titles --- page_search.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/page_search.go b/page_search.go index 4609a6c..9c92433 100644 --- a/page_search.go +++ b/page_search.go @@ -4,6 +4,7 @@ package main import ( + "fmt" "sort" "strings" @@ -233,18 +234,21 @@ func (s *SearchPage) search(search chan string) { 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) From 6c022693c2ca3b8a4e001c6578f48c43abca047f Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Tue, 15 Oct 2024 16:48:38 -0500 Subject: [PATCH 09/20] Stupid git. --- page_search.go | 1 + 1 file changed, 1 insertion(+) diff --git a/page_search.go b/page_search.go index 9c92433..2e79fa4 100644 --- a/page_search.go +++ b/page_search.go @@ -175,6 +175,7 @@ func (ui *Ui) createSearchPage() *SearchPage { } case tcell.KeyEnter: search <- "" + searchPage.artistList.Clear() searchPage.artists = make([]*subsonic.Artist, 0) searchPage.albumList.Clear() searchPage.albums = make([]*subsonic.Album, 0) From dc0ac9a9d0950659feec76c402f6009d1cb7450f Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Wed, 16 Oct 2024 08:48:46 -0500 Subject: [PATCH 10/20] feat: #67, user can disable song info panel --- gui_handlers.go | 5 +- page_queue.go | 141 ++++++++++++++++++++++++++---------------------- 2 files changed, 81 insertions(+), 65 deletions(-) diff --git a/gui_handlers.go b/gui_handlers.go index 43da1a0..8252f66 100644 --- a/gui_handlers.go +++ b/gui_handlers.go @@ -7,6 +7,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/spezifisch/stmps/mpvplayer" "github.com/spezifisch/stmps/subsonic" + "github.com/spf13/viper" ) func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { @@ -118,7 +119,9 @@ func (ui *Ui) ShowPage(name string) { func (ui *Ui) Quit() { // TODO savePlayQueue/getPlayQueue - ui.queuePage.coverArtCache.Close() + if !viper.GetBool("ui.hide-song-info") { + ui.queuePage.coverArtCache.Close() + } ui.player.Quit() ui.app.Stop() } diff --git a/page_queue.go b/page_queue.go index f5078a4..d984194 100644 --- a/page_queue.go +++ b/page_queue.go @@ -19,6 +19,7 @@ import ( "github.com/spezifisch/stmps/logger" "github.com/spezifisch/stmps/mpvplayer" "github.com/spezifisch/stmps/subsonic" + "github.com/spf13/viper" ) // TODO show total # of entries somewhere (top?) @@ -70,15 +71,21 @@ func init() { } func (ui *Ui) createQueuePage() *QueuePage { - tmpl := template.New("song info").Funcs(template.FuncMap{ - "formatTime": func(i int) string { - return (time.Duration(i) * time.Second).String() - }, - }) - songInfoTemplate, err := tmpl.Parse(songInfoTemplateString) - if err != nil { - ui.logger.PrintError("createQueuePage", err) + addSongInfo := !viper.GetBool("ui.hide-song-info") + + var songInfoTemplate *template.Template + if addSongInfo { + tmpl := template.New("song info").Funcs(template.FuncMap{ + "formatTime": func(i int) string { + return (time.Duration(i) * time.Second).String() + }, + }) + var err error + if songInfoTemplate, err = tmpl.Parse(songInfoTemplateString); err != nil { + ui.logger.PrintError("createQueuePage", err) + } } + queuePage := QueuePage{ ui: ui, logger: ui.logger, @@ -120,77 +127,83 @@ func (ui *Ui) createQueuePage() *QueuePage { return nil }) - // Song info - queuePage.songInfo = tview.NewTextView() - queuePage.songInfo.SetDynamicColors(true).SetScrollable(true) - queuePage.queueList.SetSelectionChangedFunc(queuePage.changeSelection) - queuePage.coverArt = tview.NewImage() - queuePage.coverArt.SetImage(STMPS_LOGO) - - infoFlex := tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(queuePage.songInfo, 0, 1, false). - AddItem(queuePage.coverArt, 0, 1, false) - infoFlex.SetBorder(true) - infoFlex.SetTitle(" song info ") - - // flex wrapper - queuePage.Root = tview.NewFlex().SetDirection(tview.FlexColumn). - AddItem(queuePage.queueList, 0, 2, true). - AddItem(infoFlex, 0, 1, false) - // private data queuePage.queueData = queueData{ starIdList: ui.starIdList, } - queuePage.coverArtCache = NewCache( - // zero value - STMPS_LOGO, - // function that loads assets; can be slow - ui.connection.GetCoverArt, - // function that gets called when the actual asset is loaded - func(imgId string, img image.Image) { - row, _ := queuePage.queueList.GetSelection() - // If nothing is selected, set the image to the logo - if row >= len(queuePage.queueData.playerQueue) || row < 0 { + // flex wrapper + queuePage.Root = tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(queuePage.queueList, 0, 2, true) + + if addSongInfo { + // Song info + queuePage.songInfo = tview.NewTextView() + queuePage.songInfo.SetDynamicColors(true).SetScrollable(true) + + queuePage.coverArt = tview.NewImage() + queuePage.coverArt.SetImage(STMPS_LOGO) + + infoFlex := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(queuePage.songInfo, 0, 1, false). + AddItem(queuePage.coverArt, 0, 1, false) + infoFlex.SetBorder(true) + infoFlex.SetTitle(" song info ") + queuePage.Root.AddItem(infoFlex, 0, 1, false) + + queuePage.coverArtCache = NewCache( + // zero value + STMPS_LOGO, + // function that loads assets; can be slow + ui.connection.GetCoverArt, + // function that gets called when the actual asset is loaded + func(imgId string, img image.Image) { + row, _ := queuePage.queueList.GetSelection() + // If nothing is selected, set the image to the logo + if row >= len(queuePage.queueData.playerQueue) || row < 0 { + ui.app.QueueUpdate(func() { + queuePage.coverArt.SetImage(STMPS_LOGO) + }) + return + } + // If the fetched asset isn't the asset for the current song, + // just skip it. + currentSong := queuePage.queueData.playerQueue[row] + if currentSong.CoverArtId != imgId { + return + } + // Otherwise, the asset is for the current song, so update it ui.app.QueueUpdate(func() { - queuePage.coverArt.SetImage(STMPS_LOGO) + queuePage.coverArt.SetImage(img) }) - return - } - // If the fetched asset isn't the asset for the current song, - // just skip it. - currentSong := queuePage.queueData.playerQueue[row] - if currentSong.CoverArtId != imgId { - return - } - // Otherwise, the asset is for the current song, so update it - ui.app.QueueUpdate(func() { - queuePage.coverArt.SetImage(img) - }) - }, - // function called to check if asset is invalid: - // true if it can be purged from the cache, false if it's still needed - func(assetId string) bool { - for _, song := range queuePage.queueData.playerQueue { - if song.CoverArtId == assetId { - return false + }, + // function called to check if asset is invalid: + // true if it can be purged from the cache, false if it's still needed + func(assetId string) bool { + for _, song := range queuePage.queueData.playerQueue { + if song.CoverArtId == assetId { + return false + } } - } - // Didn't find a song that needs the asset; purge it. - return true - }, - // How frequently we check for invalid assets - time.Minute, - ui.logger, - ) + // Didn't find a song that needs the asset; purge it. + return true + }, + // How frequently we check for invalid assets + time.Minute, + ui.logger, + ) + } return &queuePage } func (q *QueuePage) changeSelection(row, column int) { + // If the user disabled song info, there's nothing to do + if q.songInfo == nil { + return + } q.songInfo.Clear() if row >= len(q.queueData.playerQueue) || row < 0 || column < 0 { q.coverArt.SetImage(STMPS_LOGO) From 6c02dd285d6a5bf2bd389228cc95130a12f96ac0 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Wed, 16 Oct 2024 08:57:30 -0500 Subject: [PATCH 11/20] feat: #67, updates readme for how to disable the panel --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 6edf6b2..b703d21 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,15 @@ random-songs = 50 spinner = '▁▂▃▄▅▆▇█▇▆▅▄▃▂▁' ``` +The song info panel on the queue takes up space, and memory for album art. You can disable this panel with: + +```toml +[ui] +hide-info-panel = true +``` + +If the panel is hidden, no album art will be fetched from the server. + ## Usage ### General Navigation From 7f0335c7912610c6fbe91a16bcc40c2de6756c67 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Thu, 17 Oct 2024 11:06:33 -0500 Subject: [PATCH 12/20] fix: user interactions in search page with no results resulting in NPE. --- page_search.go | 62 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/page_search.go b/page_search.go index 3981b01..48194d9 100644 --- a/page_search.go +++ b/page_search.go @@ -89,17 +89,23 @@ func (ui *Ui) createSearchPage() *SearchPage { ui.app.SetFocus(searchPage.albumList) return nil case tcell.KeyEnter: - idx := searchPage.artistList.GetCurrentItem() - searchPage.addArtistToQueue(searchPage.artists[idx]) - return nil + if len(searchPage.artists) != 0 { + idx := searchPage.artistList.GetCurrentItem() + searchPage.addArtistToQueue(searchPage.artists[idx]) + return nil + } + return event } 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 + if len(searchPage.artists) != 0 { + idx := searchPage.artistList.GetCurrentItem() + searchPage.logger.Printf("artistList adding (%d) %s", idx, searchPage.artists[idx].Name) + searchPage.addArtistToQueue(searchPage.artists[idx]) + return nil + } + return event case '/': searchPage.ui.app.SetFocus(searchPage.searchField) return nil @@ -119,17 +125,23 @@ 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 len(searchPage.albums) != 0 { + idx := searchPage.albumList.GetCurrentItem() + searchPage.addAlbumToQueue(searchPage.albums[idx]) + return nil + } + return event } 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 + if len(searchPage.albums) != 0 { + idx := searchPage.albumList.GetCurrentItem() + searchPage.logger.Printf("albumList adding (%d) %s", idx, searchPage.albums[idx].Name) + searchPage.addAlbumToQueue(searchPage.albums[idx]) + return nil + } + return event case '/': searchPage.ui.app.SetFocus(searchPage.searchField) return nil @@ -149,18 +161,24 @@ func (ui *Ui) createSearchPage() *SearchPage { ui.app.SetFocus(searchPage.artistList) return nil case tcell.KeyEnter: - idx := searchPage.songList.GetCurrentItem() - ui.addSongToQueue(searchPage.songs[idx]) - ui.queuePage.UpdateQueue() - return nil + if len(searchPage.artists) != 0 { + idx := searchPage.songList.GetCurrentItem() + ui.addSongToQueue(searchPage.songs[idx]) + ui.queuePage.UpdateQueue() + return nil + } + return event } switch event.Rune() { case 'a': - idx := searchPage.songList.GetCurrentItem() - ui.addSongToQueue(searchPage.songs[idx]) - ui.queuePage.updateQueue() - return nil + if len(searchPage.artists) != 0 { + idx := searchPage.songList.GetCurrentItem() + ui.addSongToQueue(searchPage.songs[idx]) + ui.queuePage.updateQueue() + return nil + } + return event case '/': searchPage.ui.app.SetFocus(searchPage.searchField) return nil From 977389926d5c8217644e5f9f31a9b7868e13a735 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Fri, 18 Oct 2024 10:39:16 -0500 Subject: [PATCH 13/20] feat: #52, search by genre --- README.md | 3 + help_text.go | 1 + page_playlist.go | 3 + page_search.go | 158 +++++++++++++++++++++++++++++++++++++---------- subsonic/api.go | 39 ++++++++++++ 5 files changed, 170 insertions(+), 34 deletions(-) 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 +} From a53c7004d60758de0341766f8b21c60bf612131b Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Fri, 18 Oct 2024 10:53:58 -0500 Subject: [PATCH 14/20] fix: clarifies search help text --- help_text.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/help_text.go b/help_text.go index 7762e43..663a464 100644 --- a/help_text.go +++ b/help_text.go @@ -46,14 +46,18 @@ a add playlist or song to queue ` const helpSearchPage = ` -artist, album, or song tab +artist, album/genre, 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 - g toggle genre search / start search + g toggle genre search +In album tab + Enter/a recursively add item to quue +In genre tab + Enter shows songs with genre +In song tab + Enter/a adds song to queue search field Enter search for text ` From 25aded6e7ea44673649af254a3df6fda7900a039 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Fri, 18 Oct 2024 11:43:15 -0500 Subject: [PATCH 15/20] feat: add entire genre; show item counts in queue and genre columns --- gui_handlers.go | 4 ++- help_text.go | 5 ++-- mpvplayer/player.go | 4 +-- mpvplayer/queue_item.go | 5 ++++ page_queue.go | 2 ++ page_search.go | 55 +++++++++++++++++++++++++++++------------ subsonic/api.go | 1 + 7 files changed, 54 insertions(+), 22 deletions(-) diff --git a/gui_handlers.go b/gui_handlers.go index 14ff0cb..2fab0bb 100644 --- a/gui_handlers.go +++ b/gui_handlers.go @@ -173,6 +173,7 @@ func (ui *Ui) addSongToQueue(entity *subsonic.SubsonicEntity) { TrackNumber: entity.Track, CoverArtId: entity.CoverArtId, DiscNumber: entity.DiscNumber, + Year: entity.Year, } ui.player.AddToQueue(queueItem) } @@ -188,6 +189,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 := "" @@ -205,7 +207,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 663a464..e736094 100644 --- a/help_text.go +++ b/help_text.go @@ -51,13 +51,12 @@ artist, album/genre, or song tab Left previous column Right next column / start search + a recursively add item to quue g toggle genre search In album tab - Enter/a recursively add item to quue + Enter recursively add item to quue In genre tab Enter shows songs with genre -In song tab - Enter/a adds song to queue search field Enter search for text ` diff --git a/mpvplayer/player.go b/mpvplayer/player.go index fb13811..8ae9983 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_queue.go b/page_queue.go index a7b12e7..f306f3d 100644 --- a/page_queue.go +++ b/page_queue.go @@ -241,6 +241,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) } @@ -443,6 +444,7 @@ var songInfoTemplateString = `[blue::b]Title:[-:-:-:-] [green::i]{{.Title}}[-:-: [blue::b]Artist:[-:-:-:-] [::i]{{.Artist}}[-:-:-:-] [blue::b]Album:[-:-:-:-] [::i]{{.GetAlbum}}[-:-:-:-] [blue::b]Disc:[-:-:-:-] [::i]{{.GetDiscNumber}}[-:-:-:-] +[blue::b]Year:[-:-:-:-] [::i]{{.GetYear}}[-:-:-:-] [blue::b]Track:[-:-:-:-] [::i]{{.GetTrackNumber}}[-:-:-:-] [blue::b]Duration:[-:-:-:-] [::i]{{formatTime .Duration}}[-:-:-:-] ` diff --git a/page_search.go b/page_search.go index c0d6012..f2cf4b6 100644 --- a/page_search.go +++ b/page_search.go @@ -108,8 +108,8 @@ func (ui *Ui) createSearchPage() *SearchPage { if searchPage.queryGenre { searchPage.albumList.SetTitle(" album matches ") } else { - searchPage.albumList.SetTitle(" genres ") searchPage.populateGenres() + searchPage.albumList.SetTitle(fmt.Sprintf(" genres (%d) ", searchPage.albumList.GetItemCount())) searchPage.ui.app.SetFocus(searchPage.albumList) } searchPage.queryGenre = !searchPage.queryGenre @@ -150,7 +150,12 @@ func (ui *Ui) createSearchPage() *SearchPage { switch event.Rune() { case 'a': if searchPage.queryGenre { - return event + idx := searchPage.albumList.GetCurrentItem() + if idx < searchPage.albumList.GetItemCount() { + genre, _ := searchPage.albumList.GetItemText(idx) + searchPage.addGenreToQueue(genre) + } + return nil } idx := searchPage.albumList.GetCurrentItem() searchPage.logger.Printf("albumList adding (%d) %s", idx, searchPage.albums[idx].Name) @@ -163,8 +168,8 @@ func (ui *Ui) createSearchPage() *SearchPage { if searchPage.queryGenre { searchPage.albumList.SetTitle(" album matches ") } else { - searchPage.albumList.SetTitle(" genres ") searchPage.populateGenres() + searchPage.albumList.SetTitle(fmt.Sprintf(" genres (%d) ", searchPage.albumList.GetItemCount())) searchPage.ui.app.SetFocus(searchPage.albumList) } searchPage.queryGenre = !searchPage.queryGenre @@ -201,8 +206,8 @@ func (ui *Ui) createSearchPage() *SearchPage { if searchPage.queryGenre { searchPage.albumList.SetTitle(" album matches ") } else { - searchPage.albumList.SetTitle(" genres ") searchPage.populateGenres() + searchPage.albumList.SetTitle(fmt.Sprintf(" genres (%d) ", searchPage.albumList.GetItemCount())) searchPage.ui.app.SetFocus(searchPage.albumList) } searchPage.queryGenre = !searchPage.queryGenre @@ -250,6 +255,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 { @@ -265,11 +272,11 @@ func (s *SearchPage) search(search chan string) { } case <-more: } - var res *subsonic.SubsonicResponse - var err error 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 { @@ -286,19 +293,19 @@ func (s *SearchPage) search(search chan string) { return } - query = strings.ToLower(query) s.ui.app.QueueUpdate(func() { if s.queryGenre { if songOff == 0 { s.artistList.Box.SetTitle(" artist matches ") - s.albumList.Box.SetTitle(" genres ") } 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) @@ -320,21 +327,37 @@ func (s *SearchPage) search(search chan string) { } } 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) } + more <- true }) - 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 } } +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) { response, err := s.ui.connection.GetArtist(entity.ID()) if err != nil { diff --git a/subsonic/api.go b/subsonic/api.go index 20d5482..edbf235 100644 --- a/subsonic/api.go +++ b/subsonic/api.go @@ -187,6 +187,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 { From 7c20add91b8d24bc35e6a8da9cadce8a31f110b6 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Sun, 20 Oct 2024 20:26:00 -0500 Subject: [PATCH 16/20] Merge --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ page_search.go | 39 +++++++++++++-------------------------- 2 files changed, 56 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e65cf63..703b7af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ All notable changes to this project will be documented in this file. ## [unreleased] +### 🚀 Features + +- #76, clarify search results +- #67, user can disable song info panel +- #67, updates readme for how to disable the panel +- #52, search by genre +- Add entire genre; show item counts in queue and genre columns + +### 🐛 Bug Fixes + +- Second edge case of #33 +- User interactions in search page with no results resulting in NPE. +- Clarifies search help text + +### ⚙️ Miscellaneous Tasks + +- Add screenshotter which maybe works +- Typo +- Merge screenshot job into build job +- Download binary of termshot +- Fix leftover +- Remove if-arm64 +- Fix build +- Fix if cond +- Typo + +### Add + +- Search now pro-actively searches until no more results are returned. It still queries in batches of 20, and updates the list(s) as results are available. +- Search result counts in the column titles +- Fetch version information from build info + +## [0.1.0] - 2024-10-15 + +### 🐛 Bug Fixes + +- #33 selected entry gets stuck +- Second edge case of #33 +- Readme linting errors from last commit +- Makefile rule was never updating the change log + +## [0.0.9] - 2024-09-12 + ### 🐛 Bug Fixes - Mpris not implementing the right interface diff --git a/page_search.go b/page_search.go index d419c68..164bdda 100644 --- a/page_search.go +++ b/page_search.go @@ -137,18 +137,13 @@ func (ui *Ui) createSearchPage() *SearchPage { ui.app.SetFocus(searchPage.songList) return nil case tcell.KeyEnter: -<<<<<<< HEAD - if len(searchPage.albums) != 0 { - idx := searchPage.albumList.GetCurrentItem() - searchPage.addAlbumToQueue(searchPage.albums[idx]) - return nil - } - return event -======= if !searchPage.queryGenre { - idx := searchPage.albumList.GetCurrentItem() - searchPage.addAlbumToQueue(searchPage.albums[idx]) - return nil + if len(searchPage.albums) != 0 { + idx := searchPage.albumList.GetCurrentItem() + searchPage.addAlbumToQueue(searchPage.albums[idx]) + return nil + } + return event } else { search <- "" searchPage.artistList.Clear() @@ -162,20 +157,10 @@ func (ui *Ui) createSearchPage() *SearchPage { search <- queryStr return nil } ->>>>>>> feat-52-search-by-genre } switch event.Rune() { case 'a': -<<<<<<< HEAD - if len(searchPage.albums) != 0 { - idx := searchPage.albumList.GetCurrentItem() - searchPage.logger.Printf("albumList adding (%d) %s", idx, searchPage.albums[idx].Name) - searchPage.addAlbumToQueue(searchPage.albums[idx]) - return nil - } - return event -======= if searchPage.queryGenre { idx := searchPage.albumList.GetCurrentItem() if idx < searchPage.albumList.GetItemCount() { @@ -184,11 +169,13 @@ func (ui *Ui) createSearchPage() *SearchPage { } return nil } - idx := searchPage.albumList.GetCurrentItem() - searchPage.logger.Printf("albumList adding (%d) %s", idx, searchPage.albums[idx].Name) - searchPage.addAlbumToQueue(searchPage.albums[idx]) - return nil ->>>>>>> feat-52-search-by-genre + if len(searchPage.albums) != 0 { + idx := searchPage.albumList.GetCurrentItem() + searchPage.logger.Printf("albumList adding (%d) %s", idx, searchPage.albums[idx].Name) + searchPage.addAlbumToQueue(searchPage.albums[idx]) + return nil + } + return event case '/': searchPage.ui.app.SetFocus(searchPage.searchField) return nil From aa5584aa205cb935c7c231e1f390efba7d4f3348 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Mon, 21 Oct 2024 09:07:46 -0500 Subject: [PATCH 17/20] change: re-arranges the song info; purely aesthic chang --- page_queue.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/page_queue.go b/page_queue.go index 06a0e3f..b97a730 100644 --- a/page_queue.go +++ b/page_queue.go @@ -442,12 +442,12 @@ func (q *queueData) GetColumnCount() int { return queueDataColumns } -var songInfoTemplateString = `[blue::b]Title:[-:-:-:-] [green::i]{{.Title}}[-:-:-:-] +var songInfoTemplateString = `[blue::b]Title:[-:-:-:-] [green::i]{{.Title}}[-:-:-:-] [yellow::i]({{formatTime .Duration}})[-:-:-:-] [blue::b]Artist:[-:-:-:-] [::i]{{.Artist}}[-:-:-:-] [blue::b]Album:[-:-:-:-] [::i]{{.GetAlbum}}[-:-:-:-] -[blue::b]Disc:[-:-:-:-] [::i]{{.GetDiscNumber}}[-:-:-:-] -[blue::b]Track:[-:-:-:-] [::i]{{.GetTrackNumber}}[-:-:-:-] -[blue::b]Duration:[-:-:-:-] [::i]{{formatTime .Duration}}[-:-:-:-] ` +[blue::b]Disc:[-:-:-:-] [::i]{{.GetDiscNumber}}[-:-:-:-] [blue::b]Track:[-:-:-:-] [::i]{{.GetTrackNumber}}[-:-:-:-] +[blue::b]Year:[-:-:-:-] [::i]{{.GetYear}}[-:-:-:-] +` //go:embed docs/stmps_logo.png var _stmps_logo []byte From d2b678687ab82e12395fc8fe5b5c1202e8b19865 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Wed, 18 Dec 2024 17:52:22 -0600 Subject: [PATCH 18/20] Re-adding everything from the lyrics branch was easier than merging. Because git sucks. --- event_loop.go | 35 +++++++++++++++++++++++++++++ page_queue.go | 23 ++++++++++++++++++++ subsonic/api.go | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/event_loop.go b/event_loop.go index ded9789..27518b2 100644 --- a/event_loop.go +++ b/event_loop.go @@ -65,12 +65,42 @@ func (ui *Ui) guiEventLoop() { ui.app.QueueUpdateDraw(func() { ui.playerStatus.SetText(formatPlayerStatus(statusData.Volume, statusData.Position, statusData.Duration)) + if ui.queuePage.lyrics != nil { + cl := ui.queuePage.currentLyrics.Lines + lcl := len(cl) + if lcl == 0 { + ui.queuePage.lyrics.SetText("\n[::i]No lyrics[-:-:-]") + } else { + // We only get an update every second or so, and Position is truncated + // to seconds. Make sure that, by the time our tick comes, we're already showing + // the lyric that's being sung. Do this by pretending that we're a half-second + // in the future + p := statusData.Position*1000 + 500 + _, _, _, fh := ui.queuePage.lyrics.GetInnerRect() + ui.logger.Printf("field height is %d", fh) + for i := 0; i < lcl-1; i++ { + if p >= cl[i].Start && p < cl[i+1].Start { + txt := "" + if i > 0 { + txt = cl[i-1].Value + "\n" + } + txt += "[::b]" + cl[i].Value + "[-:-:-]\n" + for k := i + 1; k < lcl && k-i < fh; k++ { + txt += cl[k].Value + "\n" + } + ui.queuePage.lyrics.SetText(txt) + break + } + } + } + } }) case mpvplayer.EventStopped: ui.logger.Print("mpvEvent: stopped") ui.app.QueueUpdateDraw(func() { ui.startStopStatus.SetText("[red::b]Stopped[::-]") + ui.queuePage.lyrics.SetText("") ui.queuePage.UpdateQueue() }) @@ -115,6 +145,11 @@ func (ui *Ui) guiEventLoop() { ui.app.QueueUpdateDraw(func() { ui.startStopStatus.SetText(statusText) ui.queuePage.UpdateQueue() + if len(ui.queuePage.currentLyrics.Lines) == 0 { + ui.queuePage.lyrics.SetText("\n[::i]No lyrics[-:-:-]") + } else { + ui.queuePage.lyrics.SetText("") + } }) case mpvplayer.EventPaused: diff --git a/page_queue.go b/page_queue.go index b681f8f..0c999db 100644 --- a/page_queue.go +++ b/page_queue.go @@ -47,8 +47,11 @@ type QueuePage struct { queueData queueData songInfo *tview.TextView + lyrics *tview.TextView coverArt *tview.Image + currentLyrics subsonic.StructuredLyrics + // external refs ui *Ui logger logger.LoggerInterface @@ -146,11 +149,21 @@ func (ui *Ui) createQueuePage() *QueuePage { return action, nil }) + queuePage.lyrics = tview.NewTextView() + queuePage.lyrics.SetBorder(true) + queuePage.lyrics.SetTitle(" lyrics ") + queuePage.lyrics.SetTitleAlign(tview.AlignCenter) + queuePage.lyrics.SetDynamicColors(true).SetScrollable(true) + queuePage.lyrics.SetWrap(true) + queuePage.lyrics.SetWordWrap(true) + queuePage.lyrics.SetBorderPadding(1, 1, 1, 1) + queuePage.coverArt = tview.NewImage() queuePage.coverArt.SetImage(STMPS_LOGO) infoFlex := tview.NewFlex().SetDirection(tview.FlexRow). AddItem(queuePage.songInfo, 0, 1, false). + AddItem(queuePage.lyrics, 0, 1, false). AddItem(queuePage.coverArt, 0, 1, false) infoFlex.SetBorder(true) infoFlex.SetTitle(" song info ") @@ -168,6 +181,7 @@ func (ui *Ui) createQueuePage() *QueuePage { if row >= len(queuePage.queueData.playerQueue) || row < 0 { ui.app.QueueUpdate(func() { queuePage.coverArt.SetImage(STMPS_LOGO) + queuePage.lyrics.SetText("") }) return } @@ -180,6 +194,15 @@ func (ui *Ui) createQueuePage() *QueuePage { // Otherwise, the asset is for the current song, so update it ui.app.QueueUpdate(func() { queuePage.coverArt.SetImage(img) + lyrics, err := queuePage.ui.connection.GetLyricsBySongId(currentSong.Id) + if err != nil { + queuePage.logger.Printf("error fetching lyrics for %s: %v", currentSong.Title, err) + } else if len(lyrics) > 0 { + queuePage.logger.Printf("got lyrics for %s", currentSong.Title) + queuePage.currentLyrics = lyrics[0] + } else { + queuePage.currentLyrics = subsonic.StructuredLyrics{Lines: []subsonic.LyricsLine{}} + } }) }, // function called to check if asset is invalid: diff --git a/subsonic/api.go b/subsonic/api.go index a1f75ea..e753890 100644 --- a/subsonic/api.go +++ b/subsonic/api.go @@ -282,6 +282,7 @@ type SubsonicResponse struct { ScanStatus ScanStatus `json:"scanStatus"` Genres GenreEntries `json:"genres"` SongsByGenre SubsonicSongs `json:"songsByGenre"` + LyricsList LyricsList `json:"lyricsList"` } type responseWrapper struct { @@ -436,6 +437,48 @@ func (connection *SubsonicConnection) GetCoverArt(id string) (image.Image, error return art, err } +// GetLyricsBySongId fetches time synchronized song lyrics. If the server does +// not support this, an error is returned. +func (connection *SubsonicConnection) GetLyricsBySongId(id string) ([]StructuredLyrics, error) { + if id == "" { + return []StructuredLyrics{}, fmt.Errorf("GetLyricsBySongId: no ID provided") + } + query := defaultQuery(connection) + query.Set("id", id) + query.Set("f", "json") + caller := "GetLyricsBySongId" + res, err := http.Get(connection.Host + "/rest/getLyricsBySongId" + "?" + query.Encode()) + if err != nil { + return []StructuredLyrics{}, fmt.Errorf("[%s] failed to make GET request: %v", caller, err) + } + + if res.Body != nil { + defer res.Body.Close() + } else { + return []StructuredLyrics{}, fmt.Errorf("[%s] response body is nil", caller) + } + + if res.StatusCode != http.StatusOK { + return []StructuredLyrics{}, fmt.Errorf("[%s] unexpected status code: %d, status: %s", caller, res.StatusCode, res.Status) + } + + if len(res.Header["Content-Type"]) == 0 { + return []StructuredLyrics{}, fmt.Errorf("[%s] unknown image type (no content-type from server)", caller) + } + + responseBody, readErr := io.ReadAll(res.Body) + if readErr != nil { + return []StructuredLyrics{}, fmt.Errorf("[%s] failed to read response body: %v", caller, readErr) + } + + var decodedBody responseWrapper + err = json.Unmarshal(responseBody, &decodedBody) + if err != nil { + return []StructuredLyrics{}, fmt.Errorf("[%s] failed to unmarshal response body: %v", caller, err) + } + return decodedBody.Response.LyricsList.StructuredLyrics, nil +} + func (connection *SubsonicConnection) GetRandomSongs(Id string, randomType string) (*SubsonicResponse, error) { query := defaultQuery(connection) @@ -693,3 +736,18 @@ func (connection *SubsonicConnection) GetSongsByGenre(genre string, offset int, } return resp, nil } + +type LyricsList struct { + StructuredLyrics []StructuredLyrics `json:"structuredLyrics"` +} + +type StructuredLyrics struct { + Lang string `json:"lang"` + Synced bool `json:"synced"` + Lines []LyricsLine `json:"line"` +} + +type LyricsLine struct { + Start int64 `json:"start"` + Value string `json:"value"` +} From 30335eeca26f0a56152ed984a0eb87791b49e614 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Wed, 18 Dec 2024 18:02:14 -0600 Subject: [PATCH 19/20] change: readme to list the PRs in this branch that are waiting for upstream merging --- CHANGELOG.md | 26 +++++--------------------- README.md | 9 +++++++++ 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 703b7af..8361804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to this project will be documented in this file. ### 🐛 Bug Fixes +- #33 selected entry gets stuck +- Second edge case of #33 - Second edge case of #33 - User interactions in search page with no results resulting in NPE. - Clarifies search help text @@ -36,27 +38,9 @@ All notable changes to this project will be documented in this file. - Search result counts in the column titles - Fetch version information from build info -## [0.1.0] - 2024-10-15 - -### 🐛 Bug Fixes - -- #33 selected entry gets stuck -- Second edge case of #33 -- Readme linting errors from last commit -- Makefile rule was never updating the change log - -## [0.0.9] - 2024-09-12 - -### 🐛 Bug Fixes - -- Mpris not implementing the right interface - -### ⚙️ Miscellaneous Tasks - -- Rename mpris player (Player -> stmps) - -### Queue +### Change -- Fix scroll behaviour, unexport some methods +- Re-arranges the song info; purely aesthic chang +- Readme to list the PRs in this branch that are waiting for upstream merging diff --git a/README.md b/README.md index 164a5a5..a9bcd27 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,15 @@ Dev Branch: [![Build+Test Linux](https://github.com/spezifisch/stmps/actions/workflows/build-linux.yml/badge.svg?branch=dev)](https://github.com/spezifisch/stmps/actions/workflows/build-linux.yml) [![Build+Test macOS](https://github.com/spezifisch/stmps/actions/workflows/build-macos.yml/badge.svg?branch=dev)](https://github.com/spezifisch/stmps/actions/workflows/build-macos.yml) +## xxxserxxx merged branches/features + +These are PRs not yet merged upstream. + +- Synced lyrics support (lyrics) +- A minor, aesthic change that compacts the song info panel a bit (compact-songinfo) +- Search by genre support (feat-52-search-by-genre) +- Load album art concurrently, in the background, to eliminate lags on slow servers (concurrent-album-art) + ## Features - Browse by folder From 83c8a0e4c82bd0f9908b0eeb9c67365073379a4a Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Thu, 19 Dec 2024 10:48:55 -0600 Subject: [PATCH 20/20] Updates the README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 663d7a3..d95b78a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ These are PRs not yet merged upstream. - A minor, aesthic change that compacts the song info panel a bit (compact-songinfo) - Search by genre support (feat-52-search-by-genre) - Load album art concurrently, in the background, to eliminate lags on slow servers (concurrent-album-art) +- Ability to toggle info panel with `i` ## Features