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 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