Updates to discord radio module to try and fix redirection problem

This commit is contained in:
Logan Cusano
2026-01-04 01:32:46 -05:00
parent 00f4ebea2d
commit 4f5dcaf6ce

View File

@@ -3,28 +3,23 @@ import asyncio
import socket import socket
import logging import logging
import threading import threading
import struct
import subprocess import subprocess
import shlex import shlex
import time
from collections import deque from collections import deque
from typing import Optional, List, Tuple from typing import Optional, List
LOGGER = logging.getLogger("discord_radio") LOGGER = logging.getLogger("discord_radio")
class UDPAudioSource(discord.AudioSource): class UDPAudioSource(discord.AudioSource):
""" """
A custom Discord AudioSource that reads raw PCM audio from a thread-safe buffer. A custom Discord AudioSource that reads raw PCM audio from a thread-safe buffer.
This buffer is filled by the UDPListener running in the background.
It pipes the raw 8000Hz 16-bit audio into FFmpeg to convert it to It pipes the raw 8000Hz 16-bit audio into FFmpeg to convert it to
Discord's required 48000Hz stereo Opus format. Discord's required 48000Hz stereo Opus format.
""" """
def __init__(self, buffer: deque): def __init__(self, buffer: deque):
self.buffer = buffer self.buffer = buffer
# We start an FFmpeg process that reads from stdin (pipe) and outputs raw PCM (s16le) at 48k # We start an FFmpeg process that reads from stdin (pipe) and outputs raw PCM (s16le) at 48k
# discord.py then encodes this to Opus.
# Note: We use a pipe here because we are feeding it chunks from our UDP buffer.
self.ffmpeg_process = subprocess.Popen( self.ffmpeg_process = subprocess.Popen(
shlex.split( shlex.split(
"ffmpeg -f s16le -ar 8000 -ac 1 -i pipe:0 -f s16le -ar 48000 -ac 2 pipe:1 -loglevel panic" "ffmpeg -f s16le -ar 8000 -ac 1 -i pipe:0 -f s16le -ar 48000 -ac 2 pipe:1 -loglevel panic"
@@ -80,19 +75,25 @@ class DiscordRadioBot(discord.Client):
- Packet Forwarding (to keep Liquidsoap/Icecast working) - Packet Forwarding (to keep Liquidsoap/Icecast working)
- Manual Channel Move detection - Manual Channel Move detection
- Dynamic Token/Channel joining - Dynamic Token/Channel joining
- Debug Stats logging
""" """
def __init__(self, listen_port=23456, forward_ports: List[int] = None): def __init__(self, listen_port=23456, forward_ports: List[int] = None):
intents = discord.Intents.default() intents = discord.Intents.default()
super().__init__(intents=intents) super().__init__(intents=intents)
self.listen_port = listen_port self.listen_port = listen_port
self.forward_ports = forward_ports or [] # List of ports to forward UDP packets to (e.g. [23457]) self.forward_ports = forward_ports or []
self.audio_buffer = deque(maxlen=160000) # Buffer ~10 seconds of audio self.audio_buffer = deque(maxlen=160000) # Buffer ~10 seconds of audio
self.udp_sock = None self.udp_sock = None
self.running = False self.running = False
self.voice_client: Optional[discord.VoiceClient] = None self.voice_client: Optional[discord.VoiceClient] = None
# Debug Stats
self.packets_received = 0
self.packets_forwarded = 0
self.last_log_time = time.time()
# Start the UDP Listener in a separate daemon thread immediately # Start the UDP Listener in a separate daemon thread immediately
self.udp_thread = threading.Thread(target=self._udp_listener_loop, daemon=True) self.udp_thread = threading.Thread(target=self._udp_listener_loop, daemon=True)
self.udp_thread.start() self.udp_thread.start()
@@ -100,15 +101,13 @@ class DiscordRadioBot(discord.Client):
def _udp_listener_loop(self): def _udp_listener_loop(self):
""" """
Runs in a background thread. Listens for UDP packets from OP25. Runs in a background thread. Listens for UDP packets from OP25.
1. Adds data to internal audio buffer for Discord.
2. Forwards data to other local ports (for Liquidsoap).
""" """
self.udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.udp_sock.bind(('127.0.0.1', self.listen_port)) # Bind to 0.0.0.0 to ensure we catch traffic from any interface (Docker/Localhost)
self.udp_sock.bind(('0.0.0.0', self.listen_port))
self.udp_sock.settimeout(1.0) self.udp_sock.settimeout(1.0)
LOGGER.info(f"UDP Audio Bridge listening on 127.0.0.1:{self.listen_port}") LOGGER.info(f"UDP Audio Bridge listening on 0.0.0.0:{self.listen_port}")
if self.forward_ports:
LOGGER.info(f"Forwarding audio to: {self.forward_ports}") LOGGER.info(f"Forwarding audio to: {self.forward_ports}")
forward_socks = [] forward_socks = []
@@ -120,56 +119,60 @@ class DiscordRadioBot(discord.Client):
while self.running: while self.running:
try: try:
data, addr = self.udp_sock.recvfrom(4096) data, addr = self.udp_sock.recvfrom(4096)
self.packets_received += 1
# 1. Forward to Liquidsoap/Other tools # 1. Forward to Liquidsoap/Other tools
for sock, port in forward_socks: for sock, port in forward_socks:
# Send to localhost (where Liquidsoap should be listening)
sock.sendto(data, ('127.0.0.1', port)) sock.sendto(data, ('127.0.0.1', port))
self.packets_forwarded += 1
# 2. Add to Discord Buffer # 2. Add to Discord Buffer
# We extend the deque with the raw bytes
self.audio_buffer.extend(data) self.audio_buffer.extend(data)
# Periodic Debug Logging (Every 5 seconds, only if active)
if time.time() - self.last_log_time > 5:
if self.packets_received > 0:
LOGGER.info(f"UDP Stats [5s]: Rx {self.packets_received} pkts | Tx {self.packets_forwarded} pkts | Buffer: {len(self.audio_buffer)} bytes")
# Reset counters
self.packets_received = 0
self.packets_forwarded = 0
self.last_log_time = time.time()
except socket.timeout: except socket.timeout:
continue continue
except Exception as e: except Exception as e:
LOGGER.error(f"UDP Listener Error: {e}") LOGGER.error(f"UDP Listener Error: {e}")
# Prevent tight loop on socket fail time.sleep(0.1)
asyncio.sleep(0.1)
async def on_ready(self): async def on_ready(self):
LOGGER.info(f'Discord Radio Bot connected as {self.user}') LOGGER.info(f'Discord Radio Bot connected as {self.user}')
async def on_voice_state_update(self, member, before, after): async def on_voice_state_update(self, member, before, after):
""" """
Handles manual moves. If the bot is moved to a new channel by a user, Handles manual moves.
update our internal voice_client reference to ensure we keep streaming.
""" """
if member.id == self.user.id: if member.id == self.user.id:
if after.channel is not None and before.channel != after.channel: if after.channel is not None and before.channel != after.channel:
LOGGER.info(f"Bot was moved to channel: {after.channel.name}") LOGGER.info(f"Bot was moved to channel: {after.channel.name}")
# discord.py handles the connection handoff automatically,
# but we log it to confirm persistence.
self.voice_client = member.guild.voice_client self.voice_client = member.guild.voice_client
async def start_session(self, token: str, channel_id: int): async def start_session(self, token: str, channel_id: int):
""" """
Connects the bot to Discord and joins the specified channel. Connects the bot to Discord and joins the specified channel.
This should be called when the 'start' command is received from C2.
""" """
# Start the client in the background
asyncio.create_task(self.start(token)) asyncio.create_task(self.start(token))
# Wait for login to complete (simple poll) # Wait for login to complete
for _ in range(20): for _ in range(20):
if self.is_ready(): if self.is_ready():
break break
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
# Join Channel
try: try:
channel = self.get_channel(int(channel_id)) channel = self.get_channel(int(channel_id))
if not channel: if not channel:
# If cache not ready, try fetching
channel = await self.fetch_channel(int(channel_id)) channel = await self.fetch_channel(int(channel_id))
if channel: if channel: