From 3d67abbf2f1b043fda9dba31a39223256bae58f4 Mon Sep 17 00:00:00 2001 From: neptunehub Date: Fri, 12 Jun 2026 00:35:34 +0200 Subject: [PATCH 1/5] Instant playlist chat - added instrumental tool --- tasks/ai/planner.py | 14 +++++++++++++- tasks/ai/prompts.py | 2 ++ tasks/ai/tool_impl.py | 36 +++++++++++++++++++++++++++++++++++- tasks/ai/tools.py | 15 +++++++++++---- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/tasks/ai/planner.py b/tasks/ai/planner.py index e81d1b6d..ebde1eee 100644 --- a/tasks/ai/planner.py +++ b/tasks/ai/planner.py @@ -401,7 +401,7 @@ def _cat_conf(d): FILTER_LIST_KEYS = ('genres', 'voices', 'moods', 'other_features') FILTER_MIN_KEYS = ('tempo_min', 'energy_min', 'year_min', 'min_rating') FILTER_MAX_KEYS = ('tempo_max', 'energy_max', 'year_max') -FILTER_SCALAR_KEYS = ('key', 'scale', 'album', 'artist') +FILTER_SCALAR_KEYS = ('key', 'scale', 'album', 'artist', 'instrumental') FILTER_ALL_KEYS = ( FILTER_LIST_KEYS + FILTER_MIN_KEYS + FILTER_MAX_KEYS + FILTER_SCALAR_KEYS ) @@ -486,6 +486,16 @@ def extract_hints(text: str) -> Dict: except ValueError: pass + # Instrumental detection: keyword-based, same pattern as tempo/energy above. + _INSTRUMENTAL_RE = re.compile( + r'\b(?:instrumentals?|no\s+(?:vocals?|lyrics|singing|voice)|' + r'without\s+(?:vocals?|lyrics|singing|voice))\b', + re.IGNORECASE, + ) + if _INSTRUMENTAL_RE.search(text): + hints['instrumental'] = True + notes.append("instrumental requested") + if notes: hints['notes'] = notes return hints @@ -510,6 +520,8 @@ def format_hints_block(hints: Optional[Dict]) -> str: lines.append( f" energy: {hints.get('energy_min', '?')}..{hints.get('energy_max', '?')}" ) + if hints.get('instrumental') is True: + lines.append(" instrumental: true (use instrumental=true in search_database)") if not lines: return "" return "EXTRACTED_HINTS (use these values directly in search_database if relevant):\n" + "\n".join(lines) diff --git a/tasks/ai/prompts.py b/tasks/ai/prompts.py index 7ab814fd..a4191a62 100644 --- a/tasks/ai/prompts.py +++ b/tasks/ai/prompts.py @@ -221,6 +221,8 @@ def build_intent_classifier_prompt(user_message: str) -> str: "top pop radio songs of 2025" -> {{"primaries": ["knowledge"], "needs_filter": true}} "sad jazz from the 90s" -> {{"primaries": [], "needs_filter": true}} "upbeat pop roadtrip songs about summer with female vocals" -> {{"primaries": ["text"], "needs_filter": true}} +"pop instrumental" -> {{"primaries": [], "needs_filter": true}} +"instrumental jazz" -> {{"primaries": [], "needs_filter": true}} Request: "{user_message}" JSON:""" diff --git a/tasks/ai/tool_impl.py b/tasks/ai/tool_impl.py index a0328852..3a9b17a5 100644 --- a/tasks/ai/tool_impl.py +++ b/tasks/ai/tool_impl.py @@ -643,6 +643,7 @@ def _database_genre_query_sync( candidate_item_ids: Optional[List[str]] = None, voices: Optional[List[str]] = None, score_threshold: Optional[float] = None, + instrumental: Optional[bool] = None, ) -> Dict: get_songs = int(get_songs) if get_songs is not None else 100 @@ -708,6 +709,24 @@ def _database_genre_query_sync( conditions.append("(" + " OR ".join(voice_conditions) + ")") has_voice_filter = True + has_instrumental_filter = False + if instrumental is not None: + if instrumental: + # Filter: only tracks where musicnn flagged instrumental in mood_vector + conditions.append( + "COALESCE(CAST(NULLIF(SUBSTRING(mood_vector FROM %s), '') AS NUMERIC), 0) >= %s" + ) + params.append(r"(?i)(?:^|,)\s*instrumental:(\d+\.?\d*)") + params.append(mood_vector_threshold) + has_instrumental_filter = True + else: + # Filter: exclude tracks flagged as instrumental (score below threshold or absent) + conditions.append( + "COALESCE(CAST(NULLIF(SUBSTRING(mood_vector FROM %s), '') AS NUMERIC), 0) < %s" + ) + params.append(r"(?i)(?:^|,)\s*instrumental:(\d+\.?\d*)") + params.append(mood_vector_threshold) + has_other_filter = False other_confidence_threshold = other_features_threshold if other_features: @@ -773,7 +792,7 @@ def _database_genre_query_sync( order_clause = "ORDER BY RANDOM()" if pool_order_index is None else "" - if has_genre_filter or has_voice_filter or has_other_filter: + if has_genre_filter or has_voice_filter or has_other_filter or has_instrumental_filter: score_parts = [] score_params = [] if has_genre_filter: @@ -804,6 +823,19 @@ def _database_genre_query_sync( ) """) score_params.append(f"(?i)(?:^|,)\\s*{re.escape(voice)}:(\\d+\\.?\\d*)") + if has_instrumental_filter: + score_parts.append(""" + COALESCE( + CAST( + NULLIF( + SUBSTRING(mood_vector FROM %s), + '' + ) AS NUMERIC + ), + 0 + ) + """) + score_params.append(r"(?i)(?:^|,)\s*instrumental:(\d+\.?\d*)") if has_other_filter: for of in other_features: score_parts.append(""" @@ -880,6 +912,8 @@ def _database_genre_query_sync( filters.append(f"album: {album}") if artist: filters.append(f"artist: {artist}") + if instrumental is not None: + filters.append(f"instrumental: {instrumental}") log_messages.append(f"Found {len(songs)} songs matching {', '.join(filters) if filters else 'all criteria'}") diff --git a/tasks/ai/tools.py b/tasks/ai/tools.py index 9c8d912d..c519e80a 100644 --- a/tasks/ai/tools.py +++ b/tasks/ai/tools.py @@ -202,6 +202,7 @@ def execute_mcp_tool(tool_name: str, tool_args: Dict, ai_config: Dict) -> Dict: candidate_item_ids=tool_args.get("candidate_item_ids"), voices=tool_args.get("voices"), score_threshold=tool_args.get("score_threshold"), + instrumental=tool_args.get("instrumental"), ) return {"error": f"Unknown tool: {tool_name}"} @@ -278,9 +279,9 @@ def get_mcp_tools() -> List[Dict]: if text_match_modes: mode_desc_parts = [] if "audio" in text_match_modes: - mode_desc_parts.append("'audio' (default): match sound/instruments/textures ('calm piano', 'energetic guitar', 'romantic strings')") + mode_desc_parts.append("'audio' (default): match sound/instruments/textures. Include 'instrumental' in the query to find instrumental-sounding tracks ('calm instrumental piano', 'epic orchestral instrumental').") if "lyrics" in text_match_modes: - mode_desc_parts.append("'lyrics': match lyrical themes ('songs about heartbreak', 'lyrics about freedom')") + mode_desc_parts.append("'lyrics': match lyrical themes ('songs about heartbreak', 'lyrics about freedom').") mode_desc = ". ".join(mode_desc_parts) tools.append( @@ -349,8 +350,10 @@ def get_mcp_tools() -> List[Dict]: { "name": "search_database", "description": ( - "Filter the library by metadata. Use when the user names genres, moods, vocals, " - "year/decade, tempo, energy, scale, key, rating, album, or a single artist. " + "Filter the library by metadata. Use when the user names genres, vocals, " + "year/decade, tempo, energy, scale, key, rating, album, artist, or instrumental. " + "For instrumental tracks, set instrumental=true (queries musicnn score). " + "For non-instrumental, set instrumental=false. " "Can stand alone OR refine a seed_search/text_match/knowledge_lookup pool." ), "inputSchema": { @@ -393,6 +396,10 @@ def get_mcp_tools() -> List[Dict]: "min_rating": {"type": "integer", "description": "Minimum user rating 1-5"}, "album": {"type": "string", "description": "Album name to filter by"}, "artist": {"type": "string", "description": "Single artist name (use seed_search for multiple)"}, + "instrumental": { + "type": "boolean", + "description": "true = only instrumental tracks. false = only tracks with vocals.", + }, "get_songs": {"type": "integer", "default": 200}, }, }, From ef6da1b77c17af8410d7b63fdbf8857179a7bf5d Mon Sep 17 00:00:00 2001 From: neptunehub Date: Fri, 12 Jun 2026 00:42:44 +0200 Subject: [PATCH 2/5] sonarqube review fix --- tasks/ai/tool_impl.py | 80 ++++++++++++++++--------------------------- 1 file changed, 30 insertions(+), 50 deletions(-) diff --git a/tasks/ai/tool_impl.py b/tasks/ai/tool_impl.py index 3a9b17a5..f7dfc7a1 100644 --- a/tasks/ai/tool_impl.py +++ b/tasks/ai/tool_impl.py @@ -101,6 +101,26 @@ def _reroute_mood_labels_from_genres(genres, moods): _FUZZY_CANDIDATE_POOL_LIMIT = 500 _FUZZY_PREFIX_LEN = 3 +# Shared SQL fragments for mood_vector substring queries (avoids literal duplication). +_MOOD_VECTOR_GE_SQL = ( + "COALESCE(CAST(NULLIF(SUBSTRING(mood_vector FROM %s), '') AS NUMERIC), 0) >= %s" +) +_MOOD_VECTOR_LT_SQL = ( + "COALESCE(CAST(NULLIF(SUBSTRING(mood_vector FROM %s), '') AS NUMERIC), 0) < %s" +) +_MOOD_VECTOR_SCORE_SQL = """\ +COALESCE( + CAST( + NULLIF( + SUBSTRING(mood_vector FROM %s), + '' + ) AS NUMERIC + ), + 0 +)""" +# Canonical regex for the instrumental label in mood_vector (musicnn output). +_INSTRUMENTAL_REGEX = r"(?i)(?:^|,)\s*instrumental:(\d+\.?\d*)" + def _fetch_pool_features(item_ids: List[str]) -> Dict[str, Dict]: """Fetch the scoring columns for a set of item_ids via the PK fast-path. @@ -686,9 +706,7 @@ def _database_genre_query_sync( if genres: genre_conditions = [] for genre in genres: - genre_conditions.append( - "COALESCE(CAST(NULLIF(SUBSTRING(mood_vector FROM %s), '') AS NUMERIC), 0) >= %s" - ) + genre_conditions.append(_MOOD_VECTOR_GE_SQL) params.append(f"(?i)(?:^|,)\\s*{re.escape(genre)}:(\\d+\\.?\\d*)") params.append(mood_vector_threshold) conditions.append("(" + " OR ".join(genre_conditions) + ")") @@ -698,9 +716,7 @@ def _database_genre_query_sync( if voices: voice_conditions = [] for voice in voices: - voice_conditions.append( - "COALESCE(CAST(NULLIF(SUBSTRING(mood_vector FROM %s), '') AS NUMERIC), 0) >= %s" - ) + voice_conditions.append(_MOOD_VECTOR_GE_SQL) params.append(f"(?i)(?:^|,)\\s*{re.escape(voice)}:(\\d+\\.?\\d*)") params.append(mood_vector_threshold) if len(voice_conditions) == 1: @@ -712,19 +728,13 @@ def _database_genre_query_sync( has_instrumental_filter = False if instrumental is not None: if instrumental: - # Filter: only tracks where musicnn flagged instrumental in mood_vector - conditions.append( - "COALESCE(CAST(NULLIF(SUBSTRING(mood_vector FROM %s), '') AS NUMERIC), 0) >= %s" - ) - params.append(r"(?i)(?:^|,)\s*instrumental:(\d+\.?\d*)") + conditions.append(_MOOD_VECTOR_GE_SQL) + params.append(_INSTRUMENTAL_REGEX) params.append(mood_vector_threshold) has_instrumental_filter = True else: - # Filter: exclude tracks flagged as instrumental (score below threshold or absent) - conditions.append( - "COALESCE(CAST(NULLIF(SUBSTRING(mood_vector FROM %s), '') AS NUMERIC), 0) < %s" - ) - params.append(r"(?i)(?:^|,)\s*instrumental:(\d+\.?\d*)") + conditions.append(_MOOD_VECTOR_LT_SQL) + params.append(_INSTRUMENTAL_REGEX) params.append(mood_vector_threshold) has_other_filter = False @@ -797,45 +807,15 @@ def _database_genre_query_sync( score_params = [] if has_genre_filter: for genre in genres: - score_parts.append(""" - COALESCE( - CAST( - NULLIF( - SUBSTRING(mood_vector FROM %s), - '' - ) AS NUMERIC - ), - 0 - ) - """) + score_parts.append(_MOOD_VECTOR_SCORE_SQL) score_params.append(f"(?i)(?:^|,)\\s*{re.escape(genre)}:(\\d+\\.?\\d*)") if has_voice_filter: for voice in voices: - score_parts.append(""" - COALESCE( - CAST( - NULLIF( - SUBSTRING(mood_vector FROM %s), - '' - ) AS NUMERIC - ), - 0 - ) - """) + score_parts.append(_MOOD_VECTOR_SCORE_SQL) score_params.append(f"(?i)(?:^|,)\\s*{re.escape(voice)}:(\\d+\\.?\\d*)") if has_instrumental_filter: - score_parts.append(""" - COALESCE( - CAST( - NULLIF( - SUBSTRING(mood_vector FROM %s), - '' - ) AS NUMERIC - ), - 0 - ) - """) - score_params.append(r"(?i)(?:^|,)\s*instrumental:(\d+\.?\d*)") + score_parts.append(_MOOD_VECTOR_SCORE_SQL) + score_params.append(_INSTRUMENTAL_REGEX) if has_other_filter: for of in other_features: score_parts.append(""" From fe4f2c774c6c61fc1ebb6ab51ab0aa5bd5c38398 Mon Sep 17 00:00:00 2001 From: neptunehub Date: Fri, 12 Jun 2026 01:03:05 +0200 Subject: [PATCH 3/5] gemini review --- tasks/ai/planner.py | 10 +++++----- tasks/ai/tool_impl.py | 10 +++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tasks/ai/planner.py b/tasks/ai/planner.py index ebde1eee..b4139158 100644 --- a/tasks/ai/planner.py +++ b/tasks/ai/planner.py @@ -420,6 +420,11 @@ class ToolPlan: _ENERGY_NUM_RE = re.compile( r"\benergy\s*(?:above|>=?|over|min(?:imum)?)\s*([0-9]*\.[0-9]+|[0-9]+)\b", re.IGNORECASE ) +_INSTRUMENTAL_RE = re.compile( + r'\b(?:instrumentals?|no\s+(?:vocals?|lyrics|singing|voice)|' + r'without\s+(?:vocals?|lyrics|singing|voice))\b', + re.IGNORECASE, +) def _normalize_decade(prefix: str) -> int: @@ -487,11 +492,6 @@ def extract_hints(text: str) -> Dict: pass # Instrumental detection: keyword-based, same pattern as tempo/energy above. - _INSTRUMENTAL_RE = re.compile( - r'\b(?:instrumentals?|no\s+(?:vocals?|lyrics|singing|voice)|' - r'without\s+(?:vocals?|lyrics|singing|voice))\b', - re.IGNORECASE, - ) if _INSTRUMENTAL_RE.search(text): hints['instrumental'] = True notes.append("instrumental requested") diff --git a/tasks/ai/tool_impl.py b/tasks/ai/tool_impl.py index f7dfc7a1..d5946adc 100644 --- a/tasks/ai/tool_impl.py +++ b/tasks/ai/tool_impl.py @@ -99,7 +99,7 @@ def _reroute_mood_labels_from_genres(genres, moods): _FUZZY_MATCH_CUTOFF = 75 _FUZZY_CANDIDATE_POOL_LIMIT = 500 -_FUZZY_PREFIX_LEN = 3 +_FUZZY_PREFIX_LEN = 2 # Shared SQL fragments for mood_vector substring queries (avoids literal duplication). _MOOD_VECTOR_GE_SQL = ( @@ -195,12 +195,12 @@ def _fuzzy_match_author_title( prefix_params: List = [] if author_prefix: prefix_conditions.append( - "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(author), ' ', ''), '-', ''), '‐', ''), '/', ''), '''', '') LIKE %s" + "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(author), ' ', ''), '-', ''), '‐', ''), '/', ''), '''', '') ILIKE %s" ) prefix_params.append(f"{author_prefix}%") if title_prefix: prefix_conditions.append( - "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(title), ' ', ''), '-', ''), '‐', ''), '/', ''), '''', '') LIKE %s" + "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(title), ' ', ''), '-', ''), '‐', ''), '/', ''), '''', '') ILIKE %s" ) prefix_params.append(f"{title_prefix}%") if not prefix_conditions: @@ -727,6 +727,10 @@ def _database_genre_query_sync( has_instrumental_filter = False if instrumental is not None: + # Coerce string values (AI models sometimes pass 'true'/'false' as strings). + if isinstance(instrumental, str): + instrumental = instrumental.strip().lower() in ('true', '1', 'yes') + instrumental = bool(instrumental) if instrumental: conditions.append(_MOOD_VECTOR_GE_SQL) params.append(_INSTRUMENTAL_REGEX) From 82a627863b9eafd2553e3a7294b804f23eae4abc Mon Sep 17 00:00:00 2001 From: neptunehub Date: Fri, 12 Jun 2026 01:03:59 +0200 Subject: [PATCH 4/5] Kanji-only search title fix --- app_clap_search.py | 4 ++-- app_external.py | 4 ++-- app_lyrics.py | 4 ++-- app_voyager.py | 2 +- templates/clap_search.html | 4 ++-- templates/lyrics_search.html | 6 +++--- templates/path.html | 2 +- templates/similarity.html | 2 +- templates/waveform.html | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/app_clap_search.py b/app_clap_search.py index 42b8413f..cd3fc576 100644 --- a/app_clap_search.py +++ b/app_clap_search.py @@ -118,8 +118,8 @@ def clap_search_api(): if not query: return jsonify({'error': 'Query cannot be empty'}), 400 - if len(query) < 3: - return jsonify({'error': 'Query must be at least 3 characters'}), 400 + if len(query) < 2: + return jsonify({'error': 'Query must be at least 2 characters'}), 400 # Validate limit limit = min(max(1, int(limit)), 500) # Between 1 and 500 diff --git a/app_external.py b/app_external.py index 9db7d3bb..08ae98d9 100644 --- a/app_external.py +++ b/app_external.py @@ -165,8 +165,8 @@ def search_tracks_endpoint(): return jsonify([]) # Enforce minimum length constraint - if len(search_query) < 3: - return jsonify({"error": "Query must be at least 3 characters long"}), 400 + if len(search_query) < 2: + return jsonify({"error": "Query must be at least 2 characters long"}), 400 try: results = search_tracks_unified(search_query) diff --git a/app_lyrics.py b/app_lyrics.py index 3c865b63..a8f82e99 100644 --- a/app_lyrics.py +++ b/app_lyrics.py @@ -198,8 +198,8 @@ def lyrics_search_text_api(): query = (data.get('query') or '').strip() if not query: return jsonify({'error': 'Missing "query".'}), 400 - if len(query) < 3: - return jsonify({'error': 'Query must be at least 3 characters.'}), 400 + if len(query) < 2: + return jsonify({'error': 'Query must be at least 2 characters.'}), 400 try: limit = int(data.get('limit', 50)) diff --git a/app_voyager.py b/app_voyager.py index 4fef7413..44bfa36b 100644 --- a/app_voyager.py +++ b/app_voyager.py @@ -122,7 +122,7 @@ def search_tracks_endpoint(): if not search_query: return jsonify([]) - if len(search_query) < 3: + if len(search_query) < 2: return jsonify([]) # Optional index filter: 'musicnn' (default) or 'sem_grove' diff --git a/templates/clap_search.html b/templates/clap_search.html index 7403f60a..0b4951f5 100644 --- a/templates/clap_search.html +++ b/templates/clap_search.html @@ -297,8 +297,8 @@

