Web-based Winamp controller for CarPC � Go backend, mobile-first UI
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

286 строки
7.8KB

  1. // Package server wires together the HTTP API, the Winamp controller,
  2. // killist, resume, and the embedded web frontend.
  3. package server
  4. import (
  5. "encoding/json"
  6. "fmt"
  7. "log"
  8. "net/http"
  9. "os"
  10. "os/exec"
  11. "strconv"
  12. "time"
  13. "git.svabi.ch/jan/roadamp/internal/killist"
  14. "git.svabi.ch/jan/roadamp/internal/resume"
  15. "git.svabi.ch/jan/roadamp/internal/winamp"
  16. "gopkg.in/yaml.v3"
  17. )
  18. // Config holds runtime configuration loaded from config.yaml.
  19. type Config struct {
  20. Port int `yaml:"port"`
  21. WinampPath string `yaml:"winamp_path"`
  22. KillListFile string `yaml:"killist_file"`
  23. ResumeFile string `yaml:"resume_file"`
  24. }
  25. func loadConfig(path string) (Config, error) {
  26. cfg := Config{
  27. Port: 8080,
  28. WinampPath: `C:\Program Files\Winamp\Winamp.exe`,
  29. KillListFile: "killist.dat",
  30. ResumeFile: "resume.dat",
  31. }
  32. data, err := os.ReadFile(path)
  33. if os.IsNotExist(err) {
  34. return cfg, nil
  35. }
  36. if err != nil {
  37. return cfg, err
  38. }
  39. return cfg, yaml.Unmarshal(data, &cfg)
  40. }
  41. // Server is the roadamp HTTP server.
  42. type Server struct {
  43. cfg Config
  44. wa *winamp.Controller
  45. kl *killist.KillList
  46. mux *http.ServeMux
  47. }
  48. func New(configPath string) (*Server, error) {
  49. cfg, err := loadConfig(configPath)
  50. if err != nil {
  51. return nil, fmt.Errorf("config: %w", err)
  52. }
  53. kl, err := killist.Load(cfg.KillListFile)
  54. if err != nil {
  55. return nil, fmt.Errorf("killist: %w", err)
  56. }
  57. s := &Server{
  58. cfg: cfg,
  59. wa: winamp.New(),
  60. kl: kl,
  61. mux: http.NewServeMux(),
  62. }
  63. s.routes()
  64. return s, nil
  65. }
  66. func (s *Server) Run() error {
  67. // Attempt to restore resume state on startup.
  68. go s.restoreResume()
  69. // Background killist checker.
  70. go s.killChecker()
  71. addr := fmt.Sprintf(":%d", s.cfg.Port)
  72. log.Printf("roadamp listening on http://localhost%s", addr)
  73. return http.ListenAndServe(addr, s.mux)
  74. }
  75. // ── Routes ──────────────────────────────────────────────────────────────────
  76. func (s *Server) routes() {
  77. // Static frontend
  78. s.mux.Handle("/", http.FileServer(http.Dir("web/static")))
  79. // API
  80. s.mux.HandleFunc("/api/status", s.handleStatus)
  81. s.mux.HandleFunc("/api/play", s.handlePlay)
  82. s.mux.HandleFunc("/api/pause", s.handlePause)
  83. s.mux.HandleFunc("/api/stop", s.handleStop)
  84. s.mux.HandleFunc("/api/next", s.handleNext)
  85. s.mux.HandleFunc("/api/prev", s.handlePrev)
  86. s.mux.HandleFunc("/api/seek", s.handleSeek) // ?delta=±N (seconds)
  87. s.mux.HandleFunc("/api/volume", s.handleVolume) // ?level=0-255
  88. s.mux.HandleFunc("/api/killist", s.handleKillist)
  89. s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart)
  90. }
  91. // ── Handlers ─────────────────────────────────────────────────────────────────
  92. type statusResponse struct {
  93. Running bool `json:"running"`
  94. State string `json:"state"`
  95. Title string `json:"title"`
  96. Position int `json:"position"`
  97. Length int `json:"length"`
  98. PlaylistPos int `json:"playlist_pos"`
  99. PlaylistLength int `json:"playlist_length"`
  100. Version string `json:"version"`
  101. }
  102. func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
  103. resp := statusResponse{Running: s.wa.IsRunning()}
  104. if resp.Running {
  105. switch s.wa.PlayState() {
  106. case 1:
  107. resp.State = "playing"
  108. case 3:
  109. resp.State = "paused"
  110. default:
  111. resp.State = "stopped"
  112. }
  113. resp.Title = s.wa.GetTitle()
  114. resp.Position = s.wa.GetPosition()
  115. resp.Length = s.wa.GetLength()
  116. resp.PlaylistPos = s.wa.GetPlaylistPosition()
  117. resp.PlaylistLength = s.wa.GetPlaylistLength()
  118. resp.Version = s.wa.GetVersion()
  119. }
  120. jsonOK(w, resp)
  121. }
  122. func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
  123. ok := s.wa.Play()
  124. jsonOK(w, map[string]bool{"ok": ok})
  125. }
  126. func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) {
  127. ok := s.wa.Pause()
  128. jsonOK(w, map[string]bool{"ok": ok})
  129. }
  130. func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
  131. // Save resume state before stopping.
  132. if s.wa.IsPaused() {
  133. _ = resume.Save(s.cfg.ResumeFile, resume.State{
  134. PlaylistLength: s.wa.GetPlaylistLength(),
  135. PlaylistPos: s.wa.GetPlaylistPosition(),
  136. OffsetSeconds: s.wa.GetPosition(),
  137. TrackTitle: s.wa.GetTitle(),
  138. })
  139. }
  140. ok := s.wa.Stop()
  141. jsonOK(w, map[string]bool{"ok": ok})
  142. }
  143. func (s *Server) handleNext(w http.ResponseWriter, r *http.Request) {
  144. ok := s.wa.NextTrack()
  145. jsonOK(w, map[string]bool{"ok": ok})
  146. }
  147. func (s *Server) handlePrev(w http.ResponseWriter, r *http.Request) {
  148. ok := s.wa.PrevTrack()
  149. jsonOK(w, map[string]bool{"ok": ok})
  150. }
  151. func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) {
  152. delta, err := strconv.Atoi(r.URL.Query().Get("delta"))
  153. if err != nil {
  154. http.Error(w, "invalid delta", http.StatusBadRequest)
  155. return
  156. }
  157. pos := s.wa.GetPosition() + delta
  158. if pos < 0 {
  159. pos = 0
  160. }
  161. ok := s.wa.Seek(pos)
  162. jsonOK(w, map[string]bool{"ok": ok})
  163. }
  164. func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) {
  165. lvl, err := strconv.Atoi(r.URL.Query().Get("level"))
  166. if err != nil {
  167. http.Error(w, "invalid level", http.StatusBadRequest)
  168. return
  169. }
  170. ok := s.wa.SetVolume(lvl)
  171. jsonOK(w, map[string]bool{"ok": ok})
  172. }
  173. func (s *Server) handleKillist(w http.ResponseWriter, r *http.Request) {
  174. switch r.Method {
  175. case http.MethodGet:
  176. jsonOK(w, s.kl.List())
  177. case http.MethodPost:
  178. title := s.wa.GetTitle()
  179. if title == "" {
  180. http.Error(w, "no track playing", http.StatusConflict)
  181. return
  182. }
  183. if err := s.kl.Add(title); err != nil {
  184. http.Error(w, err.Error(), http.StatusInternalServerError)
  185. return
  186. }
  187. jsonOK(w, map[string]string{"added": title})
  188. case http.MethodDelete:
  189. title := r.URL.Query().Get("title")
  190. if err := s.kl.Remove(title); err != nil {
  191. http.Error(w, err.Error(), http.StatusInternalServerError)
  192. return
  193. }
  194. jsonOK(w, map[string]string{"removed": title})
  195. default:
  196. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  197. }
  198. }
  199. func (s *Server) handleWinampStart(w http.ResponseWriter, r *http.Request) {
  200. if s.wa.IsRunning() {
  201. jsonOK(w, map[string]string{"status": "already_running"})
  202. return
  203. }
  204. cmd := exec.Command(s.cfg.WinampPath)
  205. if err := cmd.Start(); err != nil {
  206. http.Error(w, err.Error(), http.StatusInternalServerError)
  207. return
  208. }
  209. jsonOK(w, map[string]string{"status": "started"})
  210. }
  211. // ── Background workers ───────────────────────────────────────────────────────
  212. func (s *Server) killChecker() {
  213. for range time.Tick(2 * time.Second) {
  214. if !s.wa.IsPlaying() {
  215. continue
  216. }
  217. title := s.wa.GetTitle()
  218. if s.kl.Contains(title) {
  219. log.Printf("killist: skipping %q", title)
  220. s.wa.NextTrack()
  221. }
  222. }
  223. }
  224. func (s *Server) restoreResume() {
  225. // Wait a moment for Winamp to start up.
  226. deadline := time.Now().Add(30 * time.Second)
  227. for time.Now().Before(deadline) {
  228. if s.wa.IsRunning() {
  229. break
  230. }
  231. time.Sleep(500 * time.Millisecond)
  232. }
  233. if !s.wa.IsRunning() {
  234. return
  235. }
  236. st, err := resume.Load(s.cfg.ResumeFile)
  237. if err != nil || st == nil {
  238. return
  239. }
  240. // Only restore if playlist length still matches (same session).
  241. if s.wa.GetPlaylistLength() != st.PlaylistLength {
  242. _ = resume.Delete(s.cfg.ResumeFile)
  243. return
  244. }
  245. s.wa.Play()
  246. s.wa.Seek(st.OffsetSeconds)
  247. s.wa.Pause()
  248. _ = resume.Delete(s.cfg.ResumeFile)
  249. log.Printf("resume: restored %q at %ds", st.TrackTitle, st.OffsetSeconds)
  250. }
  251. // ── Helpers ───────────────────────────────────────────────────────────────────
  252. func jsonOK(w http.ResponseWriter, v any) {
  253. w.Header().Set("Content-Type", "application/json")
  254. json.NewEncoder(w).Encode(v)
  255. }