From b3bbe93c1e18efa3cc2772c2c16c93b733d8a15b Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 1 Aug 2026 15:33:20 +0700 Subject: [PATCH] fix(metadata): preserve WAV and AIFF tags --- .../com/zarz/spotiflac/MainActivitySafIo.kt | 6 +- go_backend/exports_metadata.go | 5 + go_backend/wav_aiff.go | 39 ++- go_backend/wav_aiff_test.go | 235 ++++++++++++++++++ lib/services/ffmpeg_service.dart | 28 ++- test/maintainability_contracts_test.dart | 23 ++ 6 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 go_backend/wav_aiff_test.go diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt index 6ea2fbd8..9cb30431 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt @@ -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() } - diff --git a/go_backend/exports_metadata.go b/go_backend/exports_metadata.go index f1a75585..97b2204e 100644 --- a/go_backend/exports_metadata.go +++ b/go_backend/exports_metadata.go @@ -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") } diff --git a/go_backend/wav_aiff.go b/go_backend/wav_aiff.go index 0cd3d042..cbd02992 100644 --- a/go_backend/wav_aiff.go +++ b/go_backend/wav_aiff.go @@ -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 { diff --git a/go_backend/wav_aiff_test.go b/go_backend/wav_aiff_test.go new file mode 100644 index 00000000..10a9f347 --- /dev/null +++ b/go_backend/wav_aiff_test.go @@ -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) + } +} diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 9d36c73e..fee4f1cc 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -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; diff --git a/test/maintainability_contracts_test.dart b/test/maintainability_contracts_test.dart index f353d74e..0100336a 100644 --- a/test/maintainability_contracts_test.dart +++ b/test/maintainability_contracts_test.dart @@ -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'))); });