Compare commits
No commits in common. "bcc1bce74371222530244a40794f64c151bc8171" and "1a4986f29461aa9b70275f2e7255ceca82c4b1b0" have entirely different histories.
bcc1bce743
...
1a4986f294
13 changed files with 12 additions and 1049 deletions
|
@ -20,7 +20,3 @@ JUKEBOX_SUBSONIC_PASSWORD=your_subsonic_password
|
||||||
# Jukebox settings
|
# Jukebox settings
|
||||||
# JUKEBOX_AUDIO_VOLUME=0.5
|
# JUKEBOX_AUDIO_VOLUME=0.5
|
||||||
# JUKEBOX_TIMEOUT_SEC=30
|
# JUKEBOX_TIMEOUT_SEC=30
|
||||||
|
|
||||||
# Web server settings
|
|
||||||
# JUKEBOX_WEB_ENABLED=true
|
|
||||||
# JUKEBOX_WEB_ADDR=:8080
|
|
25
README.md
25
README.md
|
@ -8,7 +8,6 @@ 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
|
- Play random music in a Discord voice channel with real-time audio streaming
|
||||||
- Automatic audio format conversion for Discord compatibility
|
- Automatic audio format conversion for Discord compatibility
|
||||||
- Simple slash commands for controlling the jukebox
|
- Simple slash commands for controlling the jukebox
|
||||||
- Web interface for viewing current playback status and song history
|
|
||||||
- Configurable through environment variables
|
- Configurable through environment variables
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
@ -89,8 +88,6 @@ Configure the bot using environment variables with the `JUKEBOX_` prefix. You ca
|
||||||
- `JUKEBOX_SUBSONIC_VERSION`: Subsonic API version (default: `1.16.1`)
|
- `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_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_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
|
### Sample .env File
|
||||||
|
|
||||||
|
@ -107,8 +104,6 @@ JUKEBOX_SUBSONIC_PASSWORD=your_subsonic_password
|
||||||
# JUKEBOX_SUBSONIC_VERSION=1.16.1
|
# JUKEBOX_SUBSONIC_VERSION=1.16.1
|
||||||
# JUKEBOX_AUDIO_VOLUME=0.5
|
# JUKEBOX_AUDIO_VOLUME=0.5
|
||||||
# JUKEBOX_TIMEOUT_SEC=30
|
# JUKEBOX_TIMEOUT_SEC=30
|
||||||
# JUKEBOX_WEB_ENABLED=true
|
|
||||||
# JUKEBOX_WEB_ADDR=:8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
@ -153,26 +148,6 @@ The bot provides the following slash commands:
|
||||||
- `/jukebox play` - Starts playing random music from your Subsonic library
|
- `/jukebox play` - Starts playing random music from your Subsonic library
|
||||||
- `/jukebox stop` - Stops playing music and leaves the voice channel
|
- `/jukebox stop` - Stops playing music and leaves the voice channel
|
||||||
- `/jukebox skip` - Skips the current song and plays the next one
|
- `/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.)
|
|
||||||
- History of the last 20 played songs
|
|
||||||
- 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
|
## License
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"discord-jukebox-bot/pkg/commands"
|
"discord-jukebox-bot/pkg/commands"
|
||||||
"discord-jukebox-bot/pkg/config"
|
"discord-jukebox-bot/pkg/config"
|
||||||
"discord-jukebox-bot/pkg/logger"
|
"discord-jukebox-bot/pkg/logger"
|
||||||
"discord-jukebox-bot/pkg/web"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
@ -70,28 +69,6 @@ func main() {
|
||||||
}
|
}
|
||||||
logger.Info("Discord Jukebox Bot is now running.")
|
logger.Info("Discord Jukebox Bot is now running.")
|
||||||
logger.Info("Use /jukebox play, /jukebox stop, or /jukebox skip in your Discord server.")
|
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.")
|
logger.Info("Press Ctrl+C to exit.")
|
||||||
|
|
||||||
// Wait for a termination signal
|
// Wait for a termination signal
|
||||||
|
@ -101,17 +78,6 @@ func main() {
|
||||||
|
|
||||||
// Clean up and exit
|
// Clean up and exit
|
||||||
logger.Info("Shutting down Discord Jukebox Bot...")
|
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 {
|
if err := bot.Stop(); err != nil {
|
||||||
logger.Error("Error stopping bot", "error", err)
|
logger.Error("Error stopping bot", "error", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@ package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"discord-jukebox-bot/pkg/config"
|
"discord-jukebox-bot/pkg/config"
|
||||||
"discord-jukebox-bot/pkg/discord"
|
"discord-jukebox-bot/pkg/discord"
|
||||||
|
@ -11,12 +10,6 @@ import (
|
||||||
"github.com/bwmarrin/discordgo"
|
"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
|
// Setup sets up all commands for the Discord bot
|
||||||
func Setup(cfg *config.Config) (*discord.Bot, error) {
|
func Setup(cfg *config.Config) (*discord.Bot, error) {
|
||||||
// Create the Subsonic client
|
// Create the Subsonic client
|
||||||
|
@ -40,11 +33,6 @@ func Setup(cfg *config.Config) (*discord.Bot, error) {
|
||||||
return nil, fmt.Errorf("error creating jukebox player")
|
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
|
// Add any additional command initialization here
|
||||||
// This is where you can easily add new commands in the future
|
// This is where you can easily add new commands in the future
|
||||||
|
|
||||||
|
@ -64,10 +52,3 @@ func RegisterCustomCommand(
|
||||||
bot.RegisterCommand(name, handler)
|
bot.RegisterCommand(name, handler)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetJukebox returns the current jukebox player instance
|
|
||||||
func GetJukebox() *discord.JukeboxPlayer {
|
|
||||||
jukeboxPlayerLock.RLock()
|
|
||||||
defer jukeboxPlayerLock.RUnlock()
|
|
||||||
return jukeboxPlayer
|
|
||||||
}
|
|
|
@ -32,10 +32,6 @@ type Config struct {
|
||||||
// Jukebox configuration
|
// Jukebox configuration
|
||||||
AudioVolume float64
|
AudioVolume float64
|
||||||
TimeoutSec int
|
TimeoutSec int
|
||||||
|
|
||||||
// Web server configuration
|
|
||||||
WebEnabled bool
|
|
||||||
WebAddr string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load loads the configuration from environment variables and .env file
|
// Load loads the configuration from environment variables and .env file
|
||||||
|
@ -71,10 +67,6 @@ func Load() (*Config, error) {
|
||||||
// Jukebox
|
// Jukebox
|
||||||
AudioVolume: getEnvFloat(envPrefix+"AUDIO_VOLUME", 0.5),
|
AudioVolume: getEnvFloat(envPrefix+"AUDIO_VOLUME", 0.5),
|
||||||
TimeoutSec: getEnvInt(envPrefix+"TIMEOUT_SEC", 30),
|
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 {
|
if err := config.validate(); err != nil {
|
||||||
|
@ -145,18 +137,6 @@ func getEnvFloat(key string, defaultValue float64) float64 {
|
||||||
return defaultValue
|
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
|
// PrintDebugInfo prints helpful debugging information for environment variables
|
||||||
func PrintDebugInfo() {
|
func PrintDebugInfo() {
|
||||||
slog.Info("=== Configuration Debug Information ===")
|
slog.Info("=== Configuration Debug Information ===")
|
||||||
|
@ -172,8 +152,6 @@ func PrintDebugInfo() {
|
||||||
checkEnvVar(envPrefix + "SUBSONIC_VERSION")
|
checkEnvVar(envPrefix + "SUBSONIC_VERSION")
|
||||||
checkEnvVar(envPrefix + "AUDIO_VOLUME")
|
checkEnvVar(envPrefix + "AUDIO_VOLUME")
|
||||||
checkEnvVar(envPrefix + "TIMEOUT_SEC")
|
checkEnvVar(envPrefix + "TIMEOUT_SEC")
|
||||||
checkEnvVar(envPrefix + "WEB_ENABLED")
|
|
||||||
checkEnvVar(envPrefix + "WEB_ADDR")
|
|
||||||
|
|
||||||
slog.Info("Troubleshooting tips:")
|
slog.Info("Troubleshooting tips:")
|
||||||
slog.Info("1. Your .env file is in the correct directory")
|
slog.Info("1. Your .env file is in the correct directory")
|
||||||
|
|
|
@ -232,11 +232,6 @@ 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
|
// IsPlaying returns true if the bot is currently playing music
|
||||||
func (b *Bot) IsPlaying() bool {
|
func (b *Bot) IsPlaying() bool {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
|
|
|
@ -26,9 +26,6 @@ type JukeboxPlayer struct {
|
||||||
playlistMutex sync.Mutex
|
playlistMutex sync.Mutex
|
||||||
playingMutex sync.Mutex
|
playingMutex sync.Mutex
|
||||||
currentStreamCancel context.CancelFunc
|
currentStreamCancel context.CancelFunc
|
||||||
songHistory []*subsonic.Song
|
|
||||||
historyMutex sync.RWMutex
|
|
||||||
maxHistorySize int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewJukeboxPlayer creates a new jukebox player
|
// NewJukeboxPlayer creates a new jukebox player
|
||||||
|
@ -36,8 +33,6 @@ func NewJukeboxPlayer(bot *Bot) *JukeboxPlayer {
|
||||||
jukebox := &JukeboxPlayer{
|
jukebox := &JukeboxPlayer{
|
||||||
bot: bot,
|
bot: bot,
|
||||||
playlist: make([]subsonic.Song, 0),
|
playlist: make([]subsonic.Song, 0),
|
||||||
songHistory: make([]*subsonic.Song, 0),
|
|
||||||
maxHistorySize: 20, // Store the last 20 songs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register command handlers
|
// Register command handlers
|
||||||
|
@ -275,9 +270,6 @@ func (j *JukeboxPlayer) startPlaying() {
|
||||||
j.currentSong = song
|
j.currentSong = song
|
||||||
j.playingMutex.Unlock()
|
j.playingMutex.Unlock()
|
||||||
|
|
||||||
// Add song to history when it starts playing
|
|
||||||
j.addToHistory(song)
|
|
||||||
|
|
||||||
// Update Discord status with the current song information in format "Artist - Title (Album)"
|
// Update Discord status with the current song information in format "Artist - Title (Album)"
|
||||||
var statusText string
|
var statusText string
|
||||||
if song.Album != "" {
|
if song.Album != "" {
|
||||||
|
@ -780,33 +772,3 @@ func (j *JukeboxPlayer) GetCurrentSong() *subsonic.Song {
|
||||||
defer j.playingMutex.Unlock()
|
defer j.playingMutex.Unlock()
|
||||||
return j.currentSong
|
return j.currentSong
|
||||||
}
|
}
|
||||||
|
|
||||||
// addToHistory adds a song to the playback history
|
|
||||||
func (j *JukeboxPlayer) addToHistory(song *subsonic.Song) {
|
|
||||||
if song == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
j.historyMutex.Lock()
|
|
||||||
defer j.historyMutex.Unlock()
|
|
||||||
|
|
||||||
// Add the song to the beginning of the history list
|
|
||||||
j.songHistory = append([]*subsonic.Song{song}, j.songHistory...)
|
|
||||||
|
|
||||||
// Trim the history if it exceeds the maximum size
|
|
||||||
if len(j.songHistory) > j.maxHistorySize {
|
|
||||||
j.songHistory = j.songHistory[:j.maxHistorySize]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSongHistory returns a copy of the playback history
|
|
||||||
func (j *JukeboxPlayer) GetSongHistory() []*subsonic.Song {
|
|
||||||
j.historyMutex.RLock()
|
|
||||||
defer j.historyMutex.RUnlock()
|
|
||||||
|
|
||||||
// Create a copy of the history to return
|
|
||||||
history := make([]*subsonic.Song, len(j.songHistory))
|
|
||||||
copy(history, j.songHistory)
|
|
||||||
|
|
||||||
return history
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,348 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
File diff suppressed because one or more lines are too long
|
@ -1,13 +0,0 @@
|
||||||
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.
|
|
|
@ -1,64 +0,0 @@
|
||||||
<!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">
|
|
||||||
<meta http-equiv="refresh" content="900"> <!-- Refresh page every 15 minutes to prevent memory issues -->
|
|
||||||
</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="song-history" class="hidden">
|
|
||||||
<h2>Recently Played</h2>
|
|
||||||
<div class="history-container">
|
|
||||||
<ul id="history-list" class="history-list">
|
|
||||||
<!-- Song history will be populated here by JavaScript -->
|
|
||||||
</ul>
|
|
||||||
</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>
|
|
|
@ -1,266 +0,0 @@
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Song History Section */
|
|
||||||
#song-history {
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-container {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-list {
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-empty {
|
|
||||||
text-align: center;
|
|
||||||
color: #666;
|
|
||||||
font-style: italic;
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-item {
|
|
||||||
padding: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-cover {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-right: 15px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-details {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-title {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-artist {
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-item {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-cover {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-title, .history-artist {
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue