From 7557ffbf859ac04937d480e2c0df02904f063d3b Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 22 Aug 2026 23:24:56 +0700 Subject: [PATCH] fix(metadata): preserve explicit advisory across conversions --- lib/screens/track_metadata_convert.dart | 4 ++ lib/services/audio_metadata_mapper.dart | 48 ++++++++++++++++++++++++ lib/services/batch_track_actions.dart | 3 ++ lib/services/ffmpeg_service.dart | 36 +++++++++--------- lib/utils/lyrics_metadata_helper.dart | 8 ++++ test/lyrics_metadata_helper_test.dart | 24 ++++++++++++ test/maintainability_contracts_test.dart | 12 ++++++ 7 files changed, 118 insertions(+), 17 deletions(-) diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart index d7c5371b..b8613987 100644 --- a/lib/screens/track_metadata_convert.dart +++ b/lib/screens/track_metadata_convert.dart @@ -100,6 +100,7 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { if (label != null && label!.isNotEmpty) 'LABEL': label!, if (copyright != null && copyright!.isNotEmpty) 'COPYRIGHT': copyright!, if (composer != null && composer!.isNotEmpty) 'COMPOSER': composer!, + if (isExplicit) 'ITUNESADVISORY': '1', }; } @@ -125,6 +126,9 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { put('COMMENT', source['comment']); put('LYRICS', source['lyrics']); put('UNSYNCEDLYRICS', source['lyrics']); + if (parseExplicitFlag(source['explicit']) == true) { + mapped['ITUNESADVISORY'] = '1'; + } final trackNumber = source['track_number']; final totalTracks = source['total_tracks']; diff --git a/lib/services/audio_metadata_mapper.dart b/lib/services/audio_metadata_mapper.dart index 3be6ac50..c97f9544 100644 --- a/lib/services/audio_metadata_mapper.dart +++ b/lib/services/audio_metadata_mapper.dart @@ -93,6 +93,10 @@ class AudioMetadataMapper { case 'COMMENT': case 'DESCRIPTION': fields['comment'] = value; + case 'ITUNESADVISORY': + case 'EXPLICIT': + case 'ISEXPLICIT': + fields['explicit'] = _normalizeAdvisoryValue(value); case 'LYRICS': case 'UNSYNCEDLYRICS': fields['lyrics'] = value; @@ -163,6 +167,10 @@ class AudioMetadataMapper { vorbis['COMPOSER'] = value; case 'COMMENT': vorbis['COMMENT'] = value; + case 'ITUNESADVISORY': + case 'EXPLICIT': + case 'ISEXPLICIT': + vorbis['ITUNESADVISORY'] = _normalizeAdvisoryValue(value); case 'LYRICS': case 'UNSYNCEDLYRICS': vorbis['LYRICS'] = value; @@ -294,6 +302,32 @@ class AudioMetadataMapper { return m4a; } + /// Maps content-advisory and release identity tags to fields consumed by + /// the native M4A editor. Content advisory is written as the integer `rtng` + /// atom by that editor instead of an arbitrary MP4 text tag. + static Map m4aReleaseIdentityFields( + Map metadata, + ) { + final fields = {}; + for (final entry in metadata.entries) { + final key = _normalizeKey(entry.key); + switch (key) { + case 'ITUNESADVISORY': + case 'EXPLICIT': + case 'ISEXPLICIT': + fields['explicit'] = _normalizeAdvisoryValue(entry.value); + case 'RELEASETYPE': + fields['album_type'] = entry.value; + case 'BARCODE': + case 'UPC': + fields['upc'] = entry.value; + case 'COMPILATION': + fields['compilation'] = entry.value; + } + } + return fields; + } + /// Maps generic metadata keys to ID3 names understood by FFmpeg. static Map convertToId3Tags(Map metadata) { final id3 = {}; @@ -335,6 +369,12 @@ class AudioMetadataMapper { id3['composer'] = value; case 'COMMENT': id3['comment'] = value; + case 'ITUNESADVISORY': + case 'EXPLICIT': + case 'ISEXPLICIT': + // ID3 has no dedicated advisory frame. FFmpeg stores this as the + // conventional TXXX:ITUNESADVISORY user-text frame. + id3['ITUNESADVISORY'] = _normalizeAdvisoryValue(value); case 'REPLAYGAINTRACKGAIN': id3['REPLAYGAIN_TRACK_GAIN'] = value; case 'REPLAYGAINTRACKPEAK': @@ -377,4 +417,12 @@ class AudioMetadataMapper { static String _normalizeKey(String key) => key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); + + static String _normalizeAdvisoryValue(String value) { + return switch (value.trim().toLowerCase()) { + 'true' || 'yes' || 'explicit' => '1', + 'false' || 'no' => '0', + final value => value, + }; + } } diff --git a/lib/services/batch_track_actions.dart b/lib/services/batch_track_actions.dart index ffdc8327..31743d21 100644 --- a/lib/services/batch_track_actions.dart +++ b/lib/services/batch_track_actions.dart @@ -244,6 +244,9 @@ Future _performBatchConversion( 'TITLE': item.trackName, 'ARTIST': item.artistName, 'ALBUM': item.albumName, + if (item.historyItem?.explicit == true || + item.localItem?.explicit == true) + 'ITUNESADVISORY': '1', }; try { final result = await PlatformBridge.readFileMetadata(item.filePath); diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 0beaddb9..cb237160 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -2401,6 +2401,19 @@ class FFmpegService { return null; } + if (isAlac) { + await _writeM4AFreeformTags(outputPath, metadata); + final identityWritten = await _writeM4AReleaseIdentityTags( + outputPath, + metadata, + ); + if (!identityWritten) { + _log.e('ALAC release identity metadata write failed'); + await _cleanupConversionOutput(outputPlan); + return null; + } + } + return _finalizeConversionOutput( plan: outputPlan, inputPath: inputPath, @@ -2565,26 +2578,12 @@ class FFmpegService { /// Restores the iTunes atoms that FFmpeg does not reliably map from generic /// metadata keys. This runs after a successful remux, when the container is /// canonical enough for the native editor even if the original was not. - static Future _writeM4AReleaseIdentityTags( + static Future _writeM4AReleaseIdentityTags( String m4aPath, 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 'ITUNESADVISORY': - fields['explicit'] = entry.value; - case 'RELEASETYPE': - fields['album_type'] = entry.value; - case 'BARCODE': - case 'UPC': - fields['upc'] = entry.value; - case 'COMPILATION': - fields['compilation'] = entry.value; - } - } - if (fields.isEmpty) return; + final fields = AudioMetadataMapper.m4aReleaseIdentityFields(metadata); + if (fields.isEmpty) return true; try { final result = await PlatformBridge.editFileMetadata(m4aPath, fields); @@ -2592,9 +2591,12 @@ class FFmpegService { _log.w( 'Native M4A release identity write was not completed for $m4aPath', ); + return false; } + return true; } catch (e) { _log.w('M4A release identity write failed for $m4aPath: $e'); + return false; } } diff --git a/lib/utils/lyrics_metadata_helper.dart b/lib/utils/lyrics_metadata_helper.dart index 08feab00..0bcaf3f2 100644 --- a/lib/utils/lyrics_metadata_helper.dart +++ b/lib/utils/lyrics_metadata_helper.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; final RegExp _lrcDisplayTimestampPattern = RegExp( r'^\[\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?\]', @@ -210,6 +211,13 @@ void mergePlatformMetadataForTagEmbed({ put('LYRICS', source['lyrics']); put('UNSYNCEDLYRICS', source['lyrics']); + final explicit = parseExplicitFlag(source['explicit']); + if (explicit == true) { + target['ITUNESADVISORY'] = '1'; + } else if (explicit == false) { + target.remove('ITUNESADVISORY'); + } + final trackNumber = source['track_number']; final totalTracks = source['total_tracks']; if (trackNumber != null && trackNumber.toString() != '0') { diff --git a/test/lyrics_metadata_helper_test.dart b/test/lyrics_metadata_helper_test.dart index aa1a9de3..aadf3041 100644 --- a/test/lyrics_metadata_helper_test.dart +++ b/test/lyrics_metadata_helper_test.dart @@ -40,4 +40,28 @@ void main() { expect(cleanLyricsForDisplay('[instrumental:true]'), isEmpty); }); }); + + group('metadata conversion merge', () { + test('preserves explicit advisory using the canonical tag', () { + final metadata = {}; + + mergePlatformMetadataForTagEmbed( + target: metadata, + source: {'explicit': true}, + ); + + expect(metadata['ITUNESADVISORY'], '1'); + }); + + test('removes a stale explicit advisory for a non-explicit file', () { + final metadata = {'ITUNESADVISORY': '1'}; + + mergePlatformMetadataForTagEmbed( + target: metadata, + source: {'explicit': false}, + ); + + expect(metadata, isNot(contains('ITUNESADVISORY'))); + }); + }); } diff --git a/test/maintainability_contracts_test.dart b/test/maintainability_contracts_test.dart index 0100336a..07d74209 100644 --- a/test/maintainability_contracts_test.dart +++ b/test/maintainability_contracts_test.dart @@ -249,12 +249,16 @@ void main() { 'COPYRIGHT': 'Copyright', 'COMPOSER': 'Composer', 'COMMENT': 'Comment', + 'ITUNESADVISORY': '1', 'REPLAYGAIN_TRACK_GAIN': '-5.00 dB', 'BIT_DEPTH': '24', }; final native = AudioMetadataMapper.vorbisToNativeChunkFields(metadata); final m4a = AudioMetadataMapper.convertToM4aTags(metadata); + final m4aIdentity = AudioMetadataMapper.m4aReleaseIdentityFields( + metadata, + ); final id3 = AudioMetadataMapper.convertToId3Tags(metadata); expect(native['title'], 'Track'); @@ -272,9 +276,12 @@ void main() { expect(native['copyright'], 'Copyright'); expect(native['composer'], 'Composer'); expect(native['comment'], 'Comment'); + expect(native['explicit'], '1'); expect(m4a['isrc'], 'TEST12345678'); expect(m4a['lyrics'], 'Lyrics'); + expect(m4aIdentity['explicit'], '1'); expect(id3['TSRC'], 'TEST12345678'); + expect(id3['ITUNESADVISORY'], '1'); expect(id3['REPLAYGAIN_TRACK_GAIN'], '-5.00 dB'); expect(id3['title'], 'Track'); expect(id3['artist'], 'Artist'); @@ -298,6 +305,11 @@ void main() { entries.where((entry) => entry.key == 'TITLE').single.value, 'Track', ); + final advisory = AudioMetadataMapper.buildVorbisMetadataEntries({ + 'EXPLICIT': 'true', + }).single; + expect(advisory.key, 'ITUNESADVISORY'); + expect(advisory.value, '1'); }); test('writes one ID3v2.3 USLT frame and replaces it on update', () {