fix: probe codec to avoid fake FLAC upscale from lossy sources

The native-worker container conversion used to remux any .m4a download to .flac whenever the user requested a FLAC output, which silently upgraded lossy AAC streams to a FLAC container without adding any information. Guard the remux with an FFmpeg/FFprobe codec probe on both the Dart and Kotlin finalization paths so only genuinely lossless sources (ALAC, WavPack, PCM, etc.) are converted, and expose a requires_container_conversion capability so extensions can force conversion when they know the source is lossless.
This commit is contained in:
zarzet
2026-05-09 20:51:40 +07:00
parent 20ac6b2cd4
commit 939407675b
3 changed files with 169 additions and 18 deletions
@@ -503,13 +503,21 @@ object NativeDownloadFinalizer {
if (requestQuality(input) == "HIGH" || outputExt(input) != ".flac") return
val requestedDecryptionExt = requestedDecryptionOutputExt(input)
if (requestedDecryptionExt.isNotBlank() && requestedDecryptionExt != ".flac") return
if (!looksLikeM4a(state.filePath, state.fileName) && !shouldForceContainerConversion(input, state)) return
val mayNeedContainerConversion = shouldForceContainerConversion(input, state) ||
looksLikeM4a(state.filePath, state.fileName) ||
state.filePath.startsWith("content://")
if (!mayNeedContainerConversion) return
val localInput = materializeForFFmpeg(context, input, state)
val deleteLocalInput = state.filePath.startsWith("content://")
val output = buildOutputPath(localInput, ".flac")
var adoptedOutput = false
try {
val codec = probePrimaryAudioCodec(localInput, shouldCancel)
if (!isLosslessAudioCodec(codec) || codec == "flac") {
Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}")
return
}
val result = runFFmpeg(
"-v error -xerror -i ${q(localInput)} -c:a flac -compression_level 8 ${q(output)} -y",
shouldCancel,
@@ -1317,19 +1325,34 @@ object NativeDownloadFinalizer {
private fun shouldForceContainerConversion(input: FinalizeInput, state: FinalizeState): Boolean {
if (input.result.optBoolean("requires_container_conversion", false)) return true
if (input.request.optBoolean("requires_container_conversion", false)) return true
return false
}
val actualExt = normalizeExt(
input.result.optString("actual_extension", "")
.ifBlank { input.result.optString("output_extension", "") }
private fun probePrimaryAudioCodec(path: String, shouldCancel: () -> Boolean = { false }): String {
val result = runFFmpeg("-hide_banner -nostdin -i ${q(path)} -map 0:a:0 -frames:a 1 -f null -", shouldCancel)
val output = result.second
val match = Regex("Audio:\\s*([^,\\s]+)", RegexOption.IGNORE_CASE).find(output)
return match?.groupValues?.getOrNull(1)
?.trim()
?.lowercase(Locale.ROOT)
?.replace('-', '_')
.orEmpty()
}
private fun isLosslessAudioCodec(codec: String): Boolean {
val normalized = codec.trim().lowercase(Locale.ROOT).replace('-', '_')
if (normalized.isBlank()) return false
if (normalized.startsWith("pcm_")) return true
return normalized in setOf(
"alac",
"flac",
"wavpack",
"ape",
"tta",
"mlp",
"truehd",
"shorten"
)
if (actualExt == ".m4a" || actualExt == ".mp4") return true
val container = input.result.optString("actual_container", "")
.ifBlank { input.result.optString("container", "") }
.trim()
.lowercase(Locale.ROOT)
.removePrefix(".")
return container == "m4a" || container == "mp4" || container == "mov" || container == "aac"
}
private fun requestedDecryptionOutputExt(input: FinalizeInput): String {
+99 -6
View File
@@ -3029,6 +3029,47 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
String? _normalizeAudioExt(Object? value) {
final raw = value?.toString().trim().toLowerCase();
if (raw == null || raw.isEmpty) return null;
final normalized = raw.startsWith('.') ? raw : '.$raw';
const allowed = {'.flac', '.m4a', '.mp4', '.mp3', '.opus', '.ogg', '.aac'};
return allowed.contains(normalized) ? normalized : null;
}
String? _downloadResultOutputExt(
Map<String, dynamic> result, {
String? filePath,
}) {
final explicit =
_normalizeAudioExt(result['actual_extension']) ??
_normalizeAudioExt(result['output_extension']) ??
_normalizeAudioExt(result['actual_container']) ??
_normalizeAudioExt(result['container']);
if (explicit != null) return explicit;
for (final candidate in <String?>[
result['file_name'] as String?,
filePath,
result['file_path'] as String?,
]) {
if (candidate == null) continue;
final lower = candidate.trim().toLowerCase();
for (final ext in const [
'.flac',
'.m4a',
'.mp4',
'.mp3',
'.opus',
'.ogg',
'.aac',
]) {
if (lower.endsWith(ext)) return ext;
}
}
return null;
}
Future<String?> _getSafMimeType(String uri) async {
try {
final stat = await PlatformBridge.safStat(uri);
@@ -6049,6 +6090,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (context.quality == 'HIGH' || context.outputExt != '.flac') {
return filePath;
}
final requiresContainerConversion =
result['requires_container_conversion'] == true ||
result['requiresContainerConversion'] == true;
final resultOutputExt = _downloadResultOutputExt(
result,
filePath: filePath,
);
final lowerPath = filePath.toLowerCase();
final resultFileName = (result['file_name'] as String?)?.toLowerCase();
final mayNeedContainerConversion =
requiresContainerConversion ||
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.mp4') ||
resultOutputExt == '.m4a' ||
resultOutputExt == '.mp4' ||
isContentUri(filePath);
if (!mayNeedContainerConversion) {
return filePath;
}
final requestedDecryptionExt =
DownloadDecryptionDescriptor.fromDownloadResult(
result,
@@ -6059,15 +6119,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
return filePath;
}
final lowerPath = filePath.toLowerCase();
final resultFileName = (result['file_name'] as String?)?.toLowerCase();
final looksLikeM4a =
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.mp4') ||
resultOutputExt == '.m4a' ||
resultOutputExt == '.mp4' ||
(resultFileName != null &&
(resultFileName.endsWith('.m4a') ||
resultFileName.endsWith('.mp4')));
if (!looksLikeM4a && !isContentUri(filePath)) {
if (!requiresContainerConversion &&
!looksLikeM4a &&
!isContentUri(filePath)) {
return filePath;
}
@@ -6097,6 +6159,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? flacPath;
try {
final codec = await FFmpegService.probePrimaryAudioCodec(tempPath);
if (!FFmpegService.isLosslessAudioCodec(codec) || codec == 'flac') {
_log.d(
'Preserving native container; audio codec is ${codec ?? 'unknown'}, not a lossless source needing FLAC conversion.',
);
return filePath;
}
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
if (flacPath == null) {
return null;
@@ -6133,6 +6202,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
final codec = await FFmpegService.probePrimaryAudioCodec(filePath);
if (!FFmpegService.isLosslessAudioCodec(codec) || codec == 'flac') {
_log.d(
'Preserving native container; audio codec is ${codec ?? 'unknown'}, not a lossless source needing FLAC conversion.',
);
return filePath;
}
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
if (flacPath == null) {
return null;
@@ -7010,6 +7086,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
safRelativeDir: relativeDir,
safFileName: fileName,
safOutputExt: outputExt,
requiresContainerConversion:
outputExt == '.flac' &&
_extensionRequiresNativeContainerConversion(item.service),
songLinkRegion: settings.songLinkRegion,
);
@@ -7120,8 +7199,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final actualService =
((result['service'] as String?)?.toLowerCase()) ??
item.service.toLowerCase();
final resultOutputExt = _downloadResultOutputExt(
result,
filePath: filePath,
);
final preferredOutputExt = _extensionPreferredOutputExt(actualService);
final shouldPreserveNativeM4a =
resultOutputExt == '.m4a' ||
resultOutputExt == '.mp4' ||
preferredOutputExt == '.m4a' ||
preferredOutputExt == '.mp4' ||
_extensionPreservesNativeOutputExt(actualService, '.m4a') ||
@@ -7258,10 +7343,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
filePath != null &&
(filePath.endsWith('.m4a') ||
filePath.endsWith('.mp4') ||
resultOutputExt == '.m4a' ||
resultOutputExt == '.mp4' ||
(mimeType != null && mimeType.contains('mp4')));
final isFlacFile =
filePath != null &&
(filePath.endsWith('.flac') ||
resultOutputExt == '.flac' ||
(mimeType != null && mimeType.contains('flac')));
final shouldForceTidalSafM4aHandling =
!wasExisting &&
@@ -7756,10 +7844,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
!isM4aFile &&
!wasExisting) {
final currentFilePath = filePath;
final isOpusFile = filePath.endsWith('.opus');
final isMp3File = filePath.endsWith('.mp3');
final isOpusFile =
filePath.endsWith('.opus') ||
filePath.endsWith('.ogg') ||
resultOutputExt == '.opus' ||
resultOutputExt == '.ogg';
final isMp3File =
filePath.endsWith('.mp3') || resultOutputExt == '.mp3';
final ext = isOpusFile
? '.opus'
? (resultOutputExt == '.ogg' ? '.ogg' : '.opus')
: isMp3File
? '.mp3'
: '.flac';
+35
View File
@@ -5,6 +5,7 @@ import 'dart:typed_data';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart';
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
import 'package:path_provider/path_provider.dart';
@@ -248,6 +249,40 @@ class FFmpegService {
}
}
static Future<String?> probePrimaryAudioCodec(String filePath) async {
try {
final session = await FFprobeKit.getMediaInformation(filePath);
final info = session.getMediaInformation();
if (info == null) return null;
for (final stream in info.getStreams()) {
final props = stream.getAllProperties() ?? const <String, dynamic>{};
if (props['codec_type']?.toString() != 'audio') continue;
final codec = props['codec_name']?.toString().trim().toLowerCase();
return codec == null || codec.isEmpty ? null : codec;
}
} catch (e) {
_log.w('Audio codec probe failed for $filePath: $e');
}
return null;
}
static bool isLosslessAudioCodec(String? codec) {
final normalized = codec?.trim().toLowerCase().replaceAll('-', '_') ?? '';
if (normalized.isEmpty) return false;
if (normalized.startsWith('pcm_')) return true;
return const {
'alac',
'flac',
'wavpack',
'ape',
'tta',
'mlp',
'truehd',
'shorten',
}.contains(normalized);
}
static Future<String?> convertM4aToFlac(String inputPath) async {
final outputPath = _buildOutputPath(inputPath, '.flac');