added. playback aware lidarr discovery tool. still stand alone

This commit is contained in:
2026-05-10 15:26:01 -04:00
parent a03280d6d3
commit dd55ec0761
2 changed files with 353 additions and 67 deletions
+332
View File
@@ -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()
+21 -67
View File
@@ -3,25 +3,31 @@ source ~/.bashrc
claude
we broke host ip look up or something
❌ [ERROR] Failed to resolve Tailscale IP for unRAID-Jayred365
❌ [ERROR] Check: tailscale status | grep unRAID-Jayred365
in tailscale manage consel, it shows unRAID-Jayred365, but in tailscale plugin it shows as
━━━ ⚙️ Setup ━━━
━━━ 🌐 Lidarr API ━━━
━━━ 🎬 Albums ━━━
━━━ 🎬 Artists ━━━
━━━━━ 📋 LIDARR MISSING ART SUMMARY ━━━━━
🖥️ Identity: HOST1 (unRAID-Gmer4Lfe)
⏱️ Duration: 21s
🎬 Albums: 8050 checked, 0 already complete
🎬 Artists: 1099 checked, 0 already complete
🏁 Status: ✅ ALL DONE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Script Finished May 08, 2026 19:50.06
error starting emby
--mount type=bind,source=/mnt/ram-transcode,target=/ext-ram-transcode,bind-propagation=shared 'lscr.io/linuxserver/emby'
0e616c065aebad2604d2120fbf9c6bb449cdfc4021d68f167e4b5bc01741c133
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: error jailing process inside rootfs: open /proc/self/mountinfo: no such file or directory
Run 'docker run --help' for more information
The command failed.
had to use --mount type=bind,source=/mnt/ram-transcode,target=/ext-ram-transcode instead, for now, just to get it back online
@@ -36,7 +42,7 @@ movie Silent Hill 2: The Movie (tmdbid 466226) was removed from TMDb. same logic
fail overhand back needs to happen in stages and in reverse...... so if i hit tier 3, i want it to only stop the services in tier 3 then rsync, then hand abck, then tier 2, shutdown containers rsync, and handback, same for 1.... this will allow emby to stay up during possible long writebacks. and ensure all data issynced back before emby it handed back.
failback hand back needs to happen in stages and in reverse...... so if i hit tier 3, i want it to only stop the services in tier 3 then rsync, then hand abck, then tier 2, shutdown containers rsync, and handback, same for 1.... this will allow emby to stay up during possible long writebacks. and ensure all data is sill synced back before emby is handed back with minimal user downtime
@@ -45,64 +51,12 @@ later.
. fix failover strike list timing, maybe 30 seconds. them a t 90 seconds 3 stike triggers. just gotta test buffer. never had the strike system
. verify silent toggle switches back on good notifications
. add to partnership, on offboard, remove all of containers that belonged to remote, example remotes vaultwarden-jayred from my machine and leave my vaultwarden-Gmer4Lfe alone. and it does nothing to remote, thier side will hadle thier pc and remove my stuff from thier pc. now i use folders in docker, and have a folder i put my buddies failover containers in. dont know if we can utilize that.
. add updater to update containers while daily runs, along with a toggle to dissable in master.
. add a script to check all docker containers and update any that still need it to run after the containers that get synced and updated.
. add a script to find missing artist and album cover from fanart and itunes, or itunes as a fallback
tailscale seperated, now each user has to share thier machines to the other. make sure tailscale ssh in the tailscale setting is turned off, we will be ssh through tailscale. each user will need to run ,
Separate “automation key” (cleanest design)
Keep your main key secure
Create a dedicated rsync key with no passphrase
ssh-keygen -t ed25519 -f /root/.ssh/*_rsync_automation -N "" , example = gmer4lfe_rsync_automation
Copy it to remote:
ssh-copy-id -i /root/.ssh/*_rsync_automation.pub root@100.97.4.47
that sets up passwordless syncs
we should look into a rsync setup script. then tailscale users already running tailscale. share each others servers then each runs the script. keys are made and the remote side is gets ssh'd and copied to remote. and it should update master with the new key
could start a new folder called Initial_run. and eventualy maybe even an orch script. or set it up in the partner dcript under onboard
we need to delete those keys from both servers on offboard. like it gets triggered. remote key removed then removed from local then, and new key is made next time we onboard.
even if we use a file to trck keys. less perfered, but maybe a good fallback. all host keys that get added, maybe with a specific *_rsync_automation "tag" gets added to the list and then deleted on offboard, but that would only handle a 2 pc setup, unles we can link hosts to keys when descovered. then if 5 pcs are in a group and host 3 leaves, the script can look it up and remove it from the local pc.
cant use tailscale api, so
i think we need to just make a block list so user cant get back into the system
then they are blocked machine level untill we accually get to tailscale, worst case u see it when you join with someone else
What your block list actually does (in real terms)
Your system becomes:
✔ On exit
revoke keys / session access locally
add identifier to blocklist
✔ On future access attempts (your scripts)
check blocklist before allowing or trusting anything
refuse automation actions tied to that identity
So it functions as:
“Even if they reappear on the network, my server wont trust them”
🔒 Why this works well with Tailscales model
Tailscale already handles:
transport security
identity authentication
device connectivity
Your layer adds:
policy after connection exists
human-defined lifecycle rules
✔ Script layer manages lifecycle only when told
✔ Exit triggers:
revoke keys
block future re-entry
clean state locally
No auto-provisioning. No implicit trust expansion.