gostations/internal/data/favorites.go
Greg Gauthier a2eaa03090
All checks were successful
CI / Test (push) Successful in 55s
Release / Create Release (push) Successful in 2m11s
CI / Build (push) Successful in 47s
feat(favorites): per-favorite volume persistence (narrowed to favorited stations only); update Station and Favorites with Volume support and wiring in TUI
2026-06-06 11:59:41 +01:00

182 lines
3.8 KiB
Go

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
}
// 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
}
// Path returns the JSON file location (for diagnostics / "config path" style cmds).
func (f *Favorites) Path() string {
return f.path
}