mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge 5e017d41ec6235f08bc1be84658445dda108e60d into 7e16b6acb5c11e283fcd320a0abb82372a8ab0dd
This commit is contained in:
commit
17dc5172fd
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
191
server/radiobrowser/radiobrowser.go
Normal file
191
server/radiobrowser/radiobrowser.go
Normal 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
|
||||
}
|
||||
39
server/radiobrowser/radiobrowser_test.go
Normal file
39
server/radiobrowser/radiobrowser_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
131
ui/src/radio/RadioBrowserSearch.jsx
Normal file
131
ui/src/radio/RadioBrowserSearch.jsx
Normal 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
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user