Web-based Winamp controller for CarPC � Go backend, mobile-first UI
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

328 Zeilen
9.1KB

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