import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:shelf/shelf.dart'; import '../cors_headers.dart'; Handler spellingAudioHandler({String? merriamWebsterApiKey, String? googleCloudApiKey, http.Client? httpClient}) { final http.Client client = httpClient ?? http.Client(); final Directory cacheDir = Directory('spelling_audio_cache'); if (!cacheDir.existsSync()) { cacheDir.createSync(recursive: true); } final File logFile = File('spelling_audio_errors.log'); Future logFailure({ required String word, required String apiName, required String url, int? statusCode, String? responseBody, String? errorDetails, }) async { final String timestamp = DateTime.now().toUtc().toIso8601String(); final StringBuffer buffer = StringBuffer() ..writeln('=== Audio Fetch Failure ===') ..writeln('Timestamp: $timestamp') ..writeln('Word: $word') ..writeln('API: $apiName') ..writeln('URL: $url'); if (statusCode != null) { buffer.writeln('Status Code: $statusCode'); } if (responseBody != null) { buffer.writeln('Response Body: $responseBody'); } if (errorDetails != null) { buffer.writeln('Error Details: $errorDetails'); } buffer.writeln('==========================='); try { await logFile.writeAsString( buffer.toString(), mode: FileMode.append, flush: true, ); } catch (e) { stderr.writeln('Failed to write spelling audio log: $e'); } } Future fetchAudioUrl(String word) async { final String query = word.trim().toLowerCase(); if (query.isEmpty) return null; final String url = 'https://api.dictionaryapi.dev/api/v2/entries/en/$query'; try { final response = await client.get(Uri.parse(url)).timeout(const Duration(seconds: 4)); if (response.statusCode == 200) { final List entries = jsonDecode(response.body) as List; for (final entry in entries) { if (entry is Map) { final phonetics = entry['phonetics']; if (phonetics is List) { for (final phonetic in phonetics) { if (phonetic is Map) { final String? audio = phonetic['audio'] as String?; if (audio != null && audio.isNotEmpty) { if (audio.startsWith('//')) { return 'https:$audio'; } return audio; } } } } } } await logFailure( word: word, apiName: 'dictionaryapi.dev', url: url, statusCode: 200, responseBody: response.body, errorDetails: 'No pronunciation audio URL found in response structure.', ); } else { await logFailure( word: word, apiName: 'dictionaryapi.dev', url: url, statusCode: response.statusCode, responseBody: response.body, ); } } catch (e) { stderr.writeln('Server fetchAudioUrl failed for word $word: $e'); await logFailure( word: word, apiName: 'dictionaryapi.dev', url: url, errorDetails: e.toString(), ); } return null; } Future fetchMerriamWebsterAudioUrl(String word) async { if (merriamWebsterApiKey == null || merriamWebsterApiKey.isEmpty) { return null; } final String cleanWord = word.trim().toLowerCase(); final String url = 'https://www.dictionaryapi.com/api/v3/references/collegiate/json/$cleanWord?key=$merriamWebsterApiKey'; try { final response = await client.get(Uri.parse(url)).timeout(const Duration(seconds: 4)); if (response.statusCode != 200) { await logFailure( word: word, apiName: 'Merriam-Webster', url: url, statusCode: response.statusCode, responseBody: response.body, ); return null; } final List json = jsonDecode(response.body) as List; if (json.isEmpty || json[0] is String) { await logFailure( word: word, apiName: 'Merriam-Webster', url: url, statusCode: 200, responseBody: response.body, errorDetails: 'Empty response or suggestions returned instead of entry details.', ); return null; } final entry = json[0] as Map; final hwi = entry['hwi'] as Map?; final prs = hwi?['prs'] as List?; if (prs == null || prs.isEmpty) { await logFailure( word: word, apiName: 'Merriam-Webster', url: url, statusCode: 200, responseBody: response.body, errorDetails: 'No pronunciation (prs) array found in entry.', ); return null; } final sound = prs[0]['sound'] as Map?; final String? audioFileName = sound?['audio']; if (audioFileName == null || audioFileName.isEmpty) { await logFailure( word: word, apiName: 'Merriam-Webster', url: url, statusCode: 200, responseBody: response.body, errorDetails: 'No audio filename found in sound block.', ); return null; } String subdirectory = 'number'; if (RegExp(r'^[a-zA-Z]').hasMatch(audioFileName)) { if (audioFileName.startsWith('bix')) { subdirectory = 'bix'; } else if (audioFileName.startsWith('gg')) { subdirectory = 'gg'; } else { subdirectory = audioFileName[0]; } } return 'https://media.merriam-webster.com/audio/prons/en/us/mp3/$subdirectory/$audioFileName.mp3'; } catch (e) { stderr.writeln('Server Merriam-Webster fetchAudioUrl failed for word $word: $e'); await logFailure( word: word, apiName: 'Merriam-Webster', url: url, errorDetails: e.toString(), ); } return null; } Future synthesizeGoogleCloudTts(String word) async { if (googleCloudApiKey == null || googleCloudApiKey.isEmpty) { return null; } final String url = 'https://texttospeech.googleapis.com/v1/text:synthesize?key=$googleCloudApiKey'; try { final response = await client.post( Uri.parse(url), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'input': {'text': word}, 'voice': { 'languageCode': 'en-US', 'name': 'en-US-Neural2-F', }, 'audioConfig': { 'audioEncoding': 'MP3', 'speakingRate': 0.85, } }), ).timeout(const Duration(seconds: 8)); if (response.statusCode == 200) { final Map data = jsonDecode(response.body) as Map; final String? audioContent = data['audioContent'] as String?; if (audioContent != null && audioContent.isNotEmpty) { return base64Decode(audioContent); } await logFailure( word: word, apiName: 'Google Cloud TTS', url: url, statusCode: 200, responseBody: response.body, errorDetails: 'Empty audioContent in response payload.', ); } else { await logFailure( word: word, apiName: 'Google Cloud TTS', url: url, statusCode: response.statusCode, responseBody: response.body, ); } } catch (e) { stderr.writeln('Server Google Cloud TTS synthesis failed for word $word: $e'); await logFailure( word: word, apiName: 'Google Cloud TTS', url: url, errorDetails: e.toString(), ); } return null; } return (Request request) async { if (request.method == 'OPTIONS') { return Response.ok('', headers: apiCorsHeaders(methods: 'GET, OPTIONS')); } if (request.method != 'GET') { return Response(405, body: 'Method not allowed', headers: apiCorsHeaders(methods: 'GET, OPTIONS')); } final String path = request.requestedUri.path; // Expected path: /v1/spelling/audio/ or /v1/spelling/audio/.mp3 final String prefix = '/v1/spelling/audio/'; if (!path.startsWith(prefix)) { return Response.notFound('Not found', headers: apiCorsHeaders(methods: 'GET, OPTIONS')); } String wordPart = path.substring(prefix.length); if (wordPart.endsWith('.mp3')) { wordPart = wordPart.substring(0, wordPart.length - 4); } final String word = Uri.decodeComponent(wordPart).trim().toLowerCase(); if (word.isEmpty || word.contains('/') || word.contains('\\') || word.contains('..')) { return Response.badRequest(body: 'Invalid word', headers: apiCorsHeaders(methods: 'GET, OPTIONS')); } final File localFile = File('${cacheDir.path}/$word.mp3'); try { if (localFile.existsSync()) { final Stream> fileStream = localFile.openRead(); return Response.ok( fileStream, headers: { ...apiCorsHeaders(methods: 'GET, OPTIONS'), 'Content-Type': 'audio/mpeg', 'Content-Length': localFile.lengthSync().toString(), 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0', }, ); } // Fetch from dictionaryapi.dev String? audioUrl = await fetchAudioUrl(word); if (audioUrl == null) { // Fallback to Merriam-Webster Collegiate API audioUrl = await fetchMerriamWebsterAudioUrl(word); } Uint8List? audioBytes; if (audioUrl != null) { final http.Response audioResponse = await client.get(Uri.parse(audioUrl)).timeout(const Duration(seconds: 10)); if (audioResponse.statusCode == 200) { audioBytes = audioResponse.bodyBytes; } else { await logFailure( word: word, apiName: 'MP3 Download', url: audioUrl, statusCode: audioResponse.statusCode, responseBody: audioResponse.body, ); } } if (audioBytes == null) { // Fallback to Google Cloud TTS audioBytes = await synthesizeGoogleCloudTts(word); } if (audioBytes == null) { return Response.notFound('Audio not found', headers: apiCorsHeaders(methods: 'GET, OPTIONS')); } // Cache locally await localFile.writeAsBytes(audioBytes); return Response.ok( audioBytes, headers: { ...apiCorsHeaders(methods: 'GET, OPTIONS'), 'Content-Type': 'audio/mpeg', 'Content-Length': audioBytes.length.toString(), 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0', }, ); } catch (e, st) { stderr.writeln('Error serving spelling audio for word $word: $e\n$st'); await logFailure( word: word, apiName: 'Serving Process', url: 'N/A', errorDetails: '$e\n$st', ); return Response.internalServerError(body: 'Internal error', headers: apiCorsHeaders(methods: 'GET, OPTIONS')); } }; }