added. playback aware lidarr discovery tool. still stand alone
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user