diff options
| author | salmonik <raisersoft@proton.me> | 2026-01-04 13:31:49 +0100 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-01-04 13:31:49 +0100 |
| commit | c634305604d0c993343a34262a3f3811d4c22e2c (patch) | |
| tree | a82740253193a3c718631a97ebca1fc216cd7155 | |
| parent | bumped requirements (diff) | |
| download | senator-sperma-c634305604d0c993343a34262a3f3811d4c22e2c.tar.gz senator-sperma-c634305604d0c993343a34262a3f3811d4c22e2c.zip | |
live search with good algorithm
| -rw-r--r-- | cogs/music/play.py | 272 | ||||
| -rw-r--r-- | cogs/music/play_utils.py | 168 |
2 files changed, 307 insertions, 133 deletions
diff --git a/cogs/music/play.py b/cogs/music/play.py index 8390ff5..c9abe4b 100644 --- a/cogs/music/play.py +++ b/cogs/music/play.py | |||
| @@ -1,10 +1,11 @@ | |||
| 1 | import os | 1 | import os |
| 2 | |||
| 3 | import discord | 2 | import discord |
| 4 | import mutagen | 3 | import mutagen |
| 4 | from typing import List, Tuple, Optional, Callable, Awaitable, Union | ||
| 5 | 5 | ||
| 6 | from . import play_utils | 6 | from . import play_utils |
| 7 | from discord.ext import commands | 7 | from discord.ext import commands |
| 8 | from discord.commands import Option, OptionChoice | ||
| 8 | import logging | 9 | import logging |
| 9 | 10 | ||
| 10 | logger = logging.getLogger(__name__) | 11 | logger = logging.getLogger(__name__) |
| @@ -13,113 +14,184 @@ class Play(commands.Cog): | |||
| 13 | def __init__(self, bot): | 14 | def __init__(self, bot): |
| 14 | self.bot = bot | 15 | self.bot = bot |
| 15 | self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp") | 16 | self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp") |
| 17 | self.path_mapping = {} # Maps display names to full paths | ||
| 16 | 18 | ||
| 17 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") | 19 | async def _create_search_embed(self, candidates: List[str]) -> Tuple[discord.Embed, discord.ui.View]: |
| 18 | async def play( | 20 | """Create an embed and view for search results.""" |
| 19 | self, | 21 | embed = discord.Embed( |
| 20 | ctx: discord.ApplicationContext, | 22 | title="Wyniki wyszukiwania", |
| 21 | query: str = discord.Option(str, "co zagrać??", required=True), | 23 | description="Wybierz plik do odtworzenia:", |
| 22 | source: str = discord.Option(str, "co zagrać??", required=True, choices=["local", "soundcloud", "youtube"]) | 24 | color=discord.Color.blue() |
| 23 | 25 | ) | |
| 24 | ): | 26 | |
| 25 | await ctx.defer(ephemeral=True) | 27 | view = discord.ui.View(disable_on_timeout=True) |
| 26 | if source == "local": | 28 | |
| 27 | candidates = await play_utils.PlayUtils(self.bot).search_local(query) | 29 | for i, candidate in enumerate(candidates, start=1): |
| 28 | if candidates: | 30 | filename = os.path.basename(candidate) |
| 29 | embed = discord.Embed(title="Wyniki wyszukiwania", description="Wybierz plik do odtworzenia:", color=discord.Color.blue()) | 31 | title = None |
| 30 | view = discord.ui.View(disable_on_timeout=True) | 32 | |
| 31 | for i, candidate in enumerate(candidates, start=1): | 33 | # Try to get metadata if it's an audio file |
| 32 | filename = os.path.basename(candidate) | 34 | if os.path.splitext(filename)[1].lower() in ['.mp3', '.wav', '.opus']: |
| 33 | metadata = None | 35 | try: |
| 34 | if os.path.splitext(filename)[1].lower() in ['.mp3', '.wav', '.opus']: | 36 | metadata = mutagen.File(candidate) |
| 35 | metadata = mutagen.File(candidate) | 37 | if metadata: |
| 36 | title = metadata.get('title', None) | 38 | title = metadata.get('title', None) |
| 37 | if metadata and title: | 39 | except Exception as e: |
| 38 | embed.add_field(name=f"{i}. {title}", value="\u200b", inline=False) | 40 | logger.debug(f"Could not read metadata for {filename}: {str(e)}") |
| 39 | else: | 41 | |
| 40 | embed.add_field(name=f"{i}. {filename}", value="\u200b", inline=False) | 42 | # Add field with title if available, otherwise use filename |
| 41 | button = discord.ui.Button(label=f"Play {i}") | 43 | display_name = title if title else filename |
| 42 | async def button_callback(interaction: discord.Interaction, candidate=candidate): | 44 | embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) |
| 43 | await ctx.interaction.delete_original_response() | 45 | |
| 44 | await play_utils.PlayUtils(self.bot).play(ctx, candidate) | 46 | return embed, view |
| 45 | button.callback = button_callback | 47 | |
| 46 | view.add_item(button) | 48 | async def _handle_search_results( |
| 47 | await ctx.respond(embed=embed, view=view, ephemeral=True) | 49 | self, |
| 48 | else: | 50 | ctx: discord.ApplicationContext, |
| 49 | await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) | 51 | candidates: List[str], |
| 52 | play_callback: Callable[[discord.Interaction, str], Awaitable[None]] | ||
| 53 | ) -> None: | ||
| 54 | """Handle the display of search results and button interactions.""" | ||
| 55 | if not candidates: | ||
| 56 | await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) | ||
| 57 | return | ||
| 58 | |||
| 59 | embed, view = await self._create_search_embed(candidates) | ||
| 60 | |||
| 61 | # Add buttons for each candidate | ||
| 62 | for i, candidate in enumerate(candidates, start=1): | ||
| 63 | button = discord.ui.Button(label=f"Play {i}") | ||
| 64 | |||
| 65 | async def button_callback(interaction: discord.Interaction, candidate_path=candidate): | ||
| 66 | await interaction.response.defer(ephemeral=True) | ||
| 67 | await ctx.interaction.delete_original_response() | ||
| 68 | try: | ||
| 69 | await play_callback(interaction, candidate_path) | ||
| 70 | except Exception as e: | ||
| 71 | logger.error(f"Error in play callback: {str(e)}", exc_info=True) | ||
| 72 | await interaction.followup.send(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) | ||
| 73 | |||
| 74 | button.callback = button_callback | ||
| 75 | view.add_item(button) | ||
| 76 | |||
| 77 | await ctx.respond(embed=embed, view=view, ephemeral=True) | ||
| 50 | 78 | ||
| 51 | @discord.slash_command(name="grajcuj_zkompresowane", description="grajcujmy wszyscy w wysokiej jakosci!") | 79 | async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]: |
| 52 | async def play_compressed( | 80 | """Autocomplete handler for search queries.""" |
| 81 | query = ctx.value.lower() | ||
| 82 | try: | ||
| 83 | results = await play_utils.PlayUtils.search_local_autocomplete(query, max_results=25) | ||
| 84 | choices = [] | ||
| 85 | self.path_mapping = {} # Reset the mapping for each new query | ||
| 86 | |||
| 87 | for path in results: | ||
| 88 | filename = os.path.splitext(os.path.basename(path))[0] # Remove extension | ||
| 89 | display = filename # Default display name | ||
| 90 | |||
| 91 | # Try to get metadata for display | ||
| 92 | try: | ||
| 93 | if os.path.splitext(path)[1].lower() in ['.mp3', '.wav', '.opus', '.m4a']: | ||
| 94 | metadata = mutagen.File(path, easy=True) | ||
| 95 | |||
| 96 | # Get metadata fields, falling back to filename if not available | ||
| 97 | title = metadata.get('title', [filename])[0] | ||
| 98 | artist = metadata.get('artist', [''])[0] | ||
| 99 | album_artist = metadata.get('albumartist', [artist])[0] | ||
| 100 | |||
| 101 | # Build display name based on available metadata | ||
| 102 | if album_artist and title: | ||
| 103 | display = f"{album_artist} - {title}" | ||
| 104 | elif artist and title: | ||
| 105 | display = f"{artist} - {title}" | ||
| 106 | elif title: | ||
| 107 | display = title | ||
| 108 | |||
| 109 | except Exception as e: | ||
| 110 | logger.debug(f"Could not read metadata for {filename}: {str(e)}") | ||
| 111 | |||
| 112 | # Clean up the display name (remove any extra whitespace or brackets) | ||
| 113 | display = display.strip() | ||
| 114 | |||
| 115 | # Store the mapping (use the cleaned display name as the key) | ||
| 116 | self.path_mapping[display] = path | ||
| 117 | |||
| 118 | # Truncate display name if too long | ||
| 119 | if len(display) > 90: # Leave room for potential numbering | ||
| 120 | display = display[:87] + '...' | ||
| 121 | |||
| 122 | choices.append(OptionChoice(display[:100], display[:100])) | ||
| 123 | |||
| 124 | # Limit to 25 choices (Discord's limit) | ||
| 125 | if len(choices) >= 25: | ||
| 126 | break | ||
| 127 | |||
| 128 | return choices | ||
| 129 | |||
| 130 | except Exception as e: | ||
| 131 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) | ||
| 132 | return [] | ||
| 133 | |||
| 134 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") | ||
| 135 | async def play( | ||
| 53 | self, | 136 | self, |
| 54 | ctx: discord.ApplicationContext, | 137 | ctx: discord.ApplicationContext, |
| 55 | query: str = discord.Option(str, "co zagrać??", required=True), | 138 | file: str = discord.Option( |
| 56 | source: str = discord.Option(str, "skąd zagrać??", required=True, | 139 | str, |
| 57 | choices=["local", "soundcloud", "youtube"]) | 140 | "Wybierz plik do odtworzenia", |
| 141 | autocomplete=_search_autocomplete, | ||
| 142 | required=True | ||
| 143 | ), | ||
| 144 | source: str = discord.Option( | ||
| 145 | str, | ||
| 146 | "Źródło dźwięku", | ||
| 147 | choices=["local", "soundcloud", "youtube"], | ||
| 148 | default="local" | ||
| 149 | ), | ||
| 150 | compressed: bool = discord.Option( | ||
| 151 | bool, | ||
| 152 | "Czy skompresować dźwięk?", | ||
| 153 | default=False | ||
| 154 | ) | ||
| 58 | ): | 155 | ): |
| 59 | await ctx.defer(ephemeral=True) | 156 | """Odtwarza wybrany plik dźwiękowy z opcjonalną kompresją.""" |
| 60 | if source == "local": | 157 | await ctx.defer() |
| 61 | candidates = await play_utils.PlayUtils(self.bot).search_local(query) | 158 | |
| 62 | if candidates: | 159 | if source != "local": |
| 63 | embed = discord.Embed(title="Wyniki wyszukiwania", description="Wybierz plik do odtworzenia:", | 160 | await ctx.respond("Tylko źródło 'local' jest obecnie obsługiwane w tej komendzie.", ephemeral=True) |
| 64 | color=discord.Color.blue()) | 161 | return |
| 65 | view = discord.ui.View(disable_on_timeout=True) | 162 | |
| 66 | for i, candidate in enumerate(candidates, start=1): | 163 | # Look up the full path from the display name |
| 67 | filename = os.path.basename(candidate) | 164 | full_path = self.path_mapping.get(file, file) |
| 68 | metadata = None | 165 | |
| 69 | if os.path.splitext(filename)[1].lower() in ['.mp3', '.wav', '.opus']: | 166 | if not os.path.exists(full_path): |
| 70 | metadata = mutagen.File(candidate) | 167 | logger.warning(f"File not found: {full_path} (display name: {file})") |
| 71 | title = metadata.get('title', None) | 168 | await ctx.respond("Wybrany plik nie istnieje.", ephemeral=True) |
| 72 | if metadata and title: | 169 | return |
| 73 | embed.add_field(name=f"{i}. {title}", value="\u200b", inline=False) | 170 | |
| 74 | else: | 171 | try: |
| 75 | embed.add_field(name=f"{i}. {filename}", value="\u200b", inline=False) | 172 | if compressed: |
| 76 | button = discord.ui.Button(label=f"Play {i}") | 173 | # Compress the file before playing |
| 77 | 174 | compressed_path = await play_utils.PlayUtils(self.bot).compress(ctx, full_path) | |
| 78 | async def button_callback(interaction: discord.Interaction, candidate=candidate): | 175 | if not compressed_path: |
| 79 | await interaction.response.defer(ephemeral=True) | 176 | await ctx.respond("Nie udało się skompresować pliku.", ephemeral=True) |
| 177 | return | ||
| 178 | |||
| 179 | try: | ||
| 180 | await play_utils.PlayUtils(self.bot).play(ctx, compressed_path, delete_after_play=True) | ||
| 181 | except Exception as e: | ||
| 182 | # Clean up the compressed file if play fails | ||
| 183 | if os.path.exists(compressed_path): | ||
| 80 | try: | 184 | try: |
| 81 | await ctx.interaction.delete_original_response() | 185 | os.remove(compressed_path) |
| 82 | # Compress the file before playing | 186 | logger.info(f"Cleaned up temporary file after play error: {compressed_path}") |
| 83 | compressed_path = await play_utils.PlayUtils(self.bot).compress(ctx, candidate) | 187 | except Exception as cleanup_error: |
| 84 | if compressed_path: | 188 | logger.error(f"Error cleaning up file {compressed_path}: {str(cleanup_error)}") |
| 85 | try: | 189 | raise e |
| 86 | await play_utils.PlayUtils(self.bot).play(ctx, compressed_path, delete_after_play=True) | ||
| 87 | except Exception as e: | ||
| 88 | # Clean up the compressed file if play fails | ||
| 89 | if os.path.exists(compressed_path): | ||
| 90 | try: | ||
| 91 | os.remove(compressed_path) | ||
| 92 | logger.info(f"Cleaned up temporary file after play error: {compressed_path}") | ||
| 93 | except Exception as cleanup_error: | ||
| 94 | logger.error(f"Error cleaning up file {compressed_path}: {str(cleanup_error)}") | ||
| 95 | raise e | ||
| 96 | except Exception as e: | ||
| 97 | logger.error(f"Error in play_compressed: {str(e)}", exc_info=True) | ||
| 98 | await ctx.respond(f"Error playing compressed file: {str(e)}", ephemeral=True) | ||
| 99 | |||
| 100 | button.callback = button_callback | ||
| 101 | view.add_item(button) | ||
| 102 | await ctx.respond(embed=embed, view=view, ephemeral=True) | ||
| 103 | else: | 190 | else: |
| 104 | await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) | 191 | await play_utils.PlayUtils(self.bot).play(ctx, full_path) |
| 105 | else: | 192 | except Exception as e: |
| 106 | # For YouTube/SoundCloud | 193 | logger.error(f"Error playing file: {str(e)}", exc_info=True) |
| 107 | try: | 194 | await ctx.respond(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) |
| 108 | url, title = await play_utils.PlayUtils(self.bot).get_url(ctx, query) | ||
| 109 | if not url: | ||
| 110 | return | ||
| 111 | |||
| 112 | filename = await play_utils.PlayUtils(self.bot).download(ctx, url) | ||
| 113 | if not filename: | ||
| 114 | return | ||
| 115 | |||
| 116 | # Compress the downloaded file | ||
| 117 | compressed_filename = await play_utils.PlayUtils(self.bot).compress(ctx, filename) | ||
| 118 | if compressed_filename: | ||
| 119 | await play_utils.PlayUtils(self.bot).play(ctx, compressed_filename, title) | ||
| 120 | except Exception as e: | ||
| 121 | logger.error(f"Error in play_compressed: {str(e)}") | ||
| 122 | await ctx.respond(f"Error: {str(e)}", ephemeral=True) | ||
| 123 | 195 | ||
| 124 | @discord.slash_command(name="grajcuj_plik", description="grajcuj plik") | 196 | @discord.slash_command(name="grajcuj_plik", description="grajcuj plik") |
| 125 | async def play_file(self, ctx: discord.ApplicationContext, attachment: discord.Attachment = discord.Option(discord.Attachment, "co zagrać??", required=True)): | 197 | async def play_file(self, ctx: discord.ApplicationContext, attachment: discord.Attachment = discord.Option(discord.Attachment, "co zagrać??", required=True)): |
diff --git a/cogs/music/play_utils.py b/cogs/music/play_utils.py index b3d81a8..8de7543 100644 --- a/cogs/music/play_utils.py +++ b/cogs/music/play_utils.py | |||
| @@ -3,6 +3,7 @@ import logging | |||
| 3 | import math | 3 | import math |
| 4 | import os | 4 | import os |
| 5 | import asyncio | 5 | import asyncio |
| 6 | import re | ||
| 6 | from pathlib import Path | 7 | from pathlib import Path |
| 7 | 8 | ||
| 8 | import discord | 9 | import discord |
| @@ -33,27 +34,90 @@ class Utils: | |||
| 33 | 34 | ||
| 34 | @staticmethod | 35 | @staticmethod |
| 35 | def combined_score(query, candidate): | 36 | def combined_score(query, candidate): |
| 36 | """Calculate a combined fuzzy matching score using multiple strategies.""" | 37 | """Calculate a combined fuzzy matching score using multiple strategies. |
| 37 | # Use several scorers and combine with weights | ||
| 38 | r_ratio = fuzz.ratio(query, candidate) # overall edit similarity | ||
| 39 | r_partial = fuzz.partial_ratio(query, candidate) # substring allowance | ||
| 40 | r_token_set = fuzz.token_set_ratio(query, candidate) # word overlap ignoring extras | ||
| 41 | 38 | ||
| 42 | # Calculate prefix score using partial_ratio of the start of the strings | 39 | Args: |
| 43 | min_len = min(len(query), len(candidate)) | 40 | query: The search query |
| 44 | if min_len > 0: | 41 | candidate: The candidate string to score against |
| 45 | r_prefix = fuzz.ratio(query, candidate[:min_len]) # prefix matching | 42 | |
| 46 | else: | 43 | Returns: |
| 47 | r_prefix = 0 | 44 | float: Score between 0-100, where higher means better match |
| 48 | 45 | """ | |
| 49 | # Weighted sum (tune weights as needed) | 46 | if not query or not candidate: |
| 50 | score = (0.35 * r_token_set + # Increased weight for token set | 47 | return 0.0 |
| 51 | 0.30 * r_partial + # Slightly increased weight for partial matches | 48 | |
| 52 | 0.25 * r_ratio + # Standard ratio | 49 | query = query.lower().strip() |
| 53 | 0.10 * r_prefix) # Reduced weight for prefix matches | 50 | candidate = candidate.lower() |
| 51 | |||
| 52 | # First check for direct substring match (fast path) | ||
| 53 | if query in candidate: | ||
| 54 | # Base score of 100 for exact match, with bonus for being at word boundary | ||
| 55 | base_score = 100 | ||
| 56 | # Check if match is at word boundary | ||
| 57 | if re.search(rf'\b{re.escape(query)}\b', candidate): | ||
| 58 | base_score = 110 # Slight bonus for word boundary match | ||
| 59 | return min(100, base_score) | ||
| 60 | |||
| 61 | # For very short queries (2-3 chars), be more lenient | ||
| 62 | if len(query) <= 3: | ||
| 63 | # Check for partial matches at word boundaries | ||
| 64 | if re.search(rf'\b{re.escape(query)}', candidate): | ||
| 65 | return 90 # High score for short query matching word start | ||
| 66 | if query in candidate: | ||
| 67 | return 80 # Still good for any substring match with short query | ||
| 68 | |||
| 69 | # For longer queries, check word presence | ||
| 70 | query_words = set(word for word in re.split(r'\W+', query) if len(word) > 1) # Reduced min word length to 1 | ||
| 71 | if query_words: | ||
| 72 | candidate_words = set(re.split(r'\W+', candidate)) | ||
| 73 | # Check if all query words are in candidate (with some leniency) | ||
| 74 | matching_words = sum(1 for word in query_words if word in candidate_words) | ||
| 75 | word_ratio = matching_words / len(query_words) | ||
| 76 | |||
| 77 | # If no words match at all, return 0 | ||
| 78 | if matching_words == 0: | ||
| 79 | return 0.0 | ||
| 54 | 80 | ||
| 55 | score *= Utils.length_penalty(query, candidate) # apply length penalty | 81 | # Calculate different similarity metrics |
| 56 | return min(100, score) # Ensure score doesn't exceed 100 | 82 | r_ratio = fuzz.ratio(query, candidate) # Overall edit similarity |
| 83 | r_partial = fuzz.partial_ratio(query, candidate) # Best substring match | ||
| 84 | r_token_set = fuzz.token_set_ratio(query, candidate) # Word overlap | ||
| 85 | |||
| 86 | # Calculate word-based scores | ||
| 87 | query_tokens = set(query.split()) | ||
| 88 | candidate_tokens = set(candidate.split()) | ||
| 89 | word_intersection = len(query_tokens & candidate_tokens) | ||
| 90 | word_union = len(query_tokens | candidate_tokens) | ||
| 91 | |||
| 92 | # Jaccard similarity for word overlap | ||
| 93 | jaccard_sim = word_intersection / word_union if word_union > 0 else 0 | ||
| 94 | |||
| 95 | # Calculate prefix score (bonus for matching start of words) | ||
| 96 | prefix_bonus = 0 | ||
| 97 | for q_word in query_words: | ||
| 98 | for c_word in candidate_words: | ||
| 99 | if c_word.startswith(q_word): | ||
| 100 | prefix_bonus += (len(q_word) / max(len(c_word), 1)) * 20 # Increased weight | ||
| 101 | |||
| 102 | # Weighted sum with adjusted weights | ||
| 103 | score = ( | ||
| 104 | 0.40 * r_token_set + # Increased weight for token set | ||
| 105 | 0.25 * r_ratio + # Overall similarity | ||
| 106 | 0.15 * r_partial + # Substring matches | ||
| 107 | 0.15 * (jaccard_sim * 100) + # Word overlap ratio | ||
| 108 | 0.05 * min(prefix_bonus, 100) # Prefix bonus (capped at 100) | ||
| 109 | ) | ||
| 110 | |||
| 111 | # Apply length penalty (less severe) | ||
| 112 | score *= min(1.0, 0.9 + (0.1 * Utils.length_penalty(query, candidate))) | ||
| 113 | |||
| 114 | # Boost score based on word ratio | ||
| 115 | score = score * (0.7 + (0.3 * word_ratio)) | ||
| 116 | |||
| 117 | # Ensure score is within bounds | ||
| 118 | return min(100, max(0, score)) | ||
| 119 | |||
| 120 | return 0.0 # No match found | ||
| 57 | 121 | ||
| 58 | class PlayUtils: | 122 | class PlayUtils: |
| 59 | def __init__(self, bot): | 123 | def __init__(self, bot): |
| @@ -235,18 +299,24 @@ class PlayUtils: | |||
| 235 | 299 | ||
| 236 | # -- LOCAL PLAY -- | 300 | # -- LOCAL PLAY -- |
| 237 | 301 | ||
| 238 | @staticmethod | 302 | @classmethod |
| 239 | async def search_local(query, max_results=5): | 303 | async def search_local(cls, query: str = None, max_results: int = 25, autocomplete: bool = False): |
| 240 | """Search for audio files that contain the query in their name (case-insensitive). | 304 | """Search for audio files with fuzzy matching and optional autocomplete support. |
| 241 | 305 | ||
| 242 | Args: | 306 | Args: |
| 243 | query: Search query string | 307 | query: Search query string (can be None for autocomplete) |
| 244 | max_results: Maximum number of results to return | 308 | max_results: Maximum number of results to return |
| 309 | autocomplete: If True, returns results even with empty query | ||
| 245 | 310 | ||
| 246 | Returns: | 311 | Returns: |
| 247 | List of matching file paths | 312 | List of matching file paths with scores |
| 248 | """ | 313 | """ |
| 314 | if not query and not autocomplete: | ||
| 315 | return [] | ||
| 316 | |||
| 317 | query = (query or '').lower().strip() | ||
| 249 | logger.info(f"Starting local search for query: '{query}'") | 318 | logger.info(f"Starting local search for query: '{query}'") |
| 319 | |||
| 250 | try: | 320 | try: |
| 251 | with open('settings.json') as f: | 321 | with open('settings.json') as f: |
| 252 | data = json.load(f) | 322 | data = json.load(f) |
| @@ -254,7 +324,7 @@ class PlayUtils: | |||
| 254 | logger.info(f"Searching in paths: {search_paths}") | 324 | logger.info(f"Searching in paths: {search_paths}") |
| 255 | 325 | ||
| 256 | matches = [] | 326 | matches = [] |
| 257 | q = query.lower() | 327 | file_count = 0 |
| 258 | 328 | ||
| 259 | for path in search_paths: | 329 | for path in search_paths: |
| 260 | path = Path(path) | 330 | path = Path(path) |
| @@ -273,19 +343,51 @@ class PlayUtils: | |||
| 273 | if file_path.suffix.lower() not in {'.mp4', '.mp3', '.wav', '.opus', '.m4a'}: | 343 | if file_path.suffix.lower() not in {'.mp4', '.mp3', '.wav', '.opus', '.m4a'}: |
| 274 | continue | 344 | continue |
| 275 | 345 | ||
| 276 | # Check if query is in filename (case-insensitive) | 346 | file_count += 1 |
| 277 | if q in file_path.name.lower(): | 347 | file_name = file_path.name.lower() |
| 278 | matches.append(str(file_path.absolute())) | 348 | |
| 279 | if len(matches) >= max_results: | 349 | # For autocomplete, return all files if query is empty |
| 280 | logger.info(f"Found {len(matches)} matches, stopping search") | 350 | if not query and autocomplete: |
| 281 | return matches | 351 | matches.append((0, str(file_path.absolute()))) |
| 352 | # For regular search, calculate fuzzy match score | ||
| 353 | elif query and len(query) >= 2: | ||
| 354 | # First do a simple substring check for debugging | ||
| 355 | if query in file_name: | ||
| 356 | logger.debug(f"Found direct match: {file_name} (contains '{query}')") | ||
| 357 | |||
| 358 | score = Utils.combined_score(query, file_name) | ||
| 359 | logger.debug(f"File: {file_name} - Score: {score}") | ||
| 360 | |||
| 361 | if score >= 30: # Minimum score threshold | ||
| 362 | matches.append((score, str(file_path.absolute()))) | ||
| 363 | |||
| 364 | # Early return if we have enough high-scoring matches | ||
| 365 | if len(matches) >= max_results * 5: # Get more candidates for sorting | ||
| 366 | break | ||
| 367 | |||
| 368 | if len(matches) >= max_results * 5: # Limit memory usage | ||
| 369 | break | ||
| 370 | |||
| 371 | logger.info(f"Scanned {file_count} files total") | ||
| 282 | 372 | ||
| 283 | logger.info(f"Search complete. Found {len(matches)} matches.") | 373 | # Sort by score (descending) and take top results |
| 284 | return matches | 374 | matches.sort(reverse=True, key=lambda x: x[0]) |
| 375 | result = [path for score, path in matches[:max_results]] | ||
| 376 | |||
| 377 | logger.info(f"Search complete. Found {len(result)} matches out of {file_count} files.") | ||
| 378 | if not result and query: | ||
| 379 | logger.warning(f"No matches found for query: '{query}'. Try a different search term.") | ||
| 380 | return result | ||
| 285 | 381 | ||
| 286 | except Exception as e: | 382 | except Exception as e: |
| 287 | logger.error(f"Error during local search: {str(e)}", exc_info=True) | 383 | logger.error(f"Error during local search: {str(e)}", exc_info=True) |
| 288 | return [] | 384 | return [] |
| 385 | |||
| 386 | @classmethod | ||
| 387 | async def search_local_autocomplete(cls, query: str = None, max_results: int = 10): | ||
| 388 | """Wrapper for autocomplete searches that returns more results and allows empty query.""" | ||
| 389 | # For autocomplete, we want to show more options and allow empty queries | ||
| 390 | return await cls.search_local(query, max_results=max_results, autocomplete=True) | ||
| 289 | 391 | ||
| 290 | 392 | ||
| 291 | 393 | ||
