#!/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: 0–5 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 # # ============================================================================================== # 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 # 0–5 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 ━━━" RADARR_MOVIES_JSON=$(_radarr_get "movie") || { 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