diff --git a/lib/services/ffmpeg_conversion.dart b/lib/services/ffmpeg_conversion.dart new file mode 100644 index 00000000..f117fb5d --- /dev/null +++ b/lib/services/ffmpeg_conversion.dart @@ -0,0 +1,588 @@ +import 'dart:io'; + +import 'package:spotiflac_android/services/audio_metadata_mapper.dart'; +import 'package:spotiflac_android/services/ffmpeg_metadata.dart'; +import 'package:spotiflac_android/services/ffmpeg_models.dart'; +import 'package:spotiflac_android/services/ffmpeg_probe.dart'; +import 'package:spotiflac_android/services/ffmpeg_support.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('FFmpeg'); + +class _ResolvedLosslessQuality { + final int? targetBitDepth; + final int? targetSampleRate; + + const _ResolvedLosslessQuality({this.targetBitDepth, this.targetSampleRate}); +} + +/// Encoding and track splitting depend on inspection, tagging and output +/// publication directly, without routing through the compatibility facade. +abstract final class FFmpegConversion { + static Future<_ResolvedLosslessQuality> _resolveLosslessQuality({ + required String inputPath, + required LosslessConversionQuality quality, + int? sourceBitDepth, + }) async { + final needsDepth = sourceBitDepth == null && quality.maxBitDepth != null; + final needsRate = quality.maxSampleRate != null; + final probe = needsDepth || needsRate + ? await FFmpegProbe.audioProperties(inputPath) + : const PrimaryAudioProperties(); + final depth = sourceBitDepth ?? probe.bitDepth; + final rate = needsRate ? probe.sampleRate : null; + return _ResolvedLosslessQuality( + targetBitDepth: + quality.maxBitDepth != null && + (depth == null || depth > quality.maxBitDepth!) + ? quality.maxBitDepth + : null, + targetSampleRate: + quality.maxSampleRate != null && + (rate == null || rate > quality.maxSampleRate!) + ? quality.maxSampleRate + : null, + ); + } + + static String? _sampleFormat(String codec, int? bitDepth) { + if (bitDepth == null || bitDepth <= 0) return null; + return switch (codec) { + 'flac' || 'pcm' => bitDepth <= 16 ? 's16' : 's32', + 'alac' => bitDepth <= 16 ? 's16p' : 's32p', + _ => null, + }; + } + + static void _appendResampleFilter( + List arguments, { + int? targetSampleRate, + String? outputSampleFormat, + required LosslessConversionProcessing processing, + }) { + final hasRate = targetSampleRate != null && targetSampleRate > 0; + final hasFormat = + outputSampleFormat != null && outputSampleFormat.trim().isNotEmpty; + if (!hasRate && !hasFormat && !processing.hasDither) return; + final options = [ + ...losslessResamplerFilterOptions(processing), + if (hasRate) 'osr=$targetSampleRate', + if (hasFormat) 'osf=${outputSampleFormat.trim()}', + if (processing.hasDither) 'dither_method=${processing.normalizedDither}', + ]; + arguments.addAll(['-af', 'aresample=${options.join(':')}']); + } + + static void _appendLosslessQuality( + List arguments, { + required String codec, + int? targetBitDepth, + int? targetSampleRate, + required LosslessConversionProcessing processing, + }) { + _appendResampleFilter( + arguments, + targetSampleRate: targetSampleRate, + outputSampleFormat: _sampleFormat(codec, targetBitDepth), + processing: processing, + ); + if (targetBitDepth == null || targetBitDepth <= 0) return; + if (codec == 'flac' || codec == 'alac') { + final planar = codec == 'alac' ? 'p' : ''; + if (targetBitDepth <= 16) { + arguments.addAll(['-sample_fmt', 's16$planar']); + } else if (targetBitDepth <= 24) { + arguments.addAll([ + '-sample_fmt', + 's32$planar', + '-bits_per_raw_sample', + '24', + ]); + } + } + } + + static Future convertM4aToFlac( + String inputPath, { + String? sourceCodec, + Future Function(List)? execute, + }) async { + final plan = await FFmpegOutput.plan( + inputPath, + '.flac', + deleteOriginal: true, + ); + try { + final run = execute ?? FFmpegExecution.arguments; + final codec = sourceCodec ?? await FFmpegProbe.audioCodec(inputPath); + if (codec?.trim().toLowerCase() == 'flac') { + final remux = await run([ + '-v', + 'error', + '-xerror', + '-i', + inputPath, + '-c:a', + 'copy', + '-f', + 'flac', + plan.workingPath, + '-y', + ]); + final output = File(plan.workingPath); + if (remux.success && + await output.exists() && + await output.length() > 42 && + await FFmpegProbe.isNativeFlacFile(plan.workingPath)) { + final validation = await run([ + '-v', + 'error', + '-xerror', + '-err_detect', + 'crccheck+explode', + '-i', + plan.workingPath, + '-map', + '0:a:0', + '-f', + 'null', + '-', + ]); + if (validation.success) { + return await FFmpegOutput.finalize( + plan: plan, + inputPath: inputPath, + deleteOriginal: true, + ); + } + } + _log.w('FLAC remux validation failed; retrying with the encoder'); + } + final result = await run([ + '-v', + 'error', + '-xerror', + '-i', + inputPath, + '-c:a', + 'flac', + '-compression_level', + '8', + plan.workingPath, + '-y', + ]); + if (result.success) { + return await FFmpegOutput.finalize( + plan: plan, + inputPath: inputPath, + deleteOriginal: true, + ); + } + _log.e('M4A to FLAC conversion failed: ${result.output}'); + } catch (e) { + _log.e('M4A to FLAC conversion failed: $e'); + } + await FFmpegOutput.cleanup(plan.workingPath); + return null; + } + + static Future ensureNativeFlacExtension(String inputPath) async { + if (inputPath.toLowerCase().endsWith('.flac')) return inputPath; + final plan = await FFmpegOutput.plan( + inputPath, + '.flac', + deleteOriginal: true, + ); + try { + await File(inputPath).rename(plan.finalPath); + FFmpegProbe.invalidate(inputPath); + FFmpegProbe.invalidate(plan.finalPath); + return plan.finalPath; + } catch (_) { + await FFmpegOutput.cleanup(plan.workingPath); + rethrow; + } + } + + /// Converts audio while preserving requested metadata and cover art. Lossless + /// quality caps only reduce source quality; known depth avoids an extra probe. + static Future convertAudioFormat({ + required String inputPath, + required String targetFormat, + required String bitrate, + required Map metadata, + String? coverPath, + String artistTagMode = artistTagModeJoined, + bool deleteOriginal = true, + int? sourceBitDepth, + LosslessConversionQuality losslessQuality = + const LosslessConversionQuality(), + LosslessConversionProcessing losslessProcessing = + const LosslessConversionProcessing(), + }) async { + final format = targetFormat.toLowerCase(); + if (!const { + 'mp3', + 'opus', + 'aac', + 'alac', + 'flac', + 'wav', + 'aiff', + 'aif', + }.contains(format)) { + _log.e('Unsupported target format: $targetFormat'); + return null; + } + // Existing credits must not lose names; downloads embed provider credits + // after auto-conversion applies this preservation policy. + artistTagMode = artistTagModeForExistingTags(artistTagMode); + final quality = isLosslessConversionTarget(format) + ? await _resolveLosslessQuality( + inputPath: inputPath, + quality: losslessQuality, + sourceBitDepth: sourceBitDepth, + ) + : const _ResolvedLosslessQuality(); + if (format == 'alac' || format == 'flac') { + return _convertToLossless( + inputPath: inputPath, + metadata: metadata, + codec: format, + coverPath: coverPath, + artistTagMode: artistTagMode, + targetBitDepth: quality.targetBitDepth, + targetSampleRate: quality.targetSampleRate, + processing: losslessProcessing, + deleteOriginal: deleteOriginal, + ); + } + if (format == 'wav' || format == 'aiff' || format == 'aif') { + return _convertToPcm( + inputPath: inputPath, + metadata: metadata, + coverPath: coverPath, + container: format == 'wav' ? 'wav' : 'aiff', + sourceBitDepth: sourceBitDepth, + targetBitDepth: quality.targetBitDepth, + targetSampleRate: quality.targetSampleRate, + processing: losslessProcessing, + deleteOriginal: deleteOriginal, + ); + } + final extension = switch (format) { + 'opus' => '.opus', + 'aac' => '.m4a', + _ => '.mp3', + }; + final plan = await FFmpegOutput.plan( + inputPath, + extension, + deleteOriginal: deleteOriginal, + ); + final outputPath = plan.workingPath; + final command = switch (format) { + 'opus' => + '-v error -hide_banner -i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a "$outputPath" -y', + 'aac' => + '-v error -hide_banner -i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -f mp4 "$outputPath" -y', + _ => + '-v error -hide_banner -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 FFmpegExecution.command(command); + if (!result.success) { + _log.e('Audio conversion failed: ${result.output}'); + await FFmpegOutput.cleanup(outputPath); + return null; + } + final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty); + final hasCover = coverPath != null && coverPath.trim().isNotEmpty; + if (hasMetadata || hasCover) { + final embedded = switch (format) { + 'mp3' => await FFmpegMetadata.embedMetadataToMp3( + mp3Path: outputPath, + coverPath: coverPath, + metadata: metadata, + artistTagMode: artistTagMode, + ), + 'aac' => await FFmpegMetadata.embedMetadataToM4a( + m4aPath: outputPath, + coverPath: coverPath, + metadata: metadata, + artistTagMode: artistTagMode, + preserveMetadata: true, + ), + _ => await FFmpegMetadata.embedMetadataToOpus( + opusPath: outputPath, + coverPath: coverPath, + metadata: metadata, + artistTagMode: artistTagMode, + ), + }; + if (embedded == null) { + _log.e( + 'Metadata/Cover preservation failed, rolling back converted file', + ); + await FFmpegOutput.cleanup(outputPath); + return null; + } + } + return FFmpegOutput.finalize( + plan: plan, + inputPath: inputPath, + deleteOriginal: deleteOriginal, + ); + } + + static Future _convertToLossless({ + required String inputPath, + required Map metadata, + required String codec, + String? coverPath, + required String artistTagMode, + int? targetBitDepth, + int? targetSampleRate, + required LosslessConversionProcessing processing, + required bool deleteOriginal, + }) async { + final isAlac = codec == 'alac'; + final plan = await FFmpegOutput.plan( + inputPath, + isAlac ? '.m4a' : '.flac', + deleteOriginal: deleteOriginal, + ); + final outputPath = plan.workingPath; + final arguments = ['-v', 'error', '-hide_banner', '-i', inputPath]; + final hasCover = + coverPath != null && + coverPath.trim().isNotEmpty && + await File(coverPath).exists(); + if (hasCover) arguments.addAll(['-i', coverPath]); + arguments.addAll(['-map', '0:a']); + if (hasCover) FFmpegMetadata.appendCoverInputArguments(arguments); + arguments.addAll(['-c:a', codec]); + if (!isAlac) arguments.addAll(['-compression_level', '8']); + _appendLosslessQuality( + arguments, + codec: codec, + targetBitDepth: targetBitDepth, + targetSampleRate: targetSampleRate, + processing: processing, + ); + arguments.addAll(['-map_metadata', isAlac ? '-1' : '0']); + if (isAlac) { + AudioMetadataMapper.appendMappedMetadataArguments( + arguments, + AudioMetadataMapper.convertToM4aTags(metadata), + ); + } else { + AudioMetadataMapper.appendVorbisMetadataArguments( + arguments, + metadata, + artistTagMode: artistTagMode, + ); + } + arguments.addAll([outputPath, '-y']); + final label = isAlac ? 'ALAC' : 'FLAC'; + _log.i( + 'Converting ${inputPath.split(Platform.pathSeparator).last} to $label' + '${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}' + '${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}' + '${processing.hasDither ? ' dither=${processing.normalizedDither}' : ''}' + '${processing.normalizedResampler != 'swr' ? ' resampler=${processing.normalizedResampler}' : ''}', + ); + final result = await FFmpegExecution.arguments(arguments); + if (!result.success) { + _log.e('$label conversion failed: ${result.output}'); + await FFmpegOutput.cleanup(outputPath); + return null; + } + if (isAlac) { + await FFmpegMetadata.writeM4AFreeformTags(outputPath, metadata); + if (!await FFmpegMetadata.writeM4AReleaseIdentityTags( + outputPath, + metadata, + )) { + _log.e('ALAC release identity metadata write failed'); + await FFmpegOutput.cleanup(outputPath); + return null; + } + } + return FFmpegOutput.finalize( + plan: plan, + inputPath: inputPath, + deleteOriginal: deleteOriginal, + ); + } + + static Future _convertToPcm({ + required String inputPath, + required Map metadata, + required String container, + String? coverPath, + int? sourceBitDepth, + int? targetBitDepth, + int? targetSampleRate, + required LosslessConversionProcessing processing, + required bool deleteOriginal, + }) async { + final isAiff = container == 'aiff'; + final plan = await FFmpegOutput.plan( + inputPath, + isAiff ? '.aiff' : '.wav', + deleteOriginal: deleteOriginal, + ); + final outputPath = plan.workingPath; + var depth = targetBitDepth ?? sourceBitDepth; + if (depth == null || depth <= 0) { + depth = (await FFmpegProbe.audioProperties(inputPath)).bitDepth; + } + final use24 = depth != null && depth >= 24; + final codec = isAiff + ? (use24 ? 'pcm_s24be' : 'pcm_s16be') + : (use24 ? 'pcm_s24le' : 'pcm_s16le'); + final arguments = [ + '-v', + 'error', + '-hide_banner', + '-i', + inputPath, + '-map', + '0:a', + ]; + _appendResampleFilter( + arguments, + targetSampleRate: targetSampleRate, + outputSampleFormat: _sampleFormat('pcm', targetBitDepth), + processing: processing, + ); + arguments.addAll(['-c:a', codec, '-map_metadata', '-1']); + // Container-native common tags supplement the authoritative native ID3 + // chunks, which also hold totals, lyrics, ReplayGain and artwork. + AudioMetadataMapper.appendMappedMetadataArguments( + arguments, + AudioMetadataMapper.convertToId3Tags(metadata), + ); + arguments.addAll([outputPath, '-y']); + _log.i( + 'Converting ${inputPath.split(Platform.pathSeparator).last} to ' + '${container.toUpperCase()} (${use24 ? 24 : 16}-bit' + '${targetSampleRate != null ? ', ${targetSampleRate}Hz' : ''}' + '${processing.hasDither ? ', dither=${processing.normalizedDither}' : ''}' + '${processing.normalizedResampler != 'swr' ? ', resampler=${processing.normalizedResampler}' : ''})', + ); + final result = await FFmpegExecution.arguments(arguments); + if (!result.success) { + _log.e('${container.toUpperCase()} conversion failed: ${result.output}'); + await FFmpegOutput.cleanup(outputPath); + return null; + } + final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty); + final hasCover = coverPath != null && coverPath.trim().isNotEmpty; + if ((hasMetadata || hasCover) && + !await FFmpegMetadata.embedChunkTagsNative( + outputPath, + metadata, + coverPath, + )) { + _log.e('Native tag embed failed for $container output'); + await FFmpegOutput.cleanup(outputPath); + return null; + } + return FFmpegOutput.finalize( + plan: plan, + inputPath: inputPath, + deleteOriginal: deleteOriginal, + ); + } + + static Future?> splitCueToTracks({ + required String audioPath, + required String outputDir, + required List tracks, + required Map albumMetadata, + String? coverPath, + void Function(int current, int total)? onProgress, + }) async { + if (tracks.isEmpty) { + _log.e('No tracks to split'); + return null; + } + final outputPaths = []; + final inputExt = audioPath.toLowerCase().split('.').last; + final outputExt = const {'flac', 'wav', 'ape', 'wv'}.contains(inputExt) + ? 'flac' + : inputExt; + for (var i = 0; i < tracks.length; i++) { + final track = tracks[i]; + onProgress?.call(i + 1, tracks.length); + final title = track.title + .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + final outputName = + '${track.number.toString().padLeft(2, '0')} - $title.$outputExt'; + final outputPath = '$outputDir${Platform.pathSeparator}$outputName'; + final arguments = [ + '-v', + 'error', + '-hide_banner', + '-i', + audioPath, + '-ss', + _formatSeconds(track.startSec), + ]; + if (track.endSec > 0) { + arguments.addAll(['-to', _formatSeconds(track.endSec)]); + } + arguments.addAll( + outputExt == 'flac' + ? ['-c:a', 'flac', '-compression_level', '8'] + : ['-c:a', 'copy'], + ); + final metadata = { + 'TITLE': track.title, + 'ARTIST': track.artist.isNotEmpty + ? track.artist + : (albumMetadata['artist'] ?? ''), + 'ALBUM': albumMetadata['album'] ?? '', + 'ALBUMARTIST': albumMetadata['artist'] ?? '', + 'TRACKNUMBER': track.number.toString(), + 'GENRE': albumMetadata['genre'] ?? '', + 'DATE': albumMetadata['date'] ?? '', + 'ISRC': track.isrc, + 'COMPOSER': track.composer, + }..removeWhere((_, value) => value.isEmpty); + AudioMetadataMapper.appendMappedMetadataArguments(arguments, metadata); + arguments.addAll([outputPath, '-y']); + _log.d('CUE split track ${track.number}'); + final result = await FFmpegExecution.arguments(arguments); + if (!result.success) { + _log.e('CUE split failed for track ${track.number}: ${result.output}'); + continue; + } + outputPaths.add(outputPath); + FFmpegProbe.invalidate(outputPath); + _log.i('CUE split: track ${track.number} -> $outputName'); + } + if (outputPaths.isEmpty) { + _log.e('CUE split: no tracks were successfully extracted'); + return null; + } + _log.i('CUE split complete: ${outputPaths.length}/${tracks.length} tracks'); + return outputPaths; + } + + static String _formatSeconds(double seconds) { + if (seconds < 0) return '0'; + final hours = seconds ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + final secs = seconds - hours * 3600 - minutes * 60; + return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${secs.toStringAsFixed(3).padLeft(6, '0')}'; + } +} diff --git a/lib/services/ffmpeg_decryption.dart b/lib/services/ffmpeg_decryption.dart new file mode 100644 index 00000000..82189458 --- /dev/null +++ b/lib/services/ffmpeg_decryption.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:spotiflac_android/services/ffmpeg_models.dart'; +import 'package:spotiflac_android/services/ffmpeg_probe.dart'; +import 'package:spotiflac_android/services/ffmpeg_support.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('FFmpeg'); + +/// Extension descriptor interpretation and MOV-key container fallbacks. +abstract final class FFmpegDecryption { + static Future decrypt({ + required String inputPath, + required DownloadDecryptionDescriptor descriptor, + bool deleteOriginal = true, + }) async { + if (descriptor.normalizedStrategy != 'ffmpeg.mov_key') { + _log.e( + 'Unsupported download decryption strategy: ${descriptor.strategy}', + ); + return null; + } + final key = descriptor.key.trim(); + if (key.isEmpty) return inputPath; + return _decryptMovKeyFile( + inputPath: inputPath, + decryptionKey: key, + inputFormat: descriptor.inputFormat, + outputExtension: descriptor.outputExtension, + deleteOriginal: deleteOriginal, + ); + } + + static List _keyCandidates(String rawKey) { + final candidates = []; + void add(String key) { + final normalized = key.trim(); + if (normalized.isNotEmpty && !candidates.contains(normalized)) { + candidates.add(normalized); + } + } + + final trimmed = rawKey.trim(); + if (trimmed.isEmpty) return candidates; + add(trimmed); + final noPrefix = trimmed.startsWith(RegExp(r'0x', caseSensitive: false)) + ? trimmed.substring(2) + : trimmed; + add(noPrefix); + final compactHex = noPrefix.replaceAll(RegExp(r'[^0-9a-fA-F]'), ''); + if (compactHex.isNotEmpty && compactHex.length.isEven) add(compactHex); + try { + final decoded = base64Decode(noPrefix.replaceAll(RegExp(r'\s+'), '')); + if (decoded.isNotEmpty) { + add(decoded.map((b) => b.toRadixString(16).padLeft(2, '0')).join()); + } + } catch (_) {} + return candidates; + } + + static String _preferredExtension(String inputPath, String? requested) { + final extension = (requested ?? '').trim(); + if (extension.isNotEmpty) { + return extension.startsWith('.') ? extension : '.$extension'; + } + for (final ext in ['.mp3', '.opus', '.mp4']) { + if (inputPath.toLowerCase().endsWith(ext)) return ext; + } + return '.flac'; + } + + static String _outputPath(String inputPath, String 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 path = '${file.parent.path}${Platform.pathSeparator}$base$extension'; + return path == inputPath + ? '${file.parent.path}${Platform.pathSeparator}${base}_converted$extension' + : path; + } + + static Future _decryptMovKeyFile({ + required String inputPath, + required String decryptionKey, + String? inputFormat, + String? outputExtension, + required bool deleteOriginal, + }) async { + final preferredExt = _preferredExtension(inputPath, outputExtension); + var tempOutput = _outputPath(inputPath, preferredExt); + final demuxer = (inputFormat ?? '').trim().isNotEmpty + ? inputFormat!.trim() + : 'mov'; + String command( + String outputPath, { + required bool mapAudioOnly, + required String key, + bool forceMov = false, + }) { + final map = mapAudioOnly ? '-map 0:a ' : ''; + // Encrypted MOV may have a FLAC suffix; override input detection. Native + // FLAC output also needs an explicit muxer. MOV permits codecs such as + // AC-4 that the MP4 muxer rejects while keeping the .mp4 filename. + final muxer = forceMov + ? '-f mov ' + : outputPath.toLowerCase().endsWith('.flac') + ? '-f flac ' + : ''; + return '-v error -decryption_key "$key" -f $demuxer -i "$inputPath" $map-c copy $muxer"$outputPath" -y'; + } + + final candidates = _keyCandidates(decryptionKey); + if (candidates.isEmpty) { + _log.e('No usable decryption key candidates'); + return null; + } + FFmpegResult? lastResult; + var succeeded = false; + for (final key in candidates) { + _log.d('Executing FFmpeg decrypt command (key length: ${key.length})'); + var result = await FFmpegExecution.command( + command(tempOutput, mapAudioOnly: preferredExt == '.flac', key: key), + ); + if (!result.success && preferredExt == '.flac') { + final fallback = _outputPath(inputPath, '.m4a'); + final attempt = await FFmpegExecution.command( + command(fallback, mapAudioOnly: false, key: key), + ); + if (attempt.success) { + tempOutput = fallback; + result = attempt; + } + } + if (!result.success && + (preferredExt == '.flac' || preferredExt == '.m4a')) { + final fallback = _outputPath(inputPath, '.mp4'); + final attempt = await FFmpegExecution.command( + command(fallback, mapAudioOnly: false, key: key), + ); + if (attempt.success) { + tempOutput = fallback; + result = attempt; + } + } + if (!result.success) { + final fallback = _outputPath(inputPath, '.mp4'); + final attempt = await FFmpegExecution.command( + command(fallback, mapAudioOnly: false, key: key, forceMov: true), + ); + if (attempt.success) { + tempOutput = fallback; + result = attempt; + } + } + lastResult = result; + if (result.success) { + succeeded = true; + break; + } + await FFmpegOutput.cleanup(tempOutput); + } + if (!succeeded) { + _log.e('FFmpeg decrypt failed: ${lastResult?.output ?? 'unknown error'}'); + return null; + } + try { + if (!await File(tempOutput).exists()) { + _log.e('Decrypted output file not found: $tempOutput'); + return null; + } + final source = File(inputPath); + if (deleteOriginal && await source.exists()) await source.delete(); + FFmpegProbe.invalidate(inputPath); + FFmpegProbe.invalidate(tempOutput); + return tempOutput; + } catch (e) { + _log.e('Failed to finalize decrypted file: $e'); + return null; + } + } +} diff --git a/lib/services/ffmpeg_metadata.dart b/lib/services/ffmpeg_metadata.dart new file mode 100644 index 00000000..198bebe1 --- /dev/null +++ b/lib/services/ffmpeg_metadata.dart @@ -0,0 +1,486 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:spotiflac_android/services/audio_metadata_mapper.dart'; +import 'package:spotiflac_android/services/ffmpeg_models.dart'; +import 'package:spotiflac_android/services/ffmpeg_support.dart'; +import 'package:spotiflac_android/services/id3v23_lyrics.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('FFmpeg'); + +/// Container-specific metadata and artwork operations, independent of encoding. +abstract final class FFmpegMetadata { + static List buildCoverResizeArguments({ + required String inputPath, + required String outputPath, + required int maxDimension, + }) { + if (maxDimension < 64 || maxDimension > 8192) { + throw ArgumentError.value( + maxDimension, + 'maxDimension', + 'Must be between 64 and 8192 pixels', + ); + } + return [ + '-y', + '-i', + inputPath, + '-map_metadata', + '-1', + '-an', + '-sn', + '-vf', + 'scale=$maxDimension:$maxDimension:' + 'force_original_aspect_ratio=decrease:flags=lanczos', + '-frames:v', + '1', + '-update', + '1', + '-q:v', + '2', + outputPath, + ]; + } + + static Future resizeCoverArt({ + required String inputPath, + required String outputPath, + required int maxDimension, + }) async { + try { + if (!await File(inputPath).exists()) return false; + final result = await FFmpegExecution.arguments( + buildCoverResizeArguments( + inputPath: inputPath, + outputPath: outputPath, + maxDimension: maxDimension, + ), + ); + final output = File(outputPath); + if (result.success && + await output.exists() && + await output.length() > 0) { + return true; + } + _log.w( + 'Cover resize failed (${result.returnCode}): ' + '${FFmpegExecution.preview(result.output)}', + ); + } catch (e) { + _log.w('Cover resize failed: $e'); + } + await FFmpegOutput.cleanup(outputPath); + return false; + } + + static void appendCoverInputArguments( + List arguments, { + String map = '1:v', + String disposition = '-disposition:v:0', + }) => arguments.addAll([ + '-map', + map, + '-c:v', + 'copy', + disposition, + 'attached_pic', + '-metadata:s:v', + 'title=Album cover', + '-metadata:s:v', + 'comment=Cover (front)', + ]); + + static Future _finish( + FFmpegResult result, + String tempOutput, + String path, + String format, + ) async { + if (result.success) { + final promoted = await FFmpegOutput.promote( + tempOutput, + path, + onMissing: () => + _log.e('Temp $format output file not found: $tempOutput'), + onError: (e) => + _log.e('Failed to replace $format file after metadata embed: $e'), + ); + if (!promoted) return null; + _log.d('$format metadata embedded successfully'); + return path; + } + await FFmpegOutput.cleanup(tempOutput); + _log.e('$format Metadata/Cover embed failed: ${result.output}'); + return null; + } + + static Future embedMetadata({ + required String flacPath, + String? coverPath, + Map? metadata, + String artistTagMode = artistTagModeJoined, + }) async { + final tempOutput = await FFmpegOutput.tempPath('.flac'); + final arguments = ['-v', 'error', '-hide_banner', '-i', flacPath]; + if (coverPath != null) arguments.addAll(['-i', coverPath]); + arguments.addAll(['-map', '0:a']); + if (coverPath != null) { + appendCoverInputArguments( + arguments, + map: '1:0', + disposition: '-disposition:v', + ); + } + arguments.addAll(['-c:a', 'copy']); + if (metadata != null) { + AudioMetadataMapper.appendVorbisMetadataArguments( + arguments, + metadata, + artistTagMode: artistTagMode, + ); + } + arguments.addAll([tempOutput, '-y']); + _log.d('Executing FFmpeg FLAC embed command'); + return _finish( + await FFmpegExecution.arguments(arguments), + tempOutput, + flacPath, + 'FLAC', + ); + } + + static Future embedMetadataToMp3({ + required String mp3Path, + String? coverPath, + Map? metadata, + String artistTagMode = artistTagModeJoined, + bool preserveMetadata = false, + }) async { + // ID3 keeps one artist frame: only primary mode changes its value. + if (metadata != null) { + metadata = applyArtistTagModeToMetadata(metadata, artistTagMode); + } + var tempOutput = await FFmpegOutput.tempPath('.mp3'); + final lyrics = AudioMetadataMapper.extractLyricsForId3(metadata); + var result = await _runMp3Embed( + mp3Path: mp3Path, + tempOutput: tempOutput, + coverPath: coverPath, + metadata: metadata, + preserveMetadata: preserveMetadata, + audioCodec: 'copy', + ); + if (!result.success && + (result.output.contains('Invalid audio stream') || + result.output.contains('incorrect codec parameters'))) { + _log.w('MP3 copy failed (codec mismatch), re-encoding with libmp3lame'); + await FFmpegOutput.cleanup(tempOutput); + tempOutput = await FFmpegOutput.tempPath('.mp3'); + result = await _runMp3Embed( + mp3Path: mp3Path, + tempOutput: tempOutput, + coverPath: coverPath, + metadata: metadata, + preserveMetadata: preserveMetadata, + audioCodec: 'libmp3lame', + audioBitrate: '192k', + ); + } + final embeddedPath = await _finish(result, tempOutput, mp3Path, 'MP3'); + if (embeddedPath != null && lyrics != null) { + await _ensureMp3UnsyncedLyricsFrame(embeddedPath, lyrics); + } + return embeddedPath; + } + + static Future _runMp3Embed({ + required String mp3Path, + required String tempOutput, + String? coverPath, + Map? metadata, + required bool preserveMetadata, + required String audioCodec, + String? audioBitrate, + }) async { + final arguments = ['-v', 'error', '-hide_banner', '-i', mp3Path]; + if (coverPath != null) arguments.addAll(['-i', coverPath]); + arguments.addAll([ + '-map', + '0:a', + '-map_metadata', + preserveMetadata ? '0' : '-1', + ]); + if (coverPath != null) { + arguments.addAll([ + '-map', + '1:0', + '-c:v:0', + 'copy', + '-id3v2_version', + '3', + '-metadata:s:v', + 'title=Album cover', + '-metadata:s:v', + 'comment=Cover (front)', + ]); + } + arguments.addAll(['-c:a', audioCodec]); + if (audioBitrate != null) arguments.addAll(['-b:a', audioBitrate]); + if (metadata != null) { + AudioMetadataMapper.appendMappedMetadataArguments( + arguments, + AudioMetadataMapper.convertToId3Tags(metadata), + ); + } + arguments.addAll(['-id3v2_version', '3', tempOutput, '-y']); + _log.d('Executing FFmpeg MP3 embed command (codec: $audioCodec)'); + return FFmpegExecution.arguments(arguments); + } + + static Future _ensureMp3UnsyncedLyricsFrame( + String path, + String lyrics, + ) async { + try { + final file = File(path); + if (!await file.exists()) return; + if (!await Id3v23Lyrics.writeUnsyncedLyricsToFile(file, lyrics)) { + _log.w('Skipping MP3 USLT lyrics frame update: unsupported ID3 tag'); + return; + } + _log.d('MP3 USLT lyrics frame written (${lyrics.length} chars)'); + } catch (e) { + _log.w('Failed to write MP3 USLT lyrics frame: $e'); + } + } + + static Future embedMetadataToOpus({ + required String opusPath, + String? coverPath, + Map? metadata, + String artistTagMode = artistTagModeJoined, + bool preserveMetadata = false, + Future Function(List)? execute, + }) async { + final tempOutput = await FFmpegOutput.tempPath('.opus'); + // Opus tags are on the audio stream. Copy to output globals, override the + // selected values and let the muxer write globals into the output stream. + final arguments = [ + '-v', + 'error', + '-hide_banner', + '-i', + opusPath, + '-map', + '0:a', + '-map_metadata', + preserveMetadata ? '0:s:a:0' : '-1', + '-map_metadata:s:a', + '-1', + '-c:a', + 'copy', + ]; + if (metadata != null) { + AudioMetadataMapper.appendVorbisMetadataArguments( + arguments, + metadata, + artistTagMode: artistTagMode, + ); + } + if (coverPath != null) { + try { + final pictureBlock = await _createMetadataBlockPicture(coverPath); + if (pictureBlock != null) { + arguments.addAll([ + '-metadata', + 'METADATA_BLOCK_PICTURE=$pictureBlock', + ]); + _log.d( + 'Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)', + ); + } else { + _log.w('Failed to create METADATA_BLOCK_PICTURE, skipping cover'); + } + } catch (e) { + _log.e('Error creating METADATA_BLOCK_PICTURE: $e'); + } + } + arguments.addAll([tempOutput, '-y']); + _log.d('Executing FFmpeg Opus embed command'); + return _finish( + await (execute ?? FFmpegExecution.arguments)(arguments), + tempOutput, + opusPath, + 'Opus', + ); + } + + static Future embedMetadataToM4a({ + required String m4aPath, + String? coverPath, + Map? metadata, + String artistTagMode = artistTagModeJoined, + bool preserveMetadata = true, + }) async { + if (metadata != null) { + metadata = applyArtistTagModeToMetadata(metadata, artistTagMode); + } + final tempOutput = await FFmpegOutput.tempPath('.m4a'); + final normalizedCoverPath = coverPath?.trim(); + final hasCover = + normalizedCoverPath != null && + normalizedCoverPath.isNotEmpty && + await File(normalizedCoverPath).exists(); + final preserveExistingStreams = preserveMetadata && !hasCover; + List buildArgs(bool forceMov) { + final arguments = ['-v', 'error', '-hide_banner', '-i', m4aPath]; + if (hasCover) arguments.addAll(['-i', normalizedCoverPath]); + // Preserve attached artwork when no replacement cover is supplied. + arguments.addAll( + preserveExistingStreams + ? ['-map', '0', '-c', 'copy'] + : ['-map', '0:a', '-c:a', 'copy'], + ); + arguments.addAll(['-map_metadata', preserveMetadata ? '0' : '-1']); + if (hasCover) appendCoverInputArguments(arguments); + if (metadata != null) { + AudioMetadataMapper.appendMappedMetadataArguments( + arguments, + AudioMetadataMapper.convertToM4aTags(metadata), + ); + } + // MOV accepts codecs MP4 rejects; cover writes force a proper covr atom. + if (forceMov) { + arguments.addAll(['-f', 'mov']); + } else if (hasCover) { + arguments.addAll(['-f', 'mp4']); + } + return arguments..addAll([tempOutput, '-y']); + } + + _log.d('Executing FFmpeg M4A embed command'); + var result = await FFmpegExecution.arguments(buildArgs(false)); + if (!result.success) { + _log.w('M4A embed failed with default muxer, retrying with mov muxer'); + await FFmpegOutput.cleanup(tempOutput); + result = await FFmpegExecution.arguments(buildArgs(true)); + } + final embedded = await _finish(result, tempOutput, m4aPath, 'M4A'); + if (embedded != null && metadata != null) { + await writeM4AFreeformTags(m4aPath, metadata); + await writeM4AReleaseIdentityTags(m4aPath, metadata); + } + return embedded; + } + + static Future _createMetadataBlockPicture(String imagePath) async { + try { + final file = File(imagePath); + if (!await file.exists()) { + _log.e('Cover image not found: $imagePath'); + return null; + } + final data = await file.readAsBytes(); + final path = imagePath.toLowerCase(); + final pngSignature = + data.length >= 8 && + data[0] == 0x89 && + data[1] == 0x50 && + data[2] == 0x4e && + data[3] == 0x47; + final mime = + path.endsWith('.png') || + (!path.endsWith('.jpg') && + !path.endsWith('.jpeg') && + pngSignature) + ? 'image/png' + : 'image/jpeg'; + final mimeBytes = utf8.encode(mime); + // One allocation: type, MIME length/string, empty description, four zero + // image properties, payload length and the original image bytes. + final buffer = ByteData(32 + mimeBytes.length + data.length); + final bytes = buffer.buffer.asUint8List(); + buffer.setUint32(0, 3, Endian.big); + buffer.setUint32(4, mimeBytes.length, Endian.big); + bytes.setRange(8, 8 + mimeBytes.length, mimeBytes); + buffer.setUint32(28 + mimeBytes.length, data.length, Endian.big); + bytes.setRange(32 + mimeBytes.length, bytes.length, data); + return base64Encode(bytes); + } catch (e) { + _log.e('Error creating METADATA_BLOCK_PICTURE: $e'); + return null; + } + } + + static Future embedChunkTagsNative( + String path, + Map metadata, + String? coverPath, + ) async { + final fields = AudioMetadataMapper.vorbisToNativeChunkFields(metadata); + if (coverPath != null && coverPath.trim().isNotEmpty) { + fields['cover_path'] = coverPath; + } + if (fields.isEmpty) return true; + try { + final result = await PlatformBridge.editFileMetadata(path, fields); + if (result['error'] != null) { + _log.w('editFileMetadata for $path failed: ${result['error']}'); + return false; + } + return result['success'] == true; + } catch (e) { + _log.w('editFileMetadata for $path failed: $e'); + return false; + } + } + + static Future writeM4AFreeformTags( + String path, + Map metadata, + ) async { + final fields = {}; + for (final entry in metadata.entries) { + final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); + switch (key) { + case 'ISRC': + fields['isrc'] = entry.value; + break; + case 'LABEL': + case 'ORGANIZATION': + fields['label'] = entry.value; + break; + } + } + if (fields.isEmpty) return; + try { + await PlatformBridge.writeM4AFreeformTags(path, fields); + } catch (e) { + _log.w('writeM4AFreeformTags failed for $path: $e'); + } + } + + static Future writeM4AReleaseIdentityTags( + String path, + Map metadata, + ) async { + final fields = AudioMetadataMapper.m4aReleaseIdentityFields(metadata); + if (fields.isEmpty) return true; + try { + final result = await PlatformBridge.editFileMetadata(path, fields); + if (result['error'] != null || result['method'] != 'native_m4a') { + _log.w('Native M4A release identity write was not completed for $path'); + return false; + } + return true; + } catch (e) { + _log.w('M4A release identity write failed for $path: $e'); + return false; + } + } +} diff --git a/lib/services/ffmpeg_probe.dart b/lib/services/ffmpeg_probe.dart new file mode 100644 index 00000000..2fc68367 --- /dev/null +++ b/lib/services/ffmpeg_probe.dart @@ -0,0 +1,148 @@ +import 'dart:io'; + +import 'package:ffmpeg_kit_flutter_new_audio/ffprobe_kit.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('FFmpeg'); + +class PrimaryAudioProperties { + final String? codec; + final int? bitDepth; + final int? sampleRate; + + const PrimaryAudioProperties({this.codec, this.bitDepth, this.sampleRate}); +} + +class _AudioProbeCacheEntry { + final String identity; + final Future result; + + const _AudioProbeCacheEntry({required this.identity, required this.result}); +} + +/// Media inspection owns its cache independently of conversion and tagging. +abstract final class FFmpegProbe { + static final Map _cache = {}; + + static void invalidate(String path) => _cache.remove(path); + + static ({int width, int height})? imageDimensionsFromProperties( + Map properties, + ) { + final width = int.tryParse(properties['width']?.toString() ?? ''); + final height = int.tryParse(properties['height']?.toString() ?? ''); + if (width == null || height == null || width <= 0 || height <= 0) { + return null; + } + return (width: width, height: height); + } + + static Future<({int width, int height})?> imageDimensions(String path) async { + try { + final info = (await FFprobeKit.getMediaInformation( + path, + )).getMediaInformation(); + if (info == null) return null; + for (final stream in info.getStreams()) { + final dimensions = imageDimensionsFromProperties( + stream.getAllProperties() ?? const {}, + ); + if (dimensions != null) return dimensions; + } + } catch (e) { + _log.w('Cover dimension probe failed for $path: $e'); + } + return null; + } + + static Future audioCodec(String path) async => + (await audioProperties(path)).codec; + + static Future _identity(String path) async { + try { + final stat = await File(path).stat(); + if (stat.type != FileSystemEntityType.file) return null; + return '${stat.modified.millisecondsSinceEpoch}:${stat.size}'; + } catch (_) { + return null; + } + } + + static Future audioProperties(String path) async { + final identity = await _identity(path); + if (identity != null) { + final cached = _cache[path]; + if (cached != null && cached.identity == identity) return cached.result; + } + final result = _readAudioProperties(path); + if (identity != null) { + while (_cache.length >= 64 && _cache.isNotEmpty) { + _cache.remove(_cache.keys.first); + } + _cache[path] = _AudioProbeCacheEntry(identity: identity, result: result); + } + return result; + } + + static Future _readAudioProperties( + String path, + ) async { + try { + final info = (await FFprobeKit.getMediaInformation( + path, + )).getMediaInformation(); + if (info == null) return const PrimaryAudioProperties(); + for (final stream in info.getStreams()) { + final props = stream.getAllProperties() ?? const {}; + if (props['codec_type']?.toString() != 'audio') continue; + final codec = props['codec_name']?.toString().trim().toLowerCase(); + final bitDepth = + int.tryParse(props['bits_per_raw_sample']?.toString() ?? '') ?? + int.tryParse(props['bits_per_sample']?.toString() ?? ''); + final sampleRate = int.tryParse(props['sample_rate']?.toString() ?? ''); + return PrimaryAudioProperties( + codec: codec == null || codec.isEmpty ? null : codec, + bitDepth: bitDepth != null && bitDepth > 0 ? bitDepth : null, + sampleRate: sampleRate != null && sampleRate > 0 ? sampleRate : null, + ); + } + } catch (e) { + _log.w('Audio property probe failed for $path: $e'); + } + return const PrimaryAudioProperties(); + } + + static bool isLosslessAudioCodec(String? codec) { + final normalized = codec?.trim().toLowerCase().replaceAll('-', '_') ?? ''; + return normalized.startsWith('pcm_') || + const { + 'alac', + 'flac', + 'wavpack', + 'ape', + 'tta', + 'mlp', + 'truehd', + 'shorten', + }.contains(normalized); + } + + static Future isNativeFlacFile(String path) async { + try { + final file = await File(path).open(); + try { + final header = await file.read(4); + return header.length == 4 && + header[0] == 0x66 && + header[1] == 0x4c && + header[2] == 0x61 && + header[3] == 0x43; + } finally { + await file.close(); + } + } catch (e) { + _log.w('Native FLAC magic probe failed for $path: $e'); + return false; + } + } +} diff --git a/lib/services/ffmpeg_replaygain.dart b/lib/services/ffmpeg_replaygain.dart new file mode 100644 index 00000000..36076192 --- /dev/null +++ b/lib/services/ffmpeg_replaygain.dart @@ -0,0 +1,201 @@ +import 'dart:io'; +import 'dart:math' as math; + +import 'package:spotiflac_android/services/ffmpeg_models.dart'; +import 'package:spotiflac_android/services/ffmpeg_support.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('FFmpeg'); + +/// EBU R128 measurement and non-Ogg ReplayGain stream-copy tagging. +abstract final class FFmpegReplayGain { + static Future scan( + String filePath, { + void Function()? onUnsupportedDecoder, + }) async { + _log.d( + 'Scanning ReplayGain for: ${filePath.split(Platform.pathSeparator).last}', + ); + // Suppress per-frame measurements and retain the final ebur128 summary. + final result = await FFmpegExecution.arguments([ + '-hide_banner', + '-nostats', + '-loglevel', + 'info', + '-i', + filePath, + '-map', + '0:a:0', + '-vn', + '-sn', + '-dn', + '-af', + 'ebur128=peak=true:framelog=quiet', + '-f', + 'null', + '-', + ]); + return parseScan(result, onUnsupportedDecoder: onUnsupportedDecoder); + } + + static ReplayGainResult? parseScan( + FFmpegResult result, { + void Function()? onUnsupportedDecoder, + }) { + final output = result.output; + if (!result.success) { + final decoderMissing = RegExp( + r'decoder.*not found|no decoder found|unknown decoder', + caseSensitive: false, + ); + final diagnostic = output + .split('\n') + .where( + (line) => + decoderMissing.hasMatch(line) || + RegExp( + r'error|invalid|failed', + caseSensitive: false, + ).hasMatch(line), + ) + .take(3) + .join(' ') + .trim(); + _log.w( + 'ReplayGain scan failed (exit ${result.returnCode}): ${diagnostic.isEmpty ? "no decoder diagnostics" : diagnostic.substring(0, math.min(diagnostic.length, 500))}', + ); + if (decoderMissing.hasMatch(output)) onUnsupportedDecoder?.call(); + // A failed decoder can print a partial/default summary, not a measurement + // of the complete track. + return null; + } + + final integratedMatch = RegExp( + r'I:\s+(-?\d+\.?\d*)\s+LUFS', + ).allMatches(output); + if (integratedMatch.isEmpty) { + _log.w('ReplayGain scan: could not parse integrated loudness'); + return null; + } + // The final match is the summary, rather than a per-segment measurement. + final integratedLufs = double.tryParse(integratedMatch.last.group(1) ?? ''); + if (integratedLufs == null) { + _log.w('ReplayGain scan: invalid integrated loudness value'); + return null; + } + + // Use the maximum true peak across channels, converting dBFS to a ratio. + double? truePeakDbfs; + for (final match in RegExp( + r'Peak:\s+(-?\d+\.?\d*)\s+dBFS', + ).allMatches(output)) { + final value = double.tryParse(match.group(1) ?? ''); + if (value != null && (truePeakDbfs == null || value > truePeakDbfs)) { + truePeakDbfs = value; + } + } + final gainDb = -18.0 - integratedLufs; + final peakLinear = truePeakDbfs == null + ? 1.0 + : math.pow(10, truePeakDbfs / 20.0).toDouble(); + final trackGain = + '${gainDb >= 0 ? "+" : ""}${gainDb.toStringAsFixed(2)} dB'; + final trackPeak = peakLinear.toStringAsFixed(6); + _log.i( + 'ReplayGain scan result: gain=$trackGain, peak=$trackPeak (integrated=${integratedLufs.toStringAsFixed(1)} LUFS)', + ); + return ReplayGainResult( + trackGain: trackGain, + trackPeak: trackPeak, + integratedLufs: integratedLufs, + truePeakLinear: peakLinear, + ); + } + + static Future writeAlbumTags( + String filePath, + String gain, + String peak, { + bool returnTempPath = false, + void Function(String tempPath)? onTempReady, + }) => _writeTags( + filePath, + 'Album', + gain, + peak, + returnTempPath: returnTempPath, + onTempReady: onTempReady, + ); + + static Future writeTrackTags( + String filePath, + String gain, + String peak, + ) => _writeTags(filePath, 'Track', gain, peak); + + static Future _writeTags( + String filePath, + String scope, + String gain, + String peak, { + bool returnTempPath = false, + void Function(String tempPath)? onTempReady, + }) async { + final ext = filePath.contains('.') + ? '.${filePath.split('.').last}' + : '.tmp'; + if (ext.toLowerCase() == '.opus' || ext.toLowerCase() == '.ogg') { + _log.e('Ogg/Opus ReplayGain requires the native tag writer'); + return false; + } + final tempOutput = await FFmpegOutput.tempPath(ext); + final tag = scope.toUpperCase(); + _log.d('Writing ${scope.toLowerCase()} ReplayGain tags via FFmpeg'); + final result = await FFmpegExecution.arguments([ + '-v', + 'error', + '-hide_banner', + '-i', + filePath, + '-map', + '0', + '-c', + 'copy', + '-map_metadata', + '0', + '-metadata', + 'REPLAYGAIN_${tag}_GAIN=$gain', + '-metadata', + 'REPLAYGAIN_${tag}_PEAK=$peak', + tempOutput, + '-y', + ]); + if (!result.success) { + _log.e( + '$scope ReplayGain write failed (code ${result.returnCode}): ${result.output}', + ); + } else if (returnTempPath) { + try { + if (await File(tempOutput).exists()) { + onTempReady?.call(tempOutput); + return true; + } + } catch (e) { + _log.w( + 'Failed to replace file with ${scope.toLowerCase()} ReplayGain: $e', + ); + } + } else if (await FFmpegOutput.promote( + tempOutput, + filePath, + onError: (e) => _log.w( + 'Failed to replace file with ${scope.toLowerCase()} ReplayGain: $e', + ), + )) { + _log.d('$scope ReplayGain tags written successfully'); + return true; + } + await FFmpegOutput.cleanup(tempOutput); + return false; + } +} diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 488e6556..13c7ec05 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1,1343 +1,134 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:math' as math; import 'package:flutter/foundation.dart'; -import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_audio/ffprobe_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:spotiflac_android/services/audio_metadata_mapper.dart'; +import 'package:spotiflac_android/services/ffmpeg_conversion.dart'; +import 'package:spotiflac_android/services/ffmpeg_decryption.dart'; +import 'package:spotiflac_android/services/ffmpeg_metadata.dart'; import 'package:spotiflac_android/services/ffmpeg_models.dart'; -import 'package:spotiflac_android/services/id3v23_lyrics.dart'; -import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/ffmpeg_probe.dart'; +import 'package:spotiflac_android/services/ffmpeg_replaygain.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; -import 'package:spotiflac_android/utils/logger.dart'; export 'package:spotiflac_android/services/ffmpeg_models.dart'; -final _log = AppLogger('FFmpeg'); - -class _ResolvedLosslessConversionQuality { - final int? targetBitDepth; - final int? targetSampleRate; - - const _ResolvedLosslessConversionQuality({ - this.targetBitDepth, - this.targetSampleRate, - }); -} - -class _PrimaryAudioProperties { - final String? codec; - final int? bitDepth; - final int? sampleRate; - - const _PrimaryAudioProperties({this.codec, this.bitDepth, this.sampleRate}); -} - -class _AudioProbeCacheEntry { - final String identity; - final Future<_PrimaryAudioProperties> result; - - const _AudioProbeCacheEntry({required this.identity, required this.result}); -} - -class _ConversionOutputPlan { - final String workingPath; - final String finalPath; - - const _ConversionOutputPlan({ - required this.workingPath, - required this.finalPath, - }); - - bool get requiresPromotion => workingPath != finalPath; -} - +/// Compatibility entry point for callers; feature modules own the operations. class FFmpegService { - static const int _commandLogPreviewLength = 300; - static const int _audioProbeCacheMaxEntries = 64; - static const String _genericMovKeyDecryptionStrategy = 'ffmpeg.mov_key'; - static int _tempEmbedCounter = 0; - static final Map _audioProbeCache = - {}; - - static String _buildOutputPath(String inputPath, String extension) { - final normalizedExt = extension.startsWith('.') ? extension : '.$extension'; - final inputFile = File(inputPath); - final dir = inputFile.parent.path; - final filename = inputFile.uri.pathSegments.last; - final dotIndex = filename.lastIndexOf('.'); - final baseName = dotIndex > 0 ? filename.substring(0, dotIndex) : filename; - var outputPath = '$dir${Platform.pathSeparator}$baseName$normalizedExt'; - - if (outputPath == inputPath) { - outputPath = - '$dir${Platform.pathSeparator}${baseName}_converted$normalizedExt'; - } - return outputPath; - } - - static bool _sameLocalPath(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; - } - - // Reserve names atomically: concurrent conversions may choose the same - // sibling basename before either FFmpeg process has created its output. - static Future _reserveConversionPath(String requestedPath) async { - final file = File(requestedPath); - final fileName = file.uri.pathSegments.last; - final dotIndex = fileName.lastIndexOf('.'); - final baseName = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName; - final extension = dotIndex > 0 ? fileName.substring(dotIndex) : ''; - for (var index = 1; ; index++) { - final candidate = index == 1 - ? requestedPath - : '${file.parent.path}${Platform.pathSeparator}$baseName ($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<_ConversionOutputPlan> _conversionOutputPlan( - String inputPath, - String extension, { - required bool deleteOriginal, - }) async { - final normalizedExt = extension.startsWith('.') ? extension : '.$extension'; - final inputFile = File(inputPath); - final fileName = inputFile.uri.pathSegments.last; - final dotIndex = fileName.lastIndexOf('.'); - final baseName = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName; - final requestedPath = - '${inputFile.parent.path}${Platform.pathSeparator}$baseName$normalizedExt'; - - if (_sameLocalPath(requestedPath, inputPath) && deleteOriginal) { - final token = DateTime.now().microsecondsSinceEpoch; - return _ConversionOutputPlan( - workingPath: await _reserveConversionPath( - '${inputFile.parent.path}${Platform.pathSeparator}.$baseName.spotiflac-$token$normalizedExt', - ), - finalPath: inputPath, - ); - } - - final finalPath = await _reserveConversionPath(requestedPath); - return _ConversionOutputPlan(workingPath: finalPath, finalPath: finalPath); - } - - static Future _cleanupConversionOutput( - _ConversionOutputPlan plan, - ) async { - try { - final output = File(plan.workingPath); - if (await output.exists()) await output.delete(); - } catch (e) { - _log.w('Failed to clean conversion output: $e'); - } - } - - static Future _finalizeConversionOutput({ - required _ConversionOutputPlan plan, - required String inputPath, - required bool deleteOriginal, - }) async { - final workingFile = File(plan.workingPath); - if (!await workingFile.exists() || await workingFile.length() == 0) { - _log.e('Converted output is missing or empty: ${plan.workingPath}'); - await _cleanupConversionOutput(plan); - return null; - } - - if (plan.requiresPromotion) { - final source = File(inputPath); - final backupPath = - '$inputPath.spotiflac-backup-${DateTime.now().microsecondsSinceEpoch}'; - final backup = File(backupPath); - var sourceMovedToBackup = false; - try { - if (await source.exists()) { - await source.rename(backupPath); - sourceMovedToBackup = true; - } - await File(plan.workingPath).rename(plan.finalPath); - } catch (e) { - _log.e('Failed to replace original after conversion: $e'); - try { - if (sourceMovedToBackup && - !await source.exists() && - await backup.exists()) { - await backup.rename(inputPath); - } - } catch (restoreError) { - _log.e('Failed to restore original conversion backup: $restoreError'); - } - await _cleanupConversionOutput(plan); - return null; - } - try { - if (await backup.exists()) await backup.delete(); - } catch (e) { - _log.w('Converted file is ready but backup cleanup failed: $e'); - } - return plan.finalPath; - } - - if (deleteOriginal && !_sameLocalPath(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'); - } - } - return plan.finalPath; - } - - static String _previewCommandForLog(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(); - - if (redacted.length <= _commandLogPreviewLength) { - return redacted; - } - return '${redacted.substring(0, _commandLogPreviewLength)}...'; - } - - static String _nextTempEmbedPath(String tempDirPath, String extension) { - final normalizedExt = extension.startsWith('.') ? extension : '.$extension'; - _tempEmbedCounter = (_tempEmbedCounter + 1) & 0x7fffffff; - final timestamp = DateTime.now().microsecondsSinceEpoch; - final processId = pid; - return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt'; - } - - static List _buildDecryptionKeyCandidates(String rawKey) { - final candidates = []; - - 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 _execute(String command) async { - try { - final session = await FFmpegKit.execute(command); - final returnCode = await session.getReturnCode(); - final output = await session.getOutput() ?? ''; - - return FFmpegResult( - success: ReturnCode.isSuccess(returnCode), - returnCode: returnCode?.getValue() ?? -1, - output: output, - ); - } catch (e) { - _log.e('FFmpeg execute error: $e'); - return FFmpegResult(success: false, returnCode: -1, output: e.toString()); - } - } - - static Future _executeWithArguments( - List arguments, - ) async { - try { - final session = await FFmpegKit.executeWithArguments(arguments); - final returnCode = await session.getReturnCode(); - final output = await session.getOutput() ?? ''; - - return FFmpegResult( - success: ReturnCode.isSuccess(returnCode), - returnCode: returnCode?.getValue() ?? -1, - output: output, - ); - } catch (e) { - _log.e('FFmpeg executeWithArguments error: $e'); - return FFmpegResult(success: false, returnCode: -1, output: e.toString()); - } - } - @visibleForTesting static List buildCoverResizeArguments({ required String inputPath, required String outputPath, required int maxDimension, - }) { - if (maxDimension < 64 || maxDimension > 8192) { - throw ArgumentError.value( - maxDimension, - 'maxDimension', - 'Must be between 64 and 8192 pixels', - ); - } - return [ - '-y', - '-i', - inputPath, - '-map_metadata', - '-1', - '-an', - '-sn', - '-vf', - 'scale=$maxDimension:$maxDimension:' - 'force_original_aspect_ratio=decrease:flags=lanczos', - '-frames:v', - '1', - '-update', - '1', - '-q:v', - '2', - outputPath, - ]; - } + }) => FFmpegMetadata.buildCoverResizeArguments( + inputPath: inputPath, + outputPath: outputPath, + maxDimension: maxDimension, + ); - /// Resizes cover art so its longest edge is [maxDimension]. - /// - /// FFmpeg is allowed to upscale smaller sources because this is an explicit - /// user choice. Aspect ratio is preserved and the result is written as the - /// format implied by [outputPath]. + /// Preserves aspect ratio, allowing upscale to the explicitly chosen size. static Future resizeCoverArt({ required String inputPath, required String outputPath, required int maxDimension, - }) async { - try { - final source = File(inputPath); - if (!await source.exists()) return false; - - final result = await _executeWithArguments( - buildCoverResizeArguments( - inputPath: inputPath, - outputPath: outputPath, - maxDimension: maxDimension, - ), - ); - final output = File(outputPath); - final hasOutput = await output.exists() && await output.length() > 0; - if (!result.success || !hasOutput) { - _log.w( - 'Cover resize failed (${result.returnCode}): ' - '${_previewCommandForLog(result.output)}', - ); - try { - if (await output.exists()) await output.delete(); - } catch (_) {} - return false; - } - return true; - } catch (e) { - _log.w('Cover resize failed: $e'); - try { - final output = File(outputPath); - if (await output.exists()) await output.delete(); - } catch (_) {} - return false; - } - } + }) => FFmpegMetadata.resizeCoverArt( + inputPath: inputPath, + outputPath: outputPath, + maxDimension: maxDimension, + ); @visibleForTesting static ({int width, int height})? imageDimensionsFromProperties( Map properties, - ) { - final width = int.tryParse(properties['width']?.toString() ?? ''); - final height = int.tryParse(properties['height']?.toString() ?? ''); - if (width == null || height == null || width <= 0 || height <= 0) { - return null; - } - return (width: width, height: height); - } + ) => FFmpegProbe.imageDimensionsFromProperties(properties); - /// Reads image dimensions without decoding the full bitmap into Dart memory. + /// Reads image dimensions without decoding a bitmap into Dart memory. static Future<({int width, int height})?> probeImageDimensions( String filePath, - ) async { - try { - final session = await FFprobeKit.getMediaInformation(filePath); - final info = session.getMediaInformation(); - if (info == null) return null; - for (final stream in info.getStreams()) { - final properties = - stream.getAllProperties() ?? const {}; - final dimensions = imageDimensionsFromProperties(properties); - if (dimensions != null) return dimensions; - } - } catch (e) { - _log.w('Cover dimension probe failed for $filePath: $e'); - } - return null; - } + ) => FFmpegProbe.imageDimensions(filePath); - static Future probePrimaryAudioCodec(String filePath) async { - return (await _probePrimaryAudioProperties(filePath)).codec; - } + static Future probePrimaryAudioCodec(String filePath) => + FFmpegProbe.audioCodec(filePath); - static Future _audioProbeIdentity(String filePath) async { - try { - final stat = await File(filePath).stat(); - if (stat.type != FileSystemEntityType.file) return null; - return '${stat.modified.millisecondsSinceEpoch}:${stat.size}'; - } catch (_) { - return null; - } - } + static bool isLosslessAudioCodec(String? codec) => + FFmpegProbe.isLosslessAudioCodec(codec); - static Future<_PrimaryAudioProperties> _probePrimaryAudioProperties( - String filePath, - ) async { - final identity = await _audioProbeIdentity(filePath); - if (identity != null) { - final cached = _audioProbeCache[filePath]; - if (cached != null && cached.identity == identity) { - return cached.result; - } - } - - final result = _readPrimaryAudioProperties(filePath); - if (identity != null) { - while (_audioProbeCache.length >= _audioProbeCacheMaxEntries && - _audioProbeCache.isNotEmpty) { - _audioProbeCache.remove(_audioProbeCache.keys.first); - } - _audioProbeCache[filePath] = _AudioProbeCacheEntry( - identity: identity, - result: result, - ); - } - return result; - } - - static Future<_PrimaryAudioProperties> _readPrimaryAudioProperties( - String filePath, - ) async { - try { - final session = await FFprobeKit.getMediaInformation(filePath); - final info = session.getMediaInformation(); - if (info == null) return const _PrimaryAudioProperties(); - - for (final stream in info.getStreams()) { - final props = stream.getAllProperties() ?? const {}; - if (props['codec_type']?.toString() != 'audio') continue; - final codec = props['codec_name']?.toString().trim().toLowerCase(); - final rawBits = props['bits_per_raw_sample']?.toString(); - final bits = props['bits_per_sample']?.toString(); - final bitDepth = - int.tryParse(rawBits ?? '') ?? int.tryParse(bits ?? ''); - final sampleRate = int.tryParse(props['sample_rate']?.toString() ?? ''); - return _PrimaryAudioProperties( - codec: codec == null || codec.isEmpty ? null : codec, - bitDepth: bitDepth != null && bitDepth > 0 ? bitDepth : null, - sampleRate: sampleRate != null && sampleRate > 0 ? sampleRate : null, - ); - } - } catch (e) { - _log.w('Audio property probe failed for $filePath: $e'); - } - return const _PrimaryAudioProperties(); - } - - static bool isLosslessAudioCodec(String? codec) { - final normalized = codec?.trim().toLowerCase().replaceAll('-', '_') ?? ''; - if (normalized.isEmpty) return false; - if (normalized.startsWith('pcm_')) return true; - return const { - 'alac', - 'flac', - 'wavpack', - 'ape', - 'tta', - 'mlp', - 'truehd', - 'shorten', - }.contains(normalized); - } - - /// Returns `true` when [filePath] starts with the native FLAC magic bytes - /// (`fLaC`). Useful to distinguish a real FLAC file from a FLAC-in-MP4 - /// container that carries a `.flac` extension or claims codec=flac. - static Future isNativeFlacFile(String filePath) async { - try { - final raf = await File(filePath).open(); - try { - final header = await raf.read(4); - return header.length == 4 && - header[0] == 0x66 && // 'f' - header[1] == 0x4C && // 'L' - header[2] == 0x61 && // 'a' - header[3] == 0x43; // 'C' - } finally { - await raf.close(); - } - } catch (e) { - _log.w('Native FLAC magic probe failed for $filePath: $e'); - return false; - } - } - - static Future<_ResolvedLosslessConversionQuality> _resolveLosslessQuality({ - required String inputPath, - required LosslessConversionQuality quality, - int? sourceBitDepth, - }) async { - final needsBitDepthProbe = - sourceBitDepth == null && quality.maxBitDepth != null; - final needsSampleRateProbe = quality.maxSampleRate != null; - final probe = needsBitDepthProbe || needsSampleRateProbe - ? await _probePrimaryAudioProperties(inputPath) - : const _PrimaryAudioProperties(); - final probedBitDepth = sourceBitDepth ?? probe.bitDepth; - final probedSampleRate = needsSampleRateProbe ? probe.sampleRate : null; - - int? targetBitDepth; - if (quality.maxBitDepth != null && - (probedBitDepth == null || probedBitDepth > quality.maxBitDepth!)) { - targetBitDepth = quality.maxBitDepth; - } - - int? targetSampleRate; - if (quality.maxSampleRate != null && - (probedSampleRate == null || - probedSampleRate > quality.maxSampleRate!)) { - targetSampleRate = quality.maxSampleRate; - } - - return _ResolvedLosslessConversionQuality( - targetBitDepth: targetBitDepth, - targetSampleRate: targetSampleRate, - ); - } - - static void _appendLosslessCodecQualityArguments( - List arguments, { - required String codec, - int? targetBitDepth, - int? targetSampleRate, - LosslessConversionProcessing processing = - const LosslessConversionProcessing(), - }) { - final sampleFmt = _losslessOutputSampleFormat( - codec: codec, - targetBitDepth: targetBitDepth, - ); - _appendLosslessAresampleFilter( - arguments, - targetSampleRate: targetSampleRate, - outputSampleFormat: sampleFmt, - processing: processing, - ); - if (targetBitDepth == null || targetBitDepth <= 0) return; - - if (codec == 'flac') { - if (targetBitDepth <= 16) { - arguments - ..add('-sample_fmt') - ..add('s16'); - } else if (targetBitDepth <= 24) { - arguments - ..add('-sample_fmt') - ..add('s32') - ..add('-bits_per_raw_sample') - ..add('24'); - } - return; - } - - if (codec == 'alac') { - if (targetBitDepth <= 16) { - arguments - ..add('-sample_fmt') - ..add('s16p'); - } else if (targetBitDepth <= 24) { - arguments - ..add('-sample_fmt') - ..add('s32p') - ..add('-bits_per_raw_sample') - ..add('24'); - } - } - } - - static String? _losslessOutputSampleFormat({ - required String codec, - int? targetBitDepth, - }) { - if (targetBitDepth == null || targetBitDepth <= 0) return null; - - if (codec == 'flac') { - return targetBitDepth <= 16 ? 's16' : 's32'; - } - if (codec == 'alac') { - return targetBitDepth <= 16 ? 's16p' : 's32p'; - } - if (codec == 'pcm') { - return targetBitDepth <= 16 ? 's16' : 's32'; - } - return null; - } - - static void _appendLosslessAresampleFilter( - List arguments, { - int? targetSampleRate, - String? outputSampleFormat, - LosslessConversionProcessing processing = - const LosslessConversionProcessing(), - }) { - final hasSampleRate = targetSampleRate != null && targetSampleRate > 0; - final hasSampleFormat = - outputSampleFormat != null && outputSampleFormat.trim().isNotEmpty; - if (!hasSampleRate && !hasSampleFormat && !processing.hasDither) return; - - final options = [ - ...losslessResamplerFilterOptions(processing), - if (hasSampleRate) 'osr=$targetSampleRate', - if (hasSampleFormat) 'osf=${outputSampleFormat.trim()}', - if (processing.hasDither) 'dither_method=${processing.normalizedDither}', - ]; - - arguments - ..add('-af') - ..add('aresample=${options.join(':')}'); - } + /// Checks the native FLAC magic, distinguishing FLAC-in-MP4 payloads. + static Future isNativeFlacFile(String filePath) => + FFmpegProbe.isNativeFlacFile(filePath); static Future convertM4aToFlac( String inputPath, { String? sourceCodec, @visibleForTesting Future Function(List)? execute, - }) async { - final plan = await _conversionOutputPlan( - inputPath, - '.flac', - deleteOriginal: true, - ); - try { - final run = execute ?? _executeWithArguments; - final codec = sourceCodec ?? await probePrimaryAudioCodec(inputPath); - if (codec?.trim().toLowerCase() == 'flac') { - final remux = await run([ - '-v', - 'error', - '-xerror', - '-i', - inputPath, - '-c:a', - 'copy', - '-f', - 'flac', - plan.workingPath, - '-y', - ]); - if (remux.success && await _hasNativeFlacHeader(plan.workingPath)) { - final validation = await run([ - '-v', - 'error', - '-xerror', - '-err_detect', - 'crccheck+explode', - '-i', - plan.workingPath, - '-map', - '0:a:0', - '-f', - 'null', - '-', - ]); - if (validation.success) { - return await _finalizeConversionOutput( - plan: plan, - inputPath: inputPath, - deleteOriginal: true, - ); - } - } - _log.w('FLAC remux validation failed; retrying with the encoder'); - } - final result = await run([ - '-v', - 'error', - '-xerror', - '-i', - inputPath, - '-c:a', - 'flac', - '-compression_level', - '8', - plan.workingPath, - '-y', - ]); - if (result.success) { - return await _finalizeConversionOutput( - plan: plan, - inputPath: inputPath, - deleteOriginal: true, - ); - } - _log.e('M4A to FLAC conversion failed: ${result.output}'); - } catch (e) { - _log.e('M4A to FLAC conversion failed: $e'); - } - await _cleanupConversionOutput(plan); - return null; - } + }) => FFmpegConversion.convertM4aToFlac( + inputPath, + sourceCodec: sourceCodec, + execute: execute, + ); - static Future _hasNativeFlacHeader(String path) async { - final file = File(path); - if (!await file.exists() || await file.length() <= 42) return false; - final input = await file.open(); - try { - final magic = await input.read(4); - return magic.length == 4 && - magic[0] == 0x66 && - magic[1] == 0x4c && - magic[2] == 0x61 && - magic[3] == 0x43; - } finally { - await input.close(); - } - } - - /// Corrects a native FLAC payload's suffix without replacing a sibling file. - static Future ensureNativeFlacExtension(String inputPath) async { - if (inputPath.toLowerCase().endsWith('.flac')) return inputPath; - final plan = await _conversionOutputPlan( - inputPath, - '.flac', - deleteOriginal: true, - ); - try { - await File(inputPath).rename(plan.finalPath); - return plan.finalPath; - } catch (_) { - await _cleanupConversionOutput(plan); - rethrow; - } - } + /// Corrects the suffix without replacing an existing sibling file. + static Future ensureNativeFlacExtension(String inputPath) => + FFmpegConversion.ensureNativeFlacExtension(inputPath); static Future decryptWithDescriptor({ required String inputPath, required DownloadDecryptionDescriptor descriptor, bool deleteOriginal = true, - }) async { - final key = descriptor.key.trim(); + }) => FFmpegDecryption.decrypt( + inputPath: inputPath, + descriptor: descriptor, + deleteOriginal: deleteOriginal, + ); - switch (descriptor.normalizedStrategy) { - case _genericMovKeyDecryptionStrategy: - if (key.isEmpty) { - return inputPath; - } - return _decryptMovKeyFile( - inputPath: inputPath, - decryptionKey: key, - inputFormat: descriptor.inputFormat, - outputExtension: descriptor.outputExtension, - deleteOriginal: deleteOriginal, - ); - default: - _log.e( - 'Unsupported download decryption strategy: ${descriptor.strategy}', - ); - return null; - } - } - - static String _resolvePreferredDecryptionExtension( - String inputPath, - String? requestedExtension, - ) { - final trimmedRequested = (requestedExtension ?? '').trim(); - if (trimmedRequested.isNotEmpty) { - return trimmedRequested.startsWith('.') - ? trimmedRequested - : '.$trimmedRequested'; - } - - return inputPath.toLowerCase().endsWith('.m4a') - ? '.flac' - : inputPath.toLowerCase().endsWith('.flac') - ? '.flac' - : inputPath.toLowerCase().endsWith('.mp3') - ? '.mp3' - : inputPath.toLowerCase().endsWith('.opus') - ? '.opus' - : inputPath.toLowerCase().endsWith('.mp4') - ? '.mp4' - : '.flac'; - } - - static Future _decryptMovKeyFile({ - required String inputPath, - required String decryptionKey, - String? inputFormat, - String? outputExtension, - bool deleteOriginal = true, - }) async { - final preferredExt = _resolvePreferredDecryptionExtension( - inputPath, - outputExtension, - ); - var tempOutput = _buildOutputPath(inputPath, preferredExt); - final demuxerFormat = (inputFormat ?? '').trim().isNotEmpty - ? inputFormat!.trim() - : 'mov'; - - String buildDecryptCommand( - String outputPath, { - required bool mapAudioOnly, - required String key, - bool forceMovMuxer = false, - }) { - final audioMap = mapAudioOnly ? '-map 0:a ' : ''; - // Force MOV demuxer: -decryption_key is only supported by the MOV/MP4 - // demuxer. The input may carry a .flac extension (SAF mode) while actually - // containing an encrypted M4A stream, so we must override auto-detection. - // - // When the requested output is a native .flac we also force the flac - // muxer (-f flac). Without it, FFmpeg infers the muxer from the output - // extension AND keeps the input container's stream layout, which for - // FLAC-in-MP4 sources would still emit an ISO-BMFF payload under a - // .flac filename. That file fails native FLAC tag writers later on. - // - // forceMovMuxer routes through the MOV muxer for codecs the MP4 muxer - // rejects (e.g. AC-4), keeping the .mp4 filename. - final muxerOverride = forceMovMuxer - ? '-f mov ' - : outputPath.toLowerCase().endsWith('.flac') - ? '-f flac ' - : ''; - return '-v error -decryption_key "$key" -f $demuxerFormat -i "$inputPath" $audioMap-c copy $muxerOverride"$outputPath" -y'; - } - - final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey); - 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; - } - } - - // Second fallback: use .mp4 (mp4 muxer) for codecs not supported by - // the ipod muxer (e.g. eac3/Dolby Digital Plus, mha1/Dolby Atmos). - if (!result.success && - (preferredExt == '.flac' || preferredExt == '.m4a')) { - final mp4FallbackOutput = _buildOutputPath(inputPath, '.mp4'); - final mp4FallbackResult = await _execute( - buildDecryptCommand( - mp4FallbackOutput, - mapAudioOnly: false, - key: keyCandidate, - ), - ); - if (mp4FallbackResult.success) { - tempOutput = mp4FallbackOutput; - result = mp4FallbackResult; - } - } - - // Final fallback: force the MOV muxer for codecs the MP4 muxer rejects - // (e.g. AC-4). MOV stores the codec params and keeps the .mp4 filename. - if (!result.success) { - final movFallbackOutput = _buildOutputPath(inputPath, '.mp4'); - final movFallbackResult = await _execute( - buildDecryptCommand( - movFallbackOutput, - mapAudioOnly: false, - key: keyCandidate, - forceMovMuxer: true, - ), - ); - if (movFallbackResult.success) { - tempOutput = movFallbackOutput; - result = movFallbackResult; - } - } - - 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; - } - } - - /// Scan an audio file for EBU R128 loudness and compute ReplayGain values. - /// - /// Uses the FFmpeg `ebur128` audio filter to measure integrated loudness (LUFS) - /// and true peak. ReplayGain reference level is -18 LUFS (≈ 89 dB SPL). - /// + /// Measures EBU R128 loudness and true peak using a -18 LUFS reference. static Future scanReplayGain( String filePath, { void Function()? onUnsupportedDecoder, - }) async { - // -nostats suppresses the interactive progress line. - // ebur128=peak=true prints integrated loudness + true peak. - // framelog=quiet suppresses per-frame measurements (very verbose), - // keeping only the final summary which we parse. - _log.d( - 'Scanning ReplayGain for: ${filePath.split(Platform.pathSeparator).last}', - ); - final result = await _executeWithArguments([ - '-hide_banner', - '-nostats', - '-loglevel', - 'info', - '-i', - filePath, - '-map', - '0:a:0', - '-vn', - '-sn', - '-dn', - '-af', - 'ebur128=peak=true:framelog=quiet', - '-f', - 'null', - '-', - ]); - return parseReplayGainScan( - result, - onUnsupportedDecoder: onUnsupportedDecoder, - ); - } + }) => FFmpegReplayGain.scan( + filePath, + onUnsupportedDecoder: onUnsupportedDecoder, + ); @visibleForTesting static ReplayGainResult? parseReplayGainScan( FFmpegResult result, { void Function()? onUnsupportedDecoder, - }) { - final output = result.output; - if (!result.success) { - final decoderMissing = RegExp( - r'decoder.*not found|no decoder found|unknown decoder', - caseSensitive: false, - ); - final diagnostic = output - .split('\n') - .where( - (line) => - decoderMissing.hasMatch(line) || - RegExp( - r'error|invalid|failed', - caseSensitive: false, - ).hasMatch(line), - ) - .take(3) - .join(' ') - .trim(); - _log.w( - 'ReplayGain scan failed (exit ${result.returnCode}): ${diagnostic.isEmpty ? "no decoder diagnostics" : diagnostic.substring(0, math.min(diagnostic.length, 500))}', - ); - if (decoderMissing.hasMatch(output)) onUnsupportedDecoder?.call(); - // A failed decoder/filter can still print a partial/default summary. - // It is not a valid measurement of the complete track. - return null; - } + }) => FFmpegReplayGain.parseScan( + result, + onUnsupportedDecoder: onUnsupportedDecoder, + ); - final integratedMatch = RegExp( - r'I:\s+(-?\d+\.?\d*)\s+LUFS', - ).allMatches(output); - if (integratedMatch.isEmpty) { - _log.w('ReplayGain scan: could not parse integrated loudness'); - return null; - } - // Take the last match (the summary, not per-segment values) - final integratedLufs = double.tryParse(integratedMatch.last.group(1) ?? ''); - if (integratedLufs == null) { - _log.w('ReplayGain scan: invalid integrated loudness value'); - return null; - } - - // Parse true peak: "Peak: 0.9 dBFS" or "True peak:\n Peak: -0.3 dBFS" - // The ebur128 filter with peak=true outputs per-channel true peak. - // We want the highest (maximum) true peak across all channels. - double? truePeakDbfs; - final peakMatches = RegExp( - r'Peak:\s+(-?\d+\.?\d*)\s+dBFS', - ).allMatches(output); - for (final m in peakMatches) { - final val = double.tryParse(m.group(1) ?? ''); - if (val != null) { - if (truePeakDbfs == null || val > truePeakDbfs) { - truePeakDbfs = val; - } - } - } - - const replayGainReferenceLufs = -18.0; - final gainDb = replayGainReferenceLufs - integratedLufs; - - // Convert true peak from dBFS to linear ratio. - // If no true peak was found, fall back to 1.0 (0 dBFS). - double peakLinear; - if (truePeakDbfs != null) { - peakLinear = math.pow(10, truePeakDbfs / 20.0).toDouble(); - } else { - peakLinear = 1.0; - } - - final trackGain = - '${gainDb >= 0 ? "+" : ""}${gainDb.toStringAsFixed(2)} dB'; - final trackPeak = peakLinear.toStringAsFixed(6); - - _log.i( - 'ReplayGain scan result: gain=$trackGain, peak=$trackPeak (integrated=${integratedLufs.toStringAsFixed(1)} LUFS)', - ); - - return ReplayGainResult( - trackGain: trackGain, - trackPeak: trackPeak, - integratedLufs: integratedLufs, - truePeakLinear: peakLinear, - ); - } - - /// Write album ReplayGain tags to a file via FFmpeg. - /// - /// For local files, replaces the file in-place and returns `true`. - /// When [returnTempPath] is `true` (for SAF content:// URIs), the method - /// skips the file replacement and returns the temp output path as a String - /// via [tempOutputPath]. The caller is responsible for writing the temp - /// file to the SAF URI and cleaning it up. + /// With [returnTempPath], hands temp output to [onTempReady] for SAF write and + /// cleanup by the caller. Otherwise publishes to the local file. static Future writeAlbumReplayGainTags( String filePath, String albumGain, String albumPeak, { bool returnTempPath = false, void Function(String tempPath)? onTempReady, - }) => _writeReplayGainTags( + }) => FFmpegReplayGain.writeAlbumTags( filePath, - 'Album', albumGain, albumPeak, returnTempPath: returnTempPath, onTempReady: onTempReady, ); - /// Write track ReplayGain tags to a file via FFmpeg, replacing it in place. - /// - /// Used as a fallback for formats other than Ogg/Opus. Copies streams and - /// metadata with `-map 0 -c copy -map_metadata 0`, setting track gain and peak. - /// The caller verifies the tags after a successful rewrite. static Future writeTrackReplayGainTags( String filePath, String trackGain, String trackPeak, - ) => _writeReplayGainTags(filePath, 'Track', trackGain, trackPeak); - - /// Shared implementation for album/track ReplayGain tagging. - /// [scope] is 'Album' or 'Track'; it selects the REPLAYGAIN_* tags. - static Future _writeReplayGainTags( - String filePath, - String scope, - String gain, - String peak, { - bool returnTempPath = false, - void Function(String tempPath)? onTempReady, - }) async { - final ext = filePath.contains('.') - ? '.${filePath.split('.').last}' - : '.tmp'; - if (ext.toLowerCase() == '.opus' || ext.toLowerCase() == '.ogg') { - _log.e('Ogg/Opus ReplayGain requires the native tag writer'); - return false; - } - final tempDir = await getTemporaryDirectory(); - final tempOutput = _nextTempEmbedPath(tempDir.path, ext); - final tag = scope.toUpperCase(); - final arguments = [ - '-v', - 'error', - '-hide_banner', - '-i', - filePath, - '-map', - '0', - '-c', - 'copy', - '-map_metadata', - '0', - '-metadata', - 'REPLAYGAIN_${tag}_GAIN=$gain', - '-metadata', - 'REPLAYGAIN_${tag}_PEAK=$peak', - ]; - - arguments - ..add(tempOutput) - ..add('-y'); - - _log.d('Writing ${scope.toLowerCase()} ReplayGain tags via FFmpeg'); - final result = await _executeWithArguments(arguments); - - if (!result.success) { - _log.e( - '$scope ReplayGain write failed (code ${result.returnCode}): ${result.output}', - ); - } - if (result.success) { - if (returnTempPath) { - try { - if (await File(tempOutput).exists()) { - onTempReady?.call(tempOutput); - return true; - } - } catch (e) { - _log.w( - 'Failed to replace file with ${scope.toLowerCase()} ReplayGain: $e', - ); - } - } else if (await _promoteTempOutput( - tempOutput, - filePath, - onError: (e) => _log.w( - 'Failed to replace file with ${scope.toLowerCase()} ReplayGain: $e', - ), - )) { - _log.d('$scope ReplayGain tags written successfully'); - return true; - } - } - - try { - final tempFile = File(tempOutput); - if (await tempFile.exists()) await tempFile.delete(); - } catch (_) {} - - return false; - } - - /// Replace [targetPath] with the successful FFmpeg temp output at - /// [tempOutput]. Returns `true` when the target was replaced. The temp - /// file is always cleaned up, including when the replacement fails. - static Future _promoteTempOutput( - String tempOutput, - String targetPath, { - void Function()? onMissing, - required void Function(Object e) onError, - }) async { - try { - final tempFile = File(tempOutput); - if (await tempFile.exists()) { - final originalFile = File(targetPath); - if (await originalFile.exists()) { - await originalFile.delete(); - } - await tempFile.copy(targetPath); - await tempFile.delete(); - return true; - } - onMissing?.call(); - return false; - } catch (e) { - onError(e); - return false; - } finally { - try { - final tempFile = File(tempOutput); - if (await tempFile.exists()) await tempFile.delete(); - } catch (_) {} - } - } - - /// Map input #1 (the cover image) as attached picture art. - static void _appendCoverInputArgs( - List arguments, { - String map = '1:v', - String disposition = '-disposition:v:0', - }) { - arguments - ..add('-map') - ..add(map) - ..add('-c:v') - ..add('copy') - ..add(disposition) - ..add('attached_pic') - ..add('-metadata:s:v') - ..add('title=Album cover') - ..add('-metadata:s:v') - ..add('comment=Cover (front)'); - } + ) => FFmpegReplayGain.writeTrackTags(filePath, trackGain, trackPeak); static Future embedMetadata({ required String flacPath, String? coverPath, Map? metadata, String artistTagMode = artistTagModeJoined, - }) async { - final tempDir = await getTemporaryDirectory(); - final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac'); - final arguments = ['-v', 'error', '-hide_banner', '-i', flacPath]; - - if (coverPath != null) { - arguments - ..add('-i') - ..add(coverPath); - } - - arguments - ..add('-map') - ..add('0:a'); - - if (coverPath != null) { - _appendCoverInputArgs( - arguments, - map: '1:0', - disposition: '-disposition:v', - ); - } - - arguments - ..add('-c:a') - ..add('copy'); - - if (metadata != null) { - AudioMetadataMapper.appendVorbisMetadataArguments( - arguments, - metadata, - artistTagMode: artistTagMode, - ); - } - - arguments - ..add(tempOutput) - ..add('-y'); - - _log.d('Executing FFmpeg FLAC embed command'); - final result = await _executeWithArguments(arguments); - - if (result.success) { - final promoted = await _promoteTempOutput( - tempOutput, - flacPath, - onMissing: () => _log.e('Temp output file not found: $tempOutput'), - onError: (e) => - _log.e('Failed to replace file after metadata embed: $e'), - ); - return promoted ? flacPath : null; - } - - try { - final tempFile = File(tempOutput); - if (await tempFile.exists()) { - await tempFile.delete(); - } - } catch (e) { - _log.w('Failed to cleanup temp file: $e'); - } - - _log.e('Metadata/Cover embed failed: ${result.output}'); - return null; - } + }) => FFmpegMetadata.embedMetadata( + flacPath: flacPath, + coverPath: coverPath, + metadata: metadata, + artistTagMode: artistTagMode, + ); static Future embedMetadataToMp3({ required String mp3Path, @@ -1345,186 +136,13 @@ class FFmpegService { Map? metadata, String artistTagMode = artistTagModeJoined, bool preserveMetadata = false, - }) async { - // ID3 keeps one artist frame: only the primary mode changes its value. - if (metadata != null) { - metadata = applyArtistTagModeToMetadata(metadata, artistTagMode); - } - final tempDir = await getTemporaryDirectory(); - final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3'); - final lyrics = AudioMetadataMapper.extractLyricsForId3(metadata); - - // Try with -c:a copy first (fastest, preserves original codec) - var result = await _runMp3Embed( - mp3Path: mp3Path, - tempOutput: tempOutput, - coverPath: coverPath, - metadata: metadata, - preserveMetadata: preserveMetadata, - audioCodec: 'copy', - ); - - if (result.success) { - final embeddedPath = await _finalizeMp3Embed(mp3Path, tempOutput); - if (embeddedPath != null && lyrics != null) { - await _ensureMp3UnsyncedLyricsFrame(embeddedPath, lyrics); - } - return embeddedPath; - } - - // If copy failed (e.g. AAC/Opus in .mp3 container), re-encode to real MP3 - final output = result.output; - if (output.contains('Invalid audio stream') || - output.contains('incorrect codec parameters')) { - _log.w('MP3 copy failed (codec mismatch), re-encoding with libmp3lame'); - - try { - final tempFile = File(tempOutput); - if (await tempFile.exists()) await tempFile.delete(); - } catch (_) {} - - final reencodeOutput = _nextTempEmbedPath(tempDir.path, '.mp3'); - result = await _runMp3Embed( - mp3Path: mp3Path, - tempOutput: reencodeOutput, - coverPath: coverPath, - metadata: metadata, - preserveMetadata: preserveMetadata, - audioCodec: 'libmp3lame', - audioBitrate: '192k', // AAC 128kbps ≈ MP3 192kbps equivalent - ); - - if (result.success) { - final embeddedPath = await _finalizeMp3Embed(mp3Path, reencodeOutput); - if (embeddedPath != null && lyrics != null) { - await _ensureMp3UnsyncedLyricsFrame(embeddedPath, lyrics); - } - return embeddedPath; - } - - try { - final tempFile = File(reencodeOutput); - if (await tempFile.exists()) await tempFile.delete(); - } catch (_) {} - - _log.e('MP3 re-encode also failed: ${result.output}'); - return null; - } - - try { - final tempFile = File(tempOutput); - if (await tempFile.exists()) await tempFile.delete(); - } catch (e) { - _log.w('Failed to cleanup temp MP3 file: $e'); - } - - _log.e('MP3 Metadata/Cover embed failed: ${result.output}'); - return null; - } - - static Future _runMp3Embed({ - required String mp3Path, - required String tempOutput, - String? coverPath, - Map? metadata, - bool preserveMetadata = false, - required String audioCodec, - String? audioBitrate, - }) async { - final arguments = ['-v', 'error', '-hide_banner', '-i', mp3Path]; - - if (coverPath != null) { - arguments - ..add('-i') - ..add(coverPath); - } - - arguments - ..add('-map') - ..add('0:a') - ..add('-map_metadata') - ..add(preserveMetadata ? '0' : '-1'); - - if (coverPath != null) { - arguments - ..add('-map') - ..add('1:0') - ..add('-c:v:0') - ..add('copy') - ..add('-id3v2_version') - ..add('3') - ..add('-metadata:s:v') - ..add('title=Album cover') - ..add('-metadata:s:v') - ..add('comment=Cover (front)'); - } - - arguments - ..add('-c:a') - ..add(audioCodec); - - if (audioBitrate != null) { - arguments - ..add('-b:a') - ..add(audioBitrate); - } - - if (metadata != null) { - AudioMetadataMapper.appendMappedMetadataArguments( - arguments, - AudioMetadataMapper.convertToId3Tags(metadata), - ); - } - - arguments - ..add('-id3v2_version') - ..add('3') - ..add(tempOutput) - ..add('-y'); - - _log.d('Executing FFmpeg MP3 embed command (codec: $audioCodec)'); - return await _executeWithArguments(arguments); - } - - /// Finalize MP3 embed by replacing the original file with the temp output. - static Future _finalizeMp3Embed( - String mp3Path, - String tempOutput, - ) async { - final promoted = await _promoteTempOutput( - tempOutput, - mp3Path, - onMissing: () => _log.e('Temp MP3 output file not found: $tempOutput'), - onError: (e) => - _log.e('Failed to replace MP3 file after metadata embed: $e'), - ); - if (!promoted) return null; - _log.d('MP3 metadata embedded successfully'); - return mp3Path; - } - - static Future _ensureMp3UnsyncedLyricsFrame( - String mp3Path, - String lyrics, - ) async { - try { - final file = File(mp3Path); - if (!await file.exists()) return; - - final written = await Id3v23Lyrics.writeUnsyncedLyricsToFile( - file, - lyrics, - ); - if (!written) { - _log.w('Skipping MP3 USLT lyrics frame update: unsupported ID3 tag'); - return; - } - - _log.d('MP3 USLT lyrics frame written (${lyrics.length} chars)'); - } catch (e) { - _log.w('Failed to write MP3 USLT lyrics frame: $e'); - } - } + }) => FFmpegMetadata.embedMetadataToMp3( + mp3Path: mp3Path, + coverPath: coverPath, + metadata: metadata, + artistTagMode: artistTagMode, + preserveMetadata: preserveMetadata, + ); static Future embedMetadataToOpus({ required String opusPath, @@ -1533,87 +151,14 @@ class FFmpegService { String artistTagMode = artistTagModeJoined, bool preserveMetadata = false, @visibleForTesting Future Function(List)? execute, - }) async { - final tempDir = await getTemporaryDirectory(); - final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus'); - // Opus tags belong to the audio stream, not the input's global metadata. - // Copy them to output globals so selected -metadata values override them, - // then let the Opus muxer write those globals into the output stream. - final mapMetaValue = preserveMetadata ? '0:s:a:0' : '-1'; - final arguments = [ - '-v', - 'error', - '-hide_banner', - '-i', - opusPath, - '-map', - '0:a', - '-map_metadata', - mapMetaValue, - '-map_metadata:s:a', - '-1', - '-c:a', - 'copy', - ]; - - if (metadata != null) { - AudioMetadataMapper.appendVorbisMetadataArguments( - arguments, - metadata, - artistTagMode: artistTagMode, - ); - } - - if (coverPath != null) { - try { - final pictureBlock = await _createMetadataBlockPicture(coverPath); - if (pictureBlock != null) { - arguments - ..add('-metadata') - ..add('METADATA_BLOCK_PICTURE=$pictureBlock'); - _log.d( - 'Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)', - ); - } else { - _log.w('Failed to create METADATA_BLOCK_PICTURE, skipping cover'); - } - } catch (e) { - _log.e('Error creating METADATA_BLOCK_PICTURE: $e'); - } - } - - arguments - ..add(tempOutput) - ..add('-y'); - _log.d('Executing FFmpeg Opus embed command'); - - final result = await (execute ?? _executeWithArguments)(arguments); - - if (result.success) { - final promoted = await _promoteTempOutput( - tempOutput, - opusPath, - onMissing: () => _log.e('Temp Opus output file not found: $tempOutput'), - onError: (e) => - _log.e('Failed to replace Opus file after metadata embed: $e'), - ); - if (!promoted) return null; - _log.d('Opus metadata embedded successfully'); - return opusPath; - } - - try { - final tempFile = File(tempOutput); - if (await tempFile.exists()) { - await tempFile.delete(); - } - } catch (e) { - _log.w('Failed to cleanup temp Opus file: $e'); - } - - _log.e('Opus Metadata embed failed: ${result.output}'); - return null; - } + }) => FFmpegMetadata.embedMetadataToOpus( + opusPath: opusPath, + coverPath: coverPath, + metadata: metadata, + artistTagMode: artistTagMode, + preserveMetadata: preserveMetadata, + execute: execute, + ); static Future embedMetadataToM4a({ required String m4aPath, @@ -1621,233 +166,15 @@ class FFmpegService { Map? metadata, String artistTagMode = artistTagModeJoined, bool preserveMetadata = true, - }) async { - // MP4 keeps one artist atom: only the primary mode changes its value. - if (metadata != null) { - metadata = applyArtistTagModeToMetadata(metadata, artistTagMode); - } - final tempDir = await getTemporaryDirectory(); - final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a'); + }) => FFmpegMetadata.embedMetadataToM4a( + m4aPath: m4aPath, + coverPath: coverPath, + metadata: metadata, + artistTagMode: artistTagMode, + preserveMetadata: preserveMetadata, + ); - final normalizedCoverPath = coverPath?.trim(); - final hasCover = - normalizedCoverPath != null && - normalizedCoverPath.isNotEmpty && - await File(normalizedCoverPath).exists(); - final preserveExistingStreams = preserveMetadata && !hasCover; - - List buildArgs(bool forceMov) { - final arguments = ['-v', 'error', '-hide_banner', '-i', m4aPath]; - if (hasCover) { - arguments - ..add('-i') - ..add(normalizedCoverPath); - } - if (preserveExistingStreams) { - // When no replacement cover is provided, preserve all input streams so - // the existing attached artwork is not dropped during the metadata rewrite. - arguments - ..add('-map') - ..add('0') - ..add('-c') - ..add('copy'); - } else { - arguments - ..add('-map') - ..add('0:a') - ..add('-c:a') - ..add('copy'); - } - arguments - ..add('-map_metadata') - ..add(preserveMetadata ? '0' : '-1'); - - if (hasCover) { - // Mark the image as an attached picture so the container writes a proper - // covr atom instead of a generic MJPEG video track. - _appendCoverInputArgs(arguments); - } - - if (metadata != null) { - AudioMetadataMapper.appendMappedMetadataArguments( - arguments, - AudioMetadataMapper.convertToM4aTags(metadata), - ); - } - - // MOV muxer accepts codecs the MP4 muxer rejects (e.g. AC-4). The default - // (no -f) keeps the ipod muxer for plain .m4a; cover writes force mp4. - if (forceMov) { - arguments - ..add('-f') - ..add('mov'); - } else if (hasCover) { - arguments - ..add('-f') - ..add('mp4'); - } - - arguments - ..add(tempOutput) - ..add('-y'); - return arguments; - } - - _log.d('Executing FFmpeg M4A embed command'); - var result = await _executeWithArguments(buildArgs(false)); - if (!result.success) { - _log.w('M4A embed failed with default muxer, retrying with mov muxer'); - try { - final stale = File(tempOutput); - if (await stale.exists()) await stale.delete(); - } catch (_) {} - result = await _executeWithArguments(buildArgs(true)); - } - - if (result.success) { - final promoted = await _promoteTempOutput( - tempOutput, - m4aPath, - onMissing: () => _log.e('Temp M4A output file not found: $tempOutput'), - onError: (e) => - _log.e('Failed to replace M4A file after metadata embed: $e'), - ); - if (!promoted) return null; - - // FFmpeg's MP4 muxer ignores ISRC and label, so write them natively - // as iTunes freeform atoms. Only fields the caller supplied are - // touched (an empty value clears the tag). - if (metadata != null) { - await _writeM4AFreeformTags(m4aPath, metadata); - await _writeM4AReleaseIdentityTags(m4aPath, metadata); - } - - _log.d('M4A metadata embedded successfully'); - return m4aPath; - } - - try { - final tempFile = File(tempOutput); - if (await tempFile.exists()) { - await tempFile.delete(); - } - } catch (e) { - _log.w('Failed to cleanup temp M4A file: $e'); - } - - _log.e('M4A Metadata embed failed: ${result.output}'); - return null; - } - - static Future _createMetadataBlockPicture(String imagePath) async { - try { - final file = File(imagePath); - if (!await file.exists()) { - _log.e('Cover image not found: $imagePath'); - return null; - } - - final imageData = await file.readAsBytes(); - - String mimeType; - if (imagePath.toLowerCase().endsWith('.png')) { - mimeType = 'image/png'; - } else if (imagePath.toLowerCase().endsWith('.jpg') || - imagePath.toLowerCase().endsWith('.jpeg')) { - mimeType = 'image/jpeg'; - } else { - if (imageData.length >= 8 && - imageData[0] == 0x89 && - imageData[1] == 0x50 && - imageData[2] == 0x4E && - imageData[3] == 0x47) { - mimeType = 'image/png'; - } else if (imageData.length >= 2 && - imageData[0] == 0xFF && - imageData[1] == 0xD8) { - mimeType = 'image/jpeg'; - } else { - mimeType = 'image/jpeg'; - } - } - - final mimeBytes = utf8.encode(mimeType); - const description = ''; - final descBytes = utf8.encode(description); - - final blockSize = - 4 + - 4 + - mimeBytes.length + - 4 + - descBytes.length + - 4 + - 4 + - 4 + - 4 + - 4 + - imageData.length; - - final buffer = ByteData(blockSize); - // The header and payload share one allocation; artwork can be several - // megabytes, so copying this whole buffer doubles the block's memory. - final blockBytes = buffer.buffer.asUint8List(); - var offset = 0; - - buffer.setUint32(offset, 3, Endian.big); - offset += 4; - - buffer.setUint32(offset, mimeBytes.length, Endian.big); - offset += 4; - - blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes); - offset += mimeBytes.length; - - final tempBuffer = ByteData(4); - tempBuffer.setUint32(0, descBytes.length, Endian.big); - blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); - offset += 4; - - blockBytes.setRange(offset, offset + descBytes.length, descBytes); - offset += descBytes.length; - - tempBuffer.setUint32(0, 0, Endian.big); - blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); - offset += 4; - - tempBuffer.setUint32(0, 0, Endian.big); - blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); - offset += 4; - - tempBuffer.setUint32(0, 0, Endian.big); - blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); - offset += 4; - - tempBuffer.setUint32(0, 0, Endian.big); - blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); - offset += 4; - - tempBuffer.setUint32(0, imageData.length, Endian.big); - blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); - offset += 4; - - blockBytes.setRange(offset, offset + imageData.length, imageData); - - final base64String = base64Encode(blockBytes); - - return base64String; - } catch (e) { - _log.e('Error creating METADATA_BLOCK_PICTURE: $e'); - return null; - } - } - - /// Unified audio format conversion with full metadata + cover preservation. - /// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC/WAV/AIFF. - /// ALAC, FLAC, WAV and AIFF targets are lossless codecs (bitrate parameter - /// is ignored). [losslessQuality] can cap bit depth/sample rate, and caps are - /// only applied when they reduce the source quality. - /// [sourceBitDepth] (when known) avoids an extra probe. + /// Converts with metadata and cover preservation; lossless caps never upscale. static Future convertAudioFormat({ required String inputPath, required String targetFormat, @@ -1861,438 +188,19 @@ class FFmpegService { const LosslessConversionQuality(), LosslessConversionProcessing losslessProcessing = const LosslessConversionProcessing(), - }) async { - final format = targetFormat.toLowerCase(); - if (!const { - 'mp3', - 'opus', - 'aac', - 'alac', - 'flac', - 'wav', - 'aiff', - 'aif', - }.contains(format)) { - _log.e('Unsupported target format: $targetFormat'); - return null; - } - // Conversion carries existing tags over. Primary mode applies to provider - // credits (download, re-enrichment) and must not drop names here; the - // download auto-conversion embeds its credits after this step. - artistTagMode = artistTagModeForExistingTags(artistTagMode); + }) => FFmpegConversion.convertAudioFormat( + inputPath: inputPath, + targetFormat: targetFormat, + bitrate: bitrate, + metadata: metadata, + coverPath: coverPath, + artistTagMode: artistTagMode, + deleteOriginal: deleteOriginal, + sourceBitDepth: sourceBitDepth, + losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, + ); - final resolvedLosslessQuality = isLosslessConversionTarget(format) - ? await _resolveLosslessQuality( - inputPath: inputPath, - quality: losslessQuality, - sourceBitDepth: sourceBitDepth, - ) - : const _ResolvedLosslessConversionQuality(); - - if (format == 'alac' || format == 'flac') { - return _convertToLossless( - inputPath: inputPath, - metadata: metadata, - codec: format, - coverPath: coverPath, - artistTagMode: artistTagMode, - targetBitDepth: resolvedLosslessQuality.targetBitDepth, - targetSampleRate: resolvedLosslessQuality.targetSampleRate, - processing: losslessProcessing, - deleteOriginal: deleteOriginal, - ); - } - if (format == 'wav' || format == 'aiff' || format == 'aif') { - return _convertToPcm( - inputPath: inputPath, - metadata: metadata, - coverPath: coverPath, - container: format == 'wav' ? 'wav' : 'aiff', - sourceBitDepth: sourceBitDepth, - targetBitDepth: resolvedLosslessQuality.targetBitDepth, - targetSampleRate: resolvedLosslessQuality.targetSampleRate, - processing: losslessProcessing, - deleteOriginal: deleteOriginal, - ); - } - - final extension = switch (format) { - 'opus' => '.opus', - 'aac' => '.m4a', - _ => '.mp3', - }; - final outputPlan = await _conversionOutputPlan( - inputPath, - extension, - deleteOriginal: deleteOriginal, - ); - final outputPath = outputPlan.workingPath; - - String command; - if (format == 'opus') { - command = - '-v error -hide_banner -i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a "$outputPath" -y'; - } else if (format == 'aac') { - command = - '-v error -hide_banner -i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -f mp4 "$outputPath" -y'; - } else { - command = - '-v error -hide_banner -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}'); - await _cleanupConversionOutput(outputPlan); - return null; - } - - // 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, - artistTagMode: artistTagMode, - ); - } else if (format == 'aac') { - embedResult = await embedMetadataToM4a( - m4aPath: outputPath, - coverPath: coverPath, - metadata: metadata, - artistTagMode: artistTagMode, - preserveMetadata: true, - ); - } else { - embedResult = await embedMetadataToOpus( - opusPath: outputPath, - coverPath: coverPath, - metadata: metadata, - artistTagMode: artistTagMode, - ); - } - - if (embedResult == null) { - _log.e( - 'Metadata/Cover preservation failed, rolling back converted file', - ); - await _cleanupConversionOutput(outputPlan); - return null; - } - } - - return _finalizeConversionOutput( - plan: outputPlan, - inputPath: inputPath, - deleteOriginal: deleteOriginal, - ); - } - - /// Convert to ALAC (.m4a) or FLAC per [codec]. - /// Metadata and cover art are embedded in a single FFmpeg pass. - static Future _convertToLossless({ - required String inputPath, - required Map metadata, - required String codec, // 'alac' or 'flac' - String? coverPath, - String artistTagMode = artistTagModeJoined, - int? targetBitDepth, - int? targetSampleRate, - LosslessConversionProcessing processing = - const LosslessConversionProcessing(), - bool deleteOriginal = true, - }) async { - final isAlac = codec == 'alac'; - final outputPlan = await _conversionOutputPlan( - inputPath, - isAlac ? '.m4a' : '.flac', - deleteOriginal: deleteOriginal, - ); - final outputPath = outputPlan.workingPath; - final arguments = ['-v', 'error', '-hide_banner', '-i', inputPath]; - - final hasCover = - coverPath != null && - coverPath.trim().isNotEmpty && - await File(coverPath).exists(); - if (hasCover) { - arguments - ..add('-i') - ..add(coverPath); - } - - arguments - ..add('-map') - ..add('0:a'); - if (hasCover) { - _appendCoverInputArgs(arguments); - } - arguments - ..add('-c:a') - ..add(codec); - if (!isAlac) { - arguments - ..add('-compression_level') - ..add('8'); - } - _appendLosslessCodecQualityArguments( - arguments, - codec: codec, - targetBitDepth: targetBitDepth, - targetSampleRate: targetSampleRate, - processing: processing, - ); - arguments - ..add('-map_metadata') - ..add(isAlac ? '-1' : '0'); - - if (isAlac) { - AudioMetadataMapper.appendMappedMetadataArguments( - arguments, - AudioMetadataMapper.convertToM4aTags(metadata), - ); - } else { - AudioMetadataMapper.appendVorbisMetadataArguments( - arguments, - metadata, - artistTagMode: artistTagMode, - ); - } - - arguments - ..add(outputPath) - ..add('-y'); - - final label = isAlac ? 'ALAC' : 'FLAC'; - _log.i( - 'Converting ${inputPath.split(Platform.pathSeparator).last} to $label' - '${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}' - '${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}' - '${processing.hasDither ? ' dither=${processing.normalizedDither}' : ''}' - '${processing.normalizedResampler != 'swr' ? ' resampler=${processing.normalizedResampler}' : ''}', - ); - final result = await _executeWithArguments(arguments); - - if (!result.success) { - _log.e('$label conversion failed: ${result.output}'); - await _cleanupConversionOutput(outputPlan); - return null; - } - - if (isAlac) { - await _writeM4AFreeformTags(outputPath, metadata); - final identityWritten = await _writeM4AReleaseIdentityTags( - outputPath, - metadata, - ); - if (!identityWritten) { - _log.e('ALAC release identity metadata write failed'); - await _cleanupConversionOutput(outputPlan); - return null; - } - } - - return _finalizeConversionOutput( - plan: outputPlan, - inputPath: inputPath, - deleteOriginal: deleteOriginal, - ); - } - - /// Convert to uncompressed PCM (WAV or AIFF), preserving bit depth when known. - /// Tags and cover are written natively into an embedded ID3 chunk by the - /// native backend (RIFF "id3 " for WAV, "ID3 " for AIFF) for full-fidelity - /// tagging. - static Future _convertToPcm({ - required String inputPath, - required Map metadata, - required String container, // 'wav' or 'aiff' - String? coverPath, - int? sourceBitDepth, - int? targetBitDepth, - int? targetSampleRate, - LosslessConversionProcessing processing = - const LosslessConversionProcessing(), - bool deleteOriginal = true, - }) async { - final isAiff = container == 'aiff'; - final outputPlan = await _conversionOutputPlan( - inputPath, - isAiff ? '.aiff' : '.wav', - deleteOriginal: deleteOriginal, - ); - final outputPath = outputPlan.workingPath; - var depth = targetBitDepth ?? sourceBitDepth; - if (depth == null || depth <= 0) { - depth = (await _probePrimaryAudioProperties(inputPath)).bitDepth; - } - final use24 = depth != null && depth >= 24; - final codec = isAiff - ? (use24 ? 'pcm_s24be' : 'pcm_s16be') - : (use24 ? 'pcm_s24le' : 'pcm_s16le'); - - final arguments = [ - '-v', - 'error', - '-hide_banner', - '-i', - inputPath, - '-map', - '0:a', - ]; - _appendLosslessAresampleFilter( - arguments, - targetSampleRate: targetSampleRate, - outputSampleFormat: _losslessOutputSampleFormat( - codec: 'pcm', - targetBitDepth: targetBitDepth, - ), - processing: processing, - ); - arguments.addAll(['-c:a', codec, '-map_metadata', '-1']); - - // Keep a container-native metadata fallback for software that does not - // inspect WAV/AIFF ID3 chunks. FFmpeg writes common WAV fields into - // LIST/INFO and the fields supported by AIFF into its native text chunks. - // The native writer below remains authoritative for the complete field - // set (including track/disc totals, lyrics, ReplayGain, and cover art). - AudioMetadataMapper.appendMappedMetadataArguments( - arguments, - AudioMetadataMapper.convertToId3Tags(metadata), - ); - arguments.addAll([outputPath, '-y']); - - _log.i( - 'Converting ${inputPath.split(Platform.pathSeparator).last} to ' - '${container.toUpperCase()} (${use24 ? 24 : 16}-bit' - '${targetSampleRate != null ? ', ${targetSampleRate}Hz' : ''}' - '${processing.hasDither ? ', dither=${processing.normalizedDither}' : ''}' - '${processing.normalizedResampler != 'swr' ? ', resampler=${processing.normalizedResampler}' : ''})', - ); - final result = await _executeWithArguments(arguments); - if (!result.success) { - _log.e('${container.toUpperCase()} conversion failed: ${result.output}'); - await _cleanupConversionOutput(outputPlan); - return null; - } - - // Write tags + cover via the native ID3-chunk writer. - final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty); - final hasCover = coverPath != null && coverPath.trim().isNotEmpty; - if (hasMetadata || hasCover) { - final ok = await _embedChunkTagsNative(outputPath, metadata, coverPath); - if (!ok) { - // Metadata/cover preservation is part of the requested conversion. - // Publishing an apparently successful but untagged file is data loss. - _log.e('Native tag embed failed for $container output'); - await _cleanupConversionOutput(outputPlan); - return null; - } - } - - return _finalizeConversionOutput( - plan: outputPlan, - inputPath: inputPath, - deleteOriginal: deleteOriginal, - ); - } - - /// Writes tags + cover into a WAV/AIFF file via the native backend ID3-chunk - /// writer (PlatformBridge.editFileMetadata). Maps Vorbis-style metadata keys - /// to the lowercase field names the native editor expects. - static Future _embedChunkTagsNative( - String path, - Map vorbisMetadata, - String? coverPath, - ) async { - final fields = AudioMetadataMapper.vorbisToNativeChunkFields( - vorbisMetadata, - ); - if (coverPath != null && coverPath.trim().isNotEmpty) { - fields['cover_path'] = coverPath; - } - if (fields.isEmpty) return true; - try { - final res = await PlatformBridge.editFileMetadata(path, fields); - final error = res['error']; - if (error != null) { - _log.w('editFileMetadata for $path failed: $error'); - return false; - } - return res['success'] == true; - } catch (e) { - _log.w('editFileMetadata for $path failed: $e'); - return false; - } - } - - /// Writes ISRC and label into an M4A/MP4 file natively (iTunes freeform - /// atoms), since FFmpeg's MP4 muxer drops these keys. Only keys present in - /// [metadata] are written; an empty value clears the corresponding tag. - static Future _writeM4AFreeformTags( - String m4aPath, - Map metadata, - ) async { - final fields = {}; - for (final entry in metadata.entries) { - final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); - switch (key) { - case 'ISRC': - fields['isrc'] = entry.value; - break; - case 'LABEL': - case 'ORGANIZATION': - fields['label'] = entry.value; - break; - } - } - if (fields.isEmpty) return; - try { - await PlatformBridge.writeM4AFreeformTags(m4aPath, fields); - } catch (e) { - _log.w('writeM4AFreeformTags failed for $m4aPath: $e'); - } - } - - /// Restores the iTunes atoms that FFmpeg does not reliably map from generic - /// metadata keys. This runs after a successful remux, when the container is - /// canonical enough for the native editor even if the original was not. - static Future _writeM4AReleaseIdentityTags( - String m4aPath, - Map metadata, - ) async { - final fields = AudioMetadataMapper.m4aReleaseIdentityFields(metadata); - if (fields.isEmpty) return true; - - try { - final result = await PlatformBridge.editFileMetadata(m4aPath, fields); - if (result['error'] != null || result['method'] != 'native_m4a') { - _log.w( - 'Native M4A release identity write was not completed for $m4aPath', - ); - return false; - } - return true; - } catch (e) { - _log.w('M4A release identity write failed for $m4aPath: $e'); - return false; - } - } - - /// Split a CUE+audio file into individual track files using FFmpeg. - /// Each track is extracted with `-c copy` (no re-encoding) and metadata is embedded. - /// [audioPath] is the source audio file (FLAC, WAV, etc.) - /// [outputDir] is where individual track files will be saved - /// [tracks] is the list of track split info from the native CUE parser - /// [albumMetadata] contains album-level metadata (artist, album, genre, date) static Future?> splitCueToTracks({ required String audioPath, required String outputDir, @@ -2300,121 +208,12 @@ class FFmpegService { required Map albumMetadata, String? coverPath, void Function(int current, int total)? onProgress, - }) async { - if (tracks.isEmpty) { - _log.e('No tracks to split'); - return null; - } - - final outputPaths = []; - final inputExt = audioPath.toLowerCase().split('.').last; - final outputExt = - (inputExt == 'flac' || - inputExt == 'wav' || - inputExt == 'ape' || - inputExt == 'wv') - ? 'flac' - : inputExt; - - for (var i = 0; i < tracks.length; i++) { - final track = tracks[i]; - onProgress?.call(i + 1, tracks.length); - - final sanitizedTitle = track.title - .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - final trackNumStr = track.number.toString().padLeft(2, '0'); - final outputFileName = '$trackNumStr - $sanitizedTitle.$outputExt'; - final outputPath = '$outputDir${Platform.pathSeparator}$outputFileName'; - final arguments = [ - '-v', - 'error', - '-hide_banner', - '-i', - audioPath, - ]; - - final startTime = _formatSecondsForFFmpeg(track.startSec); - arguments - ..add('-ss') - ..add(startTime); - - if (track.endSec > 0) { - final endTime = _formatSecondsForFFmpeg(track.endSec); - arguments - ..add('-to') - ..add(endTime); - } - - if (outputExt == 'flac') { - arguments - ..add('-c:a') - ..add('flac') - ..add('-compression_level') - ..add('8'); - } else { - arguments - ..add('-c:a') - ..add('copy'); - } - - final artist = track.artist.isNotEmpty - ? track.artist - : (albumMetadata['artist'] ?? ''); - final album = albumMetadata['album'] ?? ''; - final genre = albumMetadata['genre'] ?? ''; - final date = albumMetadata['date'] ?? ''; - final cueMetadata = {}; - - void addMeta(String key, String value) { - if (value.isNotEmpty) { - cueMetadata[key] = value; - } - } - - addMeta('TITLE', track.title); - addMeta('ARTIST', artist); - addMeta('ALBUM', album); - addMeta('ALBUMARTIST', albumMetadata['artist'] ?? ''); - addMeta('TRACKNUMBER', track.number.toString()); - addMeta('GENRE', genre); - addMeta('DATE', date); - if (track.isrc.isNotEmpty) addMeta('ISRC', track.isrc); - if (track.composer.isNotEmpty) addMeta('COMPOSER', track.composer); - - AudioMetadataMapper.appendMappedMetadataArguments(arguments, cueMetadata); - arguments - ..add(outputPath) - ..add('-y'); - - _log.d('CUE split track ${track.number}'); - final result = await _executeWithArguments(arguments); - if (!result.success) { - _log.e('CUE split failed for track ${track.number}: ${result.output}'); - continue; - } - - if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {} - - outputPaths.add(outputPath); - _log.i('CUE split: track ${track.number} -> $outputFileName'); - } - - if (outputPaths.isEmpty) { - _log.e('CUE split: no tracks were successfully extracted'); - return null; - } - - _log.i('CUE split complete: ${outputPaths.length}/${tracks.length} tracks'); - return outputPaths; - } - - static String _formatSecondsForFFmpeg(double seconds) { - if (seconds < 0) return '0'; - final hours = seconds ~/ 3600; - final mins = (seconds % 3600) ~/ 60; - final secs = seconds - (hours * 3600) - (mins * 60); - return '${hours.toString().padLeft(2, '0')}:${mins.toInt().toString().padLeft(2, '0')}:${secs.toStringAsFixed(3).padLeft(6, '0')}'; - } + }) => FFmpegConversion.splitCueToTracks( + audioPath: audioPath, + outputDir: outputDir, + tracks: tracks, + albumMetadata: albumMetadata, + coverPath: coverPath, + onProgress: onProgress, + ); } diff --git a/lib/services/ffmpeg_support.dart b/lib/services/ffmpeg_support.dart new file mode 100644 index 00000000..499e43ac --- /dev/null +++ b/lib/services/ffmpeg_support.dart @@ -0,0 +1,228 @@ +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); + } + } +} diff --git a/test/ffmpeg_output_promotion_test.dart b/test/ffmpeg_output_promotion_test.dart new file mode 100644 index 00000000..692cbd15 --- /dev/null +++ b/test/ffmpeg_output_promotion_test.dart @@ -0,0 +1,145 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/ffmpeg_support.dart'; + +void main() { + late Directory directory; + setUp(() async { + directory = await Directory.systemTemp.createTemp('ffmpeg-promotion-'); + }); + tearDown(() async => directory.delete(recursive: true)); + + Future write(String name, String data) => + File('${directory.path}/$name').writeAsString(data); + + test( + 'metadata promotion publishes the complete output and removes staging', + () async { + final target = await write('song.opus', 'original'); + final temp = await write('temp.opus', 'tagged'); + expect( + await FFmpegOutput.promote( + temp.path, + target.path, + onError: (e) => fail('$e'), + ), + isTrue, + ); + expect(await target.readAsString(), 'tagged'); + expect(await directory.list().map((file) => file.path).toList(), [ + target.path, + ]); + }, + ); + + test('an empty successful temp output cannot replace the original', () async { + final target = await write('song.opus', 'original'); + final temp = await write('temp.opus', ''); + expect( + await FFmpegOutput.promote( + temp.path, + target.path, + onError: (e) => fail('$e'), + ), + isFalse, + ); + expect(await target.readAsString(), 'original'); + expect(await directory.list().map((file) => file.path).toList(), [ + target.path, + ]); + }); + + test('missing temp output reports missing and keeps the original', () async { + final target = await write('song.opus', 'original'); + var missing = false; + expect( + await FFmpegOutput.promote( + '${directory.path}/missing.opus', + target.path, + onMissing: () => missing = true, + onError: (e) => fail('$e'), + ), + isFalse, + ); + expect(missing, isTrue); + expect(await target.readAsString(), 'original'); + }); + + test('a failed publication restores the original from its backup', () async { + final target = await write('song.flac', 'original'); + final staging = await write('temp.flac', 'converted'); + final blocked = await Directory('${directory.path}/blocked.flac').create(); + await File('${blocked.path}/keep').writeAsString('untouched'); + Object? error; + var restoredBeforeCallback = false; + expect( + await FFmpegOutput.finalize( + plan: FFmpegOutputPlan( + workingPath: staging.path, + finalPath: blocked.path, + ), + inputPath: target.path, + deleteOriginal: true, + onError: (e) { + error = e; + restoredBeforeCallback = + target.readAsStringSync() == 'original' && !staging.existsSync(); + }, + ), + isNull, + ); + expect(error, isA()); + expect(restoredBeforeCallback, isTrue); + expect(await target.readAsString(), 'original'); + expect(await File('${blocked.path}/keep').readAsString(), 'untouched'); + expect(await staging.exists(), isFalse); + expect(await directory.list().length, 2); + }); + + test('promotion reports publication errors after staging cleanup', () async { + final target = await Directory('${directory.path}/blocked.opus').create(); + final original = await File( + '${target.path}/keep', + ).writeAsString('original'); + final temp = await write('temp.opus', 'tagged'); + Object? error; + var stagingRemained = true; + expect( + await FFmpegOutput.promote( + temp.path, + target.path, + onError: (e) { + error = e; + stagingRemained = directory.listSync().any( + (entry) => entry.path.contains('.spotiflac-staging-'), + ); + }, + ), + isFalse, + ); + expect(error, isA()); + expect(stagingRemained, isFalse); + expect(await original.readAsString(), 'original'); + expect(await temp.exists(), isFalse); + expect(await directory.list().length, 1); + }); + + test('a staging reservation failure preserves both existing files', () async { + final parent = await write('parent', 'keep parent'); + final temp = await write('temp.opus', 'tagged'); + Object? error; + expect( + await FFmpegOutput.promote( + temp.path, + '${parent.path}/song.opus', + onError: (e) => error = e, + ), + isFalse, + ); + expect(error, isA()); + expect(await parent.readAsString(), 'keep parent'); + expect(await temp.exists(), isFalse); + expect(await directory.list().length, 1); + }); +}