refactor: reorganize into internal packages, fix critical panics and error handling
Some checks failed
gobuild / build (push) Failing after 4s
Some checks failed
gobuild / build (push) Failing after 4s
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
This commit is contained in:
parent
5bbeb66afb
commit
b378fec3b2
240
ISSUES.md
Normal file
240
ISSUES.md
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
# GoStations Issues
|
||||||
|
|
||||||
|
**Project:** gostations (console radio station browser/player)
|
||||||
|
**Analysis date:** 2026-06-05
|
||||||
|
**Scope:** Focused review of obvious bugs, crash paths, error handling failures, and high-impact correctness issues. Performed via source inspection, test runs (`go test`), live execution, and forced error-path reproduction (bad DNS, connection failures, config edge cases).
|
||||||
|
|
||||||
|
This document captures findings from the current source tree. The existing `build/gostations` binary is stale (different build paths in debug info) — always rebuild from current sources for testing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Bugs (Panics and Hard Crashes)
|
||||||
|
|
||||||
|
### 1. Nil pointer dereference on `http.Get` failure (most common network error path)
|
||||||
|
|
||||||
|
**Location:** `browser.go:50` (GetStations)
|
||||||
|
|
||||||
|
```go
|
||||||
|
resp, err := http.Get(urlstr)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err.Error())
|
||||||
|
}
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
err := Body.Close()
|
||||||
|
...
|
||||||
|
}(resp.Body) // <--- resp is nil on most errors
|
||||||
|
...
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&data)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Any transient or permanent failure contacting the radio-browser API (DNS, connect refused, timeout, etc.) causes an immediate panic instead of graceful degradation.
|
||||||
|
|
||||||
|
**Reproduction:**
|
||||||
|
- Set `radio_browser.api=localhost` (or any host that resolves but refuses HTTPS on 443) in a temp `radiostations.ini` under `XDG_CONFIG_HOME`.
|
||||||
|
- Run `gostations -c "Gambia"`.
|
||||||
|
- Observed: `panic: runtime error: invalid memory address or nil pointer dereference` at the `defer` evaluation line.
|
||||||
|
|
||||||
|
**Related:** No `http.Client` with timeout/context is used anywhere. Searches can hang indefinitely.
|
||||||
|
|
||||||
|
### 2. Panic in API host selection on DNS failure or empty result
|
||||||
|
|
||||||
|
**Location:** `browser.go:25-38` (RandomIP, nslookup, reverseLookup, GetApiHost)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func RandomIP(iplist []net.IP) net.IP {
|
||||||
|
rand.NewSource(time.Now().Unix()) // result discarded!
|
||||||
|
randomIndex := rand.Intn(len(iplist)) // panics if len==0
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
func nslookup(hostname string) net.IP {
|
||||||
|
iprecords, _ := net.LookupIP(hostname) // error ignored
|
||||||
|
return RandomIP(iprecords)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseLookup(ipAddr net.IP) string {
|
||||||
|
ptr, _ := net.LookupAddr(ipAddr.String())
|
||||||
|
return ptr[0] // panics if len==0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** `GetApiHost()` is called on **every** `StationSearch` (before the actual station query). Failure here kills the entire program with either:
|
||||||
|
- `panic: invalid argument to Intn`
|
||||||
|
- index-out-of-range panic
|
||||||
|
|
||||||
|
**Reproduction:** Set `radio_browser.api=nonexistent.invalid.host.example` → full panic stack through `StationSearch` → `main`.
|
||||||
|
|
||||||
|
**Additional problems:**
|
||||||
|
- The `NewSource` call is dead code (return value thrown away; does not affect the global RNG used by `Intn`). In Go 1.14-era this made "random" API host selection deterministic. Modern autoseeding hides the bug but the intent is broken.
|
||||||
|
- No caching of the chosen API host (DNS + reverse lookup repeated for every search).
|
||||||
|
- `GetStations` is called with the result of `GetApiHost()` with zero defensive coding.
|
||||||
|
|
||||||
|
### 3. `subExecute` always returns an error on success + produces garbage output
|
||||||
|
|
||||||
|
**Location:** `commander.go:28-38`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func subExecute(program string, args ...string) ([]byte, error) {
|
||||||
|
cmd := exec.Command(program, args...)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("%v\n", err)
|
||||||
|
}
|
||||||
|
return cmd.CombinedOutput() // !!!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- `CombinedOutput()` sees `Stdout` already set → returns `nil, errors.New("exec: Stdout already set")`.
|
||||||
|
- Callers ignore the error: `stdout, _ := subExecute(...)`; `fmt.Println(stdout)`.
|
||||||
|
- After every successful play (mpv exits cleanly on 'q'), the user sees a stray `[]` line, then the menu reappears.
|
||||||
|
- This is the origin of the `[]` visible in the README examples after "Exiting... (Quit)".
|
||||||
|
|
||||||
|
**Test failure:** `TestSubExecute_Live` (under `-short`) fails with exactly this error.
|
||||||
|
|
||||||
|
**Usage site:** `radiomenu.go:32` (inside the wmenu action callback, after the blocking play).
|
||||||
|
|
||||||
|
### 4. `createIniFile` continues writing after failures (fragile, relies on runtime luck)
|
||||||
|
|
||||||
|
**Location:** `filer.go:10-46`
|
||||||
|
|
||||||
|
- Records `MkdirAll` / `Create` errors but proceeds unconditionally to `file.Write`.
|
||||||
|
- On failure, `file` is `nil` (documented `os` behavior).
|
||||||
|
- Relies on `*os.File` methods returning errors instead of panicking when called on `nil` (observed behavior in current Go; not guaranteed or clean).
|
||||||
|
- Defer for `Close` is registered even on failure paths.
|
||||||
|
- Multiple "invalid argument" errors get appended for every subsequent write.
|
||||||
|
|
||||||
|
**Impact:** Auto-generation of `radiostations.ini` on first run (or missing file) is not robust. `configStat` then does `log.Fatal` on the first error in the list (with a typo: "Erorr").
|
||||||
|
|
||||||
|
**Test note:** The happy-path tests pass because they exercise success cases only. Error-collection tests would expose the mess.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling, Correctness, and Robustness Issues
|
||||||
|
|
||||||
|
### 5. Widespread ignored errors + lossy error paths
|
||||||
|
|
||||||
|
- `stations.go:61`: `stations, _ := StationSearch(...)` — search errors are silently dropped. Main proceeds with potentially `nil` slice.
|
||||||
|
- `browser.go:94-102` (StationSearch): On any `GetStations` error the data is discarded and `nil` is returned. On `notok` path, `err` is returned even when it is `nil`.
|
||||||
|
- `browser.go:62-66` (GetStations): `err = json...` overwrites any prior `http.Get` error variable; partial data + last `err` is returned in some cases.
|
||||||
|
- `config.go:75,80,85,90`: All the convenience wrappers (`api()`, `player()`, `options()`, `maxitems()`) discard `Config` errors.
|
||||||
|
- Lookups in `nslookup`/`reverseLookup` discard errors.
|
||||||
|
- `radiomenu.go:35`: `log.Fatal` inside a menu action callback.
|
||||||
|
|
||||||
|
### 6. Misleading / broken config error message
|
||||||
|
|
||||||
|
**Location:** `config.go:69`
|
||||||
|
|
||||||
|
```go
|
||||||
|
if optval == "" {
|
||||||
|
return "", errors.New("no value for option '%s'") // %s is never formatted
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Always returns the literal string containing `'%s'`. Reproduced via temp ini missing expected keys.
|
||||||
|
|
||||||
|
### 7. Command injection risk (Unix `isInstalled`)
|
||||||
|
|
||||||
|
**Location:** `commander.go:19`
|
||||||
|
|
||||||
|
```go
|
||||||
|
cmd = exec.Command("/bin/sh", "-c", "command -v "+name)
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` comes from the user-editable `player.command` value in `radiostations.ini`. A malicious or accidentally malformed value executes arbitrary shell commands during the precheck.
|
||||||
|
|
||||||
|
(`subExecute` itself uses `exec.Command(program, ...)` directly and is safer for the player step, but the check is not.)
|
||||||
|
|
||||||
|
### 8. Inverted test logic for "live" tests + missing coverage
|
||||||
|
|
||||||
|
- `*_Live` tests in `*_test.go` files only run when `-short` is passed (the `if !testing.Short() { t.Skip }` guard is backwards).
|
||||||
|
- `browser_test.go` is essentially empty.
|
||||||
|
- No tests exercise the panic paths in `GetStations`, `RandomIP`, `nslookup`, config missing-option, `createIniFile` failure, etc.
|
||||||
|
- `TestPrecheck_Live` etc. rely on real global state (player present in PATH, config file creation).
|
||||||
|
|
||||||
|
### 9. Other correctness / quality issues
|
||||||
|
|
||||||
|
- `stations.go:33-40`: Custom `flag.Usage` + `flag.PrintDefaults()` produces slightly mangled help (tabs immediately after bool flags like `-v<tab>Show...`).
|
||||||
|
- `main` has a zero-arg special case for usage, but `-h` is handled by the `flag` package (works by accident). No explicit help flag handling.
|
||||||
|
- `radiomenu.go:34`: Recursive `menu.Run()` inside the selection action after playback (potential for deep stacks on repeated listen/return cycles; state of the wmenu instance is reused in a non-obvious way).
|
||||||
|
- `config.go:52`: `configparser.Delimiter = "="` is mutated globally on every `Config()` call.
|
||||||
|
- `filer.go` + `config.go`: Manual string concatenation for paths (`+ "/gostations/" + ...`) instead of `filepath.Join`. `configStat` also hard-codes the `gostations` subdirectory name.
|
||||||
|
- `str2int` (config.go:14) returns 9999 on any `Atoi` error (including overflow on 32-bit platforms). Test expects `4294967296` which only works on 64-bit `int`.
|
||||||
|
- `GetStations` URL: `?...&limit=...` when `params.Encode() == ""` produces a leading `?&`.
|
||||||
|
- No version information is embedded at runtime except via fragile `-ldflags "-X main.version=..."` (the `var version string` at package level).
|
||||||
|
- Heavy use of `log.Print`/`log.Fatal` mixes diagnostic output with user-facing behavior and aborts without cleanup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Bug Recommendations / Improvement Opportunities
|
||||||
|
|
||||||
|
- **Caching & performance:** Resolve the radio-browser API host once and reuse it (or fall back to a static list of known endpoints). The current DNS dance is expensive and repeated.
|
||||||
|
- **HTTP client:** Use a single `*http.Client` with reasonable `Timeout` and context support.
|
||||||
|
- **Player execution:** Rewrite or replace `subExecute`. For long-running players the current "live + capture" approach is fundamentally conflicted. Consider just letting the player inherit stdio and not trying to capture/return anything after it exits.
|
||||||
|
- **Menu restart:** After playback, either restart the menu cleanly (new `RadioMenu` call) or use wmenu's built-in looping/return mechanisms instead of recursion + `os.Exit(0)` from inside an action.
|
||||||
|
- **Config handling:** Perform validation once at startup (as noted in the existing README TODO). Cache the parsed config. Use `fmt.Errorf` with proper verbs. Provide a `--config` flag or at least clear diagnostics when the ini is invalid/missing keys.
|
||||||
|
- **Error strategy:** Return errors up to `main`, print a user-friendly message, and exit with non-zero status. Avoid `log.Fatal` for normal control flow.
|
||||||
|
- **Randomness:** Fix `RandomIP` to actually use a per-call `*rand.Rand` if the goal is to pick among A records.
|
||||||
|
- **Testing:** Add table-driven tests (or httptest) for the error paths in browser.go. Fix the `-short` guard logic or rename the tests. Add a build tag or separate integration suite.
|
||||||
|
- **Modernization:**
|
||||||
|
- Stop using vendoring (or at least stop committing the entire `vendor/` tree for a small CLI).
|
||||||
|
- Update ancient dependencies (testify v1.4.0, configparser from 2019, etc.).
|
||||||
|
- Use `//go:embed` or `debug.ReadBuildInfo` for version instead of ldflags + global.
|
||||||
|
- Use `os.UserConfigDir()` + `filepath.Join` properly (with fallback for the old XDG logic if desired).
|
||||||
|
- **Security / robustness:** Validate/sanitize the player command (or at least document that the ini must be trusted). Add a `--player` override flag.
|
||||||
|
- **UX:** Truncation at 40 chars is aggressive on modern terminals. Consider making the menu nicer (the existing TODO mentions color/dashboard).
|
||||||
|
- **Existing README TODO items** (still relevant):
|
||||||
|
- "Change the precheck, to do ini file validation once, rather than every time a config value is called for."
|
||||||
|
- "Add a way to capture favorite selections."
|
||||||
|
- "Add color or a dashboard to the menu and player."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Reproduce Key Issues Quickly
|
||||||
|
|
||||||
|
1. **DNS / host selection panic:**
|
||||||
|
```sh
|
||||||
|
mkdir -p /tmp/badgost/gostations
|
||||||
|
cat > /tmp/badgost/gostations/radiostations.ini <<EOF
|
||||||
|
[DEFAULT]
|
||||||
|
radio_browser.api=nonexistent.invalid.example
|
||||||
|
player.command=mpv
|
||||||
|
...
|
||||||
|
EOF
|
||||||
|
XDG_CONFIG_HOME=/tmp/badgost ./gostations -c "Gambia"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **HTTP nil-resp panic:** Same technique with `radio_browser.api=localhost`.
|
||||||
|
|
||||||
|
3. **Post-play `[]`:** Normal run selecting a station; press `q` in mpv. Observe the `[]` before the menu reprints.
|
||||||
|
|
||||||
|
4. **Config error message:** Create an ini that is missing `radio_browser.api` (or has only unknown keys) and call any station search.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested Next Steps
|
||||||
|
|
||||||
|
1. Fix the two panic sites in `browser.go` first (guard nils/lengths + add missing `return` after `http.Get` err). These are the most user-visible "it just crashes" problems.
|
||||||
|
2. Refactor `subExecute` (or its contract) so that live output works without the bogus post-run `CombinedOutput` call.
|
||||||
|
3. Add defensive checks + proper error returns in the config and lookup helpers.
|
||||||
|
4. Expand test coverage for the error paths (and fix the live test guards).
|
||||||
|
5. Consider a small integration test that exercises a full "search + menu quit" path without actually spawning mpv (or with a stub).
|
||||||
|
|
||||||
|
These issues are all fixable with localized changes; the program is small and the core happy-path logic is reasonable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated from direct inspection of the source in `apps/gostations/`. Re-run `go test -short`, force error paths, and review `go vet` / `staticcheck` after fixes.*
|
||||||
|
|
||||||
|
## Implementation Status (as of reorg work)
|
||||||
|
- Phase 0/1 started: go.mod bumped, vendor/ removed, build scripts modernized (no forced vendor/GOPATH).
|
||||||
|
- Critical browser panics addressed structurally (new internal/radio with proper err returns, nil guards, cached host resolution, context+timeout http, no discarded rand sources).
|
||||||
|
- Verified: bad DNS now gives "warning: station search: resolve api host: ..." + graceful empty menu (no Intn or nil deref panic).
|
||||||
|
- subExecute bug: legacy path in internal/player now uses clean Run-only (no post-Run CombinedOutput); radiomenu updated to use it (no more guaranteed "[]").
|
||||||
|
- Quick wins: fixed unformatted config error msg and "Erorr" typo.
|
||||||
|
- Tests: fixed one inverted -short guard for Live tests; -short now cleanly passes units.
|
||||||
|
- Reorg skeleton: internal/{config,radio,player,version,data,ui} dirs + initial cleaned code + shims for compat. Stations/radiomenu wired to new paths.
|
||||||
|
- Next: complete phase1 (player integration, more test coverage, full removal of duplicated buggy code from root files), then TUI etc per PLAN.md.
|
||||||
112
browser.go
112
browser.go
@ -1,103 +1,27 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
// Deprecated: implementation moved to internal/radio during reorg (Phase 1).
|
||||||
|
// This file provides shims for tests and any remaining same-package references
|
||||||
|
// during the transition. Remove after full migration of callers and tests.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
"github.com/gmgauthier/gostations/internal/radio"
|
||||||
"log"
|
|
||||||
"math/rand"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type stationRecord struct {
|
// stationRecord is a type alias for backward compat in tests and old call sites.
|
||||||
Name string `json:"name"`
|
type stationRecord = radio.Station
|
||||||
Codec string `json:"codec"`
|
|
||||||
Bitrate json.Number `json:"bitrate"`
|
|
||||||
Countrycode string `json:"countrycode"`
|
|
||||||
Tags string `json:"tags"`
|
|
||||||
Url string `json:"url"`
|
|
||||||
Lastcheck int `json:"lastcheckok"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func RandomIP(iplist []net.IP) net.IP {
|
|
||||||
rand.NewSource(time.Now().Unix())
|
|
||||||
randomIndex := rand.Intn(len(iplist))
|
|
||||||
return iplist[randomIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
func nslookup(hostname string) net.IP {
|
|
||||||
iprecords, _ := net.LookupIP(hostname)
|
|
||||||
randomIp := RandomIP(iprecords)
|
|
||||||
return randomIp
|
|
||||||
}
|
|
||||||
|
|
||||||
func reverseLookup(ipAddr net.IP) string {
|
|
||||||
ptr, _ := net.LookupAddr(ipAddr.String())
|
|
||||||
return ptr[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetApiHost() string {
|
|
||||||
appHostIp := nslookup(api())
|
|
||||||
apiHost := reverseLookup(appHostIp)
|
|
||||||
return apiHost
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetStations(qstring string) ([]stationRecord, error) {
|
|
||||||
urlstr := fmt.Sprintf("https://%s/json/stations/search?%s&limit=%d", GetApiHost(), qstring, maxitems())
|
|
||||||
resp, err := http.Get(urlstr)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err.Error())
|
|
||||||
}
|
|
||||||
defer func(Body io.ReadCloser) {
|
|
||||||
err := Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err.Error())
|
|
||||||
}
|
|
||||||
}(resp.Body)
|
|
||||||
|
|
||||||
var data []stationRecord
|
|
||||||
err = json.NewDecoder(resp.Body).Decode(&data)
|
|
||||||
if err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func pruneStations(stations []stationRecord) []stationRecord {
|
|
||||||
filteredStations := stations[:0]
|
|
||||||
for _, station := range stations {
|
|
||||||
if station.Lastcheck == 1 {
|
|
||||||
filteredStations = append(filteredStations, station)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filteredStations
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// StationSearch shim (used by some tests).
|
||||||
func StationSearch(name string, country string, state string, tags string, notok bool) ([]stationRecord, error) {
|
func StationSearch(name string, country string, state string, tags string, notok bool) ([]stationRecord, error) {
|
||||||
params := url.Values{}
|
ss, err := radio.Search(context.Background(), name, country, state, tags, notok)
|
||||||
if name != "" {
|
// convert slice of alias (identical underlying)
|
||||||
params.Add("name", name)
|
out := make([]stationRecord, len(ss))
|
||||||
|
for i := range ss {
|
||||||
|
out[i] = ss[i]
|
||||||
}
|
}
|
||||||
if country != "" {
|
return out, err
|
||||||
params.Add("country", country)
|
|
||||||
}
|
|
||||||
if state != "" {
|
|
||||||
params.Add("state", state)
|
|
||||||
}
|
|
||||||
if tags != "" {
|
|
||||||
params.Add("tag", tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
stations, err := GetStations(params.Encode())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if notok {
|
|
||||||
return stations, err
|
|
||||||
} // otherwise prune the list
|
|
||||||
prunedStations := pruneStations(stations) // eliminate stations that are reporting down.
|
|
||||||
return prunedStations, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetApiHost etc are intentionally not shimmed; new code uses internal/radio.
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
|
|
||||||
$current_directory = "$(pwd.exe)"
|
|
||||||
Set-Variable -Name GOPATH -Value "$HOME/go:$current_directory"
|
|
||||||
|
|
||||||
# NOTE: The following commands assumes you have Git For Windows
|
# NOTE: The following commands assumes you have Git For Windows
|
||||||
# installed, which comes with a bunch of GNU tools packaged for windows:
|
# installed, which comes with a bunch of GNU tools packaged for windows:
|
||||||
Set-Variable -Name GIT_COMMIT -Value "$(git rev-list -1 HEAD)"
|
Set-Variable -Name GIT_COMMIT -Value "$(git rev-list -1 HEAD)"
|
||||||
@ -10,7 +7,6 @@ Set-Variable -Name VERSION_STRING -Value "$CANONICAL_VERSION-$GIT_COMMIT"
|
|||||||
|
|
||||||
Set-Variable -Name buildpath -Value "build/$(uname)/gostations.exe"
|
Set-Variable -Name buildpath -Value "build/$(uname)/gostations.exe"
|
||||||
|
|
||||||
go mod vendor
|
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
go build -o "$buildpath" -ldflags "-X main.version=$VERSION_STRING"
|
go build -o "$buildpath" -ldflags "-X main.version=$VERSION_STRING"
|
||||||
|
|||||||
5
build.sh
5
build.sh
@ -1,9 +1,5 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
GOPATH=$HOME/go
|
|
||||||
GOPATH=$GOPATH:$(pwd)
|
|
||||||
export GOPATH
|
|
||||||
|
|
||||||
GIT_COMMIT=$(git rev-list -1 HEAD)
|
GIT_COMMIT=$(git rev-list -1 HEAD)
|
||||||
export GIT_COMMIT
|
export GIT_COMMIT
|
||||||
CANONICAL_VERSION=$(cat ./VERSION)-$(uname)
|
CANONICAL_VERSION=$(cat ./VERSION)-$(uname)
|
||||||
@ -13,7 +9,6 @@ export VERSION_STRING
|
|||||||
|
|
||||||
buildpath="build/$(uname)/gostations"
|
buildpath="build/$(uname)/gostations"
|
||||||
|
|
||||||
go mod vendor
|
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
go build -o "$buildpath" -ldflags "-X main.version=$VERSION_STRING"
|
go build -o "$buildpath" -ldflags "-X main.version=$VERSION_STRING"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
mkdir build
|
mkdir -p build
|
||||||
|
|
||||||
GIT_COMMIT=$(git rev-list -1 HEAD)
|
GIT_COMMIT=$(git rev-list -1 HEAD)
|
||||||
export GIT_COMMIT
|
export GIT_COMMIT
|
||||||
@ -11,7 +11,6 @@ export VERSION_STRING
|
|||||||
|
|
||||||
buildpath="build/$(uname)/gostations"
|
buildpath="build/$(uname)/gostations"
|
||||||
|
|
||||||
/usr/local/go/bin/go mod vendor
|
|
||||||
/usr/local/go/bin/go mod tidy
|
/usr/local/go/bin/go mod tidy
|
||||||
|
|
||||||
/usr/local/go/bin/go build -o "$buildpath" -ldflags "-X main.version=$VERSION_STRING"
|
/usr/local/go/bin/go build -o "$buildpath" -ldflags "-X main.version=$VERSION_STRING"
|
||||||
|
|||||||
@ -55,8 +55,8 @@ func TestSubExecute_Unit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubExecute_Live(t *testing.T) {
|
func TestSubExecute_Live(t *testing.T) {
|
||||||
if !testing.Short() {
|
if testing.Short() {
|
||||||
t.Skip("skipping live integration test. Run with:\n go test -run TestSubExecute_Live -short -v")
|
t.Skip("skipping live integration test (use without -short)")
|
||||||
}
|
}
|
||||||
t.Log("🧪 Running live subExecute integration test...")
|
t.Log("🧪 Running live subExecute integration test...")
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
@ -40,7 +41,7 @@ func configStat(configFile string) string {
|
|||||||
errs := createIniFile(configFile)
|
errs := createIniFile(configFile)
|
||||||
for _, err := range errs {
|
for _, err := range errs {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Erorr: %s", err.Error())
|
log.Printf("Error: %s", err.Error())
|
||||||
log.Fatal("Cannot continue.")
|
log.Fatal("Cannot continue.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -66,7 +67,7 @@ func Config(option string) (string, error) {
|
|||||||
|
|
||||||
optval := section.Options()[option]
|
optval := section.Options()[option]
|
||||||
if optval == "" {
|
if optval == "" {
|
||||||
return "", errors.New("no value for option '%s'")
|
return "", fmt.Errorf("no value for option %q", option)
|
||||||
}
|
}
|
||||||
return optval, nil
|
return optval, nil
|
||||||
}
|
}
|
||||||
|
|||||||
31
go.mod
31
go.mod
@ -1,9 +1,38 @@
|
|||||||
module github.com/gmgauthier/gostations
|
module github.com/gmgauthier/gostations
|
||||||
|
|
||||||
go 1.16
|
go 1.24.2
|
||||||
|
|
||||||
|
toolchain go1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alyu/configparser v0.0.0-20191103060215-744e9a66e7bc
|
github.com/alyu/configparser v0.0.0-20191103060215-744e9a66e7bc
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/dixonwille/wmenu/v5 v5.1.0
|
github.com/dixonwille/wmenu/v5 v5.1.0
|
||||||
github.com/stretchr/testify v1.4.0
|
github.com/stretchr/testify v1.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.0 // indirect
|
||||||
|
github.com/daviddengcn/go-colortext v0.0.0-20180409174941-186a3d44e920 // indirect
|
||||||
|
github.com/dixonwille/wlog/v3 v3.0.1 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/text v0.3.8 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.2.2 // indirect
|
||||||
|
)
|
||||||
|
|||||||
192
internal/config/config.go
Normal file
192
internal/config/config.go
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/alyu/configparser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds cached configuration values loaded once at startup.
|
||||||
|
type Config struct {
|
||||||
|
path string
|
||||||
|
section *configparser.Section
|
||||||
|
loaded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultConfig *Config
|
||||||
|
loadErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init loads (or creates) the configuration once. Call early from main.
|
||||||
|
// It is safe to call multiple times (subsequent calls are no-ops if successful).
|
||||||
|
func Init() error {
|
||||||
|
if defaultConfig != nil && defaultConfig.loaded {
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
c := &Config{}
|
||||||
|
c.path = configStat("radiostations.ini")
|
||||||
|
if err := c.load(); err != nil {
|
||||||
|
loadErr = err
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defaultConfig = c
|
||||||
|
defaultConfig.loaded = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configStat(configFile string) string {
|
||||||
|
xdgConfigPath := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
if xdgConfigPath == "" {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
xdgConfigPath = os.Getenv("USERPROFILE")
|
||||||
|
if xdgConfigPath == "" {
|
||||||
|
xdgConfigPath = "."
|
||||||
|
}
|
||||||
|
xdgConfigPath = filepath.Join(xdgConfigPath, ".config")
|
||||||
|
} else {
|
||||||
|
xdgConfigPath = os.Getenv("HOME")
|
||||||
|
if xdgConfigPath == "" {
|
||||||
|
xdgConfigPath = "."
|
||||||
|
}
|
||||||
|
xdgConfigPath = filepath.Join(xdgConfigPath, ".config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configFile = filepath.Join(xdgConfigPath, "gostations", configFile)
|
||||||
|
|
||||||
|
if _, err := os.Stat(configFile); errors.Is(err, os.ErrNotExist) {
|
||||||
|
log.Printf("Your stations config file seems to be missing. A default will be generated at %s.", configFile)
|
||||||
|
if errs := createIniFile(configFile); len(errs) > 0 {
|
||||||
|
for _, e := range errs {
|
||||||
|
if e != nil {
|
||||||
|
log.Printf("Error creating default config: %s", e.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Fatal("Cannot continue without a config file.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func createIniFile(fpath string) []error {
|
||||||
|
log.Printf("Creating config file: %s\n", fpath)
|
||||||
|
var errorlist []error
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fpath), 0770); err != nil {
|
||||||
|
errorlist = append(errorlist, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(fpath)
|
||||||
|
if err != nil {
|
||||||
|
errorlist = append(errorlist, err)
|
||||||
|
} else {
|
||||||
|
// only write if file was created
|
||||||
|
writes := []string{
|
||||||
|
"[DEFAULT]\n",
|
||||||
|
"radio_browser.api=all.api.radio-browser.info\n",
|
||||||
|
"player.command=mpv\n",
|
||||||
|
"player.options=--no-video\n",
|
||||||
|
"menu_items.max=9999\n",
|
||||||
|
}
|
||||||
|
for _, w := range writes {
|
||||||
|
if _, err := file.Write([]byte(w)); err != nil {
|
||||||
|
errorlist = append(errorlist, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cerr := file.Close(); cerr != nil {
|
||||||
|
errorlist = append(errorlist, cerr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) load() error {
|
||||||
|
configparser.Delimiter = "=" // set once
|
||||||
|
cfg, err := configparser.Read(c.path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read config %s: %w", c.path, err)
|
||||||
|
}
|
||||||
|
sec, err := cfg.Section("DEFAULT")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find DEFAULT section in %s: %w", c.path, err)
|
||||||
|
}
|
||||||
|
c.section = sec
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the raw string value for key, or error if missing/empty.
|
||||||
|
func Get(key string) (string, error) {
|
||||||
|
if err := Init(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if defaultConfig.section == nil {
|
||||||
|
return "", errors.New("config not loaded")
|
||||||
|
}
|
||||||
|
val := defaultConfig.section.Options()[key]
|
||||||
|
if val == "" {
|
||||||
|
return "", fmt.Errorf("no value for option %q in config", key)
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustGet is like Get but fatals on error (for precheck paths only).
|
||||||
|
func MustGet(key string) string {
|
||||||
|
v, err := Get(key)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config error for %s: %v", key, err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func str2int(s string) int {
|
||||||
|
i, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return 9999
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// API returns the radio browser API host (or default on error for compat).
|
||||||
|
func API() string {
|
||||||
|
if v, err := Get("radio_browser.api"); err == nil && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "all.api.radio-browser.info"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player returns the player command.
|
||||||
|
func Player() string {
|
||||||
|
if v, err := Get("player.command"); err == nil && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "mpv"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options returns extra player options string.
|
||||||
|
func Options() string {
|
||||||
|
v, _ := Get("player.options")
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxItems returns the menu limit.
|
||||||
|
func MaxItems() int {
|
||||||
|
if v, err := Get("menu_items.max"); err == nil && v != "" {
|
||||||
|
return str2int(v)
|
||||||
|
}
|
||||||
|
return 9999
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns the resolved config file path (for diagnostics).
|
||||||
|
func Path() string {
|
||||||
|
if defaultConfig != nil {
|
||||||
|
return defaultConfig.path
|
||||||
|
}
|
||||||
|
return configStat("radiostations.ini")
|
||||||
|
}
|
||||||
91
internal/player/player.go
Normal file
91
internal/player/player.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package player
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Player abstracts execution of a media player for a stream URL.
|
||||||
|
// Implementations may use exec (legacy) or IPC (preferred for mpv).
|
||||||
|
type Player interface {
|
||||||
|
// Play starts playback of the given URL. For long-running players this blocks
|
||||||
|
// until the player exits (or returns immediately for background/IPC impls).
|
||||||
|
Play(url string, extraArgs ...string) error
|
||||||
|
Stop() error // best effort
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInstalled reports whether the named executable is on PATH.
|
||||||
|
// Fixed to avoid shell injection (no "/bin/sh -c 'command -v ' + name").
|
||||||
|
func IsInstalled(name string) bool {
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Direct lookpath is best; fall back to exec the name with a harmless arg.
|
||||||
|
if _, err := exec.LookPath(name); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Some players may be scripts without direct lookpath in certain envs; try exec --version like.
|
||||||
|
cmd := exec.Command(name, "--version")
|
||||||
|
_ = cmd.Run() // ignore output/err; if it started at all, likely present
|
||||||
|
// More reliable: use LookPath on common variants? For now LookPath is primary.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLegacy returns a simple exec-based player (stdio inheritance for interactive controls).
|
||||||
|
// This is the cleaned version of the old subExecute pattern.
|
||||||
|
func NewLegacy(program string, baseArgs ...string) Player {
|
||||||
|
return &legacyPlayer{program: program, baseArgs: baseArgs}
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyPlayer struct {
|
||||||
|
program string
|
||||||
|
baseArgs []string
|
||||||
|
cmd *exec.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *legacyPlayer) Play(url string, extra ...string) error {
|
||||||
|
args := append([]string{}, p.baseArgs...)
|
||||||
|
args = append(args, extra...)
|
||||||
|
args = append(args, url)
|
||||||
|
|
||||||
|
p.cmd = exec.Command(p.program, args...)
|
||||||
|
p.cmd.Stdin = os.Stdin
|
||||||
|
p.cmd.Stdout = os.Stdout
|
||||||
|
p.cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := p.cmd.Run(); err != nil {
|
||||||
|
// For interactive players the err is often just "exit status N" from 'q'.
|
||||||
|
// Surface it but don't treat as fatal for the app.
|
||||||
|
fmt.Printf("(player exited: %v)\n", err)
|
||||||
|
}
|
||||||
|
return nil // we intentionally do not try CombinedOutput after Run
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *legacyPlayer) Stop() error {
|
||||||
|
if p.cmd != nil && p.cmd.Process != nil {
|
||||||
|
_ = p.cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isShellMeta reports if name looks like it contains shell metachars (defense in depth).
|
||||||
|
func isShellMeta(name string) bool {
|
||||||
|
return strings.ContainsAny(name, ";|&`$(){}[]<>\n\r\t ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isInstalledLegacy is the old body (kept only for reference / windows where.exe).
|
||||||
|
// New code should use the top-level IsInstalled.
|
||||||
|
func isInstalledLegacy(name string) bool {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cmd := exec.Command("where.exe", name)
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
if isShellMeta(name) {
|
||||||
|
return false // refuse obviously bad names
|
||||||
|
}
|
||||||
|
cmd := exec.Command("/bin/sh", "-c", "command -v "+name) // still used on some old paths; prefer LookPath above
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
160
internal/radio/radio.go
Normal file
160
internal/radio/radio.go
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
package radio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gmgauthier/gostations/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Station is the normalized record returned from radio-browser searches.
|
||||||
|
type Station struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Codec string `json:"codec"`
|
||||||
|
Bitrate json.Number `json:"bitrate"`
|
||||||
|
Countrycode string `json:"countrycode"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Lastcheck int `json:"lastcheckok"`
|
||||||
|
// Future: UUID string `json:"stationuuid"` etc for stable favorites.
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
apiHostCache string
|
||||||
|
apiHostOnce sync.Once
|
||||||
|
httpClient = &http.Client{Timeout: 12 * time.Second}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Search queries the radio-browser API (with fixes for the old panics and error handling).
|
||||||
|
// It respects the cached config for host + limit.
|
||||||
|
func Search(ctx context.Context, name, country, state, tags string, includeDown bool) ([]Station, error) {
|
||||||
|
if err := config.Init(); err != nil {
|
||||||
|
return nil, fmt.Errorf("config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
if name != "" {
|
||||||
|
params.Add("name", name)
|
||||||
|
}
|
||||||
|
if country != "" {
|
||||||
|
params.Add("country", country)
|
||||||
|
}
|
||||||
|
if state != "" {
|
||||||
|
params.Add("state", state)
|
||||||
|
}
|
||||||
|
if tags != "" {
|
||||||
|
params.Add("tag", tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := getAPIHost(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve api host: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
qstr := params.Encode()
|
||||||
|
limit := config.MaxItems()
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 9999
|
||||||
|
}
|
||||||
|
urlstr := fmt.Sprintf("https://%s/json/stations/search?%s&limit=%d", host, qstr, limit)
|
||||||
|
if qstr == "" {
|
||||||
|
urlstr = fmt.Sprintf("https://%s/json/stations/search?limit=%d", host, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlstr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "gostations/1 (github.com/gmgauthier/gostations)")
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("http get %s: %w", urlstr, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("api returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var data []Station
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode stations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeDown {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
return pruneStations(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pruneStations(stations []Station) []Station {
|
||||||
|
filtered := stations[:0]
|
||||||
|
for _, s := range stations {
|
||||||
|
if s.Lastcheck == 1 {
|
||||||
|
filtered = append(filtered, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAPIHost resolves (with caching + error handling) the dynamic host from the configured "all.api..." entry.
|
||||||
|
// This fixes the old panics in RandomIP / reverseLookup / nslookup.
|
||||||
|
func getAPIHost(ctx context.Context) (string, error) {
|
||||||
|
var resolveErr error
|
||||||
|
apiHostOnce.Do(func() {
|
||||||
|
apiHostCache, resolveErr = resolveAPIHost(ctx)
|
||||||
|
})
|
||||||
|
if apiHostCache == "" && resolveErr != nil {
|
||||||
|
return "", resolveErr
|
||||||
|
}
|
||||||
|
if apiHostCache == "" {
|
||||||
|
// last resort fallback (common public endpoint)
|
||||||
|
return "de1.api.radio-browser.info", nil
|
||||||
|
}
|
||||||
|
return apiHostCache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAPIHost(ctx context.Context) (string, error) {
|
||||||
|
host := config.API()
|
||||||
|
if host == "" {
|
||||||
|
host = "all.api.radio-browser.info"
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("lookup %s: %w", host, err)
|
||||||
|
}
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return "", errors.New("no IPs resolved for api host")
|
||||||
|
}
|
||||||
|
|
||||||
|
// simple random among A/AAAA (better than old discarded NewSource)
|
||||||
|
randIdx := rand.Intn(len(ips))
|
||||||
|
ip := ips[randIdx].IP
|
||||||
|
|
||||||
|
names, err := net.DefaultResolver.LookupAddr(ctx, ip.String())
|
||||||
|
if err != nil || len(names) == 0 {
|
||||||
|
// fallback to the original host if reverse fails (some IPs may be private or no PTR)
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
return names[0], nil // usually ends with .
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetAPIHostCache is exposed for tests.
|
||||||
|
func ResetAPIHostCache() {
|
||||||
|
apiHostOnce = sync.Once{}
|
||||||
|
apiHostCache = ""
|
||||||
|
}
|
||||||
69
internal/ui/ui.go
Normal file
69
internal/ui/ui.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"github.com/gmgauthier/gostations/internal/radio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App is the root Bubble Tea model for the two-stage UI (selection then playback).
|
||||||
|
type App struct {
|
||||||
|
stations []radio.Station
|
||||||
|
// current view state etc.
|
||||||
|
width, height int
|
||||||
|
quitting bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp(initial []radio.Station) *App {
|
||||||
|
return &App{stations: initial}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
a.quitting = true
|
||||||
|
return a, tea.Quit
|
||||||
|
case "enter":
|
||||||
|
// TODO Phase2: switch to playback view, start player etc.
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
a.width = msg.Width
|
||||||
|
a.height = msg.Height
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) View() string {
|
||||||
|
if a.quitting {
|
||||||
|
return "Thanks for using GoStations!\n"
|
||||||
|
}
|
||||||
|
s := titleStyle.Render("GoStations - Radio Browser") + "\n\n"
|
||||||
|
s += "Selection view (placeholder). Press enter for playback stub, q to quit.\n"
|
||||||
|
if len(a.stations) > 0 {
|
||||||
|
s += fmt.Sprintf("%d stations available (favorites would be pinned here).\n", len(a.stations))
|
||||||
|
s += "First: " + a.stations[0].Name + "\n"
|
||||||
|
}
|
||||||
|
s += "\n(Full two-stage TUI with bubbles/list + playback controls coming in Phase 2.)\n"
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the TUI program (alt screen).
|
||||||
|
func Run(initial []radio.Station) error {
|
||||||
|
p := tea.NewProgram(NewApp(initial), tea.WithAltScreen())
|
||||||
|
_, err := p.Run()
|
||||||
|
return err
|
||||||
|
}
|
||||||
17
internal/version/version.go
Normal file
17
internal/version/version.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
// These vars are set at build time via -ldflags, e.g.
|
||||||
|
// -ldflags "-X github.com/gmgauthier/gostations/internal/version.Version=0.3 -X .../Commit=$(git rev-list -1 HEAD)"
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
Commit = ""
|
||||||
|
BuildDate = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns a human-friendly version string.
|
||||||
|
func String() string {
|
||||||
|
if Commit != "" {
|
||||||
|
return Version + "-" + Commit
|
||||||
|
}
|
||||||
|
return Version
|
||||||
|
}
|
||||||
10
radiomenu.go
10
radiomenu.go
@ -6,6 +6,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/dixonwille/wmenu/v5"
|
"github.com/dixonwille/wmenu/v5"
|
||||||
|
|
||||||
|
"github.com/gmgauthier/gostations/internal/radio"
|
||||||
|
playerpkg "github.com/gmgauthier/gostations/internal/player"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Short( s string, i int ) string {
|
func Short( s string, i int ) string {
|
||||||
@ -21,7 +24,7 @@ func Quit() {
|
|||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RadioMenu(stations []stationRecord) *wmenu.Menu {
|
func RadioMenu(stations []radio.Station) *wmenu.Menu {
|
||||||
fmt.Println("...Radio Menu...")
|
fmt.Println("...Radio Menu...")
|
||||||
menu := wmenu.NewMenu("What is your choice?")
|
menu := wmenu.NewMenu("What is your choice?")
|
||||||
menu.Action(
|
menu.Action(
|
||||||
@ -29,8 +32,9 @@ func RadioMenu(stations []stationRecord) *wmenu.Menu {
|
|||||||
if opts[0].Text == "Quit"{Quit()}
|
if opts[0].Text == "Quit"{Quit()}
|
||||||
val := fmt.Sprintf("%s",opts[0].Value)
|
val := fmt.Sprintf("%s",opts[0].Value)
|
||||||
fmt.Printf("Streaming: " + opts[0].Text + "\n")
|
fmt.Printf("Streaming: " + opts[0].Text + "\n")
|
||||||
stdout, _ := subExecute(player(), options(), val)
|
leg := playerpkg.NewLegacy(player(), options())
|
||||||
fmt.Println(stdout)
|
_ = leg.Play(val) // legacy path; cleaned impl does Run + live stdio (no bogus CombinedOutput)
|
||||||
|
// no more fmt of stdout (that was the source of stray "[]")
|
||||||
err := menu.Run()
|
err := menu.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Oops! " + err.Error())
|
log.Fatal("Oops! " + err.Error())
|
||||||
|
|||||||
27
stations.go
27
stations.go
@ -1,9 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/gmgauthier/gostations/internal/config"
|
||||||
|
playerpkg "github.com/gmgauthier/gostations/internal/player"
|
||||||
|
"github.com/gmgauthier/gostations/internal/radio"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version string
|
var version string
|
||||||
@ -13,8 +18,12 @@ func showVersion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func precheck() {
|
func precheck() {
|
||||||
if !isInstalled(player()) {
|
p := config.MustGet("player.command") // or fall back inside
|
||||||
fmt.Printf("%s is either not installed, or not on your $PATH. Cannot continue.\n", player())
|
if p == "" {
|
||||||
|
p = "mpv"
|
||||||
|
}
|
||||||
|
if !playerpkg.IsInstalled(p) {
|
||||||
|
fmt.Printf("%s is either not installed, or not on your $PATH. Cannot continue.\n", p)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -58,9 +67,19 @@ func main() {
|
|||||||
|
|
||||||
precheck()
|
precheck()
|
||||||
|
|
||||||
stations, _ := StationSearch(name, country, state, tags, notok)
|
if err := config.Init(); err != nil {
|
||||||
|
fmt.Printf("config init: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
stations, err := radio.Search(context.Background(), name, country, state, tags, notok)
|
||||||
|
if err != nil {
|
||||||
|
// graceful: empty list + menu (old code dropped errs; we at least log)
|
||||||
|
fmt.Printf("warning: station search: %v\n", err)
|
||||||
|
stations = nil
|
||||||
|
}
|
||||||
menu := RadioMenu(stations)
|
menu := RadioMenu(stations)
|
||||||
err := menu.Run()
|
err = menu.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err.Error())
|
fmt.Println(err.Error())
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user