diff options
| author | Bobby <[email protected]> | 2025-04-13 18:10:13 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2025-04-13 18:10:13 +0530 |
| commit | 12fb3704db217cc408b662ec73cdd41e028c0e08 (patch) | |
| tree | 4cd3a34898d362773d77a31e03695cb2480286c7 | |
| parent | 1b271061415b33a8f18d1d3d960bc750b9557b69 (diff) | |
| download | ai-12fb3704db217cc408b662ec73cdd41e028c0e08.tar.xz ai-12fb3704db217cc408b662ec73cdd41e028c0e08.zip | |
implement play command; music playback working
| -rw-r--r-- | commands/commands.go | 9 | ||||
| -rw-r--r-- | commands/play.go | 180 | ||||
| -rw-r--r-- | commands/play_autocomplete.go | 119 | ||||
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | handlers/autocompleteHandler.go | 13 | ||||
| -rw-r--r-- | handlers/interactionCreateHandler.go | 5 | ||||
| -rw-r--r-- | temp/aUvwKqnQ4kM_1744547953.mp3 | bin | 0 -> 6702284 bytes | |||
| -rw-r--r-- | types/music.go | 59 | ||||
| -rw-r--r-- | utils/music/search.go | 418 | ||||
| -rw-r--r-- | utils/music/voice.go | 387 |
11 files changed, 1180 insertions, 13 deletions
diff --git a/commands/commands.go b/commands/commands.go index 53dc3dc..00f09f8 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -9,10 +9,11 @@ var ( Description: "Search and play a song from Spotify or YouTube", Options: []*discordgo.ApplicationCommandOption{ { - Type: discordgo.ApplicationCommandOptionString, - Name: "query", - Description: "Search query for the song/playlist (or URL)", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "query", + Description: "Search query for the song/playlist (or URL)", + Required: true, + Autocomplete: true, }, }, }, diff --git a/commands/play.go b/commands/play.go index 6c7fae4..b8d39fd 100644 --- a/commands/play.go +++ b/commands/play.go @@ -1,24 +1,186 @@ package commands import ( + "ai/types" + "ai/utils/logger" + "ai/utils/music" "fmt" + "strings" "github.com/bwmarrin/discordgo" ) -// Command: Play -// Takes in a search query and autocompletes the search query to play a song from Spotify or YouTube -// Also takes in a URL to a Spotify Song/Playlist or YouTube Video/Playlist -// Joins the user's voice channel and plays the song. Adds to the queue if a song is already playing -// in any of the voice channels. User must be in a voice channel to use this command -// Usage: /play <search query or URL> func Play(s *discordgo.Session, i *discordgo.InteractionCreate) { - // For now return the query as reply - reply := fmt.Sprintf("Playing: %s", i.ApplicationCommandData().Options[0].StringValue()) + options := i.ApplicationCommandData().Options + input := options[0].StringValue() + + // Special cases from autocomplete + if input == "min_chars" { + respondWithError(s, i, "Enter at least 3 characters to search.") + return + } + + if input == "no_results" || input == "search_error" { + respondWithError(s, i, "No results found for your query. Try a different search term.") + return + } + + // Check if the user is in a voice channel BEFORE deferring response + guildID := i.GuildID + userID := i.Member.User.ID + + isSameVC, userChannelID := music.IsUserInSameVC(s, guildID, userID) + + if userChannelID == "" { + respondWithError(s, i, "You must be in a voice channel to use this command.") + return + } + + // Check if bot is already in a voice channel but not the same as the user + voice, exists := music.VoiceConnection[guildID] + if exists && !isSameVC { + channel, err := s.Channel(voice.ChannelID) + if err == nil { + respondWithError(s, i, fmt.Sprintf("I'm already in the voice channel **%s**. You must be in the same voice channel to control playback.", channel.Name)) + } else { + respondWithError(s, i, "I'm already in a different voice channel. You must be in the same voice channel to control playback.") + } + return + } + + // Now that we've checked all error conditions, defer the response + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + }) + + // Process track selection or URL + var trackURL, trackID, trackTitle string + var sourceType types.SourceType + + if strings.Contains(input, "|") { + parts := strings.Split(input, "|") + if len(parts) >= 3 { + sourceType = types.SourceType(parts[0]) + trackID = parts[1] + trackURL = parts[2] + + trackInfo, err := music.GetTrackInfo(trackID, sourceType) + if err != nil { + trackTitle = "Selected track" + } else { + trackTitle = trackInfo.Title + } + + if sourceType == types.Spotify { + ytTrack, err := music.GetYouTubeForSpotify(trackInfo.Title, trackInfo.Artist) + if err != nil { + updateResponse(s, i, "❌ Error fetching YouTube equivalent for Spotify track.") + return + } + trackURL = ytTrack.URL + trackID = ytTrack.ID + } + } else { + // malformed selection + updateResponse(s, i, "❌ Invalid track selection. Please try again.") + return + } + } else { + // Direct URL or search query + if music.IsYouTubeURL(input) { + trackInfo, err := music.GetYouTubeInfo(input) + if err != nil { + updateResponse(s, i, "❌ Failed to get information for this YouTube URL.") + return + } + trackURL = input + trackID = trackInfo.ID + trackTitle = trackInfo.Title + sourceType = types.YouTube + } else if music.IsSpotifyURL(input) { + trackInfo, err := music.GetSpotifyInfo(input) + if err != nil { + updateResponse(s, i, "❌ Failed to get information for this Spotify URL.") + return + } + + // Get YouTube equivalent + ytTrack, err := music.GetYouTubeForSpotify(trackInfo.Title, trackInfo.Artist) + if err != nil { + updateResponse(s, i, "❌ Error fetching YouTube equivalent for Spotify track.") + return + } + trackURL = ytTrack.URL + trackID = ytTrack.ID + trackTitle = ytTrack.Title + sourceType = types.Spotify + } else { + // treat as search query + results, err := music.Search(input, 1) + if err != nil || len(results) == 0 { + updateResponse(s, i, "❌ No results found for your search query.") + return + } + + result := results[0] + trackTitle = result.Title + trackID = result.ID + sourceType = result.SourceType + + if result.SourceType == types.Spotify { + ytTrack, err := music.GetYouTubeForSpotify(result.Title, result.Artist) + if err != nil { + updateResponse(s, i, "❌ Error fetching YouTube equivalent for Spotify track.") + return + } + trackURL = ytTrack.URL + trackID = ytTrack.ID + } else { + trackURL = result.URL + } + } + } + + // Join voice channel + voice, err := music.JoinVoiceChannel(s, guildID, userChannelID) + if err != nil { + logger.Log(fmt.Sprintf("Failed to join voice channel: %v", err), types.LogOptions{ + Prefix: "Play Command", + Level: types.Error, + }) + updateResponse(s, i, "❌ Failed to join your voice channel.") + return + } + + // Send "now playing" message first + updateResponse(s, i, fmt.Sprintf("🎵 Now playing: **%s**", trackTitle)) + + // Play the track + go func() { + err := voice.PlayYouTube(trackURL, trackID) + if err != nil { + logger.Log(fmt.Sprintf("Failed to play track: %v", err), types.LogOptions{ + Prefix: "Play Command", + Level: types.Error, + }) + // Also update the message to show the error to the user + updateResponse(s, i, fmt.Sprintf("❌ Error playing **%s**: %v", trackTitle, err)) + } + }() +} + +func respondWithError(s *discordgo.Session, i *discordgo.InteractionCreate, message string) { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ - Content: reply, + Content: message, + Flags: discordgo.MessageFlagsEphemeral, }, }) } + +func updateResponse(s *discordgo.Session, i *discordgo.InteractionCreate, message string) { + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &message, + }) +} diff --git a/commands/play_autocomplete.go b/commands/play_autocomplete.go new file mode 100644 index 0000000..dee02d1 --- /dev/null +++ b/commands/play_autocomplete.go @@ -0,0 +1,119 @@ +package commands + +import ( + "ai/types" + "ai/utils/logger" + "ai/utils/music" + "fmt" + + "github.com/bwmarrin/discordgo" +) + +func PlayAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) { + var focusedOption *discordgo.ApplicationCommandInteractionDataOption + + for _, option := range i.ApplicationCommandData().Options { + if option.Focused { + focusedOption = option + break + } + } + + if focusedOption == nil { + return + } + + query := focusedOption.StringValue() + + if len(query) < 3 { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{ + Choices: []*discordgo.ApplicationCommandOptionChoice{ + { + Name: "Please enter at least 3 characters", + Value: "min_chars", + }, + }, + }, + }) + return + } + + // Search for tracks + results, err := music.Search(query, 10) + if err != nil { + logger.Log(fmt.Sprintf("Search error: %v", err), types.LogOptions{ + Prefix: "Play Autocomplete", + Level: types.Error, + }) + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{ + Choices: []*discordgo.ApplicationCommandOptionChoice{ + { + Name: "Error searching. Try again later.", + Value: "search_error", + }, + }, + }, + }) + return + } + + // Create choices for autocomplete + choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, 25) + + for i, result := range results { + if i >= 25 { + break // Discord limits to 25 choices + } + + // Format display name + var displayName string + if result.SourceType == types.YouTube { + displayName = fmt.Sprintf("▶️ %s - %s", result.Title, result.Artist) + } else { + displayName = fmt.Sprintf("🎵 %s - %s", result.Title, result.Artist) + } + + // Truncate name if needed + if len(displayName) > 100 { + displayName = displayName[:97] + "..." + } + + // Use just source type and ID as value to avoid length issues + valueStr := fmt.Sprintf("%s|%s|%s", result.SourceType, result.ID, result.URL) + if len(valueStr) > 100 { + valueStr = fmt.Sprintf("%s|%s", result.SourceType, result.ID) + } + + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: displayName, + Value: valueStr, + }) + } + + if len(choices) == 0 { + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: "No results found", + Value: "no_results", + }) + } + + // Send response + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{ + Choices: choices, + }, + }) + + if err != nil { + logger.Log(fmt.Sprintf("Failed to send autocomplete response: %v", err), types.LogOptions{ + Prefix: "Play Autocomplete", + Level: types.Error, + }) + } +} @@ -11,4 +11,5 @@ require ( github.com/gorilla/websocket v1.4.2 // indirect golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect + layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32 // indirect ) @@ -12,3 +12,5 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32 h1:/S1gOotFo2sADAIdSGk1sDq1VxetoCWr6f5nxOG0dpY= +layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32/go.mod h1:yDtyzWZDFCVnva8NGtg38eH2Ns4J0D/6hD+MMeUGdF0= diff --git a/handlers/autocompleteHandler.go b/handlers/autocompleteHandler.go new file mode 100644 index 0000000..a4f022c --- /dev/null +++ b/handlers/autocompleteHandler.go @@ -0,0 +1,13 @@ +package handlers + +import ( + "ai/commands" + + "github.com/bwmarrin/discordgo" +) + +var ( + AutocompleteHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ + "play": commands.PlayAutocomplete, + } +) diff --git a/handlers/interactionCreateHandler.go b/handlers/interactionCreateHandler.go index 8e04111..575c471 100644 --- a/handlers/interactionCreateHandler.go +++ b/handlers/interactionCreateHandler.go @@ -8,5 +8,10 @@ func InteractionCreateHandler(s *discordgo.Session, i *discordgo.InteractionCrea if handler, ok := SlashCommandHandlers[i.ApplicationCommandData().Name]; ok { handler(s, i) } + + case discordgo.InteractionApplicationCommandAutocomplete: + if handler, ok := AutocompleteHandlers[i.ApplicationCommandData().Name]; ok { + handler(s, i) + } } } diff --git a/temp/aUvwKqnQ4kM_1744547953.mp3 b/temp/aUvwKqnQ4kM_1744547953.mp3 Binary files differnew file mode 100644 index 0000000..bd95a13 --- /dev/null +++ b/temp/aUvwKqnQ4kM_1744547953.mp3 diff --git a/types/music.go b/types/music.go new file mode 100644 index 0000000..aa41fa7 --- /dev/null +++ b/types/music.go @@ -0,0 +1,59 @@ +package types + +type SourceType string + +const ( + YouTube SourceType = "youtube" + Spotify SourceType = "spotify" +) + +type MusicSearchResult struct { + Title string + Artist string + URL string + ID string + Duration string + Thumbnail string + SourceType SourceType +} + +type SpotifySearchResponse struct { + Tracks struct { + Items []struct { + ID string `json:"id"` + Name string `json:"name"` + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + Album struct { + Images []struct { + URL string `json:"url"` + } `json:"images"` + } `json:"album"` + DurationMs int `json:"duration_ms"` + ExternalUrls struct { + Spotify string `json:"spotify"` + } `json:"external_urls"` + } `json:"items"` + } `json:"tracks"` +} + +type YouTubeSearchResponse struct { + Items []struct { + ID struct { + VideoID string `json:"videoId"` + } `json:"id"` + Snippet struct { + Title string `json:"title"` + ChannelTitle string `json:"channelTitle"` + Thumbnails struct { + Default struct { + URL string `json:"url"` + } `json:"default"` + High struct { + URL string `json:"url"` + } `json:"high"` + } `json:"thumbnails"` + } `json:"snippet"` + } `json:"items"` +} diff --git a/utils/music/search.go b/utils/music/search.go new file mode 100644 index 0000000..3cef107 --- /dev/null +++ b/utils/music/search.go @@ -0,0 +1,418 @@ +package music + +import ( + "ai/config" + "ai/types" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "sync" +) + +var ( + youtubeRegex = regexp.MustCompile(`^(https?://)?(www\.)?(youtube\.com|youtu\.?be)/.+`) + spotifyRegex = regexp.MustCompile(`^(https?://)?(open\.)?spotify\.com/.+`) +) + +func IsYouTubeURL(input string) bool { + return youtubeRegex.MatchString(input) +} + +func IsSpotifyURL(input string) bool { + return spotifyRegex.MatchString(input) +} + +func Search(query string, limit int) ([]types.MusicSearchResult, error) { + var wg sync.WaitGroup + wg.Add(2) + + var youtubeResults []types.MusicSearchResult + var spotifyResults []types.MusicSearchResult + var youtubeErr, spotifyErr error + + go func() { + defer wg.Done() + youtubeResults, youtubeErr = SearchYouTube(query, limit/2) + }() + + go func() { + defer wg.Done() + spotifyResults, spotifyErr = SearchSpotify(query, limit/2) + }() + + wg.Wait() + + if youtubeErr != nil && spotifyErr != nil { + return nil, fmt.Errorf("both search errors: youtube: %w, spotify: %w", youtubeErr, spotifyErr) + } + + results := []types.MusicSearchResult{} + + maxLength := max(len(spotifyResults), len(youtubeResults)) + + for i := range maxLength { + if i < len(youtubeResults) { + results = append(results, youtubeResults[i]) + } + if i < len(spotifyResults) { + results = append(results, spotifyResults[i]) + } + } + + if len(results) > limit { + results = results[:limit] + } + + return results, nil +} + +func SearchSpotify(query string, limit int) ([]types.MusicSearchResult, error) { + token, err := getSpotifyToken() + if err != nil { + return nil, fmt.Errorf("spotify token error: %w", err) + } + + searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=track&limit=%d", url.QueryEscape(query), limit) + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, fmt.Errorf("request creation error: %w", err) + } + + req.Header.Add("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("search request error: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("response read error: %w", err) + } + + var searchResponse types.SpotifySearchResponse + if err := json.Unmarshal(body, &searchResponse); err != nil { + return nil, fmt.Errorf("json unmarshal error: %w", err) + } + + results := []types.MusicSearchResult{} + + for _, item := range searchResponse.Tracks.Items { + artistName := "" + if len(item.Artists) > 0 { + artistName = item.Artists[0].Name + } + + thumbnailURL := "" + if len(item.Album.Images) > 0 { + thumbnailURL = item.Album.Images[0].URL + } + + // Format duration as mm:ss + durationSec := item.DurationMs / 1000 + duration := fmt.Sprintf("%02d:%02d", durationSec/60, durationSec%60) + + results = append(results, types.MusicSearchResult{ + Title: item.Name, + Artist: artistName, + URL: item.ExternalUrls.Spotify, + ID: item.ID, + Duration: duration, + Thumbnail: thumbnailURL, + SourceType: types.Spotify, + }) + } + + return results, nil +} + +func SearchYouTube(query string, limit int) ([]types.MusicSearchResult, error) { + apiKey := config.Config.YoutubeAPIKey + searchURL := fmt.Sprintf( + "https://www.googleapis.com/youtube/v3/search?part=snippet&q=%s&key=%s&maxResults=%d&type=video", + url.QueryEscape(query), apiKey, limit, + ) + + resp, err := http.Get(searchURL) + if err != nil { + return nil, fmt.Errorf("search request error: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("response read error: %w", err) + } + + var searchResponse types.YouTubeSearchResponse + if err := json.Unmarshal(body, &searchResponse); err != nil { + return nil, fmt.Errorf("json unmarshal error: %w", err) + } + + results := []types.MusicSearchResult{} + + for _, item := range searchResponse.Items { + videoURL := fmt.Sprintf("https://www.youtube.com/watch?v=%s", item.ID.VideoID) + + results = append(results, types.MusicSearchResult{ + Title: item.Snippet.Title, + Artist: item.Snippet.ChannelTitle, + URL: videoURL, + ID: item.ID.VideoID, + Duration: "00:00", // YouTube API requires a separate call to get duration + Thumbnail: item.Snippet.Thumbnails.High.URL, + SourceType: types.YouTube, + }) + } + + return results, nil +} + +func GetTrackInfo(id string, sourceType types.SourceType) (types.MusicSearchResult, error) { + if sourceType == types.YouTube { + return GetYouTubeInfoByID(id) + } else if sourceType == types.Spotify { + return GetSpotifyInfoByID(id) + } + + return types.MusicSearchResult{}, fmt.Errorf("unsupported source type: %s", sourceType) +} + +func GetYouTubeInfoByID(videoID string) (types.MusicSearchResult, error) { + apiKey := config.Config.YoutubeAPIKey + apiURL := fmt.Sprintf( + "https://www.googleapis.com/youtube/v3/videos?part=contentDetails,snippet&id=%s&key=%s", + videoID, apiKey, + ) + + resp, err := http.Get(apiURL) + if err != nil { + return types.MusicSearchResult{}, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return types.MusicSearchResult{}, err + } + + var response struct { + Items []struct { + Snippet struct { + Title string `json:"title"` + ChannelTitle string `json:"channelTitle"` + Thumbnails struct { + High struct { + URL string `json:"url"` + } `json:"high"` + } `json:"thumbnails"` + } `json:"snippet"` + ContentDetails struct { + Duration string `json:"duration"` + } `json:"contentDetails"` + } `json:"items"` + } + + err = json.Unmarshal(body, &response) + if err != nil { + return types.MusicSearchResult{}, err + } + + if len(response.Items) == 0 { + return types.MusicSearchResult{}, fmt.Errorf("video not found") + } + + item := response.Items[0] + return types.MusicSearchResult{ + Title: item.Snippet.Title, + Artist: item.Snippet.ChannelTitle, + URL: fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID), + ID: videoID, + Duration: item.ContentDetails.Duration, + Thumbnail: item.Snippet.Thumbnails.High.URL, + SourceType: types.YouTube, + }, nil +} + +func GetSpotifyInfoByID(trackID string) (types.MusicSearchResult, error) { + token, err := getSpotifyToken() + if err != nil { + return types.MusicSearchResult{}, err + } + + apiURL := "https://api.spotify.com/v1/tracks/" + trackID + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return types.MusicSearchResult{}, err + } + + req.Header.Add("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return types.MusicSearchResult{}, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return types.MusicSearchResult{}, err + } + + var trackResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + Album struct { + Images []struct { + URL string `json:"url"` + } `json:"images"` + } `json:"album"` + DurationMs int `json:"duration_ms"` + ExternalUrls struct { + Spotify string `json:"spotify"` + } `json:"external_urls"` + } + + err = json.Unmarshal(body, &trackResponse) + if err != nil { + return types.MusicSearchResult{}, err + } + + artistName := "" + if len(trackResponse.Artists) > 0 { + artistName = trackResponse.Artists[0].Name + } + + thumbnailURL := "" + if len(trackResponse.Album.Images) > 0 { + thumbnailURL = trackResponse.Album.Images[0].URL + } + + duration := fmt.Sprintf("%02d:%02d", trackResponse.DurationMs/60000, (trackResponse.DurationMs/1000)%60) + + return types.MusicSearchResult{ + Title: trackResponse.Name, + Artist: artistName, + URL: trackResponse.ExternalUrls.Spotify, + ID: trackResponse.ID, + Duration: duration, + Thumbnail: thumbnailURL, + SourceType: types.Spotify, + }, nil +} + +func GetYouTubeForSpotify(title, artist string) (types.MusicSearchResult, error) { + query := fmt.Sprintf("%s %s", title, artist) + + results, err := SearchYouTube(query, 1) + if err != nil { + return types.MusicSearchResult{}, err + } + + if len(results) == 0 { + return types.MusicSearchResult{}, fmt.Errorf("no YouTube results found") + } + + return results[0], nil +} + +func GetYouTubeInfo(ytURL string) (types.MusicSearchResult, error) { + var videoID string + + if strings.Contains(ytURL, "youtu.be") { + parts := strings.Split(ytURL, "/") + videoID = parts[len(parts)-1] + } else if strings.Contains(ytURL, "youtube.com") { + parsedURL, err := url.Parse(ytURL) + if err != nil { + return types.MusicSearchResult{}, err + } + + query := parsedURL.Query() + videoID = query.Get("v") + } + + if videoID == "" { + return types.MusicSearchResult{}, fmt.Errorf("could not extract video ID from URL") + } + + return GetYouTubeInfoByID(videoID) +} + +func GetSpotifyInfo(spotifyURL string) (types.MusicSearchResult, error) { + var trackID string + + if strings.Contains(spotifyURL, "track") { + parts := strings.Split(spotifyURL, "/") + trackID = parts[len(parts)-1] + + // Remove any query parameters + if strings.Contains(trackID, "?") { + trackID = strings.Split(trackID, "?")[0] + } + } else { + return types.MusicSearchResult{}, fmt.Errorf("URL must be a Spotify track URL") + } + + if trackID == "" { + return types.MusicSearchResult{}, fmt.Errorf("could not extract track ID from URL") + } + + return GetSpotifyInfoByID(trackID) +} + +func getSpotifyToken() (string, error) { + clientID := config.Config.SpotifyClientId + clientSecret := config.Config.SpotifyClientSecret + + tokenURL := "https://accounts.spotify.com/api/token" + + data := url.Values{} + data.Set("grant_type", "client_credentials") + + req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + } + + if err := json.Unmarshal(body, &tokenResponse); err != nil { + return "", err + } + if tokenResponse.TokenType != "Bearer" { + return "", fmt.Errorf("unexpected token type: %s", tokenResponse.TokenType) + } + + return tokenResponse.AccessToken, nil +} diff --git a/utils/music/voice.go b/utils/music/voice.go new file mode 100644 index 0000000..961a8f5 --- /dev/null +++ b/utils/music/voice.go @@ -0,0 +1,387 @@ +package music + +import ( + "encoding/binary" + "fmt" + "io" + "os" + "os/exec" + "sync" + "time" + + "github.com/bwmarrin/discordgo" + "layeh.com/gopus" +) + +const ( + channels int = 2 // 1 for mono, 2 for stereo + frameRate int = 48000 // audio sampling rate + frameSize int = 960 // size of each audio frame + maxBytes int = (frameSize * 2) * 2 // max size of opus data +) + +// VoiceInstance represents a voice connection +type VoiceInstance struct { + GuildID string + ChannelID string + Connection *discordgo.VoiceConnection + Playing bool + StopChannel chan bool + OpusEncoder *gopus.Encoder + mu sync.Mutex + CurrentTrackID string +} + +var ( + VoiceConnection = make(map[string]*VoiceInstance) + VoiceMutex = &sync.Mutex{} +) + +// Stop stops the current playback +func (v *VoiceInstance) Stop() { + v.mu.Lock() + defer v.mu.Unlock() + + if v.Playing { + select { + case v.StopChannel <- true: + // Signal sent here + default: + // Channel already has a signal + } + close(v.StopChannel) + v.StopChannel = make(chan bool, 1) + v.Playing = false + } +} + +// JoinVoiceChannel makes the bot join a voice channel +func JoinVoiceChannel(s *discordgo.Session, guildID, channelID string) (*VoiceInstance, error) { + VoiceMutex.Lock() + defer VoiceMutex.Unlock() + + // Check if already in a voice channel in this guild + if voice, exists := VoiceConnection[guildID]; exists { + return voice, nil + } + + // not in a voice channel, create a new one + vc, err := s.ChannelVoiceJoin(guildID, channelID, false, true) + if err != nil { + return nil, fmt.Errorf("failed to join voice channel: %w", err) + } + + encoder, err := gopus.NewEncoder(frameRate, channels, gopus.Audio) + if err != nil { + vc.Disconnect() + return nil, fmt.Errorf("failed to create opus encoder: %w", err) + } + + voiceInstance := &VoiceInstance{ + GuildID: guildID, + ChannelID: channelID, + Connection: vc, + Playing: false, + StopChannel: make(chan bool, 1), + OpusEncoder: encoder, + } + + VoiceConnection[guildID] = voiceInstance + return voiceInstance, nil +} + +// LeaveVoiceChannel makes the bot leave a voice channel +func LeaveVoiceChannel(guildID string) error { + VoiceMutex.Lock() + defer VoiceMutex.Unlock() + + voice, exists := VoiceConnection[guildID] + if !exists { + return fmt.Errorf("not in a voice channel") + } + + // Stop current playback + voice.Stop() + + // Disconnect + err := voice.Connection.Disconnect() + if err != nil { + return fmt.Errorf("failed to disconnect from voice channel: %w", err) + } + + // remove from map + delete(VoiceConnection, guildID) + return nil +} + +// IsUserInSameVC checks if the user is in the same voice channel as the bot +func IsUserInSameVC(s *discordgo.Session, guildID, userID string) (bool, string) { + // Voice state + guild, err := s.State.Guild(guildID) + if err != nil { + return false, "" + } + + var userChannelID string + for _, vs := range guild.VoiceStates { + if vs.UserID == userID { + userChannelID = vs.ChannelID + break + } + } + + if userChannelID == "" { + return false, "" // user not in a voice channel + } + + // Check if bot is in a voice channel + VoiceMutex.Lock() + defer VoiceMutex.Unlock() + + voice, exists := VoiceConnection[guildID] + if !exists { + return true, userChannelID // bot not in a voice channel, but no conflict + } + + return voice.ChannelID == userChannelID, userChannelID +} + +// PlayYouTube downloads and plays a YouTube video +func (v *VoiceInstance) PlayYouTube(videoURL, videoID string) error { + fmt.Printf("Starting to play: %s (ID: %s)\n", videoURL, videoID) + + // Create a new stop channel for this playback + var oldStopChan chan bool + + v.mu.Lock() + // If already playing, properly stop the previous playback + if v.Playing { + fmt.Println("Stopping current playback before starting new one...") + // Save the old channel to send the stop signal after we release the lock + oldStopChan = v.StopChannel + // Create a new channel for the new playback + v.StopChannel = make(chan bool, 1) + } else { + v.StopChannel = make(chan bool, 1) + } + + v.Playing = true + v.CurrentTrackID = videoID + stopChan := v.StopChannel + v.mu.Unlock() + + // Send stop signal to old channel if it exists + // Do this outside the lock to avoid deadlock + if oldStopChan != nil { + // Signal the old playback to stop + select { + case oldStopChan <- true: + fmt.Println("Stop signal sent to previous playback") + default: + fmt.Println("Could not send stop signal, channel might be full or closed") + } + // Wait a moment for the previous playback to clean up + time.Sleep(100 * time.Millisecond) + } + + // Ensure temp directory exists + err := os.MkdirAll("./temp", 0755) + if err != nil { + fmt.Printf("Error creating temp directory: %v\n", err) + return fmt.Errorf("failed to create temp directory: %w", err) + } + + // Create a unique filename + fileName := fmt.Sprintf("./temp/%s_%d.mp3", videoID, time.Now().Unix()) + fmt.Printf("Downloading to: %s\n", fileName) + + // Use yt-dlp to download audio + downloadCmd := exec.Command("yt-dlp", "-x", "--audio-format", "mp3", + "--audio-quality", "0", "--no-playlist", "--output", fileName, videoURL) + + // Set up pipes to capture output for debugging + downloadCmd.Stdout = os.Stdout + downloadCmd.Stderr = os.Stderr + + fmt.Println("Starting download...") + err = downloadCmd.Run() + if err != nil { + fmt.Printf("Download error: %v\n", err) + v.mu.Lock() + v.Playing = false + v.mu.Unlock() + return fmt.Errorf("failed to download audio: %w", err) + } + + fmt.Printf("Download complete, starting playback\n") + + // Check if file exists and get its size + fileInfo, err := os.Stat(fileName) + if err != nil { + fmt.Printf("File stat error: %v\n", err) + v.mu.Lock() + v.Playing = false + v.mu.Unlock() + return fmt.Errorf("file stat error: %w", err) + } + fmt.Printf("File size: %d bytes\n", fileInfo.Size()) + + // Ensure file gets deleted after playback + defer os.Remove(fileName) + + // Make sure we're not already in a speaking state + v.Connection.Speaking(false) + time.Sleep(50 * time.Millisecond) + + // Set speaking status + err = v.Connection.Speaking(true) + if err != nil { + fmt.Printf("Speaking error: %v\n", err) + v.mu.Lock() + v.Playing = false + v.mu.Unlock() + return fmt.Errorf("speaking error: %w", err) + } + defer v.Connection.Speaking(false) + + // Use ffmpeg for playback + ffmpeg := exec.Command("ffmpeg", "-i", fileName, "-f", "s16le", "-ar", "48000", "-ac", "2", "pipe:1") + ffmpegout, err := ffmpeg.StdoutPipe() + if err != nil { + fmt.Printf("FFmpeg pipe error: %v\n", err) + return fmt.Errorf("ffmpeg pipe error: %w", err) + } + + ffmpeg.Stderr = os.Stderr + err = ffmpeg.Start() + if err != nil { + fmt.Printf("FFmpeg start error: %v\n", err) + return fmt.Errorf("ffmpeg start error: %w", err) + } + + // Store ffmpeg process for proper cleanup + ffmpegProcess := ffmpeg.Process + defer func() { + ffmpegProcess.Kill() + ffmpeg.Wait() // Wait for the process to exit to avoid zombies + }() + + // Read and send loop + buf := make([]int16, frameSize*channels) + + playbackDone := make(chan error, 1) + go func() { + for { + // Read data from ffmpeg + err = binary.Read(ffmpegout, binary.LittleEndian, &buf) + if err == io.EOF || err == io.ErrUnexpectedEOF { + playbackDone <- nil + return + } + if err != nil { + playbackDone <- fmt.Errorf("error reading from ffmpeg: %w", err) + return + } + + // Encode with opus + opus, err := v.OpusEncoder.Encode(buf, frameSize, maxBytes) + if err != nil { + playbackDone <- fmt.Errorf("opus encoding error: %w", err) + return + } + + // Send to Discord + select { + case v.Connection.OpusSend <- opus: + // Sent successfully + case <-stopChan: + playbackDone <- nil + return + } + } + }() + + // Wait for playback to finish or stop signal + select { + case err := <-playbackDone: + if err != nil { + fmt.Printf("Playback error: %v\n", err) + } else { + fmt.Println("Playback completed normally") + } + case <-stopChan: + fmt.Println("Playback stopped by request") + } + + // Make sure to kill ffmpeg + ffmpegProcess.Kill() + + v.mu.Lock() + v.Playing = false + v.mu.Unlock() + + return nil +} + +// func (v *VoiceInstance) playAudioFile(filename string, stopChan chan bool) error { +// // Start ffmpeg to convert the file to PCM +// ffmpeg := exec.Command("ffmpeg", "-i", filename, "-f", "s16le", "-ar", "48000", "-ac", "2", "pipe:1") +// ffmpegout, err := ffmpeg.StdoutPipe() +// if err != nil { +// return fmt.Errorf("ffmpeg stdout error: %w", err) +// } + +// ffmpeg.Stderr = os.Stderr +// err = ffmpeg.Start() +// if err != nil { +// return fmt.Errorf("ffmpeg start error: %w", err) +// } + +// // Make sure to kill ffmpeg when we're done +// defer ffmpeg.Process.Kill() + +// // Set speaking status +// err = v.Connection.Speaking(true) +// if err != nil { +// return fmt.Errorf("speaking error: %w", err) +// } +// defer v.Connection.Speaking(false) + +// // Create a buffer for reading from ffmpeg +// buf := make([]int16, frameSize*channels) + +// // Read and send loop +// for { +// // Check if we've been asked to stop +// select { +// case <-stopChan: +// return nil +// default: +// // Continue playing +// } + +// // Read data from ffmpeg +// err = binary.Read(ffmpegout, binary.LittleEndian, &buf) +// if err == io.EOF || err == io.ErrUnexpectedEOF { +// // End of file +// return nil +// } +// if err != nil { +// return fmt.Errorf("error reading from ffmpeg: %w", err) +// } + +// // Encode with opus +// opus, err := v.OpusEncoder.Encode(buf, frameSize, maxBytes) +// if err != nil { +// return fmt.Errorf("opus encoding error: %w", err) +// } + +// // Send to Discord +// select { +// case v.Connection.OpusSend <- opus: +// // Sent successfully +// case <-stopChan: +// return nil +// } +// } +// } |
