diff options
Diffstat (limited to '')
| -rw-r--r-- | cogs/jarvis.py | 3 | ||||
| -rw-r--r-- | cogs/math_commands.py (renamed from cogs/math.py) | 0 | ||||
| -rw-r--r-- | cogs/music/play_commands.py | 5 | ||||
| -rw-r--r-- | cogs/search.py | 73 | ||||
| -rw-r--r-- | cogs/tts.py | 25 | ||||
| -rw-r--r-- | cogs/utils/file_search.py (renamed from cogs/music/search_utils.py) | 279 | ||||
| -rw-r--r-- | cogs/vclogs.py | 55 | ||||
| -rw-r--r-- | cogs/voice.py | 140 |
8 files changed, 475 insertions, 105 deletions
diff --git a/cogs/jarvis.py b/cogs/jarvis.py index b199c8a..5417c5e 100644 --- a/cogs/jarvis.py +++ b/cogs/jarvis.py | |||
| @@ -57,7 +57,8 @@ class Jarvis(commands.Cog): | |||
| 57 | def _build_messages(message): | 57 | def _build_messages(message): |
| 58 | messages = [{ | 58 | messages = [{ |
| 59 | "role": "system", | 59 | "role": "system", |
| 60 | "content": "your task is to tell the program what to do and how to respond after receiving a message.\n" | 60 | "content": "You are an alter ego of Senator Sperma Armstrong.\n" |
| 61 | "your task is to tell the program what to do and how to respond after receiving a message.\n" | ||
| 61 | "message field has to be filled out in polish.\n" | 62 | "message field has to be filled out in polish.\n" |
| 62 | "act like jarvis, be a personal assistant to user, do not use emojis\n" | 63 | "act like jarvis, be a personal assistant to user, do not use emojis\n" |
| 63 | "do not ask the user if they want to do an action. the user will tell you that" | 64 | "do not ask the user if they want to do an action. the user will tell you that" |
diff --git a/cogs/math.py b/cogs/math_commands.py index bf164b3..bf164b3 100644 --- a/cogs/math.py +++ b/cogs/math_commands.py | |||
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index b25ce84..e3ca4c1 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py | |||
| @@ -9,7 +9,8 @@ import mutagen | |||
| 9 | from typing import List, Tuple, Callable, Awaitable | 9 | from typing import List, Tuple, Callable, Awaitable |
| 10 | from os import PathLike | 10 | from os import PathLike |
| 11 | 11 | ||
| 12 | from cogs.music import search_utils, audio_playback, media_processing | 12 | from cogs.music import audio_playback, media_processing |
| 13 | from cogs.utils import file_search as search_utils | ||
| 13 | from discord.ext import commands | 14 | from discord.ext import commands |
| 14 | from discord.commands import OptionChoice | 15 | from discord.commands import OptionChoice |
| 15 | import logging | 16 | import logging |
| @@ -110,7 +111,7 @@ class PlayCommands(commands.Cog): | |||
| 110 | """Autocomplete handler for search queries.""" | 111 | """Autocomplete handler for search queries.""" |
| 111 | query = ctx.value.lower() | 112 | query = ctx.value.lower() |
| 112 | try: | 113 | try: |
| 113 | results = await search_utils.AudioSearcher.search_local_autocomplete(query, max_results=25) | 114 | results = await search_utils.UniversalSearcher.search_local_autocomplete(query, max_results=25, filetype="Audio") |
| 114 | choices = [] | 115 | choices = [] |
| 115 | self.path_mapping = {} # Reset the mapping for each new query | 116 | self.path_mapping = {} # Reset the mapping for each new query |
| 116 | 117 | ||
diff --git a/cogs/search.py b/cogs/search.py new file mode 100644 index 0000000..2f8373b --- /dev/null +++ b/cogs/search.py | |||
| @@ -0,0 +1,73 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import urllib.parse | ||
| 7 | |||
| 8 | import discord | ||
| 9 | from discord.ext import commands | ||
| 10 | import logging | ||
| 11 | from discord import AutocompleteContext | ||
| 12 | from cogs.utils import file_search as search_utils | ||
| 13 | |||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | |||
| 16 | class Search(commands.Cog): | ||
| 17 | def __init__(self, bot): | ||
| 18 | self.bot = bot | ||
| 19 | self._cached_results = {} | ||
| 20 | |||
| 21 | async def file_autocomplete(self, ctx: AutocompleteContext): | ||
| 22 | """Autocomplete function for file search.""" | ||
| 23 | query = ctx.value.lower() if ctx.value else "" | ||
| 24 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | ||
| 25 | |||
| 26 | user_id = ctx.interaction.user.id | ||
| 27 | self._cached_results[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | ||
| 28 | |||
| 29 | choices = [] | ||
| 30 | for url in results[:25]: | ||
| 31 | filename = urllib.parse.unquote(url.split('/')[-1]) | ||
| 32 | display_name = filename[:99] + "…" if len(filename) > 100 else filename | ||
| 33 | choices.append(discord.OptionChoice(name=display_name, value=filename)) | ||
| 34 | return choices | ||
| 35 | |||
| 36 | @commands.slash_command( | ||
| 37 | name="search", | ||
| 38 | description="wyszukaj pliku" | ||
| 39 | ) | ||
| 40 | @discord.option( | ||
| 41 | name="filename", | ||
| 42 | description="Search for a file to send", | ||
| 43 | autocomplete=file_autocomplete | ||
| 44 | ) | ||
| 45 | async def search(self, ctx, filename: str): | ||
| 46 | """Send a file via URL from autocomplete selection.""" | ||
| 47 | if not filename: | ||
| 48 | await ctx.reply("Please select a file from the autocomplete options.") | ||
| 49 | return | ||
| 50 | |||
| 51 | await ctx.defer() | ||
| 52 | logger.info(f"Search command called by {ctx.author} for file: {filename}") | ||
| 53 | |||
| 54 | cached = self._cached_results.get(ctx.author.id) | ||
| 55 | if not cached: | ||
| 56 | await ctx.send_followup("Search results expired. Please search again.") | ||
| 57 | return | ||
| 58 | |||
| 59 | matching_url = cached.get(filename) | ||
| 60 | if not matching_url: | ||
| 61 | await ctx.send_followup(f"File '{filename}' not found.") | ||
| 62 | return | ||
| 63 | |||
| 64 | try: | ||
| 65 | await ctx.send_followup(matching_url) | ||
| 66 | logger.info(f"Successfully sent URL: {matching_url}") | ||
| 67 | del self._cached_results[ctx.author.id] | ||
| 68 | except Exception as e: | ||
| 69 | logger.error(f"Error sending URL {matching_url}: {str(e)}") | ||
| 70 | await ctx.send_followup(str(e)) | ||
| 71 | |||
| 72 | def setup(bot): | ||
| 73 | bot.add_cog(Search(bot)) | ||
diff --git a/cogs/tts.py b/cogs/tts.py index a330b74..46ab637 100644 --- a/cogs/tts.py +++ b/cogs/tts.py | |||
| @@ -40,16 +40,14 @@ class TTS(commands.Cog): | |||
| 40 | logger.error(f"Error sending file: {e}") | 40 | logger.error(f"Error sending file: {e}") |
| 41 | await ctx.respond("eror :c", ephemeral=True) | 41 | await ctx.respond("eror :c", ephemeral=True) |
| 42 | 42 | ||
| 43 | def _get_voice_manager(self): | ||
| 44 | from cogs.voice import get_voice_manager | ||
| 45 | return get_voice_manager(self.bot) | ||
| 46 | |||
| 43 | async def _get_or_connect_voice_client(self, channel): | 47 | async def _get_or_connect_voice_client(self, channel): |
| 44 | """Get existing voice client or connect to new channel""" | 48 | """Get existing voice client or connect to new channel""" |
| 45 | voice_client = discord.utils.get(self.bot.voice_clients, guild=channel.guild) | 49 | voice_mgr = self._get_voice_manager() |
| 46 | 50 | return await voice_mgr.join_channel(channel) | |
| 47 | if not voice_client or not voice_client.is_connected(): | ||
| 48 | voice_client = await channel.connect() | ||
| 49 | elif voice_client.channel != channel: | ||
| 50 | await voice_client.move_to(channel) | ||
| 51 | |||
| 52 | return voice_client | ||
| 53 | 51 | ||
| 54 | async def _handle_voice_output(self, ctx, temp_path, text, volume): | 52 | async def _handle_voice_output(self, ctx, temp_path, text, volume): |
| 55 | """Handle TTS voice channel output""" | 53 | """Handle TTS voice channel output""" |
| @@ -65,6 +63,10 @@ class TTS(commands.Cog): | |||
| 65 | try: | 63 | try: |
| 66 | voice_client = await self._get_or_connect_voice_client(channel) | 64 | voice_client = await self._get_or_connect_voice_client(channel) |
| 67 | 65 | ||
| 66 | if not voice_client: | ||
| 67 | await ctx.respond("Failed to connect to voice channel", ephemeral=True) | ||
| 68 | return | ||
| 69 | |||
| 68 | # Play the TTS audio with volume adjustment | 70 | # Play the TTS audio with volume adjustment |
| 69 | audio_source = discord.FFmpegPCMAudio( | 71 | audio_source = discord.FFmpegPCMAudio( |
| 70 | temp_path, | 72 | temp_path, |
| @@ -96,9 +98,10 @@ class TTS(commands.Cog): | |||
| 96 | ): | 98 | ): |
| 97 | await ctx.defer() | 99 | await ctx.defer() |
| 98 | 100 | ||
| 99 | # Create temporary file path asynchronously | 101 | # Create temporary file in temp folder |
| 100 | temp_fd, temp_path = tempfile.mkstemp(suffix=".mp3") | 102 | temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp') |
| 101 | os.close(temp_fd) # Close the file descriptor, we'll write to it asynchronously | 103 | os.makedirs(temp_dir, exist_ok=True) |
| 104 | temp_path = os.path.join(temp_dir, f"tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") | ||
| 102 | 105 | ||
| 103 | try: | 106 | try: |
| 104 | # Generate and save TTS to temp file | 107 | # Generate and save TTS to temp file |
diff --git a/cogs/music/search_utils.py b/cogs/utils/file_search.py index bc9e335..4a2bdc4 100644 --- a/cogs/music/search_utils.py +++ b/cogs/utils/file_search.py | |||
| @@ -3,12 +3,57 @@ | |||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io |
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | """ | ||
| 7 | Universal File Searcher | ||
| 8 | |||
| 9 | This module provides a universal file searcher that can search for different file types: | ||
| 10 | - Audio files (mp3, wav, mp4, opus, m4a, flac, aac, ogg) | ||
| 11 | - Video files (mp4, avi, mkv, mov, wmv, flv, webm, m4v, 3gp) | ||
| 12 | - Image files (jpg, jpeg, png, gif, bmp, tiff, webp, svg, ico) | ||
| 13 | |||
| 14 | Usage Examples: | ||
| 15 | # Search all file types | ||
| 16 | results = await UniversalSearcher.search_local("query") | ||
| 17 | |||
| 18 | # Search only audio files | ||
| 19 | results = await UniversalSearcher.search_local("query", filetype="Audio") | ||
| 20 | |||
| 21 | # Search only video files | ||
| 22 | results = await UniversalSearcher.search_local("query", filetype="Video") | ||
| 23 | |||
| 24 | # Search only image files | ||
| 25 | results = await UniversalSearcher.search_local("query", filetype="Image") | ||
| 26 | |||
| 27 | # Autocomplete search | ||
| 28 | results = await UniversalSearcher.search_local_autocomplete("partial_query", filetype="Video") | ||
| 29 | |||
| 30 | Settings.json structure: | ||
| 31 | { | ||
| 32 | "file_paths": [ | ||
| 33 | "/path/to/your/media/files", | ||
| 34 | "/another/path/with/mixed/content", | ||
| 35 | "relative/path/to/files" | ||
| 36 | ] | ||
| 37 | } | ||
| 38 | |||
| 39 | Note: file_paths should contain directories that may contain any mix of audio, video, | ||
| 40 | and image files. The searcher will filter by file type automatically based on the | ||
| 41 | filetype parameter. | ||
| 42 | """ | ||
| 43 | |||
| 6 | import json | 44 | import json |
| 7 | import logging | 45 | import logging |
| 8 | import re | 46 | import re |
| 47 | import urllib.parse | ||
| 9 | from pathlib import Path | 48 | from pathlib import Path |
| 10 | import aiofiles | 49 | import aiofiles |
| 11 | from rapidfuzz import fuzz | 50 | from rapidfuzz import fuzz |
| 51 | import aiohttp | ||
| 52 | import asyncio | ||
| 53 | import os | ||
| 54 | from dotenv import load_dotenv | ||
| 55 | |||
| 56 | load_dotenv() | ||
| 12 | 57 | ||
| 13 | logger = logging.getLogger(__name__) | 58 | logger = logging.getLogger(__name__) |
| 14 | 59 | ||
| @@ -173,14 +218,34 @@ class SearchUtils: | |||
| 173 | 218 | ||
| 174 | return score | 219 | return score |
| 175 | 220 | ||
| 176 | class AudioSearcher: | 221 | class UniversalSearcher: |
| 177 | """Handles searching for audio files in configured paths.""" | 222 | """Handles searching for files in configured paths with support for multiple file types.""" |
| 223 | |||
| 224 | FILE_TYPE_EXTENSIONS = { | ||
| 225 | 'Audio': {'.mp4', '.mp3', '.wav', '.opus', '.m4a', '.flac', '.aac', '.ogg'}, | ||
| 226 | 'Video': {'.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.3gp', '.gif'}, | ||
| 227 | 'Image': {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg', '.ico'} | ||
| 228 | } | ||
| 178 | 229 | ||
| 179 | @staticmethod | 230 | @staticmethod |
| 180 | def _is_valid_audio_file(file_path: Path) -> bool: | 231 | def _is_valid_file(file_path: Path, filetype: str = None) -> bool: |
| 181 | """Check if file is a valid audio file.""" | 232 | """Check if file is valid for the specified filetype.""" |
| 182 | valid_extensions = {'.mp4', '.mp3', '.wav', '.opus', '.m4a'} | 233 | if not file_path.is_file(): |
| 183 | return file_path.is_file() and file_path.suffix.lower() in valid_extensions | 234 | return False |
| 235 | |||
| 236 | if filetype is None: | ||
| 237 | # Search all file types | ||
| 238 | all_extensions = set() | ||
| 239 | for extensions in UniversalSearcher.FILE_TYPE_EXTENSIONS.values(): | ||
| 240 | all_extensions.update(extensions) | ||
| 241 | return file_path.suffix.lower() in all_extensions | ||
| 242 | |||
| 243 | filetype = filetype.capitalize() | ||
| 244 | if filetype not in UniversalSearcher.FILE_TYPE_EXTENSIONS: | ||
| 245 | return False | ||
| 246 | |||
| 247 | valid_extensions = UniversalSearcher.FILE_TYPE_EXTENSIONS[filetype] | ||
| 248 | return file_path.suffix.lower() in valid_extensions | ||
| 184 | 249 | ||
| 185 | @staticmethod | 250 | @staticmethod |
| 186 | def _calculate_match_score(query: str, file_name: str, autocomplete: bool) -> tuple[int, str]: | 251 | def _calculate_match_score(query: str, file_name: str, autocomplete: bool) -> tuple[int, str]: |
| @@ -201,18 +266,18 @@ class AudioSearcher: | |||
| 201 | return -1, file_name | 266 | return -1, file_name |
| 202 | 267 | ||
| 203 | @staticmethod | 268 | @staticmethod |
| 204 | async def _search_single_path(path: Path, query: str, max_results: int, autocomplete: bool) -> list[tuple[int, str]]: | 269 | async def _search_single_path(path: Path, query: str, max_results: int, autocomplete: bool, filetype: str = None) -> list[tuple[int, str]]: |
| 205 | """Search for audio files in a single path.""" | 270 | """Search for files in a single path.""" |
| 206 | matches = [] | 271 | matches = [] |
| 207 | 272 | ||
| 208 | logger.debug(f"Searching in directory: {path}") | 273 | logger.debug(f"Searching in directory: {path} (filetype: {filetype or 'All'})") |
| 209 | 274 | ||
| 210 | for file_path in path.rglob('*'): | 275 | for file_path in path.rglob('*'): |
| 211 | if not AudioSearcher._is_valid_audio_file(file_path): | 276 | if not UniversalSearcher._is_valid_file(file_path, filetype): |
| 212 | continue | 277 | continue |
| 213 | 278 | ||
| 214 | file_name = file_path.name.lower() | 279 | file_name = file_path.name.lower() |
| 215 | score, _ = AudioSearcher._calculate_match_score(query, file_name, autocomplete) | 280 | score, _ = UniversalSearcher._calculate_match_score(query, file_name, autocomplete) |
| 216 | 281 | ||
| 217 | if score >= 0: | 282 | if score >= 0: |
| 218 | matches.append((score, str(file_path.absolute()))) | 283 | matches.append((score, str(file_path.absolute()))) |
| @@ -222,14 +287,41 @@ class AudioSearcher: | |||
| 222 | 287 | ||
| 223 | return matches | 288 | return matches |
| 224 | 289 | ||
| 290 | @staticmethod | ||
| 291 | def _get_search_paths(data: dict, filetype: str = None) -> list: | ||
| 292 | """Get the search paths from settings.""" | ||
| 293 | return data.get('file_paths', []) | ||
| 294 | |||
| 225 | @classmethod | 295 | @classmethod |
| 226 | async def search_local(cls, query: str = None, max_results: int = 25, autocomplete: bool = False): | 296 | async def _search_paths(cls, search_paths: list, query: str, max_results: int, autocomplete: bool, filetype: str = None) -> tuple[list, int]: |
| 227 | """Search for audio files with fuzzy matching and optional autocomplete support. | 297 | """Search through the provided paths and return matches and file count.""" |
| 298 | matches = [] | ||
| 299 | file_count = 0 | ||
| 300 | |||
| 301 | for path_str in search_paths: | ||
| 302 | path = Path(path_str) | ||
| 303 | if not path.exists(): | ||
| 304 | logger.warning(f"Path does not exist: {path}") | ||
| 305 | continue | ||
| 306 | |||
| 307 | path_matches = await cls._search_single_path(path, query, max_results, autocomplete, filetype) | ||
| 308 | matches.extend(path_matches) | ||
| 309 | file_count += len(path_matches) | ||
| 310 | |||
| 311 | if len(matches) >= max_results * 5: | ||
| 312 | break | ||
| 313 | |||
| 314 | return matches, file_count | ||
| 315 | |||
| 316 | @classmethod | ||
| 317 | async def search_local(cls, query: str = None, max_results: int = 25, autocomplete: bool = False, filetype: str = None): | ||
| 318 | """Search for files with fuzzy matching and optional autocomplete support. | ||
| 228 | 319 | ||
| 229 | Args: | 320 | Args: |
| 230 | query: Search query string (can be None for autocomplete) | 321 | query: Search query string (can be None for autocomplete) |
| 231 | max_results: Maximum number of results to return | 322 | max_results: Maximum number of results to return |
| 232 | autocomplete: If True, returns results even with empty query | 323 | autocomplete: If True, returns results even with empty query |
| 324 | filetype: Type of files to search for ('Audio', 'Video', 'Image', or None for all) | ||
| 233 | 325 | ||
| 234 | Returns: | 326 | Returns: |
| 235 | List of matching file paths with scores | 327 | List of matching file paths with scores |
| @@ -238,30 +330,17 @@ class AudioSearcher: | |||
| 238 | return [] | 330 | return [] |
| 239 | 331 | ||
| 240 | query = (query or '').lower().strip() | 332 | query = (query or '').lower().strip() |
| 241 | logger.info(f"Starting local search for query: '{query}'") | 333 | logger.info(f"Starting local search for query: '{query}' (filetype: {filetype or 'All'})") |
| 242 | 334 | ||
| 243 | try: | 335 | try: |
| 244 | async with aiofiles.open('settings.json') as f: | 336 | async with aiofiles.open('settings.json') as f: |
| 245 | content = await f.read() | 337 | content = await f.read() |
| 246 | data = json.loads(content) | 338 | data = json.loads(content) |
| 247 | search_paths = data.get('audio_paths', []) | ||
| 248 | logger.info(f"Searching in paths: {search_paths}") | ||
| 249 | 339 | ||
| 250 | matches = [] | 340 | search_paths = cls._get_search_paths(data, filetype) |
| 251 | file_count = 0 | 341 | logger.info(f"Searching in paths: {search_paths}") |
| 252 | 342 | ||
| 253 | for path_str in search_paths: | 343 | matches, file_count = await cls._search_paths(search_paths, query, max_results, autocomplete, filetype) |
| 254 | path = Path(path_str) | ||
| 255 | if not path.exists(): | ||
| 256 | logger.warning(f"Path does not exist: {path}") | ||
| 257 | continue | ||
| 258 | |||
| 259 | path_matches = await cls._search_single_path(path, query, max_results, autocomplete) | ||
| 260 | matches.extend(path_matches) | ||
| 261 | file_count += len(path_matches) | ||
| 262 | |||
| 263 | if len(matches) >= max_results * 5: | ||
| 264 | break | ||
| 265 | 344 | ||
| 266 | logger.info(f"Scanned {file_count} files total") | 345 | logger.info(f"Scanned {file_count} files total") |
| 267 | 346 | ||
| @@ -278,10 +357,144 @@ class AudioSearcher: | |||
| 278 | return [] | 357 | return [] |
| 279 | 358 | ||
| 280 | @classmethod | 359 | @classmethod |
| 281 | async def search_local_autocomplete(cls, query: str = None, max_results: int = 10): | 360 | async def search_local_autocomplete(cls, query: str = None, max_results: int = 10, filetype: str = None): |
| 282 | """Wrapper for autocomplete searches that returns more results and allows empty query.""" | 361 | """Wrapper for autocomplete searches that returns more results and allows empty query. |
| 362 | |||
| 363 | Args: | ||
| 364 | query: Search query string (can be None for autocomplete) | ||
| 365 | max_results: Maximum number of results to return | ||
| 366 | filetype: Type of files to search for ('Audio', 'Video', 'Image', or None for all) | ||
| 367 | """ | ||
| 283 | # For autocomplete, we want to show more options and allow empty queries | 368 | # For autocomplete, we want to show more options and allow empty queries |
| 284 | if not query: | 369 | if not query: |
| 285 | return [] | 370 | return [] |
| 286 | 371 | ||
| 287 | return await cls.search_local(query, max_results=max_results, autocomplete=True) | 372 | return await cls.search_local(query, max_results=max_results, autocomplete=True, filetype=filetype) |
| 373 | |||
| 374 | @staticmethod | ||
| 375 | async def _load_baseurl(): | ||
| 376 | """Load baseurl from settings.json""" | ||
| 377 | try: | ||
| 378 | async with aiofiles.open('settings.json') as f: | ||
| 379 | content = await f.read() | ||
| 380 | data = json.loads(content) | ||
| 381 | return data.get('baseurl', 'http://localhost:8080').rstrip('/') | ||
| 382 | except Exception as e: | ||
| 383 | logger.error(f"Error loading baseurl: {e}") | ||
| 384 | return 'http://localhost:8080' | ||
| 385 | |||
| 386 | @staticmethod | ||
| 387 | async def search_copyparty(query: str, max_results: int = 25): | ||
| 388 | """Search for files using copyparty API (jPOST method). | ||
| 389 | |||
| 390 | Args: | ||
| 391 | query: Search query string | ||
| 392 | max_results: Maximum number of results to return | ||
| 393 | |||
| 394 | Returns: | ||
| 395 | List of matching file URLs | ||
| 396 | """ | ||
| 397 | |||
| 398 | |||
| 399 | if not query: | ||
| 400 | return [] | ||
| 401 | |||
| 402 | query = query.strip() | ||
| 403 | logger.info(f"Starting copyparty search for query: '{query}'") | ||
| 404 | query = f'name like "*{query}*"' | ||
| 405 | |||
| 406 | try: | ||
| 407 | baseurl = await UniversalSearcher._load_baseurl() | ||
| 408 | password = os.getenv("COPYPARTY_R_PASSWORD") | ||
| 409 | |||
| 410 | async with aiohttp.ClientSession() as session: | ||
| 411 | headers = {"Content-Type": "application/json"} | ||
| 412 | if password: | ||
| 413 | headers["PW"] = password | ||
| 414 | async with session.post(f"{baseurl}/?srch", headers=headers, data=json.dumps({"n": max_results, "q": query})) as response: | ||
| 415 | if response.status == 200: | ||
| 416 | data = await response.json() | ||
| 417 | logger.info(f"Copyparty response: {data}") | ||
| 418 | files = UniversalSearcher._extract_files_from_response(data) | ||
| 419 | results = UniversalSearcher._convert_files_to_urls(files, baseurl, max_results) | ||
| 420 | |||
| 421 | logger.info(f"Copyparty search complete. Found {len(results)} matches.") | ||
| 422 | return results | ||
| 423 | else: | ||
| 424 | logger.error(f"Copyparty API returned status {response.status}") | ||
| 425 | return [] | ||
| 426 | |||
| 427 | except aiohttp.ClientError as e: | ||
| 428 | logger.error(f"HTTP error during copyparty search: {e}") | ||
| 429 | return [] | ||
| 430 | except Exception as e: | ||
| 431 | logger.error(f"Error during copyparty search: {str(e)}", exc_info=True) | ||
| 432 | return [] | ||
| 433 | |||
| 434 | @staticmethod | ||
| 435 | def _extract_files_from_response(data): | ||
| 436 | """Extract files list from copyparty API response.""" | ||
| 437 | if isinstance(data, dict): | ||
| 438 | if 'hits' in data: | ||
| 439 | return data['hits'] | ||
| 440 | elif 'files' in data: | ||
| 441 | return data['files'] | ||
| 442 | elif 'results' in data: | ||
| 443 | return data['results'] | ||
| 444 | else: | ||
| 445 | return data if isinstance(data, list) else [] | ||
| 446 | elif isinstance(data, list): | ||
| 447 | return data | ||
| 448 | else: | ||
| 449 | return [] | ||
| 450 | |||
| 451 | @staticmethod | ||
| 452 | def _convert_files_to_urls(files, baseurl, max_results): | ||
| 453 | """Convert file info objects to URLs.""" | ||
| 454 | results = [] | ||
| 455 | |||
| 456 | for file_info in files[:max_results]: | ||
| 457 | if isinstance(file_info, dict): | ||
| 458 | file_url = UniversalSearcher._extract_url_from_dict(file_info, baseurl) | ||
| 459 | if file_url: | ||
| 460 | results.append(file_url) | ||
| 461 | elif isinstance(file_info, str): | ||
| 462 | # If it's just a string path/URL | ||
| 463 | if file_info.startswith('http'): | ||
| 464 | results.append(file_info) | ||
| 465 | else: | ||
| 466 | results.append(f"{baseurl}/{file_info.lstrip('/')}") | ||
| 467 | |||
| 468 | return results | ||
| 469 | |||
| 470 | @staticmethod | ||
| 471 | def _extract_url_from_dict(file_info, baseurl): | ||
| 472 | """Extract URL from file info dictionary.""" | ||
| 473 | if 'url' in file_info: | ||
| 474 | return file_info['url'] | ||
| 475 | elif 'rp' in file_info: | ||
| 476 | return f"{baseurl}/{file_info['rp']}" | ||
| 477 | elif 'path' in file_info: | ||
| 478 | return f"{baseurl}/{file_info['path'].lstrip('/')}" | ||
| 479 | elif 'name' in file_info: | ||
| 480 | return f"{baseurl}/{file_info['name'].lstrip('/')}" | ||
| 481 | else: | ||
| 482 | return None | ||
| 483 | |||
| 484 | @staticmethod | ||
| 485 | async def search_copyparty_autocomplete(query: str, max_results: int = 10): | ||
| 486 | """Wrapper for autocomplete searches using copyparty. | ||
| 487 | |||
| 488 | Args: | ||
| 489 | query: Search query string | ||
| 490 | max_results: Maximum number of results to return | ||
| 491 | |||
| 492 | Returns: | ||
| 493 | List of matching file URLs | ||
| 494 | """ | ||
| 495 | if not query: | ||
| 496 | return [] | ||
| 497 | |||
| 498 | return await UniversalSearcher.search_copyparty(query, max_results) | ||
| 499 | |||
| 500 | |||
diff --git a/cogs/vclogs.py b/cogs/vclogs.py index 901b557..9ed7134 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py | |||
| @@ -7,28 +7,27 @@ import datetime | |||
| 7 | import json | 7 | import json |
| 8 | import logging | 8 | import logging |
| 9 | import os | 9 | import os |
| 10 | import asyncio | ||
| 10 | import discord | 11 | import discord |
| 11 | from discord.ext import commands | 12 | from discord.ext import commands |
| 12 | from gtts import gTTS | 13 | from gtts import gTTS |
| 13 | import aiofiles | 14 | import aiofiles |
| 15 | from cogs.voice import get_voice_manager | ||
| 14 | 16 | ||
| 15 | logger = logging.getLogger(__name__) | 17 | logger = logging.getLogger(__name__) |
| 16 | 18 | ||
| 17 | 19 | ||
| 18 | |||
| 19 | class VcLogs(commands.Cog): | 20 | class VcLogs(commands.Cog): |
| 20 | def __init__(self, bot): | 21 | def __init__(self, bot): |
| 21 | self.bot = bot | 22 | self.bot = bot |
| 22 | self.settings_check() | 23 | self._voice_manager = None |
| 23 | 24 | ||
| 24 | settings_file = 'settings.json' | 25 | def get_voice_manager(self): |
| 26 | if self._voice_manager is None: | ||
| 27 | self._voice_manager = get_voice_manager(self.bot) | ||
| 28 | return self._voice_manager | ||
| 25 | 29 | ||
| 26 | @staticmethod | 30 | settings_file = 'settings.json' |
| 27 | def settings_check(): | ||
| 28 | if not os.path.exists(VcLogs.settings_file): | ||
| 29 | with open(VcLogs.settings_file, 'w') as f: | ||
| 30 | json.dump({'volume': '5'}, f, indent=4) | ||
| 31 | logger.info("settings.json created") | ||
| 32 | 31 | ||
| 33 | @staticmethod | 32 | @staticmethod |
| 34 | async def handle_state_change(member, channel, state_type, is_enabled, current_time, log_channel): | 33 | async def handle_state_change(member, channel, state_type, is_enabled, current_time, log_channel): |
| @@ -62,32 +61,34 @@ class VcLogs(commands.Cog): | |||
| 62 | return tts_text, channel | 61 | return tts_text, channel |
| 63 | 62 | ||
| 64 | async def play_tts(self, channel, text): | 63 | async def play_tts(self, channel, text): |
| 65 | tts_file = "temp_tts.mp3" | 64 | voice_mgr = self.get_voice_manager() |
| 66 | # Generate TTS audio | 65 | |
| 66 | temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp') | ||
| 67 | os.makedirs(temp_dir, exist_ok=True) | ||
| 68 | tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}.mp3") | ||
| 67 | tts = gTTS(text=text, lang='pl') | 69 | tts = gTTS(text=text, lang='pl') |
| 70 | logger.info("saving file") | ||
| 68 | tts.save(tts_file) | 71 | tts.save(tts_file) |
| 69 | 72 | ||
| 70 | if os.path.exists(VcLogs.settings_file): | 73 | if os.path.exists(VcLogs.settings_file): |
| 71 | async with aiofiles.open(VcLogs.settings_file) as f: | 74 | async with aiofiles.open(VcLogs.settings_file) as f: |
| 72 | content = await f.read() | 75 | content = await f.read() |
| 73 | data = json.loads(content) | 76 | data = json.loads(content) |
| 74 | volume = data['volume'] | 77 | volume = data.get('volume', 1.0) |
| 75 | else: | 78 | |
| 76 | logger.error("settings.json not found") | 79 | voice_client = await voice_mgr.get_voice_client(channel.guild) |
| 77 | 80 | ||
| 78 | # Connect to the voice channel if not already connected | 81 | if voice_client is None: |
| 79 | voice_client = discord.utils.get(self.bot.voice_clients, guild=channel.guild) | 82 | voice_client = await voice_mgr.join_channel(channel) |
| 80 | if not isinstance(voice_client, discord.VoiceClient): | 83 | |
| 81 | voice_client = await channel.connect() | 84 | logger.info(f"connected: {voice_client}") |
| 82 | elif voice_client.channel != channel: | 85 | |
| 83 | await voice_client.move_to(channel) | 86 | if voice_client and not voice_client.is_playing(): |
| 84 | 87 | logger.info("preparing to play audio") | |
| 85 | # Play the TTS audio | ||
| 86 | if not voice_client.is_playing(): | ||
| 87 | audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') | 88 | audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') |
| 88 | voice_client.play(audio_source) | 89 | voice_client.play(audio_source) |
| 89 | while voice_client.is_playing(): | 90 | while voice_client.is_playing(): |
| 90 | await discord.utils.sleep_until(datetime.datetime.now() + datetime.timedelta(seconds=1)) | 91 | await asyncio.sleep(0.5) |
| 91 | if os.path.exists(tts_file): | 92 | if os.path.exists(tts_file): |
| 92 | os.remove(tts_file) | 93 | os.remove(tts_file) |
| 93 | 94 | ||
| @@ -96,10 +97,6 @@ class VcLogs(commands.Cog): | |||
| 96 | ) | 97 | ) |
| 97 | async def volume(self, ctx: discord.ApplicationContext, volume: int): | 98 | async def volume(self, ctx: discord.ApplicationContext, volume: int): |
| 98 | try: | 99 | try: |
| 99 | if not os.path.exists(VcLogs.settings_file): | ||
| 100 | async with aiofiles.open(VcLogs.settings_file, 'w') as f: | ||
| 101 | json.dump({'volume': '5'}, f, indent=4) | ||
| 102 | logger.info("settings.json created") | ||
| 103 | async with aiofiles.open(VcLogs.settings_file, 'w') as f: | 100 | async with aiofiles.open(VcLogs.settings_file, 'w') as f: |
| 104 | json.dump({"volume": volume}, f, indent=4) | 101 | json.dump({"volume": volume}, f, indent=4) |
| 105 | await ctx.followup.send(f"Volume has been set to: {volume}") | 102 | await ctx.followup.send(f"Volume has been set to: {volume}") |
diff --git a/cogs/voice.py b/cogs/voice.py index dd5b74a..8ee3699 100644 --- a/cogs/voice.py +++ b/cogs/voice.py | |||
| @@ -10,33 +10,117 @@ import asyncio | |||
| 10 | 10 | ||
| 11 | logger = logging.getLogger(__name__) | 11 | logger = logging.getLogger(__name__) |
| 12 | 12 | ||
| 13 | _voice_manager = None | ||
| 14 | |||
| 15 | def get_voice_manager(bot): | ||
| 16 | global _voice_manager | ||
| 17 | if _voice_manager is None: | ||
| 18 | _voice_manager = VoiceManager(bot) | ||
| 19 | return _voice_manager | ||
| 20 | |||
| 21 | |||
| 22 | class VoiceManager: | ||
| 23 | def __init__(self, bot): | ||
| 24 | self.bot = bot | ||
| 25 | self._voice_lock = asyncio.Lock() | ||
| 26 | |||
| 27 | async def join_channel(self, channel): | ||
| 28 | """Join a voice channel by channel object.""" | ||
| 29 | async with self._voice_lock: | ||
| 30 | guild = channel.guild | ||
| 31 | voice_client = discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 32 | |||
| 33 | if voice_client and voice_client.is_connected(): | ||
| 34 | if voice_client.channel == channel: | ||
| 35 | logger.info(f"Already connected to {channel}") | ||
| 36 | return voice_client | ||
| 37 | try: | ||
| 38 | await voice_client.move_to(channel) | ||
| 39 | logger.info(f"Moved to voice channel: {channel}") | ||
| 40 | return voice_client | ||
| 41 | except Exception as e: | ||
| 42 | logger.error(f"Failed to move voice client: {e}") | ||
| 43 | try: | ||
| 44 | await voice_client.disconnect() | ||
| 45 | except Exception: | ||
| 46 | pass | ||
| 47 | |||
| 48 | await asyncio.sleep(0.3) | ||
| 49 | try: | ||
| 50 | voice_client = await channel.connect(timeout=30.0, reconnect=False) | ||
| 51 | logger.info(f"Connected to voice channel: {channel}") | ||
| 52 | return voice_client | ||
| 53 | except Exception as e: | ||
| 54 | logger.error(f"Failed to connect to voice channel: {e}") | ||
| 55 | return None | ||
| 56 | |||
| 57 | async def join_by_channel_id(self, channel_id): | ||
| 58 | """Join a voice channel by channel ID.""" | ||
| 59 | channel = self.bot.get_channel(channel_id) | ||
| 60 | if not channel: | ||
| 61 | logger.error(f"Channel {channel_id} not found") | ||
| 62 | return None | ||
| 63 | if not isinstance(channel, discord.VoiceChannel): | ||
| 64 | logger.error(f"Channel {channel_id} is not a voice channel") | ||
| 65 | return None | ||
| 66 | return await self.join_channel(channel) | ||
| 67 | |||
| 68 | async def get_voice_client(self, guild): | ||
| 69 | """Get existing voice client for a guild.""" | ||
| 70 | return discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 71 | |||
| 72 | async def play_audio(self, channel, audio_source, volume=1.0): | ||
| 73 | """Play audio in a voice channel.""" | ||
| 74 | voice_client = await self.join_channel(channel) | ||
| 75 | if not voice_client: | ||
| 76 | return False | ||
| 77 | |||
| 78 | if volume != 1.0: | ||
| 79 | audio_source = discord.PCMVolumeTransformer(audio_source) | ||
| 80 | audio_source.volume = volume | ||
| 81 | |||
| 82 | voice_client.play(audio_source) | ||
| 83 | |||
| 84 | while voice_client.is_playing(): | ||
| 85 | await asyncio.sleep(0.1) | ||
| 86 | return True | ||
| 87 | |||
| 88 | async def stop(self, guild): | ||
| 89 | """Stop playing and disconnect from voice.""" | ||
| 90 | voice_client = await self.get_voice_client(guild) | ||
| 91 | if voice_client: | ||
| 92 | voice_client.stop() | ||
| 93 | await voice_client.disconnect() | ||
| 94 | logger.info(f"Stopped and disconnected from guild {guild}") | ||
| 95 | |||
| 96 | |||
| 13 | class Voice(commands.Cog): | 97 | class Voice(commands.Cog): |
| 14 | def __init__(self, bot): | 98 | def __init__(self, bot): |
| 15 | self.bot = bot | 99 | self.bot = bot |
| 100 | self._voice_lock = asyncio.Lock() | ||
| 16 | 101 | ||
| 17 | async def _validate_user_voice_state(self, ctx): | 102 | async def _validate_user_voice_state(self, ctx): |
| 18 | """Validate that user is in a voice channel.""" | ||
| 19 | if not ctx.author.voice or not ctx.author.voice.channel: | 103 | if not ctx.author.voice or not ctx.author.voice.channel: |
| 20 | await ctx.send("You need to be in a voice channel first!") | 104 | await ctx.send("You need to be in a voice channel first!") |
| 21 | return False | 105 | return False |
| 22 | return True | 106 | return True |
| 23 | 107 | ||
| 24 | async def _create_voice_client(self, ctx): | 108 | async def _create_voice_client(self, ctx): |
| 25 | """Create a new voice client connection.""" | 109 | async with self._voice_lock: |
| 26 | try: | 110 | try: |
| 27 | voice_client = await ctx.author.voice.channel.connect(timeout=30.0, reconnect=True) | 111 | await asyncio.sleep(0.5) |
| 28 | logger.info(f"Connected to voice channel: {ctx.author.voice.channel}") | 112 | voice_client = await ctx.author.voice.channel.connect(timeout=30.0, reconnect=False) |
| 29 | return voice_client | 113 | logger.info(f"Connected to voice channel: {ctx.author.voice.channel}") |
| 30 | except asyncio.TimeoutError: | 114 | return voice_client |
| 31 | await ctx.send("Connection to voice channel timed out.") | 115 | except asyncio.TimeoutError: |
| 32 | return None | 116 | await ctx.send("Connection to voice channel timed out.") |
| 33 | except discord.ClientException as e: | 117 | return None |
| 34 | logger.error(f"Failed to connect to voice channel: {e}") | 118 | except discord.ClientException as e: |
| 35 | await ctx.send("Failed to connect to voice channel. Please try again.", ephemeral=True) | 119 | logger.error(f"Failed to connect to voice channel: {e}") |
| 36 | return None | 120 | await ctx.send("Failed to connect to voice channel. Please try again.", ephemeral=True) |
| 121 | return None | ||
| 37 | 122 | ||
| 38 | async def _move_voice_client(self, voice_client, ctx): | 123 | async def _move_voice_client(self, voice_client, ctx): |
| 39 | """Move existing voice client to user's channel.""" | ||
| 40 | try: | 124 | try: |
| 41 | await voice_client.move_to(ctx.author.voice.channel) | 125 | await voice_client.move_to(ctx.author.voice.channel) |
| 42 | logger.info(f"Moved to voice channel: {ctx.author.voice.channel}") | 126 | logger.info(f"Moved to voice channel: {ctx.author.voice.channel}") |
| @@ -50,10 +134,9 @@ class Voice(commands.Cog): | |||
| 50 | return None | 134 | return None |
| 51 | 135 | ||
| 52 | async def _verify_connection(self, voice_client): | 136 | async def _verify_connection(self, voice_client): |
| 53 | """Verify and repair voice client connection if needed.""" | ||
| 54 | if not voice_client.is_connected(): | 137 | if not voice_client.is_connected(): |
| 55 | try: | 138 | try: |
| 56 | await voice_client.connect(reconnect=True, timeout=10.0) | 139 | await voice_client.connect(reconnect=False, timeout=10.0) |
| 57 | return voice_client | 140 | return voice_client |
| 58 | except Exception as e: | 141 | except Exception as e: |
| 59 | logger.error(f"Failed to reconnect to voice: {e}") | 142 | logger.error(f"Failed to reconnect to voice: {e}") |
| @@ -61,31 +144,30 @@ class Voice(commands.Cog): | |||
| 61 | return voice_client | 144 | return voice_client |
| 62 | 145 | ||
| 63 | async def ensure_voice_client(self, ctx): | 146 | async def ensure_voice_client(self, ctx): |
| 64 | """Ensure we have a valid voice client connection.""" | ||
| 65 | if not await self._validate_user_voice_state(ctx): | 147 | if not await self._validate_user_voice_state(ctx): |
| 66 | return None | 148 | return None |
| 67 | 149 | ||
| 68 | voice_client = discord.utils.get(self.bot.voice_clients, guild=ctx.guild) | 150 | voice_client = discord.utils.get(self.bot.voice_clients, guild=ctx.guild) |
| 69 | 151 | ||
| 70 | # If no voice client exists, create one | 152 | if voice_client and not voice_client.is_connected(): |
| 153 | try: | ||
| 154 | await voice_client.disconnect() | ||
| 155 | except Exception: | ||
| 156 | pass | ||
| 157 | voice_client = None | ||
| 158 | |||
| 71 | if not voice_client or not voice_client.is_connected(): | 159 | if not voice_client or not voice_client.is_connected(): |
| 160 | if not await self._validate_user_voice_state(ctx): | ||
| 161 | return None | ||
| 72 | return await self._create_voice_client(ctx) | 162 | return await self._create_voice_client(ctx) |
| 73 | 163 | ||
| 74 | # If we're in a different channel, move to the user's channel | ||
| 75 | if voice_client.channel != ctx.author.voice.channel: | 164 | if voice_client.channel != ctx.author.voice.channel: |
| 76 | return await self._move_voice_client(voice_client, ctx) | 165 | return await self._move_voice_client(voice_client, ctx) |
| 77 | 166 | ||
| 78 | # Verify the connection is healthy | ||
| 79 | return await self._verify_connection(voice_client) | 167 | return await self._verify_connection(voice_client) |
| 80 | 168 | ||
| 81 | async def connect(self, ctx): | 169 | async def connect(self, ctx): |
| 82 | """Connect to the user's voice channel with improved connection handling.""" | ||
| 83 | voice_client = await self.ensure_voice_client(ctx) | 170 | voice_client = await self.ensure_voice_client(ctx) |
| 84 | if not voice_client: | 171 | if not voice_client: |
| 85 | return None | 172 | return None |
| 86 | 173 | return voice_client \ No newline at end of file | |
| 87 | return voice_client | ||
| 88 | |||
| 89 | |||
| 90 | def setup(bot): | ||
| 91 | bot.add_cog(Voice(bot)) \ No newline at end of file | ||
