Browse Source

feat: ID3 star rating (POPM) — display and set via tap

Backend:
- winamp.GetCurrentFile() reads file path via IPC_GETPLAYLISTFILE (211)
  + ReadProcessMemory, same pattern as playlist titles
- internal/rating: Get/Set POPM frame via bogem/id3v2
  - Email: rating@winamp.com (Winamp standard)
  - Byte scale: 0/1/64/128/196/255 = 0-5 stars
  - Compatible with Windows Explorer and Winamp
- GET /api/rating -> {stars: N}
- POST /api/rating {stars: N} -> writes POPM, returns {stars: N}

Frontend:
- 5 stars in track-info, gold when lit
- Fetched automatically on track change
- Tap to rate; tap same star again to remove rating
- Optimistic update with revert on error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
Jan Svabenik 1 month ago
parent
commit
f3db212096
8 changed files with 281 additions and 3 deletions
  1. +2
    -0
      go.mod
  2. +27
    -0
      go.sum
  3. +104
    -0
      internal/rating/rating.go
  4. +43
    -0
      internal/server/server.go
  5. +24
    -0
      internal/winamp/winamp.go
  6. +57
    -2
      web/static/app.js
  7. +7
    -0
      web/static/index.html
  8. +17
    -1
      web/static/style.css

+ 2
- 0
go.mod View File

@@ -3,7 +3,9 @@ module git.svabi.ch/jan/roadamp
go 1.25.0

require (
github.com/bogem/id3v2/v2 v2.1.4 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.3.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

+ 27
- 0
go.sum View File

@@ -1,7 +1,34 @@
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 104
- 0
internal/rating/rating.go View File

@@ -0,0 +1,104 @@
// 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]
}

+ 43
- 0
internal/server/server.go View File

@@ -17,6 +17,7 @@ import (
"time"

"git.svabi.ch/jan/roadamp/internal/killist"
"git.svabi.ch/jan/roadamp/internal/rating"
"git.svabi.ch/jan/roadamp/internal/resume"
"git.svabi.ch/jan/roadamp/internal/viz"
"git.svabi.ch/jan/roadamp/internal/volume"
@@ -110,6 +111,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/mute", s.handleMute)
s.mux.HandleFunc("/api/killist", s.handleKillist)
s.mux.HandleFunc("/api/playlist", s.handlePlaylist)
s.mux.HandleFunc("/api/rating", s.handleRating)
s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart)
}

@@ -451,6 +453,47 @@ func (s *Server) handlePlaylist(w http.ResponseWriter, r *http.Request) {
jsonOK(w, s.wa.GetPlaylist())
}

