From e742785408547eaffd3f84d96ea72ebcceafc0f8 Mon Sep 17 00:00:00 2001 From: Bunga Razvan Date: Fri, 3 Apr 2026 16:38:51 +0100 Subject: [PATCH 1/3] radio --- commands/index.ts | 2 ++ commands/radio.ts | 79 +++++++++++++++++++++++++++++++++++++++++++++++ commands/skip.ts | 5 ++- 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 commands/radio.ts 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..e2422e1 --- /dev/null +++ b/commands/radio.ts @@ -0,0 +1,79 @@ +import { CommandInteraction, SlashCommandBuilder } from "discord.js"; +import { request } from "undici"; + +import { + AudioPlayerStatus, + createAudioPlayer, + createAudioResource, + entersState, + getVoiceConnection, + joinVoiceChannel, + StreamType, + VoiceConnectionStatus, +} from "@discordjs/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) { + const guildId = interaction.guildId!; + + // @ts-ignore + const voiceChannel = interaction.member?.voice?.channel; + + if (!voiceChannel) { + return interaction.reply("You must be in a voice channel!"); + } + + await interaction.reply("đŸ“ģ Connecting..."); + + let connection = getVoiceConnection(guildId); + + if (!connection) { + connection = joinVoiceChannel({ + channelId: voiceChannel.id, + guildId: voiceChannel.guild.id, + adapterCreator: voiceChannel.guild.voiceAdapterCreator, + }); + } + + try { + await entersState(connection, VoiceConnectionStatus.Ready, 20_000); + } catch (error) { + connection.destroy(); + return interaction.editReply("❌ Failed to join voice channel."); + } + + const player = createAudioPlayer(); + + const response = await request("http://asculta.radioromanian.net:8100/"); + + const resource = createAudioResource(response.body, { + inputType: StreamType.Arbitrary, + }); + + connection.subscribe(player); + player.play(resource); + + player.on(AudioPlayerStatus.Playing, () => { + console.log("✅ Playing radio stream"); + }); + + player.on(AudioPlayerStatus.Idle, () => { + console.log("âš ī¸ Stream ended or idle"); + }); + + player.on("error", (error) => { + console.error("❌ Player error:", error); + }); + + 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, }); } From 44e52857e364322c98b6917cdd6362aa2692c2f4 Mon Sep 17 00:00:00 2001 From: Bunga Razvan Date: Wed, 8 Apr 2026 19:20:01 +0100 Subject: [PATCH 2/3] add radio to songQueue --- commands/radio.ts | 50 ++++++++++++++++++---- utils/voice.ts | 107 +++++++++++++++++++++------------------------- 2 files changed, 90 insertions(+), 67 deletions(-) diff --git a/commands/radio.ts b/commands/radio.ts index e2422e1..9ea35e5 100644 --- a/commands/radio.ts +++ b/commands/radio.ts @@ -11,6 +11,8 @@ import { StreamType, VoiceConnectionStatus, } from "@discordjs/voice"; +import { songQueue } from "../constants"; +import { shouldDisconnect } from "../utils/voice"; export const config = { name: "radio", @@ -24,8 +26,6 @@ export const data = new SlashCommandBuilder() .setDescription(config.description); export async function execute(interaction: CommandInteraction) { - const guildId = interaction.guildId!; - // @ts-ignore const voiceChannel = interaction.member?.voice?.channel; @@ -35,7 +35,8 @@ export async function execute(interaction: CommandInteraction) { await interaction.reply("đŸ“ģ Connecting..."); - let connection = getVoiceConnection(guildId); + const guildId = interaction.guildId!; + let connection = getVoiceConnection(guildId!); if (!connection) { connection = joinVoiceChannel({ @@ -43,6 +44,13 @@ export async function execute(interaction: CommandInteraction) { 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 { @@ -52,7 +60,23 @@ export async function execute(interaction: CommandInteraction) { return interaction.editReply("❌ Failed to join voice channel."); } - const player = createAudioPlayer(); + if (!songQueue.has(guildId)) { + const player = createAudioPlayer(); + connection.subscribe(player); + songQueue.set(guildId, { + tracks: [], + index: 0, + disconnectTimeout: null, + connection, + player, + }); + } + + const serverQueue = songQueue.get(guildId); + + if (serverQueue.disconnectTimeout) { + clearTimeout(serverQueue.disconnectTimeout); + } const response = await request("http://asculta.radioromanian.net:8100/"); @@ -60,20 +84,28 @@ export async function execute(interaction: CommandInteraction) { inputType: StreamType.Arbitrary, }); - connection.subscribe(player); - player.play(resource); + connection.subscribe(serverQueue.player); + serverQueue.player.play(resource); - player.on(AudioPlayerStatus.Playing, () => { + serverQueue.player.on(AudioPlayerStatus.Playing, () => { console.log("✅ Playing radio stream"); }); - player.on(AudioPlayerStatus.Idle, () => { + serverQueue.player.on(AudioPlayerStatus.Idle, () => { console.log("âš ī¸ Stream ended or idle"); }); - player.on("error", (error) => { + serverQueue.player.on("error", (error) => { console.error("❌ Player error:", error); }); + serverQueue.disconnectTimeout = setInterval(() => { + if (shouldDisconnect(interaction)) { + serverQueue.connection.destroy(); + songQueue.delete(interaction.guildId!); + clearInterval(serverQueue.disconnectTimeout); + } + }, Number(process.env.DC_IDLE)); + await interaction.editReply("đŸ“ģ Now playing radio!"); } diff --git a/utils/voice.ts b/utils/voice.ts index e489c0e..6b6c437 100644 --- a/utils/voice.ts +++ b/utils/voice.ts @@ -1,58 +1,49 @@ -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(serverQueue: any) { + const connection = serverQueue.connection; + if (!connection) return true; + + const guildId = connection.joinConfig.guildId; + const guild = serverQueue.player.client.guilds.cache.get(guildId); // get guild from bot client + if (!guild) return true; + + const channelId = connection.joinConfig.channelId; + const voiceChannel = guild.channels.cache.get(channelId) as VoiceChannel; + if (!voiceChannel) return true; + + // Count only humans + const humans = voiceChannel.members.filter((m) => !m.user.bot); + + const hasTracks = + serverQueue.tracks && serverQueue.index < serverQueue.tracks.length; + + const isRadio = !serverQueue.tracks || serverQueue.tracks.length === 0; + + // Disconnect if no humans or playlist finished + if (humans.size === 0) return true; + if (!isRadio && !hasTracks) return true; + + return false; +} From 8045519f65de6a47c884bb56a4303366f8e16e5d Mon Sep 17 00:00:00 2001 From: Bunga Razvan Date: Wed, 8 Apr 2026 19:51:04 +0100 Subject: [PATCH 3/3] switch between radio and play --- commands/radio.ts | 15 ++++- commands/stop.ts | 147 ++++++++++++++++++++++++---------------------- utils/voice.ts | 47 +++++++++------ utils/youtube.ts | 7 ++- 4 files changed, 123 insertions(+), 93 deletions(-) diff --git a/commands/radio.ts b/commands/radio.ts index 9ea35e5..5d29620 100644 --- a/commands/radio.ts +++ b/commands/radio.ts @@ -67,6 +67,7 @@ export async function execute(interaction: CommandInteraction) { tracks: [], index: 0, disconnectTimeout: null, + disconnectInterval: null, connection, player, }); @@ -78,6 +79,10 @@ export async function execute(interaction: CommandInteraction) { clearTimeout(serverQueue.disconnectTimeout); } + if (serverQueue.disconnectInterval) { + clearInterval(serverQueue.disconnectInterval); + } + const response = await request("http://asculta.radioromanian.net:8100/"); const resource = createAudioResource(response.body, { @@ -85,6 +90,9 @@ export async function execute(interaction: CommandInteraction) { }); connection.subscribe(serverQueue.player); + + serverQueue.player.stop(); + serverQueue.isRadio = true; serverQueue.player.play(resource); serverQueue.player.on(AudioPlayerStatus.Playing, () => { @@ -99,10 +107,11 @@ export async function execute(interaction: CommandInteraction) { console.error("❌ Player error:", error); }); - serverQueue.disconnectTimeout = setInterval(() => { - if (shouldDisconnect(interaction)) { + serverQueue.disconnectInterval = setInterval(() => { + console.log(serverQueue.player.state.status); + if (shouldDisconnect(serverQueue)) { serverQueue.connection.destroy(); - songQueue.delete(interaction.guildId!); + songQueue.delete(serverQueue.connection.guildId!); clearInterval(serverQueue.disconnectTimeout); } }, Number(process.env.DC_IDLE)); 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 6b6c437..ef4b4c7 100644 --- a/utils/voice.ts +++ b/utils/voice.ts @@ -21,29 +21,38 @@ export function checkCanPlay(interaction: CommandInteraction, song: string) { } } -export function shouldDisconnect(serverQueue: any) { - const connection = serverQueue.connection; - if (!connection) return true; - - const guildId = connection.joinConfig.guildId; - const guild = serverQueue.player.client.guilds.cache.get(guildId); // get guild from bot client - if (!guild) return true; +export function shouldDisconnect( + interaction: CommandInteraction | ModalSubmitInteraction, +) { + const connection = getVoiceConnection(interaction.guildId!); + const serverQueue = songQueue.get(interaction.guildId); + + if (!connection) { + return false; + } - const channelId = connection.joinConfig.channelId; - const voiceChannel = guild.channels.cache.get(channelId) as VoiceChannel; - if (!voiceChannel) return true; + const guild = interaction.client.guilds.cache.get(interaction.guildId!); - // Count only humans - const humans = voiceChannel.members.filter((m) => !m.user.bot); + if (!guild) { + return false; + } - const hasTracks = - serverQueue.tracks && serverQueue.index < serverQueue.tracks.length; + const voiceChannel = guild.channels.cache.get( + connection.joinConfig.channelId!, + ) as VoiceChannel; - const isRadio = !serverQueue.tracks || serverQueue.tracks.length === 0; + if (!voiceChannel) { + return false; + } - // Disconnect if no humans or playlist finished - if (humans.size === 0) return true; - if (!isRadio && !hasTracks) return true; + if ( + voiceChannel.members.size > 1 && + serverQueue && + serverQueue.index < serverQueue.tracks.length + ) { + // Check if bot is the only one left + return false; + } - 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 {