mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
487 lines
15 KiB
Dart
487 lines
15 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:spotiflac_android/services/audio_metadata_mapper.dart';
|
|
import 'package:spotiflac_android/services/ffmpeg_models.dart';
|
|
import 'package:spotiflac_android/services/ffmpeg_support.dart';
|
|
import 'package:spotiflac_android/services/id3v23_lyrics.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/utils/artist_utils.dart';
|
|
import 'package:spotiflac_android/utils/logger.dart';
|
|
|
|
final _log = AppLogger('FFmpeg');
|
|
|
|
/// Container-specific metadata and artwork operations, independent of encoding.
|
|
abstract final class FFmpegMetadata {
|
|
static List<String> buildCoverResizeArguments({
|
|
required String inputPath,
|
|
required String outputPath,
|
|
required int maxDimension,
|
|
}) {
|
|
if (maxDimension < 64 || maxDimension > 8192) {
|
|
throw ArgumentError.value(
|
|
maxDimension,
|
|
'maxDimension',
|
|
'Must be between 64 and 8192 pixels',
|
|
);
|
|
}
|
|
return [
|
|
'-y',
|
|
'-i',
|
|
inputPath,
|
|
'-map_metadata',
|
|
'-1',
|
|
'-an',
|
|
'-sn',
|
|
'-vf',
|
|
'scale=$maxDimension:$maxDimension:'
|
|
'force_original_aspect_ratio=decrease:flags=lanczos',
|
|
'-frames:v',
|
|
'1',
|
|
'-update',
|
|
'1',
|
|
'-q:v',
|
|
'2',
|
|
outputPath,
|
|
];
|
|
}
|
|
|
|
static Future<bool> resizeCoverArt({
|
|
required String inputPath,
|
|
required String outputPath,
|
|
required int maxDimension,
|
|
}) async {
|
|
try {
|
|
if (!await File(inputPath).exists()) return false;
|
|
final result = await FFmpegExecution.arguments(
|
|
buildCoverResizeArguments(
|
|
inputPath: inputPath,
|
|
outputPath: outputPath,
|
|
maxDimension: maxDimension,
|
|
),
|
|
);
|
|
final output = File(outputPath);
|
|
if (result.success &&
|
|
await output.exists() &&
|
|
await output.length() > 0) {
|
|
return true;
|
|
}
|
|
_log.w(
|
|
'Cover resize failed (${result.returnCode}): '
|
|
'${FFmpegExecution.preview(result.output)}',
|
|
);
|
|
} catch (e) {
|
|
_log.w('Cover resize failed: $e');
|
|
}
|
|
await FFmpegOutput.cleanup(outputPath);
|
|
return false;
|
|
}
|
|
|
|
static void appendCoverInputArguments(
|
|
List<String> arguments, {
|
|
String map = '1:v',
|
|
String disposition = '-disposition:v:0',
|
|
}) => arguments.addAll([
|
|
'-map',
|
|
map,
|
|
'-c:v',
|
|
'copy',
|
|
disposition,
|
|
'attached_pic',
|
|
'-metadata:s:v',
|
|
'title=Album cover',
|
|
'-metadata:s:v',
|
|
'comment=Cover (front)',
|
|
]);
|
|
|
|
static Future<String?> _finish(
|
|
FFmpegResult result,
|
|
String tempOutput,
|
|
String path,
|
|
String format,
|
|
) async {
|
|
if (result.success) {
|
|
final promoted = await FFmpegOutput.promote(
|
|
tempOutput,
|
|
path,
|
|
onMissing: () =>
|
|
_log.e('Temp $format output file not found: $tempOutput'),
|
|
onError: (e) =>
|
|
_log.e('Failed to replace $format file after metadata embed: $e'),
|
|
);
|
|
if (!promoted) return null;
|
|
_log.d('$format metadata embedded successfully');
|
|
return path;
|
|
}
|
|
await FFmpegOutput.cleanup(tempOutput);
|
|
_log.e('$format Metadata/Cover embed failed: ${result.output}');
|
|
return null;
|
|
}
|
|
|
|
static Future<String?> embedMetadata({
|
|
required String flacPath,
|
|
String? coverPath,
|
|
Map<String, String>? metadata,
|
|
String artistTagMode = artistTagModeJoined,
|
|
}) async {
|
|
final tempOutput = await FFmpegOutput.tempPath('.flac');
|
|
final arguments = <String>['-v', 'error', '-hide_banner', '-i', flacPath];
|
|
if (coverPath != null) arguments.addAll(['-i', coverPath]);
|
|
arguments.addAll(['-map', '0:a']);
|
|
if (coverPath != null) {
|
|
appendCoverInputArguments(
|
|
arguments,
|
|
map: '1:0',
|
|
disposition: '-disposition:v',
|
|
);
|
|
}
|
|
arguments.addAll(['-c:a', 'copy']);
|
|
if (metadata != null) {
|
|
AudioMetadataMapper.appendVorbisMetadataArguments(
|
|
arguments,
|
|
metadata,
|
|
artistTagMode: artistTagMode,
|
|
);
|
|
}
|
|
arguments.addAll([tempOutput, '-y']);
|
|
_log.d('Executing FFmpeg FLAC embed command');
|
|
return _finish(
|
|
await FFmpegExecution.arguments(arguments),
|
|
tempOutput,
|
|
flacPath,
|
|
'FLAC',
|
|
);
|
|
}
|
|
|
|
static Future<String?> embedMetadataToMp3({
|
|
required String mp3Path,
|
|
String? coverPath,
|
|
Map<String, String>? metadata,
|
|
String artistTagMode = artistTagModeJoined,
|
|
bool preserveMetadata = false,
|
|
}) async {
|
|
// ID3 keeps one artist frame: only primary mode changes its value.
|
|
if (metadata != null) {
|
|
metadata = applyArtistTagModeToMetadata(metadata, artistTagMode);
|
|
}
|
|
var tempOutput = await FFmpegOutput.tempPath('.mp3');
|
|
final lyrics = AudioMetadataMapper.extractLyricsForId3(metadata);
|
|
var result = await _runMp3Embed(
|
|
mp3Path: mp3Path,
|
|
tempOutput: tempOutput,
|
|
coverPath: coverPath,
|
|
metadata: metadata,
|
|
preserveMetadata: preserveMetadata,
|
|
audioCodec: 'copy',
|
|
);
|
|
if (!result.success &&
|
|
(result.output.contains('Invalid audio stream') ||
|
|
result.output.contains('incorrect codec parameters'))) {
|
|
_log.w('MP3 copy failed (codec mismatch), re-encoding with libmp3lame');
|
|
await FFmpegOutput.cleanup(tempOutput);
|
|
tempOutput = await FFmpegOutput.tempPath('.mp3');
|
|
result = await _runMp3Embed(
|
|
mp3Path: mp3Path,
|
|
tempOutput: tempOutput,
|
|
coverPath: coverPath,
|
|
metadata: metadata,
|
|
preserveMetadata: preserveMetadata,
|
|
audioCodec: 'libmp3lame',
|
|
audioBitrate: '192k',
|
|
);
|
|
}
|
|
final embeddedPath = await _finish(result, tempOutput, mp3Path, 'MP3');
|
|
if (embeddedPath != null && lyrics != null) {
|
|
await _ensureMp3UnsyncedLyricsFrame(embeddedPath, lyrics);
|
|
}
|
|
return embeddedPath;
|
|
}
|
|
|
|
static Future<FFmpegResult> _runMp3Embed({
|
|
required String mp3Path,
|
|
required String tempOutput,
|
|
String? coverPath,
|
|
Map<String, String>? metadata,
|
|
required bool preserveMetadata,
|
|
required String audioCodec,
|
|
String? audioBitrate,
|
|
}) async {
|
|
final arguments = <String>['-v', 'error', '-hide_banner', '-i', mp3Path];
|
|
if (coverPath != null) arguments.addAll(['-i', coverPath]);
|
|
arguments.addAll([
|
|
'-map',
|
|
'0:a',
|
|
'-map_metadata',
|
|
preserveMetadata ? '0' : '-1',
|
|
]);
|
|
if (coverPath != null) {
|
|
arguments.addAll([
|
|
'-map',
|
|
'1:0',
|
|
'-c:v:0',
|
|
'copy',
|
|
'-id3v2_version',
|
|
'3',
|
|
'-metadata:s:v',
|
|
'title=Album cover',
|
|
'-metadata:s:v',
|
|
'comment=Cover (front)',
|
|
]);
|
|
}
|
|
arguments.addAll(['-c:a', audioCodec]);
|
|
if (audioBitrate != null) arguments.addAll(['-b:a', audioBitrate]);
|
|
if (metadata != null) {
|
|
AudioMetadataMapper.appendMappedMetadataArguments(
|
|
arguments,
|
|
AudioMetadataMapper.convertToId3Tags(metadata),
|
|
);
|
|
}
|
|
arguments.addAll(['-id3v2_version', '3', tempOutput, '-y']);
|
|
_log.d('Executing FFmpeg MP3 embed command (codec: $audioCodec)');
|
|
return FFmpegExecution.arguments(arguments);
|
|
}
|
|
|
|
static Future<void> _ensureMp3UnsyncedLyricsFrame(
|
|
String path,
|
|
String lyrics,
|
|
) async {
|
|
try {
|
|
final file = File(path);
|
|
if (!await file.exists()) return;
|
|
if (!await Id3v23Lyrics.writeUnsyncedLyricsToFile(file, lyrics)) {
|
|
_log.w('Skipping MP3 USLT lyrics frame update: unsupported ID3 tag');
|
|
return;
|
|
}
|
|
_log.d('MP3 USLT lyrics frame written (${lyrics.length} chars)');
|
|
} catch (e) {
|
|
_log.w('Failed to write MP3 USLT lyrics frame: $e');
|
|
}
|
|
}
|
|
|
|
static Future<String?> embedMetadataToOpus({
|
|
required String opusPath,
|
|
String? coverPath,
|
|
Map<String, String>? metadata,
|
|
String artistTagMode = artistTagModeJoined,
|
|
bool preserveMetadata = false,
|
|
Future<FFmpegResult> Function(List<String>)? execute,
|
|
}) async {
|
|
final tempOutput = await FFmpegOutput.tempPath('.opus');
|
|
// Opus tags are on the audio stream. Copy to output globals, override the
|
|
// selected values and let the muxer write globals into the output stream.
|
|
final arguments = <String>[
|
|
'-v',
|
|
'error',
|
|
'-hide_banner',
|
|
'-i',
|
|
opusPath,
|
|
'-map',
|
|
'0:a',
|
|
'-map_metadata',
|
|
preserveMetadata ? '0:s:a:0' : '-1',
|
|
'-map_metadata:s:a',
|
|
'-1',
|
|
'-c:a',
|
|
'copy',
|
|
];
|
|
if (metadata != null) {
|
|
AudioMetadataMapper.appendVorbisMetadataArguments(
|
|
arguments,
|
|
metadata,
|
|
artistTagMode: artistTagMode,
|
|
);
|
|
}
|
|
if (coverPath != null) {
|
|
try {
|
|
final pictureBlock = await _createMetadataBlockPicture(coverPath);
|
|
if (pictureBlock != null) {
|
|
arguments.addAll([
|
|
'-metadata',
|
|
'METADATA_BLOCK_PICTURE=$pictureBlock',
|
|
]);
|
|
_log.d(
|
|
'Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)',
|
|
);
|
|
} else {
|
|
_log.w('Failed to create METADATA_BLOCK_PICTURE, skipping cover');
|
|
}
|
|
} catch (e) {
|
|
_log.e('Error creating METADATA_BLOCK_PICTURE: $e');
|
|
}
|
|
}
|
|
arguments.addAll([tempOutput, '-y']);
|
|
_log.d('Executing FFmpeg Opus embed command');
|
|
return _finish(
|
|
await (execute ?? FFmpegExecution.arguments)(arguments),
|
|
tempOutput,
|
|
opusPath,
|
|
'Opus',
|
|
);
|
|
}
|
|
|
|
static Future<String?> embedMetadataToM4a({
|
|
required String m4aPath,
|
|
String? coverPath,
|
|
Map<String, String>? metadata,
|
|
String artistTagMode = artistTagModeJoined,
|
|
bool preserveMetadata = true,
|
|
}) async {
|
|
if (metadata != null) {
|
|
metadata = applyArtistTagModeToMetadata(metadata, artistTagMode);
|
|
}
|
|
final tempOutput = await FFmpegOutput.tempPath('.m4a');
|
|
final normalizedCoverPath = coverPath?.trim();
|
|
final hasCover =
|
|
normalizedCoverPath != null &&
|
|
normalizedCoverPath.isNotEmpty &&
|
|
await File(normalizedCoverPath).exists();
|
|
final preserveExistingStreams = preserveMetadata && !hasCover;
|
|
List<String> buildArgs(bool forceMov) {
|
|
final arguments = <String>['-v', 'error', '-hide_banner', '-i', m4aPath];
|
|
if (hasCover) arguments.addAll(['-i', normalizedCoverPath]);
|
|
// Preserve attached artwork when no replacement cover is supplied.
|
|
arguments.addAll(
|
|
preserveExistingStreams
|
|
? ['-map', '0', '-c', 'copy']
|
|
: ['-map', '0:a', '-c:a', 'copy'],
|
|
);
|
|
arguments.addAll(['-map_metadata', preserveMetadata ? '0' : '-1']);
|
|
if (hasCover) appendCoverInputArguments(arguments);
|
|
if (metadata != null) {
|
|
AudioMetadataMapper.appendMappedMetadataArguments(
|
|
arguments,
|
|
AudioMetadataMapper.convertToM4aTags(metadata),
|
|
);
|
|
}
|
|
// MOV accepts codecs MP4 rejects; cover writes force a proper covr atom.
|
|
if (forceMov) {
|
|
arguments.addAll(['-f', 'mov']);
|
|
} else if (hasCover) {
|
|
arguments.addAll(['-f', 'mp4']);
|
|
}
|
|
return arguments..addAll([tempOutput, '-y']);
|
|
}
|
|
|
|
_log.d('Executing FFmpeg M4A embed command');
|
|
var result = await FFmpegExecution.arguments(buildArgs(false));
|
|
if (!result.success) {
|
|
_log.w('M4A embed failed with default muxer, retrying with mov muxer');
|
|
await FFmpegOutput.cleanup(tempOutput);
|
|
result = await FFmpegExecution.arguments(buildArgs(true));
|
|
}
|
|
final embedded = await _finish(result, tempOutput, m4aPath, 'M4A');
|
|
if (embedded != null && metadata != null) {
|
|
await writeM4AFreeformTags(m4aPath, metadata);
|
|
await writeM4AReleaseIdentityTags(m4aPath, metadata);
|
|
}
|
|
return embedded;
|
|
}
|
|
|
|
static Future<String?> _createMetadataBlockPicture(String imagePath) async {
|
|
try {
|
|
final file = File(imagePath);
|
|
if (!await file.exists()) {
|
|
_log.e('Cover image not found: $imagePath');
|
|
return null;
|
|
}
|
|
final data = await file.readAsBytes();
|
|
final path = imagePath.toLowerCase();
|
|
final pngSignature =
|
|
data.length >= 8 &&
|
|
data[0] == 0x89 &&
|
|
data[1] == 0x50 &&
|
|
data[2] == 0x4e &&
|
|
data[3] == 0x47;
|
|
final mime =
|
|
path.endsWith('.png') ||
|
|
(!path.endsWith('.jpg') &&
|
|
!path.endsWith('.jpeg') &&
|
|
pngSignature)
|
|
? 'image/png'
|
|
: 'image/jpeg';
|
|
final mimeBytes = utf8.encode(mime);
|
|
// One allocation: type, MIME length/string, empty description, four zero
|
|
// image properties, payload length and the original image bytes.
|
|
final buffer = ByteData(32 + mimeBytes.length + data.length);
|
|
final bytes = buffer.buffer.asUint8List();
|
|
buffer.setUint32(0, 3, Endian.big);
|
|
buffer.setUint32(4, mimeBytes.length, Endian.big);
|
|
bytes.setRange(8, 8 + mimeBytes.length, mimeBytes);
|
|
buffer.setUint32(28 + mimeBytes.length, data.length, Endian.big);
|
|
bytes.setRange(32 + mimeBytes.length, bytes.length, data);
|
|
return base64Encode(bytes);
|
|
} catch (e) {
|
|
_log.e('Error creating METADATA_BLOCK_PICTURE: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static Future<bool> embedChunkTagsNative(
|
|
String path,
|
|
Map<String, String> metadata,
|
|
String? coverPath,
|
|
) async {
|
|
final fields = AudioMetadataMapper.vorbisToNativeChunkFields(metadata);
|
|
if (coverPath != null && coverPath.trim().isNotEmpty) {
|
|
fields['cover_path'] = coverPath;
|
|
}
|
|
if (fields.isEmpty) return true;
|
|
try {
|
|
final result = await PlatformBridge.editFileMetadata(path, fields);
|
|
if (result['error'] != null) {
|
|
_log.w('editFileMetadata for $path failed: ${result['error']}');
|
|
return false;
|
|
}
|
|
return result['success'] == true;
|
|
} catch (e) {
|
|
_log.w('editFileMetadata for $path failed: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static Future<void> writeM4AFreeformTags(
|
|
String path,
|
|
Map<String, String> metadata,
|
|
) async {
|
|
final fields = <String, String>{};
|
|
for (final entry in metadata.entries) {
|
|
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
|
switch (key) {
|
|
case 'ISRC':
|
|
fields['isrc'] = entry.value;
|
|
break;
|
|
case 'LABEL':
|
|
case 'ORGANIZATION':
|
|
fields['label'] = entry.value;
|
|
break;
|
|
}
|
|
}
|
|
if (fields.isEmpty) return;
|
|
try {
|
|
await PlatformBridge.writeM4AFreeformTags(path, fields);
|
|
} catch (e) {
|
|
_log.w('writeM4AFreeformTags failed for $path: $e');
|
|
}
|
|
}
|
|
|
|
static Future<bool> writeM4AReleaseIdentityTags(
|
|
String path,
|
|
Map<String, String> metadata,
|
|
) async {
|
|
final fields = AudioMetadataMapper.m4aReleaseIdentityFields(metadata);
|
|
if (fields.isEmpty) return true;
|
|
try {
|
|
final result = await PlatformBridge.editFileMetadata(path, fields);
|
|
if (result['error'] != null || result['method'] != 'native_m4a') {
|
|
_log.w('Native M4A release identity write was not completed for $path');
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
_log.w('M4A release identity write failed for $path: $e');
|
|
return false;
|
|
}
|
|
}
|
|
}
|