import 'dart:io'; import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/ffmpeg_models.dart'; import 'package:spotiflac_android/services/ffmpeg_probe.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('FFmpeg'); /// Shared native execution boundary; feature modules supply their own commands. abstract final class FFmpegExecution { static Future command(String command) async { try { final session = await FFmpegKit.execute(command); final code = await session.getReturnCode(); return FFmpegResult( success: ReturnCode.isSuccess(code), returnCode: code?.getValue() ?? -1, output: await session.getOutput() ?? '', ); } catch (e) { _log.e('FFmpeg execute error: $e'); return FFmpegResult(success: false, returnCode: -1, output: e.toString()); } } static Future arguments(List arguments) async { try { final session = await FFmpegKit.executeWithArguments(arguments); final code = await session.getReturnCode(); return FFmpegResult( success: ReturnCode.isSuccess(code), returnCode: code?.getValue() ?? -1, output: await session.getOutput() ?? '', ); } catch (e) { _log.e('FFmpeg executeWithArguments error: $e'); return FFmpegResult(success: false, returnCode: -1, output: e.toString()); } } static String preview(String command) { final redacted = command .replaceAll( RegExp(r'-metadata\s+lyrics="[^"]*"', caseSensitive: false), '-metadata lyrics=""', ) .replaceAll( RegExp(r'-metadata\s+unsyncedlyrics="[^"]*"', caseSensitive: false), '-metadata unsyncedlyrics=""', ) .replaceAll(RegExp(r'\s+'), ' ') .trim(); return redacted.length <= 300 ? redacted : '${redacted.substring(0, 300)}...'; } } class FFmpegOutputPlan { final String workingPath; final String finalPath; const FFmpegOutputPlan({required this.workingPath, required this.finalPath}); } /// Owns output reservation, cleanup and publication after successful execution. abstract final class FFmpegOutput { static int _tempCounter = 0; static bool samePath(String first, String second) { final firstPath = File(first).absolute.path; final secondPath = File(second).absolute.path; return Platform.isWindows ? firstPath.toLowerCase() == secondPath.toLowerCase() : firstPath == secondPath; } // Exclusive creation keeps simultaneous conversions from choosing one name. static Future reserve(String requestedPath) async { final file = File(requestedPath); final name = file.uri.pathSegments.last; final dot = name.lastIndexOf('.'); final base = dot > 0 ? name.substring(0, dot) : name; final extension = dot > 0 ? name.substring(dot) : ''; for (var index = 1; ; index++) { final candidate = index == 1 ? requestedPath : '${file.parent.path}${Platform.pathSeparator}$base ($index)$extension'; try { await File(candidate).create(exclusive: true); return candidate; } on FileSystemException { if (await FileSystemEntity.type(candidate, followLinks: false) == FileSystemEntityType.notFound) { rethrow; } } } } static Future plan( String inputPath, String extension, { required bool deleteOriginal, }) async { final ext = extension.startsWith('.') ? extension : '.$extension'; final file = File(inputPath); final name = file.uri.pathSegments.last; final dot = name.lastIndexOf('.'); final base = dot > 0 ? name.substring(0, dot) : name; final requested = '${file.parent.path}${Platform.pathSeparator}$base$ext'; if (samePath(requested, inputPath) && deleteOriginal) { return FFmpegOutputPlan( workingPath: await reserve( '${file.parent.path}${Platform.pathSeparator}.$base.spotiflac-${DateTime.now().microsecondsSinceEpoch}$ext', ), finalPath: inputPath, ); } final finalPath = await reserve(requested); return FFmpegOutputPlan(workingPath: finalPath, finalPath: finalPath); } static Future tempPath(String extension) async { final directory = await getTemporaryDirectory(); final ext = extension.startsWith('.') ? extension : '.$extension'; _tempCounter = (_tempCounter + 1) & 0x7fffffff; return '${directory.path}${Platform.pathSeparator}temp_embed_${DateTime.now().microsecondsSinceEpoch}_${pid}_$_tempCounter$ext'; } static Future cleanup(String path) async { try { final file = File(path); if (await file.exists()) await file.delete(); } catch (e) { _log.w('Failed to clean FFmpeg output: $e'); } } static Future finalize({ required FFmpegOutputPlan plan, required String inputPath, required bool deleteOriginal, void Function(Object e)? onError, }) async { final working = File(plan.workingPath); if (!await working.exists() || await working.length() == 0) { _log.e('Converted output is missing or empty: ${plan.workingPath}'); await cleanup(plan.workingPath); return null; } if (plan.workingPath != plan.finalPath) { final source = File(inputPath); final backupPath = '$inputPath.spotiflac-backup-${DateTime.now().microsecondsSinceEpoch}'; final backup = File(backupPath); var backedUp = false; try { if (await source.exists()) { await source.rename(backupPath); backedUp = true; } await working.rename(plan.finalPath); } catch (e) { _log.e('Failed to replace original after conversion: $e'); try { if (backedUp && !await source.exists() && await backup.exists()) { await backup.rename(inputPath); } } catch (restoreError) { _log.e('Failed to restore original conversion backup: $restoreError'); } await cleanup(plan.workingPath); onError?.call(e); return null; } await cleanup(backupPath); } else if (deleteOriginal && !samePath(inputPath, plan.finalPath)) { try { final source = File(inputPath); if (await source.exists()) await source.delete(); } catch (e) { _log.w('Failed to delete original after conversion: $e'); } } FFmpegProbe.invalidate(inputPath); FFmpegProbe.invalidate(plan.finalPath); return plan.finalPath; } /// Copies across filesystems before moving the original aside. A failed copy /// or publication leaves the original available and cleans staging output. static Future promote( String tempOutput, String targetPath, { void Function()? onMissing, required void Function(Object e) onError, }) async { String? staging; try { final temp = File(tempOutput); if (!await temp.exists()) { onMissing?.call(); return false; } staging = await reserve( '$targetPath.spotiflac-staging-${DateTime.now().microsecondsSinceEpoch}', ); await temp.copy(staging); return await finalize( plan: FFmpegOutputPlan(workingPath: staging, finalPath: targetPath), inputPath: targetPath, deleteOriginal: true, onError: onError, ) != null; } catch (e) { onError(e); return false; } finally { if (staging != null) await cleanup(staging); await cleanup(tempOutput); } } }