feat: added web server with currently playing song
This commit is contained in:
parent
1a4986f294
commit
7f16452a99
12 changed files with 847 additions and 9 deletions
345
pkg/web/server.go
Normal file
345
pkg/web/server.go
Normal file
|
@ -0,0 +1,345 @@
|
|||
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"`
|
||||
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()
|
||||
isPlaying := s.bot.IsPlaying()
|
||||
|
||||
return StatusResponse{
|
||||
IsPlaying: isPlaying,
|
||||
CurrentSong: currentSong,
|
||||
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
|
||||
}
|
125
pkg/web/static/app.js
Normal file
125
pkg/web/static/app.js
Normal file
File diff suppressed because one or more lines are too long
13
pkg/web/static/default-cover.png
Normal file
13
pkg/web/static/default-cover.png
Normal file
|
@ -0,0 +1,13 @@
|
|||
I can't create or include binary content like PNG files directly in my response, as this would require binary data that can't be represented as text.
|
||||
|
||||
For the default-cover.png file, you'll need to:
|
||||
|
||||
1. Generate a simple placeholder image using an image editor
|
||||
2. Save it as "default-cover.png" in the "discord-jukebox-bot/pkg/web/static/" directory
|
||||
|
||||
Alternatively, you could:
|
||||
|
||||
1. Use a base64-encoded data URL in your JavaScript to represent a default image
|
||||
2. Update the app.js file to include a fallback image directly in the code
|
||||
|
||||
Would you like me to update the JavaScript to include a base64-encoded default image instead? This would eliminate the need for a separate PNG file.
|
54
pkg/web/static/index.html
Normal file
54
pkg/web/static/index.html
Normal file
|
@ -0,0 +1,54 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Discord Jukebox Status</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Discord Jukebox</h1>
|
||||
<div id="status-indicator" class="status-indicator">
|
||||
<span class="status-dot"></span>
|
||||
<span id="status-text">Checking status...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="now-playing" class="hidden">
|
||||
<h2>Now Playing</h2>
|
||||
<div class="song-info">
|
||||
<div id="cover-art-container">
|
||||
<img id="cover-art" src="/static/default-cover.png" alt="Album Cover">
|
||||
</div>
|
||||
<div class="song-details">
|
||||
<h3 id="song-title">Loading...</h3>
|
||||
<p><strong>Artist:</strong> <span id="song-artist">--</span></p>
|
||||
<p><strong>Album:</strong> <span id="song-album">--</span></p>
|
||||
<div class="additional-info">
|
||||
<p><strong>Duration:</strong> <span id="song-duration">--</span></p>
|
||||
<p><strong>Genre:</strong> <span id="song-genre">--</span></p>
|
||||
<p><strong>Year:</strong> <span id="song-year">--</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="not-playing" class="hidden">
|
||||
<div class="message">
|
||||
<h2>No Song Playing</h2>
|
||||
<p>The jukebox is currently inactive.</p>
|
||||
<p>Use <code>/jukebox play</code> in Discord to start the music!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Last updated: <span id="last-update">never</span></p>
|
||||
<p class="bot-info">Discord Jukebox Bot</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
193
pkg/web/static/styles.css
Normal file
193
pkg/web/static/styles.css
Normal file
|
@ -0,0 +1,193 @@
|
|||
/* Main styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
color: #5865F2; /* Discord blue */
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: gray;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background-color: #43b581; /* Discord green */
|
||||
box-shadow: 0 0 5px rgba(67, 181, 129, 0.5);
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background-color: #f04747; /* Discord red */
|
||||
}
|
||||
|
||||
/* Now Playing Section */
|
||||
#now-playing {
|
||||
padding: 20px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
margin-bottom: 15px;
|
||||
color: #5865F2;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#cover-art-container {
|
||||
flex: 0 0 200px;
|
||||
}
|
||||
|
||||
#cover-art {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.song-details {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.song-details p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.additional-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.additional-info p {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
/* Not Playing Section */
|
||||
#not-playing {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: monospace;
|
||||
background-color: #eee;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bot-info {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Helper classes */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 600px) {
|
||||
header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
#cover-art-container {
|
||||
flex: 0 0 100%;
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#cover-art {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue