This commit is contained in:
Felipe M 2025-05-13 11:52:37 +02:00
commit 1a4986f294
Signed by: fmartingr
GPG key ID: CCFBC5637D4000A8
18 changed files with 3181 additions and 0 deletions

54
pkg/commands/setup.go Normal file
View file

@ -0,0 +1,54 @@
package commands
import (
"fmt"
"discord-jukebox-bot/pkg/config"
"discord-jukebox-bot/pkg/discord"
"discord-jukebox-bot/pkg/subsonic"
"github.com/bwmarrin/discordgo"
)
// Setup sets up all commands for the Discord bot
func Setup(cfg *config.Config) (*discord.Bot, error) {
// Create the Subsonic client
subsonicClient := subsonic.New(
cfg.SubsonicServer,
cfg.SubsonicUsername,
cfg.SubsonicPassword,
cfg.SubsonicVersion,
cfg.GetTimeout(),
)
// Create the Discord bot
bot, err := discord.New(cfg, subsonicClient)
if err != nil {
return nil, fmt.Errorf("error creating bot: %w", err)
}
// Initialize the jukebox player
jukebox := discord.NewJukeboxPlayer(bot)
if jukebox == nil {
return nil, fmt.Errorf("error creating jukebox player")
}
// Add any additional command initialization here
// This is where you can easily add new commands in the future
return bot, nil
}
// RegisterCustomCommand is a helper function to register a new command
// This makes it easy to add new commands in the future
func RegisterCustomCommand(
bot *discord.Bot,
name string,
description string,
options []*discordgo.ApplicationCommandOption,
handler func(s *discordgo.Session, i *discordgo.InteractionCreate),
) error {
// Register the command handler
bot.RegisterCommand(name, handler)
return nil
}

222
pkg/config/config.go Normal file
View file

