mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-17 08:30:33 +02:00
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:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user