/**
* Navidrome MCP Server - Radio Validation Core Module
* 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 { NavidromeClient } from '../../client/navidrome-client.js';
import {
SINGLE_VALIDATION_TIMEOUT,
MIN_VALIDATION_TIMEOUT,
MAX_VALIDATION_TIMEOUT,
} from '../../constants/timeouts.js';
import {
isAudioContentType,
extractStreamingHeaders,
detectAudioFormat,
} from './stream-detector.js';
import {
validateWithHead,
sampleAudioData,
type ValidationContext,
} from './network-validator.js';
import {
generateRecommendations,
type StreamValidationResult,
} from './recommendation-engine.js';
// Validation parameter schema
const ValidateStreamSchema = z.object({
url: z.string().url('URL must be a valid URL'),
timeout: z.number().min(MIN_VALIDATION_TIMEOUT).max(MAX_VALIDATION_TIMEOUT).optional().default(SINGLE_VALIDATION_TIMEOUT),
followRedirects: z.boolean().optional().default(true),
});
/**
* Validate a radio stream URL
*/
export async function validateRadioStream(
_client: NavidromeClient,
args: unknown
): Promise<StreamValidationResult> {
const startTime = Date.now();
const errors: string[] = [];
const warnings: string[] = [];
// Parse and validate input
let params;
try {
params = ValidateStreamSchema.parse(args);
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
url: String(args),
status: 'error',
streamingHeaders: {},
validation: {
httpAccessible: false,
hasAudioContentType: false,
hasStreamingHeaders: false,
audioDataDetected: false,
},
errors: [`Invalid parameters: ${error.issues.map((e: { message: string }) => e.message).join(', ')}`],
warnings: [],
recommendations: ['❌ Please provide a valid URL'],
testDuration: Date.now() - startTime,
};
}
throw error;
}
const context: ValidationContext = {
url: params.url,
startTime,
timeout: params.timeout,
followRedirects: params.followRedirects,
};
// Initialize result
const result: StreamValidationResult = {
success: false,
url: params.url,
status: 'invalid',
streamingHeaders: {},
validation: {
httpAccessible: false,
hasAudioContentType: false,
hasStreamingHeaders: false,
audioDataDetected: false,
},
errors,
warnings,
recommendations: [],
testDuration: 0,
};
// Add overall timeout protection
const overallController = new AbortController();
const overallTimeoutId = setTimeout(() => {
overallController.abort();
errors.push(`Validation timeout after ${params.timeout}ms`);
}, params.timeout);
let headResponse: Response | null = null;
let headError: string | null = null;
let buffer: Uint8Array | null = null;
let headers: Headers | null = null;
let sampleError: string | null = null;
try {
// Step 1: Try HEAD request first
const headResult = await validateWithHead(context);
headResponse = headResult.response;
headError = headResult.error;
if (headError !== null && headError !== undefined && headError !== '') {
warnings.push(headError);
}
// Step 2: Check if HEAD response gives us enough info to determine validity
let skipAudioSampling = false;
if (headResponse) {
const contentType = headResponse.headers.get('content-type');
const streamHeaders = extractStreamingHeaders(headResponse.headers);
// If we have clear audio content-type OR streaming headers, we can skip audio sampling
const hasAudioContentType = contentType !== null && contentType !== undefined && contentType !== '' && isAudioContentType(contentType);
const hasStreamingHeaders = Object.keys(streamHeaders).length > 0;
if (hasAudioContentType === true || hasStreamingHeaders === true) {
skipAudioSampling = true;
// Create a fake successful result for audio detection based on content-type
buffer = new Uint8Array([0xFF, 0xFB]); // Minimal buffer to satisfy validation logic
headers = headResponse.headers;
sampleError = null;
}
}
// Step 3: Sample audio data only if headers were inconclusive
if (!skipAudioSampling) {
const elapsed = Date.now() - startTime;
const remainingTime = params.timeout - elapsed;
if (remainingTime > 1000 && !overallController.signal.aborted) {
const sampleResult = await sampleAudioData(params.url, remainingTime);
buffer = sampleResult.buffer;
headers = sampleResult.headers ?? headResponse?.headers ?? null;
sampleError = sampleResult.error;
} else if (remainingTime <= 1000) {
sampleError = 'Insufficient time remaining for audio sampling';
}
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
errors.push('Validation aborted due to overall timeout');
} else {
errors.push(`Validation failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
} finally {
clearTimeout(overallTimeoutId);
}
if (sampleError !== null && sampleError !== undefined && sampleError !== '' && headResponse === null) {
errors.push(sampleError);
result.status = 'error';
}
// Use whichever response we got
const finalResponse = headResponse ?? (headers !== null && headers !== undefined ? { headers, ok: true, status: 200, url: params.url } : null);
if (finalResponse) {
result.httpStatus = finalResponse.status || 200;
result.validation.httpAccessible = true;
// Check for redirects
if (finalResponse.url && finalResponse.url !== params.url) {
result.finalUrl = finalResponse.url;
}
// Extract content type
const contentType = finalResponse.headers.get('content-type');
if (contentType !== null && contentType !== undefined && contentType !== '') {
result.contentType = contentType;
result.validation.hasAudioContentType = isAudioContentType(contentType);
if (!result.validation.hasAudioContentType) {
errors.push(`Non-audio content type: ${contentType}`);
}
}
// Extract streaming headers
result.streamingHeaders = extractStreamingHeaders(finalResponse.headers);
result.validation.hasStreamingHeaders = Object.keys(result.streamingHeaders).length > 0;
}
// Step 3: Detect audio format if we got data
if (buffer && buffer.length > 0) {
const audioFormat = await detectAudioFormat(buffer);
result.audioFormat = audioFormat;
result.validation.audioDataDetected = audioFormat.detected;
if (!audioFormat.detected && result.validation.hasAudioContentType) {
warnings.push('Could not detect audio format from data sample');
}
} else if (result.validation.httpAccessible) {
warnings.push('Could not sample audio data from stream');
}
// Determine overall success
result.success = result.validation.httpAccessible &&
(result.validation.hasAudioContentType ||
result.validation.audioDataDetected ||
result.validation.hasStreamingHeaders);
result.status = result.success ? 'valid' : (errors.length > 0 ? 'error' : 'invalid');
// Generate recommendations
result.recommendations = generateRecommendations(result);
// Set test duration
result.testDuration = Date.now() - startTime;
return result;
}