Create a Playlist from Results

const query = searchQuery.value.trim(); const limit = parseInt(document.getElementById('limit').value); - if (!query || query.length < 3) { - alert('Please enter at least 3 characters'); + if (!query || query.length < 2) { + alert('Please enter at least 2 characters'); return; } diff --git a/templates/lyrics_search.html b/templates/lyrics_search.html index 19844bad..52eb3449 100644 --- a/templates/lyrics_search.html +++ b/templates/lyrics_search.html @@ -460,7 +460,7 @@

Create a Playlist from Results

clearTimeout(sgTimeout); sgTimeout = setTimeout(async () => { const q = sgInput.value.trim(); - if (q.length < 3) { sgHide(); return; } + if (q.length < 2) { sgHide(); return; } sgQuery = q; sgOffset = 0; try { const p = new URLSearchParams({ search_query: q, start: 0, end: PAGE_SIZE, index: 'sem_grove' }); @@ -498,8 +498,8 @@

Create a Playlist from Results

document.getElementById('search-form').addEventListener('submit', async function (e) { e.preventDefault(); const query = document.getElementById('search-query').value.trim(); - if (!query || query.length < 3) { - alert('Please enter at least 3 characters.'); + if (!query || query.length < 2) { + alert('Please enter at least 2 characters.'); return; } const limit = parseInt(document.getElementById('text-limit').value, 10) || 50; diff --git a/templates/path.html b/templates/path.html index 3ae0add9..eec6d9c5 100644 --- a/templates/path.html +++ b/templates/path.html @@ -373,7 +373,7 @@

Create a Playlist from Path

clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { const searchQuery = searchInputEl.value.trim(); - if (searchQuery.length < 3) { + if (searchQuery.length < 2) { resultsEl.classList.add('hidden'); return; } diff --git a/templates/similarity.html b/templates/similarity.html index fc68f825..011e802a 100644 --- a/templates/similarity.html +++ b/templates/similarity.html @@ -328,7 +328,7 @@

Create a Playlist from Results

searchTimeout = setTimeout(async () => { const searchQuery = searchInput.value.trim(); - if (searchQuery.length < 3) { + if (searchQuery.length < 2) { hideAutocomplete(); return; } diff --git a/templates/waveform.html b/templates/waveform.html index f38a6c0f..42d00ccb 100644 --- a/templates/waveform.html +++ b/templates/waveform.html @@ -130,7 +130,7 @@

Track Title

searchTimeout = setTimeout(async () => { const searchQuery = searchInput.value.trim(); - if (searchQuery.length < 3) { + if (searchQuery.length < 2) { hideAutocomplete(); return; } From 1ddc866a5b117f7b7d380d4fd07035b33f764e0d Mon Sep 17 00:00:00 2001 From: neptunehub Date: Fri, 12 Jun 2026 01:13:31 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=E6=98=9F=20char=20fix=20in=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app_clap_search.py | 4 ++-- app_external.py | 4 ++-- app_lyrics.py | 4 ++-- app_voyager.py | 2 +- tasks/ai/tool_impl.py | 2 +- templates/clap_search.html | 4 ++-- templates/lyrics_search.html | 6 +++--- templates/path.html | 2 +- templates/similarity.html | 2 +- templates/waveform.html | 2 +- tests/unit/test_external_search_validation.py | 13 +++++++------ 11 files changed, 23 insertions(+), 22 deletions(-) diff --git a/app_clap_search.py b/app_clap_search.py index cd3fc576..1a515439 100644 --- a/app_clap_search.py +++ b/app_clap_search.py @@ -118,8 +118,8 @@ def clap_search_api(): if not query: return jsonify({'error': 'Query cannot be empty'}), 400 - if len(query) < 2: - return jsonify({'error': 'Query must be at least 2 characters'}), 400 + if len(query) < 1: + return jsonify({'error': 'Query must be at least 1 character'}), 400 # Validate limit limit = min(max(1, int(limit)), 500) # Between 1 and 500 diff --git a/app_external.py b/app_external.py index 08ae98d9..098dec94 100644 --- a/app_external.py +++ b/app_external.py @@ -165,8 +165,8 @@ def search_tracks_endpoint(): return jsonify([]) # Enforce minimum length constraint - if len(search_query) < 2: - return jsonify({"error": "Query must be at least 2 characters long"}), 400 + if len(search_query) < 1: + return jsonify({"error": "Query must be at least 1 character long"}), 400 try: results = search_tracks_unified(search_query) diff --git a/app_lyrics.py b/app_lyrics.py index a8f82e99..32981366 100644 --- a/app_lyrics.py +++ b/app_lyrics.py @@ -198,8 +198,8 @@ def lyrics_search_text_api(): query = (data.get('query') or '').strip() if not query: return jsonify({'error': 'Missing "query".'}), 400 - if len(query) < 2: - return jsonify({'error': 'Query must be at least 2 characters.'}), 400 + if len(query) < 1: + return jsonify({'error': 'Query must be at least 1 character.'}), 400 try: limit = int(data.get('limit', 50)) diff --git a/app_voyager.py b/app_voyager.py index 44bfa36b..283ab8c1 100644 --- a/app_voyager.py +++ b/app_voyager.py @@ -122,7 +122,7 @@ def search_tracks_endpoint(): if not search_query: return jsonify([]) - if len(search_query) < 2: + if len(search_query) < 1: return jsonify([]) # Optional index filter: 'musicnn' (default) or 'sem_grove' diff --git a/tasks/ai/tool_impl.py b/tasks/ai/tool_impl.py index d5946adc..c2795479 100644 --- a/tasks/ai/tool_impl.py +++ b/tasks/ai/tool_impl.py @@ -99,7 +99,7 @@ def _reroute_mood_labels_from_genres(genres, moods): _FUZZY_MATCH_CUTOFF = 75 _FUZZY_CANDIDATE_POOL_LIMIT = 500 -_FUZZY_PREFIX_LEN = 2 +_FUZZY_PREFIX_LEN = 1 # Shared SQL fragments for mood_vector substring queries (avoids literal duplication). _MOOD_VECTOR_GE_SQL = ( diff --git a/templates/clap_search.html b/templates/clap_search.html index 0b4951f5..09bac800 100644 --- a/templates/clap_search.html +++ b/templates/clap_search.html @@ -297,8 +297,8 @@

Create a Playlist from Results

const query = searchQuery.value.trim(); const limit = parseInt(document.getElementById('limit').value); - if (!query || query.length < 2) { - alert('Please enter at least 2 characters'); + if (!query || query.length < 1) { + alert('Please enter at least 1 character'); return; } diff --git a/templates/lyrics_search.html b/templates/lyrics_search.html index 52eb3449..3d71cec8 100644 --- a/templates/lyrics_search.html +++ b/templates/lyrics_search.html @@ -460,7 +460,7 @@

Create a Playlist from Results

clearTimeout(sgTimeout); sgTimeout = setTimeout(async () => { const q = sgInput.value.trim(); - if (q.length < 2) { sgHide(); return; } + if (q.length < 1) { sgHide(); return; } sgQuery = q; sgOffset = 0; try { const p = new URLSearchParams({ search_query: q, start: 0, end: PAGE_SIZE, index: 'sem_grove' }); @@ -498,8 +498,8 @@

Create a Playlist from Results

document.getElementById('search-form').addEventListener('submit', async function (e) { e.preventDefault(); const query = document.getElementById('search-query').value.trim(); - if (!query || query.length < 2) { - alert('Please enter at least 2 characters.'); + if (!query || query.length < 1) { + alert('Please enter at least 1 character.'); return; } const limit = parseInt(document.getElementById('text-limit').value, 10) || 50; diff --git a/templates/path.html b/templates/path.html index eec6d9c5..8d5cd9df 100644 --- a/templates/path.html +++ b/templates/path.html @@ -373,7 +373,7 @@

Create a Playlist from Path

clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { const searchQuery = searchInputEl.value.trim(); - if (searchQuery.length < 2) { + if (searchQuery.length < 1) { resultsEl.classList.add('hidden'); return; } diff --git a/templates/similarity.html b/templates/similarity.html index 011e802a..5707abdd 100644 --- a/templates/similarity.html +++ b/templates/similarity.html @@ -328,7 +328,7 @@

Create a Playlist from Results

searchTimeout = setTimeout(async () => { const searchQuery = searchInput.value.trim(); - if (searchQuery.length < 2) { + if (searchQuery.length < 1) { hideAutocomplete(); return; } diff --git a/templates/waveform.html b/templates/waveform.html index 42d00ccb..fcc2123f 100644 --- a/templates/waveform.html +++ b/templates/waveform.html @@ -130,7 +130,7 @@

Track Title

searchTimeout = setTimeout(async () => { const searchQuery = searchInput.value.trim(); - if (searchQuery.length < 2) { + if (searchQuery.length < 1) { hideAutocomplete(); return; } diff --git a/tests/unit/test_external_search_validation.py b/tests/unit/test_external_search_validation.py index a8868e76..368cc341 100644 --- a/tests/unit/test_external_search_validation.py +++ b/tests/unit/test_external_search_validation.py @@ -53,12 +53,13 @@ def test_explicit_empty_query_returns_empty_list(self, ext, client): assert resp.get_json() == [] backend.assert_not_called() - def test_two_char_query_returns_400(self, ext, client): - with patch.object(ext, 'search_tracks_unified') as backend: - resp = client.get('/search', query_string={'search_query': 'ab'}) - assert resp.status_code == 400 - assert resp.get_json() == {"error": "Query must be at least 3 characters long"} - backend.assert_not_called() + def test_one_char_query_reaches_backend(self, ext, client): + results = [{'item_id': 'id-1', 'title': 'Song', 'author': 'Artist'}] + with patch.object(ext, 'search_tracks_unified', return_value=results) as backend: + resp = client.get('/search', query_string={'search_query': 'a'}) + assert resp.status_code == 200 + assert resp.get_json() == results + backend.assert_called_once_with('a') def test_valid_query_reaches_backend_and_returns_its_value(self, ext, client): results = [{'item_id': 'id-1', 'title': 'Song', 'author': 'Artist'}]