diff --git a/buttons/radio_stop.js b/buttons/radio_stop.js new file mode 100644 index 000000000..afbc7623a --- /dev/null +++ b/buttons/radio_stop.js @@ -0,0 +1,38 @@ +const { EmbedBuilder } = require('discord.js'); +const { Translate } = require('../process_tools'); +const { stopRadio } = require('../utils/radioPlayer'); + +module.exports = async ({ client, inter }) => { + try { + // Only allow the requester or admins to stop the radio + if (inter.member.permissions.has('ADMINISTRATOR') || inter.message.interaction.user.id === inter.user.id) { + const result = stopRadio(inter.guild.id); + + const embed = new EmbedBuilder() + .setColor('#2f3136') + .setAuthor({ name: await Translate(result ? + `Radio playback has been stopped. <✅>` : + `No active radio to stop. <❌>`) + }); + + return inter.update({ embeds: [embed], components: [] }); + } else { + return inter.reply({ + content: await Translate(`Only the person who started the radio or an administrator can stop it.`), + ephemeral: true + }); + } + } catch (error) { + console.error('Error in radio_stop button handler:', error); + + // Try to provide some feedback even if there's an error + try { + return inter.reply({ + content: await Translate(`There was an error stopping the radio. Please try again.`), + ephemeral: true + }).catch(console.error); + } catch (replyError) { + console.error('Failed to send error message:', replyError); + } + } +}; diff --git a/buttons/youtube_stop.js b/buttons/youtube_stop.js new file mode 100644 index 000000000..57b71deb3 --- /dev/null +++ b/buttons/youtube_stop.js @@ -0,0 +1,67 @@ +const { EmbedBuilder } = require('discord.js'); +const { Translate } = require('../process_tools'); + +module.exports = async ({ client, inter }) => { + try { + // Get the active connections from the YouTube command + const activeConnections = require('../commands/music/youtube').activeConnections; + + // Only allow the requester or admins to stop the YouTube player + if (inter.member.permissions.has('ADMINISTRATOR') || inter.message.interaction.user.id === inter.user.id) { + let result = false; + + // Check if there's an active connection for this guild + if (activeConnections && activeConnections.has(inter.guild.id)) { + const connection = activeConnections.get(inter.guild.id); + connection.destroy(); + activeConnections.delete(inter.guild.id); + result = true; + } + + const embed = new EmbedBuilder() + .setColor('#FF0000') + .setAuthor({ name: await Translate(result ? + `YouTube playback has been stopped. <✅>` : + `No active YouTube player to stop. <❌>`) + }); + + // Use try-catch to handle potential "already replied" errors + try { + // Try to update the message first + await inter.update({ embeds: [embed], components: [] }); + } catch (updateError) { + console.log('Could not update interaction, trying to reply instead:', updateError.message); + + // If update fails, try to reply + try { + await inter.reply({ embeds: [embed], ephemeral: true }); + } catch (replyError) { + console.error('Failed to reply to interaction:', replyError.message); + } + } + } else { + // For unauthorized users, use deferReply + editReply pattern which is more reliable + try { + await inter.deferReply({ ephemeral: true }); + await inter.editReply({ + content: await Translate(`Only the person who started the YouTube player or an administrator can stop it.`) + }); + } catch (error) { + console.error('Failed to respond to unauthorized user:', error.message); + } + } + } catch (error) { + console.error('Error in youtube_stop button handler:', error); + + // Try to provide some feedback even if there's an error + try { + // Use deferReply + editReply pattern which is more reliable + await inter.deferReply({ ephemeral: true }).catch(console.error); + await inter.editReply({ + content: await Translate(`There was an error stopping the YouTube player. Please try again.`) + }).catch(console.error); + } catch (replyError) { + console.error('Failed to send error message:', replyError); + } + } +}; diff --git a/commands/music/filter.js b/commands/music/filter.js index 878b991a9..036b93477 100644 --- a/commands/music/filter.js +++ b/commands/music/filter.js @@ -2,6 +2,12 @@ const { ApplicationCommandOptionType, EmbedBuilder } = require('discord.js'); const { AudioFilters, useQueue } = require('discord-player'); const { Translate } = require('../../process_tools'); +// Adding custom anti-static filters +AudioFilters.define('antistatic', 'highpass=f=200,lowpass=f=15000,silenceremove=start_periods=1:detection=peak'); +AudioFilters.define('clearvoice', 'pan=stereo|c0=c0|c1=c1,highpass=f=75,lowpass=f=12000,dynaudnorm=f=150:g=15:p=0.7'); +AudioFilters.define('crystalclear', 'volume=1.5,highpass=f=60,lowpass=f=17000,afftdn=nr=10:nf=-25:tn=1,loudnorm=I=-16:TP=-1.5:LRA=11'); +AudioFilters.define('crisp', 'treble=g=5,bass=g=2:f=110:w=0.6,volume=1.25,loudnorm'); + module.exports = { name: 'filter', description:('Add a filter to your track'), @@ -12,7 +18,18 @@ module.exports = { description:('The filter you want to add'), type: ApplicationCommandOptionType.String, required: true, - choices: [...Object.keys(AudioFilters.filters).map(m => Object({ name: m, value: m })).splice(0, 25)], + choices: [ + // Add custom filters at the top for better visibility + { name: 'antistatic', value: 'antistatic' }, + { name: 'clearvoice', value: 'clearvoice' }, + { name: 'crystalclear', value: 'crystalclear' }, + { name: 'crisp', value: 'crisp' }, + // Include all standard filters + ...Object.keys(AudioFilters.filters) + .filter(f => !['antistatic', 'clearvoice', 'crystalclear', 'crisp'].includes(f)) + .map(m => ({ name: m, value: m })) + .splice(0, 21) // Limit to 21 to stay under 25 choices with our 4 custom ones + ], } ], diff --git a/commands/music/play.js b/commands/music/play.js index 4e0270893..f19296aef 100644 --- a/commands/music/play.js +++ b/commands/music/play.js @@ -4,53 +4,95 @@ const { Translate } = require('../../process_tools'); module.exports = { name: 'play', - description:("Play a song!"), + description:("Play a song from YouTube, Spotify, or other sources"), voiceChannel: true, options: [ { name: 'song', - description:('The song you want to play'), + description:('The song you want to play (title, artist, or partial URL)'), type: ApplicationCommandOptionType.String, required: true, + }, + { + name: 'quality', + description:('Audio quality (higher uses more bandwidth)'), + type: ApplicationCommandOptionType.String, + required: false, + choices: [ + { name: 'Low', value: 'low' }, + { name: 'Medium', value: 'medium' }, + { name: 'High', value: 'high' } + ] } ], async execute({ inter, client }) { const player = useMainPlayer(); - const song = inter.options.getString('song'); - const res = await player.search(song, { - requestedBy: inter.member, - searchEngine: QueryType.AUTO - }); - - let defaultEmbed = new EmbedBuilder().setColor('#2f3136'); - - if (!res?.tracks.length) { - defaultEmbed.setAuthor({ name: await Translate(`No results found... try again ? <❌>`) }); - return inter.editReply({ embeds: [defaultEmbed] }); + const qualityOption = inter.options.getString('quality') || 'high'; + + const defaultEmbed = new EmbedBuilder().setColor('#2f3136'); + + // Set quality based on user selection + let volumeLevel = client.config.opt.volume; + + switch(qualityOption) { + case 'low': + volumeLevel = Math.min(volumeLevel, 70); + break; + case 'medium': + volumeLevel = Math.min(volumeLevel, 80); + break; + case 'high': + volumeLevel = client.config.opt.volume; + break; } - + try { + // Tell user we're searching + defaultEmbed.setAuthor({ name: await Translate(`Searching for "${song}"... <🔍>`) }); + await inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + + // Handle normal song playback (YouTube, Spotify, etc.) + const res = await player.search(song, { + requestedBy: inter.member, + searchEngine: QueryType.AUTO + }); + + if (!res?.tracks.length) { + defaultEmbed.setAuthor({ name: await Translate(`No results found... try again? <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + } + const { track } = await player.play(inter.member.voice.channel, song, { nodeOptions: { metadata: { channel: inter.channel }, - volume: client.config.opt.volume, + volume: volumeLevel, leaveOnEmpty: client.config.opt.leaveOnEmpty, leaveOnEmptyCooldown: client.config.opt.leaveOnEmptyCooldown, leaveOnEnd: client.config.opt.leaveOnEnd, leaveOnEndCooldown: client.config.opt.leaveOnEndCooldown, + connectionOptions: { + enableLiveBuffer: true + }, + // Don't pre-download the track + fetchBeforeQueued: false, + // Stream directly + streamOptions: { + seek: 0, + opusEncoding: true + } } }); - defaultEmbed.setAuthor({ name: await Translate(`Loading <${track.title}> to the queue... <✅>`) }); - await inter.editReply({ embeds: [defaultEmbed] }); + defaultEmbed.setAuthor({ name: await Translate(`Now playing: <${track.title}> <✅>`) }); + await inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); } catch (error) { console.log(`Play error: ${error}`); - defaultEmbed.setAuthor({ name: await Translate(`I can't join the voice channel... try again ? <❌>`) }); - return inter.editReply({ embeds: [defaultEmbed] }); + defaultEmbed.setAuthor({ name: await Translate(`I can't join the voice channel... try again? <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); } } } diff --git a/commands/music/radio.js b/commands/music/radio.js new file mode 100644 index 000000000..5c7bd71f6 --- /dev/null +++ b/commands/music/radio.js @@ -0,0 +1,111 @@ +const { ApplicationCommandOptionType, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); +const { Translate } = require('../../process_tools'); +const radioStations = require('../../radioStations'); +const { playRadioStation, stopRadio } = require('../../utils/radioPlayer'); + +module.exports = { + name: 'radio', + description: ('Play a live radio station'), + voiceChannel: true, + options: [ + { + name: 'station', + description: ('The radio station you want to listen to'), + type: ApplicationCommandOptionType.String, + required: true, + choices: radioStations.map(station => ({ + name: station.name, + value: station.name + })) + }, + { + name: 'quality', + description: ('Audio quality (higher uses more bandwidth)'), + type: ApplicationCommandOptionType.String, + required: false, + choices: [ + { name: 'Low', value: 'low' }, + { name: 'Medium', value: 'medium' }, + { name: 'High', value: 'high' } + ] + } + ], + + async execute({ inter, client }) { + const stationName = inter.options.getString('station'); + const qualityOption = inter.options.getString('quality') || 'high'; + + const defaultEmbed = new EmbedBuilder().setColor('#2f3136'); + + // Set quality based on user selection + let volumeLevel = client.config.opt.volume; + + switch(qualityOption) { + case 'low': + volumeLevel = Math.min(client.config.opt.volume, 70); + break; + case 'medium': + volumeLevel = Math.min(client.config.opt.volume, 80); + break; + case 'high': + volumeLevel = client.config.opt.volume; + break; + } + + try { + // Find the selected radio station + const station = radioStations.find(s => s.name === stationName); + + if (!station) { + defaultEmbed.setAuthor({ name: await Translate(`Radio station not found. Try again? <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + } + + // Tell user we're connecting to the radio station + defaultEmbed.setAuthor({ name: await Translate(`Connecting to ${station.name} radio... <📻>`) }); + await inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + + // Use our custom radio player instead of discord-player + try { + const result = await playRadioStation({ + voiceChannel: inter.member.voice.channel, + interaction: inter, + station: station, + volume: volumeLevel, + client: client + }); + + if (result.success) { + // Create a stop button that uses the button handler system + const stopButton = new ButtonBuilder() + .setCustomId('radio_stop') + .setLabel('Stop Radio') + .setStyle(ButtonStyle.Danger) + .setEmoji('⏹️'); + + const row = new ActionRowBuilder().addComponents(stopButton); + + // Send a non-ephemeral message with the button + await inter.editReply({ + embeds: [result.embed], + components: [row], + ephemeral: false + }); + + return; + } else { + defaultEmbed.setAuthor({ name: await Translate(`Error playing radio station. Try another station? <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + } + } catch (error) { + console.log(`Radio play error: ${error}`); + defaultEmbed.setAuthor({ name: await Translate(`Error playing radio station. Try another station? <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + } + } catch (error) { + console.log(`Radio command error: ${error}`); + defaultEmbed.setAuthor({ name: await Translate(`I can't join the voice channel... try again? <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + } + } +}; diff --git a/commands/music/radiostop.js b/commands/music/radiostop.js new file mode 100644 index 000000000..fa752a98a --- /dev/null +++ b/commands/music/radiostop.js @@ -0,0 +1,26 @@ +const { EmbedBuilder } = require('discord.js'); +const { Translate } = require('../../process_tools'); +const { stopRadio, activeRadioConnections } = require('../../utils/radioPlayer'); + +module.exports = { + name: 'radiostop', + description:("Stop the radio playback"), + voiceChannel: true, + + async execute({ inter }) { + const defaultEmbed = new EmbedBuilder().setColor('#2f3136'); + + // Check if there's a radio playing in this guild + if (activeRadioConnections.has(inter.guild.id)) { + const stopped = stopRadio(inter.guild.id); + + if (stopped) { + defaultEmbed.setAuthor({ name: await Translate(`Radio playback has been stopped. <✅>`) }); + return inter.editReply({ embeds: [defaultEmbed] }); + } + } + + defaultEmbed.setAuthor({ name: await Translate(`No radio is currently playing. <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed] }); + } +}; diff --git a/commands/music/youtube.js b/commands/music/youtube.js new file mode 100644 index 000000000..9474ae47b --- /dev/null +++ b/commands/music/youtube.js @@ -0,0 +1,203 @@ +const { ApplicationCommandOptionType, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); +const { QueryType, useMainPlayer } = require('discord-player'); +const { Translate } = require('../../process_tools'); + +// Store active YouTube connections +const activeConnections = new Map(); + +// Cache for converted URLs to avoid redundant conversions +const convertedUrlCache = new Map(); + +module.exports = { + name: 'youtube', + description: ("Play a YouTube video by URL"), + voiceChannel: true, + options: [ + { + name: 'url', + description: ('The YouTube URL you want to play'), + type: ApplicationCommandOptionType.String, + required: true, + }, + { + name: 'quality', + description: ('Audio quality (higher uses more bandwidth)'), + type: ApplicationCommandOptionType.String, + required: false, + choices: [ + { name: 'Low', value: 'low' }, + { name: 'Medium', value: 'medium' }, + { name: 'High', value: 'high' } + ] + } + ], + + async execute({ inter, client }) { + const url = inter.options.getString('url'); + const qualityOption = inter.options.getString('quality') || 'high'; + + const defaultEmbed = new EmbedBuilder().setColor('#FF0000'); + + // Validate URL input + if (!url || typeof url !== "string") { + defaultEmbed.setAuthor({ name: await Translate(`Please provide a valid YouTube URL. <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + } + + console.log("YouTube URL:", url); + + // More specific YouTube URL validation + const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})(\S*)?$/; + if (!youtubeRegex.test(url)) { + defaultEmbed.setAuthor({ name: await Translate(`Please provide a valid YouTube URL (e.g., https://www.youtube.com/watch?v=5EpyN_6dqyk). <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + } + + // Set quality based on user selection + let volumeLevel = client.config.opt.volume; + + switch(qualityOption) { + case 'low': + volumeLevel = Math.min(volumeLevel, 70); + break; + case 'medium': + volumeLevel = Math.min(volumeLevel, 80); + break; + case 'high': + volumeLevel = client.config.opt.volume; + break; + } + + try { + // Tell user we're connecting to YouTube + defaultEmbed.setAuthor({ name: await Translate(`Processing YouTube video... <🎵>`) }); + await inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + + // Normalize the URL to ensure it's in the correct format + let normalizedUrl = url; + + // Extract video ID for better handling + let videoId = null; + + // Handle youtu.be format + if (url.includes('youtu.be')) { + const urlParts = url.split('/'); + videoId = urlParts[urlParts.length - 1].split('?')[0]; + normalizedUrl = `https://www.youtube.com/watch?v=${videoId}`; + } + // Handle youtube.com format + else if (url.includes('youtube.com')) { + // Handle watch URLs + if (url.includes('/watch?v=')) { + try { + const urlObj = new URL(url); + videoId = urlObj.searchParams.get('v'); + normalizedUrl = `https://www.youtube.com/watch?v=${videoId}`; + } catch (e) { + console.error('Error parsing YouTube URL:', e); + } + } + // Handle shorts + else if (url.includes('/shorts/')) { + const shortsPath = url.split('/shorts/')[1]; + videoId = shortsPath.split('?')[0]; + normalizedUrl = `https://www.youtube.com/watch?v=${videoId}`; + } + } + + console.log(`Processing YouTube video with normalized URL: ${normalizedUrl}`); + + // Get the player instance + const player = useMainPlayer(); + + // Use the built-in search functionality of discord-player + const searchResult = await player.search(normalizedUrl, { + requestedBy: inter.user, + searchEngine: QueryType.YOUTUBE_VIDEO + }); + + if (!searchResult || !searchResult.tracks.length) { + defaultEmbed.setAuthor({ name: await Translate(`No results found for this YouTube URL... <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + } + + // Create a queue if it doesn't exist + const queue = player.nodes.create(inter.guild, { + metadata: { + channel: inter.channel, + client: client, + requestedBy: inter.user + }, + volume: volumeLevel, + leaveOnEmpty: client.config.opt.leaveOnEmpty, + leaveOnEmptyCooldown: client.config.opt.leaveOnEmptyCooldown, + leaveOnEnd: client.config.opt.leaveOnEnd, + leaveOnEndCooldown: client.config.opt.leaveOnEndCooldown, + bufferingTimeout: 0, + connectionOptions: { + selfDeaf: true + } + }); + + try { + // Connect to the voice channel + if (!queue.connection) { + await queue.connect(inter.member.voice.channel); + } + } catch (error) { + console.error('Connection error:', error); + player.nodes.delete(inter.guild.id); + defaultEmbed.setAuthor({ name: await Translate(`I can't join the voice channel... <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + } + + // Store the connection for reference + if (queue.connection) { + activeConnections.set(inter.guild.id, queue.connection); + } + + try { + // Play the first track from the search results + const track = searchResult.tracks[0]; + await queue.node.play(track); + + // Create a rich embed with video info + const youtubeEmbed = new EmbedBuilder() + .setColor('#FF0000') // YouTube red + .setTitle(`🎵 ${track.title || 'YouTube Audio'}`) + .setDescription(`**Channel:** ${track.author || 'YouTube Channel'}`) + .addFields([ + { name: 'Duration', value: track.duration || 'Unknown', inline: true }, + { name: 'Volume', value: `${volumeLevel}%`, inline: true }, + ]) + .setFooter({ text: `Requested by ${inter.member.displayName}` }) + .setTimestamp(); + + // Add the video thumbnail if available + if (track.thumbnail) { + youtubeEmbed.setThumbnail(track.thumbnail); + } + + // Send a non-ephemeral message without the button + await inter.editReply({ + embeds: [youtubeEmbed], + components: [], + ephemeral: false + }); + + } catch (error) { + console.error('Play error:', error); + player.nodes.delete(inter.guild.id); + defaultEmbed.setAuthor({ name: await Translate(`I can't play this YouTube URL... try again? <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + } + } catch (error) { + console.log(`YouTube play error: ${error}`); + defaultEmbed.setAuthor({ name: await Translate(`I can't play this YouTube URL... try again? <❌>`) }); + return inter.editReply({ embeds: [defaultEmbed], ephemeral: false }); + } + }, + + // Export the activeConnections map for the button handler + activeConnections +}; diff --git a/config.js b/config.js index 490a04304..771af8579 100644 --- a/config.js +++ b/config.js @@ -10,6 +10,9 @@ module.exports = { enableEmojis: false, }, + // API keys for external services + freeConvertApiKey: process.env.FREECONVERT_API_KEY || 'api_production_d0985e210aadb99f22a2d803c8976a75c700d2fc13f06a5f5c8c8973b1722535.67eed51a8770f87f98daeef7.67eed53c8770f87f98daef02', + emojis:{ 'back': '⏪', 'skip': '⏩', @@ -29,7 +32,7 @@ module.exports = { Translate_Timeout: 10000, maxVol: 100, spotifyBridge: true, - volume: 75, + volume: 80, // Slightly reduced to prevent distortion leaveOnEmpty: true, leaveOnEmptyCooldown: 30000, leaveOnEnd: true, @@ -37,8 +40,53 @@ module.exports = { discordPlayer: { ytdlOptions: { quality: 'highestaudio', - highWaterMark: 1 << 25 - } + highWaterMark: 1 << 25, // 32MB buffer + dlChunkSize: 0, // Disable chunking for smoother streaming + filter: 'audioonly', + liveBuffer: 60000, // Increased buffer for live streams + requestOptions: { + maxRetries: 5, // More retry attempts + maxRedirects: 10, + }, + // Better audio bitrate handling + audioBitrate: 128, + audioEncoding: "opus" + }, + connectionOptions: { + enableLiveBuffer: true, + // Opus encoder settings + opusEncoding: true, + opusEncodeType: 'frame', + // Reduce network jitter + selfDeaf: true, + samplingRate: 48000 + }, + fetchBeforeQueued: false, // Don't pre-download, stream directly + smoothVolume: true, + audioOnlyStream: true, // Ensures we're only getting audio data + // Simplified FFmpeg options for better streaming performance + ffmpegFilters: [ + 'dynaudnorm=f=200', // Dynamic audio normalization + 'bass=g=2:f=110:w=0.6', // Enhance bass slightly + 'highpass=f=55', // Remove sub-bass frequencies that can cause distortion + 'lowpass=f=16000', // Limit high frequencies that can sound static-like + 'volume=1.0' // Volume adjustment + ].join(','), + bufferingTimeout: 10000, // Reduced buffering timeout + // Stream options + streamOptions: { + seek: 0, + opusEncoding: true + }, + // Disable problematic extractors + disableExtractors: [ + 'YouTubei' // Disable the problematic YouTubei extractor + ], + // Use more reliable extractors + extractors: [ + 'play-dl', + 'Attachment' + ] } } }; diff --git a/main.js b/main.js index 15e8e0f2a..2e6634193 100644 --- a/main.js +++ b/main.js @@ -3,6 +3,7 @@ require('dotenv').config(); const { Player } = require('discord-player'); const { Client, GatewayIntentBits } = require('discord.js'); const { YoutubeiExtractor } = require('discord-player-youtubei'); // Import the new extractor +const { SpotifyExtractor } = require('@discord-player/extractor'); global.client = new Client({ intents: [ @@ -18,8 +19,9 @@ global.client = new Client({ client.config = require('./config'); const player = new Player(client, client.config.opt.discordPlayer); -// Register the new Youtubei extractor +// Register the necessary extractors player.extractors.register(YoutubeiExtractor, {}); +player.extractors.register(SpotifyExtractor, {}); console.clear(); require('./loader'); diff --git a/package.json b/package.json index 7b3271ba6..dea1294e0 100644 --- a/package.json +++ b/package.json @@ -9,17 +9,22 @@ "start": "node main.js" }, "dependencies": { - "@discord-player/extractor": "^4.3.1", + "@discord-player/extractor": "^7.1.0", "@discordjs/opus": "^0.9.0", + "@discordjs/voice": "^0.18.0", "@distube/ytdl-core": "^4.11.8", "@evan/opus": "^1.0.3", + "axios": "^1.8.4", + "bgutils-js": "^3.2.0", "discord-player": "6.6.9-dev.1", "discord-player-youtubei": "^1.3.3", "discord.js": "^14.13.0", "dotenv": "^16.4.5", "ffmpeg-static": "^5.0.2", "ms": "^3.0.0-canary.1", - "play-dl": "^1.9.6", - "translate": "^3.0.0" + "node-fetch": "^2.7.0", + "play-dl": "^1.9.7", + "translate": "^3.0.0", + "ytdl-core": "^4.11.5" } -} +} \ No newline at end of file diff --git a/radioStations.js b/radioStations.js new file mode 100644 index 000000000..a090f1f22 --- /dev/null +++ b/radioStations.js @@ -0,0 +1,42 @@ +module.exports = [ + { + name: 'Radio Colinde', + url: 'https://asculta.servereradio.ro/8140/stream', + type: 'arbitrary' + }, + { + name: 'Radio Manele', + url: 'https://ssl.servereradio.ro/8054/stream', + type: 'arbitrary' + }, + { + name: 'Radio Petrecere', + url: 'https://ssl.servereradio.ro/8123/stream', + type: 'arbitrary' + }, + { + name: 'BBC Radio 1', + url: 'http://stream.live.vc.bbcmedia.co.uk/bbc_radio_one', + type: 'arbitrary' + }, + { + name: 'CNN News', + url: 'http://tunein.streamguys1.com/CNNi', + type: 'arbitrary' + }, + { + name: 'Classical Music', + url: 'http://stream.srg-ssr.ch/m/rsc_de/mp3_128', + type: 'arbitrary' + }, + { + name: 'Lofi Hip Hop', + url: 'http://hyades.shoutca.st:8043/stream', + type: 'arbitrary' + }, + { + name: 'Jazz 24/7', + url: 'http://strm112.1.fm/jazz_mobile_mp3', + type: 'arbitrary' + } +]; diff --git a/utils/radioPlayer.js b/utils/radioPlayer.js new file mode 100644 index 000000000..8264b7d02 --- /dev/null +++ b/utils/radioPlayer.js @@ -0,0 +1,156 @@ +const { createAudioResource, createAudioPlayer, AudioPlayerStatus, joinVoiceChannel, StreamType, NoSubscriberBehavior } = require('@discordjs/voice'); +const { EmbedBuilder } = require('discord.js'); +const { Translate } = require('../process_tools'); +const { useMainPlayer } = require('discord-player'); +const https = require('https'); +const http = require('http'); + +// Store active radio connections to manage them +const activeRadioConnections = new Map(); + +/** + * Play a radio station in a voice channel + * @param {Object} options - Options for playing a radio station + * @param {Object} options.voiceChannel - The voice channel to play in + * @param {Object} options.interaction - The interaction that triggered this + * @param {Object} options.station - The radio station to play + * @param {Number} options.volume - The volume to play at (0-100) + * @param {Object} options.client - The Discord client + */ +async function playRadioStation({ voiceChannel, interaction, station, volume = 80, client }) { + try { + // Clear any existing player in this guild + if (activeRadioConnections.has(interaction.guild.id)) { + const oldConnection = activeRadioConnections.get(interaction.guild.id); + oldConnection.destroy(); + activeRadioConnections.delete(interaction.guild.id); + } + + // Create the voice connection + const connection = joinVoiceChannel({ + channelId: voiceChannel.id, + guildId: voiceChannel.guild.id, + adapterCreator: voiceChannel.guild.voiceAdapterCreator, + selfDeaf: true, + }); + + // Create the audio player + const player = createAudioPlayer({ + behaviors: { + noSubscriber: NoSubscriberBehavior.Play, + }, + }); + + // Direct stream creation using node's http/https modules + const protocol = station.url.startsWith('https') ? https : http; + + // Initial connection to create the stream + const requestStream = new Promise((resolve, reject) => { + const req = protocol.get(station.url, (res) => { + if (res.statusCode === 302 || res.statusCode === 301) { + // Handle redirects + const redirectUrl = res.headers.location; + console.log(`Redirecting to: ${redirectUrl}`); + const redirectProtocol = redirectUrl.startsWith('https') ? https : http; + + const redirectReq = redirectProtocol.get(redirectUrl, (redirectRes) => { + resolve(redirectRes); + }).on('error', (err) => { + console.error('Error during redirect:', err); + reject(err); + }); + + redirectReq.end(); + } else { + resolve(res); + } + }).on('error', (err) => { + console.error('Error connecting to radio stream:', err); + reject(err); + }); + + req.end(); + }); + + const streamResponse = await requestStream; + + // Create an audio resource from the stream + const resource = createAudioResource(streamResponse, { + inputType: StreamType.Arbitrary, + inlineVolume: true, + }); + + // Set the volume + resource.volume.setVolume(volume / 100); + + // Play the stream + player.play(resource); + connection.subscribe(player); + + // Store the connection for later reference + activeRadioConnections.set(interaction.guild.id, connection); + + // Handle player state changes + player.on(AudioPlayerStatus.Idle, () => { + console.log('Radio stream ended or errored, attempting to reconnect...'); + // Try to restart the stream after a brief delay + setTimeout(() => { + playRadioStation({ voiceChannel, interaction, station, volume, client }); + }, 5000); + }); + + // Handle player errors + player.on('error', (error) => { + console.error(`Radio player error: ${error}`); + + // Check if the error is potentially recoverable + if (error.message.includes('ETIMEDOUT') || error.message.includes('ECONNRESET')) { + console.log('Network error, attempting to reconnect...'); + setTimeout(() => { + playRadioStation({ voiceChannel, interaction, station, volume, client }); + }, 5000); + } + }); + + // Create a rich embed with radio station info + const radioEmbed = new EmbedBuilder() + .setColor('#2f3136') + .setTitle(`📻 ${station.name}`) + .setDescription(`**Now Playing:** Live Stream`) + .addFields([ + { name: 'Volume', value: `${volume}%`, inline: true }, + ]) + .setFooter({ text: `Requested by ${interaction.member.displayName}` }) + .setTimestamp(); + + // Add a thumbnail for visual appeal + radioEmbed.setThumbnail('https://cdn-icons-png.flaticon.com/512/2995/2995099.png'); + + return { + success: true, + embed: radioEmbed + }; + } catch (error) { + console.error(`Failed to play radio: ${error}`); + return { + success: false, + error: error + }; + } +} + +/** + * Stop the radio on a specific guild + * @param {string} guildId - The guild ID to stop radio on + */ +function stopRadio(guildId) { + if (activeRadioConnections.has(guildId)) { + const connection = activeRadioConnections.get(guildId); + connection.destroy(); + activeRadioConnections.delete(guildId); + return true; + } + return false; +} + +module.exports = { playRadioStation, stopRadio, activeRadioConnections }; diff --git a/utils/youtubeExtractor.js b/utils/youtubeExtractor.js new file mode 100644 index 000000000..dc28e6900 --- /dev/null +++ b/utils/youtubeExtractor.js @@ -0,0 +1,128 @@ +const axios = require('axios'); +const { createAudioResource, StreamType } = require('@discordjs/voice'); + +/** + * Extract YouTube video ID from various URL formats + * @param {string} url - YouTube URL + * @returns {string|null} - Video ID or null if invalid + */ +function extractYoutubeVideoId(url) { + try { + // Handle youtu.be format + if (url.includes('youtu.be')) { + const urlParts = url.split('/'); + return urlParts[urlParts.length - 1].split('?')[0]; + } + // Handle youtube.com format + else if (url.includes('youtube.com')) { + // Handle watch URLs + if (url.includes('/watch?v=')) { + const urlObj = new URL(url); + return urlObj.searchParams.get('v'); + } + // Handle shorts + else if (url.includes('/shorts/')) { + const shortsPath = url.split('/shorts/')[1]; + return shortsPath.split('?')[0]; + } + } + return null; + } catch (error) { + console.error('Error extracting YouTube video ID:', error); + return null; + } +} + +/** + * Get audio stream URL from cnvmp3.com + * @param {string} videoId - YouTube video ID + * @returns {Promise} - Object containing audio URL and metadata + */ +async function getYoutubeAudioUrl(videoId) { + try { + // First request to get the conversion started + const response = await axios.get(`https://cnvmp3.com/v23/api/single/mp3/${videoId}`); + + if (!response.data || !response.data.id) { + throw new Error('Failed to get conversion ID'); + } + + const conversionId = response.data.id; + const title = response.data.title || 'YouTube Audio'; + const author = response.data.author || 'Unknown Artist'; + const duration = response.data.duration || '0:00'; + const thumbnail = response.data.thumbnail || null; + + // Second request to get the download URL + const statusResponse = await axios.get(`https://cnvmp3.com/v23/api/mp3/${conversionId}`); + + if (!statusResponse.data || !statusResponse.data.url) { + throw new Error('Failed to get audio URL'); + } + + return { + url: statusResponse.data.url, + title, + author, + duration, + thumbnail + }; + } catch (error) { + console.error('Error getting YouTube audio URL:', error); + throw error; + } +} + +/** + * Create an audio resource from a YouTube URL using cnvmp3.com + * @param {string} youtubeUrl - YouTube URL + * @returns {Promise} - Object containing audio resource and metadata + */ +async function createYoutubeAudioResource(youtubeUrl) { + try { + const videoId = extractYoutubeVideoId(youtubeUrl); + + if (!videoId) { + throw new Error('Invalid YouTube URL'); + } + + console.log(`Extracting audio for YouTube video ID: ${videoId}`); + + const audioData = await getYoutubeAudioUrl(videoId); + + console.log(`Got audio URL: ${audioData.url}`); + + // Create a stream from the audio URL + const response = await axios({ + method: 'get', + url: audioData.url, + responseType: 'stream' + }); + + // Create an audio resource from the stream + const resource = createAudioResource(response.data, { + inputType: StreamType.Arbitrary, + inlineVolume: true + }); + + return { + resource, + metadata: { + title: audioData.title, + author: audioData.author, + duration: audioData.duration, + thumbnail: audioData.thumbnail, + url: youtubeUrl + } + }; + } catch (error) { + console.error('Error creating YouTube audio resource:', error); + throw error; + } +} + +module.exports = { + extractYoutubeVideoId, + getYoutubeAudioUrl, + createYoutubeAudioResource +}; diff --git a/utils/youtubePlayer.js b/utils/youtubePlayer.js new file mode 100644 index 000000000..53adf8e4b --- /dev/null +++ b/utils/youtubePlayer.js @@ -0,0 +1,174 @@ +const { EmbedBuilder } = require('discord.js'); +const { useMainPlayer, QueryType } = require('discord-player'); + +// Store active YouTube connections to manage them +const activeYoutubeConnections = new Map(); + +/** + * Validate and normalize YouTube URL + * @param {string} url - URL to validate + * @returns {string|null} - Normalized URL or null if invalid + */ +function validateAndNormalizeYoutubeUrl(url) { + // Check if it's a valid YouTube URL + const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+/; + if (!youtubeRegex.test(url)) { + return null; + } + + // Extract video ID + let videoId = null; + + // Handle youtu.be format + if (url.includes('youtu.be')) { + const urlParts = url.split('/'); + videoId = urlParts[urlParts.length - 1].split('?')[0]; + } + // Handle youtube.com format + else if (url.includes('youtube.com')) { + const urlParams = new URL(url).searchParams; + videoId = urlParams.get('v'); + + // If no 'v' parameter, check if it's a shortened URL + if (!videoId && url.includes('/shorts/')) { + const shortsPath = url.split('/shorts/')[1]; + videoId = shortsPath.split('?')[0]; + } + } + + // If we couldn't extract a video ID, return null + if (!videoId) { + return null; + } + + // Return a normalized URL + return `https://www.youtube.com/watch?v=${videoId}`; +} + +/** + * Play a YouTube video in a voice channel + * @param {Object} options - Options for playing a YouTube video + * @param {Object} options.voiceChannel - The voice channel to play in + * @param {Object} options.interaction - The interaction that triggered this + * @param {String} options.url - The YouTube URL to play + * @param {Number} options.volume - The volume to play at (0-100) + * @param {Object} options.client - The Discord client + */ +async function playYoutubeVideo({ voiceChannel, interaction, url, volume = 80, client }) { + try { + // Validate and normalize YouTube URL + const normalizedUrl = validateAndNormalizeYoutubeUrl(url); + if (!normalizedUrl) { + return { + success: false, + error: new Error('Invalid YouTube URL. Please provide a valid YouTube link.') + }; + } + + console.log(`Playing YouTube video: ${normalizedUrl}`); + + // Get the player instance + const player = useMainPlayer(); + + // Search for the video using the normalized URL + const result = await player.search(normalizedUrl, { + requestedBy: interaction.member, + searchEngine: QueryType.YOUTUBE_VIDEO // Force YouTube video search + }); + + if (!result?.tracks.length) { + return { + success: false, + error: new Error('No results found for this URL. The video might be unavailable or restricted.') + }; + } + + // Get the track + const track = result.tracks[0]; + + // Play the track with optimized settings for YouTube + await player.play(voiceChannel, track, { + nodeOptions: { + metadata: { + channel: interaction.channel, + client: client, + requestedBy: interaction.user + }, + volume: volume, + leaveOnEmpty: client.config.opt.leaveOnEmpty, + leaveOnEmptyCooldown: client.config.opt.leaveOnEmptyCooldown, + leaveOnEnd: client.config.opt.leaveOnEnd, + leaveOnEndCooldown: client.config.opt.leaveOnEndCooldown, + // Optimize for streaming + bufferingTimeout: 0, + connectionOptions: { + enableLiveBuffer: false + }, + // Don't pre-download + fetchBeforeQueued: false + } + }); + + // Store the connection for reference + const connection = player.voiceUtils.getConnection(interaction.guild.id); + if (connection) { + activeYoutubeConnections.set(interaction.guild.id, connection); + } + + // Create a rich embed with video info + const youtubeEmbed = new EmbedBuilder() + .setColor('#FF0000') // YouTube red + .setTitle(`🎵 ${track.title}`) + .setDescription(`**Channel:** ${track.author}`) + .addFields([ + { name: 'Duration', value: track.duration, inline: true }, + { name: 'Volume', value: `${volume}%`, inline: true }, + ]) + .setFooter({ text: `Requested by ${interaction.member.displayName}` }) + .setTimestamp(); + + // Add the video thumbnail if available + if (track.thumbnail) { + youtubeEmbed.setThumbnail(track.thumbnail); + } + + return { + success: true, + embed: youtubeEmbed + }; + } catch (error) { + console.error(`Failed to play YouTube video: ${error}`); + return { + success: false, + error: error + }; + } +} + +/** + * Stop the YouTube player on a specific guild + * @param {string} guildId - The guild ID to stop YouTube on + */ +function stopYoutube(guildId) { + try { + const player = useMainPlayer(); + const queue = player.nodes.get(guildId); + + if (queue) { + queue.delete(); + return true; + } else if (activeYoutubeConnections.has(guildId)) { + const connection = activeYoutubeConnections.get(guildId); + connection.destroy(); + activeYoutubeConnections.delete(guildId); + return true; + } + + return false; + } catch (error) { + console.error(`Error stopping YouTube: ${error}`); + return false; + } +} + +module.exports = { playYoutubeVideo, stopYoutube, activeYoutubeConnections };