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 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 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 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 _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 embedMetadata({ required String flacPath, String? coverPath, Map? metadata, String artistTagMode = artistTagModeJoined, }) async { final tempOutput = await FFmpegOutput.tempPath('.flac'); final arguments = ['-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 embedMetadataToMp3({ required String mp3Path, String? coverPath, Map? 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 _runMp3Embed({ required String mp3Path, required String tempOutput, String? coverPath, Map? metadata, required bool preserveMetadata, required String audioCodec, String? audioBitrate, }) async { final arguments = ['-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 _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 embedMetadataToOpus({ required String opusPath, String? coverPath, Map? metadata, String artistTagMode = artistTagModeJoined, bool preserveMetadata = false, Future Function(List)? 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 = [ '-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 embedMetadataToM4a({ required String m4aPath, String? coverPath, Map? 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 buildArgs(bool forceMov) { final arguments = ['-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 _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 embedChunkTagsNative( String path, Map 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 writeM4AFreeformTags( String path, Map metadata, ) async { final fields = {}; 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 writeM4AReleaseIdentityTags( String path, Map 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; } } }