feat(metadata): route MP3, Ogg, and M4A edits to the native writers

EditFileMetadata now answers native_mp3/native_ogg/native_m4a and only
falls back to method=ffmpeg when a native editor rejects the file, so
every editor UI switches off the remux path without Dart changes. The
download embed path does the same for flac/mp3/opus/m4a. Side effect:
ReplayGain for MP3/Opus was silently never written (the old dispatch
answered ffmpeg and the caller ignored it) - it is now written natively.
Covers all three editors with synthetic-file tests, including foreign-tag
preservation and stco offset tracking.
This commit is contained in:
zarzet
2026-07-11 12:47:28 +07:00
parent 89e5f4bde9
commit 49ecfe261a
4 changed files with 421 additions and 26 deletions
+2
View File
@@ -84,6 +84,8 @@ flutter_*.log
tool/
.claude/settings.local.json
.playwright-mcp/
.rtk/
CLAUDE.md
# FVM Version Cache
.fvm/
@@ -1212,34 +1212,48 @@ object NativeDownloadFinalizer {
val shouldEmbedLyrics = shouldResolveLyrics &&
lyrics.isNotBlank() &&
lyrics != "[instrumental:true]"
if (format == "flac") {
val coverFile = downloadCoverForMetadata(context, input)
val fields = JSONObject()
.put("title", title)
.put("artist", artist)
.put("album", album)
.put("album_artist", albumArtist)
.put("date", date)
.put("isrc", isrc)
.put("composer", composer)
.put("genre", genre)
.put("label", label)
.put("copyright", copyright)
if (trackNumberValue > 0) fields.put("track_number", trackNumberValue.toString())
if (totalTracksValue > 0) fields.put("track_total", totalTracksValue.toString())
if (discNumberValue > 0) fields.put("disc_number", discNumberValue.toString())
if (totalDiscsValue > 0) fields.put("disc_total", totalDiscsValue.toString())
if (coverFile != null) fields.put("cover_path", coverFile.absolutePath)
if (shouldEmbedLyrics) {
fields.put("lyrics", lyrics)
fields.put("unsyncedlyrics", lyrics)
}
try {
Gobackend.editFileMetadata(path, fields.toString())
// FLAC, MP3, Opus, and M4A all have native Go tag writers that edit the
// tag block atomically without an ffmpeg remux (which drops foreign
// frames and rewrites the whole container). The Go side answers
// method=ffmpeg when it cannot handle the file natively.
if (format == "flac" || format == "mp3" || format == "opus" || format == "m4a") {
val nativeCover = downloadCoverForMetadata(context, input)
val handledNatively = try {
val fields = JSONObject()
.put("title", title)
.put("artist", artist)
.put("album", album)
.put("album_artist", albumArtist)
.put("date", date)
.put("isrc", isrc)
.put("composer", composer)
.put("genre", genre)
.put("label", label)
.put("copyright", copyright)
if (trackNumberValue > 0) fields.put("track_number", trackNumberValue.toString())
if (totalTracksValue > 0) fields.put("track_total", totalTracksValue.toString())
if (discNumberValue > 0) fields.put("disc_number", discNumberValue.toString())
if (totalDiscsValue > 0) fields.put("disc_total", totalDiscsValue.toString())
if (nativeCover != null) fields.put("cover_path", nativeCover.absolutePath)
if (shouldEmbedLyrics) {
fields.put("lyrics", lyrics)
fields.put("unsyncedlyrics", lyrics)
}
val response = Gobackend.editFileMetadata(path, fields.toString())
val method = try {
JSONObject(response).optString("method", "")
} catch (_: Exception) {
""
}
method != "ffmpeg"
} catch (e: Exception) {
if (format == "flac") throw e
Log.w(TAG, "Native tag embed failed for $format: ${e.message}; falling back to ffmpeg")
false
} finally {
coverFile?.delete()
nativeCover?.delete()
}
return
if (handledNatively) return
}
val ext = normalizeExt(File(path).extension).ifBlank { ".tmp" }
+34
View File
@@ -573,6 +573,40 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
// MP3, Ogg/Opus, and M4A have native editors that preserve foreign
// tags and skip the ffmpeg remux. Any failure falls back to the ffmpeg
// response so callers keep the old behavior for exotic files.
isMp3 := strings.HasSuffix(lower, ".mp3")
isOggFile := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
if isMp3 {
if err := EditMP3Fields(filePath, fields); err != nil {
GoLog("[Metadata] Native MP3 edit failed, falling back to ffmpeg: %v\n", err)
} else {
resp := map[string]any{"success": true, "method": "native_mp3"}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
}
if isOggFile {
if err := EditOggFields(filePath, fields); err != nil {
GoLog("[Metadata] Native Ogg edit failed, falling back to ffmpeg: %v\n", err)
} else {
resp := map[string]any{"success": true, "method": "native_ogg"}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
}
if isM4AFile || isMP4ContainerFile(filePath) {
if err := EditM4AFields(filePath, fields); err != nil {
GoLog("[Metadata] Native M4A edit failed, falling back to ffmpeg: %v\n", err)
} else {
resp := map[string]any{"success": true, "method": "native_m4a"}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
}
resp := map[string]any{
"success": true,
"method": "ffmpeg",
+345
View File
@@ -0,0 +1,345 @@
package gobackend
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"strings"
"testing"
)
// --- MP3 -------------------------------------------------------------------
func writeTestMP3(t *testing.T, dir string, frames ...[]byte) (string, []byte) {
t.Helper()
audio := []byte("FAKEMP3AUDIODATA-0123456789")
tag := buildID3v23Tag(frames...)
path := filepath.Join(dir, "test.mp3")
if err := os.WriteFile(path, append(append([]byte{}, tag...), audio...), 0o644); err != nil {
t.Fatalf("write mp3: %v", err)
}
return path, audio
}
func TestEditMP3FieldsPreservesForeignFrames(t *testing.T) {
dir := t.TempDir()
popm := id3v23Frame("POPM", append([]byte("rater@example.com\x00"), 0xff))
foreignTXXX := id3UserTextFrame("TXXX", "MusicBrainz Id", "abc-123")
rgTXXX := id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-1.00 dB")
path, audio := writeTestMP3(t, dir,
id3TextFrame("TIT2", "Old Title"),
id3TextFrame("TRCK", "3"),
popm, foreignTXXX, rgTXXX,
)
err := EditMP3Fields(path, map[string]string{
"title": "New Title",
"track_total": "12",
"replaygain_track_gain": "-2.50 dB",
})
if err != nil {
t.Fatalf("EditMP3Fields: %v", err)
}
meta, err := ReadID3Tags(path)
if err != nil {
t.Fatalf("ReadID3Tags: %v", err)
}
if meta.Title != "New Title" {
t.Errorf("title = %q, want New Title", meta.Title)
}
if meta.TrackNumber != 3 || meta.TotalTracks != 12 {
t.Errorf("track = %d/%d, want 3/12 (merge with existing number)", meta.TrackNumber, meta.TotalTracks)
}
if meta.ReplayGainTrackGain != "-2.50 dB" {
t.Errorf("replaygain = %q, want -2.50 dB", meta.ReplayGainTrackGain)
}
raw := mustReadFile(t, path)
if !bytes.Contains(raw, []byte("POPM")) || !bytes.Contains(raw, []byte("rater@example.com")) {
t.Error("foreign POPM frame was dropped")
}
if !bytes.Contains(raw, []byte("MusicBrainz Id")) {
t.Error("foreign TXXX frame was dropped")
}
if bytes.Count(raw, []byte("REPLAYGAIN_TRACK_GAIN")) != 1 {
t.Error("edited TXXX description duplicated or missing")
}
if !bytes.HasSuffix(raw, audio) {
t.Error("audio bytes were modified")
}
}
func TestEditMP3FieldsClearsAndWithoutTag(t *testing.T) {
dir := t.TempDir()
path, audio := writeTestMP3(t, dir,
id3TextFrame("TIT2", "Old"),
id3CommentFrame("COMM", "remove me"),
)
if err := EditMP3Fields(path, map[string]string{"comment": ""}); err != nil {
t.Fatalf("clear comment: %v", err)
}
raw := mustReadFile(t, path)
if bytes.Contains(raw, []byte("remove me")) {
t.Error("cleared comment still present")
}
if meta, _ := ReadID3Tags(path); meta == nil || meta.Title != "Old" {
t.Error("untouched title lost")
}
// A bare MP3 with no ID3v2 header gets a fresh tag prepended.
bare := filepath.Join(dir, "bare.mp3")
if err := os.WriteFile(bare, audio, 0o644); err != nil {
t.Fatal(err)
}
if err := EditMP3Fields(bare, map[string]string{"title": "Fresh"}); err != nil {
t.Fatalf("EditMP3Fields on bare file: %v", err)
}
if meta, err := ReadID3Tags(bare); err != nil || meta.Title != "Fresh" {
t.Errorf("bare file title = %v (err %v), want Fresh", meta, err)
}
if !bytes.HasSuffix(mustReadFile(t, bare), audio) {
t.Error("bare file audio modified")
}
}
// --- M4A -------------------------------------------------------------------
// buildTestM4A assembles ftyp + moov(trak stub with stco + udta>meta>ilst) + mdat
// with moov BEFORE mdat so chunk-offset shifting is exercised.
func buildTestM4A(t *testing.T, ilstChildren []byte, mdatPayload []byte) ([]byte, int64) {
t.Helper()
ftyp := buildM4AAtom("ftyp", append([]byte("M4A "), make([]byte, 8)...))
hdlrPayload := make([]byte, 25)
copy(hdlrPayload[8:12], "mdir")
copy(hdlrPayload[12:16], "appl")
meta := buildM4AAtom("meta", append(append([]byte{0, 0, 0, 0}, buildM4AAtom("hdlr", hdlrPayload)...), buildM4AAtom("ilst", ilstChildren)...))
udta := buildM4AAtom("udta", meta)
// stco with one entry; the real value is patched below once mdat's
// position is known.
stcoPayload := make([]byte, 4+4+4)
binary.BigEndian.PutUint32(stcoPayload[4:8], 1)
stco := buildM4AAtom("stco", stcoPayload)
stbl := buildM4AAtom("stbl", stco)
minf := buildM4AAtom("minf", stbl)
mdia := buildM4AAtom("mdia", minf)
trak := buildM4AAtom("trak", mdia)
moov := buildM4AAtom("moov", append(append([]byte{}, trak...), udta...))
mdat := buildM4AAtom("mdat", mdatPayload)
file := append(append(append([]byte{}, ftyp...), moov...), mdat...)
chunkOffset := int64(len(ftyp) + len(moov) + 8) // first byte of mdat payload
idx := bytes.Index(file, []byte("stco"))
if idx < 0 {
t.Fatal("stco not found in synthetic file")
}
binary.BigEndian.PutUint32(file[idx+4+8:idx+4+12], uint32(chunkOffset))
return file, chunkOffset
}
func readTestM4ATitle(t *testing.T, data []byte) string {
t.Helper()
loc, ok := locateM4AIlstInBuf(data)
if !ok {
t.Fatal("ilst not found")
}
for pos := loc.ilst.body(); pos+8 <= loc.ilst.end(); {
child, ok := readMP4Box(data, pos)
if !ok {
t.Fatal("malformed ilst")
}
if child.typ == "\xa9nam" {
d, ok := readMP4Box(data, child.body())
if !ok || d.typ != "data" {
t.Fatal("no data atom in \xa9nam")
}
return string(data[d.body()+8 : d.end()])
}
pos = child.end()
}
return ""
}
func TestEditM4AFieldsPreservesAtomsAndShiftsChunkOffsets(t *testing.T) {
dir := t.TempDir()
existing := append([]byte{}, buildM4ATextAtom("\xa9nam", "Old")...)
existing = append(existing, buildM4ATextAtom("\xa9too", "SomeEncoder")...) // foreign, untouched
existing = append(existing, buildM4AFreeformAtom("MusicBrainz Track Id", "xyz")...)
mdatPayload := []byte("M4ADATA")
file, oldOffset := buildTestM4A(t, existing, mdatPayload)
path := filepath.Join(dir, "test.m4a")
if err := os.WriteFile(path, file, 0o644); err != nil {
t.Fatal(err)
}
if err := EditM4AFields(path, map[string]string{
"title": "A Much Longer Replacement Title",
"isrc": "USABC1234567",
}); err != nil {
t.Fatalf("EditM4AFields: %v", err)
}
updated := mustReadFile(t, path)
if got := readTestM4ATitle(t, updated); got != "A Much Longer Replacement Title" {
t.Errorf("title = %q", got)
}
if !bytes.Contains(updated, []byte("SomeEncoder")) {
t.Error("foreign \xa9too atom dropped")
}
if !bytes.Contains(updated, []byte("MusicBrainz Track Id")) {
t.Error("foreign freeform atom dropped")
}
if !bytes.Contains(updated, []byte("USABC1234567")) {
t.Error("ISRC freeform missing")
}
// stco entry must still point at the mdat payload.
idx := bytes.Index(updated, []byte("stco"))
if idx < 0 {
t.Fatal("stco lost")
}
newOffset := int64(binary.BigEndian.Uint32(updated[idx+4+8 : idx+4+12]))
payloadAt := bytes.Index(updated, mdatPayload)
if newOffset != int64(payloadAt) {
t.Errorf("stco offset %d does not track mdat payload at %d (was %d)", newOffset, payloadAt, oldOffset)
}
}
func TestEditM4AFieldsCreatesMissingChain(t *testing.T) {
dir := t.TempDir()
// moov with only a trak stub — no udta/meta/ilst.
stcoPayload := make([]byte, 12)
binary.BigEndian.PutUint32(stcoPayload[4:8], 1)
trak := buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("minf", buildM4AAtom("stbl", buildM4AAtom("stco", stcoPayload)))))
moov := buildM4AAtom("moov", trak)
ftyp := buildM4AAtom("ftyp", append([]byte("M4A "), make([]byte, 8)...))
mdat := buildM4AAtom("mdat", []byte("DATA"))
file := append(append(append([]byte{}, ftyp...), moov...), mdat...)
path := filepath.Join(dir, "bare.m4a")
if err := os.WriteFile(path, file, 0o644); err != nil {
t.Fatal(err)
}
if err := EditM4AFields(path, map[string]string{"title": "Created"}); err != nil {
t.Fatalf("EditM4AFields: %v", err)
}
if got := readTestM4ATitle(t, mustReadFile(t, path)); got != "Created" {
t.Errorf("title = %q, want Created", got)
}
}
// --- Ogg/Opus ---------------------------------------------------------------
func buildTestOpus(t *testing.T, path string, comments []string, audioPages int) {
t.Helper()
head := append([]byte("OpusHead"), make([]byte, 11)...)
tags := append([]byte("OpusTags"), serializeVorbisCommentBlock("test-vendor", comments)...)
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
bos := oggEditPage{headerType: 0x02, serial: 0xBEEF, seq: 0, segments: []byte{byte(len(head))}, data: head}
if err := bos.serialize(f); err != nil {
t.Fatal(err)
}
for _, p := range paginateOggPackets([][]byte{tags}, 0xBEEF, 1) {
if err := p.serialize(f); err != nil {
t.Fatal(err)
}
}
seq := uint32(2)
for i := 0; i < audioPages; i++ {
audio := bytes.Repeat([]byte{byte(0x40 + i)}, 100)
page := oggEditPage{granule: uint64((i + 1) * 960), serial: 0xBEEF, seq: seq, segments: []byte{100}, data: audio}
if i == audioPages-1 {
page.headerType = 0x04 // EOS
}
if err := page.serialize(f); err != nil {
t.Fatal(err)
}
seq++
}
}
func TestEditOggFieldsPreservesForeignComments(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.opus")
buildTestOpus(t, path, []string{"TITLE=Old", "FOO=BAR", "ENCODER=opusenc"}, 3)
if err := EditOggFields(path, map[string]string{"title": "New Title"}); err != nil {
t.Fatalf("EditOggFields: %v", err)
}
meta, err := ReadOggVorbisComments(path)
if err != nil {
t.Fatalf("ReadOggVorbisComments: %v", err)
}
if meta.Title != "New Title" {
t.Errorf("title = %q, want New Title", meta.Title)
}
raw := mustReadFile(t, path)
if !bytes.Contains(raw, []byte("FOO=BAR")) || !bytes.Contains(raw, []byte("ENCODER=opusenc")) {
t.Error("foreign comments dropped")
}
// The stream must reparse with sequential page numbers and valid layout.
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
pages, err := readAllOggEditPages(f)
if err != nil {
t.Fatalf("reparse: %v", err)
}
for i, p := range pages {
if p.seq != uint32(i) {
t.Errorf("page %d has seq %d", i, p.seq)
}
}
}
func TestEditOggFieldsGrowingCommentRenumbersAudioPages(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "grow.opus")
buildTestOpus(t, path, []string{"TITLE=Old"}, 2)
// >64KB of lyrics forces the OpusTags packet across multiple pages.
if err := EditOggFields(path, map[string]string{"lyrics": strings.Repeat("la ", 30000)}); err != nil {
t.Fatalf("EditOggFields: %v", err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
pages, err := readAllOggEditPages(f)
if err != nil {
t.Fatalf("reparse: %v", err)
}
if len(pages) < 4 {
t.Fatalf("expected multi-page comment, got %d pages", len(pages))
}
for i, p := range pages {
if p.seq != uint32(i) {
t.Errorf("page %d has seq %d", i, p.seq)
}
}
// Audio payloads intact after renumbering.
last := pages[len(pages)-1]
if last.headerType&0x04 == 0 || !bytes.Equal(last.data, bytes.Repeat([]byte{0x41}, 100)) {
t.Error("audio pages corrupted by renumbering")
}
if meta, err := ReadOggVorbisComments(path); err != nil || meta.Title != "Old" {
t.Errorf("untouched title lost: %v (err %v)", meta, err)
}
}