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