diff --git a/commands/index.ts b/commands/index.ts index fde63d5..29f8235 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -8,6 +8,7 @@ import * as loop from "./loop"; import * as list from "./list"; import * as playlist from "./playlist"; import * as mc_stats from "./mc_stats"; +import * as radio from "./radio"; type CommnadConfig = { name: string; @@ -35,6 +36,7 @@ const commands: CommandsCollection = { list, playlist, mc_stats, + radio, }; const slashCommands: CommandsCollection = {}; diff --git a/commands/radio.ts b/commands/radio.ts new file mode 100644 index 0000000..5d29620 --- /dev/null +++ b/commands/radio.ts @@ -0,0 +1,120 @@ +import { CommandInteraction, SlashCommandBuilder } from "discord.js"; +import { request } from "undici"; + +import { + AudioPlayerStatus, + createAudioPlayer, + createAudioResource, + entersState, + getVoiceConnection, + joinVoiceChannel, + StreamType, + VoiceConnectionStatus, +} from "@discordjs/voice"; +import { songQueue } from "../constants"; +import { shouldDisconnect } from "../utils/voice"; + +export const config = { + name: "radio", + description: "Play radio stream", + usage: "/radio", + slashCommand: true, +}; + +export const data = new SlashCommandBuilder() + .setName(config.name) + .setDescription(config.description); + +export async function execute(interaction: CommandInteraction) { + // @ts-ignore + const voiceChannel = interaction.member?.voice?.channel; + + if (!voiceChannel) { + return interaction.reply("You must be in a voice channel!"); + } + + await interaction.reply("đŸ“ģ Connecting..."); + + const guildId = interaction.guildId!; + let connection = getVoiceConnection(guildId!); + + if (!connection) { + connection = joinVoiceChannel({ + channelId: voiceChannel.id, + guildId: voiceChannel.guild.id, + adapterCreator: voiceChannel.guild.voiceAdapterCreator, + }); + } else if ( + connection && + connection.joinConfig.channelId !== voiceChannel.id + ) { + return interaction.editReply( + "Song already playing on another voice channel", + ); + } + + try { + await entersState(connection, VoiceConnectionStatus.Ready, 20_000); + } catch (error) { + connection.destroy(); + return interaction.editReply("❌ Failed to join voice channel."); + } + + if (!songQueue.has(guildId)) { + const player = createAudioPlayer(); + connection.subscribe(player); + songQueue.set(guildId, { + tracks: [], + index: 0, + disconnectTimeout: null, + disconnectInterval: null, + connection, + player, + }); + } + + const serverQueue = songQueue.get(guildId); + + if (serverQueue.disconnectTimeout) { + clearTimeout(serverQueue.disconnectTimeout); + } + + if (serverQueue.disconnectInterval) { + clearInterval(serverQueue.disconnectInterval); + } + + const response = await request("http://asculta.radioromanian.net:8100/"); + + const resource = createAudioResource(response.body, { + inputType: StreamType.Arbitrary, + }); + + connection.subscribe(serverQueue.player); + + serverQueue.player.stop(); + serverQueue.isRadio = true; + serverQueue.player.play(resource); + + serverQueue.player.on(AudioPlayerStatus.Playing, () => { + console.log("✅ Playing radio stream"); + }); + + serverQueue.player.on(AudioPlayerStatus.Idle, () => { + console.log("âš ī¸ Stream ended or idle"); + }); + + serverQueue.player.on("error", (error) => { + console.error("❌ Player error:", error); + }); + + serverQueue.disconnectInterval = setInterval(() => { + console.log(serverQueue.player.state.status); + if (shouldDisconnect(serverQueue)) { + serverQueue.connection.destroy(); + songQueue.delete(serverQueue.connection.guildId!); + clearInterval(serverQueue.disconnectTimeout); + } + }, Number(process.env.DC_IDLE)); + + await interaction.editReply("đŸ“ģ Now playing radio!"); +} diff --git a/commands/skip.ts b/commands/skip.ts index 3c1e706..1789e2d 100644 --- a/commands/skip.ts +++ b/commands/skip.ts @@ -71,10 +71,9 @@ export async function execute(interaction: CommandInteraction) { const index = serverQueue.index + skip_no; - console.log(serverQueue.tracks.length, index); - if (index > serverQueue.tracks.length) { + if (index > serverQueue.tracks.length - 1) { return interaction.reply({ - content: `Skip number ${index} is greater then the length of the playlist: ${serverQueue.tracks.length}`, + content: `No next song to play`, flags: MessageFlags.Ephemeral, }); } diff --git a/commands/stop.ts b/commands/stop.ts index 1d5e8fb..cb6e60e 100644 --- a/commands/stop.ts +++ b/commands/stop.ts @@ -1,69 +1,78 @@ -import { - CommandInteraction, - SlashCommandBuilder, - MessageFlags, -} from "discord.js"; - -import { songQueue } from "../constants"; -import { getVoiceConnection } from "@discordjs/voice"; - -import Sentry from "@sentry/node"; - -export const config = { - name: "stop", - description: "Stop song from playing and clears queue", - usage: "/stop", - slashCommand: true, -}; - -export const data = new SlashCommandBuilder() - .setName(config.name) - .setDescription(config.description); - -export async function execute(interaction: CommandInteraction) { - // @ts-ignore - const voiceChannel = interaction.member?.voice?.channel; - if (!voiceChannel) { - return interaction.reply({ - content: "Not connected to voice channel", - flags: MessageFlags.Ephemeral, - }); - } - - const guildId = interaction.guildId; - const connection = getVoiceConnection(guildId!); - - if (!connection) { - return interaction.reply({ - content: "Bot not connected to the voice channel", - flags: MessageFlags.Ephemeral, - }); - } - - if (connection && connection.joinConfig.channelId !== voiceChannel.id) { - return interaction.reply({ - content: "Not connected to the correct voice channel", - flags: MessageFlags.Ephemeral, - }); - } - - if (!songQueue.has(guildId)) { - return interaction.reply({ - content: "❌ No active song queue.", - flags: MessageFlags.Ephemeral, - }); - } - - const serverQueue = songQueue.get(guildId); - - try { - serverQueue.player.stop(); - songQueue.delete(guildId); - - return interaction.reply("âšī¸ Stopped playback and cleared queue."); - } catch (error) { - Sentry.captureException(error); - - return interaction.reply("Failed to stop playback"); - } -} +import { + CommandInteraction, + SlashCommandBuilder, + MessageFlags, +} from "discord.js"; + +import { songQueue } from "../constants"; +import { getVoiceConnection } from "@discordjs/voice"; + +import Sentry from "@sentry/node"; + +export const config = { + name: "stop", + description: "Stop song from playing and clears queue", + usage: "/stop", + slashCommand: true, +}; + +export const data = new SlashCommandBuilder() + .setName(config.name) + .setDescription(config.description); + +export async function execute(interaction: CommandInteraction) { + // @ts-ignore + const voiceChannel = interaction.member?.voice?.channel; + if (!voiceChannel) { + return interaction.reply({ + content: "Not connected to voice channel", + flags: MessageFlags.Ephemeral, + }); + } + + const guildId = interaction.guildId; + const connection = getVoiceConnection(guildId!); + + if (!connection) { + return interaction.reply({ + content: "Bot not connected to the voice channel", + flags: MessageFlags.Ephemeral, + }); + } + + if (connection && connection.joinConfig.channelId !== voiceChannel.id) { + return interaction.reply({ + content: "Not connected to the correct voice channel", + flags: MessageFlags.Ephemeral, + }); + } + + if (!songQueue.has(guildId)) { + return interaction.reply({ + content: "❌ No active song queue.", + flags: MessageFlags.Ephemeral, + }); + } + + const serverQueue = songQueue.get(guildId); + + try { + serverQueue.player.stop(); + + if (serverQueue.disconnectInterval) { + clearInterval(serverQueue.disconnectInterval); + } + + if (serverQueue.disconnectTimeout) { + clearTimeout(serverQueue.disconnectTimeout); + } + + songQueue.delete(guildId); + + return interaction.reply("âšī¸ Stopped playback and cleared queue."); + } catch (error) { + Sentry.captureException(error); + + return interaction.reply("Failed to stop playback"); + } +} diff --git a/utils/voice.ts b/utils/voice.ts index e489c0e..ef4b4c7 100644 --- a/utils/voice.ts +++ b/utils/voice.ts @@ -1,58 +1,58 @@ -import { getVoiceConnection } from "@discordjs/voice"; -import { - CommandInteraction, - ModalSubmitInteraction, - VoiceChannel, -} from "discord.js"; -import { songQueue } from "../constants"; - -export function checkCanPlay(interaction: CommandInteraction, song: string) { - const url = URL.canParse(song) ? new URL(song) : null; - - if (url && url.hostname != "www.youtube.com") { - return interaction.reply("You must provinde a youtube url"); - } - - // @ts-ignore - const voiceChannel = interaction.member?.voice?.channel; - - if (!voiceChannel) { - return interaction.reply("You must be in a voice channel!"); - } -} - -export function shouldDisconnect( - interaction: CommandInteraction | ModalSubmitInteraction, -) { - const connection = getVoiceConnection(interaction.guildId!); - const serverQueue = songQueue.get(interaction.guildId); - - if (!connection) { - return false; - } - - const guild = interaction.client.guilds.cache.get(interaction.guildId!); - - if (!guild) { - return false; - } - - const voiceChannel = guild.channels.cache.get( - connection.joinConfig.channelId!, - ) as VoiceChannel; - - if (!voiceChannel) { - return false; - } - - if ( - voiceChannel.members.size > 1 && - serverQueue && - serverQueue.index < serverQueue.tracks.length - ) { - // Check if bot is the only one left - return false; - } - - return true; -} +import { getVoiceConnection } from "@discordjs/voice"; +import { + CommandInteraction, + ModalSubmitInteraction, + VoiceChannel, +} from "discord.js"; +import { songQueue } from "../constants"; + +export function checkCanPlay(interaction: CommandInteraction, song: string) { + const url = URL.canParse(song) ? new URL(song) : null; + + if (url && url.hostname != "www.youtube.com") { + return interaction.reply("You must provinde a youtube url"); + } + + // @ts-ignore + const voiceChannel = interaction.member?.voice?.channel; + + if (!voiceChannel) { + return interaction.reply("You must be in a voice channel!"); + } +} + +export function shouldDisconnect( + interaction: CommandInteraction | ModalSubmitInteraction, +) { + const connection = getVoiceConnection(interaction.guildId!); + const serverQueue = songQueue.get(interaction.guildId); + + if (!connection) { + return false; + } + + const guild = interaction.client.guilds.cache.get(interaction.guildId!); + + if (!guild) { + return false; + } + + const voiceChannel = guild.channels.cache.get( + connection.joinConfig.channelId!, + ) as VoiceChannel; + + if (!voiceChannel) { + return false; + } + + if ( + voiceChannel.members.size > 1 && + serverQueue && + serverQueue.index < serverQueue.tracks.length + ) { + // Check if bot is the only one left + return false; + } + + return true; +} diff --git a/utils/youtube.ts b/utils/youtube.ts index 6e96aa4..d5b4757 100644 --- a/utils/youtube.ts +++ b/utils/youtube.ts @@ -211,9 +211,12 @@ export async function playQueue( guildQueue.tracks.push(...tracks); if ( - guildQueue.player.state.status !== AudioPlayerStatus.Playing && - guildQueue.player.state.status !== AudioPlayerStatus.Buffering + (guildQueue.player.state.status !== AudioPlayerStatus.Playing && + guildQueue.player.state.status !== AudioPlayerStatus.Buffering) || + (guildQueue.player.state.status == AudioPlayerStatus.Playing && + guildQueue.isRadio) ) { + guildQueue.isRadio = false; playNext(interaction); try {