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
| @@ -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 | |||
| ) | |||
| @@ -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= | |||
| @@ -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] | |||
| } | |||
| @@ -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"}) | |||
| @@ -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) | |||
| } | |||
| @@ -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' }); | |||
| @@ -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> | |||
| @@ -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 */ | |||