mnemo_cards/mnemo_cards_backend/lib/packs/voice_storage.dart
Dmitry 0f49280805
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 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
fixes and format
2025-12-20 21:26:15 +03:00

261 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,
});
}