Skip to content
Draft
44 changes: 43 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,51 @@
# This is an example .env file. Copy this to .env and fill in the values to run the bot.
# make sure to remove any comments when filling in the values, as the bot will not be able to parse them
# Also do not include any spaces around the values as that can also cause issues.
# most values can be left blank and the bot will use defaults

BOT_TOKEN=

# Logging Configuration
# LOG_LEVEL can be set to DEBUG, INFO, WARNING, or left blank for INFO
LOG_LEVEL=
LOG_FILE_PATH=
# Time in seconds before the bot leaves an inactive channel. <=0 is disabled

# Time in seconds before the bot leaves an inactive channel.
# Note: Timers only update between each heartbeat!
# default is 2700 seconds (about 45 mins)
# <=0 is disabled
EMPTY_CHANNEL_TIMEOUT=0

# Number of tries to clean up Gracefully before escalating to forceful cleanup.
# Default is 10 if left blank, setting to 0 will make the bot escalate after the first try
MAX_ATTEMPTS=

# Time in seconds between each heartbeat, which is responsible for cleaning up inactive channels and other periodic tasks.
# Default is 15 seconds, setting this too low can cause increased CPU usage, as well as higher traffic to the source server
# setting it too high can cause increased latency in cleanup and title updates, as well as other core tasks that rely on the heartbeat.
# configure carefully!
HEARTBEAT_INTERVAL=

# TLS Certificate Verification, its recommended to keep this enabled for security.
# Does not affect the bot's ability to connect to http sources, only affects the verification of TLS certificates for https sources.
# only change if you are having issues with the TLS certificate bundle!
# set to 1 to enable, 0 to disable.
TLS_VERIFY=1

# Output Buffersize for Discord
# Note: Setting this too high can cause increased latency, while setting it too low can cause audio dropouts.
# Default is 1200 KB (or 1.2 MB)
# When Adjusting, it is best to adjust for about 3 seconds of audio data
# For example, at 320 Kbps; 3 seconds of audio would be around 1200 KB (or 1.2 MB)
BUF_SIZE_IN_KB=

# Constant Bitrate for the Trancoded Audio Stream (output to Discord)
# Default is 320 Kbps, which is the upper limit for Shoutcast and ICEcast Protocols.
# Setting this higher than 320 Kbps may not provide any additional audio quality benefits and can increase bandwidth usage
# lowering this can reduce bandwidth usage but may also reduce audio quality, especially for high-quality source material.
BITRATE_IN_KBPS=

# Only change these if you know what you're doing, the bot does automatic sharding and clustering
TOTAL_SHARDS=
TOTAL_CLUSTERS=
CLUSTER_ID=
91 changes: 77 additions & 14 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO').upper()

# TLS VERIFY
TLS_VERIFY = bool(os.environ.get('TLS_VERIFY', True))
TLS_VERIFY = bool(int(os.environ.get('TLS_VERIFY', 1)))

# Technical Configurations - Be Careful!
MAX_ATTEMPTS = int(os.environ.get('MAX_ATTEMPTS', 10))
HEARTBEAT_INTERVAL = int(os.environ.get('HEARTBEAT_INTERVAL', 15))
BUF_SIZE_IN_KB = int(os.environ.get('BUF_SIZE_IN_KB', 1200))
BITRATE_IN_KBPS = int(os.environ.get('BITRATE_IN_KBPS', 320))

# CLUSETERING INFORMATION
CLUSTER_ID = int(os.environ.get('CLUSTER_ID', 0))
Expand Down Expand Up @@ -117,6 +123,31 @@ async def on_ready():
logger.info(f"Shard IDS: {bot.shard_ids}")
logger.info(f"Cluster ID: {bot.cluster_id}")

# Log all loaded environment variables
logger.info("Loaded configuration variables:")
config_vars = {
'BOT_TOKEN': BOT_TOKEN,
'LOG_FILE_PATH': str(LOG_FILE_PATH),
'LOG_LEVEL': LOG_LEVEL,
'TLS_VERIFY': TLS_VERIFY,
'TLS_Debug_int': int(TLS_VERIFY),
'MAX_ATTEMPTS': MAX_ATTEMPTS,
'HEARTBEAT_INTERVAL': f'{HEARTBEAT_INTERVAL}s',
'BUF_SIZE_IN_KB': f'{BUF_SIZE_IN_KB}KB',
'BITRATE_IN_KBPS': f'{BITRATE_IN_KBPS}kbps',
'EMPTY_CHANNEL_TIMEOUT': f'{int(os.environ.get("EMPTY_CHANNEL_TIMEOUT", 45*60))}s',
'TOTAL_CLUSTERS': TOTAL_CLUSTERS,
'TOTAL_SHARDS': TOTAL_SHARDS,
'NUMBER_OF_SHARDS_PER_CLUSTER': NUMBER_OF_SHARDS_PER_CLUSTER

}
sensitive_keys = {'BOT_TOKEN'}
for key, value in config_vars.items():
if key in sensitive_keys:
logger.info(f" {key}: [REDACTED]")
else:
logger.info(f" {key}: {value}")


