cyberhybridhub/server/lib/handlers/spelling_audio_handler.dart
nathanAndrinoid a521a74214
Some checks failed
Admin portal tests / test (push) Has been cancelled
balance controled working spelling and market questions.
2026-06-17 04:20:45 +00:00

355 lines
11 KiB
Dart

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<void> 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<String?> 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<dynamic> entries = jsonDecode(response.body) as List<dynamic>;
for (final entry in entries) {
if (entry is Map<String, dynamic>) {
final phonetics = entry['phonetics'];
if (phonetics is List<dynamic>) {
for (final phonetic in phonetics) {
if (phonetic is Map<String, dynamic>) {
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<String?> 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<dynamic> json = jsonDecode(response.body) as List<dynamic>;
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<String, dynamic>;
final hwi = entry['hwi'] as Map<String, dynamic>?;
final prs = hwi?['prs'] as List<dynamic>?;
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<String, dynamic>?;
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<Uint8List?> 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<String, dynamic> data = jsonDecode(response.body) as Map<String, dynamic>;
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/<word> or /v1/spelling/audio/<word>.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<List<int>> fileStream = localFile.openRead();
return Response.ok(
fileStream,
headers: <String, String>{
...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: <String, String>{
...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'));
}
};
}