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 }