mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
fix(metadata): preserve WAV and AIFF tags
This commit is contained in:
@@ -93,6 +93,11 @@ internal fun MainActivity.extFromFileName(name: String): String {
|
||||
name.endsWith(".opus") -> ".opus"
|
||||
name.endsWith(".flac") -> ".flac"
|
||||
name.endsWith(".ogg") -> ".ogg"
|
||||
name.endsWith(".wave") -> ".wav"
|
||||
name.endsWith(".wav") -> ".wav"
|
||||
name.endsWith(".aiff") -> ".aiff"
|
||||
name.endsWith(".aifc") -> ".aifc"
|
||||
name.endsWith(".aif") -> ".aif"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
@@ -464,4 +469,3 @@ internal fun MainActivity.runPostProcessingSafV2(fileUriStr: String, metadataJso
|
||||
respObj.put("file_path", uri.toString())
|
||||
return respObj.toString()
|
||||
}
|
||||
|
||||
|
||||
@@ -566,6 +566,11 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
|
||||
coverData, _, err = extractMP3CoverArt(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||
coverData, _, err = extractOggCoverArt(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".wav") ||
|
||||
strings.HasSuffix(lower, ".aiff") ||
|
||||
strings.HasSuffix(lower, ".aif") ||
|
||||
strings.HasSuffix(lower, ".aifc") {
|
||||
coverData, _, err = extractWAVAIFFCover(audioPath)
|
||||
} else {
|
||||
return fmt.Errorf("unsupported audio format for cover extraction")
|
||||
}
|
||||
|
||||
+31
-8
@@ -744,20 +744,37 @@ func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadCoverForTag(fields map[string]string) ([]byte, string) {
|
||||
func loadCoverForTag(fields map[string]string) ([]byte, string, error) {
|
||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||
if coverPath == "" {
|
||||
return nil, ""
|
||||
return nil, "", nil
|
||||
}
|
||||
data, err := os.ReadFile(coverPath)
|
||||
if err != nil || len(data) == 0 {
|
||||
return nil, ""
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read cover art: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, "", fmt.Errorf("cover art is empty")
|
||||
}
|
||||
mime := "image/jpeg"
|
||||
if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
|
||||
switch {
|
||||
case len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47:
|
||||
mime = "image/png"
|
||||
case len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP":
|
||||
mime = "image/webp"
|
||||
case len(data) >= 6 && (string(data[0:6]) == "GIF87a" || string(data[0:6]) == "GIF89a"):
|
||||
mime = "image/gif"
|
||||
default:
|
||||
switch strings.ToLower(filepath.Ext(coverPath)) {
|
||||
case ".png":
|
||||
mime = "image/png"
|
||||
case ".webp":
|
||||
mime = "image/webp"
|
||||
case ".gif":
|
||||
mime = "image/gif"
|
||||
}
|
||||
}
|
||||
return data, mime
|
||||
return data, mime, nil
|
||||
}
|
||||
|
||||
func audioMetadataFromEditFields(fields map[string]string) *AudioMetadata {
|
||||
@@ -851,7 +868,10 @@ func WriteWAVTags(filePath string, fields map[string]string) error {
|
||||
existing, _ := ReadWAVTags(filePath)
|
||||
meta := mergeEditFieldsOntoExisting(existing, fields)
|
||||
|
||||
coverData, coverMIME := loadCoverForTag(fields)
|
||||
coverData, coverMIME, err := loadCoverForTag(fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if coverData == nil {
|
||||
// Preserve an existing embedded cover when no new one is supplied.
|
||||
if f, err := os.Open(filePath); err == nil {
|
||||
@@ -871,7 +891,10 @@ func WriteAIFFTags(filePath string, fields map[string]string) error {
|
||||
existing, _ := ReadAIFFTags(filePath)
|
||||
meta := mergeEditFieldsOntoExisting(existing, fields)
|
||||
|
||||
coverData, coverMIME := loadCoverForTag(fields)
|
||||
coverData, coverMIME, err := loadCoverForTag(fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if coverData == nil {
|
||||
if f, err := os.Open(filePath); err == nil {
|
||||
if p, perr := streamProbeAIFF(f); perr == nil && len(p.id3) > 0 {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTestWAV(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
body.WriteString("WAVE")
|
||||
|
||||
fmtData := make([]byte, 16)
|
||||
binary.LittleEndian.PutUint16(fmtData[0:2], 1)
|
||||
binary.LittleEndian.PutUint16(fmtData[2:4], 2)
|
||||
binary.LittleEndian.PutUint32(fmtData[4:8], 44100)
|
||||
binary.LittleEndian.PutUint32(fmtData[8:12], 44100*2*2)
|
||||
binary.LittleEndian.PutUint16(fmtData[12:14], 4)
|
||||
binary.LittleEndian.PutUint16(fmtData[14:16], 16)
|
||||
writeTestRIFFChunk(&body, "fmt ", fmtData, true)
|
||||
writeTestRIFFChunk(&body, "data", []byte{0, 0, 0, 0}, true)
|
||||
|
||||
var out bytes.Buffer
|
||||
out.WriteString("RIFF")
|
||||
if err := binary.Write(&out, binary.LittleEndian, uint32(body.Len())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Write(body.Bytes())
|
||||
if err := os.WriteFile(path, out.Bytes(), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestAIFF(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
body.WriteString("AIFF")
|
||||
|
||||
comm := make([]byte, 18)
|
||||
binary.BigEndian.PutUint16(comm[0:2], 2)
|
||||
binary.BigEndian.PutUint32(comm[2:6], 1)
|
||||
binary.BigEndian.PutUint16(comm[6:8], 16)
|
||||
copy(comm[8:18], []byte{0x40, 0x0e, 0xac, 0x44, 0, 0, 0, 0, 0, 0})
|
||||
writeTestRIFFChunk(&body, "COMM", comm, false)
|
||||
writeTestRIFFChunk(&body, "SSND", make([]byte, 12), false)
|
||||
|
||||
var out bytes.Buffer
|
||||
out.WriteString("FORM")
|
||||
if err := binary.Write(&out, binary.BigEndian, uint32(body.Len())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Write(body.Bytes())
|
||||
if err := os.WriteFile(path, out.Bytes(), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestRIFFChunk(out *bytes.Buffer, id string, data []byte, littleEndian bool) {
|
||||
out.WriteString(id)
|
||||
if littleEndian {
|
||||
_ = binary.Write(out, binary.LittleEndian, uint32(len(data)))
|
||||
} else {
|
||||
_ = binary.Write(out, binary.BigEndian, uint32(len(data)))
|
||||
}
|
||||
out.Write(data)
|
||||
if len(data)&1 == 1 {
|
||||
out.WriteByte(0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWAVAIFFMetadataAndCoverRoundTrip(t *testing.T) {
|
||||
cover := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4}
|
||||
fields := map[string]string{
|
||||
"title": "Judul Lagu",
|
||||
"artist": "Artis",
|
||||
"album": "Album",
|
||||
"album_artist": "Album Artis",
|
||||
"date": "2026-08-01",
|
||||
"track_number": "3",
|
||||
"track_total": "12",
|
||||
"disc_number": "1",
|
||||
"disc_total": "2",
|
||||
"genre": "Pop",
|
||||
"isrc": "TEST12345678",
|
||||
"lyrics": "Baris pertama\nBaris kedua",
|
||||
"label": "Label",
|
||||
"copyright": "Copyright",
|
||||
"composer": "Komposer",
|
||||
"comment": "Komentar",
|
||||
"replaygain_track_gain": "-5.00 dB",
|
||||
"replaygain_track_peak": "0.987654",
|
||||
"replaygain_album_gain": "-4.00 dB",
|
||||
"replaygain_album_peak": "0.998877",
|
||||
}
|
||||
|
||||
formats := []struct {
|
||||
name string
|
||||
ext string
|
||||
write func(*testing.T, string)
|
||||
tags func(string) (*AudioMetadata, error)
|
||||
edit func(string, map[string]string) error
|
||||
method string
|
||||
}{
|
||||
{name: "WAV", ext: ".wav", write: writeTestWAV, tags: ReadWAVTags, edit: WriteWAVTags, method: "native_wav"},
|
||||
{name: "AIFF", ext: ".aiff", write: writeTestAIFF, tags: ReadAIFFTags, edit: WriteAIFFTags, method: "native_aiff"},
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
format := format
|
||||
t.Run(format.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "track"+format.ext)
|
||||
coverPath := filepath.Join(dir, "cover.png")
|
||||
format.write(t, path)
|
||||
if err := os.WriteFile(coverPath, cover, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeFields := make(map[string]string, len(fields)+1)
|
||||
for key, value := range fields {
|
||||
writeFields[key] = value
|
||||
}
|
||||
writeFields["cover_path"] = coverPath
|
||||
metadataJSON, err := json.Marshal(writeFields)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
responseJSON, err := EditFileMetadata(path, string(metadataJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("EditFileMetadata: %v", err)
|
||||
}
|
||||
var response map[string]any
|
||||
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
|
||||
t.Fatalf("decode EditFileMetadata response: %v", err)
|
||||
}
|
||||
if response["success"] != true || response["method"] != format.method {
|
||||
t.Fatalf("EditFileMetadata response = %v", response)
|
||||
}
|
||||
|
||||
meta, err := format.tags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read tags: %v", err)
|
||||
}
|
||||
assertWAVAIFFTestMetadata(t, meta)
|
||||
|
||||
extracted, mime, err := extractWAVAIFFCover(path)
|
||||
if err != nil {
|
||||
t.Fatalf("extract cover: %v", err)
|
||||
}
|
||||
if mime != "image/png" || !bytes.Equal(extracted, cover) {
|
||||
t.Fatalf("cover = %q (%x), want image/png (%x)", mime, extracted, cover)
|
||||
}
|
||||
|
||||
outputCover := filepath.Join(dir, "extracted.bin")
|
||||
if err := ExtractCoverToFile(path, outputCover); err != nil {
|
||||
t.Fatalf("ExtractCoverToFile: %v", err)
|
||||
}
|
||||
outputBytes, err := os.ReadFile(outputCover)
|
||||
if err != nil || !bytes.Equal(outputBytes, cover) {
|
||||
t.Fatalf("ExtractCoverToFile bytes = %x, err=%v", outputBytes, err)
|
||||
}
|
||||
|
||||
// A partial Edit Metadata update must retain every untouched field and
|
||||
// the existing cover instead of rebuilding a sparse tag.
|
||||
if err := format.edit(path, map[string]string{"title": "Judul Baru"}); err != nil {
|
||||
t.Fatalf("partial edit: %v", err)
|
||||
}
|
||||
meta, err = format.tags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read edited tags: %v", err)
|
||||
}
|
||||
if meta.Title != "Judul Baru" || meta.Artist != "Artis" || meta.TotalTracks != 12 {
|
||||
t.Fatalf("partial edit lost metadata: %+v", meta)
|
||||
}
|
||||
extracted, _, err = extractWAVAIFFCover(path)
|
||||
if err != nil || !bytes.Equal(extracted, cover) {
|
||||
t.Fatalf("partial edit lost cover: %x, err=%v", extracted, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWAVAIFFTagWriteRejectsMissingRequestedCover(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "track.wav")
|
||||
writeTestWAV(t, path)
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = WriteWAVTags(path, map[string]string{
|
||||
"title": "Must not be partially written",
|
||||
"cover_path": filepath.Join(t.TempDir(), "missing.jpg"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("WriteWAVTags succeeded with an inaccessible requested cover")
|
||||
}
|
||||
after, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("WAV changed even though requested cover could not be read")
|
||||
}
|
||||
}
|
||||
|
||||
func assertWAVAIFFTestMetadata(t *testing.T, meta *AudioMetadata) {
|
||||
t.Helper()
|
||||
if meta.Title != "Judul Lagu" ||
|
||||
meta.Artist != "Artis" ||
|
||||
meta.Album != "Album" ||
|
||||
meta.AlbumArtist != "Album Artis" ||
|
||||
meta.Date != "2026-08-01" ||
|
||||
meta.TrackNumber != 3 ||
|
||||
meta.TotalTracks != 12 ||
|
||||
meta.DiscNumber != 1 ||
|
||||
meta.TotalDiscs != 2 ||
|
||||
meta.Genre != "Pop" ||
|
||||
meta.ISRC != "TEST12345678" ||
|
||||
meta.Lyrics != "Baris pertama\nBaris kedua" ||
|
||||
meta.Label != "Label" ||
|
||||
meta.Copyright != "Copyright" ||
|
||||
meta.Composer != "Komposer" ||
|
||||
meta.Comment != "Komentar" ||
|
||||
meta.ReplayGainTrackGain != "-5.00 dB" ||
|
||||
meta.ReplayGainTrackPeak != "0.987654" ||
|
||||
meta.ReplayGainAlbumGain != "-4.00 dB" ||
|
||||
meta.ReplayGainAlbumPeak != "0.998877" {
|
||||
t.Fatalf("metadata did not round-trip: %+v", meta)
|
||||
}
|
||||
}
|
||||
@@ -2346,7 +2346,18 @@ class FFmpegService {
|
||||
),
|
||||
processing: processing,
|
||||
);
|
||||
arguments.addAll(['-c:a', codec, '-map_metadata', '-1', outputPath, '-y']);
|
||||
arguments.addAll(['-c:a', codec, '-map_metadata', '-1']);
|
||||
|
||||
// Keep a container-native metadata fallback for software that does not
|
||||
// inspect WAV/AIFF ID3 chunks. FFmpeg writes common WAV fields into
|
||||
// LIST/INFO and the fields supported by AIFF into its native text chunks.
|
||||
// The native writer below remains authoritative for the complete field
|
||||
// set (including track/disc totals, lyrics, ReplayGain, and cover art).
|
||||
AudioMetadataMapper.appendMappedMetadataArguments(
|
||||
arguments,
|
||||
AudioMetadataMapper.convertToId3Tags(metadata),
|
||||
);
|
||||
arguments.addAll([outputPath, '-y']);
|
||||
|
||||
_log.i(
|
||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to '
|
||||
@@ -2368,9 +2379,11 @@ class FFmpegService {
|
||||
if (hasMetadata || hasCover) {
|
||||
final ok = await _embedChunkTagsNative(outputPath, metadata, coverPath);
|
||||
if (!ok) {
|
||||
_log.w(
|
||||
'Native tag embed failed for $container output (file kept untagged)',
|
||||
);
|
||||
// Metadata/cover preservation is part of the requested conversion.
|
||||
// Publishing an apparently successful but untagged file is data loss.
|
||||
_log.e('Native tag embed failed for $container output');
|
||||
await _cleanupConversionOutput(outputPlan);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2398,7 +2411,12 @@ class FFmpegService {
|
||||
if (fields.isEmpty) return true;
|
||||
try {
|
||||
final res = await PlatformBridge.editFileMetadata(path, fields);
|
||||
return res['error'] == null;
|
||||
final error = res['error'];
|
||||
if (error != null) {
|
||||
_log.w('editFileMetadata for $path failed: $error');
|
||||
return false;
|
||||
}
|
||||
return res['success'] == true;
|
||||
} catch (e) {
|
||||
_log.w('editFileMetadata for $path failed: $e');
|
||||
return false;
|
||||
|
||||
@@ -235,11 +235,20 @@ void main() {
|
||||
group('audio metadata modules', () {
|
||||
test('maps shared tags consistently for native, M4A, and ID3 writers', () {
|
||||
final metadata = {
|
||||
'TITLE': 'Track',
|
||||
'ARTIST': 'Artist',
|
||||
'ALBUM': 'Album',
|
||||
'ALBUM_ARTIST': 'Album Artist',
|
||||
'TRACKNUMBER': '3/12',
|
||||
'DISCNUMBER': '1/2',
|
||||
'DATE': '2026-08-01',
|
||||
'GENRE': 'Pop',
|
||||
'ISRC': 'TEST12345678',
|
||||
'UNSYNCEDLYRICS': 'Lyrics',
|
||||
'ORGANIZATION': 'Label',
|
||||
'COPYRIGHT': 'Copyright',
|
||||
'COMPOSER': 'Composer',
|
||||
'COMMENT': 'Comment',
|
||||
'REPLAYGAIN_TRACK_GAIN': '-5.00 dB',
|
||||
'BIT_DEPTH': '24',
|
||||
};
|
||||
@@ -248,16 +257,30 @@ void main() {
|
||||
final m4a = AudioMetadataMapper.convertToM4aTags(metadata);
|
||||
final id3 = AudioMetadataMapper.convertToId3Tags(metadata);
|
||||
|
||||
expect(native['title'], 'Track');
|
||||
expect(native['artist'], 'Artist');
|
||||
expect(native['album'], 'Album');
|
||||
expect(native['album_artist'], 'Album Artist');
|
||||
expect(native['track_number'], '3');
|
||||
expect(native['track_total'], '12');
|
||||
expect(native['disc_number'], '1');
|
||||
expect(native['disc_total'], '2');
|
||||
expect(native['replaygain_track_gain'], '-5.00 dB');
|
||||
expect(native['date'], '2026-08-01');
|
||||
expect(native['genre'], 'Pop');
|
||||
expect(native['label'], 'Label');
|
||||
expect(native['copyright'], 'Copyright');
|
||||
expect(native['composer'], 'Composer');
|
||||
expect(native['comment'], 'Comment');
|
||||
expect(m4a['isrc'], 'TEST12345678');
|
||||
expect(m4a['lyrics'], 'Lyrics');
|
||||
expect(id3['TSRC'], 'TEST12345678');
|
||||
expect(id3['REPLAYGAIN_TRACK_GAIN'], '-5.00 dB');
|
||||
expect(id3['title'], 'Track');
|
||||
expect(id3['artist'], 'Artist');
|
||||
expect(id3['album'], 'Album');
|
||||
expect(id3['track'], '3/12');
|
||||
expect(id3['disc'], '1/2');
|
||||
expect(native, isNot(contains('bit_depth')));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user