Added Soundcloud and Bandcamp search. Local file search is available now.
This commit is contained in:
@@ -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())
|
||||||
Reference in New Issue
Block a user