aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/utils/file_search.py
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-03-23 17:43:36 +0100
committersalmonik <raisersoft@proton.me>2026-03-23 17:43:36 +0100
commit456842d6f1c36d5a04cb4f8821e641be80e077f9 (patch)
tree5235263de6ed0a25cc21576bb2f301b94d9d6f1e /cogs/utils/file_search.py
parenttriggers (diff)
downloadsenator-sperma-456842d6f1c36d5a04cb4f8821e641be80e077f9.tar.gz
senator-sperma-456842d6f1c36d5a04cb4f8821e641be80e077f9.zip
play music using the server
Diffstat (limited to '')
-rw-r--r--cogs/utils/file_search.py370
1 files changed, 20 insertions, 350 deletions
diff --git a/cogs/utils/file_search.py b/cogs/utils/file_search.py
index 2685e25..8d3e5d6 100644
--- a/cogs/utils/file_search.py
+++ b/cogs/utils/file_search.py
@@ -5,368 +5,41 @@
5 5
6import json 6import json
7import logging 7import logging
8import re
9from pathlib import Path
10import aiofiles
11from rapidfuzz import fuzz
12import aiohttp 8import aiohttp
13import os 9import os
14from dotenv import load_dotenv 10from dotenv import load_dotenv
11from cogs.utils.settings import get_baseurl
15 12
16load_dotenv() 13load_dotenv()
17 14
18logger = logging.getLogger(__name__) 15logger = logging.getLogger(__name__)
19 16
20class SearchUtils:
21 """Utilities for searching and scoring audio files."""
22
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 # Check for direct matches first
58 direct_match_score = SearchUtils._check_direct_matches(query, candidate)
59 if direct_match_score is not None:
60 return direct_match_score
61
62 # Handle short queries specially
63 short_query_score = SearchUtils._handle_short_queries(query, candidate)
64 if short_query_score is not None:
65 return short_query_score
66
67 # For longer queries, use complex scoring
68 return SearchUtils._calculate_complex_score(query, candidate)
69
70 @staticmethod
71 def _check_direct_matches(query: str, candidate: str) -> float:
72 """Check for direct substring matches."""
73 if query in candidate:
74 base_score = 100
75 if re.search(rf'\b{re.escape(query)}\b', candidate):
76 base_score = 110
77 return min(100, base_score)
78 return 0
79
80 @staticmethod
81 def _handle_short_queries(query: str, candidate: str) -> float:
82 """Handle scoring for short queries (2-3 chars)."""
83 if len(query) <= 3:
84 if re.search(rf'\b{re.escape(query)}', candidate):
85 return 90
86 if query in candidate:
87 return 80
88 return 0
89
90 @staticmethod
91 def _calculate_complex_score(query: str, candidate: str) -> float:
92 """Calculate complex score for longer queries."""
93 query_words = {word for word in re.split(r'\W+', query) if len(word) > 1}
94 if not query_words:
95 return 0.0
96
97 candidate_words = set(re.split(r'\W+', candidate))
98 word_ratio = SearchUtils._calculate_word_ratio(query_words, candidate_words)
99
100 if word_ratio == 0:
101 return 0.0
102
103 # Calculate various similarity metrics
104 similarity_scores = SearchUtils._get_similarity_scores(query, candidate)
105 word_scores = SearchUtils._get_word_scores(query, candidate, query_words, candidate_words)
106
107 # Calculate final weighted score
108 final_score = SearchUtils._calculate_weighted_score(similarity_scores, word_scores, word_ratio)
109
110 # Apply penalties and boosts
111 final_score = SearchUtils._apply_adjustments(final_score, query, candidate, word_ratio)
112
113 return min(100, max(0, int(final_score)))
114
115 @staticmethod
116 def _calculate_word_ratio(query_words: set, candidate_words: set) -> float:
117 """Calculate the ratio of matching words."""
118 matching_words = sum(1 for word in query_words if word in candidate_words)
119 return matching_words / len(query_words)
120
121 @staticmethod
122 def _get_similarity_scores(query: str, candidate: str) -> dict:
123 """Get various fuzzy similarity scores."""
124 return {
125 'ratio': fuzz.ratio(query, candidate),
126 'partial': fuzz.partial_ratio(query, candidate),
127 'token_set': fuzz.token_set_ratio(query, candidate)
128 }
129
130 @staticmethod
131 def _get_word_scores(query: str, candidate: str, query_words: set, candidate_words: set) -> dict:
132 """Calculate word-based scores."""
133 query_tokens = set(query.split())
134 candidate_tokens = set(candidate.split())
135
136 word_intersection = len(query_tokens & candidate_tokens)
137 word_union = len(query_tokens | candidate_tokens)
138 jaccard_sim = word_intersection / word_union if word_union > 0 else 0
139
140 prefix_bonus = SearchUtils._calculate_prefix_bonus(query_words, candidate_words)
141
142 return {
143 'jaccard': jaccard_sim,
144 'prefix': min(prefix_bonus, 100)
145 }
146
147 @staticmethod
148 def _calculate_prefix_bonus(query_words: set, candidate_words: set) -> float:
149 """Calculate prefix matching bonus."""
150 prefix_bonus = 0
151 for q_word in query_words:
152 for c_word in candidate_words:
153 if c_word.startswith(q_word):
154 prefix_bonus += (len(q_word) / max(len(c_word), 1)) * 20
155 return prefix_bonus
156
157 @staticmethod
158 def _calculate_weighted_score(similarity_scores: dict, word_scores: dict, word_ratio: float) -> float:
159 """Calculate the final weighted score."""
160 return (
161 0.40 * similarity_scores['token_set'] +
162 0.25 * similarity_scores['ratio'] +
163 0.15 * similarity_scores['partial'] +
164 0.15 * (word_scores['jaccard'] * 100) +
165 0.05 * word_scores['prefix']
166 )
167
168 @staticmethod
169 def _apply_adjustments(score: float, query: str, candidate: str, word_ratio: float) -> float:
170 """Apply length penalty and word ratio boost."""
171 import math
172
173 # Apply length penalty
174 score *= min(1.0, 0.9 + (0.1 * SearchUtils.length_penalty(query, candidate)))
175
176 # Apply word ratio boost
177 score = score * (0.7 + (0.3 * word_ratio))
178
179 return score
180 17
181class UniversalSearcher: 18class UniversalSearcher:
182 """Handles searching for files in configured paths with support for multiple file types.""" 19 """Handles searching for files via copyparty API."""
183 20
184 FILE_TYPE_EXTENSIONS = {
185 'Audio': {'.mp4', '.mp3', '.wav', '.opus', '.m4a', '.flac', '.aac', '.ogg'},
186 'Video': {'.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.3gp', '.gif'},
187 'Image': {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg', '.ico'}
188 }
189
190 @staticmethod
191 def _is_valid_file(file_path: Path, filetype: str = None) -> bool:
192 """Check if file is valid for the specified filetype."""
193 if not file_path.is_file():
194 return False
195
196 if filetype is None:
197 # Search all file types
198 all_extensions = set()
199 for extensions in UniversalSearcher.FILE_TYPE_EXTENSIONS.values():
200 all_extensions.update(extensions)
201 return file_path.suffix.lower() in all_extensions
202
203 filetype = filetype.capitalize()
204 if filetype not in UniversalSearcher.FILE_TYPE_EXTENSIONS:
205 return False
206
207 valid_extensions = UniversalSearcher.FILE_TYPE_EXTENSIONS[filetype]
208 return file_path.suffix.lower() in valid_extensions
209
210 @staticmethod
211 def _calculate_match_score(query: str, file_name: str, autocomplete: bool) -> tuple[int, str]:
212 """Calculate match score for a file."""
213 if not query and autocomplete:
214 return 0, file_name
215
216 if query and len(query) >= 2:
217 if query in file_name:
218 logger.debug(f"Found direct match: {file_name} (contains '{query}')")
219
220 score = SearchUtils.combined_score(query, file_name)
221 logger.debug(f"File: {file_name} - Score: {score}")
222
223 if score >= 30:
224 return score, file_name
225
226 return -1, file_name
227
228 @staticmethod
229 async def _search_single_path(path: Path, query: str, max_results: int, autocomplete: bool, filetype: str = None) -> list[tuple[int, str]]:
230 """Search for files in a single path."""
231 matches = []
232
233 logger.debug(f"Searching in directory: {path} (filetype: {filetype or 'All'})")
234
235 for file_path in path.rglob('*'):
236 if not UniversalSearcher._is_valid_file(file_path, filetype):
237 continue
238
239 file_name = file_path.name.lower()
240 score, _ = UniversalSearcher._calculate_match_score(query, file_name, autocomplete)
241
242 if score >= 0:
243 matches.append((score, str(file_path.absolute())))
244
245 if len(matches) >= max_results * 5:
246 break
247
248 return matches
249
250 @staticmethod
251 def _get_search_paths(data: dict, filetype: str = None) -> list:
252 """Get the search paths from settings."""
253 return data.get('file_paths', [])
254
255 @classmethod
256 async def _search_paths(cls, search_paths: list, query: str, max_results: int, autocomplete: bool, filetype: str = None) -> tuple[list, int]:
257 """Search through the provided paths and return matches and file count."""
258 matches = []
259 file_count = 0
260
261 for path_str in search_paths:
262 path = Path(path_str)
263 if not path.exists():
264 logger.warning(f"Path does not exist: {path}")
265 continue
266
267 path_matches = await cls._search_single_path(path, query, max_results, autocomplete, filetype)
268 matches.extend(path_matches)
269 file_count += len(path_matches)
270
271 if len(matches) >= max_results * 5:
272 break
273
274 return matches, file_count
275
276 @classmethod
277 async def search_local(cls, query: str = None, max_results: int = 25, autocomplete: bool = False, filetype: str = None):
278 """Search for files with fuzzy matching and optional autocomplete support.
279
280 Args:
281 query: Search query string (can be None for autocomplete)
282 max_results: Maximum number of results to return
283 autocomplete: If True, returns results even with empty query
284 filetype: Type of files to search for ('Audio', 'Video', 'Image', or None for all)
285
286 Returns:
287 List of matching file paths with scores
288 """
289 if not query and not autocomplete:
290 return []
291
292 query = (query or '').lower().strip()
293 logger.info(f"Starting local search for query: '{query}' (filetype: {filetype or 'All'})")
294
295 try:
296 async with aiofiles.open('settings.json') as f:
297 content = await f.read()
298 data = json.loads(content)
299
300 search_paths = cls._get_search_paths(data, filetype)
301 logger.info(f"Searching in paths: {search_paths}")
302
303 matches, file_count = await cls._search_paths(search_paths, query, max_results, autocomplete, filetype)
304
305 logger.info(f"Scanned {file_count} files total")
306
307 matches.sort(reverse=True, key=lambda x: x[0])
308 result = [path for score, path in matches[:max_results]]
309
310 logger.info(f"Search complete. Found {len(result)} matches out of {file_count} files.")
311 if not result and query:
312 logger.warning(f"No matches found for query: '{query}'. Try a different search term.")
313 return result
314
315 except Exception as e:
316 logger.error(f"Error during local search: {str(e)}", exc_info=True)
317 return []
318
319 @classmethod
320 async def search_local_autocomplete(cls, query: str = None, max_results: int = 10, filetype: str = None):
321 """Wrapper for autocomplete searches that returns more results and allows empty query.
322
323 Args:
324 query: Search query string (can be None for autocomplete)
325 max_results: Maximum number of results to return
326 filetype: Type of files to search for ('Audio', 'Video', 'Image', or None for all)
327 """
328 # For autocomplete, we want to show more options and allow empty queries
329 if not query:
330 return []
331
332 return await cls.search_local(query, max_results=max_results, autocomplete=True, filetype=filetype)
333
334 @staticmethod
335 async def _load_baseurl():
336 """Load baseurl from settings.json"""
337 try:
338 async with aiofiles.open('settings.json') as f:
339 content = await f.read()
340 data = json.loads(content)
341 return data.get('baseurl', 'http://localhost:8080').rstrip('/')
342 except Exception as e:
343 logger.error(f"Error loading baseurl: {e}")
344 return 'http://localhost:8080'
345
346 @staticmethod 21 @staticmethod
347 async def search_copyparty(query: str, max_results: int = 25): 22 async def search_copyparty(query: str, max_results: int = 25):
348 """Search for files using copyparty API (jPOST method). 23 """Search for files using copyparty API (jPOST method).
349 24
350 Args: 25 Args:
351 query: Search query string 26 query: Search query string
352 max_results: Maximum number of results to return 27 max_results: Maximum number of results to return
353 28
354 Returns: 29 Returns:
355 List of matching file URLs 30 List of matching file URLs
356 """ 31 """
357
358
359 if not query: 32 if not query:
360 return [] 33 return []
361 34
362 query = query.strip() 35 query = query.strip()
363 logger.info(f"Starting copyparty search for query: '{query}'") 36 logger.info(f"Starting copyparty search for query: '{query}'")
364 query = f'name like "*{query}*"' 37 query = f'name like "*{query}*"'
365 38
366 try: 39 try:
367 baseurl = await UniversalSearcher._load_baseurl() 40 baseurl = get_baseurl()
368 password = os.getenv("COPYPARTY_PASSWORD") 41 password = os.getenv("COPYPARTY_PASSWORD")
369 42
370 async with aiohttp.ClientSession() as session: 43 async with aiohttp.ClientSession() as session:
371 headers = {"Content-Type": "application/json"} 44 headers = {"Content-Type": "application/json"}
372 if password: 45 if password:
@@ -377,20 +50,20 @@ class UniversalSearcher:
377 logger.info(f"Copyparty response: {data}") 50 logger.info(f"Copyparty response: {data}")
378 files = UniversalSearcher._extract_files_from_response(data) 51 files = UniversalSearcher._extract_files_from_response(data)
379 results = UniversalSearcher._convert_files_to_urls(files, baseurl, max_results) 52 results = UniversalSearcher._convert_files_to_urls(files, baseurl, max_results)
380 53
381 logger.info(f"Copyparty search complete. Found {len(results)} matches.") 54 logger.info(f"Copyparty search complete. Found {len(results)} matches.")
382 return results 55 return results
383 else: 56 else:
384 logger.error(f"Copyparty API returned status {response.status}") 57 logger.error(f"Copyparty API returned status {response.status}")
385 return [] 58 return []
386 59
387 except aiohttp.ClientError as e: 60 except aiohttp.ClientError as e:
388 logger.error(f"HTTP error during copyparty search: {e}") 61 logger.error(f"HTTP error during copyparty search: {e}")
389 return [] 62 return []
390 except Exception as e: 63 except Exception as e:
391 logger.error(f"Error during copyparty search: {str(e)}", exc_info=True) 64 logger.error(f"Error during copyparty search: {str(e)}", exc_info=True)
392 return [] 65 return []
393 66
394 @staticmethod 67 @staticmethod
395 def _extract_files_from_response(data): 68 def _extract_files_from_response(data):
396 """Extract files list from copyparty API response.""" 69 """Extract files list from copyparty API response."""
@@ -407,26 +80,25 @@ class UniversalSearcher:
407 return data 80 return data
408 else: 81 else:
409 return [] 82 return []
410 83
411 @staticmethod 84 @staticmethod
412 def _convert_files_to_urls(files, baseurl, max_results): 85 def _convert_files_to_urls(files, baseurl, max_results):
413 """Convert file info objects to URLs.""" 86 """Convert file info objects to URLs."""
414 results = [] 87 results = []
415 88
416 for file_info in files[:max_results]: 89 for file_info in files[:max_results]:
417 if isinstance(file_info, dict): 90 if isinstance(file_info, dict):
418 file_url = UniversalSearcher._extract_url_from_dict(file_info, baseurl) 91 file_url = UniversalSearcher._extract_url_from_dict(file_info, baseurl)
419 if file_url: 92 if file_url:
420 results.append(file_url) 93 results.append(file_url)
421 elif isinstance(file_info, str): 94 elif isinstance(file_info, str):
422 # If it's just a string path/URL
423 if file_info.startswith('http'): 95 if file_info.startswith('http'):
424 results.append(file_info) 96 results.append(file_info)
425 else: 97 else:
426 results.append(f"{baseurl}/{file_info.lstrip('/')}") 98 results.append(f"{baseurl}/{file_info.lstrip('/')}")
427 99
428 return results 100 return results
429 101
430 @staticmethod 102 @staticmethod
431 def _extract_url_from_dict(file_info, baseurl): 103 def _extract_url_from_dict(file_info, baseurl):
432 """Extract URL from file info dictionary.""" 104 """Extract URL from file info dictionary."""
@@ -440,21 +112,19 @@ class UniversalSearcher:
440 return f"{baseurl}/{file_info['name'].lstrip('/')}" 112 return f"{baseurl}/{file_info['name'].lstrip('/')}"
441 else: 113 else:
442 return None 114 return None
443 115
444 @staticmethod 116 @staticmethod
445 async def search_copyparty_autocomplete(query: str, max_results: int = 10): 117 async def search_copyparty_autocomplete(query: str, max_results: int = 10):
446 """Wrapper for autocomplete searches using copyparty. 118 """Wrapper for autocomplete searches using copyparty.
447 119
448 Args: 120 Args:
449 query: Search query string 121 query: Search query string
450 max_results: Maximum number of results to return 122 max_results: Maximum number of results to return
451 123
452 Returns: 124 Returns:
453 List of matching file URLs 125 List of matching file URLs
454 """ 126 """
455 if not query: 127 if not query:
456 return [] 128 return []
457
458 return await UniversalSearcher.search_copyparty(query, max_results)
459
460 129
130 return await UniversalSearcher.search_copyparty(query, max_results)