#!/bin/bash # assemblrr *arr service configuration — sourced by config.sh # Provides: set_arr_auth, qbit helpers, configure_arr_service, profile lookups, # configure_radarr, configure_sonarr, configure_prowlarr, # apply_selected_indexers, sync_selected_indexers, # prowlarr_sync_gluetun_proxy # Requires: lib/core.sh (logging), lib/api.sh (api_get/post/put/delete helpers), # lib/quality.sh (tier + name lookup), # config.sh (_cfg_log_info, log_step, log_step_fail, AUTH_USERNAME, etc.) set -euo pipefail _arr_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=/dev/null [ -f "$_arr_dir/quality.sh" ] && source "$_arr_dir/quality.sh" # --- Shared auth config for *arr services --- set_arr_auth() { local service_name="$1" local port="$2" local apikey="$3" local api_version="${4:-v3}" if [ -z "$AUTH_USERNAME" ] || [ -z "$AUTH_PASSWORD" ]; then log_step_fail "${service_name}: auth credentials missing — cannot enable forms auth" return 1 fi local host_config host_config=$(api_get "$port" "/api/${api_version}/config/host" "$apikey") if [ -z "$host_config" ]; then log_step_fail "${service_name}: failed to read host config for authentication" return 1 fi local host_id host_id=$(jq_json_get "$host_config" "id") host_id=${host_id:-1} local updated_config updated_config=$(echo "$host_config" | jq --arg hid "$host_id" --arg user "$AUTH_USERNAME" --arg pass "$AUTH_PASSWORD" \ '.id = ($hid | tonumber) | .authenticationMethod = "forms" | .authenticationRequired = "enabled" | .username = $user | .password = $pass | .passwordConfirmation = $pass' 2>/dev/null || echo "") if [ -z "$updated_config" ]; then log_step_fail "${service_name}: failed to build auth config payload" return 1 fi local auth_result auth_result=$(api_put "$port" "/api/${api_version}/config/host" "$apikey" "$updated_config") if jq_json_has_key "$auth_result" "id"; then log_step "${service_name}: set authentication (username: $AUTH_USERNAME)" return 0 fi log_step_fail "${service_name}: failed to set authentication" return 1 } # --- qBittorrent helpers --- # True when this install routes qB through Gluetun. qbit_vpn_enabled() { case "${VPN_ENABLED:-n}" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac } # setPreferences JSON: save paths, Automatic Torrent Management, and tun0 when VPN is on. # ATM makes category save paths real: Radarr → /data/torrents/movies, Sonarr → /data/torrents/tv. # Per-torrent TMM is left alone, so already-downloaded torrents stay where they are. # qB 5.x + WireGuard does not bind the POINTOPOINT tun by itself; "Any" # listens on eth0 and Gluetun's kill switch then drops tracker/DHT packets # ("Operation not permitted" → stalledDL). # Optional $1/$2 = WebUI username/password (jq-escaped). qbit_core_prefs_json() { local user="${1:-}" local pass="${2:-}" local iface="" if qbit_vpn_enabled; then iface="tun0" fi if [ -n "$user" ]; then jq -nc --arg iface "$iface" --arg user "$user" --arg pass "$pass" \ '{save_path:"/data/torrents",temp_path:"/data/torrents/incomplete",temp_path_enabled:true,auto_tmm_enabled:true,current_network_interface:$iface,web_ui_username:$user,web_ui_password:$pass}' else jq -nc --arg iface "$iface" \ '{save_path:"/data/torrents",temp_path:"/data/torrents/incomplete",temp_path_enabled:true,auto_tmm_enabled:true,current_network_interface:$iface}' fi } qbit_login_form() { local cookie_jar="$1" local user="$2" local pass="$3" local qbit_port="${4:-8081}" curl -s --connect-timeout 5 -c "$cookie_jar" \ -H "Referer: http://${API_HOST}:${qbit_port}" \ --data-urlencode "username=${user}" \ --data-urlencode "password=${pass}" \ "http://${API_HOST}:${qbit_port}/api/v2/auth/login" 2>/dev/null >/dev/null || true } qbit_apply_prefs() { local cookie_jar="$1" local user="${2:-}" local pass="${3:-}" local qbit_port=8081 local json json=$(qbit_core_prefs_json "$user" "$pass") curl -s --connect-timeout 5 \ "http://${API_HOST}:${qbit_port}/api/v2/app/setPreferences" \ -b "$cookie_jar" \ -H "Referer: http://${API_HOST}:${qbit_port}" \ --data-urlencode "json=${json}" \ 2>/dev/null >/dev/null || true } # Set qBittorrent WebUI credentials, save paths, and VPN interface. # Prefer final credentials when already configured (avoids brute-force lockouts). qbit_set_credentials() { local qbit_port=8081 local qbit_cookie_jar="/tmp/qb_cookie_jar_init_$$_${qbit_port}" # Idempotent: succeed if final credentials already work if [ -n "$AUTH_USERNAME" ] && [ -n "$AUTH_PASSWORD" ]; then qbit_login_form "$qbit_cookie_jar" "$AUTH_USERNAME" "$AUTH_PASSWORD" "$qbit_port" local qbit_sid qbit_sid=$(qbit_cookie_sid "$qbit_cookie_jar") if [ -n "$qbit_sid" ]; then qbit_apply_prefs "$qbit_cookie_jar" if qbit_vpn_enabled; then _cfg_log_info "qBittorrent credentials already set; BitTorrent bound to tun0" else _cfg_log_info "qBittorrent credentials already set" fi rm -f "$qbit_cookie_jar" return 0 fi fi # First-time setup: temporary password from container logs local wait_time=0 local max_wait=60 while [ $wait_time -lt $max_wait ]; do local qbit_temp_pass qbit_temp_pass=$(docker logs qbittorrent 2>&1 | awk -F': ' '/temporary password is provided for this session:/ { print $NF; exit }' || true) if [ -n "$qbit_temp_pass" ]; then qbit_login_form "$qbit_cookie_jar" "admin" "$qbit_temp_pass" "$qbit_port" local qbit_sid qbit_sid=$(qbit_cookie_sid "$qbit_cookie_jar") if [ -n "$qbit_sid" ]; then qbit_apply_prefs "$qbit_cookie_jar" "$AUTH_USERNAME" "$AUTH_PASSWORD" echo >&2 if qbit_vpn_enabled; then _cfg_log_info "Set qBittorrent credentials, save path, and tun0 bind" else _cfg_log_info "Set qBittorrent credentials and save path" fi rm -f "$qbit_cookie_jar" return 0 fi fi wait_inline "Waiting for qBittorrent to initialize" "$wait_time" sleep 3 wait_time=$((wait_time + 3)) done echo >&2 log_warning "Could not login to qBittorrent with temp password" rm -f "$qbit_cookie_jar" return 1 } qbit_try_login() { local user="$1" local pass="$2" local cookie_jar="$3" local qbit_port=8081 [ -n "$user" ] && [ -n "$pass" ] || return 1 : >"$cookie_jar" qbit_login_form "$cookie_jar" "$user" "$pass" "$qbit_port" [ -n "$(qbit_cookie_sid "$cookie_jar")" ] } # Login with the old password, then set the new WebUI user/pass. # Tries new (already changed) then the first-boot temp password if old fails. qbit_change_credentials() { local old_user="$1" local old_pass="$2" local new_user="$3" local new_pass="$4" local qbit_port=8081 local cookie_jar="/tmp/qb_cookie_jar_chpass_$$_${qbit_port}" local verify_jar="/tmp/qb_cookie_jar_chpass_v_$$_${qbit_port}" if [ -z "$new_user" ] || [ -z "$new_pass" ]; then log_step_fail "qBittorrent: new credentials missing" return 1 fi if ! qbit_try_login "$old_user" "$old_pass" "$cookie_jar"; then if qbit_try_login "$new_user" "$new_pass" "$cookie_jar"; then qbit_apply_prefs "$cookie_jar" rm -f "$cookie_jar" log_step "qBittorrent: already using the new password" return 0 fi local qbit_temp_pass qbit_temp_pass=$(docker logs qbittorrent 2>&1 | awk -F': ' '/temporary password is provided for this session:/ { print $NF; exit }' || true) if [ -z "$qbit_temp_pass" ] || ! qbit_try_login "admin" "$qbit_temp_pass" "$cookie_jar"; then rm -f "$cookie_jar" log_step_fail "qBittorrent: could not log in with the old password" return 1 fi fi qbit_apply_prefs "$cookie_jar" "$new_user" "$new_pass" rm -f "$cookie_jar" if qbit_try_login "$new_user" "$new_pass" "$verify_jar"; then rm -f "$verify_jar" log_step "qBittorrent: set login (${new_user})" return 0 fi rm -f "$verify_jar" log_step_fail "qBittorrent: password change did not stick" return 1 } # Create or update a qBittorrent category with a save path qbit_create_category() { local service_name="$1" local category="$2" local save_path="$3" local qbit_port=8081 local qbit_cookie_jar="/tmp/qb_cookie_jar_${service_name}_$$_${qbit_port}" qbit_login_form "$qbit_cookie_jar" "$AUTH_USERNAME" "$AUTH_PASSWORD" "$qbit_port" local qbit_sid qbit_sid=$(qbit_cookie_sid "$qbit_cookie_jar") if [ -z "$qbit_sid" ]; then log_step_fail "${service_name}: could not login to qBittorrent to create categories" rm -f "$qbit_cookie_jar" return 1 fi # createCategory + editCategory so savePath is set whether the category is new or existing curl -s --connect-timeout 10 \ "http://${API_HOST}:${qbit_port}/api/v2/torrents/createCategory" \ -b "$qbit_cookie_jar" \ -H "Referer: http://${API_HOST}:${qbit_port}" \ -d "category=${category}&savePath=${save_path}" \ 2>/dev/null >/dev/null || true curl -s --connect-timeout 10 \ "http://${API_HOST}:${qbit_port}/api/v2/torrents/editCategory" \ -b "$qbit_cookie_jar" \ -H "Referer: http://${API_HOST}:${qbit_port}" \ -d "category=${category}&savePath=${save_path}" \ 2>/dev/null >/dev/null || true log_step "${service_name}: qBittorrent '${category}' category → ${save_path}" rm -f "$qbit_cookie_jar" return 0 } # Point an existing *arr qBittorrent client at the current host # (gluetun when VPN is on, qbittorrent when off). arr_update_qb_host() { local service_name="$1" local port="$2" local apikey="$3" local host="${QBITTORRENT_HOST:-qbittorrent}" local clients dc_id existing updated result [ -n "$apikey" ] || return 1 clients=$(api_get "$port" "/api/v3/downloadclient" "$apikey") dc_id=$(echo "$clients" | jq -r \ '[.[] | select(.implementationName == "qBittorrent" or .implementation == "QBittorrent")][0].id // empty' \ 2>/dev/null || true) [ -n "$dc_id" ] || return 1 existing=$(echo "$clients" | jq --argjson id "$dc_id" '.[] | select(.id == $id)' 2>/dev/null || true) [ -n "$existing" ] || return 1 updated=$(echo "$existing" | jq --arg host "$host" \ '.fields = [.fields[] | if .name == "host" then .value = $host else . end]' \ 2>/dev/null || true) [ -n "$updated" ] || return 1 result=$(api_put "$port" "/api/v3/downloadclient/${dc_id}" "$apikey" "$updated") if jq_json_has_key "$result" "id"; then log_step "${service_name}: qBittorrent host → ${host}" return 0 fi return 1 } # --- Shared *arr service configuration --- # Configure a Radarr or Sonarr instance with common settings # Parameters: # $1 service_name (e.g. "Radarr" or "Sonarr") # $2 port (e.g. "7878" or "8989") # $3 apikey # $4 root_path (e.g. "/data/media/movies" or "/data/media/tv") # $5 category_name (e.g. "movies" or "tv") # $6 category_path (e.g. "/data/torrents/movies" or "/data/torrents/tv") # $7 unmonitor_field (e.g. "autoUnmonitorPreviouslyDownloadedMovies" or "autoUnmonitorPreviouslyDownloadedEpisodes") # $8 naming_jq (jq expression for naming convention, or "" to skip) configure_arr_service() { local service_name="$1" local port="$2" local apikey="$3" local root_path="$4" local category_name="$5" local category_path="$6" local unmonitor_field="$7" local naming_jq="$8" local critical_errors=0 # 1. Add root folder local root_folders root_folders=$(api_get "$port" "/api/v3/rootfolder" "$apikey") local existing_paths existing_paths=$(jq_json_list_values "$root_folders" "path") if ! echo "$existing_paths" | grep -q "^${root_path}$"; then local rf_result rf_result=$(api_post "$port" "/api/v3/rootfolder" "$apikey" "{\"path\":\"${root_path}\"}") if jq_json_has_key "$rf_result" "id"; then log_step "${service_name}: added ${root_path} as root folder" else log_step_fail "${service_name}: failed to add root folder" critical_errors=$((critical_errors + 1)) fi else log_step "${service_name}: root folder ${root_path} already exists" fi # Keep only the configured root folder local all_root_folders all_root_folders=$(api_get "$port" "/api/v3/rootfolder" "$apikey") local extra_ids extra_ids=$(echo "$all_root_folders" | jq -r --arg rp "$root_path" '.[] | select(.path != $rp) | .id' 2>/dev/null || echo "") for extra_id in $extra_ids; do if [ -n "$extra_id" ]; then api_delete "$port" "/api/v3/rootfolder/${extra_id}" "$apikey" >/dev/null || true log_step "${service_name}: removed extra root folder (id: ${extra_id})" fi done # 2. Add qBittorrent download client local download_clients download_clients=$(api_get "$port" "/api/v3/downloadclient" "$apikey") local existing_impls existing_impls=$(jq_json_list_values "$download_clients" "implementationName") # Build download client payload — category field names differ between Radarr and Sonarr local qbit_payload qbit_payload=$(jq -n --arg host "$QBITTORRENT_HOST" --arg user "$AUTH_USERNAME" --arg pass "$AUTH_PASSWORD" \ --arg cat_name "$category_name" --arg sn "$service_name" ' { "enable": true, "removeCompletedDownloads": true, "removeFailedDownloads": true, "name": "qBittorrent", "implementation": "QBittorrent", "implementationName": "qBittorrent", "configContract": "QBittorrentSettings", "priority": 1, "fields": [ {"name": "host", "value": $host}, {"name": "port", "value": 8081}, {"name": "useSsl", "value": false}, {"name": "urlBase", "value": ""}, {"name": "username", "value": $user}, {"name": "password", "value": $pass}, {"name": (if $sn == "Radarr" then "movieCategory" else "tvCategory" end), "value": $cat_name}, {"name": (if $sn == "Radarr" then "movieImportedCategory" else "tvImportedCategory" end), "value": ""}, {"name": (if $sn == "Radarr" then "recentMoviePriority" else "recentTvPriority" end), "value": 0}, {"name": (if $sn == "Radarr" then "olderMoviePriority" else "olderTvPriority" end), "value": 0}, {"name": "initialState", "value": 0}, {"name": "sequentialOrder", "value": false}, {"name": "firstAndLast", "value": false}, {"name": "contentLayout", "value": 0} ] } ' 2>/dev/null || echo "") if ! echo "$existing_impls" | grep -q "qBittorrent"; then if [ -n "$qbit_payload" ]; then local dc_result dc_result=$(api_post "$port" "/api/v3/downloadclient" "$apikey" "$qbit_payload") if jq_json_has_key "$dc_result" "id"; then log_step "${service_name}: added qBittorrent as download client (host: ${QBITTORRENT_HOST})" else log_step_fail "${service_name}: failed to add qBittorrent download client" critical_errors=$((critical_errors + 1)) fi else log_step_fail "${service_name}: failed to build qBittorrent payload" critical_errors=$((critical_errors + 1)) fi else local existing_dc dc_id updated_dc dc_put existing_dc=$(echo "$download_clients" | jq '[.[] | select(.implementationName == "qBittorrent" or .implementation == "QBittorrent")][0]' 2>/dev/null || echo "") dc_id=$(echo "$existing_dc" | jq -r '.id // empty' 2>/dev/null || echo "") if [ -n "$dc_id" ] && [ -n "$qbit_payload" ]; then updated_dc=$(echo "$qbit_payload" | jq --argjson id "$dc_id" '.id = $id' 2>/dev/null || echo "") if [ -n "$updated_dc" ]; then dc_put=$(api_put "$port" "/api/v3/downloadclient/${dc_id}" "$apikey" "$updated_dc") if jq_json_has_key "$dc_put" "id"; then log_step "${service_name}: updated qBittorrent download client (host: ${QBITTORRENT_HOST})" else log_step "${service_name}: qBittorrent download client already exists" fi else log_step "${service_name}: qBittorrent download client already exists" fi else log_step "${service_name}: qBittorrent download client already exists" fi fi # 2b. Create qBittorrent category with correct save path if ! qbit_create_category "$service_name" "$category_name" "$category_path"; then critical_errors=$((critical_errors + 1)) fi # 2c. Enable hardlinks in Media Management (recycle bin disabled for instant space recovery) local mediamgmt mediamgmt=$(api_get "$port" "/api/v3/config/mediamanagement" "$apikey") if [ -n "$mediamgmt" ]; then local mgmt_payload mgmt_payload=$(echo "$mediamgmt" | jq --arg uf "$unmonitor_field" \ '.copyUsingHardlinks = true | .enableMediaManagement = true | .[$uf] = true | .recycleBin = "" | .recycleBinCleanupDays = 0' 2>/dev/null || echo "") if [ -n "$mgmt_payload" ]; then local mgmt_result mgmt_result=$(api_put "$port" "/api/v3/config/mediamanagement" "$apikey" "$mgmt_payload") if jq_json_has_key "$mgmt_result" "id"; then log_step "${service_name}: enabled hardlinks (recycle bin disabled)" else log_step_fail "${service_name}: failed to enable hardlinks" fi fi fi # 3. Set naming convention if [ -n "$naming_jq" ]; then local naming naming=$(api_get "$port" "/api/v3/config/naming" "$apikey") if [ -n "$naming" ]; then local naming_payload naming_payload=$(echo "$naming" | jq "$naming_jq" 2>/dev/null || echo "") if [ -n "$naming_payload" ]; then local naming_result naming_result=$(api_put "$port" "/api/v3/config/naming" "$apikey" "$naming_payload") if jq_json_has_key "$naming_result" "id"; then log_step "${service_name}: set naming convention" else log_step_fail "${service_name}: failed to set naming convention" fi else log_step_fail "${service_name}: failed to build naming config payload" fi fi fi # 4. Set authentication if ! set_arr_auth "$service_name" "$port" "$apikey"; then critical_errors=$((critical_errors + 1)) fi [ "$critical_errors" -eq 0 ] } # --- Quality sizes & default profiles --- # Set minSize=0 on every quality definition (no minimum file-size filter). # Values live in the *arr DB; they only change when written via this API. relax_quality_sizes() { local service_name="$1" local port="$2" local apikey="$3" local defs defs=$(api_get "$port" "/api/v3/qualitydefinition" "$apikey") if [ -z "$defs" ] || [ "$defs" = "[]" ]; then log_step_fail "${service_name}: no quality definitions to update" return 1 fi # Only minSize is cleared; leave max/preferred alone (bulk update is picky about nulls). local payload payload=$(echo "$defs" | jq '[.[] | .minSize = 0]' 2>/dev/null || echo "") if [ -z "$payload" ]; then log_step_fail "${service_name}: failed to build quality definition payload" return 1 fi local result result=$(api_put "$port" "/api/v3/qualitydefinition/update" "$apikey" "$payload") if echo "$result" | jq -e 'type == "array"' >/dev/null 2>&1; then log_step "${service_name}: quality minSize set to 0" return 0 fi # Fallback: one PUT per definition local ok=0 total=0 while IFS= read -r row; do [ -z "$row" ] && continue total=$((total + 1)) local id id=$(echo "$row" | jq -r '.id // empty') [ -z "$id" ] && continue if api_put "$port" "/api/v3/qualitydefinition/${id}" "$apikey" "$row" >/dev/null 2>&1; then ok=$((ok + 1)) fi done < <(echo "$payload" | jq -c '.[]' 2>/dev/null || true) if [ "$ok" -gt 0 ]; then log_step "${service_name}: quality minSize set to 0 (${ok}/${total})" return 0 fi log_step_fail "${service_name}: failed to update quality definitions" return 1 } # Item ids whose qualityProfileId still needs updating. # stdin: JSON array of {id, qualityProfileId}. $1 = target id. $2 = all | any-only | from: arr_quality_ids_needing_update() { local pid="$1" local mode="${2:-all}" case "$mode" in any-only) jq -c --argjson pid "$pid" '[.[] | select((.qualityProfileId // 0) == 1 and .id != null) | .id]' ;; from:*) local from="${mode#from:}" jq -c --argjson old "$from" '[.[] | select((.qualityProfileId // 0) == $old and .id != null) | .id]' ;; *) jq -c --argjson pid "$pid" '[.[] | select((.qualityProfileId // 0) != $pid and .id != null) | .id]' ;; esac } # stdin: source quality-profile object. $1 = dest id. $2 = dest name. arr_quality_profile_clone_json() { local dest_id="$1" local dest_name="$2" jq --argjson id "$dest_id" --arg name "$dest_name" '.id = $id | .name = $name' } # stdin: one root-folder object. $1 = target quality profile id. arr_rootfolder_with_default_profile() { local pid="$1" jq --argjson pid "$pid" 'del(.unmappedFolders) | .defaultQualityProfileId = $pid' } _arr_put_http() { local port="$1" local endpoint="$2" local apikey="$3" local data="$4" curl -s -o /tmp/arr-put-body -w "%{http_code}" --connect-timeout 8 -X PUT \ -H "Content-Type: application/json" \ -d "$data" \ "http://${API_HOST}:${port}${endpoint}?apikey=${apikey}" 2>/dev/null || echo "000" } arr_set_root_folder_quality_profile() { local service_name="$1" local port="$2" local apikey="$3" local profile_id="$4" local folders folder payload code ok=0 folders=$(api_get "$port" "/api/v3/rootfolder" "$apikey") if [ -z "$folders" ] || [ "$folders" = "[]" ]; then return 1 fi while IFS= read -r folder; do [ -z "$folder" ] && continue local fid fid=$(echo "$folder" | jq -r '.id // empty') [ -n "$fid" ] || continue payload=$(echo "$folder" | arr_rootfolder_with_default_profile "$profile_id") [ -n "$payload" ] || continue code=$(_arr_put_http "$port" "/api/v3/rootfolder/${fid}" "$apikey" "$payload") if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then ok=$((ok + 1)) fi done < <(echo "$folders" | jq -c '.[]' 2>/dev/null || true) [ "$ok" -gt 0 ] } # $5 = movie | series. $6 = all | any-only (default all). arr_set_items_quality_profile() { local service_name="$1" local port="$2" local apikey="$3" local profile_id="$4" local kind="$5" local mode="${6:-all}" local list_path editor_path id_key local items ids payload code if [ "$kind" = "series" ]; then list_path="/api/v3/series" editor_path="/api/v3/series/editor" id_key="seriesIds" else list_path="/api/v3/movie" editor_path="/api/v3/movie/editor" id_key="movieIds" fi items=$(api_get "$port" "$list_path" "$apikey") if [ -z "$items" ] || [ "$items" = "[]" ]; then return 0 fi ids=$(echo "$items" | arr_quality_ids_needing_update "$profile_id" "$mode") if [ -z "$ids" ] || [ "$ids" = "[]" ]; then return 0 fi payload=$(jq -nc --argjson ids "$ids" --argjson pid "$profile_id" --arg key "$id_key" \ '{($key): $ids, qualityProfileId: $pid}') code=$(_arr_put_http "$port" "$editor_path" "$apikey" "$payload") if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then local n n=$(echo "$ids" | jq 'length' 2>/dev/null || echo 0) log_step "${service_name}: moved ${n} title(s) → quality profile ${profile_id}" return 0 fi return 1 } arr_set_importlist_quality_profile() { local service_name="$1" local port="$2" local apikey="$3" local profile_id="$4" local lists list payload code ok=0 total=0 lists=$(api_get "$port" "/api/v3/importlist" "$apikey") if [ -z "$lists" ] || [ "$lists" = "[]" ]; then return 0 fi while IFS= read -r list; do [ -z "$list" ] && continue local lid cur lid=$(echo "$list" | jq -r '.id // empty') cur=$(echo "$list" | jq -r '.qualityProfileId // empty') [ -n "$lid" ] || continue total=$((total + 1)) if [ "$cur" = "$profile_id" ]; then ok=$((ok + 1)) continue fi payload=$(echo "$list" | jq --argjson pid "$profile_id" '.qualityProfileId = $pid') code=$(_arr_put_http "$port" "/api/v3/importlist/${lid}" "$apikey" "$payload") if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then ok=$((ok + 1)) fi done < <(echo "$lists" | jq -c '.[]' 2>/dev/null || true) [ "$total" -eq 0 ] || [ "$ok" -gt 0 ] } # *arr Add New defaults to quality profile id 1 (stock "Any"). There is no # working "default profile" API on Sonarr (root-folder PUT is a no-op). # Copy the assemblrr profile onto id 1 and drop the duplicate so the picker # opens on the assemblrr profile. arr_promote_quality_profile() { local service_name="$1" local port="$2" local apikey="$3" local src_id="$4" local src_name="$5" [ -n "$src_id" ] && [ -n "$src_name" ] || return 1 if [ "$src_id" = "1" ]; then return 0 fi # Recyclarr variants share a trash_id. Overwriting id 1 with a sibling # makes Recyclarr treat that as a rename and the other profile vanishes. local id1_name id1_name=$(api_get "$port" "/api/v3/qualityprofile/1" "$apikey" | jq -r '.name // empty') if [ "$id1_name" = "$src_name" ]; then return 0 fi local src dest tmp_name tmp_payload code src=$(api_get "$port" "/api/v3/qualityprofile/${src_id}" "$apikey") if [ -z "$src" ] || ! echo "$src" | jq -e '.id' >/dev/null 2>&1; then return 1 fi tmp_name="${src_name}.__assemblrr_tmp" tmp_payload=$(echo "$src" | jq --arg n "$tmp_name" '.name = $n') code=$(_arr_put_http "$port" "/api/v3/qualityprofile/${src_id}" "$apikey" "$tmp_payload") if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then return 1 fi dest=$(echo "$src" | arr_quality_profile_clone_json 1 "$src_name") code=$(_arr_put_http "$port" "/api/v3/qualityprofile/1" "$apikey" "$dest") if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then _arr_put_http "$port" "/api/v3/qualityprofile/${src_id}" "$apikey" "$src" >/dev/null || true return 1 fi local kind="movie" [ "$service_name" = "Sonarr" ] && kind="series" arr_set_items_quality_profile "$service_name" "$port" "$apikey" "1" "$kind" "from:${src_id}" || true arr_set_importlist_quality_profile "$service_name" "$port" "$apikey" "1" || true local del del=$(api_delete "$port" "/api/v3/qualityprofile/${src_id}" "$apikey" || echo "000") if [ "$del" -ge 200 ] && [ "$del" -lt 300 ]; then log_step "${service_name}: Add New default is ${src_name}" return 0 fi log_step "${service_name}: Add New default is ${src_name} (left extra profile id ${src_id})" return 0 } # Remove superseded assemblrr profile names left over from previous versions. clean_legacy_arr_quality_profiles() { local service_name="$1" local port="$2" local apikey="$3" local profiles profiles=$(api_get "$port" "/api/v3/qualityprofile" "$apikey") [ -n "$profiles" ] || return 0 local legacy_names=( "assemblrr HD Bluray + WEB" "assemblrr UHD Bluray + WEB" "assemblrr WEB-1080p" "assemblrr WEB-2160p" ) for lname in "${legacy_names[@]}"; do local lid lid=$(echo "$profiles" | jq -r --arg n "$lname" '.[] | select(.name == $n) | .id // empty' 2>/dev/null || true) if [ -n "$lid" ] && [ "$lid" != "1" ]; then api_delete "$port" "/api/v3/qualityprofile/${lid}" "$apikey" >/dev/null 2>&1 || true fi done } # Apply the install's chosen quality profile as the *arr Add New default. # Existing titles are left alone. Seerr uses the same lookup. set_default_quality_profile() { local service_name="$1" local port="$2" local apikey="$3" local profile_func="$4" local profile profile_id profile_name profile=$($profile_func "$apikey") profile_id="${profile%%:*}" profile_name="${profile##*:}" if [ -z "$profile_id" ]; then log_step_fail "${service_name}: no quality profile to apply" return 1 fi if quality_wants_named_profile && [ "$profile_id" = "1" ] && [ "$profile_name" = "Any" ]; then log_step_fail "${service_name}: assemblrr quality profile not found (Recyclarr sync missing?)" return 1 fi if arr_promote_quality_profile "$service_name" "$port" "$apikey" "$profile_id" "$profile_name"; then profile_id="1" fi clean_legacy_arr_quality_profiles "$service_name" "$port" "$apikey" if [ -n "${INSTALL_DIR:-}" ]; then mkdir -p "$INSTALL_DIR/config" echo "${profile_id}:${profile_name}" > "$INSTALL_DIR/config/.${service_name,,}-default-quality-profile" 2>/dev/null || true fi # Best-effort: some Radarr builds honor this. Sonarr has no such field. arr_set_root_folder_quality_profile "$service_name" "$port" "$apikey" "$profile_id" || true arr_set_importlist_quality_profile "$service_name" "$port" "$apikey" "$profile_id" || true log_step "${service_name}: default quality profile → ${profile_name} (id ${profile_id})" } # --- Quality profile lookup --- # $1 = api key, $2 = port, $3 = radarr | sonarr lookup_arr_quality_profile() { local apikey="$1" local port="$2" local kind="$3" local profiles_json spec profiles_json=$(api_get "$port" "/api/v3/qualityprofile" "$apikey") if [ -z "$profiles_json" ]; then echo "1:Any" return fi spec=$(quality_profile_names "$kind") echo "$profiles_json" | arr_match_quality_profile "${spec%%|*}" "${spec##*|}" } lookup_radarr_profile() { lookup_arr_quality_profile "$1" 7878 radarr } lookup_sonarr_profile() { lookup_arr_quality_profile "$1" 8989 sonarr } # --- Radarr configuration --- configure_radarr() { local apikey="$1" configure_arr_service \ "Radarr" "7878" "$apikey" \ "/data/media/movies" "movies" "/data/torrents/movies" \ "autoUnmonitorPreviouslyDownloadedMovies" \ '.renameMovies = true | .standardMovieFormat = "{Movie CleanTitle} ({Release Year}) {Quality Full}" | .movieFolderFormat = "{Movie CleanTitle} ({Release Year})"' } # --- Prowlarr configuration --- # Set minimumSeeders=0 on Prowlarr app profiles (call once before connecting apps). prowlarr_set_app_profile_min_seeders() { local prowlarr_key="$1" local profiles profiles=$(api_get "9696" "/api/v1/appprofile" "$prowlarr_key") if [ -z "$profiles" ] || [ "$profiles" = "[]" ]; then return 1 fi local updated=0 local profile_id profile_payload while IFS= read -r row; do [ -z "$row" ] && continue profile_id=$(echo "$row" | jq -r '.id // empty') [ -z "$profile_id" ] && continue profile_payload=$(echo "$row" | jq '.minimumSeeders = 0' 2>/dev/null || echo "") [ -z "$profile_payload" ] && continue if api_put "9696" "/api/v1/appprofile/${profile_id}" "$prowlarr_key" "$profile_payload" >/dev/null 2>&1; then updated=$((updated + 1)) fi done < <(echo "$profiles" | jq -c '.[]' 2>/dev/null || true) if [ "$updated" -gt 0 ]; then log_step "Prowlarr: app profile minimumSeeders → 0" return 0 fi return 1 } # Indexer default: minSeeders=0 only. Seed ratio/time are left as the user set them. arr_set_indexer_min_seeders() { local service_name="$1" local port="$2" local apikey="$3" [ -z "$apikey" ] && return 1 local indexers indexers=$(api_get "$port" "/api/v3/indexer" "$apikey") if [ -z "$indexers" ] || [ "$indexers" = "[]" ]; then return 1 fi local ok=0 local idx_id idx_payload result while IFS= read -r idx; do [ -z "$idx" ] && continue idx_id=$(echo "$idx" | jq -r '.id // empty') [ -z "$idx_id" ] && continue idx_payload=$(echo "$idx" | jq ' .fields = [.fields[] | if .name == "minimumSeeders" then .value = 0 else . end ] ' 2>/dev/null || echo "") [ -z "$idx_payload" ] && continue result=$(api_put "$port" "/api/v3/indexer/${idx_id}" "$apikey" "$idx_payload") if echo "$result" | jq -e '.id' >/dev/null 2>&1; then ok=$((ok + 1)) fi done < <(echo "$indexers" | jq -c '.[]' 2>/dev/null || true) if [ "$ok" -gt 0 ]; then log_step "${service_name}: indexer minSeeders=0 (${ok})" return 0 fi return 1 } # On movie/series delete, run arr-purge-hook.sh (qB torrent + files). add_arr_purge_hook() { local app_name="$1" local port="$2" local apikey="$3" [ -z "$apikey" ] && return 0 local notifications existing_id notifications=$(api_get "$port" "/api/v3/notification" "$apikey") existing_id=$(echo "$notifications" | jq -r \ '.[] | select(.implementation == "CustomScript" and .name == "assemblrr Media Purge") | .id // ""' \ 2>/dev/null | head -1 || echo "") if [ -f "${INSTALL_DIR:-}/scripts/arr-purge-hook.sh" ]; then chmod +x "${INSTALL_DIR}/scripts/arr-purge-hook.sh" 2>/dev/null || true fi if [ -f "${INSTALL_DIR:-}/scripts/media-purge.sh" ]; then chmod +x "${INSTALL_DIR}/scripts/media-purge.sh" 2>/dev/null || true fi local schema payload result schema=$(api_get "$port" "/api/v3/notification/schema" "$apikey") if [ "$app_name" = "Radarr" ]; then payload=$(echo "$schema" | jq ' [.[] | select(.implementation == "CustomScript")][0] | .fields = [.fields[] | if .name == "path" then .value = "/scripts/arr-purge-hook.sh" else . end] | .onGrab = false | .onDownload = false | .onUpgrade = false | .onRename = false | .onMovieAdded = false | .onMovieDelete = true | .onMovieFileDelete = false | .onMovieFileDeleteForUpgrade = false | .onHealthIssue = false | .onHealthRestored = false | .onApplicationUpdate = false | .onManualInteractionRequired = false | .includeHealthWarnings = false | .name = "assemblrr Media Purge" | .tags = [] ' 2>/dev/null || echo "") else payload=$(echo "$schema" | jq ' [.[] | select(.implementation == "CustomScript")][0] | .fields = [.fields[] | if .name == "path" then .value = "/scripts/arr-purge-hook.sh" else . end] | .onGrab = false | .onDownload = false | .onUpgrade = false | .onRename = false | .onSeriesAdd = false | .onSeriesDelete = true | .onEpisodeFileDelete = false | .onEpisodeFileDeleteForUpgrade = false | .onHealthIssue = false | .onHealthRestored = false | .onApplicationUpdate = false | .onManualInteractionRequired = false | .includeHealthWarnings = false | .name = "assemblrr Media Purge" | .tags = [] ' 2>/dev/null || echo "") fi if [ -z "$payload" ]; then log_step_fail "${app_name}: CustomScript schema not found for media purge" return 1 fi if [ -n "$existing_id" ]; then payload=$(echo "$payload" | jq --argjson id "$existing_id" '.id = $id' 2>/dev/null || echo "$payload") result=$(api_put "$port" "/api/v3/notification/${existing_id}" "$apikey" "$payload") if jq_json_has_key "$result" "id"; then log_step "${app_name}: updated Media Purge hook" return 0 fi fi result=$(api_post_force "$port" "/api/v3/notification" "$apikey" "$payload") if jq_json_has_key "$result" "id"; then log_step "${app_name}: added Media Purge hook" return 0 fi local err_msg err_msg=$(echo "$result" | jq -r 'if type == "array" then .[0].errorMessage // empty else .errorMessage // empty end' 2>/dev/null || echo "") if [ -n "$err_msg" ]; then log_step_fail "${app_name}: failed to add Media Purge hook (${err_msg})" else log_step_fail "${app_name}: failed to add Media Purge hook" fi return 1 } # Wait until Radarr has at least one indexer from Prowlarr fullSync (max ~30s). # Sonarr is not a gate — movie-only indexers never appear there. wait_for_arr_indexer_sync() { local radarr_key="${1:-}" local waited=0 [ -z "$radarr_key" ] && return 0 while [ $waited -lt 30 ]; do local rc rc=$(api_get "7878" "/api/v3/indexer" "$radarr_key" | jq 'length' 2>/dev/null || echo 0) if [ "${rc:-0}" -gt 0 ]; then [ "$waited" -gt 0 ] && echo >&2 return 0 fi wait_inline "Waiting for Radarr indexers from Prowlarr" "$waited" sleep 2 waited=$((waited + 2)) done echo >&2 return 1 } # Route Prowlarr indexer HTTP through Gluetun when VPN is on; remove it when off. # Empty tags = every indexer. Does not wrap Radarr/Sonarr app sync (LAN hostnames). # Manages only the proxy named below (or an existing Http proxy already on gluetun:8888). PROWLARR_GLUETUN_PROXY_NAME="assemblrr Gluetun" PROWLARR_GLUETUN_PROXY_HOST="gluetun" PROWLARR_GLUETUN_PROXY_PORT="8888" # $2 = "name" (managed only) or "any" (name or host:port — avoid duplicates) _prowlarr_gluetun_proxy_id() { local proxies="$1" local mode="${2:-any}" echo "$proxies" | jq -r \ --arg name "$PROWLARR_GLUETUN_PROXY_NAME" \ --arg host "$PROWLARR_GLUETUN_PROXY_HOST" \ --argjson port "$PROWLARR_GLUETUN_PROXY_PORT" \ --arg mode "$mode" ' .[] | select( .name == $name or ( $mode == "any" and .implementation == "Http" and (([.fields[]? | select(.name == "host") | .value][0] // "") | tostring) == $host and (([.fields[]? | select(.name == "port") | .value][0] // 0) | tonumber) == $port ) ) | .id ' 2>/dev/null | head -1 } _prowlarr_gluetun_proxy_payload() { local schema="$1" echo "$schema" | jq -c \ --arg name "$PROWLARR_GLUETUN_PROXY_NAME" \ --arg host "$PROWLARR_GLUETUN_PROXY_HOST" \ --argjson port "$PROWLARR_GLUETUN_PROXY_PORT" ' [.[] | select(.implementation == "Http")][0] | .fields = [.fields[] | if .name == "host" then .value = $host elif .name == "port" then .value = $port elif .name == "username" then .value = "" elif .name == "password" then .value = "" else . end] | .name = $name | .tags = [] ' 2>/dev/null || echo "" } prowlarr_sync_gluetun_proxy() { local apikey="$1" local proxies existing_id if [ -z "$apikey" ]; then log_step_fail "Prowlarr: no API key — cannot send searches through the VPN" return 1 fi proxies=$(api_get "9696" "/api/v1/indexerProxy" "$apikey") if [ -z "$proxies" ]; then log_step_fail "Prowlarr: could not list search proxies" return 1 fi if ! qbit_vpn_enabled; then existing_id=$(_prowlarr_gluetun_proxy_id "$proxies" "name") if [ -z "$existing_id" ]; then return 0 fi local code code=$(api_delete "9696" "/api/v1/indexerProxy/${existing_id}" "$apikey") if [ "$code" = "200" ] || [ "$code" = "204" ]; then log_step "Prowlarr: searches no longer go through the VPN" return 0 fi log_step_fail "Prowlarr: could not stop sending searches through the VPN (HTTP ${code:-?})" return 1 fi existing_id=$(_prowlarr_gluetun_proxy_id "$proxies" "any") local schema payload result schema=$(api_get "9696" "/api/v1/indexerProxy/schema" "$apikey") payload=$(_prowlarr_gluetun_proxy_payload "$schema") if [ -z "$payload" ]; then log_step_fail "Prowlarr: could not set up VPN search routing" return 1 fi if [ -n "$existing_id" ]; then payload=$(echo "$payload" | jq -c --argjson id "$existing_id" '.id = $id' 2>/dev/null || echo "$payload") result=$(curl -s --connect-timeout 5 -X PUT \ -H "Content-Type: application/json" \ -d "$payload" \ "http://${API_HOST}:9696/api/v1/indexerProxy/${existing_id}?apikey=${apikey}&forceSave=true" 2>/dev/null) if jq_json_has_key "$result" "id"; then log_step "Prowlarr: searches go through the VPN" return 0 fi log_step_fail "Prowlarr: could not send searches through the VPN" return 1 fi result=$(curl -s --connect-timeout 5 -X POST \ -H "Content-Type: application/json" \ -d "$payload" \ "http://${API_HOST}:9696/api/v1/indexerProxy?apikey=${apikey}&forceSave=true" 2>/dev/null) if jq_json_has_key "$result" "id"; then log_step "Prowlarr: searches go through the VPN" return 0 fi log_step_fail "Prowlarr: could not send searches through the VPN" return 1 } # POST indexers named in SELECTED_INDEXERS into Prowlarr (skip existing). # Used by first-run wiring and `assemblrr config edit`. apply_selected_indexers() { local apikey="$1" local indexer_schemas indexer_schemas=$(api_get "9696" "/api/v1/indexer/schema" "$apikey") if [ -z "$indexer_schemas" ]; then log_step_fail "Prowlarr: failed to fetch indexer schemas" return 1 fi local selected_indexers=() if [ -n "${SELECTED_INDEXERS+x}" ] && [ "${#SELECTED_INDEXERS[@]}" -gt 0 ]; then selected_indexers=("${SELECTED_INDEXERS[@]}") echo "Applying ${#selected_indexers[@]} selected Prowlarr indexer(s)" >&2 fi local existing_indexers existing_indexers=$(api_get "9696" "/api/v1/indexer" "$apikey") local existing_names existing_names=$(jq_json_list_values "$existing_indexers" "name") local indexer_name for indexer_name in "${selected_indexers[@]+"${selected_indexers[@]}"}"; do if echo "$existing_names" | grep -q -F -x "$indexer_name"; then log_step "Prowlarr: ${indexer_name} indexer already exists" continue fi # Cardigann indexers match on 'name'. Empty field values use definition defaults. local indexer_payload indexer_payload=$(echo "$indexer_schemas" | jq --arg idx "$indexer_name" ' [.[] | select(.name == $idx or (.name | ascii_downcase | startswith($idx | ascii_downcase)))][0] | .fields = [.fields[] | select(.value != "") | {name: .name, value: .value}] | .enable = true | .enableAutoSearch = true | .appProfileId = 1 | .priority = (.priority // 25) ' 2>/dev/null || echo "") if [ -z "$indexer_payload" ]; then log_step_fail "Prowlarr: ${indexer_name} schema not found" continue fi local idx_result idx_result=$(curl -s --connect-timeout 5 -X POST \ -H "Content-Type: application/json" \ -d "$indexer_payload" \ "http://${API_HOST}:9696/api/v1/indexer?apikey=${apikey}&forceSave=true" 2>/dev/null) if jq_json_has_key "$idx_result" "id"; then log_step "Prowlarr: added ${indexer_name} indexer" else local err_msg disabled_payload err_msg=$(echo "$idx_result" | jq -r 'if type == "array" then .[0].errorMessage // empty else .errorMessage // empty end' 2>/dev/null || echo "") disabled_payload=$(echo "$indexer_payload" | jq -c '.enable = false' 2>/dev/null || echo "") idx_result=$(curl -s --connect-timeout 5 -X POST \ -H "Content-Type: application/json" \ -d "$disabled_payload" \ "http://${API_HOST}:9696/api/v1/indexer?apikey=${apikey}&forceSave=true" 2>/dev/null) if jq_json_has_key "$idx_result" "id"; then log_step "Prowlarr: added ${indexer_name} indexer, but could not enable it" [ -n "$err_msg" ] && log_step "Prowlarr: ${err_msg}" else log_step_fail "Prowlarr: failed to add ${indexer_name} indexer" fi fi done if [ "${#selected_indexers[@]}" -gt 0 ]; then echo >&2 log_success "Prowlarr indexer configuration finished (check markers above for any failures)" >&2 fi return 0 } # Make Prowlarr match SELECTED_INDEXERS: add missing, delete unselected. # Esc/empty selection is a no-op (caller should skip). Confirm = desired set. sync_selected_indexers() { local apikey="$1" apply_selected_indexers "$apikey" || return 1 local existing_indexers existing_indexers=$(api_get "9696" "/api/v1/indexer" "$apikey") if [ -z "$existing_indexers" ] || [ "$existing_indexers" = "[]" ]; then return 0 fi local id name while IFS=$'\t' read -r id name; do [ -n "$id" ] && [ -n "$name" ] || continue if printf '%s\n' "${SELECTED_INDEXERS[@]+"${SELECTED_INDEXERS[@]}"}" | grep -q -F -x "$name"; then continue fi local code code=$(api_delete "9696" "/api/v1/indexer/${id}" "$apikey") if [ "$code" = "200" ] || [ "$code" = "204" ]; then log_step "Prowlarr: removed ${name} indexer" else log_step_fail "Prowlarr: failed to remove ${name} indexer (HTTP ${code:-?})" fi done < <(echo "$existing_indexers" | jq -r '.[] | "\(.id)\t\(.name)"' 2>/dev/null) return 0 } configure_prowlarr() { local apikey="$1" local radarr_apikey="$2" local sonarr_apikey="${3:-}" local critical_errors=0 # 1) App profile min seeders (best-effort) prowlarr_set_app_profile_min_seeders "$apikey" || true # 2. Add Radarr as connected application local apps apps=$(api_get "9696" "/api/v1/applications" "$apikey") local existing_impls existing_impls=$(jq_json_list_values "$apps" "implementationName") local app_schema="" if ! echo "$existing_impls" | grep -q "Radarr"; then if [ -z "$radarr_apikey" ]; then log_step_fail "Prowlarr: Radarr API key missing — cannot connect application" critical_errors=$((critical_errors + 1)) else # Get application schema once for Radarr/Sonarr field names app_schema=$(api_get "9696" "/api/v1/applications/schema" "$apikey") local app_payload app_payload=$(echo "$app_schema" | jq --arg prowlarr_url "$PROWLARR_DOCKER_URL" --arg radarr_url "$RADARR_DOCKER_URL" --arg radarr_key "$radarr_apikey" ' [.[] | select(.implementationName == "Radarr")][0] | .fields = [.fields[] | if .name == "prowlarrUrl" then .value = $prowlarr_url elif .name == "baseUrl" then .value = $radarr_url elif .name == "apiKey" then .value = $radarr_key elif .name == "syncCategories" then .value = [2000] elif .name == "syncRejectBlocklistedTorrentHashesWhileGrabbing" then .value = false else . end] | .enable = true | .syncLevel = "fullSync" | .name = "Radarr" | .priority = (.priority // 25) | .tags = [] ' 2>/dev/null || echo "") if [ -z "$app_payload" ]; then log_step_fail "Prowlarr: Radarr application schema not found" critical_errors=$((critical_errors + 1)) else local app_result app_result=$(api_post_force "9696" "/api/v1/applications" "$apikey" "$app_payload") if jq_json_has_key "$app_result" "id"; then log_step "Prowlarr: added Radarr as connected application" else log_step_fail "Prowlarr: failed to add Radarr application" critical_errors=$((critical_errors + 1)) fi fi fi else log_step "Prowlarr: Radarr application already connected" fi # 1b. Add Sonarr as connected application if [ -n "$sonarr_apikey" ]; then if ! echo "$existing_impls" | grep -q "Sonarr"; then if [ -z "$app_schema" ]; then app_schema=$(api_get "9696" "/api/v1/applications/schema" "$apikey") fi local sonarr_app_payload sonarr_app_payload=$(echo "$app_schema" | jq --arg prowlarr_url "$PROWLARR_DOCKER_URL" --arg sonarr_url "$SONARR_DOCKER_URL" --arg sonarr_key "$sonarr_apikey" ' [.[] | select(.implementationName == "Sonarr")][0] | .fields = [.fields[] | if .name == "prowlarrUrl" then .value = $prowlarr_url elif .name == "baseUrl" then .value = $sonarr_url elif .name == "apiKey" then .value = $sonarr_key elif .name == "syncCategories" then .value = [5000, 5010, 5020, 5030, 5040, 5045, 5050, 5090] elif .name == "animeSyncCategories" then .value = [5070] elif .name == "syncRejectBlocklistedTorrentHashesWhileGrabbing" then .value = false else . end] | .enable = true | .syncLevel = "fullSync" | .name = "Sonarr" | .priority = (.priority // 25) | .tags = [] ' 2>/dev/null || echo "") if [ -z "$sonarr_app_payload" ]; then log_step_fail "Prowlarr: Sonarr application schema not found" critical_errors=$((critical_errors + 1)) else local sonarr_app_result sonarr_app_result=$(api_post_force "9696" "/api/v1/applications" "$apikey" "$sonarr_app_payload") if jq_json_has_key "$sonarr_app_result" "id"; then log_step "Prowlarr: added Sonarr as connected application" else log_step_fail "Prowlarr: failed to add Sonarr application" critical_errors=$((critical_errors + 1)) fi fi else log_step "Prowlarr: Sonarr application already connected" fi fi # 2. Indexer HTTP via Gluetun when VPN is on (empty tags = all indexers) if ! prowlarr_sync_gluetun_proxy "$apikey"; then critical_errors=$((critical_errors + 1)) fi # 3. Selected indexers (optional; can add later via `config edit`) apply_selected_indexers "$apikey" # 4) After fullSync, set min seeders on *arr indexers (best-effort) wait_for_arr_indexer_sync "$radarr_apikey" || true arr_set_indexer_min_seeders "Radarr" "7878" "$radarr_apikey" || true arr_set_indexer_min_seeders "Sonarr" "8989" "$sonarr_apikey" || true # 5. Set authentication if ! set_arr_auth "Prowlarr" "9696" "$apikey" "v1"; then critical_errors=$((critical_errors + 1)) fi [ "$critical_errors" -eq 0 ] } # --- Sonarr configuration --- configure_sonarr() { local apikey="$1" configure_arr_service \ "Sonarr" "8989" "$apikey" \ "/data/media/tv" "tv" "/data/torrents/tv" \ "autoUnmonitorPreviouslyDownloadedEpisodes" \ '.renameEpisodes = true | .replaceIllegalCharacters = true | .standardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}" | .dailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title} {Quality Full}" | .animeEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}" | .seriesFolderFormat = "{Series Title}" | .seasonFolderFormat = "Season {season}" | .multiEpisodeStyle = 5 | .colonReplacementFormat = 4' } # --- Import existing media (unmapped folders) into *arr --- # Netflix-like: files already on disk under root folders should appear in # Radarr/Sonarr (and thus Bazarr) without a redownload. _folder_year() { local name="$1" if [[ "$name" =~ \(([0-9]{4})\)$ ]]; then echo "${BASH_REMATCH[1]}" fi } _folder_title_guess() { local name="$1" # Strip trailing " (YYYY)" echo "$name" | sed -E 's/[[:space:]]*\([0-9]{4}\)[[:space:]]*$//' } # Import Radarr unmapped movie folders. Uses default quality profile; no search. import_radarr_existing_media() { local apikey="$1" local port=7878 local root_json unmapped profile_info profile_id local added=0 skipped=0 failed=0 local folder_path folder_name year title_guess local lookup_json match payload result movie_id root_json=$(api_get "$port" "/api/v3/rootfolder" "$apikey") if [ -z "$root_json" ] || [ "$root_json" = "[]" ]; then log_step_fail "Radarr: no root folders — skip existing media import" return 1 fi profile_info=$(lookup_radarr_profile "$apikey") profile_id=${profile_info%%:*} profile_id=${profile_id:-1} # Collect unmapped folder paths while IFS=$'\t' read -r folder_path folder_name; do [ -z "$folder_path" ] && continue year=$(_folder_year "$folder_name") title_guess=$(_folder_title_guess "$folder_name") # Already in library at this path? if api_get "$port" "/api/v3/movie" "$apikey" | jq -e --arg p "$folder_path" ' any(.[]; .path == $p) ' >/dev/null 2>&1; then skipped=$((skipped + 1)) continue fi # Lookup by title (+ year in query when known) local term="$title_guess" [ -n "$year" ] && term="${title_guess} ${year}" lookup_json=$(curl -sG --connect-timeout 15 \ --data-urlencode "term=${term}" \ "http://${API_HOST}:${port}/api/v3/movie/lookup?apikey=${apikey}" 2>/dev/null || echo "[]") if [ -z "$lookup_json" ] || [ "$lookup_json" = "[]" ]; then log_step_fail "Radarr: no match for '${folder_name}'" failed=$((failed + 1)) continue fi # Prefer exact year match when folder has a year match=$(echo "$lookup_json" | jq -c --argjson y "${year:-0}" ' if $y > 0 then ([.[] | select(.year == $y)] | .[0]) // .[0] else .[0] end ' 2>/dev/null || echo "") if [ -z "$match" ] || [ "$match" = "null" ]; then log_step_fail "Radarr: could not pick match for '${folder_name}'" failed=$((failed + 1)) continue fi payload=$(echo "$match" | jq -c \ --argjson qid "$profile_id" \ --arg path "$folder_path" \ --arg root "/data/media/movies" \ ' { title: .title, tmdbId: .tmdbId, year: .year, qualityProfileId: $qid, monitored: true, minimumAvailability: (.minimumAvailability // "announced"), rootFolderPath: $root, path: $path, addOptions: { searchForMovie: false } } ' 2>/dev/null || echo "") if [ -z "$payload" ]; then failed=$((failed + 1)) continue fi result=$(api_post "$port" "/api/v3/movie" "$apikey" "$payload") movie_id=$(echo "$result" | jq -r '.id // empty' 2>/dev/null || echo "") if [ -z "$movie_id" ]; then # Already exists (tmdb) is not a hard failure if echo "$result" | jq -e '.message' 2>/dev/null | grep -qi 'exist\|already' 2>/dev/null; then skipped=$((skipped + 1)) else log_step_fail "Radarr: import failed for '${folder_name}'" failed=$((failed + 1)) fi continue fi # Link files already on disk api_post "$port" "/api/v3/command" "$apikey" \ "{\"name\":\"RescanMovie\",\"movieId\":${movie_id}}" >/dev/null 2>&1 || true added=$((added + 1)) log_step "Radarr: imported existing '${folder_name}' (id ${movie_id}, profile ${profile_id})" done < <(echo "$root_json" | jq -r ' .[] | .unmappedFolders[]? | "\(.path)\t\(.name)" ' 2>/dev/null || true) if [ "$added" -eq 0 ] && [ "$failed" -eq 0 ]; then log_step "Radarr: no unmapped movie folders to import" return 0 fi log_step "Radarr: existing media import done (added=${added}, skipped=${skipped}, failed=${failed})" [ "$failed" -eq 0 ] } # Import Sonarr unmapped series folders. No automatic search. import_sonarr_existing_media() { local apikey="$1" local port=8989 local root_json profile_info profile_id local added=0 skipped=0 failed=0 local folder_path folder_name title_guess local lookup_json match payload result series_id language_profile_id root_json=$(api_get "$port" "/api/v3/rootfolder" "$apikey") if [ -z "$root_json" ] || [ "$root_json" = "[]" ]; then log_step_fail "Sonarr: no root folders — skip existing media import" return 1 fi profile_info=$(lookup_sonarr_profile "$apikey") profile_id=${profile_info%%:*} profile_id=${profile_id:-1} # Language profile (required by modern Sonarr) language_profile_id=$(api_get "$port" "/api/v3/languageprofile" "$apikey" | jq -r '.[0].id // 1' 2>/dev/null || echo "1") while IFS=$'\t' read -r folder_path folder_name; do [ -z "$folder_path" ] && continue title_guess=$(_folder_title_guess "$folder_name") if api_get "$port" "/api/v3/series" "$apikey" | jq -e --arg p "$folder_path" ' any(.[]; .path == $p) ' >/dev/null 2>&1; then skipped=$((skipped + 1)) continue fi lookup_json=$(curl -sG --connect-timeout 15 \ --data-urlencode "term=${title_guess}" \ "http://${API_HOST}:${port}/api/v3/series/lookup?apikey=${apikey}" 2>/dev/null || echo "[]") if [ -z "$lookup_json" ] || [ "$lookup_json" = "[]" ]; then log_step_fail "Sonarr: no match for '${folder_name}'" failed=$((failed + 1)) continue fi match=$(echo "$lookup_json" | jq -c '.[0]' 2>/dev/null || echo "") if [ -z "$match" ] || [ "$match" = "null" ]; then failed=$((failed + 1)) continue fi payload=$(echo "$match" | jq -c \ --argjson qid "$profile_id" \ --argjson lid "$language_profile_id" \ --arg path "$folder_path" \ --arg root "/data/media/tv" \ ' . + { qualityProfileId: $qid, languageProfileId: $lid, rootFolderPath: $root, path: $path, monitored: true, seasonFolder: true, addOptions: { searchForMissingEpisodes: false, searchForCutoffUnmetEpisodes: false, monitor: "all" } } | del(.id, .statistics, .episodesChanged) ' 2>/dev/null || echo "") if [ -z "$payload" ]; then failed=$((failed + 1)) continue fi result=$(api_post "$port" "/api/v3/series" "$apikey" "$payload") series_id=$(echo "$result" | jq -r '.id // empty' 2>/dev/null || echo "") if [ -z "$series_id" ]; then log_step_fail "Sonarr: import failed for '${folder_name}'" failed=$((failed + 1)) continue fi api_post "$port" "/api/v3/command" "$apikey" \ "{\"name\":\"RescanSeries\",\"seriesId\":${series_id}}" >/dev/null 2>&1 || true added=$((added + 1)) log_step "Sonarr: imported existing '${folder_name}' (id ${series_id}, profile ${profile_id})" done < <(echo "$root_json" | jq -r ' .[] | .unmappedFolders[]? | "\(.path)\t\(.name)" ' 2>/dev/null || true) if [ "$added" -eq 0 ] && [ "$failed" -eq 0 ]; then log_step "Sonarr: no unmapped series folders to import" return 0 fi log_step "Sonarr: existing media import done (added=${added}, skipped=${skipped}, failed=${failed})" [ "$failed" -eq 0 ] } # Trigger Bazarr library sync after *arr imports (best-effort) trigger_bazarr_library_sync() { local bazarr_key="" local cfg for cfg in \ "$INSTALL_DIR/config/bazarr/config/config.yaml" \ "$INSTALL_DIR/config/bazarr/config/config.ini" do [ -f "$cfg" ] || continue if [[ "$cfg" == *.yaml ]]; then bazarr_key=$(awk ' /^auth:[[:space:]]*$/ { in_auth=1; next } in_auth && /^[^[:space:]]/ { in_auth=0 } in_auth && /^[[:space:]]+apikey:[[:space:]]*/ { sub(/^[[:space:]]+apikey:[[:space:]]*/, "") gsub(/["\047]/, "") print; exit } ' "$cfg" 2>/dev/null | head -1) else bazarr_key=$(sed -n 's/^[[:space:]]*apikey[[:space:]]*=[[:space:]]*//p' "$cfg" 2>/dev/null | head -1) fi [ -n "$bazarr_key" ] && break done [ -z "$bazarr_key" ] && [ -f "$INSTALL_DIR/secrets/bazarr_api_key.txt" ] && \ bazarr_key=$(cat "$INSTALL_DIR/secrets/bazarr_api_key.txt" 2>/dev/null || echo "") if [ -z "$bazarr_key" ]; then return 0 fi curl -s -o /dev/null --connect-timeout 5 -X POST -F "taskid=update_movies" \ "http://${API_HOST}:6767/api/system/tasks?apikey=${bazarr_key}" 2>/dev/null || true curl -s -o /dev/null --connect-timeout 5 -X POST -F "taskid=update_series" \ "http://${API_HOST}:6767/api/system/tasks?apikey=${bazarr_key}" 2>/dev/null || true log_step "Bazarr: triggered library sync with Sonarr/Radarr" return 0 }