diff --git a/.gitignore b/.gitignore index 0bb6fc8d..1957c269 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,8 @@ flutter_*.log tool/ .claude/settings.local.json .playwright-mcp/ +.rtk/ +CLAUDE.md # FVM Version Cache .fvm/ diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index efaa1e81..c7137ff4 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -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" } diff --git a/go_backend/exports_metadata.go b/go_backend/exports_metadata.go index 0bfcc739..fd894842 100644 --- a/go_backend/exports_metadata.go +++ b/go_backend/exports_metadata.go @@ -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", diff --git a/go_backend/native_tag_editors_test.go b/go_backend/native_tag_editors_test.go new file mode 100644 index 00000000..368f5ce8 --- /dev/null +++ b/go_backend/native_tag_editors_test.go @@ -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) + } +}