diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index bb9e8384..97e9e262 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2643,6 +2643,19 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "writeM4AFreeformTags" -> { + val filePath = call.argument("file_path") ?: "" + val metadataJson = call.argument("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("temp_path") ?: "" val safUri = call.argument("saf_uri") ?: "" diff --git a/go_backend/exports.go b/go_backend/exports.go index c9f14d0b..29232886 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -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 diff --git a/go_backend/m4a_freeform_write_test.go b/go_backend/m4a_freeform_write_test.go new file mode 100644 index 00000000..563f353a --- /dev/null +++ b/go_backend/m4a_freeform_write_test.go @@ -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) + } +} diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 763678a4..caea5664 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -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) diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index f1817d6e..6eb8779c 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -382,6 +382,11 @@ class _TrackMetadataScreenState extends ConsumerState { : 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 { storedQuality: _quality, ); - final needsAlbum = - resolvedAlbum != null && - resolvedAlbum.isNotEmpty && - (albumName.isEmpty); final needsDuration = resolvedDuration != null && resolvedDuration > 0 && @@ -408,6 +409,7 @@ class _TrackMetadataScreenState extends ConsumerState { 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 { 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 { 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 { 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, }; }); } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index f4796244..1123a47d 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -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 _writeM4AFreeformTags( + 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 '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 _convertToM4aTags(Map metadata) { final m4aMap = {}; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 1add3860..e9305162 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -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> writeM4AFreeformTags( + String filePath, + Map 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> rewriteSplitArtistTags(