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 6c6592178..6ec9d5790 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -239,6 +239,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}>
+