Add config apply, full auth change, and *arr quality defaults

Re-wire without replacing compose. Update the shared login on every
UI. Make assemblrr quality profiles the Radarr/Sonarr Add New default.
config edit is picker-only. Forward Seerr Host headers, chmod only the
VPN secrets just written, and ignore Python cache files.
This commit is contained in:
soulis-1256 2026-08-16 15:58:48 +03:00
parent e3c4b661e5
commit 962e322fdb
No known key found for this signature in database
17 changed files with 753 additions and 114 deletions

6
.gitignore vendored
View file

@ -1,6 +1,12 @@
*.log
tmp/
# Python
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
# Runtime files (generated by setup.sh)
.env
.assemblrr-config

View file

@ -83,9 +83,8 @@ After install, the CLI is on your `PATH` as `assemblrr`. Run `assemblrr <command
| `logs [service]` | Follow logs (all services, or one) |
| `config` | List config subcommands |
| `config show` | Show current configuration |
| `config edit` | Change one install setting (picker if omitted) |
| `config edit indexers` | Add/remove Prowlarr indexers (fzf) |
| `config edit all` | Re-run the full setup wizard |
| `config apply` | Re-wire running services (no compose replace). Alias: `config wire` |
| `config edit` | Pick a setting to change (indexers, login, VPN, …) |
| `backup /target/dir` | Snapshot configuration |
| `restore /backup.tar.gz` | Restore from a backup archive |
| `update-containers` | Pull latest images and restart (offers a backup first) |

View file

@ -96,7 +96,7 @@ declare -A COMMANDS=(
["upgrade"]="upgrade install files from git (or --from DIR); runs migrations"
["logs"]="shows container logs (optionally specify service name)"
["health"]="checks health status of all services"
["config"]="configuration: show, or edit one setup section"
["config"]="configuration: show, edit (picker), or apply (re-wire)"
["purge"]="delete one title from *arr, disk, and qBittorrent"
)
@ -138,8 +138,7 @@ show_help() {
echo " ${APP_CLI_NAME} status --docker # Raw docker compose ps"
echo " ${APP_CLI_NAME} config # List config subcommands"
echo " ${APP_CLI_NAME} config show # Show current configuration"
echo " ${APP_CLI_NAME} config edit # Pick a setup section to change"
echo " ${APP_CLI_NAME} config edit indexers # Reopen Prowlarr indexer fzf"
echo " ${APP_CLI_NAME} config edit # Pick a setting to change"
echo " ${APP_CLI_NAME} purge --movie-id N # Full delete (*arr + disk + qB)"
echo " ${APP_CLI_NAME} purge --path PATH # Full delete by library path"
echo " ${APP_CLI_NAME} purge --tmdb ID # Full delete by TMDB id"
@ -789,16 +788,22 @@ show_config_help() {
echo
echo "Subcommands:"
printf " %-12s %s\n" "show" "Show current configuration"
printf " %-12s %s\n" "edit" "Change one first-setup section (picker if omitted)"
printf " %-12s %s\n" "edit" "Pick a setting to change"
printf " %-12s %s\n" "apply" "Re-wire running services (no compose replace)"
echo
echo "Examples:"
echo " ${APP_CLI_NAME} config # List config options"
echo " ${APP_CLI_NAME} config show # Show configuration"
echo " ${APP_CLI_NAME} config edit # Pick a section (indexers, profile, …)"
echo " ${APP_CLI_NAME} config edit indexers # Reopen the Prowlarr indexer fzf"
echo " ${APP_CLI_NAME} config edit all # Re-run the full setup wizard"
echo
echo "Run '${APP_CLI_NAME} config edit --help' for every section."
echo " ${APP_CLI_NAME} config apply # Re-run service wiring only"
echo " ${APP_CLI_NAME} config edit # Pick a setting to change"
}
config_apply() {
if [ ! -f "$INSTALL_DIR/config.sh" ]; then
log_error "config.sh not found in $INSTALL_DIR. Run '${APP_CLI_NAME} upgrade' first."
fi
log_info "Re-wiring services (no compose replace)..."
ASSEMBLRR_NONINTERACTIVE=1 bash "$INSTALL_DIR/config.sh"
}
_load_config_edit() {
@ -824,6 +829,9 @@ config_cmd() {
show)
show_config
;;
apply|wire)
config_apply
;;
edit)
_load_config_edit
config_edit_run "${1:-}"

View file

@ -220,7 +220,7 @@ if [ "$_wire_critical_fail" -gt 0 ]; then
fi
echo -e " ${YELLOW}Log: $CONFIGURE_LOG${NC}" | tee -a "$CONFIGURE_LOG"
echo
_cfg_log_info "Fix issues, then re-run setup or: ${APP_CLI_NAME:-assemblrr} upgrade"
_cfg_log_info "Fix issues, then re-run: ${APP_CLI_NAME:-assemblrr} config apply"
echo
exit 1
fi

View file

@ -4,7 +4,7 @@ assemblrr uses [Recyclarr](https://recyclarr.dev/) to sync [TRaSH Guides](https:
**Subtitles:** Bazarr is wired separately (not via Recyclarr). On first install and every `assemblrr upgrade`, assemblrr applies [TRaSH Bazarr suggested scoring](https://trash-guides.info/Bazarr/Bazarr-suggested-scoring/) (series min score 90, movies 80, auto-sync thresholds 96/86). Subtitle providers are chosen interactively from the running Bazarr image (fzf multi-select; nothing enabled unless you pick it). OpenSubtitles.com is enabled only when you saved credentials during setup.
**Both resolutions are always installed** as assemblrr-named profiles (movies: **assemblrr HD Bluray + WEB** + **assemblrr UHD Bluray + WEB**; TV: **assemblrr WEB-1080p** + **assemblrr WEB-2160p**). The setup question only picks Seerrs *default* for movie requests — the other profile stays available in the quality dropdown. Stock Radarr/Sonarr profiles (Any, HD-1080p, …) also appear there; that is normal.
**Both resolutions are always installed** as assemblrr-named profiles (movies: **assemblrr HD Bluray + WEB** + **assemblrr UHD Bluray + WEB**; TV: **assemblrr WEB-1080p** + **assemblrr WEB-2160p**). The setup / `config edit` quality question picks the **movie** default for Radarr **and** Seerr. That profile is written over *arrs stock **Any** slot (id 1) so Add Movie / Add Series opens on it, not Any. TV uses **assemblrr WEB-1080p** the same way. A show you already set to 2160p is left alone. Stock *arr profiles (Any, HD-1080p, …) still appear in the dropdown; that is normal.
assemblrr deliberately **does not** apply TRaSH quality min-size limits (they can reject smaller legitimate releases), and softens hard-block custom formats (LQ / x265 / 3D scores set to 0). Tier preferences from the guides still apply when better releases exist.

View file

@ -157,6 +157,67 @@ qbit_set_credentials() {
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"
curl -s --connect-timeout 5 -c "$cookie_jar" \
-H "Referer: http://${API_HOST}:${qbit_port}" \
-d "username=${user}&password=${pass}" \
"http://${API_HOST}:${qbit_port}/api/v2/auth/login" 2>/dev/null >/dev/null || true
[ -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" \
",\"web_ui_username\":\"${new_user}\",\"web_ui_password\":\"${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"
@ -456,28 +517,264 @@ relax_quality_sizes() {
return 1
}
# Record the preferred quality profile id/name for this install (setup choice).
# Item ids whose qualityProfileId still needs updating.
# stdin: JSON array of {id, qualityProfileId}. $1 = target id. $2 = all | any-only | from:<id>
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
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
}
# Apply the install's chosen quality profile to *arr (id 1 / library / import lists).
# Seerr uses the same lookup so the chain stays in lockstep.
set_default_quality_profile() {
local service_name="$1"
local port="$2"
local apikey="$3"
local profile_func="$4"
local profile profile_id
local profile profile_id profile_name
profile=$($profile_func "$apikey")
profile_id="${profile%%:*}"
local profile_name="${profile##*:}"
profile_name="${profile##*:}"
if [ -z "$profile_id" ] || [ "$profile_id" = "1" ] && [ "$profile_name" = "Any" ]; then
log_step "${service_name}: preferred quality profile → Any"
return 0
if [ -z "$profile_id" ]; then
log_step_fail "${service_name}: no quality profile to apply"
return 1
fi
local want_named=0
if [ "$service_name" = "Radarr" ]; then
if [ "${SEERR_IS_4K:-false}" = "true" ] || [ "${SEERR_DEFAULT_PROFILE:-1}" != "1" ]; then
want_named=1
fi
else
want_named=1
fi
if [ "$want_named" = "1" ] && [ "$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
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
log_step "${service_name}: preferred quality profile → ${profile_name} (id ${profile_id})"
# 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
local fail=0
if [ "$service_name" = "Radarr" ]; then
if ! arr_set_items_quality_profile "$service_name" "$port" "$apikey" "$profile_id" "movie" "all"; then
log_step_fail "${service_name}: could not update movie quality profiles"
fail=1
fi
else
if ! arr_set_items_quality_profile "$service_name" "$port" "$apikey" "$profile_id" "series" "any-only"; then
log_step_fail "${service_name}: could not update series still on Any"
fail=1
fi
fi
arr_set_importlist_quality_profile "$service_name" "$port" "$apikey" "$profile_id" || true
if [ "$fail" -ne 0 ]; then
return 1
fi
log_step "${service_name}: default quality profile → ${profile_name} (id ${profile_id})"
}
# --- Quality profile lookup ---
@ -762,7 +1059,7 @@ wait_for_arr_indexer_sync() {
}
# POST indexers named in SELECTED_INDEXERS into Prowlarr (skip existing).
# Used by first-run wiring and `assemblrr config edit indexers`.
# Used by first-run wiring and `assemblrr config edit`.
apply_selected_indexers() {
local apikey="$1"
local indexer_schemas
@ -938,7 +1235,7 @@ configure_prowlarr() {
fi
fi
# 2. Selected indexers (optional; can add later via `config edit indexers`)
# 2. Selected indexers (optional; can add later via `config edit`)
apply_selected_indexers "$apikey"
# 3) After fullSync, set min seeders on *arr indexers (best-effort)

View file

@ -312,6 +312,21 @@ configure_bazarr() {
)
fi
# First wire only: Bazarr hashes the password on save, so later wires
# must not resend it (that would MD5 the hash). Auth edit uses bazarr_set_auth.
local auth_type=""
auth_type=$(curl -s --connect-timeout 8 \
"http://${API_HOST}:${BAZARR_PORT}/api/system/settings?apikey=${bazarr_key}" 2>/dev/null \
| jq -r '.auth.type // empty' 2>/dev/null || true)
if { [ -z "$auth_type" ] || [ "$auth_type" = "null" ] || [ "$auth_type" = "None" ]; } \
&& [ -n "${AUTH_USERNAME:-}" ] && [ -n "${AUTH_PASSWORD:-}" ]; then
form_args+=(
-F "settings-auth-type=form"
-F "settings-auth-username=${AUTH_USERNAME}"
-F "settings-auth-password=${AUTH_PASSWORD}"
)
fi
# Jellyfin refresh after subtitle downloads
if [ "${MEDIA_SERVICE:-}" = "jellyfin" ]; then
jf_key=$(cat "$INSTALL_DIR/secrets/jellyfin_api_key.txt" 2>/dev/null || echo "")
@ -415,3 +430,33 @@ configure_bazarr() {
return 0
}
# Set Bazarr form login. Always sends a fresh password (Bazarr MD5s it).
# Do not call this from the regular wire loop — only from auth edit.
bazarr_set_auth() {
local bazarr_key=""
if [ -z "${AUTH_USERNAME:-}" ] || [ -z "${AUTH_PASSWORD:-}" ]; then
log_step_fail "Bazarr: new credentials missing"
return 1
fi
if ! bazarr_key=$(read_bazarr_api_key); then
return 1
fi
if ! wait_for_bazarr "$bazarr_key"; then
return 1
fi
local http_code
http_code=$(_bazarr_post_form "$bazarr_key" \
-F "settings-auth-type=form" \
-F "settings-auth-username=${AUTH_USERNAME}" \
-F "settings-auth-password=${AUTH_PASSWORD}")
if [ "$http_code" = "204" ] || [ "$http_code" = "200" ]; then
log_step "Bazarr: set login (${AUTH_USERNAME})"
return 0
fi
local body=""
body=$(cat /tmp/bazarr-settings-body.txt 2>/dev/null | head -c 200 || true)
log_step_fail "Bazarr: failed to set login (HTTP ${http_code}${body:+: $body})"
return 1
}

View file

@ -12,19 +12,19 @@ _config_edit_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
[ -f "$_config_edit_dir/ui.sh" ] && source "$_config_edit_dir/ui.sh"
# id|aliases|description (pipe so empty aliases stay empty; bash IFS tab collapses)
# id|description
_config_edit_catalog() {
cat <<'EOF'
indexers||Prowlarr indexers (fzf)
providers|subtitles,subtitle-providers|Bazarr subtitle providers (fzf)
language|subtitle-language|Preferred subtitle language (fzf)
profile|quality,seerr-profile|Seerr default quality profile (movies)
opensubtitles|os|OpenSubtitles.com credentials
auth|login,credentials|Service login (Radarr / Sonarr / Prowlarr / qB)
timezone|tz|Container timezone
vpn||VPN on/off, provider, and credentials (restarts stack)
media|media-service|Jellyfin / Emby / Plex (restarts stack)
all|setup,wizard|Re-run the full setup wizard
indexers|Prowlarr indexers
providers|Bazarr subtitle providers
language|Preferred subtitle language
profile|Default movie quality (Radarr + Seerr)
opensubtitles|OpenSubtitles.com credentials
auth|Shared login for every service UI
timezone|Container timezone
vpn|VPN on/off, provider, and credentials (restarts stack)
media|Jellyfin / Emby / Plex (restarts stack)
all|Re-run the full setup wizard
EOF
}
@ -34,51 +34,30 @@ config_edit_section_ids() {
config_edit_usage() {
local cli="${APP_CLI_NAME:-assemblrr}"
echo "Usage: ${cli} config edit [section]"
echo "Usage: ${cli} config edit"
echo
echo "Change one install setting without re-running the whole wizard."
echo "No argument opens a picker (fzf when available)."
echo "Opens a picker. Choose what to change there."
echo
echo "Sections:"
local id aliases desc
while IFS='|' read -r id aliases desc; do
echo "In the picker:"
local id desc
while IFS='|' read -r id desc; do
[ -z "$id" ] && continue
if [ -n "$aliases" ]; then
printf " %-14s %s\n" "$id" "$desc"
printf " %-14s (aliases: %s)\n" "" "$aliases"
else
printf " %-14s %s\n" "$id" "$desc"
fi
printf " %-14s %s\n" "$id" "$desc"
done < <(_config_edit_catalog)
echo
echo "Examples:"
echo " ${cli} config edit # pick a section"
echo " ${cli} config edit indexers # reopen Prowlarr indexer fzf"
echo " ${cli} config edit profile # change Seerr movie default"
echo " ${cli} config edit all # full setup wizard"
}
# Resolve user input (id or alias) to a canonical section id. Empty → "".
# Resolve picker output to a catalog id. Empty → "".
config_edit_resolve() {
local want="${1:-}"
want=$(echo "$want" | tr '[:upper:]' '[:lower:]')
[ -z "$want" ] && return 1
local id aliases
while IFS='|' read -r id aliases _; do
local id
while IFS='|' read -r id _; do
[ -z "$id" ] && continue
if [ "$want" = "$id" ]; then
echo "$id"
return 0
fi
local a
local -a _aliases=()
IFS=',' read -ra _aliases <<< "$aliases"
for a in "${_aliases[@]}"; do
if [ "$want" = "$a" ]; then
echo "$id"
return 0
fi
done
done < <(_config_edit_catalog)
return 1
}
@ -210,7 +189,7 @@ _config_edit_restart_stack() {
_config_edit_pick() {
local data selected
data=$(_config_edit_catalog | awk -F'|' '{printf "%s\t%s\n", $1, $3}')
data=$(_config_edit_catalog | awk -F'|' '{printf "%s\t%s\n", $1, $2}')
if type _fzf_can_render >/dev/null 2>&1 && _fzf_can_render && _fzf_has_tty; then
selected=$(echo "$data" | fzf \
--prompt="Edit which setting> " \
@ -227,7 +206,7 @@ _config_edit_pick() {
echo
log_info "What do you want to change?"
local i=1 id desc
while IFS='|' read -r id _ desc; do
while IFS='|' read -r id desc; do
printf " %2d) %-14s %s\n" "$i" "$id" "$desc"
i=$((i + 1))
done < <(_config_edit_catalog)
@ -344,24 +323,65 @@ _config_edit_opensubtitles() {
_config_edit_auth() {
_config_edit_load
local old_user old_pass
old_user=$(cat "$INSTALL_DIR/secrets/auth_username.txt" 2>/dev/null || echo "${AUTH_USERNAME:-}")
old_pass=$(cat "$INSTALL_DIR/secrets/auth_password.txt" 2>/dev/null || echo "${AUTH_PASSWORD:-}")
configure_auth
local secrets_dir="$INSTALL_DIR/secrets"
mkdir -p "$secrets_dir"
echo -n "${auth_username:-admin}" > "$secrets_dir/auth_username.txt"
echo -n "${auth_password:-}" > "$secrets_dir/auth_password.txt"
chmod 600 "$secrets_dir/auth_username.txt" "$secrets_dir/auth_password.txt"
AUTH_USERNAME="${auth_username:-admin}"
AUTH_PASSWORD="${auth_password:-}"
log_success "Auth credentials written to $secrets_dir"
qbit_set_credentials || true
local fail=0
qbit_change_credentials "$old_user" "$old_pass" "$AUTH_USERNAME" "$AUTH_PASSWORD" || fail=1
if [ -n "${RADARR_API_KEY:-}" ]; then
set_arr_auth "Radarr" "7878" "$RADARR_API_KEY" "v3" || true
set_arr_auth "Radarr" "7878" "$RADARR_API_KEY" "v3" || fail=1
else
log_step_fail "Radarr: no API key — skipped"
fail=1
fi
if [ -n "${SONARR_API_KEY:-}" ]; then
set_arr_auth "Sonarr" "8989" "$SONARR_API_KEY" "v3" || true
set_arr_auth "Sonarr" "8989" "$SONARR_API_KEY" "v3" || fail=1
else
log_step_fail "Sonarr: no API key — skipped"
fail=1
fi
if [ -n "${PROWLARR_API_KEY:-}" ]; then
set_arr_auth "Prowlarr" "9696" "$PROWLARR_API_KEY" "v1" || true
set_arr_auth "Prowlarr" "9696" "$PROWLARR_API_KEY" "v1" || fail=1
else
log_step_fail "Prowlarr: no API key — skipped"
fail=1
fi
case "${MEDIA_SERVICE:-jellyfin}" in
jellyfin)
jellyfin_change_credentials "$old_user" "$old_pass" "$AUTH_USERNAME" "$AUTH_PASSWORD" || fail=1
;;
emby)
jellyfin_change_credentials "$old_user" "$old_pass" "$AUTH_USERNAME" "$AUTH_PASSWORD" 8096 "Emby" || fail=1
;;
plex)
log_warning "Plex account password is not this login — change it in Plex."
;;
esac
bazarr_set_auth || fail=1
if [ "${MEDIA_SERVICE:-jellyfin}" = "jellyfin" ] || [ "${MEDIA_SERVICE:-}" = "emby" ]; then
if type seerr_verify_login >/dev/null 2>&1; then
seerr_verify_login || true
fi
fi
echo -n "$AUTH_USERNAME" > "$secrets_dir/auth_username.txt"
echo -n "$AUTH_PASSWORD" > "$secrets_dir/auth_password.txt"
chmod 600 "$secrets_dir/auth_username.txt" "$secrets_dir/auth_password.txt"
log_success "Auth credentials written to $secrets_dir"
if [ "$fail" -ne 0 ]; then
log_warning "Some UIs did not take the new password. Fix those services, then: ${APP_CLI_NAME:-assemblrr} config apply"
return 1
fi
}
@ -408,7 +428,11 @@ _config_edit_vpn() {
: > "$secrets_dir/openvpn_user.txt"
: > "$secrets_dir/openvpn_password.txt"
fi
chmod 600 "$secrets_dir"/*.txt 2>/dev/null || true
chmod 600 \
"$secrets_dir/openvpn_user.txt" \
"$secrets_dir/openvpn_password.txt" \
"$secrets_dir/wireguard_private_key.txt" \
2>/dev/null || true
log_success "VPN secrets written to $secrets_dir"
fi
if [ "${VPN_ENABLED:-n}" = "y" ]; then
@ -440,7 +464,7 @@ _config_edit_media() {
configure_jellyfin || true
configure_jellyfin_notifications || true
elif [ "$previous" != "$MEDIA_SERVICE" ]; then
log_info "Switched media server to ${MEDIA_SERVICE}. Finish any first-run UI setup, then re-run: ${APP_CLI_NAME:-assemblrr} config edit all"
log_info "Switched media server to ${MEDIA_SERVICE}. Finish any first-run UI setup, then run: ${APP_CLI_NAME:-assemblrr} config apply"
fi
}
@ -474,25 +498,28 @@ config_edit_run() {
local section=""
ui_set_mode edit
if [ -z "$raw" ]; then
_config_edit_load_picker
raw=$(_config_edit_pick)
raw=$(echo "${raw:-}" | head -1 | tr -d '[:space:]')
if [ -z "$raw" ]; then
echo "Cancelled."
return 0
fi
fi
case "$raw" in
--help|-h|help)
config_edit_usage
return 0
;;
"")
;;
*)
log_error "Just run '${APP_CLI_NAME:-assemblrr} config edit' and pick from the menu"
;;
esac
_config_edit_load_picker
raw=$(_config_edit_pick)
raw=$(echo "${raw:-}" | head -1 | tr -d '[:space:]')
if [ -z "$raw" ]; then
echo "Cancelled."
return 0
fi
if ! section=$(config_edit_resolve "$raw"); then
log_error "Unknown config section: $raw\nRun '${APP_CLI_NAME:-assemblrr} config edit --help' for sections"
log_error "Unknown config section: $raw"
fi
case "$section" in

View file

@ -472,7 +472,7 @@ configure_indexers() {
log_info "Prowlarr is not responding — cannot edit indexers."
else
log_info "Prowlarr is not responding — skipping optional indexer setup."
log_info "You can add indexers later with: ${APP_CLI_NAME:-assemblrr} config edit indexers"
log_info "You can add indexers later with: ${APP_CLI_NAME:-assemblrr} config edit"
fi
fi
}
@ -523,7 +523,7 @@ configure_subtitle_providers() {
log_info "Bazarr is not running — cannot edit providers."
else
log_info "Bazarr is not running — skipping optional provider setup."
log_info "You can set providers later with: ${APP_CLI_NAME:-assemblrr} config edit providers"
log_info "You can set providers later with: ${APP_CLI_NAME:-assemblrr} config edit"
fi
return 0
fi

View file

@ -382,6 +382,116 @@ configure_jellyfin_libraries() {
[ "$critical_errors" -eq 0 ]
}
# Change an existing Jellyfin/Emby user's password (and name if it changed).
# Usage: jellyfin_change_credentials <old_user> <old_pass> <new_user> <new_pass> [port] [label]
jellyfin_change_credentials() {
local old_user="$1"
local old_pass="$2"
local new_user="$3"
local new_pass="$4"
local jellyfin_port="${5:-8096}"
local label="${6:-Jellyfin}"
local auth_header='X-Emby-Authorization: MediaBrowser Client="assemblrr", Version="1.0", Device="config-edit", DeviceId="assemblrr-auth"'
if [ -z "$new_user" ] || [ -z "$new_pass" ]; then
log_step_fail "${label}: new credentials missing"
return 1
fi
local public_info wizard_complete
public_info=$(curl -sf --connect-timeout 5 \
"http://${API_HOST}:${jellyfin_port}/System/Info/Public" 2>/dev/null || echo "")
wizard_complete=$(echo "$public_info" | jq -r '.StartupWizardCompleted // ""' 2>/dev/null || echo "")
if [ "$wizard_complete" != "True" ] && [ "$wizard_complete" != "true" ]; then
if [ "$label" = "Jellyfin" ] && type configure_jellyfin >/dev/null 2>&1; then
configure_jellyfin
return $?
fi
log_step_fail "${label}: startup wizard is still open — finish first boot, then retry"
return 1
fi
local auth_payload auth_response
auth_payload=$(jq -nc --arg u "$old_user" --arg p "$old_pass" '{Username:$u,Pw:$p}')
auth_response=$(curl -sf --connect-timeout 10 -X POST \
-H "Content-Type: application/json" \
-H "$auth_header" \
-d "$auth_payload" \
"http://${API_HOST}:${jellyfin_port}/Users/AuthenticateByName" 2>/dev/null || echo "")
if [ -z "$auth_response" ]; then
auth_payload=$(jq -nc --arg u "$new_user" --arg p "$new_pass" '{Username:$u,Pw:$p}')
auth_response=$(curl -sf --connect-timeout 10 -X POST \
-H "Content-Type: application/json" \
-H "$auth_header" \
-d "$auth_payload" \
"http://${API_HOST}:${jellyfin_port}/Users/AuthenticateByName" 2>/dev/null || echo "")
if [ -n "$auth_response" ]; then
local already_name
already_name=$(echo "$auth_response" | jq -r '.User.Name // ""' 2>/dev/null || echo "")
if [ "$already_name" = "$new_user" ]; then
log_step "${label}: already using the new password"
return 0
fi
# New password already works — only the display name still needs updating.
old_pass="$new_pass"
else
log_step_fail "${label}: could not log in with the old password"
return 1
fi
fi
local access_token user_id
access_token=$(echo "$auth_response" | jq -r '.AccessToken // ""' 2>/dev/null || echo "")
user_id=$(echo "$auth_response" | jq -r '.User.Id // ""' 2>/dev/null || echo "")
if [ -z "$access_token" ] || [ -z "$user_id" ]; then
log_step_fail "${label}: login response missing token"
return 1
fi
if [ "$old_pass" != "$new_pass" ]; then
local pw_payload pw_code
pw_payload=$(jq -nc --arg cur "$old_pass" --arg new "$new_pass" '{CurrentPw:$cur,NewPw:$new}')
pw_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 8 -X POST \
-H "Content-Type: application/json" \
-H "X-Emby-Token: ${access_token}" \
-d "$pw_payload" \
"http://${API_HOST}:${jellyfin_port}/Users/${user_id}/Password" 2>/dev/null || echo "000")
if [ "$pw_code" -lt 200 ] || [ "$pw_code" -ge 300 ]; then
log_step_fail "${label}: failed to set password (HTTP $pw_code)"
return 1
fi
fi
if [ "$old_user" != "$new_user" ]; then
local enc_name name_code
enc_name=$(jq -rn --arg n "$new_user" '$n | @uri' 2>/dev/null || echo "$new_user")
name_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 8 -X POST \
-H "X-Emby-Token: ${access_token}" \
"http://${API_HOST}:${jellyfin_port}/Users/${user_id}/Name?name=${enc_name}" \
2>/dev/null || echo "000")
if [ "$name_code" -lt 200 ] || [ "$name_code" -ge 300 ]; then
local user_obj patched
user_obj=$(curl -s --connect-timeout 8 \
-H "X-Emby-Token: ${access_token}" \
"http://${API_HOST}:${jellyfin_port}/Users/${user_id}" 2>/dev/null || echo "")
patched=$(echo "$user_obj" | jq --arg n "$new_user" '.Name = $n' 2>/dev/null || echo "")
name_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 8 -X POST \
-H "Content-Type: application/json" \
-H "X-Emby-Token: ${access_token}" \
-d "$patched" \
"http://${API_HOST}:${jellyfin_port}/Users/${user_id}" 2>/dev/null || echo "000")
fi
if [ "$name_code" -lt 200 ] || [ "$name_code" -ge 300 ]; then
log_step_fail "${label}: password updated, username change failed (HTTP $name_code)"
return 1
fi
fi
log_step "${label}: set login (${new_user})"
return 0
}
# --- Jellyfin notification connections (Radarr/Sonarr → Jellyfin) ---
configure_jellyfin_notifications() {

View file

@ -70,11 +70,11 @@ configure_seerr_profile() {
fi
ui_intro \
"What default quality profile would you like to set for Seerr (Movies)?" \
"Change Seerr's default movie quality profile."
echo " 1) Ultra-HD (4K - Optimized for HEVC/x265)"
echo " 2) 1080p"
echo " 3) Any (Radarr Default)"
"What default quality profile should movies use (Radarr and Seerr)?" \
"Change the default movie quality on Radarr and Seerr."
echo " 1) Ultra-HD (4K - assemblrr UHD Bluray + WEB)"
echo " 2) 1080p (assemblrr HD Bluray + WEB)"
echo " 3) Any"
while true; do
read -p "Choose your Seerr default profile [1]: " seerr_profile_choice
@ -187,7 +187,7 @@ configure_opensubtitles() {
log_info "The website login accepts email or username; Bazarr/API require your profile username."
log_info "Find it under your OpenSubtitles profile (not the signup email)."
ui_note \
"Other subtitle providers can be configured later with: ${APP_CLI_NAME:-assemblrr} config edit providers" \
"Other subtitle providers can be configured later with: ${APP_CLI_NAME:-assemblrr} config edit" \
""
read -p "Do you have OpenSubtitles.com credentials? (y/N) [Default = n]: " opensubtitles_enabled
opensubtitles_enabled=${opensubtitles_enabled:-n}
@ -375,11 +375,11 @@ prompt_vpn_credentials() {
configure_auth() {
ui_intro \
"Set credentials for your service web UIs." \
"Change the shared service login (Radarr, Sonarr, Prowlarr, qBittorrent)."
"Set one username and password for every service web UI." \
"Change the shared login on every service UI."
ui_note \
"These will be used to log into Radarr, Prowlarr, and other services." \
"Seerr / Jellyfin / Bazarr may still use the previous password until you sign in there or re-run wiring."
"Same login on Radarr, Sonarr, Prowlarr, qBittorrent, Jellyfin, and Bazarr. Seerr signs in with the Jellyfin (or Emby) user." \
"Updates Radarr, Sonarr, Prowlarr, qBittorrent, Jellyfin, and Bazarr. Seerr signs in with the Jellyfin (or Emby) user."
echo
auth_username=""

View file

@ -470,3 +470,16 @@ EOF
return 1
fi
}
# Seerr has no separate password — it authenticates against Jellyfin/Emby.
seerr_verify_login() {
local seerr_cookie_jar="/tmp/seerr_cookie_jar_verify_$$"
if seerr_get_cookie "$seerr_cookie_jar" "false" || seerr_get_cookie "$seerr_cookie_jar" "true"; then
rm -f "$seerr_cookie_jar"
log_step "Seerr: session works with the new Jellyfin/Emby password"
return 0
fi
rm -f "$seerr_cookie_jar"
log_step_fail "Seerr: could not open a session (it uses the Jellyfin/Emby login)"
return 1
}

View file

@ -400,7 +400,7 @@ upgrade_app() {
if ASSEMBLRR_NONINTERACTIVE=1 bash "$INSTALL_DIR/config.sh"; then
log_success "Service wiring completed"
else
log_warning "Service wiring reported failures. Fix issues, then re-run: ${APP_CLI_NAME:-assemblrr} upgrade"
log_warning "Service wiring reported failures. Fix issues, then re-run: ${APP_CLI_NAME:-assemblrr} config apply"
fi
else
log_warning "config.sh missing after upgrade — cannot wire services"

View file

@ -79,26 +79,57 @@ def configure_logging() -> None:
)
def _header(headers: Dict[str, str], name: str) -> str:
want = name.lower()
for key, value in headers.items():
if key.lower() == want:
return value
return ""
def rewrite_location(value: str, client_host: str) -> str:
if not client_host or not value:
return value
_, host, port, _ = upstream_parts()
netlocs = [f"{host}:{port}" if port not in (80, 443) else host, "seerr:5055", "seerr"]
out = value
for netloc in netlocs:
out = out.replace(f"http://{netloc}", f"http://{client_host}")
out = out.replace(f"https://{netloc}", f"http://{client_host}")
return out
def filter_request_headers(headers: List[Tuple[str, str]], client_addr: str) -> Dict[str, str]:
out: Dict[str, str] = {}
incoming_host = ""
for key, value in headers:
lk = key.lower()
if lk in HOP_BY_HOP or lk == "host":
if lk in HOP_BY_HOP:
continue
if lk == "host":
incoming_host = value
continue
out[key] = value
# Prefer client-visible host for apps that build absolute URLs from X-Forwarded-*.
if "X-Forwarded-For" not in out and "x-forwarded-for" not in {k.lower() for k in out}:
# Browser Host so Seerr CSRF / redirects match what the user typed.
if incoming_host and not _header(out, "X-Forwarded-Host"):
out["X-Forwarded-Host"] = incoming_host
if not _header(out, "X-Forwarded-For"):
out["X-Forwarded-For"] = client_addr
if "X-Forwarded-Proto" not in out and "x-forwarded-proto" not in {k.lower() for k in out}:
if not _header(out, "X-Forwarded-Proto"):
out["X-Forwarded-Proto"] = "http"
return out
def filter_response_headers(headers: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
def filter_response_headers(
headers: List[Tuple[str, str]],
client_host: str = "",
) -> List[Tuple[str, str]]:
result: List[Tuple[str, str]] = []
for key, value in headers:
if key.lower() in HOP_BY_HOP:
continue
if key.lower() == "location":
value = rewrite_location(value, client_host)
result.append((key, value))
return result
@ -115,9 +146,14 @@ def open_upstream(method: str, path: str, headers: Dict[str, str], body: Optiona
scheme, host, port, is_https = upstream_parts()
conn_cls = HTTPSConnection if is_https else HTTPConnection
conn = conn_cls(host, port, timeout=CONNECT_TIMEOUT)
# Host must match upstream service name so Seerr/Node accepts the request.
hdrs = dict(headers)
hdrs["Host"] = f"{host}:{port}" if port not in (80, 443) else host
# Pass the browser Host through (Seerr's own reverse-proxy docs). Fall back
# to the upstream name only when the client did not send one.
client_host = _header(hdrs, "X-Forwarded-Host")
if client_host:
hdrs["Host"] = client_host
else:
hdrs["Host"] = f"{host}:{port}" if port not in (80, 443) else host
if body is not None and "Content-Length" not in {k.title() for k in hdrs} and "content-length" not in {
k.lower() for k in hdrs
}:
@ -164,7 +200,7 @@ def proxy_once(
conn, resp = open_upstream(method, path, headers, body)
resp_body = resp.read()
status = resp.status
resp_headers = filter_response_headers(resp.getheaders())
resp_headers = filter_response_headers(resp.getheaders(), _header(headers, "X-Forwarded-Host"))
return status, resp_headers, resp_body
finally:
if conn is not None:
@ -683,7 +719,9 @@ class GatewayHandler(BaseHTTPRequestHandler):
try:
conn, resp = open_upstream(method, path, headers, body)
self.send_response(resp.status)
for key, value in filter_response_headers(resp.getheaders()):
for key, value in filter_response_headers(
resp.getheaders(), _header(headers, "X-Forwarded-Host")
):
if key.lower() == "content-length":
continue
self.send_header(key, value)
@ -736,6 +774,7 @@ class _MockSeerr(BaseHTTPRequestHandler):
file_delete_status: int = 204
request_delete_status: int = 204
require_api_key: Optional[str] = "test-key"
last_request_headers: Dict[str, str] = {}
def log_message(self, fmt: str, *args) -> None: # quiet
return
@ -756,7 +795,14 @@ class _MockSeerr(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0]
_MockSeerr.last_request_headers = {k: v for k, v in self.headers.items()}
_MockSeerr.calls.append(f"GET {path}")
if path == "/redir":
self.send_response(302)
self.send_header("Location", "http://seerr:5055/login")
self.send_header("Content-Length", "0")
self.end_headers()
return
if path == "/api/v1/settings/public":
self._send(200, b'{"initialized":true}')
return
@ -990,6 +1036,30 @@ def self_test() -> int:
st, _, _ = http_call("GET", f"http://127.0.0.1:{gw_port}/api/v1/settings/public")
check(st == 200, "passthrough public settings")
# Browser Host is forwarded; Location pointing at seerr:5055 is rewritten.
_MockSeerr.last_request_headers = {}
conn = HTTPConnection("127.0.0.1", gw_port, timeout=5)
conn.request("GET", "/api/v1/settings/public", headers={"Host": "localhost:5055"})
resp = conn.getresponse()
resp.read()
conn.close()
got_host = _MockSeerr.last_request_headers.get("Host") or _MockSeerr.last_request_headers.get("host")
got_xfh = (
_MockSeerr.last_request_headers.get("X-Forwarded-Host")
or _MockSeerr.last_request_headers.get("x-forwarded-host")
)
check(got_host == "localhost:5055", f"upstream Host is the browser host (got {got_host})")
check(got_xfh == "localhost:5055", f"X-Forwarded-Host is the browser host (got {got_xfh})")
conn = HTTPConnection("127.0.0.1", gw_port, timeout=5)
conn.request("GET", "/redir", headers={"Host": "localhost:5055"})
resp = conn.getresponse()
loc = resp.getheader("Location") or ""
resp.read()
conn.close()
check(resp.status == 302, f"redir passthrough → 302 (got {resp.status})")
check(loc == "http://localhost:5055/login", f"Location rewritten off seerr:5055 (got {loc})")
# Purge disabled → only request DELETE
PURGE_ON_DELETE_REQUEST = False
_MockSeerr.calls = []

View file

@ -36,6 +36,7 @@ run_scripts() {
run_unit() {
echo "Running unit tests..."
for f in \
"$SCRIPT_DIR/unit/test_arr_quality.sh" \
"$SCRIPT_DIR/unit/test_compose.sh" \
"$SCRIPT_DIR/unit/test_config_edit.sh" \
"$SCRIPT_DIR/unit/test_core.sh" \

View file

@ -0,0 +1,47 @@
#!/bin/bash
# Unit tests for *arr quality-profile apply helpers (no live stack)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/../helpers.sh"
# shellcheck disable=SC1091
source "$REPO_ROOT/lib/core.sh"
stub_log_error_no_exit
# shellcheck disable=SC1091
source "$REPO_ROOT/lib/api.sh"
# shellcheck disable=SC1091
source "$REPO_ROOT/lib/arr.sh"
test_suite "arr_quality_ids_needing_update"
items='[{"id":1,"qualityProfileId":1},{"id":2,"qualityProfileId":6},{"id":3,"qualityProfileId":1}]'
assert_eq '[2]' "$(echo "$items" | arr_quality_ids_needing_update 1 all)" "all: skip already-on-target"
assert_eq '[1,3]' "$(echo "$items" | arr_quality_ids_needing_update 6 all)" "all: ids not on target"
assert_eq '[1,3]' "$(echo "$items" | arr_quality_ids_needing_update 6 any-only)" "any-only: only profile 1"
assert_eq '[2]' "$(echo "$items" | arr_quality_ids_needing_update 1 from:6)" "from:6 only that id"
assert_eq '[]' "$(echo '[]' | arr_quality_ids_needing_update 6 all)" "empty library"
test_suite "arr_quality_profile_clone_json"
src='{"id":6,"name":"assemblrr WEB-1080p","upgradeAllowed":true,"cutoff":8}'
cloned=$(echo "$src" | arr_quality_profile_clone_json 1 "assemblrr WEB-1080p")
assert_eq "1" "$(echo "$cloned" | jq -r '.id')" "clone uses id 1"
assert_eq "assemblrr WEB-1080p" "$(echo "$cloned" | jq -r '.name')" "keeps name"
assert_eq "true" "$(echo "$cloned" | jq -r '.upgradeAllowed')" "keeps body"
test_suite "arr_rootfolder_with_default_profile"
folder='{"id":1,"path":"/data/media/movies","unmappedFolders":[{"name":"x"}],"defaultQualityProfileId":1}'
out=$(echo "$folder" | arr_rootfolder_with_default_profile 6)
assert_eq "6" "$(echo "$out" | jq -r '.defaultQualityProfileId')" "sets defaultQualityProfileId"
assert_eq "null" "$(echo "$out" | jq -r '.unmappedFolders')" "drops unmappedFolders"
assert_eq "/data/media/movies" "$(echo "$out" | jq -r '.path')" "keeps path"
test_suite "set_default_quality_profile applies, not just records"
src="$REPO_ROOT/lib/arr.sh"
assert_true "sets root-folder default" "grep -q defaultQualityProfileId \"$src\""
assert_true "bulk-updates movies" "grep -q '/api/v3/movie/editor' \"$src\""
assert_true "bulk-updates series" "grep -q '/api/v3/series/editor' \"$src\""
assert_true "updates import lists" "grep -q '/api/v3/importlist/' \"$src\""
assert_true "promotes assemblrr profile to id 1" "grep -q 'arr_promote_quality_profile' \"$src\""
assert_true "writes qualityprofile/1" "grep -q '/api/v3/qualityprofile/1' \"$src\""
test_summary

View file

@ -24,11 +24,9 @@ assert_contains "$ids" "all" "lists all (full wizard)"
test_suite "config_edit_resolve"
assert_eq "indexers" "$(config_edit_resolve indexers)" "resolves indexers"
assert_eq "providers" "$(config_edit_resolve subtitles)" "alias subtitles → providers"
assert_eq "profile" "$(config_edit_resolve quality)" "alias quality → profile"
assert_eq "all" "$(config_edit_resolve wizard)" "alias wizard → all"
assert_eq "all" "$(config_edit_resolve ALL)" "case-insensitive"
assert_failure "unknown section" config_edit_resolve not-a-section
assert_failure "old CLI alias is not a shortcut" config_edit_resolve subtitles
test_suite "config_set_kv"
cfg="$tmp/.assemblrr-config"
@ -59,7 +57,25 @@ assert_eq "1" "$env_tz" "only one TZ= line in .env"
test_suite "config_edit_usage"
usage=$(config_edit_usage)
assert_contains "$usage" "config edit indexers" "usage mentions indexers example"
assert_contains "$usage" "Sections:" "usage lists sections"
assert_contains "$usage" "config edit" "usage is just config edit"
assert_not_contains "$usage" "config edit indexers" "no per-section CLI"
assert_contains "$usage" "In the picker:" "usage lists picker entries"
test_suite "auth section is every UI"
auth_line=$(_config_edit_catalog | grep '^auth|' || true)
assert_contains "$auth_line" "every service UI" "auth catalog says every UI"
profile_line=$(_config_edit_catalog | grep '^profile|' || true)
assert_contains "$profile_line" "Radarr" "profile catalog mentions Radarr"
test_suite "vpn edit chmods only vpn secret files"
vpn_fn=$(sed -n '/^_config_edit_vpn()/,/^}/p' "$REPO_ROOT/lib/config_edit.sh")
assert_not_contains "$vpn_fn" 'chmod 600 "$secrets_dir"/*.txt' "vpn edit does not chmod every secret"
assert_contains "$vpn_fn" "openvpn_user.txt" "vpn chmod names openvpn_user"
assert_contains "$vpn_fn" "wireguard_private_key.txt" "vpn chmod names wireguard key"
test_suite "cli has config apply"
assert_true "config apply is a subcommand" "grep -q 'apply|wire)' \"$REPO_ROOT/bin/cli.sh\""
assert_true "config apply runs config.sh" "grep -q 'ASSEMBLRR_NONINTERACTIVE=1 bash' \"$REPO_ROOT/bin/cli.sh\""
assert_contains "$(grep 'config apply' "$REPO_ROOT/README.md" || true)" "config apply" "README lists config apply"
test_summary