Skip to main content
Glama
radio-discovery.ts15.9 kB
/** * Navidrome MCP Server - Radio Discovery Tools * Copyright (C) 2025 * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ import { z } from 'zod'; import type { ExternalRadioStationDTO, DiscoverRadioStationsResponse, RadioFiltersResponse, ClickRadioStationResponse, VoteRadioStationResponse } from '../types/index.js'; import type { Config } from '../config.js'; import { validateRadioStream } from './radio-validation.js'; import { DISCOVERY_VALIDATION_TIMEOUT } from '../constants/timeouts.js'; import { DEFAULT_VALUES } from '../constants/defaults.js'; import type { NavidromeClient } from '../client/navidrome-client.js'; import { ErrorFormatter } from '../utils/error-formatter.js'; const MAX_LIMIT = 500; /** * Radio Browser API station response */ interface RadioBrowserStation { stationuuid: string; name: string; url: string; url_resolved?: string; homepage?: string; favicon?: string; tags?: string; country?: string; countrycode?: string; state?: string; language?: string; languagecodes?: string; votes?: number; lastchangetime?: string; lastchangetime_iso8601?: string; codec?: string; bitrate?: number; hls?: number; lastcheckok?: number; lastchecktime?: string; lastchecktime_iso8601?: string; lastcheckoktime?: string; lastcheckoktime_iso8601?: string; lastlocalchecktime?: string; lastlocalchecktime_iso8601?: string; clicktimestamp?: string; clicktimestamp_iso8601?: string; clickcount?: number; clicktrend?: number; ssl_error?: number; geo_lat?: number | null; geo_long?: number | null; geo_distance?: number | null; has_extended_info?: boolean; } /** * Radio Browser API tag response */ interface RadioBrowserTag { name: string; stationcount: number; } /** * Radio Browser API country response */ interface RadioBrowserCountry { name: string; iso_3166_1: string; stationcount: number; } /** * Radio Browser API language response */ interface RadioBrowserLanguage { name: string; iso_639?: string; stationcount: number; } /** * Radio Browser API codec response */ interface RadioBrowserCodec { name: string; stationcount: number; } /** * Radio Browser API click/vote response */ interface RadioBrowserActionResponse { ok: boolean; message?: string; url?: string; } /** * Schema for discovering radio stations */ const DiscoverRadioStationsArgsSchema = z.object({ query: z.string().optional(), tag: z.string().optional(), countryCode: z.string().optional(), language: z.string().optional(), codec: z.string().optional(), bitrateMin: z.number().min(0).optional(), isHttps: z.boolean().optional(), order: z.enum(['name', 'votes', 'clickcount', 'bitrate', 'lastcheckok', 'random']).default('votes'), reverse: z.boolean().default(true), offset: z.number().min(0).optional(), limit: z.number().min(1).max(MAX_LIMIT).default(DEFAULT_VALUES.RADIO_DISCOVERY_LIMIT), hideBroken: z.boolean().default(true) }); /** * Schema for getting radio filter options */ const GetRadioFiltersArgsSchema = z.object({ kinds: z.array(z.enum(['tags', 'countries', 'languages', 'codecs'])).default(['tags', 'countries', 'languages', 'codecs']) }); /** * Schema for getting station by UUID */ const GetStationByUuidArgsSchema = z.object({ stationUuid: z.string() }); /** * Schema for clicking a station */ const ClickStationArgsSchema = z.object({ stationUuid: z.string() }); /** * Schema for voting for a station */ const VoteStationArgsSchema = z.object({ stationUuid: z.string() }); /** * Convert Radio Browser API response to our DTO */ function mapStationToDTO(station: RadioBrowserStation): ExternalRadioStationDTO { const dto: ExternalRadioStationDTO = { stationUuid: station.stationuuid, name: station.name, playUrl: station.url_resolved ?? station.url, tags: (station.tags !== null && station.tags !== undefined && station.tags !== '') ? station.tags.split(',').filter((t: string) => t.trim()) : [], languageCodes: (station.languagecodes !== null && station.languagecodes !== undefined && station.languagecodes !== '') ? station.languagecodes.split(',').filter((l: string) => l.trim()) : [], hls: Boolean(station.hls), votes: station.votes ?? 0, clickCount: station.clickcount ?? 0 }; // Only include essential fields for cleaner LLM context if (station.homepage !== null && station.homepage !== undefined && station.homepage !== '') dto.homepage = station.homepage; if (station.countrycode !== null && station.countrycode !== undefined && station.countrycode !== '') dto.countryCode = station.countrycode; if (station.codec !== null && station.codec !== undefined && station.codec !== '') dto.codec = station.codec; if (station.bitrate !== undefined) dto.bitrate = station.bitrate; // Skip favicon and lastCheckTime to reduce context size return dto; } /** * Validate discovered radio stations */ async function validateDiscoveredStations( client: NavidromeClient, stations: ExternalRadioStationDTO[] ): Promise<ExternalRadioStationDTO[]> { // Validate up to 8 stations with individual timeouts to handle rate limiting const maxValidations = Math.min(stations.length, 8); const stationsToValidate = stations.slice(0, maxValidations); const remainingStations = stations.slice(maxValidations); // Process validations with individual timeouts, not in parallel to avoid rate limiting const validatedStations: ExternalRadioStationDTO[] = []; for (const station of stationsToValidate) { try { // Each validation gets its own timeout - no overall time limit const validationResult = await validateRadioStream(client, { url: station.playUrl, timeout: DISCOVERY_VALIDATION_TIMEOUT }); const validation = { validated: true, isValid: validationResult.success, status: validationResult.success ? 'OK' : 'FAIL', duration: validationResult.testDuration }; validatedStations.push({ ...station, validation }); } catch { // If validation fails, mark as failed but include the station validatedStations.push({ ...station, validation: { validated: true, isValid: false, status: 'FAIL', } }); } } // Add remaining stations without validation return [...validatedStations, ...remainingStations]; } /** * Discover radio stations via Radio Browser API */ export async function discoverRadioStations( config: Config, client: NavidromeClient, args: unknown ): Promise<DiscoverRadioStationsResponse> { const params = DiscoverRadioStationsArgsSchema.parse(args); try { const url = new URL('/json/stations/search', config.radioBrowserBase); // Map parameters to Radio Browser API format if (params.query !== null && params.query !== undefined && params.query !== '') url.searchParams.set('name', params.query); if (params.tag !== null && params.tag !== undefined && params.tag !== '') url.searchParams.set('tag', params.tag); if (params.countryCode !== null && params.countryCode !== undefined && params.countryCode !== '') url.searchParams.set('countrycode', params.countryCode); if (params.language !== null && params.language !== undefined && params.language !== '') url.searchParams.set('language', params.language); if (params.codec !== null && params.codec !== undefined && params.codec !== '') url.searchParams.set('codec', params.codec); if (params.bitrateMin !== undefined) url.searchParams.set('bitrateMin', String(params.bitrateMin)); if (params.isHttps !== undefined) url.searchParams.set('is_https', params.isHttps ? 'true' : 'false'); if (params.order) url.searchParams.set('order', params.order); if (params.reverse !== undefined) url.searchParams.set('reverse', params.reverse ? 'true' : 'false'); if (params.offset !== undefined) url.searchParams.set('offset', String(params.offset)); url.searchParams.set('limit', String(params.limit)); url.searchParams.set('hidebroken', params.hideBroken ? 'true' : 'false'); const response = await fetch(url.toString(), { headers: { 'User-Agent': config.radioBrowserUserAgent ?? 'Navidrome-MCP/1.0', 'Accept': 'application/json' } }); if (!response.ok) { throw new Error(ErrorFormatter.radioBrowserApi(response)); } const data = await response.json() as RadioBrowserStation[]; const stations = data.map(mapStationToDTO); // Automatically validate all discovered stations const validatedStations = await validateDiscoveredStations(client, stations); // Create validation summary const validatedCount = validatedStations.filter(s => s.validation?.validated === true).length; const workingCount = validatedStations.filter(s => s.validation?.isValid === true).length; const result: DiscoverRadioStationsResponse = { stations: validatedStations, source: 'radio-browser', mirrorUsed: config.radioBrowserBase }; if (validatedCount > 0) { result.validationSummary = { totalStations: stations.length, validatedStations: validatedCount, workingStations: workingCount, message: `Auto-validated first ${validatedCount} stations: ${workingCount} working, ${validatedCount - workingCount} not working.` }; } return result; } catch (error) { throw new Error(ErrorFormatter.toolExecution('discoverRadioStations', error)); } } /** * Get available filter options for radio station discovery */ export async function getRadioFilters(config: Config, args: unknown): Promise<RadioFiltersResponse> { const params = GetRadioFiltersArgsSchema.parse(args); const result: RadioFiltersResponse = {}; try { const fetchPromises: Promise<void>[] = []; if (params.kinds.includes('tags')) { fetchPromises.push( fetch(`${config.radioBrowserBase}/json/tags`, { headers: { 'User-Agent': config.radioBrowserUserAgent ?? 'Navidrome-MCP/1.0', 'Accept': 'application/json' } }) .then(res => res.json()) .then((data) => { result.tags = (data as RadioBrowserTag[]) .slice(0, 100) .map(t => ({ name: t.name, stationCount: t.stationcount })); }) ); } if (params.kinds.includes('countries')) { fetchPromises.push( fetch(`${config.radioBrowserBase}/json/countries`, { headers: { 'User-Agent': config.radioBrowserUserAgent ?? 'Navidrome-MCP/1.0', 'Accept': 'application/json' } }) .then(res => res.json()) .then((data) => { result.countries = (data as RadioBrowserCountry[]) .slice(0, 100) .map(c => ({ code: c.iso_3166_1, name: c.name, stationCount: c.stationcount })); }) ); } if (params.kinds.includes('languages')) { fetchPromises.push( fetch(`${config.radioBrowserBase}/json/languages`, { headers: { 'User-Agent': config.radioBrowserUserAgent ?? 'Navidrome-MCP/1.0', 'Accept': 'application/json' } }) .then(res => res.json()) .then((data) => { result.languages = (data as RadioBrowserLanguage[]) .slice(0, 100) .map(l => ({ code: l.iso_639 ?? l.name, name: l.name, stationCount: l.stationcount })); }) ); } if (params.kinds.includes('codecs')) { fetchPromises.push( fetch(`${config.radioBrowserBase}/json/codecs`, { headers: { 'User-Agent': config.radioBrowserUserAgent ?? 'Navidrome-MCP/1.0', 'Accept': 'application/json' } }) .then(res => res.json()) .then((data) => { result.codecs = (data as RadioBrowserCodec[]) .slice(0, 50) .map(c => ({ name: c.name, stationCount: c.stationcount })); }) ); } await Promise.all(fetchPromises); return result; } catch (error) { throw new Error(ErrorFormatter.toolExecution('getRadioFilters', error)); } } /** * Get a specific radio station by UUID */ export async function getStationByUuid(config: Config, args: unknown): Promise<ExternalRadioStationDTO> { const params = GetStationByUuidArgsSchema.parse(args); try { const url = `${config.radioBrowserBase}/json/stations/byuuid?uuids=${encodeURIComponent(params.stationUuid)}`; const response = await fetch(url, { headers: { 'User-Agent': config.radioBrowserUserAgent ?? 'Navidrome-MCP/1.0', 'Accept': 'application/json' } }); if (!response.ok) { throw new Error(ErrorFormatter.radioBrowserApi(response)); } const data = await response.json() as RadioBrowserStation[]; if (data === null || data === undefined || data.length === 0) { throw new Error(ErrorFormatter.notFound('Station', params.stationUuid)); } const firstStation = data[0]; if (!firstStation) { throw new Error(ErrorFormatter.notFound('Station', params.stationUuid)); } return mapStationToDTO(firstStation); } catch (error) { throw new Error(ErrorFormatter.toolExecution('getStationByUuid', error)); } } /** * Register a play click for a station (helps with popularity metrics) */ export async function clickStation(config: Config, args: unknown): Promise<ClickRadioStationResponse> { const params = ClickStationArgsSchema.parse(args); try { const url = `${config.radioBrowserBase}/json/url/${encodeURIComponent(params.stationUuid)}`; const response = await fetch(url, { headers: { 'User-Agent': config.radioBrowserUserAgent ?? 'Navidrome-MCP/1.0', 'Accept': 'application/json' } }); if (!response.ok) { throw new Error(ErrorFormatter.radioBrowserApi(response)); } const data = await response.json() as RadioBrowserActionResponse; return { ok: Boolean(data.ok), playUrl: data.url ?? '', message: data.message ?? 'Click registered successfully' }; } catch (error) { throw new Error(ErrorFormatter.toolExecution('clickStation', error)); } } /** * Vote for a radio station */ export async function voteStation(config: Config, args: unknown): Promise<VoteRadioStationResponse> { const params = VoteStationArgsSchema.parse(args); try { const url = `${config.radioBrowserBase}/json/vote/${encodeURIComponent(params.stationUuid)}`; const response = await fetch(url, { headers: { 'User-Agent': config.radioBrowserUserAgent ?? 'Navidrome-MCP/1.0', 'Accept': 'application/json' } }); if (!response.ok) { throw new Error(ErrorFormatter.radioBrowserApi(response)); } const data = await response.json() as RadioBrowserActionResponse; return { ok: Boolean(data.ok), message: data.message ?? 'Vote registered successfully' }; } catch (error) { throw new Error(ErrorFormatter.toolExecution('voteStation', error)); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Blakeem/Navidrome-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server