"""Search-related API endpoints.""" import os import re import json import yt_dlp from pathlib import Path from flask import Blueprint, request from utils import make_response, make_error_response search_bp = Blueprint('search', __name__, url_prefix='/api') # Recent searches storage (use CONFIG_DIR env var for persistence in Docker) config_dir = os.environ.get('CONFIG_DIR', str(Path.home() / '.config' / 'youtube_cli')) recent_searches_file = Path(config_dir) / "recent_searches.json" # Banned search terms (loaded from file) _banned_terms_file = Path(__file__).parent.parent / "banned_terms.txt" _banned_terms = [] def _load_banned_terms(): """Load banned search terms from file.""" global _banned_terms if _banned_terms_file.exists(): try: with open(_banned_terms_file, 'r') as f: terms = [] for line in f: line = line.strip() if line and not line.startswith('#'): terms.append(line.lower()) _banned_terms = terms except Exception: _banned_terms = [] def _levenshtein(s1: str, s2: str) -> int: """Calculate Levenshtein distance between two strings.""" if len(s1) < len(s2): return _levenshtein(s2, s1) if len(s2) == 0: return len(s1) prev_row = range(len(s2) + 1) for i, c1 in enumerate(s1): curr_row = [i + 1] for j, c2 in enumerate(s2): insertions = prev_row[j + 1] + 1 deletions = curr_row[j] + 1 substitutions = prev_row[j] + (c1 != c2) curr_row.append(min(insertions, deletions, substitutions)) prev_row = curr_row return prev_row[-1] def _is_banned(query: str) -> bool: """Check if a search query contains any banned terms (exact or fuzzy match).""" if not _banned_terms: _load_banned_terms() query_lower = query.lower() # Exact match check for term in _banned_terms: if term in query_lower: return True # Fuzzy match check for words in the query query_words = query_lower.split() for word in query_words: # Skip very short words (3 chars or less) if len(word) <= 3: continue for term in _banned_terms: # Skip fuzzy matching for very short banned terms (too many false positives) if len(term) <= 3: continue # Only fuzzy match for terms with similar length if abs(len(word) - len(term)) > 2: continue # Allow up to 2 character differences for terms 4+ chars threshold = 2 if _levenshtein(word, term) <= threshold: return True return False # Load banned terms at startup _load_banned_terms() def _load_recent_searches(): """Load recent searches from file.""" if recent_searches_file.exists(): try: with open(recent_searches_file, 'r') as f: return json.load(f) except Exception: return [] return [] def _save_recent_searches(searches): """Save recent searches to file.""" recent_searches_file.parent.mkdir(parents=True, exist_ok=True) with open(recent_searches_file, 'w') as f: json.dump(searches, f, indent=2) @search_bp.route('/search', methods=['GET']) def search(): """Search for YouTube videos using yt-dlp Python API.""" try: query = request.args.get('q', '').strip() page = int(request.args.get('page', 1)) limit = int(request.args.get('limit', 15)) if not query: return make_error_response("Search query is required", 400) if page < 1: return make_error_response("Page must be greater than 0", 400) if _is_banned(query): return make_error_response("This search is not allowed. Please choose different keywords.", 400) sanitized_query = re.sub(r'[^\w\s\-\'"\.]+', "", query) search_query = f"ytsearch{limit * page}:{sanitized_query}" ydl_opts = { 'extract_flat': True, 'no_warnings': True, 'quiet': True, 'no_progress': True, 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', } with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(search_query, download=False) if not info: return make_response({ "query": query, "page": page, "results": [], "total": 0, "hasMore": False, }) # Get all entries then slice to the correct page range all_entries = info.get('entries', [info]) if isinstance(info, dict) else info all_entries = all_entries or [] start_idx = limit * (page - 1) end_idx = limit * page entries = all_entries[start_idx:end_idx] results = [] for entry in (entries or []): if not entry: continue vid_id = entry.get('id', '') url = entry.get('url', '') or entry.get('webpage_url', '') or f'https://www.youtube.com/watch?v={vid_id}' duration = entry.get('duration', 0) or 0 duration_str = f"{int(duration // 60)}:{int(duration % 60):02d}" if duration else "0:00" thumbnail = entry.get("thumbnail", "") or f"https://i.ytimg.com/vi/{vid_id}/hqdefault.jpg" results.append({ "id": vid_id, "videoId": vid_id, "title": entry.get("title", "Unknown Title"), "description": "", "thumbnail": thumbnail, "url": url, "duration": duration_str, "views": str(entry.get("view_count", 0) or 0), "channel": entry.get("uploader", "Unknown"), "isShort": "/shorts/" in url, "published": "", }) # Save to recent searches (only if not banned) if not _is_banned(query): searches = _load_recent_searches() if query not in searches: searches.insert(0, query) searches = searches[:10] _save_recent_searches(searches) return make_response({ "query": query, "page": page, "results": results, "total": len(results), "hasMore": len(results) >= limit, }) except Exception as e: return make_error_response(f"Search failed: {str(e)}", 500) @search_bp.route('/recent-searches', methods=['GET']) def get_recent_searches(): """Get list of recent search queries.""" try: searches = _load_recent_searches() return make_response(searches) except Exception as e: return make_error_response(f"Failed to load recent searches: {str(e)}", 500) @search_bp.route('/recent-searches', methods=['POST']) def save_recent_search(): """Save a search query to recent searches.""" try: data = request.get_json() query = data.get('query', '').strip() if data else '' if not query: return make_error_response("Search query is required", 400) searches = _load_recent_searches() if query in searches: searches.remove(query) searches.insert(0, query) searches = searches[:10] _save_recent_searches(searches) return make_response(searches) except Exception as e: return make_error_response(f"Failed to save recent search: {str(e)}", 500) @search_bp.route('/recent-searches', methods=['DELETE']) def clear_recent_searches(): """Clear recent search history.""" try: _save_recent_searches([]) return make_response({"message": "Recent searches cleared"}) except Exception as e: return make_error_response(f"Failed to clear recent searches: {str(e)}", 500) @search_bp.route('/recent-searches/', methods=['DELETE']) def remove_recent_search(query): """Remove a single recent search.""" try: searches = _load_recent_searches() if query in searches: searches.remove(query) _save_recent_searches(searches) return make_response({"message": f"Search '{query}' removed", "searches": searches}) except Exception as e: return make_error_response(f"Failed to remove recent search: {str(e)}", 500) @search_bp.route('/video/', methods=['GET']) def get_video_details(video_id): """Get video details by YouTube video ID.""" try: url = f"https://www.youtube.com/watch?v={video_id}" ydl_opts = { 'dump_single_json': True, 'no_warnings': True, 'quiet': True, 'no_progress': True, 'write_thumbnail': False, } with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(url, download=False) if not info: return make_error_response("Video not found", 404) return make_response({ "id": info.get("id", video_id), "videoId": info.get("id", video_id), "title": info.get("title", "Unknown Title"), "description": info.get("description", ""), "thumbnail": info.get("thumbnail", ""), "url": info.get("webpage_url", url), "duration": info.get("duration_string", "0:00"), "views": str(info.get("view_count", 0) or 0), "channel": info.get("uploader", "Unknown"), "isShort": "/shorts/" in url, "published": info.get("upload_date", ""), }) except Exception as e: return make_error_response(f"Failed to get video details: {str(e)}", 500) @search_bp.route('/info', methods=['GET']) def get_video_info(): """Get video info by URL.""" try: url = request.args.get('url', '').strip() if not url: return make_error_response("URL is required", 400) ydl_opts = { 'dump_single_json': True, 'no_warnings': True, 'quiet': True, 'no_progress': True, 'write_thumbnail': False, } with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(url, download=False) if not info: return make_error_response("Video not found", 404) return make_response({ "id": info.get("id", ""), "videoId": info.get("id", ""), "title": info.get("title", "Unknown Title"), "description": info.get("description", ""), "thumbnail": info.get("thumbnail", ""), "url": info.get("webpage_url", url), "duration": info.get("duration_string", "0:00"), "views": str(info.get("view_count", 0) or 0), "channel": info.get("uploader", "Unknown"), "isShort": "/shorts/" in url, "published": info.get("upload_date", ""), }) except Exception as e: return make_error_response(f"Failed to get video info: {str(e)}", 500)