348 lines
No EOL
8.4 KiB
Go
348 lines
No EOL
8.4 KiB
Go
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
|
|
} |