// Package rating reads and writes ID3v2 POPM (Popularimeter) star ratings // for MP3 files using the scale shared by Winamp and Windows Explorer: // // 0 = unrated // 1 = ★☆☆☆☆ (POPM byte 1) // 2 = ★★☆☆☆ (POPM byte 64) // 3 = ★★★☆☆ (POPM byte 128) // 4 = ★★★★☆ (POPM byte 196) // 5 = ★★★★★ (POPM byte 255) // // The email field is set to "rating@winamp.com" (Winamp's standard identifier). // Windows Explorer reads all POPM frames regardless of the email field. package rating import ( "bytes" "errors" "fmt" "io" "math/big" "os" "path/filepath" "strings" id3 "github.com/bogem/id3v2/v2" ) const emailKey = "rating@winamp.com" // Get returns the 0–5 star rating stored in the POPM frame of path. // Returns 0 (unrated) if the file has no POPM frame or is not an MP3. func Get(path string) (int, error) { if !isMP3(path) { return 0, nil } tag, err := id3.Open(path, id3.Options{Parse: true, ParseFrames: []string{"POPM"}}) if err != nil { return 0, fmt.Errorf("rating.Get: %w", err) } defer tag.Close() for _, f := range tag.GetFrames("POPM") { pf, ok := f.(id3.PopularimeterFrame) if !ok { continue } return popmToStars(pf.Rating), nil } return 0, nil } // Set writes a 0–5 star rating into the POPM frame of path. // 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 — 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)) } if stars < 0 || stars > 5 { 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.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.rewriteWithRating: open: %w", err) } tag.DeleteFrames("POPM") if stars > 0 { tag.AddFrame("POPM", id3.PopularimeterFrame{ Email: emailKey, Rating: starsToPOPM(stars), Counter: big.NewInt(0), }) } var tagBuf bytes.Buffer if _, err := tag.WriteTo(&tagBuf); err != nil { tag.Close() return fmt.Errorf("rating.rewriteWithRating: encode tag: %w", err) } tag.Close() // release read handle on original file // ── 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.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.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.rewriteWithRating: open src: %w", err) } if _, err := src.Seek(audioStart, io.SeekStart); err != nil { src.Close() tmp.Close() 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.rewriteWithRating: copy audio: %w", err) } src.Close() if err := tmp.Close(); err != nil { return fmt.Errorf("rating.rewriteWithRating: 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.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.rewriteWithRating: open dst: %w", err) } defer dst.Close() if _, err := io.Copy(dst, tmpFile); err != nil { return fmt.Errorf("rating.rewriteWithRating: 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") } // popmToStars maps a raw POPM byte to a 0–5 star rating using the // Windows Explorer / Winamp read ranges. func popmToStars(r uint8) int { switch { case r == 0: return 0 case r < 32: return 1 case r < 96: return 2 case r < 160: return 3 case r < 224: return 4 default: return 5 } } // starsToPOPM returns the canonical POPM byte for a given star count. func starsToPOPM(stars int) uint8 { return [6]uint8{0, 1, 64, 128, 196, 255}[stars] }