├── .envrc ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── api ├── browser.go ├── browser_test.go ├── dns_lookup.go ├── http.go ├── station_test.go └── utils.go ├── assets ├── assets.go ├── logo.txt └── noStations.txt ├── common ├── click_station_response.go ├── station.go ├── station_query.go └── url.go ├── config ├── config.go ├── config_test.go └── paths.go ├── data └── version.go ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── logo.png ├── main.go ├── make_release.sh ├── mocks ├── browser_mock.go ├── dns_lookup_mock.go ├── http_client_mock.go └── playback_manager_mock.go ├── models ├── error_fatal.go ├── error_fatal_test.go ├── header.go ├── loading.go ├── loading_test.go ├── model.go ├── model_test.go ├── search.go ├── search_test.go ├── selector.go ├── selector_test.go ├── stations.go └── theme.go ├── nix └── package.nix ├── playback ├── ffplay.go └── manager.go ├── screen1.png ├── screen2.png ├── screen3.png └── screen4.png /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .direnv 3 | bin -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.coverOnSave": true, 3 | "go.coverageDecorator": { 4 | "type": "gutter", 5 | "coveredHighlightColor": "rgba(64,128,128,0.5)", 6 | "uncoveredHighlightColor": "rgba(128,64,64,0.25)", 7 | "coveredGutterStyle": "blockgreen", 8 | "uncoveredGutterStyle": "blockred" 9 | }, 10 | "go.coverOnSingleTest": true 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Matteo Pacini 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RadioGoGo 📻 2 | 3 | --- 4 | 5 | 📌 Repository Archived 6 | 7 | Unfortunately, I am no longer able to commit the necessary time to maintain and develop it further. As a result, I have decided to archive this repository. 8 | 9 | The code and documentation will remain accessible, so feel free to fork it or use it as a reference for your own projects. I hope it continues to be helpful to the community in its current form. 10 | 11 | Thank you again for your understanding and support! 12 | 13 | --- 14 | 15 |
16 | RadioGoGo Logo 17 |
18 |
19 | 20 | Tune into global radio vibes directly from your terminal! 21 | 22 | Crafted with love in Go, RadioGoGo marries the elegance of the [BubbleTea TUI](https://github.com/charmbracelet/bubbletea) with the expansive reach of [RadioBrowser API](http://www.radio-browser.info/). 23 | 24 | Dive into a world of sounds with just a few keystrokes. 25 | 26 | Let's Go 🚀! 27 | 28 | RadioGoGo Search View 29 | RadioGoGo Station List View 30 | 31 | ## ⭐️ Features 32 | 33 | - Sleek and intuitive TUI that's a joy to navigate. 34 | - Search, browse, and play radio stations from a vast global database. 35 | - Enjoy cross-platform compatibility, because radio waves know no bounds. 36 | - Integrated playback using `ffplay`. 37 | 38 | ## 📋 Upcoming Features 39 | 40 | - Scroll indicator for the station list. 41 | - Report / hide broken stations. 42 | - Vote stations. 43 | - Bookmark your favorite stations for easy access. 44 | - Record your favorite broadcasts for later listening. 45 | 46 | ## ⚒️ Installation 47 | 48 | ### Dependencies: 49 | 50 | For seamless playback, ensure you have either `ffplay` installed: 51 | 52 | #### FFmpeg 53 | 54 | ##### Windows: 55 | 56 | Download FFmpeg from the [official website](https://ffmpeg.org/download.html) and add it to your system's PATH. For those unfamiliar with adding to the PATH, you might want to consult a guide or search for instructions specific to your version of Windows. 57 | 58 | It can be installed via [Chocolatey](https://chocolatey.org/): 59 | 60 | ``` 61 | choco install ffmpeg 62 | ``` 63 | 64 | Or [Scoop](https://scoop.sh/): 65 | 66 | ``` 67 | scoop install ffmpeg 68 | ``` 69 | 70 | ##### Linux: 71 | 72 | For apt-based distros (like Ubuntu and Debian): 73 | 74 | ``` 75 | sudo apt update 76 | sudo apt install ffmpeg 77 | ``` 78 | 79 | For users of dnf-based distros such as Fedora, you may need to enable the RPM Fusion repository first before installing FFmpeg: 80 | 81 | ``` 82 | sudo dnf install https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://download1.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm 83 | ``` 84 | 85 | And then: 86 | 87 | ``` 88 | sudo dnf install ffmpeg 89 | ``` 90 | 91 | For pacman-based distros, such as Arch, you can install FFmpeg using the following command: 92 | 93 | ``` 94 | sudo pacman -S ffmpeg 95 | ``` 96 | 97 | For Gentoo: 98 | 99 | ``` 100 | emerge --ask --quiet --verbose media-video/ffmpeg 101 | ``` 102 | 103 | ##### macOS: 104 | 105 | For macOS users with Homebrew installed, you can use the following command: 106 | 107 | ``` 108 | brew install ffmpeg 109 | ``` 110 | 111 | ##### \*BSD: 112 | 113 | FreeBSD: 114 | 115 | ``` 116 | pkg install ffmpeg 117 | ``` 118 | 119 | NetBSD: 120 | 121 | ``` 122 | pkg_add ffmpeg 123 | ``` 124 | 125 | OpenBSD: 126 | 127 | ``` 128 | doas pkg_add ffmpeg 129 | ``` 130 | 131 | ### Installing via Go 132 | 133 | Ensure you have [Go](https://golang.org/dl/) installed (version 1.18 or later). 134 | 135 | To install RadioGoGo: 136 | 137 | ```bash 138 | go install github.com/zi0p4tch0/radiogogo@latest 139 | ``` 140 | 141 | #### For Linux/macOS/\*BSD: 142 | 143 | To make sure `radiogogo` is available in your terminal, you might need to add the Go binary path to your system's PATH: 144 | 145 | ```bash 146 | export PATH=$PATH:$(go env GOPATH)/bin 147 | ``` 148 | 149 | #### For Windows: 150 | 151 | After installation, you might need to add the Go binary path to your system's PATH to run `radiogogo` from the Command Prompt. You can do this manually through the System Properties → Environment Variables, or run the following in Command Prompt: 152 | 153 | ```bash 154 | setx PATH "%PATH%;%USERPROFILE%\go\bin" 155 | ``` 156 | 157 | Now, you can launch `radiogogo` directly from your terminal or Command Prompt. 158 | 159 | ### Downloading the Binary 160 | 161 | Navigate to the `Releases` section of the project repository. 162 | 163 | Find the appropriate binary for your OS, download it, and place it in your system's PATH for easy access. 164 | 165 | ## 🚀 Usage 166 | 167 | Launch RadioGoGo by executing the following command: 168 | 169 | ```bash 170 | radiogogo 171 | ``` 172 | 173 | ### Terminals for an optimal RadioGoGo experience: 174 | 175 | - **Windows:** For a smooth experience on Windows, consider using [Windows Terminal](https://aka.ms/terminal). It offers multiple tabs, a GPU-accelerated text rendering engine, and a rich set of customization options. If you're fond of UNIX-like environments, [WSL (Windows Subsystem for Linux)](https://docs.microsoft.com/en-us/windows/wsl/) combined with Windows Terminal can be a powerful duo. 176 | 177 | - **Linux:** On Linux, most modern terminals should work well with RadioGoGo. However, [Alacritty](https://github.com/alacritty/alacritty), a GPU-accelerated terminal, and [Terminator](https://gnometerminator.blogspot.com/p/introduction.html), known for its flexibility, stand out as exceptional choices. Both offer great performance and customization options to enhance your TUI experience. 178 | 179 | - **macOS:** On macOS, while the default Terminal.app should work fine, you might want to explore [iTerm2](https://iterm2.com/) for its advanced features, superior performance, and extensive customization options. iTerm2's integration with macOS makes it a preferred choice for many users. 180 | 181 | ## Configuration 182 | 183 | **Config File Location:** 184 | - **Windows:** `%LOCALAPPDATA%\radiogogo\config.yaml` 185 | - **Other Platforms:** `~/.config/radiogogo/config.yaml` 186 | 187 | It gets created automatically when you launch the app for the first time. 188 | 189 | ### 🎨 Customizing App Theme 190 | 191 | Personalize the look of RadioGoGo to match your style! 192 | 193 | The application supports theme customization, which allows you to change various color attributes to give the TUI a fresh appearance. 194 | 195 | The configuration file is automatically created when the app is launched for the first time if it doesn't already exist. 196 | 197 | **Default Theme Configuration:** 198 | ```yaml 199 | theme: 200 | textColor: '#ffffff' 201 | primaryColor: '#5a4f9f' 202 | secondaryColor: '#8b77db' 203 | tertiaryColor: '#4e4e4e' 204 | errorColor: '#ff0000' 205 | ``` 206 | 207 | Adjust the color values in the configuration to your liking and relaunch the app to see the changes take effect. 208 | 209 | Here's another theme configuration to give you an idea of how you can customize the app's appearance: 210 | 211 | ```yaml 212 | theme: 213 | textColor: '#f0e6e6' 214 | primaryColor: '#c41230' 215 | secondaryColor: '#e4414f' 216 | tertiaryColor: '#f58b8d' 217 | errorColor: '#ff0000' 218 | ``` 219 | 220 | How it looks: 221 | 222 | RadioGoGo Search View 223 | RadioGoGo Station List View 224 | 225 | ## 🤔 FAQ 226 | 227 | ### I selected a radio station but there's no audio. What's happening? 228 | Upon selecting a station, the duration to initiate playback can vary based on the stream's origin and its server location. 229 | In some cases, the playback is immediate, while in others, it might necessitate a brief buffering period. 230 | It's analogous to the latency encountered with various online services – certain connections are swift, while others require a momentary lag. 231 | If you don't experience instant audio, I recommend waiting a few seconds. 232 | The broadcast is likely en route to your terminal. 233 | 234 | ### Why do some stations not work at all? 235 | Due to the dynamic nature of radio stations, some might go offline or change their streaming endpoints. 236 | Currently, RadioGoGo doesn't have a feature to report or hide these non-functioning stations. 237 | However, I am actively aware of this challenge and am planning to introduce a feature in future releases to enhance this aspect of the user experience. 238 | 239 | ### How do I adjust the volume in RadioGoGo? 240 | Volume controls in RadioGoGo are set before initiating playback. This is because the volume level is passed as a command line argument to `ffplay`. As of now, once the playback has started, adjusting the volume within RadioGoGo isn't supported. To change the volume during an ongoing playback, you'd have to stop (`ctrl+k`) and restart the stream. 241 | 242 | ## Who is talking about RadioGoGo? 243 | 244 | - Mentioned on [Golang Weekly Issue 481](https://golangweekly.com/issues/481)! 245 | 246 | ## ❤️ Contributing 247 | 248 | Hey there, fellow radio enthusiast! 249 | 250 | First off, a big thanks for even considering contributing. 251 | 252 | Every typo fix, bug report, or thought you share genuinely helps make RadioGoGo better. If you're eyeing to introduce a new feature, I'd love to hear about it! 253 | 254 | Please kick off a discussion by creating an issue before diving into crafting a pull request. This way, we can ensure everyone's on the same frequency. 255 | 256 | 📻 Happy coding! 257 | 258 | 259 | ## ⚖️ License(s) 260 | 261 | RadioGoGo is licensed under the [MIT License](LICENSE). 262 | 263 | ### Third-party dependencies 264 | 265 | BubbleTea TUI license (MIT): 266 | 267 | ``` 268 | MIT License 269 | 270 | Copyright (c) 2020-2023 Charmbracelet, Inc 271 | 272 | Permission is hereby granted, free of charge, to any person obtaining a copy 273 | of this software and associated documentation files (the "Software"), to deal 274 | in the Software without restriction, including without limitation the rights 275 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 276 | copies of the Software, and to permit persons to whom the Software is 277 | furnished to do so, subject to the following conditions: 278 | 279 | The above copyright notice and this permission notice shall be included in all 280 | copies or substantial portions of the Software. 281 | 282 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 283 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 284 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 285 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 286 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 287 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 288 | SOFTWARE. 289 | ``` 290 | -------------------------------------------------------------------------------- /api/browser.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package api 21 | 22 | import ( 23 | "encoding/json" 24 | "math/rand" 25 | "net" 26 | "net/http" 27 | "net/url" 28 | 29 | "github.com/zi0p4tch0/radiogogo/common" 30 | "github.com/zi0p4tch0/radiogogo/data" 31 | ) 32 | 33 | type RadioBrowserService interface { 34 | // GetStations retrieves a list of radio stations from the RadioBrowser API based on the provided StationQuery, searchTerm, order, reverse, offset, limit and hideBroken parameters. 35 | // If stationQuery is not StationQueryAll, the searchTerm is used to filter the results. 36 | // The order parameter specifies the field to order the results by. 37 | // The reverse parameter specifies whether the results should be returned in reverse order. 38 | // The offset parameter specifies the number of results to skip before returning the remaining results. 39 | // The limit parameter specifies the maximum number of results to return. 40 | // The hideBroken parameter specifies whether to exclude broken stations from the results. 41 | // Returns a slice of Station structs and an error if any occurred. 42 | GetStations( 43 | stationQuery common.StationQuery, 44 | searchTerm string, 45 | order string, 46 | reverse bool, 47 | offset uint64, 48 | limit uint64, 49 | hideBroken bool, 50 | ) ([]common.Station, error) 51 | // ClickStation sends a POST request to the RadioBrowser API to increment the click count of a given station. 52 | // It takes a Station struct as input and returns a ClickStationResponse struct and an error. 53 | ClickStation(station common.Station) (common.ClickStationResponse, error) 54 | } 55 | 56 | type RadioBrowserImpl struct { 57 | // The HTTP client used to make requests to the Radio Browser API. 58 | httpClient HTTPClientService 59 | // The base URL for the Radio Browser API.) 60 | baseUrl url.URL 61 | } 62 | 63 | // NewRadioBrowser returns a new instance of RadioBrowserService with the default DNS lookup and HTTP client services. 64 | func NewRadioBrowser() (RadioBrowserService, error) { 65 | return NewRadioBrowserWithDependencies( 66 | &DNSLookupServiceImpl{}, 67 | http.DefaultClient, 68 | ) 69 | } 70 | 71 | // NewRadioBrowserWithDependencies creates a new instance of RadioBrowserService with the provided dependencies. 72 | // It takes a DNSLookupService and an HTTPClientService as arguments and returns a pointer to RadioBrowserService and an error. 73 | // The function performs a DNS lookup for "all.api.radio-browser.info" and selects a random IP address from the returned list. 74 | // It then constructs a URL with the selected IP address and sets it as the base URL for the browser. 75 | // Returns an error if the DNS lookup or URL parsing fails. 76 | func NewRadioBrowserWithDependencies( 77 | dnsLookupService DNSLookupService, 78 | httpClient HTTPClientService, 79 | ) (RadioBrowserService, error) { 80 | browser := &RadioBrowserImpl{ 81 | httpClient: httpClient, 82 | } 83 | ips, err := dnsLookupService.LookupIP("all.api.radio-browser.info") 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | randomIp := ips[rand.Intn(len(ips))] 89 | 90 | if net.ParseIP(randomIp).To4() == nil { 91 | randomIp = "[" + randomIp + "]" 92 | } 93 | 94 | url, err := url.Parse("http://" + randomIp + "/json") 95 | if err != nil { 96 | return nil, err 97 | } 98 | browser.baseUrl = *url 99 | return browser, nil 100 | } 101 | 102 | func (radioBrowser *RadioBrowserImpl) GetStations( 103 | stationQuery common.StationQuery, 104 | searchTerm string, 105 | order string, 106 | reverse bool, 107 | offset uint64, 108 | limit uint64, 109 | hideBroken bool, 110 | ) ([]common.Station, error) { 111 | 112 | url := radioBrowser.baseUrl.JoinPath("/stations") 113 | if stationQuery != common.StationQueryAll { 114 | url = url.JoinPath("/" + string(stationQuery) + "/" + searchTerm) 115 | } 116 | 117 | query := url.Query() 118 | query.Set("order", order) 119 | query.Set("reverse", boolToString(reverse)) 120 | query.Set("offset", uint64ToString(offset)) 121 | query.Set("limit", uint64ToString(limit)) 122 | query.Set("hidebroken", boolToString(hideBroken)) 123 | url.RawQuery = query.Encode() 124 | 125 | headers := make(map[string]string) 126 | headers["User-Agent"] = data.UserAgent 127 | headers["Accept"] = "application/json" 128 | 129 | var stations []common.Station 130 | 131 | req, err := http.NewRequest("GET", url.String(), nil) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | for key, value := range headers { 137 | req.Header.Set(key, value) 138 | } 139 | 140 | result, err := radioBrowser.httpClient.Do(req) 141 | if err != nil { 142 | return nil, err 143 | } 144 | 145 | defer result.Body.Close() 146 | 147 | err = json.NewDecoder(result.Body).Decode(&stations) 148 | 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | return stations, nil 154 | 155 | } 156 | 157 | func (radioBrowser *RadioBrowserImpl) ClickStation(station common.Station) (common.ClickStationResponse, error) { 158 | 159 | url := radioBrowser.baseUrl.JoinPath("/url/" + station.StationUuid.String()) 160 | 161 | headers := make(map[string]string) 162 | headers["User-Agent"] = data.UserAgent 163 | headers["Accept"] = "application/json" 164 | 165 | req, err := http.NewRequest("POST", url.String(), nil) 166 | if err != nil { 167 | return common.ClickStationResponse{}, err 168 | } 169 | 170 | for key, value := range headers { 171 | req.Header.Set(key, value) 172 | } 173 | 174 | result, err := radioBrowser.httpClient.Do(req) 175 | if err != nil { 176 | return common.ClickStationResponse{}, err 177 | } 178 | 179 | defer result.Body.Close() 180 | 181 | var response common.ClickStationResponse 182 | err = json.NewDecoder(result.Body).Decode(&response) 183 | 184 | if err != nil { 185 | return common.ClickStationResponse{}, err 186 | } 187 | 188 | return response, nil 189 | } 190 | -------------------------------------------------------------------------------- /api/browser_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package api 21 | 22 | import ( 23 | "bytes" 24 | "errors" 25 | "io" 26 | "net/http" 27 | "testing" 28 | 29 | "github.com/zi0p4tch0/radiogogo/common" 30 | "github.com/zi0p4tch0/radiogogo/data" 31 | "github.com/zi0p4tch0/radiogogo/mocks" 32 | 33 | "github.com/google/uuid" 34 | "github.com/stretchr/testify/assert" 35 | ) 36 | 37 | func TestBrowserImplEscapesIPV6(t *testing.T) { 38 | 39 | mockDNSLookupService := mocks.MockDNSLookupService{ 40 | LookupIPFunc: func(host string) ([]string, error) { 41 | return []string{"2001:db8::1"}, nil 42 | }, 43 | } 44 | 45 | mockHttpClient := mocks.MockHttpClient{ 46 | DoFunc: func(req *http.Request) (*http.Response, error) { 47 | return nil, io.EOF 48 | }, 49 | } 50 | 51 | browser, err := NewRadioBrowserWithDependencies(&mockDNSLookupService, &mockHttpClient) 52 | assert.NoError(t, err) 53 | 54 | assert.Equal(t, "http://[2001:db8::1]/json", browser.(*RadioBrowserImpl).baseUrl.String()) 55 | 56 | assert.NoError(t, err) 57 | 58 | } 59 | 60 | func TestBrowserImplNewRadioBrowserWithDependencies(t *testing.T) { 61 | 62 | t.Run("returns an error if the DNS lookup fails", func(t *testing.T) { 63 | 64 | mockDNSLookupService := mocks.MockDNSLookupService{ 65 | LookupIPFunc: func(host string) ([]string, error) { 66 | return nil, errors.New("dns") 67 | }, 68 | } 69 | 70 | mockHttpClient := mocks.MockHttpClient{ 71 | DoFunc: func(req *http.Request) (*http.Response, error) { 72 | return nil, errors.New("http") 73 | }, 74 | } 75 | 76 | _, err := NewRadioBrowserWithDependencies(&mockDNSLookupService, &mockHttpClient) 77 | 78 | assert.Error(t, err) 79 | assert.Equal(t, "dns", err.Error()) 80 | 81 | }) 82 | 83 | t.Run("returns an error if the URL parsing fails", func(t *testing.T) { 84 | 85 | mockDNSLookupService := mocks.MockDNSLookupService{ 86 | LookupIPFunc: func(host string) ([]string, error) { 87 | return []string{"&!@#*)!@)@)@"}, nil 88 | }, 89 | } 90 | 91 | mockHttpClient := mocks.MockHttpClient{ 92 | DoFunc: func(req *http.Request) (*http.Response, error) { 93 | return nil, errors.New("http") 94 | }, 95 | } 96 | 97 | _, err := NewRadioBrowserWithDependencies(&mockDNSLookupService, &mockHttpClient) 98 | 99 | assert.Error(t, err) 100 | assert.Equal(t, "parse \"http://[&!@\": net/url: invalid userinfo", err.Error()) 101 | 102 | }) 103 | 104 | } 105 | 106 | func TestBrowserImplGetStations(t *testing.T) { 107 | 108 | // Note: Search term set to "searchTerm" in all test cases 109 | 110 | testCases := []struct { 111 | name string 112 | queryType common.StationQuery 113 | expectedEndpoint string 114 | }{ 115 | { 116 | name: "builds the correct URL for StationQueryAll", 117 | queryType: common.StationQueryAll, 118 | expectedEndpoint: "/json/stations", 119 | }, 120 | { 121 | name: "builds the correct URL for StationQueryByUUID", 122 | queryType: common.StationQueryByUuid, 123 | expectedEndpoint: "/json/stations/byuuid/searchTerm", 124 | }, 125 | { 126 | name: "builds the correct URL for StationQueryByName", 127 | queryType: common.StationQueryByName, 128 | expectedEndpoint: "/json/stations/byname/searchTerm", 129 | }, 130 | { 131 | name: "builds the correct URL for StationQueryByNameExact", 132 | queryType: common.StationQueryByNameExact, 133 | expectedEndpoint: "/json/stations/bynameexact/searchTerm", 134 | }, 135 | { 136 | name: "builds the correct URL for StationQueryByCodec", 137 | queryType: common.StationQueryByCodec, 138 | expectedEndpoint: "/json/stations/bycodec/searchTerm", 139 | }, 140 | { 141 | name: "builds the correct URL for StationQueryByCodecExact", 142 | queryType: common.StationQueryByCodecExact, 143 | expectedEndpoint: "/json/stations/bycodecexact/searchTerm", 144 | }, 145 | { 146 | name: "builds the correct URL for StationQueryByCountry", 147 | queryType: common.StationQueryByCountry, 148 | expectedEndpoint: "/json/stations/bycountry/searchTerm", 149 | }, 150 | { 151 | name: "builds the correct URL for StationQueryByCountryExact", 152 | queryType: common.StationQueryByCountryExact, 153 | expectedEndpoint: "/json/stations/bycountryexact/searchTerm", 154 | }, 155 | { 156 | name: "builds the correct URL for StationQueryByCountryCodeExact", 157 | queryType: common.StationQueryByCountryCodeExact, 158 | expectedEndpoint: "/json/stations/bycountrycodeexact/searchTerm", 159 | }, 160 | { 161 | name: "builds the correct URL for StationQueryByState", 162 | queryType: common.StationQueryByState, 163 | expectedEndpoint: "/json/stations/bystate/searchTerm", 164 | }, 165 | { 166 | name: "builds the correct URL for StationQueryByStateExact", 167 | queryType: common.StationQueryByStateExact, 168 | expectedEndpoint: "/json/stations/bystateexact/searchTerm", 169 | }, 170 | { 171 | name: "builds the correct URL for StationQueryByLanguage", 172 | queryType: common.StationQueryByLanguage, 173 | expectedEndpoint: "/json/stations/bylanguage/searchTerm", 174 | }, 175 | { 176 | name: "builds the correct URL for StationQueryByLanguageExact", 177 | queryType: common.StationQueryByLanguageExact, 178 | expectedEndpoint: "/json/stations/bylanguageexact/searchTerm", 179 | }, 180 | { 181 | name: "builds the correct URL for StationQueryByTag", 182 | queryType: common.StationQueryByTag, 183 | expectedEndpoint: "/json/stations/bytag/searchTerm", 184 | }, 185 | { 186 | name: "builds the correct URL for StationQueryByTagExact", 187 | queryType: common.StationQueryByTagExact, 188 | expectedEndpoint: "/json/stations/bytagexact/searchTerm", 189 | }, 190 | } 191 | 192 | for _, tc := range testCases { 193 | t.Run(tc.name, func(t *testing.T) { 194 | 195 | mockDNSLookupService := mocks.MockDNSLookupService{ 196 | LookupIPFunc: func(host string) ([]string, error) { 197 | return []string{"127.0.0.1"}, nil 198 | }, 199 | } 200 | 201 | mockHttpClient := mocks.MockHttpClient{ 202 | DoFunc: func(req *http.Request) (*http.Response, error) { 203 | assert.Equal(t, tc.expectedEndpoint, req.URL.Path) 204 | assert.Equal(t, "GET", req.Method) 205 | assert.Equal(t, "application/json", req.Header.Get("Accept")) 206 | assert.Equal(t, data.UserAgent, req.Header.Get("User-Agent")) 207 | responseBody := io.NopCloser(bytes.NewReader([]byte(`[]`))) 208 | return &http.Response{ 209 | StatusCode: 200, 210 | Body: responseBody, 211 | }, nil 212 | }, 213 | } 214 | 215 | browser, err := NewRadioBrowserWithDependencies(&mockDNSLookupService, &mockHttpClient) 216 | 217 | assert.NoError(t, err) 218 | 219 | _, err = browser.GetStations(tc.queryType, "searchTerm", "name", false, 0, 10, true) 220 | 221 | assert.NoError(t, err) 222 | 223 | }) 224 | } 225 | } 226 | func TestBrowserImplClickStation(t *testing.T) { 227 | 228 | station := common.Station{ 229 | StationUuid: uuid.MustParse("941ef6f1-0699-4821-95b1-2b678e3ff62e"), 230 | } 231 | 232 | mockDNSLookupService := mocks.MockDNSLookupService{ 233 | LookupIPFunc: func(host string) ([]string, error) { 234 | return []string{"127.0.0.1"}, nil 235 | }, 236 | } 237 | 238 | mockHttpClient := mocks.MockHttpClient{ 239 | DoFunc: func(req *http.Request) (*http.Response, error) { 240 | expectedUrl := "http://127.0.0.1/json/url/941ef6f1-0699-4821-95b1-2b678e3ff62e" 241 | assert.Equal(t, "POST", req.Method) 242 | assert.Equal(t, expectedUrl, req.URL.String()) 243 | assert.Equal(t, "application/json", req.Header.Get("Accept")) 244 | assert.Equal(t, data.UserAgent, req.Header.Get("User-Agent")) 245 | 246 | responseBody := io.NopCloser(bytes.NewReader([]byte(` 247 | { 248 | "ok": true, 249 | "message": "retrieved station url", 250 | "stationuuid": "9617a958-0601-11e8-ae97-52543be04c81", 251 | "name": "Station name", 252 | "url": "http://this.is.an.url" 253 | } 254 | `))) 255 | return &http.Response{ 256 | StatusCode: 200, 257 | Body: responseBody, 258 | }, nil 259 | }, 260 | } 261 | 262 | radioBrowser, err := NewRadioBrowserWithDependencies(&mockDNSLookupService, &mockHttpClient) 263 | assert.NoError(t, err) 264 | 265 | response, err := radioBrowser.ClickStation(station) 266 | assert.NoError(t, err) 267 | 268 | assert.Equal(t, true, response.Ok) 269 | } 270 | -------------------------------------------------------------------------------- /api/dns_lookup.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package api 21 | 22 | import ( 23 | "context" 24 | "net" 25 | ) 26 | 27 | // DNSLookupService defines the behavior for looking up IP addresses for a given host. 28 | type DNSLookupService interface { 29 | // LookupIP returns the IP address of a given host. If the host is already an IP address, it returns the same IP address. 30 | // Otherwise, it performs a DNS lookup and returns the IP addresses associated with the host. 31 | // The function uses the default resolver and the "ip4" network to perform the lookup. 32 | // If the lookup fails, it returns an empty slice and the error encountered. 33 | LookupIP(host string) ([]string, error) 34 | } 35 | 36 | // DNSLookupServiceImpl provides a default implementation of the DNSLookupService interface. 37 | type DNSLookupServiceImpl struct{} 38 | 39 | func NewDNSLookupService() DNSLookupService { 40 | return &DNSLookupServiceImpl{} 41 | } 42 | 43 | // LookupIP performs a DNS lookup to retrieve IP addresses for the given host. 44 | func (s *DNSLookupServiceImpl) LookupIP(host string) ([]string, error) { 45 | 46 | if net.ParseIP(host) != nil { 47 | return []string{host}, nil 48 | } 49 | 50 | resolver := net.DefaultResolver 51 | 52 | ips, err := resolver.LookupIP(context.Background(), "ip4", host) 53 | if err != nil { 54 | return []string{}, err 55 | } 56 | 57 | ipStrings := make([]string, len(ips)) 58 | for i, ip := range ips { 59 | ipStrings[i] = ip.String() 60 | } 61 | 62 | return ipStrings, nil 63 | 64 | } 65 | -------------------------------------------------------------------------------- /api/http.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package api 21 | 22 | import "net/http" 23 | 24 | type HTTPClientService interface { 25 | Do(req *http.Request) (*http.Response, error) 26 | } 27 | -------------------------------------------------------------------------------- /api/station_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package api 21 | 22 | import ( 23 | "encoding/json" 24 | "testing" 25 | 26 | "github.com/zi0p4tch0/radiogogo/common" 27 | 28 | "github.com/stretchr/testify/assert" 29 | ) 30 | 31 | func TestStationJSON(t *testing.T) { 32 | 33 | t.Run("parses from JSON", func(t *testing.T) { 34 | 35 | input := ` 36 | [ 37 | { 38 | "changeuuid": "610cafba-71d8-40fc-bf68-1456ec973b9d", 39 | "stationuuid": "941ef6f1-0699-4821-95b1-2b678e3ff62e", 40 | "serveruuid": "8a4a8315-6ff3-4af8-8ee7-24ce0acbaeec", 41 | "name": "\tBest FM", 42 | "url": "http://stream.bestfm.sk/128.mp3", 43 | "url_resolved": "http://stream.bestfm.sk/128.mp3", 44 | "homepage": "http://bestfm.sk/", 45 | "favicon": "", 46 | "tags": "", 47 | "country": "Slovakia", 48 | "countrycode": "SK", 49 | "iso_3166_2": null, 50 | "state": "", 51 | "language": "", 52 | "languagecodes": "", 53 | "votes": 57, 54 | "lastchangetime": "2022-11-01 08:40:32", 55 | "lastchangetime_iso8601": "2022-11-01T08:40:32Z", 56 | "codec": "MP3", 57 | "bitrate": 128, 58 | "hls": 0, 59 | "lastcheckok": 1, 60 | "lastchecktime": "2023-10-17 08:46:57", 61 | "lastchecktime_iso8601": "2023-10-17T08:46:57Z", 62 | "lastcheckoktime": "2023-10-17 08:46:57", 63 | "lastcheckoktime_iso8601": "2023-10-17T08:46:57Z", 64 | "lastlocalchecktime": "2023-10-17 08:46:57", 65 | "lastlocalchecktime_iso8601": "2023-10-17T08:46:57Z", 66 | "clicktimestamp": "2023-10-17 11:34:28", 67 | "clicktimestamp_iso8601": "2023-10-17T11:34:28Z", 68 | "clickcount": 45, 69 | "clicktrend": 3, 70 | "ssl_error": 0, 71 | "geo_lat": null, 72 | "geo_long": null, 73 | "has_extended_info": false 74 | } 75 | ] 76 | ` 77 | var stations []common.Station 78 | err := json.Unmarshal([]byte(input), &stations) 79 | 80 | assert.NoError(t, err) 81 | assert.Len(t, stations, 1) 82 | 83 | }) 84 | 85 | } 86 | -------------------------------------------------------------------------------- /api/utils.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package api 21 | 22 | import "fmt" 23 | 24 | func boolToString(b bool) string { 25 | if b { 26 | return "true" 27 | } 28 | return "false" 29 | } 30 | 31 | func uint64ToString(i uint64) string { 32 | return fmt.Sprintf("%d", i) 33 | } 34 | -------------------------------------------------------------------------------- /assets/assets.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package assets 21 | 22 | import _ "embed" 23 | 24 | //go:embed logo.txt 25 | var Logo []byte 26 | 27 | //go:embed noStations.txt 28 | var NoStations []byte 29 | -------------------------------------------------------------------------------- /assets/logo.txt: -------------------------------------------------------------------------------- 1 | @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 2 | @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 3 | @@@@@@@@@@@@@@@@@@@@@@@##%@@@@@@@@@@@@@@%@@@@@@@@@ 4 | @@@@@@@@@@@@@@@@@@@@@#=+=-:++++++++*#%++++=%@@@@@@ 5 | @@@@@@@@@@@@@@@@@@@@@::--**########*+=++-=*:%@@@@@ 6 | @@@@@@@@@@@@@@@@@@@@#-+%%%%%###%@@%@@@%+::-.%@@@@@ 7 | @@@@@@@@@@@@@@@@@@@%:%@@%. =%#%@@@# :#%*=:=%@@@@@@ 8 | @@@@@@@@@@@@@@@@@@@%.@%..  -##@@*:.  +@=-.@@@@@@@@ 9 | @@@@@@@@@@@@@@@@@@@@*-##=-***=@@@*=+#%+--.%@@@@@@@ 10 | @@@@@@@@@@@@@@@@@@@@@:=*+:#*=#%+:#**+--=:-@@@@@@@@ 11 | @@@@@@@@@@@@@@@@@%+++=##%%+.*::+%#****+=.#@@@@%##@ 12 | @@@@@@@@@@@@@@@@*:*##++#@@@++=%@@#%%%#-.=@@@@@:-:% 13 | @@@@@@@@@@@@%###.:#%%#*=*@@@@@@@%#***##+-+@@%-.=%@ 14 | @@@@@%@@@%+=++***::-=*#* %@@@@@@+-***#%%+ ##-:*@@@ 15 | @@@%==-=+-*%%%#@@@#+=---+@@@@@@*.=***+==-.=:--#%@@ 16 | @@@# :-=:+%#=--%@@@@@@@@@@@@@%-:=........-====-:%@ 17 | @@@@#:.:===-.==.+++++++*%@%%##.=+=+++++++++++== %@ 18 | @@@@@@+..:: :++::#%%%%%%##**+=.-#%%%%%%#%%%##%=.%@ 19 | @@%#***=:....++-+###***++==--: -#%%%@%%#@@#+=*= %@ 20 | @*++=+======.--::-----:::::::::.:==========:-::.=@ 21 | @*===+=+=++*=:::-------===++++*+==========------+@ 22 | @@%%#**++=======+++++++++++=--============++*##%@@ 23 | @@@@@@@@@@%%%####*****************####%%%@@@@@@@@@ 24 | @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 25 | @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 26 | -------------------------------------------------------------------------------- /assets/noStations.txt: -------------------------------------------------------------------------------- 1 | @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@####@@@@@@@@@@@@ 2 | @@@@@@@@@@@@@@@@@%%%@@@@@%%%#####-+*=--*@@@@@@@@@@ 3 | @#+++@@@@@@@@@@+==+++*#++++++++++=+::--:@@@@@@@@@@ 4 | @*-:+@@@@@@@@@=-=---+==**########+=*+=:*@@@@@@@@@@ 5 | @@@*+@@@@@@@@@#:-+=-+***#*:+#####+.:=*+=#@@@@@@@@@ 6 | @@@@=*@@@@@@@@@@*::+**##=:-########*-----*@@@@@@@@ 7 | @@@@@-#@@@@@@@@@#:+**#+-:-=*#*####*..:=+=:#@@@@@@@ 8 | @@@@@#-%@@@@@@@@==+**=-+=::.+%#**=*++%*%=--@@@@@@@ 9 | @@@@@@+=@@@@@@@@-=+**==%#%%=@@@#++@@##%%%-:*@@@@@@ 10 | @@@@@%#-=#*+++=--==+*==%%%++@@@@*+##*:%#%=:=@@@@@@ 11 | ++====++::+*****-==++-#%%%====+-=+-+=:+*++::%@@@@@ 12 | :=######==**+++=:=-==:*##*-+*%@@%%@@@=:=-=:::=#@@@ 13 | =:+***+++++++++*:-=**-===*-=*#**#%@%+==**+=::+-=@@ 14 | +:-+*=+**######*:--+*#*===.:-*%*+==-.-*#**=-%@@=*@ 15 | *:-=*=*#++=+===:-=--+****+*#*+#**++= -++++=:*%#=%@ 16 | %.::*=-=---=--:.===-:--=+@@@%:-++++--:-++++-::-@@@ 17 | @::-++-::::::::.=-=+==-::-+++:=+++++:.-====-::=##@ 18 | @-:-==:::::::::.--=+****=....-======-:.-=--::=##+- 19 | %:.:---:::::::-::=--=+*=+#*=:::----=-..--:::-##*-= 20 | =+=:--:--==++**#=:=---=:*##%+.::::::::.::::=++=:=* 21 | =+##++*+*###+*+**=-----:-*++*-..::::::.::-:----==- 22 | %--=++***++**+##*#*+=--:.:===--+===----==+++++=-:* 23 | @@*=---=++***=*#+*####***+=+++######****++=----+%@ 24 | @@@@@%*+==------====+++++**++++====------=+*#@@@@@ 25 | @@@@@@@@@@@%%##*+++=============+++**#%@@@@@@@@@@@ 26 | -------------------------------------------------------------------------------- /common/click_station_response.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package common 21 | 22 | import "github.com/google/uuid" 23 | 24 | // ClickStationResponse represents the response returned by the API when a user clicks on a station. 25 | type ClickStationResponse struct { 26 | // Ok indicates whether the request was successful or not. 27 | Ok bool `json:"ok"` 28 | 29 | // Message contains an optional message returned by the server. 30 | Message string `json:"message"` 31 | 32 | // StationUuid is the unique identifier of the station. 33 | StationUuid uuid.UUID `json:"stationuuid"` 34 | 35 | // Name is the name of the station. 36 | Name string `json:"name"` 37 | 38 | // Url is the URL of the station's stream. 39 | Url RadioGoGoURL `json:"url"` 40 | } 41 | -------------------------------------------------------------------------------- /common/station.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package common 21 | 22 | import ( 23 | "fmt" 24 | "time" 25 | 26 | "github.com/google/uuid" 27 | ) 28 | 29 | // BoolFromlInt represents a boolean value that is converted from an integer value (0 or 1). 30 | type BoolFromlInt bool 31 | 32 | type Station struct { 33 | // A globally unique identifier for the change of the station information 34 | ChangeUuid uuid.UUID `json:"changeuuid"` 35 | // A globally unique identifier for the station 36 | StationUuid uuid.UUID `json:"stationuuid"` 37 | // The name of the station 38 | Name string `json:"name"` 39 | // The stream URL provided by the user 40 | Url RadioGoGoURL `json:"url"` 41 | // An automatically "resolved" stream URL. Things resolved are playlists (M3U/PLS/ASX...), 42 | // HTTP redirects (Code 301/302). 43 | // This link is especially usefull if you use this API from a platform that is not able to 44 | // do a resolve on its own (e.g. JavaScript in browser) or you just don't want to invest 45 | // the time in decoding playlists yourself. 46 | UrlResolved RadioGoGoURL `json:"url_resolved"` 47 | // URL to an icon or picture that represents the stream. (PNG, JPG) 48 | Favicon RadioGoGoURL `json:"favicon"` 49 | // Tags of the stream with more information about it (string, multivalue, split by comma). 50 | Tags string `json:"tags"` 51 | // Official countrycodes as in ISO 3166-1 alpha-2 52 | CountryCode string `json:"countrycode"` 53 | // Full name of the entity where the station is located inside the country 54 | State string `json:"state"` 55 | // Languages that are spoken in this stream. 56 | Languages string `json:"language"` 57 | // Languages that are spoken in this stream by code ISO 639-2/B. 58 | LanguagesCodes string `json:"languagecodes"` 59 | // Number of votes for this station. This number is by server and only ever increases. 60 | // It will never be reset to 0. 61 | Votes uint64 `json:"votes"` 62 | // Last time when the stream information was changed in the database 63 | LastChangeTime time.Time `json:"lastchangetime_iso8601"` 64 | // The codec of this stream recorded at the last check. 65 | Codec string `json:"codec"` 66 | // The bitrate of this stream recorded at the last check. 67 | Bitrate uint64 `json:"bitrate"` 68 | // Mark if this stream is using HLS distribution or non-HLS. 69 | Hls BoolFromlInt `json:"hls"` 70 | // The current online/offline state of this stream. 71 | // This is a value calculated from multiple measure points in the internet. 72 | // The test servers are located in different countries. It is a majority vote. 73 | LastCheckOk BoolFromlInt `json:"lastcheckok"` 74 | // The last time when any radio-browser server checked the online state of this stream 75 | LastCheckTime time.Time `json:"lastchecktime_iso8601"` 76 | // The last time when the stream was checked for the online status with a positive result 77 | LastCheckOkTime time.Time `json:"lastcheckoktime_iso8601"` 78 | // The last time when this server checked the online state and the metadata of this stream. 79 | LastLocalCheckTime time.Time `json:"lastlocalchecktime_iso8601"` 80 | // The time of the last click recorded for this stream 81 | ClickTimestamp *time.Time `json:"clicktimestamp_iso8601,omitempty"` 82 | // Clicks within the last 24 hours 83 | ClickCount uint64 `json:"clickcount"` 84 | // The difference of the clickcounts within the last 2 days. 85 | // Posivite values mean an increase, negative a decrease of clicks. 86 | ClickTrend int64 `json:"clicktrend"` 87 | // 0 means no error, 1 means that there was an ssl error while connecting to the stream url. 88 | SslError BoolFromlInt `json:"ssl_error"` 89 | // Latitude on earth where the stream is located. 90 | GeoLat *float64 `json:"geo_lat,omitempty"` 91 | // Longitude on earth where the stream is located. 92 | GeoLong *float64 `json:"geo_long,omitempty"` 93 | // Is true, if the stream owner does provide extended information as HTTP headers 94 | // which override the information in the database. 95 | HasExtendedInfo *bool `json:"has_extended_info,omitempty"` 96 | } 97 | 98 | func (bi *BoolFromlInt) UnmarshalJSON(data []byte) error { 99 | switch string(data) { 100 | case "1": 101 | *bi = true 102 | case "0", "null": // "null" to handle if the field is null in JSON 103 | *bi = false 104 | default: 105 | return fmt.Errorf("boolean from int unmarshal error: invalid input %s", data) 106 | } 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /common/station_query.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package common 21 | 22 | // StationQuery represents the type of query that can be performed on a radio station. 23 | type StationQuery string 24 | 25 | // The following constants represent the different types of queries that can be performed on a radio station. 26 | const ( 27 | StationQueryAll StationQuery = "" // Returns all radio stations. 28 | StationQueryByUuid StationQuery = "byuuid" // Returns radio stations by UUID. 29 | StationQueryByName StationQuery = "byname" // Returns radio stations by name. 30 | StationQueryByNameExact StationQuery = "bynameexact" // Returns radio stations by exact name. 31 | StationQueryByCodec StationQuery = "bycodec" // Returns radio stations by codec. 32 | StationQueryByCodecExact StationQuery = "bycodecexact" // Returns radio stations by exact codec. 33 | StationQueryByCountry StationQuery = "bycountry" // Returns radio stations by country. 34 | StationQueryByCountryExact StationQuery = "bycountryexact" // Returns radio stations by exact country. 35 | StationQueryByCountryCodeExact StationQuery = "bycountrycodeexact" // Returns radio stations by exact country code. 36 | StationQueryByState StationQuery = "bystate" // Returns radio stations by state. 37 | StationQueryByStateExact StationQuery = "bystateexact" // Returns radio stations by exact state. 38 | StationQueryByLanguage StationQuery = "bylanguage" // Returns radio stations by language. 39 | StationQueryByLanguageExact StationQuery = "bylanguageexact" // Returns radio stations by exact language. 40 | StationQueryByTag StationQuery = "bytag" // Returns radio stations by tag. 41 | StationQueryByTagExact StationQuery = "bytagexact" // Returns radio stations by exact tag. 42 | ) 43 | 44 | func (m StationQuery) Render() string { 45 | switch m { 46 | case StationQueryByUuid: 47 | return "By UUID" 48 | case StationQueryByName: 49 | return "By Name" 50 | case StationQueryByNameExact: 51 | return "By Exact Name" 52 | case StationQueryByCodec: 53 | return "By Codec" 54 | case StationQueryByCodecExact: 55 | return "By Exact Codec" 56 | case StationQueryByCountry: 57 | return "By Country" 58 | case StationQueryByCountryExact: 59 | return "By Exact Country" 60 | case StationQueryByCountryCodeExact: 61 | return "By Exact Country Code" 62 | case StationQueryByState: 63 | return "By State" 64 | case StationQueryByStateExact: 65 | return "By Exact State" 66 | case StationQueryByLanguage: 67 | return "By Language" 68 | case StationQueryByLanguageExact: 69 | return "By Exact Language" 70 | case StationQueryByTag: 71 | return "By Tag" 72 | case StationQueryByTagExact: 73 | return "By Exact Tag" 74 | } 75 | return "None" 76 | } 77 | 78 | func (m StationQuery) ExampleString() string { 79 | switch m { 80 | case StationQueryByName: 81 | return ` 82 | Examples: 83 | - "BBC Radio" matches stations with "BBC Radio" in their name. 84 | - "Italia" matches stations with "Italia" in their name. 85 | - "Romance" matches stations with "Romance" in their name. 86 | ` 87 | case StationQueryByNameExact: 88 | return ` 89 | Examples: 90 | - "BBC Radio 1" matches stations with "BBC Radio 1" as their name. 91 | - "Radio Italia" matches stations with "Radio Italia" as their name. 92 | - "Radio Romance" mtaches stations with "Radio Romance" as their name. 93 | ` 94 | case StationQueryByCodec: 95 | return ` 96 | Examples: 97 | - "mp3" matches stations with "mp3" in their codec. 98 | - "aac" matches stations with "aac" in their codec. 99 | - "ogg" matches stations with "ogg" in their codec. 100 | ` 101 | case StationQueryByCodecExact: 102 | return ` 103 | Examples: 104 | - "mp3" matches stations with "mp3" as their codec. 105 | - "aac" matches stations with "aac" as their codec. 106 | - "ogg" matches stations with "ogg" as their codec. 107 | ` 108 | case StationQueryByCountry: 109 | return ` 110 | Examples: 111 | - "Italy" matches stations with "Italy" in their country name. 112 | - "United" matches stations with "United" in their country name. 113 | - "Republic" matches stations with "Republic" in their country name. 114 | ` 115 | case StationQueryByCountryExact: 116 | return ` 117 | Examples: 118 | - "Italy" matches stations with "Italy" as their country. 119 | - "Spain" matches stations with "Spain" as their country. 120 | - "Ireland" matches stations with "Ireland" as their country. 121 | ` 122 | case StationQueryByCountryCodeExact: 123 | return ` 124 | Examples: 125 | - "IT" matches stations with "IT" as their country code. 126 | - "US" matches stations with "US" as their country code. 127 | - "UK" matches stations with "UK" as their country code. 128 | ` 129 | case StationQueryByState: 130 | return ` 131 | Examples: 132 | - "Lombardy" matches stations with "Lombardy" in their state. 133 | - "California" matches stations with "California" in their state. 134 | - "New York" matches stations with "New York" in their state. 135 | ` 136 | case StationQueryByStateExact: 137 | return ` 138 | Examples: 139 | - "Lombardy" matches stations with "Lombardy" as their state. 140 | - "California" matches stations with "California" as their state. 141 | - "New York" matches stations with "New York" as their state. 142 | ` 143 | case StationQueryByLanguage: 144 | return ` 145 | Examples: 146 | - "Italian" matches stations with "Italian" in their language. 147 | - "English" matches stations with "English" in their language. 148 | - "Spanish" matches stations with "Spanish" in their language. 149 | ` 150 | case StationQueryByLanguageExact: 151 | return ` 152 | Examples: 153 | - "Italian" matches stations with "Italian" as their language. 154 | - "English" matches stations with "English" as their language. 155 | - "Spanish" matches stations with "Spanish" as their language. 156 | ` 157 | case StationQueryByTag: 158 | return ` 159 | Examples: 160 | - "rock" matches stations with "rock" in their tags. 161 | - "jazz" matches stations with "jazz" in their tags. 162 | - "pop" matches stations with "pop" in their tags. 163 | ` 164 | case StationQueryByTagExact: 165 | return ` 166 | Examples: 167 | - "rock" matches stations with "rock" as their tags. 168 | - "jazz" matches stations with "jazz" as their tags. 169 | - "pop" matches stations with "pop" as their tags. 170 | ` 171 | } 172 | return "" 173 | } 174 | -------------------------------------------------------------------------------- /common/url.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package common 21 | 22 | import ( 23 | "encoding/json" 24 | "net/url" 25 | ) 26 | 27 | // RadioGoGoURL represents a URL for a radio station. 28 | type RadioGoGoURL struct { 29 | URL url.URL 30 | } 31 | 32 | func (m *RadioGoGoURL) UnmarshalJSON(data []byte) error { 33 | 34 | // Unquote the JSON string 35 | var unquotedURL string 36 | err := json.Unmarshal(data, &unquotedURL) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | // Parse the URL string 42 | u, err := url.Parse(unquotedURL) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | m.URL = *u 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package config 21 | 22 | import ( 23 | "errors" 24 | "os" 25 | 26 | "gopkg.in/yaml.v3" 27 | ) 28 | 29 | type Config struct { 30 | Theme struct { 31 | TextColor string `yaml:"textColor"` 32 | PrimaryColor string `yaml:"primaryColor"` 33 | SecondaryColor string `yaml:"secondaryColor"` 34 | TertiaryColor string `yaml:"tertiaryColor"` 35 | ErrorColor string `yaml:"errorColor"` 36 | } 37 | } 38 | 39 | // NewDefaultConfig returns a Config struct with default values for RadioGoGo. 40 | func NewDefaultConfig() Config { 41 | return Config{ 42 | Theme: struct { 43 | TextColor string `yaml:"textColor"` 44 | PrimaryColor string `yaml:"primaryColor"` 45 | SecondaryColor string `yaml:"secondaryColor"` 46 | TertiaryColor string `yaml:"tertiaryColor"` 47 | ErrorColor string `yaml:"errorColor"` 48 | }{ 49 | TextColor: "#ffffff", 50 | PrimaryColor: "#5a4f9f", 51 | SecondaryColor: "#8b77db", 52 | TertiaryColor: "#4e4e4e", 53 | ErrorColor: "#ff0000", 54 | }, 55 | } 56 | } 57 | 58 | // Load reads the configuration file from the given path and decodes it into the Config struct. 59 | // It returns an error if the file cannot be opened or if there is an error decoding the file. 60 | func (c *Config) Load(path string) error { 61 | file, err := os.Open(path) 62 | if err != nil { 63 | return err 64 | } 65 | defer file.Close() 66 | 67 | decoder := yaml.NewDecoder(file) 68 | err = decoder.Decode(&c) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | return nil 74 | } 75 | 76 | // Save saves the configuration to a file at the given path. 77 | // It returns an error if the file cannot be created or if there is an error encoding the configuration. 78 | func (c Config) Save(path string) error { 79 | file, err := os.Create(path) 80 | if err != nil { 81 | return err 82 | } 83 | defer file.Close() 84 | 85 | encoder := yaml.NewEncoder(file) 86 | err = encoder.Encode(c) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | return nil 92 | } 93 | 94 | // LoadOrCreateNew loads the configuration file if it exists, or creates a new one if it doesn't. 95 | // It returns an error if it fails to create the directory or load/save the configuration file. 96 | func (c *Config) LoadOrCreateNew() error { 97 | 98 | err := os.MkdirAll(ConfigDir(), 0755) 99 | 100 | if err != nil { 101 | return err 102 | } 103 | 104 | cfgFile := ConfigFile() 105 | 106 | if _, err := os.Stat(cfgFile); errors.Is(err, os.ErrNotExist) { 107 | err := c.Save(cfgFile) 108 | if err != nil { 109 | return err 110 | } 111 | } else { 112 | err := c.Load(cfgFile) 113 | if err != nil { 114 | return err 115 | } 116 | } 117 | 118 | return nil 119 | 120 | } 121 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | func TestConfig(t *testing.T) { 11 | t.Run("parses from YAML", func(t *testing.T) { 12 | input := ` 13 | theme: 14 | textColor: "#000000" 15 | primaryColor: "#FFFFFF" 16 | secondaryColor: "#CCCCCC" 17 | tertiaryColor: "#999999" 18 | errorColor: "#FF0000" 19 | ` 20 | var cfg Config 21 | err := yaml.Unmarshal([]byte(input), &cfg) 22 | 23 | assert.NoError(t, err) 24 | assert.Equal(t, "#000000", cfg.Theme.TextColor) 25 | assert.Equal(t, "#FFFFFF", cfg.Theme.PrimaryColor) 26 | assert.Equal(t, "#CCCCCC", cfg.Theme.SecondaryColor) 27 | assert.Equal(t, "#999999", cfg.Theme.TertiaryColor) 28 | assert.Equal(t, "#FF0000", cfg.Theme.ErrorColor) 29 | }) 30 | 31 | t.Run("parses from YAML with partial values", func(t *testing.T) { 32 | input := ` 33 | theme: 34 | primaryColor: "#FFFFFF" 35 | tertiaryColor: "#999999" 36 | errorColor: "#FF0000" 37 | ` 38 | var cfg Config 39 | err := yaml.Unmarshal([]byte(input), &cfg) 40 | 41 | assert.NoError(t, err) 42 | assert.Equal(t, "", cfg.Theme.TextColor) 43 | assert.Equal(t, "#FFFFFF", cfg.Theme.PrimaryColor) 44 | assert.Equal(t, "", cfg.Theme.SecondaryColor) 45 | assert.Equal(t, "#999999", cfg.Theme.TertiaryColor) 46 | assert.Equal(t, "#FF0000", cfg.Theme.ErrorColor) 47 | }) 48 | 49 | } 50 | -------------------------------------------------------------------------------- /config/paths.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | ) 8 | 9 | // ConfigDir returns the path to the directory where the application's configuration files are stored. 10 | // On Windows, the directory is %LOCALAPPDATA%\radiogogo. 11 | // On other platforms, the directory is ~/.config/radiogogo. 12 | func ConfigDir() string { 13 | var cfgDir string 14 | if runtime.GOOS == "windows" { 15 | localAppData := os.Getenv("LOCALAPPDATA") 16 | cfgDir = filepath.Join(localAppData, "radiogogo") 17 | } else { 18 | home := os.Getenv("HOME") 19 | cfgDir = filepath.Join(home, ".config", "radiogogo") 20 | } 21 | return cfgDir 22 | } 23 | 24 | // ConfigFile returns the path to the configuration file. 25 | func ConfigFile() string { 26 | return filepath.Join(ConfigDir(), "config.yaml") 27 | } 28 | -------------------------------------------------------------------------------- /data/version.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package data 21 | 22 | const ( 23 | Version = "0.3.2" 24 | UserAgent = "radiogogo/" + Version 25 | ) 26 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1710146030, 9 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1715087517, 24 | "narHash": "sha256-CLU5Tsg24Ke4+7sH8azHWXKd0CFd4mhLWfhYgUiDBpQ=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "b211b392b8486ee79df6cdfb1157ad2133427a29", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "RadioGoGo Flake"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { 10 | self, 11 | nixpkgs, 12 | flake-utils, 13 | ... 14 | }: 15 | flake-utils.lib.eachDefaultSystem (system: let 16 | pkgs = import nixpkgs { 17 | inherit system; 18 | overlays = [ 19 | (final: prev: { 20 | radiogogo = prev.callPackage ./nix/package.nix {}; 21 | }) 22 | ]; 23 | }; 24 | in { 25 | devShells.default = pkgs.mkShell { 26 | buildInputs = with pkgs; [ 27 | go 28 | delve 29 | gopls 30 | go-tools 31 | gotools 32 | ffmpeg 33 | ]; 34 | }; 35 | packages = { 36 | radiogogo = pkgs.radiogogo; 37 | }; 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zi0p4tch0/radiogogo 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.18.0 7 | github.com/charmbracelet/bubbletea v0.26.2 8 | github.com/charmbracelet/lipgloss v0.10.0 9 | github.com/google/uuid v1.6.0 10 | github.com/stretchr/testify v1.9.0 11 | gopkg.in/yaml.v3 v3.0.1 12 | ) 13 | 14 | require ( 15 | github.com/atotto/clipboard v0.1.4 // indirect 16 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 19 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 20 | github.com/mattn/go-isatty v0.0.20 // indirect 21 | github.com/mattn/go-localereader v0.0.1 // indirect 22 | github.com/mattn/go-runewidth v0.0.15 // indirect 23 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 24 | github.com/muesli/cancelreader v0.2.2 // indirect 25 | github.com/muesli/reflow v0.3.0 // indirect 26 | github.com/muesli/termenv v0.15.2 // indirect 27 | github.com/pmezard/go-difflib v1.0.0 // indirect 28 | github.com/rivo/uniseg v0.4.7 // indirect 29 | golang.org/x/sync v0.7.0 // indirect 30 | golang.org/x/sys v0.20.0 // indirect 31 | golang.org/x/term v0.20.0 // indirect 32 | golang.org/x/text v0.15.0 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= 6 | github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= 7 | github.com/charmbracelet/bubbletea v0.26.2 h1:Eeb+n75Om9gQ+I6YpbCXQRKHt5Pn4vMwusQpwLiEgJQ= 8 | github.com/charmbracelet/bubbletea v0.26.2/go.mod h1:6I0nZ3YHUrQj7YHIHlM8RySX4ZIthTliMY+W8X8b+Gs= 9 | github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= 10 | github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 14 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 15 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 16 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 17 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 18 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 19 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 20 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 21 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 22 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 23 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 24 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 25 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 26 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 27 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 28 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 29 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 30 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 31 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 32 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 33 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 34 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 35 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 36 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 37 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 38 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 39 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 40 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 41 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 42 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 43 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 44 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 47 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 48 | golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= 49 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 50 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= 51 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 52 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 54 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 55 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 56 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matteo-pacini/RadioGoGo/efad593841c4f8ba8b5918763a57ac6757f9b0f2/logo.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package main 21 | 22 | import ( 23 | "fmt" 24 | "os" 25 | 26 | "github.com/zi0p4tch0/radiogogo/config" 27 | "github.com/zi0p4tch0/radiogogo/models" 28 | 29 | tea "github.com/charmbracelet/bubbletea" 30 | ) 31 | 32 | func main() { 33 | 34 | // Create config 35 | 36 | cfg := config.NewDefaultConfig() 37 | err := cfg.LoadOrCreateNew() 38 | 39 | if err != nil { 40 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) 41 | fmt.Fprintf(os.Stderr, "Using default config\n") 42 | cfg = config.NewDefaultConfig() 43 | } 44 | 45 | // Create model 46 | 47 | model, err := models.NewDefaultModel(cfg) 48 | 49 | if err != nil { 50 | fmt.Fprintf(os.Stderr, "Error initializing model: %v\n", err) 51 | os.Exit(1) 52 | } 53 | 54 | p := tea.NewProgram(model, tea.WithAltScreen()) 55 | 56 | if _, err := p.Run(); err != nil { 57 | fmt.Fprintf(os.Stderr, "Error starting program: %v\n", err) 58 | os.Exit(1) 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /make_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | rm -rf bin 2> /dev/null 6 | 7 | if [ -z "$1" ]; then 8 | echo "Please provide a version number" 9 | exit 1 10 | fi 11 | 12 | TARGETS=( 13 | # macOS 14 | "darwin|arm64" 15 | "darwin|amd64" 16 | # Linux 17 | "linux|386" 18 | "linux|arm" 19 | "linux|arm64" 20 | "linux|amd64" 21 | # Windows 22 | "windows|386" 23 | "windows|amd64" 24 | "windows|arm" 25 | "windows|arm64" 26 | # FreeBSD 27 | "freebsd|386" 28 | "freebsd|arm" 29 | "freebsd|amd64" 30 | "freebsd|arm64" 31 | # OpenBSD 32 | "openbsd|386" 33 | "openbsd|arm" 34 | "openbsd|amd64" 35 | "openbsd|arm64" 36 | # NetBSD 37 | "netbsd|386" 38 | "netbsd|arm" 39 | "netbsd|amd64" 40 | "netbsd|arm64" 41 | ) 42 | 43 | mkdir bin 44 | touch bin/checksums.txt 45 | 46 | for target in "${TARGETS[@]}"; do 47 | IFS='|' read -ra target <<< "$target" 48 | GOOS=${target[0]} 49 | GOARCH=${target[1]} 50 | 51 | OUTPUT="bin/radiogogo" 52 | 53 | # Output name: adjust for windows 54 | if [ "$GOOS" == "windows" ]; then 55 | OUTPUT+=".exe" 56 | fi 57 | 58 | echo "Building for $GOOS/$GOARCH" 59 | GOOS=$GOOS GOARCH=$GOARCH go build -o $OUTPUT 60 | 61 | zip -j "bin/radiogogo_$1_${GOOS}_${GOARCH}.zip" $OUTPUT 62 | rm -rf $OUTPUT 63 | 64 | cd bin && shasum -a 256 "radiogogo_$1_${GOOS}_${GOARCH}.zip" >> "checksums.txt" && cd .. 65 | 66 | done 67 | -------------------------------------------------------------------------------- /mocks/browser_mock.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package mocks 21 | 22 | import "github.com/zi0p4tch0/radiogogo/common" 23 | 24 | type MockRadioBrowserService struct { 25 | GetStationsFunc func( 26 | stationQuery common.StationQuery, 27 | searchTerm string, 28 | order string, 29 | reverse bool, 30 | offset uint64, 31 | limit uint64, 32 | hideBroken bool, 33 | ) ([]common.Station, error) 34 | 35 | ClickStationFunc func(station common.Station) (common.ClickStationResponse, error) 36 | } 37 | 38 | func (m *MockRadioBrowserService) GetStations( 39 | stationQuery common.StationQuery, 40 | searchTerm string, 41 | order string, 42 | reverse bool, 43 | offset uint64, 44 | limit uint64, 45 | hideBroken bool, 46 | ) ([]common.Station, error) { 47 | return m.GetStationsFunc(stationQuery, searchTerm, order, reverse, offset, limit, hideBroken) 48 | } 49 | 50 | func (m *MockRadioBrowserService) ClickStation(station common.Station) (common.ClickStationResponse, error) { 51 | return m.ClickStationFunc(station) 52 | } 53 | -------------------------------------------------------------------------------- /mocks/dns_lookup_mock.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package mocks 21 | 22 | type MockDNSLookupService struct { 23 | LookupIPFunc func(host string) ([]string, error) 24 | } 25 | 26 | func (m *MockDNSLookupService) LookupIP(host string) ([]string, error) { 27 | if m.LookupIPFunc != nil { 28 | return m.LookupIPFunc(host) 29 | } 30 | return []string{}, nil 31 | } 32 | -------------------------------------------------------------------------------- /mocks/http_client_mock.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package mocks 21 | 22 | import "net/http" 23 | 24 | type MockHttpClient struct { 25 | DoFunc func(req *http.Request) (*http.Response, error) 26 | } 27 | 28 | func (m *MockHttpClient) Do(req *http.Request) (*http.Response, error) { 29 | return m.DoFunc(req) 30 | } 31 | -------------------------------------------------------------------------------- /mocks/playback_manager_mock.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package mocks 21 | 22 | import "github.com/zi0p4tch0/radiogogo/common" 23 | 24 | type MockPlaybackManagerService struct { 25 | NameResult string 26 | IsAvailableResult bool 27 | NotAvailableErrorStringResult string 28 | IsPlayingResult bool 29 | PlayStationFunc func(station common.Station, volume int) error 30 | StopStationFunc func() error 31 | VolumeMinResult int 32 | VolumeDefaultResult int 33 | VolumeMaxResult int 34 | VolumeIsPercentageResult bool 35 | } 36 | 37 | func (m *MockPlaybackManagerService) IsAvailable() bool { 38 | return m.IsAvailableResult 39 | } 40 | 41 | func (m *MockPlaybackManagerService) Name() string { 42 | return m.NameResult 43 | } 44 | 45 | func (m *MockPlaybackManagerService) NotAvailableErrorString() string { 46 | return m.NotAvailableErrorStringResult 47 | } 48 | 49 | func (m *MockPlaybackManagerService) IsPlaying() bool { 50 | return m.IsPlayingResult 51 | } 52 | 53 | func (m *MockPlaybackManagerService) PlayStation(station common.Station, volume int) error { 54 | return m.PlayStationFunc(station, volume) 55 | } 56 | 57 | func (m *MockPlaybackManagerService) StopStation() error { 58 | return m.StopStationFunc() 59 | } 60 | 61 | func (m *MockPlaybackManagerService) VolumeMin() int { 62 | return m.VolumeMinResult 63 | } 64 | 65 | func (m *MockPlaybackManagerService) VolumeDefault() int { 66 | return m.VolumeDefaultResult 67 | } 68 | 69 | func (m *MockPlaybackManagerService) VolumeMax() int { 70 | return m.VolumeMaxResult 71 | } 72 | 73 | func (m *MockPlaybackManagerService) VolumeIsPercentage() bool { 74 | return m.VolumeIsPercentageResult 75 | } 76 | -------------------------------------------------------------------------------- /models/error_fatal.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "fmt" 24 | "time" 25 | 26 | tea "github.com/charmbracelet/bubbletea" 27 | ) 28 | 29 | const ( 30 | // How many ticks (one tick per second) to wait before quitting 31 | quitTicks = 30 32 | ) 33 | 34 | // Messages 35 | 36 | type quitTickMsg struct{} 37 | 38 | // Model 39 | 40 | type ErrorModel struct { 41 | theme Theme 42 | 43 | message string 44 | 45 | tickCount int 46 | width int 47 | height int 48 | } 49 | 50 | func NewErrorModel(theme Theme, err string) ErrorModel { 51 | 52 | return ErrorModel{ 53 | theme: theme, 54 | message: err, 55 | } 56 | 57 | } 58 | 59 | func (m ErrorModel) Init() tea.Cmd { 60 | return tea.Tick(time.Second, func(t time.Time) tea.Msg { 61 | return quitTickMsg{} 62 | }) 63 | } 64 | 65 | func (m ErrorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 66 | 67 | switch msg := msg.(type) { 68 | case tea.KeyMsg: 69 | switch msg.String() { 70 | case "q": 71 | return m, quitCmd 72 | } 73 | case quitTickMsg: 74 | m.tickCount++ 75 | if m.tickCount >= quitTicks { 76 | return m, quitCmd 77 | } 78 | return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { 79 | return quitTickMsg{} 80 | }) 81 | } 82 | 83 | return m, nil 84 | 85 | } 86 | 87 | func (m ErrorModel) View() string { 88 | 89 | message := fmt.Sprintf("%s\n\nQuitting in %d seconds (or press \"q\" to exit now)...", m.message, quitTicks-m.tickCount) 90 | 91 | return "\n" + m.theme.ErrorText.Render(message) + "\n\n" 92 | 93 | } 94 | 95 | func (m *ErrorModel) SetWidthAndHeight(width int, height int) { 96 | m.width = width 97 | m.height = height 98 | } 99 | -------------------------------------------------------------------------------- /models/error_fatal_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "testing" 24 | 25 | tea "github.com/charmbracelet/bubbletea" 26 | "github.com/stretchr/testify/assert" 27 | ) 28 | 29 | func TestErrorModel_Init(t *testing.T) { 30 | 31 | model := NewErrorModel(Theme{}, "this is an error") 32 | 33 | t.Run("broadcasts a quitTickMsg", func(t *testing.T) { 34 | 35 | cmd := model.Init() 36 | assert.NotNil(t, cmd) 37 | 38 | msg := cmd() 39 | assert.IsType(t, quitTickMsg{}, msg) 40 | 41 | }) 42 | 43 | } 44 | 45 | func TestErrorModel_Update(t *testing.T) { 46 | 47 | model := NewErrorModel(Theme{}, "this is an error") 48 | 49 | t.Run("broadcasts a quitMsg when 'q' is pressed", func(t *testing.T) { 50 | 51 | input := tea.Msg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) 52 | 53 | _, cmd := model.Update(input) 54 | assert.NotNil(t, cmd) 55 | 56 | msg := cmd() 57 | 58 | assert.IsType(t, quitMsg{}, msg) 59 | 60 | }) 61 | 62 | t.Run("broadcasts a quitTickMsg after receiving onem and increments the tick count", func(t *testing.T) { 63 | 64 | input := tea.Msg(quitTickMsg{}) 65 | newModel, cmd := model.Update(input) 66 | 67 | assert.Equal(t, newModel.(ErrorModel).tickCount, 1) 68 | assert.NotNil(t, cmd) 69 | 70 | msg := cmd() 71 | assert.IsType(t, quitTickMsg{}, msg) 72 | 73 | }) 74 | 75 | t.Run("quits after 30 ticks", func(t *testing.T) { 76 | 77 | model.tickCount = 29 78 | 79 | _, cmd := model.Update(quitTickMsg{}) 80 | assert.NotNil(t, cmd) 81 | msg := cmd() 82 | assert.IsType(t, quitMsg{}, msg) 83 | 84 | }) 85 | 86 | } 87 | -------------------------------------------------------------------------------- /models/header.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "fmt" 24 | 25 | tea "github.com/charmbracelet/bubbletea" 26 | "github.com/charmbracelet/lipgloss" 27 | "github.com/zi0p4tch0/radiogogo/data" 28 | "github.com/zi0p4tch0/radiogogo/playback" 29 | ) 30 | 31 | type HeaderModel struct { 32 | theme Theme 33 | 34 | width int 35 | showOffset bool 36 | stationOffset int 37 | totalStations int 38 | } 39 | 40 | func NewHeaderModel(theme Theme, playbackManager playback.PlaybackManagerService) HeaderModel { 41 | return HeaderModel{ 42 | theme: theme, 43 | } 44 | } 45 | 46 | func (m HeaderModel) Init() tea.Cmd { 47 | return nil 48 | } 49 | 50 | func (m HeaderModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 51 | switch msg := msg.(type) { 52 | case stationCursorMovedMsg: 53 | m.stationOffset = msg.offset 54 | m.totalStations = msg.totalStations 55 | } 56 | return m, nil 57 | } 58 | 59 | func (m HeaderModel) View() string { 60 | 61 | header := m.theme.PrimaryBlock.Render("radiogogo") 62 | version := m.theme.SecondaryBlock.Render(fmt.Sprintf("v%s", data.Version)) 63 | 64 | leftHeader := header + version 65 | 66 | if m.showOffset { 67 | 68 | rightHeader := m.theme.PrimaryBlock.Render(fmt.Sprintf("%d/%d", m.stationOffset+1, m.totalStations)) 69 | 70 | fillerWidth := m.width - lipgloss.Width(leftHeader) - lipgloss.Width(rightHeader) 71 | filler := lipgloss.NewStyle().Width(fillerWidth).Render(" ") 72 | 73 | return leftHeader + filler + rightHeader + "\n" 74 | 75 | } else { 76 | 77 | return leftHeader + "\n" 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /models/loading.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "github.com/zi0p4tch0/radiogogo/api" 24 | "github.com/zi0p4tch0/radiogogo/common" 25 | 26 | "github.com/charmbracelet/bubbles/spinner" 27 | tea "github.com/charmbracelet/bubbletea" 28 | ) 29 | 30 | type LoadingModel struct { 31 | theme Theme 32 | 33 | spinnerModel spinner.Model 34 | query common.StationQuery 35 | queryText string 36 | width int 37 | height int 38 | 39 | browser api.RadioBrowserService 40 | } 41 | 42 | func NewLoadingModel( 43 | theme Theme, 44 | browser api.RadioBrowserService, 45 | query common.StationQuery, 46 | queryText string, 47 | ) LoadingModel { 48 | 49 | s := spinner.New() 50 | s.Spinner = spinner.Dot 51 | s.Style = theme.SecondaryText 52 | 53 | return LoadingModel{ 54 | theme: theme, 55 | spinnerModel: s, 56 | query: query, 57 | queryText: queryText, 58 | browser: browser, 59 | } 60 | 61 | } 62 | 63 | func (m LoadingModel) Init() tea.Cmd { 64 | return tea.Batch(m.spinnerModel.Tick, searchStations(m.browser, m.query, m.queryText)) 65 | } 66 | 67 | func (m LoadingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 68 | newSpinnerModel, cmd := m.spinnerModel.Update(msg) 69 | m.spinnerModel = newSpinnerModel 70 | return m, cmd 71 | } 72 | 73 | func (m LoadingModel) View() string { 74 | return "\n" + m.spinnerModel.View() + " Fetching radio stations..." 75 | } 76 | 77 | // Commands 78 | 79 | func searchStations(browser api.RadioBrowserService, query common.StationQuery, queryText string) tea.Cmd { 80 | return func() tea.Msg { 81 | stations, err := browser.GetStations(query, queryText, "votes", true, 0, 100, true) 82 | if err != nil { 83 | return switchToErrorModelMsg{err: err.Error()} 84 | } 85 | return switchToStationsModelMsg{stations: stations} 86 | } 87 | } 88 | 89 | func (m *LoadingModel) SetWidthAndHeight(width int, height int) { 90 | m.width = width 91 | m.height = height 92 | } 93 | -------------------------------------------------------------------------------- /models/loading_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "io" 24 | "testing" 25 | 26 | "github.com/zi0p4tch0/radiogogo/common" 27 | 28 | "github.com/zi0p4tch0/radiogogo/mocks" 29 | 30 | "github.com/charmbracelet/bubbles/spinner" 31 | tea "github.com/charmbracelet/bubbletea" 32 | "github.com/stretchr/testify/assert" 33 | ) 34 | 35 | func TestLoadingModel_Init(t *testing.T) { 36 | 37 | t.Run("starts the spinner", func(t *testing.T) { 38 | 39 | mockBrowser := mocks.MockRadioBrowserService{} 40 | model := NewLoadingModel(Theme{}, &mockBrowser, common.StationQueryAll, "text") 41 | 42 | cmd := model.Init() 43 | assert.NotNil(t, cmd) 44 | 45 | var batchMsg tea.BatchMsg = cmd().(tea.BatchMsg) 46 | 47 | found := false 48 | for _, msg := range batchMsg { 49 | currentMsg := msg() 50 | if _, ok := currentMsg.(spinner.TickMsg); ok { 51 | found = true 52 | break 53 | } 54 | } 55 | 56 | assert.True(t, found) 57 | 58 | }) 59 | 60 | t.Run("searches for stations and broadcasts switchToStationsModelMsg on success", func(t *testing.T) { 61 | 62 | mockBrowser := mocks.MockRadioBrowserService{ 63 | GetStationsFunc: func(stationQuery common.StationQuery, searchTerm string, order string, reverse bool, offset uint64, limit uint64, hideBroken bool) ([]common.Station, error) { 64 | return []common.Station{}, nil 65 | }, 66 | ClickStationFunc: func(station common.Station) (common.ClickStationResponse, error) { 67 | return common.ClickStationResponse{}, nil 68 | }, 69 | } 70 | 71 | model := NewLoadingModel(Theme{}, &mockBrowser, common.StationQueryAll, "text") 72 | 73 | cmd := model.Init() 74 | assert.NotNil(t, cmd) 75 | 76 | var batchMsg tea.BatchMsg = cmd().(tea.BatchMsg) 77 | 78 | found := false 79 | for _, msg := range batchMsg { 80 | currentMsg := msg() 81 | if _, ok := currentMsg.(switchToStationsModelMsg); ok { 82 | found = true 83 | break 84 | } 85 | } 86 | 87 | assert.True(t, found) 88 | 89 | }) 90 | 91 | t.Run("searches for stations and broadcasts switchToErrorModelMsg on error", func(t *testing.T) { 92 | 93 | mockBrowser := mocks.MockRadioBrowserService{ 94 | GetStationsFunc: func(stationQuery common.StationQuery, searchTerm string, order string, reverse bool, offset uint64, limit uint64, hideBroken bool) ([]common.Station, error) { 95 | return nil, io.EOF 96 | }, 97 | ClickStationFunc: func(station common.Station) (common.ClickStationResponse, error) { 98 | return common.ClickStationResponse{}, io.EOF 99 | }, 100 | } 101 | 102 | model := NewLoadingModel(Theme{}, &mockBrowser, common.StationQueryAll, "text") 103 | 104 | cmd := model.Init() 105 | assert.NotNil(t, cmd) 106 | 107 | var batchMsg tea.BatchMsg = cmd().(tea.BatchMsg) 108 | 109 | found := false 110 | for _, msg := range batchMsg { 111 | currentMsg := msg() 112 | if _, ok := currentMsg.(switchToErrorModelMsg); ok { 113 | found = true 114 | break 115 | } 116 | } 117 | 118 | assert.True(t, found) 119 | 120 | }) 121 | 122 | } 123 | -------------------------------------------------------------------------------- /models/model.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "github.com/zi0p4tch0/radiogogo/api" 24 | "github.com/zi0p4tch0/radiogogo/common" 25 | "github.com/zi0p4tch0/radiogogo/config" 26 | "github.com/zi0p4tch0/radiogogo/playback" 27 | 28 | tea "github.com/charmbracelet/bubbletea" 29 | "github.com/charmbracelet/lipgloss" 30 | ) 31 | 32 | type modelState int 33 | 34 | const ( 35 | bootState modelState = iota 36 | searchState 37 | errorState 38 | loadingState 39 | stationsState 40 | ) 41 | 42 | // State switching messages 43 | 44 | type switchToErrorModelMsg struct { 45 | err string 46 | } 47 | type switchToSearchModelMsg struct { 48 | } 49 | type switchToLoadingModelMsg struct { 50 | query common.StationQuery 51 | queryText string 52 | } 53 | type switchToStationsModelMsg struct { 54 | stations []common.Station 55 | } 56 | 57 | // UI messages 58 | 59 | type bottomBarUpdateMsg struct { 60 | commands []string 61 | } 62 | 63 | // Quit message 64 | 65 | type quitMsg struct{} 66 | 67 | func quitCmd() tea.Msg { 68 | return quitMsg{} 69 | } 70 | 71 | // Commands 72 | 73 | func checkIfPlaybackIsPossibleCmd(playbackManager playback.PlaybackManagerService) tea.Cmd { 74 | return func() tea.Msg { 75 | if !playbackManager.IsAvailable() { 76 | return switchToErrorModelMsg{ 77 | err: playbackManager.NotAvailableErrorString(), 78 | } 79 | } 80 | return switchToSearchModelMsg{} 81 | } 82 | } 83 | 84 | // Model 85 | 86 | type Model struct { 87 | 88 | // Theme 89 | theme Theme 90 | 91 | // Models 92 | headerModel HeaderModel 93 | searchModel SearchModel 94 | errorModel ErrorModel 95 | loadingModel LoadingModel 96 | stationsModel StationsModel 97 | bottomBarCommands []string 98 | 99 | // State 100 | state modelState 101 | width int 102 | height int 103 | browser api.RadioBrowserService 104 | playbackManager playback.PlaybackManagerService 105 | } 106 | 107 | func NewDefaultModel(config config.Config) (Model, error) { 108 | 109 | browser, err := api.NewRadioBrowser() 110 | if err != nil { 111 | return Model{}, err 112 | } 113 | 114 | playbackManager := playback.NewFFPlaybackManager() 115 | 116 | return NewModel(config, browser, playbackManager), nil 117 | 118 | } 119 | 120 | func NewModel( 121 | config config.Config, 122 | browser api.RadioBrowserService, 123 | playbackManager playback.PlaybackManagerService, 124 | ) Model { 125 | 126 | theme := NewTheme(config) 127 | 128 | return Model{ 129 | theme: theme, 130 | headerModel: NewHeaderModel(theme, playbackManager), 131 | state: bootState, 132 | browser: browser, 133 | playbackManager: playbackManager, 134 | } 135 | } 136 | 137 | func (m Model) Init() tea.Cmd { 138 | return checkIfPlaybackIsPossibleCmd(m.playbackManager) 139 | } 140 | 141 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 142 | 143 | // Top-level messages 144 | switch msg := msg.(type) { 145 | case stationCursorMovedMsg: 146 | m.headerModel.totalStations = msg.totalStations 147 | m.headerModel.stationOffset = msg.offset 148 | return m, nil 149 | case tea.WindowSizeMsg: 150 | m.width = msg.Width 151 | m.height = msg.Height 152 | m.headerModel.width = msg.Width 153 | childHeight := m.height - 2 // 2 = header height + bottom bar height 154 | switch m.state { 155 | case searchState: 156 | m.searchModel.SetWidthAndHeight(m.width, childHeight) 157 | case loadingState: 158 | m.loadingModel.SetWidthAndHeight(m.width, childHeight) 159 | case stationsState: 160 | m.stationsModel.SetWidthAndHeight(m.width, childHeight) 161 | case errorState: 162 | m.errorModel.SetWidthAndHeight(m.width, childHeight) 163 | } 164 | return m, nil 165 | case quitMsg: 166 | return m, tea.Quit 167 | case bottomBarUpdateMsg: 168 | m.bottomBarCommands = msg.commands 169 | return m, nil 170 | } 171 | 172 | // State transitions 173 | 174 | childHeight := m.height - 2 // 2 = header height + bottom bar height 175 | 176 | switch msg := msg.(type) { 177 | case switchToSearchModelMsg: 178 | m.headerModel.showOffset = false 179 | m.searchModel = NewSearchModel(m.theme) 180 | m.searchModel.SetWidthAndHeight(m.width, childHeight) 181 | m.state = searchState 182 | return m, m.searchModel.Init() 183 | case switchToLoadingModelMsg: 184 | m.headerModel.showOffset = false 185 | m.loadingModel = NewLoadingModel(m.theme, m.browser, msg.query, msg.queryText) 186 | m.loadingModel.SetWidthAndHeight(m.width, childHeight) 187 | m.state = loadingState 188 | return m, m.loadingModel.Init() 189 | case switchToStationsModelMsg: 190 | m.headerModel.showOffset = true 191 | m.stationsModel = NewStationsModel(m.theme, m.browser, m.playbackManager, msg.stations) 192 | m.stationsModel.SetWidthAndHeight(m.width, childHeight) 193 | m.state = stationsState 194 | return m, m.stationsModel.Init() 195 | case switchToErrorModelMsg: 196 | m.headerModel.showOffset = false 197 | m.errorModel = NewErrorModel(m.theme, msg.err) 198 | m.errorModel.SetWidthAndHeight(m.width, childHeight) 199 | m.state = errorState 200 | return m, m.errorModel.Init() 201 | } 202 | 203 | // State handling 204 | 205 | switch m.state { 206 | case searchState: 207 | newSearchModel, cmd := m.searchModel.Update(msg) 208 | m.searchModel = newSearchModel.(SearchModel) 209 | return m, cmd 210 | case loadingState: 211 | newLoadingModel, cmd := m.loadingModel.Update(msg) 212 | m.loadingModel = newLoadingModel.(LoadingModel) 213 | return m, cmd 214 | case stationsState: 215 | newStationsModel, cmd := m.stationsModel.Update(msg) 216 | m.stationsModel = newStationsModel.(StationsModel) 217 | return m, cmd 218 | case errorState: 219 | newErrorModel, cmd := m.errorModel.Update(msg) 220 | m.errorModel = newErrorModel.(ErrorModel) 221 | return m, cmd 222 | } 223 | 224 | return m, nil 225 | } 226 | 227 | func (m Model) View() string { 228 | 229 | var view string 230 | 231 | view = m.headerModel.View() 232 | 233 | var currentView string 234 | 235 | switch m.state { 236 | case bootState: 237 | currentView = "\nInitializing..." 238 | case searchState: 239 | currentView = m.searchModel.View() 240 | case loadingState: 241 | currentView = m.loadingModel.View() 242 | case stationsState: 243 | currentView = m.stationsModel.View() 244 | case errorState: 245 | currentView = m.errorModel.View() 246 | } 247 | 248 | currentViewHeight := lipgloss.Height(currentView) 249 | 250 | // Render the current view 251 | 252 | view += currentView 253 | 254 | // Push the bottom bar at the bottom of the terminal 255 | 256 | view += lipgloss.NewStyle(). 257 | Height(m.height - currentViewHeight). 258 | Render() 259 | 260 | // Render bottom bar 261 | 262 | view += m.theme.StyleBottomBar(m.bottomBarCommands) 263 | 264 | return view 265 | } 266 | -------------------------------------------------------------------------------- /models/model_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "testing" 24 | 25 | "github.com/zi0p4tch0/radiogogo/config" 26 | "github.com/zi0p4tch0/radiogogo/mocks" 27 | 28 | tea "github.com/charmbracelet/bubbletea" 29 | "github.com/stretchr/testify/assert" 30 | ) 31 | 32 | func TestCheckIfPlaybackIsPossibleCmd(t *testing.T) { 33 | 34 | t.Run("returns switchToErrorModelMsg if playback is not available", func(t *testing.T) { 35 | 36 | playbackManager := mocks.MockPlaybackManagerService{ 37 | IsAvailableResult: false, 38 | } 39 | 40 | msg := checkIfPlaybackIsPossibleCmd(&playbackManager)() 41 | 42 | assert.IsType(t, switchToErrorModelMsg{}, msg) 43 | 44 | }) 45 | 46 | t.Run("returns switchToSearchModelMsg if playback is available", func(t *testing.T) { 47 | 48 | playbackManager := mocks.MockPlaybackManagerService{ 49 | IsAvailableResult: true, 50 | } 51 | 52 | msg := checkIfPlaybackIsPossibleCmd(&playbackManager)() 53 | 54 | assert.IsType(t, switchToSearchModelMsg{}, msg) 55 | 56 | }) 57 | 58 | } 59 | 60 | func TestModel_Init(t *testing.T) { 61 | 62 | t.Run("starts the search model if playback is available", func(t *testing.T) { 63 | 64 | playbackManager := mocks.MockPlaybackManagerService{ 65 | IsAvailableResult: true, 66 | } 67 | 68 | browser := mocks.MockRadioBrowserService{} 69 | 70 | model := NewModel(config.Config{}, &browser, &playbackManager) 71 | 72 | cmd := model.Init() 73 | assert.NotNil(t, cmd) 74 | 75 | msg := cmd() 76 | 77 | assert.IsType(t, switchToSearchModelMsg{}, msg) 78 | 79 | }) 80 | 81 | t.Run("starts the error model if playback is not available", func(t *testing.T) { 82 | 83 | playbackManager := mocks.MockPlaybackManagerService{ 84 | IsAvailableResult: false, 85 | } 86 | 87 | browser := mocks.MockRadioBrowserService{} 88 | 89 | model := NewModel(config.Config{}, &browser, &playbackManager) 90 | 91 | cmd := model.Init() 92 | assert.NotNil(t, cmd) 93 | 94 | msg := cmd() 95 | 96 | assert.IsType(t, switchToErrorModelMsg{}, msg) 97 | 98 | }) 99 | 100 | } 101 | 102 | func TestModel_Update(t *testing.T) { 103 | 104 | t.Run("stores terminal size changes and returns a nil command", func(t *testing.T) { 105 | 106 | browser := mocks.MockRadioBrowserService{} 107 | playbackManager := mocks.MockPlaybackManagerService{} 108 | 109 | model := NewModel(config.Config{}, &browser, &playbackManager) 110 | 111 | msg := tea.WindowSizeMsg{Width: 100, Height: 100} 112 | 113 | newModel, cmd := model.Update(tea.Msg(msg)) 114 | 115 | assert.Equal(t, 100, newModel.(Model).width) 116 | assert.Equal(t, 100, newModel.(Model).height) 117 | assert.Nil(t, cmd) 118 | 119 | }) 120 | 121 | t.Run("propagates adjusted terminal size changes to SearchModel if active", func(t *testing.T) { 122 | 123 | browser := mocks.MockRadioBrowserService{} 124 | playbackManager := mocks.MockPlaybackManagerService{} 125 | 126 | model := NewModel(config.Config{}, &browser, &playbackManager) 127 | model.state = searchState 128 | 129 | msg := tea.WindowSizeMsg{Width: 100, Height: 100} 130 | 131 | newModel, cmd := model.Update(tea.Msg(msg)) 132 | 133 | assert.Equal(t, 100, newModel.(Model).searchModel.width) 134 | assert.Equal(t, 98 /* -2 for top and bottom bars */, newModel.(Model).searchModel.height) 135 | 136 | assert.Nil(t, cmd) 137 | 138 | }) 139 | 140 | t.Run("propagates adjusted terminal size changes to ErrorModel if active", func(t *testing.T) { 141 | 142 | browser := mocks.MockRadioBrowserService{} 143 | playbackManager := mocks.MockPlaybackManagerService{} 144 | 145 | model := NewModel(config.Config{}, &browser, &playbackManager) 146 | model.state = errorState 147 | 148 | msg := tea.WindowSizeMsg{Width: 100, Height: 100} 149 | 150 | newModel, cmd := model.Update(tea.Msg(msg)) 151 | 152 | assert.Equal(t, 100, newModel.(Model).errorModel.width) 153 | assert.Equal(t, 98 /* -2 for top and bottom bars */, newModel.(Model).errorModel.height) 154 | 155 | assert.Nil(t, cmd) 156 | 157 | }) 158 | 159 | t.Run("propagates adjusted terminal size changes to LoadingModel if active", func(t *testing.T) { 160 | 161 | browser := mocks.MockRadioBrowserService{} 162 | playbackManager := mocks.MockPlaybackManagerService{} 163 | 164 | model := NewModel(config.Config{}, &browser, &playbackManager) 165 | model.state = loadingState 166 | 167 | msg := tea.WindowSizeMsg{Width: 100, Height: 100} 168 | 169 | newModel, cmd := model.Update(tea.Msg(msg)) 170 | 171 | assert.Equal(t, 100, newModel.(Model).loadingModel.width) 172 | assert.Equal(t, 98 /* -2 for top and bottom bars */, newModel.(Model).loadingModel.height) 173 | 174 | assert.Nil(t, cmd) 175 | 176 | }) 177 | 178 | t.Run("propagates adjusted terminal size changes to StationsModel if active", func(t *testing.T) { 179 | 180 | browser := mocks.MockRadioBrowserService{} 181 | playbackManager := mocks.MockPlaybackManagerService{} 182 | 183 | model := NewModel(config.Config{}, &browser, &playbackManager) 184 | model.state = stationsState 185 | 186 | msg := tea.WindowSizeMsg{Width: 100, Height: 100} 187 | 188 | newModel, cmd := model.Update(tea.Msg(msg)) 189 | 190 | assert.Equal(t, 100, newModel.(Model).stationsModel.width) 191 | assert.Equal(t, 98 /* -2 for top and bottom bars */, newModel.(Model).stationsModel.height) 192 | 193 | assert.Nil(t, cmd) 194 | 195 | }) 196 | 197 | t.Run("broadcasts a tea.QuitMsg command if a quitMsg is received", func(t *testing.T) { 198 | 199 | browser := mocks.MockRadioBrowserService{} 200 | playbackManager := mocks.MockPlaybackManagerService{} 201 | 202 | model := NewModel(config.Config{}, &browser, &playbackManager) 203 | 204 | msg := quitMsg{} 205 | 206 | _, cmd := model.Update(tea.Msg(msg)) 207 | 208 | returnedMsg := cmd() 209 | 210 | assert.IsType(t, tea.QuitMsg{}, returnedMsg) 211 | 212 | }) 213 | 214 | t.Run("stores bottom bar commands and returns a nil command", func(t *testing.T) { 215 | 216 | browser := mocks.MockRadioBrowserService{} 217 | playbackManager := mocks.MockPlaybackManagerService{} 218 | 219 | model := NewModel(config.Config{}, &browser, &playbackManager) 220 | 221 | msg := bottomBarUpdateMsg{commands: []string{"test"}} 222 | 223 | newModel, cmd := model.Update(tea.Msg(msg)) 224 | 225 | assert.Equal(t, []string{"test"}, newModel.(Model).bottomBarCommands) 226 | assert.Nil(t, cmd) 227 | 228 | }) 229 | 230 | t.Run("recreates and switches to search model if switchToSearchModelMsg is received", func(t *testing.T) { 231 | 232 | browser := mocks.MockRadioBrowserService{} 233 | playbackManager := mocks.MockPlaybackManagerService{} 234 | 235 | model := NewModel(config.Config{}, &browser, &playbackManager) 236 | model.searchModel.width = 111 237 | 238 | msg := switchToSearchModelMsg{} 239 | 240 | newModel, cmd := model.Update(tea.Msg(msg)) 241 | 242 | assert.Equal(t, searchState, newModel.(Model).state) 243 | assert.Equal(t, 0, newModel.(Model).searchModel.width) 244 | assert.NotNil(t, cmd) 245 | 246 | }) 247 | 248 | t.Run("recreates and switches to loading model if switchToLoadingModelMsg is received", func(t *testing.T) { 249 | 250 | browser := mocks.MockRadioBrowserService{} 251 | 252 | playbackManager := mocks.MockPlaybackManagerService{} 253 | 254 | model := NewModel(config.Config{}, &browser, &playbackManager) 255 | model.loadingModel.queryText = "test" 256 | 257 | msg := switchToLoadingModelMsg{queryText: "test2"} 258 | 259 | newModel, cmd := model.Update(tea.Msg(msg)) 260 | 261 | assert.Equal(t, loadingState, newModel.(Model).state) 262 | assert.Equal(t, "test2", newModel.(Model).loadingModel.queryText) 263 | assert.NotNil(t, cmd) 264 | 265 | }) 266 | 267 | t.Run("recreates and switches to stations model if switchToStationsModelMsg is received", func(t *testing.T) { 268 | 269 | browser := mocks.MockRadioBrowserService{} 270 | playbackManager := mocks.MockPlaybackManagerService{} 271 | 272 | model := NewModel(config.Config{}, &browser, &playbackManager) 273 | model.stationsModel.volume = 1 274 | 275 | msg := switchToStationsModelMsg{} 276 | 277 | newModel, cmd := model.Update(tea.Msg(msg)) 278 | 279 | assert.Equal(t, stationsState, newModel.(Model).state) 280 | assert.NotEqual(t, 1, newModel.(Model).stationsModel.volume) 281 | assert.NotNil(t, cmd) 282 | 283 | }) 284 | 285 | t.Run("recreates and switches to error model if switchToErrorModelMsg is received", func(t *testing.T) { 286 | 287 | browser := mocks.MockRadioBrowserService{} 288 | playbackManager := mocks.MockPlaybackManagerService{} 289 | 290 | model := NewModel(config.Config{}, &browser, &playbackManager) 291 | model.errorModel.message = "test" 292 | 293 | msg := switchToErrorModelMsg{err: "test2"} 294 | 295 | newModel, cmd := model.Update(tea.Msg(msg)) 296 | 297 | assert.Equal(t, errorState, newModel.(Model).state) 298 | assert.Equal(t, "test2", newModel.(Model).errorModel.message) 299 | assert.NotNil(t, cmd) 300 | 301 | }) 302 | 303 | } 304 | -------------------------------------------------------------------------------- /models/search.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "fmt" 24 | "strings" 25 | 26 | "github.com/zi0p4tch0/radiogogo/assets" 27 | "github.com/zi0p4tch0/radiogogo/common" 28 | 29 | "github.com/charmbracelet/bubbles/textinput" 30 | tea "github.com/charmbracelet/bubbletea" 31 | "github.com/charmbracelet/lipgloss" 32 | ) 33 | 34 | type SearchModel struct { 35 | theme Theme 36 | inputModel textinput.Model 37 | querySelector SelectorModel[common.StationQuery] 38 | width int 39 | height int 40 | } 41 | 42 | func NewSearchModel(theme Theme) SearchModel { 43 | i := textinput.New() 44 | i.Placeholder = "Name" 45 | i.Width = 30 46 | i.TextStyle = theme.Text 47 | i.PlaceholderStyle = theme.TertiaryText 48 | i.Focus() 49 | 50 | selector := NewSelectorModel[common.StationQuery]( 51 | theme, 52 | "Filter:", 53 | []common.StationQuery{ 54 | common.StationQueryByName, 55 | common.StationQueryByNameExact, 56 | common.StationQueryByCodec, 57 | common.StationQueryByCodecExact, 58 | common.StationQueryByCountry, 59 | common.StationQueryByCountryExact, 60 | common.StationQueryByCountryCodeExact, 61 | common.StationQueryByState, 62 | common.StationQueryByStateExact, 63 | common.StationQueryByLanguage, 64 | common.StationQueryByLanguageExact, 65 | common.StationQueryByTag, 66 | common.StationQueryByTagExact, 67 | }, 68 | 0, 69 | ) 70 | 71 | return SearchModel{ 72 | theme: theme, 73 | inputModel: i, 74 | querySelector: selector, 75 | } 76 | 77 | } 78 | 79 | // Commands 80 | 81 | func updateCommandsForTextfieldFocus() tea.Msg { 82 | return bottomBarUpdateMsg{ 83 | commands: []string{"q: quit", "tab: cycle focus", "enter: search"}, 84 | } 85 | } 86 | 87 | func updateCommandsForSelectorFocus() tea.Msg { 88 | return bottomBarUpdateMsg{ 89 | commands: []string{"q: quit", "tab: cycle focus", "↑/↓: change filter"}, 90 | } 91 | } 92 | 93 | // Bubbletea 94 | 95 | func (m SearchModel) Init() tea.Cmd { 96 | return tea.Batch(textinput.Blink, updateCommandsForTextfieldFocus) 97 | } 98 | 99 | func (m SearchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 100 | 101 | switch msg := msg.(type) { 102 | case tea.KeyMsg: 103 | switch msg.String() { 104 | case "tab": 105 | if m.inputModel.Focused() { 106 | m.inputModel.Blur() 107 | m.querySelector.Focus() 108 | return m, updateCommandsForSelectorFocus 109 | } else { 110 | m.inputModel.Focus() 111 | m.querySelector.Blur() 112 | return m, updateCommandsForTextfieldFocus 113 | } 114 | case "q": 115 | if !m.inputModel.Focused() { 116 | return m, quitCmd 117 | } 118 | case "enter": 119 | if !m.inputModel.Focused() { 120 | return m, nil 121 | } 122 | return m, func() tea.Msg { 123 | return switchToLoadingModelMsg{ 124 | query: m.querySelector.Selection(), 125 | queryText: m.inputModel.Value(), 126 | } 127 | } 128 | } 129 | } 130 | 131 | var cmds []tea.Cmd 132 | 133 | newInputModel, inputCmd := m.inputModel.Update(msg) 134 | m.inputModel = newInputModel 135 | 136 | if inputCmd != nil { 137 | cmds = append(cmds, inputCmd) 138 | } 139 | 140 | newSelectorModel, selectorCmd := m.querySelector.Update(msg) 141 | m.querySelector = newSelectorModel 142 | 143 | if selectorCmd != nil { 144 | cmds = append(cmds, selectorCmd) 145 | } 146 | 147 | return m, tea.Batch(cmds...) 148 | } 149 | 150 | func (m SearchModel) View() string { 151 | 152 | searchType := m.querySelector.Selection().Render() 153 | searchType = strings.ToLower(searchType) 154 | 155 | rightOfLogoStyle := lipgloss.NewStyle(). 156 | PaddingLeft(2) 157 | 158 | rightV := rightOfLogoStyle.Render( 159 | fmt.Sprintf("\n%s\n\n%s\n\n%s\n%s", 160 | m.theme.SecondaryText.Render(fmt.Sprint("Search radio ", searchType)), 161 | m.inputModel.View(), 162 | m.querySelector.View(), 163 | m.theme.TertiaryText.Render(m.querySelector.Selection().ExampleString()), 164 | )) 165 | 166 | leftV := fmt.Sprintf( 167 | "\n%s\n\n", 168 | assets.Logo, 169 | ) 170 | 171 | v := lipgloss.JoinHorizontal(lipgloss.Top, leftV, rightV) 172 | 173 | return v 174 | } 175 | 176 | func (m *SearchModel) SetWidthAndHeight(width int, height int) { 177 | m.width = width 178 | m.height = height 179 | } 180 | -------------------------------------------------------------------------------- /models/search_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "testing" 24 | 25 | "github.com/zi0p4tch0/radiogogo/common" 26 | 27 | "github.com/charmbracelet/bubbles/cursor" 28 | "github.com/charmbracelet/bubbles/textarea" 29 | tea "github.com/charmbracelet/bubbletea" 30 | "github.com/stretchr/testify/assert" 31 | ) 32 | 33 | func TestSearchModel_Init(t *testing.T) { 34 | 35 | t.Run("starts blinking the input field cursor", func(t *testing.T) { 36 | 37 | model := NewSearchModel(Theme{}) 38 | 39 | cmd := model.Init() 40 | assert.NotNil(t, cmd) 41 | 42 | var batchMsg tea.BatchMsg = cmd().(tea.BatchMsg) 43 | found := false 44 | 45 | for _, msg := range batchMsg { 46 | currentMsg := msg() 47 | if currentMsg == textarea.Blink() { 48 | found = true 49 | break 50 | } 51 | } 52 | 53 | assert.True(t, found) 54 | 55 | }) 56 | 57 | t.Run("broadcasts a bottomBarUpdateMsg", func(t *testing.T) { 58 | 59 | model := NewSearchModel(Theme{}) 60 | 61 | cmd := model.Init() 62 | assert.NotNil(t, cmd) 63 | 64 | var batchMsg tea.BatchMsg = cmd().(tea.BatchMsg) 65 | 66 | found := false 67 | var commands []string 68 | for _, msg := range batchMsg { 69 | currentMsg := msg() 70 | if _, ok := currentMsg.(bottomBarUpdateMsg); ok { 71 | commands = currentMsg.(bottomBarUpdateMsg).commands 72 | found = true 73 | break 74 | } 75 | } 76 | 77 | assert.True(t, found) 78 | 79 | expectedCommands := []string{"q: quit", "tab: cycle focus", "enter: search"} 80 | 81 | assert.Equal(t, expectedCommands, commands) 82 | 83 | }) 84 | 85 | } 86 | 87 | func TestSearchModel_Update(t *testing.T) { 88 | 89 | t.Run("does not broadcast quitMsg when 'q' is pressed and textarea is focused", func(t *testing.T) { 90 | 91 | model := NewSearchModel(Theme{}) 92 | 93 | model.inputModel.Focus() 94 | model.querySelector.Blur() 95 | 96 | input := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")} 97 | 98 | _, cmd := model.Update(input) 99 | assert.NotNil(t, cmd) 100 | 101 | msg := cmd() 102 | 103 | assert.IsType(t, cursor.BlinkMsg{}, msg) 104 | 105 | }) 106 | 107 | t.Run("broadcasts a quitMsg when 'q' is pressed and textarea is not focused", func(t *testing.T) { 108 | 109 | model := NewSearchModel(Theme{}) 110 | 111 | model.inputModel.Blur() 112 | model.querySelector.Focus() 113 | 114 | input := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")} 115 | 116 | _, cmd := model.Update(input) 117 | assert.NotNil(t, cmd) 118 | 119 | msg := cmd() 120 | 121 | assert.IsType(t, quitMsg{}, msg) 122 | 123 | }) 124 | 125 | t.Run("broadcasts a switchToLoadingModelMsg when 'enter' is pressed, propagating text area value", func(t *testing.T) { 126 | 127 | model := NewSearchModel(Theme{}) 128 | model.inputModel.SetValue("fancy value") 129 | 130 | input := tea.KeyMsg{Type: tea.KeyEnter} 131 | 132 | _, cmd := model.Update(input) 133 | assert.NotNil(t, cmd) 134 | 135 | msg := cmd() 136 | 137 | assert.Equal(t, switchToLoadingModelMsg{ 138 | query: common.StationQueryByName, 139 | queryText: "fancy value", 140 | }, msg) 141 | 142 | }) 143 | 144 | t.Run("ignores 'enter' when is not in focus", func(t *testing.T) { 145 | 146 | model := NewSearchModel(Theme{}) 147 | model.inputModel.SetValue("fancy value") 148 | model.inputModel.Blur() 149 | 150 | input := tea.KeyMsg{Type: tea.KeyEnter} 151 | 152 | _, cmd := model.Update(input) 153 | assert.Nil(t, cmd) 154 | 155 | }) 156 | 157 | t.Run("cycles focused input when 'tab' is pressed and updates bottom bar", func(t *testing.T) { 158 | 159 | model := NewSearchModel(Theme{}) 160 | 161 | input := tea.KeyMsg{Type: tea.KeyTab} 162 | 163 | newModel, cmd := model.Update(input) 164 | 165 | assert.Equal(t, newModel.(SearchModel).inputModel.Focused(), false) 166 | assert.Equal(t, newModel.(SearchModel).querySelector.Focused(), true) 167 | assert.NotNil(t, cmd) 168 | 169 | msg := cmd() 170 | assert.IsType(t, bottomBarUpdateMsg{}, msg) 171 | 172 | newModel, cmd = newModel.Update(input) 173 | 174 | assert.Equal(t, newModel.(SearchModel).inputModel.Focused(), true) 175 | assert.Equal(t, newModel.(SearchModel).querySelector.Focused(), false) 176 | 177 | msg = cmd() 178 | assert.IsType(t, bottomBarUpdateMsg{}, msg) 179 | 180 | }) 181 | 182 | } 183 | func TestUpdateCommandsForTextfieldFocus(t *testing.T) { 184 | expectedCommands := []string{"q: quit", "tab: cycle focus", "enter: search"} 185 | 186 | msg := updateCommandsForTextfieldFocus() 187 | 188 | updateMsg, ok := msg.(bottomBarUpdateMsg) 189 | 190 | assert.True(t, ok) 191 | assert.Equal(t, expectedCommands, updateMsg.commands) 192 | } 193 | 194 | func TestUpdateCommandsForSelectorFocus(t *testing.T) { 195 | expectedCommands := []string{"q: quit", "tab: cycle focus", "↑/↓: change filter"} 196 | 197 | msg := updateCommandsForSelectorFocus() 198 | 199 | updateMsg, ok := msg.(bottomBarUpdateMsg) 200 | 201 | assert.True(t, ok) 202 | assert.Equal(t, expectedCommands, updateMsg.commands) 203 | } 204 | -------------------------------------------------------------------------------- /models/selector.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "fmt" 24 | 25 | tea "github.com/charmbracelet/bubbletea" 26 | ) 27 | 28 | type StringRenderable interface { 29 | Render() string 30 | } 31 | 32 | type SelectorModel[T StringRenderable] struct { 33 | theme Theme 34 | title string 35 | items []T 36 | selection int 37 | focus bool 38 | } 39 | 40 | func NewSelectorModel[T StringRenderable](theme Theme, title string, items []T, initialSelection int) SelectorModel[T] { 41 | return SelectorModel[T]{ 42 | theme: theme, 43 | title: title, 44 | items: items, 45 | selection: initialSelection, 46 | focus: false, 47 | } 48 | } 49 | 50 | // Selection 51 | 52 | func (m SelectorModel[T]) Selection() T { 53 | return m.items[m.selection] 54 | } 55 | 56 | // Focus 57 | 58 | func (m SelectorModel[T]) Focused() bool { 59 | return m.focus 60 | } 61 | 62 | func (m *SelectorModel[T]) Focus() { 63 | m.focus = true 64 | } 65 | 66 | func (m *SelectorModel[T]) Blur() { 67 | m.focus = false 68 | } 69 | 70 | // Bubbletea 71 | 72 | func (m SelectorModel[T]) Init() tea.Cmd { 73 | return nil 74 | } 75 | 76 | func (m SelectorModel[T]) Update(msg tea.Msg) (SelectorModel[T], tea.Cmd) { 77 | 78 | if !m.focus { 79 | return m, nil 80 | } 81 | 82 | switch msg := msg.(type) { 83 | case tea.KeyMsg: 84 | switch msg.String() { 85 | case "up": 86 | if m.selection > 0 { 87 | m.selection-- 88 | } 89 | case "down": 90 | if m.selection < len(m.items)-1 { 91 | m.selection++ 92 | } 93 | } 94 | } 95 | 96 | return m, nil 97 | } 98 | 99 | func (m SelectorModel[T]) View() string { 100 | 101 | v := m.theme.SecondaryText.Bold(true).Render(m.title) + "\n\n" 102 | 103 | for i, item := range m.items { 104 | if i == m.selection { 105 | if m.focus { 106 | v += fmt.Sprintf( 107 | "%s%s%s ", 108 | m.theme.Text.Render("> ["), 109 | m.theme.SecondaryText.Render("•"), 110 | m.theme.Text.Render("]"), 111 | ) 112 | } else { 113 | v += fmt.Sprintf( 114 | "%s%s%s ", 115 | m.theme.Text.Render(" ["), 116 | m.theme.SecondaryText.Render("•"), 117 | m.theme.Text.Render("]"), 118 | ) 119 | } 120 | } else { 121 | v += m.theme.Text.Render(" [ ] ") 122 | } 123 | v += m.theme.Text.Render(item.Render()) 124 | v += "\n" 125 | } 126 | 127 | return v 128 | 129 | } 130 | -------------------------------------------------------------------------------- /models/selector_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "testing" 24 | 25 | tea "github.com/charmbracelet/bubbletea" 26 | "github.com/stretchr/testify/assert" 27 | ) 28 | 29 | type MockRenderable struct { 30 | value string 31 | } 32 | 33 | func (m MockRenderable) Render() string { 34 | return m.value 35 | } 36 | 37 | func TestSelectorModel(t *testing.T) { 38 | 39 | items := []MockRenderable{ 40 | {value: "Item 1"}, 41 | {value: "Item 2"}, 42 | {value: "Item 3"}, 43 | } 44 | 45 | t.Run("selection returns the correct item", func(t *testing.T) { 46 | model := NewSelectorModel(Theme{}, "Title", items, 0) 47 | assert.Equal(t, items[0], model.Selection()) 48 | }) 49 | 50 | t.Run("selection returns the correct item after changing selection", func(t *testing.T) { 51 | model := NewSelectorModel(Theme{}, "Title", items, 0) 52 | model.selection = 1 53 | assert.Equal(t, items[1], model.Selection()) 54 | }) 55 | 56 | t.Run("focused returns false by default", func(t *testing.T) { 57 | model := NewSelectorModel(Theme{}, "Title", items, 0) 58 | assert.False(t, model.Focused()) 59 | }) 60 | 61 | t.Run("focus sets focus to true", func(t *testing.T) { 62 | model := NewSelectorModel(Theme{}, "Title", items, 0) 63 | model.Focus() 64 | assert.True(t, model.Focused()) 65 | }) 66 | 67 | t.Run("blur sets focus to false", func(t *testing.T) { 68 | model := NewSelectorModel(Theme{}, "Title", items, 0) 69 | model.Blur() 70 | assert.False(t, model.Focused()) 71 | }) 72 | 73 | t.Run("view returns the correct string", func(t *testing.T) { 74 | model := NewSelectorModel(Theme{}, "Title", items, 0) 75 | expected := "Title\n\n [•] Item 1\n [ ] Item 2\n [ ] Item 3\n" 76 | assert.Equal(t, expected, model.View()) 77 | }) 78 | 79 | t.Run("view returns the correct string with a different selection", func(t *testing.T) { 80 | model := NewSelectorModel(Theme{}, "Title", items, 0) 81 | model.selection = 2 82 | expected := "Title\n\n [ ] Item 1\n [ ] Item 2\n [•] Item 3\n" 83 | assert.Equal(t, expected, model.View()) 84 | }) 85 | 86 | t.Run("view returns the correct string on focus", func(t *testing.T) { 87 | model := NewSelectorModel(Theme{}, "Title", items, 0) 88 | model.Focus() 89 | expected := "Title\n\n> [•] Item 1\n [ ] Item 2\n [ ] Item 3\n" 90 | assert.Equal(t, expected, model.View()) 91 | }) 92 | 93 | t.Run("view returns the correct string with a different selection on focus", func(t *testing.T) { 94 | model := NewSelectorModel(Theme{}, "Title", items, 0) 95 | model.Focus() 96 | model.selection = 2 97 | expected := "Title\n\n [ ] Item 1\n [ ] Item 2\n> [•] Item 3\n" 98 | assert.Equal(t, expected, model.View()) 99 | }) 100 | 101 | t.Run("up key moves the selection up", func(t *testing.T) { 102 | 103 | model := NewSelectorModel(Theme{}, "Title", items, 2) 104 | model.Focus() 105 | 106 | msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("up")} 107 | 108 | model, _ = model.Update(msg) 109 | 110 | assert.Equal(t, 1, model.selection) 111 | 112 | }) 113 | 114 | t.Run("up key does not move the selection up if we're at index zero", func(t *testing.T) { 115 | 116 | model := NewSelectorModel(Theme{}, "Title", items, 0) 117 | model.Focus() 118 | 119 | msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("up")} 120 | 121 | model, _ = model.Update(msg) 122 | 123 | assert.Equal(t, 0, model.selection) 124 | 125 | }) 126 | 127 | t.Run("down key moves the selection down", func(t *testing.T) { 128 | 129 | model := NewSelectorModel(Theme{}, "Title", items, 1) 130 | model.Focus() 131 | 132 | msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("down")} 133 | 134 | model, _ = model.Update(msg) 135 | 136 | assert.Equal(t, 2, model.selection) 137 | 138 | }) 139 | 140 | t.Run("down key does not move selection down if we're at max index", func(t *testing.T) { 141 | 142 | model := NewSelectorModel(Theme{}, "Title", items, 2) 143 | model.Focus() 144 | 145 | msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("down")} 146 | 147 | model, _ = model.Update(msg) 148 | 149 | assert.Equal(t, 2, model.selection) 150 | 151 | }) 152 | 153 | } 154 | -------------------------------------------------------------------------------- /models/stations.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "fmt" 24 | "time" 25 | 26 | "github.com/zi0p4tch0/radiogogo/api" 27 | "github.com/zi0p4tch0/radiogogo/assets" 28 | "github.com/zi0p4tch0/radiogogo/common" 29 | "github.com/zi0p4tch0/radiogogo/playback" 30 | 31 | "github.com/charmbracelet/bubbles/spinner" 32 | "github.com/charmbracelet/bubbles/table" 33 | tea "github.com/charmbracelet/bubbletea" 34 | ) 35 | 36 | type StationsModel struct { 37 | theme Theme 38 | 39 | stations []common.Station 40 | stationsTable table.Model 41 | currentStation common.Station 42 | currentStationSpinner spinner.Model 43 | volume int 44 | err string 45 | 46 | browser api.RadioBrowserService 47 | playbackManager playback.PlaybackManagerService 48 | width int 49 | height int 50 | } 51 | 52 | func NewStationsModel( 53 | theme Theme, 54 | browser api.RadioBrowserService, 55 | playbackManager playback.PlaybackManagerService, 56 | stations []common.Station, 57 | ) StationsModel { 58 | 59 | return StationsModel{ 60 | theme: theme, 61 | stations: stations, 62 | stationsTable: newStationsTableModel(theme, stations), 63 | volume: playbackManager.VolumeDefault(), 64 | browser: browser, 65 | playbackManager: playbackManager, 66 | } 67 | } 68 | 69 | func newStationsTableModel(theme Theme, stations []common.Station) table.Model { 70 | 71 | rows := make([]table.Row, len(stations)) 72 | for i, station := range stations { 73 | rows[i] = table.Row{ 74 | station.Name, 75 | station.CountryCode, 76 | station.LanguagesCodes, 77 | station.Codec, 78 | fmt.Sprintf("%d", station.Votes), 79 | } 80 | } 81 | 82 | t := table.New( 83 | table.WithColumns([]table.Column{ 84 | {Title: "Name", Width: 30}, 85 | {Title: "Country", Width: 10}, 86 | {Title: "Language(s)", Width: 15}, 87 | {Title: "Codec(s)", Width: 15}, 88 | {Title: "Votes", Width: 10}, 89 | }), 90 | table.WithRows(rows), 91 | table.WithFocused(true), 92 | ) 93 | 94 | t.SetStyles(theme.StationsTableStyle) 95 | 96 | return t 97 | 98 | } 99 | 100 | // Messages 101 | 102 | type playbackStartedMsg struct { 103 | station common.Station 104 | } 105 | type playbackStoppedMsg struct{} 106 | 107 | type nonFatalError struct { 108 | stopPlayback bool 109 | err error 110 | } 111 | type clearNonFatalError struct{} 112 | 113 | type stationCursorMovedMsg struct { 114 | offset int 115 | totalStations int 116 | } 117 | 118 | // Commands 119 | 120 | func playStationCmd( 121 | playbackManager playback.PlaybackManagerService, 122 | station common.Station, 123 | volume int, 124 | ) tea.Cmd { 125 | return func() tea.Msg { 126 | err := playbackManager.PlayStation(station, volume) 127 | if err != nil { 128 | return nonFatalError{stopPlayback: false, err: err} 129 | } 130 | return playbackStartedMsg{station: station} 131 | } 132 | } 133 | 134 | func stopStationCmd(playbackManager playback.PlaybackManagerService) tea.Cmd { 135 | return func() tea.Msg { 136 | err := playbackManager.StopStation() 137 | if err != nil { 138 | return nonFatalError{stopPlayback: false, err: err} 139 | } 140 | return playbackStoppedMsg{} 141 | } 142 | } 143 | 144 | func notifyRadioBrowserCmd(browser api.RadioBrowserService, station common.Station) tea.Cmd { 145 | return func() tea.Msg { 146 | _, err := browser.ClickStation(station) 147 | if err != nil { 148 | return nonFatalError{stopPlayback: false, err: err} 149 | } 150 | return nil 151 | } 152 | } 153 | 154 | func updateCommandsCmd(isPlaying bool, volume int, volumeIsPercentage bool) tea.Cmd { 155 | return func() tea.Msg { 156 | 157 | commands := []string{"q: quit", "s: search", "enter: play", "↑/↓: move"} 158 | 159 | if isPlaying { 160 | commands = append(commands, "ctrl+k: stop") 161 | } else { 162 | 163 | volume := fmt.Sprintf("%d", volume) 164 | if volumeIsPercentage { 165 | volume += "%" 166 | } 167 | 168 | commands = append(commands, "9/0: vol down/up", "vol: "+volume) 169 | } 170 | 171 | return bottomBarUpdateMsg{ 172 | commands: commands, 173 | } 174 | } 175 | } 176 | 177 | // Model 178 | 179 | func (m StationsModel) Init() tea.Cmd { 180 | return tea.Batch( 181 | updateCommandsCmd(false, m.volume, m.playbackManager.VolumeIsPercentage()), 182 | func() tea.Msg { 183 | return stationCursorMovedMsg{ 184 | offset: m.stationsTable.Cursor(), 185 | totalStations: len(m.stations), 186 | } 187 | }, 188 | ) 189 | } 190 | 191 | func (m StationsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 192 | 193 | var cmds []tea.Cmd 194 | 195 | switch msg := msg.(type) { 196 | case playbackStartedMsg: 197 | m.currentStation = msg.station 198 | m.currentStationSpinner = spinner.New() 199 | m.currentStationSpinner.Spinner = spinner.Dot 200 | m.currentStationSpinner.Style = m.theme.PrimaryText 201 | return m, tea.Batch( 202 | m.currentStationSpinner.Tick, 203 | notifyRadioBrowserCmd(m.browser, m.currentStation), 204 | updateCommandsCmd(true, m.volume, m.playbackManager.VolumeIsPercentage()), 205 | ) 206 | case playbackStoppedMsg: 207 | m.currentStation = common.Station{} 208 | m.currentStationSpinner = spinner.Model{} 209 | return m, updateCommandsCmd(false, m.volume, m.playbackManager.VolumeIsPercentage()) 210 | case nonFatalError: 211 | var cmds []tea.Cmd 212 | if msg.stopPlayback { 213 | cmds = append(cmds, stopStationCmd(m.playbackManager)) 214 | } 215 | m.err = msg.err.Error() 216 | cmds = append(cmds, tea.Tick(3*time.Second, func(t time.Time) tea.Msg { 217 | return clearNonFatalError{} 218 | })) 219 | 220 | return m, tea.Sequence(cmds...) 221 | case clearNonFatalError: 222 | m.err = "" 223 | return m, nil 224 | case tea.KeyMsg: 225 | switch msg.String() { 226 | case "up", "down", "j", "k": 227 | cmds = append(cmds, func() tea.Msg { 228 | return stationCursorMovedMsg{ 229 | offset: m.stationsTable.Cursor(), 230 | totalStations: len(m.stations), 231 | } 232 | }) 233 | case "ctrl+k": 234 | return m, func() tea.Msg { 235 | err := m.playbackManager.StopStation() 236 | if err != nil { 237 | return nonFatalError{stopPlayback: false, err: err} 238 | } 239 | return playbackStoppedMsg{} 240 | } 241 | case "q": 242 | return m, tea.Sequence(stopStationCmd(m.playbackManager), quitCmd) 243 | case "s": 244 | return m, tea.Sequence( 245 | stopStationCmd(m.playbackManager), 246 | func() tea.Msg { 247 | return switchToSearchModelMsg{} 248 | }, 249 | ) 250 | case "9": 251 | if m.volume > m.playbackManager.VolumeMin() && !m.playbackManager.IsPlaying() { 252 | m.volume -= 10 253 | return m, updateCommandsCmd(false, m.volume, m.playbackManager.VolumeIsPercentage()) 254 | } 255 | return m, nil 256 | case "0": 257 | if m.volume < m.playbackManager.VolumeMax() && !m.playbackManager.IsPlaying() { 258 | m.volume += 10 259 | return m, updateCommandsCmd(false, m.volume, m.playbackManager.VolumeIsPercentage()) 260 | } 261 | return m, nil 262 | case "enter": 263 | if len(m.stations) == 0 { 264 | return m, nil 265 | } 266 | station := m.stations[m.stationsTable.Cursor()] 267 | return m, playStationCmd(m.playbackManager, station, m.volume) 268 | } 269 | } 270 | 271 | if m.playbackManager.IsPlaying() { 272 | newSpinner, cmd := m.currentStationSpinner.Update(msg) 273 | m.currentStationSpinner = newSpinner 274 | cmds = append(cmds, cmd) 275 | } 276 | 277 | newStationsTable, cmd := m.stationsTable.Update(msg) 278 | m.stationsTable = newStationsTable 279 | 280 | cmds = append(cmds, cmd) 281 | 282 | return m, tea.Batch(cmds...) 283 | } 284 | 285 | func (m StationsModel) View() string { 286 | 287 | extraBar := "" 288 | 289 | if m.err != "" { 290 | extraBar += m.theme.ErrorText.Render(m.err) 291 | } else if m.playbackManager.IsPlaying() { 292 | extraBar += 293 | m.currentStationSpinner.View() + 294 | m.theme.SecondaryText.Bold(true).Render("Listening to: "+m.currentStation.Name) 295 | } else { 296 | extraBar += m.theme.PrimaryText.Bold(true).Render("It's quiet here, time to play something!") 297 | } 298 | 299 | var v string 300 | if len(m.stations) == 0 { 301 | v = fmt.Sprintf( 302 | "\n%s\n\n%s\n", 303 | assets.NoStations, 304 | m.theme.SecondaryText.Bold(true).Render("No stations found, try another search!"), 305 | ) 306 | } else { 307 | v = "\n" + m.stationsTable.View() + "\n" 308 | v += extraBar 309 | } 310 | 311 | return v 312 | } 313 | 314 | func (m *StationsModel) SetWidthAndHeight(width int, height int) { 315 | m.width = width 316 | m.height = height 317 | m.stationsTable.SetWidth(width) 318 | m.stationsTable.SetHeight(height - 4) 319 | } 320 | -------------------------------------------------------------------------------- /models/theme.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package models 21 | 22 | import ( 23 | "github.com/zi0p4tch0/radiogogo/config" 24 | 25 | "github.com/charmbracelet/bubbles/table" 26 | "github.com/charmbracelet/lipgloss" 27 | ) 28 | 29 | // Theme represents a style configuration for the application. 30 | type Theme struct { 31 | PrimaryBlock lipgloss.Style 32 | SecondaryBlock lipgloss.Style 33 | 34 | Text lipgloss.Style 35 | PrimaryText lipgloss.Style 36 | SecondaryText lipgloss.Style 37 | TertiaryText lipgloss.Style 38 | ErrorText lipgloss.Style 39 | 40 | StationsTableStyle table.Styles 41 | } 42 | 43 | func NewTheme(config config.Config) Theme { 44 | 45 | primaryBlock := lipgloss.NewStyle(). 46 | Foreground(lipgloss.Color(config.Theme.TextColor)). 47 | Background(lipgloss.Color(config.Theme.PrimaryColor)). 48 | PaddingLeft(2). 49 | PaddingRight(2) 50 | 51 | secondaryBlock := lipgloss.NewStyle(). 52 | Foreground(lipgloss.Color(config.Theme.TextColor)). 53 | Background(lipgloss.Color(config.Theme.SecondaryColor)). 54 | PaddingLeft(2). 55 | PaddingRight(2) 56 | 57 | text := lipgloss.NewStyle(). 58 | Foreground(lipgloss.Color(config.Theme.TextColor)) 59 | 60 | primaryText := lipgloss.NewStyle(). 61 | Foreground(lipgloss.Color(config.Theme.PrimaryColor)) 62 | 63 | secondaryText := lipgloss.NewStyle(). 64 | Foreground(lipgloss.Color(config.Theme.SecondaryColor)) 65 | 66 | tertiaryText := lipgloss.NewStyle(). 67 | Foreground(lipgloss.Color(config.Theme.TertiaryColor)) 68 | 69 | errorText := lipgloss.NewStyle(). 70 | Foreground(lipgloss.Color(config.Theme.ErrorColor)) 71 | 72 | stationsTableStyles := table.DefaultStyles() 73 | stationsTableStyles.Header = stationsTableStyles.Header. 74 | BorderStyle(lipgloss.NormalBorder()). 75 | BorderForeground(lipgloss.Color(config.Theme.TextColor)). 76 | BorderBottom(true). 77 | Bold(false) 78 | stationsTableStyles.Cell = stationsTableStyles.Cell. 79 | Foreground(lipgloss.Color(config.Theme.TextColor)) 80 | stationsTableStyles.Selected = stationsTableStyles.Selected. 81 | Foreground(lipgloss.Color(config.Theme.TextColor)). 82 | Background(lipgloss.Color(config.Theme.PrimaryColor)). 83 | Bold(false) 84 | 85 | return Theme{ 86 | PrimaryBlock: primaryBlock, 87 | SecondaryBlock: secondaryBlock, 88 | Text: text, 89 | PrimaryText: primaryText, 90 | SecondaryText: secondaryText, 91 | TertiaryText: tertiaryText, 92 | ErrorText: errorText, 93 | StationsTableStyle: stationsTableStyles, 94 | } 95 | } 96 | 97 | // StyleBottomBar returns a string representing the styled bottom bar of the given Theme. 98 | // It takes a slice of strings representing the commands to be displayed in the bottom bar. 99 | // The function iterates over the commands and applies a different style to each one based on its index. 100 | // If the index is even, the command is styled with the primary color of the Theme as background. 101 | // If the index is odd, the command is styled with the secondary color of the Theme as background. 102 | // The styled commands are concatenated into a single string and returned. 103 | func (t Theme) StyleBottomBar(commands []string) string { 104 | 105 | var bottomBar string 106 | for i, command := range commands { 107 | if i%2 == 0 { 108 | bottomBar += t.PrimaryBlock.Render(command) 109 | } else { 110 | bottomBar += t.SecondaryBlock.Render(command) 111 | } 112 | } 113 | return bottomBar 114 | 115 | } 116 | -------------------------------------------------------------------------------- /nix/package.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | stdenv, 4 | fetchFromGitHub, 5 | buildGoModule, 6 | ffmpeg, 7 | makeWrapper, 8 | }: let 9 | version = "0.3.2"; 10 | in 11 | buildGoModule { 12 | pname = "radiogogo"; 13 | inherit version; 14 | 15 | src = fetchFromGitHub { 16 | owner = "matteo-pacini"; 17 | repo = "RadioGoGo"; 18 | rev = "v${version}"; 19 | hash = "sha256-vEZUBA+KeDHgqZvzrAN6ramZ5D4iqQdVU+qFOK/39co="; 20 | }; 21 | 22 | vendorHash = "sha256-hYEXzKrACpSyvrAYbV0jkX504Ix/ch2PVrhksYKFhwE="; 23 | 24 | nativeBuildInputs = [makeWrapper]; 25 | 26 | ldflags = [ 27 | "-s" 28 | "-w" 29 | ]; 30 | 31 | postInstall = '' 32 | wrapProgram $out/bin/radiogogo \ 33 | --prefix PATH : ${lib.makeBinPath [ffmpeg]} 34 | ''; 35 | 36 | meta = with lib; { 37 | homepage = "https://github.com/matteo=pacini/RadioGoGo"; 38 | description = "Go-powered CLI to surf global radio waves via a sleek TUI"; 39 | license = licenses.mit; 40 | maintainers = with maintainers; [matteopacini]; 41 | mainProgram = "radiogogo"; 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /playback/ffplay.go: -------------------------------------------------------------------------------- 1 | package playback 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "runtime" 7 | 8 | "github.com/zi0p4tch0/radiogogo/common" 9 | ) 10 | 11 | // FFPlayPlaybackManager represents a playback manager for FFPlay. 12 | type FFPlayPlaybackManager struct { 13 | nowPlaying *exec.Cmd 14 | } 15 | 16 | func NewFFPlaybackManager() PlaybackManagerService { 17 | return &FFPlayPlaybackManager{} 18 | } 19 | 20 | func (d FFPlayPlaybackManager) Name() string { 21 | return "ffplay" 22 | } 23 | 24 | func (d FFPlayPlaybackManager) IsPlaying() bool { 25 | return d.nowPlaying != nil 26 | } 27 | 28 | func (d FFPlayPlaybackManager) IsAvailable() bool { 29 | _, err := exec.LookPath("ffplay") 30 | return err == nil 31 | } 32 | 33 | func (d FFPlayPlaybackManager) NotAvailableErrorString() string { 34 | return `RadioGoGo requires "ffplay" (part of "ffmpeg") to be installed and available in your PATH.` 35 | } 36 | 37 | func (d *FFPlayPlaybackManager) PlayStation(station common.Station, volume int) error { 38 | err := d.StopStation() 39 | if err != nil { 40 | return err 41 | } 42 | cmd := exec.Command("ffplay", "-nodisp", "-volume", fmt.Sprintf("%d", volume), station.Url.URL.String()) 43 | err = cmd.Start() 44 | if err != nil { 45 | return err 46 | } 47 | d.nowPlaying = cmd 48 | return nil 49 | } 50 | 51 | func (d *FFPlayPlaybackManager) StopStation() error { 52 | if d.nowPlaying != nil { 53 | if runtime.GOOS == "windows" { 54 | // On Windows, use taskkill to ensure all child processes are also killed. 55 | killCmd := exec.Command("taskkill", "/T", "/F", "/PID", fmt.Sprintf("%d", d.nowPlaying.Process.Pid)) 56 | if err := killCmd.Run(); err != nil { 57 | return err 58 | } 59 | } else { 60 | // On other platforms, just use the normal Kill method. 61 | if err := d.nowPlaying.Process.Kill(); err != nil { 62 | return err 63 | } 64 | } 65 | 66 | _, err := d.nowPlaying.Process.Wait() 67 | if err != nil { 68 | return err 69 | } 70 | d.nowPlaying = nil 71 | } 72 | return nil 73 | } 74 | 75 | func (d FFPlayPlaybackManager) VolumeMin() int { 76 | return 0 77 | } 78 | 79 | func (d FFPlayPlaybackManager) VolumeDefault() int { 80 | return 80 81 | } 82 | 83 | func (d FFPlayPlaybackManager) VolumeMax() int { 84 | return 100 85 | } 86 | 87 | func (d FFPlayPlaybackManager) VolumeIsPercentage() bool { 88 | return false 89 | } 90 | -------------------------------------------------------------------------------- /playback/manager.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Matteo Pacini 2 | // Permission is hereby granted, free of charge, to any person obtaining a copy 3 | // of this software and associated documentation files (the "Software"), to deal 4 | // in the Software without restriction, including without limitation the rights 5 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | // copies of the Software, and to permit persons to whom the Software is 7 | // furnished to do so, subject to the following conditions: 8 | // 9 | // The above copyright notice and this permission notice shall be included in all 10 | // copies or substantial portions of the Software. 11 | // 12 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | // SOFTWARE. 19 | 20 | package playback 21 | 22 | import ( 23 | "github.com/zi0p4tch0/radiogogo/common" 24 | ) 25 | 26 | // PlaybackManagerService is an interface that defines methods for managing playback of a radio station. 27 | type PlaybackManagerService interface { 28 | // Name returns the name of the playback manager. 29 | Name() string 30 | // IsAvailable returns true if the playback manager is available for use. 31 | IsAvailable() bool 32 | // NotAvailableErrorString returns a string that describes why the playback manager is not available. 33 | NotAvailableErrorString() string 34 | // IsPlaying returns true if a radio station is currently being played. 35 | IsPlaying() bool 36 | // PlayStation starts playing the specified radio station at the given volume. 37 | // If a radio station is already being played, it is stopped first. 38 | PlayStation(station common.Station, volume int) error 39 | // StopStation stops the currently playing radio station. 40 | // If no radio station is being played, this method does nothing. 41 | StopStation() error 42 | // VolumeMin returns the minimum volume level. 43 | VolumeMin() int 44 | // VolumeDefault returns the default volume level. 45 | VolumeDefault() int 46 | // VolumeMax returns the maximum volume level. 47 | VolumeMax() int 48 | // VolumeIsPercentage returns true if the volume is represented as a percentage. 49 | VolumeIsPercentage() bool 50 | } 51 | -------------------------------------------------------------------------------- /screen1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matteo-pacini/RadioGoGo/efad593841c4f8ba8b5918763a57ac6757f9b0f2/screen1.png -------------------------------------------------------------------------------- /screen2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matteo-pacini/RadioGoGo/efad593841c4f8ba8b5918763a57ac6757f9b0f2/screen2.png -------------------------------------------------------------------------------- /screen3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matteo-pacini/RadioGoGo/efad593841c4f8ba8b5918763a57ac6757f9b0f2/screen3.png -------------------------------------------------------------------------------- /screen4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matteo-pacini/RadioGoGo/efad593841c4f8ba8b5918763a57ac6757f9b0f2/screen4.png --------------------------------------------------------------------------------