initial
This commit is contained in:
commit
1a4986f294
18 changed files with 3181 additions and 0 deletions
54
pkg/commands/setup.go
Normal file
54
pkg/commands/setup.go
Normal 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
222
pkg/config/config.go
Normal 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
390
pkg/discord/bot.go
Normal 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
774
pkg/discord/jukebox.go
Normal 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
91
pkg/logger/logger.go
Normal 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
352
pkg/subsonic/client.go
Normal 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
145
pkg/subsonic/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
147
pkg/subsonic/testdata/random_songs_response.json
vendored
Normal file
147
pkg/subsonic/testdata/random_songs_response.json
vendored
Normal 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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue