feat(wave): implement Phase 1 Smart Wave recommendations and buffer rotation
This commit is contained in:
1 parent
aa6c605d14
commit
9fbe372fab
10 files changed
+879
No files matched your search
@@ -0,0 +1,132 @@
|
||||
import random
|
||||
import re
|
||||
from typing import List, Dict, Set, Any, Optional
|
||||
from app.providers.yandex import YandexMusicProvider
|
||||
from app.providers.base import TrackMetadata
|
||||
|
||||
def _normalize(s: Optional[str]) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
s = s.lower()
|
||||
s = re.sub(r"[^\w\s]", "", s)
|
||||
return " ".join(s.split())
|
||||
|
||||
class WaveRecommender:
|
||||
def __init__(self, yandex_provider: Optional[YandexMusicProvider] = None):
|
||||
self.yandex_provider = yandex_provider or YandexMusicProvider()
|
||||
|
||||
def get_candidate_tracks(
|
||||
self,
|
||||
anchor_profile: Dict[str, Any],
|
||||
existing_keys: Set[str],
|
||||
wave_keys: Set[str],
|
||||
target_count: int = 20
|
||||
) -> List[TrackMetadata]:
|
||||
"""Discover candidate tracks based on user anchors not yet in the library."""
|
||||
candidates: List[TrackMetadata] = []
|
||||
seen_keys: Set[str] = set()
|
||||
|
||||
try:
|
||||
client = self.yandex_provider.get_client()
|
||||
except Exception as e:
|
||||
print(f"[WaveRecommender] Yandex client unavailable: {e}")
|
||||
return candidates
|
||||
|
||||
# 1. Select anchor artists from profile
|
||||
anchor_artists: List[str] = []
|
||||
|
||||
# Pull from starred
|
||||
for s in anchor_profile.get("starred", []):
|
||||
if s.get("artist") and s["artist"] not in anchor_artists:
|
||||
anchor_artists.append(s["artist"])
|
||||
|
||||
# Pull from top played
|
||||
for p in anchor_profile.get("top_played", []):
|
||||
if p.get("artist") and p["artist"] not in anchor_artists:
|
||||
anchor_artists.append(p["artist"])
|
||||
|
||||
# Pull from top library artists
|
||||
for a in anchor_profile.get("top_artists", []):
|
||||
if a.get("artist") and a["artist"] not in anchor_artists:
|
||||
anchor_artists.append(a["artist"])
|
||||
|
||||
# Fallback to recent if empty
|
||||
if not anchor_artists:
|
||||
for r in anchor_profile.get("recent_added", []):
|
||||
if r.get("artist") and r["artist"] not in anchor_artists:
|
||||
anchor_artists.append(r["artist"])
|
||||
|
||||
if not anchor_artists:
|
||||
print("[WaveRecommender] No anchors found in user profile.")
|
||||
return candidates
|
||||
|
||||
# Randomly sample 3-5 anchors for diversity in this run
|
||||
sample_size = min(5, len(anchor_artists))
|
||||
selected_anchors = random.sample(anchor_artists, sample_size)
|
||||
print(f"[WaveRecommender] Sourcing candidates using anchors: {selected_anchors}")
|
||||
|
||||
for anchor_name in selected_anchors:
|
||||
if len(candidates) >= target_count:
|
||||
break
|
||||
try:
|
||||
# Search for anchor artist to get artist ID
|
||||
search_res = client.search(anchor_name, type_="artist")
|
||||
if not search_res or not search_res.artists or not search_res.artists.results:
|
||||
continue
|
||||
|
||||
artist_obj = search_res.artists.results[0]
|
||||
|
||||
# Sourcing A: Similar artists
|
||||
sim_res = client.artists_similar(artist_obj.id)
|
||||
similar_artists = sim_res.similar_artists[:4] if sim_res and sim_res.similar_artists else []
|
||||
|
||||
for sim_art in similar_artists:
|
||||
if len(candidates) >= target_count:
|
||||
break
|
||||
try:
|
||||
tracks_res = client.artists_tracks(sim_art.id, page=0, page_size=6)
|
||||
if not tracks_res or not tracks_res.tracks:
|
||||
continue
|
||||
|
||||
for t in tracks_res.tracks:
|
||||
if not t or not t.available:
|
||||
continue
|
||||
art_name = ", ".join(a.name for a in t.artists) if t.artists else sim_art.name
|
||||
key = f"{_normalize(art_name)}|{_normalize(t.title)}"
|
||||
|
||||
# Filter out existing library or wave tracks
|
||||
if key in existing_keys or key in wave_keys or key in seen_keys:
|
||||
continue
|
||||
|
||||
meta = self.yandex_provider._convert_track(t)
|
||||
if meta:
|
||||
seen_keys.add(key)
|
||||
candidates.append(meta)
|
||||
if len(candidates) >= target_count:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[WaveRecommender] Error fetching tracks for {sim_art.name}: {e}")
|
||||
|
||||
# Sourcing B: Also get popular tracks from the anchor artist's own catalog that user doesn't have yet
|
||||
if len(candidates) < target_count:
|
||||
own_tracks = client.artists_tracks(artist_obj.id, page=0, page_size=6)
|
||||
if own_tracks and own_tracks.tracks:
|
||||
for t in own_tracks.tracks:
|
||||
if not t or not t.available:
|
||||
continue
|
||||
art_name = ", ".join(a.name for a in t.artists) if t.artists else artist_obj.name
|
||||
key = f"{_normalize(art_name)}|{_normalize(t.title)}"
|
||||
if key not in existing_keys and key not in wave_keys and key not in seen_keys:
|
||||
meta = self.yandex_provider._convert_track(t)
|
||||
if meta:
|
||||
seen_keys.add(key)
|
||||
candidates.append(meta)
|
||||
if len(candidates) >= target_count:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WaveRecommender] Error processing anchor {anchor_name}: {e}")
|
||||
|
||||
# Randomize candidates order so the playlist feels fresh
|
||||
random.shuffle(candidates)
|
||||
return candidates[:target_count]
|
||||
Reference in new issue
Block a user