### Custom Checks ###

Expand Down Expand Up @@ -204,8 +235,9 @@ async def play(interaction: discord.Interaction, url: str, private_stream: bool

response_message = f"Starting channel {url}" if not private_stream else "Starting channel ***OMINOUSLY***"
await interaction.response.send_message(response_message, ephemeral=True)
if await play_stream(interaction, url):
STATE_MANAGER.set_state(interaction.guild_id, 'private_stream', private_stream)
STATE_MANAGER.set_state(interaction.guild_id, 'private_stream', private_stream)
await asyncio.sleep(0.5)
await play_stream(interaction, url)

@bot.tree.command(
name='leave',
Expand Down Expand Up @@ -378,6 +410,7 @@ async def maint(interaction: discord.Interaction, status: bool = True):

await interaction.response.send_message("🛠️ Toggling maintenance mode... please wait")
await STATE_MANAGER.set_maint(status=status)
await asyncio.sleep(0.5)
active_guild_ids = STATE_MANAGER.all_active_guild_ids()
for guild_id in active_guild_ids:
text_channel = bot.get_channel(STATE_MANAGER.get_state(guild_id, 'text_channel_id'))
Expand All @@ -393,6 +426,7 @@ async def maint(interaction: discord.Interaction, status: bool = True):
STATE_MANAGER.set_state(guild_id, 'was_active', True)
embed = discord.Embed.from_dict(embed_data)
await text_channel.send(embed=embed)
await asyncio.sleep(0.5)
else:
was_active = STATE_MANAGER.get_state(guild_id, 'was_active') or False
if was_active is True and status == False:
Expand All @@ -408,13 +442,13 @@ async def maint(interaction: discord.Interaction, status: bool = True):
if status:
await interaction.edit_original_response(content="💾 saving state...")
await STATE_MANAGER.save_state()
asyncio.sleep(5)
await asyncio.sleep(2)
await interaction.edit_original_response(content="👷 Maintenance mode enabled")
else:
await interaction.edit_original_response(content="🧼 Purging State + DB...")
STATE_MANAGER.clear_state(force=True)
await STATE_MANAGER.clear_state_db()
asyncio.sleep(5)
await asyncio.sleep(3)
await STATE_MANAGER.set_maint(status=status)
await interaction.edit_original_response(content="👷 Maintenance mode disabled")

Expand Down Expand Up @@ -957,7 +991,7 @@ async def play_stream(interaction, url):
# Try to connect to voice chat, and only consider connected if both conditions met
if not voice_client or not voice_client.is_connected():
try:
voice_client = await voice_channel.connect(timeout=7)
voice_client = await voice_channel.connect(timeout=7, self_mute=False, self_deaf=True)
logger.info("Connected to voice channel for playback")
except Exception as e:
logger.error(f"Failed to connect to voice channel: {e}")
Expand All @@ -968,14 +1002,31 @@ async def play_stream(interaction, url):
else:
await interaction.edit_original_response(content="Failed to connect to voice channel. Please try again.")
return False


# TRY to Pipe music stream to FFMpeg:
## Opus trancoding with loudnorm (12dB LRA)
## Buffer size: 15Mb
## Analyze Duration: 5 seconds
## Allowed Protocols: http,https,tls,pipe
try:
music_stream = discord.FFmpegOpusAudio(source=url, options="-filter:a dynaudnorm=f=200:g=5:p=0.8,volume=0.06 -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 120 -tls_verify 0 -protocol_whitelist http,https,tls,pipe -ar 48000 -ac 2 -b:a 320k -rtsp_flags prefer_tcp -rtbufsize 15000000 -analyzeduration 5000000")
ffmpeg_options = [
"-filter:a dynaudnorm=f=200:g=25:p=0.8,volume=0.1",
"-reconnect 1",
"-reconnect_streamed 1",
"-reconnect_delay_max 120",
f"-tls_verify {int(TLS_VERIFY)}",
"-protocol_whitelist http,https,tls,pipe",
"-ar 48000",
"-ac 2",
f"-b:a {BITRATE_IN_KBPS}k",
"-rtsp_flags prefer_tcp",
"-rtbufsize 15000000",
"-analyzeduration 5000000",
"-fflags +discardcorrupt",
f"-bufsize {BUF_SIZE_IN_KB}k"
]
music_stream = discord.FFmpegOpusAudio(source=url, options=" ".join(ffmpeg_options))
await asyncio.sleep(1) # Give FFmpeg a moment to start
except Exception as e:
logger.error(f"Failed to start FFmpeg stream: {e}")
Expand Down Expand Up @@ -1026,9 +1077,12 @@ def stream_finished_callback(error):
STATE_MANAGER.set_state(interaction.guild.id, 'is_active', True)

# And let the user know what song is playing
await interaction.channel.send(content="Playing Toonz! 🎶")
await send_song_info(interaction.guild.id)
STATE_MANAGER.set_state(interaction.guild.id, 'cleaning_up', False)
logger.info(f"[{interaction.guild.id}]: Spawning Heartbeat")
create_and_start_heartbeat(interaction.guild.id)
await asyncio.sleep(0.5)

return True

Expand All @@ -1042,18 +1096,26 @@ async def stop_playback(guild: discord.Guild):
if voice_client:
# fist we stop playback if it says its playing
if voice_client.is_playing():
while voice_client.is_playing():
for i in range(MAX_ATTEMPTS):
if not voice_client.is_playing():
logger.info(f"[{guild.id}]: voice client stopped")
break
voice_client.stop()
logger.debug("Attempting to stop client")
await asyncio.sleep(1)
logger.info("voice client stopped")
if i + 1 == MAX_ATTEMPTS:
logger.warning(f"[{guild.id}]: Failed to stop voice client after {MAX_ATTEMPTS} attempts")
# then we handle disconnect from voice
if voice_client.is_connected():
while voice_client.is_connected():
for i in range(MAX_ATTEMPTS):
if not voice_client.is_connected():
logger.info(f"[{guild.id}]: voice client disconnected")
break
await voice_client.disconnect()
logger.debug("Attempting to disconnect client")
await asyncio.sleep(1)
logger.info("voice client disconnected")
if i + 1 == MAX_ATTEMPTS:
logger.warning(f"[{guild.id}]: Failed to disconnect voice client after {MAX_ATTEMPTS} attempts")
# if we still have voice_client after all that, tell it to go away so we can just forget it ever happened
if hasattr(guild, 'voice_client'):
try:
Expand All @@ -1075,10 +1137,11 @@ async def stop_playback(guild: discord.Guild):
STATE_MANAGER.set_state(guild.id, 'cleaning_up', False)

_active_heartbeats[guild.id].cancel()

await asyncio.sleep(.5)
logger.info(f"[{guild.id}]: Heartbeat Destroyed")

def create_and_start_heartbeat(guild_id: int):
@tasks.loop(seconds = 15)
@tasks.loop(seconds = HEARTBEAT_INTERVAL)
async def heartbeat(guild_id: int):
try:
logger.debug(f"Running heartbeat for: {guild_id}")
Expand All @@ -1088,7 +1151,7 @@ async def heartbeat(guild_id: int):
return

# Loop through monitors and execute. Let them handle their own shit
stationinfo = streamscrobbler.get_server_info(url)
stationinfo = await asyncio.to_thread(streamscrobbler.get_server_info, url)
for monitor in MONITORS:
await monitor.execute(guild_id=guild_id, state=STATE_MANAGER.get_state(guild_id), stationinfo=stationinfo)
except Exception as e:
Expand Down
10 changes: 9 additions & 1 deletion pls_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
import logging
import os
import subprocess
import validators
from typing import Optional

logger = logging.getLogger('discord')

def url_valid(url: str) -> bool:
return validators.url(url)

async def parse_pls(url: str) -> Optional[str]:
"""
parse a .pls playlist file using curl, Returns the first
Expand Down Expand Up @@ -48,7 +52,11 @@ async def parse_pls(url: str) -> Optional[str]:
await curl.wait() # Wait for it to die
except:
pass # closed by itself
logger.debug(f"Found Stream Link to be: {stream_url}")
logger.debug("Verifying New URL is safe")
if not url_valid(stream_url):
logger.error("We Found a stream url but it was tainted, so we skipped it!")
return None
logger.debug(f"All checks Passed! Found Stream Link to be: {stream_url}")
return stream_url
except UnicodeDecodeError:
continue
Expand Down