Skip to main content
Glama
radio.ts12.9 kB
import crypto from 'crypto'; import type { Config } from '../config.js'; import type { RadioStationDTO, CreateRadioStationRequest, CreateRadioStationResponse, DeleteRadioStationResponse, ListRadioStationsResponse, RadioPlaybackInfo } from '../types/index.js'; import { logger } from '../utils/logger.js'; import { getMessageManager } from '../utils/message-manager.js'; import { BATCH_VALIDATION_TIMEOUT } from '../constants/timeouts.js'; import { ErrorFormatter } from '../utils/error-formatter.js'; interface SubsonicResponse<T = unknown> { 'subsonic-response': { status: string; version: string; type?: string; serverVersion?: string; error?: { code: number; message: string; }; internetRadioStations?: { internetRadioStation?: Array<{ id: string; name: string; streamUrl: string; homePageUrl?: string; }>; }; } & T; } function createSubsonicAuth(config: Config): URLSearchParams { const salt = crypto.randomBytes(16).toString('hex'); const token = crypto.createHash('md5').update(config.navidromePassword + salt).digest('hex'); return new URLSearchParams({ u: config.navidromeUsername, t: token, s: salt, v: '1.16.1', c: 'NavidromeMCP', f: 'json', }); } /** * List all internet radio stations */ export async function listRadioStations( config: Config, args: unknown ): Promise<ListRadioStationsResponse> { try { logger.debug('Listing radio stations', args); const authParams = createSubsonicAuth(config); const httpResponse = await fetch(`${config.navidromeUrl}/rest/getInternetRadioStations?${authParams.toString()}`); if (!httpResponse.ok) { throw new Error(ErrorFormatter.subsonicApi(httpResponse)); } const data = await httpResponse.json() as SubsonicResponse; if (data['subsonic-response'].status !== 'ok') { const errorMsg = data['subsonic-response'].error?.message ?? 'Unknown error'; throw new Error(ErrorFormatter.subsonicResponse(errorMsg)); } const radioStations = data['subsonic-response'].internetRadioStations?.internetRadioStation ?? []; const stations: RadioStationDTO[] = radioStations.map(station => { const stationDto: RadioStationDTO = { id: station.id, name: station.name, streamUrl: station.streamUrl, createdAt: new Date().toISOString(), // Subsonic API doesn't provide timestamps updatedAt: new Date().toISOString(), }; if (station.homePageUrl !== null && station.homePageUrl !== undefined && station.homePageUrl !== '') { stationDto.homePageUrl = station.homePageUrl; } return stationDto; }); // Get one-time message for radio list tip const messageManager = getMessageManager(); const tip = messageManager.getMessage('radio.list_tip'); const apiResponse: ListRadioStationsResponse = { stations, total: stations.length, }; // Add tip if this is the first time showing the list if (tip !== null && tip !== undefined && tip !== '') { apiResponse.tip = tip; } return apiResponse; } catch (error) { logger.error('Error listing radio stations:', error); throw new Error(ErrorFormatter.toolExecution('listRadioStations', error)); } } /** * Create radio stations - always processes as batch (single station = batch of 1) */ export async function createRadioStation( config: Config, args: unknown ): Promise<{ results: CreateRadioStationResponse[]; summary: string }> { const params = args as { stations: CreateRadioStationRequest[]; validateBeforeAdd?: boolean; }; // Validate input if (params.stations === null || params.stations === undefined || !Array.isArray(params.stations)) { throw new Error('Provide stations array. Example: {"stations": [{"name": "Station Name", "streamUrl": "http://stream.url"}]}'); } if (params.stations.length === 0) { throw new Error('At least one station must be provided in the stations array'); } logger.debug(`Creating ${params.stations.length} radio station(s)`); const results: CreateRadioStationResponse[] = []; let successCount = 0; let failedCount = 0; let validationFailedCount = 0; // Process each station for (const station of params.stations) { try { // Validate required fields if (!station.name || station.name.trim() === '') { results.push({ success: false, error: 'Station name is required and cannot be empty' }); failedCount++; continue; } if (!station.streamUrl || station.streamUrl.trim() === '') { results.push({ success: false, error: `Stream URL is required for station "${station.name}"` }); failedCount++; continue; } logger.debug('Creating radio station:', station); // Optional stream validation if (params.validateBeforeAdd === true) { const { validateRadioStream } = await import('./radio-validation.js'); const { NavidromeClient } = await import('../client/navidrome-client.js'); const client = new NavidromeClient(config); await client.initialize(); const validationResult = await validateRadioStream(client, { url: station.streamUrl, timeout: BATCH_VALIDATION_TIMEOUT }); if (!validationResult.success) { results.push({ success: false, error: `Stream validation failed for "${station.name}": ${validationResult.errors.join(', ')}` }); failedCount++; validationFailedCount++; continue; } } // Create the station via Subsonic API const authParams = createSubsonicAuth(config); authParams.set('streamUrl', station.streamUrl); authParams.set('name', station.name); if (station.homePageUrl !== null && station.homePageUrl !== undefined && station.homePageUrl.trim() !== '') { authParams.set('homePageUrl', station.homePageUrl); } const httpResponse = await fetch(`${config.navidromeUrl}/rest/createInternetRadioStation?${authParams.toString()}`, { method: 'POST', }); if (!httpResponse.ok) { throw new Error(ErrorFormatter.subsonicApi(httpResponse)); } const data = await httpResponse.json() as SubsonicResponse; if (data['subsonic-response'].status !== 'ok') { const errorMsg = data['subsonic-response'].error?.message ?? 'Unknown error'; throw new Error(ErrorFormatter.subsonicResponse(errorMsg)); } // Successfully created const createdStation: RadioStationDTO = { id: 'created', // Subsonic API doesn't return the created station details name: station.name, streamUrl: station.streamUrl, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; if (station.homePageUrl !== null && station.homePageUrl !== undefined && station.homePageUrl.trim() !== '') { createdStation.homePageUrl = station.homePageUrl; } results.push({ success: true, station: createdStation, }); successCount++; } catch (error) { logger.error(`Error creating radio station "${station.name}":`, error); results.push({ success: false, error: `Failed to add "${station.name}": ${error instanceof Error ? error.message : 'Unknown error'}` }); failedCount++; } } // Generate summary let summary = `Added ${successCount} of ${params.stations.length} station(s).`; if (failedCount > 0) { summary += ` ${failedCount} failed`; if (validationFailedCount > 0) { summary += ` (${validationFailedCount} due to validation)`; } summary += '.'; } // Add validation reminder for first-time users (single station only) if (successCount > 0 && params.stations.length === 1) { const messageManager = getMessageManager(); const validationReminder = messageManager.getMessage('radio.validation_reminder'); if (validationReminder !== null && validationReminder !== undefined && validationReminder !== '') { summary += ` ${validationReminder}`; } } return { results, summary }; } /** * Delete a radio station by ID */ export async function deleteRadioStation( config: Config, args: unknown ): Promise<DeleteRadioStationResponse> { try { const params = args as { id: string }; if (!params.id) { throw new Error('Radio station ID is required'); } logger.debug('Deleting radio station:', params.id); const authParams = createSubsonicAuth(config); authParams.set('id', params.id); const httpResponse = await fetch(`${config.navidromeUrl}/rest/deleteInternetRadioStation?${authParams.toString()}`, { method: 'POST', }); if (!httpResponse.ok) { throw new Error(ErrorFormatter.subsonicApi(httpResponse)); } const data = await httpResponse.json() as SubsonicResponse; if (data['subsonic-response'].status !== 'ok') { const errorMsg = data['subsonic-response'].error?.message ?? 'Unknown error'; throw new Error(ErrorFormatter.subsonicResponse(errorMsg)); } return { success: true, id: params.id, }; } catch (error) { logger.error('Error deleting radio station:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', }; } } /** * Get a specific radio station by ID */ export async function getRadioStation( config: Config, args: unknown ): Promise<RadioStationDTO> { try { const params = args as { id: string }; if (!params.id) { throw new Error('Radio station ID is required'); } logger.debug('Getting radio station:', params.id); // Since Subsonic API doesn't have a get single station endpoint, // we'll get all stations and filter by ID const allStations = await listRadioStations(config, {}); const station = allStations.stations.find(s => s.id === params.id); if (!station) { throw new Error(`Radio station with ID ${params.id} not found`); } return station; } catch (error) { logger.error('Error getting radio station:', error); throw new Error(ErrorFormatter.toolExecution('getRadioStation', error)); } } /** * Play a radio station (sets it in the queue) */ export async function playRadioStation( config: Config, args: unknown ): Promise<{ success: boolean; message: string; station?: RadioStationDTO }> { try { const params = args as { id: string }; if (!params.id) { throw new Error('Radio station ID is required'); } logger.debug('Playing radio station:', params.id); // First get the station details const station = await getRadioStation(config, params); // Note: The actual playback implementation would require additional Subsonic API calls // to set up playback. For now, we'll return success with the station info. // In a full implementation, this might involve: // 1. Calling setQueue to set up radio playback // 2. Using stream endpoint to start playing return { success: true, message: `Radio station "${station.name}" is ready to play. Stream URL: ${station.streamUrl}`, station, }; } catch (error) { logger.error('Error playing radio station:', error); return { success: false, message: error instanceof Error ? error.message : 'Unknown error', }; } } /** * Get current radio playback information */ export async function getCurrentRadioInfo( _config: Config, _args: unknown ): Promise<RadioPlaybackInfo> { try { logger.debug('Getting current radio info'); // Note: This implementation depends on Navidrome's current playback status API // The Subsonic API has getNowPlaying but it's for regular tracks, not radio streams // This is a placeholder implementation that would need to be adjusted based on // the actual Navidrome radio status endpoints // For now, we'll return a basic response indicating no radio is currently playing // In a real implementation, this would query the current playback status return { playing: false, metadata: { description: 'Radio playback status not available - use getNowPlaying for regular track status', }, }; } catch (error) { logger.error('Error getting current radio info:', error); return { playing: false, metadata: { description: `Error getting radio info: ${error instanceof Error ? error.message : 'Unknown 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