package radio import ( "errors" "fmt" "io/fs" "os" "path/filepath" "strconv" "strings" "github.com/bjarneo/cliamp/internal/appdir" "github.com/bjarneo/cliamp/internal/tomlutil" ) const favoritesFile = "radio_favorites.toml" // Favorites manages a persistent set of favorite radio stations. type Favorites struct { stations []CatalogStation byURL map[string]struct{} path string } // LoadFavorites reads favorites from ~/.config/cliamp/radio_favorites.toml. func LoadFavorites() *Favorites { f := &Favorites{byURL: make(map[string]struct{})} dir, err := appdir.Dir() if err != nil { return f } f.path = filepath.Join(dir, favoritesFile) stations, err := loadFavoriteStations(f.path) if err != nil { return f } f.stations = stations for _, s := range stations { f.byURL[s.URL] = struct{}{} } return f } // Stations returns all favorite stations. func (f *Favorites) Stations() []CatalogStation { return f.stations } // Contains returns true if the station URL is in favorites. func (f *Favorites) Contains(url string) bool { _, ok := f.byURL[url] return ok } // Add adds a station to favorites and saves to disk. func (f *Favorites) Add(s CatalogStation) error { if f.Contains(s.URL) { return nil } f.stations = append(f.stations, s) f.byURL[s.URL] = struct{}{} return f.save() } // Remove removes a station by URL from favorites and saves to disk. func (f *Favorites) Remove(url string) error { if !f.Contains(url) { return nil } for i, s := range f.stations { if s.URL == url { f.stations = append(f.stations[:i], f.stations[i+1:]...) break } } delete(f.byURL, url) return f.save() } func (f *Favorites) save() error { if f.path == "" { dir, err := appdir.Dir() if err != nil { return err } f.path = filepath.Join(dir, favoritesFile) } if err := os.MkdirAll(filepath.Dir(f.path), 0o755); err != nil { return err } // Build the full content in memory (writes to a Builder can't fail), then // write a temp file and rename so a partial/failed write can never truncate // or corrupt the existing favorites file. var b strings.Builder for i, s := range f.stations { if i > 0 { fmt.Fprintln(&b) } fmt.Fprintln(&b, "[[station]]") fmt.Fprintf(&b, "name = %q\n", s.Name) fmt.Fprintf(&b, "url = %q\n", s.URL) if s.Country != "" { fmt.Fprintf(&b, "country = %q\n", s.Country) } if s.Bitrate > 0 { fmt.Fprintf(&b, "bitrate = %d\n", s.Bitrate) } if s.Codec != "" { fmt.Fprintf(&b, "codec = %q\n", s.Codec) } if s.Tags != "" { fmt.Fprintf(&b, "tags = %q\n", s.Tags) } if s.Homepage != "" { fmt.Fprintf(&b, "homepage = %q\n", s.Homepage) } } tmp := f.path + ".tmp" if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil { return err } return os.Rename(tmp, f.path) } // loadFavoriteStations parses the favorites TOML file. func loadFavoriteStations(path string) ([]CatalogStation, error) { data, err := os.ReadFile(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, nil } return nil, err } var stations []CatalogStation tomlutil.ParseSections(data, "station", func(f map[string]string) { s := CatalogStation{ Name: f["name"], URL: f["url"], Country: f["country"], Codec: f["codec"], Tags: f["tags"], Homepage: f["homepage"], } if n, err := strconv.Atoi(f["bitrate"]); err == nil { s.Bitrate = n } if s.Name != "" && s.URL != "" { stations = append(stations, s) } }) return stations, nil }