diff --git a/.gitignore b/.gitignore index 622170c..9b2d5bf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ roadamp roadamp.exe *.exe +*.exe~ # Build output dist/ diff --git a/internal/rating/popm.go b/internal/rating/popm.go new file mode 100644 index 0000000..44b14e9 --- /dev/null +++ b/internal/rating/popm.go @@ -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 +} diff --git a/internal/rating/popm_test.go b/internal/rating/popm_test.go new file mode 100644 index 0000000..57384eb --- /dev/null +++ b/internal/rating/popm_test.go @@ -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) + } +} diff --git a/internal/rating/rating.go b/internal/rating/rating.go index 6b75b68..ada5c0e 100644 --- a/internal/rating/rating.go +++ b/internal/rating/rating.go @@ -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 }