import requests from collections import defaultdict from dataclasses import dataclass from datetime import datetime # ========================================================= # CONFIG # ========================================================= LASTFM_API_KEY = "YOUR_LASTFM_KEY" LASTFM_USER = "YOUR_USERNAME" EMBY_URL = "http://YOUR_EMBY:8096" EMBY_API_KEY = "YOUR_EMBY_API_KEY" # influence constraints MAX_USER_WEIGHT = 0.35 # no single user > 35% MIN_USER_ACTIVITY = 5 # ignore near-zero listeners # scoring thresholds CORE_THRESHOLD = 90 CONTEXT_THRESHOLD = 80 FINAL_SELECTION_LIMIT = 3 # ========================================================= # PLAYLIST CONTEXT (intentional listening structure) # ========================================================= PLAYLIST_CONTEXTS = [ {"name": "90s Alternative", "tags": ["alternative", "rock", "90s"], "weight": 1.3}, {"name": "Favorites Mix", "tags": ["all-time", "mixed"], "weight": 1.5}, {"name": "ICP / Aggressive", "tags": ["hardcore", "rap", "aggressive"], "weight": 1.2}, {"name": "Country Party", "tags": ["country", "party"], "weight": 1.3}, {"name": "2000s Rock", "tags": ["rock", "2000s"], "weight": 1.25}, ] # ========================================================= # DATA MODEL # ========================================================= @dataclass class ArtistCandidate: name: str score: float = 0.0 tier: str = "rotation" # core | context | rotation # ========================================================= # EMBY: PLAYBACK ACTIVITY (TRUTH SOURCE) # ========================================================= def get_emby_recent_events(days=30): """ Pull recent playback activity from Emby. Each event should include: user + artist """ url = f"{EMBY_URL}/Users/{EMBY_API_KEY}/Items/Latest" headers = {"X-Emby-Token": EMBY_API_KEY} try: r = requests.get(url, headers=headers) data = r.json() except Exception: return [] events = [] for item in data: if "ArtistName" in item and "UserId" in item: events.append({ "user": item["UserId"], "artist": item["ArtistName"] }) return events # ========================================================= # ACTIVE USER DETECTION (TIME-BOUND CONTEXT) # ========================================================= def get_active_users(events): user_activity = defaultdict(int) for e in events: user_activity[e["user"]] += 1 # filter low activity users filtered = { u: c for u, c in user_activity.items() if c >= MIN_USER_ACTIVITY } if not filtered: return {} # normalize weights max_count = max(filtered.values()) user_weights = {} for u, c in filtered.items(): weight = c / max_count user_weights[u] = min(weight, MAX_USER_WEIGHT) return user_weights # ========================================================= # LAST.FM SIGNALS (GLOBAL TASTE GRAPH) # ========================================================= def lastfm_similar(artist): url = "http://ws.audioscrobbler.com/2.0/" params = { "method": "artist.getsimilar", "artist": artist, "api_key": LASTFM_API_KEY, "format": "json", "limit": 20 } try: r = requests.get(url, params=params) data = r.json() except Exception: return [] results = [] try: for a in data["similarartists"]["artist"]: results.append({ "name": a["name"], "match": float(a["match"]) }) except Exception: pass return results def lastfm_top_artists(): url = "http://ws.audioscrobbler.com/2.0/" params = { "method": "user.gettopartists", "user": LASTFM_USER, "api_key": LASTFM_API_KEY, "format": "json", "limit": 50 } try: r = requests.get(url, params=params) data = r.json() except Exception: return [] results = [] try: for a in data["topartists"]["artist"]: results.append((a["name"], int(a["playcount"]))) except Exception: pass return results # ========================================================= # CONTEXT VECTOR (PLAYLIST BLENDING) # ========================================================= def build_context_vector(): ctx = defaultdict(float) for p in PLAYLIST_CONTEXTS: for tag in p["tags"]: ctx[tag] += p["weight"] return ctx # ========================================================= # SCORING ENGINE (TIME-BOUND + MULTI-SIGNAL) # ========================================================= def build_scores(): scores = defaultdict(float) context_vector = build_context_vector() # ----------------------------------------------------- # 1. EMBY: playback events (current taste context) # ----------------------------------------------------- events = get_emby_recent_events() active_users = get_active_users(events) user_artist_counts = defaultdict(lambda: defaultdict(int)) for e in events: if e["user"] in active_users: user_artist_counts[e["user"]][e["artist"]] += 1 # weighted user contribution for user, artists in user_artist_counts.items(): weight = active_users[user] for artist, count in artists.items(): scores[artist] += count * 50 * weight # ----------------------------------------------------- # 2. LAST.FM: long-term identity # ----------------------------------------------------- top = lastfm_top_artists() for artist, plays in top: scores[artist] += min(plays * 0.25, 80) # ----------------------------------------------------- # 3. GRAPH EXPANSION # ----------------------------------------------------- seeds = list(scores.keys())[:15] for seed in seeds: for s in lastfm_similar(seed): scores[s["name"]] += s["match"] * 60 # ----------------------------------------------------- # 4. CONTEXT BLENDING (playlist influence) # ----------------------------------------------------- for artist in list(scores.keys()): for tag, weight in context_vector.items(): if tag.lower() in artist.lower(): scores[artist] += weight * 8 return scores # ========================================================= # TIER CLASSIFICATION # ========================================================= def classify_tier(score): if score >= CORE_THRESHOLD: return "core" elif score >= CONTEXT_THRESHOLD: return "context" return "rotation" # ========================================================= # SURVIVAL FILTER # ========================================================= def filter_candidates(scores): candidates = [] for name, score in scores.items(): candidates.append(ArtistCandidate( name=name, score=score, tier=classify_tier(score) )) candidates = [c for c in candidates if c.score >= CONTEXT_THRESHOLD] candidates.sort(key=lambda x: x.score, reverse=True) return candidates # ========================================================= # FINAL SELECTION (balanced survival) # ========================================================= def select_final(candidates): if not candidates: return [] final = [] cores = [c for c in candidates if c.tier == "core"] contexts = [c for c in candidates if c.tier == "context"] final.extend(cores[:2]) final.extend(contexts[:1]) return final[:FINAL_SELECTION_LIMIT] # ========================================================= # OUTPUT (LIDARR HOOK) # ========================================================= def send_to_lidarr(artists): for a in artists: print(f"[{a.tier.upper()}] {a.name} ({a.score:.2f})") # ========================================================= # MAIN CYCLE # ========================================================= def run(): print("\n=== DISCOVERY CYCLE START ===") print(f"Time: {datetime.now()}") scores = build_scores() candidates = filter_candidates(scores) final = select_final(candidates) if not final: print("\nNo artists survived this cycle.") return print("\nSurvivors:") for f in final: print(f"- {f.name} [{f.tier}] ({f.score:.2f})") send_to_lidarr(final) print("\n=== CYCLE COMPLETE ===") if __name__ == "__main__": run()