feat(download): add automatic lossy conversion

This commit is contained in:
zarzet
2026-08-11 15:19:45 +07:00
parent a59c749089
commit 5237aed25e
30 changed files with 1272 additions and 12 deletions
@@ -28,6 +28,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/audio_format_utils.dart';
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
import 'package:spotiflac_android/utils/progress_stream_poller.dart';
@@ -556,6 +557,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
embedReplayGain: settings.embedReplayGain,
postProcessingEnabled: postProcessingEnabled,
tidalHighFormat: settings.tidalHighFormat,
autoConvertDownloads: settings.autoConvertDownloads,
autoConvertFormat: normalizeAutoConvertFormat(settings.autoConvertFormat),
autoConvertBitrate: normalizeAutoConvertBitrate(
settings.autoConvertBitrate,
),
trackNumber: normalizedTrackNumber,
playlistPosition: _validPlaylistPosition(item),
discNumber: normalizedDiscNumber,
@@ -23,6 +23,20 @@ class _QualityVariantFileOutcome {
});
}
class _AutoConversionOutcome {
final String filePath;
final String? fileName;
final String quality;
final bool converted;
const _AutoConversionOutcome({
required this.filePath,
required this.fileName,
required this.quality,
required this.converted,
});
}
/// AC-4 repair only applies to MP4 containers; decrypt can also emit raw
/// FLAC, which the native MP4 box parser would reject as corrupt.
bool _isMp4Container(String path) {
@@ -31,6 +45,160 @@ bool _isMp4Container(String path) {
}
extension _DownloadQueueFinalization on DownloadQueueNotifier {
Future<_AutoConversionOutcome> _autoConvertDownloadedFile({
required String itemId,
required String filePath,
required String? fileName,
required String currentQuality,
required AppSettings settings,
required Track track,
required Map<String, dynamic> result,
required String downloadService,
required String storageMode,
String? downloadTreeUri,
String? safRelativeDir,
}) async {
if (!settings.autoConvertDownloads) {
return _AutoConversionOutcome(
filePath: filePath,
fileName: fileName,
quality: currentQuality,
converted: false,
);
}
final targetFormat = normalizeAutoConvertFormat(settings.autoConvertFormat);
final targetBitrate = normalizeAutoConvertBitrate(
settings.autoConvertBitrate,
);
final targetBitrateKbps = autoConvertBitrateKbps(targetBitrate);
if (autoConversionAlreadySatisfied(
filePath: filePath,
fileName: fileName,
targetFormat: targetFormat,
targetBitrate: targetBitrate,
quality: currentQuality,
bitrateKbps: readPositiveBitrateKbps(
result['bitrate'] ?? result['actual_bitrate'],
),
)) {
return _AutoConversionOutcome(
filePath: filePath,
fileName: fileName,
quality: currentQuality,
converted: false,
);
}
final baseFileName = (fileName?.trim().isNotEmpty == true
? fileName!.trim()
: File(filePath).uri.pathSegments.last);
final convertedFileName = convertedOutputFileName(
originalFileName: baseFileName,
targetFormat: targetFormat,
);
final convertedQuality =
'${displayFormatForLossyFormat(targetFormat)} ${targetBitrateKbps}kbps';
Future<void> embedConvertedMetadata(String convertedPath) async {
if (!settings.embedMetadata) return;
try {
await _embedMetadataToFile(
convertedPath,
track,
format: metadataFormatForLossyFormat(targetFormat),
genre: result['genre'] as String?,
label: result['label'] as String?,
copyright: result['copyright'] as String?,
downloadService: downloadService,
writeExternalLrc: storageMode != 'saf',
);
} catch (e) {
// The audio conversion itself is still valid. Preserve the converted
// file if an optional tag/cover write fails.
_log.w('Automatic conversion metadata embed failed: $e');
result['auto_conversion_metadata_warning'] = e.toString();
}
}
try {
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 0.97);
String? convertedPath;
String? publishedFileName = convertedFileName;
if (storageMode == 'saf' && isContentUri(filePath)) {
if (downloadTreeUri == null || downloadTreeUri.isEmpty) {
throw StateError('Missing SAF tree for automatic conversion');
}
convertedPath = await _replaceSafFileVia(
uri: filePath,
treeUri: downloadTreeUri,
relativeDir: safRelativeDir ?? '',
avoidOverwrite:
convertedFileName.toLowerCase() != baseFileName.toLowerCase(),
onPublishedFileName: (value) => publishedFileName = value,
op: (tempPath, addCleanup) async {
final output = await FFmpegService.convertAudioFormat(
inputPath: tempPath,
targetFormat: targetFormat,
bitrate: targetBitrate,
metadata: const {},
deleteOriginal: false,
);
if (output == null) return null;
addCleanup(output);
await embedConvertedMetadata(output);
return (output, convertedFileName);
},
);
} else {
convertedPath = await FFmpegService.convertAudioFormat(
inputPath: filePath,
targetFormat: targetFormat,
bitrate: targetBitrate,
metadata: const {},
deleteOriginal: true,
);
if (convertedPath != null) {
publishedFileName = File(convertedPath).uri.pathSegments.last;
await embedConvertedMetadata(convertedPath);
}
}
if (convertedPath == null || convertedPath.isEmpty) {
throw StateError('FFmpeg returned no automatic conversion output');
}
result['file_path'] = convertedPath;
result['file_name'] = publishedFileName;
result['audio_codec'] = targetFormat;
result['format'] = targetFormat;
result['bitrate'] = targetBitrateKbps;
result.remove('actual_bit_depth');
result.remove('actual_sample_rate');
_log.i(
'Automatic conversion completed: ${autoConvertFormatLabel(targetFormat)} @ $targetBitrate',
);
return _AutoConversionOutcome(
filePath: convertedPath,
fileName: publishedFileName,
quality: convertedQuality,
converted: true,
);
} catch (e) {
// A successful download remains usable when the optional conversion
// fails. Conversion helpers only remove the source after atomic output
// promotion, so returning the original path is safe here.
result['auto_conversion_warning'] = e.toString();
_log.w('Automatic conversion failed; keeping downloaded source: $e');
return _AutoConversionOutcome(
filePath: filePath,
fileName: fileName,
quality: currentQuality,
converted: false,
);
}
}
/// Builds the [DownloadHistoryItem] shared by the native-worker and inline
/// completion paths. Fields whose source/derivation legitimately differs
/// between the two callers (SAF location, probed vs. raw audio metadata,
@@ -709,7 +877,12 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
return filePath;
}
final tidalHighFormat = settings.tidalHighFormat;
final tidalHighFormat = settings.autoConvertDownloads
? autoConvertLossySetting(
format: settings.autoConvertFormat,
bitrate: settings.autoConvertBitrate,
)
: settings.tidalHighFormat;
final format = lossyFormatForSetting(tidalHighFormat);
final newExt = lossyExtensionForFormat(format);
final displayFormat = displayFormatForLossyFormat(format);
@@ -761,6 +934,11 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
}
result['file_name'] = newFileName;
result['_native_actual_quality'] = '$displayFormat $bitrateDisplay';
result['audio_codec'] = format;
result['format'] = format;
result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last);
result.remove('actual_bit_depth');
result.remove('actual_sample_rate');
return newUri;
}
@@ -775,6 +953,11 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
}
await embedConvertedMetadata(convertedPath);
result['_native_actual_quality'] = '$displayFormat $bitrateDisplay';
result['audio_codec'] = format;
result['format'] = format;
result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last);
result.remove('actual_bit_depth');
result.remove('actual_sample_rate');
return convertedPath;
}
@@ -1212,6 +1212,30 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
if (postProcessedPath != null && postProcessedPath.isNotEmpty) {
filePath = postProcessedPath;
}
final autoConvertOutcome = await _autoConvertDownloadedFile(
itemId: item.id,
filePath: filePath,
fileName: result['file_name'] as String? ?? context.safFileName,
currentQuality: actualQuality,
settings: settings,
track: trackToDownload,
result: result,
downloadService: context.item.service,
storageMode: context.storageMode,
downloadTreeUri: context.downloadTreeUri,
safRelativeDir: context.safRelativeDir,
);
filePath = autoConvertOutcome.filePath;
actualQuality = autoConvertOutcome.quality;
if (autoConvertOutcome.fileName != null) {
result['file_name'] = autoConvertOutcome.fileName;
}
if (autoConvertOutcome.converted) {
actualBitDepth = null;
actualSampleRate = null;
actualFormat = normalizeAutoConvertFormat(settings.autoConvertFormat);
actualBitrate = autoConvertBitrateKbps(settings.autoConvertBitrate);
}
await _writeNativeWorkerReplayGain(
context: context,
settings: settings,
@@ -713,6 +713,27 @@ class _DownloadRun {
}
}
final autoConvertInput = filePath;
if (!wasExisting && autoConvertInput != null) {
final outcome = await n._autoConvertDownloadedFile(
itemId: item.id,
filePath: autoConvertInput,
fileName: finalSafFileName ?? result['file_name'] as String?,
currentQuality: actualQuality,
settings: settings,
track: trackToDownload,
result: result,
downloadService: item.service,
storageMode: effectiveSafMode ? 'saf' : 'app',
downloadTreeUri: settings.downloadTreeUri,
safRelativeDir: effectiveOutputDir,
);
filePath = outcome.filePath;
finalSafFileName = outcome.fileName;
actualQuality = outcome.quality;
if (outcome.converted) probedFinalMetadata = null;
}
final variantInput = filePath;
if (variantInput != null && item.preserveQualityVariant) {
final variantOutcome = await n._finalizeQualityVariantFilename(
@@ -911,7 +932,12 @@ class _DownloadRun {
}
Future<void> _convertSafM4aToLossy(String currentFilePath) async {
final tidalHighFormat = settings.tidalHighFormat;
final tidalHighFormat = settings.autoConvertDownloads
? autoConvertLossySetting(
format: settings.autoConvertFormat,
bitrate: settings.autoConvertBitrate,
)
: settings.tidalHighFormat;
_log.i(
'Lossy 320kbps quality (SAF), converting M4A to $tidalHighFormat...',
);
@@ -972,6 +998,11 @@ class _DownloadRun {
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '$displayFormat $bitrateDisplay';
result['audio_codec'] = format;
result['format'] = format;
result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last);
result.remove('actual_bit_depth');
result.remove('actual_sample_rate');
} else if (convertFailed) {
_log.w('M4A to $format conversion failed, keeping M4A file');
actualQuality = 'AAC 320kbps';
@@ -1115,7 +1146,12 @@ class _DownloadRun {
}
Future<void> _convertLocalM4aToLossy(String currentFilePath) async {
final tidalHighFormat = settings.tidalHighFormat;
final tidalHighFormat = settings.autoConvertDownloads
? autoConvertLossySetting(
format: settings.autoConvertFormat,
bitrate: settings.autoConvertBitrate,
)
: settings.tidalHighFormat;
_log.i(
'Lossy 320kbps quality download, converting M4A to $tidalHighFormat...',
);
@@ -1138,6 +1174,11 @@ class _DownloadRun {
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '$displayFormat $bitrateDisplay';
result['audio_codec'] = format;
result['format'] = format;
result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last);
result.remove('actual_bit_depth');
result.remove('actual_sample_rate');
_log.i('Successfully converted M4A to $format: $convertedPath');
_log.i('Embedding metadata to $format...');
+26
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/audio_format_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -162,6 +163,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
libraryQualityLabelMode: _normalizeLibraryQualityLabelMode(
loaded.libraryQualityLabelMode,
),
autoConvertFormat: normalizeAutoConvertFormat(
loaded.autoConvertFormat,
),
autoConvertBitrate: normalizeAutoConvertBitrate(
loaded.autoConvertBitrate,
),
defaultService: loaded.defaultService,
searchProvider: loaded.searchProvider,
extensionVerificationBrowserMode:
@@ -735,6 +742,25 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setAutoConvertDownloads(bool enabled) {
state = state.copyWith(autoConvertDownloads: enabled);
_saveSettings();
}
void setAutoConvertFormat(String format) {
state = state.copyWith(
autoConvertFormat: normalizeAutoConvertFormat(format),
);
_saveSettings();
}
void setAutoConvertBitrate(String bitrate) {
state = state.copyWith(
autoConvertBitrate: normalizeAutoConvertBitrate(bitrate),
);
_saveSettings();
}
void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings();