|
- //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,
- WinampPath: `C:\Program Files\Winamp\Winamp.exe`,
- 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() {
- _ = 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 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)
- }
|