419 lines
17 KiB
Python
419 lines
17 KiB
Python
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
|
||
|
||
# 6. Priority bonus for user's preferred music service: Spotify
|
||
if item.get("service") == "spotify":
|
||
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_spotify(query: str) -> Dict[str, List[Dict[str, Any]]]:
|
||
"""Search tracks and albums via Spotify."""
|
||
loop = asyncio.get_event_loop()
|
||
return await loop.run_in_executor(None, _search_spotify_sync, query)
|
||
|
||
def _search_spotify_sync(query: str) -> Dict[str, List[Dict[str, Any]]]:
|
||
tracks = []
|
||
albums = []
|
||
try:
|
||
from spotify_scraper import SpotifyClient
|
||
with SpotifyClient() as client:
|
||
res = client.search(query, limit=8)
|
||
if hasattr(res, "tracks") and res.tracks:
|
||
for t in res.tracks:
|
||
if not t:
|
||
continue
|
||
artists = ", ".join(a.name for a in t.artists) if t.artists else "Unknown"
|
||
cover = None
|
||
if hasattr(t, "images") and t.images:
|
||
img = t.images[0]
|
||
cover = getattr(img, "url", None) or (img.get("url") if isinstance(img, dict) else None)
|
||
elif hasattr(t, "album") and t.album and getattr(t.album, "images", None):
|
||
img = t.album.images[0]
|
||
cover = getattr(img, "url", None) or (img.get("url") if isinstance(img, dict) else None)
|
||
|
||
album_title = getattr(t.album, "name", "Spotify Single") if getattr(t, "album", None) else "Spotify Single"
|
||
explicit_status = "explicit" if getattr(t, "explicit", False) else "neutral"
|
||
|
||
track_url = getattr(t, "url", None) or f"https://open.spotify.com/track/{t.id}"
|
||
tracks.append({
|
||
"service": "spotify",
|
||
"service_name": "Spotify",
|
||
"id": str(t.id),
|
||
"title": t.name,
|
||
"artist": artists,
|
||
"album": album_title,
|
||
"year": None,
|
||
"duration": format_duration(getattr(t, "duration_ms", None)),
|
||
"cover": cover,
|
||
"url": track_url,
|
||
"explicit_status": explicit_status,
|
||
"version": None,
|
||
"preview_url": getattr(t, "preview_url", None)
|
||
})
|
||
|
||
if hasattr(res, "albums") and res.albums:
|
||
for a in res.albums[:4]:
|
||
if not a:
|
||
continue
|
||
cover = None
|
||
if hasattr(a, "images") and a.images:
|
||
img = a.images[0]
|
||
cover = getattr(img, "url", None) or (img.get("url") if isinstance(img, dict) else None)
|
||
album_url = getattr(a, "url", None) or f"https://open.spotify.com/album/{a.id}"
|
||
artist_name = "Spotify"
|
||
if hasattr(a, "artists") and a.artists:
|
||
artist_name = ", ".join(art.name for art in a.artists)
|
||
albums.append({
|
||
"service": "spotify",
|
||
"service_name": "Spotify",
|
||
"id": str(a.id),
|
||
"title": a.name,
|
||
"artist": artist_name,
|
||
"year": None,
|
||
"track_count": getattr(a, "total_tracks", None),
|
||
"cover": cover,
|
||
"url": album_url
|
||
})
|
||
except Exception as e:
|
||
print(f"Spotify search error: {e}")
|
||
return {"tracks": tracks, "albums": albums}
|
||
|
||
async def search_all(query: str) -> Dict[str, Any]:
|
||
"""Run concurrent search across all platforms (Spotify preferred) with relevance ranking."""
|
||
sp_task = search_spotify(query)
|
||
y_task = search_yandex(query)
|
||
sc_task = search_soundcloud(query)
|
||
yt_task = search_youtube(query)
|
||
a_task = search_apple(query)
|
||
|
||
sp_res, y_res, sc_res, yt_res, a_res = await asyncio.gather(
|
||
sp_task, y_task, sc_task, yt_task, a_task, return_exceptions=True
|
||
)
|
||
|
||
all_tracks = []
|
||
all_albums = []
|
||
|
||
for res in (sp_res, 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 (Spotify prioritized at 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
|
||
}
|