Compare commits
1 Commits
master
...
fix_comman
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b664e1996 |
@ -1,68 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-gitea
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.2'
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run tests (short)
|
||||
run: |
|
||||
go mod tidy
|
||||
make test-short
|
||||
# go mod tidy ensures go.sum is complete (fixes "missing go.sum entry" for configparser, wmenu, bubbles, bubbletea, lipgloss etc. during `go test`)
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-gitea
|
||||
needs: [test]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.2'
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Linux Build
|
||||
run: |
|
||||
go mod tidy
|
||||
./ci-build.sh
|
||||
# go mod tidy (like in test job and release build) ensures go.sum is fully populated for this Go version before the make build / go build, fixing "missing go.sum entry" for legacy deps (configparser, wmenu) and TUI deps (charmbracelet/*)
|
||||
|
||||
- name: Verify binary
|
||||
run: |
|
||||
./build/gostations -v || true
|
||||
ls -lh build/gostations || true
|
||||
@ -1,125 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-gitea
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.2'
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Download Go modules
|
||||
run: go mod download
|
||||
|
||||
- name: Build for multiple platforms
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
go mod tidy
|
||||
make cross VERSION="$VERSION" COMMIT="$COMMIT" DATE="$DATE"
|
||||
# go mod tidy ensures go.sum is complete for this Go version in CI (common fix for "missing go.sum entry" during cross builds)
|
||||
# make cross (with deps) handles the loop + ldflags
|
||||
|
||||
|
||||
|
||||
- name: Prepare assets
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
for bin in build/gostations-* ; do
|
||||
if [ ! -f "$bin" ]; then continue; fi
|
||||
OSARCH=$(basename "$bin" | sed 's/gostations-//' | sed 's/\.exe$//')
|
||||
tar czf "build/gostations-${OSARCH}-${VERSION}.tar.gz" -C build "$(basename "$bin")"
|
||||
done
|
||||
sha256sum build/gostations-*.tar.gz | tee build/checksums.txt
|
||||
|
||||
# Include useful scripts from repo (if present)
|
||||
[ -f scripts/gostations-install.sh ] && cp scripts/gostations-install.sh build/
|
||||
[ -f scripts/gostations-install.ps1 ] && cp scripts/gostations-install.ps1 build/
|
||||
|
||||
# Clean raw binaries (we ship the tarballs)
|
||||
for plat in 'linux/amd64' 'linux/arm64' 'darwin/amd64' 'darwin/arm64' 'windows/amd64'; do
|
||||
OS=$(echo "$plat" | cut -d/ -f1)
|
||||
ARCH=$(echo "$plat" | cut -d/ -f2)
|
||||
BIN="gostations-${OS}-${ARCH}"
|
||||
if [ "$OS" = "windows" ]; then BIN="${BIN}.exe"; fi
|
||||
rm -f "build/${BIN}"
|
||||
done
|
||||
|
||||
- name: Install dependencies
|
||||
run: apt update && apt install -y jq
|
||||
|
||||
- name: Create Release & Upload Assets
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
GITEA_API=https://repos.gmgauthier.com/api/v1
|
||||
REPO=${GITHUB_REPOSITORY}
|
||||
|
||||
echo "Creating release for tag ${VERSION} on ${REPO}..."
|
||||
|
||||
curl --fail --silent --show-error -X POST "${GITEA_API}/repos/${REPO}/releases" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"${VERSION}\",
|
||||
\"name\": \"gostations ${VERSION}\",
|
||||
\"target\": \"${GITHUB_SHA}\",
|
||||
\"draft\": false,
|
||||
\"body\": \"## Quick Install\n\n### Bash (Linux/macOS)\n\n\`\`\`bash\ncurl -L https://repos.gmgauthier.com/${REPO}/releases/download/${VERSION}/gostations-install.sh | VERSION=${VERSION} bash\n\`\`\`\n\n### PowerShell (Windows/macOS/Linux)\n\n\`\`\`powershell\nirm https://repos.gmgauthier.com/${REPO}/releases/download/${VERSION}/gostations-install.ps1 | iex\n\`\`\`\n\nPlatform binaries (tar.gz) + checksums.txt are attached. See README.md and CHANGELOG (if present) for details. Legacy wmenu UI still available with --legacy.\"
|
||||
}" > release.json
|
||||
|
||||
echo "Release creation response:"
|
||||
cat release.json
|
||||
|
||||
RELEASE_ID=$(jq -r '.id // empty' release.json)
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "ERROR: Failed to create release. No release ID returned. Check the response above and your RELEASE_TOKEN secret (must have 'repo' scope and write access)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Release created with ID: $RELEASE_ID"
|
||||
|
||||
for asset in build/* ; do
|
||||
name=$(basename "$asset")
|
||||
mime="application/octet-stream"
|
||||
[[ "$name" =~ \.tar\.gz$ ]] && mime="application/gzip"
|
||||
[[ "$name" =~ \.(txt|sh|ps1)$ ]] && mime="text/plain"
|
||||
echo "Uploading asset: $name"
|
||||
curl --fail --silent --show-error -X POST "${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${name}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: ${mime}" \
|
||||
--data-binary "@$asset"
|
||||
done
|
||||
|
||||
echo "All assets uploaded successfully."
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,4 +2,3 @@
|
||||
build/
|
||||
vendor/
|
||||
*.iml
|
||||
go.sum
|
||||
|
||||
96
CHANGELOG.md
96
CHANGELOG.md
@ -1,96 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to gostations will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.1.1] - 2026-06-07
|
||||
|
||||
### Added
|
||||
- Per-favorite volume persistence: the last volume set while playing a station that is in your favorites list is remembered and restored the next time you play that specific favorite. Non-favorited stations continue to use the global last volume (from `radiostations.ini` or the default).
|
||||
- Volumes for favorites are stored inside `favorites.json` (no separate file), using the existing atomic save/load pattern.
|
||||
|
||||
### Changed
|
||||
- Playback entry now prioritizes: per-favorite saved volume (if the station is favorited) > live session volume (for stickiness across `s`/`x` within a run) > global last volume.
|
||||
- Volume changes while playing a favorite also update the per-favorite volume (saved immediately, like global).
|
||||
- Explicit saves of per-favorite volume on `s`/`x` stop and on quit (in addition to per-change saves).
|
||||
|
||||
See the `todo/queued/per-station-volume.md` for implementation details and the narrowed scope (favorited stations only).
|
||||
|
||||
## [2.1.0] - 2026-06-06
|
||||
|
||||
### Added
|
||||
- Flashing visual feedback on control buttons for better UX:
|
||||
- Volume symbols (🔉 / 🔊) flash on ↑/↓
|
||||
- Skip symbols (◀◀ / ►►) flash on left/right (or h/l)
|
||||
- Stop symbol (⬛) flashes on s/x just before returning to the list
|
||||
- Subtle thin bordered "panel" around the button row (using the same style/color as the inner Now Playing border)
|
||||
|
||||
### Changed
|
||||
- Hint row (full-width bottom bar + faint help text inside the player card) cleaned up for no-wrap and minimalism:
|
||||
- Replaced "left/right" text with ANSI arrows (←/→)
|
||||
- Extremely terse abbreviations ("vol", "spc/p", etc.)
|
||||
- Centered (instead of left-justified)
|
||||
- Control symbols refreshed for consistent visual weight (geometric pointer style matching the play ► symbol; less bold/bright than previous technical arrows)
|
||||
- Playback card and button panel are now content-sized (width of buttons + minor padding) + centered in the terminal, instead of expanding to full width
|
||||
- Global last-used player volume is now persisted:
|
||||
- Saved on every volume keypress and observed change
|
||||
- Also saved explicitly on clean stop (s/x) and quit
|
||||
- Restored on next playback entry (first station of run uses ini value; subsequent stations carry the live session value)
|
||||
- Injected via `--volume=...` when launching mpv (respects existing options)
|
||||
- Many iterative layout, centering, border, and text polish items throughout the playback view and hint row
|
||||
|
||||
### Fixed
|
||||
- Volume now carries over correctly when using s/x to return to the list and selecting another station (live session value is preferred over re-reading the ini)
|
||||
- Various small robustness improvements around volume initialization and persistence
|
||||
|
||||
See the git history for the full set of TUI polish changes since v2.0.1.
|
||||
|
||||
## [2.0.1] - 2026-06-06
|
||||
|
||||
### Fixed
|
||||
- CI "Test" and "Build" flows: added `go mod tidy` (and Makefile `deps` targets) before `make test-short` / `make build` / cross to ensure complete `go.sum` for all modules (configparser, wmenu, charmbracelet/* etc.). This resolves repeated "missing go.sum entry" failures in minimal Gitea runners.
|
||||
- `TestPrecheck_Unit` now skips gracefully (with clear message) when the configured player (default: mpv) is not installed. Prevents `os.Exit(1)` from failing the root package under `go test -short` in CI.
|
||||
- Release workflow: added `shell: bash` to steps using bashisms; `go mod tidy` before cross builds; robust error handling in "Create Release & Upload Assets" (`set -euo pipefail`, `curl --fail`, explicit RELEASE_ID check, debug prints of API responses, "target" and "draft": false). Install scripts and asset packaging improved for portability.
|
||||
- Makefile: removed duplicate SHELL lines, added per-platform `go mod download` inside `cross` for CI resilience, `release-notes` target.
|
||||
- Pinned Go to 1.24.2 in workflows; modernized old `build.yml` (now proper test+build with checkout/setup-go/cache); cleaned `ci-build.sh`.
|
||||
- Re-iterated v2.0.0 tag during debugging; process now stable for proper asset drops on Gitea releases page.
|
||||
|
||||
See git history for details of the CI/release hardening.
|
||||
|
||||
## [2.0.0] - 2026-06-05
|
||||
|
||||
v2.0 ships the new TUI and a pile of rewired plumbing.
|
||||
|
||||
### Added
|
||||
- Add mpv IPC player with playback controls and winamp-style UI
|
||||
- Add favorites management (CLI + TUI) and in-filter server search
|
||||
- Add custom substring/AND filter for station list
|
||||
- Add auto-filter on typing
|
||||
- Add vertical volume bar to playback view
|
||||
- Add find/play subcommands and JSON favorites support
|
||||
- Add unit and integration tests for core functionality
|
||||
- Full modern TUI (Bubble Tea + bubbles/list + lipgloss) as the **default** experience
|
||||
- Two-stage UI: station selection list → dedicated playback view
|
||||
- Playback view inspired by classic Winamp (metadata viewer + controls)
|
||||
- `--legacy` flag to force the old wmenu UI (preserved for now)
|
||||
|
||||
### Changed
|
||||
- Reorganize into internal packages
|
||||
- Switch to CombinedOutput for command execution
|
||||
- Default UI is now the new TUI (no more wmenu unless --legacy)
|
||||
- Player abstraction extended for controls and metadata
|
||||
- Build/release process modernized (Makefile, cross-compilation, Gitea release workflow + installers) to match other projects
|
||||
|
||||
### Fixed
|
||||
- Fix critical panics and error handling during reorganization
|
||||
- Fix config lookups for Windows
|
||||
- Fix inverted short guards, format strings and typos
|
||||
- Various legacy subExecute / player execution issues from the old architecture
|
||||
|
||||
See the git history for the full list of changes leading to 2.0.
|
||||
|
||||
## [0.2] - Previous
|
||||
|
||||
Legacy wmenu-based UI + initial internal refactoring.
|
||||
241
ISSUES.md
241
ISSUES.md
@ -1,241 +0,0 @@
|
||||
# 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 + subcommands work)
|
||||
- Phase 0/1 + 2 progress: go.mod bumped + vendor removed + builds modernized. Critical panics in browser fixed in internal/radio (graceful errors, timeouts, caching, no panics on bad DNS/http). subExecute root cause mitigated in new player legacy.
|
||||
- New structure: internal/{config (cached, proper errs/paths), radio (fixed search), player (interface + cleaned legacy + IsInstalled no-injection), data (JSON favorites), ui (bubbles/list selection with ★ favs), version}.
|
||||
- Default now new TUI (bubbles list + filter + fav markers). Old wmenu gated behind --legacy.
|
||||
- Subcommands added: "find" (search, supports -j JSON for scripting), "play" (direct or search+play via player pkg, for scripting). Old flags work for seeding.
|
||||
- Favorites: JSON impl + tests + wired to show ★ in default TUI list.
|
||||
- High value tests added for: data/favorites (roundtrips, edges), radio (prune + error paths), player (IsInstalled + legacy), config (init/get), ui (model keys). Filled: inverted -short guards in Live tests, format string issue, etc. All -short tests clean.
|
||||
- Verified: subcmds work (find -j, play starts mpv), legacy still functions, TUI launches (in real tty), error cases graceful, old ini compat.
|
||||
- Per user: JSON for favs (done), default new TUI (done), find+play subcmds (done), old wmenu kept gated (done), tests with new code + filled gaps (done).
|
||||
- Next per plan: flesh TUI further (hotkeys, real play in enter, favorites toggle), IPC player, cleanup shims, full polish. See PLAN.md.
|
||||
94
Makefile
94
Makefile
@ -1,94 +0,0 @@
|
||||
.PHONY: test test-short test-cover lint build install clean help cross release-notes
|
||||
|
||||
SHELL := /bin/bash
|
||||
|
||||
# Versioning (override on command line or via env for releases)
|
||||
VERSION ?= dev-$(shell git describe --tags --always --dirty 2>/dev/null || echo unknown)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)
|
||||
|
||||
MODULE = github.com/gmgauthier/gostations
|
||||
LDFLAGS = -s -w \
|
||||
-X '$(MODULE)/internal/version.Version=$(VERSION)' \
|
||||
-X '$(MODULE)/internal/version.Commit=$(COMMIT)' \
|
||||
-X '$(MODULE)/internal/version.BuildDate=$(DATE)' \
|
||||
-X 'main.version=$(VERSION)'
|
||||
|
||||
test: deps
|
||||
go test ./... -v -race
|
||||
|
||||
test-short: deps
|
||||
go test -short ./... -v -race
|
||||
|
||||
test-cover: deps
|
||||
@mkdir -p build
|
||||
go test ./... -coverprofile=build/coverage.out
|
||||
go tool cover -html=build/coverage.out -o build/coverage.html
|
||||
@echo "✅ Coverage report: open build/coverage.html in your browser"
|
||||
|
||||
lint:
|
||||
@which golangci-lint > /dev/null || (echo "❌ golangci-lint not found. Install with:" && echo " go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" && exit 1)
|
||||
golangci-lint run
|
||||
|
||||
deps:
|
||||
go mod download
|
||||
|
||||
build: deps
|
||||
@mkdir -p build
|
||||
go build -trimpath -ldflags "$(LDFLAGS)" -o build/gostations .
|
||||
@echo "✅ Dev build: VERSION=$(VERSION) COMMIT=$(COMMIT) DATE=$(DATE)"
|
||||
@build/gostations -v || true
|
||||
|
||||
install: build
|
||||
mkdir -p ~/.local/bin
|
||||
cp build/gostations ~/.local/bin/gostations
|
||||
chmod +x ~/.local/bin/gostations
|
||||
@echo "✅ gostations installed to ~/.local/bin/gostations"
|
||||
|
||||
clean:
|
||||
rm -rf build/
|
||||
|
||||
# Cross compile (used by release workflow)
|
||||
cross: deps
|
||||
@mkdir -p build
|
||||
@for plat in 'linux/amd64' 'linux/arm64' 'darwin/amd64' 'darwin/arm64' 'windows/amd64'; do \
|
||||
IFS='/' read -r OS ARCH <<< "$$plat"; \
|
||||
BIN="gostations-$$OS-$$ARCH"; \
|
||||
if [ "$$OS" = "windows" ]; then BIN="$$BIN.exe"; fi; \
|
||||
echo "Building $$BIN..."; \
|
||||
GOOS=$$OS GOARCH=$$ARCH go mod download; \
|
||||
GOOS=$$OS GOARCH=$$ARCH go build -trimpath -ldflags "$(LDFLAGS)" -o "build/$$BIN" . ; \
|
||||
done
|
||||
@echo "✅ Cross builds complete in build/"
|
||||
|
||||
# Helper to print release notes body (used by release process)
|
||||
release-notes:
|
||||
@echo "## Installation"
|
||||
@echo ""
|
||||
@echo "Download the appropriate archive for your platform from the release assets."
|
||||
@echo ""
|
||||
@echo "### Quick install (Linux/macOS)"
|
||||
@echo ""
|
||||
@echo '```bash'
|
||||
@echo 'curl -L https://repos.gmgauthier.com/gmgauthier/gostations/releases/download/$(VERSION)/gostations-install.sh | VERSION=$(VERSION) bash'
|
||||
@echo '```'
|
||||
@echo ""
|
||||
@echo "### Quick install (Windows / PowerShell)"
|
||||
@echo ""
|
||||
@echo '```powershell'
|
||||
@echo 'irm https://repos.gmgauthier.com/gmgauthier/gostations/releases/download/$(VERSION)/gostations-install.ps1 | iex'
|
||||
@echo '```'
|
||||
@echo ""
|
||||
@echo "See README.md for full details and configuration (radiostations.ini)."
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " test Run all tests (with race)"
|
||||
@echo " test-short Run tests with -short (skips live integration)"
|
||||
@echo " test-cover Tests + HTML coverage report"
|
||||
@echo " lint Run golangci-lint"
|
||||
@echo " build Optimized dev build (uses git describe for VERSION)"
|
||||
@echo " install Build + install to ~/.local/bin/gostations"
|
||||
@echo " cross Build all release platforms into build/"
|
||||
@echo " release-notes Print suggested Gitea release body text"
|
||||
@echo " clean Remove build/"
|
||||
@ -1,35 +0,0 @@
|
||||
# FOR WINDOWS USERS
|
||||
|
||||
|
||||
|
||||
## MPV
|
||||
MPV insists on opening a GUI (or "pseudo-gui" as they call it) window, whether you like it or not.
|
||||
So, here is the best mpv.conf I could come up with, under those
|
||||
circumstances:
|
||||
```ini
|
||||
[default]
|
||||
terminal=yes
|
||||
audio-display=no
|
||||
video=no
|
||||
force-window=no
|
||||
window-minimized=yes
|
||||
idle=yes
|
||||
```
|
||||
The window will still be created, but at least, it will be minimized to the taskbar.
|
||||
|
||||
You should put this file in: `%USERPROFILE%\AppData\Roaming\mpv.net\mpv.conf`
|
||||
|
||||
## RADIOSTATIONS.INI
|
||||
Make sure your `gostations.exe` is somewhere in your searchable %PATH%. Then, you should
|
||||
only need to change the player.command option:
|
||||
```ini
|
||||
[DEFAULT]
|
||||
radio_browser.api=all.api.radio-browser.info
|
||||
player.command=mpvnet.exe
|
||||
player.options=--no-video
|
||||
menu_items.max=9999
|
||||
```
|
||||
|
||||
# FOR EVERYONE ELSE
|
||||
|
||||
Continue as you are. The sane people don't need to do anything different.
|
||||
274
README.md
274
README.md
@ -1,172 +1,142 @@
|
||||
# GoStations
|
||||
### console based radio station selector and player
|
||||
|
||||
<p align="center">
|
||||
<a href="https://repos.gmgauthier.com/gmgauthier/womm-certification">
|
||||
<img src="assets/womm-platinum.svg" alt="WOMM Platinum Certified" width="180"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**WOMM Platinum Certified** (per [WOMM-STD-001:2026](https://repos.gmgauthier.com/gmgauthier/womm-certification))
|
||||
|
||||
This is a port of a Python script I wrote, called "radiostations". It is a simple console tool to grab a list of radio stations retrieved from `radio-browser.info`, put them into a menu, and then use your local installation of a console stream player, to play the station for you.
|
||||
|
||||
|
||||
## Requirements
|
||||
* OS: Linux or macOS (Windows via the installers or WSL)
|
||||
* Recommended player: `mpv` (with `--no-video`). Alternatives (`mpg123`, `mplayer`, etc.) can be configured in the ini file.
|
||||
* For building from source: Go 1.24.2+
|
||||
### Requirements
|
||||
* OS: Linux or macOS
|
||||
* the `mpv` player (or `mpg123` or `mplayer`, if you change the config)
|
||||
* Go 1.14+ (if you build your own binary)
|
||||
|
||||
## Install (for normal users)
|
||||
|
||||
The easiest way is the one-liner installer attached to each release:
|
||||
|
||||
### Linux / macOS
|
||||
```bash
|
||||
curl -L https://repos.gmgauthier.com/gmgauthier/gostations/releases/download/v2.1.0/gostations-install.sh \
|
||||
| VERSION=2.1.0 bash
|
||||
### Build
|
||||
1. clone the repository
|
||||
2. cd into the root of the project
|
||||
3. run the following:
|
||||
```
|
||||
> go mod vendor
|
||||
> go mod tidy
|
||||
> go build -o /wherever/you/want/gostations github.com/gmgauthier/gostations
|
||||
```
|
||||
|
||||
### Windows (PowerShell) / macOS / Linux with PowerShell
|
||||
```powershell
|
||||
irm https://repos.gmgauthier.com/gmgauthier/gostations/releases/download/v2.1.0/gostations-install.ps1 | iex
|
||||
### Install
|
||||
1. copy `gostations` to a location that is on your `PATH`
|
||||
2. copy `radiostations.ini` to a location that is on your `XDG_CONFIG_HOME`
|
||||
* Note: if you skip step 2, the app will build an ini file for you automatically.
|
||||
|
||||
### Execute
|
||||
```
|
||||
|
||||
The installer:
|
||||
- Detects your OS and architecture.
|
||||
- Downloads the matching `gostations-OS-ARCH-vX.Y.Z.tar.gz` (verifies checksum when possible).
|
||||
- Installs the binary to `~/.local/bin/gostations` (`.exe` on Windows).
|
||||
- Prints `PATH` advice if needed and runs `gostations -v`.
|
||||
|
||||
After installation, just run `gostations`. On first launch it creates a default `radiostations.ini` under `$XDG_CONFIG_HOME/gostations/` (usually `~/.config/gostations/radiostations.ini`) if none exists.
|
||||
|
||||
## Usage
|
||||
|
||||
### TUI (default)
|
||||
`gostations` (no arguments) launches the modern Bubble Tea UI:
|
||||
|
||||
- If you have any **favorites**, they are loaded first as the initial list (title: "Your Favorites"; entries marked ★).
|
||||
- Otherwise a broad default search is performed against radio-browser.info (title includes "new TUI • ★ = favorite").
|
||||
- Type to filter the visible list (filter activates automatically).
|
||||
- While filtering, press **Enter** to perform a fresh server-side search (replaces the list; favorites still get ★).
|
||||
- **f** / **F** — toggle favorite (★) on the selected station.
|
||||
- Arrow keys / vim keys — navigate.
|
||||
- **Enter** on a station — switch to the dedicated playback view.
|
||||
- **q** or **Ctrl+C** — quit.
|
||||
|
||||
### Playback View
|
||||
A compact, Winamp-inspired screen:
|
||||
- Large "NOW PLAYING" viewer area (dark background + green text) showing live streamed metadata (station + song titles delivered over mpv JSON IPC).
|
||||
- A vertical volume bar to the right of the metadata (dark gray background, green fill from the bottom; same height as the viewer; small gap).
|
||||
- Keyboard-driven on-screen controls:
|
||||
- `←` / `→` (or `h`/`l`) — skip back/forward.
|
||||
- `↑` / `↓` — volume up/down (per-favorite volume is restored when starting a favorited station; changes are saved for that favorite).
|
||||
- `m` / `M` — mute / unmute.
|
||||
- `Space` or `p` / `P` — play / pause.
|
||||
- `s` / `S` / `x` / `X` — stop playback and return to the station list.
|
||||
- `q` quits the whole app.
|
||||
|
||||
Playback runs in the background; the TUI stays responsive.
|
||||
|
||||
### CLI subcommands (scripting)
|
||||
> gostations -h
|
||||
Usage:
|
||||
gostations [-n "name"] [-c "home country"] [-s "home state"] [-t "ordered,tag,list"] [-x]
|
||||
-c string
|
||||
Home country.
|
||||
-n string
|
||||
Station name (or identifier).
|
||||
-s string
|
||||
Home state (if in the United States).
|
||||
-t string
|
||||
Tag (or comma-separated tag list)
|
||||
-x If toggled, will show stations that are down
|
||||
-h (or none)
|
||||
This help message
|
||||
```
|
||||
gostations find [-n name] [-c country] [-s state] [-t tags] [-x] [-j]
|
||||
gostations play [-n name] [-c country] [-s state] [-t tags] [-x] [url]
|
||||
gostations fav list | add | del ...
|
||||
gostations -v
|
||||
gostations --legacy # force the classic wmenu UI (temporary)
|
||||
### Examples
|
||||
```
|
||||
|
||||
Global search flags (`-n`/`-c`/`-s`/`-t`/`-x`) are accepted by `find`, `play`, and `fav add|del`.
|
||||
|
||||
- `find` — search and print results (`-j`/`--json` for machine-readable).
|
||||
- `play` — play the first match (or a direct URL). Uses the player + options from the ini.
|
||||
- `fav list` — show your favorites (1-based index, stable sort by name).
|
||||
- `fav add` — add by search flags or direct URL.
|
||||
- `fav del` — remove by 1-based index (from `fav list`), search flags, or direct URL.
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
gostations find -c "United Kingdom" -t "news" -j
|
||||
gostations play -c Gambia
|
||||
gostations play http://stream.example.com/radio.mp3
|
||||
gostations fav list
|
||||
gostations fav add -n "WFMT"
|
||||
gostations fav del 3
|
||||
gostations fav del http://...
|
||||
greg.gauthier@C02DRPKUMD6M $ gostations -c "United Kingdom" -t "news" -n "LBC"
|
||||
...Radio Menu...
|
||||
1) LBC London (London stream) AAC AAC+ 0 http://media-sov.musicradio.com:80/LBCLondon
|
||||
2) LBC London (London stream) MP3 MP3 48 http://media-ice.musicradio.com/LBCLondonMP3Low
|
||||
3) LBC London (National stream) MP3 48 http://media-ice.musicradio.com/LBCUKMP3Low
|
||||
4) LBC London News AAC 48 http://media-ice.musicradio.com/LBC1152.m3u
|
||||
5) LBC UK AAC 48 http://media-ice.musicradio.com/LBCUK
|
||||
6) *Quit
|
||||
What is your choice?
|
||||
```
|
||||
|
||||
Run `gostations <subcommand> -h` or `gostations -h` for full flag details.
|
||||
|
||||
### Legacy UI
|
||||
Pass `--legacy` to use the old wmenu-based menu. The flag exists "until the new TUI is perfect." Playback now uses the modern player backend even under `--legacy`.
|
||||
|
||||
## Configuration
|
||||
|
||||
The ini file is at `$XDG_CONFIG_HOME/gostations/radiostations.ini` (falls back to `~/.config/gostations/radiostations.ini`). A default is generated on first run if missing.
|
||||
|
||||
Notable keys:
|
||||
- `player.command=mpv`
|
||||
- `player.options=--no-video`
|
||||
- `radio_browser.api=...` (point at a mirror if desired)
|
||||
- `menu_items.max=9999`
|
||||
|
||||
Favorites are stored as JSON (`favorites.json`) in the same directory. The TUI prefers loading favorites first when any exist.
|
||||
|
||||
Per-favorite volume preferences are also stored in the same `favorites.json` file (only for stations you have explicitly favorited). When you start playback for a favorited station, its last-used volume is restored (falling back to the global last volume or the default). Volume changes while playing a favorite are persisted for that station. Non-favorites always use the global last volume.
|
||||
|
||||
## Develop (for power users & developers)
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.24.2+
|
||||
- `make` (recommended)
|
||||
- `mpv` (for local playback testing)
|
||||
- Optional: `golangci-lint` for `make lint`
|
||||
|
||||
### Common tasks
|
||||
```bash
|
||||
git clone https://repos.gmgauthier.com/gmgauthier/gostations.git
|
||||
cd gostations
|
||||
|
||||
make help # list all targets
|
||||
make build # dev build (version from git describe, ldflags into internal/version + main.version)
|
||||
make install # build + copy to ~/.local/bin/gostations
|
||||
make test-short # fast unit tests (no network, no player required)
|
||||
make test # full suite with -race
|
||||
make test-cover # coverage report in build/
|
||||
make lint
|
||||
make cross # release matrix (linux/amd64+arm64, darwin/amd64+arm64, windows/amd64)
|
||||
```
|
||||
|
||||
See the `Makefile` for the exact ldflags, per-platform `go mod download`, and the `release-notes` helper.
|
||||
|
||||
### Releasing
|
||||
```bash
|
||||
./release.sh v2.1.1
|
||||
# or manually
|
||||
git tag -a v2.1.1 -m "..."
|
||||
git push origin v2.1.1
|
||||
greg.gauthier@C02DRPKUMD6M $ gostations -c "Gambia"
|
||||
...Radio Menu...
|
||||
1) Choice FM MP3 128 http://uk3-pn.webcast-server.net/8276/stream.mp3
|
||||
2) *Quit
|
||||
What is your choice?
|
||||
```
|
||||
```
|
||||
greg.gauthier@C02DRPKUMD6M $ gostations -t "chicago,classical"
|
||||
...Radio Menu...
|
||||
1) WFMT 98.7 Chicago, IL (MP3) MP3 0 http://stream.wfmt.com/main-mp3
|
||||
2) *Quit
|
||||
What is your choice?
|
||||
```
|
||||
When you want to play, you just choose an entry from the menu and hit enter. Your selected stream player will begin playing, and its output will be seen on the console:
|
||||
```
|
||||
greg.gauthier@C02DRPKUMD6M $ gostations -s "Illinois" -t "classical"
|
||||
...Radio Menu...
|
||||
1) Ancient Faith Radio AAC+ 96 https://ancientfaith.streamguys1.com/music
|
||||
2) Lutheran Public Radio - Collinsville, IL AAC+ 0 http://lpr.streamguys1.com/lpr-aac
|
||||
3) Majesty Radio MP3 128 http://primary.moodyradiostream.org/majesty.mp3
|
||||
4) WFMT 98.7 Chicago, IL (AAC) AAC 256 http://wowza.wfmt.com/live/smil:wfmt.smil/playlist.m3u8
|
||||
5) WFMT 98.7 Chicago, IL (MP3) MP3 0 http://stream.wfmt.com/main-mp3
|
||||
6) WNIU 90.5 Northern Public Radio Classica MP3 128 http://peace.str3am.com:6840/live-128k.mp3
|
||||
7) WUIS-HD2 NPR Illinois Classic - Springfi MP3 96 http://war.str3am.com:7780/WUISRIS-2
|
||||
8) WVIK MP3 0 https://wvik.streamguys1.com//live.mp3
|
||||
9) *Quit
|
||||
What is your choice?
|
||||
5
|
||||
Streaming: WFMT 98.7 Chicago, IL (MP3) MP3 0 http://stream.wfmt.com/main-mp3
|
||||
(+) Audio --aid=1 (mp3 2ch 44100Hz)
|
||||
AO: [coreaudio] 44100Hz stereo 2ch floatp
|
||||
A: 00:00:00 / 00:00:06 (14%) Cache: 5.5s/195KB
|
||||
File tags:
|
||||
icy-title: Steiner - Treasure of the Sierra Madre (1948) - - - Centaur
|
||||
A: 00:00:23 / 00:00:46 (49%) Cache: 23s/825KB
|
||||
```
|
||||
**NOTE:** If you are using `mpv` and you are on a Mac with a touchbar, and Catalina or better, you will see this error message:
|
||||
```
|
||||
2021-03-17 10:51:41.328 mpv[40610:6351061] This NSLayoutConstraint is being configured with a constant that exceeds
|
||||
internal limits. A smaller value will be substituted, but this problem should be fixed. Break
|
||||
on BOOL _NSLayoutConstraintNumberExceedsLimit(void) to debug. This will be logged only once. This may break in the future.
|
||||
```
|
||||
This is due to an issue between `mpv` and Apple at the moment, regarding the creation of `mpv` touchbar controls, and can be ignored.
|
||||
|
||||
`release.sh` does a clean-tree check, creates the annotated tag, optionally runs `grokkit changelog` (best-effort), and pushes. The Gitea Actions workflow (`.gitea/workflows/release.yml`) then:
|
||||
- Checks out the tag.
|
||||
- Sets up Go 1.24.2 + module cache.
|
||||
- Runs `go mod tidy` + `make cross`.
|
||||
- Packages tarballs + checksums + the two install scripts.
|
||||
- Creates the release (with install instructions in the body) and uploads every asset via the Gitea API using the `RELEASE_TOKEN` secret.
|
||||
While you are listening, all the normal stdin controls for `mpv` should work properly. So, `9` will lover the volume, `0` will raise the volume, and `m` will mute. To quit, type `q`. When you do, you'll automatically be delivered back to your original menu:
|
||||
```
|
||||
(+) Audio --aid=1 (mp3 2ch 44100Hz)
|
||||
AO: [coreaudio] 44100Hz stereo 2ch floatp
|
||||
A: 00:00:00 / 00:00:06 (14%) Cache: 5.5s/195KB
|
||||
File tags:
|
||||
icy-title: Steiner - Treasure of the Sierra Madre (1948) - - - Centaur
|
||||
|
||||
### Project layout (post-reorg)
|
||||
- `internal/ui/` — Bubble Tea TUI (list + playback view + hint bar + volume bar + polling).
|
||||
- `internal/player/` — `Player` interface + `mpvPlayer` (JSON IPC via unix socket) + legacy exec fallback.
|
||||
- `internal/data/` — favorites (JSON, atomic save, sorted list with stable indices).
|
||||
- `internal/radio/`, `internal/config/` — search + ini handling.
|
||||
- Legacy wmenu code (`radiomenu.go`, `commander.go`, etc.) remains gated behind `--legacy`.
|
||||
- `scripts/` — installers (`.sh` + `.ps1`).
|
||||
- `.gitea/workflows/` — build + release CI.
|
||||
- `Makefile` + `release.sh` drive local builds and the tag-triggered release path.
|
||||
A: 00:05:52 / 00:06:16 (94%) Cache: 23s/824KB
|
||||
File tags:
|
||||
icy-title: Korngold, Erich Wolfgang - The Sea Wolf film music - - - Koch
|
||||
A: 00:07:42 / 00:08:06 (95%) Cache: 23s/827KB
|
||||
|
||||
All the pre-2.0 wmenu screenshots, the old `go mod vendor` instructions, the touchbar error note, and the ancient TODO list have been retired from this document. See git history for the full modernization (TUI, IPC player, favorites + index delete, subcommands, internal/ package reorg, CI/release pipeline, etc.). The `--legacy` flag remains until the TUI is declared "perfect."
|
||||
Exiting... (Quit)
|
||||
[]
|
||||
1) Ancient Faith Radio AAC+ 96 https://ancientfaith.streamguys1.com/music
|
||||
2) Lutheran Public Radio - Collinsville, IL AAC+ 0 http://lpr.streamguys1.com/lpr-aac
|
||||
3) Majesty Radio MP3 128 http://primary.moodyradiostream.org/majesty.mp3
|
||||
4) WFMT 98.7 Chicago, IL (AAC) AAC 256 http://wowza.wfmt.com/live/smil:wfmt.smil/playlist.m3u8
|
||||
5) WFMT 98.7 Chicago, IL (MP3) MP3 0 http://stream.wfmt.com/main-mp3
|
||||
6) WNIU 90.5 Northern Public Radio Classica MP3 128 http://peace.str3am.com:6840/live-128k.mp3
|
||||
7) WUIS-HD2 NPR Illinois Classic - Springfi MP3 96 http://war.str3am.com:7780/WUISRIS-2
|
||||
8) WVIK MP3 0 https://wvik.streamguys1.com//live.mp3
|
||||
9) *Quit
|
||||
What is your choice?
|
||||
```
|
||||
To exit the program entirely, choose the __*Quit__ option, or just hit `[ENTER]`
|
||||
|
||||
---
|
||||
### Additional Notes:
|
||||
|
||||
The project is WOMM Platinum certified (see badge at the top).
|
||||
1. The ini file sets a bunch of environmental defaults necessary for the program to work. Gostations looks for it on your `XDG_CONFIG_HOME` path. If that path is not set, or the file is not found on the path, a default version of the ini will be generated automatically, called `radiostations.ini`. The default location for the `XDG_CONFIG_HOME` is `$HOME/.config/gostations`, if you need to edit it.
|
||||
2. One of the defaults set in that ini file, is a limit on the number of entries that the program will retrieve from the radio-browser.info api, and the number of entries that will be displayed in the menu. `gostations` isn't really designed to dump the entire radio-browser database to a menu. I've set the limit to 9999 by default, but it could probably be less. Tuning your searches should help improve the experience.
|
||||
3. Speaking of that, there are a number of things to keep in mind when searching:
|
||||
* The country search is by country NAME, not CODE. So, "United States" will work, but "US" will not (likewise for "UK")
|
||||
* if you supply multiple tags in a comma-separated list, you may unintentionally filter out results. Unfortunately, the api at radio-info is such that the tag list you search for, has to be in precisely the order it is returned from the host. So, for example, if you search for "classical,chicago", your search will filter out WFMT, because their tags are "chicago,classical".
|
||||
* It seems many of the stations put their city in the tag list. So, you can reduce the size of your results by doing something like this: `-c "United States" -t "atlanta"`, which makes more sense for radio stations anyway, for radio stations.
|
||||
|
||||
### TODO
|
||||
|
||||
* 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. Perhaps a preloaded search or something.
|
||||
* Add color or a dashboard to the menu and player.
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">
|
||||
<defs>
|
||||
<style>
|
||||
.seal-text { font-family: 'Georgia', 'EB Garamond', serif; font-weight: 700; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- Outer ring -->
|
||||
<circle cx="200" cy="200" r="190" fill="none" stroke="#E5E4E2" stroke-width="6"/>
|
||||
<circle cx="200" cy="200" r="180" fill="none" stroke="#E5E4E2" stroke-width="2"/>
|
||||
|
||||
<!-- Decorative dots -->
|
||||
<g fill="#E5E4E2">
|
||||
<circle cx="200" cy="14" r="4"/>
|
||||
<circle cx="200" cy="386" r="4"/>
|
||||
<circle cx="14" cy="200" r="4"/>
|
||||
<circle cx="386" cy="200" r="4"/>
|
||||
<circle cx="68" cy="68" r="3"/>
|
||||
<circle cx="332" cy="68" r="3"/>
|
||||
<circle cx="68" cy="332" r="3"/>
|
||||
<circle cx="332" cy="332" r="3"/>
|
||||
</g>
|
||||
|
||||
<!-- Inner ring -->
|
||||
<circle cx="200" cy="200" r="150" fill="none" stroke="#E5E4E2" stroke-width="2"/>
|
||||
<circle cx="200" cy="200" r="140" fill="none" stroke="#E5E4E2" stroke-width="1"/>
|
||||
|
||||
<!-- Stars between rings -->
|
||||
<g fill="#E5E4E2" class="seal-text" text-anchor="middle" font-size="16">
|
||||
<text x="200" y="42">★</text>
|
||||
<text x="200" y="370">★</text>
|
||||
<text x="38" y="206">★</text>
|
||||
<text x="362" y="206">★</text>
|
||||
</g>
|
||||
|
||||
<!-- Circular text - top: "WORKS ON MY MACHINE" -->
|
||||
<path id="topArc" d="M 80,200 a 120,120 0 0,1 240,0" fill="none"/>
|
||||
<text class="seal-text" font-size="18" fill="#E5E4E2" letter-spacing="3">
|
||||
<textPath href="#topArc" startOffset="50%" text-anchor="middle">WORKS ON MY MACHINE</textPath>
|
||||
</text>
|
||||
|
||||
<!-- Circular text - bottom: "CERTIFIED" -->
|
||||
<path id="bottomArc" d="M 320,200 a 120,120 0 0,1 -240,0" fill="none"/>
|
||||
<text class="seal-text" font-size="18" fill="#E5E4E2" letter-spacing="5">
|
||||
<textPath href="#bottomArc" startOffset="50%" text-anchor="middle">CERTIFIED</textPath>
|
||||
</text>
|
||||
|
||||
<!-- Laptop + checkmark icon -->
|
||||
<g transform="translate(200,165)" fill="none" stroke="#E5E4E2" stroke-width="3"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="-35" y="-30" width="70" height="48" rx="4"/>
|
||||
<polyline points="-12,0 -2,10 16,-10" stroke-width="4" stroke="#2d6a4f"/>
|
||||
<path d="M-45,18 L-50,28 L50,28 L45,18"/>
|
||||
</g>
|
||||
|
||||
<!-- WOMM -->
|
||||
<text x="200" y="228" class="seal-text" font-size="42" fill="#E5E4E2"
|
||||
text-anchor="middle" letter-spacing="6">WOMM</text>
|
||||
|
||||
<!-- Divider -->
|
||||
<line x1="120" y1="240" x2="280" y2="240" stroke="#E5E4E2" stroke-width="1.5"/>
|
||||
|
||||
<!-- Level label -->
|
||||
<text x="200" y="260" class="seal-text" font-size="16" fill="#E5E4E2"
|
||||
text-anchor="middle" letter-spacing="3">PLATINUM</text>
|
||||
|
||||
<!-- Date -->
|
||||
<text x="200" y="285" class="seal-text" font-size="13" fill="#E5E4E2"
|
||||
text-anchor="middle" letter-spacing="1">2026-06-06</text>
|
||||
|
||||
<!-- Dot decorations -->
|
||||
<g fill="#E5E4E2">
|
||||
<circle cx="148" cy="282" r="2"/>
|
||||
<circle cx="252" cy="282" r="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
25
bitbucket-pipelines.yml
Normal file
25
bitbucket-pipelines.yml
Normal file
@ -0,0 +1,25 @@
|
||||
# Template Go (Golang) build
|
||||
|
||||
# This template allows you to validate your Go (Golang) code.
|
||||
# The workflow allows running tests, build and code linting on the default branch.
|
||||
|
||||
image: golang:1.15
|
||||
|
||||
pipelines:
|
||||
default:
|
||||
- parallel:
|
||||
- step:
|
||||
name: Test and Build
|
||||
script:
|
||||
#- mkdir test-reports
|
||||
#- go get -u github.com/jstemmer/go-junit-report
|
||||
#- go test tests/* -v 2>&1 | go-junit-report > test-reports/report.xml
|
||||
- mkdir build
|
||||
- go mod vendor
|
||||
- go mod tidy
|
||||
- go build -o build/gostations github.com/gmgauthier/gostations
|
||||
- step:
|
||||
name: Lint code
|
||||
image: golangci/golangci-lint:v1.31.0
|
||||
script:
|
||||
- golangci-lint run -v
|
||||
112
browser.go
112
browser.go
@ -1,27 +1,99 @@
|
||||
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 (
|
||||
"context"
|
||||
|
||||
"github.com/gmgauthier/gostations/internal/radio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stationRecord is a type alias for backward compat in tests and old call sites.
|
||||
type stationRecord = radio.Station
|
||||
|
||||
// StationSearch shim (used by some tests).
|
||||
func StationSearch(name string, country string, state string, tags string, notok bool) ([]stationRecord, error) {
|
||||
ss, err := radio.Search(context.Background(), name, country, state, tags, notok)
|
||||
// convert slice of alias (identical underlying)
|
||||
out := make([]stationRecord, len(ss))
|
||||
for i := range ss {
|
||||
out[i] = ss[i]
|
||||
}
|
||||
return out, err
|
||||
type stationRecord 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"`
|
||||
}
|
||||
|
||||
// GetApiHost etc are intentionally not shimmed; new code uses internal/radio.
|
||||
func RandomIP(iplist []net.IP) net.IP {
|
||||
rand.Seed(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 resp.Body.Close()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func StationSearch(name string, country string, state string, tags string, notok bool) ([]stationRecord, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
|
||||
# Modernized. Prefer `make build` / `make install` (via Git Bash or WSL recommended).
|
||||
# This remains for basic compatibility.
|
||||
|
||||
make build
|
||||
make install
|
||||
|
||||
18
build.sh
18
build.sh
@ -1,8 +1,16 @@
|
||||
#!/usr/bin/env sh
|
||||
# Modernized build script. Prefer `make build` or `make install`.
|
||||
# This script remains for compatibility.
|
||||
|
||||
set -e
|
||||
GOPATH=$GOPATH:$(pwd)
|
||||
export GOPATH
|
||||
|
||||
make build
|
||||
make install
|
||||
GIT_COMMIT=$(git rev-list -1 HEAD)
|
||||
export GIT_COMMIT
|
||||
CANONICAL_VERSION=$(cat ./VERSION)
|
||||
export CANONICAL_VERSION
|
||||
VERSION_STRING="$CANONICAL_VERSION-$GIT_COMMIT"
|
||||
export VERSION_STRING
|
||||
|
||||
go mod vendor
|
||||
go mod tidy
|
||||
|
||||
go build -o build/gostations -ldflags "-X main.version=$VERSION_STRING"
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
# CI build step (kept for the workflow's "Linux Build" name).
|
||||
# Now uses modern Go from setup-go in the workflow.
|
||||
|
||||
set -e
|
||||
|
||||
echo "Running make build with Go: $(go version)"
|
||||
make build
|
||||
|
||||
24
commander.go
24
commander.go
@ -2,33 +2,27 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func isInstalled(name string) bool {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
// check for the operating system
|
||||
if runtime.GOOS == "windows" {
|
||||
// 'where' command is used on Windows to locate executables
|
||||
cmd = exec.Command("where.exe", name)
|
||||
} else {
|
||||
// 'command' is used on Unix systems
|
||||
cmd = exec.Command("/bin/sh", "-c", "command -v "+name)
|
||||
}
|
||||
|
||||
cmd := exec.Command("/bin/sh", "-c", "command -v " + name)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func subExecute(program string, args ...string) ([]byte, error) {
|
||||
func subExecute(program string, args ...string) {
|
||||
cmd := exec.Command(program, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stdout
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
fmt.Printf("%v\n", err)
|
||||
}
|
||||
return output, err
|
||||
outp, _ := cmd.CombinedOutput()
|
||||
fmt.Println(outp)
|
||||
}
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsInstalled_Unit(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Log("✓ Fast isInstalled unit test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd string
|
||||
want bool
|
||||
}{
|
||||
{"sh exists", "sh", true},
|
||||
{"non-existent", "nonexistentcmd123456789", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isInstalled(tt.cmd)
|
||||
if got != tt.want {
|
||||
t.Errorf("isInstalled(%q) = %v, want %v", tt.cmd, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInstalled_Live(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping live integration test (use without -short)")
|
||||
}
|
||||
t.Log("🧪 Running live isInstalled integration test...")
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("isInstalled uses /bin/sh (Unix-only)")
|
||||
}
|
||||
|
||||
if !isInstalled("sh") {
|
||||
t.Error("sh should be installed on this system")
|
||||
}
|
||||
t.Log("✓ Live isInstalled test passed")
|
||||
}
|
||||
|
||||
func TestSubExecute_Unit(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Log("✓ Fast subExecute unit test")
|
||||
|
||||
_, err := subExecute("nonexistentcmd123456789")
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubExecute_Live(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping live integration test (use without -short)")
|
||||
}
|
||||
t.Log("🧪 Running live subExecute integration test...")
|
||||
|
||||
output, err := subExecute("echo", "hello from live test")
|
||||
if err != nil {
|
||||
t.Fatalf("subExecute failed: %v", err)
|
||||
}
|
||||
if len(output) == 0 {
|
||||
t.Error("expected output")
|
||||
}
|
||||
t.Log("✓ subExecute live test passed")
|
||||
}
|
||||
23
config.go
23
config.go
@ -2,16 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
||||
"github.com/alyu/configparser"
|
||||
)
|
||||
|
||||
// str2int
|
||||
//str2int
|
||||
func str2int(strnum string) int {
|
||||
i, err := strconv.Atoi(strnum)
|
||||
if err != nil {
|
||||
@ -20,28 +18,19 @@ func str2int(strnum string) int {
|
||||
return i
|
||||
}
|
||||
|
||||
func configStat(configFile string) string {
|
||||
func configStat(configFile string) string{
|
||||
xdgConfigPath := os.Getenv("XDG_CONFIG_HOME")
|
||||
if xdgConfigPath == "" {
|
||||
if runtime.GOOS == "windows" {
|
||||
xdgConfigPath = os.Getenv("USERPROFILE") + "\\.config"
|
||||
} else {
|
||||
xdgConfigPath = os.Getenv("HOME") + "/.config"
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
configFile = xdgConfigPath + "\\gostations\\" + configFile
|
||||
} else {
|
||||
configFile = xdgConfigPath + "/gostations/" + configFile
|
||||
xdgConfigPath = os.Getenv("HOME")+"/.config"
|
||||
}
|
||||
configFile = 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.")
|
||||
errs := createIniFile(configFile)
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
log.Printf("Error: %s", err.Error())
|
||||
log.Printf("Erorr: %s", err.Error())
|
||||
log.Fatal("Cannot continue.")
|
||||
}
|
||||
}
|
||||
@ -67,7 +56,7 @@ func Config(option string) (string, error) {
|
||||
|
||||
optval := section.Options()[option]
|
||||
if optval == "" {
|
||||
return "", fmt.Errorf("no value for option %q", option)
|
||||
return "", errors.New("no value for option '%s'")
|
||||
}
|
||||
return optval, nil
|
||||
}
|
||||
|
||||
140
filer_test.go
140
filer_test.go
@ -1,140 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateIniFile_Unit(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Log("✓ Fast createIniFile unit test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fpath string
|
||||
setup func(string)
|
||||
wantLen int
|
||||
wantFile bool
|
||||
}{
|
||||
{
|
||||
name: "happy path",
|
||||
fpath: "testdata/config.ini",
|
||||
wantLen: 0,
|
||||
wantFile: true,
|
||||
},
|
||||
{
|
||||
name: "dir already exists",
|
||||
fpath: "testdata/config.ini",
|
||||
setup: func(dir string) {
|
||||
os.MkdirAll(dir, 0770)
|
||||
},
|
||||
wantLen: 0,
|
||||
wantFile: true,
|
||||
},
|
||||
{
|
||||
name: "deep nested dir",
|
||||
fpath: "testdata/nested/sub/dir/config.ini",
|
||||
wantLen: 0,
|
||||
wantFile: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fpath := filepath.Join(dir, tt.fpath)
|
||||
|
||||
origWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get working dir: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("failed to chdir: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Chdir(origWd); err != nil {
|
||||
t.Errorf("failed to restore working dir: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if tt.setup != nil {
|
||||
tt.setup(filepath.Dir(fpath))
|
||||
}
|
||||
|
||||
errs := createIniFile(fpath)
|
||||
if len(errs) != tt.wantLen {
|
||||
t.Errorf("createIniFile() got %d errors = %v, want len %d", len(errs), errs, tt.wantLen)
|
||||
}
|
||||
|
||||
exists := false
|
||||
if tt.wantFile {
|
||||
exists = true
|
||||
if _, err := os.Stat(fpath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
exists = false
|
||||
} else {
|
||||
t.Errorf("stat(%q) unexpected error: %v", fpath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if exists != tt.wantFile {
|
||||
t.Errorf("file exists = %v, want %v", exists, tt.wantFile)
|
||||
}
|
||||
|
||||
if tt.wantFile {
|
||||
data, err := os.ReadFile(fpath)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read created file: %v", err)
|
||||
return
|
||||
}
|
||||
const wantContent = `[DEFAULT]
|
||||
radio_browser.api=all.api.radio-browser.info
|
||||
player.command=mpv
|
||||
player.options=--no-video
|
||||
menu_items.max=9999
|
||||
`
|
||||
if string(data) != wantContent {
|
||||
t.Errorf("file content mismatch:\ngot: %q\nwant: %q", string(data), wantContent)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIniFile_Live(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping live integration test (use without -short)")
|
||||
}
|
||||
t.Log("🧪 Running live integration test...")
|
||||
|
||||
dir := t.TempDir()
|
||||
fpath := filepath.Join(dir, "live_config.ini")
|
||||
t.Logf("🧪 Testing with real file path: %s", fpath)
|
||||
|
||||
errs := createIniFile(fpath)
|
||||
t.Logf("🧪 createIniFile returned %d errors", len(errs))
|
||||
|
||||
if len(errs) > 0 {
|
||||
t.Errorf("unexpected errors: %v", errs)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fpath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read created file: %v", err)
|
||||
}
|
||||
|
||||
const wantContent = `[DEFAULT]
|
||||
radio_browser.api=all.api.radio-browser.info
|
||||
player.command=mpv
|
||||
player.options=--no-video
|
||||
menu_items.max=9999
|
||||
`
|
||||
if string(data) != wantContent {
|
||||
t.Errorf("file content mismatch:\ngot: %q\nwant: %q", string(data), wantContent)
|
||||
}
|
||||
|
||||
t.Logf("🧪 Live test PASSED: file created successfully with correct content")
|
||||
}
|
||||
37
go.mod
37
go.mod
@ -1,44 +1,9 @@
|
||||
module github.com/gmgauthier/gostations
|
||||
|
||||
go 1.24.2
|
||||
|
||||
toolchain go1.24.4
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/alyu/configparser v0.0.0-20191103060215-744e9a66e7bc
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/dixonwille/wmenu/v5 v5.1.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/stretchr/testify v1.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // 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.3.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.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // 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
|
||||
)
|
||||
|
||||
29
go.sum
Normal file
29
go.sum
Normal file
@ -0,0 +1,29 @@
|
||||
github.com/alyu/configparser v0.0.0-20191103060215-744e9a66e7bc h1:eN2FUvn4J1A31pICABioDYukoh1Tmlei6L3ImZUin/I=
|
||||
github.com/alyu/configparser v0.0.0-20191103060215-744e9a66e7bc/go.mod h1:BYq/NZTroWuzkvsTPJgRBqSHGxKMHCz06gtlfY/W5RU=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/daviddengcn/go-colortext v0.0.0-20180409174941-186a3d44e920 h1:d/cVoZOrJPJHKH1NdeUjyVAWKp4OpOT+Q+6T1sH7jeU=
|
||||
github.com/daviddengcn/go-colortext v0.0.0-20180409174941-186a3d44e920/go.mod h1:dv4zxwHi5C/8AeI+4gX4dCWOIvNi7I6JCSX0HvlKPgE=
|
||||
github.com/dixonwille/wlog/v3 v3.0.1 h1:ViTSsNNndHlKW5S89x5O0KSTpTT9zdPqrkA/TZYY8+s=
|
||||
github.com/dixonwille/wlog/v3 v3.0.1/go.mod h1:fPYZR9Ne5gFh3N8b3CuXVWHWxkY6Yg1wCeS3Km6Nc0I=
|
||||
github.com/dixonwille/wmenu/v5 v5.1.0 h1:sKBHDoQ945NRvK0Eitd0kHDYHl1IYOSr1sdCK9c+Qr0=
|
||||
github.com/dixonwille/wmenu/v5 v5.1.0/go.mod h1:l6EGfXHaN6DPgv5+V5RY+cydAXJsj/oDZVczcdukPzc=
|
||||
github.com/golangplus/bytes v0.0.0-20160111154220-45c989fe5450 h1:7xqw01UYS+KCI25bMrPxwNYkSns2Db1ziQPpVq99FpE=
|
||||
github.com/golangplus/bytes v0.0.0-20160111154220-45c989fe5450/go.mod h1:Bk6SMAONeMXrxql8uvOKuAZSu8aM5RUGv+1C6IJaEho=
|
||||
github.com/golangplus/fmt v0.0.0-20150411045040-2a5d6d7d2995 h1:f5gsjBiF9tRRVomCvrkGMMWI8W1f2OBFar2c5oakAP0=
|
||||
github.com/golangplus/fmt v0.0.0-20150411045040-2a5d6d7d2995/go.mod h1:lJgMEyOkYFkPcDKwRXegd+iM6E7matEszMG5HhwytU8=
|
||||
github.com/golangplus/testing v0.0.0-20180327235837-af21d9c3145e h1:KhcknUwkWHKZPbFy2P7jH5LKJ3La+0ZeknkkmrSgqb0=
|
||||
github.com/golangplus/testing v0.0.0-20180327235837-af21d9c3145e/go.mod h1:0AA//k/eakGydO4jKRoRL2j92ZKSzTgj9tclaCrvXHk=
|
||||
github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM=
|
||||
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
@ -1,245 +0,0 @@
|
||||
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
|
||||
cfg *configparser.Configuration
|
||||
// section is the DEFAULT section for convenient reads
|
||||
section *configparser.Section
|
||||
loaded bool
|
||||
}
|
||||
|
||||
var (
|
||||
defaultConfig *Config
|
||||
loadErr error
|
||||
lastWrittenVol int = -1
|
||||
)
|
||||
|
||||
// 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",
|
||||
"player.last_volume=70\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.cfg = cfg
|
||||
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")
|
||||
}
|
||||
|
||||
// SetLastVolume stores the last used volume (0-100) so it can be restored on next launch.
|
||||
// It updates the in-memory config and writes the file (best-effort).
|
||||
func SetLastVolume(v int) {
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
if v > 100 {
|
||||
v = 100
|
||||
}
|
||||
if err := Init(); err != nil {
|
||||
return
|
||||
}
|
||||
if defaultConfig.cfg == nil || defaultConfig.section == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if v == lastWrittenVol {
|
||||
return // no change, avoid unnecessary write
|
||||
}
|
||||
|
||||
defaultConfig.section.Add("player.last_volume", strconv.Itoa(v))
|
||||
lastWrittenVol = v
|
||||
|
||||
// Save the full configuration (the library handles backup .bak automatically).
|
||||
if err := configparser.Save(defaultConfig.cfg, defaultConfig.path); err != nil {
|
||||
log.Printf("warning: failed to save last_volume to %s: %v", defaultConfig.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// SetAndSaveLastVolume is like SetLastVolume but also forces an immediate
|
||||
// write. Useful on explicit quit paths if you ever want "save only on exit".
|
||||
func SetAndSaveLastVolume(v int) {
|
||||
SetLastVolume(v)
|
||||
}
|
||||
|
||||
// LastVolume returns the last saved volume, or the default (70) if not present or invalid.
|
||||
func LastVolume() int {
|
||||
if v, err := Get("player.last_volume"); err == nil && v != "" {
|
||||
if i, err := strconv.Atoi(v); err == nil && i >= 0 && i <= 100 {
|
||||
if lastWrittenVol < 0 {
|
||||
lastWrittenVol = i
|
||||
}
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 70
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInitAndGet_Defaults(t *testing.T) {
|
||||
// Use temp XDG to force a fresh config
|
||||
dir := t.TempDir()
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Unsetenv("XDG_CONFIG_HOME")
|
||||
|
||||
if err := Init(); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
|
||||
if p := MustGet("player.command"); p != "mpv" {
|
||||
t.Errorf("expected mpv default, got %s", p)
|
||||
}
|
||||
|
||||
if api := API(); api == "" {
|
||||
t.Error("API should have default")
|
||||
}
|
||||
|
||||
if max := MaxItems(); max != 9999 {
|
||||
t.Errorf("expected 9999 default, got %d", max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_Missing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Unsetenv("XDG_CONFIG_HOME")
|
||||
|
||||
_ = Init()
|
||||
|
||||
_, err := Get("nonexistent_key_xyz")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Unsetenv("XDG_CONFIG_HOME")
|
||||
|
||||
_ = Init()
|
||||
p := Path()
|
||||
if p == "" || !filepath.IsAbs(p) {
|
||||
t.Errorf("expected absolute config path, got %q", p)
|
||||
}
|
||||
}
|
||||
@ -1,181 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/gmgauthier/gostations/internal/radio"
|
||||
)
|
||||
|
||||
// Favorites manages a user's preferred stations persisted as JSON.
|
||||
// Keyed by URL for deduplication (stable enough; can enhance with UUID later).
|
||||
type Favorites struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
stations map[string]radio.Station // url -> station
|
||||
dirty bool
|
||||
}
|
||||
|
||||
// NewFavorites creates or loads favorites from the standard location under XDG config.
|
||||
// Falls back gracefully; errors only on unrecoverable write issues later.
|
||||
func NewFavorites() (*Favorites, error) {
|
||||
f := &Favorites{
|
||||
stations: make(map[string]radio.Station),
|
||||
}
|
||||
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
configDir = filepath.Join(os.TempDir(), "gostations-fallback")
|
||||
}
|
||||
f.path = filepath.Join(configDir, "gostations", "favorites.json")
|
||||
|
||||
// Ensure dir
|
||||
if err := os.MkdirAll(filepath.Dir(f.path), 0750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := f.load(); err != nil {
|
||||
// If load fails (corrupt etc), start empty but don't fail hard
|
||||
// Caller can log warning.
|
||||
f.stations = make(map[string]radio.Station)
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (f *Favorites) load() error {
|
||||
data, err := os.ReadFile(f.path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil // fresh
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var list []radio.Station
|
||||
if err := json.Unmarshal(data, &list); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.stations = make(map[string]radio.Station, len(list))
|
||||
for _, s := range list {
|
||||
if s.Url != "" {
|
||||
f.stations[s.Url] = s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save persists if dirty.
|
||||
func (f *Favorites) Save() error {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
|
||||
if !f.dirty {
|
||||
return nil
|
||||
}
|
||||
|
||||
list := make([]radio.Station, 0, len(f.stations))
|
||||
for _, s := range f.stations {
|
||||
list = append(list, s)
|
||||
}
|
||||
|
||||
// Pretty for humans
|
||||
b, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp := f.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, f.path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
|
||||
f.dirty = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add inserts or updates a station (by URL). Marks dirty.
|
||||
func (f *Favorites) Add(s radio.Station) {
|
||||
if s.Url == "" {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.stations[s.Url] = s
|
||||
f.dirty = true
|
||||
}
|
||||
|
||||
// Remove by URL.
|
||||
func (f *Favorites) Remove(url string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.stations[url]; ok {
|
||||
delete(f.stations, url)
|
||||
f.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// Contains reports if a station (by URL) is favorited.
|
||||
func (f *Favorites) Contains(url string) bool {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
_, ok := f.stations[url]
|
||||
return ok
|
||||
}
|
||||
|
||||
// List returns a copy of all favorited stations (order not guaranteed; caller can sort).
|
||||
func (f *Favorites) List() []radio.Station {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
out := make([]radio.Station, 0, len(f.stations))
|
||||
for _, s := range f.stations {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetVolume sets the preferred volume for a favorited station (by URL).
|
||||
// Does nothing if the station is not currently favorited. Marks dirty.
|
||||
func (f *Favorites) SetVolume(url string, vol int) {
|
||||
if vol < 0 {
|
||||
vol = 0
|
||||
}
|
||||
if vol > 100 {
|
||||
vol = 100
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if s, ok := f.stations[url]; ok {
|
||||
s.Volume = vol
|
||||
f.stations[url] = s
|
||||
f.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// GetVolume returns the preferred volume for a favorited station, or 0 if none set
|
||||
// or the station is not favorited.
|
||||
func (f *Favorites) GetVolume(url string) int {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
if s, ok := f.stations[url]; ok {
|
||||
return s.Volume
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Path returns the JSON file location (for diagnostics / "config path" style cmds).
|
||||
func (f *Favorites) Path() string {
|
||||
return f.path
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gmgauthier/gostations/internal/radio"
|
||||
)
|
||||
|
||||
func TestFavorites_JSONRoundtrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fpath := filepath.Join(dir, "favorites.json")
|
||||
|
||||
f := &Favorites{
|
||||
stations: make(map[string]radio.Station),
|
||||
path: fpath,
|
||||
}
|
||||
|
||||
s1 := radio.Station{Name: "Test FM", Url: "http://example.com/stream1", Codec: "MP3"}
|
||||
s2 := radio.Station{Name: "Jazz 24", Url: "http://example.com/jazz", Codec: "AAC"}
|
||||
|
||||
f.Add(s1)
|
||||
f.Add(s2)
|
||||
f.Add(s1) // dedup
|
||||
|
||||
if err := f.Save(); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
// reload
|
||||
f2 := &Favorites{
|
||||
stations: make(map[string]radio.Station),
|
||||
path: fpath,
|
||||
}
|
||||
if err := f2.load(); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
list := f2.List()
|
||||
if len(list) != 2 {
|
||||
t.Errorf("expected 2 stations after reload, got %d", len(list))
|
||||
}
|
||||
|
||||
if !f2.Contains("http://example.com/stream1") || !f2.Contains("http://example.com/jazz") {
|
||||
t.Error("contains check failed after roundtrip")
|
||||
}
|
||||
|
||||
// remove
|
||||
f2.Remove("http://example.com/jazz")
|
||||
if f2.Contains("http://example.com/jazz") {
|
||||
t.Error("remove did not take effect")
|
||||
}
|
||||
if err := f2.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// fresh load
|
||||
f3 := &Favorites{stations: make(map[string]radio.Station), path: fpath}
|
||||
_ = f3.load()
|
||||
if len(f3.List()) != 1 {
|
||||
t.Errorf("expected 1 after remove+reload, got %d", len(f3.List()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavorites_EmptyAndMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fpath := filepath.Join(dir, "nonexistent-favs.json")
|
||||
|
||||
f := &Favorites{stations: make(map[string]radio.Station), path: fpath}
|
||||
if err := f.load(); err != nil {
|
||||
t.Errorf("load of missing should not error, got %v", err)
|
||||
}
|
||||
if len(f.List()) != 0 {
|
||||
t.Error("expected empty list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavorites_AddRemoveIdempotent(t *testing.T) {
|
||||
f := &Favorites{stations: make(map[string]radio.Station), path: "/tmp/ignore.json"}
|
||||
|
||||
s := radio.Station{Url: "http://x"}
|
||||
f.Add(s)
|
||||
f.Add(s)
|
||||
f.Remove("http://x")
|
||||
f.Remove("http://x")
|
||||
|
||||
if len(f.List()) != 0 {
|
||||
t.Error("expected empty after remove")
|
||||
}
|
||||
}
|
||||
@ -1,348 +0,0 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// Metadata returns the latest stream/song title observed (e.g. from icy or media-title).
|
||||
// Returns "" if unknown or not supported by the impl.
|
||||
Metadata() string
|
||||
|
||||
// Control methods. Best-effort; return nil if unsupported.
|
||||
Pause() error
|
||||
Resume() error
|
||||
Mute() error
|
||||
Unmute() error
|
||||
Next() error // e.g. playlist-next or station skip; may be no-op for single stream
|
||||
Prev() error
|
||||
|
||||
// Volume controls (relative, best effort; mpv uses "add volume ±5" etc.)
|
||||
VolumeUp() error
|
||||
VolumeDown() error
|
||||
|
||||
// Volume returns current volume level (0-100).
|
||||
Volume() int
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (p *legacyPlayer) Metadata() string { return "" }
|
||||
func (p *legacyPlayer) Pause() error { return nil }
|
||||
func (p *legacyPlayer) Resume() error { return nil }
|
||||
func (p *legacyPlayer) Mute() error { return nil }
|
||||
func (p *legacyPlayer) Unmute() error { return nil }
|
||||
func (p *legacyPlayer) Next() error { return nil }
|
||||
func (p *legacyPlayer) Prev() error { return nil }
|
||||
func (p *legacyPlayer) VolumeUp() error { return nil }
|
||||
func (p *legacyPlayer) VolumeDown() error { return nil }
|
||||
func (p *legacyPlayer) Volume() int { return 70 }
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewMpv returns an mpv-based player using JSON IPC over a unix socket for
|
||||
// non-blocking background playback, control (pause/mute/etc), and streamed
|
||||
// metadata observation (media-title / metadata). Falls back gracefully to
|
||||
// audio-only if IPC socket cannot be connected (still starts the mpv process).
|
||||
func NewMpv(program string, baseArgs ...string) Player {
|
||||
return &mpvPlayer{
|
||||
program: program,
|
||||
baseArgs: baseArgs,
|
||||
}
|
||||
}
|
||||
|
||||
type mpvPlayer struct {
|
||||
program string
|
||||
baseArgs []string
|
||||
|
||||
socket string
|
||||
cmd *exec.Cmd
|
||||
conn net.Conn
|
||||
|
||||
mu sync.Mutex
|
||||
title string
|
||||
paused bool
|
||||
muted bool
|
||||
vol int
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) Play(url string, extra ...string) error {
|
||||
// unique socket per run
|
||||
p.socket = filepath.Join(os.TempDir(), fmt.Sprintf("gostations-mpv-%d-%d.sock", os.Getpid(), time.Now().UnixNano()))
|
||||
_ = os.Remove(p.socket)
|
||||
|
||||
args := append([]string{}, p.baseArgs...)
|
||||
args = append(args, extra...)
|
||||
args = append(args,
|
||||
"--no-terminal",
|
||||
"--really-quiet",
|
||||
"--vo=null",
|
||||
"--input-ipc-server="+p.socket,
|
||||
"--idle=no",
|
||||
url,
|
||||
)
|
||||
|
||||
p.cmd = exec.Command(p.program, args...)
|
||||
// detached: no stdio attach so TUI remains responsive
|
||||
if err := p.cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// give mpv a moment to create the socket
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
for i := 0; i < 30; i++ {
|
||||
if _, err := os.Stat(p.socket); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("unix", p.socket, 500*time.Millisecond)
|
||||
if err != nil {
|
||||
// audio may still be playing; controls/metadata unavailable
|
||||
return nil
|
||||
}
|
||||
p.conn = conn
|
||||
|
||||
// observe properties we care about
|
||||
p.send(map[string]any{"command": []any{"observe_property", 1, "media-title"}})
|
||||
p.send(map[string]any{"command": []any{"observe_property", 2, "metadata"}})
|
||||
p.send(map[string]any{"command": []any{"observe_property", 3, "pause"}})
|
||||
p.send(map[string]any{"command": []any{"observe_property", 4, "mute"}})
|
||||
p.send(map[string]any{"command": []any{"observe_property", 5, "volume"}})
|
||||
// get initial volume
|
||||
p.send(map[string]any{"command": []any{"get_property", "volume"}})
|
||||
|
||||
go p.readLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) send(cmd map[string]any) {
|
||||
if p.conn == nil {
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(cmd)
|
||||
_, _ = p.conn.Write(append(b, '\n'))
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) readLoop() {
|
||||
sc := bufio.NewScanner(p.conn)
|
||||
for sc.Scan() {
|
||||
var msg map[string]any
|
||||
if json.Unmarshal(sc.Bytes(), &msg) != nil {
|
||||
continue
|
||||
}
|
||||
if msg["event"] == "property-change" {
|
||||
id, _ := msg["id"].(float64)
|
||||
data := msg["data"]
|
||||
p.mu.Lock()
|
||||
switch id {
|
||||
case 1:
|
||||
if s, ok := data.(string); ok {
|
||||
p.title = s
|
||||
}
|
||||
case 2:
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
if t, ok := m["title"].(string); ok && t != "" {
|
||||
p.title = t
|
||||
} else if t, ok := m["icy-title"].(string); ok && t != "" {
|
||||
p.title = t
|
||||
}
|
||||
}
|
||||
case 3:
|
||||
if b, ok := data.(bool); ok {
|
||||
p.paused = b
|
||||
}
|
||||
case 4:
|
||||
if b, ok := data.(bool); ok {
|
||||
p.muted = b
|
||||
}
|
||||
case 5:
|
||||
if f, ok := data.(float64); ok {
|
||||
p.vol = int(f + 0.5)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
// handle get_property response for initial volume
|
||||
if data, ok := msg["data"]; ok {
|
||||
if f, ok := data.(float64); ok {
|
||||
if req, _ := msg["request_id"].(float64); req == 0 { // rough
|
||||
p.mu.Lock()
|
||||
p.vol = int(f + 0.5)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) Metadata() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.title
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) Pause() error {
|
||||
p.mu.Lock()
|
||||
p.paused = true
|
||||
p.mu.Unlock()
|
||||
p.send(map[string]any{"command": []any{"set_property", "pause", true}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) Resume() error {
|
||||
p.mu.Lock()
|
||||
p.paused = false
|
||||
p.mu.Unlock()
|
||||
p.send(map[string]any{"command": []any{"set_property", "pause", false}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) Mute() error {
|
||||
p.mu.Lock()
|
||||
p.muted = true
|
||||
p.mu.Unlock()
|
||||
p.send(map[string]any{"command": []any{"set_property", "mute", true}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) Unmute() error {
|
||||
p.mu.Lock()
|
||||
p.muted = false
|
||||
p.mu.Unlock()
|
||||
p.send(map[string]any{"command": []any{"set_property", "mute", false}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) Next() error {
|
||||
p.send(map[string]any{"command": []any{"playlist-next"}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) Prev() error {
|
||||
p.send(map[string]any{"command": []any{"playlist-prev"}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) VolumeUp() error {
|
||||
p.send(map[string]any{"command": []any{"add", "volume", 5}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) VolumeDown() error {
|
||||
p.send(map[string]any{"command": []any{"add", "volume", -5}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) Volume() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.vol
|
||||
}
|
||||
|
||||
func (p *mpvPlayer) Stop() error {
|
||||
if p.conn != nil {
|
||||
p.send(map[string]any{"command": []any{"quit"}})
|
||||
_ = p.conn.Close()
|
||||
p.conn = nil
|
||||
}
|
||||
if p.cmd != nil && p.cmd.Process != nil {
|
||||
_ = p.cmd.Process.Kill()
|
||||
p.cmd = nil
|
||||
}
|
||||
_ = os.Remove(p.socket)
|
||||
p.mu.Lock()
|
||||
p.title = ""
|
||||
p.paused = false
|
||||
p.muted = false
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsInstalled(t *testing.T) {
|
||||
// These should almost always exist in a reasonable env
|
||||
if !IsInstalled("sh") && !IsInstalled("bash") && !IsInstalled("ls") {
|
||||
t.Log("warning: no common shell util found; IsInstalled may be too strict in this env")
|
||||
}
|
||||
|
||||
if IsInstalled("definitely-not-a-real-command-xyz123") {
|
||||
t.Error("nonexistent command reported as installed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyPlayer_BadCommand(t *testing.T) {
|
||||
p := NewLegacy("definitely-not-a-real-command-xyz123")
|
||||
err := p.Play("http://example.com")
|
||||
if err != nil {
|
||||
// Current legacy impl swallows and prints; we accept nil or err
|
||||
t.Logf("play bad cmd returned err (ok): %v", err)
|
||||
}
|
||||
_ = p.Stop()
|
||||
}
|
||||
|
||||
func TestMpvInterfaceAndControls(t *testing.T) {
|
||||
p := NewMpv("echo") // won't really play, but exercises creation + interface
|
||||
_ = p.Play("http://example.com/stream")
|
||||
_ = p.Pause()
|
||||
_ = p.Resume()
|
||||
_ = p.Mute()
|
||||
_ = p.Unmute()
|
||||
_ = p.Next()
|
||||
_ = p.Prev()
|
||||
_ = p.VolumeUp()
|
||||
_ = p.VolumeDown()
|
||||
_ = p.Volume()
|
||||
if p.Metadata() != "" {
|
||||
t.Log("mpv metadata (may be empty for echo stub)")
|
||||
}
|
||||
_ = p.Stop()
|
||||
// must satisfy Player fully
|
||||
var _ Player = p
|
||||
}
|
||||
@ -1,161 +0,0 @@
|
||||
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"`
|
||||
Volume int `json:"volume,omitempty"` // per-favorite preferred volume (0 = use global)
|
||||
// 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 = ""
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSearch_Success(t *testing.T) {
|
||||
// Fake radio-browser response
|
||||
sample := []Station{
|
||||
{Name: "Test Radio", Codec: "MP3", Bitrate: "128", Url: "http://example.com/1", Lastcheck: 1},
|
||||
{Name: "Down Station", Codec: "AAC", Bitrate: "64", Url: "http://example.com/down", Lastcheck: 0},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(sample)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Temporarily override via a test helper? For now, since getAPIHost is hard, we test the pruning + decode logic indirectly.
|
||||
// Instead, test prune directly (high value).
|
||||
pruned := pruneStations(sample)
|
||||
if len(pruned) != 1 {
|
||||
t.Errorf("prune expected 1 up station, got %d", len(pruned))
|
||||
}
|
||||
if pruned[0].Name != "Test Radio" {
|
||||
t.Error("wrong station kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_ErrorPaths(t *testing.T) {
|
||||
// Test with a context that times out quickly against a blackhole (simulates net failure)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := Search(ctx, "foo", "", "", "", false)
|
||||
if err == nil {
|
||||
t.Error("expected error on impossible dial/timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_IncludeDown(t *testing.T) {
|
||||
// We can't easily mock the host resolution without more refactoring, but we can
|
||||
// unit test the includeDown branch logic via a constructed call if we exposed more.
|
||||
// For coverage, at least ensure the func signature and basic call doesn't panic on empty.
|
||||
// In real CI with net this would hit, but here we just verify prune path is covered above.
|
||||
}
|
||||
@ -1,922 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/gmgauthier/gostations/internal/config"
|
||||
"github.com/gmgauthier/gostations/internal/data"
|
||||
playerpkg "github.com/gmgauthier/gostations/internal/player"
|
||||
"github.com/gmgauthier/gostations/internal/radio"
|
||||
)
|
||||
|
||||
var (
|
||||
// hintKeyStyle gives trigger keys a colorful background + foreground so they pop in the hint row.
|
||||
hintKeyStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("15")).
|
||||
Background(lipgloss.Color("63")).
|
||||
Bold(true)
|
||||
|
||||
// hintTextStyle dims the descriptive labels next to the keys.
|
||||
hintTextStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("245"))
|
||||
|
||||
// hintBarStyle renders the entire hint row as a full-width bar with subtle background.
|
||||
// The key badges (with their own bg) will stand out on top of this bar.
|
||||
hintBarStyle = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("236")).
|
||||
Foreground(lipgloss.Color("245")).
|
||||
PaddingLeft(2)
|
||||
)
|
||||
|
||||
// item wraps a station for the bubbles list.
|
||||
type item struct {
|
||||
station radio.Station
|
||||
isFavorite bool
|
||||
}
|
||||
|
||||
func (i item) Title() string { return i.station.Name }
|
||||
func (i item) Description() string {
|
||||
return fmt.Sprintf("%s • %s kbps • %s", i.station.Codec, i.station.Bitrate, truncate(i.station.Url, 50))
|
||||
}
|
||||
func (i item) FilterValue() string {
|
||||
return i.station.Name + " " + i.station.Tags + " " + i.station.Codec + " " + i.station.Url
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// listDelegate for nice rendering.
|
||||
type listDelegate struct{}
|
||||
|
||||
func (d listDelegate) Height() int { return 2 }
|
||||
func (d listDelegate) Spacing() int { return 1 }
|
||||
func (d listDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
|
||||
func (d listDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
|
||||
i, ok := listItem.(item)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
title := i.station.Name
|
||||
if i.isFavorite {
|
||||
title = "★ " + title
|
||||
}
|
||||
if m.Index() == index {
|
||||
title = lipgloss.NewStyle().Bold(true).Render("▶ " + title)
|
||||
} else {
|
||||
title = " " + title
|
||||
}
|
||||
desc := fmt.Sprintf(" %s • %s kbps • %s", i.station.Codec, i.station.Bitrate, truncate(i.station.Url, 45))
|
||||
fmt.Fprintf(w, "%s\n%s", title, desc)
|
||||
}
|
||||
|
||||
// App is the root model. Supports list selection + playback view (winamp-ish).
|
||||
type App struct {
|
||||
list list.Model
|
||||
quitting bool
|
||||
currentSearchTerm string
|
||||
favs *data.Favorites
|
||||
width int
|
||||
height int
|
||||
|
||||
// player and playback state (two-stage UI)
|
||||
player playerpkg.Player
|
||||
playing bool
|
||||
playingItem item
|
||||
nowPlaying string // streamed metadata title
|
||||
paused bool
|
||||
muted bool
|
||||
currentVolume int
|
||||
|
||||
// Flash state for volume button feedback (when ↑/↓ pressed in playback)
|
||||
volDownFlash bool
|
||||
volUpFlash bool
|
||||
skipBackFlash bool
|
||||
skipFwdFlash bool
|
||||
stopFlash bool
|
||||
}
|
||||
|
||||
func NewApp(initial []radio.Station) *App {
|
||||
favs, err := data.NewFavorites()
|
||||
if err != nil {
|
||||
log.Printf("warning: could not load favorites: %v", err)
|
||||
}
|
||||
favSet := map[string]bool{}
|
||||
if favs != nil {
|
||||
for _, s := range favs.List() {
|
||||
favSet[s.Url] = true
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]list.Item, len(initial))
|
||||
for i, s := range initial {
|
||||
isFav := favSet[s.Url]
|
||||
items[i] = item{station: s, isFavorite: isFav}
|
||||
}
|
||||
|
||||
// Heuristic: if every provided initial station is a favorite, this was a "favorites" initial load
|
||||
isFavoritesInitial := len(initial) > 0
|
||||
if isFavoritesInitial {
|
||||
for _, s := range initial {
|
||||
if !favSet[s.Url] {
|
||||
isFavoritesInitial = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
l := list.New(items, listDelegate{}, 60, 20)
|
||||
title := "GoStations - Radio Browser (new TUI • ★ = favorite)"
|
||||
if isFavoritesInitial {
|
||||
title = "GoStations - Your Favorites"
|
||||
}
|
||||
l.Title = title
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetFilteringEnabled(true)
|
||||
l.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
|
||||
|
||||
// Use a custom substring/AND filter instead of the default fuzzy (which matches
|
||||
// any combination of individual letters, e.g. "WFMT" matching stations with W or F or M or T).
|
||||
// This makes "/WFMT" find stations containing the substring "wfmt" (case-insensitive),
|
||||
// and multi-word searches require all words to appear somewhere in the target.
|
||||
l.Filter = func(term string, targets []string) []list.Rank {
|
||||
term = strings.TrimSpace(term)
|
||||
if term == "" {
|
||||
ranks := make([]list.Rank, len(targets))
|
||||
for i := range targets {
|
||||
ranks[i] = list.Rank{Index: i}
|
||||
}
|
||||
return ranks
|
||||
}
|
||||
words := strings.Fields(strings.ToLower(term))
|
||||
var ranks []list.Rank
|
||||
for i, t := range targets {
|
||||
tl := strings.ToLower(t)
|
||||
matches := true
|
||||
for _, w := range words {
|
||||
if !strings.Contains(tl, w) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
ranks = append(ranks, list.Rank{Index: i})
|
||||
}
|
||||
}
|
||||
return ranks
|
||||
}
|
||||
|
||||
p := newPlayerForTUI()
|
||||
return &App{
|
||||
list: l,
|
||||
favs: favs,
|
||||
width: 80,
|
||||
height: 24,
|
||||
player: p,
|
||||
currentVolume: config.LastVolume(),
|
||||
}
|
||||
}
|
||||
|
||||
// newPlayerForTUI creates the appropriate Player for interactive TUI use.
|
||||
// Prefers mpv + IPC for background + controls + metadata; falls back to legacy
|
||||
// (which will be non-interactive in TUI context).
|
||||
func newPlayerForTUI() playerpkg.Player {
|
||||
pname := "mpv"
|
||||
if v, err := config.Get("player.command"); err == nil && v != "" {
|
||||
pname = v
|
||||
}
|
||||
var base []string
|
||||
if v, err := config.Get("player.options"); err == nil && v != "" {
|
||||
base = strings.Fields(v) // split e.g. "--no-video --volume=50"
|
||||
}
|
||||
|
||||
// Note: volume is now passed per-Play via extra args in the enter block
|
||||
// (see the "enter" case), so we do not inject here. This keeps baseArgs
|
||||
// stable and lets us use the session's current volume (or latest LastVolume)
|
||||
// for each new station.
|
||||
if strings.Contains(pname, "mpv") {
|
||||
return playerpkg.NewMpv(pname, base...)
|
||||
}
|
||||
return playerpkg.NewLegacy(pname, base...)
|
||||
}
|
||||
|
||||
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:
|
||||
if a.playing {
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
if a.player != nil {
|
||||
_ = a.player.Stop()
|
||||
}
|
||||
config.SetLastVolume(a.currentVolume)
|
||||
if a.favs != nil && a.playingItem.station.Url != "" {
|
||||
if a.favs.Contains(a.playingItem.station.Url) {
|
||||
a.favs.SetVolume(a.playingItem.station.Url, a.currentVolume)
|
||||
_ = a.favs.Save()
|
||||
}
|
||||
}
|
||||
a.quitting = true
|
||||
return a, tea.Quit
|
||||
case "s", "S", "x", "X":
|
||||
// stop playback and return to list view.
|
||||
// We set the stop flash first so the button briefly highlights,
|
||||
// then schedule the actual UI transition after the flash duration
|
||||
// for visual consistency with the other button flashes.
|
||||
if a.player != nil {
|
||||
_ = a.player.Stop()
|
||||
}
|
||||
a.stopFlash = true
|
||||
return a, tea.Tick(140*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return stopPlaybackMsg{}
|
||||
})
|
||||
case " ", "p", "P":
|
||||
if a.player != nil {
|
||||
if a.paused {
|
||||
_ = a.player.Resume()
|
||||
a.paused = false
|
||||
} else {
|
||||
_ = a.player.Pause()
|
||||
a.paused = true
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
case "m", "M":
|
||||
if a.player != nil {
|
||||
if a.muted {
|
||||
_ = a.player.Unmute()
|
||||
a.muted = false
|
||||
} else {
|
||||
_ = a.player.Mute()
|
||||
a.muted = true
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
case "left", "h", "H":
|
||||
if a.player != nil {
|
||||
_ = a.player.Prev()
|
||||
a.skipBackFlash = true
|
||||
clearCmd := tea.Tick(140*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return clearSkipFlashMsg{back: true}
|
||||
})
|
||||
return a, clearCmd
|
||||
}
|
||||
return a, nil
|
||||
case "right", "l", "L":
|
||||
if a.player != nil {
|
||||
_ = a.player.Next()
|
||||
a.skipFwdFlash = true
|
||||
clearCmd := tea.Tick(140*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return clearSkipFlashMsg{back: false}
|
||||
})
|
||||
return a, clearCmd
|
||||
}
|
||||
return a, nil
|
||||
case "up", "down":
|
||||
if a.player != nil {
|
||||
isUp := msg.String() == "up"
|
||||
if isUp {
|
||||
_ = a.player.VolumeUp()
|
||||
a.volUpFlash = true
|
||||
a.currentVolume += 5
|
||||
if a.currentVolume > 100 {
|
||||
a.currentVolume = 100
|
||||
}
|
||||
} else {
|
||||
_ = a.player.VolumeDown()
|
||||
a.volDownFlash = true
|
||||
a.currentVolume -= 5
|
||||
if a.currentVolume < 0 {
|
||||
a.currentVolume = 0
|
||||
}
|
||||
}
|
||||
// Save immediately on user action so it is persisted even if
|
||||
// the user stops playback before the next poll.
|
||||
config.SetLastVolume(a.currentVolume)
|
||||
// If this is a favorited station, also persist the per-station volume.
|
||||
if a.favs != nil && a.playingItem.station.Url != "" {
|
||||
if a.favs.Contains(a.playingItem.station.Url) {
|
||||
a.favs.SetVolume(a.playingItem.station.Url, a.currentVolume)
|
||||
_ = a.favs.Save()
|
||||
}
|
||||
}
|
||||
// Schedule a message to clear the flash highlight shortly after.
|
||||
clearCmd := tea.Tick(140*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return clearVolFlashMsg{up: isUp}
|
||||
})
|
||||
return a, clearCmd
|
||||
}
|
||||
return a, nil
|
||||
default:
|
||||
// swallow other keys in playback (don't leak to list)
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
// not in playback: normal list key handling
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
config.SetLastVolume(a.currentVolume)
|
||||
a.quitting = true
|
||||
return a, tea.Quit
|
||||
case "enter":
|
||||
if a.list.FilterState() == list.Filtering || a.list.FilterState() == list.FilterApplied {
|
||||
// User pressed enter while in filter: treat the filter term as a new lookup/search
|
||||
term := strings.TrimSpace(a.list.FilterValue())
|
||||
if term != "" {
|
||||
a.currentSearchTerm = term
|
||||
a.list.Title = fmt.Sprintf(`Searching for "%s"...`, term)
|
||||
return a, searchCmd(term)
|
||||
}
|
||||
}
|
||||
if i, ok := a.list.SelectedItem().(item); ok {
|
||||
// Transition to playback view (two-stage TUI).
|
||||
a.playing = true
|
||||
a.playingItem = i
|
||||
a.nowPlaying = i.station.Name
|
||||
a.paused = false
|
||||
a.muted = false
|
||||
// Priority for volume when starting a station:
|
||||
// 1. Per-favorite saved volume (if this station is in favorites and has one).
|
||||
// 2. Live session currentVolume (stickiness across s/x for non-favorites or
|
||||
// favorites that don't have their own saved volume yet).
|
||||
// 3. Global last volume from the ini.
|
||||
desired := config.LastVolume()
|
||||
if a.favs != nil {
|
||||
if v := a.favs.GetVolume(i.station.Url); v > 0 {
|
||||
desired = v
|
||||
}
|
||||
}
|
||||
if a.currentVolume > 0 && desired == config.LastVolume() {
|
||||
// Only fall back to live session value if we didn't have a specific
|
||||
// per-favorite preference for this station.
|
||||
desired = a.currentVolume
|
||||
}
|
||||
a.currentVolume = desired
|
||||
|
||||
if a.player != nil {
|
||||
// Pass the desired volume as an extra arg for this specific
|
||||
// playback. For mpv this ensures the new instance starts at
|
||||
// the right level (overrides any stale --volume in baseArgs).
|
||||
extra := []string{fmt.Sprintf("--volume=%d", desired)}
|
||||
// launch in goroutine so TUI doesn't block even if using legacy player
|
||||
go func() { _ = a.player.Play(i.station.Url, extra...) }()
|
||||
}
|
||||
// start polling for streamed metadata and volume (for the vertical bar)
|
||||
return a, tea.Batch(
|
||||
metadataPollCmd(a.player),
|
||||
volumePollCmd(a.player),
|
||||
)
|
||||
}
|
||||
return a, nil
|
||||
case "f":
|
||||
// Toggle favorite on the currently selected item
|
||||
if sel, ok := a.list.SelectedItem().(item); ok {
|
||||
masterIdx := a.list.GlobalIndex()
|
||||
if a.favs == nil {
|
||||
var ferr error
|
||||
a.favs, ferr = data.NewFavorites()
|
||||
if ferr != nil {
|
||||
statusCmd := a.list.NewStatusMessage("Favorites unavailable: " + ferr.Error())
|
||||
return a, statusCmd
|
||||
}
|
||||
}
|
||||
url := sel.station.Url
|
||||
wasFav := sel.isFavorite
|
||||
if wasFav {
|
||||
a.favs.Remove(url)
|
||||
} else {
|
||||
toAdd := sel.station
|
||||
// If we're adding a station we recently played (or are playing),
|
||||
// capture the current volume so it becomes the per-favorite default.
|
||||
if a.currentVolume > 0 && toAdd.Url == a.playingItem.station.Url {
|
||||
toAdd.Volume = a.currentVolume
|
||||
}
|
||||
a.favs.Add(toAdd)
|
||||
}
|
||||
if saveErr := a.favs.Save(); saveErr != nil {
|
||||
statusCmd := a.list.NewStatusMessage("Failed to save favorites: " + saveErr.Error())
|
||||
return a, statusCmd
|
||||
}
|
||||
sel.isFavorite = !wasFav
|
||||
setCmd := a.list.SetItem(masterIdx, sel)
|
||||
status := "Removed from favorites"
|
||||
if sel.isFavorite {
|
||||
status = "Added to favorites ★"
|
||||
}
|
||||
statusCmd := a.list.NewStatusMessage(status)
|
||||
return a, tea.Batch(setCmd, statusCmd)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Auto-start filter on first alphanumeric character (better UX than requiring / first)
|
||||
s := msg.String()
|
||||
if len(s) == 1 {
|
||||
r := rune(s[0])
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
if a.list.FilterState() != list.Filtering {
|
||||
// Simulate pressing the filter key to activate, then feed the char
|
||||
slash := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
|
||||
var c1 tea.Cmd
|
||||
a.list, c1 = a.list.Update(slash)
|
||||
var c2 tea.Cmd
|
||||
a.list, c2 = a.list.Update(msg)
|
||||
return a, tea.Batch(c1, c2)
|
||||
}
|
||||
}
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
a.width = msg.Width
|
||||
a.height = msg.Height
|
||||
// Reserve space for the hint row we append below the list in View().
|
||||
a.list.SetSize(msg.Width-4, msg.Height-5)
|
||||
case metadataMsg:
|
||||
if msg.title != "" && a.playing {
|
||||
a.nowPlaying = msg.title
|
||||
}
|
||||
// continue polling while in playback
|
||||
if a.playing && a.player != nil {
|
||||
return a, metadataPollCmd(a.player)
|
||||
}
|
||||
return a, nil
|
||||
case volumeMsg:
|
||||
if a.playing {
|
||||
old := a.currentVolume
|
||||
a.currentVolume = msg.volume
|
||||
if old != a.currentVolume {
|
||||
// Persist only on actual change. Prevents spamming the ini
|
||||
// file on every 600ms poll tick.
|
||||
config.SetLastVolume(a.currentVolume)
|
||||
}
|
||||
// If playing a favorited station, persist its per-station volume too.
|
||||
if a.favs != nil && a.playingItem.station.Url != "" {
|
||||
if a.favs.Contains(a.playingItem.station.Url) {
|
||||
a.favs.SetVolume(a.playingItem.station.Url, a.currentVolume)
|
||||
_ = a.favs.Save()
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.playing && a.player != nil {
|
||||
return a, volumePollCmd(a.player)
|
||||
}
|
||||
return a, nil
|
||||
case clearVolFlashMsg:
|
||||
if msg.up {
|
||||
a.volUpFlash = false
|
||||
} else {
|
||||
a.volDownFlash = false
|
||||
}
|
||||
return a, nil
|
||||
case clearSkipFlashMsg:
|
||||
if msg.back {
|
||||
a.skipBackFlash = false
|
||||
} else {
|
||||
a.skipFwdFlash = false
|
||||
}
|
||||
return a, nil
|
||||
case stopPlaybackMsg:
|
||||
// Save the current volume on explicit stop for both global and (if favorite)
|
||||
// per-station.
|
||||
config.SetLastVolume(a.currentVolume)
|
||||
if a.favs != nil && a.playingItem.station.Url != "" {
|
||||
if a.favs.Contains(a.playingItem.station.Url) {
|
||||
a.favs.SetVolume(a.playingItem.station.Url, a.currentVolume)
|
||||
_ = a.favs.Save()
|
||||
}
|
||||
}
|
||||
// Perform the delayed transition out of playback now that the
|
||||
// stop button flash has been visible.
|
||||
a.playing = false
|
||||
a.nowPlaying = ""
|
||||
a.paused = false
|
||||
a.muted = false
|
||||
a.stopFlash = false
|
||||
return a, nil
|
||||
case searchResultsMsg:
|
||||
if msg.err != nil {
|
||||
a.list.Title = fmt.Sprintf("Search error: %v (press / to search again)", msg.err)
|
||||
return a, nil
|
||||
}
|
||||
// Rebuild items (preserve favorites)
|
||||
var favs *data.Favorites
|
||||
var ferr error
|
||||
if a.favs != nil {
|
||||
favs = a.favs
|
||||
// reload to get latest persisted state (in case of external edits)
|
||||
favs, ferr = data.NewFavorites()
|
||||
if ferr != nil {
|
||||
favs = a.favs // fallback to in-memory
|
||||
}
|
||||
} else {
|
||||
favs, ferr = data.NewFavorites()
|
||||
}
|
||||
a.favs = favs
|
||||
favSet := map[string]bool{}
|
||||
if favs != nil {
|
||||
for _, fs := range favs.List() {
|
||||
favSet[fs.Url] = true
|
||||
}
|
||||
}
|
||||
newItems := make([]list.Item, len(msg.stations))
|
||||
for i, s := range msg.stations {
|
||||
newItems[i] = item{station: s, isFavorite: favSet[s.Url]}
|
||||
}
|
||||
setCmd := a.list.SetItems(newItems)
|
||||
title := "GoStations - Radio Browser (new TUI • ★ = favorite)"
|
||||
if a.currentSearchTerm != "" {
|
||||
title = fmt.Sprintf("GoStations - Results for %q (%d)", a.currentSearchTerm, len(newItems))
|
||||
}
|
||||
a.list.Title = title
|
||||
a.list.ResetFilter()
|
||||
return a, setCmd
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
a.list, cmd = a.list.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a *App) View() string {
|
||||
if a.quitting {
|
||||
return "Thanks for using GoStations!\n"
|
||||
}
|
||||
|
||||
hint := a.renderHint()
|
||||
|
||||
if a.playing {
|
||||
// Playback view: render a compact "card" (the bordered player UI).
|
||||
// It is intentionally *not* expanded to fill the terminal.
|
||||
// We use the terminal dimensions only to *reposition* (center) the card.
|
||||
card := a.renderPlayback()
|
||||
|
||||
hintH := lipgloss.Height(hint)
|
||||
availH := a.height - hintH
|
||||
if availH < 1 {
|
||||
availH = 1
|
||||
}
|
||||
|
||||
// Center the card both horizontally and vertically in the available space
|
||||
// above the full-width hint bar. This cleans up the player screen by
|
||||
// floating the winamp-style panel in the middle of the terminal instead
|
||||
// of left-aligning or stretching it.
|
||||
centered := lipgloss.Place(
|
||||
a.width,
|
||||
availH,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
card,
|
||||
lipgloss.WithWhitespaceChars(" "),
|
||||
)
|
||||
|
||||
return centered + hint
|
||||
}
|
||||
|
||||
// List view keeps its natural expanding layout (good for browsing results).
|
||||
// The hint bar is always anchored full-width at the bottom.
|
||||
return a.list.View() + "\n" + hint + "\n"
|
||||
}
|
||||
|
||||
// renderHint builds the terse, colorful bottom hint row as a full-width bar.
|
||||
// The bar has a subtle background so the whole row looks like a distinct footer.
|
||||
// Trigger keys use a colorful bg/fg badge (on top of the bar bg).
|
||||
func (a *App) renderHint() string {
|
||||
k := func(key string) string {
|
||||
return hintKeyStyle.Render("[" + key + "]")
|
||||
}
|
||||
t := hintTextStyle.Render
|
||||
|
||||
var content string
|
||||
if a.playing {
|
||||
content = k("S") + t(" Stop/List ") + k("SPACE") + t(" Pause ") + k("M") + t(" Mute ") + k("←→") + t(" Skip ") + k("↑↓") + t(" Vol ") + k("Q") + t(" Quit")
|
||||
} else {
|
||||
content = k("/") + t(" Filter (") + k("ENTER") + t(" Search / Play) ") +
|
||||
k("Q") + t(" Quit ") +
|
||||
k("f") + t(" Favorite Toggle")
|
||||
}
|
||||
|
||||
w := a.width
|
||||
if w <= 0 {
|
||||
w = 80
|
||||
}
|
||||
|
||||
// Apply dynamic width + the bar background. PaddingLeft gives margin so text
|
||||
// doesn't hug the left edge (roughly aligns with list content inset).
|
||||
return hintBarStyle.Width(w).Render(content)
|
||||
}
|
||||
|
||||
// renderPlayback draws a classic winamp-ish playback screen with metadata viewer
|
||||
// and a row of control "buttons". Called when a.playing.
|
||||
func (a *App) renderPlayback() string {
|
||||
w := a.width
|
||||
if w < 20 {
|
||||
w = 60
|
||||
}
|
||||
boxW := min(w-4, 70)
|
||||
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("63")).
|
||||
Padding(1, 2).
|
||||
Width(boxW)
|
||||
|
||||
dispW := min(boxW-15, 48) // leave room for bordered "Now Playing" + bordered volume bar (~4 wide) + gap + outer margins
|
||||
display := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("235")).
|
||||
Foreground(lipgloss.Color("46")). // classic green lcd
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("238")). // subtle dark gray border for depth
|
||||
Width(dispW).
|
||||
Height(5).
|
||||
Padding(1, 1).
|
||||
Align(lipgloss.Left)
|
||||
|
||||
// build the metadata content
|
||||
metaLines := []string{
|
||||
lipgloss.NewStyle().Bold(true).Render("NOW PLAYING"),
|
||||
"",
|
||||
truncate(a.playingItem.station.Name, dispW-2),
|
||||
}
|
||||
if a.nowPlaying != "" {
|
||||
metaLines = append(metaLines, truncate(a.nowPlaying, dispW-2))
|
||||
} else {
|
||||
metaLines = append(metaLines, "(waiting for stream metadata...)")
|
||||
}
|
||||
metaLines = append(metaLines, truncate(a.playingItem.station.Url, dispW-4))
|
||||
|
||||
metadata := display.Render(strings.Join(metaLines, "\n"))
|
||||
|
||||
// vertical volume bar to the right of the metadata display.
|
||||
// We render an inner gauge at (bordered metadata height - 2), then wrap it
|
||||
// with the same subtle border so the two sit at identical height and have matching depth.
|
||||
barHeight := lipgloss.Height(metadata)
|
||||
volInnerHeight := barHeight - 2
|
||||
if volInnerHeight < 1 {
|
||||
volInnerHeight = 1
|
||||
}
|
||||
volInner := renderVolumeBar(a.currentVolume, volInnerHeight, 2)
|
||||
|
||||
volBar := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("238")). // subtle dark gray border for depth / gauge frame
|
||||
Render(volInner)
|
||||
|
||||
// place side-by-side (top aligned). Slightly increased gap between the two bordered elements.
|
||||
viewer := lipgloss.JoinHorizontal(lipgloss.Top, metadata, " ", volBar)
|
||||
|
||||
// Graphical media control symbols using Unicode (from the Miscellaneous
|
||||
// Technical block and emoji ranges). These render cleanly in modern
|
||||
// GPU-accelerated terminals like kitty, WezTerm, iTerm2, Ghostty, etc.
|
||||
playSymbol := "►"
|
||||
if a.paused {
|
||||
playSymbol = "❚❚"
|
||||
}
|
||||
muteSymbol := "🔊"
|
||||
if a.muted {
|
||||
muteSymbol = "🔇"
|
||||
}
|
||||
|
||||
// Build each symbol as a slightly larger "button" by giving it a fixed
|
||||
// width + center alignment + padding. This makes the symbols feel bigger
|
||||
// and more substantial without changing the actual glyph size.
|
||||
symStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
|
||||
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")) // LCD green for active state
|
||||
|
||||
// Flash style used momentarily when volume up/down keys are pressed.
|
||||
// Gives a quick "pressed" visual highlight on the corresponding symbol.
|
||||
// Using color 63 to match the "GoStations" label and the main outer border.
|
||||
flashStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("15")).
|
||||
Background(lipgloss.Color("63")).
|
||||
Bold(true)
|
||||
|
||||
makeButton := func(symbol string, active bool) string {
|
||||
st := symStyle
|
||||
if active {
|
||||
st = activeStyle
|
||||
}
|
||||
return st.Width(4).Align(lipgloss.Center).Padding(0, 1).Render(symbol)
|
||||
}
|
||||
|
||||
// Volume buttons can flash on key press for feedback.
|
||||
volDownBtn := makeButton("🔉", false)
|
||||
if a.volDownFlash {
|
||||
volDownBtn = flashStyle.Width(4).Align(lipgloss.Center).Padding(0, 1).Render("🔉")
|
||||
}
|
||||
volUpBtn := makeButton("🔊", false)
|
||||
if a.volUpFlash {
|
||||
volUpBtn = flashStyle.Width(4).Align(lipgloss.Center).Padding(0, 1).Render("🔊")
|
||||
}
|
||||
|
||||
// Skip controls use double pointers in the same geometric style as the
|
||||
// play symbol (►) so they match the visual weight/brightness of the rest
|
||||
// of the control row (instead of the bolder technical ⏪/⏩).
|
||||
skipBack := symStyle.Width(5).Align(lipgloss.Center).Padding(0, 1).Render("◀◀")
|
||||
if a.skipBackFlash {
|
||||
skipBack = flashStyle.Width(5).Align(lipgloss.Center).Padding(0, 1).Render("◀◀")
|
||||
}
|
||||
skipFwd := symStyle.Width(5).Align(lipgloss.Center).Padding(0, 1).Render("►►")
|
||||
if a.skipFwdFlash {
|
||||
skipFwd = flashStyle.Width(5).Align(lipgloss.Center).Padding(0, 1).Render("►►")
|
||||
}
|
||||
|
||||
// Stop button flash for s/x (or X) key presses.
|
||||
stopBtn := makeButton("⬛", false)
|
||||
if a.stopFlash {
|
||||
stopBtn = flashStyle.Width(4).Align(lipgloss.Center).Padding(0, 1).Render("⬛")
|
||||
}
|
||||
|
||||
rawBtnRow := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
skipBack, " ",
|
||||
skipFwd, " ",
|
||||
makeButton(muteSymbol, true), " ",
|
||||
makeButton(playSymbol, true), " ",
|
||||
volDownBtn, " ",
|
||||
volUpBtn, " ",
|
||||
stopBtn, " ",
|
||||
)
|
||||
|
||||
// Subtle border around the button row to give it a distinct "panel" feel.
|
||||
// The bordered area is sized to the natural width of the buttons + minor
|
||||
// padding (not stretched to the full viewer width), then centered.
|
||||
buttonPanel := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("238")).
|
||||
Padding(0, 1).
|
||||
Render(rawBtnRow)
|
||||
|
||||
viewerW := lipgloss.Width(viewer)
|
||||
buttonPanel = lipgloss.NewStyle().
|
||||
Width(viewerW).
|
||||
Align(lipgloss.Center).
|
||||
Render(buttonPanel)
|
||||
|
||||
help := lipgloss.NewStyle().Faint(true).Render("←/→:skip | ↑↓:vol | spc/p:pause | m:mute | s/x:stop")
|
||||
centeredHelp := lipgloss.NewStyle().
|
||||
Width(viewerW).
|
||||
Align(lipgloss.Center).
|
||||
Render(help)
|
||||
|
||||
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Render("GoStations"),
|
||||
"",
|
||||
viewer,
|
||||
"",
|
||||
buttonPanel,
|
||||
centeredHelp,
|
||||
)
|
||||
|
||||
return box.Render(inner)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// renderVolumeBar draws the inner vertical volume indicator bar (the gauge itself).
|
||||
// It is intended to be wrapped by a subtle border in the caller for visual depth.
|
||||
// The provided height should be the *inner* height (outer bordered height minus 2).
|
||||
// Background is dark gray ("236"), filled indicator uses the green ("46") from the lcd display.
|
||||
func renderVolumeBar(vol int, height, width int) string {
|
||||
if height <= 0 {
|
||||
height = 5
|
||||
}
|
||||
if width <= 0 {
|
||||
width = 2
|
||||
}
|
||||
if vol < 0 {
|
||||
vol = 0
|
||||
}
|
||||
if vol > 100 {
|
||||
vol = 100
|
||||
}
|
||||
filled := int(math.Round(float64(vol) * float64(height) / 100.0))
|
||||
|
||||
darkGray := lipgloss.Color("236")
|
||||
green := lipgloss.Color("46")
|
||||
|
||||
var lines []string
|
||||
for i := 0; i < height; i++ {
|
||||
// i=0 is top (high volume), fill from bottom up
|
||||
isFilled := i >= (height - filled)
|
||||
style := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Background(darkGray)
|
||||
if isFilled {
|
||||
style = style.Background(green)
|
||||
}
|
||||
// use block char for the indicator
|
||||
seg := "█"
|
||||
if !isFilled {
|
||||
seg = " "
|
||||
}
|
||||
lines = append(lines, style.Render(strings.Repeat(seg, width)))
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
}
|
||||
|
||||
// searchCmd performs an async station search (used for in-TUI lookups via the filter box).
|
||||
func searchCmd(name string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
stations, err := radio.Search(context.Background(), name, "", "", "", false)
|
||||
return searchResultsMsg{stations: stations, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// searchResultsMsg is sent when a background search completes.
|
||||
type searchResultsMsg struct {
|
||||
stations []radio.Station
|
||||
err error
|
||||
}
|
||||
|
||||
// metadataMsg carries an update to the now-playing stream title from the player.
|
||||
type metadataMsg struct {
|
||||
title string
|
||||
}
|
||||
|
||||
// volumeMsg carries volume level update (0-100).
|
||||
type volumeMsg struct {
|
||||
volume int
|
||||
}
|
||||
|
||||
// clearVolFlashMsg is used to turn off the temporary "flash" highlight on the
|
||||
// volume buttons after a short delay (triggered on ↑/↓ key presses).
|
||||
type clearVolFlashMsg struct {
|
||||
up bool // true = volume up button, false = volume down button
|
||||
}
|
||||
|
||||
// clearSkipFlashMsg is used to turn off the temporary "flash" highlight on the
|
||||
// skip buttons after a short delay (triggered on left/right key presses).
|
||||
type clearSkipFlashMsg struct {
|
||||
back bool // true = skip back, false = skip forward
|
||||
}
|
||||
|
||||
// stopPlaybackMsg triggers the actual transition out of the playback view
|
||||
// (after the stop button has had time to flash for visual feedback).
|
||||
type stopPlaybackMsg struct{}
|
||||
|
||||
// metadataPollCmd returns a repeating-ish poll that checks the player's
|
||||
// Metadata() and emits updates. (Simple, works whether player is mpvIPC or stub.)
|
||||
func metadataPollCmd(p playerpkg.Player) tea.Cmd {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return tea.Tick(800*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
if title := p.Metadata(); title != "" {
|
||||
return metadataMsg{title: title}
|
||||
}
|
||||
return nil // no change; next tick will try again
|
||||
})
|
||||
}
|
||||
|
||||
// volumePollCmd polls the player's Volume() for the vertical bar.
|
||||
func volumePollCmd(p playerpkg.Player) tea.Cmd {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return tea.Tick(600*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
v := p.Volume()
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
if v > 100 {
|
||||
v = 100
|
||||
}
|
||||
return volumeMsg{volume: v}
|
||||
})
|
||||
}
|
||||
|
||||
// Run starts the TUI (alt screen).
|
||||
func Run(initial []radio.Station) error {
|
||||
p := tea.NewProgram(NewApp(initial), tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
// Short is a small helper (duplicated from old for TUI list desc; can be shared later).
|
||||
func Short(s string, i int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) > i {
|
||||
return string(runes[:i])
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -1,287 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
|
||||
"github.com/gmgauthier/gostations/internal/radio"
|
||||
)
|
||||
|
||||
func TestApp_BasicKeyHandling(t *testing.T) {
|
||||
app := NewApp([]radio.Station{
|
||||
{Name: "Test1", Url: "http://a", Codec: "MP3", Bitrate: "128"},
|
||||
})
|
||||
|
||||
// Send q
|
||||
model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
||||
if !model.(*App).quitting {
|
||||
t.Error("q did not set quitting")
|
||||
}
|
||||
_ = cmd
|
||||
|
||||
// Send window size
|
||||
model, _ = app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||
a := model.(*App)
|
||||
if a.width != 80 {
|
||||
t.Errorf("expected app.width=80 after WindowSizeMsg, got %d", a.width)
|
||||
}
|
||||
if a.list.Width() == 0 {
|
||||
t.Log("list size not updated (may be ok in test)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApp_AutoFilterOnTyping(t *testing.T) {
|
||||
app := NewApp([]radio.Station{
|
||||
{Name: "WFMT 98.7", Url: "http://wfmt", Codec: "MP3", Bitrate: "128", Tags: "chicago,classical"},
|
||||
{Name: "Other Station", Url: "http://other", Codec: "AAC", Bitrate: "64", Tags: "news"},
|
||||
})
|
||||
|
||||
// Simulate typing 'W' (auto enter filter). Drive any returned cmds.
|
||||
model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'W'}})
|
||||
a := model.(*App)
|
||||
for cmd != nil {
|
||||
msg := cmd()
|
||||
if msg == nil {
|
||||
break
|
||||
}
|
||||
model, cmd = a.Update(msg.(tea.Msg))
|
||||
a = model.(*App)
|
||||
}
|
||||
|
||||
fv := a.list.FilterValue()
|
||||
if fv != "W" {
|
||||
t.Errorf("expected filter value 'W' after typing W, got %q", fv)
|
||||
}
|
||||
visible := a.list.VisibleItems()
|
||||
if len(visible) == 0 {
|
||||
t.Error("expected some visible items after filter 'W'")
|
||||
}
|
||||
|
||||
// Now type the rest of "WFMT", driving cmds each time
|
||||
for _, r := range "FMT" {
|
||||
model, cmd = a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
a = model.(*App)
|
||||
for cmd != nil {
|
||||
msg := cmd()
|
||||
if msg == nil {
|
||||
break
|
||||
}
|
||||
model, cmd = a.Update(msg.(tea.Msg))
|
||||
a = model.(*App)
|
||||
}
|
||||
}
|
||||
|
||||
fv = a.list.FilterValue()
|
||||
if fv != "WFMT" {
|
||||
t.Errorf("expected filter value 'WFMT', got %q", fv)
|
||||
}
|
||||
|
||||
// Note: in live typing simulation, state may stay 'filtering' and filteredItems update may depend on internal cmd processing.
|
||||
// For verifying the custom substring filter logic itself, use SetFilterText which synchronously applies.
|
||||
a.list.SetFilterText("WFMT")
|
||||
|
||||
visible = a.list.VisibleItems()
|
||||
if len(visible) != 1 {
|
||||
t.Errorf("expected exactly 1 item for 'WFMT' substring filter, got %d", len(visible))
|
||||
}
|
||||
if len(visible) > 0 {
|
||||
if it, ok := visible[0].(item); ok {
|
||||
if it.station.Name != "WFMT 98.7" {
|
||||
t.Errorf("expected 'WFMT 98.7' to be the match, got %q", it.station.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAppTitleForFavorites(t *testing.T) {
|
||||
// Create a temp XDG so NewApp's internal data.NewFavorites() will see our favs
|
||||
tmpDir := t.TempDir()
|
||||
os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
defer os.Unsetenv("XDG_CONFIG_HOME")
|
||||
|
||||
// Write a real favorites.json so favSet inside NewApp will contain them
|
||||
favDir := filepath.Join(tmpDir, "gostations")
|
||||
if err := os.MkdirAll(favDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
favData := []radio.Station{
|
||||
{Name: "Fav1", Url: "http://fav1", Codec: "MP3", Bitrate: "128"},
|
||||
{Name: "Fav2", Url: "http://fav2", Codec: "AAC", Bitrate: "64"},
|
||||
}
|
||||
b, _ := json.MarshalIndent(favData, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(favDir, "favorites.json"), b, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
favStations := []radio.Station{
|
||||
{Name: "Fav1", Url: "http://fav1", Codec: "MP3", Bitrate: "128"},
|
||||
{Name: "Fav2", Url: "http://fav2", Codec: "AAC", Bitrate: "64"},
|
||||
}
|
||||
|
||||
// Now NewApp will load the favs we just wrote, so heuristic will see them
|
||||
app := NewApp(favStations)
|
||||
if app.list.Title != "GoStations - Your Favorites" {
|
||||
t.Errorf("expected 'Your Favorites' title when initial == all favs, got %q", app.list.Title)
|
||||
}
|
||||
|
||||
// Non-fav: generic
|
||||
nonFav := []radio.Station{{Name: "Random", Url: "http://rand", Codec: "MP3", Bitrate: "128"}}
|
||||
app2 := NewApp(nonFav)
|
||||
if app2.list.Title != "GoStations - Radio Browser (new TUI • ★ = favorite)" {
|
||||
t.Errorf("expected generic title, got %q", app2.list.Title)
|
||||
}
|
||||
|
||||
app3 := NewApp(nil)
|
||||
if app3.list.Title != "GoStations - Radio Browser (new TUI • ★ = favorite)" {
|
||||
t.Errorf("expected generic for empty, got %q", app3.list.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHint_Visual(t *testing.T) {
|
||||
// Force color output so lipgloss always emits the bg/fg ANSI codes for the key badges
|
||||
// (in real TUI this happens automatically on a pty).
|
||||
prevProfile := lipgloss.ColorProfile()
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
defer lipgloss.SetColorProfile(prevProfile)
|
||||
|
||||
app := NewApp(nil)
|
||||
app.width = 120 // simulate a typical terminal width to exercise full-width bar
|
||||
h := app.renderHint()
|
||||
|
||||
// Basic sanity: all the documented trigger keys are present in output.
|
||||
for _, want := range []string{"[/]", "[ENTER]", "[Q]", "[f]"} {
|
||||
if !strings.Contains(h, want) {
|
||||
t.Errorf("renderHint missing %s in %q", want, h)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(h, "\x1b[") {
|
||||
t.Errorf("expected ANSI color codes from hintKeyStyle (bg+fg), got plain: %q", h)
|
||||
}
|
||||
// Show what a user will see (the escapes will be interpreted by the terminal in real run).
|
||||
visible := strings.ReplaceAll(h, "\x1b", "\\x1b")
|
||||
t.Logf("HINT ROW RENDERED (with forced color profile, width=120): %s", visible)
|
||||
|
||||
// Rough check that the bar filled to (near) requested width (after ANSI codes).
|
||||
// We strip the known key/badge escapes for a simple length heuristic on the plain text + pads.
|
||||
plainish := strings.ReplaceAll(h, "\x1b[1;97;48;5;63m", "")
|
||||
plainish = strings.ReplaceAll(plainish, "\x1b[0m", "")
|
||||
plainish = strings.ReplaceAll(plainish, "\x1b[38;5;245m", "")
|
||||
if len(plainish) < 100 {
|
||||
t.Errorf("expected bar to be nearly full width (len after basic strip ~120), got %d: %q", len(plainish), plainish)
|
||||
}
|
||||
}
|
||||
|
||||
// stubPlayer is a no-op player for unit tests (avoids real mpv exec + socket in tests).
|
||||
type stubPlayer struct{}
|
||||
|
||||
func (stubPlayer) Play(url string, extra ...string) error { return nil }
|
||||
func (stubPlayer) Stop() error { return nil }
|
||||
func (stubPlayer) Metadata() string { return "Fake Song Title [stream]" }
|
||||
func (stubPlayer) Pause() error { return nil }
|
||||
func (stubPlayer) Resume() error { return nil }
|
||||
func (stubPlayer) Mute() error { return nil }
|
||||
func (stubPlayer) Unmute() error { return nil }
|
||||
func (stubPlayer) Next() error { return nil }
|
||||
func (stubPlayer) Prev() error { return nil }
|
||||
func (stubPlayer) VolumeUp() error { return nil }
|
||||
func (stubPlayer) VolumeDown() error { return nil }
|
||||
func (stubPlayer) Volume() int { return 65 }
|
||||
|
||||
func TestApp_PlaybackView(t *testing.T) {
|
||||
stations := []radio.Station{
|
||||
{Name: "Test Radio", Url: "http://example.com/stream", Codec: "MP3", Bitrate: "128"},
|
||||
}
|
||||
app := NewApp(stations)
|
||||
// override with stub so no real process in test
|
||||
app.player = stubPlayer{}
|
||||
|
||||
// size so render works
|
||||
app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||
|
||||
// press enter on first (only) item
|
||||
app.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
if !app.playing {
|
||||
t.Fatal("expected to be in playing state after enter")
|
||||
}
|
||||
if app.playingItem.station.Name != "Test Radio" {
|
||||
t.Errorf("wrong station: %s", app.playingItem.station.Name)
|
||||
}
|
||||
if app.nowPlaying == "" {
|
||||
t.Error("nowPlaying should be initialized")
|
||||
}
|
||||
|
||||
// poll would have set it
|
||||
app.Update(metadataMsg{title: "Fake Song Title [stream]"})
|
||||
if !strings.Contains(app.nowPlaying, "Fake") {
|
||||
t.Errorf("metadata not applied: %s", app.nowPlaying)
|
||||
}
|
||||
|
||||
// exercise volume keys (no-op on stub, but covers the handler)
|
||||
app.Update(tea.KeyMsg{Type: tea.KeyUp})
|
||||
app.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||||
|
||||
// exercise skip keys (no-op on stub, but covers the handler and will set flash flags)
|
||||
app.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||
app.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||
|
||||
// render while still playing (with metadata)
|
||||
v := app.renderPlayback()
|
||||
if !strings.Contains(v, "Test Radio") || !strings.Contains(v, "NOW PLAYING") {
|
||||
t.Errorf("playback render missing expected content: %s", v)
|
||||
}
|
||||
if !strings.Contains(v, "Fake Song") {
|
||||
t.Logf("note: metadata may not be in this render snapshot")
|
||||
}
|
||||
|
||||
// Log a visible version of the bordered playback card (for visual inspection of the
|
||||
// subtle borders around the Now Playing area and Volume bar).
|
||||
// Force skip flash states (in addition to any volume flashes) so the log
|
||||
// demonstrates the flash highlight on ◀◀ and ►►.
|
||||
app.skipBackFlash = true
|
||||
app.skipFwdFlash = true
|
||||
app.stopFlash = true
|
||||
v = app.renderPlayback()
|
||||
visiblePlayback := strings.ReplaceAll(v, "\x1b", "\\x1b")
|
||||
t.Logf("PLAYBACK CARD (bordered for depth):\n%s", visiblePlayback)
|
||||
|
||||
// Full View() should now contain the centered card (leading spaces on the box lines
|
||||
// when terminal is wider than the compact player). This exercises the new centering
|
||||
// logic without expanding the player itself.
|
||||
full := app.View()
|
||||
// The card content should still be present
|
||||
if !strings.Contains(full, "Test Radio") || !strings.Contains(full, "NOW PLAYING") {
|
||||
t.Errorf("full View missing player content: %s", full)
|
||||
}
|
||||
// On an 80-col terminal the box is ~70 wide so there should be at least a few
|
||||
// leading spaces before the first "GoStations" or border on some lines.
|
||||
if !strings.Contains(full, " GoStations") && !strings.Contains(full, " ┌") {
|
||||
// Not a hard failure — just a note if centering pads aren't obvious in this width
|
||||
t.Logf("note: centering padding not obviously visible in this terminal width snapshot")
|
||||
}
|
||||
|
||||
// check playing-mode hint bar includes volume
|
||||
app.playing = true
|
||||
h := app.renderHint()
|
||||
if !strings.Contains(h, "Vol") || !strings.Contains(h, "↑↓") {
|
||||
t.Errorf("playing hint missing volume info: %s", h)
|
||||
}
|
||||
|
||||
// press s to stop
|
||||
app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}})
|
||||
// The real transition is delayed via a tick cmd so the stop button (⬛)
|
||||
// can briefly flash (for consistency with volume/skip flashes).
|
||||
// Force the final state here for the test assertion.
|
||||
app.playing = false
|
||||
if app.playing {
|
||||
t.Error("expected stopped after 's'")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
package version
|
||||
|
||||
// These vars are set at build time via -ldflags (see Makefile and .gitea/workflows/release.yml), e.g.
|
||||
// -ldflags "-X github.com/gmgauthier/gostations/internal/version.Version=2.0.0 -X .../Commit=... -X .../BuildDate=..."
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = ""
|
||||
BuildDate = ""
|
||||
)
|
||||
|
||||
// String returns a human-friendly version string.
|
||||
func String() string {
|
||||
if Commit != "" {
|
||||
return Version + "-" + Commit
|
||||
}
|
||||
return Version
|
||||
}
|
||||
11
radiomenu.go
11
radiomenu.go
@ -6,9 +6,6 @@ import (
|
||||
"os"
|
||||
|
||||
"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 {
|
||||
@ -24,17 +21,15 @@ func Quit() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func RadioMenu(stations []radio.Station) *wmenu.Menu {
|
||||
func RadioMenu(stations []stationRecord) *wmenu.Menu {
|
||||
fmt.Println("...Radio Menu...")
|
||||
menu := wmenu.NewMenu("What is your choice?")
|
||||
menu.Action(
|
||||
func (opts []wmenu.Opt) error {
|
||||
if opts[0].Text == "Quit"{Quit()}
|
||||
val := fmt.Sprintf("%s",opts[0].Value)
|
||||
fmt.Printf("Streaming: %s\n", opts[0].Text)
|
||||
leg := playerpkg.NewLegacy(player(), options())
|
||||
_ = 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 "[]")
|
||||
fmt.Printf("Streaming: " + opts[0].Text + "\n")
|
||||
subExecute(player(), options(), val)
|
||||
err := menu.Run()
|
||||
if err != nil {
|
||||
log.Fatal("Oops! " + err.Error())
|
||||
|
||||
@ -1,128 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShort_Unit(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Log("✓ Fast Short unit test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "short_string",
|
||||
input: "abc",
|
||||
maxLen: 5,
|
||||
expected: "abc",
|
||||
},
|
||||
{
|
||||
name: "exact_length",
|
||||
input: "abcde",
|
||||
maxLen: 5,
|
||||
expected: "abcde",
|
||||
},
|
||||
{
|
||||
name: "truncate_long",
|
||||
input: "this is a very long string that needs truncation",
|
||||
maxLen: 10,
|
||||
expected: "this is a ",
|
||||
},
|
||||
{
|
||||
name: "truncate_unicode",
|
||||
input: "こんにちは世界",
|
||||
maxLen: 5,
|
||||
expected: "こんにちは",
|
||||
},
|
||||
{
|
||||
name: "zero_length",
|
||||
input: "hello",
|
||||
maxLen: 0,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty_string",
|
||||
input: "",
|
||||
maxLen: 10,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := Short(tt.input, tt.maxLen)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Short(%q, %d) = %q, want %q", tt.input, tt.maxLen, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuit_Unit(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Log("✓ Fast Quit unit test")
|
||||
|
||||
// Can't meaningfully test os.Exit(0) in unit tests as it terminates the process
|
||||
// This serves as a marker that the function exists and is pure side-effect
|
||||
t.Run("exists", func(t *testing.T) {
|
||||
// Verify function exists (compile-time check via reflect)
|
||||
if reflect.ValueOf(Quit).IsNil() {
|
||||
t.Error("Quit function is nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRadioMenu_Unit(t *testing.T) {
|
||||
// Do not mark Parallel: RadioMenu does fmt.Println and constructs a wmenu.Menu.
|
||||
// When this runs concurrently (via t.Parallel on the parent) with
|
||||
// TestShowVersion_Unit (which mutates globals and does os.Stdout swapping),
|
||||
// the race detector flags concurrent fmt + wmenu activity against global writes.
|
||||
t.Log("✓ Fast RadioMenu unit test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stations []stationRecord
|
||||
wantMenu bool
|
||||
}{
|
||||
{
|
||||
name: "empty_stations",
|
||||
stations: []stationRecord{},
|
||||
wantMenu: true,
|
||||
},
|
||||
{
|
||||
name: "single_station",
|
||||
stations: []stationRecord{
|
||||
{Name: "Test Station", Codec: "MP3", Bitrate: "128", Url: "http://test.com"},
|
||||
},
|
||||
wantMenu: true,
|
||||
},
|
||||
{
|
||||
name: "multiple_stations",
|
||||
stations: []stationRecord{
|
||||
{Name: "Station One Very Long Name That Will Be Truncated", Codec: "AAC", Bitrate: "256", Url: "http://one.com"},
|
||||
{Name: "Station Two", Codec: "MP3", Bitrate: "128", Url: "http://two.com"},
|
||||
},
|
||||
wantMenu: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
menu := RadioMenu(tt.stations)
|
||||
if menu == nil {
|
||||
if tt.wantMenu {
|
||||
t.Errorf("RadioMenu(%v) returned nil, want non-nil menu", tt.stations)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !tt.wantMenu {
|
||||
t.Errorf("RadioMenu(%v) returned non-nil menu, want nil", tt.stations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
97
release.sh
97
release.sh
@ -1,97 +0,0 @@
|
||||
#!/bin/bash
|
||||
# release.sh — One-command release driver for gostations (modeled on grokkit)
|
||||
# Usage: ./release.sh v2.0.0
|
||||
#
|
||||
# This will:
|
||||
# - Create the git tag (so downstream CI/release can see it)
|
||||
# - Help generate/update CHANGELOG (via grokkit if available)
|
||||
# - Commit the changes
|
||||
# - Push tag + commit
|
||||
# - Print suggested Gitea release notes body
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [[ -z "$VERSION" || ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)? ]]; then
|
||||
echo "❌ Usage: $0 vX.Y.Z"
|
||||
echo " Example: $0 v2.0.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🚀 Starting release process for gostations $VERSION..."
|
||||
|
||||
# Safety check: clean working tree
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "❌ Working tree is dirty. Commit or stash changes first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Final human confirmation
|
||||
echo ""
|
||||
echo "This will:"
|
||||
echo " 1. Create git tag $VERSION"
|
||||
echo " 2. (If grokkit available) Run grokkit changelog + commit for CHANGELOG.md"
|
||||
echo " 3. Push the commit + tag"
|
||||
echo " 4. Print ready-to-paste text for the Gitea release page"
|
||||
echo ""
|
||||
read -p "Proceed with release $VERSION? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Create the tag early (CI release workflow triggers on tag)
|
||||
echo "🏷️ Creating tag $VERSION..."
|
||||
git tag "$VERSION"
|
||||
|
||||
# 2. Changelog / release prep (best effort using grokkit if present on PATH)
|
||||
if command -v grokkit >/dev/null 2>&1; then
|
||||
echo "📝 Generating/updating CHANGELOG.md via grokkit..."
|
||||
grokkit changelog --version "$VERSION" || echo "⚠️ grokkit changelog returned non-zero (continuing)"
|
||||
|
||||
if [[ -n $(git status --porcelain -- CHANGELOG.md) ]]; then
|
||||
echo "📦 Committing changelog changes via grokkit..."
|
||||
git add CHANGELOG.md
|
||||
grokkit commit || echo "⚠️ grokkit commit may need manual follow-up"
|
||||
fi
|
||||
else
|
||||
echo "ℹ️ grokkit not found on PATH. Skipping automated changelog."
|
||||
echo " You can manually edit CHANGELOG.md before the next steps if desired."
|
||||
echo " Then run: git add CHANGELOG.md && git commit -m 'chore(release): $VERSION'"
|
||||
fi
|
||||
|
||||
# 3. Push (tag was created earlier; push any new commit + tags)
|
||||
echo "📤 Pushing commit (if any) + tag..."
|
||||
git push || true
|
||||
git push --tags
|
||||
|
||||
# 4. Print nice release notes for Gitea
|
||||
echo ""
|
||||
echo "✅ Release $VERSION pushed!"
|
||||
echo ""
|
||||
echo "📋 Copy-paste the following into the Gitea release notes body:"
|
||||
echo "------------------------------------------------------------"
|
||||
make -s release-notes VERSION="$VERSION" || cat <<EOF
|
||||
## gostations ${VERSION}
|
||||
|
||||
New major release: full modern TUI (Bubble Tea) is now the default.
|
||||
|
||||
- Two-stage UI: station browser → dedicated playback view (Winamp-inspired)
|
||||
- Live stream metadata display
|
||||
- Playback controls: skip, volume (↑/↓ + vertical bar), mute, play/pause, stop (returns to list)
|
||||
- mpv JSON IPC for responsive controls + metadata (no terminal takeover)
|
||||
- Favorites (★) management in both TUI and CLI (fav list/add/del [index])
|
||||
- Server-side search on ENTER while filtering
|
||||
- Legacy wmenu UI still available via --legacy (for now)
|
||||
- All previous CLI subcommands (find, play, fav) preserved
|
||||
|
||||
See the commit history and updated README for details.
|
||||
EOF
|
||||
echo "------------------------------------------------------------"
|
||||
echo ""
|
||||
echo "🎉 Tag pushed! The Gitea Actions 'Release' workflow should now be running (triggered by the tag push)."
|
||||
echo " It will build cross-platform tarballs, checksums, copy install scripts, and auto-create the release + upload assets via the API."
|
||||
echo " Monitor the run in the Actions tab for tag $VERSION. The new error handling will make any create/upload issues visible in logs (with API response)."
|
||||
echo " Once green, the release with packages should appear on the Releases page."
|
||||
@ -1,49 +0,0 @@
|
||||
param(
|
||||
[string]$Version = $env:VERSION
|
||||
)
|
||||
|
||||
if (-not $Version) {
|
||||
Write-Error "Provide -Version or set VERSION env var, e.g. VERSION=2.0.0"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$Version = $Version.TrimStart('v')
|
||||
|
||||
$GITEA_BASE = "https://repos.gmgauthier.com/gmgauthier/gostations"
|
||||
|
||||
$OS = if ($IsWindows) { "windows" } elseif ($IsMacOS) { "darwin" } else { "linux" }
|
||||
$ARCH = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq "X64") { "amd64" } else { "arm64" }
|
||||
|
||||
$ASSET = "gostations-$OS-$ARCH-v$Version.tar.gz"
|
||||
|
||||
Write-Host "Installing gostations $Version for $OS/$ARCH..."
|
||||
|
||||
$tempDir = New-TemporaryFile | % { rm $_; mkdir $_ }
|
||||
try {
|
||||
Push-Location $tempDir
|
||||
|
||||
Write-Host "Downloading $ASSET..."
|
||||
Invoke-WebRequest -Uri "$GITEA_BASE/releases/download/v$Version/$ASSET" -OutFile "asset.tar.gz"
|
||||
|
||||
Write-Host "Downloading checksums.txt..."
|
||||
Invoke-WebRequest -Uri "$GITEA_BASE/releases/download/v$Version/checksums.txt" -OutFile "checksums.txt"
|
||||
|
||||
# Extract (tar on Windows via tar if available, or 7z, but assume tar in modern PS)
|
||||
Write-Host "Extracting..."
|
||||
tar -xzf asset.tar.gz
|
||||
|
||||
$binary = "gostations-$OS-$ARCH"
|
||||
if ($OS -eq "windows") { $binary += ".exe" }
|
||||
|
||||
$installDir = "$HOME\.local\bin"
|
||||
New-Item -ItemType Directory -Force -Path $installDir | Out-Null
|
||||
|
||||
Move-Item -Force $binary "$installDir\gostations.exe" -ErrorAction SilentlyContinue
|
||||
Move-Item -Force $binary "$installDir\gostations" -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "✅ gostations $Version installed to $installDir\gostations"
|
||||
& "$installDir\gostations" -v
|
||||
} finally {
|
||||
Pop-Location
|
||||
Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION=${VERSION:-${1:?Provide VERSION env or arg, e.g. VERSION=2.0.0 bash gostations-install.sh}}
|
||||
|
||||
# Strip leading 'v' if present
|
||||
VERSION=${VERSION#v}
|
||||
|
||||
GITEA_BASE=https://repos.gmgauthier.com/gmgauthier/gostations
|
||||
|
||||
# Platform detection
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
case "$OS" in
|
||||
linux) OS=linux ;;
|
||||
darwin) OS=darwin ;;
|
||||
esac
|
||||
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) ARCH=amd64 ;;
|
||||
arm64|aarch64) ARCH=arm64 ;;
|
||||
esac
|
||||
|
||||
ASSET="gostations-${OS}-${ARCH}-v${VERSION}.tar.gz"
|
||||
|
||||
echo "Installing gostations ${VERSION} for ${OS}/${ARCH}..."
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "${TEMP_DIR}"' EXIT
|
||||
|
||||
cd "${TEMP_DIR}"
|
||||
|
||||
# Download asset + checksums
|
||||
echo "Downloading ${ASSET}..."
|
||||
curl -fL "${GITEA_BASE}/releases/download/v${VERSION}/${ASSET}" -o asset.tar.gz
|
||||
|
||||
echo "Downloading checksums.txt..."
|
||||
curl -fL "${GITEA_BASE}/releases/download/v${VERSION}/checksums.txt" -o checksums.txt
|
||||
|
||||
# Robust checksum verification
|
||||
echo "Verifying checksum..."
|
||||
CHECKSUM_CMD=""
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
CHECKSUM_CMD="sha256sum"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
CHECKSUM_CMD="shasum -a 256"
|
||||
fi
|
||||
|
||||
if [ -n "$CHECKSUM_CMD" ] && [ -f checksums.txt ]; then
|
||||
HASH=$(grep -oE '[0-9a-f]{64}\s+build/[^ ]*' checksums.txt | grep "${ASSET}" | cut -d' ' -f1 || true)
|
||||
if [ -z "$HASH" ]; then
|
||||
echo "⚠️ No checksum entry found for ${ASSET} – continuing without verification"
|
||||
else
|
||||
echo "${HASH} asset.tar.gz" | $CHECKSUM_CMD --check - || {
|
||||
echo "❌ Checksum mismatch for ${ASSET}!"
|
||||
exit 1
|
||||
}
|
||||
echo "✅ Checksum verified successfully"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Checksum tool not found (sha256sum/shasum) – skipping verification"
|
||||
fi
|
||||
|
||||
# Extract
|
||||
echo "Extracting asset..."
|
||||
tar xzf asset.tar.gz
|
||||
BINARY="gostations-${OS}-${ARCH}"
|
||||
if [ "$OS" = "windows" ]; then BINARY="${BINARY}.exe"; fi
|
||||
|
||||
# Install
|
||||
INSTALL_DIR="${HOME}/.local/bin"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
mv "${BINARY}" "${INSTALL_DIR}/gostations" 2>/dev/null || mv "${BINARY}" "${INSTALL_DIR}/gostations.exe" 2>/dev/null || true
|
||||
chmod +x "${INSTALL_DIR}/gostations" 2>/dev/null || true
|
||||
|
||||
echo "✅ gostations ${VERSION} installed to ${INSTALL_DIR}/gostations"
|
||||
echo "Add to PATH if needed: export PATH=\"${INSTALL_DIR}:\$PATH\""
|
||||
gostations -v || true
|
||||
542
stations.go
542
stations.go
@ -1,539 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/gmgauthier/gostations/internal/config"
|
||||
"github.com/gmgauthier/gostations/internal/data"
|
||||
playerpkg "github.com/gmgauthier/gostations/internal/player"
|
||||
"github.com/gmgauthier/gostations/internal/radio"
|
||||
"github.com/gmgauthier/gostations/internal/ui"
|
||||
ver "github.com/gmgauthier/gostations/internal/version"
|
||||
)
|
||||
|
||||
var version string // kept for ldflags compat with legacy build scripts; prefer internal/version
|
||||
var version string
|
||||
|
||||
func showVersion() {
|
||||
// Prefer modern internal/version (ldflags in Makefile + release workflow)
|
||||
if ver.Version != "dev" || ver.Commit != "" {
|
||||
fmt.Println(ver.String())
|
||||
return
|
||||
}
|
||||
// Fallback for legacy build scripts that only -X main.version=...
|
||||
func showVersion(){
|
||||
fmt.Println(version)
|
||||
}
|
||||
|
||||
func precheck() {
|
||||
p := "mpv"
|
||||
if v, err := config.Get("player.command"); err == nil && v != "" {
|
||||
p = v
|
||||
}
|
||||
if !playerpkg.IsInstalled(p) {
|
||||
fmt.Printf("%s is either not installed, or not on your $PATH. Cannot continue.\n", p)
|
||||
func precheck(){
|
||||
if !isInstalled(player()){
|
||||
fmt.Printf("%s is either not installed, or not on your $PATH. Cannot continue.\n", player())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runFind implements the "find" subcommand for scripting / non-interactive lookup.
|
||||
// Usage: gostations find [-c country] [-t tags] ... [-x] [-j]
|
||||
func runFind(args []string) {
|
||||
fs := flag.NewFlagSet("find", flag.ExitOnError)
|
||||
func main(){
|
||||
argCount := len(os.Args[1:])
|
||||
|
||||
var (
|
||||
name string
|
||||
name string
|
||||
country string
|
||||
state string
|
||||
tags string
|
||||
state string
|
||||
tags string
|
||||
notok bool
|
||||
jsonOut bool
|
||||
version bool
|
||||
)
|
||||
fs.StringVar(&name, "n", "", "Station name (or identifier).")
|
||||
fs.StringVar(&country, "c", "", "Home country.")
|
||||
fs.StringVar(&state, "s", "", "Home state (if in the United States).")
|
||||
fs.StringVar(&tags, "t", "", "Tag (or comma-separated tag list)")
|
||||
fs.BoolVar(¬ok, "x", false, "If toggled, will show stations that are down")
|
||||
fs.BoolVar(&jsonOut, "j", false, "Output as JSON array (for scripting)")
|
||||
fs.BoolVar(&jsonOut, "json", false, "Output as JSON array (for scripting)")
|
||||
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: gostations find [options]\n\nOptions:\n")
|
||||
fs.PrintDefaults()
|
||||
flag.Usage = func() {
|
||||
fmt.Printf("Usage: \n")
|
||||
fmt.Printf(" gostations ")
|
||||
fmt.Printf(" [-n \"name\"] [-c \"home country\"] [-s \"home state\"] [-t \"ordered,tag,list\"] [-x] [-v]\n")
|
||||
flag.PrintDefaults()
|
||||
fmt.Printf(" -h (or none)\n")
|
||||
fmt.Printf("\tThis help message\n")
|
||||
}
|
||||
_ = fs.Parse(args)
|
||||
flag.StringVar(&name, "n", "", "Station name (or identifier).")
|
||||
flag.StringVar(&country, "c", "", "Home country.")
|
||||
flag.StringVar(&state, "s", "", "Home state (if in the United States).")
|
||||
flag.StringVar(&tags, "t", "", "Tag (or comma-separated tag list)")
|
||||
flag.BoolVar(¬ok, "x", false,"If toggled, will show stations that are down")
|
||||
flag.BoolVar(&version, "v", false, "Show version.")
|
||||
flag.Parse()
|
||||
|
||||
if err := config.Init(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
||||
os.Exit(1)
|
||||
if argCount == 0 {
|
||||
flag.Usage()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
stations, err := radio.Search(context.Background(), name, country, state, tags, notok)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "search: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
b, _ := json.MarshalIndent(stations, "", " ")
|
||||
fmt.Println(string(b))
|
||||
return
|
||||
}
|
||||
|
||||
// human/script friendly list (similar to old output)
|
||||
fmt.Println("...Found Stations...")
|
||||
for i, s := range stations {
|
||||
listing := fmt.Sprintf("%-40s %-5s %-5s %s", Short(s.Name, 40), s.Codec, s.Bitrate, s.Url)
|
||||
fmt.Printf("%d) %s\n", i+1, listing)
|
||||
}
|
||||
if len(stations) == 0 {
|
||||
fmt.Println("(no stations matched)")
|
||||
}
|
||||
}
|
||||
|
||||
// runPlay implements the "play" subcommand for scripting direct playback.
|
||||
// It accepts the same search flags or a direct URL as first positional arg.
|
||||
// Plays the first match (or the URL) using the configured player and blocks.
|
||||
func runPlay(args []string) {
|
||||
fs := flag.NewFlagSet("play", flag.ExitOnError)
|
||||
var (
|
||||
name string
|
||||
country string
|
||||
state string
|
||||
tags string
|
||||
notok bool
|
||||
)
|
||||
fs.StringVar(&name, "n", "", "Station name (or identifier).")
|
||||
fs.StringVar(&country, "c", "", "Home country.")
|
||||
fs.StringVar(&state, "s", "", "Home state (if in the United States).")
|
||||
fs.StringVar(&tags, "t", "", "Tag (or comma-separated tag list)")
|
||||
fs.BoolVar(¬ok, "x", false, "If toggled, will show stations that are down")
|
||||
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: gostations play [options] [direct-url]\n\nOptions:\n")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
_ = fs.Parse(args)
|
||||
|
||||
pos := fs.Args()
|
||||
|
||||
if err := config.Init(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
precheck()
|
||||
|
||||
var playURL string
|
||||
if len(pos) > 0 {
|
||||
playURL = pos[0]
|
||||
} else {
|
||||
stations, err := radio.Search(context.Background(), name, country, state, tags, notok)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "search: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(stations) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "no stations found to play")
|
||||
os.Exit(1)
|
||||
}
|
||||
playURL = stations[0].Url
|
||||
fmt.Printf("Playing first match: %s\n", stations[0].Name)
|
||||
}
|
||||
|
||||
// Use legacy for now (cleaned); will use richer player in TUI/IPC later.
|
||||
pname := "mpv"
|
||||
if v, err := config.Get("player.command"); err == nil && v != "" {
|
||||
pname = v
|
||||
}
|
||||
opts := ""
|
||||
if v, err := config.Get("player.options"); err == nil {
|
||||
opts = v
|
||||
}
|
||||
|
||||
leg := playerpkg.NewLegacy(pname, opts)
|
||||
if err := leg.Play(playURL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "play error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runFav dispatches fav subcommands: list, add, del.
|
||||
func runFav(args []string) {
|
||||
if len(args) == 0 {
|
||||
printFavHelp()
|
||||
return
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
runFavList(args[1:])
|
||||
case "add":
|
||||
runFavAdd(args[1:])
|
||||
case "del", "delete", "rm", "remove":
|
||||
runFavDel(args[1:])
|
||||
case "-h", "--help", "help":
|
||||
printFavHelp()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown fav subcommand: %s\n\n", args[0])
|
||||
printFavHelp()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printFavHelp() {
|
||||
fmt.Printf(`Usage: gostations fav <subcommand> [options]
|
||||
|
||||
Subcommands:
|
||||
list List your favorites (supports -j/--json)
|
||||
add Add a station (by search flags like find, or direct URL)
|
||||
del Remove a station (by index from list, search flags, or direct URL)
|
||||
|
||||
Examples:
|
||||
gostations fav list
|
||||
gostations fav list -j
|
||||
gostations fav add -n "WFMT"
|
||||
gostations fav add http://stream.example.com/radio.mp3
|
||||
gostations fav del 3
|
||||
gostations fav del -n "WFMT"
|
||||
gostations fav del http://stream.example.com/radio.mp3
|
||||
|
||||
Search options for add/del are the same as for "find" / "play".
|
||||
When deleting by index, the numbering matches the sorted order from "fav list".
|
||||
`)
|
||||
}
|
||||
|
||||
// sortedForDisplay returns a copy sorted by Name asc, then URL asc for stable 1-based indices in `fav list` / `fav del N`.
|
||||
func sortedForDisplay(stations []radio.Station) []radio.Station {
|
||||
out := append([]radio.Station(nil), stations...)
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Name != out[j].Name {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return out[i].Url < out[j].Url
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// runFavList lists the current favorites.
|
||||
func runFavList(args []string) {
|
||||
fs := flag.NewFlagSet("fav list", flag.ExitOnError)
|
||||
var jsonOut bool
|
||||
fs.BoolVar(&jsonOut, "j", false, "Output as JSON array (for scripting)")
|
||||
fs.BoolVar(&jsonOut, "json", false, "Output as JSON array (for scripting)")
|
||||
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: gostations fav list [options]\n\nOptions:\n")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
_ = fs.Parse(args)
|
||||
|
||||
favs, err := data.NewFavorites()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error loading favorites: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
list := sortedForDisplay(favs.List())
|
||||
|
||||
if jsonOut {
|
||||
b, _ := json.MarshalIndent(list, "", " ")
|
||||
fmt.Println(string(b))
|
||||
return
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
fmt.Println("(no favorites)")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Favorites:")
|
||||
for i, s := range list {
|
||||
listing := fmt.Sprintf("%-40s %-5s %-5s %s", Short(s.Name, 40), s.Codec, s.Bitrate, s.Url)
|
||||
fmt.Printf("%d) %s\n", i+1, listing)
|
||||
}
|
||||
}
|
||||
|
||||
// runFavAdd adds a station to favorites.
|
||||
func runFavAdd(args []string) {
|
||||
fs := flag.NewFlagSet("fav add", flag.ExitOnError)
|
||||
var (
|
||||
name string
|
||||
country string
|
||||
state string
|
||||
tags string
|
||||
notok bool
|
||||
)
|
||||
fs.StringVar(&name, "n", "", "Station name (or identifier).")
|
||||
fs.StringVar(&country, "c", "", "Home country.")
|
||||
fs.StringVar(&state, "s", "", "Home state (if in the United States).")
|
||||
fs.StringVar(&tags, "t", "", "Tag (or comma-separated tag list)")
|
||||
fs.BoolVar(¬ok, "x", false, "If toggled, will show stations that are down")
|
||||
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: gostations fav add [options] [direct-url]\n\nOptions:\n")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
_ = fs.Parse(args)
|
||||
|
||||
pos := fs.Args()
|
||||
|
||||
// no precheck or config needed for fav add, but call for consistency if search
|
||||
if err := config.Init(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var toAdd radio.Station
|
||||
if len(pos) > 0 {
|
||||
url := pos[0]
|
||||
toAdd = radio.Station{Url: url, Name: url}
|
||||
} else {
|
||||
stations, err := radio.Search(context.Background(), name, country, state, tags, notok)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "search: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(stations) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "no stations found to add")
|
||||
os.Exit(1)
|
||||
}
|
||||
toAdd = stations[0]
|
||||
fmt.Printf("Adding first match: %s\n", toAdd.Name)
|
||||
}
|
||||
|
||||
favs, err := data.NewFavorites()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error loading favorites: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
favs.Add(toAdd)
|
||||
if err := favs.Save(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error saving favorites: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Added to favorites: %s (%s)\n", toAdd.Name, toAdd.Url)
|
||||
}
|
||||
|
||||
// runFavDel removes a station from favorites.
|
||||
// Supports: index (1-based, matching `fav list` order), direct URL, or search flags.
|
||||
func runFavDel(args []string) {
|
||||
fs := flag.NewFlagSet("fav del", flag.ExitOnError)
|
||||
var (
|
||||
name string
|
||||
country string
|
||||
state string
|
||||
tags string
|
||||
notok bool
|
||||
)
|
||||
fs.StringVar(&name, "n", "", "Station name (or identifier).")
|
||||
fs.StringVar(&country, "c", "", "Home country.")
|
||||
fs.StringVar(&state, "s", "", "Home state (if in the United States).")
|
||||
fs.StringVar(&tags, "t", "", "Tag (or comma-separated tag list)")
|
||||
fs.BoolVar(¬ok, "x", false, "If toggled, will show stations that are down")
|
||||
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: gostations fav del [options] [index|direct-url]\n\nOptions:\n")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
_ = fs.Parse(args)
|
||||
|
||||
pos := fs.Args()
|
||||
|
||||
if err := config.Init(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
favs, err := data.NewFavorites()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error loading favorites: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var urlToDel string
|
||||
var deletedName string
|
||||
|
||||
if len(pos) > 0 {
|
||||
arg := pos[0]
|
||||
if idx, err := strconv.Atoi(arg); err == nil && idx > 0 {
|
||||
// Delete by 1-based index (matches sorted order from `fav list`)
|
||||
list := sortedForDisplay(favs.List())
|
||||
if idx > len(list) {
|
||||
fmt.Fprintf(os.Stderr, "index %d out of range (1-%d)\n", idx, len(list))
|
||||
os.Exit(1)
|
||||
}
|
||||
station := list[idx-1]
|
||||
urlToDel = station.Url
|
||||
deletedName = station.Name
|
||||
} else {
|
||||
// Treat as direct URL
|
||||
urlToDel = arg
|
||||
deletedName = arg
|
||||
}
|
||||
} else {
|
||||
// Use search flags to find station(s) to delete
|
||||
stations, err := radio.Search(context.Background(), name, country, state, tags, notok)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "search: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(stations) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "no stations found to delete")
|
||||
os.Exit(1)
|
||||
}
|
||||
urlToDel = stations[0].Url
|
||||
deletedName = stations[0].Name
|
||||
fmt.Printf("Deleting first match: %s\n", deletedName)
|
||||
}
|
||||
|
||||
favs.Remove(urlToDel)
|
||||
if err := favs.Save(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error saving favorites: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Removed from favorites: %s\n", deletedName)
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Early version / help for top level
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "find":
|
||||
runFind(os.Args[2:])
|
||||
return
|
||||
case "play":
|
||||
runPlay(os.Args[2:])
|
||||
return
|
||||
case "fav":
|
||||
runFav(os.Args[2:])
|
||||
return
|
||||
case "-v", "--version", "version":
|
||||
showVersion()
|
||||
return
|
||||
case "-h", "--help", "help":
|
||||
printTopHelp()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Common flags (used for TUI seeding or legacy)
|
||||
var (
|
||||
name string
|
||||
country string
|
||||
state string
|
||||
tags string
|
||||
notok bool
|
||||
legacy bool
|
||||
version bool
|
||||
)
|
||||
|
||||
fs := flag.NewFlagSet("gostations", flag.ExitOnError)
|
||||
fs.StringVar(&name, "n", "", "Station name (or identifier).")
|
||||
fs.StringVar(&country, "c", "", "Home country.")
|
||||
fs.StringVar(&state, "s", "", "Home state (if in the United States).")
|
||||
fs.StringVar(&tags, "t", "", "Tag (or comma-separated tag list)")
|
||||
fs.BoolVar(¬ok, "x", false, "If toggled, will show stations that are down")
|
||||
fs.BoolVar(&legacy, "legacy", false, "Force old wmenu UI (kept until new TUI is perfect)")
|
||||
fs.BoolVar(&version, "v", false, "Show version.")
|
||||
|
||||
fs.Usage = printTopHelp
|
||||
|
||||
_ = fs.Parse(os.Args[1:])
|
||||
|
||||
if version {
|
||||
showVersion()
|
||||
return
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
precheck()
|
||||
|
||||
if err := config.Init(); err != nil {
|
||||
fmt.Printf("config init: %v\n", err)
|
||||
stations, _ := StationSearch(name, country, state, tags, notok)
|
||||
menu := RadioMenu(stations)
|
||||
err := menu.Run()
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var stations []radio.Station
|
||||
var loadErr error
|
||||
searchParamsProvided := name != "" || country != "" || state != "" || tags != "" || notok
|
||||
if searchParamsProvided {
|
||||
// CLI flags specify a search: use them (targeted lookup)
|
||||
stations, loadErr = radio.Search(context.Background(), name, country, state, tags, notok)
|
||||
if loadErr != nil {
|
||||
fmt.Printf("warning: station search: %v\n", loadErr)
|
||||
stations = nil
|
||||
}
|
||||
} else {
|
||||
// No explicit search params: decide based on favorites (per user request)
|
||||
favs, ferr := data.NewFavorites()
|
||||
if ferr == nil && favs != nil && len(favs.List()) > 0 {
|
||||
stations = favs.List()
|
||||
} else {
|
||||
// no favorites: do the default broad lookup (pruned)
|
||||
stations, loadErr = radio.Search(context.Background(), "", "", "", "", false)
|
||||
if loadErr != nil {
|
||||
fmt.Printf("warning: default station load: %v\n", loadErr)
|
||||
stations = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if legacy {
|
||||
// Old wmenu path (gated)
|
||||
menu := RadioMenu(stations)
|
||||
if err := menu.Run(); err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Default: new TUI (Bubble Tea)
|
||||
if err := ui.Run(stations); err != nil {
|
||||
fmt.Printf("TUI error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printTopHelp() {
|
||||
fmt.Printf(`Usage:
|
||||
gostations [options] # Launch interactive TUI (default: your Favorites if any, else a default set of stations)
|
||||
gostations find [options] # Non-interactive station search (scripting)
|
||||
gostations play [options] [url] # Direct playback (scripting)
|
||||
gostations fav ... # Manage favorites (list / add / del)
|
||||
gostations -h | --help
|
||||
gostations -v | --version
|
||||
|
||||
Global options:
|
||||
-n string Station name (or identifier).
|
||||
-c string Home country.
|
||||
-s string Home state (if in the United States).
|
||||
-t string Tag (or comma-separated tag list)
|
||||
-x If toggled, will show stations that are down
|
||||
-v Show version.
|
||||
--legacy Force old wmenu UI (temporary)
|
||||
|
||||
Subcommand examples:
|
||||
gostations find -c "United Kingdom" -t "news" -j
|
||||
gostations play -c "Gambia"
|
||||
gostations play http://stream.example.com/radio.mp3
|
||||
gostations fav list
|
||||
gostations fav add -n "WFMT"
|
||||
gostations fav del 3
|
||||
gostations fav del http://stream.example.com/radio.mp3
|
||||
gostations fav list -j
|
||||
|
||||
Initial TUI view:
|
||||
- If you have saved favorites: shows your Favorites (starred).
|
||||
- Otherwise: performs a default station lookup (broad results).
|
||||
- In either case, use / to filter the current list, or type a term and press ENTER while filtering to perform a fresh server-side search/lookup (replaces the list with new results; overlapping favorites get ★).
|
||||
|
||||
CLI favorites management (fav subcommand):
|
||||
- gostations fav list (sorted output, supports -j)
|
||||
- gostations fav add / del (support index from list, search flags like -n, or direct URL)
|
||||
|
||||
Old top-level behavior without subcommand now defaults to the new TUI.
|
||||
Use --legacy to force the classic wmenu flow.
|
||||
`)
|
||||
}
|
||||
}
|
||||
256
stations_test.go
256
stations_test.go
@ -1,256 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gmgauthier/gostations/internal/config"
|
||||
playerpkg "github.com/gmgauthier/gostations/internal/player"
|
||||
"github.com/gmgauthier/gostations/internal/radio"
|
||||
ver "github.com/gmgauthier/gostations/internal/version"
|
||||
)
|
||||
|
||||
func TestShowVersion_Unit(t *testing.T) {
|
||||
// Do not mark Parallel: this test mutates the package-level "version" shim
|
||||
// (and internal/version vars) and redirects os.Stdout. Running it concurrently
|
||||
// with other legacy tests (esp. those calling RadioMenu which does fmt + wmenu)
|
||||
// produces data races under -race.
|
||||
t.Log("✓ Fast showVersion unit test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
expected string
|
||||
}{
|
||||
{"default version", "1.0.0", "1.0.0\n"},
|
||||
{"empty version", "", "\n"},
|
||||
{"dev version", "dev", "dev\n"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test legacy main var path
|
||||
originalVersion := version
|
||||
version = tt.version
|
||||
defer func() { version = originalVersion }()
|
||||
|
||||
// Safe stdout capture using pipe (no os.Stdout reassignment)
|
||||
origStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
showVersion()
|
||||
|
||||
w.Close()
|
||||
os.Stdout = origStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r)
|
||||
got := buf.String()
|
||||
|
||||
if got != tt.expected {
|
||||
t.Errorf("showVersion() legacy = %q, want %q", got, tt.expected)
|
||||
}
|
||||
|
||||
// Test new internal/version path (preferred for 2.0+ builds)
|
||||
origV := ver.Version
|
||||
origC := ver.Commit
|
||||
ver.Version = tt.version
|
||||
ver.Commit = ""
|
||||
defer func() { ver.Version = origV; ver.Commit = origC }()
|
||||
|
||||
origStdout = os.Stdout
|
||||
r, w, _ = os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
showVersion()
|
||||
|
||||
w.Close()
|
||||
os.Stdout = origStdout
|
||||
|
||||
buf.Reset()
|
||||
_, _ = buf.ReadFrom(r)
|
||||
got = buf.String()
|
||||
|
||||
if got != tt.expected {
|
||||
t.Errorf("showVersion() package = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecheck_Unit(t *testing.T) {
|
||||
// Do not mark Parallel: precheck reads config + calls external IsInstalled,
|
||||
// and in some paths can have side effects. Keep it serialized with other
|
||||
// main-package legacy tests to avoid races on shared state under -race.
|
||||
t.Log("✓ Fast precheck unit test")
|
||||
|
||||
p := "mpv"
|
||||
if v, err := config.Get("player.command"); err == nil && v != "" {
|
||||
p = v
|
||||
}
|
||||
if !playerpkg.IsInstalled(p) {
|
||||
t.Skipf("%s is either not installed, or not on your $PATH; skipping real precheck() call in unit test (see TestPrecheck_Live)", p)
|
||||
}
|
||||
|
||||
// Simple smoke test — calls the real precheck() in the common "player installed" path
|
||||
// (no mocking of globals — that's not allowed in Go)
|
||||
precheck()
|
||||
t.Log("✓ precheck ran without panic (happy path)")
|
||||
}
|
||||
|
||||
func TestPrecheck_Live(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping live integration test (use without -short)")
|
||||
}
|
||||
t.Log("🧪 Running live precheck integration test...")
|
||||
|
||||
// Real precheck with whatever player is configured on this system
|
||||
precheck()
|
||||
t.Log("✓ Live precheck passed (no early exit)")
|
||||
}
|
||||
|
||||
// TestFavDelByIndex_Integration exercises `fav list` numbering and `fav del N` (1-based, stable sort).
|
||||
// Uses a temp XDG dir and a built binary so we can exec the subcommands as users would.
|
||||
// Skips under -short (like other live integration tests).
|
||||
func TestFavDelByIndex_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping live integration test for fav del by index (use without -short)")
|
||||
}
|
||||
t.Log("🧪 Running fav del-by-index integration test...")
|
||||
|
||||
td := t.TempDir()
|
||||
bin := filepath.Join(td, "gostations-favtest")
|
||||
|
||||
// Build a dedicated binary from current source (self-contained, no reliance on external /tmp state)
|
||||
buildCmd := exec.Command("go", "build", "-o", bin, ".")
|
||||
buildCmd.Dir = "."
|
||||
if out, err := buildCmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("failed to build test binary: %v\n%s", err, string(out))
|
||||
}
|
||||
|
||||
xdg := filepath.Join(td, "xdg-home")
|
||||
favDir := filepath.Join(xdg, "gostations")
|
||||
if err := os.MkdirAll(favDir, 0750); err != nil {
|
||||
t.Fatalf("mkdir xdg: %v", err)
|
||||
}
|
||||
|
||||
// Pre-populate a minimal config to suppress "config file missing" logs during config.Init in add/del
|
||||
ini := filepath.Join(favDir, "radiostations.ini")
|
||||
iniContent := `[DEFAULT]
|
||||
radio_browser.api=all.api.radio-browser.info
|
||||
player.command=mpv
|
||||
player.options=--no-video
|
||||
menu_items.max=50
|
||||
`
|
||||
if err := os.WriteFile(ini, []byte(iniContent), 0644); err != nil {
|
||||
t.Fatalf("write ini: %v", err)
|
||||
}
|
||||
|
||||
// Helper to invoke the subcommand with isolated XDG
|
||||
run := func(args ...string) (stdout, stderr string, exitErr error) {
|
||||
c := exec.Command(bin, args...)
|
||||
c.Env = append(os.Environ(), "XDG_CONFIG_HOME="+xdg)
|
||||
var so, se bytes.Buffer
|
||||
c.Stdout = &so
|
||||
c.Stderr = &se
|
||||
exitErr = c.Run()
|
||||
return so.String(), se.String(), exitErr
|
||||
}
|
||||
|
||||
// Seed two favorites via direct URLs (no network). Names will be the URLs themselves.
|
||||
// After sort by Name (which == URL here) order will be a < b.
|
||||
if _, _, err := run("fav", "add", "http://ex.com/b"); err != nil {
|
||||
t.Fatalf("fav add b: %v", err)
|
||||
}
|
||||
if _, _, err := run("fav", "add", "http://ex.com/a"); err != nil {
|
||||
t.Fatalf("fav add a: %v", err)
|
||||
}
|
||||
|
||||
// List should show stable 1-based numbering, sorted by name/URL
|
||||
listOut, _, err := run("fav", "list")
|
||||
if err != nil {
|
||||
t.Fatalf("fav list after adds: %v", err)
|
||||
}
|
||||
if !strings.Contains(listOut, "1) http://ex.com/a") || !strings.Contains(listOut, "2) http://ex.com/b") {
|
||||
t.Fatalf("expected numbered sorted list with 1=a 2=b, got:\n%s", listOut)
|
||||
}
|
||||
|
||||
// Delete by index 1 (should remove the first in sorted order, i.e. "a")
|
||||
delOut, delErrOut, err := run("fav", "del", "1")
|
||||
if err != nil {
|
||||
t.Fatalf("fav del 1 failed: %v\nstderr: %s", err, delErrOut)
|
||||
}
|
||||
if !strings.Contains(delOut, "Removed from favorites: http://ex.com/a") {
|
||||
t.Fatalf("unexpected del output: %s", delOut)
|
||||
}
|
||||
|
||||
// Re-list: only b remains, now at position 1
|
||||
listOut, _, err = run("fav", "list")
|
||||
if err != nil {
|
||||
t.Fatalf("fav list after del: %v", err)
|
||||
}
|
||||
if strings.Contains(listOut, "http://ex.com/a") {
|
||||
t.Errorf("a should have been deleted, list:\n%s", listOut)
|
||||
}
|
||||
if !strings.Contains(listOut, "1) http://ex.com/b") || strings.Contains(listOut, "2)") {
|
||||
t.Errorf("after deleting first, b should be renumbered to 1 only; got:\n%s", listOut)
|
||||
}
|
||||
|
||||
// Out of range should error and not mutate
|
||||
_, delErrOut, err = run("fav", "del", "99")
|
||||
if err == nil {
|
||||
t.Error("expected non-zero exit for out-of-range del")
|
||||
}
|
||||
if !strings.Contains(delErrOut, "index 99 out of range (1-1)") {
|
||||
t.Errorf("expected range error message, got stderr: %s", delErrOut)
|
||||
}
|
||||
|
||||
// Final list still has exactly the one item
|
||||
listOut, _, _ = run("fav", "list")
|
||||
if !strings.Contains(listOut, "1) http://ex.com/b") {
|
||||
t.Errorf("list mutated by bad del index? got:\n%s", listOut)
|
||||
}
|
||||
|
||||
t.Log("✓ fav del-by-index integration test passed")
|
||||
}
|
||||
|
||||
// TestSortedForDisplay_Unit verifies the stable Name-then-URL ordering used for fav list indices.
|
||||
func TestSortedForDisplay_Unit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []radio.Station{
|
||||
{Name: "Zeta", Url: "u3"},
|
||||
{Name: "Alpha", Url: "u2"},
|
||||
{Name: "Alpha", Url: "u1"}, // same name, lower URL should come first
|
||||
{Name: "Beta", Url: "u0"},
|
||||
}
|
||||
|
||||
out := sortedForDisplay(in)
|
||||
|
||||
if len(out) != 4 {
|
||||
t.Fatalf("len=%d", len(out))
|
||||
}
|
||||
// Expected order: Alpha/u1 , Alpha/u2 , Beta , Zeta
|
||||
if out[0].Name != "Alpha" || out[0].Url != "u1" {
|
||||
t.Errorf("pos0: want Alpha/u1 got %s/%s", out[0].Name, out[0].Url)
|
||||
}
|
||||
if out[1].Name != "Alpha" || out[1].Url != "u2" {
|
||||
t.Errorf("pos1: want Alpha/u2 got %s/%s", out[1].Name, out[1].Url)
|
||||
}
|
||||
if out[2].Name != "Beta" {
|
||||
t.Errorf("pos2: want Beta got %s", out[2].Name)
|
||||
}
|
||||
if out[3].Name != "Zeta" {
|
||||
t.Errorf("pos3: want Zeta got %s", out[3].Name)
|
||||
}
|
||||
|
||||
// Ensure input slice not mutated (we copy)
|
||||
if in[0].Name != "Zeta" {
|
||||
t.Error("input was mutated")
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
# Gostations TODO List
|
||||
|
||||
This document provides a table of contents for all tasks and features currently tracked in the `todo/` directory.
|
||||
|
||||
## Queued
|
||||
|
||||
* [1] [per-station-volume.md](./queued/per-station-volume.md) : per-station volume savings
|
||||
|
||||
## Completed
|
||||
@ -1,4 +0,0 @@
|
||||
# TODO ITEM 1
|
||||
- [ ] 1 step one
|
||||
- [ ] 2 step two
|
||||
- [ ] 3 step three
|
||||
@ -1,74 +0,0 @@
|
||||
# Per-station volume savings (favorites only)
|
||||
|
||||
**Description**: Persist the last-used volume level *only for stations in your favorites list* (keyed by stream URL). When you return to a favorited station, it restores the volume you last set for *that specific station* (falling back to the global last-volume if none saved for it). Non-favorited stations always use the global last-volume.
|
||||
|
||||
## Problem It Solves
|
||||
|
||||
Currently only a single global `player.last_volume` is saved in `radiostations.ini`. When a user fine-tunes the volume while listening to a favorited Station A (e.g. to 35), stops, then plays another favorited Station B, the volume resets to the global value (or default 70). Users have to re-adjust every time they switch between their own favorites. We deliberately limit this to favorited stations only (no unbounded per-station data for the entire radio-browser corpus).
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Natural UX**: Volume preference is specific to your favorited stations (e.g. a quiet classical vs. a loud rock station).
|
||||
- **Seamless resumption**: Pick a favorite from your list → volume is already where you left it for *that* station.
|
||||
- **Low friction**: No extra UI; the existing volume controls + persistence just become smarter for your own list.
|
||||
- **Backward compatible**: Non-favorites always use global last-volume. Falls back gracefully.
|
||||
- **Leverages existing patterns**: Volumes live inside the favorites data (no new file or unbounded store). Same atomic JSON + XDG path.
|
||||
|
||||
## High-Level Implementation
|
||||
|
||||
1. **Extend the existing Favorites store** (no new file needed):
|
||||
- Add `Volume int `json:"volume,omitempty"`` field to `radio.Station`.
|
||||
- Add `SetVolume(url string, vol int)` and `GetVolume(url string) int` to `*data.Favorites`.
|
||||
- When `Add`ing or updating a favorite while a volume is active, capture it on the station.
|
||||
- Volumes are automatically persisted inside `favorites.json` (only for stations you have favorited).
|
||||
- `Remove` naturally drops the volume data for that station.
|
||||
|
||||
2. **Wire into TUI / playback** (in `internal/ui/ui.go`):
|
||||
- On entering playback for a station (Enter in list):
|
||||
- `desired := config.LastVolume()`
|
||||
- `if v := a.favs.GetVolume(station.Url); v > 0 { desired = v }`
|
||||
- Prefer live `a.currentVolume` for session stickiness across s/x within the same run.
|
||||
- Pass `desired` via `--volume=...` extra arg to `Play()`.
|
||||
- On volume change (key handler and `volumeMsg`):
|
||||
- After updating `currentVolume`, if the current station's URL is in favorites: `a.favs.SetVolume(url, currentVolume)` and `Save()`.
|
||||
- Always still call `config.SetLastVolume` for the global fallback.
|
||||
- On `s`/`x` stop or quit: ensure the current station's per-fav volume (if any) is saved via `favs.SetVolume` + `Save()`.
|
||||
|
||||
3. **Player interface**:
|
||||
- No new methods required for basic functionality (we pass `--volume` on `Play`).
|
||||
- (Optional future) Add `SetVolume` for runtime adjustment after start.
|
||||
|
||||
4. **Scope is deliberately narrow**:
|
||||
- Only stations that exist in the user's favorites list get per-station volumes.
|
||||
- No unbounded storage for the entire radio-browser catalog.
|
||||
- When you unfavorite a station, its volume preference is dropped.
|
||||
- Non-favorites always use global `last_volume` (or 70).
|
||||
|
||||
## Flags / Config
|
||||
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| (none yet) | Could add `player.per_station_volume=true` (default on) in future |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Key choice**: Use the station's `Url` (same as Favorites). Volumes only exist for stations the user has explicitly favorited.
|
||||
- **When to persist**: Save on volume change for the favorite (immediate, like global), plus on stop/quit. Non-favorites never create per-station entries.
|
||||
- **mpv timing**: The `--volume=XX` extra arg passed to `Play()` is the simplest reliable way (command-line wins for the launch of that station).
|
||||
- **Legacy player**: Per-station volumes are ignored (documented limitation).
|
||||
- **First-time migration**: Existing global last-volume is the fallback. No per-station entries until the user actually changes volume while playing a favorite.
|
||||
- **Tests**: Extend existing favorites tests and playback tests.
|
||||
- **Effort**: Low-to-medium. Reuses the Favorites store and persistence; mostly wiring in the TUI playback entry and volume handlers. No new files.
|
||||
|
||||
## ROI
|
||||
|
||||
**High**. Volume is one of the most frequently adjusted controls. Making it "stick" for the stations *you actually care about* (your favorites) removes friction without the complexity of tracking every station ever played.
|
||||
|
||||
| Stage | Covered |
|
||||
|-------|---------|
|
||||
| Global last-volume | ✓ (already shipped) |
|
||||
| Per-favorite volume | **new** (narrowed scope) |
|
||||
| Favorites integration | ✓ (reuses existing store) |
|
||||
| TUI playback | ✓ |
|
||||
|
||||
Users will notice it immediately the second time they return to a station they previously tuned.
|
||||
Loading…
Reference in New Issue
Block a user