@ -0,0 +1,222 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
"log/slog"
)
const (
// Environment variable prefix for the application
envPrefix = "JUKEBOX_"
)
// Config holds all the configuration for the application
type Config struct {
// Discord configuration
DiscordToken string
DiscordGuildID string
DiscordChannelID string
// Subsonic configuration
SubsonicServer string
SubsonicUsername string
SubsonicPassword string
SubsonicVersion string
// Jukebox configuration
AudioVolume float64
TimeoutSec int
}
// Load loads the configuration from environment variables and .env file
func Load() (*Config, error) {
// Try to load .env file
if err := godotenv.Load(); err != nil {
slog.Info("No .env file found, using environment variables only")
} else {
slog.Info("Loaded environment variables from .env file")
}
// Load the raw values first
rawGuildID := getEnv(envPrefix+"DISCORD_GUILD_ID", "")
// Clean up Guild ID - ensure we have just the snowflake
cleanGuildID := cleanSnowflake(rawGuildID)
if cleanGuildID != rawGuildID && rawGuildID != "" {
slog.Warn("Cleaned Discord Guild ID", "original", rawGuildID, "cleaned", cleanGuildID)
}
config := &Config{
// Discord
DiscordToken: getEnv(envPrefix+"DISCORD_TOKEN", ""),
DiscordGuildID: cleanGuildID,
DiscordChannelID: getEnv(envPrefix+"DISCORD_CHANNEL_ID", ""),
// Subsonic
SubsonicServer: getEnv(envPrefix+"SUBSONIC_SERVER", ""),
SubsonicUsername: getEnv(envPrefix+"SUBSONIC_USERNAME", ""),
SubsonicPassword: getEnv(envPrefix+"SUBSONIC_PASSWORD", ""),
SubsonicVersion: getEnv(envPrefix+"SUBSONIC_VERSION", "1.16.1"),
// Jukebox
AudioVolume: getEnvFloat(envPrefix+"AUDIO_VOLUME", 0.5),
TimeoutSec: getEnvInt(envPrefix+"TIMEOUT_SEC", 30),
}
if err := config.validate(); err != nil {
// Print helpful debug information
PrintDebugInfo()
return nil, err
}
return config, nil
}
// validate checks if the required configuration values are set
func (c *Config) validate() error {
if c.DiscordToken == "" {
return errorMissingConfig("discord token")
}
if c.SubsonicServer == "" {
return errorMissingConfig("subsonic server")
}
if c.SubsonicUsername == "" {
return errorMissingConfig("subsonic username")
}
if c.SubsonicPassword == "" {
return errorMissingConfig("subsonic password")
}
return nil
}
// errorMissingConfig returns a formatted error for missing configuration
func errorMissingConfig(name string) error {
return fmt.Errorf("%s is required", name)
}
// GetTimeout returns the timeout duration
func (c *Config) GetTimeout() time.Duration {
return time.Duration(c.TimeoutSec) * time.Second
}
// getEnv gets an environment variable or returns a default value
func getEnv(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}
// getEnvInt gets an environment variable as an integer or returns a default value
func getEnvInt(key string, defaultValue int) int {
if value, exists := os.LookupEnv(key); exists {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
// getEnvFloat gets an environment variable as a float or returns a default value
func getEnvFloat(key string, defaultValue float64) float64 {
if value, exists := os.LookupEnv(key); exists {
if floatValue, err := strconv.ParseFloat(value, 64); err == nil {
return floatValue
}
}
return defaultValue
}
// PrintDebugInfo prints helpful debugging information for environment variables
func PrintDebugInfo() {
slog.Info("=== Configuration Debug Information ===")
slog.Info("Required environment variables:")
checkEnvVar(envPrefix + "DISCORD_TOKEN")
checkEnvVar(envPrefix + "DISCORD_GUILD_ID")
checkEnvVar(envPrefix + "SUBSONIC_SERVER")
checkEnvVar(envPrefix + "SUBSONIC_USERNAME")
checkEnvVar(envPrefix + "SUBSONIC_PASSWORD")
slog.Info("Optional environment variables:")
checkEnvVar(envPrefix + "DISCORD_CHANNEL_ID")
checkEnvVar(envPrefix + "SUBSONIC_VERSION")
checkEnvVar(envPrefix + "AUDIO_VOLUME")
checkEnvVar(envPrefix + "TIMEOUT_SEC")
slog.Info("Troubleshooting tips:")
slog.Info("1. Your .env file is in the correct directory")
slog.Info("2. All variable names have the JUKEBOX_ prefix")
slog.Info("3. There are no spaces around the = sign in your .env file")
slog.Info(" Example: JUKEBOX_DISCORD_TOKEN=your_token_here")
slog.Info("4. Your .env file has been loaded (check for errors above)")
slog.Info("===================================")
}
// checkEnvVar checks if an environment variable is set and prints its status
func checkEnvVar(name string) {
if value, exists := os.LookupEnv(name); exists {
// Mask sensitive values
displayValue := value
if contains(name, "TOKEN", "PASSWORD") {
if len(displayValue) > 8 {
displayValue = displayValue[:4] + "..." + displayValue[len(displayValue)-4:]
} else {
displayValue = "****"
}
}
// Special handling for Guild ID to detect common issues
if strings.Contains(name, "GUILD_ID") && strings.Contains(value, "=") {
slog.Warn("Environment variable contains equals sign",
"name", name,
"raw_value", displayValue,
"cleaned_value", cleanSnowflake(value))
} else {
slog.Info("Environment variable check", "name", name, "value", displayValue, "status", "set")
}
} else {
slog.Warn("Environment variable not set", "name", name)
}
}
// contains checks if the string contains any of the substrings
func contains(str string, substrings ...string) bool {
for _, substring := range substrings {
if strings.Contains(str, substring) {
return true
}
}
return false
}
// cleanSnowflake cleans a Discord snowflake (ID) from potential contamination
// This handles cases where the ID might include the variable name or other text
func cleanSnowflake(input string) string {
// If the input contains an equals sign, it might be contaminated
if strings.Contains(input, "=") {
parts := strings.SplitN(input, "=", 2)
if len(parts) == 2 {
input = parts[1] // Take the part after the equals sign
}
}
// Extract only digits
var result strings.Builder
for _, char := range input {
if char >= '0' && char <= '9' {
result.WriteRune(char)
}
}
return result.String()
}

390
pkg/discord/bot.go Normal file
View file

@ -0,0 +1,390 @@
package discord
import (
"fmt"
"log/slog"
"sync"
"time"
"discord-jukebox-bot/pkg/config"
"discord-jukebox-bot/pkg/subsonic"
"github.com/bwmarrin/discordgo"
)
// Bot represents a Discord bot with voice capabilities
type Bot struct {
config *config.Config
session *discordgo.Session
subsonic *subsonic.Client
commandsMux sync.RWMutex
commands map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate)
voiceConn *discordgo.VoiceConnection
playing bool
stopChan chan struct{}
mu sync.Mutex
}
// New creates a new Discord bot
func New(cfg *config.Config, subsonic *subsonic.Client) (*Bot, error) {
session, err := discordgo.New("Bot " + cfg.DiscordToken)
if err != nil {
return nil, fmt.Errorf("error creating Discord session: %w", err)
}
bot := &Bot{
config: cfg,
session: session,
subsonic: subsonic,
commands: make(map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate)),
stopChan: make(chan struct{}),
}
// Set up event handlers
session.AddHandler(bot.onReady)
session.AddHandler(bot.onInteractionCreate)
// Add needed intents
session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildVoiceStates
return bot, nil
}
// RegisterCommand registers a new slash command handler
func (b *Bot) RegisterCommand(name string, handler func(s *discordgo.Session, i *discordgo.InteractionCreate)) {
b.commandsMux.Lock()
defer b.commandsMux.Unlock()
b.commands[name] = handler
}
// Start starts the Discord bot
func (b *Bot) Start() error {
// Add voice state update handler
b.session.AddHandler(b.onVoiceStateUpdate)
if err := b.session.Open(); err != nil {
return fmt.Errorf("error opening connection: %w", err)
}
// Register slash commands
commands := []*discordgo.ApplicationCommand{
{
Name: "jukebox",
Description: "Control the jukebox",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "play",
Description: "Start playing random music",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "stop",
Description: "Stop playing music",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "skip",
Description: "Skip to the next song",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "info",
Description: "Display information about the current song",
},
},
},
}
for _, cmd := range commands {
// Check if guild ID is provided, create global command if not
guildID := b.config.DiscordGuildID
if guildID == "" {
slog.Warn("No Guild ID provided, registering commands globally")
}
_, err := b.session.ApplicationCommandCreate(b.session.State.User.ID, guildID, cmd)
if err != nil {
return fmt.Errorf("error creating command '%s': %w", cmd.Name, err)
}
}
return nil
}
// Stop stops the Discord bot
func (b *Bot) Stop() error {
slog.Info("Shutting down Discord bot")
// Clear Discord status
if b.session != nil {
b.session.UpdateGameStatus(0, "")
}
// Stop any playing music
b.stopPlaying()
// Clean up voice connection if it exists
b.leaveVoiceChannel()
// Close the Discord session with timeout
disconnectDone := make(chan error)
go func() {
disconnectDone <- b.session.Close()
}()
select {
case err := <-disconnectDone:
if err != nil {
return fmt.Errorf("error closing connection: %w", err)
}
case <-time.After(10 * time.Second):
return fmt.Errorf("timeout closing Discord session")
}
slog.Info("Discord bot shutdown complete")
return nil
}
// JoinVoiceChannel joins a voice channel
func (b *Bot) JoinVoiceChannel(guildID, channelID string) error {
b.mu.Lock()
// Store current playing state if we're already connected
wasPlaying := b.playing && b.voiceConn != nil
// Check if we're already in the requested channel
alreadyInChannel := b.voiceConn != nil && b.voiceConn.ChannelID == channelID
b.mu.Unlock()
// If we're already in this channel, no need to reconnect
if alreadyInChannel {
slog.Info("Already in the requested voice channel", "channel_id", channelID)
return nil
}
// Leave the current voice channel if we're in one
b.leaveVoiceChannel()
// Join the new voice channel
slog.Info("Joining voice channel", "guild_id", guildID, "channel_id", channelID)
vc, err := b.session.ChannelVoiceJoin(guildID, channelID, false, true)
if err != nil {
return fmt.Errorf("error joining voice channel: %w", err)
}
// Wait for the connection to be established
timeout := time.After(10 * time.Second)
for !vc.Ready {
select {
case <-timeout:
b.leaveVoiceChannel()
return fmt.Errorf("timeout waiting for voice connection to be ready")
default:
slog.Debug("Waiting for voice connection to be ready...")
time.Sleep(100 * time.Millisecond)
}
}
slog.Info("Successfully connected to voice channel", "guild_id", guildID, "channel_id", channelID)
b.voiceConn = vc
// Restore playing state if we were playing before
if wasPlaying {
b.mu.Lock()
b.playing = true
b.mu.Unlock()
slog.Info("Restoring playback state after rejoining channel")
}
return nil
}
// leaveVoiceChannel leaves the current voice channel
func (b *Bot) leaveVoiceChannel() {
b.mu.Lock()
defer b.mu.Unlock()
if b.voiceConn != nil {
slog.Info("Leaving voice channel", "channel_id", b.voiceConn.ChannelID)
// Make sure we're not sending audio
b.voiceConn.Speaking(false)
// Disconnect with a timeout
disconnectErr := make(chan error, 1)
go func() {
disconnectErr <- b.voiceConn.Disconnect()
}()
// Wait for disconnect or timeout
select {
case err := <-disconnectErr:
if err != nil {
slog.Error("Error disconnecting from voice channel", "error", err)
}
case <-time.After(5 * time.Second):
slog.Warn("Timeout disconnecting from voice channel")
}
// Cleanup resources
b.voiceConn = nil
slog.Info("Voice channel disconnected")
// Note: We intentionally don't reset the playing state here
// so that if we rejoin, we can resume playing
}
}
// IsPlaying returns true if the bot is currently playing music
func (b *Bot) IsPlaying() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.playing
}
// stopPlaying stops playing music completely
func (b *Bot) stopPlaying() {
b.mu.Lock()
defer b.mu.Unlock()
if b.playing {
slog.Debug("Stopping playback")
// Signal all audio processors to stop
close(b.stopChan)
// Create a new channel for future use
b.stopChan = make(chan struct{})
// Mark as not playing anymore
b.playing = false
// Update Discord status
if b.session != nil {
err := b.session.UpdateGameStatus(0, "Ready to play music | /jukebox play")
if err != nil {
slog.Warn("Failed to update status after stopping", "error", err)
}
}
slog.Debug("Playback stopped completely")
}
}
// onReady is called when the Discord bot is ready
func (b *Bot) onReady(s *discordgo.Session, event *discordgo.Ready) {
slog.Info("Bot is now running",
"username", s.State.User.Username,
"discriminator", s.State.User.Discriminator,
"guilds", len(event.Guilds))
// Set initial status
err := s.UpdateGameStatus(0, "Ready to play music | /jukebox play")
if err != nil {
slog.Warn("Failed to set initial status", "error", err)
}
}
// onVoiceStateUpdate handles voice state changes
func (b *Bot) onVoiceStateUpdate(s *discordgo.Session, v *discordgo.VoiceStateUpdate) {
b.mu.Lock()
defer b.mu.Unlock()
// If we're not connected to voice, nothing to do
if b.voiceConn == nil {
return
}
// Check if this update is for our bot
if v.UserID == s.State.User.ID {
// If we got disconnected
if v.ChannelID == "" && b.voiceConn != nil && b.voiceConn.ChannelID != "" {
slog.Warn("Bot was disconnected from voice channel",
"previous_channel", b.voiceConn.ChannelID)
// Stop playing and clean up
b.stopPlaying()
b.voiceConn = nil
} else if v.ChannelID != "" && b.voiceConn != nil && v.ChannelID != b.voiceConn.ChannelID {
slog.Info("Bot was moved to different voice channel",
"previous_channel", b.voiceConn.ChannelID,
"new_channel", v.ChannelID)
// Update our voice connection to the new channel
oldChannelID := b.voiceConn.ChannelID
// Store the current playing state before reconnecting
wasPlaying := b.playing
// We need to create a new voice connection for the new channel
err := b.voiceConn.ChangeChannel(v.ChannelID, false, true)
if err != nil {
slog.Error("Failed to update voice channel", "error", err)
// Since changing channel failed, attempt a full reconnect
b.leaveVoiceChannel()
err = b.JoinVoiceChannel(v.GuildID, v.ChannelID)
if err != nil {
slog.Error("Failed to rejoin voice channel after move", "error", err)
return
}
}
slog.Info("Successfully moved to new voice channel",
"from", oldChannelID,
"to", v.ChannelID)
// If we were playing before, and we're not playing now, restore playing state
if wasPlaying && !b.playing {
b.playing = true
slog.Info("Restoring playback state after channel move")
}
}
}
// Check if we're in a voice channel but alone
if b.voiceConn != nil && b.playing {
// Count how many users are in our voice channel
usersInChannel := 0
guild, err := s.State.Guild(b.voiceConn.GuildID)
if err == nil {
for _, state := range guild.VoiceStates {
if state.ChannelID == b.voiceConn.ChannelID {
usersInChannel++
}
}
}
// If the bot is alone (just the bot itself), stop playing and disconnect
if usersInChannel <= 1 {
slog.Info("Bot is alone in voice channel, disconnecting")
b.stopPlaying()
b.leaveVoiceChannel()
}
}
}
// onInteractionCreate is called when a slash command is invoked
func (b *Bot) onInteractionCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Type != discordgo.InteractionApplicationCommand {
return
}
// Handle jukebox commands
if i.ApplicationCommandData().Name == "jukebox" {
options := i.ApplicationCommandData().Options
if len(options) > 0 {
subCommand := options[0].Name
b.commandsMux.RLock()
handler, exists := b.commands[subCommand]
b.commandsMux.RUnlock()
if exists {
handler(s, i)
return
}
}
}
// Unknown command
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Unknown command",
},
})
}

774
pkg/discord/jukebox.go Normal file
View file

@ -0,0 +1,774 @@
package discord
import (
"bufio"
"context"
"encoding/binary"
"fmt"
"io"
"log/slog"
"os/exec"
"strings"
"sync"
"time"
"discord-jukebox-bot/pkg/subsonic"
"github.com/bwmarrin/discordgo"
"layeh.com/gopus"
)
// JukeboxPlayer handles the music playback functionality
type JukeboxPlayer struct {
bot *Bot
currentSong *subsonic.Song
playlist []subsonic.Song
playlistMutex sync.Mutex
playingMutex sync.Mutex
currentStreamCancel context.CancelFunc
}
// NewJukeboxPlayer creates a new jukebox player
func NewJukeboxPlayer(bot *Bot) *JukeboxPlayer {
jukebox := &JukeboxPlayer{
bot: bot,
playlist: make([]subsonic.Song, 0),
}
// Register command handlers
bot.RegisterCommand("play", jukebox.handlePlay)
bot.RegisterCommand("stop", jukebox.handleStop)
bot.RegisterCommand("skip", jukebox.handleSkip)
bot.RegisterCommand("info", jukebox.handleInfo)
return jukebox
}
// handlePlay handles the play command
func (j *JukeboxPlayer) handlePlay(s *discordgo.Session, i *discordgo.InteractionCreate) {
// Acknowledge the interaction immediately
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
})
if err != nil {
slog.Error("Error responding to interaction", "error", err)
return
}
// Find the voice channel the user is in
var channelID string
if i.GuildID != "" {
// Find the voice state of the user
vs, err := s.State.VoiceState(i.GuildID, i.Member.User.ID)
if err == nil && vs != nil && vs.ChannelID != "" {
channelID = vs.ChannelID
}
}
// If we couldn't find the user's voice channel
if channelID == "" {
content := "You need to be in a voice channel to use this command."
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &content,
})
if err != nil {
slog.Error("Error editing interaction response", "error", err)
}
return
}
// Join the voice channel
err = j.bot.JoinVoiceChannel(i.GuildID, channelID)
if err != nil {
content := "Failed to join voice channel: " + err.Error()
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &content,
})
if err != nil {
slog.Error("Error editing interaction response", "error", err)
}
return
}
// Start playing music if not already playing
if !j.bot.IsPlaying() {
go j.startPlaying()
content := "🎵 Jukebox started! Random songs will be played from your Subsonic library."
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &content,
})
} else {
content := "🎵 Jukebox is already playing!"
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &content,
})
}
if err != nil {
slog.Error("Error editing interaction response", "error", err)
}
}
// handleStop handles the stop command
func (j *JukeboxPlayer) handleStop(s *discordgo.Session, i *discordgo.InteractionCreate) {
// Acknowledge the interaction immediately
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Stopping jukebox...",
},
})
if err != nil {
slog.Error("Error responding to interaction", "error", err)
return
}
// First stop the music
j.bot.stopPlaying()
// Then leave the voice channel
j.bot.leaveVoiceChannel()
// Clear the Discord status when stopping
j.bot.session.UpdateGameStatus(0, "")
// Update the response
content := "🛑 Jukebox stopped."
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &content,
})
if err != nil {
slog.Error("Error editing interaction response", "error", err)
}
}
// handleSkip handles the skip command
func (j *JukeboxPlayer) handleSkip(s *discordgo.Session, i *discordgo.InteractionCreate) {
// Acknowledge the interaction immediately
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Skipping to next song...",
},
})
if err != nil {
slog.Error("Error responding to interaction", "error", err)
return
}
if !j.bot.IsPlaying() {
content := "Jukebox is not currently playing."
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &content,
})
if err != nil {
slog.Error("Error editing interaction response", "error", err)
}
return
}
// Get the current song info for the response message
currentSong := j.GetCurrentSong()
songInfo := ""
if currentSong != nil {
songInfo = fmt.Sprintf(" (\"%s\" by %s)", currentSong.Title, currentSong.Artist)
}
// Skip the current song without stopping the playback
j.skipCurrentSong()
// Update the response
content := fmt.Sprintf("⏭️ Skipped current song%s. Loading next track...", songInfo)
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &content,
})
if err != nil {
slog.Error("Error editing interaction response", "error", err)
}
}
// startPlaying starts the jukebox playback
func (j *JukeboxPlayer) startPlaying() {
j.bot.mu.Lock()
if j.bot.playing {
slog.Debug("Jukebox is already playing, ignoring play request")
j.bot.mu.Unlock()
return
}
j.bot.playing = true
stopChan := j.bot.stopChan
j.bot.mu.Unlock()
// Make sure the voice connection is speaking
if j.bot.voiceConn != nil && j.bot.voiceConn.Ready {
err := j.bot.voiceConn.Speaking(true)
if err != nil {
slog.Warn("Failed to set speaking state", "error", err)
// Try to reconnect voice
if j.bot.voiceConn != nil && j.bot.voiceConn.GuildID != "" && j.bot.voiceConn.ChannelID != "" {
err = j.bot.JoinVoiceChannel(j.bot.voiceConn.GuildID, j.bot.voiceConn.ChannelID)
if err != nil {
slog.Error("Failed to restore voice connection", "error", err)
// Continue anyway, we'll try again when playing song
}
}
}
}
slog.Info("Starting jukebox playback")
for {
// For each iteration, get the latest stop channel
j.bot.mu.Lock()
localStopChan := j.bot.stopChan
isPlaying := j.bot.playing
j.bot.mu.Unlock()
// If we're completely stopped, exit the playback loop
if !isPlaying {
slog.Info("Jukebox playback stopped")
// Clear current song when stopping
j.playingMutex.Lock()
j.currentSong = nil
j.playingMutex.Unlock()
return
}
stopChan = localStopChan
// Check if we should stop
select {
case <-stopChan:
// Check if we're still supposed to be playing (complete stop vs. skip)
j.bot.mu.Lock()
stillPlaying := j.bot.playing
j.bot.mu.Unlock()
if !stillPlaying {
return // Completely stop playback
}
slog.Info("Skipping to next song")
// Otherwise, continue to the next song (was a skip)
default:
// Continue playing
}
// Check if we need to load more songs
j.ensurePlaylist()
// Get the next song to play
song := j.getNextSong()
if song == nil {
// No songs available
time.Sleep(1 * time.Second)
continue
}
j.playingMutex.Lock()
j.currentSong = song
j.playingMutex.Unlock()
// Update Discord status with the current song information in format "Artist - Title (Album)"
var statusText string
if song.Album != "" {
statusText = fmt.Sprintf("%s - %s (%s)", song.Artist, song.Title, song.Album)
} else {
statusText = fmt.Sprintf("%s - %s", song.Artist, song.Title)
}
// Truncate if too long for Discord status (128 char limit)
if len(statusText) > 128 {
statusText = statusText[:125] + "..."
}
statusErr := j.bot.session.UpdateGameStatus(0, statusText)
if statusErr != nil {
slog.Warn("Failed to update Discord status", "error", statusErr)
} else {
slog.Debug("Updated Discord status", "status", statusText)
}
// Announce the song in the voice channel
if j.bot.voiceConn != nil && j.bot.voiceConn.Ready {
slog.Info("Now playing",
"artist", song.Artist,
"title", song.Title,
"album", song.Album,
"id", song.ID,
"duration", song.Duration,
"path", song.Path)
}
// Play the song
err := j.playSong(song)
if err != nil {
slog.Error("Error playing song", "error", err)
time.Sleep(1 * time.Second)
}
}
}
// ensurePlaylist ensures that the playlist has songs
func (j *JukeboxPlayer) ensurePlaylist() {
j.playlistMutex.Lock()
defer j.playlistMutex.Unlock()
// If we have songs in the playlist, we're good
if len(j.playlist) > 0 {
return
}
// Fetch random songs from Subsonic
songs, err := j.bot.subsonic.GetRandomSongs(10)
if err != nil {
slog.Error("Error getting random songs", "error", err)
return
}
j.playlist = songs
}
// getNextSong gets the next song from the playlist
func (j *JukeboxPlayer) getNextSong() *subsonic.Song {
j.playlistMutex.Lock()
defer j.playlistMutex.Unlock()
if len(j.playlist) == 0 {
return nil
}
// Get the first song
song := j.playlist[0]
// Remove it from the playlist
j.playlist = j.playlist[1:]
return &song
}
// playSong plays a song over the voice connection
func (j *JukeboxPlayer) playSong(song *subsonic.Song) error {
// Check if voice connection is ready, and attempt to reconnect if needed
if j.bot.voiceConn == nil || !j.bot.voiceConn.Ready {
slog.Warn("Voice connection not ready, attempting to restore it")
// If we have guild ID and channel ID available, try to reconnect
if j.bot.voiceConn != nil && j.bot.voiceConn.GuildID != "" && j.bot.voiceConn.ChannelID != "" {
err := j.bot.JoinVoiceChannel(j.bot.voiceConn.GuildID, j.bot.voiceConn.ChannelID)
if err != nil {
return fmt.Errorf("failed to restore voice connection: %w", err)
}
slog.Info("Successfully restored voice connection")
} else {
return fmt.Errorf("voice connection not ready and cannot be restored")
}
}
// Get the stream URL (raw format for better compatibility)
streamURL := j.bot.subsonic.GetRawStreamURL(song.ID)
slog.Debug("Attempting to play song with direct FFmpeg method", "url", streamURL)
// Check if ffmpeg is available
ffmpegPath, err := exec.LookPath("ffmpeg")
if err != nil || ffmpegPath == "" {
return fmt.Errorf("ffmpeg not found, required for audio streaming: %w", err)
}
// Create a context that can be cancelled when skipping songs
streamCtx, cancelStream := context.WithCancel(context.Background())
// We'll cancel this context when the song ends or is skipped
j.bot.mu.Lock()
j.currentStreamCancel = cancelStream
j.bot.mu.Unlock()
// Make sure we clean up our context if we exit
defer func() {
j.bot.mu.Lock()
// No need to check equality, just clean up if we have a cancellation function
if j.currentStreamCancel != nil {
j.currentStreamCancel = nil
}
j.bot.mu.Unlock()
cancelStream()
}()
// Create an opusEncoder and begin speaking
slog.Debug("Setting Discord voice status to Speaking")
err = j.bot.voiceConn.Speaking(true)
if err != nil {
slog.Warn("Failed to set speaking state", "error", err)
// Try to recover the voice connection
if j.bot.voiceConn != nil && j.bot.voiceConn.GuildID != "" && j.bot.voiceConn.ChannelID != "" {
err = j.bot.JoinVoiceChannel(j.bot.voiceConn.GuildID, j.bot.voiceConn.ChannelID)
if err != nil {
slog.Error("Failed to reconnect to voice channel", "error", err)
} else {
err = j.bot.voiceConn.Speaking(true)
if err != nil {
slog.Error("Still failed to set speaking state after reconnection", "error", err)
}
}
}
}
defer j.bot.voiceConn.Speaking(false)
// Create FFmpeg command with optimized parameters for Discord streaming
cmd := exec.CommandContext(streamCtx, ffmpegPath,
"-hide_banner", // Reduce console output
"-loglevel", "warning", // Only show warnings and errors
"-reconnect", "1", // Allow reconnection
"-reconnect_streamed", "1", // Reconnect to streamed resources
"-reconnect_delay_max", "5", // Maximum delay between reconnection attempts (seconds)
"-i", streamURL, // Input from Subsonic stream URL
"-acodec", "pcm_s16le",
"-f", "s16le",
"-ar", "48000",
"-ac", "2",
"-")
// Get the stdout and stderr pipes
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("error creating stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("error creating stderr pipe: %w", err)
}
// Start the FFmpeg process
if err := cmd.Start(); err != nil {
return fmt.Errorf("error starting FFmpeg: %w", err)
}
// Ensure we clean up the FFmpeg process
defer func() {
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
}()
// Monitor stderr for debugging
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
// Check if we've been cancelled
select {
case <-streamCtx.Done():
return
default:
line := scanner.Text()
if len(line) > 0 {
slog.Debug("FFmpeg output", "message", line)
}
}
}
}()
// Use this to signal that streaming has started
frameSent := make(chan struct{}, 1)
// For signaling errors or completion
done := make(chan error, 1)
// Create Opus encoder
opusEncoder, err := gopus.NewEncoder(48000, 2, gopus.Audio)
if err != nil {
return fmt.Errorf("failed to create opus encoder: %w", err)
}
// Set the bitrate
opusEncoder.SetBitrate(64000) // 64 kbps is sufficient for music over Discord
// Buffer for reading PCM data
pcmBuffer := make([]int16, 960*2) // 960 PCM samples * 2 channels
opusBuffer := make([]byte, 1000) // Buffer for opus data
go func() {
defer close(done)
slog.Debug("Starting direct PCM to Opus conversion")
framesSent := 0
firstFrameSent := false
// Read from stdout, encode to opus, send to Discord
for {
// Check if we should stop
select {
case <-j.bot.stopChan:
slog.Debug("Audio streaming interrupted by stop channel")
return
case <-streamCtx.Done():
slog.Debug("Audio streaming interrupted by context cancellation")
return
default:
// Continue streaming
}
// Read raw PCM data with a timeout
readDone := make(chan error, 1)
go func() {
err := binary.Read(stdout, binary.LittleEndian, pcmBuffer)
readDone <- err
}()
// Wait for read or cancellation
select {
case err := <-readDone:
if err == io.EOF {
if framesSent == 0 {
done <- fmt.Errorf("stream ended without producing any audio frames")
} else {
slog.Debug("End of audio stream reached", "total_frames", framesSent)
}
return
}
if err != nil {
// Check if context was cancelled or if this is a closed file error (which is normal during skips)
select {
case <-streamCtx.Done():
slog.Debug("PCM read interrupted by context cancellation")
return
case <-j.bot.stopChan:
slog.Debug("PCM read interrupted by stop channel")
return
default:
// Only log warnings for errors that aren't related to normal stream endings
errMsg := err.Error()
if !strings.Contains(errMsg, "file already closed") &&
!strings.Contains(errMsg, "unexpected EOF") {
slog.Warn("Error reading PCM data", "error", err)
}
time.Sleep(20 * time.Millisecond)
continue
}
}
case <-streamCtx.Done():
slog.Debug("PCM read interrupted by context cancellation while reading")
return
case <-j.bot.stopChan:
slog.Debug("PCM read interrupted by stop channel while reading")
return
}
// Encode the PCM data to Opus
opus, err := opusEncoder.Encode(pcmBuffer, 960, len(opusBuffer))
if err != nil {
slog.Warn("Failed to encode PCM to Opus", "error", err)
time.Sleep(20 * time.Millisecond)
continue
}
// Send the Opus data to Discord
select {
case j.bot.voiceConn.OpusSend <- opus:
framesSent++
// Signal that the first frame was sent
if !firstFrameSent {
firstFrameSent = true
select {
case frameSent <- struct{}{}:
default:
// Channel buffer full, no need to block
}
}
// Log progress
if framesSent%250 == 0 {
slog.Debug("Audio streaming progress", "frames_sent", framesSent)
}
case <-j.bot.stopChan:
return
case <-streamCtx.Done():
return
case <-time.After(50 * time.Millisecond):
// Timeout, try again with the next frame
continue
}
// Control the frame timing (20ms per frame)
time.Sleep(15 * time.Millisecond)
}
}()
// Wait for the first frame to be sent or a timeout
select {
case <-frameSent:
slog.Info("Audio streaming started successfully")
case err := <-done:
if err != nil {
return fmt.Errorf("failed to start audio streaming: %w", err)
}
case <-time.After(10 * time.Second):
return fmt.Errorf("timeout waiting for first audio frame to be sent")
}
// Wait for the song to finish or be interrupted
slog.Debug("Waiting for song to complete or be interrupted")
// Create a timeout for the entire song (based on song duration plus a margin)
songDurationSecs := song.Duration
if songDurationSecs <= 0 {
// If we don't have a proper duration, use a default max duration
songDurationSecs = 600 // 10 minutes maximum
}
// Add a 30-second margin for buffering and network delays
songTimeout := time.Duration(songDurationSecs+30) * time.Second
select {
case err := <-done:
if err != nil {
slog.Error("Song ended with error", "error", err)
} else {
slog.Debug("Song completed successfully")
}
return err
case <-j.bot.stopChan:
slog.Debug("Song playback interrupted")
return nil
case <-time.After(songTimeout):
slog.Warn("Song playback exceeded maximum duration",
"expected_duration_seconds", songDurationSecs,
"timeout_seconds", songTimeout/time.Second)
return fmt.Errorf("song playback timed out after %d seconds", songTimeout/time.Second)
}
}
// skipCurrentSong skips the current song without stopping the playback
func (j *JukeboxPlayer) skipCurrentSong() {
// Signal to stop the current song only but keep overall playback going
j.bot.mu.Lock()
if j.bot.playing {
// Cancel the current stream context if it exists
if j.currentStreamCancel != nil {
slog.Debug("Cancelling current stream context")
j.currentStreamCancel()
j.currentStreamCancel = nil
}
// Also close the stop channel for compatibility with other parts of the code
close(j.bot.stopChan)
j.bot.stopChan = make(chan struct{})
slog.Debug("Skip signal sent - current song will stop")
// Show "Skipping..." status while transitioning between songs
j.bot.session.UpdateGameStatus(0, "Skipping to next track...")
} else {
slog.Debug("Skip requested but jukebox is not playing")
}
j.bot.mu.Unlock()
}
// handleInfo handles the info command
func (j *JukeboxPlayer) handleInfo(s *discordgo.Session, i *discordgo.InteractionCreate) {
// Acknowledge the interaction immediately
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
})
if err != nil {
slog.Error("Failed to acknowledge interaction", "error", err)
return
}
// Get the current song information
j.playingMutex.Lock()
currentSong := j.currentSong
j.playingMutex.Unlock()
// Check if the bot is playing and has a voice connection
isPlaying := j.bot.IsPlaying() && j.bot.voiceConn != nil && j.bot.voiceConn.Ready
if currentSong == nil || !isPlaying {
// No song is playing, inform the user
content := "No song is currently playing. Start the jukebox with `/jukebox play`!"
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &content,
})
if err != nil {
slog.Error("Failed to send 'no song playing' message", "error", err)
}
return
}
// Create an embed with song information
embed := &discordgo.MessageEmbed{
Title: "Currently Playing",
Description: fmt.Sprintf("**%s**", currentSong.Title),
Color: 0x1DB954, // Spotify green color
Fields: []*discordgo.MessageEmbedField{
{
Name: "Artist",
Value: currentSong.Artist,
Inline: true,
},
{
Name: "Album",
Value: currentSong.Album,
Inline: true,
},
},
Timestamp: time.Now().Format(time.RFC3339),
Footer: &discordgo.MessageEmbedFooter{
Text: "Discord Jukebox Bot",
},
}
// Add duration field if available
if currentSong.Duration > 0 {
minutes := currentSong.Duration / 60
seconds := currentSong.Duration % 60
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
Name: "Duration",
Value: fmt.Sprintf("%d:%02d", minutes, seconds),
Inline: true,
})
}
// Add genre field if available
if currentSong.Genre != "" {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
Name: "Genre",
Value: currentSong.Genre,
Inline: true,
})
}
// Add year field if available
if currentSong.Year > 0 {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
Name: "Year",
Value: fmt.Sprintf("%d", currentSong.Year),
Inline: true,
})
}
// Add cover art if available
if currentSong.CoverArt != "" {
coverArtURL := j.bot.subsonic.GetCoverArtURL(currentSong.CoverArt)
embed.Thumbnail = &discordgo.MessageEmbedThumbnail{
URL: strings.Replace(coverArtURL, ".fmartingr.dev", "", -1),
}
}
// Send the embed response
embeds := []*discordgo.MessageEmbed{embed}
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Embeds: &embeds,
})
if err != nil {
slog.Error("Failed to send song info message", "error", err)
}
}
// GetCurrentSong returns the current song
func (j *JukeboxPlayer) GetCurrentSong() *subsonic.Song {
j.playingMutex.Lock()
defer j.playingMutex.Unlock()
return j.currentSong
}

