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
+63 -3
View File
@@ -4878,6 +4878,47 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? coverPath
: null;
// AC-4 is passthrough-only: the FFmpeg mov muxer would re-wrap it as
// QuickTime and break the ISO MP4 from decryption. writeAC4Metadata is a
// no-op for non-AC-4 files, so other m4a downloads fall through to FFmpeg.
if (isM4a) {
try {
final ac4Meta = <String, String>{
'title': track.name,
'artist': track.artistName,
'album': track.albumName,
'albumArtist': ?albumArtist,
if (track.releaseDate != null) 'date': track.releaseDate!,
if (genre != null && genre.isNotEmpty) 'genre': genre,
if (track.composer != null && track.composer!.isNotEmpty)
'composer': track.composer!,
if (track.trackNumber != null && track.trackNumber! > 0)
'trackNumber': track.trackNumber!.toString(),
if (track.totalTracks != null && track.totalTracks! > 0)
'totalTracks': track.totalTracks!.toString(),
if (track.discNumber != null && track.discNumber! > 0)
'discNumber': track.discNumber!.toString(),
if (track.totalDiscs != null && track.totalDiscs! > 0)
'totalDiscs': track.totalDiscs!.toString(),
if (track.isrc != null) 'isrc': track.isrc!,
if (label != null && label.isNotEmpty) 'label': label,
if (copyright != null && copyright.isNotEmpty) 'copyright': copyright,
if (shouldEmbedLyrics) 'lyrics': ?lrcContent,
};
final ac4Result = await PlatformBridge.writeAC4Metadata(
filePath,
ac4Meta,
validCover ?? '',
);
if (ac4Result['handled'] == true) {
_log.d('AC-4 metadata embedded natively for $format');
return;
}
} catch (e) {
_log.w('AC-4 metadata path failed, falling back to FFmpeg: $e');
}
}
String? ffmpegResult;
if (isFlac) {
ffmpegResult = await FFmpegService.embedMetadata(
@@ -7565,6 +7606,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
// Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted
// source. No-op for other codecs.
try {
await PlatformBridge.ensureAC4Config(decryptedTempPath, tempPath);
} catch (e) {
_log.w('AC-4 container repair skipped: $e');
}
final dotIndex = decryptedTempPath.lastIndexOf('.');
final decryptedExt = dotIndex >= 0
? decryptedTempPath.substring(dotIndex).toLowerCase()
@@ -7617,10 +7666,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} else {
final encryptedSource = filePath;
final decryptedPath = await FFmpegService.decryptWithDescriptor(
inputPath: filePath,
inputPath: encryptedSource,
descriptor: decryptionDescriptor,
deleteOriginal: true,
deleteOriginal: false,
);
if (decryptedPath == null) {
_log.e('FFmpeg decrypt failed for local file');
@@ -7631,10 +7681,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorType: DownloadErrorType.unknown,
);
try {
await deleteFile(filePath);
await deleteFile(encryptedSource);
} catch (_) {}
return;
}
// Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted
// source before discarding it. No-op for other codecs.
try {
await PlatformBridge.ensureAC4Config(decryptedPath, encryptedSource);
} catch (e) {
_log.w('AC-4 container repair skipped: $e');
}
try {
await deleteFile(encryptedSource);
} catch (_) {}
filePath = decryptedPath;
_log.i('Local decryption completed');
}
+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(