mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
refactor(ffmpeg): split media operations and safely publish outputs
This commit is contained in:
1 parent
e77996ddbd
commit
f8f489a7d5
8 files changed
+2081
-2303
No files matched your search
@@ -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')}';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+102
-2303
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user