//go:build windows // Package server wires together the HTTP API, WebSocket hub, Winamp controller, // WASAPI viz capture, killist, and resume. package server import ( "context" "encoding/json" "fmt" "io/fs" "log" "net/http" "os" "os/exec" "strconv" "time" "git.svabi.ch/jan/roadamp/internal/killist" "git.svabi.ch/jan/roadamp/internal/resume" "git.svabi.ch/jan/roadamp/internal/volume" "git.svabi.ch/jan/roadamp/internal/viz" "git.svabi.ch/jan/roadamp/internal/winamp" "github.com/gorilla/websocket" "gopkg.in/yaml.v3" ) // ── Config ──────────────────────────────────────────────────────────────────── type Config struct { Port int `yaml:"port"` WinampPath string `yaml:"winamp_path"` KillListFile string `yaml:"killist_file"` ResumeFile string `yaml:"resume_file"` } func loadConfig(path string) (Config, error) { cfg := Config{ Port: 8080, KillListFile: "killist.dat", ResumeFile: "resume.dat", } data, err := os.ReadFile(path) if os.IsNotExist(err) { return cfg, nil } if err != nil { return cfg, err } return cfg, yaml.Unmarshal(data, &cfg) } // ── Server ──────────────────────────────────────────────────────────────────── type Server struct { cfg Config wa *winamp.Controller kl *killist.KillList hub *hub mux *http.ServeMux staticFS fs.FS } func New(configPath string, staticFS fs.FS) (*Server, error) { cfg, err := loadConfig(configPath) if err != nil { return nil, fmt.Errorf("config: %w", err) } kl, err := killist.Load(cfg.KillListFile) if err != nil { return nil, fmt.Errorf("killist: %w", err) } s := &Server{cfg: cfg, wa: winamp.New(), kl: kl, mux: http.NewServeMux(), staticFS: staticFS} s.hub = newHub(s.handleCommand) s.routes() return s, nil } func (s *Server) Run() error { go s.hub.run() go s.broadcastStatus() go s.broadcastViz() go s.restoreResume() go s.killChecker() addr := fmt.Sprintf(":%d", s.cfg.Port) log.Printf("roadamp listening on http://localhost%s", addr) return http.ListenAndServe(addr, s.mux) } // ── Routes ──────────────────────────────────────────────────────────────────── func (s *Server) routes() { s.mux.Handle("/", http.FileServer(http.FS(s.staticFS))) // WebSocket (primary interface) s.mux.HandleFunc("/ws", s.handleWS) // REST API (kept for curl/debugging) s.mux.HandleFunc("/api/status", s.handleStatus) s.mux.HandleFunc("/api/play", s.handlePlay) s.mux.HandleFunc("/api/pause", s.handlePause) s.mux.HandleFunc("/api/stop", s.handleStop) s.mux.HandleFunc("/api/next", s.handleNext) s.mux.HandleFunc("/api/prev", s.handlePrev) s.mux.HandleFunc("/api/seek", s.handleSeek) s.mux.HandleFunc("/api/volume", s.handleVolume) s.mux.HandleFunc("/api/mute", s.handleMute) s.mux.HandleFunc("/api/killist", s.handleKillist) s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) } // ── WebSocket ───────────────────────────────────────────────────────────────── var wsUpgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 4096, CheckOrigin: func(r *http.Request) bool { return true }, } func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { conn, err := wsUpgrader.Upgrade(w, r, nil) if err != nil { log.Printf("ws upgrade: %v", err) return } c := &wsClient{hub: s.hub, conn: conn, send: make(chan []byte, 32)} s.hub.register <- c // Send current status immediately upon connect. if msg, err := s.statusMsg(); err == nil { c.send <- msg } go c.writePump() go c.readPump() } // ── WebSocket broadcast workers ─────────────────────────────────────────────── // broadcastStatus pushes a status message to all clients every 500 ms. func (s *Server) broadcastStatus() { for range time.Tick(500 * time.Millisecond) { msg, err := s.statusMsg() if err != nil { continue } select { case s.hub.broadcast <- msg: default: } } } // broadcastViz starts the WASAPI loopback capturer and forwards spectrum // frames to all WebSocket clients at ~30 fps. func (s *Server) broadcastViz() { cap := viz.NewCapturer() ctx, cancel := context.WithCancel(context.Background()) defer cancel() go cap.Start(ctx) for bars := range cap.C { data, err := json.Marshal(struct { Type string `json:"type"` Bars []float32 `json:"bars"` }{"viz", bars}) if err != nil { continue } select { case s.hub.broadcast <- data: default: } } } // ── Command dispatcher (WebSocket) ──────────────────────────────────────────── type wsCommand struct { Cmd string `json:"cmd"` Delta int `json:"delta"` Level int `json:"level"` Muted bool `json:"muted"` Title string `json:"title"` } func (s *Server) handleCommand(raw []byte) { var cmd wsCommand if err := json.Unmarshal(raw, &cmd); err != nil { return } switch cmd.Cmd { case "play": s.wa.Play() case "pause": s.wa.Pause() case "stop": if s.wa.IsPaused() { _ = resume.Save(s.cfg.ResumeFile, resume.State{ PlaylistLength: s.wa.GetPlaylistLength(), PlaylistPos: s.wa.GetPlaylistPosition(), OffsetSeconds: s.wa.GetPosition(), TrackTitle: s.wa.GetTitle(), }) } s.wa.Stop() case "next": s.wa.NextTrack() case "prev": s.wa.PrevTrack() case "seek": pos := s.wa.GetPosition() + cmd.Delta if pos < 0 { pos = 0 } s.wa.Seek(pos) case "volume": _ = volume.Set(cmd.Level) case "mute": _ = volume.SetMute(cmd.Muted) case "killist_add": if title := s.wa.GetTitle(); title != "" { _ = s.kl.Add(title) } case "killist_remove": _ = s.kl.Remove(cmd.Title) case "winamp_start": if !s.wa.IsRunning() { if s.cfg.WinampPath != "" { _ = exec.Command(s.cfg.WinampPath).Start() } } } // Push a fresh status after any command. if msg, err := s.statusMsg(); err == nil { select { case s.hub.broadcast <- msg: default: } } } // ── Shared status builder ───────────────────────────────────────────────────── type statusMsg struct { Type string `json:"type"` Running bool `json:"running"` State string `json:"state"` Title string `json:"title"` Position int `json:"position"` Length int `json:"length"` PlaylistPos int `json:"playlist_pos"` PlaylistLength int `json:"playlist_length"` Version string `json:"version"` Volume int `json:"volume"` Muted bool `json:"muted"` } func (s *Server) statusMsg() ([]byte, error) { msg := statusMsg{Type: "status", Running: s.wa.IsRunning()} if msg.Running { switch s.wa.PlayState() { case 1: msg.State = "playing" case 3: msg.State = "paused" default: msg.State = "stopped" } msg.Title = s.wa.GetTitle() // Winamp returns 0xFFFFFFFF when stopped/no track — clamp to 0. pos := s.wa.GetPosition() length := s.wa.GetLength() if pos > 86400 { pos = 0 } // > 24h → garbage value if length > 86400 { length = 0 } msg.Position = pos msg.Length = length msg.PlaylistPos = s.wa.GetPlaylistPosition() msg.PlaylistLength = s.wa.GetPlaylistLength() msg.Version = s.wa.GetVersion() } if vol, err := volume.Get(); err == nil { msg.Volume = vol } if muted, err := volume.GetMute(); err == nil { msg.Muted = muted } return json.Marshal(msg) } // ── Background workers ──────────────────────────────────────────────────────── func (s *Server) killChecker() { for range time.Tick(2 * time.Second) { if !s.wa.IsPlaying() { continue } if title := s.wa.GetTitle(); s.kl.Contains(title) { log.Printf("killist: skipping %q", title) s.wa.NextTrack() } } } func (s *Server) restoreResume() { deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { if s.wa.IsRunning() { break } time.Sleep(500 * time.Millisecond) } st, err := resume.Load(s.cfg.ResumeFile) if err != nil || st == nil { return } if s.wa.GetPlaylistLength() != st.PlaylistLength { _ = resume.Delete(s.cfg.ResumeFile) return } s.wa.Play() s.wa.Seek(st.OffsetSeconds) s.wa.Pause() _ = resume.Delete(s.cfg.ResumeFile) log.Printf("resume: restored %q at %ds", st.TrackTitle, st.OffsetSeconds) } // ── REST handlers (for curl/debug) ─────────────────────────────────────────── func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { msg, err := s.statusMsg() if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/json") w.Write(msg) } func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.Play()) } func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.Pause()) } func (s *Server) handleNext(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.NextTrack()) } func (s *Server) handlePrev(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.PrevTrack()) } func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { if s.wa.IsPaused() { _ = resume.Save(s.cfg.ResumeFile, resume.State{ PlaylistLength: s.wa.GetPlaylistLength(), PlaylistPos: s.wa.GetPlaylistPosition(), OffsetSeconds: s.wa.GetPosition(), TrackTitle: s.wa.GetTitle(), }) } jsonOK(w, s.wa.Stop()) } func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) { delta, err := strconv.Atoi(r.URL.Query().Get("delta")) if err != nil { http.Error(w, "invalid delta", http.StatusBadRequest) return } pos := s.wa.GetPosition() + delta if pos < 0 { pos = 0 } jsonOK(w, s.wa.Seek(pos)) } func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { vol, _ := volume.Get() jsonOK(w, map[string]int{"volume": vol}) return } lvl, err := strconv.Atoi(r.URL.Query().Get("level")) if err != nil { http.Error(w, "invalid level", http.StatusBadRequest) return } _ = volume.Set(lvl) jsonOK(w, map[string]int{"volume": lvl}) } func (s *Server) handleMute(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { muted, _ := volume.GetMute() jsonOK(w, map[string]bool{"muted": muted}) return } val := r.URL.Query().Get("muted") muted := val == "true" || val == "1" _ = volume.SetMute(muted) jsonOK(w, map[string]bool{"muted": muted}) } func (s *Server) handleKillist(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: jsonOK(w, s.kl.List()) case http.MethodPost: title := s.wa.GetTitle() if title == "" { http.Error(w, "no track playing", http.StatusConflict) return } _ = s.kl.Add(title) jsonOK(w, map[string]string{"added": title}) case http.MethodDelete: _ = s.kl.Remove(r.URL.Query().Get("title")) jsonOK(w, map[string]bool{"ok": true}) } } func (s *Server) handleWinampStart(w http.ResponseWriter, r *http.Request) { if s.wa.IsRunning() { jsonOK(w, map[string]string{"status": "already_running"}) return } if s.cfg.WinampPath == "" { http.Error(w, "winamp_path not configured", http.StatusServiceUnavailable) return } if err := exec.Command(s.cfg.WinampPath).Start(); err != nil { http.Error(w, err.Error(), 500) return } jsonOK(w, map[string]string{"status": "started"}) } func jsonOK(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) }