Files
navidrome-music-sync/app/wave/recommender.py
T

133 lines
5.8 KiB
Python

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]