fix(download): probe M4A streams before preserving requested FLAC

Provider and decryption extensions describe the downloaded container, not
its audio codec. A decrypted M4A/MP4 can still carry FLAC, so a requested
FLAC output now reaches the codec probe instead of being preserved early.
Lossy streams keep their native container and still receive metadata.
This commit is contained in:
zarzet committed 2026-10-13 18:27:16 +07:00
1 parent 65dbce27f5
commit e3b69a25ef
7 files changed
+341 -50

No files matched your search

@@ -202,6 +202,60 @@ class NativeDownloadContainerTest {
}
}
@Test
fun requestedFlacUsesTheActualStreamDespiteM4aProviderAndDecryptionHints() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val root = File(context.cacheDir, "lossless-container-${System.nanoTime()}").apply { mkdirs() }
try {
for ((codec, requested, expected) in listOf(
Triple("flac", ".flac", "flac"),
Triple("flac", ".m4a", "m4a"),
Triple("aac", ".flac", "m4a"),
)) {
val input = File(root, "track-$codec-$expected.m4a")
val fixture = NativeDownloadFinalizer.runFFmpegArguments(arrayOf(
"-v", "error", "-f", "lavfi", "-i", "sine=frequency=997:sample_rate=48000",
"-t", "0.3", "-c:a", codec, "-strict", "-2", "-f", "mp4", input.path,
))
assertTrue(fixture.second, fixture.first)
assertTrue(isMP4ContainerFile(input.path))
fun decodedAudio(file: File, name: String): ByteArray {
val pcm = File(root, name)
val decoded = NativeDownloadFinalizer.runFFmpegArguments(arrayOf(
"-v", "error", "-i", file.path, "-map", "0:a:0",
"-c:a", "pcm_s32le", "-f", "s32le", "-y", pcm.path,
))
assertTrue(decoded.second, decoded.first)
return pcm.readBytes()
}
val before = decodedAudio(input, "before.pcm")
val request = JSONObject()
.put("contract_version", 1).put("item_id", "example-track")
.put("service", "example-provider").put("track_name", "Lossless container test")
.put("artist_name", "Example artist").put("album_name", "Example album")
.put("quality", "LOSSLESS").put("storage_mode", "app")
.put("output_ext", requested).put("embed_metadata", true)
val result = NativeDownloadFinalizer.finalize(
context, "example-track", request.toString(), "{}",
JSONObject().put("success", true).put("file_path", input.path)
.put("file_name", input.name).put("audio_codec", "m4a")
.put("output_extension", ".m4a")
.put("decryption", JSONObject().put("output_extension", ".m4a")),
"{\"save_download_history\":false}",
)
assertTrue(result.toString(), result.getBoolean("success"))
assertTrue(result.toString(), result.getBoolean("native_finalized"))
val output = File(result.getString("file_path"))
assertEquals(expected, output.extension)
assertEquals(expected == "flac", NativeDownloadFinalizer.isNativeFlacFile(output.path))
assertEquals(codec, NativeDownloadFinalizer.probePrimaryAudioCodec(output.path))
assertTrue("$codec -> $expected changed decoded audio", before.contentEquals(decodedAudio(output, "after.pcm")))
val tags = JSONObject(backend.readFileMetadata(output.path, output.name))
assertEquals("Lossless container test", tags.getString("title"))
}
} finally { root.deleteRecursively() }
}
@Test
fun misnamedMp4IsTaggedAndPublishedAsM4aWithoutOverwritingExistingAudio() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
@@ -646,9 +646,9 @@ object NativeDownloadFinalizer {
shouldCancel: () -> Boolean,
) {
if (outputExt(input) != ".flac") return
val requestedDecryptionExt = requestedDecryptionOutputExt(input)
val forceContainerConversion = shouldForceContainerConversion(input, state)
if (!forceContainerConversion && requestedDecryptionExt.isNotBlank() && requestedDecryptionExt != ".flac") return
// The decrypted container may still contain FLAC. Only the measured
// stream codec can decide whether an M4A/MP4 should be preserved.
val mayNeedContainerConversion = forceContainerConversion ||
looksLikeM4a(state.filePath, state.fileName) ||
isMP4ContainerFile(state.filePath) ||
@@ -242,11 +242,3 @@ internal fun NativeDownloadFinalizer.isNativeFlacFile(path: String): Boolean {
false
}
}
internal fun NativeDownloadFinalizer.requestedDecryptionOutputExt(input: NativeDownloadFinalizer.FinalizeInput): String {
val descriptor = input.result.optJSONObject("decryption")
return normalizeExt(
descriptor?.optString("output_extension", "")
?.ifBlank { input.result.optString("output_extension", "") }
)
}
@@ -0,0 +1,216 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
// ignore: depend_on_referenced_packages
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class _Paths extends PathProviderPlatform {
_Paths(this._path);
final String _path;
@override
Future<String?> getApplicationDocumentsPath() async => _path;
}
class _Settings extends SettingsNotifier {
_Settings(this._path);
final String _path;
@override
AppSettings build() => AppSettings(
downloadDirectory: _path,
nativeDownloadWorkerEnabled: false,
autoFallback: false,
embedLyrics: false,
embedReplayGain: false,
saveDownloadHistory: false,
);
}
class _Extensions extends ExtensionNotifier {
@override
ExtensionState build() => const ExtensionState(
isInitialized: true,
extensions: [
Extension(
id: 'example.audio',
name: 'example.audio',
displayName: 'Example',
version: '1',
description: '',
enabled: true,
status: 'loaded',
hasDownloadProvider: true,
skipMetadataEnrichment: true,
skipLyrics: true,
),
],
);
}
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'foreground queue finalizes lossless M4A without provider hints',
(tester) async {
final root = await Directory.systemTemp.createTemp('lossless-queue-');
final originalPaths = PathProviderPlatform.instance;
PathProviderPlatform.instance = _Paths(root.path);
SharedPreferences.setMockInitialValues({
'app_state_migrated_queue_to_sqlite_v1': true,
});
final db = await AppStateDatabase.instance.database;
final output = await Directory('${root.path}/output').create();
final sources = await Directory('${root.path}/extensions').create();
final data = await Directory('${root.path}/data').create();
final scope = ProviderContainer(
overrides: [
settingsProvider.overrideWith(() => _Settings(output.path)),
extensionProvider.overrideWith(_Extensions.new),
],
);
try {
await PlatformBridge.initExtensionSystem(
sources.path,
data.path,
masterKey: base64Encode(List<int>.filled(32, 1)),
lyricsProviders: const [],
lyricsFetchOptions: const {},
allowedDirectories: [output.path],
);
final samples = <String, String>{};
for (final codec in ['flac', 'aac', 'alac']) {
final source = File('${root.path}/$codec.m4a');
final session = await FFmpegKit.executeWithArguments([
'-v',
'error',
'-f',
'lavfi',
'-i',
'sine=frequency=997:sample_rate=48000',
'-t',
'0.3',
'-c:a',
codec,
'-strict',
'-2',
'-f',
'mp4',
source.path,
]);
expect(
ReturnCode.isSuccess(await session.getReturnCode()),
true,
reason: await session.getOutput(),
);
samples[codec] = base64Encode(await source.readAsBytes());
}
final extension = await Directory(
'${sources.path}/example.audio',
).create();
await File('${extension.path}/manifest.json').writeAsString(
jsonEncode({
'name': 'example.audio',
'version': '1',
'description': 'Container fixture',
'type': ['download_provider'],
'permissions': {'file': true},
'skipMetadataEnrichment': true,
}),
);
await File('${extension.path}/index.js').writeAsString('''
const samples = ${jsonEncode(samples)};
registerExtension({
checkAvailability(track) { return { available: true, track_id: track.id }; },
download(trackId, quality, outputPath) {
const codec = quality === 'ALAC' ? 'alac' : (quality === 'LOSSY_FALLBACK' ? 'aac' : 'flac');
const path = outputPath.slice(0, outputPath.lastIndexOf('.')) + '.m4a';
file.writeBytes(path, samples[codec]);
return { success: true, file_path: path, output_extension: '.m4a' };
}
});
''');
await PlatformBridge.loadExtensionsFromDir(sources.path);
await PlatformBridge.setExtensionEnabled('example.audio', true);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final queue = scope.read(downloadQueueProvider.notifier);
queue.pauseQueue(persistAcrossRestarts: false);
await queue.flushQueuePersistence();
for (final (quality, codec, extension) in [
('LOSSLESS', 'flac', '.flac'),
('LOSSY_FALLBACK', 'aac', '.m4a'),
('ALAC', 'alac', '.m4a'),
]) {
final done = Completer<DownloadItem>();
final id = queue.addToQueue(
Track(
id: 'example.audio:$codec',
source: 'example.audio',
name: 'Track $codec',
artistName: 'Example artist',
albumName: 'Example album',
duration: 1,
),
'example.audio',
qualityOverride: quality,
);
DownloadItem? finished;
final subscription = scope.listen(downloadQueueProvider, (_, next) {
final item = next.items.where((item) => item.id == id).firstOrNull;
if (item != null &&
(item.status == DownloadStatus.completed ||
item.status == DownloadStatus.failed)) {
finished = item;
}
// Successful items leave the queue before processing becomes idle.
if (!next.isProcessing && finished != null && !done.isCompleted) {
done.complete(finished);
}
});
try {
queue.resumeQueue();
final item = await done.future.timeout(const Duration(seconds: 45));
expect(item.status, DownloadStatus.completed, reason: item.error);
expect(item.filePath, endsWith(extension));
expect(
await FFmpegService.probePrimaryAudioCodec(item.filePath!),
codec,
);
expect(
await FFmpegService.isNativeFlacFile(item.filePath!),
codec == 'flac',
);
final tags = await PlatformBridge.readFileMetadata(item.filePath!);
expect(tags['title'], 'Track $codec');
expect(tags['artist'], 'Example artist');
} finally {
subscription.close();
}
}
await queue.flushQueuePersistence();
} finally {
scope.dispose();
await PlatformBridge.cleanupExtensions();
await db.close();
PathProviderPlatform.instance = originalPaths;
await root.delete(recursive: true);
}
},
);
}
@@ -116,6 +116,7 @@ class _DownloadRun {
String actualQuality = '';
String? resultOutputExt;
bool shouldPreserveNativeM4a = false;
bool forceContainerConversion = false;
/// Filled by the SAF embed op from the local temp so the final quality
/// probe doesn't have to copy the published file back out of SAF.
@@ -650,13 +651,16 @@ class _DownloadRun {
final resultIsKnownLossyAudio =
isLossyAudioFormat(resultAudioFormat) &&
!isInconclusiveAudioCodec(resultAudioFormat);
final requiresContainerConversion =
forceContainerConversion =
result.requiresContainerConversion ||
(!resultIsKnownLossyAudio &&
n._shouldRequestContainerConversion(actualService, safOutputExt));
final preferredOutputExt = n._extensionPreferredOutputExt(actualService);
shouldPreserveNativeM4a =
!requiresContainerConversion &&
// M4A/MP4 may contain FLAC. A requested FLAC output must reach the
// codec probe even if the extension did not ask for conversion.
safOutputExt != '.flac' &&
!forceContainerConversion &&
(resultOutputExt == '.m4a' ||
resultOutputExt == '.mp4' ||
preferredOutputExt == '.m4a' ||
@@ -1141,8 +1145,7 @@ class _DownloadRun {
}
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.
// Preserve explicitly requested M4A/MP4 output, including spatial audio.
_log.d('M4A/MP4 file detected (SAF), preserving native container...');
final preserveExt = currentFilePath.toLowerCase().endsWith('.mp4')
? '.mp4'
@@ -1204,7 +1207,7 @@ class _DownloadRun {
codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath);
final shouldAttemptConversion =
shouldAttemptLosslessContainerConversion(
forceConversion: true,
forceConversion: forceContainerConversion,
probedCodec: codec,
);
if (!shouldAttemptConversion) {
@@ -1216,6 +1219,13 @@ class _DownloadRun {
final preserveExt = resultOutputExt == '.mp4' ? '.mp4' : '.m4a';
final newFileName = '${safBaseName ?? 'track'}$preserveExt';
producedFileName = newFileName;
if (metadataEmbeddingEnabled) {
await _embedFinalMetadata(
tempPath,
format: 'm4a',
writeExternalLrc: false,
);
}
return (tempPath, newFileName);
} else if (isAlreadyNativeFlac) {
_log.d(
@@ -1267,7 +1277,9 @@ class _DownloadRun {
if (newUri != null) {
filePath = newUri;
finalSafFileName = producedFileName;
_markFinalOutputAsFlac();
if (branch == 'nativeFlac' || branch == 'convert') {
_markFinalOutputAsFlac();
}
} else if (branch == 'nativeFlac') {
_log.w('Failed to write native FLAC to SAF');
} else if (branch == 'convert') {
@@ -1339,7 +1351,7 @@ class _DownloadRun {
await FFmpegService.isNativeFlacFile(currentFilePath);
final shouldAttemptConversion =
shouldAttemptLosslessContainerConversion(
forceConversion: true,
forceConversion: forceContainerConversion,
probedCodec: codec,
);
if (!shouldAttemptConversion) {
@@ -1347,6 +1359,7 @@ class _DownloadRun {
'Preserving native container; audio codec is ${codec ?? 'unknown'}, '
'no FLAC container conversion needed.',
);
await _preserveLocalNativeM4a(currentFilePath);
} else if (isAlreadyNativeFlac) {
_log.d(
'Native FLAC payload detected; ensuring .flac '
+2 -16
View File
@@ -34,13 +34,6 @@ class DownloadContainerFinalizer {
}) async {
if (requestedExtension != '.flac') return filePath;
forceConversion = forceConversion || result.requiresContainerConversion;
final reportedCodec = normalizeAudioFormatValue(result.audioCodec);
if (!forceConversion && isLossyAudioFormat(reportedCodec)) {
debug(
'Native-worker output is $reportedCodec; preserving native container.',
);
return filePath;
}
final outputExtension = result.outputExtension(path: filePath);
final lowerPath = filePath.toLowerCase();
final isSafSource = filePath.startsWith('content://');
@@ -50,15 +43,8 @@ class DownloadContainerFinalizer {
outputExtension == '.m4a' ||
outputExtension == '.mp4';
if (!forceConversion && !pathLooksLikeM4a && !isSafSource) return filePath;
final decryptionExtension = result.decryption?.normalizedOutputExtension;
if (!forceConversion &&
decryptionExtension != null &&
decryptionExtension != '.flac') {
debug(
'Native-worker decrypted output requested $decryptionExtension; preserving native container.',
);
return filePath;
}
// Provider/decryption extensions describe the input container, not its
// audio codec. Inspect the stream before preserving an M4A/MP4 download.
bool shouldConvert(String? codec) {
if (shouldAttemptLosslessContainerConversion(
forceConversion: forceConversion,
+47 -17
View File
@@ -60,27 +60,47 @@ void main() {
embedMetadata: embed,
);
test(
'native output and explicit non-FLAC decryption bypass conversion',
() async {
for (final raw in <Map<String, dynamic>>[
{'audio_codec': 'aac'},
{'decryption_key': 'abcd', 'output_extension': '.mp4'},
]) {
expect(
await finalize(finalizer(), DownloadResult.fromMap(raw)),
'/Song.m4a',
);
}
test('explicit non-FLAC requests bypass conversion', () async {
for (final extension in ['.m4a', '.mp4', '.opus']) {
expect(
await finalize(
finalizer(),
DownloadResult.fromMap({}),
extension: '.opus',
DownloadResult.fromMap({'audio_codec': 'flac'}),
extension: extension,
force: true,
),
'/Song.m4a',
);
}
});
test(
'container hints cannot override measured FLAC or lossy audio',
() async {
final source = await File(
'${directory.path}/Song.m4a',
).writeAsString('MP4');
for (final codec in ['flac', 'aac', 'eac3']) {
for (final raw in <Map<String, dynamic>>[
{'audio_codec': 'm4a'},
{'audio_codec': 'aac'},
{'decryption_key': 'abcd', 'output_extension': '.mp4'},
]) {
final result = DownloadResult.fromMap(raw);
var converted = false;
final stage = finalizer(
probe: (_) async => codec,
convert: (_) async {
converted = true;
return '${directory.path}/Song.flac';
},
);
final path = await finalize(stage, result, path: source.path);
expect(converted, codec == 'flac', reason: '$codec / $raw');
expect(path, converted ? '${directory.path}/Song.flac' : source.path);
if (converted) expect(result.outputExtension(), '.flac');
}
}
},
);
@@ -146,7 +166,12 @@ void main() {
return output.path;
},
);
final result = DownloadResult.fromMap({'file_name': 'Song.m4a'});
final result = DownloadResult.fromMap({
'file_name': 'Song.m4a',
'audio_codec': 'm4a',
'decryption_key': 'abcd',
'output_extension': '.mp4',
});
final path = await finalize(
stage,
result,
@@ -192,7 +217,12 @@ void main() {
},
deleteSource: (_) async => deleted = true,
);
final result = DownloadResult.fromMap({'file_name': 'Song.m4a'});
final result = DownloadResult.fromMap({
'file_name': 'Song.m4a',
'audio_codec': 'm4a',
'decryption_key': 'abcd',
'output_extension': '.mp4',
});
final stage = finalizer(
probe: (_) async => 'flac',
replaceSaf: replacer.replace,
@@ -206,7 +236,7 @@ void main() {
);
expect(path, published ? 'content://final' : null);
expect(result.fileName, published ? 'Song.flac' : 'Song.m4a');
expect(result.audioCodec, published ? 'flac' : null);
expect(result.audioCodec, published ? 'flac' : 'm4a');
expect(deleted, published);
expect(await directory.list().length, 0);
}