mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
312 lines
10 KiB
Dart
312 lines
10 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:spotiflac_android/models/download_result.dart';
|
|
import 'package:spotiflac_android/services/download_saf_file_replacer.dart';
|
|
import 'package:spotiflac_android/services/ffmpeg_models.dart';
|
|
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
|
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
|
|
|
typedef DecryptDownloadFile =
|
|
Future<String?> Function({
|
|
required String inputPath,
|
|
required DownloadDecryptionDescriptor descriptor,
|
|
required bool deleteOriginal,
|
|
Future<void> Function(String)? prepareOutput,
|
|
});
|
|
|
|
typedef ConvertDownloadAudio =
|
|
Future<String?> Function({
|
|
required String inputPath,
|
|
required String targetFormat,
|
|
required String bitrate,
|
|
required bool deleteOriginal,
|
|
});
|
|
|
|
enum DownloadDecryptionFailure { safAccess, decrypt, safWrite }
|
|
|
|
class DownloadDecryptionOutcome {
|
|
const DownloadDecryptionOutcome(this.path, {this.fileName, this.failure});
|
|
final String? path;
|
|
final String? fileName;
|
|
final DownloadDecryptionFailure? failure;
|
|
}
|
|
|
|
class DownloadConversionOutcome {
|
|
const DownloadConversionOutcome({
|
|
required this.filePath,
|
|
required this.fileName,
|
|
required this.quality,
|
|
this.converted = false,
|
|
});
|
|
final String filePath;
|
|
final String? fileName;
|
|
final String quality;
|
|
final bool converted;
|
|
}
|
|
|
|
/// Owns audio transforms and their optional-failure policy. Queue state,
|
|
/// settings, track enrichment and platform storage stay at the call boundary.
|
|
class DownloadFileFinalizer {
|
|
const DownloadFileFinalizer({
|
|
required this.decryptFile,
|
|
required this.probeCodec,
|
|
required this.repairAc4,
|
|
required this.convertAudio,
|
|
required this.replaceSafFile,
|
|
required this.warn,
|
|
required this.info,
|
|
});
|
|
|
|
final DecryptDownloadFile decryptFile;
|
|
final Future<String?> Function(String) probeCodec;
|
|
final Future<void> Function(String path, String encryptedPath) repairAc4;
|
|
final ConvertDownloadAudio convertAudio;
|
|
final ReplaceSafDownloadFile replaceSafFile;
|
|
final void Function(String) warn;
|
|
final void Function(String) info;
|
|
|
|
Future<String> _normalizeIsoBmffPath(
|
|
String path,
|
|
DownloadResult result,
|
|
) async {
|
|
if (!_isMp4Container(path)) return path;
|
|
final codec = await probeCodec(path);
|
|
final extension = isoBmffAudioExtensionForCodec(
|
|
codec ?? result.reportedFormat,
|
|
);
|
|
if (path.toLowerCase().endsWith(extension)) return path;
|
|
final targetPath = path.replaceFirst(
|
|
RegExp(r'\.(?:m4a|mp4)$', caseSensitive: false),
|
|
extension,
|
|
);
|
|
if (targetPath == path) return path;
|
|
try {
|
|
if (await File(targetPath).exists()) {
|
|
warn(
|
|
'Cannot normalize ISO-BMFF audio extension; target already exists: $targetPath',
|
|
);
|
|
return path;
|
|
}
|
|
return (await File(path).rename(targetPath)).path;
|
|
} catch (e) {
|
|
warn('Failed to normalize ISO-BMFF audio extension for $path: $e');
|
|
return path;
|
|
}
|
|
}
|
|
|
|
Future<void> _repair(String path, String encryptedPath) async {
|
|
if (!_isMp4Container(path)) return;
|
|
try {
|
|
await repairAc4(path, encryptedPath);
|
|
} catch (e) {
|
|
warn('AC-4 container repair skipped: $e');
|
|
}
|
|
}
|
|
|
|
Future<DownloadDecryptionOutcome> decrypt({
|
|
required DownloadResult result,
|
|
required String filePath,
|
|
required bool useSaf,
|
|
String? treeUri,
|
|
required String relativeDir,
|
|
required String baseName,
|
|
required String extensionFallback,
|
|
required bool repairContainer,
|
|
void Function(String strategy)? onStart,
|
|
}) async {
|
|
if (result.alreadyExists) return DownloadDecryptionOutcome(filePath);
|
|
final descriptor = result.decryption;
|
|
if (descriptor == null) {
|
|
return DownloadDecryptionOutcome(filePath);
|
|
}
|
|
onStart?.call(descriptor.normalizedStrategy);
|
|
if (useSaf && filePath.startsWith('content://')) {
|
|
if (treeUri == null || treeUri.isEmpty) {
|
|
return const DownloadDecryptionOutcome(
|
|
null,
|
|
failure: DownloadDecryptionFailure.safAccess,
|
|
);
|
|
}
|
|
DownloadDecryptionFailure? failure;
|
|
var started = false;
|
|
String? fileName;
|
|
final uri = await replaceSafFile(
|
|
uri: filePath,
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
op: (tempPath, addCleanup) async {
|
|
started = true;
|
|
final decrypted = await decryptFile(
|
|
inputPath: tempPath,
|
|
descriptor: descriptor,
|
|
deleteOriginal: false,
|
|
);
|
|
if (decrypted == null) {
|
|
failure = DownloadDecryptionFailure.decrypt;
|
|
return null;
|
|
}
|
|
addCleanup(decrypted);
|
|
final normalized = await _normalizeIsoBmffPath(decrypted, result);
|
|
if (normalized != decrypted) addCleanup(normalized);
|
|
if (repairContainer) await _repair(normalized, tempPath);
|
|
final dotIndex = normalized.lastIndexOf('.');
|
|
final extension = dotIndex >= 0
|
|
? normalized.substring(dotIndex).toLowerCase()
|
|
: extensionFallback;
|
|
const allowed = {'.flac', '.m4a', '.mp4', '.mp3', '.opus'};
|
|
fileName =
|
|
'$baseName${allowed.contains(extension) ? extension : extensionFallback}';
|
|
return (normalized, fileName!);
|
|
},
|
|
);
|
|
return DownloadDecryptionOutcome(
|
|
uri,
|
|
fileName: uri == null ? null : fileName,
|
|
failure: uri == null
|
|
? failure ??
|
|
(started
|
|
? DownloadDecryptionFailure.safWrite
|
|
: DownloadDecryptionFailure.safAccess)
|
|
: null,
|
|
);
|
|
}
|
|
final decrypted = await decryptFile(
|
|
inputPath: filePath,
|
|
descriptor: descriptor,
|
|
deleteOriginal: true,
|
|
// Repair before the encrypted source is removed by atomic promotion.
|
|
prepareOutput: repairContainer ? (path) => _repair(path, filePath) : null,
|
|
);
|
|
return DownloadDecryptionOutcome(
|
|
decrypted == null ? null : await _normalizeIsoBmffPath(decrypted, result),
|
|
failure: decrypted == null ? DownloadDecryptionFailure.decrypt : null,
|
|
);
|
|
}
|
|
|
|
Future<DownloadConversionOutcome> autoConvert({
|
|
required DownloadResult result,
|
|
required String filePath,
|
|
required String? fileName,
|
|
required String quality,
|
|
required bool enabled,
|
|
required String targetFormat,
|
|
required String targetBitrate,
|
|
required bool useSaf,
|
|
String? treeUri,
|
|
String relativeDir = '',
|
|
Future<void> Function(String path, String format)? embedMetadata,
|
|
void Function()? onStart,
|
|
}) async {
|
|
final original = DownloadConversionOutcome(
|
|
filePath: filePath,
|
|
fileName: fileName,
|
|
quality: quality,
|
|
);
|
|
if (!enabled) return original;
|
|
targetFormat = normalizeAutoConvertFormat(targetFormat);
|
|
targetBitrate = normalizeAutoConvertBitrate(targetBitrate);
|
|
final bitrateKbps = autoConvertBitrateKbps(targetBitrate);
|
|
if (autoConversionAlreadySatisfied(
|
|
filePath: filePath,
|
|
fileName: fileName,
|
|
targetFormat: targetFormat,
|
|
targetBitrate: targetBitrate,
|
|
quality: quality,
|
|
bitrateKbps: result.bitrateKbps,
|
|
)) {
|
|
return original;
|
|
}
|
|
final baseName = fileName?.trim().isNotEmpty == true
|
|
? fileName!.trim()
|
|
: File(filePath).uri.pathSegments.last;
|
|
final convertedName = convertedOutputFileName(
|
|
originalFileName: baseName,
|
|
targetFormat: targetFormat,
|
|
);
|
|
Future<void> embed(String path) async {
|
|
try {
|
|
await embedMetadata?.call(
|
|
path,
|
|
metadataFormatForLossyFormat(targetFormat),
|
|
);
|
|
} catch (e) {
|
|
warn('Automatic conversion metadata embed failed: $e');
|
|
result['auto_conversion_metadata_warning'] = e.toString();
|
|
}
|
|
}
|
|
|
|
try {
|
|
onStart?.call();
|
|
String? convertedPath;
|
|
String? publishedName = convertedName;
|
|
if (useSaf && filePath.startsWith('content://')) {
|
|
if (treeUri == null || treeUri.isEmpty) {
|
|
throw StateError('Missing SAF tree for automatic conversion');
|
|
}
|
|
convertedPath = await replaceSafFile(
|
|
uri: filePath,
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
avoidOverwrite: convertedName.toLowerCase() != baseName.toLowerCase(),
|
|
onPublishedFileName: (value) => publishedName = value,
|
|
op: (tempPath, addCleanup) async {
|
|
final output = await convertAudio(
|
|
inputPath: tempPath,
|
|
targetFormat: targetFormat,
|
|
bitrate: targetBitrate,
|
|
deleteOriginal: false,
|
|
);
|
|
if (output == null) return null;
|
|
addCleanup(output);
|
|
await embed(output);
|
|
return (output, convertedName);
|
|
},
|
|
);
|
|
} else {
|
|
convertedPath = await convertAudio(
|
|
inputPath: filePath,
|
|
targetFormat: targetFormat,
|
|
bitrate: targetBitrate,
|
|
deleteOriginal: true,
|
|
);
|
|
if (convertedPath != null) {
|
|
// Deferred SAF outputs keep their logical name while in cache.
|
|
if (!useSaf) {
|
|
publishedName = File(convertedPath).uri.pathSegments.last;
|
|
}
|
|
await embed(convertedPath);
|
|
}
|
|
}
|
|
if (convertedPath == null || convertedPath.isEmpty) {
|
|
throw StateError('FFmpeg returned no automatic conversion output');
|
|
}
|
|
result.markConvertedAudio(
|
|
path: convertedPath,
|
|
name: publishedName,
|
|
format: targetFormat,
|
|
bitrateKbps: bitrateKbps,
|
|
);
|
|
info(
|
|
'Automatic conversion completed: ${autoConvertFormatLabel(targetFormat)} @ $targetBitrate',
|
|
);
|
|
return DownloadConversionOutcome(
|
|
filePath: convertedPath,
|
|
fileName: publishedName,
|
|
quality:
|
|
'${displayFormatForLossyFormat(targetFormat)} ${bitrateKbps}kbps',
|
|
converted: true,
|
|
);
|
|
} catch (e) {
|
|
// FFmpeg removes the source only after atomic output promotion.
|
|
result['auto_conversion_warning'] = e.toString();
|
|
warn('Automatic conversion failed; keeping downloaded source: $e');
|
|
return original;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _isMp4Container(String path) {
|
|
final lower = path.toLowerCase();
|
|
return lower.endsWith('.m4a') || lower.endsWith('.mp4');
|
|
}
|