Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Mobile App CI / test (push) Waiting to run
Mobile App CI / build-android (push) Blocked by required conditions
Mobile App CI / build-ios (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run
260 lines
7.3 KiB
Dart
260 lines
7.3 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:mnemo_cards_backend/packs/pack_manager.dart'
|
|
show PackManagerUtils;
|
|
|
|
/// Utilities for storing and serving voice audio files.
|
|
///
|
|
/// **Invariant**: `voice_models.voice_url` should store a **path-like reference**
|
|
/// (usually a file name inside `data/voice/`) or a remote URL, never raw base64.
|
|
class VoiceStorage {
|
|
const VoiceStorage();
|
|
|
|
static final _base64Regex = RegExp(r'^[A-Za-z0-9+/]*={0,2}$');
|
|
|
|
static bool isRemoteUrl(String value) {
|
|
final v = value.trim();
|
|
return v.startsWith('http://') || v.startsWith('https://');
|
|
}
|
|
|
|
/// Accepts:
|
|
/// - `file.mp3`
|
|
/// - `voice/file.mp3`
|
|
/// - `/voice/file.mp3`
|
|
///
|
|
/// Returns sanitized file name (without any directories) or `null`.
|
|
static String? sanitizeVoiceFileName(String value) {
|
|
final normalized =
|
|
value.trim().replaceAll('\\', '/').replaceFirst(RegExp('^/'), '');
|
|
if (normalized.isEmpty) return null;
|
|
if (normalized.contains('..')) return null;
|
|
|
|
final withoutPrefix = normalized.startsWith('voice/')
|
|
? normalized.substring('voice/'.length)
|
|
: normalized;
|
|
|
|
// We only allow a plain file name here.
|
|
if (withoutPrefix.contains('/')) return null;
|
|
|
|
return withoutPrefix.isEmpty ? null : withoutPrefix;
|
|
}
|
|
|
|
static ParsedVoiceAudio? tryParseBase64Audio(String value) {
|
|
final v = value.trim();
|
|
if (v.isEmpty) return null;
|
|
|
|
if (v.startsWith('data:')) {
|
|
final commaIndex = v.indexOf(',');
|
|
if (commaIndex <= 0) return null;
|
|
final header = v.substring(0, commaIndex);
|
|
final payload = v.substring(commaIndex + 1);
|
|
|
|
if (!header.contains(';base64')) return null;
|
|
|
|
final bytes = _decodeBase64(payload);
|
|
if (bytes == null) return null;
|
|
|
|
final contentType = _contentTypeFromDataUrlHeader(header) ??
|
|
_detectContentType(bytes) ??
|
|
'audio/mpeg';
|
|
final ext = _extensionFromContentType(contentType) ?? 'mp3';
|
|
|
|
return ParsedVoiceAudio(bytes: bytes, contentType: contentType, ext: ext);
|
|
}
|
|
|
|
// Plain base64 (no data URL header)
|
|
if (v.length < 50) return null;
|
|
if (!_base64Regex.hasMatch(v)) return null;
|
|
|
|
final bytes = _decodeBase64(v);
|
|
if (bytes == null) return null;
|
|
|
|
final contentType = _detectContentType(bytes) ?? 'audio/mpeg';
|
|
final ext = _extensionFromContentType(contentType) ?? 'mp3';
|
|
return ParsedVoiceAudio(bytes: bytes, contentType: contentType, ext: ext);
|
|
}
|
|
|
|
static Uint8List? _decodeBase64(String input) {
|
|
try {
|
|
// normalize() fixes missing padding (=) and whitespace issues.
|
|
final normalized = base64.normalize(input.trim());
|
|
return base64Decode(normalized);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static String? _contentTypeFromDataUrlHeader(String header) {
|
|
// Example: data:audio/mpeg;base64
|
|
final match = RegExp(r'^data:([^;]+);base64$').firstMatch(header);
|
|
return match?.group(1);
|
|
}
|
|
|
|
static String? _detectContentType(Uint8List bytes) {
|
|
if (bytes.length >= 12) {
|
|
// MP3: "ID3" tag or frame sync (0xFF 0xFB / 0xF3 / 0xF2)
|
|
if (bytes[0] == 0x49 && bytes[1] == 0x44 && bytes[2] == 0x33) {
|
|
return 'audio/mpeg';
|
|
}
|
|
if (bytes[0] == 0xFF &&
|
|
(bytes[1] == 0xFB || bytes[1] == 0xF3 || bytes[1] == 0xF2)) {
|
|
return 'audio/mpeg';
|
|
}
|
|
|
|
// WAV: "RIFF....WAVE"
|
|
if (bytes[0] == 0x52 &&
|
|
bytes[1] == 0x49 &&
|
|
bytes[2] == 0x46 &&
|
|
bytes[3] == 0x46 &&
|
|
bytes[8] == 0x57 &&
|
|
bytes[9] == 0x41 &&
|
|
bytes[10] == 0x56 &&
|
|
bytes[11] == 0x45) {
|
|
return 'audio/wav';
|
|
}
|
|
|
|
// OGG: "OggS"
|
|
if (bytes[0] == 0x4F &&
|
|
bytes[1] == 0x67 &&
|
|
bytes[2] == 0x67 &&
|
|
bytes[3] == 0x53) {
|
|
return 'audio/ogg';
|
|
}
|
|
|
|
// FLAC: "fLaC"
|
|
if (bytes[0] == 0x66 &&
|
|
bytes[1] == 0x4C &&
|
|
bytes[2] == 0x61 &&
|
|
bytes[3] == 0x43) {
|
|
return 'audio/flac';
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
static String? _extensionFromContentType(String contentType) {
|
|
return switch (contentType) {
|
|
'audio/mpeg' => 'mp3',
|
|
'audio/mp3' => 'mp3',
|
|
'audio/wav' => 'wav',
|
|
'audio/x-wav' => 'wav',
|
|
'audio/ogg' => 'ogg',
|
|
'audio/flac' => 'flac',
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
static String _defaultFileName({
|
|
required String voiceId,
|
|
required String ext,
|
|
}) {
|
|
return '$voiceId.$ext';
|
|
}
|
|
|
|
static Directory _assetsDirectory(Directory? override) {
|
|
return override ?? PackManagerUtils.assetsDirectory;
|
|
}
|
|
|
|
static Future<void> _ensureVoiceDirExists(Directory assetsDirectory) async {
|
|
final dir = Directory('${assetsDirectory.path}/voice');
|
|
if (!dir.existsSync()) {
|
|
await dir.create(recursive: true);
|
|
}
|
|
}
|
|
|
|
/// Stores `voiceValue` (base64 or data URL) into `data/voice/` and returns
|
|
/// the file name that should be stored in DB.
|
|
static Future<StoredVoiceAudio?> persistFromBase64({
|
|
required String voiceId,
|
|
required String voiceValue,
|
|
Directory? assetsDirectory,
|
|
}) async {
|
|
final parsed = tryParseBase64Audio(voiceValue);
|
|
if (parsed == null) return null;
|
|
|
|
final assetsDir = _assetsDirectory(assetsDirectory);
|
|
await _ensureVoiceDirExists(assetsDir);
|
|
|
|
final fileName = _defaultFileName(voiceId: voiceId, ext: parsed.ext);
|
|
final file = File('${assetsDir.path}/voice/$fileName');
|
|
await file.writeAsBytes(parsed.bytes, flush: true);
|
|
|
|
return StoredVoiceAudio(fileName: fileName, contentType: parsed.contentType);
|
|
}
|
|
|
|
/// Tries to resolve a local voice file from a DB value.
|
|
///
|
|
/// Returns the resolved file if it exists, otherwise `null`.
|
|
static Future<ResolvedVoiceAudio?> tryResolveLocalFile({
|
|
required String voiceValue,
|
|
Directory? assetsDirectory,
|
|
}) async {
|
|
final assetsDir = _assetsDirectory(assetsDirectory);
|
|
final v = voiceValue.trim();
|
|
if (v.isEmpty) return null;
|
|
|
|
final sanitized = sanitizeVoiceFileName(v);
|
|
if (sanitized == null) return null;
|
|
|
|
final file = File('${assetsDir.path}/voice/$sanitized');
|
|
if (!file.existsSync()) return null;
|
|
|
|
final bytes = await file.readAsBytes();
|
|
final contentType = _detectContentType(bytes) ?? _contentTypeFromFileName(
|
|
sanitized,
|
|
);
|
|
|
|
return ResolvedVoiceAudio(
|
|
fileName: sanitized,
|
|
bytes: bytes,
|
|
contentType: contentType,
|
|
);
|
|
}
|
|
|
|
static String _contentTypeFromFileName(String fileName) {
|
|
final lower = fileName.toLowerCase();
|
|
if (lower.endsWith('.mp3')) return 'audio/mpeg';
|
|
if (lower.endsWith('.wav')) return 'audio/wav';
|
|
if (lower.endsWith('.ogg')) return 'audio/ogg';
|
|
if (lower.endsWith('.flac')) return 'audio/flac';
|
|
return 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
class ParsedVoiceAudio {
|
|
final Uint8List bytes;
|
|
final String contentType;
|
|
final String ext;
|
|
|
|
const ParsedVoiceAudio({
|
|
required this.bytes,
|
|
required this.contentType,
|
|
required this.ext,
|
|
});
|
|
}
|
|
|
|
class StoredVoiceAudio {
|
|
final String fileName;
|
|
final String contentType;
|
|
|
|
const StoredVoiceAudio({
|
|
required this.fileName,
|
|
required this.contentType,
|
|
});
|
|
}
|
|
|
|
class ResolvedVoiceAudio {
|
|
final String fileName;
|
|
final Uint8List bytes;
|
|
final String contentType;
|
|
|
|
const ResolvedVoiceAudio({
|
|
required this.fileName,
|
|
required this.bytes,
|
|
required this.contentType,
|
|
});
|
|
}
|
|
|