mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-12 02:37:18 +02:00
fix(metadata): write M4A ISRC/label natively and read all edited fields from file
FFmpeg's MP4 muxer drops ISRC and label, so write them as iTunes freeform atoms natively after every M4A embed pass. The metadata screen now reads all edited fields from the file on load (file is the source of truth), fixing edited values reverting to stale cached values after reopening.
This commit is contained in:
1 parent
ded8b68098
commit
ccc93f881a
7 files changed
+284
-51
No files matched your search
@@ -382,6 +382,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
: null;
|
||||
final resolvedDuration = readPositiveInt(metadata['duration']);
|
||||
final resolvedAlbum = metadata['album']?.toString();
|
||||
final resolvedTitle = metadata['title']?.toString();
|
||||
final resolvedArtist = metadata['artist']?.toString();
|
||||
final resolvedAlbumArtist = metadata['album_artist']?.toString();
|
||||
final resolvedDate = metadata['date']?.toString();
|
||||
final resolvedGenre = metadata['genre']?.toString();
|
||||
final resolvedQuality = _displayQualityForValues(
|
||||
format: resolvedFormat ?? _storedAudioFormat,
|
||||
bitDepth: resolvedBitDepth ?? bitDepth,
|
||||
@@ -390,10 +395,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
storedQuality: _quality,
|
||||
);
|
||||
|
||||
final needsAlbum =
|
||||
resolvedAlbum != null &&
|
||||
resolvedAlbum.isNotEmpty &&
|
||||
(albumName.isEmpty);
|
||||
final needsDuration =
|
||||
resolvedDuration != null &&
|
||||
resolvedDuration > 0 &&
|
||||
@@ -408,6 +409,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final resolvedComposer = metadata['composer']?.toString();
|
||||
final resolvedLabel = metadata['label']?.toString();
|
||||
final resolvedCopyright = metadata['copyright']?.toString();
|
||||
final resolvedISRC = metadata['isrc']?.toString();
|
||||
final needsTrackNumber =
|
||||
resolvedTrackNumber != null &&
|
||||
resolvedTrackNumber > 0 &&
|
||||
@@ -428,14 +430,30 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
resolvedComposer != null &&
|
||||
resolvedComposer.isNotEmpty &&
|
||||
(composer == null || composer!.isEmpty);
|
||||
final needsLabel =
|
||||
resolvedLabel != null &&
|
||||
resolvedLabel.isNotEmpty &&
|
||||
(label == null || label!.isEmpty);
|
||||
final needsCopyright =
|
||||
resolvedCopyright != null &&
|
||||
resolvedCopyright.isNotEmpty &&
|
||||
(copyright == null || copyright!.isEmpty);
|
||||
// The file is the source of truth for edited tags: an edit writes the
|
||||
// new value into the file, but the cached history item may still carry
|
||||
// the old one. Prefer the file value whenever present so re-opening the
|
||||
// screen reflects the latest saved tags instead of the stale model.
|
||||
// (Empty file values never override the model, so nothing is hidden.)
|
||||
bool present(String? v) => v != null && v.trim().isNotEmpty;
|
||||
final fileHasTitle = present(resolvedTitle);
|
||||
final fileHasArtist = present(resolvedArtist);
|
||||
final fileHasAlbumArtist = present(resolvedAlbumArtist);
|
||||
final fileHasAlbum = present(resolvedAlbum);
|
||||
final fileHasDate = present(resolvedDate);
|
||||
final fileHasGenre = present(resolvedGenre);
|
||||
final fileHasComposer = present(resolvedComposer);
|
||||
final fileHasCopyright = present(resolvedCopyright);
|
||||
final fileHasISRC = present(resolvedISRC);
|
||||
final fileHasLabel = present(resolvedLabel);
|
||||
final fileHasTrackNumber =
|
||||
resolvedTrackNumber != null && resolvedTrackNumber > 0;
|
||||
final fileHasTotalTracks =
|
||||
resolvedTotalTracks != null && resolvedTotalTracks > 0;
|
||||
final fileHasDiscNumber =
|
||||
resolvedDiscNumber != null && resolvedDiscNumber > 0;
|
||||
final fileHasTotalDiscs =
|
||||
resolvedTotalDiscs != null && resolvedTotalDiscs > 0;
|
||||
|
||||
final shouldPersistResolvedAudioMetadata =
|
||||
!_isLocalItem &&
|
||||
@@ -453,15 +471,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
if ((resolvedBitDepth != null ||
|
||||
resolvedSampleRate != null ||
|
||||
needsAlbum ||
|
||||
fileHasTitle ||
|
||||
fileHasArtist ||
|
||||
fileHasAlbumArtist ||
|
||||
fileHasAlbum ||
|
||||
fileHasDate ||
|
||||
fileHasGenre ||
|
||||
needsDuration ||
|
||||
needsTrackNumber ||
|
||||
needsTotalTracks ||
|
||||
needsDiscNumber ||
|
||||
needsTotalDiscs ||
|
||||
needsComposer ||
|
||||
needsLabel ||
|
||||
needsCopyright ||
|
||||
fileHasTrackNumber ||
|
||||
fileHasTotalTracks ||
|
||||
fileHasDiscNumber ||
|
||||
fileHasTotalDiscs ||
|
||||
fileHasComposer ||
|
||||
fileHasISRC ||
|
||||
fileHasLabel ||
|
||||
fileHasCopyright ||
|
||||
isPlaceholderQualityLabel(_quality)) &&
|
||||
mounted) {
|
||||
setState(() {
|
||||
@@ -471,15 +495,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth,
|
||||
// ignore: use_null_aware_elements
|
||||
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
|
||||
if (needsAlbum) 'album': resolvedAlbum,
|
||||
if (fileHasTitle) 'title': resolvedTitle,
|
||||
if (fileHasArtist) 'artist': resolvedArtist,
|
||||
if (fileHasAlbumArtist) 'album_artist': resolvedAlbumArtist,
|
||||
if (fileHasAlbum) 'album': resolvedAlbum,
|
||||
if (fileHasDate) 'date': resolvedDate,
|
||||
if (fileHasGenre) 'genre': resolvedGenre,
|
||||
if (needsDuration) 'duration': resolvedDuration,
|
||||
if (needsTrackNumber) 'track_number': resolvedTrackNumber,
|
||||
if (needsTotalTracks) 'total_tracks': resolvedTotalTracks,
|
||||
if (needsDiscNumber) 'disc_number': resolvedDiscNumber,
|
||||
if (needsTotalDiscs) 'total_discs': resolvedTotalDiscs,
|
||||
if (needsComposer) 'composer': resolvedComposer,
|
||||
if (needsLabel) 'label': resolvedLabel,
|
||||
if (needsCopyright) 'copyright': resolvedCopyright,
|
||||
if (fileHasTrackNumber) 'track_number': resolvedTrackNumber,
|
||||
if (fileHasTotalTracks) 'total_tracks': resolvedTotalTracks,
|
||||
if (fileHasDiscNumber) 'disc_number': resolvedDiscNumber,
|
||||
if (fileHasTotalDiscs) 'total_discs': resolvedTotalDiscs,
|
||||
if (fileHasComposer) 'composer': resolvedComposer,
|
||||
if (fileHasISRC) 'isrc': resolvedISRC,
|
||||
if (fileHasLabel) 'label': resolvedLabel,
|
||||
if (fileHasCopyright) 'copyright': resolvedCopyright,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2014,6 +2014,13 @@ class FFmpegService {
|
||||
await tempFile.copy(m4aPath);
|
||||
await tempFile.delete();
|
||||
|
||||
// FFmpeg's MP4 muxer ignores ISRC and label, so write them natively
|
||||
// as iTunes freeform atoms. Only fields the caller supplied are
|
||||
// touched (an empty value clears the tag).
|
||||
if (metadata != null) {
|
||||
await _writeM4AFreeformTags(m4aPath, metadata);
|
||||
}
|
||||
|
||||
_log.d('M4A metadata embedded successfully');
|
||||
return m4aPath;
|
||||
} else {
|
||||
@@ -2805,6 +2812,34 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes ISRC and label into an M4A/MP4 file natively (iTunes freeform
|
||||
/// atoms), since FFmpeg's MP4 muxer drops these keys. Only keys present in
|
||||
/// [metadata] are written; an empty value clears the corresponding tag.
|
||||
static Future<void> _writeM4AFreeformTags(
|
||||
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 'ISRC':
|
||||
fields['isrc'] = entry.value;
|
||||
break;
|
||||
case 'LABEL':
|
||||
case 'ORGANIZATION':
|
||||
fields['label'] = entry.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fields.isEmpty) return;
|
||||
try {
|
||||
await PlatformBridge.writeM4AFreeformTags(m4aPath, fields);
|
||||
} catch (e) {
|
||||
_log.w('writeM4AFreeformTags failed for $m4aPath: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
|
||||
static Map<String, String> _convertToM4aTags(Map<String, String> metadata) {
|
||||
final m4aMap = <String, String>{};
|
||||
|
||||
@@ -793,6 +793,22 @@ class PlatformBridge {
|
||||
return _decodeRequiredMapResult(result, 'editFileMetadata');
|
||||
}
|
||||
|
||||
/// Writes ISRC and label into an M4A/MP4 file as iTunes freeform atoms.
|
||||
/// FFmpeg's MP4 muxer drops these keys, so they must be written natively
|
||||
/// after the FFmpeg metadata pass. [filePath] must be a local file path.
|
||||
/// Only the keys present in [fields] are touched; an empty value clears it.
|
||||
static Future<Map<String, dynamic>> writeM4AFreeformTags(
|
||||
String filePath,
|
||||
Map<String, String> fields,
|
||||
) async {
|
||||
final metadataJSON = jsonEncode(fields);
|
||||
final result = await _channel.invokeMethod('writeM4AFreeformTags', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': metadataJSON,
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'writeM4AFreeformTags');
|
||||
}
|
||||
|
||||
/// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries
|
||||
/// using the native Go FLAC writer, fixing FFmpeg's tag deduplication.
|
||||
static Future<Map<String, dynamic>> rewriteSplitArtistTags(
|
||||
|
||||
Reference in new issue
Block a user