// Package server wires together the HTTP API, the Winamp controller, // killist, resume, and the embedded web frontend. package server import ( "encoding/json" "fmt" "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/winamp" "gopkg.in/yaml.v3" ) // Config holds runtime configuration loaded from config.yaml. 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 is the roadamp HTTP server. type Server struct { cfg Config wa *winamp.Controller kl *killist.KillList mux *http.ServeMux } func New(configPath string) (*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(), } s.routes() return s, nil } func (s *Server) Run() error { // Attempt to restore resume state on startup. go s.restoreResume() // Background killist checker. 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() { // Static frontend s.mux.Handle("/", http.FileServer(http.Dir("web/static"))) // API 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) // ?delta=±N (seconds) s.mux.HandleFunc("/api/volume", s.handleVolume) // GET | POST ?level=0-100 s.mux.HandleFunc("/api/mute", s.handleMute) // GET | POST ?muted=true|false s.mux.HandleFunc("/api/killist", s.handleKillist) s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) } // ── Handlers ───────────────────────────────────────────────────────────────── type statusResponse struct { 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"` // system master volume 0–100 Muted bool `json:"muted"` } func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { resp := statusResponse{Running: s.wa.IsRunning()} if resp.Running { switch s.wa.PlayState() { case 1: resp.State = "playing" case 3: resp.State = "paused" default: resp.State = "stopped" } resp.Title = s.wa.GetTitle() resp.Position = s.wa.GetPosition() resp.Length = s.wa.GetLength() resp.PlaylistPos = s.wa.GetPlaylistPosition() resp.PlaylistLength = s.wa.GetPlaylistLength() resp.Version = s.wa.GetVersion() } // System volume is available regardless of whether Winamp is running. if vol, err := volume.Get(); err == nil { resp.Volume = vol } if muted, err := volume.GetMute(); err == nil { resp.Muted = muted } jsonOK(w, resp) } func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) { ok := s.wa.Play() jsonOK(w, map[string]bool{"ok": ok}) } func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) { ok := s.wa.Pause() jsonOK(w, map[string]bool{"ok": ok}) } func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { // Save resume state before stopping. 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(), }) } ok := s.wa.Stop() jsonOK(w, map[string]bool{"ok": ok}) } func (s *Server) handleNext(w http.ResponseWriter, r *http.Request) { ok := s.wa.NextTrack() jsonOK(w, map[string]bool{"ok": ok}) } func (s *Server) handlePrev(w http.ResponseWriter, r *http.Request) { ok := s.wa.PrevTrack() jsonOK(w, map[string]bool{"ok": ok}) } 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 } ok := s.wa.Seek(pos) jsonOK(w, map[string]bool{"ok": ok}) } func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { vol, err := volume.Get() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } 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 (0–100)", http.StatusBadRequest) return } if err := volume.Set(lvl); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } jsonOK(w, map[string]int{"volume": lvl}) } func (s *Server) handleMute(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { muted, err := volume.GetMute() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } jsonOK(w, map[string]bool{"muted": muted}) return } val := r.URL.Query().Get("muted") muted := val == "true" || val == "1" if err := volume.SetMute(muted); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } 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 } if err := s.kl.Add(title); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } jsonOK(w, map[string]string{"added": title}) case http.MethodDelete: title := r.URL.Query().Get("title") if err := s.kl.Remove(title); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } jsonOK(w, map[string]string{"removed": title}) 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"}) return } cmd := exec.Command(s.cfg.WinampPath) if err := cmd.Start(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } jsonOK(w, map[string]string{"status": "started"}) } // ── Background workers ─────────────────────────────────────────────────────── func (s *Server) killChecker() { for range time.Tick(2 * time.Second) { if !s.wa.IsPlaying() { continue } title := s.wa.GetTitle() if s.kl.Contains(title) { log.Printf("killist: skipping %q", title) s.wa.NextTrack() } } } func (s *Server) restoreResume() { // Wait a moment for Winamp to start up. deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { if s.wa.IsRunning() { break } time.Sleep(500 * time.Millisecond) } if !s.wa.IsRunning() { return } st, err := resume.Load(s.cfg.ResumeFile) if err != nil || st == nil { return } // Only restore if playlist length still matches (same session). 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) } // ── Helpers ─────────────────────────────────────────────────────────────────── func jsonOK(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) }