mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-15 22:35:36 +02:00
fix(download): remove legacy HIGH quality conversion
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user