diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index 96070f1..7bb2c75 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -270,6 +270,7 @@ type LyricsProvider interface { } type RadioProvider interface { + GetRadioStation(id string) (*RadioStation, error) GetRadioStations() ([]*RadioStation, error) } diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 51c7525..40390ba 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -215,6 +215,7 @@ const ( ContentTypePlaylist ContentTypeTrack ContentTypeGenre + ContentTypeRadioStation ) func (c ContentType) String() string { @@ -229,6 +230,8 @@ func (c ContentType) String() string { return "Playlist" case ContentTypeGenre: return "Genre" + case ContentTypeRadioStation: + return "Radio station" default: return "Unknown" } @@ -243,8 +246,9 @@ type SearchResult struct { // for Album / Playlist: track count // Artist / Genre: album count // Track: length (seconds) + // for Radio: none Size int - // Unset for ContentTypes Artist, Playlist, and Genre + // Unset for ContentTypes Artist, Playlist, Genre, and RadioStation ArtistName string } diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go index 8c47963..7a9d95a 100644 --- a/backend/mediaprovider/subsonic/searchall.go +++ b/backend/mediaprovider/subsonic/searchall.go @@ -18,6 +18,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ var result *subsonic.SearchResult3 var playlists []*subsonic.Playlist var genres []*subsonic.Genre + var radios []*mediaprovider.RadioStation wg.Add(1) go func() { @@ -60,12 +61,23 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Done() }() + wg.Add(1) + go func() { + 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) + }) + } + wg.Done() + }() + wg.Wait() if err != nil { return nil, err } - results := mergeResults(result, playlists, genres) + results := mergeResults(result, playlists, genres, radios) helpers.RankSearchResults(results, querySanitized, queryLowerWords) if len(results) > maxResults { results = results[:maxResults] @@ -77,6 +89,7 @@ func mergeResults( searchResult *subsonic.SearchResult3, matchingPlaylists []*subsonic.Playlist, matchingGenres []*subsonic.Genre, + matchingRadios []*mediaprovider.RadioStation, ) []*mediaprovider.SearchResult { var results []*mediaprovider.SearchResult @@ -131,6 +144,14 @@ func mergeResults( }) } + for _, r := range matchingRadios { + results = append(results, &mediaprovider.SearchResult{ + Type: mediaprovider.ContentTypeRadioStation, + ID: r.ID, + Name: r.Name, + }) + } + return results } diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index 8ca96af..45fa8c2 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -18,7 +18,10 @@ import ( "github.com/dweymouth/supersonic/sharedutil" ) -const cacheValidDurationSeconds = 60 +const ( + playlistCacheValidDurationSeconds = 60 + cacheValidDurationSeconds = 120 // genres and radios aren't expected to change as much +) type subsonicMediaProvider struct { client *subsonic.Client @@ -29,6 +32,9 @@ type subsonicMediaProvider struct { playlistsCached []*mediaprovider.Playlist playlistsCachedAt int64 // unix + + radiosCached []*mediaprovider.RadioStation + radiosCachedAt int64 // unix } func SubsonicMediaProvider(subsonicClient *subsonic.Client) mediaprovider.MediaProvider { @@ -194,7 +200,7 @@ func (s *subsonicMediaProvider) GetPlaylist(playlistID string) (*mediaprovider.P } func (s *subsonicMediaProvider) GetPlaylists() ([]*mediaprovider.Playlist, error) { - if s.playlistsCached != nil && time.Now().Unix()-s.playlistsCachedAt < cacheValidDurationSeconds { + if s.playlistsCached != nil && time.Now().Unix()-s.playlistsCachedAt < playlistCacheValidDurationSeconds { return s.playlistsCached, nil } @@ -436,11 +442,15 @@ func (s *subsonicMediaProvider) GetPlayQueue() (*mediaprovider.SavedPlayQueue, e var _ mediaprovider.RadioProvider = (*subsonicMediaProvider)(nil) func (s *subsonicMediaProvider) GetRadioStations() ([]*mediaprovider.RadioStation, error) { + if s.radiosCached != nil && time.Now().Unix()-s.radiosCachedAt < cacheValidDurationSeconds { + return s.radiosCached, nil + } + rs, err := s.client.GetInternetRadioStations() if err != nil { return nil, err } - return sharedutil.MapSlice(rs, func(rs *subsonic.InternetRadioStation) *mediaprovider.RadioStation { + s.radiosCached = sharedutil.MapSlice(rs, func(rs *subsonic.InternetRadioStation) *mediaprovider.RadioStation { return &mediaprovider.RadioStation{ // TODO - subsonic library is missing ID in its radiostation object. add it ID: "radio-" + strings.ReplaceAll(rs.Name, " ", ""), @@ -448,7 +458,23 @@ func (s *subsonicMediaProvider) GetRadioStations() ([]*mediaprovider.RadioStatio HomePageURL: rs.HomePageUrl, StreamURL: rs.StreamUrl, } - }), nil + }) + s.radiosCachedAt = time.Now().Unix() + return s.radiosCached, nil +} + +func (s *subsonicMediaProvider) GetRadioStation(id string) (*mediaprovider.RadioStation, error) { + rs, err := s.GetRadioStations() + if err != nil { + return nil, err + } + index := slices.IndexFunc(rs, func(r *mediaprovider.RadioStation) bool { + return r.ID == id + }) + if index < 0 { + return nil, errors.New("radio station not found") + } + return rs[index], nil } func toTrack(ch *subsonic.Child) *mediaprovider.Track { diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 3542a65..da928dd 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -234,6 +234,11 @@ func (p *PlaybackManager) LoadRadioStation(station *mediaprovider.RadioStation, p.engine.LoadRadioStation(station, queueMode) } +func (p *PlaybackManager) PlayRadioStation(station *mediaprovider.RadioStation) error { + p.LoadRadioStation(station, Replace) + return p.PlayFromBeginning() +} + func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Track, error)) { if songs, err := fetchFn(); err != nil { log.Printf("error fetching tracks: %s", err.Error()) diff --git a/ui/browsing/radiospage.go b/ui/browsing/radiospage.go index 2cb04c9..1fd9acf 100644 --- a/ui/browsing/radiospage.go +++ b/ui/browsing/radiospage.go @@ -99,8 +99,7 @@ func (a *RadiosPage) load(searchOnLoad bool, scrollPos float32) { } func (a *RadiosPage) onPlay(station *mediaprovider.RadioStation) { - a.pm.LoadRadioStation(station, backend.Replace) - a.pm.PlayFromBeginning() + a.pm.PlayRadioStation(station) } func (a *RadiosPage) onQueue(station *mediaprovider.RadioStation, next bool) { diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 30a44a1..08f30e7 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -591,6 +591,12 @@ func (c *Controller) ShowQuickSearch() { c.NavigateTo(PlaylistRoute(id)) case mediaprovider.ContentTypeGenre: c.NavigateTo(GenreRoute(id)) + case mediaprovider.ContentTypeRadioStation: + if rp, ok := c.App.ServerManager.Server.(mediaprovider.RadioProvider); ok { + if radio, err := rp.GetRadioStation(id); err == nil { + go c.App.PlaybackManager.PlayRadioStation(radio) + } + } } }) c.ClosePopUpOnEscape(pop) diff --git a/ui/dialogs/searchdialog.go b/ui/dialogs/searchdialog.go index 8e24b5e..849c660 100644 --- a/ui/dialogs/searchdialog.go +++ b/ui/dialogs/searchdialog.go @@ -212,6 +212,8 @@ func placeholderIconForContentType(c mediaprovider.ContentType) fyne.Resource { return myTheme.GenreIcon case mediaprovider.ContentTypePlaylist: return myTheme.PlaylistIcon + case mediaprovider.ContentTypeRadioStation: + return myTheme.RadioIcon default: return theme.WarningIcon() // unreached } @@ -284,6 +286,8 @@ func (s *searchResult) Update(result *mediaprovider.SearchResult) { secondaryText = result.ArtistName case mediaprovider.ContentTypePlaylist: secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("track", result.Size)) + case mediaprovider.ContentTypeRadioStation: + fallthrough // result.Size is always 0 for radios case mediaprovider.ContentTypeGenre: if result.Size > 0 { secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size))