added decision engine
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -1,444 +1,113 @@
|
|||||||
#currently a consecpt
|
|
||||||
|
|
||||||
#!/bin/bash
|
#!/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
|
# Consumes:
|
||||||
# downstream automation systems such as Lidarr, Sonarr, Radarr, and future services.
|
# Kernel/decision_engine.sh
|
||||||
#
|
#
|
||||||
# The engine is:
|
# Purpose:
|
||||||
# Time-aware — recent activity matters more than historical ownership
|
# Discover high-quality artists/albums for acquisition using
|
||||||
# Behavior-driven — decisions are based on actual usage patterns
|
# family-aware and behavior-driven scoring logic.
|
||||||
# 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 script is intentionally selective.
|
||||||
#
|
#
|
||||||
# This is NOT:
|
# Music discovery is treated differently than TV/movies:
|
||||||
# • a recommendation engine
|
|
||||||
# • a media AI
|
|
||||||
# • a centralized content manager
|
|
||||||
#
|
#
|
||||||
# This IS:
|
# Higher strictness
|
||||||
# • a decision layer
|
# Stronger quality bias
|
||||||
# • a weighting system
|
# Lower tolerance for trend chasing
|
||||||
# • a behavioral context engine
|
# 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
|
|
||||||
|
|
||||||
# =========================================================
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
# CONFIG
|
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||||
# =========================================================
|
|
||||||
|
source "$ROOT_DIR/load_config.sh"
|
||||||
LASTFM_API_KEY = "YOUR_LASTFM_KEY"
|
source "$ROOT_DIR/Kernel/decision_engine.sh"
|
||||||
LASTFM_USER = "YOUR_USERNAME"
|
|
||||||
|
# ==============================================================================================
|
||||||
EMBY_URL = "http://YOUR_EMBY:8096"
|
# ── CONFIG ────────────────────────────────────────────────────────────────────────────────────
|
||||||
EMBY_API_KEY = "YOUR_EMBY_API_KEY"
|
# ==============================================================================================
|
||||||
|
|
||||||
# influence constraints
|
DISCOVERY_THRESHOLD=70
|
||||||
MAX_USER_WEIGHT = 0.35 # no single user > 35%
|
|
||||||
MIN_USER_ACTIVITY = 5 # ignore near-zero listeners
|
# ==============================================================================================
|
||||||
|
# ── EXAMPLE CANDIDATE ─────────────────────────────────────────────────────────────────────────
|
||||||
# scoring thresholds
|
# ==============================================================================================
|
||||||
CORE_THRESHOLD = 90
|
#
|
||||||
CONTEXT_THRESHOLD = 80
|
# Real implementation would pull:
|
||||||
|
#
|
||||||
FINAL_SELECTION_LIMIT = 3
|
# Last.fm
|
||||||
|
# Trakt-style behavior history
|
||||||
|
# Lidarr metadata
|
||||||
# =========================================================
|
# User weighting
|
||||||
# PLAYLIST CONTEXT (intentional listening structure)
|
# Genre affinity
|
||||||
# =========================================================
|
# Temporal activity
|
||||||
|
#
|
||||||
PLAYLIST_CONTEXTS = [
|
# ==============================================================================================
|
||||||
{"name": "90s Alternative", "tags": ["alternative", "rock", "90s"], "weight": 1.3},
|
|
||||||
{"name": "Favorites Mix", "tags": ["all-time", "mixed"], "weight": 1.5},
|
ARTIST_NAME="Example Artist"
|
||||||
{"name": "ICP / Aggressive", "tags": ["hardcore", "rap", "aggressive"], "weight": 1.2},
|
|
||||||
{"name": "Country Party", "tags": ["country", "party"], "weight": 1.3},
|
USER_SCORE=35
|
||||||
{"name": "2000s Rock", "tags": ["rock", "2000s"], "weight": 1.25},
|
POPULARITY_SCORE=15
|
||||||
]
|
RECENCY_SCORE=10
|
||||||
|
QUALITY_SCORE=20
|
||||||
|
|
||||||
# =========================================================
|
# ==============================================================================================
|
||||||
# DATA MODEL
|
# ── SCORING ───────────────────────────────────────────────────────────────────────────────────
|
||||||
# =========================================================
|
# ==============================================================================================
|
||||||
|
|
||||||
@dataclass
|
TOTAL_SCORE=$(score_candidate \
|
||||||
class ArtistCandidate:
|
"$USER_SCORE" \
|
||||||
name: str
|
"$POPULARITY_SCORE" \
|
||||||
score: float = 0.0
|
"$RECENCY_SCORE" \
|
||||||
tier: str = "rotation" # core | context | rotation
|
"$QUALITY_SCORE"
|
||||||
|
)
|
||||||
|
|
||||||
# =========================================================
|
# ==============================================================================================
|
||||||
# EMBY: PLAYBACK ACTIVITY (TRUTH SOURCE)
|
# ── DECISION ──────────────────────────────────────────────────────────────────────────────────
|
||||||
# =========================================================
|
# ==============================================================================================
|
||||||
|
|
||||||
def get_emby_recent_events(days=30):
|
DECISION=$(make_decision "$TOTAL_SCORE" "$DISCOVERY_THRESHOLD")
|
||||||
"""
|
|
||||||
Pull recent playback activity from Emby.
|
# ==============================================================================================
|
||||||
Each event should include: user + artist
|
# ── OUTPUT ────────────────────────────────────────────────────────────────────────────────────
|
||||||
"""
|
# ==============================================================================================
|
||||||
|
|
||||||
url = f"{EMBY_URL}/Users/{EMBY_API_KEY}/Items/Latest"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
headers = {"X-Emby-Token": EMBY_API_KEY}
|
echo "🎵 Lidarr Discovery Candidate"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
try:
|
echo "Artist: $ARTIST_NAME"
|
||||||
r = requests.get(url, headers=headers)
|
echo "Score : $TOTAL_SCORE"
|
||||||
data = r.json()
|
echo "Result: $DECISION"
|
||||||
except Exception:
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
return []
|
|
||||||
|
# ==============================================================================================
|
||||||
events = []
|
# ── ACTION ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
# ==============================================================================================
|
||||||
for item in data:
|
#
|
||||||
if "ArtistName" in item and "UserId" in item:
|
# Real implementation would:
|
||||||
events.append({
|
#
|
||||||
"user": item["UserId"],
|
# Add artist to Lidarr
|
||||||
"artist": item["ArtistName"]
|
# Queue search
|
||||||
})
|
# Log decision
|
||||||
|
# Record scoring metadata
|
||||||
return events
|
# Update history state
|
||||||
|
#
|
||||||
|
# ==============================================================================================
|
||||||
# =========================================================
|
|
||||||
# ACTIVE USER DETECTION (TIME-BOUND CONTEXT)
|
if [[ "$DECISION" == "ACCEPT" ]]; then
|
||||||
# =========================================================
|
|
||||||
|
echo "Adding artist to Lidarr..."
|
||||||
def get_active_users(events):
|
|
||||||
user_activity = defaultdict(int)
|
# future:
|
||||||
|
# curl -X POST "$LIDARR_URL/api/v1/artist"
|
||||||
for e in events:
|
|
||||||
user_activity[e["user"]] += 1
|
else
|
||||||
|
|
||||||
# filter low activity users
|
echo "Candidate rejected."
|
||||||
filtered = {
|
|
||||||
u: c for u, c in user_activity.items()
|
fi
|
||||||
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