|
- // 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)
- }
|