feat: added web server with currently playing song

This commit is contained in:
Felipe M 2025-05-13 12:27:01 +02:00
parent 1a4986f294
commit 7f16452a99
Signed by: fmartingr
GPG key ID: CCFBC5637D4000A8
12 changed files with 847 additions and 9 deletions

View file

@ -19,4 +19,8 @@ JUKEBOX_SUBSONIC_PASSWORD=your_subsonic_password
# Jukebox settings
# JUKEBOX_AUDIO_VOLUME=0.5
# JUKEBOX_TIMEOUT_SEC=30
# JUKEBOX_TIMEOUT_SEC=30
# Web server settings
# JUKEBOX_WEB_ENABLED=true
# JUKEBOX_WEB_ADDR=:8080

View file

@ -8,6 +8,7 @@ A Discord bot written in Go that plays random music from a Subsonic-compatible s
- Play random music in a Discord voice channel with real-time audio streaming
- Automatic audio format conversion for Discord compatibility
- Simple slash commands for controlling the jukebox
- Web interface for viewing current playback status
- Configurable through environment variables
## Requirements
@ -88,6 +89,8 @@ Configure the bot using environment variables with the `JUKEBOX_` prefix. You ca
- `JUKEBOX_SUBSONIC_VERSION`: Subsonic API version (default: `1.16.1`)
- `JUKEBOX_AUDIO_VOLUME`: Volume level from 0.0 to 1.0 for audio playback (default: `0.5`)
- `JUKEBOX_TIMEOUT_SEC`: HTTP request timeout in seconds (default: `30`)
- `JUKEBOX_WEB_ENABLED`: Enable the web interface (default: `true`)
- `JUKEBOX_WEB_ADDR`: Address and port for the web server (default: `:8080`)
### Sample .env File
@ -104,6 +107,8 @@ JUKEBOX_SUBSONIC_PASSWORD=your_subsonic_password
# JUKEBOX_SUBSONIC_VERSION=1.16.1
# JUKEBOX_AUDIO_VOLUME=0.5
# JUKEBOX_TIMEOUT_SEC=30
# JUKEBOX_WEB_ENABLED=true
# JUKEBOX_WEB_ADDR=:8080
```
## Troubleshooting
@ -148,6 +153,25 @@ The bot provides the following slash commands:
- `/jukebox play` - Starts playing random music from your Subsonic library
- `/jukebox stop` - Stops playing music and leaves the voice channel
- `/jukebox skip` - Skips the current song and plays the next one
- `/jukebox info` - Displays information about the currently playing song
### Web Interface
The bot includes a web interface that displays the current playback status. By default, it runs on port 8080 and can be accessed at:
```
http://your-server-ip:8080
```
The web interface features:
- Real-time updates when songs change or playback stops
- Current song information (artist, album, title, etc.)
- Responsive design that works on mobile and desktop
- No additional setup required - it works out of the box
You can configure the web interface using these environment variables:
- `JUKEBOX_WEB_ENABLED`: Set to `false` to disable the web interface
- `JUKEBOX_WEB_ADDR`: Change the address and port (e.g., `:8000` for port 8000)
## License

View file

@ -10,20 +10,21 @@ import (
"discord-jukebox-bot/pkg/commands"
"discord-jukebox-bot/pkg/config"
"discord-jukebox-bot/pkg/logger"
"discord-jukebox-bot/pkg/web"
)
func main() {
// Parse command-line flags
debugMode := flag.Bool("debug", false, "Enable debug mode with verbose logging")
flag.Parse()
// Initialize logger
logger.Init(*debugMode)
logger.Info("Discord Jukebox Bot - Starting up...")
if *debugMode {
logger.Debug("Debug mode enabled - verbose logging will be shown")
}
// Load configuration from environment variables and .env file
logger.Info("Loading configuration...")
cfg, err := config.Load()
@ -33,7 +34,7 @@ func main() {
os.Exit(1)
}
logger.Info("Configuration loaded successfully")
// Print config summary in debug mode
if logger.IsDebug() {
logger.Debug("Configuration Summary",
@ -42,14 +43,14 @@ func main() {
"subsonic_username", cfg.SubsonicUsername,
"subsonic_api_version", cfg.SubsonicVersion)
}
// Check if ffmpeg is available - important for audio transcoding
ffmpegPath, err := exec.LookPath("ffmpeg")
if err != nil {
logger.Warn("ffmpeg not found in system PATH. Audio streaming may be less reliable.",
logger.Warn("ffmpeg not found in system PATH. Audio streaming may be less reliable.",
"error", err.Error())
} else {
logger.Info("ffmpeg found and available for audio transcoding",
logger.Info("ffmpeg found and available for audio transcoding",
"path", ffmpegPath)
}
@ -69,6 +70,28 @@ func main() {
}
logger.Info("Discord Jukebox Bot is now running.")
logger.Info("Use /jukebox play, /jukebox stop, or /jukebox skip in your Discord server.")
// Get jukebox player reference
jukebox := commands.GetJukebox()
// HTTP server reference
var webServer *web.Server
// Initialize and start web server if enabled
if cfg.WebEnabled {
logger.Info("Starting HTTP server...", "address", cfg.WebAddr)
var err error
webServer, err = web.StartServer(jukebox, bot, cfg.WebAddr)
if err != nil {
logger.Error("Error starting HTTP server", "error", err)
os.Exit(1)
}
logger.Info("HTTP server is now running", "address", cfg.WebAddr)
} else {
logger.Info("HTTP server is disabled. Set JUKEBOX_WEB_ENABLED=true to enable")
}
logger.Info("Press Ctrl+C to exit.")
// Wait for a termination signal
@ -78,8 +101,19 @@ func main() {
// Clean up and exit
logger.Info("Shutting down Discord Jukebox Bot...")
// Stop the HTTP server if it was started
if cfg.WebEnabled && webServer != nil {
logger.Info("Stopping HTTP server...")
if err := webServer.Stop(); err != nil {
logger.Error("Error stopping HTTP server", "error", err)
}
logger.Info("HTTP server stopped")
}
// Stop the Discord bot
if err := bot.Stop(); err != nil {
logger.Error("Error stopping bot", "error", err)
}
logger.Info("Bot stopped. Goodbye!")
}
}

2
go.mod
View file

@ -14,4 +14,4 @@ require (
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
)
)

View file

@ -2,6 +2,7 @@ package commands
import (
"fmt"
"sync"
"discord-jukebox-bot/pkg/config"
"discord-jukebox-bot/pkg/discord"
@ -10,6 +11,12 @@ import (
"github.com/bwmarrin/discordgo"
)
// Store a reference to the jukebox player for external access
var (
jukeboxPlayer *discord.JukeboxPlayer
jukeboxPlayerLock sync.RWMutex
)
// Setup sets up all commands for the Discord bot
func Setup(cfg *config.Config) (*discord.Bot, error) {
// Create the Subsonic client
@ -32,6 +39,11 @@ func Setup(cfg *config.Config) (*discord.Bot, error) {
if jukebox == nil {
return nil, fmt.Errorf("error creating jukebox player")
}
// Store the jukebox player reference
jukeboxPlayerLock.Lock()
jukeboxPlayer = jukebox
jukeboxPlayerLock.Unlock()
// Add any additional command initialization here
// This is where you can easily add new commands in the future
@ -51,4 +63,11 @@ func RegisterCustomCommand(
// Register the command handler
bot.RegisterCommand(name, handler)
return nil
}
// GetJukebox returns the current jukebox player instance
func GetJukebox() *discord.JukeboxPlayer {
jukeboxPlayerLock.RLock()
defer jukeboxPlayerLock.RUnlock()
return jukeboxPlayer
}

View file

@ -32,6 +32,10 @@ type Config struct {
// Jukebox configuration
AudioVolume float64
TimeoutSec int
// Web server configuration
WebEnabled bool
WebAddr string
}
// Load loads the configuration from environment variables and .env file
@ -67,6 +71,10 @@ func Load() (*Config, error) {
// Jukebox
AudioVolume: getEnvFloat(envPrefix+"AUDIO_VOLUME", 0.5),
TimeoutSec: getEnvInt(envPrefix+"TIMEOUT_SEC", 30),
// Web server
WebEnabled: getEnvBool(envPrefix+"WEB_ENABLED", true),
WebAddr: getEnv(envPrefix+"WEB_ADDR", ":8080"),
}
if err := config.validate(); err != nil {
@ -137,6 +145,18 @@ func getEnvFloat(key string, defaultValue float64) float64 {
return defaultValue
}
// getEnvBool gets an environment variable as a boolean or returns a default value
func getEnvBool(key string, defaultValue bool) bool {
if value, exists := os.LookupEnv(key); exists {
if strings.ToLower(value) == "true" || value == "1" {
return true
} else if strings.ToLower(value) == "false" || value == "0" {
return false
}
}
return defaultValue
}
// PrintDebugInfo prints helpful debugging information for environment variables
func PrintDebugInfo() {
slog.Info("=== Configuration Debug Information ===")
@ -152,6 +172,8 @@ func PrintDebugInfo() {
checkEnvVar(envPrefix + "SUBSONIC_VERSION")
checkEnvVar(envPrefix + "AUDIO_VOLUME")
checkEnvVar(envPrefix + "TIMEOUT_SEC")
checkEnvVar(envPrefix + "WEB_ENABLED")
checkEnvVar(envPrefix + "WEB_ADDR")
slog.Info("Troubleshooting tips:")
slog.Info("1. Your .env file is in the correct directory")

View file

@ -232,6 +232,11 @@ func (b *Bot) leaveVoiceChannel() {
}
}
// GetSubsonicClient returns the Subsonic client
func (b *Bot) GetSubsonicClient() *subsonic.Client {
return b.subsonic
}
// IsPlaying returns true if the bot is currently playing music
func (b *Bot) IsPlaying() bool {
b.mu.Lock()

345
pkg/web/server.go Normal file
View 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

File diff suppressed because one or more lines are too long

View 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
View 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
View 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;
}
}