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 | go 1.25.0 | ||||
| require ( | require ( | ||||
| github.com/bogem/id3v2/v2 v2.1.4 // indirect | |||||
| github.com/gorilla/websocket v1.5.3 // indirect | github.com/gorilla/websocket v1.5.3 // indirect | ||||
| golang.org/x/sys v0.45.0 // 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 | 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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= | ||||
| github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= | 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 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= | ||||
| golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | 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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | 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" | "time" | ||||
| "git.svabi.ch/jan/roadamp/internal/killist" | "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/resume" | ||||
| "git.svabi.ch/jan/roadamp/internal/viz" | "git.svabi.ch/jan/roadamp/internal/viz" | ||||
| "git.svabi.ch/jan/roadamp/internal/volume" | "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/mute", s.handleMute) | ||||
| s.mux.HandleFunc("/api/killist", s.handleKillist) | s.mux.HandleFunc("/api/killist", s.handleKillist) | ||||
| s.mux.HandleFunc("/api/playlist", s.handlePlaylist) | s.mux.HandleFunc("/api/playlist", s.handlePlaylist) | ||||
| s.mux.HandleFunc("/api/rating", s.handleRating) | |||||
| s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) | 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()) | 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) { | func (s *Server) handleWinampStart(w http.ResponseWriter, r *http.Request) { | ||||
| if s.wa.IsRunning() { | if s.wa.IsRunning() { | ||||
| jsonOK(w, map[string]string{"status": "already_running"}) | jsonOK(w, map[string]string{"status": "already_running"}) | ||||
| @@ -52,6 +52,7 @@ const ( | |||||
| userGetPlaylistLen = 124 | userGetPlaylistLen = 124 | ||||
| userSetPlaylistPos = 121 | userSetPlaylistPos = 121 | ||||
| userGetPlaylistTitle = 212 | userGetPlaylistTitle = 212 | ||||
| userGetPlaylistFile = 211 | |||||
| userRestart = 135 | userRestart = 135 | ||||
| // OpenProcess access right | // OpenProcess access right | ||||
| @@ -335,3 +336,26 @@ func (c *Controller) JumpToTrack(zeroBasedIndex int) bool { | |||||
| send(h, wmCommand, cmdPlay, 0) | send(h, wmCommand, cmdPlay, 0) | ||||
| return true | 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'); | const ctx2d = canvas.getContext('2d'); | ||||
| // ── State ───────────────────────────────────────────────────────────────────── | // ── 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 ws = null; | ||||
| let reconnectTimer = null; | let reconnectTimer = null; | ||||
| @@ -91,6 +93,17 @@ function applyStatus(st) { | |||||
| stateLabel.textContent = stateMap[st.state] ?? st.state; | stateLabel.textContent = stateMap[st.state] ?? st.state; | ||||
| trackTitle.textContent = st.title || '–'; | 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 | playlistPos.textContent = st.playlist_length | ||||
| ? `${st.playlist_pos} / ${st.playlist_length}` : ''; | ? `${st.playlist_pos} / ${st.playlist_length}` : ''; | ||||
| @@ -182,6 +195,48 @@ function updateMuteBtn(muted) { | |||||
| btnMute.classList.toggle('muted', 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 ────────────────────────────────────────────────────────────────── | // ── KillList ────────────────────────────────────────────────────────────────── | ||||
| $('btn-kill').addEventListener('click', () => { | $('btn-kill').addEventListener('click', () => { | ||||
| send({ cmd: 'killist_add' }); | send({ cmd: 'killist_add' }); | ||||
| @@ -22,6 +22,13 @@ | |||||
| <div id="track-info"> | <div id="track-info"> | ||||
| <div id="track-title">Nicht verbunden</div> | <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 id="playlist-pos"></div> | ||||
| </div> | </div> | ||||
| @@ -56,10 +56,26 @@ html, body { | |||||
| overflow: hidden; | overflow: hidden; | ||||
| text-overflow: ellipsis; | 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 { | #playlist-pos { | ||||
| font-size: 12px; | font-size: 12px; | ||||
| color: var(--text-dim); | color: var(--text-dim); | ||||
| margin-top: 4px; | |||||
| margin-top: 6px; | |||||
| } | } | ||||
| /* Visualisation canvas */ | /* Visualisation canvas */ | ||||