444 lines
14 KiB
Bash
444 lines
14 KiB
Bash
#currently a consecpt
|
|
|
|
#!/bin/bash
|
|
# ==============================================================================================
|
|
# ================================== DECISION ENGINE ===========================================
|
|
# ==============================================================================================
|
|
# Central behavioral decision engine for the media ecosystem.
|
|
#
|
|
# This engine transforms recent user activity into weighted decision output used by
|
|
# downstream automation systems such as Lidarr, Sonarr, Radarr, and future services.
|
|
#
|
|
# The engine is:
|
|
# Time-aware — recent activity matters more than historical ownership
|
|
# Behavior-driven — decisions are based on actual usage patterns
|
|
# Family-aware — balances influence across active users dynamically
|
|
# Domain-agnostic — policies can adapt behavior per media type
|
|
# Constraint-based — no single user can dominate system-wide discovery
|
|
#
|
|
# ── CORE PHILOSOPHY ────────────────────────────────────────────────────────────────────────────
|
|
#
|
|
# This is NOT:
|
|
# • a recommendation engine
|
|
# • a media AI
|
|
# • a centralized content manager
|
|
#
|
|
# This IS:
|
|
# • a decision layer
|
|
# • a weighting system
|
|
# • a behavioral context engine
|
|
#
|
|
# Recent activity defines current context.
|
|
#
|
|
# Discovery influence is earned through participation, not ownership.
|
|
# Users who actively engage with media contribute more strongly to discovery weighting,
|
|
# while configurable caps prevent any single user from overwhelming the ecosystem.
|
|
#
|
|
# The result is a continuously adapting media environment that reflects the current
|
|
# behavioral state of the household rather than static long-term bias.
|
|
#
|
|
# ── RESPONSIBILITIES ───────────────────────────────────────────────────────────────────────────
|
|
#
|
|
# • Ingest recent activity from media systems and APIs
|
|
# • Normalize activity across users and domains
|
|
# • Apply time-decay and recency weighting
|
|
# • Enforce per-user influence caps
|
|
# • Generate ranked discovery candidates
|
|
# • Return structured decision output to consumer scripts
|
|
#
|
|
# ── NON-RESPONSIBILITIES ───────────────────────────────────────────────────────────────────────
|
|
#
|
|
# The decision engine NEVER:
|
|
# • Executes downloads
|
|
# • Calls arr APIs directly
|
|
# • Starts/stops containers
|
|
# • Performs filesystem operations
|
|
# • Handles infrastructure orchestration
|
|
#
|
|
# Infrastructure execution belongs to:
|
|
# • adapter scripts
|
|
# • orchestrators
|
|
# • common.sh shared runtime functions
|
|
#
|
|
# ── ARCHITECTURE ROLE ─────────────────────────────────────────────────────────────────────────
|
|
#
|
|
# Ecosystem Layers:
|
|
#
|
|
# Config Layer
|
|
# master.conf + host configs
|
|
# ↓
|
|
# Runtime Layer
|
|
# common.sh
|
|
# ↓
|
|
# Decision Layer
|
|
# decision_engine.sh ← THIS FILE
|
|
# ↓
|
|
# Domain Adapters
|
|
# lidarr_discovery.sh
|
|
# sonarr_discovery.sh
|
|
# radarr_discovery.sh
|
|
# transcoding_manager.sh
|
|
#
|
|
# This separation keeps decision logic centralized while allowing execution systems
|
|
# to evolve independently.
|
|
#
|
|
# ── FUTURE EXPANSION ──────────────────────────────────────────────────────────────────────────
|
|
#
|
|
# Planned consumers:
|
|
# • Lidarr music discovery
|
|
# • Sonarr TV discovery balancing
|
|
# • Radarr movie discovery weighting
|
|
# • Transcoding priority orchestration
|
|
# • Queue scheduling systems
|
|
# • Resource-aware automation policies
|
|
#
|
|
# Shared decision primitives may eventually include:
|
|
# • Recency decay models
|
|
# • User weighting models
|
|
# • Fairness constraints
|
|
# • Diversity scoring
|
|
# • Load-sensitive prioritization
|
|
#
|
|
# ── DESIGN RULES ──────────────────────────────────────────────────────────────────────────────
|
|
#
|
|
# • Engines decide — adapters execute
|
|
# • Policies tune behavior — engines apply logic
|
|
# • Shared infrastructure belongs in common.sh
|
|
# • Domain logic belongs in policy modules
|
|
# • No infrastructure execution inside the engine
|
|
#
|
|
# ── VERSION ───────────────────────────────────────────────────────────────────────────────────
|
|
# v1.0 — Initial decision engine architecture
|
|
# ==============================================================================================
|
|
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() |