瀏覽代碼

fix(rating): preserve audio data on Set(); add unit tests

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>
master
Jan Svabenik 1 月之前
父節點
當前提交
7c017e2766
共有 2 個檔案被更改,包括 283 行新增19 行删除
  1. +97
    -19
      internal/rating/rating.go
  2. +186
    -0
      internal/rating/rating_test.go

+ 97
- 19
internal/rating/rating.go 查看文件

@@ -15,6 +15,7 @@ package rating
import (
"bytes"
"fmt"
"io"
"math/big"
"os"
"path/filepath"
@@ -50,12 +51,15 @@ 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).
//
// bogem/id3v2's Save() writes a temp file and renames it over the original,
// which Windows denies when another process (Winamp) holds the file open
// without FILE_SHARE_DELETE. Instead we use WriteTo to stream the modified
// tag+audio into an in-memory buffer, close our read handle, then overwrite
// the original file in-place — which succeeds because Winamp opens files
// with FILE_SHARE_WRITE.
// 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).
func Set(path string, stars int) error {
if !isMP3(path) {
return fmt.Errorf("rating.Set: not an MP3 file: %s", filepath.Base(path))
@@ -64,11 +68,17 @@ func Set(path string, stars int) error {
return fmt.Errorf("rating.Set: stars must be 0–5, got %d", stars)
}

// ── 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)
}

// ── 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)
}

tag.DeleteFrames("POPM")
if stars > 0 {
tag.AddFrame("POPM", id3.PopularimeterFrame{
@@ -77,27 +87,95 @@ func Set(path string, stars int) error {
Counter: big.NewInt(0),
})
}

// Stream modified tag + audio into memory buffer while file is still open.
var buf bytes.Buffer
if _, err := tag.WriteTo(&buf); err != nil {
var tagBuf bytes.Buffer
if _, err := tag.WriteTo(&tagBuf); err != nil {
tag.Close()
return fmt.Errorf("rating.Set: encode: %w", err)
return fmt.Errorf("rating.Set: encode tag: %w", err)
}
tag.Close() // release read handle before we open for writing
tag.Close() // release read handle on original file

// Overwrite the original file in-place (no rename needed).
f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644)
// ── Step 3: write tag + audio to a temp file ──────────────────────────────
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".rating-*.tmp")
if err != nil {
return fmt.Errorf("rating.Set: open for write: %w", err)
return fmt.Errorf("rating.Set: create temp: %w", err)
}
defer f.Close()
if _, err := f.Write(buf.Bytes()); err != nil {
return fmt.Errorf("rating.Set: write: %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)
}

// 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)
}
if _, err := src.Seek(audioStart, io.SeekStart); err != nil {
src.Close()
tmp.Close()
return fmt.Errorf("rating.Set: 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)
}
src.Close()
if err := tmp.Close(); err != nil {
return fmt.Errorf("rating.Set: close temp: %w", err)
}

// ── Step 4: try atomic rename (works when file is not open by Winamp) ─────
if err := os.Rename(tmpPath, path); err == nil {
return nil
}

// ── Step 5: rename failed — stream temp into the original file directly ───
// 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)
}
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)
}
defer dst.Close()

if _, err := io.Copy(dst, tmpFile); err != nil {
return fmt.Errorf("rating.Set: stream to dst: %w", err)
}
return nil
}

// id3v2AudioStart reads the 10-byte ID3v2 header and returns the byte offset
// where audio data begins (i.e. the end of the tag). Returns 0 if no ID3v2
// tag is present.
func id3v2AudioStart(path string) (int64, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()

var hdr [10]byte
if _, err := io.ReadFull(f, hdr[:]); err != nil {
return 0, nil // file too short — no tag
}
if string(hdr[:3]) != "ID3" {
return 0, nil // no ID3v2 tag
}
// Bytes 6–9 are a synchsafe integer (7 bits per byte).
size := int64(hdr[6])<<21 | int64(hdr[7])<<14 | int64(hdr[8])<<7 | int64(hdr[9])
return 10 + size, nil
}

func isMP3(path string) bool {
return strings.EqualFold(filepath.Ext(path), ".mp3")
}


+ 186
- 0
internal/rating/rating_test.go 查看文件

@@ -0,0 +1,186 @@
package rating

import (
"bytes"
"os"
"testing"
)

