package web import ( "context" "embed" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "sync" "time" "discord-jukebox-bot/pkg/discord" "discord-jukebox-bot/pkg/subsonic" ) //go:embed static/* var staticFiles embed.FS // Server represents the HTTP server for the jukebox status type Server struct { jukebox *discord.JukeboxPlayer bot *discord.Bot httpServer *http.Server addr string clients map[chan []byte]bool clientsMutex sync.RWMutex isRunning bool stop chan struct{} } // NewServer creates a new HTTP server func NewServer(jukebox *discord.JukeboxPlayer, bot *discord.Bot, addr string) *Server { server := &Server{ jukebox: jukebox, bot: bot, addr: addr, clients: make(map[chan []byte]bool), isRunning: false, stop: make(chan struct{}), } return server } // Start starts the HTTP server func (s *Server) Start() error { mux := http.NewServeMux() // Set up routes mux.HandleFunc("/", s.handleIndex) mux.HandleFunc("/status", s.handleStatus) mux.HandleFunc("/events", s.handleEvents) mux.HandleFunc("/static/", s.handleStatic) mux.HandleFunc("/cover/", s.handleCoverArt) s.httpServer = &http.Server{ Addr: s.addr, Handler: mux, } // Start sending updates to clients go s.broadcastUpdates() slog.Info("Starting HTTP server", "address", s.addr) s.isRunning = true // Start the server in a goroutine so Start doesn't block go func() { if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("HTTP server error", "error", err) } }() return nil } // Stop stops the HTTP server func (s *Server) Stop() error { if !s.isRunning { return nil } slog.Info("Stopping HTTP server") s.isRunning = false // Signal the broadcast loop to stop close(s.stop) // Close all client connections s.clientsMutex.Lock() for clientChan := range s.clients { close(clientChan) delete(s.clients, clientChan) } s.clientsMutex.Unlock() // Shutdown the server with a 5 second timeout ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return s.httpServer.Shutdown(ctx) } // handleIndex serves the main page func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } html, err := staticFiles.ReadFile("static/index.html") if err != nil { http.Error(w, "Error reading index.html", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html") w.Write(html) } // handleStatic serves static files func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { // Strip the leading "/static/" from the path filePath := strings.TrimPrefix(r.URL.Path, "/static/") filePath = "static/" + filePath content, err := staticFiles.ReadFile(filePath) if err != nil { http.NotFound(w, r) return } // Set the content type based on file extension contentType := "text/plain" if strings.HasSuffix(filePath, ".css") { contentType = "text/css" } else if strings.HasSuffix(filePath, ".js") { contentType = "application/javascript" } else if strings.HasSuffix(filePath, ".html") { contentType = "text/html" } w.Header().Set("Content-Type", contentType) w.Write(content) } // StatusResponse contains the jukebox status type StatusResponse struct { IsPlaying bool `json:"isPlaying"` CurrentSong *subsonic.Song `json:"currentSong"` SongHistory []*subsonic.Song `json:"songHistory"` UpdateTime string `json:"updateTime"` } // handleStatus returns the current jukebox status as JSON func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { status := s.getStatus() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(status) } // getStatus returns the current status func (s *Server) getStatus() StatusResponse { currentSong := s.jukebox.GetCurrentSong() songHistory := s.jukebox.GetSongHistory() isPlaying := s.bot.IsPlaying() return StatusResponse{ IsPlaying: isPlaying, CurrentSong: currentSong, SongHistory: songHistory, UpdateTime: time.Now().Format(time.RFC3339), } } // handleEvents sets up a Server-Sent Events connection func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { // Set required headers for SSE w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") // Create a channel for this client messageChan := make(chan []byte) // Register the client s.clientsMutex.Lock() s.clients[messageChan] = true s.clientsMutex.Unlock() // Clean up when the client disconnects defer func() { s.clientsMutex.Lock() delete(s.clients, messageChan) close(messageChan) s.clientsMutex.Unlock() }() // Set up a notification channel for when the client disconnects notifyChan := make(chan bool, 1) go func() { <-r.Context().Done() notifyChan <- true }() // Send initial status update status := s.getStatus() data, err := json.Marshal(status) if err == nil { fmt.Fprintf(w, "data: %s\n\n", data) w.(http.Flusher).Flush() } // Keep the connection open and send updates as they come for { select { case <-notifyChan: // Client disconnected return case message, ok := <-messageChan: if !ok { // Channel closed return } fmt.Fprintf(w, "data: %s\n\n", message) w.(http.Flusher).Flush() } } } // handleCoverArt proxies requests for cover art to the Subsonic server func (s *Server) handleCoverArt(w http.ResponseWriter, r *http.Request) { // Extract the cover art ID from the URL path coverArtID := strings.TrimPrefix(r.URL.Path, "/cover/") if coverArtID == "" { http.Error(w, "Cover art ID is required", http.StatusBadRequest) return } // Get the cover art URL from the Subsonic client coverArtURL := s.bot.GetSubsonicClient().GetCoverArtURL(coverArtID) // Create a new request to the Subsonic server req, err := http.NewRequest("GET", coverArtURL, nil) if err != nil { http.Error(w, "Error creating request for cover art", http.StatusInternalServerError) return } // Forward the request to the Subsonic server client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { http.Error(w, "Error fetching cover art", http.StatusInternalServerError) return } defer resp.Body.Close() // Check if the request was successful if resp.StatusCode != http.StatusOK { http.Error(w, "Error fetching cover art from Subsonic", resp.StatusCode) return } // Copy the content type from the response contentType := resp.Header.Get("Content-Type") if contentType != "" { w.Header().Set("Content-Type", contentType) } else { w.Header().Set("Content-Type", "image/jpeg") // Default to JPEG if no content type } // Copy the image data to the response _, err = io.Copy(w, resp.Body) if err != nil { slog.Error("Error copying cover art data", "error", err) } } // broadcastUpdates periodically sends status updates to all connected clients func (s *Server) broadcastUpdates() { // Keep track of the previous status to only send updates when there are changes var prevIsPlaying bool var prevSongID string ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-s.stop: return case <-ticker.C: if !s.isRunning { return } // Get current status status := s.getStatus() // Determine if there's a change currentSongID := "" if status.CurrentSong != nil { currentSongID = status.CurrentSong.ID } hasChanged := (prevIsPlaying != status.IsPlaying) || (prevSongID != currentSongID) if hasChanged { // Update previous state prevIsPlaying = status.IsPlaying prevSongID = currentSongID // Broadcast to all clients data, err := json.Marshal(status) if err != nil { slog.Error("Error marshaling status", "error", err) continue } s.clientsMutex.RLock() for client := range s.clients { select { case client <- data: // Message sent default: // Skip if channel is blocked } } s.clientsMutex.RUnlock() } } } } // StartServer creates and starts a new HTTP server func StartServer(jukebox *discord.JukeboxPlayer, bot *discord.Bot, addr string) (*Server, error) { server := NewServer(jukebox, bot, addr) if err := server.Start(); err != nil { return nil, err } return server, nil }