mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge 66000164385fe15d30a3021d9e74657fe6f9a570 into 23f3556371321faf199866989b906f2ef06a8034
This commit is contained in:
commit
8c092fb4b8
@ -1,6 +1,6 @@
|
|||||||
module github.com/navidrome/navidrome/plugins/cmd/ndpgen
|
module github.com/navidrome/navidrome/plugins/cmd/ndpgen
|
||||||
|
|
||||||
go 1.25
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/extism/go-pdk v1.1.3
|
github.com/extism/go-pdk v1.1.3
|
||||||
|
|||||||
@ -2,15 +2,20 @@ package nativeapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
|
"github.com/navidrome/navidrome/server/radiobrowser"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (api *Router) addRadioRoute(r chi.Router) {
|
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.Route("/radio", func(r chi.Router) {
|
||||||
r.Get("/", rest.GetAll(constructor))
|
r.Get("/", rest.GetAll(constructor))
|
||||||
r.Post("/", rest.Post(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.Route("/{id}", func(r chi.Router) {
|
||||||
r.Use(server.URLParamsMiddleware)
|
r.Use(server.URLParamsMiddleware)
|
||||||
r.Get("/", rest.Get(constructor))
|
r.Get("/", rest.Get(constructor))
|
||||||
@ -68,3 +75,50 @@ func (api *Router) deleteRadioImage() http.HandlerFunc {
|
|||||||
return api.ds.Radio(ctx).Put(radio, "UploadedImage")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -239,6 +239,13 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"playNow": "Play Now"
|
"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": {
|
"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'
|
} from 'react-admin'
|
||||||
import { Title } from '../common'
|
import { Title } from '../common'
|
||||||
import { urlValidate } from '../utils/validations'
|
import { urlValidate } from '../utils/validations'
|
||||||
|
import RadioBrowserSearch from './RadioBrowserSearch'
|
||||||
|
|
||||||
const RadioTitle = () => {
|
const RadioTitle = () => {
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
@ -23,6 +24,7 @@ const RadioCreate = (props) => {
|
|||||||
return (
|
return (
|
||||||
<Create title={<RadioTitle />} {...props}>
|
<Create title={<RadioTitle />} {...props}>
|
||||||
<SimpleForm redirect="list" variant={'outlined'}>
|
<SimpleForm redirect="list" variant={'outlined'}>
|
||||||
|
<RadioBrowserSearch />
|
||||||
<TextInput source="name" validate={[required()]} />
|
<TextInput source="name" validate={[required()]} />
|
||||||
<TextInput
|
<TextInput
|
||||||
type="url"
|
type="url"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user