// 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 ( "fmt" "math/big" "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 removes any existing POPM frame (unrated). 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) } tag, err := id3.Open(path, id3.Options{Parse: true}) if err != nil { return fmt.Errorf("rating.Set: %w", err) } defer tag.Close() tag.DeleteFrames("POPM") if stars > 0 { tag.AddFrame("POPM", id3.PopularimeterFrame{ Email: emailKey, Rating: starsToPOPM(stars), Counter: big.NewInt(0), }) } if err := tag.Save(); err != nil { return fmt.Errorf("rating.Set: save %s: %w", filepath.Base(path), err) } return 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] }