diff --git a/Media/Kernel/decision_engine.sh b/Media/Kernel/decision_engine.sh new file mode 100644 index 0000000..278a545 --- /dev/null +++ b/Media/Kernel/decision_engine.sh @@ -0,0 +1,231 @@ +#!/bin/bash +# ============================================================================================== +# ================================= DECISION ENGINE ============================================ +# ============================================================================================== +# Central behavior-driven decision kernel used by media automation systems. +# +# This engine does NOT download media. +# This engine does NOT search indexers. +# This engine does NOT manage applications directly. +# +# Instead: +# It evaluates candidates. +# Scores them against ecosystem behavior. +# Applies adaptive filtering rules. +# Returns decisions to consumer scripts. +# +# ============================================================================================== +# ── DESIGN PHILOSOPHY ───────────────────────────────────────────────────────────────────────── +# +# The ecosystem is built around: +# +# Family-aware decisions +# Time-aware weighting +# Behavior-driven adaptation +# Domain-specific strictness +# +# Each media domain consumes the engine differently: +# +# Lidarr → highly selective, quality-first discovery +# Sonarr → balanced family-aware episodic intake +# Radarr → broader flexibility with intelligent filtering +# +# The engine itself remains domain-agnostic. +# Consumers define their own thresholds, weights, and strictness profiles. +# +# This separation prevents: +# +# Cross-domain bias pollution +# Unified-feed degeneration +# Overfitting to a single user's habits +# Low-quality recommendation drift over time +# +# Result: +# +# Music stays curated and intentional +# TV stays balanced across users +# Movies remain adaptive without chaos +# +# ============================================================================================== +# ── RESPONSIBILITIES ────────────────────────────────────────────────────────────────────────── +# +# The decision engine is responsible for: +# +# Candidate scoring +# User weighting +# Temporal decay +# Popularity normalization +# Duplicate prevention +# Strictness enforcement +# Threshold evaluation +# Final decision output +# +# The engine returns: +# +# ACCEPT +# REJECT +# SCORE +# REASON +# +# Consumer scripts decide what to do with the result. +# +# ============================================================================================== +# ── ECOSYSTEM ROLE ──────────────────────────────────────────────────────────────────────────── +# +# Kernel Position: +# +# Kernel/ +# ├── decision_engine.sh +# ├── transcoding_engine.sh +# ├── future_engine_modules... +# +# Shared reusable logic belongs in: +# +# common.sh +# +# Shared ecosystem configuration belongs in: +# +# master.conf +# +# Host-specific secrets/configuration belong in: +# +# master_host*.conf +# +# The kernel contains: +# +# Stateful logic +# Adaptive systems +# Scoring systems +# Cross-domain intelligence +# +# ============================================================================================== +# ── VERSION ─────────────────────────────────────────────────────────────────────────────────── +# +# v1.0 +# Initial decision kernel architecture +# Built first for Lidarr discovery orchestration +# +# ============================================================================================== + +# ============================================================================================== +# ── SCORE CANDIDATE ─────────────────────────────────────────────────────────────────────────── +# ============================================================================================== +# Calculates weighted score for a media candidate. +# +# Inputs: +# USER_SCORE +# POPULARITY_SCORE +# RECENCY_SCORE +# QUALITY_SCORE +# +# Output: +# TOTAL_SCORE +# +# Consumer scripts define actual weighting values. + +score_candidate() { + + local user_score="${1:-0}" + local popularity_score="${2:-0}" + local recency_score="${3:-0}" + local quality_score="${4:-0}" + + TOTAL_SCORE=$(( \ + user_score + \ + popularity_score + \ + recency_score + \ + quality_score \ + )) + + echo "$TOTAL_SCORE" +} + +# ============================================================================================== +# ── THRESHOLD CHECK ─────────────────────────────────────────────────────────────────────────── +# ============================================================================================== +# Determines if candidate passes scoring threshold. +# +# Usage: +# evaluate_threshold "$score" "$minimum" + +evaluate_threshold() { + + local score="$1" + local minimum="$2" + + if (( score >= minimum )); then + return 0 + fi + + return 1 +} + +# ============================================================================================== +# ── TEMPORAL DECAY ──────────────────────────────────────────────────────────────────────────── +# ============================================================================================== +# Reduces influence of old behavior over time. +# +# Prevents: +# Permanent genre lock-in +# Historical bias accumulation +# Dead-user dominance +# +# Usage: +# apply_temporal_decay current_score age_days + +apply_temporal_decay() { + + local score="$1" + local age_days="$2" + + local decay=$(( age_days / 30 )) + + local adjusted=$(( score - decay )) + + (( adjusted < 0 )) && adjusted=0 + + echo "$adjusted" +} + +# ============================================================================================== +# ── DUPLICATE PROTECTION ────────────────────────────────────────────────────────────────────── +# ============================================================================================== +# Prevents repetitive acquisitions. +# +# Consumer defines: +# cooldown periods +# replay windows +# duplicate tolerance +# +# Returns: +# 0 = duplicate +# 1 = unique + +is_duplicate_candidate() { + + local candidate="$1" + local history_file="$2" + + grep -qi "^${candidate}$" "$history_file" 2>/dev/null +} + +# ============================================================================================== +# ── FINAL DECISION ──────────────────────────────────────────────────────────────────────────── +# ============================================================================================== +# Produces final engine verdict. +# +# Outputs: +# ACCEPT +# REJECT + +make_decision() { + + local score="$1" + local threshold="$2" + + if evaluate_threshold "$score" "$threshold"; then + echo "ACCEPT" + else + echo "REJECT" + fi +} \ No newline at end of file diff --git a/Media/playback_aware_lidarr_discovery.sh b/Media/playback_aware_lidarr_discovery.sh index fa79ffc..655951e 100644 --- a/Media/playback_aware_lidarr_discovery.sh +++ b/Media/playback_aware_lidarr_discovery.sh @@ -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() \ No newline at end of file +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 \ No newline at end of file