91
pkg/logger/logger.go Normal file
View file

@ -0,0 +1,91 @@
package logger
import (
"io"
"log/slog"
"os"
"time"
"github.com/lmittmann/tint"
)
var (
// Default logger is a tinted logger that writes to stdout
defaultLogger *slog.Logger
// Debug flag controls verbose logging
debugMode bool
)
// Init initializes the global logger with the specified debug level
func Init(debug bool) {
debugMode = debug
// Set the minimum log level based on debug mode
level := slog.LevelInfo
if debug {
level = slog.LevelDebug
}
// Create a tinted logger with time, level, and source
defaultLogger = slog.New(
tint.NewHandler(os.Stdout, &tint.Options{
Level: level,
TimeFormat: time.RFC3339,
AddSource: debug,
}),
)
// Set the default slog logger
slog.SetDefault(defaultLogger)
}
// InitWithWriter initializes the logger with a custom writer (useful for testing)
func InitWithWriter(w io.Writer, debug bool) {
debugMode = debug
level := slog.LevelInfo
if debug {
level = slog.LevelDebug
}
defaultLogger = slog.New(
tint.NewHandler(w, &tint.Options{
Level: level,
TimeFormat: time.RFC3339,
AddSource: debug,
}),
)
slog.SetDefault(defaultLogger)
}
// IsDebug returns true if debug mode is enabled
func IsDebug() bool {
return debugMode
}
// Debug logs a message at debug level
func Debug(msg string, args ...any) {
slog.Debug(msg, args...)
}
// Info logs a message at info level
func Info(msg string, args ...any) {
slog.Info(msg, args...)
}
// Warn logs a message at warn level
func Warn(msg string, args ...any) {
slog.Warn(msg, args...)
}
// Error logs a message at error level
func Error(msg string, args ...any) {
slog.Error(msg, args...)
}
// With returns a new logger with the given attributes
func With(args ...any) *slog.Logger {
return slog.With(args...)
}

