diff options
Diffstat (limited to '')
| -rw-r--r-- | AGENTS.md | 28 | ||||
| -rw-r--r-- | cogs/music/play_commands.py | 108 | ||||
| -rw-r--r-- | cogs/vclogs.py | 14 | ||||
| -rw-r--r-- | pyrightconfig.json | 1 | ||||
| -rw-r--r-- | tests/cogs/test_music_external_play.py | 108 |
5 files changed, 253 insertions, 6 deletions
diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..44be9d5 --- /dev/null +++ b/AGENTS.md | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | # Agent Instructions for Senator Sperma | ||
| 2 | |||
| 3 | ## Required Setup | ||
| 4 | - Requires **Python 3.13** (3.14 not supported - py-cord incompatibility) | ||
| 5 | - System dependencies: `ffmpeg`, `zbar` library (installed system-wide) | ||
| 6 | - Bot token in `.env` file (copy from `.env.example`) | ||
| 7 | |||
| 8 | ## Commands | ||
| 9 | - Run bot: `.venv/bin/python main.py` | ||
| 10 | - Type check: `.venv/bin/basedpyright` | ||
| 11 | - Test: `.venv/bin/pytest` | ||
| 12 | |||
| 13 | ## Architecture | ||
| 14 | - Slash commands defined as cogs in `cogs/*.py` | ||
| 15 | - Discord API uses `discord.ApplicationContext` | ||
| 16 | - Music auto-complete queries copyparty server (`COPYPARTY_URL` config) | ||
| 17 | |||
| 18 | ## Environment | ||
| 19 | - Copyparty URL for read-only access is COPYPARTY_URL | ||
| 20 | - Copyparty URL for write-only access is COPYPARTY_URL_UPLOAD | ||
| 21 | - Above should be accessed using COPYPARTY_PASSWORD as a cookie | ||
| 22 | - Channel for vclogs and alike is STATUS_CHANNEL_ID | ||
| 23 | - Trigger word for assistant is ASSISTANT_KEYWORD | ||
| 24 | |||
| 25 | ## Testing | ||
| 26 | - Tests in `tests/cogs/test_*.py` | ||
| 27 | - Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function` | ||
| 28 | - `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 | |||
| 9 | import asyncio | 9 | import asyncio |
| 10 | from os import PathLike | 10 | from os import PathLike |
| 11 | from typing import Awaitable, Callable, List, Tuple | 11 | from typing import Awaitable, Callable, List, Tuple |
| 12 | import re | ||
| 13 | from yt_dlp import YoutubeDL | ||
| 12 | 14 | ||
| 13 | import discord | 15 | import discord |
| 14 | from discord.commands import OptionChoice | 16 | from discord.commands import OptionChoice |
| 15 | from discord.ext import commands | 17 | from discord.ext import commands |
| 16 | 18 | ||
| 17 | from cogs.music import audio_playback, media_processing | 19 | from cogs.music import audio_playback, media_processing |
| 18 | from cogs.utils import file_search as search_utils | 20 | from cogs.utils.file_search import UniversalSearcher |
| 19 | 21 | ||
| 20 | logger = logging.getLogger(__name__) | 22 | logger = logging.getLogger(__name__) |
| 21 | 23 | ||
| 24 | EXTERNAL_URL_PATTERN = re.compile( | ||
| 25 | r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))" | ||
| 26 | ) | ||
| 27 | |||
| 22 | 28 | ||
| 23 | class PlayCommands(commands.Cog): | 29 | class PlayCommands(commands.Cog): |
| 24 | """Discord slash commands for music playback.""" | 30 | """Discord slash commands for music playback.""" |
| @@ -89,9 +95,12 @@ class PlayCommands(commands.Cog): | |||
| 89 | self, ctx: discord.AutocompleteContext | 95 | self, ctx: discord.AutocompleteContext |
| 90 | ) -> List[OptionChoice]: | 96 | ) -> List[OptionChoice]: |
| 91 | """Autocomplete handler for search queries.""" | 97 | """Autocomplete handler for search queries.""" |
| 98 | if ctx.value.lower().startswith(("http://", "https://")): | ||
| 99 | return [] | ||
| 100 | |||
| 92 | query = ctx.value.lower() | 101 | query = ctx.value.lower() |
| 93 | try: | 102 | try: |
| 94 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio( | 103 | results = await UniversalSearcher.search_copyparty_autocomplete_audio( |
| 95 | query, max_results=25 | 104 | query, max_results=25 |
| 96 | ) | 105 | ) |
| 97 | choices = [] | 106 | choices = [] |
| @@ -164,6 +173,96 @@ class PlayCommands(commands.Cog): | |||
| 164 | except Exception: | 173 | except Exception: |
| 165 | pass | 174 | pass |
| 166 | 175 | ||
| 176 | async def _handle_external_url_play( | ||
| 177 | self, ctx: discord.ApplicationContext, url: str | ||
| 178 | ) -> None: | ||
| 179 | """Download from external URL, upload to copyparty, then play.""" | ||
| 180 | local_path = None | ||
| 181 | try: | ||
| 182 | ytdl_opts = { | ||
| 183 | "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"), | ||
| 184 | "postprocessors": [ | ||
| 185 | { | ||
| 186 | "key": "FFmpegExtractAudio", | ||
| 187 | "preferredcodec": "mp3", | ||
| 188 | "preferredquality": "192", | ||
| 189 | } | ||
| 190 | ], | ||
| 191 | "force_ipv4": True, | ||
| 192 | } | ||
| 193 | |||
| 194 | loop = asyncio.get_event_loop() | ||
| 195 | with YoutubeDL(ytdl_opts) as ydl: | ||
| 196 | info_dict = await loop.run_in_executor( | ||
| 197 | None, lambda: ydl.extract_info(url, download=True) | ||
| 198 | ) | ||
| 199 | title = info_dict.get("title", "nieznany") | ||
| 200 | base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] | ||
| 201 | local_path = f"{base_filename}.mp3" | ||
| 202 | |||
| 203 | if not os.path.exists(local_path): | ||
| 204 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) | ||
| 205 | return | ||
| 206 | |||
| 207 | upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path)) | ||
| 208 | |||
| 209 | await audio_playback.AudioPlaybackManager.play( | ||
| 210 | ctx, local_path, delete_after_play=True | ||
| 211 | ) | ||
| 212 | |||
| 213 | try: | ||
| 214 | await asyncio.wait_for(upload_task, timeout=30.0) | ||
| 215 | except asyncio.TimeoutError: | ||
| 216 | logger.warning("Upload timed out") | ||
| 217 | except Exception as e: | ||
| 218 | logger.error(f"Error downloading external URL: {str(e)}", exc_info=True) | ||
| 219 | if local_path and os.path.exists(local_path): | ||
| 220 | try: | ||
| 221 | os.remove(local_path) | ||
| 222 | except Exception: | ||
| 223 | pass | ||
| 224 | await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True) | ||
| 225 | |||
| 226 | async def _upload_to_copyparty_async(self, file_path: str) -> None: | ||
| 227 | """Upload file to copyparty asynchronously (fire and forget).""" | ||
| 228 | import aiohttp | ||
| 229 | |||
| 230 | try: | ||
| 231 | logger.info(f"Starting async upload: {file_path}") | ||
| 232 | baseurl = os.getenv("COPYPARTY_URL_UPLOAD") | ||
| 233 | password = os.getenv("COPYPARTY_PASSWORD", "") | ||
| 234 | |||
| 235 | logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") | ||
| 236 | |||
| 237 | if not baseurl: | ||
| 238 | logger.error("COPYPARTY_URL_UPLOAD not set!") | ||
| 239 | return | ||
| 240 | |||
| 241 | with open(file_path, "rb") as f: | ||
| 242 | content = f.read() | ||
| 243 | |||
| 244 | files = aiohttp.FormData() | ||
| 245 | files.add_field( | ||
| 246 | "f", | ||
| 247 | content, | ||
| 248 | filename=os.path.basename(file_path), | ||
| 249 | content_type="application/octet-stream", | ||
| 250 | ) | ||
| 251 | headers = {"Cookie": f"cppwd={password}"} | ||
| 252 | params = {"j": "1", "ck": "b2"} | ||
| 253 | |||
| 254 | logger.info(f"Posting to {baseurl}") | ||
| 255 | async with aiohttp.ClientSession() as session: | ||
| 256 | async with session.post(baseurl, data=files, headers=headers, params=params) as response: | ||
| 257 | text = await response.text() | ||
| 258 | logger.info(f"Upload response: {response.status} - {text[:200] if text else 'empty'}") | ||
| 259 | |||
| 260 | if response.status in [200, 201]: | ||
| 261 | result = await response.json() | ||
| 262 | logger.info(f"Uploaded to copyparty: {result.get('fileurl')}") | ||
| 263 | except Exception as e: | ||
| 264 | logger.error(f"Async upload failed: {e}", exc_info=True) | ||
| 265 | |||
| 167 | async def _handle_local_file_play( | 266 | async def _handle_local_file_play( |
| 168 | self, ctx: discord.ApplicationContext, file: str, compressed: bool | 267 | self, ctx: discord.ApplicationContext, file: str, compressed: bool |
| 169 | ) -> None: | 268 | ) -> None: |
| @@ -211,7 +310,10 @@ class PlayCommands(commands.Cog): | |||
| 211 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) | 310 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) |
| 212 | else: | 311 | else: |
| 213 | try: | 312 | try: |
| 214 | await self._handle_local_file_play(ctx, melodia, compressed) | 313 | if EXTERNAL_URL_PATTERN.match(melodia.lower()): |
| 314 | await self._handle_external_url_play(ctx, melodia) | ||
| 315 | else: | ||
| 316 | await self._handle_local_file_play(ctx, melodia, compressed) | ||
| 215 | except Exception as e: | 317 | except Exception as e: |
| 216 | logger.error(f"Error playing: {str(e)}", exc_info=True) | 318 | logger.error(f"Error playing: {str(e)}", exc_info=True) |
| 217 | await ctx.respond( | 319 | 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): | |||
| 102 | volume = get_volume() | 102 | volume = get_volume() |
| 103 | logger.info(f"connected: {voice_client}") | 103 | logger.info(f"connected: {voice_client}") |
| 104 | 104 | ||
| 105 | if voice_client and not is_playing: | 105 | if voice_client: |
| 106 | if is_playing: | ||
| 107 | logger.info("voice already playing, stopping current audio") | ||
| 108 | voice_client.stop() | ||
| 109 | await asyncio.sleep(0.1) | ||
| 110 | |||
| 106 | logger.info("preparing to play audio") | 111 | logger.info("preparing to play audio") |
| 107 | audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') | 112 | audio_source = discord.FFmpegPCMAudio(tts_file) |
| 113 | if volume != 1.0: | ||
| 114 | audio_source = discord.PCMVolumeTransformer(audio_source) | ||
| 115 | audio_source.volume = volume | ||
| 108 | voice_client.play(audio_source) | 116 | voice_client.play(audio_source) |
| 109 | while voice_client.is_playing(): | 117 | while voice_client.is_playing(): |
| 110 | await asyncio.sleep(0.5) | 118 | await asyncio.sleep(0.1) |
| 111 | except Exception as e: | 119 | except Exception as e: |
| 112 | logger.error(f"TTS playback error: {e}") | 120 | logger.error(f"TTS playback error: {e}") |
| 113 | finally: | 121 | finally: |
diff --git a/pyrightconfig.json b/pyrightconfig.json index f0b2a2d..7722ae1 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json | |||
| @@ -15,4 +15,5 @@ | |||
| 15 | "reportAttributeAccessIssue": false, | 15 | "reportAttributeAccessIssue": false, |
| 16 | "reportPossiblyUnboundVariable": false, | 16 | "reportPossiblyUnboundVariable": false, |
| 17 | "reportArgumentType": false, | 17 | "reportArgumentType": false, |
| 18 | "reportUnreachable": false, | ||
| 18 | } | 19 | } |
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 @@ | |||
| 1 | import pytest | ||
| 2 | import os | ||
| 3 | import asyncio | ||
| 4 | import tempfile | ||
| 5 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 6 | from tests.conftest import MockApplicationContext | ||
| 7 | |||
| 8 | |||
| 9 | @pytest.fixture | ||
| 10 | def play_commands_cog(bot): | ||
| 11 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 12 | from cogs.music.play_commands import PlayCommands | ||
| 13 | cog = PlayCommands(bot) | ||
| 14 | return cog | ||
| 15 | |||
| 16 | |||
| 17 | @pytest.fixture | ||
| 18 | def temp_mp3_file(): | ||
| 19 | with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: | ||
| 20 | f.write(b'fake mp3 content') | ||
| 21 | temp_path = f.name | ||
| 22 | yield temp_path | ||
| 23 | if os.path.exists(temp_path): | ||
| 24 | os.remove(temp_path) | ||
| 25 | |||
| 26 | |||
| 27 | @pytest.mark.asyncio | ||
| 28 | async def test_external_url_pattern_matches_youtube(play_commands_cog): | ||
| 29 | from cogs.music.play_commands import EXTERNAL_URL_PATTERN | ||
| 30 | |||
| 31 | assert EXTERNAL_URL_PATTERN.match("https://youtube.com/watch?v=test") | ||
| 32 | assert EXTERNAL_URL_PATTERN.match("https://youtu.be/test") | ||
| 33 | assert EXTERNAL_URL_PATTERN.match("https://www.youtube.com/watch?v=test") | ||
| 34 | |||
| 35 | |||
| 36 | @pytest.mark.asyncio | ||
| 37 | async def test_external_url_pattern_matches_soundcloud(play_commands_cog): | ||
| 38 | from cogs.music.play_commands import EXTERNAL_URL_PATTERN | ||
| 39 | |||
| 40 | assert EXTERNAL_URL_PATTERN.match("https://soundcloud.com/artist/track") | ||
| 41 | assert EXTERNAL_URL_PATTERN.match("https://api.soundcloud.com/tracks") | ||
| 42 | |||
| 43 | |||
| 44 | @pytest.mark.asyncio | ||
| 45 | async def test_external_url_pattern_matches_bandcamp(play_commands_cog): | ||
| 46 | from cogs.music.play_commands import EXTERNAL_URL_PATTERN | ||
| 47 | |||
| 48 | assert EXTERNAL_URL_PATTERN.match("https://artist.bandcamp.com/track") | ||
| 49 | |||
| 50 | |||
| 51 | @pytest.mark.asyncio | ||
| 52 | async def test_external_url_pattern_rejects_copyparty(play_commands_cog): | ||
| 53 | from cogs.music.play_commands import EXTERNAL_URL_PATTERN | ||
| 54 | |||
| 55 | assert not EXTERNAL_URL_PATTERN.match("https://catbox.2137697.xyz/sperma/file.mp3") | ||
| 56 | assert not EXTERNAL_URL_PATTERN.match("some search query") | ||
| 57 | |||
| 58 | |||
| 59 | @pytest.mark.asyncio | ||
| 60 | async def test_play_command_calls_external_handler(play_commands_cog, mock_channel, mock_guild, bot): | ||
| 61 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 62 | |||
| 63 | with patch.object(play_commands_cog, '_handle_external_url_play', new_callable=AsyncMock) as mock_handler: | ||
| 64 | mock_handler.return_value = None | ||
| 65 | mock_defer = AsyncMock() | ||
| 66 | ctx.defer = mock_defer | ||
| 67 | ctx.respond = AsyncMock() | ||
| 68 | |||
| 69 | await play_commands_cog.play.callback( | ||
| 70 | play_commands_cog, ctx, | ||
| 71 | melodia="https://youtube.com/watch?v=test", | ||
| 72 | file=None, compressed=False | ||
| 73 | ) | ||
| 74 | |||
| 75 | mock_handler.assert_called_once_with(ctx, "https://youtube.com/watch?v=test") | ||
| 76 | |||
| 77 | |||
| 78 | @pytest.mark.asyncio | ||
| 79 | async def test_play_command_calls_local_handler(play_commands_cog, mock_channel, mock_guild, bot): | ||
| 80 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 81 | |||
| 82 | with patch.object(play_commands_cog, '_handle_local_file_play', new_callable=AsyncMock) as mock_handler: | ||
| 83 | mock_handler.return_value = None | ||
| 84 | mock_defer = AsyncMock() | ||
| 85 | ctx.defer = mock_defer | ||
| 86 | ctx.respond = AsyncMock() | ||
| 87 | |||
| 88 | await play_commands_cog.play.callback( | ||
| 89 | play_commands_cog, ctx, | ||
| 90 | melodia="some search query", | ||
| 91 | file=None, compressed=False | ||
| 92 | ) | ||
| 93 | |||
| 94 | mock_handler.assert_called_once_with(ctx, "some search query", False) | ||
| 95 | |||
| 96 | |||
| 97 | @pytest.mark.asyncio | ||
| 98 | async def test_upload_to_copyparty_async(play_commands_cog, temp_mp3_file, monkeypatch): | ||
| 99 | mock_response = MagicMock() | ||
| 100 | mock_response.status_code = 200 | ||
| 101 | mock_response.json.return_value = {"fileurl": "https://example.com/file.mp3"} | ||
| 102 | |||
| 103 | monkeypatch.setenv("COPYPARTY_URL_UPLOAD", "https://catbox.2137697.xyz/sperma/") | ||
| 104 | monkeypatch.setenv("COPYPARTY_PASSWORD", "testpassword") | ||
| 105 | |||
| 106 | with patch('cogs.music.play_commands.requests.post', return_value=mock_response) as mock_post: | ||
| 107 | await play_commands_cog._upload_to_copyparty_async(temp_mp3_file) | ||
| 108 | mock_post.assert_called_once() | ||
