mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-27 13:22:49 +02:00
fix(metadata): preserve explicit advisory across conversions
This commit is contained in:
@@ -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'];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user