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]