diff --git a/Media/playback_aware_radarr_discovery.sh b/Media/playback_aware_radarr_discovery.sh new file mode 100755 index 0000000..28a5bc0 --- /dev/null +++ b/Media/playback_aware_radarr_discovery.sh @@ -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 diff --git a/Tools/emby_to_lidarr_sync.sh b/Tools/emby_to_lidarr_sync.sh index f0d1a72..6e8c360 100755 --- a/Tools/emby_to_lidarr_sync.sh +++ b/Tools/emby_to_lidarr_sync.sh @@ -116,6 +116,8 @@ _is_dirty_artist() { [[ "$1" == *" & "* ]] && return 0 [[ "$lower" == *" vs "* ]] && return 0 [[ "$1" == *" - "* ]] && return 0 + [[ "$1" == *"?"* ]] && return 0 # ASCII ? — encoding corruption + [[ "$1" == *$'\xef\xbf\xbd'* ]] && return 0 # U+FFFD replacement character — encoding corruption return 1 } diff --git a/Tools/emby_to_radarr_sync.sh b/Tools/emby_to_radarr_sync.sh index 4b1e599..a0b9ae6 100644 --- a/Tools/emby_to_radarr_sync.sh +++ b/Tools/emby_to_radarr_sync.sh @@ -100,6 +100,21 @@ _radarr_post() { "${RADARR_URL}/api/v3/movie" 2>/dev/null } +# Episode/garbage title filter — catches anime episodes stored as individual files +# in the Movies library, fansub release names, and codec metadata strings. +_is_episode_title() { + local t="$1" + [[ "$t" == *"_-_"* ]] && return 0 # NANA_-_01_ + [[ "$t" == *".-."* ]] && return 0 # Fractale.-.01 + [[ "$t" =~ ^\[.*\]\[.*\] ]] && return 0 # [Title][NNN][...] + [[ "$t" =~ [[:space:]]'-'[[:space:]][0-9]{2,3}$ ]] && return 0 # Title - 001 + [[ "$t" =~ [[:space:]]E[0-9]{2,3}([[:space:]]|\[) ]] && return 0 # Title E01 [group] + [[ "${t^^}" == *"HEVC"* ]] && return 0 # codec metadata + [[ "$t" == *"(TMDB:"* ]] && return 0 # codec metadata + [[ "$t" == *$'\xef\xbf\xbd'* || "$t" == *"?"* ]] && return 0 # encoding corruption + return 1 +} + # ============================================================================================== # ━━━ Fetch Emby Movie Library ━━━ # ============================================================================================== @@ -120,6 +135,7 @@ declare -A EMBY_MOVIES # name → tmdb_id (empty string if none) while IFS='|' read -r name tmdb_id; do [[ -z "$name" || "$name" == "null" ]] && continue + _is_episode_title "$name" && { log " $ICON_SKIP Skipping episode/garbage: $name"; continue; } EMBY_MOVIES["$name"]="$tmdb_id" done < <(echo "$EMBY_MOVIES_JSON" | jq -r '.Items[] | [.Name, (.ProviderIds.Tmdb // "")] | join("|")' 2>/dev/null) diff --git a/Tools/emby_to_sonarr_sync.sh b/Tools/emby_to_sonarr_sync.sh index 657a651..278f338 100644 --- a/Tools/emby_to_sonarr_sync.sh +++ b/Tools/emby_to_sonarr_sync.sh @@ -100,6 +100,15 @@ _sonarr_post() { "${SONARR_URL}/api/v3/series" 2>/dev/null } +# Garbage title filter — catches multi-season folder names and bare fansub tags. +_is_garbage_title() { + local t="$1" + [[ "$t" =~ S[0-9]{2}-S[0-9]{2} ]] && return 0 # Fruits Basket S01-S03 + [[ "$t" =~ ^\[.*\]$ ]] && return 0 # [Prof] bare bracket tag + [[ "$t" == *$'\xef\xbf\xbd'* || "$t" == *"?"* ]] && return 0 # encoding corruption + return 1 +} + # ============================================================================================== # ━━━ Fetch Emby Series Library ━━━ # ============================================================================================== @@ -111,17 +120,33 @@ echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)" echo "" echo "━━━ $ICON_SYNC Emby Series Library ━━━" -EMBY_SERIES_JSON=$(_emby_api "Items?IncludeItemTypes=Series&Recursive=true&Fields=ProviderIds&Limit=5000") || { - error "Could not fetch Emby series" +if [[ "${#SONARR_EMBY_LIBRARIES[@]}" -eq 0 ]]; then + error "SONARR_EMBY_LIBRARIES not configured — check master.conf" exit 1 -} +fi + +# Fetch library list and resolve names → ItemIds +LIBRARIES_JSON=$(_emby_api "Library/VirtualFolders") || { error "Could not fetch Emby libraries"; exit 1; } declare -A EMBY_SERIES # name → tvdb_id (empty string if none) -while IFS='|' read -r name tvdb_id; do - [[ -z "$name" || "$name" == "null" ]] && continue - EMBY_SERIES["$name"]="$tvdb_id" -done < <(echo "$EMBY_SERIES_JSON" | jq -r '.Items[] | [.Name, (.ProviderIds.Tvdb // "")] | join("|")' 2>/dev/null) +for lib_name in "${SONARR_EMBY_LIBRARIES[@]}"; do + lib_id=$(echo "$LIBRARIES_JSON" | jq -r --arg n "$lib_name" '.[] | select(.Name == $n) | .ItemId' 2>/dev/null) + if [[ -z "$lib_id" ]]; then + warn "Emby library not found: $lib_name" + continue + fi + log "Scanning library: $lib_name (ItemId: $lib_id)" + LIB_JSON=$(_emby_api "Items?ParentId=${lib_id}&IncludeItemTypes=Series&Recursive=true&Fields=ProviderIds&Limit=5000") || { + warn "Could not fetch series from library: $lib_name" + continue + } + while IFS='|' read -r name tvdb_id; do + [[ -z "$name" || "$name" == "null" ]] && continue + _is_garbage_title "$name" && { log " $ICON_SKIP Skipping garbage title: $name"; continue; } + EMBY_SERIES["$name"]="$tvdb_id" + done < <(echo "$LIB_JSON" | jq -r '.Items[] | [.Name, (.ProviderIds.Tvdb // "")] | join("|")' 2>/dev/null) +done EMBY_COUNT=${#EMBY_SERIES[@]} log "$EMBY_COUNT series in Emby library" diff --git a/common.sh b/common.sh index faa056e..72773af 100644 --- a/common.sh +++ b/common.sh @@ -519,6 +519,7 @@ detect_hosts() { LIDARR_MUSIC_ROOT_VAR="${MY_ID}_LIDARR_MUSIC_ROOT"; LIDARR_MUSIC_ROOT="${!LIDARR_MUSIC_ROOT_VAR:-}" FANART_API_KEY_VAR="${MY_ID}_FANART_API_KEY"; FANART_API_KEY="${!FANART_API_KEY_VAR:-}" LASTFM_API_KEY_VAR="${MY_ID}_LASTFM_API_KEY"; LASTFM_API_KEY="${!LASTFM_API_KEY_VAR:-}" + TMDB_API_KEY_VAR="${MY_ID}_TMDB_API_KEY"; TMDB_API_KEY="${!TMDB_API_KEY_VAR:-}" SONARR_URL_VAR="${MY_ID}_SONARR_URL"; SONARR_URL="${!SONARR_URL_VAR:-}" SONARR_API_KEY_VAR="${MY_ID}_SONARR_API_KEY"; SONARR_API_KEY="${!SONARR_API_KEY_VAR:-}" diff --git a/master.conf b/master.conf index 4a7b344..a2d67e6 100644 --- a/master.conf +++ b/master.conf @@ -381,6 +381,7 @@ "Docker_Essentials/docker_update_remaining.sh" # pull updates for all other containers "unRAID_Essentials/clear_logs.sh" # purge aged logs — Sunday only, low priority "Media/playback_aware_lidarr_discovery.sh" # behavior-driven music discovery using weekly Emby playback history + "Media/playback_aware_radarr_discovery.sh" # behavior-driven movie discovery using TMDB recommendations ) # Pull updates for all running containers NOT in daily/weekly restart lists. @@ -949,6 +950,23 @@ LIDARR_DISCOVERY_REJECT_COOLDOWN=30 # days before re-evaluating a rejected artist LIDARR_DISCOVERY_HISTORY="$DATA_DIR/lidarr_discovery_history.db" +# Radarr discovery shared settings + RADARR_DISCOVERY_THRESHOLD=60 # score to accept candidate (0-100) — lower than Lidarr since movie tastes are broader + RADARR_DISCOVERY_LOOKBACK_DAYS=30 # Emby watch history window in days (movies rewatched less often) + RADARR_DISCOVERY_MAX_SEEDS=5 # max seed movies from Stage 1 + RADARR_DISCOVERY_MAX_ADDS=5 # max movies to add per run + RADARR_DISCOVERY_MIN_VOTE_COUNT=100 # min TMDB votes to be considered a candidate + RADARR_DISCOVERY_MIN_RATING=60 # min TMDB vote_average × 10 (60 = 6.0/10) + RADARR_DISCOVERY_REJECT_COOLDOWN=60 # days before re-evaluating a rejected movie + RADARR_DISCOVERY_SEED_LIBRARIES=("Movies") # Emby libraries to draw seed movies from + RADARR_DISCOVERY_HISTORY="$DATA_DIR/radarr_discovery_history.db" + +# Emby → arr sync library allowlists +# Only these Emby library names will be considered by the sync tools. +# Names must match exactly as shown in Emby > Dashboard > Libraries. + SONARR_EMBY_LIBRARIES=("Anime" "Kids Shows" "Stand-Up Comedy" "TV shows") + RADARR_EMBY_LIBRARIES=("Movies" "Kid's Movies") + # Sonarr shared settings SONARR_ORPHAN_AGE=7 # days — files must be older than this before eligible for deletion SONARR_MAX_DELETE_GB=10 # require --i-know-what-im-doing if deletion exceeds this