import 'package:spotiflac_android/utils/artist_utils.dart'; /// Pure metadata-key conversion used by the different audio containers. /// /// Keeping these mappings independent from FFmpeg execution makes the tag /// contract directly testable and prevents container-specific policy from /// being buried in the conversion orchestration. class AudioMetadataMapper { const AudioMetadataMapper._(); static String? extractLyricsForId3(Map? metadata) { if (metadata == null) return null; String? fallback; for (final entry in metadata.entries) { final key = _normalizeKey(entry.key); if (key != 'UNSYNCEDLYRICS' && key != 'LYRICS') continue; final value = entry.value; if (value.trim().isEmpty) continue; if (key == 'UNSYNCEDLYRICS') return value; fallback ??= value; } return fallback; } /// Maps Vorbis-comment keys to the fields consumed by the native WAV/AIFF /// metadata writer. static Map vorbisToNativeChunkFields( Map metadata, ) { final fields = {}; void setIndexPair(String numberKey, String totalKey, String value) { final normalized = value.trim(); if (normalized.isEmpty || normalized == '0') return; if (normalized.contains('/')) { final parts = normalized.split('/'); fields[numberKey] = parts[0].trim(); if (parts.length > 1 && parts[1].trim().isNotEmpty) { fields[totalKey] = parts[1].trim(); } } else { fields[numberKey] = normalized; } } for (final entry in metadata.entries) { final key = _normalizeKey(entry.key); final value = entry.value; if (value.trim().isEmpty) continue; switch (key) { case 'TITLE': fields['title'] = value; case 'ARTIST': fields['artist'] = value; case 'ALBUM': fields['album'] = value; case 'ALBUMARTIST': fields['album_artist'] = value; case 'TRACKNUMBER': case 'TRACK': case 'TRCK': setIndexPair('track_number', 'track_total', value); case 'TRACKTOTAL': case 'TOTALTRACKS': if (value.trim() != '0') fields['track_total'] = value.trim(); case 'DISCNUMBER': case 'DISC': case 'TPOS': setIndexPair('disc_number', 'disc_total', value); case 'DISCTOTAL': case 'TOTALDISCS': if (value.trim() != '0') fields['disc_total'] = value.trim(); case 'DATE': fields['date'] = value; case 'YEAR': if ((fields['date'] ?? '').isEmpty) fields['date'] = value; case 'ISRC': fields['isrc'] = value; case 'GENRE': fields['genre'] = value; case 'COMPOSER': fields['composer'] = value; case 'ORGANIZATION': case 'LABEL': case 'PUBLISHER': fields['label'] = value; case 'COPYRIGHT': fields['copyright'] = value; case 'COMMENT': case 'DESCRIPTION': fields['comment'] = value; case 'LYRICS': case 'UNSYNCEDLYRICS': fields['lyrics'] = value; case 'REPLAYGAINTRACKGAIN': fields['replaygain_track_gain'] = value; case 'REPLAYGAINTRACKPEAK': fields['replaygain_track_peak'] = value; case 'REPLAYGAINALBUMGAIN': fields['replaygain_album_gain'] = value; case 'REPLAYGAINALBUMPEAK': fields['replaygain_album_peak'] = value; } } return fields; } /// Normalizes arbitrary tag spellings to standard Vorbis comment names. static Map normalizeToVorbisComments( Map metadata, ) { final vorbis = {}; for (final entry in metadata.entries) { final key = _normalizeKey(entry.key); final value = entry.value; switch (key) { case 'TITLE': vorbis['TITLE'] = value; case 'ARTIST': vorbis['ARTIST'] = value; case 'ALBUM': vorbis['ALBUM'] = value; case 'ALBUMARTIST': vorbis['ALBUMARTIST'] = value; case 'TRACKNUMBER': case 'TRACKNBR': case 'TRACK': case 'TRCK': if (value != '0') vorbis['TRACKNUMBER'] = value; case 'DISCNUMBER': case 'DISC': case 'TPOS': if (value != '0') vorbis['DISCNUMBER'] = value; case 'DATE': vorbis['DATE'] = value; final yearMatch = RegExp(r'^(\d{4})').firstMatch(value); if (yearMatch != null && (!vorbis.containsKey('YEAR') || vorbis['YEAR']!.isEmpty)) { vorbis['YEAR'] = yearMatch.group(1)!; } case 'YEAR': vorbis['YEAR'] = value; if (!vorbis.containsKey('DATE') || vorbis['DATE']!.isEmpty) { vorbis['DATE'] = value; } case 'GENRE': vorbis['GENRE'] = value; case 'ISRC': vorbis['ISRC'] = value; case 'LABEL': case 'ORGANIZATION': vorbis['ORGANIZATION'] = value; case 'COPYRIGHT': vorbis['COPYRIGHT'] = value; case 'COMPOSER': vorbis['COMPOSER'] = value; case 'COMMENT': vorbis['COMMENT'] = value; case 'LYRICS': case 'UNSYNCEDLYRICS': vorbis['LYRICS'] = value; vorbis['UNSYNCEDLYRICS'] = value; case 'REPLAYGAINTRACKGAIN': vorbis['REPLAYGAIN_TRACK_GAIN'] = value; case 'REPLAYGAINTRACKPEAK': vorbis['REPLAYGAIN_TRACK_PEAK'] = value; case 'REPLAYGAINALBUMGAIN': vorbis['REPLAYGAIN_ALBUM_GAIN'] = value; case 'REPLAYGAINALBUMPEAK': vorbis['REPLAYGAIN_ALBUM_PEAK'] = value; case 'R128TRACKGAIN': vorbis['R128_TRACK_GAIN'] = value; case 'R128ALBUMGAIN': vorbis['R128_ALBUM_GAIN'] = value; } } return vorbis; } static void appendVorbisMetadataArguments( List arguments, Map metadata, { String artistTagMode = artistTagModeJoined, }) { for (final entry in buildVorbisMetadataEntries( metadata, artistTagMode: artistTagMode, )) { arguments ..add('-metadata') ..add('${entry.key}=${entry.value}'); } } static void appendMappedMetadataArguments( List arguments, Map metadata, ) { for (final entry in metadata.entries) { arguments ..add('-metadata') ..add('${entry.key}=${entry.value}'); } } static List> buildVorbisMetadataEntries( Map metadata, { String artistTagMode = artistTagModeJoined, }) { final vorbis = normalizeToVorbisComments(metadata); final entries = >[]; for (final entry in vorbis.entries) { if (entry.key == 'ARTIST' || entry.key == 'ALBUMARTIST') { continue; } entries.add(entry); } _appendVorbisArtistEntries( entries, 'ARTIST', vorbis['ARTIST'], artistTagMode: artistTagMode, ); _appendVorbisArtistEntries( entries, 'ALBUMARTIST', vorbis['ALBUMARTIST'], artistTagMode: artistTagMode, ); return entries; } /// Maps generic metadata keys to M4A/MP4 tag names understood by FFmpeg. static Map convertToM4aTags(Map metadata) { final m4a = {}; for (final entry in metadata.entries) { final key = _normalizeKey(entry.key); final value = entry.value; switch (key) { case 'TITLE': m4a['title'] = value; case 'ARTIST': m4a['artist'] = value; case 'ALBUM': m4a['album'] = value; case 'ALBUMARTIST': m4a['album_artist'] = value; case 'TRACKNUMBER': case 'TRACK': case 'TRCK': m4a['track'] = value; case 'DISCNUMBER': case 'DISC': case 'TPOS': m4a['disc'] = value; case 'DATE': m4a['date'] = value; case 'YEAR': if (!m4a.containsKey('date') || m4a['date']!.isEmpty) { m4a['date'] = value; } case 'GENRE': m4a['genre'] = value; case 'ISRC': m4a['isrc'] = value; case 'COMPOSER': m4a['composer'] = value; case 'COMMENT': m4a['comment'] = value; case 'COPYRIGHT': m4a['copyright'] = value; case 'LABEL': case 'ORGANIZATION': m4a['organization'] = value; case 'LYRICS': case 'UNSYNCEDLYRICS': m4a['lyrics'] = value; } } return m4a; } /// Maps generic metadata keys to ID3 names understood by FFmpeg. static Map convertToId3Tags(Map metadata) { final id3 = {}; for (final entry in metadata.entries) { final originalKey = entry.key.toUpperCase(); final key = _normalizeKey(originalKey); final value = entry.value; switch (key) { case 'TITLE': id3['title'] = value; case 'ARTIST': id3['artist'] = value; case 'ALBUM': id3['album'] = value; case 'ALBUMARTIST': id3['album_artist'] = value; case 'TRACKNUMBER': case 'TRACK': case 'TRCK': if (value != '0') id3['track'] = value; case 'DISCNUMBER': case 'DISC': case 'TPOS': if (value != '0') id3['disc'] = value; case 'DATE': id3['date'] = value; case 'YEAR': if (!id3.containsKey('date') || id3['date']!.isEmpty) { id3['date'] = value; } case 'ISRC': id3['TSRC'] = value; case 'LYRICS': case 'UNSYNCEDLYRICS': id3['lyrics'] = value; case 'COMPOSER': id3['composer'] = value; case 'COMMENT': id3['comment'] = value; case 'REPLAYGAINTRACKGAIN': id3['REPLAYGAIN_TRACK_GAIN'] = value; case 'REPLAYGAINTRACKPEAK': id3['REPLAYGAIN_TRACK_PEAK'] = value; case 'REPLAYGAINALBUMGAIN': id3['REPLAYGAIN_ALBUM_GAIN'] = value; case 'REPLAYGAINALBUMPEAK': id3['REPLAYGAIN_ALBUM_PEAK'] = value; default: id3[originalKey.toLowerCase()] = value; } } return id3; } static void _appendVorbisArtistEntries( List> entries, String key, String? rawValue, { String artistTagMode = artistTagModeJoined, }) { if (rawValue == null) return; final value = rawValue.trim(); if (value.isEmpty) { // Preserve an empty entry so FFmpeg clears an existing tag. entries.add(MapEntry(key, '')); return; } if (!shouldSplitVorbisArtistTags(artistTagMode)) { entries.add(MapEntry(key, value)); return; } for (final artist in splitArtistTagValues(value)) { entries.add(MapEntry(key, artist)); } } static String _normalizeKey(String key) => key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); }