feat: add favorites management (CLI + TUI) and in-filter server search
Some checks failed
gobuild / build (push) Failing after 5s
Some checks failed
gobuild / build (push) Failing after 5s
- New `gostations fav` subcommand with `list`, `add`, `del` (supports index, URL, or search flags) - TUI favorites toggle via `f` key; favorites auto-detected on load for "Your Favorites" title - Pressing ENTER while filtering performs a server-side search and refreshes the list - Default TUI now loads favorites if present, otherwise falls back to broad station lookup - Added `sortedForDisplay` for stable fav list ordering and index-based deletion - Tests for favorites title heuristic, sorted display, and fav del-by-index integration
This commit is contained in:
parent
3924aae93b
commit
e0f45bdd5e
BIN
gostations
BIN
gostations
Binary file not shown.
@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@ -61,8 +62,10 @@ func (d listDelegate) Render(w io.Writer, m list.Model, index int, listItem list
|
||||
|
||||
// App is the root model. Currently focused on selection (playback integration later).
|
||||
type App struct {
|
||||
list list.Model
|
||||
quitting bool
|
||||
list list.Model
|
||||
quitting bool
|
||||
currentSearchTerm string
|
||||
favs *data.Favorites
|
||||
}
|
||||
|
||||
func NewApp(initial []radio.Station) *App {
|
||||
@ -83,8 +86,23 @@ func NewApp(initial []radio.Station) *App {
|
||||
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)
|
||||
l.Title = "GoStations - Radio Browser (new TUI • ★ = favorite)"
|
||||
title := "GoStations - Radio Browser (new TUI • ★ = favorite)"
|
||||
if isFavoritesInitial {
|
||||
title = "GoStations - Your Favorites"
|
||||
}
|
||||
l.Title = title
|
||||
l.SetShowStatusBar(true)
|
||||
l.SetFilteringEnabled(true)
|
||||
l.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
|
||||
@ -120,7 +138,7 @@ func NewApp(initial []radio.Station) *App {
|
||||
return ranks
|
||||
}
|
||||
|
||||
return &App{list: l}
|
||||
return &App{list: l, favs: favs}
|
||||
}
|
||||
|
||||
func (a *App) Init() tea.Cmd {
|
||||
@ -135,12 +153,54 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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 {
|
||||
// Placeholder: for now just show what would be played.
|
||||
// Later: switch to playback model + use player.Play
|
||||
a.list.Title = "Would play: " + i.station.Name + " (press q)"
|
||||
}
|
||||
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 {
|
||||
a.favs.Add(sel.station)
|
||||
}
|
||||
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)
|
||||
@ -161,6 +221,43 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
a.list.SetSize(msg.Width-4, msg.Height-4)
|
||||
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
|
||||
@ -172,7 +269,21 @@ func (a *App) View() string {
|
||||
if a.quitting {
|
||||
return "Thanks for using GoStations!\n"
|
||||
}
|
||||
return "\n" + a.list.View() + "\n\n(type to filter • enter=play stub • / or letters for filter • q quit • --legacy for old UI)\n"
|
||||
return "\n" + a.list.View() + "\n\n(type to filter current list • ENTER while filtering = server search for term & refresh list • f = toggle ★ favorite • q quit • --legacy for old UI)\n"
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Run starts the TUI (alt screen).
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@ -90,3 +93,48 @@ func TestApp_AutoFilterOnTyping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
286
stations.go
286
stations.go
@ -6,8 +6,11 @@ import (
|
||||
"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"
|
||||
@ -150,6 +153,242 @@ func runPlay(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@ -160,6 +399,9 @@ func main() {
|
||||
case "play":
|
||||
runPlay(os.Args[2:])
|
||||
return
|
||||
case "fav":
|
||||
runFav(os.Args[2:])
|
||||
return
|
||||
case "-v", "--version", "version":
|
||||
showVersion()
|
||||
return
|
||||
@ -205,10 +447,29 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
stations, err := radio.Search(context.Background(), name, country, state, tags, notok)
|
||||
if err != nil {
|
||||
fmt.Printf("warning: station search: %v\n", err)
|
||||
stations = nil
|
||||
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 {
|
||||
@ -230,9 +491,10 @@ func main() {
|
||||
|
||||
func printTopHelp() {
|
||||
fmt.Printf(`Usage:
|
||||
gostations [options] # Launch interactive TUI (default)
|
||||
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
|
||||
|
||||
@ -249,6 +511,20 @@ 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.
|
||||
|
||||
146
stations_test.go
146
stations_test.go
@ -3,7 +3,12 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gmgauthier/gostations/internal/radio"
|
||||
)
|
||||
|
||||
func TestShowVersion_Unit(t *testing.T) {
|
||||
@ -67,3 +72,144 @@ func TestPrecheck_Live(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user