mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +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:
@@ -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") ?: ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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