feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
package data
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"sync"
|
|
|
|
|
|
|
|
|
|
"github.com/gmgauthier/gostations/internal/radio"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Favorites manages a user's preferred stations persisted as JSON.
|
|
|
|
|
// Keyed by URL for deduplication (stable enough; can enhance with UUID later).
|
|
|
|
|
type Favorites struct {
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
path string
|
|
|
|
|
stations map[string]radio.Station // url -> station
|
|
|
|
|
dirty bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewFavorites creates or loads favorites from the standard location under XDG config.
|
|
|
|
|
// Falls back gracefully; errors only on unrecoverable write issues later.
|
|
|
|
|
func NewFavorites() (*Favorites, error) {
|
|
|
|
|
f := &Favorites{
|
|
|
|
|
stations: make(map[string]radio.Station),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
configDir, err := os.UserConfigDir()
|
|
|
|
|
if err != nil {
|
|
|
|
|
configDir = filepath.Join(os.TempDir(), "gostations-fallback")
|
|
|
|
|
}
|
|
|
|
|
f.path = filepath.Join(configDir, "gostations", "favorites.json")
|
|
|
|
|
|
|
|
|
|
// Ensure dir
|
|
|
|
|
if err := os.MkdirAll(filepath.Dir(f.path), 0750); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := f.load(); err != nil {
|
|
|
|
|
// If load fails (corrupt etc), start empty but don't fail hard
|
|
|
|
|
// Caller can log warning.
|
|
|
|
|
f.stations = make(map[string]radio.Station)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return f, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (f *Favorites) load() error {
|
|
|
|
|
data, err := os.ReadFile(f.path)
|
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
|
return nil // fresh
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if len(data) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var list []radio.Station
|
|
|
|
|
if err := json.Unmarshal(data, &list); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
f.mu.Lock()
|
|
|
|
|
defer f.mu.Unlock()
|
|
|
|
|
f.stations = make(map[string]radio.Station, len(list))
|
|
|
|
|
for _, s := range list {
|
|
|
|
|
if s.Url != "" {
|
|
|
|
|
f.stations[s.Url] = s
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save persists if dirty.
|
|
|
|
|
func (f *Favorites) Save() error {
|
|
|
|
|
f.mu.RLock()
|
|
|
|
|
defer f.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
if !f.dirty {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
list := make([]radio.Station, 0, len(f.stations))
|
|
|
|
|
for _, s := range f.stations {
|
|
|
|
|
list = append(list, s)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pretty for humans
|
|
|
|
|
b, err := json.MarshalIndent(list, "", " ")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tmp := f.path + ".tmp"
|
|
|
|
|
if err := os.WriteFile(tmp, b, 0644); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := os.Rename(tmp, f.path); err != nil {
|
|
|
|
|
_ = os.Remove(tmp)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
f.dirty = false
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add inserts or updates a station (by URL). Marks dirty.
|
|
|
|
|
func (f *Favorites) Add(s radio.Station) {
|
|
|
|
|
if s.Url == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
f.mu.Lock()
|
|
|
|
|
defer f.mu.Unlock()
|
|
|
|
|
f.stations[s.Url] = s
|
|
|
|
|
f.dirty = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove by URL.
|
|
|
|
|
func (f *Favorites) Remove(url string) {
|
|
|
|
|
f.mu.Lock()
|
|
|
|
|
defer f.mu.Unlock()
|
|
|
|
|
if _, ok := f.stations[url]; ok {
|
|
|
|
|
delete(f.stations, url)
|
|
|
|
|
f.dirty = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Contains reports if a station (by URL) is favorited.
|
|
|
|
|
func (f *Favorites) Contains(url string) bool {
|
|
|
|
|
f.mu.RLock()
|
|
|
|
|
defer f.mu.RUnlock()
|
|
|
|
|
_, ok := f.stations[url]
|
|
|
|
|
return ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// List returns a copy of all favorited stations (order not guaranteed; caller can sort).
|
|
|
|
|
func (f *Favorites) List() []radio.Station {
|
|
|
|
|
f.mu.RLock()
|
|
|
|
|
defer f.mu.RUnlock()
|
|
|
|
|
out := make([]radio.Station, 0, len(f.stations))
|
|
|
|
|
for _, s := range f.stations {
|
|
|
|
|
out = append(out, s)
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 10:59:41 +00:00
|
|
|
// SetVolume sets the preferred volume for a favorited station (by URL).
|
|
|
|
|
// Does nothing if the station is not currently favorited. Marks dirty.
|
|
|
|
|
func (f *Favorites) SetVolume(url string, vol int) {
|
|
|
|
|
if vol < 0 {
|
|
|
|
|
vol = 0
|
|
|
|
|
}
|
|
|
|
|
if vol > 100 {
|
|
|
|
|
vol = 100
|
|
|
|
|
}
|
|
|
|
|
f.mu.Lock()
|
|
|
|
|
defer f.mu.Unlock()
|
|
|
|
|
if s, ok := f.stations[url]; ok {
|
|
|
|
|
s.Volume = vol
|
|
|
|
|
f.stations[url] = s
|
|
|
|
|
f.dirty = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetVolume returns the preferred volume for a favorited station, or 0 if none set
|
|
|
|
|
// or the station is not favorited.
|
|
|
|
|
func (f *Favorites) GetVolume(url string) int {
|
|
|
|
|
f.mu.RLock()
|
|
|
|
|
defer f.mu.RUnlock()
|
|
|
|
|
if s, ok := f.stations[url]; ok {
|
|
|
|
|
return s.Volume
|
|
|
|
|
}
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
// Path returns the JSON file location (for diagnostics / "config path" style cmds).
|
|
|
|
|
func (f *Favorites) Path() string {
|
|
|
|
|
return f.path
|
|
|
|
|
}
|