|
- // 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]
- }
|