mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-27 05:12:29 +02:00
feat(download): add automatic lossy conversion
This commit is contained in:
@@ -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...');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user