Files
Varaverk/Arrs_Stack/playback_aware_radarr_discovery.sh
T
Gmer4Lfe bac1ef1c17 Update headers on today's arr-caching changes
Comment-only. Headers on the scripts touched during today's caching work
(cache-first fetches, write-through per-item cache, single-walk
consolidation, movieFile-embedded fix) still described pre-change
behavior. Also brought common.sh's top-level cache doc block current --
it was written for the single-consumer 2026-07-16 state and didn't
mention the tmpfs move, the write guard, or the 15+ consumers that now
go through it.
2026-07-17 01:08:46 -04:00

707 lines
30 KiB
Bash
Executable File
Raw 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.
#
# ==============================================================================================
# FLOW
# ==============================================================================================
#
# 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)
#
# ==============================================================================================
# REQUIREMENTS
# ==============================================================================================
#
# TMDB API key — required for Stage 2 recommendations
# Configure HOST*_TMDB_API_KEY in host*.conf
# Free key at: https://www.themoviedb.org/settings/api
#
# ==============================================================================================
# 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