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
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"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).
|
// App is the root model. Currently focused on selection (playback integration later).
|
||||||
type App struct {
|
type App struct {
|
||||||
list list.Model
|
list list.Model
|
||||||
quitting bool
|
quitting bool
|
||||||
|
currentSearchTerm string
|
||||||
|
favs *data.Favorites
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(initial []radio.Station) *App {
|
func NewApp(initial []radio.Station) *App {
|
||||||
@ -83,8 +86,23 @@ func NewApp(initial []radio.Station) *App {
|
|||||||
items[i] = item{station: s, isFavorite: isFav}
|
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 := 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.SetShowStatusBar(true)
|
||||||
l.SetFilteringEnabled(true)
|
l.SetFilteringEnabled(true)
|
||||||
l.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
|
l.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
|
||||||
@ -120,7 +138,7 @@ func NewApp(initial []radio.Station) *App {
|
|||||||
return ranks
|
return ranks
|
||||||
}
|
}
|
||||||
|
|
||||||
return &App{list: l}
|
return &App{list: l, favs: favs}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Init() tea.Cmd {
|
func (a *App) Init() tea.Cmd {
|
||||||
@ -135,12 +153,54 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
a.quitting = true
|
a.quitting = true
|
||||||
return a, tea.Quit
|
return a, tea.Quit
|
||||||
case "enter":
|
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 {
|
if i, ok := a.list.SelectedItem().(item); ok {
|
||||||
// Placeholder: for now just show what would be played.
|
// Placeholder: for now just show what would be played.
|
||||||
// Later: switch to playback model + use player.Play
|
// Later: switch to playback model + use player.Play
|
||||||
a.list.Title = "Would play: " + i.station.Name + " (press q)"
|
a.list.Title = "Would play: " + i.station.Name + " (press q)"
|
||||||
}
|
}
|
||||||
return a, nil
|
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)
|
// 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:
|
case tea.WindowSizeMsg:
|
||||||
a.list.SetSize(msg.Width-4, msg.Height-4)
|
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
|
var cmd tea.Cmd
|
||||||
@ -172,7 +269,21 @@ func (a *App) View() string {
|
|||||||
if a.quitting {
|
if a.quitting {
|
||||||
return "Thanks for using GoStations!\n"
|
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).
|
// Run starts the TUI (alt screen).
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
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"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gmgauthier/gostations/internal/config"
|
"github.com/gmgauthier/gostations/internal/config"
|
||||||
|
"github.com/gmgauthier/gostations/internal/data"
|
||||||
playerpkg "github.com/gmgauthier/gostations/internal/player"
|
playerpkg "github.com/gmgauthier/gostations/internal/player"
|
||||||
"github.com/gmgauthier/gostations/internal/radio"
|
"github.com/gmgauthier/gostations/internal/radio"
|
||||||
"github.com/gmgauthier/gostations/internal/ui"
|
"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() {
|
func main() {
|
||||||
// Early version / help for top level
|
// Early version / help for top level
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
@ -160,6 +399,9 @@ func main() {
|
|||||||
case "play":
|
case "play":
|
||||||
runPlay(os.Args[2:])
|
runPlay(os.Args[2:])
|
||||||
return
|
return
|
||||||
|
case "fav":
|
||||||
|
runFav(os.Args[2:])
|
||||||
|
return
|
||||||
case "-v", "--version", "version":
|
case "-v", "--version", "version":
|
||||||
showVersion()
|
showVersion()
|
||||||
return
|
return
|
||||||
@ -205,10 +447,29 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
stations, err := radio.Search(context.Background(), name, country, state, tags, notok)
|
var stations []radio.Station
|
||||||
if err != nil {
|
var loadErr error
|
||||||
fmt.Printf("warning: station search: %v\n", err)
|
searchParamsProvided := name != "" || country != "" || state != "" || tags != "" || notok
|
||||||
stations = nil
|
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 {
|
if legacy {
|
||||||
@ -230,9 +491,10 @@ func main() {
|
|||||||
|
|
||||||
func printTopHelp() {
|
func printTopHelp() {
|
||||||
fmt.Printf(`Usage:
|
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 find [options] # Non-interactive station search (scripting)
|
||||||
gostations play [options] [url] # Direct playback (scripting)
|
gostations play [options] [url] # Direct playback (scripting)
|
||||||
|
gostations fav ... # Manage favorites (list / add / del)
|
||||||
gostations -h | --help
|
gostations -h | --help
|
||||||
gostations -v | --version
|
gostations -v | --version
|
||||||
|
|
||||||
@ -249,6 +511,20 @@ Subcommand examples:
|
|||||||
gostations find -c "United Kingdom" -t "news" -j
|
gostations find -c "United Kingdom" -t "news" -j
|
||||||
gostations play -c "Gambia"
|
gostations play -c "Gambia"
|
||||||
gostations play http://stream.example.com/radio.mp3
|
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.
|
Old top-level behavior without subcommand now defaults to the new TUI.
|
||||||
Use --legacy to force the classic wmenu flow.
|
Use --legacy to force the classic wmenu flow.
|
||||||
|
|||||||
146
stations_test.go
146
stations_test.go
@ -3,7 +3,12 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gmgauthier/gostations/internal/radio"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestShowVersion_Unit(t *testing.T) {
|
func TestShowVersion_Unit(t *testing.T) {
|
||||||
@ -67,3 +72,144 @@ func TestPrecheck_Live(t *testing.T) {
|
|||||||
precheck()
|
precheck()
|
||||||
t.Log("✓ Live precheck passed (no early exit)")
|
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