540 lines
15 KiB
Go
540 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
|
|
"github.com/gmgauthier/gostations/internal/config"
|
|
"github.com/gmgauthier/gostations/internal/data"
|
|
playerpkg "github.com/gmgauthier/gostations/internal/player"
|
|
"github.com/gmgauthier/gostations/internal/radio"
|
|
"github.com/gmgauthier/gostations/internal/ui"
|
|
ver "github.com/gmgauthier/gostations/internal/version"
|
|
)
|
|
|
|
var version string // kept for ldflags compat with legacy build scripts; prefer internal/version
|
|
|
|
func showVersion() {
|
|
// Prefer modern internal/version (ldflags in Makefile + release workflow)
|
|
if ver.Version != "dev" || ver.Commit != "" {
|
|
fmt.Println(ver.String())
|
|
return
|
|
}
|
|
// Fallback for legacy build scripts that only -X main.version=...
|
|
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 <subcommand> [options]
|
|
|
|
Subcommands:
|
|
list List your favorites (supports -j/--json)
|
|
add Add a station (by search flags like find, or direct URL)
|
|
del Remove a station (by index from list, search flags, or direct URL)
|
|
|
|
Examples:
|
|
gostations fav list
|
|
gostations fav list -j
|
|
gostations fav add -n "WFMT"
|
|
gostations fav add http://stream.example.com/radio.mp3
|
|
gostations fav del 3
|
|
gostations fav del -n "WFMT"
|
|
gostations fav del http://stream.example.com/radio.mp3
|
|
|
|
Search options for add/del are the same as for "find" / "play".
|
|
When deleting by index, the numbering matches the sorted order from "fav list".
|
|
`)
|
|
}
|
|
|
|
// sortedForDisplay returns a copy sorted by Name asc, then URL asc for stable 1-based indices in `fav list` / `fav del N`.
|
|
func sortedForDisplay(stations []radio.Station) []radio.Station {
|
|
out := append([]radio.Station(nil), stations...)
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Name != out[j].Name {
|
|
return out[i].Name < out[j].Name
|
|
}
|
|
return out[i].Url < out[j].Url
|
|
})
|
|
return out
|
|
}
|
|
|
|
// runFavList lists the current favorites.
|
|
func runFavList(args []string) {
|
|
fs := flag.NewFlagSet("fav list", flag.ExitOnError)
|
|
var jsonOut bool
|
|
fs.BoolVar(&jsonOut, "j", false, "Output as JSON array (for scripting)")
|
|
fs.BoolVar(&jsonOut, "json", false, "Output as JSON array (for scripting)")
|
|
|
|
fs.Usage = func() {
|
|
fmt.Fprintf(os.Stderr, "Usage: gostations fav list [options]\n\nOptions:\n")
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
|
|
favs, err := data.NewFavorites()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error loading favorites: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
list := sortedForDisplay(favs.List())
|
|
|
|
if jsonOut {
|
|
b, _ := json.MarshalIndent(list, "", " ")
|
|
fmt.Println(string(b))
|
|
return
|
|
}
|
|
|
|
if len(list) == 0 {
|
|
fmt.Println("(no favorites)")
|
|
return
|
|
}
|
|
|
|
fmt.Println("Favorites:")
|
|
for i, s := range list {
|
|
listing := fmt.Sprintf("%-40s %-5s %-5s %s", Short(s.Name, 40), s.Codec, s.Bitrate, s.Url)
|
|
fmt.Printf("%d) %s\n", i+1, listing)
|
|
}
|
|
}
|
|
|
|
// runFavAdd adds a station to favorites.
|
|
func runFavAdd(args []string) {
|
|
fs := flag.NewFlagSet("fav add", flag.ExitOnError)
|
|
var (
|
|
name string
|
|
country string
|
|
state string
|
|
tags string
|
|
notok bool
|
|
)
|
|
fs.StringVar(&name, "n", "", "Station name (or identifier).")
|
|
fs.StringVar(&country, "c", "", "Home country.")
|
|
fs.StringVar(&state, "s", "", "Home state (if in the United States).")
|
|
fs.StringVar(&tags, "t", "", "Tag (or comma-separated tag list)")
|
|
fs.BoolVar(¬ok, "x", false, "If toggled, will show stations that are down")
|
|
|
|
fs.Usage = func() {
|
|
fmt.Fprintf(os.Stderr, "Usage: gostations fav add [options] [direct-url]\n\nOptions:\n")
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
|
|
pos := fs.Args()
|
|
|
|
// no precheck or config needed for fav add, but call for consistency if search
|
|
if err := config.Init(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
var toAdd radio.Station
|
|
if len(pos) > 0 {
|
|
url := pos[0]
|
|
toAdd = radio.Station{Url: url, Name: url}
|
|
} else {
|
|
stations, err := radio.Search(context.Background(), name, country, state, tags, notok)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "search: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if len(stations) == 0 {
|
|
fmt.Fprintln(os.Stderr, "no stations found to add")
|
|
os.Exit(1)
|
|
}
|
|
toAdd = stations[0]
|
|
fmt.Printf("Adding first match: %s\n", toAdd.Name)
|
|
}
|
|
|
|
favs, err := data.NewFavorites()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error loading favorites: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
favs.Add(toAdd)
|
|
if err := favs.Save(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error saving favorites: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Added to favorites: %s (%s)\n", toAdd.Name, toAdd.Url)
|
|
}
|
|
|
|
// runFavDel removes a station from favorites.
|
|
// Supports: index (1-based, matching `fav list` order), direct URL, or search flags.
|
|
func runFavDel(args []string) {
|
|
fs := flag.NewFlagSet("fav del", flag.ExitOnError)
|
|
var (
|
|
name string
|
|
country string
|
|
state string
|
|
tags string
|
|
notok bool
|
|
)
|
|
fs.StringVar(&name, "n", "", "Station name (or identifier).")
|
|
fs.StringVar(&country, "c", "", "Home country.")
|
|
fs.StringVar(&state, "s", "", "Home state (if in the United States).")
|
|
fs.StringVar(&tags, "t", "", "Tag (or comma-separated tag list)")
|
|
fs.BoolVar(¬ok, "x", false, "If toggled, will show stations that are down")
|
|
|
|
fs.Usage = func() {
|
|
fmt.Fprintf(os.Stderr, "Usage: gostations fav del [options] [index|direct-url]\n\nOptions:\n")
|
|
fs.PrintDefaults()
|
|
}
|
|
_ = fs.Parse(args)
|
|
|
|
pos := fs.Args()
|
|
|
|
if err := config.Init(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
favs, err := data.NewFavorites()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error loading favorites: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
var urlToDel string
|
|
var deletedName string
|
|
|
|
if len(pos) > 0 {
|
|
arg := pos[0]
|
|
if idx, err := strconv.Atoi(arg); err == nil && idx > 0 {
|
|
// Delete by 1-based index (matches sorted order from `fav list`)
|
|
list := sortedForDisplay(favs.List())
|
|
if idx > len(list) {
|
|
fmt.Fprintf(os.Stderr, "index %d out of range (1-%d)\n", idx, len(list))
|
|
os.Exit(1)
|
|
}
|
|
station := list[idx-1]
|
|
urlToDel = station.Url
|
|
deletedName = station.Name
|
|
} else {
|
|
// Treat as direct URL
|
|
urlToDel = arg
|
|
deletedName = arg
|
|
}
|
|
} else {
|
|
// Use search flags to find station(s) to delete
|
|
stations, err := radio.Search(context.Background(), name, country, state, tags, notok)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "search: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if len(stations) == 0 {
|
|
fmt.Fprintln(os.Stderr, "no stations found to delete")
|
|
os.Exit(1)
|
|
}
|
|
urlToDel = stations[0].Url
|
|
deletedName = stations[0].Name
|
|
fmt.Printf("Deleting first match: %s\n", deletedName)
|
|
}
|
|
|
|
favs.Remove(urlToDel)
|
|
if err := favs.Save(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error saving favorites: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Removed from favorites: %s\n", deletedName)
|
|
}
|
|
|
|
func main() {
|
|
// Early version / help for top level
|
|
if len(os.Args) > 1 {
|
|
switch os.Args[1] {
|
|
case "find":
|
|
runFind(os.Args[2:])
|
|
return
|
|
case "play":
|
|
runPlay(os.Args[2:])
|
|
return
|
|
case "fav":
|
|
runFav(os.Args[2:])
|
|
return
|
|
case "-v", "--version", "version":
|
|
showVersion()
|
|
return
|
|
case "-h", "--help", "help":
|
|
printTopHelp()
|
|
return
|
|
}
|
|
}
|
|
|
|
// Common flags (used for TUI seeding or legacy)
|
|
var (
|
|
name string
|
|
country string
|
|
state string
|
|
tags string
|
|
notok bool
|
|
legacy bool
|
|
version bool
|
|
)
|
|
|
|
fs := flag.NewFlagSet("gostations", flag.ExitOnError)
|
|
fs.StringVar(&name, "n", "", "Station name (or identifier).")
|
|
fs.StringVar(&country, "c", "", "Home country.")
|
|
fs.StringVar(&state, "s", "", "Home state (if in the United States).")
|
|
fs.StringVar(&tags, "t", "", "Tag (or comma-separated tag list)")
|
|
fs.BoolVar(¬ok, "x", false, "If toggled, will show stations that are down")
|
|
fs.BoolVar(&legacy, "legacy", false, "Force old wmenu UI (kept until new TUI is perfect)")
|
|
fs.BoolVar(&version, "v", false, "Show version.")
|
|
|
|
fs.Usage = printTopHelp
|
|
|
|
_ = fs.Parse(os.Args[1:])
|
|
|
|
if version {
|
|
showVersion()
|
|
return
|
|
}
|
|
|
|
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.
|
|
`)
|
|
}
|