Web-based Winamp controller for CarPC � Go backend, mobile-first UI
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

206 řádky
6.0KB

  1. // Package rating reads and writes ID3v2 POPM (Popularimeter) star ratings
  2. // for MP3 files using the scale shared by Winamp and Windows Explorer:
  3. //
  4. // 0 = unrated
  5. // 1 = ★☆☆☆☆ (POPM byte 1)
  6. // 2 = ★★☆☆☆ (POPM byte 64)
  7. // 3 = ★★★☆☆ (POPM byte 128)
  8. // 4 = ★★★★☆ (POPM byte 196)
  9. // 5 = ★★★★★ (POPM byte 255)
  10. //
  11. // The email field is set to "rating@winamp.com" (Winamp's standard identifier).
  12. // Windows Explorer reads all POPM frames regardless of the email field.
  13. package rating
  14. import (
  15. "bytes"
  16. "fmt"
  17. "io"
  18. "math/big"
  19. "os"
  20. "path/filepath"
  21. "strings"
  22. id3 "github.com/bogem/id3v2/v2"
  23. )
  24. const emailKey = "rating@winamp.com"
  25. // Get returns the 0–5 star rating stored in the POPM frame of path.
  26. // Returns 0 (unrated) if the file has no POPM frame or is not an MP3.
  27. func Get(path string) (int, error) {
  28. if !isMP3(path) {
  29. return 0, nil
  30. }
  31. tag, err := id3.Open(path, id3.Options{Parse: true, ParseFrames: []string{"POPM"}})
  32. if err != nil {
  33. return 0, fmt.Errorf("rating.Get: %w", err)
  34. }
  35. defer tag.Close()
  36. for _, f := range tag.GetFrames("POPM") {
  37. pf, ok := f.(id3.PopularimeterFrame)
  38. if !ok {
  39. continue
  40. }
  41. return popmToStars(pf.Rating), nil
  42. }
  43. return 0, nil
  44. }
  45. // Set writes a 0–5 star rating into the POPM frame of path.
  46. // stars=0 removes any existing POPM frame (unrated).
  47. //
  48. // Strategy: bogem/id3v2's Save() creates a temp file and renames it over
  49. // the original. The rename fails when Winamp holds the file open (Windows
  50. // denies rename without FILE_SHARE_DELETE). Instead we:
  51. // 1. Find where the audio data starts (parse the ID3v2 header size).
  52. // 2. Write the new ID3 tag to an in-memory buffer via WriteTo.
  53. // 3. Build the complete new file (tag + audio) in a temp file.
  54. // 4. Try rename — works when the file is not currently playing.
  55. // 5. If rename fails, stream the temp file directly into the original file
  56. // (requires Winamp to have opened with FILE_SHARE_WRITE, which it does).
  57. func Set(path string, stars int) error {
  58. if !isMP3(path) {
  59. return fmt.Errorf("rating.Set: not an MP3 file: %s", filepath.Base(path))
  60. }
  61. if stars < 0 || stars > 5 {
  62. return fmt.Errorf("rating.Set: stars must be 0–5, got %d", stars)
  63. }
  64. // ── Step 1: locate audio data in the original file ────────────────────────
  65. audioStart, err := id3v2AudioStart(path)
  66. if err != nil {
  67. return fmt.Errorf("rating.Set: parse header: %w", err)
  68. }
  69. // ── Step 2: encode new tag into memory ────────────────────────────────────
  70. tag, err := id3.Open(path, id3.Options{Parse: true})
  71. if err != nil {
  72. return fmt.Errorf("rating.Set: open: %w", err)
  73. }
  74. tag.DeleteFrames("POPM")
  75. if stars > 0 {
  76. tag.AddFrame("POPM", id3.PopularimeterFrame{
  77. Email: emailKey,
  78. Rating: starsToPOPM(stars),
  79. Counter: big.NewInt(0),
  80. })
  81. }
  82. var tagBuf bytes.Buffer
  83. if _, err := tag.WriteTo(&tagBuf); err != nil {
  84. tag.Close()
  85. return fmt.Errorf("rating.Set: encode tag: %w", err)
  86. }
  87. tag.Close() // release read handle on original file
  88. // ── Step 3: write tag + audio to a temp file ──────────────────────────────
  89. dir := filepath.Dir(path)
  90. tmp, err := os.CreateTemp(dir, ".rating-*.tmp")
  91. if err != nil {
  92. return fmt.Errorf("rating.Set: create temp: %w", err)
  93. }
  94. tmpPath := tmp.Name()
  95. defer os.Remove(tmpPath) // clean up on any exit path
  96. if _, err := tmp.Write(tagBuf.Bytes()); err != nil {
  97. tmp.Close()
  98. return fmt.Errorf("rating.Set: write tag to temp: %w", err)
  99. }
  100. // Copy audio data from original file into the temp file.
  101. src, err := os.Open(path)
  102. if err != nil {
  103. tmp.Close()
  104. return fmt.Errorf("rating.Set: open src: %w", err)
  105. }
  106. if _, err := src.Seek(audioStart, io.SeekStart); err != nil {
  107. src.Close()
  108. tmp.Close()
  109. return fmt.Errorf("rating.Set: seek audio: %w", err)
  110. }
  111. if _, err := io.Copy(tmp, src); err != nil {
  112. src.Close()
  113. tmp.Close()
  114. return fmt.Errorf("rating.Set: copy audio: %w", err)
  115. }
  116. src.Close()
  117. if err := tmp.Close(); err != nil {
  118. return fmt.Errorf("rating.Set: close temp: %w", err)
  119. }
  120. // ── Step 4: try atomic rename (works when file is not open by Winamp) ─────
  121. if err := os.Rename(tmpPath, path); err == nil {
  122. return nil
  123. }
  124. // ── Step 5: rename failed — stream temp into the original file directly ───
  125. // This works because Winamp opens MP3s with FILE_SHARE_WRITE.
  126. tmpFile, err := os.Open(tmpPath)
  127. if err != nil {
  128. return fmt.Errorf("rating.Set: open temp for streaming: %w", err)
  129. }
  130. defer tmpFile.Close()
  131. dst, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644)
  132. if err != nil {
  133. return fmt.Errorf("rating.Set: open dst: %w", err)
  134. }
  135. defer dst.Close()
  136. if _, err := io.Copy(dst, tmpFile); err != nil {
  137. return fmt.Errorf("rating.Set: stream to dst: %w", err)
  138. }
  139. return nil
  140. }
  141. // id3v2AudioStart reads the 10-byte ID3v2 header and returns the byte offset
  142. // where audio data begins (i.e. the end of the tag). Returns 0 if no ID3v2
  143. // tag is present.
  144. func id3v2AudioStart(path string) (int64, error) {
  145. f, err := os.Open(path)
  146. if err != nil {
  147. return 0, err
  148. }
  149. defer f.Close()
  150. var hdr [10]byte
  151. if _, err := io.ReadFull(f, hdr[:]); err != nil {
  152. return 0, nil // file too short — no tag
  153. }
  154. if string(hdr[:3]) != "ID3" {
  155. return 0, nil // no ID3v2 tag
  156. }
  157. // Bytes 6–9 are a synchsafe integer (7 bits per byte).
  158. size := int64(hdr[6])<<21 | int64(hdr[7])<<14 | int64(hdr[8])<<7 | int64(hdr[9])
  159. return 10 + size, nil
  160. }
  161. func isMP3(path string) bool {
  162. return strings.EqualFold(filepath.Ext(path), ".mp3")
  163. }
  164. // popmToStars maps a raw POPM byte to a 0–5 star rating using the
  165. // Windows Explorer / Winamp read ranges.
  166. func popmToStars(r uint8) int {
  167. switch {
  168. case r == 0:
  169. return 0
  170. case r < 32:
  171. return 1
  172. case r < 96:
  173. return 2
  174. case r < 160:
  175. return 3
  176. case r < 224:
  177. return 4
  178. default:
  179. return 5
  180. }
  181. }
  182. // starsToPOPM returns the canonical POPM byte for a given star count.
  183. func starsToPOPM(stars int) uint8 {
  184. return [6]uint8{0, 1, 64, 128, 196, 255}[stars]
  185. }