package radio import ( "context" "encoding/json" "errors" "fmt" "io" "math/rand" "net" "net/http" "net/url" "sync" "time" "github.com/gmgauthier/gostations/internal/config" ) // Station is the normalized record returned from radio-browser searches. type Station struct { Name string `json:"name"` Codec string `json:"codec"` Bitrate json.Number `json:"bitrate"` Countrycode string `json:"countrycode"` Tags string `json:"tags"` Url string `json:"url"` Lastcheck int `json:"lastcheckok"` // Future: UUID string `json:"stationuuid"` etc for stable favorites. } var ( apiHostCache string apiHostOnce sync.Once httpClient = &http.Client{Timeout: 12 * time.Second} ) // Search queries the radio-browser API (with fixes for the old panics and error handling). // It respects the cached config for host + limit. func Search(ctx context.Context, name, country, state, tags string, includeDown bool) ([]Station, error) { if err := config.Init(); err != nil { return nil, fmt.Errorf("config: %w", err) } params := url.Values{} if name != "" { params.Add("name", name) } if country != "" { params.Add("country", country) } if state != "" { params.Add("state", state) } if tags != "" { params.Add("tag", tags) } host, err := getAPIHost(ctx) if err != nil { return nil, fmt.Errorf("resolve api host: %w", err) } qstr := params.Encode() limit := config.MaxItems() if limit <= 0 { limit = 9999 } urlstr := fmt.Sprintf("https://%s/json/stations/search?%s&limit=%d", host, qstr, limit) if qstr == "" { urlstr = fmt.Sprintf("https://%s/json/stations/search?limit=%d", host, limit) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlstr, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "gostations/1 (github.com/gmgauthier/gostations)") resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("http get %s: %w", urlstr, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("api returned %d: %s", resp.StatusCode, string(body)) } var data []Station if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, fmt.Errorf("decode stations: %w", err) } if includeDown { return data, nil } return pruneStations(data), nil } func pruneStations(stations []Station) []Station { filtered := stations[:0] for _, s := range stations { if s.Lastcheck == 1 { filtered = append(filtered, s) } } return filtered } // getAPIHost resolves (with caching + error handling) the dynamic host from the configured "all.api..." entry. // This fixes the old panics in RandomIP / reverseLookup / nslookup. func getAPIHost(ctx context.Context) (string, error) { var resolveErr error apiHostOnce.Do(func() { apiHostCache, resolveErr = resolveAPIHost(ctx) }) if apiHostCache == "" && resolveErr != nil { return "", resolveErr } if apiHostCache == "" { // last resort fallback (common public endpoint) return "de1.api.radio-browser.info", nil } return apiHostCache, nil } func resolveAPIHost(ctx context.Context) (string, error) { host := config.API() if host == "" { host = "all.api.radio-browser.info" } ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return "", fmt.Errorf("lookup %s: %w", host, err) } if len(ips) == 0 { return "", errors.New("no IPs resolved for api host") } // simple random among A/AAAA (better than old discarded NewSource) randIdx := rand.Intn(len(ips)) ip := ips[randIdx].IP names, err := net.DefaultResolver.LookupAddr(ctx, ip.String()) if err != nil || len(names) == 0 { // fallback to the original host if reverse fails (some IPs may be private or no PTR) return host, nil } return names[0], nil // usually ends with . } // ResetAPIHostCache is exposed for tests. func ResetAPIHostCache() { apiHostOnce = sync.Once{} apiHostCache = "" }