diff options
| author | salmonik <salmonik@noreply.codeberg.org> | 2026-04-19 18:10:09 +0200 |
|---|---|---|
| committer | salmonik <salmonik@noreply.codeberg.org> | 2026-04-19 18:10:09 +0200 |
| commit | f8e1cf36e1efa0602bf2e450835f2a67654762a6 (patch) | |
| tree | fe6b12226d7eebe2a0dc6525de3414d0c3718cc8 /cogs/music/play_commands.py | |
| parent | add support for x^2+y^2=0,4 and alike (diff) | |
| parent | fix: proper aiohttp async context manager mocking in test (diff) | |
| download | senator-sperma-f8e1cf36e1efa0602bf2e450835f2a67654762a6.tar.gz senator-sperma-f8e1cf36e1efa0602bf2e450835f2a67654762a6.zip | |
Merge pull request 'massive changes' (#3) from dev into main
Reviewed-on: https://codeberg.org/salmonik/senator-sperma/pulls/3
stable enough
Diffstat (limited to '')
| -rw-r--r-- | cogs/music/play_commands.py | 112 |
1 files changed, 108 insertions, 4 deletions
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index fd5113a..5373436 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py | |||
| @@ -6,18 +6,25 @@ | |||
| 6 | import logging | 6 | import logging |
| 7 | import os | 7 | import os |
| 8 | import urllib.parse | 8 | import urllib.parse |
| 9 | import asyncio | ||
| 9 | from os import PathLike | 10 | from os import PathLike |
| 10 | from typing import Awaitable, Callable, List, Tuple | 11 | from typing import Awaitable, Callable, List, Tuple |
| 12 | import re | ||
| 13 | from yt_dlp import YoutubeDL | ||
| 11 | 14 | ||
| 12 | import discord | 15 | import discord |
| 13 | from discord.commands import OptionChoice | 16 | from discord.commands import OptionChoice |
| 14 | from discord.ext import commands | 17 | from discord.ext import commands |
| 15 | 18 | ||
| 16 | from cogs.music import audio_playback, media_processing | 19 | from cogs.music import audio_playback, media_processing |
| 17 | from cogs.utils import file_search as search_utils | 20 | from cogs.utils.file_search import UniversalSearcher |
| 18 | 21 | ||
| 19 | logger = logging.getLogger(__name__) | 22 | logger = logging.getLogger(__name__) |
| 20 | 23 | ||
| 24 | EXTERNAL_URL_PATTERN = re.compile( | ||
| 25 | r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))" | ||
| 26 | ) | ||
| 27 | |||
| 21 | 28 | ||
| 22 | class PlayCommands(commands.Cog): | 29 | class PlayCommands(commands.Cog): |
| 23 | """Discord slash commands for music playback.""" | 30 | """Discord slash commands for music playback.""" |
| @@ -88,9 +95,12 @@ class PlayCommands(commands.Cog): | |||
| 88 | self, ctx: discord.AutocompleteContext | 95 | self, ctx: discord.AutocompleteContext |
| 89 | ) -> List[OptionChoice]: | 96 | ) -> List[OptionChoice]: |
| 90 | """Autocomplete handler for search queries.""" | 97 | """Autocomplete handler for search queries.""" |
| 98 | if ctx.value.lower().startswith(("http://", "https://")): | ||
| 99 | return [] | ||
| 100 | |||
| 91 | query = ctx.value.lower() | 101 | query = ctx.value.lower() |
| 92 | try: | 102 | try: |
| 93 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio( | 103 | results = await UniversalSearcher.search_copyparty_autocomplete_audio( |
| 94 | query, max_results=25 | 104 | query, max_results=25 |
| 95 | ) | 105 | ) |
| 96 | choices = [] | 106 | choices = [] |
| @@ -139,9 +149,10 @@ class PlayCommands(commands.Cog): | |||
| 139 | local_path = os.path.join(self.temp_dir, filename) | 149 | local_path = os.path.join(self.temp_dir, filename) |
| 140 | os.makedirs(self.temp_dir, exist_ok=True) | 150 | os.makedirs(self.temp_dir, exist_ok=True) |
| 141 | 151 | ||
| 152 | loop = asyncio.get_event_loop() | ||
| 142 | with open(local_path, "wb") as f: | 153 | with open(local_path, "wb") as f: |
| 143 | async for chunk in response.content.iter_chunked(8192): | 154 | async for chunk in response.content.iter_chunked(8192): |
| 144 | f.write(chunk) | 155 | await loop.run_in_executor(None, f.write, chunk) |
| 145 | 156 | ||
| 146 | compressed_path = await media_processing.AudioCompressor.compress( | 157 | compressed_path = await media_processing.AudioCompressor.compress( |
| 147 | ctx, local_path | 158 | ctx, local_path |
| @@ -162,6 +173,96 @@ class PlayCommands(commands.Cog): | |||
| 162 | except Exception: | 173 | except Exception: |
| 163 | pass | 174 | pass |
| 164 | 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 | |||
| 165 | async def _handle_local_file_play( | 266 | async def _handle_local_file_play( |
| 166 | self, ctx: discord.ApplicationContext, file: str, compressed: bool | 267 | self, ctx: discord.ApplicationContext, file: str, compressed: bool |
| 167 | ) -> None: | 268 | ) -> None: |
| @@ -209,7 +310,10 @@ class PlayCommands(commands.Cog): | |||
| 209 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) | 310 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) |
| 210 | else: | 311 | else: |
| 211 | try: | 312 | try: |
| 212 | 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) | ||
| 213 | except Exception as e: | 317 | except Exception as e: |
| 214 | logger.error(f"Error playing: {str(e)}", exc_info=True) | 318 | logger.error(f"Error playing: {str(e)}", exc_info=True) |
| 215 | await ctx.respond( | 319 | await ctx.respond( |
