diff --git a/gostations b/gostations index 98ff703..93e3b33 100755 Binary files a/gostations and b/gostations differ diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6b3a957..6290266 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -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). diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index ffd1d2f..421da04 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -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) + } +} + diff --git a/stations.go b/stations.go index 52cd7b9..5693a33 100644 --- a/stations.go +++ b/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 [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. diff --git a/stations_test.go b/stations_test.go index 0cc1b8b..ce1c1c0 100644 --- a/stations_test.go +++ b/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") + } +}