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>
The previous implementation used tag.WriteTo() which only emits the ID3
tag (~311 bytes), leaving audio data behind. Files were truncated to just
the tag on every rating write.
New strategy:
1. Parse the 10-byte ID3v2 header to find the audio start offset.
2. Encode the new tag into an in-memory buffer via WriteTo.
3. Write tag + original audio into a temp file.
4. Try atomic os.Rename (works when Winamp does not hold the file).
5. Fall back to direct O_WRONLY|O_TRUNC write (works while Winamp plays,
because Winamp opens with FILE_SHARE_WRITE on Windows).
Tests cover: POPM<->stars mapping, id3v2AudioStart, full round-trip
(1-5 stars + unrate) with audio-integrity check, and error cases.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bogem/id3v2 Save() renames a temp file over the original, which Windows
denies when Winamp holds the file open (no FILE_SHARE_DELETE). Fix:
- Use tag.WriteTo(&buf) to encode tag+audio into memory while the read
handle is still open
- Close the read handle
- Reopen the original file with O_WRONLY|O_TRUNC and write the buffer
Winamp opens MP3s with FILE_SHARE_WRITE so the in-place overwrite succeeds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- winamp.GetCurrentFile() reads file path via IPC_GETPLAYLISTFILE (211)
+ ReadProcessMemory, same pattern as playlist titles
- internal/rating: Get/Set POPM frame via bogem/id3v2
- Email: rating@winamp.com (Winamp standard)
- Byte scale: 0/1/64/128/196/255 = 0-5 stars
- Compatible with Windows Explorer and Winamp
- GET /api/rating -> {stars: N}
- POST /api/rating {stars: N} -> writes POPM, returns {stars: N}
Frontend:
- 5 stars in track-info, gold when lit
- Fetched automatically on track change
- Tap to rate; tap same star again to remove rating
- Optimistic update with revert on error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>