gostations/internal/radio/radio.go

161 lines
3.9 KiB
Go
Raw Permalink Normal View History

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 = ""
}