mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-10 14:38:43 +02:00
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:
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user