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" ) var version string 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) 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) var ( name string country string state string tags string notok bool jsonOut 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() } _ = fs.Parse(args) if err := config.Init(); err != nil { fmt.Fprintf(os.Stderr, "config: %v\n", err) os.Exit(1) } 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 [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 } precheck() if err := config.Init(); err != nil { fmt.Printf("config init: %v\n", err) 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. `) }