gostations/stations.go

540 lines
15 KiB
Go
Raw Normal View History

2021-03-16 12:32:59 +00:00
package main
2021-03-16 13:09:27 +00:00
import (
"context"
"encoding/json"
2021-03-16 13:09:27 +00:00
"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"
2021-03-16 13:09:27 +00:00
)
2021-03-16 12:32:59 +00:00
var version string // kept for ldflags compat with legacy build scripts; prefer internal/version
2024-07-09 20:29:11 +00:00
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)
}
2024-07-09 20:29:11 +00:00
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(&notok, "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(&notok, "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(&notok, "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(&notok, "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)
}
2024-07-09 20:29:11 +00:00
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
}
}
2021-03-16 13:09:27 +00:00
// Common flags (used for TUI seeding or legacy)
2021-03-16 13:09:27 +00:00
var (
2024-07-09 20:29:11 +00:00
name string
2021-03-16 13:09:27 +00:00
country string
2024-07-09 20:29:11 +00:00
state string
tags string
notok bool
legacy bool
2024-07-09 20:29:11 +00:00
version bool
2021-03-16 13:09:27 +00:00
)
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(&notok, "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()
2021-03-16 12:32:59 +00:00
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)
2021-03-16 23:06:20 +00:00
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.
`)
2024-07-09 20:29:11 +00:00
}