v3.6.5: audio format conversion, PC v7.0.8 backend merge, Amazon re-enabled

This commit is contained in:
zarzet
2026-02-10 23:35:41 +07:00
parent bae2bf63eb
commit fe1c96ea12
36 changed files with 3130 additions and 549 deletions
+239 -1
View File
@@ -56,6 +56,48 @@ class FFmpegService {
return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt';
}
static List<String> _buildDecryptionKeyCandidates(String rawKey) {
final candidates = <String>[];
void addCandidate(String key) {
final normalized = key.trim();
if (normalized.isEmpty) return;
if (!candidates.contains(normalized)) {
candidates.add(normalized);
}
}
final trimmed = rawKey.trim();
if (trimmed.isEmpty) return candidates;
addCandidate(trimmed);
final noPrefix = trimmed.startsWith(RegExp(r'0x', caseSensitive: false))
? trimmed.substring(2)
: trimmed;
addCandidate(noPrefix);
final compactHex = noPrefix.replaceAll(RegExp(r'[^0-9a-fA-F]'), '');
if (compactHex.isNotEmpty && compactHex.length.isEven) {
addCandidate(compactHex);
}
try {
final b64 = noPrefix.replaceAll(RegExp(r'\s+'), '');
final decoded = base64Decode(b64);
if (decoded.isNotEmpty) {
final hex = decoded
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
if (hex.isNotEmpty) {
addCandidate(hex);
}
}
} catch (_) {}
return candidates;
}
static Future<FFmpegResult> _execute(String command) async {
try {
final session = await FFmpegKit.execute(command);
@@ -77,7 +119,7 @@ class FFmpegService {
final outputPath = _buildOutputPath(inputPath, '.flac');
final command =
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
'-v error -xerror -i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
final result = await _execute(command);
@@ -133,6 +175,111 @@ class FFmpegService {
return null;
}
static Future<String?> decryptAudioFile({
required String inputPath,
required String decryptionKey,
bool deleteOriginal = true,
}) async {
final trimmedKey = decryptionKey.trim();
if (trimmedKey.isEmpty) return inputPath;
// Amazon encrypted streams are commonly MP4 container with FLAC audio.
// Prefer FLAC output to avoid MP4 muxing errors during decrypt copy.
final preferredExt = inputPath.toLowerCase().endsWith('.m4a')
? '.flac'
: inputPath.toLowerCase().endsWith('.flac')
? '.flac'
: inputPath.toLowerCase().endsWith('.mp3')
? '.mp3'
: inputPath.toLowerCase().endsWith('.opus')
? '.opus'
: '.flac';
var tempOutput = _buildOutputPath(inputPath, preferredExt);
String buildDecryptCommand(
String outputPath, {
required bool mapAudioOnly,
required String key,
}) {
final audioMap = mapAudioOnly ? '-map 0:a ' : '';
return '-v error -decryption_key "$key" -i "$inputPath" $audioMap-c copy "$outputPath" -y';
}
final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey);
if (keyCandidates.isEmpty) {
_log.e('No usable decryption key candidates');
return null;
}
FFmpegResult? lastResult;
var decryptSucceeded = false;
for (final keyCandidate in keyCandidates) {
_log.d(
'Executing FFmpeg decrypt command (key length: ${keyCandidate.length})',
);
var result = await _execute(
buildDecryptCommand(
tempOutput,
mapAudioOnly: preferredExt == '.flac',
key: keyCandidate,
),
);
// Fallback for uncommon streams that cannot be remuxed into FLAC.
if (!result.success && preferredExt == '.flac') {
final fallbackOutput = _buildOutputPath(inputPath, '.m4a');
final fallbackResult = await _execute(
buildDecryptCommand(
fallbackOutput,
mapAudioOnly: false,
key: keyCandidate,
),
);
if (fallbackResult.success) {
tempOutput = fallbackOutput;
result = fallbackResult;
}
}
if (result.success) {
decryptSucceeded = true;
lastResult = result;
break;
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
lastResult = result;
}
if (!decryptSucceeded) {
_log.e('FFmpeg decrypt failed: ${lastResult?.output ?? 'unknown error'}');
return null;
}
try {
final tempFile = File(tempOutput);
final inputFile = File(inputPath);
if (!await tempFile.exists()) {
_log.e('Decrypted output file not found: $tempOutput');
return null;
}
if (deleteOriginal && await inputFile.exists()) {
await inputFile.delete();
}
return tempOutput;
} catch (e) {
_log.e('Failed to finalize decrypted file: $e');
return null;
}
}
static Future<String?> convertFlacToMp3(
String inputPath, {
String bitrate = '320k',
@@ -616,6 +763,97 @@ class FFmpegService {
}
}
/// Unified audio format conversion with full metadata + cover preservation.
/// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format).
/// Returns the new file path on success, null on failure.
static Future<String?> convertAudioFormat({
required String inputPath,
required String targetFormat,
required String bitrate,
required Map<String, String> metadata,
String? coverPath,
bool deleteOriginal = true,
}) async {
final format = targetFormat.toLowerCase();
if (format != 'mp3' && format != 'opus') {
_log.e('Unsupported target format: $targetFormat');
return null;
}
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = _buildOutputPath(inputPath, extension);
// Step 1: Convert audio
String command;
if (format == 'opus') {
command =
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
} else {
command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 "$outputPath" -y';
}
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to $format @ $bitrate',
);
final result = await _execute(command);
if (!result.success) {
_log.e('Audio conversion failed: ${result.output}');
return null;
}
// Step 2: Embed metadata + cover into the converted file.
// Treat embed failure as conversion failure when metadata/cover was requested.
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
if (hasMetadata || hasCover) {
String? embedResult;
if (format == 'mp3') {
embedResult = await embedMetadataToMp3(
mp3Path: outputPath,
coverPath: coverPath,
metadata: metadata,
);
} else {
embedResult = await embedMetadataToOpus(
opusPath: outputPath,
coverPath: coverPath,
metadata: metadata,
);
}
if (embedResult == null) {
_log.e(
'Metadata/Cover preservation failed, rolling back converted file',
);
try {
final out = File(outputPath);
if (await out.exists()) {
await out.delete();
}
} catch (e) {
_log.w('Failed to cleanup failed converted file: $e');
}
return null;
}
}
// Step 3: Delete original if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();
_log.i(
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
);
} catch (e) {
_log.w('Failed to delete original: $e');
}
}
return outputPath;
}
static Map<String, String> _convertToId3Tags(
Map<String, String> vorbisMetadata,
) {
+109 -68
View File
@@ -17,21 +17,21 @@ String? _currentContainerPath;
class HistoryDatabase {
static final HistoryDatabase instance = HistoryDatabase._init();
static Database? _database;
HistoryDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('history.db');
return _database!;
}
Future<Database> _initDB(String fileName) async {
final dbPath = await getApplicationDocumentsDirectory();
final path = join(dbPath.path, fileName);
_log.i('Initializing database at: $path');
return await openDatabase(
path,
version: 3,
@@ -39,10 +39,10 @@ class HistoryDatabase {
onUpgrade: _upgradeDB,
);
}
Future<void> _createDB(Database db, int version) async {
_log.i('Creating database schema v$version');
await db.execute('''
CREATE TABLE history (
id TEXT PRIMARY KEY,
@@ -73,16 +73,20 @@ class HistoryDatabase {
copyright TEXT
)
''');
// Indexes for fast lookups
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
await db.execute('CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)');
await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)');
await db.execute(
'CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)',
);
await db.execute(
'CREATE INDEX idx_album ON history(album_name, album_artist)',
);
_log.i('Database schema created with indexes');
}
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading database from v$oldVersion to v$newVersion');
if (oldVersion < 2) {
@@ -95,20 +99,20 @@ class HistoryDatabase {
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
}
}
// ==================== iOS Path Normalization ====================
/// Pattern to match iOS container paths
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
static final _iosContainerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
caseSensitive: false,
);
/// Initialize and cache the current iOS container path
Future<void> _initContainerPath() async {
if (!Platform.isIOS || _currentContainerPath != null) return;
try {
final docDir = await getApplicationDocumentsDirectory();
// Extract container path up to and including the UUID folder
@@ -122,55 +126,58 @@ class HistoryDatabase {
_log.w('Failed to get iOS container path: $e');
}
}
/// Normalize iOS file path by replacing old container UUID with current one
/// This fixes the issue where iOS changes container UUID after app updates
String _normalizeIosPath(String? filePath) {
if (filePath == null || filePath.isEmpty) return filePath ?? '';
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
// Check if path contains an iOS container path
if (_iosContainerPattern.hasMatch(filePath)) {
final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!);
final normalized = filePath.replaceFirst(
_iosContainerPattern,
_currentContainerPath!,
);
if (normalized != filePath) {
_log.d('Normalized iOS path: $filePath -> $normalized');
}
return normalized;
}
return filePath;
}
/// Migrate iOS paths in database to use current container UUID
/// This is called once after app update if container changed
Future<bool> migrateIosContainerPaths() async {
if (!Platform.isIOS) return false;
await _initContainerPath();
if (_currentContainerPath == null) return false;
final prefs = await _prefs;
final lastContainer = prefs.getString('ios_last_container_path');
if (lastContainer == _currentContainerPath) {
_log.d('iOS container path unchanged, skipping migration');
return false;
}
_log.i('iOS container changed: $lastContainer -> $_currentContainerPath');
try {
final db = await database;
// Get all items with iOS paths
final rows = await db.query('history', columns: ['id', 'file_path']);
int updatedCount = 0;
final batch = db.batch();
for (final row in rows) {
final id = row['id'] as String;
final oldPath = row['file_path'] as String?;
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
final newPath = _normalizeIosPath(oldPath);
if (newPath != oldPath) {
@@ -184,14 +191,14 @@ class HistoryDatabase {
}
}
}
if (updatedCount > 0) {
await batch.commit(noResult: true);
}
// Save current container path
await prefs.setString('ios_last_container_path', _currentContainerPath!);
_log.i('iOS path migration complete: $updatedCount paths updated');
return updatedCount > 0;
} catch (e, stack) {
@@ -199,32 +206,34 @@ class HistoryDatabase {
return false;
}
}
/// Migrate data from SharedPreferences to SQLite
/// Returns true if migration was performed, false if already migrated
Future<bool> migrateFromSharedPreferences() async {
final prefs = await _prefs;
final migrationKey = 'history_migrated_to_sqlite';
if (prefs.getBool(migrationKey) == true) {
_log.d('Already migrated to SQLite');
return false;
}
final jsonStr = prefs.getString('download_history');
if (jsonStr == null || jsonStr.isEmpty) {
_log.d('No SharedPreferences history to migrate');
await prefs.setBool(migrationKey, true);
return false;
}
try {
final List<dynamic> jsonList = jsonDecode(jsonStr);
_log.i('Migrating ${jsonList.length} items from SharedPreferences to SQLite');
_log.i(
'Migrating ${jsonList.length} items from SharedPreferences to SQLite',
);
final db = await database;
final batch = db.batch();
for (final json in jsonList) {
final map = json as Map<String, dynamic>;
batch.insert(
@@ -233,20 +242,20 @@ class HistoryDatabase {
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
// Mark as migrated but keep old data for safety
await prefs.setBool(migrationKey, true);
_log.i('Migration complete: ${jsonList.length} items');
return true;
} catch (e, stack) {
_log.e('Migration failed: $e', e, stack);
return false;
}
}
/// Convert JSON format (camelCase) to DB row (snake_case)
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
return {
@@ -278,7 +287,7 @@ class HistoryDatabase {
'copyright': json['copyright'],
};
}
/// Convert DB row (snake_case) to JSON format (camelCase)
/// Also normalizes iOS paths if container UUID changed
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
@@ -311,9 +320,9 @@ class HistoryDatabase {
'copyright': row['copyright'],
};
}
// ==================== CRUD Operations ====================
/// Insert or update a history item
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
@@ -323,7 +332,7 @@ class HistoryDatabase {
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// Get all history items ordered by download date (newest first)
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database;
@@ -335,7 +344,7 @@ class HistoryDatabase {
);
return rows.map(_dbRowToJson).toList();
}
/// Get item by ID
Future<Map<String, dynamic>?> getById(String id) async {
final db = await database;
@@ -348,7 +357,7 @@ class HistoryDatabase {
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Get item by Spotify ID - O(1) with index
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
final db = await database;
@@ -361,7 +370,7 @@ class HistoryDatabase {
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Get item by ISRC - O(1) with index
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
final db = await database;
@@ -374,7 +383,7 @@ class HistoryDatabase {
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Check if spotify_id exists - O(1) with index
Future<bool> existsBySpotifyId(String spotifyId) async {
final db = await database;
@@ -384,42 +393,42 @@ class HistoryDatabase {
);
return result.isNotEmpty;
}
/// Get all spotify_ids as Set for fast in-memory lookup
Future<Set<String>> getAllSpotifyIds() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""'
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""',
);
return rows.map((r) => r['spotify_id'] as String).toSet();
}
/// Delete by ID
Future<void> deleteById(String id) async {
final db = await database;
await db.delete('history', where: 'id = ?', whereArgs: [id]);
}
/// Delete by Spotify ID
Future<void> deleteBySpotifyId(String spotifyId) async {
final db = await database;
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
}
/// Clear all history
Future<void> clearAll() async {
final db = await database;
await db.delete('history');
_log.i('Cleared all history');
}
/// Get total count
Future<int> getCount() async {
final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
return Sqflite.firstIntValue(result) ?? 0;
}
/// Find existing item by spotify_id or isrc (for deduplication)
Future<Map<String, dynamic>?> findExisting({
String? spotifyId,
@@ -428,7 +437,7 @@ class HistoryDatabase {
if (spotifyId != null && spotifyId.isNotEmpty) {
final bySpotify = await getBySpotifyId(spotifyId);
if (bySpotify != null) return bySpotify;
// Check for deezer: prefix matching
if (spotifyId.startsWith('deezer:')) {
final deezerId = spotifyId.substring(7);
@@ -442,31 +451,63 @@ class HistoryDatabase {
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
}
}
if (isrc != null && isrc.isNotEmpty) {
return await getByIsrc(isrc);
}
return null;
}
/// Close database
/// Close database
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
}
/// Update file path for a history entry (e.g. after format conversion)
Future<void> updateFilePath(
String id,
String newFilePath, {
String? newSafFileName,
String? newQuality,
int? newBitDepth,
int? newSampleRate,
bool clearAudioSpecs = false,
}) async {
final db = await database;
final values = <String, dynamic>{'file_path': newFilePath};
if (newSafFileName != null) {
values['saf_file_name'] = newSafFileName;
}
if (newQuality != null) {
values['quality'] = newQuality;
}
if (clearAudioSpecs) {
values['bit_depth'] = null;
values['sample_rate'] = null;
} else {
if (newBitDepth != null) {
values['bit_depth'] = newBitDepth;
}
if (newSampleRate != null) {
values['sample_rate'] = newSampleRate;
}
}
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
}
/// Get all file paths from download history
/// Used to exclude downloaded files from local library scan
Future<Set<String>> getAllFilePaths() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""'
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""',
);
return rows.map((r) => r['file_path'] as String).toSet();
}
/// Get all entries with file paths for orphan detection
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
@@ -478,11 +519,11 @@ class HistoryDatabase {
''');
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// Delete multiple entries by IDs
Future<int> deleteByIds(List<String> ids) async {
if (ids.isEmpty) return 0;
final db = await database;
final placeholders = List.filled(ids.length, '?').join(',');
final count = await db.rawDelete(