352
pkg/subsonic/client.go Normal file
View file

@ -0,0 +1,352 @@
package subsonic
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"log/slog"
"math"
"math/rand"
"net/http"
"net/url"
"path"
"strconv"
"time"
)
// Client represents a Subsonic API client
type Client struct {
baseURL string
username string
password string
version string
httpClient *http.Client
}
// New creates a new Subsonic client
func New(server, username, password, version string, timeout time.Duration) *Client {
// Create a transport with reasonable defaults
transport := &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableCompression: false, // Allow compression for efficiency
TLSHandshakeTimeout: 10 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
ForceAttemptHTTP2: false, // Avoid HTTP/2 stream errors
ResponseHeaderTimeout: 15 * time.Second,
}
return &Client{
baseURL: server,
username: username,
password: password,
version: version,
httpClient: &http.Client{
Timeout: timeout,
Transport: transport,
},
}
}
// Response is the base response from the Subsonic API
type Response struct {
XMLName xml.Name `xml:"subsonic-response" json:"-"`
Status string `xml:"status,attr" json:"status"`
Version string `xml:"version,attr" json:"version"`
Type string `xml:"-" json:"type,omitempty"`
ServerVersion string `xml:"-" json:"serverVersion,omitempty"`
OpenSubsonic bool `xml:"-" json:"openSubsonic,omitempty"`
Error *APIError `xml:"error" json:"error,omitempty"`
RandomSong *MusicList `xml:"randomSongs" json:"randomSongs,omitempty"`
}
// APIError represents an error from the Subsonic API
type APIError struct {
Code int `xml:"code,attr" json:"code"`
Message string `xml:"message,attr" json:"message"`
}
// MusicList represents a list of songs
type MusicList struct {
Song []Song `xml:"song" json:"song"`
}
// Genre represents a music genre
type Genre struct {
Name string `xml:"name,attr" json:"name"`
}
// Artist represents an artist
type Artist struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
}
// ReplayGain represents replay gain information
type ReplayGain struct {
TrackGain float64 `xml:"trackGain,attr" json:"trackGain"`
TrackPeak float64 `xml:"trackPeak,attr" json:"trackPeak"`
AlbumPeak float64 `xml:"albumPeak,attr" json:"albumPeak"`
}
// Contributor represents a contributor to a song
type Contributor struct {
Role string `xml:"role,attr" json:"role"`
Artist *Artist `xml:"artist" json:"artist"`
}
// Song represents a song in the Subsonic API
type Song struct {
ID string `xml:"id,attr" json:"id"`
ParentID string `xml:"parent,attr" json:"parent"`
IsDir bool `xml:"isDir,attr" json:"isDir"`
Title string `xml:"title,attr" json:"title"`
Album string `xml:"album,attr" json:"album"`
Artist string `xml:"artist,attr" json:"artist"`
Track int `xml:"track,attr" json:"track"`
Year int `xml:"year,attr" json:"year"`
Genre string `xml:"genre,attr" json:"genre"`
CoverArt string `xml:"coverArt,attr" json:"coverArt"`
Size int `xml:"size,attr" json:"size"`
ContentType string `xml:"contentType,attr" json:"contentType"`
Suffix string `xml:"suffix,attr" json:"suffix"`
Duration int `xml:"duration,attr" json:"duration"`
BitRate int `xml:"bitRate,attr" json:"bitRate"`
Path string `xml:"path,attr" json:"path"`
DiscNumber int `xml:"discNumber,attr" json:"discNumber"`
Created string `xml:"created,attr" json:"created"`
AlbumId string `xml:"albumId,attr" json:"albumId"`
ArtistId string `xml:"artistId,attr" json:"artistId"`
Type string `xml:"type,attr" json:"type"`
IsVideo bool `xml:"isVideo,attr" json:"isVideo"`
Bpm int `xml:"bpm,attr" json:"bpm"`
Comment string `xml:"comment,attr" json:"comment"`
SortName string `xml:"sortName,attr" json:"sortName"`
MediaType string `xml:"mediaType,attr" json:"mediaType"`
MusicBrainzId string `xml:"musicBrainzId,attr" json:"musicBrainzId"`
Genres []Genre `xml:"genres>genre" json:"genres"`
ReplayGain *ReplayGain `xml:"replayGain" json:"replayGain"`
ChannelCount int `xml:"channelCount,attr" json:"channelCount"`
SamplingRate int `xml:"samplingRate,attr" json:"samplingRate"`
BitDepth int `xml:"bitDepth,attr" json:"bitDepth"`
Moods []string `xml:"moods>mood" json:"moods"`
Artists []Artist `xml:"artists>artist" json:"artists"`
DisplayArtist string `xml:"displayArtist,attr" json:"displayArtist"`
AlbumArtists []Artist `xml:"albumArtists>artist" json:"albumArtists"`
DisplayAlbumArtist string `xml:"displayAlbumArtist,attr" json:"displayAlbumArtist"`
Contributors []Contributor `xml:"contributors>contributor" json:"contributors"`
DisplayComposer string `xml:"displayComposer,attr" json:"displayComposer"`
ExplicitStatus string `xml:"explicitStatus,attr" json:"explicitStatus"`
}
// GetRandomSongs retrieves random songs from the library
func (c *Client) GetRandomSongs(size int) ([]Song, error) {
params := url.Values{}
params.Set("size", strconv.Itoa(size))
slog.Debug("Requesting random songs from Subsonic server", "count", size)
resp := &Response{}
err := c.makeRequest("getRandomSongs", params, resp)
if err != nil {
slog.Error("Error getting random songs", "error", err)
return nil, err
}
if resp.Error != nil {
slog.Error("Subsonic API error", "code", resp.Error.Code, "message", resp.Error.Message)
return nil, fmt.Errorf("subsonic API error %d: %s", resp.Error.Code, resp.Error.Message)
}
if resp.RandomSong == nil || len(resp.RandomSong.Song) == 0 {
slog.Info("No random songs returned from Subsonic server")
return []Song{}, nil
}
slog.Debug("Successfully retrieved random songs", "count", len(resp.RandomSong.Song))
// Debug output to verify song data
for i, song := range resp.RandomSong.Song {
slog.Debug("Song details",
"index", i+1,
"id", song.ID,
"title", song.Title,
"artist", song.Artist,
"content_type", song.ContentType,
"suffix", song.Suffix,
"bit_rate", song.BitRate,
"duration", song.Duration)
}
return resp.RandomSong.Song, nil
}
// GetStreamURL returns the URL for streaming a song with processed format
func (c *Client) GetStreamURL(id string) string {
params := c.getBaseParams()
params.Set("id", id)
// Request specific format and bitrate to ensure compatibility with Discord
params.Set("format", "mp3") // Common format that most servers support
params.Set("estimateContentLength", "true") // Helps with proper buffering
params.Set("maxBitRate", "128") // Lower bitrate for better stability
params.Set("timeOffset", "0") // Start from the beginning
params.Set("size", "") // Don't resize
baseURL, _ := url.Parse(c.baseURL)
baseURL.Path = path.Join(baseURL.Path, "rest", "stream")
baseURL.RawQuery = params.Encode()
streamURL := baseURL.String()
slog.Debug("Generated stream URL", "song_id", id, "url", streamURL, "format", "mp3", "bitrate", "128")
return streamURL
}
// GetRawStreamURL returns the URL for streaming a song in its raw format
func (c *Client) GetRawStreamURL(id string) string {
params := c.getBaseParams()
params.Set("id", id)
// Don't specify format to get the raw file
params.Set("estimateContentLength", "true")
baseURL, _ := url.Parse(c.baseURL)
baseURL.Path = path.Join(baseURL.Path, "rest", "stream")
baseURL.RawQuery = params.Encode()
streamURL := baseURL.String()
slog.Debug("Generated raw stream URL", "song_id", id, "url", streamURL)
return streamURL
}
// GetCoverArtURL returns the URL for getting cover art
func (c *Client) GetCoverArtURL(id string) string {
params := c.getBaseParams()
params.Set("id", id)
baseURL, _ := url.Parse(c.baseURL)
baseURL.Path = path.Join(baseURL.Path, "rest", "getCoverArt")
baseURL.RawQuery = params.Encode()
return baseURL.String()
}
// makeRequest makes a request to the Subsonic API
func (c *Client) makeRequest(method string, additionalParams url.Values, result interface{}) error {
params := c.getBaseParams()
for k, v := range additionalParams {
params[k] = v
}
baseURL, err := url.Parse(c.baseURL)
if err != nil {
return err
}
baseURL.Path = path.Join(baseURL.Path, "rest", method)
baseURL.RawQuery = params.Encode()
fullURL := baseURL.String()
slog.Debug("Making Subsonic API request", "url", fullURL, "method", method)
// Create a request with additional headers
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
slog.Error("Error creating HTTP request", "error", err)
return err
}
// Add headers for better compatibility
req.Header.Set("User-Agent", "DiscordJukeboxBot/1.0")
req.Header.Set("Accept", "application/json, application/xml, */*")
// Execute the request
resp, err := c.httpClient.Do(req)
if err != nil {
slog.Error("HTTP request error", "error", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slog.Error("Unexpected HTTP status", "code", resp.StatusCode, "status", resp.Status)
return fmt.Errorf("unexpected status code: %d, status: %s", resp.StatusCode, resp.Status)
}
contentType := resp.Header.Get("Content-Type")
slog.Debug("Response content type", "type", contentType)
// For debugging, read the raw response
bodyBytes, _ := io.ReadAll(resp.Body)
if len(bodyBytes) < 1000 {
slog.Debug("Response body", "body", string(bodyBytes))
} else {
slog.Debug("Response body length", "length", len(bodyBytes),
"preview", string(bodyBytes[:int(math.Min(200, float64(len(bodyBytes))))]))
}
var decodeErr error
if contentType == "application/json" || contentType == "text/json" {
// For JSON, handle the "subsonic-response" wrapper
var respWrapper struct {
SubsonicResponse *Response `json:"subsonic-response"`
}
// Decode into the wrapper first
decodeErr = json.Unmarshal(bodyBytes, &respWrapper)
if decodeErr == nil && respWrapper.SubsonicResponse != nil {
// Copy the response fields to the result
resultAsResponse, ok := result.(*Response)
if ok {
*resultAsResponse = *respWrapper.SubsonicResponse
} else {
decodeErr = fmt.Errorf("expected result to be *Response, got %T", result)
}
}
} else {
// For XML, decode directly
decoder := xml.NewDecoder(bytes.NewReader(bodyBytes))
decodeErr = decoder.Decode(result)
}
if decodeErr != nil {
slog.Error("Error decoding response", "error", decodeErr,
"response_preview", string(bodyBytes[:int(math.Min(500, float64(len(bodyBytes))))]))
}
return decodeErr
}
// getBaseParams returns the base parameters for a Subsonic API request
func (c *Client) getBaseParams() url.Values {
params := url.Values{}
params.Set("u", c.username)
// Generate a random salt
salt := generateRandomString(10)
params.Set("s", salt)
// Use MD5 for password security
token := md5Hash(c.password + salt)
params.Set("t", token)
params.Set("v", c.version)
params.Set("c", "JukeboxBot")
params.Set("f", "json") // We prefer JSON, but handle XML too
return params
}
// generateRandomString generates a random string of the given length
func generateRandomString(length int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := make([]byte, length)
r := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := range result {
result[i] = chars[r.Intn(len(chars))]
}
return string(result)
}
// md5Hash computes the MD5 hash of a string
func md5Hash(text string) string {
hash := md5.Sum([]byte(text))
return hex.EncodeToString(hash[:])
}

