Merge 66000164385fe15d30a3021d9e74657fe6f9a570 into 23f3556371321faf199866989b906f2ef06a8034

This commit is contained in:
Markus Busche 2026-04-03 00:30:43 +02:00 committed by GitHub
commit 8c092fb4b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 425 additions and 1 deletions

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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": {

View File

@ -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 (
<Box marginBottom={2}>
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
{translate('resources.radio.radioBrowser.hint')}
</Typography>
<Box display="flex" alignItems="flex-start" style={{ gap: 8 }}>
<TextField
label={translate('resources.radio.radioBrowser.placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
runSearch()
}
}}
variant="outlined"
size="small"
fullWidth
disabled={loading}
/>
<Button
variant="outlined"
color="primary"
onClick={runSearch}
disabled={loading}
>
{loading ? (
<CircularProgress size={20} />
) : (
translate('resources.radio.radioBrowser.search')
)}
</Button>
</Box>
{searched && !loading && results.length === 0 && (
<Typography
variant="body2"
color="textSecondary"
style={{ marginTop: 8 }}
>
{translate('resources.radio.radioBrowser.noResults')}
</Typography>
)}
{results.length > 0 && (
<List dense disablePadding style={listStyle}>
{results.map((s) => (
<ListItem
key={s.stationuuid || `${s.name}-${s.streamUrl}`}
button
onClick={() => pickStation(s)}
>
<ListItemText primary={s.name} secondary={s.streamUrl} />
</ListItem>
))}
</List>
)}
</Box>
)
}
const RadioBrowserSearch = () => (
<FormSpy subscription={{}}>
{({ form }) => <RadioBrowserSearchFields form={form} />}
</FormSpy>
)
export default RadioBrowserSearch

View File

@ -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 (
<Create title={<RadioTitle />} {...props}>
<SimpleForm redirect="list" variant={'outlined'}>
<RadioBrowserSearch />
<TextInput source="name" validate={[required()]} />
<TextInput
type="url"