feat(search): add SoundCloud source and cross-platform relevance ranking

This commit is contained in:
Antigravity committed 2026-10-12 14:23:50 +03:00
1 parent 30ba67c707
commit aa6c605d14
4 files changed
+214 -63

No files matched your search

+203 -58
View File
@@ -1,4 +1,6 @@
import asyncio import asyncio
import re
import difflib
import urllib.parse import urllib.parse
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
import httpx import httpx
@@ -11,11 +13,63 @@ yandex_provider = YandexMusicProvider()
def format_duration(ms: Optional[int]) -> str: def format_duration(ms: Optional[int]) -> str:
if not ms: if not ms:
return "00:00" return "00:00"
total_sec = ms // 1000 total_sec = int(ms) // 1000
m = total_sec // 60 m = total_sec // 60
s = total_sec % 60 s = total_sec % 60
return f"{m:02d}:{s:02d}" 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]]]: async def search_yandex(query: str) -> Dict[str, List[Dict[str, Any]]]:
"""Search tracks and albums via Yandex Music.""" """Search tracks and albums via Yandex Music."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@@ -86,6 +140,124 @@ def _search_yandex_sync(query: str) -> Dict[str, List[Dict[str, Any]]]:
print(f"Yandex search error: {e}") print(f"Yandex search error: {e}")
return {"tracks": tracks, "albums": albums} 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]]]: async def search_apple(query: str) -> Dict[str, List[Dict[str, Any]]]:
"""Search tracks and albums via Apple Music / iTunes public API.""" """Search tracks and albums via Apple Music / iTunes public API."""
tracks = [] tracks = []
@@ -123,72 +295,45 @@ async def search_apple(query: str) -> Dict[str, List[Dict[str, Any]]]:
"cover": cover, "cover": cover,
"url": r.get("trackViewUrl"), "url": r.get("trackViewUrl"),
"explicit_status": explicit_status, "explicit_status": explicit_status,
"version": None, "version": "30с превью",
"preview_url": r.get("previewUrl") "preview_url": r.get("previewUrl")
}) })
except Exception as e: except Exception as e:
print(f"Apple search error: {e}") print(f"Apple search error: {e}")
return {"tracks": tracks, "albums": albums} return {"tracks": tracks, "albums": albums}
async def search_youtube(query: str) -> Dict[str, List[Dict[str, Any]]]:
"""Search tracks via YouTube Music."""
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,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(f"ytsearch3:{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 ""
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"
tracks.append({
"service": "youtube",
"service_name": "YouTube",
"id": entry.get("id"),
"title": title_raw,
"artist": entry.get("uploader") or entry.get("channel") or "Unknown",
"album": "YouTube Audio",
"year": None,
"duration": f"{sec // 60:02d}:{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_all(query: str) -> Dict[str, Any]: async def search_all(query: str) -> Dict[str, Any]:
"""Run concurrent search across all platforms.""" """Run concurrent search across all platforms with relevance ranking."""
yandex_task = search_yandex(query) y_task = search_yandex(query)
apple_task = search_apple(query) sc_task = search_soundcloud(query)
youtube_task = search_youtube(query) yt_task = search_youtube(query)
a_task = search_apple(query)
y_res, a_res, yt_res = await asyncio.gather(yandex_task, apple_task, youtube_task) y_res, sc_res, yt_res, a_res = await asyncio.gather(
y_task, sc_task, yt_task, a_task, return_exceptions=True
)
all_tracks = y_res["tracks"] + a_res["tracks"] + yt_res["tracks"] all_tracks = []
all_albums = y_res["albums"] + a_res["albums"] 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 { return {
"query": query, "query": query,
+8 -4
View File
@@ -17,7 +17,10 @@ class UniversalProvider(BaseProvider):
"music.apple.com", "music.apple.com",
"youtube.com", "youtube.com",
"youtu.be", "youtu.be",
"music.youtube.com" "music.youtube.com",
"soundcloud.com",
"snd.sc",
"api.soundcloud.com"
]) ])
async def resolve(self, url: str) -> List[TrackMetadata]: async def resolve(self, url: str) -> List[TrackMetadata]:
@@ -106,15 +109,16 @@ class UniversalProvider(BaseProvider):
continue continue
title = entry.get("title", "Unknown") title = entry.get("title", "Unknown")
uploader = entry.get("uploader") or entry.get("artist") or "Unknown" uploader = entry.get("uploader") or entry.get("artist") or "Unknown"
service_name = "soundcloud" if ("soundcloud" in (entry.get("webpage_url") or url).lower() or "snd.sc" in url.lower()) else "youtube"
tracks.append(TrackMetadata( tracks.append(TrackMetadata(
service="youtube", service=service_name,
service_id=str(entry.get("id", "")), service_id=str(entry.get("id", "")),
title=title, title=title,
artist=uploader, artist=uploader,
album=entry.get("album"), album=entry.get("album"),
duration_ms=int(entry.get("duration", 0) * 1000) if entry.get("duration") else None, duration_ms=int(entry.get("duration", 0) * 1000) if entry.get("duration") else None,
cover_url=entry.get("thumbnail"), cover_url=entry.get("thumbnail"),
source_url=entry.get("webpage_url") or f"https://www.youtube.com/watch?v={entry.get('id')}" source_url=entry.get("webpage_url") or url
)) ))
except Exception as e: except Exception as e:
print(f"yt-dlp resolve error: {e}") print(f"yt-dlp resolve error: {e}")
@@ -144,7 +148,7 @@ class UniversalProvider(BaseProvider):
} }
try: try:
target_input = track.source_url if track.service == "youtube" and track.source_url else f"ytsearch1:{query}" target_input = track.source_url if track.service in ("youtube", "soundcloud") and track.source_url else f"ytsearch1:{query}"
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([target_input]) ydl.download([target_input])
+2
View File
@@ -400,8 +400,10 @@ input[type="text"]:focus {
} }
.tag-yandex { background-color: rgba(239, 68, 68, 0.2); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.4); } .tag-yandex { background-color: rgba(239, 68, 68, 0.2); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.4); }
.tag-soundcloud { background-color: rgba(249, 115, 22, 0.2); color: #fb923c; border: 1px solid rgba(249, 115, 22, 0.4); }
.tag-apple { background-color: rgba(236, 72, 153, 0.2); color: #f472b6; border: 1px solid rgba(236, 72, 153, 0.4); } .tag-apple { background-color: rgba(236, 72, 153, 0.2); color: #f472b6; border: 1px solid rgba(236, 72, 153, 0.4); }
.tag-youtube { background-color: rgba(225, 29, 72, 0.2); color: #fb7185; border: 1px solid rgba(225, 29, 72, 0.4); } .tag-youtube { background-color: rgba(225, 29, 72, 0.2); color: #fb7185; border: 1px solid rgba(225, 29, 72, 0.4); }
.tag-spotify { background-color: rgba(34, 197, 94, 0.2); color: #4ade80; border: 1px solid rgba(34, 197, 94, 0.4); }
.empty-state { .empty-state {
text-align: center; text-align: center;
+1 -1
View File
@@ -54,7 +54,7 @@
</div> </div>
<div id="searchLoading" class="search-loading" style="display: none;"> <div id="searchLoading" class="search-loading" style="display: none;">
<div class="spinner"></div> Ищем на Яндекс Музыке, Apple Music и YouTube... <div class="spinner"></div> Ищем на Яндекс Музыке, SoundCloud, YouTube и Apple Music...
</div> </div>
<!-- Search Tracks --> <!-- Search Tracks -->