145
pkg/subsonic/client_test.go Normal file
View file

@ -0,0 +1,145 @@
package subsonic
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
)
func TestParseRandomSongsJSON(t *testing.T) {
// Open the test JSON file
file, err := os.Open("testdata/random_songs_response.json")
if err != nil {
t.Fatalf("Failed to open test file: %v", err)
}
defer file.Close()
// Read the file content
jsonData, err := io.ReadAll(file)
if err != nil {
t.Fatalf("Failed to read test file: %v", err)
}
// Parse JSON into Response struct
var respWrapper struct {
SubsonicResponse Response `json:"subsonic-response"`
}
err = json.Unmarshal(jsonData, &respWrapper)
if err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
resp := respWrapper.SubsonicResponse
// Verify the Response fields
if resp.Status != "ok" {
t.Errorf("Expected status 'ok', got '%s'", resp.Status)
}
if resp.Version != "1.16.1" {
t.Errorf("Expected version '1.16.1', got '%s'", resp.Version)
}
if resp.Type != "navidrome" {
t.Errorf("Expected type 'navidrome', got '%s'", resp.Type)
}
if resp.ServerVersion != "0.55.2 (a057a680)" {
t.Errorf("Expected serverVersion '0.55.2 (a057a680)', got '%s'", resp.ServerVersion)
}
if !resp.OpenSubsonic {
t.Error("Expected openSubsonic to be true")
}
// Verify RandomSong data
if resp.RandomSong == nil {
t.Fatal("RandomSong is nil, expected data")
}
// We should have 2 songs in our test data
if len(resp.RandomSong.Song) != 2 {
t.Fatalf("Expected 2 songs, got %d", len(resp.RandomSong.Song))
}
// Check the first song
song1 := resp.RandomSong.Song[0]
if song1.ID != "WxADUtZQmq1rvWMKRteTvh" {
t.Errorf("Expected song ID 'WxADUtZQmq1rvWMKRteTvh', got '%s'", song1.ID)
}
if song1.Title != "The First Book (Extended)" {
t.Errorf("Expected song title 'The First Book (Extended)', got '%s'", song1.Title)
}
if song1.Artist != "桜庭統" {
t.Errorf("Expected artist '桜庭統', got '%s'", song1.Artist)
}
if song1.Album != "Golden Sun" {
t.Errorf("Expected album 'Golden Sun', got '%s'", song1.Album)
}
if song1.Duration != 377 {
t.Errorf("Expected duration 377, got %d", song1.Duration)
}
if song1.Path != "桜庭統/Golden Sun/01-02 - The First Book (Extended).mp3" {
t.Errorf("Expected path '桜庭統/Golden Sun/01-02 - The First Book (Extended).mp3', got '%s'", song1.Path)
}
// Check nested structures
if len(song1.Genres) != 1 {
t.Errorf("Expected 1 genre, got %d", len(song1.Genres))
} else if song1.Genres[0].Name != "Game Soundtrack" {
t.Errorf("Expected genre 'Game Soundtrack', got '%s'", song1.Genres[0].Name)
}
// Check second song
song2 := resp.RandomSong.Song[1]
if song2.ID != "1LuCYVkmVmNfmJgc8orwCi" {
t.Errorf("Expected song ID '1LuCYVkmVmNfmJgc8orwCi', got '%s'", song2.ID)
}
if song2.Title != "Divine Beast Vah Ruta Battle" {
t.Errorf("Expected song title 'Divine Beast Vah Ruta Battle', got '%s'", song2.Title)
}
if song2.Artist != "Yasuaki Iwata" {
t.Errorf("Expected artist 'Yasuaki Iwata', got '%s'", song2.Artist)
}
}
func TestMakeRequest(t *testing.T) {
// Create a test server that returns our test JSON
testFile, err := os.ReadFile("testdata/random_songs_response.json")
if err != nil {
t.Fatalf("Failed to read test file: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(testFile)
}))
defer server.Close()
// Create a client using our test server URL
client := &Client{
baseURL: server.URL,
username: "testuser",
password: "testpass",
version: "1.16.1",
httpClient: &http.Client{},
}
// Test the GetRandomSongs method which uses makeRequest
songs, err := client.GetRandomSongs(10)
if err != nil {
t.Fatalf("GetRandomSongs failed: %v", err)
}
// Verify we got some songs back
if len(songs) != 2 {
t.Errorf("Expected 2 songs, got %d", len(songs))
}
// Check that the song data was parsed correctly
if songs[0].Title != "The First Book (Extended)" {
t.Errorf("Expected song title 'The First Book (Extended)', got '%s'", songs[0].Title)
}
if songs[1].Title != "Divine Beast Vah Ruta Battle" {
t.Errorf("Expected song title 'Divine Beast Vah Ruta Battle', got '%s'", songs[1].Title)
}
}

