import asyncio import re import difflib import urllib.parse from typing import List, Dict, Any, Optional import httpx import yt_dlp from app.config import settings from app.providers.yandex import YandexMusicProvider yandex_provider = YandexMusicProvider() def format_duration(ms: Optional[int]) -> str: if not ms: return "00:00" total_sec = int(ms) // 1000 m = total_sec // 60 s = total_sec % 60 return f"{m:02d}:{s:02d}" def normalize_text(s: str) -> str: if not s: return "" s = s.lower() s = re.sub(r"[^\w\s]", " ", s) return " ".join(s.split()) def calculate_track_relevance(query: str, item: Dict[str, Any]) -> float: """Calculate match score between search query and track metadata.""" q_norm = normalize_text(query) q_words = set(q_norm.split()) if not q_words: return 0.0 art_norm = normalize_text(item.get("artist", "")) tit_norm = normalize_text(item.get("title", "")) full_norm = f"{art_norm} {tit_norm}" full_words = set(full_norm.split()) # 1. Overlap of query words (0..100) overlap = len(q_words & full_words) / len(q_words) score = overlap * 100.0 # 2. Sequence similarity ratio (0..50) seq_ratio = difflib.SequenceMatcher(None, q_norm, full_norm).ratio() score += seq_ratio * 50.0 # 3. Exact query substring in title or combined string if q_norm in full_norm or full_norm in q_norm: score += 30.0 # 4. Handle "Artist - Title" query pattern if " - " in query or " — " in query: parts = re.split(r"\s+[-—]\s+", query, maxsplit=1) if len(parts) == 2: q_art = normalize_text(parts[0]) q_tit = normalize_text(parts[1]) if q_art and (q_art in art_norm or art_norm in q_art): score += 35.0 if q_tit and (q_tit in tit_norm or tit_norm in q_tit): score += 45.0 # 5. Exact title match bonus if tit_norm and (tit_norm == q_norm or (len(q_words) > 1 and q_norm.endswith(tit_norm))): score += 25.0 # Slight penalty for overly long titles (e.g. video descriptions) if len(full_norm) > len(q_norm) * 3: score -= 10.0 return max(0.0, score) async def search_yandex(query: str) -> Dict[str, List[Dict[str, Any]]]: """Search tracks and albums via Yandex Music.""" loop = asyncio.get_event_loop() return await loop.run_in_executor(None, _search_yandex_sync, query) def _search_yandex_sync(query: str) -> Dict[str, List[Dict[str, Any]]]: tracks = [] albums = [] try: client = yandex_provider.get_client() res = client.search(query, type_="all") if res.tracks and res.tracks.results: for t in res.tracks.results[:8]: if not t: continue artists = ", ".join(a.name for a in t.artists) if t.artists else "Unknown" album_title = t.albums[0].title if t.albums else "Single" year = t.albums[0].year if t.albums and t.albums[0].year else None cover = "https://" + t.cover_uri.replace("%%", "400x400") if t.cover_uri else None content_warning = getattr(t, "content_warning", None) version = getattr(t, "version", None) title_lower = (t.title or "").lower() version_lower = (version or "").lower() if content_warning == "explicit" or any(w in title_lower or w in version_lower for w in ["explicit", "18+", "uncensored"]): explicit_status = "explicit" elif content_warning == "clean" or any(w in title_lower or w in version_lower for w in ["clean", "censored", "радио", "radio edit"]): explicit_status = "clean" else: explicit_status = "neutral" tracks.append({ "service": "yandex", "service_name": "Яндекс Музыка", "id": str(t.id), "title": t.title, "artist": artists, "album": album_title, "year": year, "duration": format_duration(t.duration_ms), "cover": cover, "url": f"https://music.yandex.ru/track/{t.id}", "explicit_status": explicit_status, "version": version, "preview_url": None }) if res.albums and res.albums.results: for a in res.albums.results[:4]: if not a: continue artists = ", ".join(art.name for art in a.artists) if a.artists else "Unknown" cover = "https://" + a.cover_uri.replace("%%", "400x400") if a.cover_uri else None albums.append({ "service": "yandex", "service_name": "Яндекс Музыка", "id": str(a.id), "title": a.title, "artist": artists, "year": a.year, "track_count": a.track_count, "cover": cover, "url": f"https://music.yandex.ru/album/{a.id}" }) except Exception as e: print(f"Yandex search error: {e}") return {"tracks": tracks, "albums": albums} async def search_soundcloud(query: str) -> Dict[str, List[Dict[str, Any]]]: """Search tracks via SoundCloud.""" loop = asyncio.get_event_loop() return await loop.run_in_executor(None, _search_soundcloud_sync, query) def _search_soundcloud_sync(query: str) -> Dict[str, List[Dict[str, Any]]]: tracks = [] encoded_query = urllib.parse.quote(query) ydl_opts = { "extract_flat": True, "quiet": True, "no_warnings": True, "ignoreerrors": True, } try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(f"scsearch5:{encoded_query}", download=False) if info and "entries" in info: for entry in info["entries"]: if not entry: continue sec = entry.get("duration", 0) title_raw = urllib.parse.unquote(entry.get("title") or "") uploader_raw = urllib.parse.unquote(entry.get("uploader") or entry.get("channel") or "Unknown") title_lower = title_raw.lower() if any(w in title_lower for w in ["explicit", "uncensored", "18+"]): explicit_status = "explicit" elif any(w in title_lower for w in ["clean", "censored", "radio edit", "радиоверсия"]): explicit_status = "clean" else: explicit_status = "neutral" artist = uploader_raw title = title_raw if " - " in title_raw: parts = title_raw.split(" - ", 1) artist = parts[0].strip() title = parts[1].strip() url = entry.get("webpage_url") or entry.get("url") or "" tracks.append({ "service": "soundcloud", "service_name": "SoundCloud", "id": str(entry.get("id")), "title": title, "artist": artist, "album": "SoundCloud", "year": None, "duration": f"{int(sec) // 60:02d}:{int(sec) % 60:02d}" if sec else "00:00", "cover": entry.get("thumbnail"), "url": url, "explicit_status": explicit_status, "version": None, "preview_url": None }) except Exception as e: print(f"SoundCloud search error: {e}") return {"tracks": tracks, "albums": []} async def search_youtube(query: str) -> Dict[str, List[Dict[str, Any]]]: """Search tracks via YouTube Music / YouTube.""" loop = asyncio.get_event_loop() return await loop.run_in_executor(None, _search_youtube_sync, query) def _search_youtube_sync(query: str) -> Dict[str, List[Dict[str, Any]]]: tracks = [] ydl_opts = { "extract_flat": True, "quiet": True, "no_warnings": True, "ignoreerrors": True, } try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(f"ytsearch5:{query}", download=False) if info and "entries" in info: for entry in info["entries"]: if not entry: continue sec = entry.get("duration", 0) title_raw = entry.get("title") or "" uploader_raw = entry.get("uploader") or entry.get("channel") or "Unknown" title_lower = title_raw.lower() if any(w in title_lower for w in ["explicit", "uncensored", "18+"]): explicit_status = "explicit" elif any(w in title_lower for w in ["clean", "censored", "radio edit", "радиоверсия"]): explicit_status = "clean" else: explicit_status = "neutral" artist = uploader_raw title = title_raw if " - " in title_raw: parts = title_raw.split(" - ", 1) artist = parts[0].strip() title = parts[1].strip() tracks.append({ "service": "youtube", "service_name": "YouTube", "id": str(entry.get("id")), "title": title, "artist": artist, "album": "YouTube Audio", "year": None, "duration": f"{int(sec) // 60:02d}:{int(sec) % 60:02d}" if sec else "00:00", "cover": entry.get("thumbnail"), "url": f"https://www.youtube.com/watch?v={entry.get('id')}", "explicit_status": explicit_status, "version": None, "preview_url": None }) except Exception as e: print(f"YouTube search error: {e}") return {"tracks": tracks, "albums": []} async def search_apple(query: str) -> Dict[str, List[Dict[str, Any]]]: """Search tracks and albums via Apple Music / iTunes public API.""" tracks = [] albums = [] try: encoded = urllib.parse.quote(query) url = f"https://itunes.apple.com/search?term={encoded}&entity=song&limit=5" async with httpx.AsyncClient(timeout=8.0) as client: resp = await client.get(url) if resp.status_code == 200: data = resp.json() for r in data.get("results", []): cover = r.get("artworkUrl100", "").replace("100x100bb", "400x400bb") duration_ms = r.get("trackTimeMillis", 0) release_date = r.get("releaseDate", "") year = int(release_date[:4]) if release_date else None track_explicitness = (r.get("trackExplicitness") or "").lower() if track_explicitness == "explicit": explicit_status = "explicit" elif track_explicitness == "cleaned": explicit_status = "clean" else: explicit_status = "neutral" tracks.append({ "service": "apple", "service_name": "Apple Music", "id": str(r.get("trackId")), "title": r.get("trackName"), "artist": r.get("artistName"), "album": r.get("collectionName"), "year": year, "duration": format_duration(duration_ms), "cover": cover, "url": r.get("trackViewUrl"), "explicit_status": explicit_status, "version": "30с превью", "preview_url": r.get("previewUrl") }) except Exception as e: print(f"Apple search error: {e}") return {"tracks": tracks, "albums": albums} async def search_all(query: str) -> Dict[str, Any]: """Run concurrent search across all platforms with relevance ranking.""" y_task = search_yandex(query) sc_task = search_soundcloud(query) yt_task = search_youtube(query) a_task = search_apple(query) y_res, sc_res, yt_res, a_res = await asyncio.gather( y_task, sc_task, yt_task, a_task, return_exceptions=True ) all_tracks = [] all_albums = [] for res in (y_res, sc_res, yt_res, a_res): if isinstance(res, dict): all_tracks.extend(res.get("tracks", [])) all_albums.extend(res.get("albums", [])) # Calculate relevance score for each track for t in all_tracks: t["relevance_score"] = round(calculate_track_relevance(query, t), 2) # Sort tracks descending by relevance score: # Tracks with the highest match (e.g. found on Yandex, SoundCloud, YouTube) appear at the top! all_tracks.sort(key=lambda t: t.get("relevance_score", 0), reverse=True) # Sort albums by relevance as well if all_albums: for al in all_albums: al["relevance_score"] = round(calculate_track_relevance(query, al), 2) all_albums.sort(key=lambda a: a.get("relevance_score", 0), reverse=True) return { "query": query, "tracks": all_tracks, "albums": all_albums }