Added Soundcloud and Bandcamp search. Local file search is available now.

This commit is contained in:
2025-12-17 17:22:41 +00:00
parent 5812899b76
commit 6cc9500153

View File

@@ -1,11 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""
Main Application Entry Point.
This module initializes the XMPP bot, loads configuration, manages plugins
(OMEMO, MUC, Jingle), and orchestrates the interaction between the audio
streamer, playlist manager, and XMPP interface.
"""
import os import os
import sys import sys
@@ -59,9 +52,7 @@ NS_JINGLE = "urn:xmpp:jingle:1"
NS_JINGLE_MSG = "urn:xmpp:jingle-message:0" NS_JINGLE_MSG = "urn:xmpp:jingle-message:0"
NS_HINTS = "urn:xmpp:hints" NS_HINTS = "urn:xmpp:hints"
class LRUCache(OrderedDict): class LRUCache(OrderedDict):
"""Simple LRU Cache implementation for storing search results."""
def __init__(self, maxsize=25): def __init__(self, maxsize=25):
super().__init__() super().__init__()
self.maxsize = maxsize self.maxsize = maxsize
@@ -85,10 +76,8 @@ class LRUCache(OrderedDict):
oldest = next(iter(self)) oldest = next(iter(self))
del self[oldest] del self[oldest]
if OMEMO_AVAILABLE: if OMEMO_AVAILABLE:
class OMEMOStorageImpl(Storage): class OMEMOStorageImpl(Storage):
"""File-based storage backend for OMEMO keys."""
def __init__(self, json_file_path: Path) -> None: def __init__(self, json_file_path: Path) -> None:
super().__init__() super().__init__()
@@ -117,7 +106,6 @@ if OMEMO_AVAILABLE:
class XEP_0384Impl(XEP_0384): class XEP_0384Impl(XEP_0384):
"""OMEMO Plugin Configuration."""
default_config = { default_config = {
"fallback_message": "This message is OMEMO encrypted.", "fallback_message": "This message is OMEMO encrypted.",
@@ -164,10 +152,8 @@ if OMEMO_AVAILABLE:
register_plugin(XEP_0384Impl) register_plugin(XEP_0384Impl)
@dataclass(slots=True) @dataclass(slots=True)
class MessageContext: class MessageContext:
"""Encapsulates context about an incoming message for command handling."""
sender: JID sender: JID
sender_bare: str sender_bare: str
mtype: str mtype: str
@@ -176,7 +162,6 @@ class MessageContext:
room_jid: Optional[str] = None room_jid: Optional[str] = None
nick: Optional[str] = None nick: Optional[str] = None
@dataclass @dataclass
class BotConfig: class BotConfig:
jid: str jid: str
@@ -210,7 +195,6 @@ class BotConfig:
omemo_enabled: bool omemo_enabled: bool
omemo_store_path: str omemo_store_path: str
def load_config(config_file: str = "config.ini") -> BotConfig: def load_config(config_file: str = "config.ini") -> BotConfig:
if not os.path.exists(config_file): if not os.path.exists(config_file):
raise FileNotFoundError(f"Config file not found: {config_file}") raise FileNotFoundError(f"Config file not found: {config_file}")
@@ -256,18 +240,7 @@ def load_config(config_file: str = "config.ini") -> BotConfig:
omemo_store_path=c.get('OMEMO', 'store_path', fallback='radio_omemo_store.json') omemo_store_path=c.get('OMEMO', 'store_path', fallback='radio_omemo_store.json')
) )
class RadioBot(slixmpp.ClientXMPP): class RadioBot(slixmpp.ClientXMPP):
"""
Core XMPP Bot logic.
Handles:
- Connection and Authentication
- OMEMO and Plaintext message routing
- Command processing
- Integration with PlaylistManager, AudioStreamer, and JingleWebRTCHandler
- Task management and memory monitoring
"""
MEMORY_WARNING_THRESHOLD = 400 MEMORY_WARNING_THRESHOLD = 400
MEMORY_CRITICAL_THRESHOLD = 600 MEMORY_CRITICAL_THRESHOLD = 600
@@ -303,9 +276,9 @@ class RadioBot(slixmpp.ClientXMPP):
send_transport_info_callback=self._send_transport_info, send_transport_info_callback=self._send_transport_info,
on_track_end=self.on_track_end on_track_end=self.on_track_end
) )
logger.info("Jingle enabled with WebRTC") logger.info("Jingle enabled with WebRTC")
elif config.jingle_enabled: elif config.jingle_enabled:
logger.warning("Jingle enabled but 'aiortc' missing") logger.warning("⚠️ Jingle enabled but 'aiortc' missing")
self.current_station = None self.current_station = None
self.is_playing_station = False self.is_playing_station = False
@@ -316,19 +289,17 @@ class RadioBot(slixmpp.ClientXMPP):
self._is_transitioning = False self._is_transitioning = False
# Rapid skip detection variables
self._skip_count = 0 self._skip_count = 0
self._skip_window_start = 0 self._skip_window_start = 0
# Task management set to allow proper cleanup on shutdown
self._managed_tasks: Set[asyncio.Task] = set() self._managed_tasks: Set[asyncio.Task] = set()
self._gc_task: Optional[asyncio.Task] = None self._gc_task: Optional[asyncio.Task] = None
self._memory_monitor_task: Optional[asyncio.Task] = None self._memory_monitor_task: Optional[asyncio.Task] = None
self.register_plugin('xep_0030') # Service Discovery self.register_plugin('xep_0030')
self.register_plugin('xep_0045') # MUC self.register_plugin('xep_0045')
self.register_plugin('xep_0115') # Entity Capabilities self.register_plugin('xep_0115')
self.register_plugin('xep_0199') # Ping self.register_plugin('xep_0199')
if self.omemo_active: if self.omemo_active:
self.register_plugin('xep_0085') self.register_plugin('xep_0085')
@@ -339,9 +310,9 @@ class RadioBot(slixmpp.ClientXMPP):
{"json_file_path": config.omemo_store_path}, {"json_file_path": config.omemo_store_path},
module=sys.modules[__name__] module=sys.modules[__name__]
) )
logger.info("OMEMO encryption enabled") logger.info("🔒 OMEMO encryption enabled")
elif config.omemo_enabled: elif config.omemo_enabled:
logger.warning("OMEMO enabled but libraries missing") logger.warning("⚠️ OMEMO enabled but libraries missing")
if self.jingle: if self.jingle:
for f in [NS_JINGLE, NS_JINGLE_MSG, for f in [NS_JINGLE, NS_JINGLE_MSG,
@@ -366,7 +337,6 @@ class RadioBot(slixmpp.ClientXMPP):
self.add_event_handler("message", self._handle_plain_message) self.add_event_handler("message", self._handle_plain_message)
self.add_event_handler("groupchat_message", self._handle_plain_muc_message) self.add_event_handler("groupchat_message", self._handle_plain_muc_message)
# Jingle Message Initiation (XEP-0353)
self.register_handler(Callback( self.register_handler(Callback(
'JinglePropose', 'JinglePropose',
MatchXPath(f'{{jabber:client}}message/{{{NS_JINGLE_MSG}}}propose'), MatchXPath(f'{{jabber:client}}message/{{{NS_JINGLE_MSG}}}propose'),
@@ -379,7 +349,6 @@ class RadioBot(slixmpp.ClientXMPP):
self._handle_jingle_retract self._handle_jingle_retract
)) ))
# Standard Jingle IQs
self.register_handler(Callback( self.register_handler(Callback(
'JingleIQ', 'JingleIQ',
MatchXPath(f'{{jabber:client}}iq/{{{NS_JINGLE}}}jingle'), MatchXPath(f'{{jabber:client}}iq/{{{NS_JINGLE}}}jingle'),
@@ -401,6 +370,10 @@ class RadioBot(slixmpp.ClientXMPP):
'list': self.cmd_list, 'list': self.cmd_list,
'shuffle': self.cmd_shuffle, 'shuffle': self.cmd_shuffle,
'search': self.cmd_search, 'search': self.cmd_search,
'sc': self.cmd_sc,
'bc': self.cmd_bc,
'lib': self.cmd_library,
'local': self.cmd_library,
'dl': self.cmd_dl, 'dl': self.cmd_dl,
'scan': self.cmd_scan, 'scan': self.cmd_scan,
'url': self.cmd_url, 'url': self.cmd_url,
@@ -418,41 +391,38 @@ class RadioBot(slixmpp.ClientXMPP):
} }
def _create_task(self, coro) -> asyncio.Task: def _create_task(self, coro) -> asyncio.Task:
"""Helper to create asyncio tasks and track them for cleanup."""
task = asyncio.create_task(coro) task = asyncio.create_task(coro)
self._managed_tasks.add(task) self._managed_tasks.add(task)
task.add_done_callback(lambda t: self._managed_tasks.discard(t)) task.add_done_callback(lambda t: self._managed_tasks.discard(t))
return task return task
def _cleanup_done_tasks(self): def _cleanup_done_tasks(self):
"""Removes completed tasks from the tracking set."""
done_tasks = {t for t in self._managed_tasks if t.done()} done_tasks = {t for t in self._managed_tasks if t.done()}
self._managed_tasks -= done_tasks self._managed_tasks -= done_tasks
return len(done_tasks) return len(done_tasks)
async def on_start(self, event): async def on_start(self, event):
"""Called when XMPP session is established.""" logger.info("🚀 Bot Session Started")
logger.info("Bot Session Started")
self.send_presence() self.send_presence()
await self.get_roster() await self.get_roster()
if self.streamer: if self.streamer:
await self.streamer.start() await self.streamer.start()
logger.info(f"HTTP Stream: {self.streamer.stream_url}") logger.info(f"📡 HTTP Stream: {self.streamer.stream_url}")
if self.jingle: if self.jingle:
await self.jingle.start_cleanup_task() await self.jingle.start_cleanup_task()
count = await self.playlist_manager.scan_library() count = await self.playlist_manager.scan_library()
self.playlist_manager.add_library_to_playlist() self.playlist_manager.add_library_to_playlist()
logger.info(f"Library loaded: {count} tracks") logger.info(f"🎵 Library loaded: {count} tracks")
for room, nick in [(self.config.radio_room, self.config.radio_room_nick), for room, nick in [(self.config.radio_room, self.config.radio_room_nick),
(self.config.management_room, self.config.management_room_nick)]: (self.config.management_room, self.config.management_room_nick)]:
if room: if room:
try: try:
await self.plugin['xep_0045'].join_muc(room, nick) await self.plugin['xep_0045'].join_muc(room, nick)
logger.info(f"Joined MUC: {room}") logger.info(f"📢 Joined MUC: {room}")
except Exception as e: except Exception as e:
logger.error(f"MUC Join Failed ({room}): {e}") logger.error(f"MUC Join Failed ({room}): {e}")
@@ -465,26 +435,20 @@ class RadioBot(slixmpp.ClientXMPP):
self._memory_monitor_task = asyncio.create_task(self._memory_monitor()) self._memory_monitor_task = asyncio.create_task(self._memory_monitor())
async def _periodic_gc(self): async def _periodic_gc(self):
"""Aggressive garbage collection to handle heavy media object churn."""
while True: while True:
await asyncio.sleep(120) await asyncio.sleep(120)
try: try:
self._search_results.clear_old(keep=10) self._search_results.clear_old(keep=10)
cleaned = self._cleanup_done_tasks() cleaned = self._cleanup_done_tasks()
gc.collect(generation=0) gc.collect(generation=0)
gc.collect(generation=1) gc.collect(generation=1)
gc.collect(generation=2) gc.collect(generation=2)
logger.debug(f"🧹 GC completed, cleaned {cleaned} tasks, remaining: {len(self._managed_tasks)}")
logger.debug(f"GC completed, cleaned {cleaned} tasks, remaining: {len(self._managed_tasks)}")
except Exception as e: except Exception as e:
logger.error(f"GC error: {e}") logger.error(f"GC error: {e}")
async def _memory_monitor(self): async def _memory_monitor(self):
"""Monitors RSS memory usage and triggers emergency cleanup if critical."""
try: try:
import resource import resource
has_resource = True has_resource = True
@@ -498,32 +462,26 @@ class RadioBot(slixmpp.ClientXMPP):
try: try:
if has_resource: if has_resource:
usage = resource.getrusage(resource.RUSAGE_SELF) usage = resource.getrusage(resource.RUSAGE_SELF)
# Convert to MB (Linux returns KB, macOS returns bytes)
mem_mb = usage.ru_maxrss / 1024 mem_mb = usage.ru_maxrss / 1024
if sys.platform == 'darwin': if sys.platform == 'darwin':
mem_mb = usage.ru_maxrss / (1024 * 1024) mem_mb = usage.ru_maxrss / (1024 * 1024)
logger.info(f"Memory usage: {mem_mb:.1f} MB, Tasks: {len(self._managed_tasks)}") logger.info(f"📊 Memory usage: {mem_mb:.1f} MB, Tasks: {len(self._managed_tasks)}")
if mem_mb > self.MEMORY_WARNING_THRESHOLD: if mem_mb > self.MEMORY_WARNING_THRESHOLD:
logger.warning(f"High memory usage ({mem_mb:.1f} MB)") logger.warning(f"⚠️ High memory usage ({mem_mb:.1f} MB)")
self._search_results.clear() self._search_results.clear()
self._cleanup_done_tasks() self._cleanup_done_tasks()
gc.collect() gc.collect()
gc.collect() gc.collect()
gc.collect() gc.collect()
if mem_mb > self.MEMORY_CRITICAL_THRESHOLD: if mem_mb > self.MEMORY_CRITICAL_THRESHOLD:
logger.error(f"Critical memory usage ({mem_mb:.1f} MB), aggressive cleanup") logger.error(f"🚨 Critical memory usage ({mem_mb:.1f} MB), aggressive cleanup")
# Terminate oldest calls to save memory
if self.jingle and len(self.jingle.sessions) > 5: if self.jingle and len(self.jingle.sessions) > 5:
oldest_sessions = list(self.jingle.sessions.keys())[:-5] oldest_sessions = list(self.jingle.sessions.keys())[:-5]
for sid in oldest_sessions: for sid in oldest_sessions:
await self.jingle.stop_session(sid) await self.jingle.stop_session(sid)
gc.collect() gc.collect()
except Exception as e: except Exception as e:
@@ -541,7 +499,6 @@ class RadioBot(slixmpp.ClientXMPP):
return 0.0 return 0.0
async def reply(self, ctx: MessageContext, text: str) -> None: async def reply(self, ctx: MessageContext, text: str) -> None:
"""Sends a reply matching the context (MUC vs Direct, OMEMO vs Plain)."""
if ctx.is_muc: if ctx.is_muc:
msg = self.make_message(mto=ctx.room_jid, mtype='groupchat') msg = self.make_message(mto=ctx.room_jid, mtype='groupchat')
msg["body"] = text msg["body"] = text
@@ -578,7 +535,6 @@ class RadioBot(slixmpp.ClientXMPP):
await self._send_plain(mto, mtype, text) await self._send_plain(mto, mtype, text)
async def _handle_direct_message(self, stanza: Message) -> None: async def _handle_direct_message(self, stanza: Message) -> None:
"""Handles OMEMO-aware direct messages."""
try: try:
mfrom = stanza["from"] mfrom = stanza["from"]
mtype = stanza["type"] mtype = stanza["type"]
@@ -589,7 +545,6 @@ class RadioBot(slixmpp.ClientXMPP):
if mfrom.bare == self.boundjid.bare: if mfrom.bare == self.boundjid.bare:
return return
# Ignore Jingle signal messages treated as chat
if stanza.xml.find(f'{{{NS_JINGLE_MSG}}}propose') is not None: if stanza.xml.find(f'{{{NS_JINGLE_MSG}}}propose') is not None:
return return
if stanza.xml.find(f'{{{NS_JINGLE_MSG}}}proceed') is not None: if stanza.xml.find(f'{{{NS_JINGLE_MSG}}}proceed') is not None:
@@ -615,7 +570,7 @@ class RadioBot(slixmpp.ClientXMPP):
sender=mfrom, sender_bare=str(mfrom.bare), sender=mfrom, sender_bare=str(mfrom.bare),
mtype=mtype, is_encrypted=True, is_muc=False mtype=mtype, is_encrypted=True, is_muc=False
) )
await self.reply(ctx, f"Could not decrypt: {e}") await self.reply(ctx, f"Could not decrypt: {e}")
return return
else: else:
if stanza["body"]: if stanza["body"]:
@@ -709,17 +664,16 @@ class RadioBot(slixmpp.ClientXMPP):
if body.startswith('!'): if body.startswith('!'):
await self._run_command(ctx, body[1:]) await self._run_command(ctx, body[1:])
elif body.lower() in ('radio', 'help'): elif body.lower() in ('radio', 'help'):
await self.reply(ctx, "XMPP Radio Bot\nType !help for commands.\nCall me to listen!") await self.reply(ctx, "📻 XMPP Radio Bot\nType !help for commands.\nCall me to listen!")
async def _run_command(self, ctx: MessageContext, cmdline: str): async def _run_command(self, ctx: MessageContext, cmdline: str):
"""Parses and executes bot commands."""
parts = cmdline.split(None, 1) parts = cmdline.split(None, 1)
cmd = parts[0].lower() cmd = parts[0].lower()
arg = parts[1] if len(parts) > 1 else "" arg = parts[1] if len(parts) > 1 else ""
needs_auth = {'stop', 'next', 'station', 'dl', 'scan', 'hangup', 'clearq'} needs_auth = {'stop', 'next', 'station', 'dl', 'scan', 'hangup', 'clearq'}
if cmd in needs_auth and not self._check_access(ctx.sender_bare): if cmd in needs_auth and not self._check_access(ctx.sender_bare):
await self.reply(ctx, "Permission Denied") await self.reply(ctx, "Permission Denied")
return return
if cmd in self.commands: if cmd in self.commands:
@@ -727,10 +681,9 @@ class RadioBot(slixmpp.ClientXMPP):
await self.commands[cmd](ctx, arg) await self.commands[cmd](ctx, arg)
except Exception as e: except Exception as e:
logger.error(f"Command error: {e}", exc_info=True) logger.error(f"Command error: {e}", exc_info=True)
await self.reply(ctx, f"Error: {e}") await self.reply(ctx, f"Error: {e}")
async def _send_transport_info(self, session: JingleSession, candidate: Dict): async def _send_transport_info(self, session: JingleSession, candidate: Dict):
"""Sends an ICE candidate to the peer via Jingle transport-info."""
iq = self.make_iq_set(ito=session.peer_jid) iq = self.make_iq_set(ito=session.peer_jid)
jingle = ET.Element('jingle', { jingle = ET.Element('jingle', {
@@ -769,17 +722,16 @@ class RadioBot(slixmpp.ClientXMPP):
try: try:
iq.send() iq.send()
logger.debug(f"Sent transport-info: {candidate.get('type')} {candidate.get('ip')}") logger.debug(f"📤 Sent transport-info: {candidate.get('type')} {candidate.get('ip')}")
except Exception as e: except Exception as e:
logger.error(f"Failed to send transport-info: {e}") logger.error(f"Failed to send transport-info: {e}")
def _handle_jingle_propose(self, msg): def _handle_jingle_propose(self, msg):
"""Handles incoming XEP-0353 Jingle Message initiation."""
sender = str(msg['from']) sender = str(msg['from'])
propose = msg.xml.find(f'{{{NS_JINGLE_MSG}}}propose') propose = msg.xml.find(f'{{{NS_JINGLE_MSG}}}propose')
sid = propose.get('id') sid = propose.get('id')
logger.info(f"Call from {sender}, ID={sid}") logger.info(f"📞 Call from {sender}, ID={sid}")
self._send_proceed(sender, sid) self._send_proceed(sender, sid)
if self.jingle: if self.jingle:
@@ -788,7 +740,7 @@ class RadioBot(slixmpp.ClientXMPP):
def _handle_jingle_retract(self, msg): def _handle_jingle_retract(self, msg):
retract = msg.xml.find(f'{{{NS_JINGLE_MSG}}}retract') retract = msg.xml.find(f'{{{NS_JINGLE_MSG}}}retract')
sid = retract.get('id') sid = retract.get('id')
logger.info(f"Call Retracted: {sid}") logger.info(f"🔴 Call Retracted: {sid}")
if self.jingle: if self.jingle:
self._create_task(self.jingle.stop_session(sid)) self._create_task(self.jingle.stop_session(sid))
@@ -798,7 +750,7 @@ class RadioBot(slixmpp.ClientXMPP):
msg.xml.append(proceed) msg.xml.append(proceed)
msg.xml.append(ET.Element(f'{{{NS_HINTS}}}store')) msg.xml.append(ET.Element(f'{{{NS_HINTS}}}store'))
msg.send() msg.send()
logger.info(f"Sent PROCEED to {to_jid}") logger.info(f"📤 Sent PROCEED to {to_jid}")
def _handle_jingle_iq(self, iq): def _handle_jingle_iq(self, iq):
if not self.jingle: if not self.jingle:
@@ -815,7 +767,7 @@ class RadioBot(slixmpp.ClientXMPP):
sid = jingle.get('sid') sid = jingle.get('sid')
peer = str(iq['from']) peer = str(iq['from'])
logger.info(f"Jingle IQ: {action} (sid={sid})") logger.info(f"📨 Jingle IQ: {action} (sid={sid})")
try: try:
if action == 'session-initiate': if action == 'session-initiate':
@@ -830,7 +782,7 @@ class RadioBot(slixmpp.ClientXMPP):
accept_iq = self.make_iq_set(ito=peer) accept_iq = self.make_iq_set(ito=peer)
accept_iq.xml.append(ET.fromstring(accept_xml)) accept_iq.xml.append(ET.fromstring(accept_xml))
accept_iq.send() accept_iq.send()
logger.info("Sent session-accept") logger.info("Sent session-accept")
elif action == 'transport-info': elif action == 'transport-info':
session = self.jingle.get_session(sid) session = self.jingle.get_session(sid)
@@ -843,7 +795,7 @@ class RadioBot(slixmpp.ClientXMPP):
iq.reply().send() iq.reply().send()
elif action == 'session-terminate': elif action == 'session-terminate':
logger.info(f"Peer hung up: {sid}") logger.info(f"🔴 Peer hung up: {sid}")
await self.jingle.stop_session(sid) await self.jingle.stop_session(sid)
iq.reply().send() iq.reply().send()
@@ -866,12 +818,11 @@ class RadioBot(slixmpp.ClientXMPP):
return jid in self.config.allowed_jids return jid in self.config.allowed_jids
async def _play_track(self, track: Track): async def _play_track(self, track: Track):
"""Starts playback for both HTTP and Jingle subsystems."""
async with self._track_transition_lock: async with self._track_transition_lock:
self.is_playing_station = False self.is_playing_station = False
self.current_station = None self.current_station = None
logger.info(f"Playing track: {track.display_name}") logger.info(f"🎵 Playing track: {track.display_name}")
try: try:
if self.streamer: if self.streamer:
@@ -882,7 +833,6 @@ class RadioBot(slixmpp.ClientXMPP):
) )
if self.jingle: if self.jingle:
# Slight delay ensures files handle gets released if necessary
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
self.jingle.set_audio_source(track.path, force_restart=True) self.jingle.set_audio_source(track.path, force_restart=True)
@@ -892,17 +842,15 @@ class RadioBot(slixmpp.ClientXMPP):
gc.collect(generation=0) gc.collect(generation=0)
async def on_track_end(self): async def on_track_end(self):
"""Callback for when a track finishes. Handles auto-advance logic."""
current_time = time.time() current_time = time.time()
# Detect rapid skipping loop (usually corrupt files)
if current_time - self._skip_window_start > 10.0: if current_time - self._skip_window_start > 10.0:
self._skip_count = 0 self._skip_count = 0
self._skip_window_start = current_time self._skip_window_start = current_time
self._skip_count += 1 self._skip_count += 1
if self._skip_count > 5: if self._skip_count > 5:
logger.error("Detected rapid skipping loop. Stopping playback.") logger.error("🚨 Detected rapid skipping loop. Stopping playback.")
await self.cmd_stop(None, "force") await self.cmd_stop(None, "force")
return return
@@ -922,13 +870,13 @@ class RadioBot(slixmpp.ClientXMPP):
logger.debug("Playing station, not auto-transitioning") logger.debug("Playing station, not auto-transitioning")
return return
logger.info("Track ended, getting next track...") logger.info("🔄 Track ended, getting next track...")
t = self.playlist_manager.next_track() t = self.playlist_manager.next_track()
if t: if t:
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
await self._play_track(t) await self._play_track(t)
logger.info(f"Auto-playing: {t.display_name}") logger.info(f"▶️ Auto-playing: {t.display_name}")
else: else:
logger.info("No more tracks in queue/playlist") logger.info("No more tracks in queue/playlist")
@@ -946,7 +894,7 @@ class RadioBot(slixmpp.ClientXMPP):
def _get_np_text(self): def _get_np_text(self):
if self.is_playing_station: if self.is_playing_station:
return f"Station: {self.current_station}" return f"📻 {self.current_station}"
t = self.playlist_manager.get_current_track() t = self.playlist_manager.get_current_track()
if t: if t:
@@ -959,13 +907,13 @@ class RadioBot(slixmpp.ClientXMPP):
async def cmd_help(self, ctx: MessageContext, arg: str): async def cmd_help(self, ctx: MessageContext, arg: str):
help_text = ( help_text = (
"Radio Bot Commands:\n\n" "🎵 **Radio Bot Commands:**\n\n"
"Playback:\n" "**Playback:**\n"
"!play [query] - Play/Search music\n" "!play [query] - Play/Search music\n"
"!stop / !next / !prev - Control\n" "!stop / !next / !prev - Control\n"
"!np - Now playing\n" "!np - Now playing\n"
"!shuffle - Shuffle playlist\n\n" "!shuffle - Shuffle playlist\n\n"
"Queue:\n" "**Queue:**\n"
"!queue (!q) - View queue\n" "!queue (!q) - View queue\n"
"!addq <query> - Add to queue\n" "!addq <query> - Add to queue\n"
"!playnext <query> - Play next\n" "!playnext <query> - Play next\n"
@@ -973,21 +921,24 @@ class RadioBot(slixmpp.ClientXMPP):
"!removeq <#> - Remove from queue\n" "!removeq <#> - Remove from queue\n"
"!shuffleq - Shuffle queue\n" "!shuffleq - Shuffle queue\n"
"!history - Recent tracks\n\n" "!history - Recent tracks\n\n"
"Download:\n" "**Search & Download:**\n"
"!search <query> - Search YouTube\n" "!search <query> - Search YouTube\n"
"!sc <query> - Search SoundCloud\n"
"!bc <query> - Search Bandcamp\n"
"!lib <query> - Search Local Library\n"
"!dl <url|query|#num> - Download\n\n" "!dl <url|query|#num> - Download\n\n"
"Radio:\n" "**Radio:**\n"
"!station <name> - Play station\n" "!station <name> - Play station\n"
"!stations - List stations\n" "!stations - List stations\n"
"!list [page] - Show playlist\n" "!list [page] - Show playlist\n"
"!scan - Rescan library\n" "!scan - Rescan library\n"
"!callers - Active calls\n\n" "!callers - Active calls\n\n"
"Unnecessary commands DO NOT USE:\n" "**Unnecessary commands DO NOT USE:**\n"
"!url - Returns a local URL so nobody needs it\n" "!url - Returns a local URL so nobody needs it\n"
"!mem - Shows memory usage but it crashes anyways\n" "!mem - Shows memory usage but it crashes anyways\n"
"!hangup - Just use your clients disconnect button\n\n" "!hangup - Just use your clients disconnect button\n\n"
"Version Info and About:\n" "**Version Info and About:**\n"
"XMPP Radio Bot v0.1 (codename: Absolute Solver)" "XMPP Radio Bot v0.2 (codename: Branded Pens)"
) )
await self.reply(ctx, help_text) await self.reply(ctx, help_text)
@@ -996,14 +947,14 @@ class RadioBot(slixmpp.ClientXMPP):
res = self.playlist_manager.search_library(arg) res = self.playlist_manager.search_library(arg)
if res: if res:
await self._play_track(res[0]) await self._play_track(res[0])
await self.reply(ctx, f"Playing: {res[0].display_name}") await self.reply(ctx, f"▶️ {res[0].display_name}")
else: else:
await self.reply(ctx, "Not found in library") await self.reply(ctx, "Not found in library")
else: else:
t = self.playlist_manager.next_track() t = self.playlist_manager.next_track()
if t: if t:
await self._play_track(t) await self._play_track(t)
await self.reply(ctx, f"Playing: {t.display_name}") await self.reply(ctx, f"▶️ {t.display_name}")
async def cmd_stop(self, ctx: MessageContext, arg: str): async def cmd_stop(self, ctx: MessageContext, arg: str):
if self.streamer: if self.streamer:
@@ -1016,34 +967,34 @@ class RadioBot(slixmpp.ClientXMPP):
self.is_playing_station = False self.is_playing_station = False
self.current_station = None self.current_station = None
await self.reply(ctx, "Stopped") await self.reply(ctx, "⏹️ Stopped")
async def cmd_next(self, ctx: MessageContext, arg: str): async def cmd_next(self, ctx: MessageContext, arg: str):
t = self.playlist_manager.next_track() t = self.playlist_manager.next_track()
if t: if t:
await self._play_track(t) await self._play_track(t)
await self.reply(ctx, f"Next: {t.display_name}") await self.reply(ctx, f"⏭️ {t.display_name}")
else: else:
await self.reply(ctx, "No next track") await self.reply(ctx, "No next track")
async def cmd_prev(self, ctx: MessageContext, arg: str): async def cmd_prev(self, ctx: MessageContext, arg: str):
t = self.playlist_manager.previous_track() t = self.playlist_manager.previous_track()
if t: if t:
await self._play_track(t) await self._play_track(t)
await self.reply(ctx, f"Previous: {t.display_name}") await self.reply(ctx, f"⏮️ {t.display_name}")
else: else:
await self.reply(ctx, "No previous track") await self.reply(ctx, "No previous track")
async def cmd_np(self, ctx: MessageContext, arg: str): async def cmd_np(self, ctx: MessageContext, arg: str):
np_text = self._get_np_text() np_text = self._get_np_text()
queue_size = self.playlist_manager.queue.size queue_size = self.playlist_manager.queue.size
queue_info = f"\nQueue: {queue_size} tracks" if queue_size > 0 else "" queue_info = f"\n📋 Queue: {queue_size} tracks" if queue_size > 0 else ""
await self.reply(ctx, f"Now Playing: {np_text}{queue_info}") await self.reply(ctx, f"🎵 Now Playing: {np_text}{queue_info}")
async def cmd_station(self, ctx: MessageContext, arg: str): async def cmd_station(self, ctx: MessageContext, arg: str):
if not arg or arg not in self.config.stations: if not arg or arg not in self.config.stations:
stations = ', '.join(self.config.stations.keys()) or "None configured" stations = ', '.join(self.config.stations.keys()) or "None configured"
await self.reply(ctx, f"Stations: {stations}") await self.reply(ctx, f"📻 Stations: {stations}")
return return
url, _ = await self.station_parser.resolve_stream_url(self.config.stations[arg]) url, _ = await self.station_parser.resolve_stream_url(self.config.stations[arg])
@@ -1060,16 +1011,16 @@ class RadioBot(slixmpp.ClientXMPP):
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
self.jingle.set_audio_source(url, force_restart=True) self.jingle.set_audio_source(url, force_restart=True)
await self.reply(ctx, f"Playing: {arg}") await self.reply(ctx, f"📻 Playing: {arg}")
except Exception as e: except Exception as e:
logger.error(f"Error playing station: {e}") logger.error(f"Error playing station: {e}")
await self.reply(ctx, f"Failed: {e}") await self.reply(ctx, f"Failed: {e}")
else: else:
await self.reply(ctx, "Failed to connect to station") await self.reply(ctx, "Failed to connect to station")
async def cmd_stations(self, ctx: MessageContext, arg: str): async def cmd_stations(self, ctx: MessageContext, arg: str):
stations = ', '.join(self.config.stations.keys()) or "None configured" stations = ', '.join(self.config.stations.keys()) or "None configured"
await self.reply(ctx, f"Stations: {stations}") await self.reply(ctx, f"📻 Stations: {stations}")
async def cmd_list(self, ctx: MessageContext, arg: str): async def cmd_list(self, ctx: MessageContext, arg: str):
page_size = 15 page_size = 15
@@ -1086,38 +1037,59 @@ class RadioBot(slixmpp.ClientXMPP):
if tracks: if tracks:
text = "\n".join([f"{idx+1}. {track.display_name}" for idx, track in tracks]) text = "\n".join([f"{idx+1}. {track.display_name}" for idx, track in tracks])
await self.reply(ctx, f"Playlist (Page {page}):\n{text}\n\nType '!list {page+1}' for more.") await self.reply(ctx, f"📋 Playlist (Page {page}):\n{text}\n\nType '!list {page+1}' for more.")
else: else:
if page > 1: if page > 1:
await self.reply(ctx, f"No tracks on page {page}.") await self.reply(ctx, f"📋 No tracks on page {page}.")
else: else:
await self.reply(ctx, "Playlist is empty") await self.reply(ctx, "📋 Playlist is empty")
async def cmd_shuffle(self, ctx: MessageContext, arg: str): async def cmd_shuffle(self, ctx: MessageContext, arg: str):
if self.playlist_manager.current_playlist: if self.playlist_manager.current_playlist:
self.playlist_manager.current_playlist.shuffle() self.playlist_manager.current_playlist.shuffle()
await self.reply(ctx, "Playlist shuffled") await self.reply(ctx, "🔀 Playlist shuffled")
async def cmd_search(self, ctx: MessageContext, arg: str): async def _search_generic(self, ctx: MessageContext, query: str, source: str):
if not self.ytdlp: if not self.ytdlp:
await self.reply(ctx, "yt-dlp is disabled") await self.reply(ctx, "yt-dlp is disabled")
return return
if not arg: if not query:
await self.reply(ctx, "Usage: !search <query>") await self.reply(ctx, f"Usage: !{source} <query>")
return return
await self.reply(ctx, f"Searching: {arg}...") await self.reply(ctx, f"🔍 Searching {source}: {query}...")
res = await self.ytdlp.search(arg) res = await self.ytdlp.search(query, source=source)
if res: if res:
self._search_results[ctx.sender_bare] = res self._search_results[ctx.sender_bare] = res
text = "\n".join([f"{i+1}. {r.title} [{r.duration_str}]" for i, r in enumerate(res[:5])]) text = "\n".join([f"{i+1}. {r.title} [{r.duration_str}]" for i, r in enumerate(res[:10])])
await self.reply(ctx, f"Results:\n{text}\n\nUse !dl #<number> to download") await self.reply(ctx, f"🔍 Results ({source}):\n{text}\n\nUse !dl #<number> to download")
else: else:
await self.reply(ctx, "No results found") await self.reply(ctx, "No results found")
async def cmd_search(self, ctx: MessageContext, arg: str):
await self._search_generic(ctx, arg, "youtube")
async def cmd_sc(self, ctx: MessageContext, arg: str):
await self._search_generic(ctx, arg, "soundcloud")
async def cmd_bc(self, ctx: MessageContext, arg: str):
await self._search_generic(ctx, arg, "bandcamp")
async def cmd_library(self, ctx: MessageContext, arg: str):
if not arg:
await self.reply(ctx, "Usage: !lib <query>")
return
results = self.playlist_manager.search_library(arg, limit=10)
if results:
text = "\n".join([f"{i+1}. {t.display_name}" for i, t in enumerate(results)])
await self.reply(ctx, f"📚 Library Results:\n{text}\n\nUse !play <query> to play.")
else:
await self.reply(ctx, "❌ No tracks found in library")
async def cmd_dl(self, ctx: MessageContext, arg: str): async def cmd_dl(self, ctx: MessageContext, arg: str):
if not self.ytdlp: if not self.ytdlp:
await self.reply(ctx, "yt-dlp is disabled") await self.reply(ctx, "yt-dlp is disabled")
return return
if not arg: if not arg:
await self.reply(ctx, "Usage: !dl <url|query|#num>") await self.reply(ctx, "Usage: !dl <url|query|#num>")
@@ -1135,23 +1107,23 @@ class RadioBot(slixmpp.ClientXMPP):
results = self._search_results[ctx.sender_bare] results = self._search_results[ctx.sender_bare]
if 0 <= idx < len(results): if 0 <= idx < len(results):
res = results[idx] res = results[idx]
target_url = f"https://youtube.com/watch?v={res.id}" target_url = f"https://youtube.com/watch?v={res.id}" if 'youtube' in res.url else res.url
title = res.title title = res.title
except ValueError: except ValueError:
pass pass
if not target_url: if not target_url:
await self.reply(ctx, f"Searching: {arg}...") await self.reply(ctx, f"🔍 Searching: {arg}...")
res = await self.ytdlp.search(arg) res = await self.ytdlp.search(arg)
if res: if res:
target_url = f"https://youtube.com/watch?v={res[0].id}" target_url = res[0].url
title = res[0].title title = res[0].title
await self.reply(ctx, f"Found: {title}") await self.reply(ctx, f"🎯 Found: {title}")
else: else:
await self.reply(ctx, "No results found") await self.reply(ctx, "No results found")
return return
await self.reply(ctx, f"Downloading: {title}...") await self.reply(ctx, f"⬇️ Downloading: {title}...")
path = await self.ytdlp.download(target_url) path = await self.ytdlp.download(target_url)
if path: if path:
@@ -1159,38 +1131,38 @@ class RadioBot(slixmpp.ClientXMPP):
if track: if track:
self.playlist_manager.library.append(track) self.playlist_manager.library.append(track)
self.playlist_manager.add_to_playlist(track) self.playlist_manager.add_to_playlist(track)
await self.reply(ctx, f"Downloaded: {track.display_name}") await self.reply(ctx, f"Downloaded: {track.display_name}")
else: else:
await self.reply(ctx, "Downloaded file") await self.reply(ctx, "Downloaded file")
else: else:
await self.reply(ctx, "Download failed") await self.reply(ctx, "Download failed")
async def cmd_scan(self, ctx: MessageContext, arg: str): async def cmd_scan(self, ctx: MessageContext, arg: str):
c = await self.playlist_manager.scan_library() c = await self.playlist_manager.scan_library()
self.playlist_manager.add_library_to_playlist() self.playlist_manager.add_library_to_playlist()
await self.reply(ctx, f"Found {c} tracks") await self.reply(ctx, f"Found {c} tracks")
async def cmd_url(self, ctx: MessageContext, arg: str): async def cmd_url(self, ctx: MessageContext, arg: str):
if self.streamer: if self.streamer:
await self.reply(ctx, f"Stream: {self.streamer.stream_url}") await self.reply(ctx, f"🔗 Stream: {self.streamer.stream_url}")
else: else:
await self.reply(ctx, "HTTP streaming is disabled") await self.reply(ctx, "HTTP streaming is disabled")
async def cmd_callers(self, ctx: MessageContext, arg: str): async def cmd_callers(self, ctx: MessageContext, arg: str):
if self.jingle: if self.jingle:
sessions = self.jingle.get_active_sessions() sessions = self.jingle.get_active_sessions()
await self.reply(ctx, f"Active callers: {len(sessions)}") await self.reply(ctx, f"📞 Active callers: {len(sessions)}")
else: else:
await self.reply(ctx, "Jingle/calls are disabled") await self.reply(ctx, "Jingle/calls are disabled")
async def cmd_hangup(self, ctx: MessageContext, arg: str): async def cmd_hangup(self, ctx: MessageContext, arg: str):
if self.jingle: if self.jingle:
count = len(self.jingle.sessions) count = len(self.jingle.sessions)
for s in list(self.jingle.sessions.values()): for s in list(self.jingle.sessions.values()):
await self.jingle.stop_session(s.sid) await self.jingle.stop_session(s.sid)
await self.reply(ctx, f"Ended {count} call(s)") await self.reply(ctx, f"🔴 Ended {count} call(s)")
else: else:
await self.reply(ctx, "Jingle/calls are disabled") await self.reply(ctx, "Jingle/calls are disabled")
async def cmd_memory(self, ctx: MessageContext, arg: str): async def cmd_memory(self, ctx: MessageContext, arg: str):
mem_mb = self._get_memory_mb() mem_mb = self._get_memory_mb()
@@ -1198,7 +1170,11 @@ class RadioBot(slixmpp.ClientXMPP):
sessions = len(self.jingle.sessions) if self.jingle else 0 sessions = len(self.jingle.sessions) if self.jingle else 0
cache_size = len(self._search_results) cache_size = len(self._search_results)
status = f"Memory Status:\n• Memory: {mem_mb:.1f} MB\n• Managed Tasks: {tasks}\n• Jingle Sessions: {sessions}\n• Search Cache: {cache_size}" status = f"""📊 **Memory Status:**
Memory: {mem_mb:.1f} MB
Managed Tasks: {tasks}
Jingle Sessions: {sessions}
Search Cache: {cache_size}"""
if self.streamer: if self.streamer:
status += f"\n• HTTP Listeners: {self.streamer.listener_count}" status += f"\n• HTTP Listeners: {self.streamer.listener_count}"
@@ -1211,16 +1187,16 @@ class RadioBot(slixmpp.ClientXMPP):
lines = [] lines = []
if current: if current:
lines.append(f"Now: {current.display_name}") lines.append(f"▶️ Now: {current.display_name}")
if queue: if queue:
lines.append("\nUp Next:") lines.append("\n📋 Up Next:")
for i, track in enumerate(queue, 1): for i, track in enumerate(queue, 1):
lines.append(f" {i}. {track.display_name}") lines.append(f" {i}. {track.display_name}")
else: else:
lines.append("\nQueue is empty") lines.append("\n📋 Queue is empty")
lines.append(f"\n{self.playlist_manager.queue.size} in queue") lines.append(f"\n📊 {self.playlist_manager.queue.size} in queue")
await self.reply(ctx, "\n".join(lines)) await self.reply(ctx, "\n".join(lines))
async def cmd_add_queue(self, ctx: MessageContext, arg: str): async def cmd_add_queue(self, ctx: MessageContext, arg: str):
@@ -1232,11 +1208,11 @@ class RadioBot(slixmpp.ClientXMPP):
if results: if results:
track = results[0] track = results[0]
if self.playlist_manager.add_to_queue(track): if self.playlist_manager.add_to_queue(track):
await self.reply(ctx, f"Added to queue: {track.display_name}") await self.reply(ctx, f" Added to queue: {track.display_name}")
else: else:
await self.reply(ctx, "Queue is full") await self.reply(ctx, "Queue is full")
else: else:
await self.reply(ctx, "Not found in library. Try !dl first.") await self.reply(ctx, "Not found in library. Try !dl first.")
async def cmd_play_next(self, ctx: MessageContext, arg: str): async def cmd_play_next(self, ctx: MessageContext, arg: str):
if not arg: if not arg:
@@ -1247,15 +1223,15 @@ class RadioBot(slixmpp.ClientXMPP):
if results: if results:
track = results[0] track = results[0]
if self.playlist_manager.add_next_to_queue(track): if self.playlist_manager.add_next_to_queue(track):
await self.reply(ctx, f"Playing next: {track.display_name}") await self.reply(ctx, f"⏭️ Playing next: {track.display_name}")
else: else:
await self.reply(ctx, "Queue is full") await self.reply(ctx, "Queue is full")
else: else:
await self.reply(ctx, "Not found in library") await self.reply(ctx, "Not found in library")
async def cmd_clear_queue(self, ctx: MessageContext, arg: str): async def cmd_clear_queue(self, ctx: MessageContext, arg: str):
self.playlist_manager.clear_queue() self.playlist_manager.clear_queue()
await self.reply(ctx, "Queue cleared") await self.reply(ctx, "🗑️ Queue cleared")
async def cmd_remove_queue(self, ctx: MessageContext, arg: str): async def cmd_remove_queue(self, ctx: MessageContext, arg: str):
if not arg: if not arg:
@@ -1266,29 +1242,27 @@ class RadioBot(slixmpp.ClientXMPP):
idx = int(arg) - 1 idx = int(arg) - 1
track = self.playlist_manager.remove_from_queue(idx) track = self.playlist_manager.remove_from_queue(idx)
if track: if track:
await self.reply(ctx, f"Removed: {track.display_name}") await self.reply(ctx, f"🗑️ Removed: {track.display_name}")
else: else:
await self.reply(ctx, "Invalid index") await self.reply(ctx, "Invalid index")
except ValueError: except ValueError:
await self.reply(ctx, "Invalid number") await self.reply(ctx, "Invalid number")
async def cmd_shuffle_queue(self, ctx: MessageContext, arg: str): async def cmd_shuffle_queue(self, ctx: MessageContext, arg: str):
self.playlist_manager.shuffle_queue() self.playlist_manager.shuffle_queue()
await self.reply(ctx, "Queue shuffled") await self.reply(ctx, "🔀 Queue shuffled")
async def cmd_history(self, ctx: MessageContext, arg: str): async def cmd_history(self, ctx: MessageContext, arg: str):
history = self.playlist_manager.queue.get_history(10) history = self.playlist_manager.queue.get_history(10)
if history: if history:
lines = ["Recently Played:"] lines = ["📜 Recently Played:"]
for i, track in enumerate(reversed(history), 1): for i, track in enumerate(reversed(history), 1):
lines.append(f" {i}. {track.display_name}") lines.append(f" {i}. {track.display_name}")
await self.reply(ctx, "\n".join(lines)) await self.reply(ctx, "\n".join(lines))
else: else:
await self.reply(ctx, "No history yet") await self.reply(ctx, "📜 No history yet")
async def main(): async def main():
"""Main execution function; handles CLI args and shutdown signals."""
import argparse import argparse
p = argparse.ArgumentParser(description="XMPP Radio Bot") p = argparse.ArgumentParser(description="XMPP Radio Bot")
p.add_argument('-c', '--config', default='config.ini', help='Config file path') p.add_argument('-c', '--config', default='config.ini', help='Config file path')
@@ -1307,7 +1281,6 @@ async def main():
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("Shutting down...") logger.info("Shutting down...")
finally: finally:
# Graceful cleanup of background tasks
if bot._gc_task: if bot._gc_task:
bot._gc_task.cancel() bot._gc_task.cancel()
try: try:
@@ -1341,8 +1314,7 @@ async def main():
gc.collect() gc.collect()
gc.collect() gc.collect()
logger.info("Bot stopped cleanly") logger.info("👋 Bot stopped cleanly")
if __name__ == '__main__': if __name__ == '__main__':
asyncio.run(main()) asyncio.run(main())