355 lines
11 KiB
Dart
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'));
|
|
}
|
|
};
|
|
}
|