diff options
| author | salmonik <raisersoft@proton.me> | 2026-03-23 17:43:36 +0100 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-03-23 17:43:36 +0100 |
| commit | 456842d6f1c36d5a04cb4f8821e641be80e077f9 (patch) | |
| tree | 5235263de6ed0a25cc21576bb2f301b94d9d6f1e /cogs/utils/file_search.py | |
| parent | triggers (diff) | |
| download | senator-sperma-456842d6f1c36d5a04cb4f8821e641be80e077f9.tar.gz senator-sperma-456842d6f1c36d5a04cb4f8821e641be80e077f9.zip | |
play music using the server
Diffstat (limited to '')
| -rw-r--r-- | cogs/utils/file_search.py | 370 |
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 | ||
| 6 | import json | 6 | import json |
| 7 | import logging | 7 | import logging |
| 8 | import re | ||
| 9 | from pathlib import Path | ||
| 10 | import aiofiles | ||
| 11 | from rapidfuzz import fuzz | ||
| 12 | import aiohttp | 8 | import aiohttp |
| 13 | import os | 9 | import os |
| 14 | from dotenv import load_dotenv | 10 | from dotenv import load_dotenv |
| 11 | from cogs.utils.settings import get_baseurl | ||
| 15 | 12 | ||
| 16 | load_dotenv() | 13 | load_dotenv() |
| 17 | 14 | ||
| 18 | logger = logging.getLogger(__name__) | 15 | logger = logging.getLogger(__name__) |
| 19 | 16 | ||
| 20 | class 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 | ||
| 181 | class UniversalSearcher: | 18 | class 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) | ||
