mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
fix(downloads): preserve filenames after decryption and conversion
This commit is contained in:
1 parent
bbc2c96971
commit
772bd97f7d
7 files changed
+347
-130
No files matched your search
@@ -613,7 +613,7 @@ object NativeDownloadFinalizer {
|
||||
)
|
||||
replaceStatePath(context, input, state, decryptedPath, deleteOld = true)
|
||||
} finally {
|
||||
if (successPath == null) {
|
||||
if (successPath == null && outputPath != localInput) {
|
||||
File(outputPath).delete()
|
||||
}
|
||||
if (originalPath != successPath && originalPath.startsWith(context.cacheDir.absolutePath)) {
|
||||
@@ -694,15 +694,13 @@ object NativeDownloadFinalizer {
|
||||
if (!conversion.first || !File(stagedOutput).exists()) {
|
||||
throw IllegalStateException("automatic conversion failed: ${conversion.second}")
|
||||
}
|
||||
val metadataFormat = if (target.codec == "aac") "m4a" else target.codec
|
||||
embedBasicMetadata(context, stagedOutput, input, metadataFormat)
|
||||
if (!promoteStagedConversion(stagedOutput, output)) {
|
||||
throw IllegalStateException("failed to promote automatic conversion output")
|
||||
}
|
||||
|
||||
val metadataFormat = if (target.codec == "aac") "m4a" else target.codec
|
||||
embedBasicMetadata(context, output, input, metadataFormat)
|
||||
|
||||
if (sameLocalExtension) {
|
||||
replaceSameFormatLocalOutput(localInput, output)
|
||||
state.filePath = localInput
|
||||
state.fileName = File(localInput).name
|
||||
} else {
|
||||
@@ -712,7 +710,7 @@ object NativeDownloadFinalizer {
|
||||
} finally {
|
||||
if (!adoptedOutput) {
|
||||
File(stagedOutput).delete()
|
||||
File(output).delete()
|
||||
if (output != localInput) File(output).delete()
|
||||
}
|
||||
if (sourceWasSaf) File(localInput).delete()
|
||||
}
|
||||
@@ -724,24 +722,6 @@ object NativeDownloadFinalizer {
|
||||
state.audioCodec = target.codec
|
||||
}
|
||||
|
||||
private fun replaceSameFormatLocalOutput(inputPath: String, convertedPath: String) {
|
||||
val source = File(inputPath)
|
||||
val converted = File(convertedPath)
|
||||
val backup = File("$inputPath.spotiflac-backup-${System.nanoTime()}")
|
||||
if (!source.renameTo(backup)) {
|
||||
throw IllegalStateException("failed to stage original for same-format conversion")
|
||||
}
|
||||
try {
|
||||
if (!converted.renameTo(source)) {
|
||||
throw IllegalStateException("failed to publish same-format conversion")
|
||||
}
|
||||
backup.delete()
|
||||
} catch (e: Exception) {
|
||||
if (!source.exists()) backup.renameTo(source)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun uniqueAutoConversionOutputPath(inputPath: String, extension: String): String {
|
||||
val source = File(inputPath)
|
||||
val requested = File(source.parentFile, "${source.nameWithoutExtension}$extension")
|
||||
@@ -819,13 +799,12 @@ object NativeDownloadFinalizer {
|
||||
execute = { arguments -> runFFmpegArguments(arguments, shouldCancel) },
|
||||
checkCancelled = { checkCancelled(shouldCancel) },
|
||||
)
|
||||
// Tag the staged file before replacing a same-suffix source.
|
||||
embedBasicMetadata(context, stagedOutput, input, "flac")
|
||||
if (!promoteStagedConversion(stagedOutput, output)) {
|
||||
throw IllegalStateException("failed to publish container conversion output")
|
||||
}
|
||||
createdOutput = true
|
||||
// Keep metadata failures before adoption so the source survives
|
||||
// and the unsuccessful output is removed by the local cleanup.
|
||||
embedBasicMetadata(context, output, input, "flac")
|
||||
replaceStatePath(context, input, state, output, deleteOld = true)
|
||||
adoptedOutput = true
|
||||
} finally {
|
||||
|
||||
@@ -171,19 +171,27 @@ internal fun NativeDownloadFinalizer.stagedConversionPath(finalPath: String): St
|
||||
|
||||
internal fun NativeDownloadFinalizer.promoteStagedConversion(stagedPath: String, finalPath: String): Boolean {
|
||||
val staged = File(stagedPath)
|
||||
if (!staged.isFile || staged.length() == 0L) return false
|
||||
fsyncQuietly(staged)
|
||||
val final = File(finalPath)
|
||||
if (staged.renameTo(final)) return true
|
||||
return final.delete() && staged.renameTo(final)
|
||||
val backup = File("$finalPath.spotiflac-backup-${System.nanoTime()}")
|
||||
val backedUp = final.exists()
|
||||
if (backedUp && (!final.isFile || !final.renameTo(backup))) return false
|
||||
if (staged.renameTo(final)) {
|
||||
if (backedUp) backup.delete()
|
||||
return true
|
||||
}
|
||||
if (backedUp) check(backup.renameTo(final)) { "failed to restore conversion source" }
|
||||
return false
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.buildOutputPath(inputPath: String, extension: String): String {
|
||||
val ext = normalizeExt(extension).ifBlank { ".tmp" }
|
||||
val file = File(inputPath)
|
||||
val base = file.nameWithoutExtension.ifBlank { "track" }
|
||||
val candidate = File(file.parentFile, "$base$ext").absolutePath
|
||||
if (candidate != inputPath) return candidate
|
||||
return File(file.parentFile, "${base}_converted$ext").absolutePath
|
||||
// This is the final name. FFmpeg writes via stagedConversionPath even
|
||||
// when the source and final extensions match.
|
||||
return File(file.parentFile, "$base$ext").absolutePath
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.desiredFileName(input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState, extension: String): String {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeConversionOutputTest {
|
||||
@Test
|
||||
fun sameSuffixOutputKeepsItsNameAndPublishesOnlyWhenComplete() {
|
||||
val directory = Files.createTempDirectory("native-conversion-output-").toFile()
|
||||
try {
|
||||
for (name in listOf("Song.flac", "Song.m4a", "Song.mp4", "Song_converted.flac")) {
|
||||
val input = File(directory, name).apply { writeText("source") }
|
||||
val output = NativeDownloadFinalizer.buildOutputPath(input.path, input.extension)
|
||||
assertEquals(input.absolutePath, output)
|
||||
val staged = File(NativeDownloadFinalizer.stagedConversionPath(output))
|
||||
staged.writeText("complete")
|
||||
assertEquals("source", input.readText())
|
||||
assertTrue(NativeDownloadFinalizer.promoteStagedConversion(staged.path, output))
|
||||
assertEquals("complete", input.readText())
|
||||
assertFalse(staged.exists())
|
||||
input.delete()
|
||||
assertEquals(0, directory.listFiles()!!.size)
|
||||
}
|
||||
} finally {
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyOrMissingStageCannotReplaceTheSource() {
|
||||
val directory = Files.createTempDirectory("native-conversion-output-").toFile()
|
||||
try {
|
||||
val input = File(directory, "Song.flac").apply { writeText("source") }
|
||||
val staged = File(NativeDownloadFinalizer.stagedConversionPath(input.path))
|
||||
assertFalse(NativeDownloadFinalizer.promoteStagedConversion(staged.path, input.path))
|
||||
staged.createNewFile()
|
||||
assertFalse(NativeDownloadFinalizer.promoteStagedConversion(staged.path, input.path))
|
||||
assertEquals("source", input.readText())
|
||||
} finally {
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1025,42 +1025,22 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
return _DecryptOutcome(newUri, newFileName: producedFileName);
|
||||
}
|
||||
|
||||
if (repairAc4) {
|
||||
final rawDecryptedPath = await FFmpegService.decryptWithDescriptor(
|
||||
inputPath: filePath,
|
||||
descriptor: descriptor,
|
||||
deleteOriginal: false,
|
||||
);
|
||||
if (rawDecryptedPath == null) {
|
||||
try {
|
||||
await deleteFile(filePath);
|
||||
} catch (_) {}
|
||||
return const _DecryptOutcome(
|
||||
null,
|
||||
failStage: DownloadQueueNotifier._decryptStageDecrypt,
|
||||
);
|
||||
}
|
||||
final decryptedPath = await _normalizeDecryptedIsoBmffAudioPath(
|
||||
rawDecryptedPath,
|
||||
result,
|
||||
);
|
||||
if (_isMp4Container(decryptedPath)) {
|
||||
try {
|
||||
await PlatformBridge.ensureAC4Config(decryptedPath, filePath);
|
||||
} catch (e) {
|
||||
_log.w('AC-4 container repair skipped: $e');
|
||||
}
|
||||
}
|
||||
try {
|
||||
await deleteFile(filePath);
|
||||
} catch (_) {}
|
||||
return _DecryptOutcome(decryptedPath);
|
||||
}
|
||||
|
||||
final rawDecryptedPath = await FFmpegService.decryptWithDescriptor(
|
||||
inputPath: filePath,
|
||||
descriptor: descriptor,
|
||||
deleteOriginal: true,
|
||||
// Repair while the encrypted source still exists, then publish under
|
||||
// its original name instead of keeping a conversion suffix.
|
||||
prepareOutput: repairAc4
|
||||
? (path) async {
|
||||
if (!_isMp4Container(path)) return;
|
||||
try {
|
||||
await PlatformBridge.ensureAC4Config(path, filePath);
|
||||
} catch (e) {
|
||||
_log.w('AC-4 container repair skipped: $e');
|
||||
}
|
||||
}
|
||||
: null,
|
||||
);
|
||||
final decryptedPath = rawDecryptedPath == null
|
||||
? null
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:spotiflac_android/services/ffmpeg_models.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_probe.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_support.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
@@ -14,6 +13,8 @@ abstract final class FFmpegDecryption {
|
||||
required String inputPath,
|
||||
required DownloadDecryptionDescriptor descriptor,
|
||||
bool deleteOriginal = true,
|
||||
Future<FFmpegResult> Function(String)? execute,
|
||||
Future<void> Function(String)? prepareOutput,
|
||||
}) async {
|
||||
if (descriptor.normalizedStrategy != 'ffmpeg.mov_key') {
|
||||
_log.e(
|
||||
@@ -29,6 +30,8 @@ abstract final class FFmpegDecryption {
|
||||
inputFormat: descriptor.inputFormat,
|
||||
outputExtension: descriptor.outputExtension,
|
||||
deleteOriginal: deleteOriginal,
|
||||
execute: execute ?? FFmpegExecution.command,
|
||||
prepareOutput: prepareOutput,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,26 +73,16 @@ abstract final class FFmpegDecryption {
|
||||
return '.flac';
|
||||
}
|
||||
|
||||
static String _outputPath(String inputPath, String extension) {
|
||||
final file = File(inputPath);
|
||||
final name = file.uri.pathSegments.last;
|
||||
final dot = name.lastIndexOf('.');
|
||||
final base = dot > 0 ? name.substring(0, dot) : name;
|
||||
final path = '${file.parent.path}${Platform.pathSeparator}$base$extension';
|
||||
return path == inputPath
|
||||
? '${file.parent.path}${Platform.pathSeparator}${base}_converted$extension'
|
||||
: path;
|
||||
}
|
||||
|
||||
static Future<String?> _decryptMovKeyFile({
|
||||
required String inputPath,
|
||||
required String decryptionKey,
|
||||
String? inputFormat,
|
||||
String? outputExtension,
|
||||
required bool deleteOriginal,
|
||||
required Future<FFmpegResult> Function(String) execute,
|
||||
Future<void> Function(String)? prepareOutput,
|
||||
}) async {
|
||||
final preferredExt = _preferredExtension(inputPath, outputExtension);
|
||||
var tempOutput = _outputPath(inputPath, preferredExt);
|
||||
final demuxer = (inputFormat ?? '').trim().isNotEmpty
|
||||
? inputFormat!.trim()
|
||||
: 'mov';
|
||||
@@ -117,67 +110,60 @@ abstract final class FFmpegDecryption {
|
||||
return null;
|
||||
}
|
||||
FFmpegResult? lastResult;
|
||||
var succeeded = false;
|
||||
for (final key in candidates) {
|
||||
_log.d('Executing FFmpeg decrypt command (key length: ${key.length})');
|
||||
var result = await FFmpegExecution.command(
|
||||
command(tempOutput, mapAudioOnly: preferredExt == '.flac', key: key),
|
||||
FFmpegOutputPlan? plan;
|
||||
var published = false;
|
||||
Future<bool> attempt(
|
||||
String extension,
|
||||
String key, {
|
||||
bool forceMov = false,
|
||||
}) async {
|
||||
if (plan != null) await FFmpegOutput.cleanup(plan!.workingPath);
|
||||
final output = await FFmpegOutput.plan(
|
||||
inputPath,
|
||||
extension,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
plan = output;
|
||||
final result = await execute(
|
||||
command(
|
||||
output.workingPath,
|
||||
mapAudioOnly: extension == '.flac',
|
||||
key: key,
|
||||
forceMov: forceMov,
|
||||
),
|
||||
);
|
||||
if (!result.success && preferredExt == '.flac') {
|
||||
final fallback = _outputPath(inputPath, '.m4a');
|
||||
final attempt = await FFmpegExecution.command(
|
||||
command(fallback, mapAudioOnly: false, key: key),
|
||||
);
|
||||
if (attempt.success) {
|
||||
tempOutput = fallback;
|
||||
result = attempt;
|
||||
}
|
||||
}
|
||||
if (!result.success &&
|
||||
(preferredExt == '.flac' || preferredExt == '.m4a')) {
|
||||
final fallback = _outputPath(inputPath, '.mp4');
|
||||
final attempt = await FFmpegExecution.command(
|
||||
command(fallback, mapAudioOnly: false, key: key),
|
||||
);
|
||||
if (attempt.success) {
|
||||
tempOutput = fallback;
|
||||
result = attempt;
|
||||
}
|
||||
}
|
||||
if (!result.success) {
|
||||
final fallback = _outputPath(inputPath, '.mp4');
|
||||
final attempt = await FFmpegExecution.command(
|
||||
command(fallback, mapAudioOnly: false, key: key, forceMov: true),
|
||||
);
|
||||
if (attempt.success) {
|
||||
tempOutput = fallback;
|
||||
result = attempt;
|
||||
}
|
||||
}
|
||||
lastResult = result;
|
||||
if (result.success) {
|
||||
succeeded = true;
|
||||
break;
|
||||
}
|
||||
await FFmpegOutput.cleanup(tempOutput);
|
||||
final file = File(output.workingPath);
|
||||
return result.success && await file.exists() && await file.length() > 0;
|
||||
}
|
||||
if (!succeeded) {
|
||||
|
||||
try {
|
||||
for (final key in candidates) {
|
||||
_log.d('Executing FFmpeg decrypt command (key length: ${key.length})');
|
||||
if (await attempt(preferredExt, key) ||
|
||||
(preferredExt == '.flac' && await attempt('.m4a', key)) ||
|
||||
((preferredExt == '.flac' || preferredExt == '.m4a') &&
|
||||
await attempt('.mp4', key)) ||
|
||||
await attempt('.mp4', key, forceMov: true)) {
|
||||
await prepareOutput?.call(plan!.workingPath);
|
||||
final path = await FFmpegOutput.finalize(
|
||||
plan: plan!,
|
||||
inputPath: inputPath,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
published = path != null;
|
||||
return path;
|
||||
}
|
||||
}
|
||||
_log.e('FFmpeg decrypt failed: ${lastResult?.output ?? 'unknown error'}');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (!await File(tempOutput).exists()) {
|
||||
_log.e('Decrypted output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
final source = File(inputPath);
|
||||
if (deleteOriginal && await source.exists()) await source.delete();
|
||||
FFmpegProbe.invalidate(inputPath);
|
||||
FFmpegProbe.invalidate(tempOutput);
|
||||
return tempOutput;
|
||||
} catch (e) {
|
||||
_log.e('Failed to finalize decrypted file: $e');
|
||||
return null;
|
||||
} finally {
|
||||
if (!published && plan != null) {
|
||||
await FFmpegOutput.cleanup(plan!.workingPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,10 +72,12 @@ class FFmpegService {
|
||||
required String inputPath,
|
||||
required DownloadDecryptionDescriptor descriptor,
|
||||
bool deleteOriginal = true,
|
||||
Future<void> Function(String)? prepareOutput,
|
||||
}) => FFmpegDecryption.decrypt(
|
||||
inputPath: inputPath,
|
||||
descriptor: descriptor,
|
||||
deleteOriginal: deleteOriginal,
|
||||
prepareOutput: prepareOutput,
|
||||
);
|
||||
|
||||
/// Measures EBU R128 loudness and true peak using a -18 LUFS reference.
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_decryption.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_models.dart';
|
||||
|
||||
void main() {
|
||||
late Directory directory;
|
||||
setUp(() async {
|
||||
directory = await Directory.systemTemp.createTemp('decryption-output-');
|
||||
});
|
||||
tearDown(() async => directory.delete(recursive: true));
|
||||
|
||||
DownloadDecryptionDescriptor descriptor(String extension) =>
|
||||
DownloadDecryptionDescriptor(
|
||||
strategy: 'ffmpeg.mov_key',
|
||||
key: '00112233445566778899aabbccddeeff',
|
||||
outputExtension: extension,
|
||||
);
|
||||
|
||||
String outputPath(String command) =>
|
||||
FFmpegKitConfig.parseArguments(command).reversed.skip(1).first;
|
||||
|
||||
Future<FFmpegResult> succeed(String command) async {
|
||||
await File(outputPath(command)).writeAsString('decrypted');
|
||||
return FFmpegResult(success: true, returnCode: 0, output: '');
|
||||
}
|
||||
|
||||
for (final name in [
|
||||
'Song.flac',
|
||||
'Song.m4a',
|
||||
'Song.mp4',
|
||||
'Song_converted.flac',
|
||||
]) {
|
||||
test('decryption retains the original name $name', () async {
|
||||
final source = await File(
|
||||
'${directory.path}/$name',
|
||||
).writeAsString('encrypted');
|
||||
final result = await FFmpegDecryption.decrypt(
|
||||
inputPath: source.path,
|
||||
descriptor: descriptor(
|
||||
'.${source.uri.pathSegments.last.split('.').last}',
|
||||
),
|
||||
execute: (command) async {
|
||||
expect(outputPath(command), isNot(source.path));
|
||||
expect(await source.readAsString(), 'encrypted');
|
||||
return succeed(command);
|
||||
},
|
||||
);
|
||||
expect(result, source.path);
|
||||
expect(await source.readAsString(), 'decrypted');
|
||||
expect(await directory.list().length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('failed FLAC attempt falls back to the original M4A name', () async {
|
||||
final source = await File(
|
||||
'${directory.path}/Song.m4a',
|
||||
).writeAsString('encrypted');
|
||||
String? failedPath;
|
||||
final result = await FFmpegDecryption.decrypt(
|
||||
inputPath: source.path,
|
||||
descriptor: descriptor('.flac'),
|
||||
execute: (command) async {
|
||||
expect(await source.readAsString(), 'encrypted');
|
||||
if (outputPath(command).endsWith('.flac')) {
|
||||
failedPath = outputPath(command);
|
||||
await File(failedPath!).writeAsString('partial');
|
||||
return FFmpegResult(
|
||||
success: false,
|
||||
returnCode: 1,
|
||||
output: 'unsupported codec',
|
||||
);
|
||||
}
|
||||
expect(await File(failedPath!).exists(), isFalse);
|
||||
return succeed(command);
|
||||
},
|
||||
);
|
||||
expect(result, source.path);
|
||||
expect(await source.readAsString(), 'decrypted');
|
||||
expect(await directory.list().length, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'decryption failures and exceptions retain the source and clean outputs',
|
||||
() async {
|
||||
final source = await File(
|
||||
'${directory.path}/Song.flac',
|
||||
).writeAsString('encrypted');
|
||||
for (final execute in <Future<FFmpegResult> Function(String)>[
|
||||
(command) async {
|
||||
await File(outputPath(command)).writeAsString('partial');
|
||||
return FFmpegResult(success: false, returnCode: 1, output: 'bad key');
|
||||
},
|
||||
(_) async => throw StateError('execution failed'),
|
||||
(_) async => FFmpegResult(success: true, returnCode: 0, output: ''),
|
||||
]) {
|
||||
expect(
|
||||
await FFmpegDecryption.decrypt(
|
||||
inputPath: source.path,
|
||||
descriptor: descriptor('.flac'),
|
||||
execute: execute,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(await source.readAsString(), 'encrypted');
|
||||
expect(await directory.list().length, 1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'decryption preserves an existing output and a requested original',
|
||||
() async {
|
||||
final source = await File(
|
||||
'${directory.path}/Song.m4a',
|
||||
).writeAsString('encrypted');
|
||||
final sibling = await File(
|
||||
'${directory.path}/Song.flac',
|
||||
).writeAsString('existing');
|
||||
final result = await FFmpegDecryption.decrypt(
|
||||
inputPath: source.path,
|
||||
descriptor: descriptor('.flac'),
|
||||
deleteOriginal: false,
|
||||
execute: succeed,
|
||||
);
|
||||
expect(result, '${directory.path}/Song (2).flac');
|
||||
expect(await source.readAsString(), 'encrypted');
|
||||
expect(await sibling.readAsString(), 'existing');
|
||||
expect(await File(result!).readAsString(), 'decrypted');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'container preparation completes while the original is available',
|
||||
() async {
|
||||
final source = await File(
|
||||
'${directory.path}/Song.mp4',
|
||||
).writeAsString('encrypted');
|
||||
final result = await FFmpegDecryption.decrypt(
|
||||
inputPath: source.path,
|
||||
descriptor: descriptor('.mp4'),
|
||||
execute: succeed,
|
||||
prepareOutput: (path) async {
|
||||
expect(await source.readAsString(), 'encrypted');
|
||||
await File(path).writeAsString('repaired');
|
||||
},
|
||||
);
|
||||
expect(result, source.path);
|
||||
expect(await source.readAsString(), 'repaired');
|
||||
expect(await directory.list().length, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'encrypted audio round trip keeps its name and remains decodable',
|
||||
() async {
|
||||
final source = File('${directory.path}/Song.m4a');
|
||||
final key = descriptor('.m4a').key;
|
||||
ProcessResult generated;
|
||||
try {
|
||||
generated = await Process.run('ffmpeg', [
|
||||
'-v',
|
||||
'error',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'sine=frequency=440:duration=0.2',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-encryption_scheme',
|
||||
'cenc-aes-ctr',
|
||||
'-encryption_key',
|
||||
key,
|
||||
'-encryption_kid',
|
||||
'ffeeddccbbaa99887766554433221100',
|
||||
source.path,
|
||||
]);
|
||||
} on ProcessException {
|
||||
markTestSkipped('Host FFmpeg is unavailable');
|
||||
return;
|
||||
}
|
||||
expect(generated.exitCode, 0, reason: '${generated.stderr}');
|
||||
final result = await FFmpegDecryption.decrypt(
|
||||
inputPath: source.path,
|
||||
descriptor: descriptor('.m4a'),
|
||||
execute: (command) async {
|
||||
final result = await Process.run(
|
||||
'ffmpeg',
|
||||
FFmpegKitConfig.parseArguments(command),
|
||||
);
|
||||
return FFmpegResult(
|
||||
success: result.exitCode == 0,
|
||||
returnCode: result.exitCode,
|
||||
output: '${result.stderr}',
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(result, source.path);
|
||||
final decoded = await Process.run('ffmpeg', [
|
||||
'-v',
|
||||
'error',
|
||||
'-xerror',
|
||||
'-i',
|
||||
source.path,
|
||||
'-f',
|
||||
'null',
|
||||
'-',
|
||||
]);
|
||||
expect(decoded.exitCode, 0, reason: '${decoded.stderr}');
|
||||
expect(await directory.list().length, 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user