├── .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 |

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 |
29 |
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 |
223 |
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 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
2 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
3 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;151m#[0m[38;5;151m#[0m[38;5;151m%[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m%[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
4 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;151m#[0m[38;5;59m=[0m[38;5;137m+[0m[38;5;95m=[0m[38;5;58m-[0m[38;5;59m:[0m[38;5;102m+[0m[38;5;102m+[0m[38;5;102m+[0m[38;5;102m+[0m[38;5;102m+[0m[38;5;102m+[0m[38;5;102m+[0m[38;5;108m+[0m[38;5;108m*[0m[38;5;151m#[0m[38;5;151m%[0m[38;5;102m+[0m[38;5;101m+[0m[38;5;101m+[0m[38;5;101m+[0m[38;5;101m=[0m[38;5;151m%[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
5 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;59m:[0m[38;5;94m:[0m[38;5;95m-[0m[38;5;59m-[0m[38;5;143m*[0m[38;5;179m*[0m[38;5;179m#[0m[38;5;215m#[0m[38;5;221m#[0m[38;5;221m#[0m[38;5;221m#[0m[38;5;215m#[0m[38;5;215m#[0m[38;5;215m#[0m[38;5;179m*[0m[38;5;137m+[0m[38;5;95m=[0m[38;5;136m+[0m[38;5;173m+[0m[38;5;131m-[0m[38;5;131m=[0m[38;5;173m*[0m[38;5;58m:[0m[38;5;151m%[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
6 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;151m#[0m[38;5;59m-[0m[38;5;103m+[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;187m%[0m[38;5;223m%[0m[38;5;222m%[0m[38;5;221m#[0m[38;5;221m#[0m[38;5;221m#[0m[38;5;222m%[0m[38;5;229m@[0m[38;5;230m@[0m[38;5;188m%[0m[38;5;230m@[0m[38;5;230m@[0m[38;5;229m@[0m[38;5;221m%[0m[38;5;173m+[0m[38;5;89m:[0m[38;5;89m:[0m[38;5;131m-[0m[38;5;52m.[0m[38;5;151m%[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
7 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m%[0m[38;5;59m:[0m[38;5;188m%[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;188m%[0m[38;5;17m.[0m[38;5;0m [0m[38;5;102m=[0m[38;5;221m%[0m[38;5;221m#[0m[38;5;222m%[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;145m#[0m[38;5;0m [0m[38;5;59m:[0m[38;5;188m#[0m[38;5;189m%[0m[38;5;179m*[0m[38;5;131m=[0m[38;5;59m:[0m[38;5;101m=[0m[38;5;151m%[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
8 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;187m%[0m[38;5;53m.[0m[38;5;15m@[0m[38;5;188m%[0m[38;5;0m.[0m[38;5;53m.[0m[38;5;0m [0m[38;5;0m [0m[38;5;59m-[0m[38;5;221m#[0m[38;5;215m#[0m[38;5;223m@[0m[38;5;15m@[0m[38;5;145m*[0m[38;5;59m:[0m[38;5;53m.[0m[38;5;0m [0m[38;5;0m [0m[38;5;102m+[0m[38;5;15m@[0m[38;5;97m=[0m[38;5;94m-[0m[38;5;59m.[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
9 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;145m*[0m[38;5;59m-[0m[38;5;182m#[0m[38;5;188m#[0m[38;5;96m=[0m[38;5;59m-[0m[38;5;174m*[0m[38;5;210m*[0m[38;5;204m*[0m[38;5;168m=[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;145m*[0m[38;5;60m=[0m[38;5;102m+[0m[38;5;145m#[0m[38;5;189m%[0m[38;5;140m+[0m[38;5;131m-[0m[38;5;131m-[0m[38;5;52m.[0m[38;5;194m%[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
10 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;23m:[0m[38;5;95m=[0m[38;5;180m*[0m[38;5;137m+[0m[38;5;59m:[0m[38;5;146m#[0m[38;5;139m*[0m[38;5;96m=[0m[38;5;146m#[0m[38;5;188m%[0m[38;5;103m+[0m[38;5;59m:[0m[38;5;181m#[0m[38;5;182m*[0m[38;5;175m*[0m[38;5;139m+[0m[38;5;96m-[0m[38;5;131m-[0m[38;5;131m=[0m[38;5;88m:[0m[38;5;59m-[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
11 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;151m%[0m[38;5;102m+[0m[38;5;101m+[0m[38;5;101m+[0m[38;5;101m=[0m[38;5;221m#[0m[38;5;221m#[0m[38;5;221m%[0m[38;5;188m%[0m[38;5;102m+[0m[38;5;0m.[0m[38;5;145m*[0m[38;5;59m:[0m[38;5;59m:[0m[38;5;139m+[0m[38;5;187m%[0m[38;5;215m#[0m[38;5;215m*[0m[38;5;173m*[0m[38;5;173m*[0m[38;5;179m*[0m[38;5;167m+[0m[38;5;131m=[0m[38;5;52m.[0m[38;5;151m#[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;151m%[0m[38;5;151m#[0m[38;5;151m#[0m[38;5;194m@[0m
12 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;109m*[0m[38;5;58m:[0m[38;5;179m*[0m[38;5;215m#[0m[38;5;221m#[0m[38;5;137m+[0m[38;5;137m+[0m[38;5;221m#[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;102m+[0m[38;5;103m+[0m[38;5;60m=[0m[38;5;188m%[0m[38;5;15m@[0m[38;5;230m@[0m[38;5;221m#[0m[38;5;221m%[0m[38;5;221m%[0m[38;5;221m%[0m[38;5;179m#[0m[38;5;95m-[0m[38;5;52m.[0m[38;5;65m=[0m[38;5;230m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;59m:[0m[38;5;60m-[0m[38;5;59m:[0m[38;5;151m%[0m
13 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;151m%[0m[38;5;151m#[0m[38;5;151m#[0m[38;5;151m#[0m[38;5;17m.[0m[38;5;94m:[0m[38;5;215m#[0m[38;5;221m%[0m[38;5;221m%[0m[38;5;179m#[0m[38;5;137m*[0m[38;5;101m=[0m[38;5;145m*[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;222m%[0m[38;5;215m#[0m[38;5;179m*[0m[38;5;137m*[0m[38;5;137m*[0m[38;5;215m#[0m[38;5;215m#[0m[38;5;137m+[0m[38;5;59m-[0m[38;5;102m+[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;151m%[0m[38;5;59m-[0m[38;5;53m.[0m[38;5;66m=[0m[38;5;187m%[0m[38;5;194m@[0m
14 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;158m%[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;152m%[0m[38;5;102m+[0m[38;5;102m=[0m[38;5;101m+[0m[38;5;137m+[0m[38;5;143m*[0m[38;5;137m*[0m[38;5;144m*[0m[38;5;59m:[0m[38;5;52m:[0m[38;5;95m-[0m[38;5;131m=[0m[38;5;173m*[0m[38;5;215m#[0m[38;5;179m*[0m[38;5;0m [0m[38;5;188m%[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;230m@[0m[38;5;136m+[0m[38;5;95m-[0m[38;5;143m*[0m[38;5;179m*[0m[38;5;179m*[0m[38;5;221m#[0m[38;5;221m%[0m[38;5;221m%[0m[38;5;167m+[0m[38;5;0m [0m[38;5;151m#[0m[38;5;151m#[0m[38;5;59m-[0m[38;5;59m:[0m[38;5;108m*[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
15 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;151m%[0m[38;5;59m=[0m[38;5;95m=[0m[38;5;95m-[0m[38;5;101m=[0m[38;5;102m+[0m[38;5;59m-[0m[38;5;137m*[0m[38;5;221m%[0m[38;5;221m%[0m[38;5;221m%[0m[38;5;221m#[0m[38;5;229m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;146m#[0m[38;5;103m+[0m[38;5;96m=[0m[38;5;59m-[0m[38;5;59m-[0m[38;5;59m-[0m[38;5;139m+[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;223m@[0m[38;5;137m*[0m[38;5;0m.[0m[38;5;95m=[0m[38;5;179m*[0m[38;5;173m*[0m[38;5;173m*[0m[38;5;173m+[0m[38;5;131m=[0m[38;5;131m=[0m[38;5;95m-[0m[38;5;52m.[0m[38;5;59m=[0m[38;5;59m:[0m[38;5;59m-[0m[38;5;59m-[0m[38;5;145m#[0m[38;5;194m%[0m[38;5;194m@[0m[38;5;194m@[0m
16 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;151m#[0m[38;5;0m [0m[38;5;89m:[0m[38;5;161m-[0m[38;5;167m=[0m[38;5;125m:[0m[38;5;173m+[0m[38;5;221m%[0m[38;5;215m#[0m[38;5;131m=[0m[38;5;95m-[0m[38;5;95m-[0m[38;5;188m%[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;189m@[0m[38;5;189m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;15m@[0m[38;5;223m@[0m[38;5;221m%[0m[38;5;94m-[0m[38;5;89m:[0m[38;5;167m=[0m[38;5;53m.[0m[38;5;53m.[0m[38;5;53m.[0m[38;5;53m.[0m[38;5;53m.[0m[38;5;53m.[0m[38;5;53m.[0m[38;5;53m.[0m[38;5;95m-[0m[38;5;131m=[0m[38;5;131m=[0m[38;5;131m=[0m[38;5;167m=[0m[38;5;125m-[0m[38;5;59m:[0m[38;5;194m%[0m[38;5;194m@[0m
17 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;151m#[0m[38;5;59m:[0m[38;5;53m.[0m[38;5;125m:[0m[38;5;167m=[0m[38;5;167m=[0m[38;5;131m=[0m[38;5;95m-[0m[38;5;52m.[0m[38;5;167m=[0m[38;5;167m=[0m[38;5;52m.[0m[38;5;103m+[0m[38;5;103m+[0m[38;5;102m+[0m[38;5;101m+[0m[38;5;137m+[0m[38;5;137m+[0m[38;5;137m+[0m[38;5;144m*[0m[38;5;223m%[0m[38;5;223m@[0m[38;5;222m%[0m[38;5;221m%[0m[38;5;221m#[0m[38;5;215m#[0m[38;5;52m.[0m[38;5;167m=[0m[38;5;168m+[0m[38;5;36m=[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;42m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;42m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;35m+[0m[38;5;66m=[0m[38;5;203m=[0m[38;5;0m [0m[38;5;151m%[0m[38;5;194m@[0m
18 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;66m+[0m[38;5;53m.[0m[38;5;53m.[0m[38;5;89m:[0m[38;5;89m:[0m[38;5;52m [0m[38;5;52m:[0m[38;5;203m+[0m[38;5;203m+[0m[38;5;89m:[0m[38;5;58m:[0m[38;5;179m#[0m[38;5;221m%[0m[38;5;221m%[0m[38;5;221m%[0m[38;5;221m%[0m[38;5;221m%[0m[38;5;221m%[0m[38;5;221m#[0m[38;5;215m#[0m[38;5;179m*[0m[38;5;173m*[0m[38;5;167m+[0m[38;5;131m=[0m[38;5;52m.[0m[38;5;161m-[0m[38;5;217m#[0m[38;5;188m%[0m[38;5;187m%[0m[38;5;122m%[0m[38;5;158m%[0m[38;5;193m%[0m[38;5;151m%[0m[38;5;151m#[0m[38;5;193m%[0m[38;5;193m%[0m[38;5;158m%[0m[38;5;86m#[0m[38;5;122m#[0m[38;5;188m%[0m[38;5;203m=[0m[38;5;53m.[0m[38;5;151m%[0m[38;5;194m@[0m
19 | [38;5;194m@[0m[38;5;194m@[0m[38;5;157m%[0m[38;5;84m#[0m[38;5;84m*[0m[38;5;42m*[0m[38;5;42m*[0m[38;5;35m=[0m[38;5;23m:[0m[38;5;17m.[0m[38;5;17m.[0m[38;5;23m.[0m[38;5;52m.[0m[38;5;203m+[0m[38;5;203m+[0m[38;5;125m-[0m[38;5;173m+[0m[38;5;215m#[0m[38;5;215m#[0m[38;5;215m#[0m[38;5;179m*[0m[38;5;179m*[0m[38;5;173m*[0m[38;5;173m+[0m[38;5;167m+[0m[38;5;131m=[0m[38;5;131m=[0m[38;5;131m-[0m[38;5;131m-[0m[38;5;95m:[0m[38;5;0m [0m[38;5;161m-[0m[38;5;217m#[0m[38;5;194m%[0m[38;5;194m%[0m[38;5;158m%[0m[38;5;194m@[0m[38;5;217m%[0m[38;5;224m%[0m[38;5;182m#[0m[38;5;193m@[0m[38;5;194m@[0m[38;5;117m#[0m[38;5;39m+[0m[38;5;39m=[0m[38;5;140m*[0m[38;5;203m=[0m[38;5;0m [0m[38;5;151m%[0m[38;5;194m@[0m
20 | [38;5;194m@[0m[38;5;78m*[0m[38;5;41m+[0m[38;5;47m+[0m[38;5;41m=[0m[38;5;41m+[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;29m=[0m[38;5;0m.[0m[38;5;161m-[0m[38;5;161m-[0m[38;5;89m:[0m[38;5;89m:[0m[38;5;89m-[0m[38;5;89m-[0m[38;5;89m-[0m[38;5;89m-[0m[38;5;89m-[0m[38;5;89m:[0m[38;5;89m:[0m[38;5;89m:[0m[38;5;53m:[0m[38;5;59m:[0m[38;5;59m:[0m[38;5;23m:[0m[38;5;23m:[0m[38;5;23m:[0m[38;5;53m.[0m[38;5;89m:[0m[38;5;95m=[0m[38;5;102m=[0m[38;5;96m=[0m[38;5;96m=[0m[38;5;96m=[0m[38;5;96m=[0m[38;5;96m=[0m[38;5;96m=[0m[38;5;102m=[0m[38;5;96m=[0m[38;5;60m:[0m[38;5;60m-[0m[38;5;89m:[0m[38;5;53m:[0m[38;5;23m.[0m[38;5;66m=[0m[38;5;194m@[0m
21 | [38;5;194m@[0m[38;5;73m*[0m[38;5;36m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;41m+[0m[38;5;35m=[0m[38;5;41m+[0m[38;5;35m=[0m[38;5;47m+[0m[38;5;47m+[0m[38;5;47m*[0m[38;5;35m=[0m[38;5;23m:[0m[38;5;23m:[0m[38;5;23m:[0m[38;5;29m-[0m[38;5;29m-[0m[38;5;29m-[0m[38;5;29m-[0m[38;5;29m-[0m[38;5;29m-[0m[38;5;35m-[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;41m=[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;47m*[0m[38;5;41m+[0m[38;5;41m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m-[0m[38;5;35m-[0m[38;5;29m-[0m[38;5;29m-[0m[38;5;29m-[0m[38;5;30m-[0m[38;5;72m+[0m[38;5;194m@[0m
22 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m%[0m[38;5;151m%[0m[38;5;115m#[0m[38;5;73m*[0m[38;5;72m*[0m[38;5;72m+[0m[38;5;36m+[0m[38;5;36m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;41m+[0m[38;5;35m=[0m[38;5;29m-[0m[38;5;29m-[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;35m=[0m[38;5;36m=[0m[38;5;36m=[0m[38;5;36m=[0m[38;5;72m+[0m[38;5;72m+[0m[38;5;73m*[0m[38;5;115m#[0m[38;5;151m#[0m[38;5;194m%[0m[38;5;194m@[0m[38;5;194m@[0m
23 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m%[0m[38;5;158m%[0m[38;5;151m%[0m[38;5;151m#[0m[38;5;151m#[0m[38;5;115m#[0m[38;5;115m#[0m[38;5;115m*[0m[38;5;115m*[0m[38;5;109m*[0m[38;5;73m*[0m[38;5;73m*[0m[38;5;73m*[0m[38;5;73m*[0m[38;5;73m*[0m[38;5;73m*[0m[38;5;73m*[0m[38;5;73m*[0m[38;5;73m*[0m[38;5;73m*[0m[38;5;109m*[0m[38;5;109m*[0m[38;5;109m*[0m[38;5;109m*[0m[38;5;115m#[0m[38;5;151m#[0m[38;5;151m#[0m[38;5;151m#[0m[38;5;152m%[0m[38;5;194m%[0m[38;5;194m%[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
24 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
25 | [38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m[38;5;194m@[0m
26 |
--------------------------------------------------------------------------------
/assets/noStations.txt:
--------------------------------------------------------------------------------
1 | [38;2;255;255;255m@[0m[38;2;253;253;253m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;253;252;252m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;254;254;254m@[0m[38;2;253;252;252m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;253;252;252m@[0m[38;2;253;252;252m@[0m[38;2;254;253;253m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;252;252;252m@[0m[38;2;203;196;201m#[0m[38;2;193;183;180m#[0m[38;2;194;182;171m#[0m[38;2;200;191;185m#[0m[38;2;232;232;235m@[0m[38;2;255;255;255m@[0m[38;2;254;254;254m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
2 | [38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;252;250;248m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;238;238;243m@[0m[38;2;226;225;227m%[0m[38;2;223;220;223m%[0m[38;2;227;226;232m%[0m[38;2;249;251;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;242;245;249m@[0m[38;2;227;228;233m%[0m[38;2;216;214;218m%[0m[38;2;207;202;205m%[0m[38;2;201;194;197m#[0m[38;2;200;192;194m#[0m[38;2;201;194;196m#[0m[38;2;207;201;204m#[0m[38;2;195;192;196m#[0m[38;2;119;93;91m-[0m[38;2;181;133;91m+[0m[38;2;200;141;90m*[0m[38;2;172;85;55m=[0m[38;2;152;72;47m-[0m[38;2;125;68;47m-[0m[38;2;186;170;170m*[0m[38;2;254;254;254m@[0m[38;2;253;253;253m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
3 | [38;2;255;255;255m@[0m[38;2;190;178;179m#[0m[38;2;134;125;136m+[0m[38;2;149;147;160m+[0m[38;2;158;142;146m+[0m[38;2;255;254;254m@[0m[38;2;254;254;254m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;254;254;254m@[0m[38;2;254;254;254m@[0m[38;2;239;238;239m@[0m[38;2;156;130;126m+[0m[38;2;161;114;83m=[0m[38;2;169;116;76m=[0m[38;2;176;130;91m+[0m[38;2;183;143;101m+[0m[38;2;164;127;110m+[0m[38;2;182;169;173m*[0m[38;2;188;175;175m#[0m[38;2;164;133;123m+[0m[38;2;167;127;104m+[0m[38;2;175;124;90m+[0m[38;2;183;124;82m+[0m[38;2;192;128;79m+[0m[38;2;199;132;78m+[0m[38;2;200;131;78m+[0m[38;2;198;129;76m+[0m[38;2;194;126;76m+[0m[38;2;177;114;71m+[0m[38;2;172;112;72m=[0m[38;2;185;125;77m+[0m[38;2;121;54;43m:[0m[38;2;126;19;43m:[0m[38;2;192;50;77m-[0m[38;2;167;55;33m-[0m[38;2;102;65;55m:[0m[38;2;252;253;253m@[0m[38;2;254;253;253m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
4 | [38;2;255;255;255m@[0m[38;2;185;172;173m*[0m[38;2;105;96;111m-[0m[38;2;74;64;84m:[0m[38;2;143;125;128m+[0m[38;2;255;254;254m@[0m[38;2;253;253;253m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;252;251;251m@[0m[38;2;255;255;255m@[0m[38;2;136;113;115m=[0m[38;2;140;58;16m-[0m[38;2;191;80;66m=[0m[38;2;180;41;60m-[0m[38;2;179;42;55m-[0m[38;2;161;56;48m-[0m[38;2;198;124;69m+[0m[38;2;165;93;49m=[0m[38;2;169;94;47m=[0m[38;2;221;139;71m*[0m[38;2;234;151;76m*[0m[38;2;253;170;86m#[0m[38;2;255;183;99m#[0m[38;2;252;176;94m#[0m[38;2;249;174;94m#[0m[38;2;250;175;95m#[0m[38;2;250;175;94m#[0m[38;2;248;172;93m#[0m[38;2;254;176;95m#[0m[38;2;208;135;75m+[0m[38;2;168;111;73m=[0m[38;2;221;152;95m*[0m[38;2;193;128;80m+[0m[38;2;160;93;78m=[0m[38;2;92;38;38m:[0m[38;2;182;174;175m*[0m[38;2;255;255;255m@[0m[38;2;252;251;251m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
5 | [38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;254;252m@[0m[38;2;170;154;153m*[0m[38;2;143;122;125m+[0m[38;2;255;255;255m@[0m[38;2;252;252;252m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;253;253;253m@[0m[38;2;254;254;254m@[0m[38;2;194;184;185m#[0m[38;2;113;56;37m:[0m[38;2;167;56;43m-[0m[38;2;237;91;97m+[0m[38;2;209;73;84m=[0m[38;2;159;68;46m-[0m[38;2;209;129;69m+[0m[38;2;236;153;81m*[0m[38;2;237;159;85m*[0m[38;2;238;164;88m*[0m[38;2;248;176;97m#[0m[38;2;201;153;106m*[0m[38;2;78;47;38m:[0m[38;2;195;131;73m+[0m[38;2;254;188;104m#[0m[38;2;248;178;99m#[0m[38;2;250;180;100m#[0m[38;2;247;177;99m#[0m[38;2;252;183;102m#[0m[38;2;197;134;75m+[0m[38;2;76;39;26m.[0m[38;2;101;61;45m:[0m[38;2;167;110;68m=[0m[38;2;218;142;79m*[0m[38;2;203;129;85m+[0m[38;2;146;105;98m=[0m[38;2;184;177;180m#[0m[38;2;255;255;255m@[0m[38;2;253;252;252m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
6 | [38;2;255;255;255m@[0m[38;2;253;253;253m@[0m[38;2;253;254;254m@[0m[38;2;255;255;255m@[0m[38;2;134;113;116m=[0m[38;2;166;149;152m*[0m[38;2;255;255;255m@[0m[38;2;252;252;252m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;254;254;254m@[0m[38;2;255;255;255m@[0m[38;2;231;231;233m@[0m[38;2;187;175;170m*[0m[38;2;94;59;50m:[0m[38;2;128;47;25m:[0m[38;2;207;119;60m+[0m[38;2;230;148;77m*[0m[38;2;229;154;82m*[0m[38;2;249;174;94m#[0m[38;2;237;168;94m#[0m[38;2;161;114;74m=[0m[38;2;102;63;45m:[0m[38;2;143;90;52m-[0m[38;2;238;170;95m#[0m[38;2;248;179;98m#[0m[38;2;248;179;99m#[0m[38;2;246;176;97m#[0m[38;2;245;173;95m#[0m[38;2;244;172;95m#[0m[38;2;250;177;96m#[0m[38;2;255;179;97m#[0m[38;2;211;142;75m*[0m[38;2;135;75;34m-[0m[38;2;128;70;35m-[0m[38;2;140;70;31m-[0m[38;2;158;70;35m-[0m[38;2;130;68;52m-[0m[38;2;171;159;160m*[0m[38;2;255;255;255m@[0m[38;2;253;252;252m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
7 | [38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;254;253;253m@[0m[38;2;253;253;254m@[0m[38;2;244;241;240m@[0m[38;2;105;82;88m-[0m[38;2;199;190;192m#[0m[38;2;255;255;255m@[0m[38;2;252;252;252m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;251;250;250m@[0m[38;2;255;255;255m@[0m[38;2;204;201;203m#[0m[38;2;112;58;51m:[0m[38;2;202;104;56m+[0m[38;2;224;144;73m*[0m[38;2;229;153;81m*[0m[38;2;241;165;88m#[0m[38;2;191;116;54m+[0m[38;2;124;63;27m-[0m[38;2;106;51;26m:[0m[38;2;127;68;35m-[0m[38;2;166;96;45m=[0m[38;2;222;151;80m*[0m[38;2;246;175;96m#[0m[38;2;240;164;82m*[0m[38;2;243;168;85m#[0m[38;2;244;178;98m#[0m[38;2;237;171;95m#[0m[38;2;242;178;98m#[0m[38;2;227;152;75m*[0m[38;2;69;24;12m.[0m[38;2;35;38;52m.[0m[38;2;55;66;81m:[0m[38;2;92;116;129m=[0m[38;2;121;137;144m+[0m[38;2;125;94;88m=[0m[38;2;108;58;47m:[0m[38;2;195;188;190m#[0m[38;2;255;255;255m@[0m[38;2;253;252;252m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
8 | [38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;252;252;252m@[0m[38;2;255;255;255m@[0m[38;2;207;200;200m#[0m[38;2;96;78;87m-[0m[38;2;224;217;217m%[0m[38;2;255;255;255m@[0m[38;2;251;251;251m@[0m[38;2;252;252;252m@[0m[38;2;252;252;252m@[0m[38;2;253;252;253m@[0m[38;2;253;253;253m@[0m[38;2;253;252;252m@[0m[38;2;254;254;254m@[0m[38;2;121;98;100m=[0m[38;2;170;94;69m=[0m[38;2;212;122;60m+[0m[38;2;222;143;74m*[0m[38;2;240;158;83m*[0m[38;2;167;105;57m=[0m[38;2;83;80;88m-[0m[38;2;126;138;147m+[0m[38;2;91;121;132m=[0m[38;2;54;77;96m:[0m[38;2;38;76;98m:[0m[38;2;68;42;42m.[0m[38;2;217;136;65m+[0m[38;2;246;196;142m%[0m[38;2;228;190;164m#[0m[38;2;210;141;134m*[0m[38;2;221;149;149m*[0m[38;2;202;95;97m=[0m[38;2;203;158;136m*[0m[38;2;159;125;105m+[0m[38;2;113;138;146m+[0m[38;2;127;235;243m%[0m[38;2;105;207;219m*[0m[38;2;146;235;248m%[0m[38;2;106;100;107m=[0m[38;2;169;79;41m-[0m[38;2;113;78;76m-[0m[38;2;242;242;242m@[0m[38;2;254;254;254m@[0m[38;2;254;254;254m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
9 | [38;2;253;252;252m@[0m[38;2;254;254;254m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;155;142;146m+[0m[38;2;118;107;118m=[0m[38;2;237;231;231m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;236;233;233m@[0m[38;2;115;84;84m-[0m[38;2;191;106;71m=[0m[38;2;206;116;55m+[0m[38;2;217;137;71m*[0m[38;2;237;150;73m*[0m[38;2;136;93;67m=[0m[38;2;87;128;161m=[0m[38;2;160;235;248m%[0m[38;2;113;216;225m#[0m[38;2;135;247;252m%[0m[38;2;151;227;236m%[0m[38;2;154;98;67m=[0m[38;2;250;227;203m@[0m[38;2;255;255;255m@[0m[38;2;244;242;245m@[0m[38;2;213;164;174m#[0m[38;2;198;119;137m+[0m[38;2;187;114;131m+[0m[38;2;248;244;246m@[0m[38;2;255;255;255m@[0m[38;2;202;175;164m#[0m[38;2;154;191;191m#[0m[38;2;152;255;255m%[0m[38;2;135;253;252m%[0m[38;2;169;228;226m%[0m[38;2;148;76;55m-[0m[38;2;126;55;37m:[0m[38;2;172;164;167m*[0m[38;2;254;253;253m@[0m[38;2;252;251;251m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
10 | [38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;254;253m@[0m[38;2;247;245;245m@[0m[38;2;236;233;233m@[0m[38;2;225;221;222m%[0m[38;2;201;190;190m#[0m[38;2;92;80;93m-[0m[38;2;126;113;120m=[0m[38;2;189;177;179m#[0m[38;2;167;155;159m*[0m[38;2;155;143;147m+[0m[38;2;145;133;138m+[0m[38;2;139;127;132m+[0m[38;2;135;123;129m=[0m[38;2;107;96;103m-[0m[38;2;109;72;71m-[0m[38;2;191;103;68m=[0m[38;2;198;105;50m=[0m[38;2;213;126;64m+[0m[38;2;228;143;74m*[0m[38;2;194;106;46m=[0m[38;2;139;119;116m=[0m[38;2;157;253;252m%[0m[38;2;140;252;253m%[0m[38;2;162;255;255m%[0m[38;2;149;146;144m+[0m[38;2;180;111;76m+[0m[38;2;246;250;255m@[0m[38;2;253;255;247m@[0m[38;2;250;249;240m@[0m[38;2;234;237;234m@[0m[38;2;176;166;163m*[0m[38;2;156;144;143m+[0m[38;2;192;180;180m#[0m[38;2;202;189;189m#[0m[38;2;176;150;153m*[0m[38;2;82;67;75m:[0m[38;2;125;234;251m%[0m[38;2;119;231;249m#[0m[38;2;125;245;255m%[0m[38;2;88;117;141m=[0m[38;2;131;42;19m:[0m[38;2;130;105;105m=[0m[38;2;255;255;255m@[0m[38;2;255;254;253m@[0m[38;2;253;252;252m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
11 | [38;2;159;143;146m+[0m[38;2;138;128;136m+[0m[38;2;129;116;124m=[0m[38;2;127;115;123m=[0m[38;2;128;118;128m=[0m[38;2;130;121;131m=[0m[38;2;139;131;142m+[0m[38;2;140;130;139m+[0m[38;2;73;66;82m:[0m[38;2;80;71;88m:[0m[38;2;156;149;158m+[0m[38;2;167;160;169m*[0m[38;2;172;165;171m*[0m[38;2;179;170;173m*[0m[38;2;186;173;173m*[0m[38;2;167;151;147m*[0m[38;2;112;77;76m-[0m[38;2;182;92;66m=[0m[38;2;184;84;40m=[0m[38;2;198;107;55m+[0m[38;2;225;132;66m+[0m[38;2;146;76;42m-[0m[38;2;139;198;214m#[0m[38;2;138;254;255m%[0m[38;2;129;237;248m%[0m[38;2;148;254;255m%[0m[38;2;112;108;111m=[0m[38;2;193;101;44m=[0m[38;2;164;103;82m=[0m[38;2;145;99;92m=[0m[38;2;160;128;125m+[0m[38;2;114;81;82m-[0m[38;2;140;115;119m=[0m[38;2;154;131;136m+[0m[38;2;111;78;79m-[0m[38;2;172;136;130m+[0m[38;2;171;87;49m=[0m[38;2;139;55;23m:[0m[38;2;66;152;198m+[0m[38;2;76;186;248m*[0m[38;2;60;165;233m+[0m[38;2;69;171;229m+[0m[38;2;115;50;41m:[0m[38;2;116;61;52m:[0m[38;2;203;203;206m%[0m[38;2;249;253;255m@[0m[38;2;255;255;255m@[0m[38;2;255;254;254m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
12 | [38;2;72;52;60m:[0m[38;2;113;105;118m=[0m[38;2;200;195;196m#[0m[38;2;201;191;188m#[0m[38;2;204;189;182m#[0m[38;2;206;187;175m#[0m[38;2;207;183;165m#[0m[38;2;206;177;152m#[0m[38;2;153;114;88m=[0m[38;2;155;113;82m=[0m[38;2;202;150;106m*[0m[38;2;204;148;100m*[0m[38;2;200;138;85m+[0m[38;2;197;128;72m+[0m[38;2;195;120;65m+[0m[38;2;170;89;38m=[0m[38;2;104;64;57m:[0m[38;2;175;90;72m=[0m[38;2;175;70;34m-[0m[38;2;194;100;55m=[0m[38;2;201;102;51m=[0m[38;2;102;55;41m:[0m[38;2;77;189;235m*[0m[38;2;107;213;250m#[0m[38;2;105;206;243m#[0m[38;2;90;203;247m*[0m[38;2;87;84;90m-[0m[38;2;224;125;54m+[0m[38;2;231;145;79m*[0m[38;2;239;209;178m%[0m[38;2;255;255;247m@[0m[38;2;255;255;245m@[0m[38;2;218;211;203m%[0m[38;2;219;208;199m%[0m[38;2;255;252;238m@[0m[38;2;255;255;255m@[0m[38;2;255;255;236m@[0m[38;2;177;108;61m=[0m[38;2;51;76;98m:[0m[38;2;66;123;171m=[0m[38;2;67;98;140m-[0m[38;2;85;136;172m=[0m[38;2;78;48;51m:[0m[38;2;105;33;20m:[0m[38;2;131;54;32m:[0m[38;2;160;116;95m=[0m[38;2;189;175;174m#[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
13 | [38;2;115;98;105m=[0m[38;2;90;66;72m:[0m[38;2;193;122;69m+[0m[38;2;203;143;85m*[0m[38;2;210;148;82m*[0m[38;2;212;144;74m*[0m[38;2;203;133;67m+[0m[38;2;199;129;66m+[0m[38;2;203;137;79m+[0m[38;2;201;137;83m+[0m[38;2;186;131;83m+[0m[38;2;182;134;93m+[0m[38;2;176;138;104m+[0m[38;2;172;143;116m+[0m[38;2;170;145;124m+[0m[38;2;160;153;140m*[0m[38;2;83;62;61m:[0m[38;2;155;69;53m-[0m[38;2;191;94;50m=[0m[38;2;223;144;79m*[0m[38;2;220;141;81m*[0m[38;2;132;88;67m-[0m[38;2;57;134;178m=[0m[38;2;43;144;214m=[0m[38;2;51;147;217m=[0m[38;2;71;178;247m*[0m[38;2;88;84;92m-[0m[38;2;187;93;34m=[0m[38;2;193;159;130m*[0m[38;2;197;171;168m#[0m[38;2;189;144;135m*[0m[38;2;192;150;140m*[0m[38;2;202;178;166m#[0m[38;2;210;203;188m%[0m[38;2;230;232;216m@[0m[38;2;222;223;207m%[0m[38;2;174;139;136m+[0m[38;2;180;76;76m=[0m[38;2;197;86;88m=[0m[38;2;227;146;147m*[0m[38;2;237;121;120m*[0m[38;2;232;92;94m+[0m[38;2;215;65;73m=[0m[38;2;144;45;52m:[0m[38;2;115;58;49m:[0m[38;2;173;120;89m+[0m[38;2;133;90;70m-[0m[38;2;133;106;104m=[0m[38;2;252;252;252m@[0m[38;2;253;253;253m@[0m
14 | [38;2;150;133;137m+[0m[38;2;73;50;61m:[0m[38;2;163;65;19m-[0m[38;2;205;121;65m+[0m[38;2;210;160;107m*[0m[38;2;125;110;111m=[0m[38;2;156;151;142m+[0m[38;2;165;178;172m*[0m[38;2;166;184;177m*[0m[38;2;163;194;189m#[0m[38;2;170;201;195m#[0m[38;2;167;198;194m#[0m[38;2;167;199;193m#[0m[38;2;174;199;188m#[0m[38;2;162;197;195m#[0m[38;2;158;160;156m*[0m[38;2;88;64;63m:[0m[38;2;158;73;57m-[0m[38;2;171;71;40m-[0m[38;2;216;134;73m+[0m[38;2;234;157;86m*[0m[38;2;245;171;100m#[0m[38;2;204;146;98m*[0m[38;2;127;118;113m=[0m[38;2;72;117;148m=[0m[38;2;61;144;182m=[0m[38;2;52;34;45m.[0m[38;2;139;29;28m:[0m[38;2;191;44;53m-[0m[38;2;235;147;150m*[0m[38;2;240;193;195m%[0m[38;2;236;149;150m*[0m[38;2;226;104;106m+[0m[38;2;208;69;72m=[0m[38;2;189;78;79m=[0m[38;2;128;57;58m-[0m[38;2;109;16;18m.[0m[38;2;177;47;51m-[0m[38;2;252;122;123m*[0m[38;2;255;163;163m#[0m[38;2;255;125;125m*[0m[38;2;255;109;110m*[0m[38;2;236;74;77m=[0m[38;2;121;69;71m-[0m[38;2;214;211;212m%[0m[38;2;240;242;243m@[0m[38;2;255;255;255m@[0m[38;2;131;99;95m=[0m[38;2;187;174;175m*[0m[38;2;255;255;255m@[0m
15 | [38;2;183;168;167m*[0m[38;2;63;52;68m:[0m[38;2;97;85;98m-[0m[38;2;165;89;49m=[0m[38;2;208;146;93m*[0m[38;2;107;119;144m=[0m[38;2;157;166;159m*[0m[38;2;181;183;154m#[0m[38;2;148;148;154m+[0m[38;2;168;117;124m+[0m[38;2;131;121;134m=[0m[38;2;123;136;153m+[0m[38;2;125;124;131m=[0m[38;2;134;104;86m=[0m[38;2;105;120;140m=[0m[38;2;78;42;53m:[0m[38;2;113;67;65m-[0m[38;2;189;98;65m=[0m[38;2;136;57;39m-[0m[38;2;149;71;53m-[0m[38;2;199;112;65m+[0m[38;2;220;134;72m*[0m[38;2;237;153;82m*[0m[38;2;245;160;84m*[0m[38;2;222;146;82m*[0m[38;2;169;144;129m+[0m[38;2;187;166;167m*[0m[38;2;206;173;176m#[0m[38;2;191;146;147m*[0m[38;2;170;127;127m+[0m[38;2;249;164;164m#[0m[38;2;255;141;138m*[0m[38;2;255;110;107m*[0m[38;2;255;108;105m+[0m[38;2;255;103;102m+[0m[38;2;202;70;71m=[0m[38;2;57;12;12m [0m[38;2;150;69;67m-[0m[38;2;242;100;98m+[0m[38;2;249;87;85m+[0m[38;2;253;94;95m+[0m[38;2;254;98;96m+[0m[38;2;237;74;78m=[0m[38;2;92;43;49m:[0m[38;2;177;170;171m*[0m[38;2;216;209;206m%[0m[38;2;200;184;190m#[0m[38;2;124;98;102m=[0m[38;2;215;209;208m%[0m[38;2;253;253;253m@[0m
16 | [38;2;214;206;205m%[0m[38;2;70;36;40m.[0m[38;2;68;44;60m:[0m[38;2;128;58;36m:[0m[38;2;215;137;75m*[0m[38;2;114;122;136m=[0m[38;2;109;94;106m-[0m[38;2;124;99;94m=[0m[38;2;102;90;107m-[0m[38;2;105;85;102m-[0m[38;2;96;88;102m-[0m[38;2;103;107;120m=[0m[38;2;90;94;109m-[0m[38;2;76;75;90m-[0m[38;2;76;68;79m:[0m[38;2;50;38;46m.[0m[38;2;143;89;80m=[0m[38;2;182;82;44m=[0m[38;2;186;97;50m=[0m[38;2;133;60;41m-[0m[38;2;112;46;45m:[0m[38;2;133;60;52m-[0m[38;2;157;77;57m-[0m[38;2;175;83;48m=[0m[38;2;198;133;110m+[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;228;227;224m%[0m[38;2;92;61;62m:[0m[38;2;204;51;55m-[0m[38;2;255;96;96m+[0m[38;2;252;98;97m+[0m[38;2;253;100;98m+[0m[38;2;247;100;99m+[0m[38;2;167;69;68m-[0m[38;2;144;59;57m-[0m[38;2;105;34;34m:[0m[38;2;150;59;60m-[0m[38;2;255;101;104m+[0m[38;2;255;90;93m+[0m[38;2;250;85;88m+[0m[38;2;247;78;84m+[0m[38;2;203;54;66m-[0m[38;2;132;34;51m:[0m[38;2;122;42;64m:[0m[38;2;107;72;79m-[0m[38;2;241;239;238m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m
17 | [38;2;237;239;240m@[0m[38;2;106;45;34m:[0m[38;2;137;33;5m:[0m[38;2;156;64;29m-[0m[38;2;200;110;56m+[0m[38;2;131;129;131m+[0m[38;2;91;97;115m-[0m[38;2;70;65;81m:[0m[38;2;74;69;80m:[0m[38;2;70;64;76m:[0m[38;2;69;60;70m:[0m[38;2;64;52;63m:[0m[38;2;63;51;63m:[0m[38;2;63;52;63m:[0m[38;2;61;52;65m:[0m[38;2;47;28;34m.[0m[38;2;152;89;79m=[0m[38;2;167;64;35m-[0m[38;2;189;98;55m=[0m[38;2;209;117;62m+[0m[38;2;191;106;59m=[0m[38;2;164;91;57m=[0m[38;2;143;77;55m-[0m[38;2;120;53;44m:[0m[38;2;100;41;45m:[0m[38;2;111;81;93m-[0m[38;2;163;132;143m+[0m[38;2;167;144;149m+[0m[38;2;149;121;131m+[0m[38;2;112;50;61m:[0m[38;2;216;63;71m=[0m[38;2;253;87;92m+[0m[38;2;252;84;88m+[0m[38;2;253;86;88m+[0m[38;2;254;86;89m+[0m[38;2;254;91;94m+[0m[38;2;133;44;45m:[0m[38;2;71;21;22m.[0m[38;2;170;61;66m-[0m[38;2;200;62;69m=[0m[38;2;236;73;82m=[0m[38;2;236;71;80m=[0m[38;2;229;67;77m=[0m[38;2;207;49;65m-[0m[38;2;186;23;52m:[0m[38;2;129;20;53m:[0m[38;2;119;98;98m=[0m[38;2;202;193;192m#[0m[38;2;195;184;181m#[0m[38;2;241;239;236m@[0m
18 | [38;2;255;255;255m@[0m[38;2;123;76;72m-[0m[38;2;143;42;13m:[0m[38;2;145;54;24m-[0m[38;2;183;89;40m=[0m[38;2;128;103;95m=[0m[38;2;64;60;75m:[0m[38;2;57;49;62m:[0m[38;2;61;56;70m:[0m[38;2;63;59;73m:[0m[38;2;65;61;75m:[0m[38;2;70;63;77m:[0m[38;2;74;63;79m:[0m[38;2;79;62;79m:[0m[38;2;87;63;80m:[0m[38;2;57;20;35m.[0m[38;2;133;83;92m-[0m[38;2;168;65;43m-[0m[38;2;178;84;44m=[0m[38;2;206;124;70m+[0m[38;2;225;145;80m*[0m[38;2;237;158;86m*[0m[38;2;237;159;87m*[0m[38;2;237;158;86m*[0m[38;2;186;107;52m=[0m[38;2;77;22;4m.[0m[38;2;93;10;30m.[0m[38;2;121;0;25m.[0m[38;2;144;3;29m.[0m[38;2;196;33;53m-[0m[38;2;230;62;77m=[0m[38;2;233;67;79m=[0m[38;2;238;70;82m=[0m[38;2;239;71;81m=[0m[38;2;241;71;82m=[0m[38;2;228;66;77m=[0m[38;2;199;58;67m-[0m[38;2;138;39;44m:[0m[38;2;74;16;19m.[0m[38;2;158;44;54m-[0m[38;2;223;61;77m=[0m[38;2;211;52;69m-[0m[38;2;185;32;54m-[0m[38;2;171;18;48m:[0m[38;2;139;31;62m:[0m[38;2;126;100;103m=[0m[38;2;205;197;195m#[0m[38;2;208;193;197m#[0m[38;2;170;142;151m+[0m[38;2;120;85;97m-[0m
19 | [38;2;199;221;151m%[0m[38;2;87;76;18m:[0m[38;2;115;23;23m.[0m[38;2;133;37;22m:[0m[38;2;168;68;35m-[0m[38;2;130;78;68m-[0m[38;2;84;71;90m-[0m[38;2;89;60;76m:[0m[38;2;94;59;73m:[0m[38;2;98;56;68m:[0m[38;2;101;54;62m:[0m[38;2;104;55;52m:[0m[38;2;104;57;42m:[0m[38;2;107;65;33m:[0m[38;2;108;74;25m-[0m[38;2;95;79;8m:[0m[38;2;84;70;39m:[0m[38;2;160;95;110m=[0m[38;2;161;59;38m-[0m[38;2;171;75;36m-[0m[38;2;191;104;55m=[0m[38;2;203;120;65m+[0m[38;2;219;135;73m*[0m[38;2;164;96;52m=[0m[38;2;160;128;114m+[0m[38;2;195;176;171m#[0m[38;2;180;164;163m*[0m[38;2;151;101;105m=[0m[38;2;132;14;35m:[0m[38;2;175;24;54m:[0m[38;2;178;21;48m:[0m[38;2;186;29;51m-[0m[38;2;193;38;57m-[0m[38;2;201;45;62m-[0m[38;2;205;48;65m-[0m[38;2;215;51;68m=[0m[38;2;203;49;64m-[0m[38;2;102;22;29m.[0m[38;2;101;26;32m.[0m[38;2;187;42;59m-[0m[38;2;183;30;51m-[0m[38;2;168;17;46m:[0m[38;2;161;22;53m:[0m[38;2;121;38;59m:[0m[38;2;115;88;100m-[0m[38;2;202;184;191m#[0m[38;2;199;174;197m#[0m[38;2;181;148;186m*[0m[38;2;117;89;90m-[0m[38;2;118;127;44m=[0m
20 | [38;2;116;153;1m=[0m[38;2;140;186;0m+[0m[38;2;104;122;8m=[0m[38;2;95;79;15m:[0m[38;2;116;68;33m-[0m[38;2;119;68;18m-[0m[38;2;97;64;8m:[0m[38;2;110;87;9m-[0m[38;2;113;99;13m-[0m[38;2;115;116;0m=[0m[38;2;123;138;0m=[0m[38;2;133;158;0m+[0m[38;2;143;177;3m+[0m[38;2;157;197;11m*[0m[38;2;163;214;5m*[0m[38;2;184;238;5m#[0m[38;2;114;149;0m=[0m[38;2;71;67;39m:[0m[38;2;137;89;103m=[0m[38;2;157;69;72m-[0m[38;2;161;58;43m-[0m[38;2;167;65;36m-[0m[38;2;182;80;39m=[0m[38;2;88;37;25m:[0m[38;2;179;163;184m*[0m[38;2;195;172;194m#[0m[38;2;199;176;194m#[0m[38;2;219;210;213m%[0m[38;2;155;135;131m+[0m[38;2;88;23;37m.[0m[38;2;138;33;63m:[0m[38;2;151;18;56m:[0m[38;2;159;12;51m:[0m[38;2;162;11;47m:[0m[38;2;163;13;45m:[0m[38;2;161;14;42m:[0m[38;2;169;17;44m:[0m[38;2;132;19;36m:[0m[38;2;88;12;25m.[0m[38;2;146;11;44m:[0m[38;2;153;12;54m:[0m[38;2;128;27;50m:[0m[38;2;96;48;65m:[0m[38;2;137;114;148m=[0m[38;2;170;138;179m+[0m[38;2;156;121;164m+[0m[38;2;130;99;127m=[0m[38;2;84;67;50m:[0m[38;2;105;130;0m=[0m[38;2;145;193;0m*[0m
21 | [38;2;111;112;56m=[0m[38;2;123;168;0m+[0m[38;2;175;232;6m#[0m[38;2;171;227;4m#[0m[38;2;134;179;2m+[0m[38;2;127;141;87m+[0m[38;2;164;173;154m*[0m[38;2;135;155;86m+[0m[38;2;163;216;0m*[0m[38;2;173;221;32m#[0m[38;2;178;222;40m#[0m[38;2;170;217;1m#[0m[38;2;145;159;87m+[0m[38;2;166;179;134m*[0m[38;2;139;149;78m+[0m[38;2;162;201;0m*[0m[38;2;155;199;0m*[0m[38;2;101;137;0m=[0m[38;2;72;95;4m-[0m[38;2;87;83;52m-[0m[38;2;112;74;72m-[0m[38;2;128;61;70m-[0m[38;2;143;53;61m-[0m[38;2;120;31;34m:[0m[38;2;103;64;84m-[0m[38;2;168;140;176m*[0m[38;2;166;137;169m+[0m[38;2;160;129;160m+[0m[38;2;186;159;187m*[0m[38;2;109;82;101m-[0m[38;2;59;37;26m.[0m[38;2;68;45;25m.[0m[38;2;89;52;34m:[0m[38;2;108;43;44m:[0m[38;2;116;29;44m:[0m[38;2;128;23;52m:[0m[38;2;134;15;53m:[0m[38;2;137;13;54m:[0m[38;2;115;16;46m.[0m[38;2;103;33;45m:[0m[38;2;86;63;32m:[0m[38;2;74;94;11m-[0m[38;2;68;84;8m:[0m[38;2;100;93;70m-[0m[38;2;97;89;67m-[0m[38;2;89;87;39m-[0m[38;2;83;103;2m-[0m[38;2;97;138;0m=[0m[38;2;98;144;0m=[0m[38;2;86;104;1m-[0m
22 | [38;2;226;218;221m%[0m[38;2;108;85;59m-[0m[38;2;86;88;0m-[0m[38;2;119;151;0m=[0m[38;2;132;178;0m+[0m[38;2;139;170;53m+[0m[38;2;152;180;73m*[0m[38;2;151;181;43m*[0m[38;2;150;184;8m*[0m[38;2;141;141;112m+[0m[38;2;140;138;118m+[0m[38;2;154;186;8m*[0m[38;2;166;206;3m*[0m[38;2;141;159;58m+[0m[38;2;178;186;149m#[0m[38;2;189;206;137m#[0m[38;2;172;196;65m*[0m[38;2;176;218;11m#[0m[38;2;158;204;1m*[0m[38;2;126;170;0m+[0m[38;2;104;139;5m=[0m[38;2;90;117;14m-[0m[38;2;81;97;23m-[0m[38;2;86;79;26m:[0m[38;2;63;48;9m.[0m[38;2;75;63;46m:[0m[38;2;120;94;119m=[0m[38;2;132;103;144m=[0m[38;2;135;106;147m=[0m[38;2;93;68;81m-[0m[38;2;75;97;0m-[0m[38;2;120;162;0m+[0m[38;2;104;147;0m=[0m[38;2;96;134;5m=[0m[38;2;103;125;38m=[0m[38;2;89;109;18m-[0m[38;2;85;98;12m-[0m[38;2;88;93;20m-[0m[38;2;102;109;36m-[0m[38;2;97;133;6m=[0m[38;2;110;152;2m=[0m[38;2;135;162;27m+[0m[38;2;133;176;3m+[0m[38;2;140;188;0m+[0m[38;2;134;184;0m+[0m[38;2;124;176;0m+[0m[38;2;113;157;0m=[0m[38;2;91;113;0m-[0m[38;2;87;75;14m:[0m[38;2;169;153;143m*[0m
23 | [38;2;255;255;255m@[0m[38;2;254;246;251m@[0m[38;2;194;165;175m*[0m[38;2;137;105;97m=[0m[38;2;102;85;39m-[0m[38;2;93;90;0m-[0m[38;2;93;107;0m-[0m[38;2;104;138;0m=[0m[38;2;119;163;0m+[0m[38;2;121;163;0m+[0m[38;2;138;190;0m*[0m[38;2;154;204;0m*[0m[38;2;158;207;0m*[0m[38;2;123;136;74m=[0m[38;2;158;170;160m*[0m[38;2;176;184;184m#[0m[38;2;133;142;104m+[0m[38;2;162;209;1m*[0m[38;2;185;235;4m#[0m[38;2;189;238;6m#[0m[38;2;193;229;39m#[0m[38;2;178;225;10m#[0m[38;2;169;210;18m*[0m[38;2;169;197;54m*[0m[38;2;148;197;4m*[0m[38;2;135;176;0m+[0m[38;2;122;154;0m=[0m[38;2;125;153;10m+[0m[38;2;125;153;7m+[0m[38;2;132;172;0m+[0m[38;2;169;218;7m#[0m[38;2;186;234;5m#[0m[38;2;179;229;4m#[0m[38;2;180;226;19m#[0m[38;2;195;217;75m#[0m[38;2;168;215;20m#[0m[38;2;154;209;0m*[0m[38;2;151;207;4m*[0m[38;2;166;203;43m*[0m[38;2;147;192;5m*[0m[38;2;138;185;0m+[0m[38;2;128;170;0m+[0m[38;2;117;154;0m=[0m[38;2;102;116;0m-[0m[38;2;97;98;1m-[0m[38;2;98;83;24m-[0m[38;2;120;96;67m-[0m[38;2;166;134;134m+[0m[38;2;229;212;219m%[0m[38;2;255;255;255m@[0m
24 | [38;2;254;254;254m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;250;241;247m@[0m[38;2;226;203;215m%[0m[38;2;196;166;175m*[0m[38;2;161;134;129m+[0m[38;2;140;115;90m=[0m[38;2;125;97;64m=[0m[38;2;115;93;42m-[0m[38;2;101;90;22m-[0m[38;2;105;106;10m-[0m[38;2;99;116;0m-[0m[38;2;96;101;0m-[0m[38;2;88;98;0m-[0m[38;2;107;139;0m=[0m[38;2;107;131;0m=[0m[38;2;112;147;0m=[0m[38;2;119;150;0m=[0m[38;2;119;158;0m+[0m[38;2;124;159;0m+[0m[38;2;139;183;0m+[0m[38;2;137;167;4m+[0m[38;2;133;167;0m+[0m[38;2;149;199;0m*[0m[38;2;147;196;0m*[0m[38;2;132;168;0m+[0m[38;2;135;180;0m+[0m[38;2;124;162;0m+[0m[38;2;127;162;0m+[0m[38;2;114;147;0m=[0m[38;2;107;130;0m=[0m[38;2;116;138;0m=[0m[38;2;105;147;0m=[0m[38;2;104;111;0m-[0m[38;2;102;117;0m-[0m[38;2;99;96;4m-[0m[38;2;96;94;4m-[0m[38;2;113;83;33m-[0m[38;2;114;96;45m-[0m[38;2;134;96;78m=[0m[38;2;148;127;104m+[0m[38;2;183;147;152m*[0m[38;2;211;187;193m#[0m[38;2;240;225;233m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;253;253;253m@[0m
25 | [38;2;255;255;255m@[0m[38;2;255;254;254m@[0m[38;2;253;252;252m@[0m[38;2;253;253;252m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;251;255m@[0m[38;2;250;234;240m@[0m[38;2;235;216;220m%[0m[38;2;223;196;202m%[0m[38;2;206;179;181m#[0m[38;2;210;171;172m#[0m[38;2;195;158;151m*[0m[38;2;178;143;130m+[0m[38;2;173;129;118m+[0m[38;2;162;125;105m+[0m[38;2;156;116;95m=[0m[38;2;157;115;90m=[0m[38;2;159;109;86m=[0m[38;2;139;110;68m=[0m[38;2;158;105;80m=[0m[38;2;155;101;78m=[0m[38;2;131;110;58m=[0m[38;2;139;108;67m=[0m[38;2;161;101;85m=[0m[38;2;144;103;74m=[0m[38;2;144;102;80m=[0m[38;2;152;108;87m=[0m[38;2;159;116;94m=[0m[38;2;167;122;108m+[0m[38;2;181;130;123m+[0m[38;2;162;140;121m+[0m[38;2;192;152;152m*[0m[38;2;200;168;167m*[0m[38;2;213;183;186m#[0m[38;2;228;203;209m%[0m[38;2;245;226;232m@[0m[38;2;252;241;247m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;255;255;255m@[0m[38;2;254;255;254m@[0m[38;2;252;251;251m@[0m[38;2;254;253;253m@[0m[38;2;255;255;255m@[0m
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
--------------------------------------------------------------------------------