Add Radarr discovery + tighten arr sync filters
- playback_aware_radarr_discovery.sh: new two-stage movie discovery using recently watched Emby movies as seeds and TMDB recommendations as the similarity engine; scores on recency + rating + vote count; adds HOST*_TMDB_API_KEY alias to detect_hosts() in common.sh - emby_to_sonarr_sync.sh: restrict to SONARR_EMBY_LIBRARIES allowlist (Anime, Kids Shows, Stand-Up Comedy, TV shows) — excludes Youtube and Recorded Sports libraries; add garbage title filter for multi- season folder names (S01-S03) and bare bracket tags ([Prof]) - emby_to_radarr_sync.sh: add _is_episode_title filter to block anime episode files stored in Movies library (underscore/dot patterns, fansub bracket format, episode markers, codec metadata strings) - emby_to_lidarr_sync.sh: add U+FFFD Unicode replacement character filter alongside the existing ASCII ? filter for encoding corruption - master.conf: RADARR_DISCOVERY_* settings, SONARR/RADARR_EMBY_LIBRARIES allowlists, Radarr discovery added to WEEKLY_MAINTENANCE_SCRIPTS
This commit is contained in:
Executable
+643
@@ -0,0 +1,643 @@
|
||||
#!/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 master_host*.conf
|
||||
# Free key at: https://www.themoviedb.org/settings/api
|
||||
#
|
||||
# ==============================================================================================
|
||||
# CONFIGURATION (master.conf)
|
||||
# ==============================================================================================
|
||||
#
|
||||
# RADARR_DISCOVERY_THRESHOLD — minimum score to add a candidate (default: 60)
|
||||
# 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"
|
||||
source "$SCRIPT_DIR/../Kernel/decision_engine.sh"
|
||||
|
||||
parse_args "$@"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Setup ━━━
|
||||
# ==============================================================================================
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
error "Must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
acquire_lock
|
||||
detect_hosts
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
error "jq not installed — required for API JSON parsing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${EMBY_URL:-}" || -z "${EMBY_API_KEY:-}" ]]; then
|
||||
error "EMBY_URL / EMBY_API_KEY not configured — check master_host*.conf"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${RADARR_URL:-}" || -z "${RADARR_API_KEY:-}" ]]; then
|
||||
error "RADARR_URL / RADARR_API_KEY not configured — check master_host*.conf"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${TMDB_API_KEY:-}" ]]; then
|
||||
error "TMDB_API_KEY not configured — required for discovery"
|
||||
error "Get a free key at https://www.themoviedb.org/settings/api"
|
||||
error "Configure HOST*_TMDB_API_KEY in master_host*.conf"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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}")
|
||||
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no movies will be added to Radarr"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ 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} ($(echo "scale=1; $MIN_RATING/10" | bc)/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 ───────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
_emby_api() {
|
||||
local response http_code body
|
||||
response=$(curl -sf --max-time 20 \
|
||||
-H "X-Emby-Token: $EMBY_API_KEY" \
|
||||
-w "\n%{http_code}" \
|
||||
"${EMBY_URL}/${1}" 2>/dev/null)
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
[[ "$http_code" != "200" ]] && { error "Emby API HTTP $http_code: $1"; return 1; }
|
||||
echo "$body"
|
||||
}
|
||||
|
||||
_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
|
||||
}
|
||||
|
||||
_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
|
||||
}
|
||||
|
||||
# vote_avg_int = vote_average × 10 as integer (e.g. 7.8 → 78)
|
||||
_rating_score_s1() {
|
||||
local v="$1"
|
||||
if (( v >= 80 )); then echo 30
|
||||
elif (( v >= 75 )); then echo 25
|
||||
elif (( v >= 70 )); then echo 18
|
||||
elif (( v >= 65 )); then echo 12
|
||||
elif (( v >= 60 )); then echo 8
|
||||
else echo 3
|
||||
fi
|
||||
}
|
||||
|
||||
_rating_score_s2() {
|
||||
local v="$1"
|
||||
if (( v >= 80 )); then echo 40
|
||||
elif (( v >= 75 )); then echo 32
|
||||
elif (( v >= 70 )); then echo 25
|
||||
elif (( v >= 65 )); then echo 18
|
||||
elif (( v >= 60 )); then echo 12
|
||||
else echo 5
|
||||
fi
|
||||
}
|
||||
|
||||
_votes_score() {
|
||||
local c="$1"
|
||||
if (( c >= 10000 )); then echo 20
|
||||
elif (( c >= 5000 )); then echo 15
|
||||
elif (( c >= 1000 )); then echo 10
|
||||
elif (( c >= 200 )); then echo 5
|
||||
else echo 2
|
||||
fi
|
||||
}
|
||||
|
||||
_breadth_score() {
|
||||
local seeds="$1"
|
||||
if (( seeds >= 3 )); then echo 40
|
||||
elif (( seeds == 2 )); then echo 25
|
||||
else echo 10
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ 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)
|
||||
|
||||
declare -A WATCHED_MOVIES # tmdb_id → "title|last_played_date|vote_avg_int|vote_count"
|
||||
declare -A EMBY_TMDB_IDS # tmdb_id → 1 (all movies in Emby, for Stage 2 filter)
|
||||
|
||||
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 "Scanning library: $lib_name (ItemId: $lib_id)"
|
||||
|
||||
LIB_JSON=$(_emby_api "Items?ParentId=${lib_id}&IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds,UserData&SortBy=DatePlayed&SortOrder=Descending&Limit=200") || {
|
||||
warn "Could not fetch movies from library: $lib_name"
|
||||
continue
|
||||
}
|
||||
|
||||
while IFS='|' read -r name tmdb_id play_count last_played vote_avg vote_count; do
|
||||
[[ -z "$name" || "$name" == "null" ]] && continue
|
||||
[[ -z "$tmdb_id" || "$tmdb_id" == "null" || "$tmdb_id" == "" ]] && continue
|
||||
EMBY_TMDB_IDS["$tmdb_id"]=1
|
||||
|
||||
[[ "$play_count" -le 0 ]] 2>/dev/null && continue
|
||||
[[ -z "$last_played" || "$last_played" == "null" ]] && continue
|
||||
[[ "$last_played" < "$CUTOFF_ISO" ]] && continue
|
||||
|
||||
vote_avg_int=$(echo "$vote_avg" | awk '{printf "%d", $1 * 10 + 0.5}' 2>/dev/null)
|
||||
vote_avg_int=${vote_avg_int:-0}
|
||||
vote_count=${vote_count:-0}
|
||||
|
||||
WATCHED_MOVIES["$tmdb_id"]="${name}|${last_played}|${vote_avg_int}|${vote_count}"
|
||||
done < <(echo "$LIB_JSON" | jq -r '.Items[] |
|
||||
[
|
||||
.Name,
|
||||
(.ProviderIds.Tmdb // ""),
|
||||
(.UserData.PlayCount // 0 | tostring),
|
||||
(.UserData.LastPlayedDate // ""),
|
||||
(.CommunityRating // 0 | tostring),
|
||||
(.VoteCount // 0 | tostring)
|
||||
] | join("|")' 2>/dev/null)
|
||||
done
|
||||
|
||||
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 ━━━
|
||||
# ==============================================================================================
|
||||
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 last_played vote_avg_int vote_count <<< "${WATCHED_MOVIES["$tmdb_id"]}"
|
||||
|
||||
last_epoch=$(date -d "$last_played" +%s 2>/dev/null || echo "$TODAY_EPOCH")
|
||||
days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 ))
|
||||
|
||||
recency_s=$(_recency_score "$days_ago")
|
||||
rating_s=$(_rating_score_s1 "$vote_avg_int")
|
||||
votes_s=$(_votes_score "$vote_count")
|
||||
total=$(( recency_s + rating_s + votes_s ))
|
||||
|
||||
log " [${total}] $title (TMDB: $tmdb_id | ${days_ago}d ago | rating: $(echo "scale=1; $vote_avg_int/10" | bc) | votes: $vote_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: $(echo "scale=1; $vote_avg_int/10" | bc) | 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: $(echo "scale=1; $vote_avg_int/10" | bc) | 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=$(echo "scale=1; $avg_int/10" | bc 2>/dev/null || echo "?")
|
||||
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
|
||||
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
|
||||
Reference in New Issue
Block a user