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 | |
| parent | triggers (diff) | |
| download | senator-sperma-456842d6f1c36d5a04cb4f8821e641be80e077f9.tar.gz senator-sperma-456842d6f1c36d5a04cb4f8821e641be80e077f9.zip | |
play music using the server
| -rw-r--r-- | cogs/music/play_commands.py | 166 | ||||
| -rw-r--r-- | cogs/utils/file_search.py | 370 |
2 files changed, 104 insertions, 432 deletions
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index e3ca4c1..23808d3 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py | |||
| @@ -4,8 +4,8 @@ | |||
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import os | 6 | import os |
| 7 | import urllib.parse | ||
| 7 | import discord | 8 | import discord |
| 8 | 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 | ||
| @@ -37,20 +37,8 @@ class PlayCommands(commands.Cog): | |||
| 37 | view = discord.ui.View(disable_on_timeout=True) | 37 | view = discord.ui.View(disable_on_timeout=True) |
| 38 | 38 | ||
| 39 | for i, candidate in enumerate(candidates, start=1): | 39 | for i, candidate in enumerate(candidates, start=1): |
| 40 | filename = os.path.basename(candidate) | 40 | filename = candidate.split('/')[-1].split('?')[0] |
| 41 | title = None | 41 | display_name = os.path.splitext(filename)[0] |
| 42 | |||
| 43 | # Try to get metadata if it's an audio file | ||
| 44 | if os.path.splitext(filename)[1].lower() in ['.mp3', '.wav', '.opus']: | ||
| 45 | try: | ||
| 46 | metadata = mutagen.File(candidate) | ||
| 47 | if metadata: | ||
| 48 | title = metadata.get('title', None) | ||
| 49 | except Exception as e: | ||
| 50 | logger.debug(f"Could not read metadata for {filename}: {str(e)}") | ||
| 51 | |||
| 52 | # Add field with title if available, otherwise use filename | ||
| 53 | display_name = title if title else filename | ||
| 54 | embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) | 42 | embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) |
| 55 | 43 | ||
| 56 | return embed, view | 44 | return embed, view |
| @@ -86,69 +74,46 @@ class PlayCommands(commands.Cog): | |||
| 86 | 74 | ||
| 87 | await ctx.respond(embed=embed, view=view, ephemeral=True) | 75 | await ctx.respond(embed=embed, view=view, ephemeral=True) |
| 88 | 76 | ||
| 89 | def _get_display_name_from_metadata(self, path: str, filename: str) -> str: | ||
| 90 | """Extract display name from audio file metadata.""" | ||
| 91 | try: | ||
| 92 | if os.path.splitext(path)[1].lower() in ['.mp3', '.wav', '.opus', '.m4a']: | ||
| 93 | metadata = mutagen.File(path, easy=True) | ||
| 94 | |||
| 95 | title = metadata.get('title', [filename])[0] | ||
| 96 | artist = metadata.get('artist', [''])[0] | ||
| 97 | album_artist = metadata.get('albumartist', [artist])[0] | ||
| 98 | |||
| 99 | if album_artist and title: | ||
| 100 | return f"{album_artist} - {title}" | ||
| 101 | elif artist and title: | ||
| 102 | return f"{artist} - {title}" | ||
| 103 | elif title: | ||
| 104 | return title | ||
| 105 | except Exception as e: | ||
| 106 | logger.debug(f"Could not read metadata for {filename}: {str(e)}") | ||
| 107 | |||
| 108 | return filename | ||
| 109 | |||
| 110 | async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]: | 77 | async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]: |
| 111 | """Autocomplete handler for search queries.""" | 78 | """Autocomplete handler for search queries.""" |
| 112 | query = ctx.value.lower() | 79 | query = ctx.value.lower() |
| 113 | try: | 80 | try: |
| 114 | results = await search_utils.UniversalSearcher.search_local_autocomplete(query, max_results=25, filetype="Audio") | 81 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) |
| 115 | choices = [] | 82 | choices = [] |
| 116 | self.path_mapping = {} # Reset the mapping for each new query | 83 | self.path_mapping = {} # Reset the mapping for each new query |
| 117 | 84 | ||
| 118 | for path in results: | 85 | for url in results: |
| 119 | filename = os.path.splitext(os.path.basename(path))[0] # Remove extension | 86 | filename = url.split('/')[-1] |
| 120 | display = self._get_display_name_from_metadata(path, filename) | 87 | filename = filename.split('?')[0] # Remove query params if any |
| 121 | 88 | filename = urllib.parse.unquote(filename) # Decode URL encoding | |
| 122 | # Clean up the display name (remove any extra whitespace or brackets) | 89 | display = os.path.splitext(filename)[0] # Remove extension |
| 90 | |||
| 123 | display = display.strip() | 91 | display = display.strip() |
| 124 | 92 | ||
| 125 | # Store the mapping (use the cleaned display name as the key) | 93 | self.path_mapping[display] = url |
| 126 | self.path_mapping[display] = path | 94 | |
| 127 | 95 | if len(display) > 90: | |
| 128 | # Truncate display name if too long | ||
| 129 | if len(display) > 90: # Leave room for potential numbering | ||
| 130 | display = display[:87] + '...' | 96 | display = display[:87] + '...' |
| 131 | 97 | ||
| 132 | choices.append(OptionChoice(display[:100], display[:100])) | 98 | choices.append(OptionChoice(display[:100], display[:100])) |
| 133 | 99 | ||
| 134 | # Limit to 25 choices (Discord's limit) | ||
| 135 | if len(choices) >= 25: | 100 | if len(choices) >= 25: |
| 136 | break | 101 | break |
| 137 | 102 | ||
| 138 | return choices | 103 | return choices |
| 139 | 104 | ||
| 140 | except Exception as e: | 105 | except Exception as e: |
| 141 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) | 106 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) |
| 142 | return [] | 107 | return [] |
| 143 | 108 | ||
| 144 | def _validate_play_inputs(self, file: str, stream: str, ctx: discord.ApplicationContext) -> bool: | 109 | async def _validate_play_inputs(self, file: str, stream: str, ctx: discord.ApplicationContext) -> bool: |
| 145 | """Validate input parameters for play command.""" | 110 | """Validate input parameters for play command.""" |
| 146 | if not file and not stream: | 111 | if not file and not stream: |
| 147 | ctx.respond("Musisz podać plik do odtworzenia lub adres streamu.", ephemeral=True) | 112 | await ctx.respond("Musisz podać plik do odtworzenia lub adres streamu.", ephemeral=True) |
| 148 | return False | 113 | return False |
| 149 | 114 | ||
| 150 | if file and stream: | 115 | if file and stream: |
| 151 | ctx.respond("Możesz podać tylko plik LUB stream, nie oba naraz.", ephemeral=True) | 116 | await ctx.respond("Możesz podać tylko plik LUB stream, nie oba naraz.", ephemeral=True) |
| 152 | return False | 117 | return False |
| 153 | 118 | ||
| 154 | return True | 119 | return True |
| @@ -157,38 +122,75 @@ class PlayCommands(commands.Cog): | |||
| 157 | """Handle internet stream playback.""" | 122 | """Handle internet stream playback.""" |
| 158 | await audio_playback.AudioPlaybackManager.play(ctx, stream) | 123 | await audio_playback.AudioPlaybackManager.play(ctx, stream) |
| 159 | 124 | ||
| 160 | async def _handle_compressed_file_play(self, ctx: discord.ApplicationContext, full_path: str) -> None: | 125 | async def _handle_compressed_file_play(self, ctx: discord.ApplicationContext, url: str) -> None: |
| 161 | """Handle compressed file playback with cleanup.""" | 126 | """Handle compressed file playback with cleanup.""" |
| 162 | compressed_path = await media_processing.AudioCompressor.compress(ctx, full_path) | 127 | is_url = url.startswith(('http://', 'https://')) |
| 163 | if not compressed_path: | 128 | |
| 164 | await ctx.respond("Nie udało się skompresować pliku.", ephemeral=True) | 129 | if is_url: |
| 165 | return | 130 | import aiohttp |
| 166 | 131 | local_path = None | |
| 167 | try: | 132 | try: |
| 168 | await audio_playback.AudioPlaybackManager.play(ctx, compressed_path, delete_after_play=True) | 133 | async with aiohttp.ClientSession() as session: |
| 169 | except Exception as e: | 134 | async with session.get(url) as response: |
| 170 | # Clean up the compressed file if play fails | 135 | if response.status != 200: |
| 171 | if os.path.exists(compressed_path): | 136 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) |
| 137 | return | ||
| 138 | |||
| 139 | filename = url.split('/')[-1].split('?')[0] | ||
| 140 | local_path = os.path.join(self.temp_dir, filename) | ||
| 141 | os.makedirs(self.temp_dir, exist_ok=True) | ||
| 142 | |||
| 143 | with open(local_path, 'wb') as f: | ||
| 144 | async for chunk in response.content.iter_chunked(8192): | ||
| 145 | f.write(chunk) | ||
| 146 | |||
| 147 | compressed_path = await media_processing.AudioCompressor.compress(ctx, local_path) | ||
| 148 | if not compressed_path: | ||
| 149 | await ctx.respond("Nie udało się skompresować pliku.", ephemeral=True) | ||
| 150 | return | ||
| 151 | |||
| 172 | try: | 152 | try: |
| 173 | os.remove(compressed_path) | 153 | await audio_playback.AudioPlaybackManager.play(ctx, compressed_path, delete_after_play=True) |
| 174 | logger.info(f"Cleaned up temporary file after play error: {compressed_path}") | 154 | except Exception as e: |
| 175 | except Exception as cleanup_error: | 155 | if os.path.exists(compressed_path): |
| 176 | logger.error(f"Error cleaning up file {compressed_path}: {str(cleanup_error)}") | 156 | try: |
| 177 | raise e | 157 | os.remove(compressed_path) |
| 158 | except Exception: | ||
| 159 | pass | ||
| 160 | raise e | ||
| 161 | finally: | ||
| 162 | if os.path.exists(local_path): | ||
| 163 | try: | ||
| 164 | os.remove(local_path) | ||
| 165 | except Exception: | ||
| 166 | pass | ||
| 167 | except Exception as e: | ||
| 168 | logger.error(f"Error downloading file from URL: {str(e)}", exc_info=True) | ||
| 169 | await ctx.respond(f"Błąd pobierania pliku: {str(e)}", ephemeral=True) | ||
| 170 | else: | ||
| 171 | compressed_path = await media_processing.AudioCompressor.compress(ctx, url) | ||
| 172 | if not compressed_path: | ||
| 173 | await ctx.respond("Nie udało się skompresować pliku.", ephemeral=True) | ||
| 174 | return | ||
| 175 | |||
| 176 | try: | ||
| 177 | await audio_playback.AudioPlaybackManager.play(ctx, compressed_path, delete_after_play=True) | ||
| 178 | except Exception as e: | ||
| 179 | if os.path.exists(compressed_path): | ||
| 180 | try: | ||
| 181 | os.remove(compressed_path) | ||
| 182 | except Exception: | ||
| 183 | pass | ||
| 184 | raise e | ||
| 178 | 185 | ||
| 179 | async def _handle_local_file_play(self, ctx: discord.ApplicationContext, file: str, compressed: bool) -> None: | 186 | async def _handle_local_file_play(self, ctx: discord.ApplicationContext, file: str, compressed: bool) -> None: |
| 180 | """Handle local file playback.""" | 187 | """Handle file playback from copyparty URL.""" |
| 181 | full_path = self.path_mapping.get(file, file) | 188 | url = self.path_mapping.get(file, file) |
| 182 | |||
| 183 | if not os.path.exists(full_path): | ||
| 184 | logger.warning(f"File not found: {full_path} (display name: {file})") | ||
| 185 | await ctx.respond("Wybrany plik nie istnieje.", ephemeral=True) | ||
| 186 | return | ||
| 187 | 189 | ||
| 188 | if compressed: | 190 | if compressed: |
| 189 | await self._handle_compressed_file_play(ctx, full_path) | 191 | await self._handle_compressed_file_play(ctx, url) |
| 190 | else: | 192 | else: |
| 191 | await audio_playback.AudioPlaybackManager.play(ctx, full_path) | 193 | await audio_playback.AudioPlaybackManager.play(ctx, url) |
| 192 | 194 | ||
| 193 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") | 195 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") |
| 194 | async def play( | 196 | async def play( |
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) | ||
