From cfa45a4647bcd1f0f75d31019e2d380fa215f8ca Mon Sep 17 00:00:00 2001 From: tdakkota Date: Tue, 21 Apr 2026 05:36:11 +0300 Subject: [PATCH] feat(ipc): add an endpoint for getting current track (#911) --- backend/app.go | 6 +++ backend/cmdlineoptions.go | 1 + backend/ipc/api.go | 1 + backend/ipc/client.go | 4 ++ backend/ipc/server.go | 44 +++++++++++++++---- backend/mediaprovider/model.go | 26 +++++++++-- backend/mediaprovider/subsonic/searchall.go | 4 +- .../subsonic/subsonicmediaprovider.go | 2 +- backend/playbackmanager.go | 20 ++++++++- ui/browsing/radiospage.go | 4 +- 10 files changed, 94 insertions(+), 18 deletions(-) diff --git a/backend/app.go b/backend/app.go index 77769e3..e9593fa 100644 --- a/backend/app.go +++ b/backend/app.go @@ -717,6 +717,12 @@ func (a *App) checkFlagsAndSendIPCMsg(cli *ipc.Client) error { return cli.Show() case *FlagReloadTheme: return cli.ReloadTheme() + case *FlagCurrentTrack: + data, err := cli.CurrentTrack() + if err == nil { + fmt.Println(data) + } + return err case VolumeCLIArg >= 0: return cli.SetVolume(VolumeCLIArg) case VolumePctCLIArg != 0: diff --git a/backend/cmdlineoptions.go b/backend/cmdlineoptions.go index a06b970..dfb6490 100644 --- a/backend/cmdlineoptions.go +++ b/backend/cmdlineoptions.go @@ -34,6 +34,7 @@ var ( FlagShow = flag.Bool("show", false, "show minimized app") FlagReloadTheme = flag.Bool("reload-theme", false, "reload the current theme") FlagShuffle = flag.Bool("shuffle", false, "shuffle the tracklist (to be used with either -play-album-by-id or -play-playlist-by-id)") + FlagCurrentTrack = flag.Bool("current-track", false, "print current track metadata as JSON") FlagVersion = flag.Bool("version", false, "print app version and exit") FlagHelp = flag.Bool("help", false, "print command line options and exit") diff --git a/backend/ipc/api.go b/backend/ipc/api.go index 4038475..0abf674 100644 --- a/backend/ipc/api.go +++ b/backend/ipc/api.go @@ -28,6 +28,7 @@ const ( ShowPath = "/window/show" ReloadThemePath = "/window/reload-theme" QuitPath = "/window/quit" + CurrentTrackPath = "/current_track" RateCurrentTrackPath = "/current_track/rate" // ?r= ) diff --git a/backend/ipc/client.go b/backend/ipc/client.go index 1656ca3..b7e23bd 100644 --- a/backend/ipc/client.go +++ b/backend/ipc/client.go @@ -118,6 +118,10 @@ func (c *Client) AdjustVolumePct(pct float64) error { return err } +func (c *Client) CurrentTrack() (string, error) { + return c.sendRequest(CurrentTrackPath) +} + func (c *Client) RateCurrentTrack(rating int) error { _, err := c.sendRequest(BuildRateCurrentTrackPath(rating)) return err diff --git a/backend/ipc/server.go b/backend/ipc/server.go index 0f8d61a..4c43bee 100644 --- a/backend/ipc/server.go +++ b/backend/ipc/server.go @@ -29,6 +29,7 @@ type PlaybackHandler interface { PlayAlbum(string, int, bool) error PlayPlaylist(string, int, bool) error PlayTrack(string) error + NowPlaying() mediaprovider.MediaItem } type IPCServer interface { @@ -41,16 +42,21 @@ type ServerManager interface { } type serverImpl struct { - server *http.Server - pbHandler PlaybackHandler - rateFn func(int) - sm ServerManager - showFn func() - quitFn func() - reloadThemeFn func() + server *http.Server + pbHandler PlaybackHandler + rateFn func(int) + sm ServerManager + showFn func() + quitFn func() + reloadThemeFn func() } -func NewServer(pbHandler PlaybackHandler, rateFn func(int), sm ServerManager, showFn, quitFn, reloadThemeFn func()) IPCServer { +func NewServer( + pbHandler PlaybackHandler, + rateFn func(int), + sm ServerManager, + showFn, quitFn, reloadThemeFn func(), +) IPCServer { s := &serverImpl{pbHandler: pbHandler, rateFn: rateFn, sm: sm, showFn: showFn, quitFn: quitFn, reloadThemeFn: reloadThemeFn} s.server = &http.Server{ Handler: s.createHandler(), @@ -175,6 +181,13 @@ func (s *serverImpl) createHandler() http.Handler { return tracks, nil })) + m.HandleFunc(CurrentTrackPath, s.makeStatusEndpointHandler(func() (any, error) { + track := s.pbHandler.NowPlaying() + if track == nil { + return nil, errors.New("nothing is playing right now") + } + return track.Metadata(), nil + })) m.HandleFunc(RateCurrentTrackPath, func(w http.ResponseWriter, r *http.Request) { v := r.URL.Query().Get("r") if rating, err := strconv.Atoi(v); err == nil { @@ -231,6 +244,21 @@ func (s *serverImpl) makeTracklistEndpointHandler(f func(string, int, bool) erro } } +func (s *serverImpl) makeStatusEndpointHandler(f func() (any, error)) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + data, err := f() + if err != nil { + s.writeErr(w, err) + return + } + bytes, err := json.Marshal(data) + if err != nil { + s.writeErr(w, err) + } + s.writeData(w, bytes) + } +} + func (s *serverImpl) makeSearchEndpointHandler(f func(string) (any, error)) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { search := r.URL.Query().Get("s") diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 734b060..4194ae9 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -1,6 +1,7 @@ package mediaprovider import ( + "slices" "time" "fyne.io/fyne/v2" @@ -205,11 +206,14 @@ type SavedPlayQueue struct { } type RadioStation struct { - Name string ID string + StationName string HomePageURL string StreamURL string - CoverArtID string + + Title string + Artists []string + CoverArtID string } type MediaItemType int @@ -267,16 +271,30 @@ func (r *RadioStation) Metadata() MediaItemMetadata { if r == nil { return MediaItemMetadata{} } + name := r.Title + if name == "" { + name = r.StationName + } return MediaItemMetadata{ Type: MediaItemTypeRadioStation, ID: r.ID, - Name: r.Name, + Name: name, CoverArtID: r.CoverArtID, + Artists: r.Artists, } } func (r *RadioStation) Copy() MediaItem { - return r // no need to copy since RadioStations are immutable + return &RadioStation{ + ID: r.ID, + StationName: r.StationName, + HomePageURL: r.HomePageURL, + StreamURL: r.StreamURL, + + Title: r.Title, + Artists: slices.Clone(r.Artists), + CoverArtID: r.CoverArtID, + } } type ContentType int diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go index c071ca0..24e01d4 100644 --- a/backend/mediaprovider/subsonic/searchall.go +++ b/backend/mediaprovider/subsonic/searchall.go @@ -70,7 +70,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ r, e := s.GetRadioStations() if e == nil { radios = sharedutil.FilterSlice(r, func(r *mediaprovider.RadioStation) bool { - return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(r.Name)), queryLowerWords) + return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(r.StationName)), queryLowerWords) }) } wg.Done() @@ -156,7 +156,7 @@ func mergeResults( results = append(results, &mediaprovider.SearchResult{ Type: mediaprovider.ContentTypeRadioStation, ID: r.ID, - Name: r.Name, + Name: r.StationName, Item: r, }) } diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index 401736c..3d14b35 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -505,7 +505,7 @@ func (s *subsonicMediaProvider) GetRadioStations() ([]*mediaprovider.RadioStatio return &mediaprovider.RadioStation{ // TODO - subsonic library is missing ID in its radiostation object. add it ID: "radio-" + strings.ReplaceAll(rs.Name, " ", ""), - Name: rs.Name, + StationName: rs.Name, HomePageURL: rs.HomePageUrl, StreamURL: rs.StreamUrl, CoverArtID: rs.CoverArt, diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 7ab190c..b13e599 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -47,6 +47,11 @@ type PlaybackManager struct { // whether autoplay tracks are currently being fetched/enqueued pendingAutoplay bool wasLoadTrackPaused bool + + // current radio metadata + radioStationName string + radioIcyTitle string + radioIcyArtist string } type RemotePlaybackDevice struct { @@ -157,6 +162,11 @@ func (p *PlaybackManager) addOnTrackChangeHook() { }() } }) + p.OnRadioMetadataChange(func(radioName, title, artist string) { + p.radioStationName = radioName + p.radioIcyTitle = title + p.radioIcyArtist = artist + }) } func (p *PlaybackManager) handleWaveformImageSongChange(item mediaprovider.MediaItem) { @@ -320,7 +330,15 @@ func (p *PlaybackManager) DisableCallbacks() { // Gets the now playing media item, if any. func (p *PlaybackManager) NowPlaying() mediaprovider.MediaItem { - return p.engine.NowPlaying() + item := p.engine.NowPlaying() + if station, ok := item.(*mediaprovider.RadioStation); ok { + station = station.Copy().(*mediaprovider.RadioStation) + station.StationName = p.radioStationName + station.Title = p.radioIcyTitle + station.Artists = []string{p.radioIcyArtist} + item = station + } + return item } func (p *PlaybackManager) NowPlayingIndex() int { diff --git a/ui/browsing/radiospage.go b/ui/browsing/radiospage.go index 543a81c..baa3489 100644 --- a/ui/browsing/radiospage.go +++ b/ui/browsing/radiospage.go @@ -122,7 +122,7 @@ func (a *RadiosPage) onSearched(query string) { } else { query = strings.ToLower(query) result := sharedutil.FilterSlice(a.radios, func(x *mediaprovider.RadioStation) bool { - return strings.Contains(strings.ToLower(x.Name), query) + return strings.Contains(strings.ToLower(x.StationName), query) }) a.list.SetRadios(result) } @@ -288,7 +288,7 @@ func NewRadioList(nowPlayingIDPtr *string) *RadioList { row.EnsureUnfocused() row.ListItemID = id row.Item = a.radios[id] - row.nameLabel.Segments[0].(*widget.TextSegment).Text = row.Item.Name + row.nameLabel.Segments[0].(*widget.TextSegment).Text = row.Item.StationName row.homePageLink.Text = row.Item.HomePageURL if u, err := url.Parse(row.Item.HomePageURL); err == nil { row.homePageLink.URL = u