#!/bin/bash # ============================================================================================== # =============================== Emby → Radarr Sync ========================================== # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # One-shot tool. Finds movies present in Emby that are not tracked in Radarr # and adds them. No scoring — if it's in your library, Radarr should monitor it. # # Intended as a bootstrap / catch-up tool. Run after Radarr setup, after a # database wipe, or any time you suspect gaps between your library and Radarr. # # Cache-first Radarr library fetch (2026-07-17) — comes from the shared tracked-data cache # via arr_get_tracked_data(), fresh (kept warm every 30min by arr_cache_prefill.sh), live # fetch as fallback. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # 1. Fetch all Movie items from Emby (with TMDB provider IDs) # 2. Fetch current Radarr library (indexed by TMDB ID and title) # 3. For each Emby movie not in Radarr → add to Radarr # # Matching prefers TMDB ID when available (immune to title differences), # falling back to case-insensitive title comparison. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Add-Only, Never Remove # The tool only adds movies the arr is missing. It never deletes or unmonitors anything, # so the worst outcome of a bad match is an extra tracked entry — trivially reversible — # rather than lost tracking on something already curated. # # Library Membership Is the Whole Criterion # No scoring, no thresholds, no metadata quality gates. If it is in Emby, the arr should # know about it. This is deliberately not a discovery tool; the playback_aware_* scripts # own that job and its judgement calls. # # Provider ID Over Title # Matching prefers the TMDB ID and falls back to case-insensitive title only when the ID # is absent. Titles differ across sources by punctuation, year suffixes and articles; # matching on them alone would re-add things the arr already tracks. # # Cache-First Read # The arr library comes from the shared tracked-data cache, kept warm by # arr_cache_prefill.sh, with a live fetch as fallback. One read regardless of library size. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # Root Required # acquire_lock requires root. Script exits cleanly if not root. # # Dry-Run Mode # --dry-run shows all movies that would be added without making any Radarr API calls. # Always run first when closing the gap after a fresh Radarr install or database wipe. # # Add-Only # Only adds movies to Radarr. Items already tracked by TMDB ID or title are skipped # without modification. Safe to run multiple times — the second run finds nothing to add. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # RADARR_EMBY_LIBRARIES — Emby library names to scan (master.conf); empty = all libraries # HOST*_RADARR_URL / HOST*_RADARR_API_KEY — Radarr connection (aliased by detect_hosts) # HOST*_EMBY_URL / HOST*_EMBY_API_KEY — Emby connection (aliased by detect_hosts) # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # emby_to_radarr_sync.sh — add all untracked movies to Radarr # emby_to_radarr_sync.sh --dry-run — show what would be added, no changes # emby_to_radarr_sync.sh --log — verbose output # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" parse_args "$@" 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" 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 log "$ICON_GEAR Config: emby=${EMBY_URL} radarr=${RADARR_URL}" [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no movies will be added to Radarr" # ============================================================================================== # ── API HELPERS ─────────────────────────────────────────────────────────────────────────────── # ============================================================================================== _radarr_get() { curl -sf --max-time 30 \ -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=$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 } # 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 ━━━ # ============================================================================================== echo "" echo "━━━━━ $ICON_SUMMARY Emby → Radarr Sync — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━" echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)" [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no movies will be added" echo "" echo "━━━ $ICON_SYNC Emby Movie Library ━━━" declare -A EMBY_MOVIES # name → tmdb_id (empty string if none) if [[ "${#RADARR_EMBY_LIBRARIES[@]}" -gt 0 ]]; then LIBRARIES_JSON=$(emby_api "Library/VirtualFolders") || { error "Could not fetch Emby libraries"; exit 1; } for lib_name in "${RADARR_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=Movie&Recursive=true&Fields=ProviderIds&Limit=5000") || { warn "Could not fetch movies from library: $lib_name" continue } 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 "$LIB_JSON" | jq -r '.Items[] | [.Name, (.ProviderIds.Tmdb // "")] | join("|")' 2>/dev/null) done else log "RADARR_EMBY_LIBRARIES empty — scanning all Emby libraries" EMBY_MOVIES_JSON=$(emby_api "Items?IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds&Limit=10000") || { error "Could not fetch Emby movies" exit 1 } 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) fi EMBY_COUNT=${#EMBY_MOVIES[@]} log "$EMBY_COUNT movies in Emby library" if [[ "$EMBY_COUNT" -eq 0 ]]; then warn "No movies found in Emby library" exit 0 fi # ============================================================================================== # ━━━ Fetch Radarr Library ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_SYNC Radarr Library ━━━" # 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 movies"; exit 1; } declare -A RADARR_TMDB # tmdb_id → 1 declare -A RADARR_TITLES # lower(title) → 1 while IFS='|' read -r title tmdb_id; do [[ -n "$title" ]] && RADARR_TITLES["${title,,}"]=1 [[ -n "$tmdb_id" && "$tmdb_id" != "0" ]] && RADARR_TMDB["$tmdb_id"]=1 done < <(echo "$RADARR_MOVIES_JSON" | jq -r '.[] | [.title, (.tmdbId // 0 | tostring)] | join("|")' 2>/dev/null) RADARR_COUNT=${#RADARR_TITLES[@]} log "$RADARR_COUNT movies already in Radarr" # ============================================================================================== # ━━━ Find Gaps ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_GEAR Comparing Libraries ━━━" declare -A MISSING # name → tmdb_id ALREADY=0 for name in "${!EMBY_MOVIES[@]}"; do tmdb_id="${EMBY_MOVIES[$name]}" if [[ -n "$tmdb_id" && "${RADARR_TMDB[$tmdb_id]+x}" ]]; then (( ALREADY++ )) log " $ICON_SKIP Already tracked (TMDB $tmdb_id): $name" continue fi if [[ "${RADARR_TITLES[${name,,}]+x}" ]]; then (( ALREADY++ )) log " $ICON_SKIP Already tracked (title): $name" continue fi MISSING["$name"]="$tmdb_id" log " $ICON_WARN Not in Radarr: $name${tmdb_id:+ (TMDB: $tmdb_id)}" done IFS=$'\n' SORTED_MISSING=($(printf '%s\n' "${!MISSING[@]}" | sort)) echo " Already tracked: $ALREADY | Missing from Radarr: ${#MISSING[@]}" if [[ "${#MISSING[@]}" -eq 0 ]]; then echo "Radarr already tracks everything in Emby" exit 0 fi echo "" echo "━━━ $ICON_SUMMARY Movies to add (${#MISSING[@]}) ━━━" for name in "${SORTED_MISSING[@]}"; do _tmdb="${MISSING[$name]}" log " $name${_tmdb:+ (TMDB: $_tmdb)}" done if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN complete — run without --dry-run to add these movies" 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) QUALITY_ID=$(_radarr_get "qualityprofile" | jq -r '.[0].id' 2>/dev/null) if [[ -z "$RADARR_ROOT" ]]; then error "Could not determine Radarr root folder" exit 1 fi log "Root folder: $RADARR_ROOT | Quality profile: $QUALITY_ID" ADDED=0 FAILED=0 for name in "${SORTED_MISSING[@]}"; do tmdb_id="${MISSING[$name]}" if [[ -n "$tmdb_id" ]]; then LOOKUP=$(_radarr_lookup "tmdb:$tmdb_id") else LOOKUP=$(_radarr_lookup "$name") fi if [[ -z "$LOOKUP" ]] || echo "$LOOKUP" | jq -e '. == [] or . == null' >/dev/null 2>&1; then warn " $ICON_WARN No match in Radarr lookup: $name" (( 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: $name" (( 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" (( ADDED++ )) else warn " $ICON_WARN Failed to add: $name" log " $(echo "$RESULT" | head -c 200)" (( FAILED++ )) fi done # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== echo "" echo "━━━━━ $ICON_SUMMARY SYNC COMPLETE ━━━━━" echo " $ICON_DONE Added: $ADDED" [[ "$FAILED" -gt 0 ]] && echo " $ICON_WARN Failed: $FAILED" echo " $ICON_SKIP Already tracked: $ALREADY" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" [[ "$ADDED" -gt 0 ]] && notify \ "$ADDED movie(s) added to Radarr from Emby library on $(hostname)" \ "Emby → Radarr Sync" "normal" exit 0