added decision engine

This commit is contained in:
2026-05-10 20:07:19 -04:00
parent 62cbea7141
commit 6948755c86
2 changed files with 333 additions and 433 deletions
+102 -433
View File
@@ -1,444 +1,113 @@
#currently a consecpt
#!/bin/bash
# ==============================================================================================
# ================================== DECISION ENGINE ===========================================
# =============================== LIDARR DISCOVERY =============================================
# ==============================================================================================
# Central behavioral decision engine for the media ecosystem.
# Lidarr discovery orchestrator.
#
# This engine transforms recent user activity into weighted decision output used by
# downstream automation systems such as Lidarr, Sonarr, Radarr, and future services.
# Consumes:
# Kernel/decision_engine.sh
#
# 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
# Purpose:
# Discover high-quality artists/albums for acquisition using
# family-aware and behavior-driven scoring logic.
#
# ── CORE PHILOSOPHY ────────────────────────────────────────────────────────────────────────────
# This script is intentionally selective.
#
# This is NOT:
# • a recommendation engine
# • a media AI
# • a centralized content manager
# Music discovery is treated differently than TV/movies:
#
# This IS:
# • a decision layer
# • a weighting system
# • a behavioral context engine
# Higher strictness
# Stronger quality bias
# Lower tolerance for trend chasing
# Longer behavioral memory
#
# 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()
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
source "$ROOT_DIR/load_config.sh"
source "$ROOT_DIR/Kernel/decision_engine.sh"
# ==============================================================================================
# ── CONFIG ────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
DISCOVERY_THRESHOLD=70
# ==============================================================================================
# ── EXAMPLE CANDIDATE ─────────────────────────────────────────────────────────────────────────
# ==============================================================================================
#
# Real implementation would pull:
#
# Last.fm
# Trakt-style behavior history
# Lidarr metadata
# User weighting
# Genre affinity
# Temporal activity
#
# ==============================================================================================
ARTIST_NAME="Example Artist"
USER_SCORE=35
POPULARITY_SCORE=15
RECENCY_SCORE=10
QUALITY_SCORE=20
# ==============================================================================================
# ── SCORING ───────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
TOTAL_SCORE=$(score_candidate \
"$USER_SCORE" \
"$POPULARITY_SCORE" \
"$RECENCY_SCORE" \
"$QUALITY_SCORE"
)
# ==============================================================================================
# ── DECISION ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
DECISION=$(make_decision "$TOTAL_SCORE" "$DISCOVERY_THRESHOLD")
# ==============================================================================================
# ── OUTPUT ────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🎵 Lidarr Discovery Candidate"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Artist: $ARTIST_NAME"
echo "Score : $TOTAL_SCORE"
echo "Result: $DECISION"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ==============================================================================================
# ── ACTION ────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
#
# Real implementation would:
#
# Add artist to Lidarr
# Queue search
# Log decision
# Record scoring metadata
# Update history state
#
# ==============================================================================================
if [[ "$DECISION" == "ACCEPT" ]]; then
echo "Adding artist to Lidarr..."
# future:
# curl -X POST "$LIDARR_URL/api/v1/artist"
else
echo "Candidate rejected."
fi