331 lines
11 KiB
Python
331 lines
11 KiB
Python
"""Search-related API endpoints."""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import yt_dlp
|
|
from flask import Blueprint, request
|
|
from utils import make_error_response, make_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 (word-boundary aware to avoid "hero" matching "ero", etc.)
|
|
for term in _banned_terms:
|
|
if re.search(r'\b' + re.escape(term) + r'\b', query_lower):
|
|
return True
|
|
# Multi-word exact match (e.g. "no nut november") — substring OK for phrases
|
|
for term in _banned_terms:
|
|
if ' ' in term and 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 short words (5 chars or less) to avoid false positives (e.g. "hero" matching "ero")
|
|
if len(word) <= 5:
|
|
continue
|
|
for term in _banned_terms:
|
|
# Skip fuzzy matching for short banned terms (too many false positives)
|
|
if len(term) <= 5:
|
|
continue
|
|
# Skip fuzzy matching for terms that cause false positives
|
|
if term in ("strip", "gooning"):
|
|
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("Unable to query — banned search term detected.", 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/<path:query>', 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/<video_id>', 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)
|