diff options
Diffstat (limited to 'cogs/music')
| -rw-r--r-- | cogs/music/audio_playback.py | 179 | ||||
| -rw-r--r-- | cogs/music/media_processing.py | 124 | ||||
| -rw-r--r-- | cogs/music/play_commands.py (renamed from cogs/music/play.py) | 145 | ||||
| -rw-r--r-- | cogs/music/play_utils.py | 426 | ||||
| -rw-r--r-- | cogs/music/search_utils.py | 286 |
5 files changed, 670 insertions, 490 deletions
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py new file mode 100644 index 0000000..3d4f453 --- /dev/null +++ b/cogs/music/audio_playback.py | |||
| @@ -0,0 +1,179 @@ | |||
| 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 os | ||
| 7 | import logging | ||
| 8 | from pathlib import Path | ||
| 9 | |||
| 10 | import discord | ||
| 11 | import mutagen | ||
| 12 | |||
| 13 | logger = logging.getLogger(__name__) | ||
| 14 | |||
| 15 | class AudioPlaybackManager: | ||
| 16 | """Handles audio playback, voice connections, and source preparation.""" | ||
| 17 | |||
| 18 | @staticmethod | ||
| 19 | async def cleanup_temp_file(source): | ||
| 20 | """Clean up temporary file if it exists.""" | ||
| 21 | if source and os.path.exists(source): | ||
| 22 | try: | ||
| 23 | os.remove(source) | ||
| 24 | logger.info(f"Cleaned up temporary file: {source}") | ||
| 25 | except Exception as e: | ||
| 26 | logger.error(f"Error cleaning up file {source}: {str(e)}") | ||
| 27 | |||
| 28 | @staticmethod | ||
| 29 | async def validate_voice_channel(ctx): | ||
| 30 | """Validate user is in a voice channel. Returns channel or None.""" | ||
| 31 | if not ctx.author.voice or not ctx.author.voice.channel: | ||
| 32 | await ctx.respond("Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True) | ||
| 33 | return None | ||
| 34 | return ctx.author.voice.channel | ||
| 35 | |||
| 36 | @staticmethod | ||
| 37 | async def prepare_file_source(source): | ||
| 38 | """Prepare and validate local file source. Returns (display_name, absolute_path) or (None, None).""" | ||
| 39 | logger.info(f"Attempting to play local file: {source}") | ||
| 40 | |||
| 41 | # Get absolute path and verify file exists | ||
| 42 | if not os.path.isabs(source): | ||
| 43 | source = os.path.abspath(source) | ||
| 44 | logger.debug(f"Converted to absolute path: {source}") | ||
| 45 | |||
| 46 | if not os.path.exists(source): | ||
| 47 | logger.error(f"File not found: {source}") | ||
| 48 | return None, None | ||
| 49 | |||
| 50 | filename = os.path.basename(source) | ||
| 51 | display_name = os.path.splitext(filename)[0] | ||
| 52 | return display_name, source | ||
| 53 | |||
| 54 | @staticmethod | ||
| 55 | async def connect_to_voice_channel(ctx, vc, target_channel): | ||
| 56 | """Connect or move to voice channel. Returns voice client.""" | ||
| 57 | try: | ||
| 58 | # Connect to voice channel if not connected | ||
| 59 | if vc is None: | ||
| 60 | logger.info(f"Connecting to voice channel: {target_channel.name}") | ||
| 61 | vc = await target_channel.connect() | ||
| 62 | logger.info(f"Connected to voice channel: {target_channel.name}") | ||
| 63 | # Move to user's channel if in a different one | ||
| 64 | elif vc.channel != target_channel: | ||
| 65 | logger.info(f"Moving to voice channel: {target_channel.name}") | ||
| 66 | await vc.move_to(target_channel) | ||
| 67 | logger.info(f"Moved to voice channel: {target_channel.name}") | ||
| 68 | return vc | ||
| 69 | except Exception as e: | ||
| 70 | logger.error(f"Error connecting to voice channel: {str(e)}") | ||
| 71 | raise | ||
| 72 | |||
| 73 | @staticmethod | ||
| 74 | async def prepare_audio_source(source, is_url, display_name): | ||
| 75 | """Prepare audio source for playback. Returns (source, final_display_name).""" | ||
| 76 | if is_url: | ||
| 77 | # For URLs, use FFmpeg with the direct URL | ||
| 78 | ffmpeg_options = { | ||
| 79 | 'options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', | ||
| 80 | 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5' | ||
| 81 | } | ||
| 82 | audio_source = discord.FFmpegPCMAudio(source, **ffmpeg_options) | ||
| 83 | return audio_source, display_name | ||
| 84 | else: | ||
| 85 | # For local files, use default options | ||
| 86 | audio_source = discord.FFmpegPCMAudio(source=source) | ||
| 87 | |||
| 88 | # Try to get metadata for local files | ||
| 89 | try: | ||
| 90 | metadata = mutagen.File(source) | ||
| 91 | if metadata: | ||
| 92 | title = metadata.get('title', [None])[0] if hasattr(metadata, 'get') else None | ||
| 93 | if title: | ||
| 94 | display_name = title | ||
| 95 | logger.debug(f"Read metadata - title: {title}") | ||
| 96 | except Exception as e: | ||
| 97 | logger.warning(f"Could not read metadata from {source}: {str(e)}") | ||
| 98 | |||
| 99 | return audio_source, display_name | ||
| 100 | |||
| 101 | @staticmethod | ||
| 102 | async def cleanup_if_needed(source, delete_after_play, is_url): | ||
| 103 | """Clean up temporary file if needed.""" | ||
| 104 | if delete_after_play and not is_url: | ||
| 105 | await AudioPlaybackManager.cleanup_temp_file(source) | ||
| 106 | |||
| 107 | @staticmethod | ||
| 108 | async def prepare_source(ctx, source): | ||
| 109 | """Prepare source and get display name. Returns (source, display_name, is_url) or (None, None, None).""" | ||
| 110 | is_url = source.startswith(('http://', 'https://')) | ||
| 111 | |||
| 112 | if is_url: | ||
| 113 | logger.info(f"Attempting to play stream from URL: {source}") | ||
| 114 | return source, source, is_url | ||
| 115 | |||
| 116 | display_name, absolute_source = await AudioPlaybackManager.prepare_file_source(source) | ||
| 117 | if not display_name: | ||
| 118 | return None, None, None | ||
| 119 | |||
| 120 | return absolute_source, display_name, is_url | ||
| 121 | |||
| 122 | @staticmethod | ||
| 123 | async def execute_playback(ctx, source, display_name, is_url): | ||
| 124 | """Execute the actual audio playback.""" | ||
| 125 | vc = ctx.voice_client | ||
| 126 | target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) | ||
| 127 | if not target_channel: | ||
| 128 | return False | ||
| 129 | |||
| 130 | try: | ||
| 131 | vc = await AudioPlaybackManager.connect_to_voice_channel(ctx, vc, target_channel) | ||
| 132 | |||
| 133 | # Stop any currently playing audio | ||
| 134 | if vc.is_playing() or vc.is_paused(): | ||
| 135 | vc.stop() | ||
| 136 | |||
| 137 | # Prepare audio source | ||
| 138 | audio_source, final_display_name = await AudioPlaybackManager.prepare_audio_source(source, is_url, display_name) | ||
| 139 | |||
| 140 | # Play the audio | ||
| 141 | vc.play(audio_source) | ||
| 142 | logger.info(f"Playing: {final_display_name}") | ||
| 143 | await ctx.respond(f"grom: {final_display_name}") | ||
| 144 | return True | ||
| 145 | |||
| 146 | except Exception as e: | ||
| 147 | logger.error(f"Error in play: {str(e)}", exc_info=True) | ||
| 148 | await ctx.respond(f"eror: {str(e)}", ephemeral=True) | ||
| 149 | return False | ||
| 150 | |||
| 151 | @staticmethod | ||
| 152 | async def validate_and_prepare_source(ctx, source): | ||
| 153 | """Validate and prepare source for playback. Returns (source, display_name, is_url) or (None, None, None).""" | ||
| 154 | prepared_source, display_name, is_url = await AudioPlaybackManager.prepare_source(ctx, source) | ||
| 155 | if not prepared_source: | ||
| 156 | return None, None, None | ||
| 157 | return prepared_source, display_name, is_url | ||
| 158 | |||
| 159 | @staticmethod | ||
| 160 | async def play(ctx, source, delete_after_play=False): | ||
| 161 | """Play audio from a local file or URL. | ||
| 162 | |||
| 163 | Args: | ||
| 164 | ctx: Discord context | ||
| 165 | source: Either a local file path or a URL to stream from | ||
| 166 | delete_after_play: If True, delete the file after playing (only for local files) | ||
| 167 | """ | ||
| 168 | # Validate and prepare source | ||
| 169 | prepared_source, display_name, is_url = await AudioPlaybackManager.validate_and_prepare_source(ctx, source) | ||
| 170 | if not prepared_source: | ||
| 171 | await AudioPlaybackManager.cleanup_if_needed(source, delete_after_play, False) | ||
| 172 | return | ||
| 173 | |||
| 174 | # Execute playback | ||
| 175 | success = await AudioPlaybackManager.execute_playback(ctx, prepared_source, display_name, is_url) | ||
| 176 | |||
| 177 | # Cleanup if needed | ||
| 178 | if not success: | ||
| 179 | await AudioPlaybackManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
diff --git a/cogs/music/media_processing.py b/cogs/music/media_processing.py new file mode 100644 index 0000000..430b51f --- /dev/null +++ b/cogs/music/media_processing.py | |||
| @@ -0,0 +1,124 @@ | |||
| 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 os | ||
| 7 | import asyncio | ||
| 8 | import logging | ||
| 9 | from pathlib import Path | ||
| 10 | |||
| 11 | import yt_dlp | ||
| 12 | from youtubesearchpython import VideosSearch | ||
| 13 | |||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | |||
| 16 | class MediaDownloader: | ||
| 17 | """Handles downloading media from various sources.""" | ||
| 18 | |||
| 19 | @staticmethod | ||
| 20 | async def download(ctx, url): | ||
| 21 | """Download audio from YouTube URL.""" | ||
| 22 | # Create temp directory if it doesn't exist | ||
| 23 | temp_dir = os.path.join('cogs', 'temp', 'music') | ||
| 24 | os.makedirs(temp_dir, exist_ok=True) | ||
| 25 | |||
| 26 | ytdl_opts = { | ||
| 27 | "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), | ||
| 28 | "postprocessors": [ | ||
| 29 | { | ||
| 30 | "key": "FFmpegExtractAudio", | ||
| 31 | "preferredcodec": "mp3", | ||
| 32 | "preferredquality": "192", | ||
| 33 | } | ||
| 34 | ], | ||
| 35 | "force_ipv4": True, | ||
| 36 | } | ||
| 37 | |||
| 38 | try: | ||
| 39 | with yt_dlp.YoutubeDL(ytdl_opts) as ydl: | ||
| 40 | info_dict = ydl.extract_info(url, download=True) | ||
| 41 | # Get the base filename without extension | ||
| 42 | base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] | ||
| 43 | # Add .mp3 extension since we're extracting audio | ||
| 44 | filename = f"{base_filename}.mp3" | ||
| 45 | logger.info(f"Downloaded file: {filename}") | ||
| 46 | return filename | ||
| 47 | except Exception as e: | ||
| 48 | logger.error(f"Error downloading video: {e}") | ||
| 49 | await ctx.respond("eror pobirajync wideło", ephemeral=True) | ||
| 50 | |||
| 51 | @staticmethod | ||
| 52 | async def get_url(ctx, query): | ||
| 53 | """Search YouTube and get URL for a query.""" | ||
| 54 | search = VideosSearch(query, limit=1) | ||
| 55 | if search.result()["result"]: | ||
| 56 | title = search.result()["result"][0]["title"] | ||
| 57 | url = search.result()["result"][0]["link"] | ||
| 58 | logger.info(f"Found video: {title}") | ||
| 59 | return url, title | ||
| 60 | else: | ||
| 61 | logger.warning("No results found for the query.") | ||
| 62 | await ctx.respond("nic ni ma", ephemeral=True) | ||
| 63 | return None | ||
| 64 | |||
| 65 | class AudioCompressor: | ||
| 66 | """Handles audio compression using FFmpeg.""" | ||
| 67 | |||
| 68 | @staticmethod | ||
| 69 | async def compress(ctx, filename): | ||
| 70 | """Compress audio file to opus format.""" | ||
| 71 | if not filename: | ||
| 72 | await ctx.respond("dzie wideło", ephemeral=True) | ||
| 73 | return None | ||
| 74 | |||
| 75 | # Create temp directory if it doesn't exist | ||
| 76 | temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp', 'compressed') | ||
| 77 | os.makedirs(temp_dir, exist_ok=True) | ||
| 78 | |||
| 79 | # Create a temporary output filename in the temp directory | ||
| 80 | base_name = os.path.basename(filename) | ||
| 81 | output_filename = os.path.join(temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus") | ||
| 82 | |||
| 83 | # Ensure the output directory exists | ||
| 84 | os.makedirs(os.path.dirname(output_filename) or '.', exist_ok=True) | ||
| 85 | |||
| 86 | # Use proper quoting for filenames with spaces | ||
| 87 | ffmpeg_command = [ | ||
| 88 | 'ffmpeg', | ||
| 89 | '-y', # Overwrite output file if it exists | ||
| 90 | '-i', filename, | ||
| 91 | '-ac', '1', # Mono audio | ||
| 92 | '-b:a', '500', # 500k bitrate | ||
| 93 | '-acodec', 'libopus', | ||
| 94 | output_filename | ||
| 95 | ] | ||
| 96 | |||
| 97 | try: | ||
| 98 | logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}") | ||
| 99 | process = await asyncio.create_subprocess_exec( | ||
| 100 | *ffmpeg_command, | ||
| 101 | stdout=asyncio.subprocess.PIPE, | ||
| 102 | stderr=asyncio.subprocess.PIPE | ||
| 103 | ) | ||
| 104 | |||
| 105 | _, stderr = await process.communicate() | ||
| 106 | |||
| 107 | if process.returncode != 0: | ||
| 108 | error_msg = stderr.decode() | ||
| 109 | logger.error(f"FFmpeg error ({process.returncode}): {error_msg}") | ||
| 110 | await ctx.respond("eror kompresujync pilk", ephemeral=True) | ||
| 111 | return None | ||
| 112 | |||
| 113 | if not os.path.exists(output_filename): | ||
| 114 | logger.error(f"Output file was not created: {output_filename}") | ||
| 115 | await ctx.respond("eror kompresujync pilk - output file not created", ephemeral=True) | ||
| 116 | return None | ||
| 117 | |||
| 118 | logger.info(f"Successfully compressed file: {output_filename}") | ||
| 119 | return output_filename | ||
| 120 | |||
| 121 | except Exception as e: | ||
| 122 | logger.error(f"Error compressing file: {str(e)}", exc_info=True) | ||
| 123 | await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True) | ||
| 124 | return None | ||
diff --git a/cogs/music/play.py b/cogs/music/play_commands.py index c7dd9f1..b25ce84 100644 --- a/cogs/music/play.py +++ b/cogs/music/play_commands.py | |||
| @@ -9,14 +9,16 @@ 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 play_utils | 12 | from cogs.music import search_utils, audio_playback, media_processing |
| 13 | from discord.ext import commands | 13 | from discord.ext import commands |
| 14 | from discord.commands import OptionChoice | 14 | from discord.commands import OptionChoice |
| 15 | import logging | 15 | import logging |
| 16 | 16 | ||
| 17 | logger = logging.getLogger(__name__) | 17 | logger = logging.getLogger(__name__) |
| 18 | 18 | ||
| 19 | class Play(commands.Cog): | 19 | class PlayCommands(commands.Cog): |
| 20 | """Discord slash commands for music playback.""" | ||
| 21 | |||
| 20 | def __init__(self, bot): | 22 | def __init__(self, bot): |
| 21 | self.bot = bot | 23 | self.bot = bot |
| 22 | self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp") | 24 | self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp") |
| @@ -83,38 +85,38 @@ class Play(commands.Cog): | |||
| 83 | 85 | ||
| 84 | await ctx.respond(embed=embed, view=view, ephemeral=True) | 86 | await ctx.respond(embed=embed, view=view, ephemeral=True) |
| 85 | 87 | ||
| 88 | def _get_display_name_from_metadata(self, path: str, filename: str) -> str: | ||
| 89 | """Extract display name from audio file metadata.""" | ||
| 90 | try: | ||
| 91 | if os.path.splitext(path)[1].lower() in ['.mp3', '.wav', '.opus', '.m4a']: | ||
| 92 | metadata = mutagen.File(path, easy=True) | ||
| 93 | |||
| 94 | title = metadata.get('title', [filename])[0] | ||
| 95 | artist = metadata.get('artist', [''])[0] | ||
| 96 | album_artist = metadata.get('albumartist', [artist])[0] | ||
| 97 | |||
| 98 | if album_artist and title: | ||
| 99 | return f"{album_artist} - {title}" | ||
| 100 | elif artist and title: | ||
| 101 | return f"{artist} - {title}" | ||
| 102 | elif title: | ||
| 103 | return title | ||
| 104 | except Exception as e: | ||
| 105 | logger.debug(f"Could not read metadata for {filename}: {str(e)}") | ||
| 106 | |||
| 107 | return filename | ||
| 108 | |||
| 86 | async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]: | 109 | async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]: |
| 87 | """Autocomplete handler for search queries.""" | 110 | """Autocomplete handler for search queries.""" |
| 88 | query = ctx.value.lower() | 111 | query = ctx.value.lower() |
| 89 | try: | 112 | try: |
| 90 | results = await play_utils.PlayUtils.search_local_autocomplete(query, max_results=25) | 113 | results = await search_utils.AudioSearcher.search_local_autocomplete(query, max_results=25) |
| 91 | choices = [] | 114 | choices = [] |
| 92 | self.path_mapping = {} # Reset the mapping for each new query | 115 | self.path_mapping = {} # Reset the mapping for each new query |
| 93 | 116 | ||
| 94 | for path in results: | 117 | for path in results: |
| 95 | filename = os.path.splitext(os.path.basename(path))[0] # Remove extension | 118 | filename = os.path.splitext(os.path.basename(path))[0] # Remove extension |
| 96 | display = filename # Default display name | 119 | display = self._get_display_name_from_metadata(path, filename) |
| 97 | |||
| 98 | # Try to get metadata for display | ||
| 99 | try: | ||
| 100 | if os.path.splitext(path)[1].lower() in ['.mp3', '.wav', '.opus', '.m4a']: | ||
| 101 | metadata = mutagen.File(path, easy=True) | ||
| 102 | |||
| 103 | # Get metadata fields, falling back to filename if not available | ||
| 104 | title = metadata.get('title', [filename])[0] | ||
| 105 | artist = metadata.get('artist', [''])[0] | ||
| 106 | album_artist = metadata.get('albumartist', [artist])[0] | ||
| 107 | |||
| 108 | # Build display name based on available metadata | ||
| 109 | if album_artist and title: | ||
| 110 | display = f"{album_artist} - {title}" | ||
| 111 | elif artist and title: | ||
| 112 | display = f"{artist} - {title}" | ||
| 113 | elif title: | ||
| 114 | display = title | ||
| 115 | |||
| 116 | except Exception as e: | ||
| 117 | logger.debug(f"Could not read metadata for {filename}: {str(e)}") | ||
| 118 | 120 | ||
| 119 | # Clean up the display name (remove any extra whitespace or brackets) | 121 | # Clean up the display name (remove any extra whitespace or brackets) |
| 120 | display = display.strip() | 122 | display = display.strip() |
| @@ -138,6 +140,55 @@ class Play(commands.Cog): | |||
| 138 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) | 140 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) |
| 139 | return [] | 141 | return [] |
| 140 | 142 | ||
| 143 | def _validate_play_inputs(self, file: str, stream: str, ctx: discord.ApplicationContext) -> bool: | ||
| 144 | """Validate input parameters for play command.""" | ||
| 145 | if not file and not stream: | ||
| 146 | ctx.respond("Musisz podać plik do odtworzenia lub adres streamu.", ephemeral=True) | ||
| 147 | return False | ||
| 148 | |||
| 149 | if file and stream: | ||
| 150 | ctx.respond("Możesz podać tylko plik LUB stream, nie oba naraz.", ephemeral=True) | ||
| 151 | return False | ||
| 152 | |||
| 153 | return True | ||
| 154 | |||
| 155 | async def _handle_stream_play(self, ctx: discord.ApplicationContext, stream: str) -> None: | ||
| 156 | """Handle internet stream playback.""" | ||
| 157 | await audio_playback.AudioPlaybackManager.play(ctx, stream) | ||
| 158 | |||
| 159 | async def _handle_compressed_file_play(self, ctx: discord.ApplicationContext, full_path: str) -> None: | ||
| 160 | """Handle compressed file playback with cleanup.""" | ||
| 161 | compressed_path = await media_processing.AudioCompressor.compress(ctx, full_path) | ||
| 162 | if not compressed_path: | ||
| 163 | await ctx.respond("Nie udało się skompresować pliku.", ephemeral=True) | ||
| 164 | return | ||
| 165 | |||
| 166 | try: | ||
| 167 | await audio_playback.AudioPlaybackManager.play(ctx, compressed_path, delete_after_play=True) | ||
| 168 | except Exception as e: | ||
| 169 | # Clean up the compressed file if play fails | ||
| 170 | if os.path.exists(compressed_path): | ||
| 171 | try: | ||
| 172 | os.remove(compressed_path) | ||
| 173 | logger.info(f"Cleaned up temporary file after play error: {compressed_path}") | ||
| 174 | except Exception as cleanup_error: | ||
| 175 | logger.error(f"Error cleaning up file {compressed_path}: {str(cleanup_error)}") | ||
| 176 | raise e | ||
| 177 | |||
| 178 | async def _handle_local_file_play(self, ctx: discord.ApplicationContext, file: str, compressed: bool) -> None: | ||
| 179 | """Handle local file playback.""" | ||
| 180 | full_path = self.path_mapping.get(file, file) | ||
| 181 | |||
| 182 | if not os.path.exists(full_path): | ||
| 183 | logger.warning(f"File not found: {full_path} (display name: {file})") | ||
| 184 | await ctx.respond("Wybrany plik nie istnieje.", ephemeral=True) | ||
| 185 | return | ||
| 186 | |||
| 187 | if compressed: | ||
| 188 | await self._handle_compressed_file_play(ctx, full_path) | ||
| 189 | else: | ||
| 190 | await audio_playback.AudioPlaybackManager.play(ctx, full_path) | ||
| 191 | |||
| 141 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") | 192 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") |
| 142 | async def play( | 193 | async def play( |
| 143 | self, | 194 | self, |
| @@ -162,48 +213,14 @@ class Play(commands.Cog): | |||
| 162 | """Odtwarza wybrany plik dźwiękowy lub stream internetowy.""" | 213 | """Odtwarza wybrany plik dźwiękowy lub stream internetowy.""" |
| 163 | await ctx.defer() | 214 | await ctx.defer() |
| 164 | 215 | ||
| 165 | # Check if either file or stream is provided | 216 | if not self._validate_play_inputs(file, stream, ctx): |
| 166 | if not file and not stream: | ||
| 167 | await ctx.respond("Musisz podać plik do odtworzenia lub adres streamu.", ephemeral=True) | ||
| 168 | return | ||
| 169 | |||
| 170 | if file and stream: | ||
| 171 | await ctx.respond("Możesz podać tylko plik LUB stream, nie oba naraz.", ephemeral=True) | ||
| 172 | return | 217 | return |
| 173 | 218 | ||
| 174 | try: | 219 | try: |
| 175 | if stream: | 220 | if stream: |
| 176 | # Handle internet stream | 221 | await self._handle_stream_play(ctx, stream) |
| 177 | await play_utils.PlayUtils(self.bot).play(ctx, stream) | ||
| 178 | else: | 222 | else: |
| 179 | # Handle local file | 223 | await self._handle_local_file_play(ctx, file, compressed) |
| 180 | full_path = self.path_mapping.get(file, file) | ||
| 181 | |||
| 182 | if not os.path.exists(full_path): | ||
| 183 | logger.warning(f"File not found: {full_path} (display name: {file})") | ||
| 184 | await ctx.respond("Wybrany plik nie istnieje.", ephemeral=True) | ||
| 185 | return | ||
| 186 | |||
| 187 | if compressed: | ||
| 188 | # Compress the file before playing | ||
| 189 | compressed_path = await play_utils.PlayUtils(self.bot).compress(ctx, full_path) | ||
| 190 | if not compressed_path: | ||
| 191 | await ctx.respond("Nie udało się skompresować pliku.", ephemeral=True) | ||
| 192 | return | ||
| 193 | |||
| 194 | try: | ||
| 195 | await play_utils.PlayUtils(self.bot).play(ctx, compressed_path, delete_after_play=True) | ||
| 196 | except Exception as e: | ||
| 197 | # Clean up the compressed file if play fails | ||
| 198 | if os.path.exists(compressed_path): | ||
| 199 | try: | ||
| 200 | os.remove(compressed_path) | ||
| 201 | logger.info(f"Cleaned up temporary file after play error: {compressed_path}") | ||
| 202 | except Exception as cleanup_error: | ||
| 203 | logger.error(f"Error cleaning up file {compressed_path}: {str(cleanup_error)}") | ||
| 204 | raise e | ||
| 205 | else: | ||
| 206 | await play_utils.PlayUtils(self.bot).play(ctx, full_path) | ||
| 207 | except Exception as e: | 224 | except Exception as e: |
| 208 | logger.error(f"Error playing: {str(e)}", exc_info=True) | 225 | logger.error(f"Error playing: {str(e)}", exc_info=True) |
| 209 | await ctx.respond(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) | 226 | await ctx.respond(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) |
| @@ -213,7 +230,7 @@ class Play(commands.Cog): | |||
| 213 | await ctx.defer() | 230 | await ctx.defer() |
| 214 | file_path: PathLike[str] | str = os.path.join(self.temp_dir, attachment.filename) | 231 | file_path: PathLike[str] | str = os.path.join(self.temp_dir, attachment.filename) |
| 215 | await attachment.save(file_path) | 232 | await attachment.save(file_path) |
| 216 | await play_utils.PlayUtils(self.bot).play(ctx, file_path) | 233 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) |
| 217 | 234 | ||
| 218 | def setup(bot): | 235 | def setup(bot): |
| 219 | bot.add_cog(Play(bot)) \ No newline at end of file | 236 | bot.add_cog(PlayCommands(bot)) |
diff --git a/cogs/music/play_utils.py b/cogs/music/play_utils.py deleted file mode 100644 index ed98395..0000000 --- a/cogs/music/play_utils.py +++ /dev/null | |||
| @@ -1,426 +0,0 @@ | |||
| 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 json | ||
| 7 | import logging | ||
| 8 | import math | ||
| 9 | import os | ||
| 10 | import asyncio | ||
| 11 | import re | ||
| 12 | from pathlib import Path | ||
| 13 | |||
| 14 | import discord | ||
| 15 | import mutagen | ||
| 16 | import yt_dlp | ||
| 17 | from rapidfuzz import fuzz | ||
| 18 | from youtubesearchpython import VideosSearch | ||
| 19 | |||
| 20 | logger = logging.getLogger(__name__) | ||
| 21 | |||
| 22 | class Utils: | ||
| 23 | @staticmethod | ||
| 24 | def normalize_name(p: Path, keep_ext=False): | ||
| 25 | """Preprocess filenames: lower, replace separators, strip extensions optionally.""" | ||
| 26 | name = str(p.name if keep_ext else p.stem) | ||
| 27 | return name.lower().replace('_', ' ').replace('-', ' ') | ||
| 28 | |||
| 29 | @staticmethod | ||
| 30 | def length_penalty(query, candidate): | ||
| 31 | """Penalize tiny filenames when query is long; returns multiplier [0.0,1.0].""" | ||
| 32 | ql = len(query) | ||
| 33 | cl = len(candidate) | ||
| 34 | if ql == 0: | ||
| 35 | return 1.0 | ||
| 36 | ratio = cl / ql | ||
| 37 | # Prefer candidate lengths near query length; gentle falloff otherwise | ||
| 38 | return math.exp(-0.5 * ((math.log(max(ratio, 1e-6)))**2)) | ||
| 39 | |||
| 40 | @staticmethod | ||
| 41 | def combined_score(query, candidate): | ||
| 42 | """Calculate a combined fuzzy matching score using multiple strategies. | ||
| 43 | |||
| 44 | Args: | ||
| 45 | query: The search query | ||
| 46 | candidate: The candidate string to score against | ||
| 47 | |||
| 48 | Returns: | ||
| 49 | float: Score between 0-100, where higher means better match | ||
| 50 | """ | ||
| 51 | if not query or not candidate: | ||
| 52 | return 0.0 | ||
| 53 | |||
| 54 | query = query.lower().strip() | ||
| 55 | candidate = candidate.lower() | ||
| 56 | |||
| 57 | # First check for direct substring match (fast path) | ||
| 58 | if query in candidate: | ||
| 59 | # Base score of 100 for exact match, with bonus for being at word boundary | ||
| 60 | base_score = 100 | ||
| 61 | # Check if match is at word boundary | ||
| 62 | if re.search(rf'\b{re.escape(query)}\b', candidate): | ||
| 63 | base_score = 110 # Slight bonus for word boundary match | ||
| 64 | return min(100, base_score) | ||
| 65 | |||
| 66 | # For very short queries (2-3 chars), be more lenient | ||
| 67 | if len(query) <= 3: | ||
| 68 | # Check for partial matches at word boundaries | ||
| 69 | if re.search(rf'\b{re.escape(query)}', candidate): | ||
| 70 | return 90 # High score for short query matching word start | ||
| 71 | if query in candidate: | ||
| 72 | return 80 # Still good for any substring match with short query | ||
| 73 | |||
| 74 | # For longer queries, check word presence | ||
| 75 | query_words = set(word for word in re.split(r'\W+', query) if len(word) > 1) # Reduced min word length to 1 | ||
| 76 | if query_words: | ||
| 77 | candidate_words = set(re.split(r'\W+', candidate)) | ||
| 78 | # Check if all query words are in candidate (with some leniency) | ||
| 79 | matching_words = sum(1 for word in query_words if word in candidate_words) | ||
| 80 | word_ratio = matching_words / len(query_words) | ||
| 81 | |||
| 82 | # If no words match at all, return 0 | ||
| 83 | if matching_words == 0: | ||
| 84 | return 0.0 | ||
| 85 | |||
| 86 | # Calculate different similarity metrics | ||
| 87 | r_ratio = fuzz.ratio(query, candidate) # Overall edit similarity | ||
| 88 | r_partial = fuzz.partial_ratio(query, candidate) # Best substring match | ||
| 89 | r_token_set = fuzz.token_set_ratio(query, candidate) # Word overlap | ||
| 90 | |||
| 91 | # Calculate word-based scores | ||
| 92 | query_tokens = set(query.split()) | ||
| 93 | candidate_tokens = set(candidate.split()) | ||
| 94 | word_intersection = len(query_tokens & candidate_tokens) | ||
| 95 | word_union = len(query_tokens | candidate_tokens) | ||
| 96 | |||
| 97 | # Jaccard similarity for word overlap | ||
| 98 | jaccard_sim = word_intersection / word_union if word_union > 0 else 0 | ||
| 99 | |||
| 100 | # Calculate prefix score (bonus for matching start of words) | ||
| 101 | prefix_bonus = 0 | ||
| 102 | for q_word in query_words: | ||
| 103 | for c_word in candidate_words: | ||
| 104 | if c_word.startswith(q_word): | ||
| 105 | prefix_bonus += (len(q_word) / max(len(c_word), 1)) * 20 # Increased weight | ||
| 106 | |||
| 107 | # Weighted sum with adjusted weights | ||
| 108 | score = ( | ||
| 109 | 0.40 * r_token_set + # Increased weight for token set | ||
| 110 | 0.25 * r_ratio + # Overall similarity | ||
| 111 | 0.15 * r_partial + # Substring matches | ||
| 112 | 0.15 * (jaccard_sim * 100) + # Word overlap ratio | ||
| 113 | 0.05 * min(prefix_bonus, 100) # Prefix bonus (capped at 100) | ||
| 114 | ) | ||
| 115 | |||
| 116 | # Apply length penalty (less severe) | ||
| 117 | score *= min(1.0, 0.9 + (0.1 * Utils.length_penalty(query, candidate))) | ||
| 118 | |||
| 119 | # Boost score based on word ratio | ||
| 120 | score = score * (0.7 + (0.3 * word_ratio)) | ||
| 121 | |||
| 122 | score = int(score) | ||
| 123 | |||
| 124 | # Ensure score is within bounds | ||
| 125 | return min(100, max(0, score)) | ||
| 126 | |||
| 127 | return 0.0 # No match found | ||
| 128 | |||
| 129 | class PlayUtils: | ||
| 130 | def __init__(self, bot): | ||
| 131 | self.bot = bot | ||
| 132 | |||
| 133 | @staticmethod | ||
| 134 | async def download(ctx, url): | ||
| 135 | |||
| 136 | # Create temp directory if it doesn't exist | ||
| 137 | temp_dir = os.path.join('cogs', 'temp', 'music') | ||
| 138 | os.makedirs(temp_dir, exist_ok=True) | ||
| 139 | |||
| 140 | ytdl_opts = { | ||
| 141 | "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), | ||
| 142 | "postprocessors": [ | ||
| 143 | { | ||
| 144 | "key": "FFmpegExtractAudio", | ||
| 145 | "preferredcodec": "mp3", | ||
| 146 | "preferredquality": "192", | ||
| 147 | } | ||
| 148 | ], | ||
| 149 | "force_ipv4": True, | ||
| 150 | } | ||
| 151 | |||
| 152 | try: | ||
| 153 | with yt_dlp.YoutubeDL(ytdl_opts) as ydl: | ||
| 154 | info_dict = ydl.extract_info(url, download=True) | ||
| 155 | # Get the base filename without extension | ||
| 156 | base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] | ||
| 157 | # Add .mp3 extension since we're extracting audio | ||
| 158 | filename = f"{base_filename}.mp3" | ||
| 159 | logger.info(f"Downloaded file: {filename}") | ||
| 160 | return filename | ||
| 161 | except Exception as e: | ||
| 162 | logger.error(f"Error downloading video: {e}") | ||
| 163 | await ctx.respond("eror pobirajync wideło", ephemeral=True) | ||
| 164 | |||
| 165 | @staticmethod | ||
| 166 | async def get_url(ctx, query): | ||
| 167 | search = VideosSearch(query, limit=1) | ||
| 168 | if search.result()["result"]: | ||
| 169 | title = search.result()["result"][0]["title"] | ||
| 170 | url = search.result()["result"][0]["link"] | ||
| 171 | logger.info(f"Found video: {title}") | ||
| 172 | return url, title | ||
| 173 | else: | ||
| 174 | logger.warning("No results found for the query.") | ||
| 175 | await ctx.respond("nic ni ma", ephemeral=True) | ||
| 176 | return None | ||
| 177 | |||
| 178 | @staticmethod | ||
| 179 | async def compress(ctx, filename): | ||
| 180 | if not filename: | ||
| 181 | await ctx.respond("dzie wideło", ephemeral=True) | ||
| 182 | return None | ||
| 183 | |||
| 184 | # Create temp directory if it doesn't exist | ||
| 185 | temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp', 'compressed') | ||
| 186 | os.makedirs(temp_dir, exist_ok=True) | ||
| 187 | |||
| 188 | # Create a temporary output filename in the temp directory | ||
| 189 | base_name = os.path.basename(filename) | ||
| 190 | output_filename = os.path.join(temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus") | ||
| 191 | |||
| 192 | # Ensure the output directory exists | ||
| 193 | os.makedirs(os.path.dirname(output_filename) or '.', exist_ok=True) | ||
| 194 | |||
| 195 | # Use proper quoting for filenames with spaces | ||
| 196 | ffmpeg_command = [ | ||
| 197 | 'ffmpeg', | ||
| 198 | '-y', # Overwrite output file if it exists | ||
| 199 | '-i', filename, | ||
| 200 | '-ac', '1', # Mono audio | ||
| 201 | '-b:a', '500', # 500k bitrate | ||
| 202 | '-acodec', 'libopus', | ||
| 203 | output_filename | ||
| 204 | ] | ||
| 205 | |||
| 206 | try: | ||
| 207 | logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}") | ||
| 208 | process = await asyncio.create_subprocess_exec( | ||
| 209 | *ffmpeg_command, | ||
| 210 | stdout=asyncio.subprocess.PIPE, | ||
| 211 | stderr=asyncio.subprocess.PIPE | ||
| 212 | ) | ||
| 213 | |||
| 214 | stdout, stderr = await process.communicate() | ||
| 215 | |||
| 216 | if process.returncode != 0: | ||
| 217 | error_msg = stderr.decode() | ||
| 218 | logger.error(f"FFmpeg error ({process.returncode}): {error_msg}") | ||
| 219 | await ctx.respond("eror kompresujync pilk", ephemeral=True) | ||
| 220 | return None | ||
| 221 | |||
| 222 | if not os.path.exists(output_filename): | ||
| 223 | logger.error(f"Output file was not created: {output_filename}") | ||
| 224 | await ctx.respond("eror kompresujync pilk - output file not created", ephemeral=True) | ||
| 225 | return None | ||
| 226 | |||
| 227 | logger.info(f"Successfully compressed file: {output_filename}") | ||
| 228 | return output_filename | ||
| 229 | |||
| 230 | except Exception as e: | ||
| 231 | logger.error(f"Error compressing file: {str(e)}", exc_info=True) | ||
| 232 | await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True) | ||
| 233 | return None | ||
| 234 | |||
| 235 | @staticmethod | ||
| 236 | async def play(ctx, source, delete_after_play=False): | ||
| 237 | """Play audio from a local file or URL. | ||
| 238 | |||
| 239 | Args: | ||
| 240 | ctx: Discord context | ||
| 241 | source: Either a local file path or a URL to stream from | ||
| 242 | delete_after_play: If True, delete the file after playing (only for local files) | ||
| 243 | """ | ||
| 244 | # Check if user is in a voice channel | ||
| 245 | if not ctx.author.voice or not ctx.author.voice.channel: | ||
| 246 | await ctx.respond("Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True) | ||
| 247 | if delete_after_play and os.path.exists(source): | ||
| 248 | try: | ||
| 249 | os.remove(source) | ||
| 250 | logger.info(f"Cleaned up temporary file: {source}") | ||
| 251 | except Exception as e: | ||
| 252 | logger.error(f"Error cleaning up file {source}: {str(e)}") | ||
| 253 | return | ||
| 254 | |||
| 255 | is_url = source.startswith(('http://', 'https://')) | ||
| 256 | |||
| 257 | if not is_url: | ||
| 258 | # Handle local file | ||
| 259 | logger.info(f"Attempting to play local file: {source}") | ||
| 260 | |||
| 261 | # Get absolute path and verify file exists | ||
| 262 | if not os.path.isabs(source): | ||
| 263 | source = os.path.abspath(source) | ||
| 264 | logger.debug(f"Converted to absolute path: {source}") | ||
| 265 | |||
| 266 | if not os.path.exists(source): | ||
| 267 | error_msg = f"File not found: {source}" | ||
| 268 | logger.error(error_msg) | ||
| 269 | await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) | ||
| 270 | return | ||
| 271 | |||
| 272 | filename = os.path.basename(source) | ||
| 273 | display_name = os.path.splitext(filename)[0] | ||
| 274 | else: | ||
| 275 | # Handle URL | ||
| 276 | logger.info(f"Attempting to play stream from URL: {source}") | ||
| 277 | display_name = source # Use URL as display name for streams | ||
| 278 | |||
| 279 | # Get or create voice client | ||
| 280 | vc = ctx.voice_client | ||
| 281 | target_channel = ctx.author.voice.channel | ||
| 282 | |||
| 283 | try: | ||
| 284 | # Connect to voice channel if not connected | ||
| 285 | if vc is None: | ||
| 286 | logger.info(f"Connecting to voice channel: {target_channel.name}") | ||
| 287 | vc = await target_channel.connect() | ||
| 288 | logger.info(f"Connected to voice channel: {target_channel.name}") | ||
| 289 | # Move to user's channel if in a different one | ||
| 290 | elif vc.channel != target_channel: | ||
| 291 | logger.info(f"Moving to voice channel: {target_channel.name}") | ||
| 292 | await vc.move_to(target_channel) | ||
| 293 | logger.info(f"Moved to voice channel: {target_channel.name}") | ||
| 294 | |||
| 295 | # Stop any currently playing audio | ||
| 296 | if vc.is_playing() or vc.is_paused(): | ||
| 297 | vc.stop() | ||
| 298 | |||
| 299 | # Play the audio | ||
| 300 | if is_url: | ||
| 301 | # For URLs, use FFmpeg with the direct URL | ||
| 302 | ffmpeg_options = { | ||
| 303 | 'options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', | ||
| 304 | 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5' | ||
| 305 | } | ||
| 306 | source = discord.FFmpegPCMAudio(source, **ffmpeg_options) | ||
| 307 | else: | ||
| 308 | # For local files, use default options | ||
| 309 | source = discord.FFmpegPCMAudio(source=source) | ||
| 310 | |||
| 311 | # Try to get metadata for local files | ||
| 312 | try: | ||
| 313 | metadata = mutagen.File(source) | ||
| 314 | if metadata: | ||
| 315 | title = metadata.get('title', [None])[0] if hasattr(metadata, 'get') else None | ||
| 316 | if title: | ||
| 317 | display_name = title | ||
| 318 | logger.debug(f"Read metadata - title: {title}") | ||
| 319 | except Exception as e: | ||
| 320 | logger.warning(f"Could not read metadata from {source}: {str(e)}") | ||
| 321 | |||
| 322 | vc.play(source) | ||
| 323 | logger.info(f"Playing: {display_name}") | ||
| 324 | await ctx.respond(f"grom: {display_name}") | ||
| 325 | |||
| 326 | except Exception as e: | ||
| 327 | logger.error(f"Error in play: {str(e)}", exc_info=True) | ||
| 328 | await ctx.respond(f"eror: {str(e)}", ephemeral=True) | ||
| 329 | |||
| 330 | # -- LOCAL PLAY -- | ||
| 331 | |||
| 332 | @classmethod | ||
| 333 | async def search_local(cls, query: str = None, max_results: int = 25, autocomplete: bool = False): | ||
| 334 | """Search for audio files with fuzzy matching and optional autocomplete support. | ||
| 335 | |||
| 336 | Args: | ||
| 337 | query: Search query string (can be None for autocomplete) | ||
| 338 | max_results: Maximum number of results to return | ||
| 339 | autocomplete: If True, returns results even with empty query | ||
| 340 | |||
| 341 | Returns: | ||
| 342 | List of matching file paths with scores | ||
| 343 | """ | ||
| 344 | if not query and not autocomplete: | ||
| 345 | return [] | ||
| 346 | |||
| 347 | query = (query or '').lower().strip() | ||
| 348 | logger.info(f"Starting local search for query: '{query}'") | ||
| 349 | |||
| 350 | try: | ||
| 351 | with open('settings.json') as f: | ||
| 352 | data = json.load(f) | ||
| 353 | search_paths = data.get('audio_paths', []) | ||
| 354 | logger.info(f"Searching in paths: {search_paths}") | ||
| 355 | |||
| 356 | matches = [] | ||
| 357 | file_count = 0 | ||
| 358 | |||
| 359 | for path in search_paths: | ||
| 360 | path = Path(path) | ||
| 361 | if not path.exists(): | ||
| 362 | logger.warning(f"Path does not exist: {path}") | ||
| 363 | continue | ||
| 364 | |||
| 365 | logger.debug(f"Searching in directory: {path}") | ||
| 366 | |||
| 367 | # Walk through all subdirectories | ||
| 368 | for file_path in path.rglob('*'): | ||
| 369 | if not file_path.is_file(): | ||
| 370 | continue | ||
| 371 | |||
| 372 | # Check if file has a valid extension | ||
| 373 | if file_path.suffix.lower() not in {'.mp4', '.mp3', '.wav', '.opus', '.m4a'}: | ||
| 374 | continue | ||
| 375 | |||
| 376 | file_count += 1 | ||
| 377 | file_name = file_path.name.lower() | ||
| 378 | |||
| 379 | # For autocomplete, return all files if query is empty | ||
| 380 | if not query and autocomplete: | ||
| 381 | matches.append((0, str(file_path.absolute()))) | ||
| 382 | # For regular search, calculate fuzzy match score | ||
| 383 | elif query and len(query) >= 2: | ||
| 384 | # First do a simple substring check for debugging | ||
| 385 | if query in file_name: | ||
| 386 | logger.debug(f"Found direct match: {file_name} (contains '{query}')") | ||
| 387 | |||
| 388 | score = Utils.combined_score(query, file_name) | ||
| 389 | logger.debug(f"File: {file_name} - Score: {score}") | ||
| 390 | |||
| 391 | if score >= 30: # Minimum score threshold | ||
| 392 | matches.append((score, str(file_path.absolute()))) | ||
| 393 | |||
| 394 | # Early return if we have enough high-scoring matches | ||
| 395 | if len(matches) >= max_results * 5: # Get more candidates for sorting | ||
| 396 | break | ||
| 397 | |||
| 398 | if len(matches) >= max_results * 5: # Limit memory usage | ||
| 399 | break | ||
| 400 | |||
| 401 | logger.info(f"Scanned {file_count} files total") | ||
| 402 | |||
| 403 | # Sort by score (descending) and take top results | ||
| 404 | matches.sort(reverse=True, key=lambda x: x[0]) | ||
| 405 | result = [path for score, path in matches[:max_results]] | ||
| 406 | |||
| 407 | logger.info(f"Search complete. Found {len(result)} matches out of {file_count} files.") | ||
| 408 | if not result and query: | ||
| 409 | logger.warning(f"No matches found for query: '{query}'. Try a different search term.") | ||
| 410 | return result | ||
| 411 | |||
| 412 | except Exception as e: | ||
| 413 | logger.error(f"Error during local search: {str(e)}", exc_info=True) | ||
| 414 | return [] | ||
| 415 | |||
| 416 | @classmethod | ||
| 417 | async def search_local_autocomplete(cls, query: str = None, max_results: int = 10): | ||
| 418 | """Wrapper for autocomplete searches that returns more results and allows empty query.""" | ||
| 419 | # For autocomplete, we want to show more options and allow empty queries | ||
| 420 | if not query: | ||
| 421 | return [] | ||
| 422 | |||
| 423 | return await cls.search_local(query, max_results=max_results, autocomplete=True) | ||
| 424 | |||
| 425 | |||
| 426 | |||
diff --git a/cogs/music/search_utils.py b/cogs/music/search_utils.py new file mode 100644 index 0000000..50d229c --- /dev/null +++ b/cogs/music/search_utils.py | |||
| @@ -0,0 +1,286 @@ | |||
| 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 json | ||
| 7 | import logging | ||
| 8 | import re | ||
| 9 | from pathlib import Path | ||
| 10 | import aiofiles | ||
| 11 | from rapidfuzz import fuzz | ||
| 12 | |||
| 13 | logger = logging.getLogger(__name__) | ||
| 14 | |||
| 15 | class SearchUtils: | ||
| 16 | """Utilities for searching and scoring audio files.""" | ||
| 17 | |||
| 18 | @staticmethod | ||
| 19 | def normalize_name(p: Path, keep_ext=False): | ||
| 20 | """Preprocess filenames: lower, replace separators, strip extensions optionally.""" | ||
| 21 | name = str(p.name if keep_ext else p.stem) | ||
| 22 | return name.lower().replace('_', ' ').replace('-', ' ') | ||
| 23 | |||
| 24 | @staticmethod | ||
| 25 | def length_penalty(query, candidate): | ||
| 26 | """Penalize tiny filenames when query is long; returns multiplier [0.0,1.0].""" | ||
| 27 | ql = len(query) | ||
| 28 | cl = len(candidate) | ||
| 29 | if ql == 0: | ||
| 30 | return 1.0 | ||
| 31 | ratio = cl / ql | ||
| 32 | # Prefer candidate lengths near query length; gentle falloff otherwise | ||
| 33 | return math.exp(-0.5 * ((math.log(max(ratio, 1e-6)))**2)) | ||
| 34 | |||
| 35 | @staticmethod | ||
| 36 | def combined_score(query, candidate): | ||
| 37 | """Calculate a combined fuzzy matching score using multiple strategies. | ||
| 38 | |||
| 39 | Args: | ||
| 40 | query: The search query | ||
| 41 | candidate: The candidate string to score against | ||
| 42 | |||
| 43 | Returns: | ||
| 44 | float: Score between 0-100, where higher means better match | ||
| 45 | """ | ||
| 46 | if not query or not candidate: | ||
| 47 | return 0.0 | ||
| 48 | |||
| 49 | query = query.lower().strip() | ||
| 50 | candidate = candidate.lower() | ||
| 51 | |||
| 52 | # Check for direct matches first | ||
| 53 | direct_match_score = SearchUtils._check_direct_matches(query, candidate) | ||
| 54 | if direct_match_score is not None: | ||
| 55 | return direct_match_score | ||
| 56 | |||
| 57 | # Handle short queries specially | ||
| 58 | short_query_score = SearchUtils._handle_short_queries(query, candidate) | ||
| 59 | if short_query_score is not None: | ||
| 60 | return short_query_score | ||
| 61 | |||
| 62 | # For longer queries, use complex scoring | ||
| 63 | return SearchUtils._calculate_complex_score(query, candidate) | ||
| 64 | |||
| 65 | @staticmethod | ||
| 66 | def _check_direct_matches(query: str, candidate: str) -> float: | ||
| 67 | """Check for direct substring matches.""" | ||
| 68 | if query in candidate: | ||
| 69 | base_score = 100 | ||
| 70 | if re.search(rf'\b{re.escape(query)}\b', candidate): | ||
| 71 | base_score = 110 | ||
| 72 | return min(100, base_score) | ||
| 73 | return 0 | ||
| 74 | |||
| 75 | @staticmethod | ||
| 76 | def _handle_short_queries(query: str, candidate: str) -> float: | ||
| 77 | """Handle scoring for short queries (2-3 chars).""" | ||
| 78 | if len(query) <= 3: | ||
| 79 | if re.search(rf'\b{re.escape(query)}', candidate): | ||
| 80 | return 90 | ||
| 81 | if query in candidate: | ||
| 82 | return 80 | ||
| 83 | return 0 | ||
| 84 | |||
| 85 | @staticmethod | ||
| 86 | def _calculate_complex_score(query: str, candidate: str) -> float: | ||
| 87 | """Calculate complex score for longer queries.""" | ||
| 88 | query_words = {word for word in re.split(r'\W+', query) if len(word) > 1} | ||
| 89 | if not query_words: | ||
| 90 | return 0.0 | ||
| 91 | |||
| 92 | candidate_words = set(re.split(r'\W+', candidate)) | ||
| 93 | word_ratio = SearchUtils._calculate_word_ratio(query_words, candidate_words) | ||
| 94 | |||
| 95 | if word_ratio == 0: | ||
| 96 | return 0.0 | ||
| 97 | |||
| 98 | # Calculate various similarity metrics | ||
| 99 | similarity_scores = SearchUtils._get_similarity_scores(query, candidate) | ||
| 100 | word_scores = SearchUtils._get_word_scores(query, candidate, query_words, candidate_words) | ||
| 101 | |||
| 102 | # Calculate final weighted score | ||
| 103 | final_score = SearchUtils._calculate_weighted_score(similarity_scores, word_scores, word_ratio) | ||
| 104 | |||
| 105 | # Apply penalties and boosts | ||
| 106 | final_score = SearchUtils._apply_adjustments(final_score, query, candidate, word_ratio) | ||
| 107 | |||
| 108 | return min(100, max(0, int(final_score))) | ||
| 109 | |||
| 110 | @staticmethod | ||
| 111 | def _calculate_word_ratio(query_words: set, candidate_words: set) -> float: | ||
| 112 | """Calculate the ratio of matching words.""" | ||
| 113 | matching_words = sum(1 for word in query_words if word in candidate_words) | ||
| 114 | return matching_words / len(query_words) | ||
| 115 | |||
| 116 | @staticmethod | ||
| 117 | def _get_similarity_scores(query: str, candidate: str) -> dict: | ||
| 118 | """Get various fuzzy similarity scores.""" | ||
| 119 | return { | ||
| 120 | 'ratio': fuzz.ratio(query, candidate), | ||
| 121 | 'partial': fuzz.partial_ratio(query, candidate), | ||
| 122 | 'token_set': fuzz.token_set_ratio(query, candidate) | ||
| 123 | } | ||
| 124 | |||
| 125 | @staticmethod | ||
| 126 | def _get_word_scores(query: str, candidate: str, query_words: set, candidate_words: set) -> dict: | ||
| 127 | """Calculate word-based scores.""" | ||
| 128 | query_tokens = set(query.split()) | ||
| 129 | candidate_tokens = set(candidate.split()) | ||
| 130 | |||
| 131 | word_intersection = len(query_tokens & candidate_tokens) | ||
| 132 | word_union = len(query_tokens | candidate_tokens) | ||
| 133 | jaccard_sim = word_intersection / word_union if word_union > 0 else 0 | ||
| 134 | |||
| 135 | prefix_bonus = SearchUtils._calculate_prefix_bonus(query_words, candidate_words) | ||
| 136 | |||
| 137 | return { | ||
| 138 | 'jaccard': jaccard_sim, | ||
| 139 | 'prefix': min(prefix_bonus, 100) | ||
| 140 | } | ||
| 141 | |||
| 142 | @staticmethod | ||
| 143 | def _calculate_prefix_bonus(query_words: set, candidate_words: set) -> float: | ||
| 144 | """Calculate prefix matching bonus.""" | ||
| 145 | prefix_bonus = 0 | ||
| 146 | for q_word in query_words: | ||
| 147 | for c_word in candidate_words: | ||
| 148 | if c_word.startswith(q_word): | ||
| 149 | prefix_bonus += (len(q_word) / max(len(c_word), 1)) * 20 | ||
| 150 | return prefix_bonus | ||
| 151 | |||
| 152 | @staticmethod | ||
| 153 | def _calculate_weighted_score(similarity_scores: dict, word_scores: dict, word_ratio: float) -> float: | ||
| 154 | """Calculate the final weighted score.""" | ||
| 155 | return ( | ||
| 156 | 0.40 * similarity_scores['token_set'] + | ||
| 157 | 0.25 * similarity_scores['ratio'] + | ||
| 158 | 0.15 * similarity_scores['partial'] + | ||
| 159 | 0.15 * (word_scores['jaccard'] * 100) + | ||
| 160 | 0.05 * word_scores['prefix'] | ||
| 161 | ) | ||
| 162 | |||
| 163 | @staticmethod | ||
| 164 | def _apply_adjustments(score: float, query: str, candidate: str, word_ratio: float) -> float: | ||
| 165 | """Apply length penalty and word ratio boost.""" | ||
| 166 | import math | ||
| 167 | |||
| 168 | # Apply length penalty | ||
| 169 | score *= min(1.0, 0.9 + (0.1 * SearchUtils.length_penalty(query, candidate))) | ||
| 170 | |||
| 171 | # Apply word ratio boost | ||
| 172 | score = score * (0.7 + (0.3 * word_ratio)) | ||
| 173 | |||
| 174 | return score | ||
| 175 | |||
| 176 | class AudioSearcher: | ||
| 177 | """Handles searching for audio files in configured paths.""" | ||
| 178 | |||
| 179 | @staticmethod | ||
| 180 | def _is_valid_audio_file(file_path: Path) -> bool: | ||
| 181 | """Check if file is a valid audio file.""" | ||
| 182 | valid_extensions = {'.mp4', '.mp3', '.wav', '.opus', '.m4a'} | ||
| 183 | return file_path.is_file() and file_path.suffix.lower() in valid_extensions | ||
| 184 | |||
| 185 | @staticmethod | ||
| 186 | def _calculate_match_score(query: str, file_name: str, autocomplete: bool) -> tuple[int, str]: | ||
| 187 | """Calculate match score for a file.""" | ||
| 188 | if not query and autocomplete: | ||
| 189 | return 0, file_name | ||
| 190 | |||
| 191 | if query and len(query) >= 2: | ||
| 192 | if query in file_name: | ||
| 193 | logger.debug(f"Found direct match: {file_name} (contains '{query}')") | ||
| 194 | |||
| 195 | score = SearchUtils.combined_score(query, file_name) | ||
| 196 | logger.debug(f"File: {file_name} - Score: {score}") | ||
| 197 | |||
| 198 | if score >= 30: | ||
| 199 | return score, file_name | ||
| 200 | |||
| 201 | return -1, file_name | ||
| 202 | |||
| 203 | @staticmethod | ||
| 204 | async def _search_single_path(path: Path, query: str, max_results: int, autocomplete: bool) -> list[tuple[int, str]]: | ||
| 205 | """Search for audio files in a single path.""" | ||
| 206 | matches = [] | ||
| 207 | |||
| 208 | logger.debug(f"Searching in directory: {path}") | ||
| 209 | |||
| 210 | for file_path in path.rglob('*'): | ||
| 211 | if not AudioSearcher._is_valid_audio_file(file_path): | ||
| 212 | continue | ||
| 213 | |||
| 214 | file_name = file_path.name.lower() | ||
| 215 | score, _ = AudioSearcher._calculate_match_score(query, file_name, autocomplete) | ||
| 216 | |||
| 217 | if score >= 0: | ||
| 218 | matches.append((score, str(file_path.absolute()))) | ||
| 219 | |||
| 220 | if len(matches) >= max_results * 5: | ||
| 221 | break | ||
| 222 | |||
| 223 | return matches | ||
| 224 | |||
| 225 | @classmethod | ||
| 226 | async def search_local(cls, query: str = None, max_results: int = 25, autocomplete: bool = False): | ||
| 227 | """Search for audio files with fuzzy matching and optional autocomplete support. | ||
| 228 | |||
| 229 | Args: | ||
| 230 | query: Search query string (can be None for autocomplete) | ||
| 231 | max_results: Maximum number of results to return | ||
| 232 | autocomplete: If True, returns results even with empty query | ||
| 233 | |||
| 234 | Returns: | ||
| 235 | List of matching file paths with scores | ||
| 236 | """ | ||
| 237 | if not query and not autocomplete: | ||
| 238 | return [] | ||
| 239 | |||
| 240 | query = (query or '').lower().strip() | ||
| 241 | logger.info(f"Starting local search for query: '{query}'") | ||
| 242 | |||
| 243 | try: | ||
| 244 | async with aiofiles.open('settings.json') as f: | ||
| 245 | data = json.load(f) | ||
| 246 | search_paths = data.get('audio_paths', []) | ||
| 247 | logger.info(f"Searching in paths: {search_paths}") | ||
| 248 | |||
| 249 | matches = [] | ||
| 250 | file_count = 0 | ||
| 251 | |||
| 252 | for path_str in search_paths: | ||
| 253 | path = Path(path_str) | ||
| 254 | if not path.exists(): | ||
| 255 | logger.warning(f"Path does not exist: {path}") | ||
| 256 | continue | ||
| 257 | |||
| 258 | path_matches = await cls._search_single_path(path, query, max_results, autocomplete) | ||
| 259 | matches.extend(path_matches) | ||
| 260 | file_count += len(path_matches) | ||
| 261 | |||
| 262 | if len(matches) >= max_results * 5: | ||
| 263 | break | ||
| 264 | |||
| 265 | logger.info(f"Scanned {file_count} files total") | ||
| 266 | |||
| 267 | matches.sort(reverse=True, key=lambda x: x[0]) | ||
| 268 | result = [path for score, path in matches[:max_results]] | ||
| 269 | |||
| 270 | logger.info(f"Search complete. Found {len(result)} matches out of {file_count} files.") | ||
| 271 | if not result and query: | ||
| 272 | logger.warning(f"No matches found for query: '{query}'. Try a different search term.") | ||
| 273 | return result | ||
| 274 | |||
| 275 | except Exception as e: | ||
| 276 | logger.error(f"Error during local search: {str(e)}", exc_info=True) | ||
| 277 | return [] | ||
| 278 | |||
| 279 | @classmethod | ||
| 280 | async def search_local_autocomplete(cls, query: str = None, max_results: int = 10): | ||
| 281 | """Wrapper for autocomplete searches that returns more results and allows empty query.""" | ||
| 282 | # For autocomplete, we want to show more options and allow empty queries | ||
| 283 | if not query: | ||
| 284 | return [] | ||
| 285 | |||
| 286 | return await cls.search_local(query, max_results=max_results, autocomplete=True) | ||
