Web-based Winamp controller for CarPC � Go backend, mobile-first UI
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

517 line
14KB

  1. //go:build windows
  2. // Package server wires together the HTTP API, WebSocket hub, Winamp controller,
  3. // WASAPI viz capture, killist, and resume.
  4. package server
  5. import (
  6. "context"
  7. "encoding/json"
  8. "fmt"
  9. "io/fs"
  10. "log"
  11. "net/http"
  12. "os"
  13. "os/exec"
  14. "strconv"
  15. "time"
  16. "git.svabi.ch/jan/roadamp/internal/killist"
  17. "git.svabi.ch/jan/roadamp/internal/rating"
  18. "git.svabi.ch/jan/roadamp/internal/resume"
  19. "git.svabi.ch/jan/roadamp/internal/viz"
  20. "git.svabi.ch/jan/roadamp/internal/volume"
  21. "git.svabi.ch/jan/roadamp/internal/winamp"
  22. "github.com/gorilla/websocket"
  23. "gopkg.in/yaml.v3"
  24. )
  25. // ── Config ────────────────────────────────────────────────────────────────────
  26. type Config struct {
  27. Host string `yaml:"host"`
  28. Port int `yaml:"port"`
  29. WinampPath string `yaml:"winamp_path"`
  30. KillListFile string `yaml:"killist_file"`
  31. ResumeFile string `yaml:"resume_file"`
  32. }
  33. func loadConfig(path string) (Config, error) {
  34. cfg := Config{
  35. Host: "0.0.0.0",
  36. Port: 8889,
  37. KillListFile: "killist.dat",
  38. ResumeFile: "resume.dat",
  39. }
  40. data, err := os.ReadFile(path)
  41. if os.IsNotExist(err) {
  42. return cfg, nil
  43. }
  44. if err != nil {
  45. return cfg, err
  46. }
  47. return cfg, yaml.Unmarshal(data, &cfg)
  48. }
  49. // ── Server ────────────────────────────────────────────────────────────────────
  50. type Server struct {
  51. cfg Config
  52. wa *winamp.Controller
  53. kl *killist.KillList
  54. hub *hub
  55. mux *http.ServeMux
  56. staticFS fs.FS
  57. }
  58. func New(configPath string, staticFS fs.FS) (*Server, error) {
  59. cfg, err := loadConfig(configPath)
  60. if err != nil {
  61. return nil, fmt.Errorf("config: %w", err)
  62. }
  63. kl, err := killist.Load(cfg.KillListFile)
  64. if err != nil {
  65. return nil, fmt.Errorf("killist: %w", err)
  66. }
  67. s := &Server{cfg: cfg, wa: winamp.New(), kl: kl, mux: http.NewServeMux(), staticFS: staticFS}
  68. s.hub = newHub(s.handleCommand)
  69. s.routes()
  70. return s, nil
  71. }
  72. func (s *Server) Run() error {
  73. go s.hub.run()
  74. go s.broadcastStatus()
  75. go s.broadcastViz()
  76. go s.restoreResume()
  77. go s.killChecker()
  78. addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
  79. log.Printf("roadamp listening on http://%s", addr)
  80. return http.ListenAndServe(addr, s.mux)
  81. }
  82. // ── Routes ────────────────────────────────────────────────────────────────────
  83. func (s *Server) routes() {
  84. s.mux.Handle("/", http.FileServer(http.FS(s.staticFS)))
  85. // WebSocket (primary interface)
  86. s.mux.HandleFunc("/ws", s.handleWS)
  87. // REST API (kept for curl/debugging)
  88. s.mux.HandleFunc("/api/status", s.handleStatus)
  89. s.mux.HandleFunc("/api/play", s.handlePlay)
  90. s.mux.HandleFunc("/api/pause", s.handlePause)
  91. s.mux.HandleFunc("/api/stop", s.handleStop)
  92. s.mux.HandleFunc("/api/next", s.handleNext)
  93. s.mux.HandleFunc("/api/prev", s.handlePrev)
  94. s.mux.HandleFunc("/api/seek", s.handleSeek)
  95. s.mux.HandleFunc("/api/volume", s.handleVolume)
  96. s.mux.HandleFunc("/api/mute", s.handleMute)
  97. s.mux.HandleFunc("/api/killist", s.handleKillist)
  98. s.mux.HandleFunc("/api/playlist", s.handlePlaylist)
  99. s.mux.HandleFunc("/api/rating", s.handleRating)
  100. s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart)
  101. }
  102. // ── WebSocket ─────────────────────────────────────────────────────────────────
  103. var wsUpgrader = websocket.Upgrader{
  104. ReadBufferSize: 1024,
  105. WriteBufferSize: 4096,
  106. CheckOrigin: func(r *http.Request) bool { return true },
  107. }
  108. func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
  109. conn, err := wsUpgrader.Upgrade(w, r, nil)
  110. if err != nil {
  111. log.Printf("ws upgrade: %v", err)
  112. return
  113. }
  114. c := &wsClient{hub: s.hub, conn: conn, send: make(chan []byte, 32)}
  115. s.hub.register <- c
  116. // Send current status immediately upon connect.
  117. if msg, err := s.statusMsg(); err == nil {
  118. c.send <- msg
  119. }
  120. go c.writePump()
  121. go c.readPump()
  122. }
  123. // ── WebSocket broadcast workers ───────────────────────────────────────────────
  124. // broadcastStatus pushes a status message to all clients every 500 ms.
  125. func (s *Server) broadcastStatus() {
  126. for range time.Tick(500 * time.Millisecond) {
  127. msg, err := s.statusMsg()
  128. if err != nil {
  129. continue
  130. }
  131. select {
  132. case s.hub.broadcast <- msg:
  133. default:
  134. }
  135. }
  136. }
  137. // broadcastViz starts the WASAPI loopback capturer and forwards spectrum
  138. // frames to all WebSocket clients at ~30 fps.
  139. func (s *Server) broadcastViz() {
  140. cap := viz.NewCapturer()
  141. ctx, cancel := context.WithCancel(context.Background())
  142. defer cancel()
  143. go cap.Start(ctx)
  144. for bars := range cap.C {
  145. data, err := json.Marshal(struct {
  146. Type string `json:"type"`
  147. Bars []float32 `json:"bars"`
  148. }{"viz", bars})
  149. if err != nil {
  150. continue
  151. }
  152. select {
  153. case s.hub.broadcast <- data:
  154. default:
  155. }
  156. }
  157. }
  158. // ── Command dispatcher (WebSocket) ────────────────────────────────────────────
  159. type wsCommand struct {
  160. Cmd string `json:"cmd"`
  161. Delta int `json:"delta"`
  162. Level int `json:"level"`
  163. Muted bool `json:"muted"`
  164. Title string `json:"title"`
  165. Index int `json:"index"` // 0-based track index for "jump"
  166. }
  167. func (s *Server) handleCommand(raw []byte) {
  168. var cmd wsCommand
  169. if err := json.Unmarshal(raw, &cmd); err != nil {
  170. return
  171. }
  172. switch cmd.Cmd {
  173. case "play":
  174. s.wa.Play()
  175. case "pause":
  176. s.wa.Pause()
  177. case "stop":
  178. s.saveResumeState()
  179. s.wa.Stop()
  180. case "next":
  181. s.wa.NextTrack()
  182. case "prev":
  183. s.wa.PrevTrack()
  184. case "seek":
  185. pos := s.wa.GetPosition() + cmd.Delta
  186. if pos < 0 {
  187. pos = 0
  188. }
  189. s.wa.Seek(pos)
  190. case "volume":
  191. _ = volume.Set(cmd.Level)
  192. case "mute":
  193. _ = volume.SetMute(cmd.Muted)
  194. case "killist_add":
  195. if title := s.wa.GetTitle(); title != "" {
  196. _ = s.kl.Add(title)
  197. }
  198. case "jump":
  199. s.wa.JumpToTrack(cmd.Index)
  200. case "killist_remove":
  201. _ = s.kl.Remove(cmd.Title)
  202. case "winamp_start":
  203. if !s.wa.IsRunning() {
  204. if s.cfg.WinampPath != "" {
  205. _ = exec.Command(s.cfg.WinampPath).Start()
  206. }
  207. }
  208. }
  209. // Push a fresh status after any command.
  210. if msg, err := s.statusMsg(); err == nil {
  211. select {
  212. case s.hub.broadcast <- msg:
  213. default:
  214. }
  215. }
  216. }
  217. // ── Shared status builder ─────────────────────────────────────────────────────
  218. type statusMsg struct {
  219. Type string `json:"type"`
  220. Running bool `json:"running"`
  221. State string `json:"state"`
  222. Title string `json:"title"`
  223. Position int `json:"position"`
  224. Length int `json:"length"`
  225. PlaylistPos int `json:"playlist_pos"`
  226. PlaylistLength int `json:"playlist_length"`
  227. Version string `json:"version"`
  228. Volume int `json:"volume"`
  229. Muted bool `json:"muted"`
  230. }
  231. func (s *Server) statusMsg() ([]byte, error) {
  232. msg := statusMsg{Type: "status", Running: s.wa.IsRunning()}
  233. if msg.Running {
  234. switch s.wa.PlayState() {
  235. case 1:
  236. msg.State = "playing"
  237. case 3:
  238. msg.State = "paused"
  239. default:
  240. msg.State = "stopped"
  241. }
  242. msg.Title = s.wa.GetTitle()
  243. // Winamp returns 0xFFFFFFFF when stopped/no track — clamp to 0.
  244. pos := s.wa.GetPosition()
  245. length := s.wa.GetLength()
  246. if pos > 86400 {
  247. pos = 0
  248. } // > 24h → garbage value
  249. if length > 86400 {
  250. length = 0
  251. }
  252. msg.Position = pos
  253. msg.Length = length
  254. msg.PlaylistPos = s.wa.GetPlaylistPosition()
  255. msg.PlaylistLength = s.wa.GetPlaylistLength()
  256. msg.Version = s.wa.GetVersion()
  257. }
  258. if vol, err := volume.Get(); err == nil {
  259. msg.Volume = vol
  260. }
  261. if muted, err := volume.GetMute(); err == nil {
  262. msg.Muted = muted
  263. }
  264. return json.Marshal(msg)
  265. }
  266. // ── Background workers ────────────────────────────────────────────────────────
  267. func (s *Server) killChecker() {
  268. for range time.Tick(2 * time.Second) {
  269. if !s.wa.IsPlaying() {
  270. continue
  271. }
  272. if title := s.wa.GetTitle(); s.kl.Contains(title) {
  273. log.Printf("killist: skipping %q", title)
  274. s.wa.NextTrack()
  275. }
  276. }
  277. }
  278. // saveResumeState persists the current playback position when a track is
  279. // active (playing or paused). Safe to call unconditionally before Stop.
  280. func (s *Server) saveResumeState() {
  281. state := s.wa.PlayState()
  282. if state != 1 && state != 3 { // not playing and not paused
  283. return
  284. }
  285. title := s.wa.GetTitle()
  286. if title == "" {
  287. return
  288. }
  289. _ = resume.Save(s.cfg.ResumeFile, resume.State{
  290. PlaylistLength: s.wa.GetPlaylistLength(),
  291. PlaylistPos: s.wa.GetPlaylistPosition(),
  292. OffsetSeconds: s.wa.GetPosition(),
  293. TrackTitle: title,
  294. })
  295. }
  296. func (s *Server) restoreResume() {
  297. deadline := time.Now().Add(30 * time.Second)
  298. for time.Now().Before(deadline) {
  299. if s.wa.IsRunning() {
  300. break
  301. }
  302. time.Sleep(500 * time.Millisecond)
  303. }
  304. st, err := resume.Load(s.cfg.ResumeFile)
  305. if err != nil || st == nil {
  306. return
  307. }
  308. if s.wa.GetPlaylistLength() != st.PlaylistLength {
  309. log.Printf("resume: playlist length mismatch (saved %d, got %d) — aborting",
  310. st.PlaylistLength, s.wa.GetPlaylistLength())
  311. _ = resume.Delete(s.cfg.ResumeFile)
  312. return
  313. }
  314. // Jump to the saved track (JumpToTrack is 0-based, also starts playback).
  315. if st.PlaylistPos > 0 {
  316. s.wa.JumpToTrack(st.PlaylistPos - 1)
  317. time.Sleep(300 * time.Millisecond) // give Winamp a moment to load
  318. // Validate title to catch same-length playlists with different content.
  319. if st.TrackTitle != "" {
  320. if got := s.wa.GetTitle(); got != "" && got != st.TrackTitle {
  321. log.Printf("resume: title mismatch (expected %q, got %q) — aborting",
  322. st.TrackTitle, got)
  323. s.wa.Stop()
  324. _ = resume.Delete(s.cfg.ResumeFile)
  325. return
  326. }
  327. }
  328. } else {
  329. s.wa.Play()
  330. }
  331. s.wa.Seek(st.OffsetSeconds)
  332. s.wa.Pause()
  333. _ = resume.Delete(s.cfg.ResumeFile)
  334. log.Printf("resume: restored %q at %ds (playlist pos %d)",
  335. st.TrackTitle, st.OffsetSeconds, st.PlaylistPos)
  336. }
  337. // ── REST handlers (for curl/debug) ───────────────────────────────────────────
  338. func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
  339. msg, err := s.statusMsg()
  340. if err != nil {
  341. http.Error(w, err.Error(), 500)
  342. return
  343. }
  344. w.Header().Set("Content-Type", "application/json")
  345. w.Write(msg)
  346. }
  347. func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.Play()) }
  348. func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.Pause()) }
  349. func (s *Server) handleNext(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.NextTrack()) }
  350. func (s *Server) handlePrev(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.PrevTrack()) }
  351. func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
  352. s.saveResumeState()
  353. jsonOK(w, s.wa.Stop())
  354. }
  355. func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) {
  356. delta, err := strconv.Atoi(r.URL.Query().Get("delta"))
  357. if err != nil {
  358. http.Error(w, "invalid delta", http.StatusBadRequest)
  359. return
  360. }
  361. pos := s.wa.GetPosition() + delta
  362. if pos < 0 {
  363. pos = 0
  364. }
  365. jsonOK(w, s.wa.Seek(pos))
  366. }
  367. func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) {
  368. if r.Method == http.MethodGet {
  369. vol, _ := volume.Get()
  370. jsonOK(w, map[string]int{"volume": vol})
  371. return
  372. }
  373. lvl, err := strconv.Atoi(r.URL.Query().Get("level"))
  374. if err != nil {
  375. http.Error(w, "invalid level", http.StatusBadRequest)
  376. return
  377. }
  378. _ = volume.Set(lvl)
  379. jsonOK(w, map[string]int{"volume": lvl})
  380. }
  381. func (s *Server) handleMute(w http.ResponseWriter, r *http.Request) {
  382. if r.Method == http.MethodGet {
  383. muted, _ := volume.GetMute()
  384. jsonOK(w, map[string]bool{"muted": muted})
  385. return
  386. }
  387. val := r.URL.Query().Get("muted")
  388. muted := val == "true" || val == "1"
  389. _ = volume.SetMute(muted)
  390. jsonOK(w, map[string]bool{"muted": muted})
  391. }
  392. func (s *Server) handleKillist(w http.ResponseWriter, r *http.Request) {
  393. switch r.Method {
  394. case http.MethodGet:
  395. jsonOK(w, s.kl.List())
  396. case http.MethodPost:
  397. title := s.wa.GetTitle()
  398. if title == "" {
  399. http.Error(w, "no track playing", http.StatusConflict)
  400. return
  401. }
  402. _ = s.kl.Add(title)
  403. jsonOK(w, map[string]string{"added": title})
  404. case http.MethodDelete:
  405. _ = s.kl.Remove(r.URL.Query().Get("title"))
  406. jsonOK(w, map[string]bool{"ok": true})
  407. }
  408. }
  409. func (s *Server) handlePlaylist(w http.ResponseWriter, r *http.Request) {
  410. jsonOK(w, s.wa.GetPlaylist())
  411. }
  412. func (s *Server) handleRating(w http.ResponseWriter, r *http.Request) {
  413. switch r.Method {
  414. case http.MethodGet:
  415. path := s.wa.GetCurrentFile()
  416. if path == "" {
  417. jsonOK(w, map[string]int{"stars": 0})
  418. return
  419. }
  420. stars, err := rating.Get(path)
  421. if err != nil {
  422. log.Printf("rating.Get: %v", err)
  423. jsonOK(w, map[string]int{"stars": 0})
  424. return
  425. }
  426. jsonOK(w, map[string]int{"stars": stars})
  427. case http.MethodPost:
  428. var req struct {
  429. Stars int `json:"stars"`
  430. }
  431. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  432. http.Error(w, "bad request", http.StatusBadRequest)
  433. return
  434. }
  435. path := s.wa.GetCurrentFile()
  436. if path == "" {
  437. http.Error(w, "no track playing", http.StatusConflict)
  438. return
  439. }
  440. if err := rating.Set(path, req.Stars); err != nil {
  441. log.Printf("rating.Set: %v", err)
  442. http.Error(w, err.Error(), http.StatusInternalServerError)
  443. return
  444. }
  445. jsonOK(w, map[string]int{"stars": req.Stars})
  446. default:
  447. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  448. }
  449. }
  450. func (s *Server) handleWinampStart(w http.ResponseWriter, r *http.Request) {
  451. if s.wa.IsRunning() {
  452. jsonOK(w, map[string]string{"status": "already_running"})
  453. return
  454. }
  455. if s.cfg.WinampPath == "" {
  456. http.Error(w, "winamp_path not configured", http.StatusServiceUnavailable)
  457. return
  458. }
  459. if err := exec.Command(s.cfg.WinampPath).Start(); err != nil {
  460. http.Error(w, err.Error(), 500)
  461. return
  462. }
  463. jsonOK(w, map[string]string{"status": "started"})
  464. }
  465. func jsonOK(w http.ResponseWriter, v any) {
  466. w.Header().Set("Content-Type", "application/json")
  467. json.NewEncoder(w).Encode(v)
  468. }