func (s *Server) handleRating(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
path := s.wa.GetCurrentFile()
if path == "" {
jsonOK(w, map[string]int{"stars": 0})
return
}
stars, err := rating.Get(path)
if err != nil {
log.Printf("rating.Get: %v", err)
jsonOK(w, map[string]int{"stars": 0})
return
}
jsonOK(w, map[string]int{"stars": stars})

case http.MethodPost:
var req struct {
Stars int `json:"stars"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
path := s.wa.GetCurrentFile()
if path == "" {
http.Error(w, "no track playing", http.StatusConflict)
return
}
if err := rating.Set(path, req.Stars); err != nil {
log.Printf("rating.Set: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, map[string]int{"stars": req.Stars})

default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}

func (s *Server) handleWinampStart(w http.ResponseWriter, r *http.Request) {
if s.wa.IsRunning() {
jsonOK(w, map[string]string{"status": "already_running"})


+ 24
- 0
internal/winamp/winamp.go View File

@@ -52,6 +52,7 @@ const (
userGetPlaylistLen = 124
userSetPlaylistPos = 121
userGetPlaylistTitle = 212
userGetPlaylistFile = 211
userRestart = 135

// OpenProcess access right
@@ -335,3 +336,26 @@ func (c *Controller) JumpToTrack(zeroBasedIndex int) bool {
send(h, wmCommand, cmdPlay, 0)
return true
}

// GetCurrentFile returns the file system path of the currently playing track.
// It reads the path from Winamp's process memory via IPC_GETPLAYLISTFILE.
func (c *Controller) GetCurrentFile() string {
h := c.handle()
if h == 0 {
return ""
}
// userGetPlaylistPos returns 0-based current index
pos := send(h, wmUser, 0, userGetPlaylistPos)
ptr := send(h, wmUser, pos, userGetPlaylistFile)
if ptr == 0 {
return ""
}
var pid uint32
getWindowThreadProcessId.Call(uintptr(h), uintptr(unsafe.Pointer(&pid)))
proc, _, _ := openProcess.Call(processVMRead, 0, uintptr(pid))
if proc == 0 {
return ""
}
defer syscall.CloseHandle(syscall.Handle(proc))
return readRemoteString(syscall.Handle(proc), ptr)
}

+ 57
- 2
web/static/app.js View File

@@ -22,8 +22,10 @@ const canvas = $('viz');
const ctx2d = canvas.getContext('2d');

// ── State ─────────────────────────────────────────────────────────────────────
let currentVolume = 50;
let currentPlaylistPos = 0; // 1-based, updated from status
let currentVolume = 50;
let currentPlaylistPos = 0; // 1-based, updated from status
let currentRating = -1; // -1 = not yet loaded
let lastRatedTitle = ''; // track we last fetched rating for
let ws = null;
let reconnectTimer = null;

@@ -91,6 +93,17 @@ function applyStatus(st) {
stateLabel.textContent = stateMap[st.state] ?? st.state;

trackTitle.textContent = st.title || '–';

// Fetch rating whenever the track changes
if (st.title && st.title !== lastRatedTitle) {
lastRatedTitle = st.title;
fetchRating();
}
if (!st.title || !st.running) {
lastRatedTitle = '';
renderStars(0);
}

playlistPos.textContent = st.playlist_length
? `${st.playlist_pos} / ${st.playlist_length}` : '';

@@ -182,6 +195,48 @@ function updateMuteBtn(muted) {
btnMute.classList.toggle('muted', muted);
}

// ── Rating (stars) ────────────────────────────────────────────────────────────
const starEls = document.querySelectorAll('.star');

async function fetchRating() {
try {
const r = await fetch('/api/rating').then(r => r.json());
currentRating = r.stars ?? 0;
renderStars(currentRating);
} catch {
renderStars(0);
}
}

function renderStars(n) {
currentRating = n;
starEls.forEach(s => {
const v = parseInt(s.dataset.v, 10);
const lit = v <= n;
s.textContent = lit ? '★' : '☆';
s.classList.toggle('lit', lit);
});
}

starEls.forEach(s => {
s.addEventListener('click', async () => {
const v = parseInt(s.dataset.v, 10);
// Tap the current rating again → remove it (set to 0)
const newRating = v === currentRating ? 0 : v;
renderStars(newRating);
try {
await fetch('/api/rating', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stars: newRating }),
});
} catch {
// Revert on error
renderStars(currentRating);
}
});
});

// ── KillList ──────────────────────────────────────────────────────────────────
$('btn-kill').addEventListener('click', () => {
send({ cmd: 'killist_add' });


+ 7
- 0
web/static/index.html View File

@@ -22,6 +22,13 @@

<div id="track-info">
<div id="track-title">Nicht verbunden</div>
<div id="star-rating" aria-label="Bewertung">
<span class="star" data-v="1">☆</span>
<span class="star" data-v="2">☆</span>
<span class="star" data-v="3">☆</span>
<span class="star" data-v="4">☆</span>
<span class="star" data-v="5">☆</span>
</div>
<div id="playlist-pos"></div>
</div>



+ 17
- 1
web/static/style.css View File

@@ -56,10 +56,26 @@ html, body {
overflow: hidden;
text-overflow: ellipsis;
}
#star-rating {
font-size: 32px;
margin-top: 10px;
letter-spacing: 4px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.star {
color: #333;
transition: color 0.12s, transform 0.1s;
display: inline-block;
}
.star.lit { color: #f5a623; }
.star:active { transform: scale(1.25); }

#playlist-pos {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
margin-top: 6px;
}

/* Visualisation canvas */


Loading…
Cancel
Save