aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-04-19 17:41:05 +0200
committersalmonik <raisersoft@proton.me>2026-04-19 17:41:05 +0200
commit920d4b18e253f5642b895a1205d9826d54570db8 (patch)
tree8539d6e633bee7e2a2f74e5bd725d6a49e7268d1
parentreadd main.py (diff)
downloadsenator-sperma-920d4b18e253f5642b895a1205d9826d54570db8.tar.gz
senator-sperma-920d4b18e253f5642b895a1205d9826d54570db8.zip
feat: add external URL playback support, fix vclogs audio handling
Diffstat (limited to '')
-rw-r--r--AGENTS.md28
-rw-r--r--cogs/music/play_commands.py108
-rw-r--r--cogs/vclogs.py14
-rw-r--r--pyrightconfig.json1
-rw-r--r--tests/cogs/test_music_external_play.py108
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
9import asyncio 9import asyncio
10from os import PathLike 10from os import PathLike
11from typing import Awaitable, Callable, List, Tuple 11from typing import Awaitable, Callable, List, Tuple
12import re
13from yt_dlp import YoutubeDL
12 14
13import discord 15import discord
14from discord.commands import OptionChoice 16from discord.commands import OptionChoice
15from discord.ext import commands 17from discord.ext import commands
16 18
17from cogs.music import audio_playback, media_processing 19from cogs.music import audio_playback, media_processing
18from cogs.utils import file_search as search_utils 20from cogs.utils.file_search import UniversalSearcher
19 21
20logger = logging.getLogger(__name__) 22logger = logging.getLogger(__name__)
21 23
24EXTERNAL_URL_PATTERN = re.compile(
25 r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))"
26)
27
22 28
23class PlayCommands(commands.Cog): 29class 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 @@
1import pytest
2import os
3import asyncio
4import tempfile
5from unittest.mock import AsyncMock, patch, MagicMock
6from tests.conftest import MockApplicationContext
7
8
9@pytest.fixture
10def 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
18def 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
28async 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
37async 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
45async 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
52async 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
60async 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
79async 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
98async 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()