diff --git a/plugins/cmd/ndpgen/go.mod b/plugins/cmd/ndpgen/go.mod index af9fce441..4af62658e 100644 --- a/plugins/cmd/ndpgen/go.mod +++ b/plugins/cmd/ndpgen/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome/plugins/cmd/ndpgen -go 1.25 +go 1.25.0 require ( github.com/extism/go-pdk v1.1.3 diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go index 701c6c926..3a734c455 100644 --- a/server/nativeapi/radios.go +++ b/server/nativeapi/radios.go @@ -2,15 +2,20 @@ package nativeapi import ( "context" + "encoding/json" "errors" "io" "net/http" + "strconv" + "strings" + "time" "github.com/deluan/rest" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/server/radiobrowser" ) func (api *Router) addRadioRoute(r chi.Router) { @@ -20,6 +25,8 @@ func (api *Router) addRadioRoute(r chi.Router) { r.Route("/radio", func(r chi.Router) { r.Get("/", rest.GetAll(constructor)) r.Post("/", rest.Post(constructor)) + r.Get("/browser/search", api.searchRadioBrowser()) + r.Post("/browser/click", api.radioBrowserClick()) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) r.Get("/", rest.Get(constructor)) @@ -68,3 +75,50 @@ func (api *Router) deleteRadioImage() http.HandlerFunc { return api.ds.Radio(ctx).Put(radio, "UploadedImage") }) } + +func (api *Router) searchRadioBrowser() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + limit := 0 + if ls := strings.TrimSpace(r.URL.Query().Get("limit")); ls != "" { + if n, err := strconv.Atoi(ls); err == nil { + limit = n + } + } + stations, err := radiobrowser.Search(r.Context(), q, limit) + if err != nil { + if errors.Is(err, radiobrowser.ErrQueryTooShort) || errors.Is(err, radiobrowser.ErrQueryTooLong) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"stations": stations}) + } +} + +func (api *Router) radioBrowserClick() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + StreamURL string `json:"streamUrl"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + streamURL := strings.TrimSpace(body.StreamURL) + if streamURL == "" { + http.Error(w, "streamUrl required", http.StatusBadRequest) + return + } +go func(u string) { + defer func() { _ = recover() }() + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + radiobrowser.NotifyClick(ctx, u) + }(streamURL) + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/radiobrowser/radiobrowser.go b/server/radiobrowser/radiobrowser.go new file mode 100644 index 000000000..03da2d1b9 --- /dev/null +++ b/server/radiobrowser/radiobrowser.go @@ -0,0 +1,191 @@ +// Package radiobrowser queries the community Radio Browser API (https://api.radio-browser.info). +package radiobrowser + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/navidrome/navidrome/consts" +) + +const ( + defaultLimit = 30 + maxLimit = 100 + minQueryLen = 2 + maxQueryLen = 200 +) + +// Station is a minimal subset returned to the Navidrome UI. +type Station struct { + StationUUID string `json:"stationuuid"` + Name string `json:"name"` + StreamURL string `json:"streamUrl"` + HomePageURL string `json:"homePageUrl"` +} + +type apiStation struct { + Name string `json:"name"` + URL string `json:"url"` + URLResolved string `json:"url_resolved"` + Homepage string `json:"homepage"` + StationUUID string `json:"stationuuid"` +} + +// Sentinel errors for query validation. Use errors.Is to detect them. +var ( + ErrQueryTooShort = errors.New("query too short") + ErrQueryTooLong = errors.New("query too long") +) + +var fallbackAPIHosts = []string{ + "de1.api.radio-browser.info", + "nl1.api.radio-browser.info", + "at1.api.radio-browser.info", + "fi1.api.radio-browser.info", +} + +// APIHosts returns hostnames of Radio Browser API servers (DNS with static fallback). +func APIHosts() []string { + ips, err := net.LookupIP("all.api.radio-browser.info") + if err != nil || len(ips) == 0 { + return append([]string(nil), fallbackAPIHosts...) + } + seen := map[string]struct{}{} + var hosts []string + for _, ip := range ips { + names, err := net.LookupAddr(ip.String()) + if err != nil { + continue + } + for _, n := range names { + h := strings.TrimSuffix(n, ".") + if h == "" { + continue + } + if _, ok := seen[h]; ok { + continue + } + seen[h] = struct{}{} + hosts = append(hosts, h) + } + } + if len(hosts) == 0 { + return append([]string(nil), fallbackAPIHosts...) + } + return hosts +} + +func shuffleHosts(hosts []string) []string { + out := append([]string(nil), hosts...) + rand.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] }) + return out +} + +// Search queries station search on the Radio Browser network. +func Search(ctx context.Context, rawQuery string, limit int) ([]Station, error) { + q := strings.TrimSpace(rawQuery) + if len(q) < minQueryLen { + return nil, fmt.Errorf("query too short (min %d characters): %w", minQueryLen, ErrQueryTooShort) + } + if len(q) > maxQueryLen { + return nil, fmt.Errorf("query too long (max %d characters): %w", maxQueryLen, ErrQueryTooLong) + } + if limit <= 0 { + limit = defaultLimit + } + if limit > maxLimit { + limit = maxLimit + } + + path := "/json/stations/search?name=" + url.QueryEscape(q) + + "&limit=" + fmt.Sprintf("%d", limit) + "&order=votes&reverse=true" + + body, err := get(ctx, path) + if err != nil { + return nil, err + } + + var raw []apiStation + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parse stations: %w", err) + } + + return normalizeStations(raw), nil +} + +func normalizeStations(raw []apiStation) []Station { + out := make([]Station, 0, len(raw)) + for _, s := range raw { + stream := strings.TrimSpace(s.URLResolved) + if stream == "" { + stream = strings.TrimSpace(s.URL) + } + if stream == "" { + continue + } + out = append(out, Station{ + StationUUID: s.StationUUID, + Name: strings.TrimSpace(s.Name), + StreamURL: stream, + HomePageURL: strings.TrimSpace(s.Homepage), + }) + } + return out +} + +// NotifyClick reports a station stream URL click to the Radio Browser API (best-effort). +func NotifyClick(ctx context.Context, streamURL string) { + u := strings.TrimSpace(streamURL) + if u == "" { + return + } + path := "/json/url?url=" + url.QueryEscape(u) + _, _ = get(ctx, path) +} + +// Shared client for Radio Browser requests: safe for concurrent use and reuses TCP connections. +var apiHTTPClient = &http.Client{Timeout: 20 * time.Second} + +func get(ctx context.Context, path string) ([]byte, error) { + hosts := shuffleHosts(APIHosts()) + var lastErr error + for _, host := range hosts { + reqURL := "https://" + host + path + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + lastErr = err + continue + } + req.Header.Set("User-Agent", consts.HTTPUserAgent) + + resp, err := apiHTTPClient.Do(req) + if err != nil { + lastErr = err + continue + } + body, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + lastErr = readErr + continue + } + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("radio-browser %s: status %d", host, resp.StatusCode) + continue + } + return body, nil + } + if lastErr == nil { + lastErr = fmt.Errorf("no radio-browser server responded") + } + return nil, lastErr +} diff --git a/server/radiobrowser/radiobrowser_test.go b/server/radiobrowser/radiobrowser_test.go new file mode 100644 index 000000000..c6dfcb90e --- /dev/null +++ b/server/radiobrowser/radiobrowser_test.go @@ -0,0 +1,39 @@ +package radiobrowser + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestNormalizeStations(t *testing.T) { + raw := []apiStation{ + {Name: "A", URLResolved: "https://a.example/stream", Homepage: "https://a.example", StationUUID: "1"}, + {Name: "B", URL: "http://b-only", StationUUID: "2"}, + {Name: "skip", StationUUID: "3"}, + } + got := normalizeStations(raw) + if len(got) != 2 { + t.Fatalf("len %d, want 2", len(got)) + } + if got[0].StreamURL != "https://a.example/stream" || got[0].HomePageURL != "https://a.example" { + t.Fatalf("first: %+v", got[0]) + } + if got[1].StreamURL != "http://b-only" { + t.Fatalf("second stream: %q", got[1].StreamURL) + } +} + +func TestSearchSentinelErrors(t *testing.T) { + ctx := context.Background() + _, err := Search(ctx, "x", 10) + if !errors.Is(err, ErrQueryTooShort) { + t.Fatalf("short query: want ErrQueryTooShort, got %v", err) + } + long := strings.Repeat("a", maxQueryLen+1) + _, err = Search(ctx, long, 10) + if !errors.Is(err, ErrQueryTooLong) { + t.Fatalf("long query: want ErrQueryTooLong, got %v", err) + } +} diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 74fb23ab9..36c639a57 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -241,6 +241,13 @@ }, "actions": { "playNow": "Play Now" + }, + "radioBrowser": { + "hint": "Search the Radio Browser directory (radio-browser.info). Tap a result to fill the fields below.", + "placeholder": "Station name or keywords", + "search": "Search", + "noResults": "No stations found", + "error": "Radio Browser search failed" } }, "share": { diff --git a/ui/src/radio/RadioBrowserSearch.jsx b/ui/src/radio/RadioBrowserSearch.jsx new file mode 100644 index 000000000..14e52adf1 --- /dev/null +++ b/ui/src/radio/RadioBrowserSearch.jsx @@ -0,0 +1,131 @@ +import React, { useCallback, useState } from 'react' +import { + Box, + Button, + CircularProgress, + List, + ListItem, + ListItemText, + TextField, + Typography, +} from '@material-ui/core' +import { FormSpy } from 'react-final-form' +import { useNotify, useTranslate } from 'react-admin' +import { httpClient } from '../dataProvider' +import { REST_URL } from '../consts' + +const listStyle = { maxHeight: 280, overflow: 'auto', marginTop: 8 } + +const RadioBrowserSearchFields = ({ form }) => { + const translate = useTranslate() + const notify = useNotify() + const [query, setQuery] = useState('') + const [loading, setLoading] = useState(false) + const [results, setResults] = useState([]) + const [searched, setSearched] = useState(false) + + const runSearch = useCallback(async () => { + const q = query.trim() + if (q.length < 2) { + return + } + setLoading(true) + setSearched(true) + try { + const { json } = await httpClient( + `${REST_URL}/radio/browser/search?q=${encodeURIComponent(q)}`, + ) + setResults(Array.isArray(json.stations) ? json.stations : []) + } catch (_e) { + setResults([]) + notify('resources.radio.radioBrowser.error', 'warning') + } finally { + setLoading(false) + } + }, [query, notify]) + + const pickStation = useCallback( + async (station) => { + try { + await httpClient(`${REST_URL}/radio/browser/click`, { + method: 'POST', + body: JSON.stringify({ streamUrl: station.streamUrl }), + headers: new Headers({ 'Content-Type': 'application/json' }), + }) + } catch (_e) { + // best-effort popularity ping for radio-browser.info + } + form.change('name', station.name) + form.change('streamUrl', station.streamUrl) + form.change('homePageUrl', station.homePageUrl || '') + }, + [form], + ) + + return ( + + + {translate('resources.radio.radioBrowser.hint')} + + + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + runSearch() + } + }} + variant="outlined" + size="small" + fullWidth + disabled={loading} + /> + + + {searched && !loading && results.length === 0 && ( + + {translate('resources.radio.radioBrowser.noResults')} + + )} + {results.length > 0 && ( + + {results.map((s) => ( + pickStation(s)} + > + + + ))} + + )} + + ) +} + +const RadioBrowserSearch = () => ( + + {({ form }) => } + +) + +export default RadioBrowserSearch diff --git a/ui/src/radio/RadioCreate.jsx b/ui/src/radio/RadioCreate.jsx index 04398d2ad..1f00e445b 100644 --- a/ui/src/radio/RadioCreate.jsx +++ b/ui/src/radio/RadioCreate.jsx @@ -7,6 +7,7 @@ import { } from 'react-admin' import { Title } from '../common' import { urlValidate } from '../utils/validations' +import RadioBrowserSearch from './RadioBrowserSearch' const RadioTitle = () => { const translate = useTranslate() @@ -23,6 +24,7 @@ const RadioCreate = (props) => { return ( } {...props}> +