feat(download): AC-4 passthrough support

Decrypt AC-4 via the FFmpeg mov muxer with a -f mov fallback, then repair the output to a standards-compliant ISO MP4: inject the dac4 config box from the encrypted source, normalize the QuickTime container/sample entry, and write iTunes metadata (incl. cover and lyrics) natively. Codec-keyed and generic, so it applies to any extension that returns AC-4 streams. Wired through PlatformBridge/MainActivity for both SAF and local decrypt paths.
This commit is contained in:
zarzet
2026-06-23 02:44:08 +07:00
parent 26987459f3
commit 21347420f3
8 changed files with 762 additions and 65 deletions
+94 -51
View File
@@ -483,6 +483,7 @@ class FFmpegService {
String outputPath, {
required bool mapAudioOnly,
required String key,
bool forceMovMuxer = false,
}) {
final audioMap = mapAudioOnly ? '-map 0:a ' : '';
// Force MOV demuxer: -decryption_key is only supported by the MOV/MP4
@@ -494,7 +495,12 @@ class FFmpegService {
// extension AND keeps the input container's stream layout, which for
// FLAC-in-MP4 sources would still emit an ISO-BMFF payload under a
// .flac filename. That file fails native FLAC tag writers later on.
final muxerOverride = outputPath.toLowerCase().endsWith('.flac')
//
// forceMovMuxer routes through the MOV muxer for codecs the MP4 muxer
// rejects (e.g. AC-4), keeping the .mp4 filename.
final muxerOverride = forceMovMuxer
? '-f mov '
: outputPath.toLowerCase().endsWith('.flac')
? '-f flac '
: '';
return '-v error -decryption_key "$key" -f $demuxerFormat -i "$inputPath" $audioMap-c copy $muxerOverride"$outputPath" -y';
@@ -555,6 +561,24 @@ class FFmpegService {
}
}
// Final fallback: force the MOV muxer for codecs the MP4 muxer rejects
// (e.g. AC-4). MOV stores the codec params and keeps the .mp4 filename.
if (!result.success) {
final movFallbackOutput = _buildOutputPath(inputPath, '.mp4');
final movFallbackResult = await _execute(
buildDecryptCommand(
movFallbackOutput,
mapAudioOnly: false,
key: keyCandidate,
forceMovMuxer: true,
),
);
if (movFallbackResult.success) {
tempOutput = movFallbackOutput;
result = movFallbackResult;
}
}
if (result.success) {
decryptSucceeded = true;
lastResult = result;
@@ -1974,69 +1998,88 @@ class FFmpegService {
}) async {
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', m4aPath];
final normalizedCoverPath = coverPath?.trim();
final hasCover =
normalizedCoverPath != null &&
normalizedCoverPath.isNotEmpty &&
await File(normalizedCoverPath).exists();
if (hasCover) {
arguments
..add('-i')
..add(normalizedCoverPath);
}
final preserveExistingStreams = preserveMetadata && !hasCover;
if (preserveExistingStreams) {
// When no replacement cover is provided, preserve all input streams so
// the existing attached artwork is not dropped during the metadata rewrite.
arguments
..add('-map')
..add('0')
..add('-c')
..add('copy');
} else {
arguments
..add('-map')
..add('0:a')
..add('-c:a')
..add('copy');
}
arguments
..add('-map_metadata')
..add(preserveMetadata ? '0' : '-1');
// For M4A cover replacements, mark the image as an attached picture so the
// mp4 muxer writes a proper covr atom instead of a generic MJPEG video track.
// Force the mp4 muxer because the default ipod muxer (auto-selected for .m4a)
// does not register a codec tag for mjpeg on FFmpeg 8.0+.
if (hasCover) {
List<String> buildArgs(bool forceMov) {
final arguments = <String>['-v', 'error', '-hide_banner', '-i', m4aPath];
if (hasCover) {
arguments
..add('-i')
..add(normalizedCoverPath);
}
if (preserveExistingStreams) {
// When no replacement cover is provided, preserve all input streams so
// the existing attached artwork is not dropped during the metadata rewrite.
arguments
..add('-map')
..add('0')
..add('-c')
..add('copy');
} else {
arguments
..add('-map')
..add('0:a')
..add('-c:a')
..add('copy');
}
arguments
..add('-map')
..add('1:v')
..add('-c:v')
..add('copy')
..add('-disposition:v:0')
..add('attached_pic')
..add('-metadata:s:v')
..add('title=Album cover')
..add('-metadata:s:v')
..add('comment=Cover (front)')
..add('-f')
..add('mp4');
}
..add('-map_metadata')
..add(preserveMetadata ? '0' : '-1');
if (metadata != null) {
_appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
}
if (hasCover) {
// Mark the image as an attached picture so the container writes a proper
// covr atom instead of a generic MJPEG video track.
arguments
..add('-map')
..add('1:v')
..add('-c:v')
..add('copy')
..add('-disposition:v:0')
..add('attached_pic')
..add('-metadata:s:v')
..add('title=Album cover')
..add('-metadata:s:v')
..add('comment=Cover (front)');
}
arguments
..add(tempOutput)
..add('-y');
if (metadata != null) {
_appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
}
// MOV muxer accepts codecs the MP4 muxer rejects (e.g. AC-4). The default
// (no -f) keeps the ipod muxer for plain .m4a; cover writes force mp4.
if (forceMov) {
arguments
..add('-f')
..add('mov');
} else if (hasCover) {
arguments
..add('-f')
..add('mp4');
}
arguments
..add(tempOutput)
..add('-y');
return arguments;
}
_log.d('Executing FFmpeg M4A embed command');
final result = await _executeWithArguments(arguments);
var result = await _executeWithArguments(buildArgs(false));
if (!result.success) {
_log.w('M4A embed failed with default muxer, retrying with mov muxer');
try {
final stale = File(tempOutput);
if (await stale.exists()) await stale.delete();
} catch (_) {}
result = await _executeWithArguments(buildArgs(true));
}
if (result.success) {
try {
+33
View File
@@ -809,6 +809,39 @@ class PlatformBridge {
return _decodeRequiredMapResult(result, 'writeM4AFreeformTags');
}
/// Normalizes a decrypted AC-4 file to a standards-compliant ISO MP4 and
/// injects the dac4 configuration box from the encrypted [sourcePath]. The
/// FFmpeg mov muxer drops dac4 and writes a QuickTime-flavored container that
/// players reject, so this repair is required for AC-4 to be playable.
static Future<Map<String, dynamic>> ensureAC4Config(
String filePath,
String sourcePath,
) async {
final result = await _channel.invokeMethod('ensureAC4Config', {
'file_path': filePath,
'source_path': sourcePath,
});
return _decodeRequiredMapResult(result, 'ensureAC4Config');
}
/// Writes iTunes-style metadata (and cover art) into an AC-4 MP4. Returns a
/// map whose `handled` flag is `true` when the file was AC-4 and metadata was
/// written natively, signalling the caller to skip the FFmpeg metadata pass
/// (which would re-wrap the file as QuickTime).
static Future<Map<String, dynamic>> writeAC4Metadata(
String filePath,
Map<String, String> metadata,
String coverPath,
) async {
final metadataJSON = jsonEncode(metadata);
final result = await _channel.invokeMethod('writeAC4Metadata', {
'file_path': filePath,
'metadata_json': metadataJSON,
'cover_path': coverPath,
});
return _decodeRequiredMapResult(result, 'writeAC4Metadata');
}
/// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries
/// using the native Go FLAC writer, fixing FFmpeg's tag deduplication.
static Future<Map<String, dynamic>> rewriteSplitArtistTags(