fix(metadata): preserve explicit advisory across conversions

This commit is contained in:
zarzet
2026-08-22 23:24:56 +07:00
parent 19591bdf19
commit 7557ffbf85
7 changed files with 118 additions and 17 deletions
+4
View File
@@ -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'];
+48
View File
@@ -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<String, String> m4aReleaseIdentityFields(
Map<String, String> metadata,
) {
final fields = <String, String>{};
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<String, String> convertToId3Tags(Map<String, String> metadata) {
final id3 = <String, String>{};
@@ -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,
};
}
}
+3
View File
@@ -244,6 +244,9 @@ Future<void> _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);
+19 -17
View File
@@ -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<void> _writeM4AReleaseIdentityTags(
static Future<bool> _writeM4AReleaseIdentityTags(
String m4aPath,
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 '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;
}
}
+8
View File
@@ -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') {
+24
View File
@@ -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 = <String, String>{};
mergePlatformMetadataForTagEmbed(
target: metadata,
source: {'explicit': true},
);
expect(metadata['ITUNESADVISORY'], '1');
});
test('removes a stale explicit advisory for a non-explicit file', () {
final metadata = <String, String>{'ITUNESADVISORY': '1'};
mergePlatformMetadataForTagEmbed(
target: metadata,
source: {'explicit': false},
);
expect(metadata, isNot(contains('ITUNESADVISORY')));
});
});
}
+12
View File
@@ -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', () {