mirror of
https://github.com/soulis-1256/assemblrr.git
synced 2026-08-29 17:41:40 +00:00
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:
parent
e3c4b661e5
commit
962e322fdb
17 changed files with 753 additions and 114 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,6 +1,12 @@
|
|||
*.log
|
||||
tmp/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.pytest_cache/
|
||||
|
||||
# Runtime files (generated by setup.sh)
|
||||
.env
|
||||
.assemblrr-config
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
26
bin/cli.sh
26
bin/cli.sh
|
|
@ -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:-}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 Seerr’s *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 *arr’s 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.
|
||||
|
||||
|
|
|
|||
315
lib/arr.sh
315
lib/arr.sh
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
110
lib/jellyfin.sh
110
lib/jellyfin.sh
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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=""
|
||||
|
|
|
|||
13
lib/seerr.sh
13
lib/seerr.sh
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
47
tests/unit/test_arr_quality.sh
Normal file
47
tests/unit/test_arr_quality.sh
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue