fix(download): remove legacy HIGH quality conversion

This commit is contained in:
zarzet
2026-09-07 01:20:28 +07:00
parent e8bf40ae41
commit 81ce677149
15 changed files with 73 additions and 615 deletions
@@ -228,8 +228,6 @@ object NativeDownloadFinalizer {
currentStatus("finalizing")
finalizeDecryption(context, effectiveInput, state, shouldCancel)
checkCancelled(shouldCancel)
finalizeHighConversion(context, effectiveInput, state, shouldCancel)
checkCancelled(shouldCancel)
finalizeContainerConversion(context, effectiveInput, state, shouldCancel)
checkCancelled(shouldCancel)
finalizeMetadata(context, effectiveInput, state)
@@ -491,12 +489,7 @@ object NativeDownloadFinalizer {
private fun outputExt(input: FinalizeInput): String {
val safExt = input.request.optString("saf_output_ext", "")
val ext = safExt.ifBlank { input.request.optString("output_ext", "") }
return normalizeExt(ext.ifBlank {
when (requestQuality(input)) {
"HIGH" -> ".mp3"
else -> ".flac"
}
})
return normalizeExt(ext.ifBlank { ".flac" })
}
private fun finalizeDecryption(
@@ -619,77 +612,6 @@ object NativeDownloadFinalizer {
return target.absolutePath
}
private fun finalizeHighConversion(
context: Context,
input: FinalizeInput,
state: FinalizeState,
shouldCancel: () -> Boolean,
) {
if (requestQuality(input) != "HIGH") return
if (!looksLikeM4a(state.filePath, state.fileName)) return
val autoTarget = NativeFinalizationPolicy.autoConversionTarget(
enabled = input.request.optBoolean("auto_convert_downloads", false),
format = input.request.optString("auto_convert_format", ""),
bitrate = input.request.optString("auto_convert_bitrate", ""),
)
val tidalHighFormat = input.request.optString("tidal_high_format", "").ifBlank { "mp3_320" }
val format = autoTarget?.codec ?: when {
tidalHighFormat.startsWith("opus") -> "opus"
tidalHighFormat.startsWith("aac") || tidalHighFormat.startsWith("m4a") -> "aac"
else -> "mp3"
}
val metadataFormat = if (format == "aac") "m4a" else format
val displayFormat = if (format == "aac") "AAC" else format.uppercase(Locale.ROOT)
val bitrate = if (autoTarget != null) {
"${autoTarget.bitrateKbps}k"
} else if (tidalHighFormat.contains("_")) {
"${tidalHighFormat.substringAfterLast("_")}k"
} else {
if (format == "opus") "128k" else "320k"
}
val ext = when (format) {
"opus" -> ".opus"
"aac" -> ".m4a"
else -> ".mp3"
}
val localInput = materializeForFFmpeg(context, input, state)
val deleteLocalInput = state.filePath.startsWith("content://")
val output = buildOutputPath(localInput, ext)
val stagedOutput = stagedConversionPath(output)
var adoptedOutput = false
try {
val command = if (format == "opus") {
"-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(stagedOutput)} -y"
} else if (format == "aac") {
"-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(stagedOutput)} -y"
} else {
"-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(stagedOutput)} -y"
}
val result = runFFmpeg(command, shouldCancel)
if (!result.first || !File(stagedOutput).exists()) {
throw IllegalStateException("HIGH conversion failed: ${result.second}")
}
if (!promoteStagedConversion(stagedOutput, output)) {
throw IllegalStateException("failed to publish HIGH conversion output")
}
embedBasicMetadata(context, output, input, metadataFormat)
replaceStatePath(context, input, state, output, deleteOld = true)
adoptedOutput = true
} finally {
if (!adoptedOutput) {
File(stagedOutput).delete()
File(output).delete()
}
if (deleteLocalInput) File(localInput).delete()
}
state.quality = "$displayFormat ${bitrate.removeSuffix("k")}kbps"
state.bitDepth = null
state.sampleRate = null
state.bitrateKbps = bitrate.removeSuffix("k").toIntOrNull()
state.audioCodec = format
}
private fun finalizeAutoConversion(
context: Context,
input: FinalizeInput,
@@ -799,7 +721,7 @@ object NativeDownloadFinalizer {
state: FinalizeState,
shouldCancel: () -> Boolean,
) {
if (requestQuality(input) == "HIGH" || outputExt(input) != ".flac") return
if (outputExt(input) != ".flac") return
val requestedDecryptionExt = requestedDecryptionOutputExt(input)
val forceContainerConversion = shouldForceContainerConversion(input, state)
if (!forceContainerConversion && requestedDecryptionExt.isNotBlank() && requestedDecryptionExt != ".flac") return
-1
View File
@@ -31,7 +31,6 @@ type DownloadRequest struct {
EmbedLyrics bool `json:"embed_lyrics"`
EmbedReplayGain bool `json:"embed_replaygain,omitempty"`
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
TidalHighFormat string `json:"tidal_high_format,omitempty"`
TrackNumber int `json:"track_number"`
PlaylistPosition int `json:"playlist_position,omitempty"`
DiscNumber int `json:"disc_number"`
-5
View File
@@ -74,8 +74,6 @@ class AppSettings {
extensionVerificationBrowserMode; // 'external_first' or 'in_app_first'
final String locale;
final String lyricsMode;
final String
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
final bool autoConvertDownloads;
final String autoConvertFormat; // 'mp3', 'aac' (M4A), or 'opus'
final String autoConvertBitrate; // '128k', '192k', '256k', or '320k'
@@ -169,7 +167,6 @@ class AppSettings {
this.extensionVerificationBrowserMode = 'in_app_first',
this.locale = 'system',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.autoConvertDownloads = false,
this.autoConvertFormat = 'mp3',
this.autoConvertBitrate = '320k',
@@ -247,7 +244,6 @@ class AppSettings {
String? extensionVerificationBrowserMode,
String? locale,
String? lyricsMode,
String? tidalHighFormat,
bool? autoConvertDownloads,
String? autoConvertFormat,
String? autoConvertBitrate,
@@ -339,7 +335,6 @@ class AppSettings {
this.extensionVerificationBrowserMode,
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
autoConvertDownloads: autoConvertDownloads ?? this.autoConvertDownloads,
autoConvertFormat: autoConvertFormat ?? this.autoConvertFormat,
autoConvertBitrate: autoConvertBitrate ?? this.autoConvertBitrate,
-2
View File
@@ -60,7 +60,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['extensionVerificationBrowserMode'] as String? ?? 'in_app_first',
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
autoConvertDownloads: json['autoConvertDownloads'] as bool? ?? false,
autoConvertFormat: json['autoConvertFormat'] as String? ?? 'mp3',
autoConvertBitrate: json['autoConvertBitrate'] as String? ?? '320k',
@@ -148,7 +147,6 @@ Map<String, dynamic> _$AppSettingsToJson(
'extensionVerificationBrowserMode': instance.extensionVerificationBrowserMode,
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'autoConvertDownloads': instance.autoConvertDownloads,
'autoConvertFormat': instance.autoConvertFormat,
'autoConvertBitrate': instance.autoConvertBitrate,
@@ -873,7 +873,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
!_shouldSkipLyrics(extensionState, track.source, item.service),
embedReplayGain: settings.embedReplayGain,
postProcessingEnabled: postProcessingEnabled,
tidalHighFormat: settings.tidalHighFormat,
autoConvertDownloads: settings.autoConvertDownloads,
autoConvertFormat: normalizeAutoConvertFormat(settings.autoConvertFormat),
autoConvertBitrate: normalizeAutoConvertBitrate(
@@ -992,114 +992,6 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
);
}
Future<String?> _finalizeNativeWorkerHighConversion({
required _NativeWorkerRequestContext context,
required Map<String, dynamic> result,
required AppSettings settings,
required Track track,
required String filePath,
}) async {
if (context.quality != 'HIGH') {
return filePath;
}
final lowerPath = filePath.toLowerCase();
final resultFileName = (result['file_name'] as String?)?.toLowerCase();
final looksLikeM4a =
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.mp4') ||
(resultFileName != null &&
(resultFileName.endsWith('.m4a') ||
resultFileName.endsWith('.mp4')));
if (!looksLikeM4a) {
return filePath;
}
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);
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
Future<void> embedConvertedMetadata(String convertedPath) async {
if (!settings.embedMetadata) return;
await _embedMetadataToFile(
convertedPath,
track,
format: metadataFormatForLossyFormat(format),
genre: result['genre'] as String?,
label: result['label'] as String?,
copyright: result['copyright'] as String?,
comment: result['comment'] as String?,
downloadService: context.item.service,
);
}
if (context.storageMode == 'saf' && isContentUri(filePath)) {
final treeUri = context.downloadTreeUri;
if (treeUri == null || treeUri.isEmpty) {
return null;
}
final rawFileName =
(result['file_name'] as String?) ?? context.safFileName ?? 'track';
final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
final newFileName = '$baseName$newExt';
final newUri = await _replaceSafFileVia(
uri: filePath,
treeUri: treeUri,
relativeDir: context.safRelativeDir ?? '',
op: (tempPath, addCleanup) async {
final convertedPath = await FFmpegService.convertM4aToLossy(
tempPath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: false,
);
if (convertedPath == null) return null;
addCleanup(convertedPath);
await embedConvertedMetadata(convertedPath);
return (convertedPath, newFileName);
},
);
if (newUri == null) {
return null;
}
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;
}
final convertedPath = await FFmpegService.convertM4aToLossy(
filePath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: true,
);
if (convertedPath == null) {
return null;
}
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;
}
Future<String?> _finalizeNativeWorkerContainerConversion({
required _NativeWorkerRequestContext context,
required Map<String, dynamic> result,
@@ -1107,7 +999,7 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
required Track track,
required String filePath,
}) async {
if (context.quality == 'HIGH' || context.outputExt != '.flac') {
if (context.outputExt != '.flac') {
return filePath;
}
final resultAudioFormat = normalizeAudioFormatValue(
@@ -1341,28 +1341,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
result,
resolvedAlbumArtist,
);
final convertedHighPath = await _finalizeNativeWorkerHighConversion(
context: context,
result: result,
settings: settings,
track: trackToDownload,
filePath: filePath,
);
if (convertedHighPath == null) {
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Failed to convert HIGH quality download',
errorType: DownloadErrorType.unknown,
);
_failedInSession++;
return;
}
filePath = convertedHighPath;
final nativeActualQuality = result['_native_actual_quality'] as String?;
if (nativeActualQuality != null && nativeActualQuality.isNotEmpty) {
actualQuality = nativeActualQuality;
}
final convertedContainerPath =
await _finalizeNativeWorkerContainerConversion(
context: context,
@@ -1413,6 +1391,46 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
actualSampleRate = null;
actualFormat = normalizeAutoConvertFormat(settings.autoConvertFormat);
actualBitrate = autoConvertBitrateKbps(settings.autoConvertBitrate);
} else if (settings.embedMetadata &&
_isMp4Container(result['file_name']?.toString() ?? filePath)) {
// Native lossy containers still need tags and lyrics even though their
// audio is no longer passed through the retired quality conversion.
Future<void> embedNativeMetadata(String path) async {
final lyrics = await _embedMetadataToFile(
path,
trackToDownload,
format: 'm4a',
genre: result['genre'] as String?,
label: result['label'] as String?,
copyright: result['copyright'] as String?,
comment: result['comment'] as String?,
downloadService: context.item.service,
writeExternalLrc: context.storageMode != 'saf',
);
if (lyrics != null && lyrics.isNotEmpty) result['lyrics_lrc'] = lyrics;
}
if (context.storageMode == 'saf' && isContentUri(filePath)) {
final treeUri = context.downloadTreeUri;
if (treeUri != null && treeUri.isNotEmpty) {
final fileName =
result['file_name']?.toString() ?? context.safFileName;
if (fileName != null && fileName.isNotEmpty) {
final updatedUri = await _replaceSafFileVia(
uri: filePath,
treeUri: treeUri,
relativeDir: context.safRelativeDir ?? '',
op: (tempPath, _) async {
await embedNativeMetadata(tempPath);
return (tempPath, fileName);
},
);
if (updatedUri != null) filePath = updatedUri;
}
}
} else {
await embedNativeMetadata(filePath);
}
}
await _writeNativeWorkerReplayGain(
settings: settings,
@@ -1479,7 +1497,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
final resultSafFileName = result['file_name'] as String?;
final lowerFilePath = filePath.toLowerCase();
// Recompute from the FINAL file path/result: the HIGH and container
// Recompute from the FINAL file path/result: automatic and container
// conversions above may have changed the format since actualFormat was
// derived from the pre-conversion output.
final historyFormat =
@@ -933,16 +933,12 @@ class _DownloadRun {
if (isContentUriPath && effectiveSafMode) {
if (shouldPreserveNativeM4a) {
await _preserveSafNativeM4a(path);
} else if (quality == 'HIGH') {
await _convertSafM4aToLossy(path);
} else {
await _convertSafM4aToFlac(path);
}
} else {
if (shouldPreserveNativeM4a) {
await _preserveLocalNativeM4a(path);
} else if (quality == 'HIGH') {
await _convertLocalM4aToLossy(path);
} else {
await _convertLocalM4aToFlac(path);
}
@@ -1110,91 +1106,6 @@ class _DownloadRun {
return true;
}
Future<void> _convertSafM4aToLossy(String currentFilePath) async {
final tidalHighFormat = settings.autoConvertDownloads
? autoConvertLossySetting(
format: settings.autoConvertFormat,
bitrate: settings.autoConvertBitrate,
)
: settings.tidalHighFormat;
_log.i(
'Lossy 320kbps quality (SAF), converting M4A to $tidalHighFormat...',
);
final format = lossyFormatForSetting(tidalHighFormat);
final displayFormat = displayFormatForLossyFormat(format);
final newExt = lossyExtensionForFormat(format);
final newFileName = '${safBaseName ?? 'track'}$newExt';
var opStarted = false;
var convertFailed = false;
try {
final newUri = await n._replaceSafFileVia(
uri: currentFilePath,
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
op: (tempPath, addCleanup) async {
opStarted = true;
n.updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.95,
);
final convertedPath = await FFmpegService.convertM4aToLossy(
tempPath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: false,
);
if (convertedPath == null) {
convertFailed = true;
return null;
}
addCleanup(convertedPath);
_log.i(
'Successfully converted M4A to $format (temp): $convertedPath',
);
_log.i('Embedding metadata to $format...');
n.updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.99,
);
await _embedFinalMetadata(
convertedPath,
format: metadataFormatForLossyFormat(format),
rebuildTrack: false,
);
return (convertedPath, newFileName);
},
);
if (newUri != null) {
filePath = newUri;
finalSafFileName = newFileName;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${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';
} else if (opStarted) {
_log.w('Failed to write converted $format to SAF, keeping M4A');
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('SAF M4A conversion failed: $e');
actualQuality = 'AAC 320kbps';
}
}
Future<void> _preserveSafNativeM4a(String currentFilePath) async {
// Decrypted streams are already in their final format.
// Converting e.g. eac3 M4A to FLAC would produce fake upscaled output.
@@ -1328,61 +1239,6 @@ class _DownloadRun {
}
}
Future<void> _convertLocalM4aToLossy(String currentFilePath) async {
final tidalHighFormat = settings.autoConvertDownloads
? autoConvertLossySetting(
format: settings.autoConvertFormat,
bitrate: settings.autoConvertBitrate,
)
: settings.tidalHighFormat;
_log.i(
'Lossy 320kbps quality download, converting M4A to $tidalHighFormat...',
);
try {
n.updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.95);
final format = lossyFormatForSetting(tidalHighFormat);
final displayFormat = displayFormatForLossyFormat(format);
final convertedPath = await FFmpegService.convertM4aToLossy(
currentFilePath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: true,
);
if (convertedPath != null) {
filePath = convertedPath;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${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...');
n.updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.99);
await _embedFinalMetadata(
convertedPath,
format: metadataFormatForLossyFormat(format),
rebuildTrack: false,
);
_log.d('Metadata embedded successfully');
} else {
_log.w('M4A to $format conversion failed, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('M4A conversion process failed: $e, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
}
Future<void> _preserveLocalNativeM4a(String currentFilePath) async {
_log.d('M4A/MP4 file detected, preserving native container...');
@@ -1608,21 +1464,16 @@ class _DownloadRun {
/// Final metadata embed shared by every publish branch. Backend-provided
/// genre/label/copyright win over the Deezer extended-metadata lookup.
/// [rebuildTrack] is false only for the lossy-HIGH branches, which embed
/// the track as-is instead of re-merging the download result into it.
Future<String?> _embedFinalMetadata(
String path, {
required String format,
bool writeExternalLrc = true,
bool rebuildTrack = true,
}) async {
final track = rebuildTrack
? buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
)
: trackToDownload;
final track = buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
);
final lrcContent = await n._embedMetadataToFile(
path,
track,
-5
View File
@@ -768,11 +768,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setTidalHighFormat(String format) {
state = state.copyWith(tidalHighFormat: format);
_saveSettings();
}
void setAutoConvertDownloads(bool enabled) {
state = state.copyWith(autoConvertDownloads: enabled);
_saveSettings();
@@ -42,14 +42,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final qualityOptions =
selectedDownloadExtension?.qualityOptions ?? const <QualityOption>[];
final canSelectQuality = qualityOptions.isNotEmpty;
final usesTidalCompatibilityOptions = selectedDownloadService.isNotEmpty
? ref
.read(extensionProvider.notifier)
.downloadProviderReplacesLegacyProvider(
selectedDownloadService,
'tidal',
)
: false;
final nativeWorkerAvailable = Platform.isAndroid && hasDownloadExtensions;
return PopScope(
@@ -113,22 +105,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
.setAudioQuality(quality.id),
showDivider: true,
),
if (usesTidalCompatibilityOptions &&
settings.audioQuality == 'HIGH')
SettingsItem(
icon: Icons.tune,
title: context.l10n.downloadLossyFormat,
subtitle: _getLossyCompatibilityFormatLabel(
context,
settings.tidalHighFormat,
),
onTap: () => _showLossyCompatibilityFormatPicker(
context,
ref,
settings.tidalHighFormat,
),
showDivider: true,
),
],
SettingsSwitchItem(
icon: Icons.auto_fix_high_outlined,
@@ -391,8 +367,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
return context.l10n.qualityHiResFlac;
case 'HI_RES_LOSSLESS':
return context.l10n.qualityHiResFlacMax;
case 'HIGH':
return context.l10n.downloadLossy320;
default:
return quality.label;
}
@@ -409,130 +383,11 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
return context.l10n.qualityHiResFlacSubtitle;
case 'HI_RES_LOSSLESS':
return context.l10n.qualityHiResFlacMaxSubtitle;
case 'HIGH':
return _getLossyCompatibilityFormatLabel(
context,
ref.read(settingsProvider).tidalHighFormat,
);
default:
return quality.description ?? '';
}
}
String _getLossyCompatibilityFormatLabel(
BuildContext context,
String format,
) {
switch (format) {
case 'mp3_320':
return context.l10n.downloadLossyMp3;
case 'aac_320':
return context.l10n.downloadLossyAac;
case 'opus_256':
return context.l10n.downloadLossyOpus256;
case 'opus_128':
return context.l10n.downloadLossyOpus128;
default:
return context.l10n.downloadLossyMp3;
}
}
void _showLossyCompatibilityFormatPicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.downloadLossy320Format,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.downloadLossy320FormatDesc,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: Text(context.l10n.downloadLossyMp3),
subtitle: Text(context.l10n.downloadLossyMp3Subtitle),
trailing: current == 'mp3_320'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('mp3_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.album_outlined),
title: Text(context.l10n.downloadLossyAac),
subtitle: Text(context.l10n.downloadLossyAacSubtitle),
trailing: current == 'aac_320'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('aac_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: Text(context.l10n.downloadLossyOpus256),
subtitle: Text(context.l10n.downloadLossyOpus256Subtitle),
trailing: current == 'opus_256'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('opus_256');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: Text(context.l10n.downloadLossyOpus128),
subtitle: Text(context.l10n.downloadLossyOpus128Subtitle),
trailing: current == 'opus_128'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('opus_128');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showAutoConvertFormatPicker(
BuildContext context,
WidgetRef ref,
@@ -21,7 +21,6 @@ class DownloadRequestPayload {
final bool embedLyrics;
final bool embedReplayGain;
final bool postProcessingEnabled;
final String tidalHighFormat;
final bool autoConvertDownloads;
final String autoConvertFormat;
final String autoConvertBitrate;
@@ -84,7 +83,6 @@ class DownloadRequestPayload {
this.embedLyrics = true,
this.embedReplayGain = false,
this.postProcessingEnabled = false,
this.tidalHighFormat = 'mp3_320',
this.autoConvertDownloads = false,
this.autoConvertFormat = 'mp3',
this.autoConvertBitrate = '320k',
@@ -149,7 +147,6 @@ class DownloadRequestPayload {
'embed_lyrics': embedLyrics,
'embed_replaygain': embedReplayGain,
'post_processing_enabled': postProcessingEnabled,
'tidal_high_format': tidalHighFormat,
'auto_convert_downloads': autoConvertDownloads,
'auto_convert_format': autoConvertFormat,
'auto_convert_bitrate': autoConvertBitrate,
@@ -218,7 +215,6 @@ class DownloadRequestPayload {
embedLyrics: embedLyrics,
embedReplayGain: embedReplayGain,
postProcessingEnabled: postProcessingEnabled,
tidalHighFormat: tidalHighFormat,
autoConvertDownloads: autoConvertDownloads,
autoConvertFormat: autoConvertFormat,
autoConvertBitrate: autoConvertBitrate,
-54
View File
@@ -717,60 +717,6 @@ class FFmpegService {
}
}
static Future<String?> convertM4aToLossy(
String inputPath, {
required String format,
String? bitrate,
bool deleteOriginal = true,
}) async {
final normalizedFormat = format.toLowerCase();
String bitrateValue = normalizedFormat == 'opus' ? '128k' : '320k';
if (bitrate != null && bitrate.contains('_')) {
final parts = bitrate.split('_');
if (parts.length == 2) {
bitrateValue = '${parts[1]}k';
}
}
final extension = switch (normalizedFormat) {
'opus' => '.opus',
'aac' || 'm4a' => '.m4a',
_ => '.mp3',
};
final outputPlan = await _conversionOutputPlan(
inputPath,
extension,
deleteOriginal: deleteOriginal,
);
final outputPath = outputPlan.workingPath;
String command;
if (normalizedFormat == 'opus') {
command =
'-v error -hide_banner -i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
} else if (normalizedFormat == 'aac' || normalizedFormat == 'm4a') {
command =
'-v error -hide_banner -i "$inputPath" -codec:a aac -b:a $bitrateValue -map 0:a -f mp4 "$outputPath" -y';
} else {
command =
'-v error -hide_banner -i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y';
}
final result = await _execute(command);
if (result.success) {
return _finalizeConversionOutput(
plan: outputPlan,
inputPath: inputPath,
deleteOriginal: deleteOriginal,
);
}
_log.e('M4A to $normalizedFormat conversion failed: ${result.output}');
await _cleanupConversionOutput(outputPlan);
return null;
}
static Future<String?> decryptWithDescriptor({
required String inputPath,
required DownloadDecryptionDescriptor descriptor,
-26
View File
@@ -304,23 +304,6 @@ String resolveQualityVariantFilename({
);
}
String lossyFormatForSetting(String value) {
final normalized = value.trim().toLowerCase();
if (normalized.startsWith('opus')) return 'opus';
if (normalized.startsWith('aac') || normalized.startsWith('m4a')) {
return 'aac';
}
return 'mp3';
}
String lossyExtensionForFormat(String format) {
return switch (format) {
'opus' => '.opus',
'aac' => '.m4a',
_ => '.mp3',
};
}
String metadataFormatForLossyFormat(String format) {
return format == 'aac' ? 'm4a' : format;
}
@@ -348,15 +331,6 @@ int autoConvertBitrateKbps(String value) {
return int.parse(normalizeAutoConvertBitrate(value).replaceAll('k', ''));
}
String autoConvertLossySetting({
required String format,
required String bitrate,
}) {
final normalizedFormat = normalizeAutoConvertFormat(format);
final normalizedBitrate = autoConvertBitrateKbps(bitrate);
return '${normalizedFormat}_$normalizedBitrate';
}
String autoConvertFormatLabel(String format) {
return switch (normalizeAutoConvertFormat(format)) {
'aac' => 'M4A (AAC)',
-4
View File
@@ -93,10 +93,6 @@ void main() {
expect(normalizeAutoConvertFormat('unexpected'), 'mp3');
expect(normalizeAutoConvertBitrate('256 kbps'), '256k');
expect(normalizeAutoConvertBitrate('999k'), '320k');
expect(
autoConvertLossySetting(format: 'opus', bitrate: '192k'),
'opus_192',
);
});
test('skips only an output that already matches format and bitrate', () {
+24 -2
View File
@@ -23,6 +23,30 @@ import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
void main() {
test(
'retired quality conversion settings do not enable automatic conversion',
() {
for (final format in ['mp3_320', 'aac_320', 'opus_256', 'opus_128']) {
final settings = AppSettings.fromJson({
'audioQuality': 'HIGH',
'tidalHighFormat': format,
});
expect(settings.audioQuality, 'HIGH');
expect(settings.autoConvertDownloads, isFalse);
expect(settings.toJson().containsKey('tidalHighFormat'), isFalse);
}
final settings = AppSettings.fromJson({
'tidalHighFormat': 'mp3_320',
'autoConvertDownloads': true,
'autoConvertFormat': 'opus',
'autoConvertBitrate': '192k',
});
expect(settings.autoConvertDownloads, isTrue);
expect(settings.autoConvertFormat, 'opus');
expect(settings.autoConvertBitrate, '192k');
},
);
group('Finalized SAF audio names', () {
for (final extension in ['mp3', 'opus', 'flac', 'ogg', 'm4a', 'mp4']) {
test(
@@ -1112,7 +1136,6 @@ void main() {
embedLyrics: false,
embedReplayGain: true,
postProcessingEnabled: true,
tidalHighFormat: 'opus_256',
autoConvertDownloads: true,
autoConvertFormat: 'opus',
autoConvertBitrate: '192k',
@@ -1173,7 +1196,6 @@ void main() {
'embed_lyrics': false,
'embed_replaygain': true,
'post_processing_enabled': true,
'tidal_high_format': 'opus_256',
'auto_convert_downloads': true,
'auto_convert_format': 'opus',
'auto_convert_bitrate': '192k',