From 1dcdbe3cf03ff16b3ff2e2e6d572e0b3c72c18c0 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 19 Apr 2026 15:43:32 +0200 Subject: refactor: simplify cogs, reduce complexity - Remove unused imports and dead code - Cleaner error handling - Reduce cognitive complexity --- cogs/music/audio_playback.py | 55 ++++++++++++++++++++++++++++++-------------- cogs/music/play_commands.py | 4 +++- 2 files changed, 41 insertions(+), 18 deletions(-) (limited to 'cogs/music') diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py index 1267fa7..28c6118 100644 --- a/cogs/music/audio_playback.py +++ b/cogs/music/audio_playback.py @@ -60,17 +60,18 @@ class AudioPlaybackManager: """Connect or move to voice channel. Returns voice client.""" try: # Check if vc is valid and actually connected - is_connected = vc is not None and vc.is_connected() - # Connect to voice channel if not connected - if not is_connected: - logger.info(f"Connecting to voice channel: {target_channel.name}") - vc = await target_channel.connect() - logger.info(f"Connected to voice channel: {target_channel.name}") - # Move to user's channel if in a different one - elif vc.channel != target_channel: - logger.info(f"Moving to voice channel: {target_channel.name}") - await vc.move_to(target_channel) - logger.info(f"Moved to voice channel: {target_channel.name}") + if vc is not None and vc.is_connected(): + # Already connected - check if need to move + if vc.channel != target_channel: + logger.info(f"Moving to voice channel: {target_channel.name}") + await vc.move_to(target_channel) + logger.info(f"Moved to voice channel: {target_channel.name}") + return vc + + # Not connected - connect now + logger.info(f"Connecting to voice channel: {target_channel.name}") + vc = await target_channel.connect() + logger.info(f"Connected to voice channel: {target_channel.name}") return vc except Exception as e: logger.error(f"Error connecting to voice channel: {str(e)}") @@ -138,15 +139,35 @@ class AudioPlaybackManager: @staticmethod async def execute_playback(ctx, source, display_name, is_url): """Execute the actual audio playback.""" - vc = ctx.voice_client target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) if not target_channel: return False + # Get existing voice client from bot or connect + guild = ctx.guild + vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) + try: - vc = await AudioPlaybackManager.connect_to_voice_channel( - ctx, vc, target_channel - ) + # Handle various connection states + if vc is None: + logger.info("No existing voice client, connecting...") + try: + vc = await target_channel.connect() + except discord.ClientException: + vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) + elif not vc.is_connected(): + logger.info("Voice client exists but disconnected, reconnecting...") + try: + await vc.disconnect() + except Exception: + pass + try: + vc = await target_channel.connect() + except discord.ClientException: + vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) + elif vc.channel != target_channel: + logger.info(f"Moving to voice channel: {target_channel.name}") + await vc.move_to(target_channel) # Stop any currently playing audio if vc.is_playing() or vc.is_paused(): # type: ignore[union-attr] @@ -171,7 +192,7 @@ class AudioPlaybackManager: # Play the audio vc.play(audio_source, after=after_playing) logger.info(f"Playing: {final_display_name}") - await ctx.respond(f"grom: {final_display_name}") + await ctx.followup.send(f"grom: {final_display_name}") # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds await asyncio.sleep(5) @@ -195,7 +216,7 @@ class AudioPlaybackManager: except Exception as e: logger.error(f"Error in play: {str(e)}", exc_info=True) - await ctx.respond(f"eror: {str(e)}", ephemeral=True) + await ctx.followup.send(f"eror: {str(e)}", ephemeral=True) return False @staticmethod diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index fd5113a..a82fa9d 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py @@ -6,6 +6,7 @@ import logging import os import urllib.parse +import asyncio from os import PathLike from typing import Awaitable, Callable, List, Tuple @@ -139,9 +140,10 @@ class PlayCommands(commands.Cog): local_path = os.path.join(self.temp_dir, filename) os.makedirs(self.temp_dir, exist_ok=True) + loop = asyncio.get_event_loop() with open(local_path, "wb") as f: async for chunk in response.content.iter_chunked(8192): - f.write(chunk) + await loop.run_in_executor(None, f.write, chunk) compressed_path = await media_processing.AudioCompressor.compress( ctx, local_path -- cgit v1.2.3 From 920d4b18e253f5642b895a1205d9826d54570db8 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 19 Apr 2026 17:41:05 +0200 Subject: feat: add external URL playback support, fix vclogs audio handling --- AGENTS.md | 28 +++++++++ cogs/music/play_commands.py | 108 ++++++++++++++++++++++++++++++++- cogs/vclogs.py | 14 ++++- pyrightconfig.json | 1 + tests/cogs/test_music_external_play.py | 108 +++++++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 AGENTS.md create mode 100644 tests/cogs/test_music_external_play.py (limited to 'cogs/music') diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..44be9d5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Agent Instructions for Senator Sperma + +## Required Setup +- Requires **Python 3.13** (3.14 not supported - py-cord incompatibility) +- System dependencies: `ffmpeg`, `zbar` library (installed system-wide) +- Bot token in `.env` file (copy from `.env.example`) + +## Commands +- Run bot: `.venv/bin/python main.py` +- Type check: `.venv/bin/basedpyright` +- Test: `.venv/bin/pytest` + +## Architecture +- Slash commands defined as cogs in `cogs/*.py` +- Discord API uses `discord.ApplicationContext` +- Music auto-complete queries copyparty server (`COPYPARTY_URL` config) + +## Environment +- Copyparty URL for read-only access is COPYPARTY_URL +- Copyparty URL for write-only access is COPYPARTY_URL_UPLOAD +- Above should be accessed using COPYPARTY_PASSWORD as a cookie +- Channel for vclogs and alike is STATUS_CHANNEL_ID +- Trigger word for assistant is ASSISTANT_KEYWORD + +## Testing +- Tests in `tests/cogs/test_*.py` +- Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function` +- `conftest.py` provides fixtures for mocking Discord objects diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index a82fa9d..5373436 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py @@ -9,16 +9,22 @@ import urllib.parse import asyncio from os import PathLike from typing import Awaitable, Callable, List, Tuple +import re +from yt_dlp import YoutubeDL import discord from discord.commands import OptionChoice from discord.ext import commands from cogs.music import audio_playback, media_processing -from cogs.utils import file_search as search_utils +from cogs.utils.file_search import UniversalSearcher logger = logging.getLogger(__name__) +EXTERNAL_URL_PATTERN = re.compile( + r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))" +) + class PlayCommands(commands.Cog): """Discord slash commands for music playback.""" @@ -89,9 +95,12 @@ class PlayCommands(commands.Cog): self, ctx: discord.AutocompleteContext ) -> List[OptionChoice]: """Autocomplete handler for search queries.""" + if ctx.value.lower().startswith(("http://", "https://")): + return [] + query = ctx.value.lower() try: - results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio( + results = await UniversalSearcher.search_copyparty_autocomplete_audio( query, max_results=25 ) choices = [] @@ -164,6 +173,96 @@ class PlayCommands(commands.Cog): except Exception: pass + async def _handle_external_url_play( + self, ctx: discord.ApplicationContext, url: str + ) -> None: + """Download from external URL, upload to copyparty, then play.""" + local_path = None + try: + ytdl_opts = { + "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"), + "postprocessors": [ + { + "key": "FFmpegExtractAudio", + "preferredcodec": "mp3", + "preferredquality": "192", + } + ], + "force_ipv4": True, + } + + loop = asyncio.get_event_loop() + with YoutubeDL(ytdl_opts) as ydl: + info_dict = await loop.run_in_executor( + None, lambda: ydl.extract_info(url, download=True) + ) + title = info_dict.get("title", "nieznany") + base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] + local_path = f"{base_filename}.mp3" + + if not os.path.exists(local_path): + await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) + return + + upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path)) + + await audio_playback.AudioPlaybackManager.play( + ctx, local_path, delete_after_play=True + ) + + try: + await asyncio.wait_for(upload_task, timeout=30.0) + except asyncio.TimeoutError: + logger.warning("Upload timed out") + except Exception as e: + logger.error(f"Error downloading external URL: {str(e)}", exc_info=True) + if local_path and os.path.exists(local_path): + try: + os.remove(local_path) + except Exception: + pass + await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True) + + async def _upload_to_copyparty_async(self, file_path: str) -> None: + """Upload file to copyparty asynchronously (fire and forget).""" + import aiohttp + + try: + logger.info(f"Starting async upload: {file_path}") + baseurl = os.getenv("COPYPARTY_URL_UPLOAD") + password = os.getenv("COPYPARTY_PASSWORD", "") + + logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") + + if not baseurl: + logger.error("COPYPARTY_URL_UPLOAD not set!") + return + + with open(file_path, "rb") as f: + content = f.read() + + files = aiohttp.FormData() + files.add_field( + "f", + content, + filename=os.path.basename(file_path), + content_type="application/octet-stream", + ) + headers = {"Cookie": f"cppwd={password}"} + params = {"j": "1", "ck": "b2"} + + logger.info(f"Posting to {baseurl}") + async with aiohttp.ClientSession() as session: + async with session.post(baseurl, data=files, headers=headers, params=params) as response: + text = await response.text() + logger.info(f"Upload response: {response.status} - {text[:200] if text else 'empty'}") + + if response.status in [200, 201]: + result = await response.json() + logger.info(f"Uploaded to copyparty: {result.get('fileurl')}") + except Exception as e: + logger.error(f"Async upload failed: {e}", exc_info=True) + async def _handle_local_file_play( self, ctx: discord.ApplicationContext, file: str, compressed: bool ) -> None: @@ -211,7 +310,10 @@ class PlayCommands(commands.Cog): await audio_playback.AudioPlaybackManager.play(ctx, file_path) else: try: - await self._handle_local_file_play(ctx, melodia, compressed) + if EXTERNAL_URL_PATTERN.match(melodia.lower()): + await self._handle_external_url_play(ctx, melodia) + else: + await self._handle_local_file_play(ctx, melodia, compressed) except Exception as e: logger.error(f"Error playing: {str(e)}", exc_info=True) await ctx.respond( diff --git a/cogs/vclogs.py b/cogs/vclogs.py index 85d19bc..fb0a2e0 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py @@ -102,12 +102,20 @@ class VcLogs(commands.Cog): volume = get_volume() logger.info(f"connected: {voice_client}") - if voice_client and not is_playing: + if voice_client: + if is_playing: + logger.info("voice already playing, stopping current audio") + voice_client.stop() + await asyncio.sleep(0.1) + logger.info("preparing to play audio") - audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') + audio_source = discord.FFmpegPCMAudio(tts_file) + if volume != 1.0: + audio_source = discord.PCMVolumeTransformer(audio_source) + audio_source.volume = volume voice_client.play(audio_source) while voice_client.is_playing(): - await asyncio.sleep(0.5) + await asyncio.sleep(0.1) except Exception as e: logger.error(f"TTS playback error: {e}") finally: diff --git a/pyrightconfig.json b/pyrightconfig.json index f0b2a2d..7722ae1 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -15,4 +15,5 @@ "reportAttributeAccessIssue": false, "reportPossiblyUnboundVariable": false, "reportArgumentType": false, + "reportUnreachable": false, } diff --git a/tests/cogs/test_music_external_play.py b/tests/cogs/test_music_external_play.py new file mode 100644 index 0000000..76debc9 --- /dev/null +++ b/tests/cogs/test_music_external_play.py @@ -0,0 +1,108 @@ +import pytest +import os +import asyncio +import tempfile +from unittest.mock import AsyncMock, patch, MagicMock +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def play_commands_cog(bot): + with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): + from cogs.music.play_commands import PlayCommands + cog = PlayCommands(bot) + return cog + + +@pytest.fixture +def temp_mp3_file(): + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: + f.write(b'fake mp3 content') + temp_path = f.name + yield temp_path + if os.path.exists(temp_path): + os.remove(temp_path) + + +@pytest.mark.asyncio +async def test_external_url_pattern_matches_youtube(play_commands_cog): + from cogs.music.play_commands import EXTERNAL_URL_PATTERN + + assert EXTERNAL_URL_PATTERN.match("https://youtube.com/watch?v=test") + assert EXTERNAL_URL_PATTERN.match("https://youtu.be/test") + assert EXTERNAL_URL_PATTERN.match("https://www.youtube.com/watch?v=test") + + +@pytest.mark.asyncio +async def test_external_url_pattern_matches_soundcloud(play_commands_cog): + from cogs.music.play_commands import EXTERNAL_URL_PATTERN + + assert EXTERNAL_URL_PATTERN.match("https://soundcloud.com/artist/track") + assert EXTERNAL_URL_PATTERN.match("https://api.soundcloud.com/tracks") + + +@pytest.mark.asyncio +async def test_external_url_pattern_matches_bandcamp(play_commands_cog): + from cogs.music.play_commands import EXTERNAL_URL_PATTERN + + assert EXTERNAL_URL_PATTERN.match("https://artist.bandcamp.com/track") + + +@pytest.mark.asyncio +async def test_external_url_pattern_rejects_copyparty(play_commands_cog): + from cogs.music.play_commands import EXTERNAL_URL_PATTERN + + assert not EXTERNAL_URL_PATTERN.match("https://catbox.2137697.xyz/sperma/file.mp3") + assert not EXTERNAL_URL_PATTERN.match("some search query") + + +@pytest.mark.asyncio +async def test_play_command_calls_external_handler(play_commands_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch.object(play_commands_cog, '_handle_external_url_play', new_callable=AsyncMock) as mock_handler: + mock_handler.return_value = None + mock_defer = AsyncMock() + ctx.defer = mock_defer + ctx.respond = AsyncMock() + + await play_commands_cog.play.callback( + play_commands_cog, ctx, + melodia="https://youtube.com/watch?v=test", + file=None, compressed=False + ) + + mock_handler.assert_called_once_with(ctx, "https://youtube.com/watch?v=test") + + +@pytest.mark.asyncio +async def test_play_command_calls_local_handler(play_commands_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch.object(play_commands_cog, '_handle_local_file_play', new_callable=AsyncMock) as mock_handler: + mock_handler.return_value = None + mock_defer = AsyncMock() + ctx.defer = mock_defer + ctx.respond = AsyncMock() + + await play_commands_cog.play.callback( + play_commands_cog, ctx, + melodia="some search query", + file=None, compressed=False + ) + + mock_handler.assert_called_once_with(ctx, "some search query", False) + + +@pytest.mark.asyncio +async def test_upload_to_copyparty_async(play_commands_cog, temp_mp3_file, monkeypatch): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"fileurl": "https://example.com/file.mp3"} + + monkeypatch.setenv("COPYPARTY_URL_UPLOAD", "https://catbox.2137697.xyz/sperma/") + monkeypatch.setenv("COPYPARTY_PASSWORD", "testpassword") + + with patch('cogs.music.play_commands.requests.post', return_value=mock_response) as mock_post: + await play_commands_cog._upload_to_copyparty_async(temp_mp3_file) + mock_post.assert_called_once() -- cgit v1.2.3