From 6ffe5bea701eeea53590dd4003c49794b1f6a4fa Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Wed, 20 May 2026 17:27:07 -0400 Subject: [PATCH] Add Sonarr discovery; wire search commands to all arr adds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New: Media/playback_aware_sonarr_discovery.sh — two-stage TV show discovery using Emby episode play history → TMDB TV recommendations. Multi-user aware: diversity score (0-50) is primary seed driver so one user binge-watching alone cannot dominate. Per-user episode cap (default 8) limits single-user volume contribution. TMDB→TVDB via external_ids for reliable Sonarr lookup. monitor=future by default. - Add to WEEKLY_MAINTENANCE_SCRIPTS; Sonarr discovery config block in master.conf (threshold=52, lookback=14d, max_adds=3, user_ep_cap=8). - Fix: all arr add scripts were not triggering searches after adding. Added _radarr_command/_lidarr_command/_sonarr_command helpers and fire MoviesSearch/ArtistSearch/SeriesSearch immediately after each successful add. Affects: playback_aware_radarr_discovery.sh, emby_to_lidarr_sync.sh, emby_to_radarr_sync.sh, emby_to_sonarr_sync.sh. --- Media/playback_aware_radarr_discovery.sh | 10 + Media/playback_aware_sonarr_discovery.sh | 826 +++++++++++++++++++++++ Tools/emby_to_lidarr_sync.sh | 10 + Tools/emby_to_radarr_sync.sh | 10 + Tools/emby_to_sonarr_sync.sh | 10 + master.conf | 13 + 6 files changed, 879 insertions(+) create mode 100755 Media/playback_aware_sonarr_discovery.sh diff --git a/Media/playback_aware_radarr_discovery.sh b/Media/playback_aware_radarr_discovery.sh index b454558..894eb0b 100755 --- a/Media/playback_aware_radarr_discovery.sh +++ b/Media/playback_aware_radarr_discovery.sh @@ -200,6 +200,14 @@ _radarr_post() { "${RADARR_URL}/api/v3/movie" 2>/dev/null } +_radarr_command() { + curl -sf --max-time 20 -X POST \ + -H "X-Api-Key: $RADARR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$1" \ + "${RADARR_URL}/api/v3/command" 2>/dev/null +} + _tmdb_recommendations() { curl -sf --max-time 15 \ "https://api.themoviedb.org/3/movie/${1}/recommendations?api_key=${TMDB_API_KEY}&language=en-US&page=1" \ @@ -646,6 +654,8 @@ for entry in "${SORTED_ACCEPTS[@]}"; do RESULT=$(_radarr_post "$PAYLOAD") if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then + MOVIE_ID=$(echo "$RESULT" | jq -r '.id') + _radarr_command "{\"name\":\"MoviesSearch\",\"movieIds\":[${MOVIE_ID}]}" >/dev/null log " $ICON_DONE Added: $RADARR_TITLE (score: $score | seeds: $seeds)" echo "ACCEPT|${rec_id}|${TODAY}|${RADARR_TITLE}" >> "$HISTORY_FILE" 2>/dev/null (( ADDED++ )) diff --git a/Media/playback_aware_sonarr_discovery.sh b/Media/playback_aware_sonarr_discovery.sh new file mode 100755 index 0000000..caad9bf --- /dev/null +++ b/Media/playback_aware_sonarr_discovery.sh @@ -0,0 +1,826 @@ +#!/bin/bash +# ============================================================================================== +# =========================== Playback-Aware Sonarr Discovery ================================== +# ============================================================================================== +# +# PURPOSE +# ───────────────────────────────────────────────────────────────────────────── +# Two-stage behavior-driven TV show discovery. +# +# Stage 1 — Score recently watched series in Emby. The top series become +# high-quality seeds, weighted by user diversity, recency, and +# per-user-capped episode volume. +# +# Stage 2 — Run TMDB TV recommendations on those seeds. Score the candidates +# and add the top shows to Sonarr. +# +# Goal: 0–3 meaningful Sonarr adds per run, not bulk imports. +# +# ============================================================================================== +# FLOW +# ============================================================================================== +# +# 1. Fetch all Series from Emby SONARR_EMBY_LIBRARIES — build TMDB+TVDB index +# +# ── Stage 1 ───────────────────────────────────────────────────────────────── +# 2. Fetch Emby activity log (last LOOKBACK_DAYS days) — episode play events +# 3. Batch-fetch episode items → map episode → series → TMDB ID +# 4. Aggregate per-user episode counts per series +# 5. Score each series: user diversity + recency + capped volume +# 6. Take top MAX_SEEDS → discovery seeds +# +# ── Stage 2 ───────────────────────────────────────────────────────────────── +# 7. For each seed, call TMDB TV recommendations → collect candidates +# 8. Aggregate: breadth (distinct seeds recommending this show) +# 9. Filter: already in Sonarr, already in Emby, below min votes/rating, cooldown +# 10. Score candidates: breadth + TMDB rating + vote count +# 11. Take top MAX_ADDS above threshold +# 12. Get TVDB ID via TMDB external_ids → Sonarr lookup → add + trigger SeriesSearch +# +# ============================================================================================== +# SCORING MODEL +# ============================================================================================== +# +# Stage 1 — seed selection (max 100) +# user_diversity_score (0-50) — unique users who watched: 1→10, 2→25, 3→40, 4+→50 +# recency_score (0-30) — days since last episode; 0-7d→30, 8-14d→20, 15-21d→12, 22+→5 +# volume_score (0-20) — sum of min(user_eps, USER_EPISODE_CAP); 1-4→3, 5-12→8, 13-24→14, 25+→20 +# +# USER_EPISODE_CAP prevents one person binge-watching from dominating seeds. +# Example: 4 users × 2 eps each beats 1 user × 50 eps (58 vs 18, before recency). +# +# Stage 2 — candidate scoring (max 100) +# breadth_score (0-40) — 3+ seeds→40, 2 seeds→25, 1 seed→10 +# rating_score (0-40) — TMDB vote_average × 10: 80+→40, 75+→32, 70+→25, 65+→18, 60+→12, else→5 +# votes_score (0-20) — TMDB vote_count: 10k+→20, 5k+→15, 1k+→10, 200+→5, else→2 +# +# Threshold: SONARR_DISCOVERY_THRESHOLD (default 52) +# Max adds: SONARR_DISCOVERY_MAX_ADDS (default 3) — TV is a larger commitment than movies +# +# ============================================================================================== +# REQUIREMENTS +# ============================================================================================== +# +# TMDB API key — required for Stage 2 recommendations and external_ids lookup +# Configure HOST*_TMDB_API_KEY in master_host*.conf +# Free key at: https://www.themoviedb.org/settings/api +# +# ============================================================================================== +# CONFIGURATION (master.conf) +# ============================================================================================== +# +# SONARR_DISCOVERY_THRESHOLD — minimum score to add a candidate (default: 52) +# SONARR_DISCOVERY_LOOKBACK_DAYS — Emby watch history window in days (default: 14) +# SONARR_DISCOVERY_MAX_SEEDS — max seed series from Stage 1 (default: 5) +# SONARR_DISCOVERY_MAX_ADDS — max shows to add per run (default: 3) +# SONARR_DISCOVERY_MIN_VOTE_COUNT — min TMDB votes for a candidate (default: 50) +# SONARR_DISCOVERY_MIN_RATING — min TMDB vote_average × 10 (default: 65 = 6.5/10) +# SONARR_DISCOVERY_REJECT_COOLDOWN — days before re-evaluating a rejected show (default: 60) +# SONARR_DISCOVERY_USER_EPISODE_CAP — max episodes per user in seed scoring (default: 8) +# SONARR_DISCOVERY_MONITOR_MODE — Sonarr monitor mode on add (default: "future") +# SONARR_EMBY_LIBRARIES — Emby library names to draw seeds from +# SONARR_DISCOVERY_HISTORY — history/state file path +# +# ============================================================================================== +# RUNTIME MODES +# ============================================================================================== +# +# playback_aware_sonarr_discovery.sh — normal run +# playback_aware_sonarr_discovery.sh --dry-run — score and rank, no Sonarr changes +# playback_aware_sonarr_discovery.sh --log — verbose output +# playback_aware_sonarr_discovery.sh --status — show config and exit +# +# Recommended schedule: weekly (WEEKLY_MAINTENANCE_SCRIPTS in master.conf) +# +# ============================================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source "$SCRIPT_DIR/../load_config.sh" +source "$SCRIPT_DIR/../Kernel/decision_engine.sh" + +parse_args "$@" + +# ============================================================================================== +# ━━━ Setup ━━━ +# ============================================================================================== +if [[ "$EUID" -ne 0 ]]; then + error "Must be run as root" + exit 1 +fi + +acquire_lock +detect_hosts + +if ! command -v jq >/dev/null 2>&1; then + error "jq not installed — required for API JSON parsing" + exit 1 +fi + +if [[ -z "${EMBY_URL:-}" || -z "${EMBY_API_KEY:-}" ]]; then + error "EMBY_URL / EMBY_API_KEY not configured — check master_host*.conf" + exit 1 +fi + +if [[ -z "${SONARR_URL:-}" || -z "${SONARR_API_KEY:-}" ]]; then + error "SONARR_URL / SONARR_API_KEY not configured — check master_host*.conf" + exit 1 +fi + +if [[ -z "${TMDB_API_KEY:-}" ]]; then + error "TMDB_API_KEY not configured — required for discovery" + error "Get a free key at https://www.themoviedb.org/settings/api" + error "Configure HOST*_TMDB_API_KEY in master_host*.conf" + exit 1 +fi + +if [[ "${#SONARR_EMBY_LIBRARIES[@]}" -eq 0 ]]; then + error "SONARR_EMBY_LIBRARIES not configured — check master.conf" + exit 1 +fi + +THRESHOLD="${SONARR_DISCOVERY_THRESHOLD:-52}" +LOOKBACK_DAYS="${SONARR_DISCOVERY_LOOKBACK_DAYS:-14}" +MAX_SEEDS="${SONARR_DISCOVERY_MAX_SEEDS:-5}" +MAX_ADDS="${SONARR_DISCOVERY_MAX_ADDS:-3}" +MIN_VOTE_COUNT="${SONARR_DISCOVERY_MIN_VOTE_COUNT:-50}" +MIN_RATING="${SONARR_DISCOVERY_MIN_RATING:-65}" +REJECT_COOLDOWN="${SONARR_DISCOVERY_REJECT_COOLDOWN:-60}" +USER_EPISODE_CAP="${SONARR_DISCOVERY_USER_EPISODE_CAP:-8}" +MONITOR_MODE="${SONARR_DISCOVERY_MONITOR_MODE:-future}" +HISTORY_FILE="${SONARR_DISCOVERY_HISTORY:-${DATA_DIR}/sonarr_discovery_history.db}" + +[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no shows will be added to Sonarr" + +_fmt_rating() { local v="${1:-0}"; echo "${v::-1}.${v: -1}" 2>/dev/null || echo "$v"; } + +# ============================================================================================== +# ━━━ Status ━━━ +# ============================================================================================== +if [[ "$SHOW_STATUS" == true ]]; then + echo "" + echo "━━━━━ $ICON_SUMMARY SONARR DISCOVERY STATUS ━━━━━" + echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" + echo "$ICON_SYNC Emby: ${EMBY_URL}" + echo "$ICON_SYNC Sonarr: ${SONARR_URL}" + echo "$ICON_GEAR Threshold: ${THRESHOLD} / 100" + echo "$ICON_TIME Lookback: ${LOOKBACK_DAYS} days" + echo "$ICON_GEAR Max seeds: ${MAX_SEEDS}" + echo "$ICON_GEAR Max adds: ${MAX_ADDS}" + echo "$ICON_GEAR Min rating: ${MIN_RATING} ($(_fmt_rating "$MIN_RATING")/10 TMDB)" + echo "$ICON_GEAR Min votes: ${MIN_VOTE_COUNT}" + echo "$ICON_GEAR Reject TTL: ${REJECT_COOLDOWN} days" + echo "$ICON_GEAR User ep cap: ${USER_EPISODE_CAP} episodes" + echo "$ICON_GEAR Monitor mode: ${MONITOR_MODE}" + echo "$ICON_GEAR Seed libs: ${SONARR_EMBY_LIBRARIES[*]}" + echo "$ICON_GEAR History file: ${HISTORY_FILE}" + echo "$ICON_GEAR TMDB: configured" + echo "$ICON_GEAR Dry Run: $DRY_RUN" + echo "━━━━━━━━━━━━━━━━━━━━━━━" + exit 0 +fi + +# ============================================================================================== +# ── API HELPERS ─────────────────────────────────────────────────────────────────────────────── +# ============================================================================================== + +_emby_api() { + local response http_code body + response=$(curl -sf --max-time 30 \ + -H "X-Emby-Token: $EMBY_API_KEY" \ + -w "\n%{http_code}" \ + "${EMBY_URL}/${1}" 2>/dev/null) + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | head -n -1) + [[ "$http_code" != "200" ]] && { error "Emby API HTTP $http_code: $1"; return 1; } + echo "$body" +} + +_sonarr_get() { + curl -sf --max-time 30 \ + -H "X-Api-Key: $SONARR_API_KEY" \ + "${SONARR_URL}/api/v3/${1}" 2>/dev/null +} + +_sonarr_lookup() { + curl -sf --max-time 20 --get \ + --data-urlencode "term=$1" \ + -H "X-Api-Key: $SONARR_API_KEY" \ + "${SONARR_URL}/api/v3/series/lookup" 2>/dev/null +} + +_sonarr_post() { + curl -sf --max-time 20 -X POST \ + -H "X-Api-Key: $SONARR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$1" \ + "${SONARR_URL}/api/v3/series" 2>/dev/null +} + +_sonarr_command() { + curl -sf --max-time 20 -X POST \ + -H "X-Api-Key: $SONARR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$1" \ + "${SONARR_URL}/api/v3/command" 2>/dev/null +} + +_tmdb_tv_recommendations() { + curl -sf --max-time 15 \ + "https://api.themoviedb.org/3/tv/${1}/recommendations?api_key=${TMDB_API_KEY}&language=en-US&page=1" \ + 2>/dev/null +} + +_tmdb_external_ids() { + curl -sf --max-time 15 \ + "https://api.themoviedb.org/3/tv/${1}/external_ids?api_key=${TMDB_API_KEY}" \ + 2>/dev/null +} + +# ============================================================================================== +# ── SCORING HELPERS ─────────────────────────────────────────────────────────────────────────── +# ============================================================================================== + +# Stage 1: unique user count score (0-50) — primary seed driver +_diversity_score() { + local n="$1" + if (( n >= 4 )); then echo 50 + elif (( n == 3 )); then echo 40 + elif (( n == 2 )); then echo 25 + else echo 10 + fi +} + +# Stage 1: recency of most recent episode watched (0-30) +_recency_score() { + local days="$1" + if (( days <= 7 )); then echo 30 + elif (( days <= 14 )); then echo 20 + elif (( days <= 21 )); then echo 12 + elif (( days <= 30 )); then echo 5 + else echo 0 + fi +} + +# Stage 1: per-user-capped episode volume score (0-20) +_volume_score() { + local v="$1" + if (( v >= 25 )); then echo 20 + elif (( v >= 13 )); then echo 14 + elif (( v >= 5 )); then echo 8 + elif (( v >= 1 )); then echo 3 + else echo 0 + fi +} + +# Stage 2: TMDB vote_average × 10 (0-40) +_rating_score_s2() { + local v="$1" + if (( v >= 80 )); then echo 40 + elif (( v >= 75 )); then echo 32 + elif (( v >= 70 )); then echo 25 + elif (( v >= 65 )); then echo 18 + elif (( v >= 60 )); then echo 12 + else echo 5 + fi +} + +# Stage 2: vote count (0-20) +_votes_score() { + local c="$1" + if (( c >= 10000 )); then echo 20 + elif (( c >= 5000 )); then echo 15 + elif (( c >= 1000 )); then echo 10 + elif (( c >= 200 )); then echo 5 + else echo 2 + fi +} + +# Stage 2: seed breadth (0-40) +_breadth_score() { + local seeds="$1" + if (( seeds >= 3 )); then echo 40 + elif (( seeds == 2 )); then echo 25 + else echo 10 + fi +} + +# ============================================================================================== +# ━━━ Fetch Emby Series Library ━━━ +# ============================================================================================== +echo "" +echo "━━━━━ $ICON_SUMMARY Sonarr Discovery — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━" +echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) | lookback: ${LOOKBACK_DAYS}d | threshold: ${THRESHOLD}/100 | max: ${MAX_ADDS}" +[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no shows will be added" + +echo "" +echo "━━━ $ICON_SYNC Emby Series Library ━━━" + +LIBRARIES_JSON=$(_emby_api "Library/VirtualFolders") || { error "Could not fetch Emby libraries"; exit 1; } +CUTOFF_ISO=$(date -d "${LOOKBACK_DAYS} days ago" '+%Y-%m-%dT%H:%M:%SZ') +TODAY_EPOCH=$(date +%s) +TODAY=$(date +%Y-%m-%d) + +# EMBY_SERIES_BY_ID[emby_item_id] = tmdb_id — used to map episode.SeriesId → series TMDB ID +# EMBY_SERIES_NAME[tmdb_id] = name — used to display seed names in Stage 1 +# EMBY_TVDB_IDS[tvdb_id] = 1 — used to filter already-owned shows in Stage 2 +# EMBY_TMDB_IDS[tmdb_id] = 1 — used to filter already-owned shows in Stage 2 +declare -A EMBY_SERIES_BY_ID +declare -A EMBY_SERIES_NAME +declare -A EMBY_TVDB_IDS +declare -A EMBY_TMDB_IDS + +for lib_name in "${SONARR_EMBY_LIBRARIES[@]}"; do + lib_id=$(echo "$LIBRARIES_JSON" | jq -r --arg n "$lib_name" '.[] | select(.Name == $n) | .ItemId' 2>/dev/null) + if [[ -z "$lib_id" ]]; then + warn "Emby library not found: $lib_name" + continue + fi + log "Scanning library: $lib_name (ItemId: $lib_id)" + LIB_JSON=$(_emby_api "Items?ParentId=${lib_id}&IncludeItemTypes=Series&Recursive=true&Fields=ProviderIds&Limit=5000") || { + warn "Could not fetch series from library: $lib_name" + continue + } + while IFS='|' read -r emby_id tmdb_id tvdb_id name; do + [[ -z "$emby_id" || "$emby_id" == "null" ]] && continue + [[ -n "$tmdb_id" && "$tmdb_id" != "null" ]] && { + EMBY_SERIES_BY_ID["$emby_id"]="$tmdb_id" + EMBY_TMDB_IDS["$tmdb_id"]=1 + [[ -n "$name" && "$name" != "null" ]] && EMBY_SERIES_NAME["$tmdb_id"]="$name" + } + [[ -n "$tvdb_id" && "$tvdb_id" != "null" ]] && EMBY_TVDB_IDS["$tvdb_id"]=1 + done < <(echo "$LIB_JSON" | jq -r '.Items[] | + [.Id, (.ProviderIds.Tmdb // ""), (.ProviderIds.Tvdb // ""), .Name] | join("|") + ' 2>/dev/null) +done + +log "${#EMBY_SERIES_BY_ID[@]} series in Emby TMDB index" + +if [[ "${#EMBY_SERIES_BY_ID[@]}" -eq 0 ]]; then + warn "No series found in Emby libraries — nothing to seed from" + exit 0 +fi + +# ============================================================================================== +# ━━━ Fetch Emby Activity Log — Episode Plays ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Emby Watch History ━━━" + +# Activity log entries: each playback.stop has ItemId (episode), UserId, Date. +# Track per-user episode plays so we can cap any single user's influence on seed scoring. +declare -A ITEM_USER_PLAYS # "episode_item_id|user_id" → play count +declare -A ITEM_LAST_PLAY # episode_item_id → ISO date of most recent play + +ACTIVITY_JSON=$(_emby_api "System/ActivityLog/Entries?MinDate=${CUTOFF_ISO}&Limit=5000") || { + error "Could not fetch Emby activity log" + exit 1 +} + +while IFS='|' read -r item_id user_id play_date; do + [[ -z "$item_id" || "$item_id" == "null" ]] && continue + [[ -z "$user_id" || "$user_id" == "null" ]] && user_id="unknown" + key="${item_id}|${user_id}" + ITEM_USER_PLAYS["$key"]=$(( ${ITEM_USER_PLAYS["$key"]:-0} + 1 )) + current="${ITEM_LAST_PLAY["$item_id"]:-}" + if [[ -z "$current" || "$play_date" > "$current" ]]; then + ITEM_LAST_PLAY["$item_id"]="$play_date" + fi +done < <(echo "$ACTIVITY_JSON" | jq -r ' + .Items[] | + select(.Type == "playback.stop") | + select(.ItemId != null and .ItemId != "") | + [.ItemId, (.UserId // "unknown"), .Date] | join("|") +' 2>/dev/null) + +# Deduplicate episode IDs for batch fetch +declare -A _UNIQUE_IDS +for key in "${!ITEM_USER_PLAYS[@]}"; do + episode_id="${key%%|*}" + _UNIQUE_IDS["$episode_id"]=1 +done +ALL_EPISODE_IDS=("${!_UNIQUE_IDS[@]}") +unset _UNIQUE_IDS + +log "${#ALL_EPISODE_IDS[@]} unique episode items in activity log" + +# ============================================================================================== +# ━━━ Map Episodes → Series via Batch Fetch ━━━ +# ============================================================================================== + +# EPISODE_TO_SERIES[episode_item_id] = series_tmdb_id +declare -A EPISODE_TO_SERIES + +if [[ "${#ALL_EPISODE_IDS[@]}" -gt 0 ]]; then + BATCH_SIZE=100 + for (( _b=0; _b<${#ALL_EPISODE_IDS[@]}; _b+=BATCH_SIZE )); do + BATCH=("${ALL_EPISODE_IDS[@]:_b:BATCH_SIZE}") + IDS_CSV=$(printf '%s,' "${BATCH[@]}"); IDS_CSV="${IDS_CSV%,}" + ITEMS_DETAIL=$(_emby_api "Items?Ids=${IDS_CSV}&Fields=SeriesId,Type&Limit=200") || { + warn "Could not fetch episode batch starting at $_b" + continue + } + while IFS='|' read -r item_id item_type series_emby_id; do + [[ "$item_type" != "Episode" ]] && continue + [[ -z "$series_emby_id" || "$series_emby_id" == "null" ]] && continue + series_tmdb="${EMBY_SERIES_BY_ID["$series_emby_id"]:-}" + [[ -z "$series_tmdb" ]] && continue + EPISODE_TO_SERIES["$item_id"]="$series_tmdb" + done < <(echo "$ITEMS_DETAIL" | jq -r '.Items[] | + [(.Id | tostring), .Type, (.SeriesId // "")] | join("|") + ' 2>/dev/null) + done +fi + +# Aggregate per-user episode counts per series +# SERIES_USER_PLAYS["series_tmdb_id|user_id"] = total episodes played by that user +# One key per (series, user) pair — safe to count for diversity and cap for volume +declare -A SERIES_USER_PLAYS +declare -A SERIES_LAST_PLAY + +for key in "${!ITEM_USER_PLAYS[@]}"; do + episode_id="${key%%|*}" + user_id="${key#*|}" + series_tmdb="${EPISODE_TO_SERIES["$episode_id"]:-}" + [[ -z "$series_tmdb" ]] && continue + plays="${ITEM_USER_PLAYS[$key]}" + skey="${series_tmdb}|${user_id}" + SERIES_USER_PLAYS["$skey"]=$(( ${SERIES_USER_PLAYS["$skey"]:-0} + plays )) + play_date="${ITEM_LAST_PLAY["$episode_id"]:-}" + current="${SERIES_LAST_PLAY["$series_tmdb"]:-}" + if [[ -n "$play_date" && ( -z "$current" || "$play_date" > "$current" ) ]]; then + SERIES_LAST_PLAY["$series_tmdb"]="$play_date" + fi +done + +# Pre-aggregate stats per series for scoring +declare -A SERIES_UNIQUE_USERS # series_tmdb → unique user count +declare -A SERIES_CAPPED_VOLUME # series_tmdb → sum of min(user_episodes, cap) + +for key in "${!SERIES_USER_PLAYS[@]}"; do + series_tmdb="${key%%|*}" + plays="${SERIES_USER_PLAYS[$key]}" + capped=$(( plays > USER_EPISODE_CAP ? USER_EPISODE_CAP : plays )) + SERIES_UNIQUE_USERS["$series_tmdb"]=$(( ${SERIES_UNIQUE_USERS["$series_tmdb"]:-0} + 1 )) + SERIES_CAPPED_VOLUME["$series_tmdb"]=$(( ${SERIES_CAPPED_VOLUME["$series_tmdb"]:-0} + capped )) +done + +SERIES_COUNT=${#SERIES_UNIQUE_USERS[@]} +log "$SERIES_COUNT series with recent episode activity" + +if [[ "$SERIES_COUNT" -eq 0 ]]; then + warn "No recently watched series found — nothing to seed from" + exit 0 +fi + +# ============================================================================================== +# ━━━ Stage 1: Score Watched Series → Select Seeds ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_GEAR Stage 1: Scoring Watched Series ━━━" + +S1_SCORED=() # "score|tmdb_id|title" + +for series_tmdb in "${!SERIES_UNIQUE_USERS[@]}"; do + n_users="${SERIES_UNIQUE_USERS[$series_tmdb]}" + capped_vol="${SERIES_CAPPED_VOLUME[$series_tmdb]:-0}" + last_play="${SERIES_LAST_PLAY[$series_tmdb]:-}" + series_name="${EMBY_SERIES_NAME[$series_tmdb]:-TMDB:${series_tmdb}}" + + last_epoch=$(date -d "$last_play" +%s 2>/dev/null || echo "$TODAY_EPOCH") + days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 )) + + diversity_s=$(_diversity_score "$n_users") + recency_s=$(_recency_score "$days_ago") + volume_s=$(_volume_score "$capped_vol") + total=$(( diversity_s + recency_s + volume_s )) + + log " [${total}] $series_name | users: $n_users | vol(capped): $capped_vol | ${days_ago}d ago" + S1_SCORED+=("${total}|${series_tmdb}|${series_name}") +done + +IFS=$'\n' S1_SORTED=($(printf '%s\n' "${S1_SCORED[@]}" | sort -t'|' -k1 -rn 2>/dev/null)) + +echo "" +echo "━━━ $ICON_SUMMARY Stage 1 Results (top ${MAX_SEEDS} seeds) ━━━" + +SEEDS_TMDB=() + +for _entry in "${S1_SORTED[@]}"; do + (( ${#SEEDS_TMDB[@]} >= MAX_SEEDS )) && break + _score="${_entry%%|*}" + _rest="${_entry#*|}" + _tmdb="${_rest%%|*}" + _name="${_rest#*|}" + SEEDS_TMDB+=("$_tmdb") + n_users="${SERIES_UNIQUE_USERS[$_tmdb]:-0}" + capped_vol="${SERIES_CAPPED_VOLUME[$_tmdb]:-0}" + printf " Seed [%3s] %-40s | users: %s | vol: %s\n" "$_score" "$_name" "$n_users" "$capped_vol" +done + +if [[ "${#SEEDS_TMDB[@]}" -eq 0 ]]; then + warn "No series qualify as seeds — nothing to discover from" + exit 0 +fi + +log "${#SEEDS_TMDB[@]} seed(s) selected" + +# ============================================================================================== +# ━━━ Stage 2: TMDB TV Recommendations ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Stage 2: TMDB Recommendations ━━━" + +declare -A CANDIDATE_SEEDS # tmdb_id → seed count +declare -A CANDIDATE_TITLE # tmdb_id → show name +declare -A CANDIDATE_RATING # tmdb_id → vote_avg_int +declare -A CANDIDATE_VOTES # tmdb_id → vote_count + +for seed_tmdb in "${SEEDS_TMDB[@]}"; do + log " Seed TMDB: $seed_tmdb" + + RECS_JSON=$(_tmdb_tv_recommendations "$seed_tmdb") + if [[ -z "$RECS_JSON" ]] || echo "$RECS_JSON" | jq -e '.results == [] or .results == null' >/dev/null 2>&1; then + warn " No TMDB recommendations for TMDB:$seed_tmdb" + continue + fi + + while IFS='|' read -r rec_id rec_name rec_avg rec_votes; do + [[ -z "$rec_id" || "$rec_id" == "null" ]] && continue + [[ "$rec_id" == "$seed_tmdb" ]] && continue + + vote_avg_int=$(echo "$rec_avg" | awk '{printf "%d", $1 * 10 + 0.5}' 2>/dev/null) + vote_avg_int=${vote_avg_int:-0} + rec_votes=${rec_votes:-0} + + CANDIDATE_SEEDS["$rec_id"]=$(( ${CANDIDATE_SEEDS["$rec_id"]:-0} + 1 )) + CANDIDATE_TITLE["$rec_id"]="$rec_name" + CANDIDATE_RATING["$rec_id"]="$vote_avg_int" + CANDIDATE_VOTES["$rec_id"]="$rec_votes" + done < <(echo "$RECS_JSON" | jq -r '.results[] | + [ + (.id | tostring), + .name, + (.vote_average | tostring), + (.vote_count | tostring) + ] | join("|")' 2>/dev/null) +done + +CANDIDATE_COUNT=${#CANDIDATE_SEEDS[@]} +log "$CANDIDATE_COUNT discovery candidates from TMDB" + +if [[ "$CANDIDATE_COUNT" -eq 0 ]]; then + warn "No candidates returned from TMDB — check API key" + exit 0 +fi + +# ============================================================================================== +# ━━━ Fetch Existing Sonarr Library ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Existing Libraries ━━━" + +SONARR_SERIES_JSON=$(_sonarr_get "series") || { error "Could not fetch Sonarr library"; exit 1; } + +declare -A SONARR_TVDB # tvdb_id → 1 +declare -A SONARR_TMDB # tmdb_id → 1 (Sonarr v4 exposes tmdbId) + +while IFS='|' read -r tvdb_id tmdb_id; do + [[ -n "$tvdb_id" && "$tvdb_id" != "0" ]] && SONARR_TVDB["$tvdb_id"]=1 + [[ -n "$tmdb_id" && "$tmdb_id" != "0" ]] && SONARR_TMDB["$tmdb_id"]=1 +done < <(echo "$SONARR_SERIES_JSON" | jq -r '.[] | + [(.tvdbId // 0 | tostring), (.tmdbId // 0 | tostring)] | join("|") +' 2>/dev/null) + +log "${#SONARR_TVDB[@]} series in Sonarr | ${#EMBY_TMDB_IDS[@]} series in Emby" + +_in_sonarr() { [[ "${SONARR_TVDB["$1"]+x}" || "${SONARR_TMDB["$2"]+x}" ]]; } +_in_emby() { [[ "${EMBY_TMDB_IDS["$1"]+x}" || "${EMBY_TVDB_IDS["$2"]+x}" ]]; } + +# ============================================================================================== +# ━━━ Score Stage 2 Candidates ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_GEAR Stage 2: Scoring Candidates ━━━" + +ACCEPT_LIST=() +REJECT_LIST=() +ALREADY_KNOWN=0 +SKIP_COOLDOWN=0 +SKIP_QUALITY=0 + +for rec_tmdb in "${!CANDIDATE_SEEDS[@]}"; do + seed_count="${CANDIDATE_SEEDS["$rec_tmdb"]}" + title="${CANDIDATE_TITLE["$rec_tmdb"]}" + vote_avg_int="${CANDIDATE_RATING["$rec_tmdb"]}" + vote_count="${CANDIDATE_VOTES["$rec_tmdb"]}" + + # Check Sonarr by TMDB (tvdb unknown at this point — we resolve it only at add time) + if [[ "${SONARR_TMDB["$rec_tmdb"]+x}" ]]; then + (( ALREADY_KNOWN++ )) + log " $ICON_SKIP In Sonarr: $title" + continue + fi + + if [[ "${EMBY_TMDB_IDS["$rec_tmdb"]+x}" ]]; then + (( ALREADY_KNOWN++ )) + log " $ICON_SKIP In Emby: $title" + continue + fi + + # Hard quality floor — skip before cooldown to avoid polluting history + if (( vote_count < MIN_VOTE_COUNT || vote_avg_int < MIN_RATING )); then + (( SKIP_QUALITY++ )) + log " $ICON_SKIP Below quality floor (rating: $(_fmt_rating "$vote_avg_int") | votes: $vote_count): $title" + continue + fi + + if [[ -f "$HISTORY_FILE" ]]; then + last_rejection=$(grep -i "^REJECT|${rec_tmdb}|" "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d'|' -f3) + if [[ -n "$last_rejection" ]]; then + last_epoch=$(date -d "$last_rejection" +%s 2>/dev/null || echo 0) + days_since=$(( (TODAY_EPOCH - last_epoch) / 86400 )) + if (( days_since < REJECT_COOLDOWN )); then + (( SKIP_COOLDOWN++ )) + log " $ICON_SKIP Cooldown (rejected ${days_since}d ago): $title" + continue + fi + fi + fi + + breadth_s=$(_breadth_score "$seed_count") + rating_s=$(_rating_score_s2 "$vote_avg_int") + votes_s=$(_votes_score "$vote_count") + total=$(( breadth_s + rating_s + votes_s )) + + entry="${total}|${rec_tmdb}|${title}|${seed_count}|${vote_avg_int}|${vote_count}" + if (( total >= THRESHOLD )); then + ACCEPT_LIST+=("$entry") + else + REJECT_LIST+=("$entry") + fi + + log " [${total}] $title (seeds: $seed_count | rating: $(_fmt_rating "$vote_avg_int") | votes: $vote_count)" +done + +# ============================================================================================== +# ━━━ Results ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SUMMARY Stage 2 Results (threshold: ${THRESHOLD}, max: ${MAX_ADDS}) ━━━" + +IFS=$'\n' _ALL_ACCEPTS=($(printf '%s\n' "${ACCEPT_LIST[@]}" | sort -t'|' -k1 -rn 2>/dev/null)) +IFS=$'\n' SORTED_REJECTS=($(printf '%s\n' "${REJECT_LIST[@]}" | sort -t'|' -k1 -rn 2>/dev/null)) + +SORTED_ACCEPTS=("${_ALL_ACCEPTS[@]:0:$MAX_ADDS}") +for (( _i=MAX_ADDS; _i<${#_ALL_ACCEPTS[@]}; _i++ )); do + SORTED_REJECTS+=("${_ALL_ACCEPTS[$_i]}") +done + +_print_row() { + local label="$1" entry="$2" + IFS='|' read -r score rec_tmdb title seeds avg_int votes <<< "$entry" + local rating_fmt + rating_fmt=$(_fmt_rating "$avg_int") + printf " %-8s [%3s] %-45s seeds: %s | rating: %s | votes: %s\n" \ + "$label" "$score" "$title" "$seeds" "$rating_fmt" "$votes" +} + +for entry in "${SORTED_ACCEPTS[@]}"; do [[ -n "$entry" ]] && _print_row "ACCEPT" "$entry"; done +for entry in "${SORTED_REJECTS[@]}"; do [[ -n "$entry" ]] && _print_row "REJECT" "$entry"; done + +echo "" +echo " Already in library: $ALREADY_KNOWN | Below quality floor: $SKIP_QUALITY | Cooldown: $SKIP_COOLDOWN" +echo " ACCEPT: ${#SORTED_ACCEPTS[@]} | REJECT: ${#SORTED_REJECTS[@]}" + +if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN complete — run without --dry-run to add accepted shows" + exit 0 +fi + +if [[ "${#SORTED_ACCEPTS[@]}" -eq 0 ]]; then + log "No shows above threshold — nothing to add" + for entry in "${SORTED_REJECTS[@]}"; do + [[ -z "$entry" ]] && continue + IFS='|' read -r score rec_tmdb title _ <<< "$entry" + echo "REJECT|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null + done + exit 0 +fi + +# ============================================================================================== +# ━━━ Add to Sonarr ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Adding to Sonarr ━━━" + +SONARR_ROOT=$(_sonarr_get "rootfolder" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null) +if [[ -z "$SONARR_ROOT" ]]; then error "Could not determine Sonarr root folder"; exit 1; fi + +QUALITY_ID=$(_sonarr_get "qualityprofile" | jq -r '.[0].id' 2>/dev/null) +log "Root folder: $SONARR_ROOT | Quality profile: $QUALITY_ID | Monitor: $MONITOR_MODE" + +ADDED=0 +FAILED=0 + +for entry in "${SORTED_ACCEPTS[@]}"; do + [[ -z "$entry" ]] && continue + IFS='|' read -r score rec_tmdb title seeds avg_int votes <<< "$entry" + + # Resolve TVDB ID via TMDB external_ids — Sonarr lookup requires tvdb: term + EXT_JSON=$(_tmdb_external_ids "$rec_tmdb") + tvdb_id=$(echo "$EXT_JSON" | jq -r '.tvdb_id // empty' 2>/dev/null) + + if [[ -z "$tvdb_id" ]]; then + warn " $ICON_WARN No TVDB ID from TMDB for: $title (TMDB: $rec_tmdb)" + echo "FAIL|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null + (( FAILED++ )) + continue + fi + + # Check if already in Sonarr by TVDB (may have been added since we loaded the library) + if [[ "${SONARR_TVDB["$tvdb_id"]+x}" ]]; then + log " $ICON_SKIP Already in Sonarr (TVDB $tvdb_id): $title" + (( ALREADY_KNOWN++ )) + continue + fi + + # Check if already in Emby by TVDB + if [[ "${EMBY_TVDB_IDS["$tvdb_id"]+x}" ]]; then + log " $ICON_SKIP Already in Emby (TVDB $tvdb_id): $title" + (( ALREADY_KNOWN++ )) + continue + fi + + LOOKUP=$(_sonarr_lookup "tvdb:$tvdb_id") + + if [[ -z "$LOOKUP" ]] || echo "$LOOKUP" | jq -e '. == [] or . == null' >/dev/null 2>&1; then + warn " $ICON_WARN No match in Sonarr lookup: $title (TVDB: $tvdb_id)" + echo "FAIL|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null + (( FAILED++ )) + continue + fi + + SERIES_DATA=$(echo "$LOOKUP" | jq '.[0]' 2>/dev/null) + SONARR_TITLE=$(echo "$SERIES_DATA" | jq -r '.title // ""' 2>/dev/null) + + if [[ -z "$SONARR_TITLE" ]]; then + warn " $ICON_WARN Empty result for: $title" + echo "FAIL|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null + (( FAILED++ )) + continue + fi + + PAYLOAD=$(echo "$SERIES_DATA" | jq \ + --arg root "$SONARR_ROOT" \ + --argjson qid "$QUALITY_ID" \ + --arg mon "$MONITOR_MODE" \ + '. + { + rootFolderPath: $root, + qualityProfileId: $qid, + monitored: true, + seasonFolder: true, + addOptions: { + monitor: $mon, + searchForMissingEpisodes: false, + searchForCutoffUnmetEpisodes: false + } + }' 2>/dev/null) + + RESULT=$(_sonarr_post "$PAYLOAD") + + if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then + SERIES_ID=$(echo "$RESULT" | jq -r '.id') + _sonarr_command "{\"name\":\"SeriesSearch\",\"seriesId\":${SERIES_ID}}" >/dev/null + log " $ICON_DONE Added: $SONARR_TITLE (score: $score | seeds: $seeds | TVDB: $tvdb_id)" + echo "ACCEPT|${rec_tmdb}|${TODAY}|${SONARR_TITLE}" >> "$HISTORY_FILE" 2>/dev/null + (( ADDED++ )) + else + warn " $ICON_WARN Failed to add: $title" + log " $(echo "$RESULT" | head -c 200)" + echo "FAIL|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null + (( FAILED++ )) + fi +done + +for entry in "${SORTED_REJECTS[@]}"; do + [[ -z "$entry" ]] && continue + IFS='|' read -r score rec_tmdb title _ <<< "$entry" + echo "REJECT|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null +done + +# ============================================================================================== +# ━━━ Summary ━━━ +# ============================================================================================== +echo "" +echo "━━━━━ $ICON_SUMMARY DISCOVERY COMPLETE ━━━━━" +echo " $ICON_DONE Added: $ADDED" +[[ "$FAILED" -gt 0 ]] && echo " $ICON_WARN Failed: $FAILED" +echo " $ICON_SKIP Rejected: ${#SORTED_REJECTS[@]}" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +[[ "$ADDED" -gt 0 ]] && notify \ + "$ADDED show(s) added to Sonarr via discovery on $(hostname)" \ + "Sonarr Discovery" "normal" + +exit 0 diff --git a/Tools/emby_to_lidarr_sync.sh b/Tools/emby_to_lidarr_sync.sh index 6e8c360..baf3401 100755 --- a/Tools/emby_to_lidarr_sync.sh +++ b/Tools/emby_to_lidarr_sync.sh @@ -102,6 +102,14 @@ _lidarr_post() { "${LIDARR_URL}/api/v1/artist" 2>/dev/null } +_lidarr_command() { + curl -sf --max-time 20 -X POST \ + -H "X-Api-Key: $LIDARR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$1" \ + "${LIDARR_URL}/api/v1/command" 2>/dev/null +} + _is_placeholder_artist() { local a="${1,,}" [[ "$a" =~ ^(va|various|various artists|unknown artist|unknown|soundtrack|original soundtrack|ost)$ ]] @@ -267,6 +275,8 @@ for artist in "${MISSING[@]}"; do RESULT=$(_lidarr_post "$PAYLOAD") if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then + ARTIST_ID=$(echo "$RESULT" | jq -r '.id') + _lidarr_command "{\"name\":\"ArtistSearch\",\"artistId\":${ARTIST_ID}}" >/dev/null log " $ICON_DONE Added: $LIDARR_NAME" (( ADDED++ )) else diff --git a/Tools/emby_to_radarr_sync.sh b/Tools/emby_to_radarr_sync.sh index a0b9ae6..6b58724 100644 --- a/Tools/emby_to_radarr_sync.sh +++ b/Tools/emby_to_radarr_sync.sh @@ -100,6 +100,14 @@ _radarr_post() { "${RADARR_URL}/api/v3/movie" 2>/dev/null } +_radarr_command() { + curl -sf --max-time 20 -X POST \ + -H "X-Api-Key: $RADARR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$1" \ + "${RADARR_URL}/api/v3/command" 2>/dev/null +} + # Episode/garbage title filter — catches anime episodes stored as individual files # in the Movies library, fansub release names, and codec metadata strings. _is_episode_title() { @@ -272,6 +280,8 @@ for name in "${SORTED_MISSING[@]}"; do RESULT=$(_radarr_post "$PAYLOAD") if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then + MOVIE_ID=$(echo "$RESULT" | jq -r '.id') + _radarr_command "{\"name\":\"MoviesSearch\",\"movieIds\":[${MOVIE_ID}]}" >/dev/null log " $ICON_DONE Added: $RADARR_TITLE" (( ADDED++ )) else diff --git a/Tools/emby_to_sonarr_sync.sh b/Tools/emby_to_sonarr_sync.sh index 278f338..1d4f269 100644 --- a/Tools/emby_to_sonarr_sync.sh +++ b/Tools/emby_to_sonarr_sync.sh @@ -100,6 +100,14 @@ _sonarr_post() { "${SONARR_URL}/api/v3/series" 2>/dev/null } +_sonarr_command() { + curl -sf --max-time 20 -X POST \ + -H "X-Api-Key: $SONARR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$1" \ + "${SONARR_URL}/api/v3/command" 2>/dev/null +} + # Garbage title filter — catches multi-season folder names and bare fansub tags. _is_garbage_title() { local t="$1" @@ -283,6 +291,8 @@ for name in "${SORTED_MISSING[@]}"; do RESULT=$(_sonarr_post "$PAYLOAD") if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then + SERIES_ID=$(echo "$RESULT" | jq -r '.id') + _sonarr_command "{\"name\":\"SeriesSearch\",\"seriesId\":${SERIES_ID}}" >/dev/null log " $ICON_DONE Added: $SONARR_TITLE" (( ADDED++ )) else diff --git a/master.conf b/master.conf index 4f6924c..89af7a1 100644 --- a/master.conf +++ b/master.conf @@ -382,6 +382,7 @@ "unRAID_Essentials/clear_logs.sh" # purge aged logs — Sunday only, low priority "Media/playback_aware_lidarr_discovery.sh" # behavior-driven music discovery using weekly Emby playback history "Media/playback_aware_radarr_discovery.sh" # behavior-driven movie discovery using TMDB recommendations + "Media/playback_aware_sonarr_discovery.sh" # behavior-driven TV discovery using TMDB recommendations ) # Pull updates for all running containers NOT in daily/weekly restart lists. @@ -950,6 +951,18 @@ LIDARR_DISCOVERY_REJECT_COOLDOWN=30 # days before re-evaluating a rejected artist LIDARR_DISCOVERY_HISTORY="$DATA_DIR/lidarr_discovery_history.db" +# Sonarr discovery settings (playback_aware_sonarr_discovery.sh) + SONARR_DISCOVERY_THRESHOLD=52 # score to accept candidate (0-100) + SONARR_DISCOVERY_LOOKBACK_DAYS=14 # Emby episode play history window in days (shorter than movies — TV watched more frequently) + SONARR_DISCOVERY_MAX_SEEDS=5 # max seed series from Stage 1 + SONARR_DISCOVERY_MAX_ADDS=3 # max shows to add per run — TV is a larger commitment than movies + SONARR_DISCOVERY_MIN_VOTE_COUNT=50 # min TMDB votes (TV has fewer votes than movies at same popularity) + SONARR_DISCOVERY_MIN_RATING=65 # min TMDB vote_average × 10 (65 = 6.5/10) + SONARR_DISCOVERY_REJECT_COOLDOWN=60 # days before re-evaluating a rejected show + SONARR_DISCOVERY_USER_EPISODE_CAP=8 # max episodes any one user contributes to seed volume score + SONARR_DISCOVERY_MONITOR_MODE="future" # Sonarr monitor mode on add: future | all | first | latest | none + SONARR_DISCOVERY_HISTORY="$DATA_DIR/sonarr_discovery_history.db" + # Radarr discovery shared settings RADARR_DISCOVERY_THRESHOLD=52 # score to accept candidate (0-100) — lower than Lidarr since diverse seeds rarely overlap RADARR_DISCOVERY_LOOKBACK_DAYS=30 # Emby watch history window in days (movies rewatched less often)