161 lines
3.9 KiB
Go
161 lines
3.9 KiB
Go
|
|
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 = ""
|
||
|
|
}
|