Files
Varaverk/Arrs_Stack/playback_aware_radarr_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

723 lines
31 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 Radarr Discovery ==================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Two-stage behavior-driven movie discovery.
#
# Stage 1 — Score recently watched movies in Emby. The top movies become
# high-quality seeds, weighted by recency and TMDB rating.
#
# Stage 2 — Run TMDB recommendations on those seeds. Score the candidates
# and add the top movies to Radarr.
#
# Goal: 05 meaningful Radarr adds per run, not bulk imports.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Fetch recently watched movies from Emby (SEED_LIBRARIES, last LOOKBACK_DAYS days)
# — only movies with a TMDB ID are eligible as seeds
#
# ── Stage 1 ─────────────────────────────────────────────────────────────────
# 2. Score each watched movie: recency + TMDB rating + vote count
# 3. Take top MAX_SEEDS by score → discovery seeds
#
# ── Stage 2 ─────────────────────────────────────────────────────────────────
# 4. For each seed, call TMDB movie recommendations → collect candidates
# 5. Aggregate: breadth (distinct seeds recommending this movie)
# 6. Filter: already in Radarr, already in Emby, below min votes/rating, cooldown
# 7. Score candidates: breadth + TMDB rating + vote count
# 8. Take top MAX_ADDS above threshold → add to Radarr
#
# Step 6's "already in Radarr" 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)
# recency_score (0-50) — days since last watch; 0-3d→50, 4-7d→40, 8-14d→30, 15-21d→20, 22-30d→10
# rating_score (0-30) — TMDB vote_average: 8.0+→30, 7.5+→25, 7.0+→18, 6.5+→12, 6.0+→8, else→3
# votes_score (0-20) — TMDB vote_count: 10k+→20, 5k+→15, 1k+→10, 200+→5, else→2
#
# 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: 8.0+→40, 7.5+→32, 7.0+→25, 6.5+→18, 6.0+→12, else→5
# votes_score (0-20) — TMDB vote_count (same thresholds)
#
# Threshold: RADARR_DISCOVERY_THRESHOLD (default 60) — lower than Lidarr since tastes are broader
# Max adds: RADARR_DISCOVERY_MAX_ADDS (default 5)
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf (aliased by detect_hosts())
#
# HOST*_TMDB_API_KEY Required for Stage 2 recommendations. Free key at
# https://www.themoviedb.org/settings/api — without it the run
# exits cleanly rather than adding anything unscored.
# RADARR_URL / RADARR_API_KEY Target arr
# EMBY_URL / EMBY_API_KEY Play history source for seed selection
#
# master.conf
#
# RADARR_DISCOVERY_THRESHOLD Score required to accept a candidate (0-100)
# RADARR_DISCOVERY_LOOKBACK_DAYS Emby watch history window
# RADARR_DISCOVERY_MAX_SEEDS Max seed movies taken from Stage 1
# RADARR_DISCOVERY_MAX_ADDS Hard cap on movies added per run
# RADARR_DISCOVERY_MIN_VOTE_COUNT Min TMDB votes for a candidate to be considered
# RADARR_DISCOVERY_MIN_RATING Min TMDB vote_average × 10
# RADARR_DISCOVERY_REJECT_COOLDOWN Days before a rejected movie is re-evaluated
# RADARR_DISCOVERY_SEED_LIBRARIES Emby libraries to draw seed movies from
# RADARR_DISCOVERY_HISTORY Decision history DB — accepted and rejected
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Playback as Intent Signal
# Recently watched movies are a stronger signal than what is in the library or
# on watchlists. The scoring model weights demonstrated viewing behaviour —
# recency, rating, vote confidence — over passive ownership.
#
# Selective by Design
# 05 adds per run is the target, not bulk imports. A score threshold combined
# with MAX_ADDS ensures only high-confidence recommendations are acted on.
# Volume is not the goal — meaningful discovery is.
#
# Two-Stage Filtering
# Stage 1 rejects weak seeds before they drive Stage 2. Low-quality or
# low-confidence watched movies produce poor recommendations. Filtering at
# the seed stage improves the entire output.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# acquire_lock and Radarr API writes require root. Script exits cleanly if not root.
#
# Dry-Run Mode
# --dry-run scores and ranks all candidates but makes no Radarr 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 movies to Radarr. Never deletes or modifies existing entries.
#
# Cooldown Guard
# Candidates rejected this run are recorded in the history file and not re-evaluated
# until RADARR_DISCOVERY_REJECT_COOLDOWN days have elapsed.
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# RADARR_DISCOVERY_HISTORY (default: $DATA_DIR/radarr_discovery_history.db)
# Tracks added movies 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)
# ==============================================================================================
#
# RADARR_DISCOVERY_THRESHOLD — minimum score to add a candidate (default: 52)
# RADARR_DISCOVERY_LOOKBACK_DAYS — Emby watch history window in days (default: 30)
# RADARR_DISCOVERY_MAX_SEEDS — max seed movies from Stage 1 (default: 5)
# RADARR_DISCOVERY_MAX_ADDS — max movies to add per run (default: 5)
# RADARR_DISCOVERY_MIN_VOTE_COUNT — min TMDB votes for a candidate (default: 100)
# RADARR_DISCOVERY_MIN_RATING — min TMDB vote_average × 10 (default: 60 = 6.0/10)
# RADARR_DISCOVERY_REJECT_COOLDOWN — days before re-evaluating a rejected movie (default: 60)
# RADARR_DISCOVERY_SEED_LIBRARIES — Emby library names to draw seeds from (default: ("Movies"))
# RADARR_DISCOVERY_HISTORY — history/state file path
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# playback_aware_radarr_discovery.sh — normal run
# playback_aware_radarr_discovery.sh --dry-run — score and rank, no Radarr changes
# playback_aware_radarr_discovery.sh --log — verbose output
# playback_aware_radarr_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 "${RADARR_URL:-}" || -z "${RADARR_API_KEY:-}" ]]; then
error "RADARR_URL / RADARR_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
THRESHOLD="${RADARR_DISCOVERY_THRESHOLD:-60}"
LOOKBACK_DAYS="${RADARR_DISCOVERY_LOOKBACK_DAYS:-30}"
MAX_SEEDS="${RADARR_DISCOVERY_MAX_SEEDS:-5}"
MAX_ADDS="${RADARR_DISCOVERY_MAX_ADDS:-5}"
MIN_VOTE_COUNT="${RADARR_DISCOVERY_MIN_VOTE_COUNT:-100}"
MIN_RATING="${RADARR_DISCOVERY_MIN_RATING:-60}"
REJECT_COOLDOWN="${RADARR_DISCOVERY_REJECT_COOLDOWN:-60}"
HISTORY_FILE="${RADARR_DISCOVERY_HISTORY:-${DATA_DIR}/radarr_discovery_history.db}"
SEED_LIBRARIES=("${RADARR_DISCOVERY_SEED_LIBRARIES[@]:-Movies}")
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"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no movies will be added to Radarr"
# _fmt_rating() — provided by common.sh
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY RADARR DISCOVERY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Emby: ${EMBY_URL}"
echo "$ICON_SYNC Radarr: ${RADARR_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 Seed libs: ${SEED_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 ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
_radarr_get() {
curl -sf --max-time 20 \
-H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/${1}" 2>/dev/null
}
_radarr_lookup() {
curl -sf --max-time 20 --get \
--data-urlencode "term=tmdb:${1}" \
-H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/movie/lookup" 2>/dev/null
}
_radarr_post() {
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/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" \
2>/dev/null
}
# ==============================================================================================
# ── SCORING HELPERS ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
_recency_score() {
local days="$1"
if (( days <= 3 )); then echo 50
elif (( days <= 7 )); then echo 40
elif (( days <= 14 )); then echo 30
elif (( days <= 21 )); then echo 20
elif (( days <= 30 )); then echo 10
else echo 0
fi
}
# Stage 1: play frequency score (max 50) — complements recency (max 50) for 100 total
_freq_score() {
local c="$1"
if (( c >= 4 )); then echo 50
elif (( c >= 2 )); then echo 35
else echo 20
fi
}
# _rating_score_s2(), _votes_score(), _breadth_score() — provided by common.sh
# ==============================================================================================
# ━━━ Fetch Emby Libraries + Recently Watched Movies ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY Radarr 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 movies will be added"
echo ""
echo "━━━ $ICON_SYNC Emby Watch History ━━━"
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)
# Build TMDB index of all movies in Emby — used in Stage 2 to filter already-owned movies
declare -A EMBY_TMDB_IDS # tmdb_id → 1
for lib_name in "${SEED_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 "Indexing library: $lib_name (ItemId: $lib_id)"
LIB_JSON=$(emby_api "Items?ParentId=${lib_id}&IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds&Limit=10000") || {
warn "Could not index library: $lib_name"
continue
}
while IFS= read -r tmdb_id; do
[[ -n "$tmdb_id" && "$tmdb_id" != "null" ]] && EMBY_TMDB_IDS["$tmdb_id"]=1
done < <(echo "$LIB_JSON" | jq -r '.Items[].ProviderIds.Tmdb // empty' 2>/dev/null)
done
log "${#EMBY_TMDB_IDS[@]} movies in Emby TMDB index"
# Fetch recent play completions from the server-level activity log.
# Each entry includes an ItemId — batch-fetch those items to determine type (Movie vs. Music/TV).
# SortBy=DatePlayed on Items requires UserId context and errors without one; the activity log
# is server-scoped and doesn't have that limitation.
ACTIVITY_JSON=$(emby_api "System/ActivityLog/Entries?MinDate=${CUTOFF_ISO}&Limit=5000") || {
error "Could not fetch Emby activity log"
exit 1
}
declare -A ITEM_PLAYS # emby_item_id → play count
declare -A ITEM_LAST_PLAY # emby_item_id → ISO date of most recent play
while IFS='|' read -r item_id play_date; do
[[ -z "$item_id" || "$item_id" == "null" ]] && continue
ITEM_PLAYS["$item_id"]=$(( ${ITEM_PLAYS["$item_id"]:-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, .Date] | join("|")
' 2>/dev/null)
log "${#ITEM_PLAYS[@]} unique items in activity log"
declare -A WATCHED_MOVIES # tmdb_id → "title|play_count|last_play_date"
if [[ "${#ITEM_PLAYS[@]}" -gt 0 ]]; then
# Fetch in batches of 100 — large ID lists exceed GET URL limits
ALL_ITEM_IDS=("${!ITEM_PLAYS[@]}")
BATCH_SIZE=100
for (( _b=0; _b<${#ALL_ITEM_IDS[@]}; _b+=BATCH_SIZE )); do
BATCH=("${ALL_ITEM_IDS[@]:_b:BATCH_SIZE}")
IDS_CSV=$(printf '%s,' "${BATCH[@]}"); IDS_CSV="${IDS_CSV%,}"
ITEMS_DETAIL=$(emby_api "Items?Ids=${IDS_CSV}&Fields=ProviderIds,Type&Limit=200") || {
warn "Could not fetch item batch starting at $_b"
continue
}
while IFS='|' read -r item_id item_type tmdb_id title; do
[[ "$item_type" != "Movie" ]] && continue
[[ -z "$tmdb_id" || "$tmdb_id" == "null" ]] && continue
play_count="${ITEM_PLAYS["$item_id"]:-1}"
last_play="${ITEM_LAST_PLAY["$item_id"]:-}"
if [[ -n "${WATCHED_MOVIES["$tmdb_id"]:-}" ]]; then
IFS='|' read -r ex_title ex_plays ex_date <<< "${WATCHED_MOVIES["$tmdb_id"]}"
play_count=$(( ex_plays + play_count ))
[[ "$last_play" > "$ex_date" ]] || last_play="$ex_date"
title="$ex_title"
fi
WATCHED_MOVIES["$tmdb_id"]="${title}|${play_count}|${last_play}"
done < <(echo "$ITEMS_DETAIL" | jq -r '.Items[] |
[(.Id | tostring), .Type, (.ProviderIds.Tmdb // ""), .Name] | join("|")
' 2>/dev/null)
done
fi
WATCHED_COUNT=${#WATCHED_MOVIES[@]}
log "$WATCHED_COUNT movies watched in the last ${LOOKBACK_DAYS} days with TMDB IDs"
if [[ "$WATCHED_COUNT" -eq 0 ]]; then
warn "No recently watched movies found — nothing to seed from"
exit 0
fi
# ==============================================================================================
# ━━━ Stage 1: Score Watched Movies → Select Seeds ━━━
# ==============================================================================================
# Scoring: recency (0-50) + play frequency across all users (0-50) = max 100
# No TMDB rating at Stage 1 — CommunityRating is not exposed by Emby's Items API
echo ""
echo "━━━ $ICON_GEAR Stage 1: Scoring Watched Movies ━━━"
S1_SCORED=() # "score|tmdb_id|title"
for tmdb_id in "${!WATCHED_MOVIES[@]}"; do
IFS='|' read -r title play_count last_play <<< "${WATCHED_MOVIES["$tmdb_id"]}"
last_epoch=$(date -d "$last_play" +%s 2>/dev/null || echo "$TODAY_EPOCH")
days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 ))
recency_s=$(_recency_score "$days_ago")
freq_s=$(_freq_score "$play_count")
total=$(( recency_s + freq_s ))
log " [${total}] $title (TMDB: $tmdb_id | ${days_ago}d ago | plays: $play_count)"
S1_SCORED+=("${total}|${tmdb_id}|${title}")
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=()
SEEDS_TITLES=()
for _entry in "${S1_SORTED[@]}"; do
(( ${#SEEDS_TMDB[@]} >= MAX_SEEDS )) && break
_score="${_entry%%|*}"
_rest="${_entry#*|}"
_tmdb="${_rest%%|*}"
_title="${_rest#*|}"
SEEDS_TMDB+=("$_tmdb")
SEEDS_TITLES+=("$_title")
printf " Seed [%3s] %s (TMDB: %s)\n" "$_score" "$_title" "$_tmdb"
done
if [[ "${#SEEDS_TMDB[@]}" -eq 0 ]]; then
warn "No recently watched movies qualify — no seeds for discovery"
exit 0
fi
log "${#SEEDS_TMDB[@]} seed(s) selected"
# ==============================================================================================
# ━━━ Stage 2: TMDB Recommendations ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Stage 2: TMDB Recommendations ━━━"
declare -A CANDIDATE_SEEDS # tmdb_id → seed count
declare -A CANDIDATE_TITLE # tmdb_id → title
declare -A CANDIDATE_RATING # tmdb_id → vote_avg_int
declare -A CANDIDATE_VOTES # tmdb_id → vote_count
for (( _i=0; _i<${#SEEDS_TMDB[@]}; _i++ )); do
seed_tmdb="${SEEDS_TMDB[$_i]}"
seed_title="${SEEDS_TITLES[$_i]}"
log " Seed: $seed_title (TMDB: $seed_tmdb)"
RECS_JSON=$(_tmdb_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: $seed_title"
continue
fi
while IFS='|' read -r rec_id rec_title 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_title"
CANDIDATE_RATING["$rec_id"]="$vote_avg_int"
CANDIDATE_VOTES["$rec_id"]="$rec_votes"
done < <(echo "$RECS_JSON" | jq -r '.results[] |
[
(.id | tostring),
.title,
(.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 Radarr 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.
RADARR_MOVIES_JSON=$(arr_get_tracked_data "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3") || { error "Could not fetch Radarr library"; exit 1; }
declare -A RADARR_TMDB # tmdb_id → 1
while read -r tmdb_id; do
[[ -n "$tmdb_id" && "$tmdb_id" != "0" ]] && RADARR_TMDB["$tmdb_id"]=1
done < <(echo "$RADARR_MOVIES_JSON" | jq -r '.[].tmdbId // 0 | tostring' 2>/dev/null)
RADARR_COUNT=${#RADARR_TMDB[@]}
EMBY_COUNT=${#EMBY_TMDB_IDS[@]}
log "$RADARR_COUNT movies in Radarr | $EMBY_COUNT movies in Emby"
_in_radarr() { [[ "${RADARR_TMDB["$1"]+x}" ]]; }
_in_emby() { [[ "${EMBY_TMDB_IDS["$1"]+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_id in "${!CANDIDATE_SEEDS[@]}"; do
seed_count="${CANDIDATE_SEEDS["$rec_id"]}"
title="${CANDIDATE_TITLE["$rec_id"]}"
vote_avg_int="${CANDIDATE_RATING["$rec_id"]}"
vote_count="${CANDIDATE_VOTES["$rec_id"]}"
if _in_radarr "$rec_id"; then
(( ALREADY_KNOWN++ ))
log " $ICON_SKIP In Radarr: $title"
continue
fi
if _in_emby "$rec_id"; then
(( ALREADY_KNOWN++ ))
log " $ICON_SKIP In Emby: $title"
continue
fi
# Hard quality floor — skip before cooldown check 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_id}|" "$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_id}|${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_id 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 movies"
exit 0
fi
if [[ "${#SORTED_ACCEPTS[@]}" -eq 0 ]]; then
log "No movies above threshold — nothing to add"
for entry in "${SORTED_REJECTS[@]}"; do
[[ -z "$entry" ]] && continue
IFS='|' read -r score rec_id title _ <<< "$entry"
echo "REJECT|${rec_id}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
done
exit 0
fi
# ==============================================================================================
# ━━━ Add to Radarr ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Adding to Radarr ━━━"
RADARR_ROOT=$(_radarr_get "rootfolder" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null)
if [[ -z "$RADARR_ROOT" ]]; then error "Could not determine Radarr root folder"; exit 1; fi
QUALITY_ID=$(_radarr_get "qualityprofile" | jq -r '.[0].id' 2>/dev/null)
log "Root folder: $RADARR_ROOT | Quality profile: $QUALITY_ID"
ADDED=0
FAILED=0
for entry in "${SORTED_ACCEPTS[@]}"; do
[[ -z "$entry" ]] && continue
IFS='|' read -r score rec_id title seeds avg_int votes <<< "$entry"
LOOKUP=$(_radarr_lookup "$rec_id")
if [[ -z "$LOOKUP" ]] || echo "$LOOKUP" | jq -e '. == [] or . == null' >/dev/null 2>&1; then
warn " $ICON_WARN No match in Radarr lookup: $title (TMDB: $rec_id)"
echo "FAIL|${rec_id}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
continue
fi
MOVIE_DATA=$(echo "$LOOKUP" | jq '.[0]' 2>/dev/null)
RADARR_TITLE=$(echo "$MOVIE_DATA" | jq -r '.title // ""' 2>/dev/null)
if [[ -z "$RADARR_TITLE" ]]; then
warn " $ICON_WARN Empty result for: $title"
echo "FAIL|${rec_id}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
continue
fi
PAYLOAD=$(echo "$MOVIE_DATA" | jq \
--arg root "$RADARR_ROOT" \
--argjson qid "$QUALITY_ID" \
'. + {
rootFolderPath: $root,
qualityProfileId: $qid,
monitored: true,
addOptions: {
searchForMovie: false
}
}' 2>/dev/null)
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++ ))
else
warn " $ICON_WARN Failed to add: $title"
log " $(echo "$RESULT" | head -c 200)"
echo "FAIL|${rec_id}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
(( FAILED++ ))
fi
done
for entry in "${SORTED_REJECTS[@]}"; do
[[ -z "$entry" ]] && continue
IFS='|' read -r score rec_id title _ <<< "$entry"
echo "REJECT|${rec_id}|${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 movie(s) added to Radarr via discovery on $(hostname)" \
"Radarr Discovery" "normal"
exit 0