feat(spotify): add Spotify search, prioritize Spotify in ranking and recommendations
This commit is contained in:
1 parent
fb1a5e01dd
commit
3db9bdb38b
6 files changed
+207
-14
No files matched your search
@@ -17,7 +17,8 @@
|
|||||||
- **Обход цензуры и региональных блокировок**:
|
- **Обход цензуры и региональных блокировок**:
|
||||||
- Если трек в каталоге РФ заблокирован или зацензурен («запикан»), движок автоматически переключается на глобальные источники и скачивает полноценную **Explicit (18+)** версию.
|
- Если трек в каталоге РФ заблокирован или зацензурен («запикан»), движок автоматически переключается на глобальные источники и скачивает полноценную **Explicit (18+)** версию.
|
||||||
- **Мультисервисный поиск & Предпрослушивание**:
|
- **Мультисервисный поиск & Предпрослушивание**:
|
||||||
- Параллельный поиск по **Яндекс Музыке**, **SoundCloud**, **YouTube** и **Apple Music**.
|
- Параллельный поиск по **Spotify** (приоритетный источник), **Яндекс Музыке**, **SoundCloud**, **YouTube** и **Apple Music**.
|
||||||
|
- **Приоритет Spotify**: треки и альбомы со Spotify получают бонус релевантности и всегда выводятся первыми в выдаче.
|
||||||
- Сквозное ранжирование по точности совпадения: лучшие совпадения сразу со всех платформ показываются вверху.
|
- Сквозное ранжирование по точности совпадения: лучшие совпадения сразу со всех платформ показываются вверху.
|
||||||
- Определение цензуры: бейджи `🔞 18+ Explicit` (без цензуры) и `⚠️ Clean` (зацензуренная версия).
|
- Определение цензуры: бейджи `🔞 18+ Explicit` (без цензуры) и `⚠️ Clean` (зацензуренная версия).
|
||||||
- Встроенный аудиоплеер предпрослушивания с возможностью перемотки на лету (**HTTP Range 206**).
|
- Встроенный аудиоплеер предпрослушивания с возможностью перемотки на лету (**HTTP Range 206**).
|
||||||
|
|||||||
+82
-6
@@ -64,6 +64,10 @@ def calculate_track_relevance(query: str, item: Dict[str, Any]) -> float:
|
|||||||
if tit_norm and (tit_norm == q_norm or (len(q_words) > 1 and q_norm.endswith(tit_norm))):
|
if tit_norm and (tit_norm == q_norm or (len(q_words) > 1 and q_norm.endswith(tit_norm))):
|
||||||
score += 25.0
|
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)
|
# Slight penalty for overly long titles (e.g. video descriptions)
|
||||||
if len(full_norm) > len(q_norm) * 3:
|
if len(full_norm) > len(q_norm) * 3:
|
||||||
score -= 10.0
|
score -= 10.0
|
||||||
@@ -302,21 +306,94 @@ async def search_apple(query: str) -> Dict[str, List[Dict[str, Any]]]:
|
|||||||
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_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]:
|
async def search_all(query: str) -> Dict[str, Any]:
|
||||||
"""Run concurrent search across all platforms with relevance ranking."""
|
"""Run concurrent search across all platforms (Spotify preferred) with relevance ranking."""
|
||||||
|
sp_task = search_spotify(query)
|
||||||
y_task = search_yandex(query)
|
y_task = search_yandex(query)
|
||||||
sc_task = search_soundcloud(query)
|
sc_task = search_soundcloud(query)
|
||||||
yt_task = search_youtube(query)
|
yt_task = search_youtube(query)
|
||||||
a_task = search_apple(query)
|
a_task = search_apple(query)
|
||||||
|
|
||||||
y_res, sc_res, yt_res, a_res = await asyncio.gather(
|
sp_res, y_res, sc_res, yt_res, a_res = await asyncio.gather(
|
||||||
y_task, sc_task, yt_task, a_task, return_exceptions=True
|
sp_task, y_task, sc_task, yt_task, a_task, return_exceptions=True
|
||||||
)
|
)
|
||||||
|
|
||||||
all_tracks = []
|
all_tracks = []
|
||||||
all_albums = []
|
all_albums = []
|
||||||
|
|
||||||
for res in (y_res, sc_res, yt_res, a_res):
|
for res in (sp_res, y_res, sc_res, yt_res, a_res):
|
||||||
if isinstance(res, dict):
|
if isinstance(res, dict):
|
||||||
all_tracks.extend(res.get("tracks", []))
|
all_tracks.extend(res.get("tracks", []))
|
||||||
all_albums.extend(res.get("albums", []))
|
all_albums.extend(res.get("albums", []))
|
||||||
@@ -325,8 +402,7 @@ async def search_all(query: str) -> Dict[str, Any]:
|
|||||||
for t in all_tracks:
|
for t in all_tracks:
|
||||||
t["relevance_score"] = round(calculate_track_relevance(query, t), 2)
|
t["relevance_score"] = round(calculate_track_relevance(query, t), 2)
|
||||||
|
|
||||||
# Sort tracks descending by relevance score:
|
# Sort tracks descending by relevance score (Spotify prioritized at top)
|
||||||
# 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)
|
all_tracks.sort(key=lambda t: t.get("relevance_score", 0), reverse=True)
|
||||||
|
|
||||||
# Sort albums by relevance as well
|
# Sort albums by relevance as well
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ class UniversalProvider(BaseProvider):
|
|||||||
def _resolve_sync(self, url: str) -> List[TrackMetadata]:
|
def _resolve_sync(self, url: str) -> List[TrackMetadata]:
|
||||||
lower = url.lower()
|
lower = url.lower()
|
||||||
|
|
||||||
# Handle Spotify via open oEmbed API & yt-dlp fallback
|
# Handle Spotify via spotifyscraper & open oEmbed fallback
|
||||||
if "spotify.com" in lower:
|
if "spotify.com" in lower:
|
||||||
tracks = self._resolve_spotify_oembed(url)
|
tracks = self._resolve_spotify(url)
|
||||||
if tracks:
|
if tracks:
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
@@ -45,6 +45,86 @@ class UniversalProvider(BaseProvider):
|
|||||||
# Default: Handle YouTube / YouTube Music and other generic links via yt-dlp
|
# Default: Handle YouTube / YouTube Music and other generic links via yt-dlp
|
||||||
return self._resolve_ytdlp(url)
|
return self._resolve_ytdlp(url)
|
||||||
|
|
||||||
|
def _resolve_spotify(self, url: str) -> List[TrackMetadata]:
|
||||||
|
tracks: List[TrackMetadata] = []
|
||||||
|
try:
|
||||||
|
from spotify_scraper import SpotifyClient
|
||||||
|
m_track = re.search(r'track/([a-zA-Z0-9]+)', url)
|
||||||
|
m_album = re.search(r'album/([a-zA-Z0-9]+)', url)
|
||||||
|
m_playlist = re.search(r'playlist/([a-zA-Z0-9]+)', url)
|
||||||
|
|
||||||
|
with SpotifyClient() as client:
|
||||||
|
if m_track:
|
||||||
|
t = client.get_track(m_track.group(1))
|
||||||
|
if t:
|
||||||
|
artist = ", ".join(a.name for a in t.artists) if t.artists else "Unknown"
|
||||||
|
album = t.album.name if (t.album and hasattr(t.album, "name")) else "Singles"
|
||||||
|
cover = None
|
||||||
|
if hasattr(t, "images") and t.images:
|
||||||
|
cover = getattr(t.images[0], "url", None)
|
||||||
|
elif hasattr(t, "album") and t.album and getattr(t.album, "images", None):
|
||||||
|
cover = getattr(t.album.images[0], "url", None)
|
||||||
|
|
||||||
|
tracks.append(TrackMetadata(
|
||||||
|
service="spotify",
|
||||||
|
service_id=str(t.id),
|
||||||
|
title=t.name,
|
||||||
|
artist=artist,
|
||||||
|
album=album,
|
||||||
|
duration_ms=getattr(t, "duration_ms", None),
|
||||||
|
cover_url=cover,
|
||||||
|
is_explicit=getattr(t, "explicit", False),
|
||||||
|
source_url=t.url or url
|
||||||
|
))
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
elif m_album:
|
||||||
|
alb = client.get_album(m_album.group(1))
|
||||||
|
if alb and hasattr(alb, "tracks"):
|
||||||
|
cover = getattr(alb.images[0], "url", None) if hasattr(alb, "images") and alb.images else None
|
||||||
|
for idx, t in enumerate(alb.tracks, 1):
|
||||||
|
artist = ", ".join(a.name for a in t.artists) if t.artists else "Unknown"
|
||||||
|
tracks.append(TrackMetadata(
|
||||||
|
service="spotify",
|
||||||
|
service_id=str(t.id),
|
||||||
|
title=t.name,
|
||||||
|
artist=artist,
|
||||||
|
album=alb.name,
|
||||||
|
track_number=idx,
|
||||||
|
duration_ms=getattr(t, "duration_ms", None),
|
||||||
|
cover_url=cover,
|
||||||
|
is_explicit=getattr(t, "explicit", False),
|
||||||
|
source_url=t.url or url
|
||||||
|
))
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
elif m_playlist:
|
||||||
|
pl = client.get_playlist(m_playlist.group(1))
|
||||||
|
if pl and hasattr(pl, "tracks"):
|
||||||
|
cover = getattr(pl.images[0], "url", None) if hasattr(pl, "images") and pl.images else None
|
||||||
|
for idx, t in enumerate(pl.tracks, 1):
|
||||||
|
artist = ", ".join(a.name for a in t.artists) if t.artists else "Unknown"
|
||||||
|
album = getattr(t.album, "name", pl.name) if getattr(t, "album", None) else pl.name
|
||||||
|
tracks.append(TrackMetadata(
|
||||||
|
service="spotify",
|
||||||
|
service_id=str(t.id),
|
||||||
|
title=t.name,
|
||||||
|
artist=artist,
|
||||||
|
album=album,
|
||||||
|
track_number=idx,
|
||||||
|
duration_ms=getattr(t, "duration_ms", None),
|
||||||
|
cover_url=cover,
|
||||||
|
is_explicit=getattr(t, "explicit", False),
|
||||||
|
source_url=t.url or url
|
||||||
|
))
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Spotify scraper resolution failed: {e}")
|
||||||
|
|
||||||
|
# Fallback to open oembed if scraper failed
|
||||||
|
return self._resolve_spotify_oembed(url)
|
||||||
|
|
||||||
def _resolve_spotify_oembed(self, url: str) -> List[TrackMetadata]:
|
def _resolve_spotify_oembed(self, url: str) -> List[TrackMetadata]:
|
||||||
try:
|
try:
|
||||||
oembed_url = f"https://open.spotify.com/oembed?url={url}"
|
oembed_url = f"https://open.spotify.com/oembed?url={url}"
|
||||||
@@ -52,9 +132,7 @@ class UniversalProvider(BaseProvider):
|
|||||||
resp = client.get(oembed_url)
|
resp = client.get(oembed_url)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
# data has title ("Track Name" or "Track Name - Single"), thumbnail_url
|
|
||||||
full_title = data.get("title", "Unknown")
|
full_title = data.get("title", "Unknown")
|
||||||
# Spotfiy oEmbed titles are often "Title" or "Artist - Title"
|
|
||||||
artist = "Unknown"
|
artist = "Unknown"
|
||||||
title = full_title
|
title = full_title
|
||||||
if " - " in full_title:
|
if " - " in full_title:
|
||||||
|
|||||||
@@ -58,7 +58,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> Ищем на Яндекс Музыке, SoundCloud, YouTube и Apple Music...
|
<div class="spinner"></div> Ищем на Spotify, Яндекс Музыке, SoundCloud, YouTube и Apple Music...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Tracks -->
|
<!-- Search Tracks -->
|
||||||
|
|||||||
+39
-2
@@ -127,6 +127,43 @@ class WaveRecommender:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WaveRecommender] Error processing anchor {anchor_name}: {e}")
|
print(f"[WaveRecommender] Error processing anchor {anchor_name}: {e}")
|
||||||
|
|
||||||
# Randomize candidates order so the playlist feels fresh
|
# Sourcing C: Spotify top tracks for anchor artists (User preference: Spotify)
|
||||||
random.shuffle(candidates)
|
try:
|
||||||
|
from spotify_scraper import SpotifyClient
|
||||||
|
with SpotifyClient() as sp_client:
|
||||||
|
for anchor_name in selected_anchors:
|
||||||
|
try:
|
||||||
|
sp_search = sp_client.search(anchor_name, types=["artist"], limit=1)
|
||||||
|
if hasattr(sp_search, "artists") and sp_search.artists:
|
||||||
|
sp_art = sp_client.get_artist(sp_search.artists[0].id)
|
||||||
|
if hasattr(sp_art, "top_tracks") and sp_art.top_tracks:
|
||||||
|
for st in sp_art.top_tracks[:5]:
|
||||||
|
st_art = ", ".join(a.name for a in st.artists) if st.artists else anchor_name
|
||||||
|
key = f"{_normalize(st_art)}|{_normalize(st.name)}"
|
||||||
|
if key in existing_keys or key in wave_keys or key in seen_keys:
|
||||||
|
continue
|
||||||
|
cover = None
|
||||||
|
if hasattr(st, "images") and st.images:
|
||||||
|
img = st.images[0]
|
||||||
|
cover = getattr(img, "url", None) or (img.get("url") if isinstance(img, dict) else None)
|
||||||
|
album_name = getattr(st.album, "name", "Singles") if getattr(st, "album", None) else "Singles"
|
||||||
|
meta = TrackMetadata(
|
||||||
|
service="spotify",
|
||||||
|
service_id=str(st.id),
|
||||||
|
title=st.name,
|
||||||
|
artist=st_art,
|
||||||
|
album=album_name,
|
||||||
|
duration_ms=getattr(st, "duration_ms", None),
|
||||||
|
cover_url=cover,
|
||||||
|
is_explicit=getattr(st, "explicit", False),
|
||||||
|
source_url=getattr(st, "url", None) or f"https://open.spotify.com/track/{st.id}"
|
||||||
|
)
|
||||||
|
seen_keys.add(key)
|
||||||
|
# Insert Spotify tracks at the beginning so they are preferred
|
||||||
|
candidates.insert(0, meta)
|
||||||
|
except Exception as err:
|
||||||
|
print(f"[WaveRecommender] Spotify sourcing notice for {anchor_name}: {err}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WaveRecommender] Spotify client notice: {e}")
|
||||||
|
|
||||||
return candidates[:target_count]
|
return candidates[:target_count]
|
||||||
@@ -10,3 +10,4 @@ aiofiles>=24.1.0
|
|||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
jinja2>=3.1.0
|
jinja2>=3.1.0
|
||||||
python-multipart>=0.0.12
|
python-multipart>=0.0.12
|
||||||
|
spotifyscraper>=3.9.0
|
||||||
Reference in new issue
Block a user