// buildMinimalMP3 returns a minimal valid MP3: a 10-byte ID3v2.3 header with
// no frames (tag size = 0) followed by 4096 bytes of fake audio data (0xFF 0xFB
// sync word repeated, which is close enough for our purposes).
func buildMinimalMP3() []byte {
var buf bytes.Buffer
// ID3v2.3 header: "ID3" + version 2.3.0 + flags 0 + synchsafe size 0
buf.Write([]byte{'I', 'D', '3', 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
// Fake audio data
audio := make([]byte, 4096)
for i := range audio {
audio[i] = byte(i & 0xFF)
}
buf.Write(audio)
return buf.Bytes()
}

func writeTempMP3(t *testing.T, data []byte) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "test-*.mp3")
if err != nil {
t.Fatalf("create temp: %v", err)
}
if _, err := f.Write(data); err != nil {
t.Fatalf("write temp: %v", err)
}
f.Close()
return f.Name()
}

func TestPopmToStars(t *testing.T) {
cases := []struct{ in uint8; want int }{
{0, 0}, {1, 1}, {31, 1}, {32, 2}, {95, 2},
{96, 3}, {127, 3}, {128, 3}, {159, 3},
{160, 4}, {195, 4}, {196, 4}, {223, 4},
{224, 5}, {255, 5},
}
for _, c := range cases {
got := popmToStars(c.in)
if got != c.want {
t.Errorf("popmToStars(%d) = %d, want %d", c.in, got, c.want)
}
}
}

func TestStarsToPOPM(t *testing.T) {
want := [6]uint8{0, 1, 64, 128, 196, 255}
for i, w := range want {
got := starsToPOPM(i)
if got != w {
t.Errorf("starsToPOPM(%d) = %d, want %d", i, got, w)
}
}
}

func TestId3v2AudioStart_noTag(t *testing.T) {
// File with no ID3 header
f, err := os.CreateTemp(t.TempDir(), "noTag-*.mp3")
if err != nil {
t.Fatal(err)
}
f.Write([]byte{0xFF, 0xFB, 0x90, 0x00}) // MP3 sync
f.Close()

start, err := id3v2AudioStart(f.Name())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if start != 0 {
t.Errorf("got audioStart=%d, want 0", start)
}
}

func TestId3v2AudioStart_withHeader(t *testing.T) {
// Header with tag size 0 → audioStart should be 10
data := buildMinimalMP3()
path := writeTempMP3(t, data)

start, err := id3v2AudioStart(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if start != 10 {
t.Errorf("got audioStart=%d, want 10", start)
}
}

func TestSetAndGet_roundtrip(t *testing.T) {
original := buildMinimalMP3()
path := writeTempMP3(t, original)

// File should start with no rating
r, err := Get(path)
if err != nil {
t.Fatalf("Get before Set: %v", err)
}
if r != 0 {
t.Errorf("expected 0 before Set, got %d", r)
}

// Set each star value and read back
for stars := 1; stars <= 5; stars++ {
if err := Set(path, stars); err != nil {
t.Fatalf("Set(%d): %v", stars, err)
}

// File must not be truncated — must be larger than tag-only (> original size)
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat after Set(%d): %v", stars, err)
}
if info.Size() < int64(len(original)) {
t.Errorf("Set(%d) shrunk file: got %d bytes, original was %d",
stars, info.Size(), len(original))
}

got, err := Get(path)
if err != nil {
t.Fatalf("Get after Set(%d): %v", stars, err)
}
if got != stars {
t.Errorf("Get after Set(%d) = %d, want %d", stars, got, stars)
}
}

// Unrate (stars=0) must remove POPM
if err := Set(path, 0); err != nil {
t.Fatalf("Set(0): %v", err)
}
got, err := Get(path)
if err != nil {
t.Fatalf("Get after Set(0): %v", err)
}
if got != 0 {
t.Errorf("Get after Set(0) = %d, want 0", got)
}

// Audio data must be intact after all the Set calls
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
audioStart, _ := id3v2AudioStart(path)
if audioStart >= int64(len(data)) {
t.Fatalf("audioStart %d >= fileSize %d", audioStart, len(data))
}
got4096 := data[audioStart:]
orig4096 := original[10:] // original audio starts at byte 10 (tag size 0)
if len(got4096) < len(orig4096) {
t.Errorf("audio data truncated: got %d bytes, want %d", len(got4096), len(orig4096))
} else {
// Last 4096 bytes should match original audio
tail := got4096[len(got4096)-len(orig4096):]
if !bytes.Equal(tail, orig4096) {
t.Error("audio data corrupted after rating Set calls")
}
}
}

func TestSet_invalidStars(t *testing.T) {
path := writeTempMP3(t, buildMinimalMP3())
if err := Set(path, 6); err == nil {
t.Error("Set(6) should return error")
}
if err := Set(path, -1); err == nil {
t.Error("Set(-1) should return error")
}
}

func TestSet_notMP3(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "test-*.flac")
if err != nil {
t.Fatal(err)
}
f.Close()
if err := Set(f.Name(), 3); err == nil {
t.Error("Set on non-MP3 should return error")
}
}

Loading…
取消
儲存