Files
Varaverk/Arrs_Stack/playback_aware_sonarr_discovery.sh
Gmer4Lfe e8b114094a Bring script headers onto the template and close safeguard gaps
Headers claimed protections the code never had, and several destructive paths had no
guard against a collapsed config value.
2026-08-01 20:37:59 -04:00

860 lines
37 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: 03 meaningful Sonarr adds per run, not bulk imports.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 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
#
# Step 9's "already in Sonarr" check reads the shared tracked-data cache via
# arr_get_tracked_data() (cache-first, live fallback, 2026-07-17) instead of a live fetch —
# this runs weekly right after arr_full_rescan.sh, so it's reading the genuine post-rescan
# snapshot arr_full_rescan.sh just wrote.
#
# ==============================================================================================
# 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
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf (aliased by detect_hosts())
#
# HOST*_TMDB_API_KEY Required for Stage 2 recommendations and external_ids lookup.
# Free key at https://www.themoviedb.org/settings/api — without it
# the run exits cleanly rather than adding anything unscored.
# SONARR_URL / SONARR_API_KEY Target arr
# EMBY_URL / EMBY_API_KEY Play history source for seed selection
#
# master.conf
#
# SONARR_DISCOVERY_THRESHOLD Score required to accept a candidate (0-100)
# SONARR_DISCOVERY_LOOKBACK_DAYS Emby episode play history window
# SONARR_DISCOVERY_MAX_SEEDS Max seed series taken from Stage 1
# SONARR_DISCOVERY_MAX_ADDS Hard cap on shows added per run
# SONARR_DISCOVERY_MIN_VOTE_COUNT Min TMDB votes for a candidate to be considered
# SONARR_DISCOVERY_MIN_RATING Min TMDB vote_average × 10
# SONARR_DISCOVERY_REJECT_COOLDOWN Days before a rejected show is re-evaluated
# SONARR_DISCOVERY_USER_EPISODE_CAP Max episodes one user contributes to seed volume
# SONARR_DISCOVERY_MONITOR_MODE Sonarr monitor mode on add
# SONARR_DISCOVERY_HISTORY Decision history DB — accepted and rejected
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Playback as Intent Signal
# Recently watched episodes are a stronger signal than what is in the library.
# User diversity across a series is weighted above a single user binge —
# broad household interest is a better predictor of a good addition than
# one person's session.
#
# Selective by Design
# 03 adds per run is the target. TV is a larger commitment than movies —
# a lower MAX_ADDS cap reflects that. Volume is not the goal.
#
# Two-Stage Filtering
# Stage 1 rejects weak seeds before they drive Stage 2. A poorly-watched
# or niche series produces poor recommendations. Filtering at the seed
# stage improves the entire output.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# acquire_lock and Sonarr API writes require root. Script exits cleanly if not root.
#
# Dry-Run Mode
# --dry-run scores and ranks all candidates but makes no Sonarr API calls and does not
# write to the history file. Safe to run at any time to preview what would be added.
#
# Add-Only
# Only adds series to Sonarr. Never deletes or modifies existing entries.
#
# Cooldown Guard
# Candidates rejected this run are recorded in the history file and not re-evaluated
# until SONARR_DISCOVERY_REJECT_COOLDOWN days have elapsed.
#
# Monitor Mode
# SONARR_DISCOVERY_MONITOR_MODE="all" monitors every season on add — correct for shows
# where you want Sonarr to search back-catalogue. "future" only marks upcoming seasons;
# use only when you intentionally want to skip existing seasons.
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# SONARR_DISCOVERY_HISTORY (default: $DATA_DIR/sonarr_discovery_history.db)
# Tracks added series and rejected candidates with timestamps. Written after every
# non-dry-run. Enforces reject cooldown and prevents re-adding items added by previous
# runs. Safe to delete — next run starts fresh with no memory.
#
# ==============================================================================================
# 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: "all" or "future" (default: "all")
# 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"
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 host*.conf"
exit 1
fi
if [[ -z "${SONARR_URL:-}" || -z "${SONARR_API_KEY:-}" ]]; then
error "SONARR_URL / SONARR_API_KEY not configured — check 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 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:-all}"
HISTORY_FILE="${SONARR_DISCOVERY_HISTORY:-${DATA_DIR}/sonarr_discovery_history.db}"
log "$ICON_GEAR Config: threshold=${THRESHOLD} lookback=${LOOKBACK_DAYS}d max-seeds=${MAX_SEEDS} max-adds=${MAX_ADDS} min-votes=${MIN_VOTE_COUNT} min-rating=${MIN_RATING} reject-cooldown=${REJECT_COOLDOWN}d monitor=${MONITOR_MODE}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no shows will be added to Sonarr"
# _fmt_rating() — provided by common.sh
# ==============================================================================================
# ━━━ 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 ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
_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
}
# _rating_score_s2(), _votes_score(), _breadth_score() — provided by common.sh
# ==============================================================================================
# ━━━ 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 ━━━"
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (kept current
# every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to a live
# fetch when it's stale, and waits out an active rescan before either.
SONARR_SERIES_JSON=$(arr_get_tracked_data "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") || { 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"
# ==============================================================================================
# ━━━ 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