View file

@ -0,0 +1,147 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "0.55.2 (a057a680)",
"openSubsonic": true,
"randomSongs": {
"song": [
{
"id": "WxADUtZQmq1rvWMKRteTvh",
"parent": "0xRVI3OwZhprfTzN6vs6ti",
"isDir": false,
"title": "The First Book (Extended)",
"album": "Golden Sun",
"artist": "桜庭統",
"track": 2,
"year": 2007,
"genre": "Game Soundtrack",
"coverArt": "mf-WxADUtZQmq1rvWMKRteTvh_65045c88",
"size": 3910630,
"contentType": "audio/mpeg",
"suffix": "mp3",
"duration": 377,
"bitRate": 80,
"path": "桜庭統/Golden Sun/01-02 - The First Book (Extended).mp3",
"discNumber": 1,
"created": "2025-05-12T18:22:47.912430557Z",
"albumId": "0xRVI3OwZhprfTzN6vs6ti",
"artistId": "6I2Nnx3eA2xnsUGwQTtr3D",
"type": "music",
"isVideo": false,
"bpm": 0,
"comment": "www.sittingonclouds.net",
"sortName": "the first book (extended)",
"mediaType": "song",
"musicBrainzId": "46d99fd8-4b84-4202-aeeb-56ffd03bc4aa",
"genres": [
{
"name": "Game Soundtrack"
}
],
"replayGain": {
"trackPeak": 1,
"albumPeak": 1
},
"channelCount": 2,
"samplingRate": 32000,
"bitDepth": 0,
"moods": [],
"artists": [
{
"id": "6I2Nnx3eA2xnsUGwQTtr3D",
"name": "桜庭統"
}
],
"displayArtist": "桜庭統",
"albumArtists": [
{
"id": "6I2Nnx3eA2xnsUGwQTtr3D",
"name": "桜庭統"
}
],
"displayAlbumArtist": "桜庭統",
"contributors": [
{
"role": "composer",
"artist": {
"id": "7ALBQiGRFZDSjXkrHPK9xX",
"name": "Motoi Sakuraba"
}
}
],
"displayComposer": "Motoi Sakuraba",
"explicitStatus": ""
},
{
"id": "1LuCYVkmVmNfmJgc8orwCi",
"parent": "092dQuAiPh55hzAd7Y06lM",
"isDir": false,
"title": "Divine Beast Vah Ruta Battle",
"album": "The Legend of Zelda: Breath of the Wild Original Soundtrack",
"artist": "Yasuaki Iwata",
"track": 8,
"year": 2018,
"genre": "Game Soundtrack",
"coverArt": "mf-1LuCYVkmVmNfmJgc8orwCi_65045c9c",
"size": 12278801,
"contentType": "audio/flac",
"suffix": "flac",
"duration": 119,
"bitRate": 819,
"path": "Manaka Kataoka, Yasuaki Iwata & Hajime Wakai/The Legend of Zelda: Breath of the Wild Original Soundtrack/02-08 - Divine Beast Vah Ruta Battle.flac",
"discNumber": 2,
"created": "2025-05-12T18:22:47.166382716Z",
"albumId": "092dQuAiPh55hzAd7Y06lM",
"artistId": "7aGQqm0XpMOc4e4rEgj5iV",
"type": "music",
"isVideo": false,
"bpm": 0,
"comment": "sittingoncloudsost.com/ost",
"sortName": "divine beast vah ruta battle",
"mediaType": "song",
"musicBrainzId": "e2102713-f587-4eae-9538-2fc019e1c440",
"genres": [
{
"name": "Game Soundtrack"
}
],
"replayGain": {
"trackPeak": 1,
"albumPeak": 1
},
"channelCount": 2,
"samplingRate": 44100,
"bitDepth": 16,
"moods": [],
"artists": [
{
"id": "7aGQqm0XpMOc4e4rEgj5iV",
"name": "Yasuaki Iwata"
}
],
"displayArtist": "Yasuaki Iwata",
"albumArtists": [
{
"id": "600iKBbJhoZWOE782AHR14",
"name": "Manaka Kataoka, Yasuaki Iwata & Hajime Wakai"
}
],
"displayAlbumArtist": "Manaka Kataoka, Yasuaki Iwata & Hajime Wakai",
"contributors": [
{
"role": "composer",
"artist": {
"id": "7aGQqm0XpMOc4e4rEgj5iV",
"name": "Yasuaki Iwata"
}
}
],
"displayComposer": "Yasuaki Iwata",
"explicitStatus": ""
}
]
}
}
}