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:
zarzet
2026-06-14 15:00:34 +07:00
parent ded8b68098
commit ccc93f881a
7 changed files with 284 additions and 51 deletions
@@ -2643,6 +2643,19 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"writeM4AFreeformTags" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
val response = withContext(Dispatchers.IO) {
try {
Gobackend.writeM4AFreeformTags(filePath, metadataJson)
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "writeM4AFreeformTags failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"writeTempToSaf" -> {
val tempPath = call.argument<String>("temp_path") ?: ""
val safUri = call.argument<String>("saf_uri") ?: ""
+19
View File
@@ -1510,6 +1510,25 @@ func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefi
return string(jsonBytes), nil
}
// WriteM4AFreeformTags writes ISRC and label into an M4A/MP4 file as iTunes
// freeform atoms. FFmpeg's MP4 muxer ignores these keys, so they must be
// written natively after the FFmpeg metadata pass for the values to persist.
// Only keys present in the JSON are touched; an empty value clears the tag.
func WriteM4AFreeformTags(filePath, metadataJSON string) (string, error) {
var fields map[string]string
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
return "", fmt.Errorf("invalid metadata JSON: %w", err)
}
if err := EditM4AFreeformText(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A freeform tags: %w", err)
}
resp := map[string]any{"success": true, "method": "native_m4a_freeform"}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg.
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
var fields map[string]string
+68
View File
@@ -0,0 +1,68 @@
package gobackend
import (
"os"
"path/filepath"
"testing"
)
func TestEditM4AFreeformTextWritesISRCAndLabel(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "track.m4a")
ilst := buildM4ATextTag("\xa9nam", "Title")
if err := os.WriteFile(path, buildM4AFileWithIlst(ilst, true), 0600); err != nil {
t.Fatal(err)
}
if err := EditM4AFreeformText(path, map[string]string{
"isrc": "USRC17607839",
"label": "Some Label",
}); err != nil {
t.Fatalf("EditM4AFreeformText: %v", err)
}
meta, err := ReadM4ATags(path)
if err != nil {
t.Fatalf("ReadM4ATags: %v", err)
}
if meta.ISRC != "USRC17607839" {
t.Fatalf("ISRC = %q, want USRC17607839", meta.ISRC)
}
if meta.Label != "Some Label" {
t.Fatalf("Label = %q, want Some Label", meta.Label)
}
if meta.Title != "Title" {
t.Fatalf("Title = %q, want Title (existing tag must survive)", meta.Title)
}
}
func TestEditM4AFreeformTextReplacesExisting(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "track.m4a")
ilst := buildM4ATextTag("\xa9nam", "Title")
ilst = append(ilst, buildM4AFreeformAtom("ISRC", "OLDISRC00001")...)
ilst = append(ilst, buildM4AFreeformAtom("LABEL", "Old Label")...)
if err := os.WriteFile(path, buildM4AFileWithIlst(ilst, true), 0600); err != nil {
t.Fatal(err)
}
if err := EditM4AFreeformText(path, map[string]string{
"isrc": "NEWISRC00002",
"label": "",
}); err != nil {
t.Fatalf("EditM4AFreeformText: %v", err)
}
meta, err := ReadM4ATags(path)
if err != nil {
t.Fatalf("ReadM4ATags: %v", err)
}
if meta.ISRC != "NEWISRC00002" {
t.Fatalf("ISRC = %q, want NEWISRC00002", meta.ISRC)
}
if meta.Label != "" {
t.Fatalf("Label = %q, want empty (cleared)", meta.Label)
}
}
+75 -23
View File
@@ -1432,6 +1432,51 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
return nil
}
remove := map[string]struct{}{
"REPLAYGAIN_TRACK_GAIN": {},
"REPLAYGAIN_TRACK_PEAK": {},
"REPLAYGAIN_ALBUM_GAIN": {},
"REPLAYGAIN_ALBUM_PEAK": {},
"ITUNNORM": {},
}
order := []string{
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
"iTunNORM",
}
tags := make([]m4aFreeformTag, 0, len(order))
for _, key := range order {
value := strings.TrimSpace(replayGainFields[key])
if value == "" {
continue
}
name := key
if key != "iTunNORM" {
name = strings.ToLower(key)
}
tags = append(tags, m4aFreeformTag{name: name, value: value})
}
return writeM4AFreeformTags(filePath, remove, tags)
}
type m4aFreeformTag struct {
name string
value string
}
// writeM4AFreeformTags rewrites the ilst atom in place: it drops every existing
// freeform ("----") atom whose uppercased name is in `remove`, then appends the
// supplied tags (empty values are skipped, which effectively clears the field).
// Atom sizes are fixed up along the ilst -> meta -> udta -> moov chain.
//
// FFmpeg's MP4 muxer only writes a fixed set of recognized keys to the ilst, so
// fields like ISRC and LABEL are silently dropped when written via -metadata.
// Writing them as iTunes freeform atoms natively is the only way they persist.
func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4aFreeformTag) error {
f, err := os.Open(filePath)
if err != nil {
return err
@@ -1456,13 +1501,6 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
bodyStart := path.ilst.offset + path.ilst.headerSize
bodyEnd := path.ilst.offset + path.ilst.size
newBody := make([]byte, 0, int(path.ilst.size))
targets := map[string]struct{}{
"REPLAYGAIN_TRACK_GAIN": {},
"REPLAYGAIN_TRACK_PEAK": {},
"REPLAYGAIN_ALBUM_GAIN": {},
"REPLAYGAIN_ALBUM_PEAK": {},
"ITUNNORM": {},
}
for pos := bodyStart; pos+8 <= bodyEnd; {
header, readErr := readAtomHeaderAt(f, pos, info.Size())
@@ -1480,7 +1518,7 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
if header.typ == "----" {
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
if freeformErr == nil {
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
if _, ok := remove[strings.ToUpper(strings.TrimSpace(name))]; ok {
keep = false
}
}
@@ -1492,23 +1530,11 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
pos += header.size
}
order := []string{
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
"iTunNORM",
}
for _, key := range order {
value := strings.TrimSpace(replayGainFields[key])
if value == "" {
for _, tag := range tags {
if strings.TrimSpace(tag.value) == "" {
continue
}
name := key
if key != "iTunNORM" {
name = strings.ToLower(key)
}
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
newBody = append(newBody, buildM4AFreeformAtom(tag.name, tag.value)...)
}
newIlst := buildM4AAtom("ilst", newBody)
@@ -1535,6 +1561,32 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
return os.WriteFile(filePath, updated, 0o644)
}
// EditM4AFreeformText writes ISRC and label tags into an M4A/MP4 file as iTunes
// freeform atoms. These keys are not part of FFmpeg's MP4 metadata key set, so
// they must be written natively for the values to actually persist. An empty
// value clears the corresponding tag. Other (recognized) tags are left intact.
func EditM4AFreeformText(filePath string, fields map[string]string) error {
_, hasISRC := fields["isrc"]
_, hasLabel := fields["label"]
if !hasISRC && !hasLabel {
return nil
}
remove := map[string]struct{}{}
tags := make([]m4aFreeformTag, 0, 2)
if hasISRC {
remove["ISRC"] = struct{}{}
tags = append(tags, m4aFreeformTag{name: "ISRC", value: strings.TrimSpace(fields["isrc"])})
}
if hasLabel {
remove["LABEL"] = struct{}{}
remove["ORGANIZATION"] = struct{}{}
tags = append(tags, m4aFreeformTag{name: "LABEL", value: strings.TrimSpace(fields["label"])})
}
return writeM4AFreeformTags(filePath, remove, tags)
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
+58 -28
View File
@@ -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,
};
});
}
+35
View File
@@ -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>{};
+16
View File
@@ -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(