Web-based Winamp controller for CarPC � Go backend, mobile-first UI
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

235 lines
7.6KB

  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. "errors"
  17. "fmt"
  18. "io"
  19. "math/big"
  20. "os"
  21. "path/filepath"
  22. "strings"
  23. id3 "github.com/bogem/id3v2/v2"
  24. )
  25. const emailKey = "rating@winamp.com"
  26. // Get returns the 0–5 star rating stored in the POPM frame of path.
  27. // Returns 0 (unrated) if the file has no POPM frame or is not an MP3.
  28. func Get(path string) (int, error) {
  29. if !isMP3(path) {
  30. return 0, nil
  31. }
  32. tag, err := id3.Open(path, id3.Options{Parse: true, ParseFrames: []string{"POPM"}})
  33. if err != nil {
  34. return 0, fmt.Errorf("rating.Get: %w", err)
  35. }
  36. defer tag.Close()
  37. for _, f := range tag.GetFrames("POPM") {
  38. pf, ok := f.(id3.PopularimeterFrame)
  39. if !ok {
  40. continue
  41. }
  42. return popmToStars(pf.Rating), nil
  43. }
  44. return 0, nil
  45. }
  46. // Set writes a 0–5 star rating into the POPM frame of path.
  47. // stars=0 sets the POPM rating byte to 0 ("unknown" per spec); Get() then
  48. // reports 0. If no POPM frame exists and stars=0, this is a no-op.
  49. //
  50. // Strategy — patch first, rewrite as fallback:
  51. //
  52. // 1. tryPatchPOPM: minimal in-place write. If a POPM frame exists we just
  53. // overwrite its rating byte (1 byte). If not, but the tag has enough
  54. // zero-padding, we write a fresh POPM frame into the padding (tag size
  55. // unchanged, audio offset unchanged). No rename, no temp file, works
  56. // while Winamp holds the file open.
  57. //
  58. // 2. If the patcher refuses (no ID3 tag, exotic header flags, padding too
  59. // small, malformed structure) we fall back to rewriteWithRating below.
  60. //
  61. // The rewrite path itself is non-trivial because bogem/id3v2's Save() does
  62. // a temp-file + rename, and the rename fails when Winamp holds the file
  63. // open (Windows denies rename without FILE_SHARE_DELETE). So the fallback:
  64. // - parses the original ID3v2 header to find where audio data begins,
  65. // - encodes the new tag into a buffer via WriteTo,
  66. // - builds tag + audio in a temp file,
  67. // - tries os.Rename (works when file is not currently playing),
  68. // - else streams the temp directly into the original via O_WRONLY|O_TRUNC
  69. // (works because Winamp opens with FILE_SHARE_WRITE).
  70. func Set(path string, stars int) error {
  71. if !isMP3(path) {
  72. return fmt.Errorf("rating.Set: not an MP3 file: %s", filepath.Base(path))
  73. }
  74. if stars < 0 || stars > 5 {
  75. return fmt.Errorf("rating.Set: stars must be 0–5, got %d", stars)
  76. }
  77. // ── Fast path: try in-place patch ─────────────────────────────────────────
  78. if err := tryPatchPOPM(path, stars); err == nil {
  79. return nil
  80. } else if !errors.Is(err, errPatchRefused) {
  81. // Real I/O error — surface it instead of corrupting things via fallback.
  82. return fmt.Errorf("rating.Set: %w", err)
  83. }
  84. // ── Slow path: full rewrite ───────────────────────────────────────────────
  85. return rewriteWithRating(path, stars)
  86. }
  87. // rewriteWithRating performs the full tag-rewrite path. Used as a fallback
  88. // when tryPatchPOPM refuses (no ID3 tag yet, exotic header flags, or
  89. // insufficient padding to add a new POPM frame).
  90. func rewriteWithRating(path string, stars int) error {
  91. // ── Step 1: locate audio data in the original file ────────────────────────
  92. audioStart, err := id3v2AudioStart(path)
  93. if err != nil {
  94. return fmt.Errorf("rating.rewriteWithRating: parse header: %w", err)
  95. }
  96. // ── Step 2: encode new tag into memory ────────────────────────────────────
  97. tag, err := id3.Open(path, id3.Options{Parse: true})
  98. if err != nil {
  99. return fmt.Errorf("rating.rewriteWithRating: open: %w", err)
  100. }
  101. tag.DeleteFrames("POPM")
  102. if stars > 0 {
  103. tag.AddFrame("POPM", id3.PopularimeterFrame{
  104. Email: emailKey,
  105. Rating: starsToPOPM(stars),
  106. Counter: big.NewInt(0),
  107. })
  108. }
  109. var tagBuf bytes.Buffer
  110. if _, err := tag.WriteTo(&tagBuf); err != nil {
  111. tag.Close()
  112. return fmt.Errorf("rating.rewriteWithRating: encode tag: %w", err)
  113. }
  114. tag.Close() // release read handle on original file
  115. // ── Step 3: write tag + audio to a temp file ──────────────────────────────
  116. dir := filepath.Dir(path)
  117. tmp, err := os.CreateTemp(dir, ".rating-*.tmp")
  118. if err != nil {
  119. return fmt.Errorf("rating.rewriteWithRating: create temp: %w", err)
  120. }
  121. tmpPath := tmp.Name()
  122. defer os.Remove(tmpPath) // clean up on any exit path
  123. if _, err := tmp.Write(tagBuf.Bytes()); err != nil {
  124. tmp.Close()
  125. return fmt.Errorf("rating.rewriteWithRating: write tag to temp: %w", err)
  126. }
  127. // Copy audio data from original file into the temp file.
  128. src, err := os.Open(path)
  129. if err != nil {
  130. tmp.Close()
  131. return fmt.Errorf("rating.rewriteWithRating: open src: %w", err)
  132. }
  133. if _, err := src.Seek(audioStart, io.SeekStart); err != nil {
  134. src.Close()
  135. tmp.Close()
  136. return fmt.Errorf("rating.rewriteWithRating: seek audio: %w", err)
  137. }
  138. if _, err := io.Copy(tmp, src); err != nil {
  139. src.Close()
  140. tmp.Close()
  141. return fmt.Errorf("rating.rewriteWithRating: copy audio: %w", err)
  142. }
  143. src.Close()
  144. if err := tmp.Close(); err != nil {
  145. return fmt.Errorf("rating.rewriteWithRating: close temp: %w", err)
  146. }
  147. // ── Step 4: try atomic rename (works when file is not open by Winamp) ─────
  148. if err := os.Rename(tmpPath, path); err == nil {
  149. return nil
  150. }
  151. // ── Step 5: rename failed — stream temp into the original file directly ───
  152. // This works because Winamp opens MP3s with FILE_SHARE_WRITE.
  153. tmpFile, err := os.Open(tmpPath)
  154. if err != nil {
  155. return fmt.Errorf("rating.rewriteWithRating: open temp for streaming: %w", err)
  156. }
  157. defer tmpFile.Close()
  158. dst, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644)
  159. if err != nil {
  160. return fmt.Errorf("rating.rewriteWithRating: open dst: %w", err)
  161. }
  162. defer dst.Close()
  163. if _, err := io.Copy(dst, tmpFile); err != nil {
  164. return fmt.Errorf("rating.rewriteWithRating: stream to dst: %w", err)
  165. }
  166. return nil
  167. }
  168. // id3v2AudioStart reads the 10-byte ID3v2 header and returns the byte offset
  169. // where audio data begins (i.e. the end of the tag). Returns 0 if no ID3v2
  170. // tag is present.
  171. func id3v2AudioStart(path string) (int64, error) {
  172. f, err := os.Open(path)
  173. if err != nil {
  174. return 0, err
  175. }
  176. defer f.Close()
  177. var hdr [10]byte
  178. if _, err := io.ReadFull(f, hdr[:]); err != nil {
  179. return 0, nil // file too short — no tag
  180. }
  181. if string(hdr[:3]) != "ID3" {
  182. return 0, nil // no ID3v2 tag
  183. }
  184. // Bytes 6–9 are a synchsafe integer (7 bits per byte).
  185. size := int64(hdr[6])<<21 | int64(hdr[7])<<14 | int64(hdr[8])<<7 | int64(hdr[9])
  186. return 10 + size, nil
  187. }
  188. func isMP3(path string) bool {
  189. return strings.EqualFold(filepath.Ext(path), ".mp3")
  190. }
  191. // popmToStars maps a raw POPM byte to a 0–5 star rating using the
  192. // Windows Explorer / Winamp read ranges.
  193. func popmToStars(r uint8) int {
  194. switch {
  195. case r == 0:
  196. return 0
  197. case r < 32:
  198. return 1
  199. case r < 96:
  200. return 2
  201. case r < 160:
  202. return 3
  203. case r < 224:
  204. return 4
  205. default:
  206. return 5
  207. }
  208. }
  209. // starsToPOPM returns the canonical POPM byte for a given star count.
  210. func starsToPOPM(stars int) uint8 {
  211. return [6]uint8{0, 1, 64, 128, 196, 255}[stars]
  212. }