refactor(ffmpeg): split media operations and safely publish outputs

This commit is contained in:
zarzet committed 2026-10-05 03:28:26 +07:00
1 parent e77996ddbd
commit f8f489a7d5
8 files changed
+2081 -2303

No files matched your search

+588
View File
@@ -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<String> 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 = <String>[
...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<String> 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<String?> convertM4aToFlac(
String inputPath, {
String? sourceCodec,
Future<FFmpegResult> Function(List<String>)? 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<String> 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<String?> convertAudioFormat({
required String inputPath,
required String targetFormat,
required String bitrate,
required Map<String, String> 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<String?> _convertToLossless({
required String inputPath,
required Map<String, String> 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 = <String>['-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<String?> _convertToPcm({
required String inputPath,
required Map<String, String> 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 = <String>[
'-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<List<String>?> splitCueToTracks({
required String audioPath,
required String outputDir,
required List<CueSplitTrackInfo> tracks,
required Map<String, String> albumMetadata,
String? coverPath,
void Function(int current, int total)? onProgress,
}) async {
if (tracks.isEmpty) {
_log.e('No tracks to split');
return null;
}
final outputPaths = <String>[];
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 = <String>[
'-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 = <String, String>{
'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')}';
}
}
+183
View File
@@ -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<String?> 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<String> _keyCandidates(String rawKey) {
final candidates = <String>[];
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<String?> _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;
}
}
}
+486
View File
@@ -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<String> 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<bool> 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<String> 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<String?> _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<String?> embedMetadata({
required String flacPath,
String? coverPath,
Map<String, String>? metadata,
String artistTagMode = artistTagModeJoined,
}) async {
final tempOutput = await FFmpegOutput.tempPath('.flac');
final arguments = <String>['-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<String?> embedMetadataToMp3({
required String mp3Path,
String? coverPath,
Map<String, String>? 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<FFmpegResult> _runMp3Embed({
required String mp3Path,
required String tempOutput,
String? coverPath,
Map<String, String>? metadata,
required bool preserveMetadata,
required String audioCodec,
String? audioBitrate,
}) async {
final arguments = <String>['-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<void> _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<String?> embedMetadataToOpus({
required String opusPath,
String? coverPath,
Map<String, String>? metadata,
String artistTagMode = artistTagModeJoined,
bool preserveMetadata = false,
Future<FFmpegResult> Function(List<String>)? 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 = <String>[
'-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<String?> embedMetadataToM4a({
required String m4aPath,
String? coverPath,
Map<String, String>? 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<String> buildArgs(bool forceMov) {
final arguments = <String>['-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<String?> _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<bool> embedChunkTagsNative(
String path,
Map<String, String> 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<void> writeM4AFreeformTags(
String path,
Map<String, String> metadata,
) async {
final fields = <String, String>{};
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<bool> writeM4AReleaseIdentityTags(
String path,
Map<String, String> 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;
}
}
}
+148
View File
@@ -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<PrimaryAudioProperties> 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<String, _AudioProbeCacheEntry> _cache = {};
static void invalidate(String path) => _cache.remove(path);
static ({int width, int height})? imageDimensionsFromProperties(
Map<dynamic, dynamic> 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 <String, dynamic>{},
);
if (dimensions != null) return dimensions;
}
} catch (e) {
_log.w('Cover dimension probe failed for $path: $e');
}
return null;
}
static Future<String?> audioCodec(String path) async =>
(await audioProperties(path)).codec;
static Future<String?> _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<PrimaryAudioProperties> 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<PrimaryAudioProperties> _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 <String, dynamic>{};
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<bool> 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;
}
}
}
+201
View File
@@ -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<ReplayGainResult?> 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<bool> 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<bool> writeTrackTags(
String filePath,
String gain,
String peak,
) => _writeTags(filePath, 'Track', gain, peak);
static Future<bool> _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;
}
}
File diff suppressed because it is too large. Load diff
+228
View File
@@ -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<FFmpegResult> 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<FFmpegResult> arguments(List<String> 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="<redacted>"',
)
.replaceAll(
RegExp(r'-metadata\s+unsyncedlyrics="[^"]*"', caseSensitive: false),
'-metadata unsyncedlyrics="<redacted>"',
)
.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<String> 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<FFmpegOutputPlan> 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<String> 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<void> 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<String?> 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<bool> 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);
}
}
}
+145
View File
@@ -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<File> 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<FileSystemException>());
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<FileSystemException>());
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<FileSystemException>());
expect(await parent.readAsString(), 'keep parent');
expect(await temp.exists(), isFalse);
expect(await directory.list().length, 1);
});
}