The previous Set() always did a full tag+audio rewrite, even for changing
a single byte. That triggered the FILE_SHARE_DELETE rename dance and
required streaming kilobytes through a temp file just to flip a rating.
New strategy (tryPatchPOPM in popm.go):
- Existing POPM frame: seek + 1 byte write. Done.
- No POPM, padding available: write a fresh ~33-byte POPM frame into
the existing zero-padding. Tag size unchanged, audio offset unchanged,
no rename, works while Winamp holds the file open.
- Anything weird (no ID3 tag, header flags set, unknown version,
padding too small, malformed frame, POPM with frame flags): refuse
with errPatchRefused; Set() falls back to the existing rewrite path.
Safety invariants enforced before every write:
- Strict bounds: write region must lie inside [10, 10+tagSize).
- Padding region must be verified all-zero.
- Only ID3v2.3 and v2.4 with zero header flags accepted.
- POPM frame must have zero frame flags (rejects compression /
encryption / data-length indicator that would shift the rating
offset).
- All frame sizes are validated against tag bounds before use.
Tests (popm_test.go): patch existing POPM for both v2.3 and v2.4,
insert into padding, no-op when stars=0 and no frame, walk past TIT2
to find POPM, plus seven refusal cases (no ID3, unsync flag, unknown
version, small padding, POPM with flags, invalid frame ID, oversized
frame size). Also: integration test that Set() preserves file size
when padding is available, proving the patch path is taken.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
| @@ -2,6 +2,7 @@ | |||
| roadamp | |||
| roadamp.exe | |||
| *.exe | |||
| *.exe~ | |||
| # Build output | |||
| dist/ | |||
| @@ -0,0 +1,223 @@ | |||
| package rating | |||
| import ( | |||
| "encoding/binary" | |||
| "errors" | |||
| "fmt" | |||
| "io" | |||
| "os" | |||
| ) | |||
| // errPatchRefused signals that the in-place patcher cannot (or should not) | |||
| // apply the change. The caller falls back to a full rewrite. Real I/O errors | |||
| // are returned as-is so we don't silently retry corrupt-disk situations. | |||
| var errPatchRefused = errors.New("rating: in-place patch refused") | |||
| // popmEmail is the identifier we write into new POPM frames (Winamp convention). | |||
| const popmEmail = "rating@winamp.com" | |||
| // popmCounterBytes — how many bytes of zero "play counter" we append after the | |||
| // rating byte when inserting a fresh frame. 0 is spec-legal but some readers | |||
| // (notably old WMP builds) expect ≥4. 4 zero bytes costs us nothing and is | |||
| // what bogem/id3v2 emits by default for a fresh frame. | |||
| const popmCounterBytes = 4 | |||
| // tryPatchPOPM attempts to update (or insert) the POPM rating with the | |||
| // smallest possible write — ideally one byte. It never grows the tag area: | |||
| // when there is no existing POPM and no room in padding it refuses | |||
| // (returns errPatchRefused) so the caller can fall back to a full rewrite. | |||
| // | |||
| // stars=0 with no existing POPM is a no-op success (file already unrated). | |||
| // stars=0 with an existing POPM rewrites the rating byte to 0 (the spec's | |||
| // "unknown" value); the frame remains but Get() returns 0. | |||
| // | |||
| // Safety invariants enforced: | |||
| // - Only ID3v2.3 / v2.4 with no header-level flags (no unsync, no extended | |||
| // header, no footer, no experimental). | |||
| // - All writes must land strictly inside the tag area | |||
| // (offset ∈ [10, 10+tagSize)). | |||
| // - Padding region must verify as all-zero before we write into it. | |||
| // - POPM frame flags must be zero (no compression / encryption / data-length | |||
| // indicator that would change how we'd interpret the rating offset). | |||
| // - Frame walking respects each frame's declared size; any inconsistency | |||
| // (size out of bounds, invalid frame ID) aborts via errPatchRefused. | |||
| func tryPatchPOPM(path string, stars int) error { | |||
| if stars < 0 || stars > 5 { | |||
| return fmt.Errorf("tryPatchPOPM: stars out of range: %d", stars) | |||
| } | |||
| f, err := os.OpenFile(path, os.O_RDWR, 0) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| defer f.Close() | |||
| // ── Parse 10-byte ID3v2 header ──────────────────────────────────────────── | |||
| var hdr [10]byte | |||
| if _, err := io.ReadFull(f, hdr[:]); err != nil { | |||
| return errPatchRefused | |||
| } | |||
| if string(hdr[:3]) != "ID3" { | |||
| return errPatchRefused | |||
| } | |||
| ver := hdr[3] | |||
| if ver != 3 && ver != 4 { | |||
| return errPatchRefused | |||
| } | |||
| // Any header-level flag bit set → refuse. Includes unsynchronisation, | |||
| // extended header, experimental, and (v2.4) footer present. | |||
| if hdr[5] != 0 { | |||
| return errPatchRefused | |||
| } | |||
| // Synchsafe size: each of the 4 bytes must have its high bit clear. | |||
| if hdr[6]&0x80 != 0 || hdr[7]&0x80 != 0 || hdr[8]&0x80 != 0 || hdr[9]&0x80 != 0 { | |||
| return errPatchRefused | |||
| } | |||
| tagSize := int(uint32(hdr[6])<<21 | uint32(hdr[7])<<14 | uint32(hdr[8])<<7 | uint32(hdr[9])) | |||
| if tagSize <= 0 || tagSize > 64*1024*1024 { | |||
| return errPatchRefused | |||
| } | |||
| body := make([]byte, tagSize) | |||
| if _, err := io.ReadFull(f, body); err != nil { | |||
| return errPatchRefused | |||
| } | |||
| // ── Walk frames, look for existing POPM ─────────────────────────────────── | |||
| off := 0 | |||
| for off+10 <= tagSize { | |||
| // First byte zero where a frame ID would start → padding region begins. | |||
| if body[off] == 0 { | |||
| break | |||
| } | |||
| id := string(body[off : off+4]) | |||
| if !validFrameID(id) { | |||
| return errPatchRefused | |||
| } | |||
| var frameSize int | |||
| switch ver { | |||
| case 3: | |||
| frameSize = int(binary.BigEndian.Uint32(body[off+4 : off+8])) | |||
| case 4: | |||
| b := body[off+4 : off+8] | |||
| if b[0]&0x80 != 0 || b[1]&0x80 != 0 || b[2]&0x80 != 0 || b[3]&0x80 != 0 { | |||
| return errPatchRefused | |||
| } | |||
| frameSize = int(uint32(b[0])<<21 | uint32(b[1])<<14 | uint32(b[2])<<7 | uint32(b[3])) | |||
| } | |||
| if frameSize <= 0 || off+10+frameSize > tagSize { | |||
| return errPatchRefused | |||
| } | |||
| if id == "POPM" { | |||
| // Reject if frame has any flags — they could change data layout | |||
| // (compression, encryption, v2.4 data-length indicator). | |||
| if body[off+8] != 0 || body[off+9] != 0 { | |||
| return errPatchRefused | |||
| } | |||
| data := body[off+10 : off+10+frameSize] | |||
| // Find the null terminator of the email field. | |||
| nullIdx := -1 | |||
| for i, c := range data { | |||
| if c == 0 { | |||
| nullIdx = i | |||
| break | |||
| } | |||
| } | |||
| if nullIdx < 0 || nullIdx+1 >= len(data) { | |||
| return errPatchRefused | |||
| } | |||
| ratingFileOffset := int64(10 + off + 10 + nullIdx + 1) | |||
| // Strict bounds check: must be strictly inside the tag region. | |||
| if ratingFileOffset < 10 || ratingFileOffset >= int64(10+tagSize) { | |||
| return errPatchRefused | |||
| } | |||
| newRating := starsToPOPM(stars) | |||
| if _, err := f.WriteAt([]byte{newRating}, ratingFileOffset); err != nil { | |||
| return err | |||
| } | |||
| return f.Sync() | |||
| } | |||
| off += 10 + frameSize | |||
| } | |||
| // ── No POPM frame in the tag ────────────────────────────────────────────── | |||
| if stars == 0 { | |||
| return nil // already unrated, nothing to write | |||
| } | |||
| padStart := off | |||
| // Verify the entire remaining tag region is zero. If anything else is | |||
| // hiding there (some other tool's data, a malformed frame), refuse. | |||
| for i := padStart; i < tagSize; i++ { | |||
| if body[i] != 0 { | |||
| return errPatchRefused | |||
| } | |||
| } | |||
| paddingLen := tagSize - padStart | |||
| frame := buildPOPMFrame(ver, stars) | |||
| if len(frame) > paddingLen { | |||
| return errPatchRefused | |||
| } | |||
| writeOffset := int64(10 + padStart) | |||
| // Strict bounds: write must end at or before the end of the tag region. | |||
| if writeOffset < 10 || writeOffset+int64(len(frame)) > int64(10+tagSize) { | |||
| return errPatchRefused | |||
| } | |||
| if _, err := f.WriteAt(frame, writeOffset); err != nil { | |||
| return err | |||
| } | |||
| return f.Sync() | |||
| } | |||
| // validFrameID returns true if id is a 4-character ASCII frame identifier | |||
| // using only [A-Z0-9], per ID3v2.3/2.4. Anything else and we're not looking | |||
| // at a real frame. | |||
| func validFrameID(id string) bool { | |||
| if len(id) != 4 { | |||
| return false | |||
| } | |||
| for i := 0; i < 4; i++ { | |||
| c := id[i] | |||
| if !((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { | |||
| return false | |||
| } | |||
| } | |||
| return true | |||
| } | |||
| // buildPOPMFrame returns the full bytes of a fresh POPM frame: | |||
| // | |||
| // "POPM" | size(4) | flags(2) | email | 0x00 | rating | counter(4 zero bytes) | |||
| // | |||
| // Size encoding depends on the ID3v2 minor version (3 = plain BE uint32, | |||
| // 4 = synchsafe). | |||
| func buildPOPMFrame(ver byte, stars int) []byte { | |||
| dataLen := len(popmEmail) + 1 + 1 + popmCounterBytes | |||
| out := make([]byte, 10+dataLen) | |||
| copy(out[0:4], "POPM") | |||
| switch ver { | |||
| case 3: | |||
| binary.BigEndian.PutUint32(out[4:8], uint32(dataLen)) | |||
| case 4: | |||
| s := uint32(dataLen) | |||
| out[4] = byte((s >> 21) & 0x7F) | |||
| out[5] = byte((s >> 14) & 0x7F) | |||
| out[6] = byte((s >> 7) & 0x7F) | |||
| out[7] = byte(s & 0x7F) | |||
| } | |||
| // flags out[8], out[9] are zero | |||
| copy(out[10:], popmEmail) | |||
| out[10+len(popmEmail)] = 0 // null terminator | |||
| out[10+len(popmEmail)+1] = starsToPOPM(stars) | |||
| // counter bytes already zero | |||
| return out | |||
| } | |||
| @@ -0,0 +1,312 @@ | |||
| package rating | |||
| import ( | |||
| "bytes" | |||
| "encoding/binary" | |||
| "os" | |||
| "testing" | |||
| ) | |||
| // buildTaggedMP3 builds a synthetic MP3 with a real ID3v2 tag. | |||
| // - ver: 3 or 4 (ID3v2 minor version) | |||
| // - frames: pre-built frame bytes (full frame including 10-byte frame header) | |||
| // - paddingLen: bytes of zero padding inside the tag | |||
| // - audio: payload after the tag | |||
| // | |||
| // The returned bytes are: 10-byte ID3 header | frames | padding | audio. | |||
| func buildTaggedMP3(ver byte, frames []byte, paddingLen int, audio []byte) []byte { | |||
| tagSize := len(frames) + paddingLen | |||
| var hdr [10]byte | |||
| hdr[0], hdr[1], hdr[2] = 'I', 'D', '3' | |||
| hdr[3] = ver | |||
| hdr[4] = 0 | |||
| hdr[5] = 0 // no flags | |||
| s := uint32(tagSize) | |||
| hdr[6] = byte((s >> 21) & 0x7F) | |||
| hdr[7] = byte((s >> 14) & 0x7F) | |||
| hdr[8] = byte((s >> 7) & 0x7F) | |||
| hdr[9] = byte(s & 0x7F) | |||
| out := make([]byte, 0, 10+tagSize+len(audio)) | |||
| out = append(out, hdr[:]...) | |||
| out = append(out, frames...) | |||
| out = append(out, make([]byte, paddingLen)...) | |||
| out = append(out, audio...) | |||
| return out | |||
| } | |||
| // makePOPMFrame builds a POPM frame matching ver's size encoding. | |||
| func makePOPMFrame(ver byte, email string, rating byte, counter []byte) []byte { | |||
| dataLen := len(email) + 1 + 1 + len(counter) | |||
| out := make([]byte, 10+dataLen) | |||
| copy(out[0:4], "POPM") | |||
| switch ver { | |||
| case 3: | |||
| binary.BigEndian.PutUint32(out[4:8], uint32(dataLen)) | |||
| case 4: | |||
| s := uint32(dataLen) | |||
| out[4] = byte((s >> 21) & 0x7F) | |||
| out[5] = byte((s >> 14) & 0x7F) | |||
| out[6] = byte((s >> 7) & 0x7F) | |||
| out[7] = byte(s & 0x7F) | |||
| } | |||
| copy(out[10:], email) | |||
| out[10+len(email)] = 0 | |||
| out[10+len(email)+1] = rating | |||
| copy(out[10+len(email)+2:], counter) | |||
| return out | |||
| } | |||
| func fakeAudio(n int) []byte { | |||
| a := make([]byte, n) | |||
| for i := range a { | |||
| a[i] = byte((i * 7) & 0xFF) | |||
| } | |||
| return a | |||
| } | |||
| // --- TESTS: patch path with existing POPM (1-byte write) --------------------- | |||
| func TestPatch_existingPOPM_v23(t *testing.T) { | |||
| for _, stars := range []int{1, 2, 3, 4, 5, 0} { | |||
| t.Run("", func(t *testing.T) { | |||
| // Use a starting rating (200) that doesn't match any starsToPOPM value | |||
| // so we always observe a 1-byte change regardless of target stars. | |||
| frame := makePOPMFrame(3, "rating@winamp.com", 200, []byte{0, 0, 0, 0}) | |||
| audio := fakeAudio(2048) | |||
| data := buildTaggedMP3(3, frame, 512, audio) | |||
| path := writeTempMP3(t, data) | |||
| before, _ := os.ReadFile(path) | |||
| if err := tryPatchPOPM(path, stars); err != nil { | |||
| t.Fatalf("tryPatchPOPM: %v", err) | |||
| } | |||
| after, _ := os.ReadFile(path) | |||
| if len(before) != len(after) { | |||
| t.Fatalf("file size changed: %d → %d", len(before), len(after)) | |||
| } | |||
| // Exactly ONE byte should differ — the rating byte. | |||
| diffCount := 0 | |||
| diffOff := -1 | |||
| for i := range before { | |||
| if before[i] != after[i] { | |||
| diffCount++ | |||
| diffOff = i | |||
| } | |||
| } | |||
| if diffCount != 1 { | |||
| t.Fatalf("expected exactly 1 byte changed, got %d", diffCount) | |||
| } | |||
| // The rating byte sits at: 10 (header) + 10 (frame hdr) + len(email)+1 | |||
| wantOff := 10 + 10 + len("rating@winamp.com") + 1 | |||
| if diffOff != wantOff { | |||
| t.Errorf("changed byte at %d, expected at %d", diffOff, wantOff) | |||
| } | |||
| if after[diffOff] != starsToPOPM(stars) { | |||
| t.Errorf("rating byte = %d, want %d", after[diffOff], starsToPOPM(stars)) | |||
| } | |||
| // Audio region untouched. | |||
| audioStart := 10 + 10 + 23 + 512 // hdr + frame + padding | |||
| if !bytes.Equal(after[audioStart:], audio) { | |||
| t.Error("audio data corrupted") | |||
| } | |||
| // Get() should reflect the new rating | |||
| got, _ := Get(path) | |||
| if got != stars { | |||
| t.Errorf("Get() = %d, want %d", got, stars) | |||
| } | |||
| }) | |||
| } | |||
| } | |||
| func TestPatch_existingPOPM_v24(t *testing.T) { | |||
| frame := makePOPMFrame(4, "rating@winamp.com", 1, []byte{0, 0, 0, 0}) | |||
| audio := fakeAudio(1024) | |||
| data := buildTaggedMP3(4, frame, 256, audio) | |||
| path := writeTempMP3(t, data) | |||
| if err := tryPatchPOPM(path, 5); err != nil { | |||
| t.Fatalf("tryPatchPOPM: %v", err) | |||
| } | |||
| got, _ := Get(path) | |||
| if got != 5 { | |||
| t.Errorf("Get() = %d, want 5", got) | |||
| } | |||
| } | |||
| // --- TESTS: patch path inserts new POPM into padding ------------------------- | |||
| func TestPatch_insertIntoPadding(t *testing.T) { | |||
| audio := fakeAudio(1024) | |||
| data := buildTaggedMP3(3, nil, 1024, audio) // empty tag body, all padding | |||
| path := writeTempMP3(t, data) | |||
| beforeSize := int64(len(data)) | |||
| if err := tryPatchPOPM(path, 4); err != nil { | |||
| t.Fatalf("tryPatchPOPM: %v", err) | |||
| } | |||
| st, _ := os.Stat(path) | |||
| if st.Size() != beforeSize { | |||
| t.Fatalf("file size changed: %d → %d", beforeSize, st.Size()) | |||
| } | |||
| got, err := Get(path) | |||
| if err != nil { | |||
| t.Fatalf("Get: %v", err) | |||
| } | |||
| if got != 4 { | |||
| t.Errorf("Get() = %d, want 4", got) | |||
| } | |||
| // Audio untouched. | |||
| after, _ := os.ReadFile(path) | |||
| audioStart := 10 + 1024 | |||
| if !bytes.Equal(after[audioStart:], audio) { | |||
| t.Error("audio data corrupted") | |||
| } | |||
| } | |||
| // --- TESTS: patch path refuses safely on unsupported inputs ------------------ | |||
| func TestPatch_refusesNoID3(t *testing.T) { | |||
| path := writeTempMP3(t, fakeAudio(2048)) | |||
| if err := tryPatchPOPM(path, 3); err != errPatchRefused { | |||
| t.Errorf("got %v, want errPatchRefused", err) | |||
| } | |||
| } | |||
| func TestPatch_refusesUnsyncFlag(t *testing.T) { | |||
| data := buildTaggedMP3(3, nil, 1024, fakeAudio(512)) | |||
| data[5] = 0x80 // set unsync flag | |||
| path := writeTempMP3(t, data) | |||
| if err := tryPatchPOPM(path, 3); err != errPatchRefused { | |||
| t.Errorf("got %v, want errPatchRefused", err) | |||
| } | |||
| } | |||
| func TestPatch_refusesUnknownVersion(t *testing.T) { | |||
| data := buildTaggedMP3(2, nil, 1024, fakeAudio(512)) | |||
| path := writeTempMP3(t, data) | |||
| if err := tryPatchPOPM(path, 3); err != errPatchRefused { | |||
| t.Errorf("got %v, want errPatchRefused", err) | |||
| } | |||
| } | |||
| func TestPatch_refusesPaddingTooSmall(t *testing.T) { | |||
| // No POPM, only 5 bytes padding — new frame needs ~33 bytes. | |||
| data := buildTaggedMP3(3, nil, 5, fakeAudio(512)) | |||
| path := writeTempMP3(t, data) | |||
| if err := tryPatchPOPM(path, 3); err != errPatchRefused { | |||
| t.Errorf("got %v, want errPatchRefused", err) | |||
| } | |||
| } | |||
| func TestPatch_refusesPOPMWithFlags(t *testing.T) { | |||
| frame := makePOPMFrame(3, "rating@winamp.com", 128, []byte{0, 0, 0, 0}) | |||
| frame[9] = 0x01 // set a frame flag | |||
| data := buildTaggedMP3(3, frame, 256, fakeAudio(512)) | |||
| path := writeTempMP3(t, data) | |||
| if err := tryPatchPOPM(path, 3); err != errPatchRefused { | |||
| t.Errorf("got %v, want errPatchRefused", err) | |||
| } | |||
| } | |||
| func TestPatch_refusesInvalidFrameID(t *testing.T) { | |||
| // Garbage frame ID (lowercase invalid). | |||
| bad := []byte{'a', 'b', 'c', 'd', 0, 0, 0, 4, 0, 0, 0, 0, 0, 0} | |||
| data := buildTaggedMP3(3, bad, 256, fakeAudio(512)) | |||
| path := writeTempMP3(t, data) | |||
| if err := tryPatchPOPM(path, 3); err != errPatchRefused { | |||
| t.Errorf("got %v, want errPatchRefused", err) | |||
| } | |||
| } | |||
| func TestPatch_refusesPOPMOversizedSize(t *testing.T) { | |||
| // POPM with size larger than what fits in the tag — must refuse, not panic. | |||
| frame := makePOPMFrame(3, "rating@winamp.com", 128, []byte{0, 0, 0, 0}) | |||
| binary.BigEndian.PutUint32(frame[4:8], 999999) // lie about size | |||
| data := buildTaggedMP3(3, frame, 256, fakeAudio(512)) | |||
| path := writeTempMP3(t, data) | |||
| if err := tryPatchPOPM(path, 3); err != errPatchRefused { | |||
| t.Errorf("got %v, want errPatchRefused", err) | |||
| } | |||
| } | |||
| // --- TESTS: stars=0 semantics in patch path ---------------------------------- | |||
| func TestPatch_starsZeroWithNoFrame_noWrite(t *testing.T) { | |||
| data := buildTaggedMP3(3, nil, 1024, fakeAudio(512)) | |||
| path := writeTempMP3(t, data) | |||
| before, _ := os.ReadFile(path) | |||
| if err := tryPatchPOPM(path, 0); err != nil { | |||
| t.Fatalf("unexpected: %v", err) | |||
| } | |||
| after, _ := os.ReadFile(path) | |||
| if !bytes.Equal(before, after) { | |||
| t.Error("file changed even though stars=0 and no existing frame") | |||
| } | |||
| } | |||
| // --- TESTS: walks past non-POPM frames correctly ---------------------------- | |||
| func TestPatch_skipsOtherFrames(t *testing.T) { | |||
| // TIT2 (title) frame first, then POPM. Encoding byte 0x00 = ISO-8859-1. | |||
| tit2Data := append([]byte{0x00}, "Test Title"...) | |||
| tit2 := make([]byte, 10+len(tit2Data)) | |||
| copy(tit2[0:4], "TIT2") | |||
| binary.BigEndian.PutUint32(tit2[4:8], uint32(len(tit2Data))) | |||
| copy(tit2[10:], tit2Data) | |||
| popm := makePOPMFrame(3, "rating@winamp.com", 1, []byte{0, 0, 0, 0}) | |||
| frames := append(tit2, popm...) | |||
| audio := fakeAudio(1024) | |||
| data := buildTaggedMP3(3, frames, 512, audio) | |||
| path := writeTempMP3(t, data) | |||
| if err := tryPatchPOPM(path, 5); err != nil { | |||
| t.Fatalf("tryPatchPOPM: %v", err) | |||
| } | |||
| got, _ := Get(path) | |||
| if got != 5 { | |||
| t.Errorf("Get() = %d, want 5", got) | |||
| } | |||
| // Audio untouched. | |||
| after, _ := os.ReadFile(path) | |||
| audioStart := 10 + len(frames) + 512 | |||
| if !bytes.Equal(after[audioStart:], audio) { | |||
| t.Error("audio data corrupted") | |||
| } | |||
| } | |||
| // --- TESTS: Set() integration uses patch when it can ------------------------ | |||
| func TestSet_usesPatchPathWhenPossible(t *testing.T) { | |||
| // File has padding → patch should succeed → audio MUST remain byte-identical. | |||
| audio := fakeAudio(4096) | |||
| data := buildTaggedMP3(3, nil, 2048, audio) | |||
| path := writeTempMP3(t, data) | |||
| beforeSize := int64(len(data)) | |||
| if err := Set(path, 3); err != nil { | |||
| t.Fatalf("Set: %v", err) | |||
| } | |||
| st, _ := os.Stat(path) | |||
| if st.Size() != beforeSize { | |||
| t.Errorf("Set() with available padding changed file size: %d → %d (patch path should be used)", | |||
| beforeSize, st.Size()) | |||
| } | |||
| got, _ := Get(path) | |||
| if got != 3 { | |||
| t.Errorf("Get() = %d, want 3", got) | |||
| } | |||
| } | |||
| @@ -14,6 +14,7 @@ package rating | |||
| import ( | |||
| "bytes" | |||
| "errors" | |||
| "fmt" | |||
| "io" | |||
| "math/big" | |||
| @@ -49,17 +50,29 @@ func Get(path string) (int, error) { | |||
| } | |||
| // Set writes a 0–5 star rating into the POPM frame of path. | |||
| // stars=0 removes any existing POPM frame (unrated). | |||
| // stars=0 sets the POPM rating byte to 0 ("unknown" per spec); Get() then | |||
| // reports 0. If no POPM frame exists and stars=0, this is a no-op. | |||
| // | |||
| // Strategy: bogem/id3v2's Save() creates a temp file and renames it over | |||
| // the original. The rename fails when Winamp holds the file open (Windows | |||
| // denies rename without FILE_SHARE_DELETE). Instead we: | |||
| // 1. Find where the audio data starts (parse the ID3v2 header size). | |||
| // 2. Write the new ID3 tag to an in-memory buffer via WriteTo. | |||
| // 3. Build the complete new file (tag + audio) in a temp file. | |||
| // 4. Try rename — works when the file is not currently playing. | |||
| // 5. If rename fails, stream the temp file directly into the original file | |||
| // (requires Winamp to have opened with FILE_SHARE_WRITE, which it does). | |||
| // Strategy — patch first, rewrite as fallback: | |||
| // | |||
| // 1. tryPatchPOPM: minimal in-place write. If a POPM frame exists we just | |||
| // overwrite its rating byte (1 byte). If not, but the tag has enough | |||
| // zero-padding, we write a fresh POPM frame into the padding (tag size | |||
| // unchanged, audio offset unchanged). No rename, no temp file, works | |||
| // while Winamp holds the file open. | |||
| // | |||
| // 2. If the patcher refuses (no ID3 tag, exotic header flags, padding too | |||
| // small, malformed structure) we fall back to rewriteWithRating below. | |||
| // | |||
| // The rewrite path itself is non-trivial because bogem/id3v2's Save() does | |||
| // a temp-file + rename, and the rename fails when Winamp holds the file | |||
| // open (Windows denies rename without FILE_SHARE_DELETE). So the fallback: | |||
| // - parses the original ID3v2 header to find where audio data begins, | |||
| // - encodes the new tag into a buffer via WriteTo, | |||
| // - builds tag + audio in a temp file, | |||
| // - tries os.Rename (works when file is not currently playing), | |||
| // - else streams the temp directly into the original via O_WRONLY|O_TRUNC | |||
| // (works because Winamp opens with FILE_SHARE_WRITE). | |||
| func Set(path string, stars int) error { | |||
| if !isMP3(path) { | |||
| return fmt.Errorf("rating.Set: not an MP3 file: %s", filepath.Base(path)) | |||
| @@ -68,16 +81,32 @@ func Set(path string, stars int) error { | |||
| return fmt.Errorf("rating.Set: stars must be 0–5, got %d", stars) | |||
| } | |||
| // ── Fast path: try in-place patch ───────────────────────────────────────── | |||
| if err := tryPatchPOPM(path, stars); err == nil { | |||
| return nil | |||
| } else if !errors.Is(err, errPatchRefused) { | |||
| // Real I/O error — surface it instead of corrupting things via fallback. | |||
| return fmt.Errorf("rating.Set: %w", err) | |||
| } | |||
| // ── Slow path: full rewrite ─────────────────────────────────────────────── | |||
| return rewriteWithRating(path, stars) | |||
| } | |||
| // rewriteWithRating performs the full tag-rewrite path. Used as a fallback | |||
| // when tryPatchPOPM refuses (no ID3 tag yet, exotic header flags, or | |||
| // insufficient padding to add a new POPM frame). | |||
| func rewriteWithRating(path string, stars int) error { | |||
| // ── Step 1: locate audio data in the original file ──────────────────────── | |||
| audioStart, err := id3v2AudioStart(path) | |||
| if err != nil { | |||
| return fmt.Errorf("rating.Set: parse header: %w", err) | |||
| return fmt.Errorf("rating.rewriteWithRating: parse header: %w", err) | |||
| } | |||
| // ── Step 2: encode new tag into memory ──────────────────────────────────── | |||
| tag, err := id3.Open(path, id3.Options{Parse: true}) | |||
| if err != nil { | |||
| return fmt.Errorf("rating.Set: open: %w", err) | |||
| return fmt.Errorf("rating.rewriteWithRating: open: %w", err) | |||
| } | |||
| tag.DeleteFrames("POPM") | |||
| if stars > 0 { | |||
| @@ -90,7 +119,7 @@ func Set(path string, stars int) error { | |||
| var tagBuf bytes.Buffer | |||
| if _, err := tag.WriteTo(&tagBuf); err != nil { | |||
| tag.Close() | |||
| return fmt.Errorf("rating.Set: encode tag: %w", err) | |||
| return fmt.Errorf("rating.rewriteWithRating: encode tag: %w", err) | |||
| } | |||
| tag.Close() // release read handle on original file | |||
| @@ -98,35 +127,35 @@ func Set(path string, stars int) error { | |||
| dir := filepath.Dir(path) | |||
| tmp, err := os.CreateTemp(dir, ".rating-*.tmp") | |||
| if err != nil { | |||
| return fmt.Errorf("rating.Set: create temp: %w", err) | |||
| return fmt.Errorf("rating.rewriteWithRating: create temp: %w", err) | |||
| } | |||
| tmpPath := tmp.Name() | |||
| defer os.Remove(tmpPath) // clean up on any exit path | |||
| if _, err := tmp.Write(tagBuf.Bytes()); err != nil { | |||
| tmp.Close() | |||
| return fmt.Errorf("rating.Set: write tag to temp: %w", err) | |||
| return fmt.Errorf("rating.rewriteWithRating: write tag to temp: %w", err) | |||
| } | |||
| // Copy audio data from original file into the temp file. | |||
| src, err := os.Open(path) | |||
| if err != nil { | |||
| tmp.Close() | |||
| return fmt.Errorf("rating.Set: open src: %w", err) | |||
| return fmt.Errorf("rating.rewriteWithRating: open src: %w", err) | |||
| } | |||
| if _, err := src.Seek(audioStart, io.SeekStart); err != nil { | |||
| src.Close() | |||
| tmp.Close() | |||
| return fmt.Errorf("rating.Set: seek audio: %w", err) | |||
| return fmt.Errorf("rating.rewriteWithRating: seek audio: %w", err) | |||
| } | |||
| if _, err := io.Copy(tmp, src); err != nil { | |||
| src.Close() | |||
| tmp.Close() | |||
| return fmt.Errorf("rating.Set: copy audio: %w", err) | |||
| return fmt.Errorf("rating.rewriteWithRating: copy audio: %w", err) | |||
| } | |||
| src.Close() | |||
| if err := tmp.Close(); err != nil { | |||
| return fmt.Errorf("rating.Set: close temp: %w", err) | |||
| return fmt.Errorf("rating.rewriteWithRating: close temp: %w", err) | |||
| } | |||
| // ── Step 4: try atomic rename (works when file is not open by Winamp) ───── | |||
| @@ -138,18 +167,18 @@ func Set(path string, stars int) error { | |||
| // This works because Winamp opens MP3s with FILE_SHARE_WRITE. | |||
| tmpFile, err := os.Open(tmpPath) | |||
| if err != nil { | |||
| return fmt.Errorf("rating.Set: open temp for streaming: %w", err) | |||
| return fmt.Errorf("rating.rewriteWithRating: open temp for streaming: %w", err) | |||
| } | |||
| defer tmpFile.Close() | |||
| dst, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644) | |||
| if err != nil { | |||
| return fmt.Errorf("rating.Set: open dst: %w", err) | |||
| return fmt.Errorf("rating.rewriteWithRating: open dst: %w", err) | |||
| } | |||
| defer dst.Close() | |||
| if _, err := io.Copy(dst, tmpFile); err != nil { | |||
| return fmt.Errorf("rating.Set: stream to dst: %w", err) | |||
| return fmt.Errorf("rating.rewriteWithRating: stream to dst: %w", err) | |||
| } | |||
| return nil | |||
| } | |||