aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-01-04 13:31:49 +0100
committersalmonik <raisersoft@proton.me>2026-01-04 13:31:49 +0100
commitc634305604d0c993343a34262a3f3811d4c22e2c (patch)
treea82740253193a3c718631a97ebca1fc216cd7155
parentbumped requirements (diff)
downloadsenator-sperma-c634305604d0c993343a34262a3f3811d4c22e2c.tar.gz
senator-sperma-c634305604d0c993343a34262a3f3811d4c22e2c.zip
live search with good algorithm
-rw-r--r--cogs/music/play.py272
-rw-r--r--cogs/music/play_utils.py168
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 @@
1import os 1import os
2
3import discord 2import discord
4import mutagen 3import mutagen
4from typing import List, Tuple, Optional, Callable, Awaitable, Union
5 5
6from . import play_utils 6from . import play_utils
7from discord.ext import commands 7from discord.ext import commands
8from discord.commands import Option, OptionChoice
8import logging 9import logging
9 10
10logger = logging.getLogger(__name__) 11logger = 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
3import math 3import math
4import os 4import os
5import asyncio 5import asyncio
6import re
6from pathlib import Path 7from pathlib import Path
7 8
8import discord 9import 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
58class PlayUtils: 122class 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