#!/usr/bin/env bash # ============================================================================ # TrueNAS *arr stack, the complete edition: datasets + one compose stack per # container + Dockge + full auto-configuration # # What it does, in order: # 1. Creates the datasets: configs (one subdataset per app), media (with the # movies / tv / downloads folder structure) and stacks (one directory # per container, each holding its own compose.yaml, the layout Dockge # manages). # 2. Creates the shared Docker network "arr" and writes a compose stack per # container; every stack joins "arr", so containers keep reaching each # other by name across stacks. # 3. If you choose to launch: gets qBittorrent's WireGuard config (paste a # wg0.conf, or generate one from AirVPN with airvpn-wg.sh, which can also # set up alerts and automatic server switching) and asks for a forwarded # port if your VPN gave you one, then launches every # stack (including Dockge) and configures every app # through its API in the order from wiki.serversatho.me/en/InstallInstructions: # # qBittorrent -> Radarr -> Sonarr -> Trawl -> Prowlarr # -> Profilarr -> Jellyfin -> Seerr -> Bazarr -> Qui -> Tracearr -> Dockge # # Along the way it detects a GPU (NVIDIA, Intel or AMD, dedicated or # integrated), measures what it can decode and encode with Jellyfin's own # ffmpeg, passes it into the Jellyfin stack and switches Jellyfin to # hardware transcoding with exactly those capabilities. No GPU: software # transcoding, as before. # # Every step is idempotent (safe to re-run) and isolated: a step that cannot be # completed prints what to finish in the UI and the rest still runs. # # Logins: the script asks for one username and password and creates that # login in every app (Enter keeps the defaults admin / changeme; the email is # admin@admin.com). The choice is remembered in configs/arr-complete.env for # re-runs. Dockge rejects letters-only passwords such as the default, so it # gets a generated one in that case, printed at the end, and its # authentication is switched off either way. # # Run in the TrueNAS shell as root: # (wget -O replaces an older copy; plain wget would save arr-complete.sh.1 and # run the old one): # sudo su -c "wget -O arr-complete.sh https://raw.githubusercontent.com/serversathome/ServersatHome/refs/heads/main/arr-complete.sh && chmod +x arr-complete.sh && bash arr-complete.sh" # ============================================================================ # ok()/warn()/todo() always succeed, so "cmd && ok ... || todo ..." is a safe # one-line if/else throughout this file. # shellcheck disable=SC2015 set -uo pipefail # ── Defaults for every login this script creates (asked for below) ───────── ADMIN_USER="admin" ADMIN_PASS="changeme" ADMIN_EMAIL="admin@admin.com" QB_FORWARDED_PORT="" # fixed forwarded port from the VPN provider, if any # ── Helpers ───────────────────────────────────────────────────────────────── info() { echo ">>> $*"; } ok() { echo " ✔ $*"; } warn() { echo "⚠️ $*"; } q() { printf '%q' "$1"; } # a path quoted for a command the user copies die() { echo "❌ $*" >&2; exit 1; } # wait_for wait_for() { local what="$1" timeout="$2"; shift 2 local waited=0 # Progress goes to stderr: callers like `KEY=$(arr_key radarr)` capture # stdout, and a chatty wait_for would otherwise end up inside the value. echo "Waiting for $what (up to ${timeout}s)..." >&2 until "$@" >/dev/null 2>&1; do sleep 5; waited=$(( waited + 5 )) if [ "$waited" -ge "$timeout" ]; then warn "Timed out waiting for $what." >&2 return 1 fi # A sign of life every 30s, so a long wait is not mistaken for a hang. [ $(( waited % 30 )) -eq 0 ] && echo " ... still waiting (${waited}s of ${timeout}s)" >&2 done return 0 } # http [body] [extra curl args...] # Prints the response body. The HTTP status goes to a temp file (not a # variable) so it survives `x=$(http ...)` command substitutions; read it with # http_code / http_ok. Never aborts the script. # The body travels through a file (--data-binary @file), never as an argument: # the *arr schema bodies run to tens of KB and must reach curl byte for byte. # curl's stderr is kept so a transport failure shows up as "no response: # curl: (7) ..." instead of a blank status in the messages below. HTTP_STATUS_FILE=$(mktemp) HTTP_BODY_FILE=$(mktemp) HTTP_ERR_FILE=$(mktemp) COOKIE_DIR=$(mktemp -d) # session cookies for the apps configured below trap 'rm -rf "$COOKIE_DIR" "$HTTP_STATUS_FILE" "$HTTP_BODY_FILE" "$HTTP_ERR_FILE"' EXIT http() { local method="$1" url="$2" body="${3-}" shift $(( $# < 3 ? $# : 3 )) local out : > "$HTTP_ERR_FILE" if [ -n "$body" ]; then printf '%s' "$body" > "$HTTP_BODY_FILE" out=$(curl -sS -m 120 -X "$method" "$url" --data-binary "@$HTTP_BODY_FILE" "$@" -w $'\n%{http_code}' 2>"$HTTP_ERR_FILE") else out=$(curl -sS -m 120 -X "$method" "$url" "$@" -w $'\n%{http_code}' 2>"$HTTP_ERR_FILE") fi printf '%s' "${out##*$'\n'}" > "$HTTP_STATUS_FILE" printf '%s' "${out%$'\n'*}" } # http_code prints the status; with no usable status it prints the curl error # so the messages read "HTTP no response: curl: (28) Connection timed out". http_code() { local code err code=$(cat "$HTTP_STATUS_FILE" 2>/dev/null) if [[ "$code" =~ ^[1-5][0-9][0-9]$ ]]; then printf '%s' "$code"; return; fi err=$(tr -d '\r' < "$HTTP_ERR_FILE" 2>/dev/null | grep -v '^$' | head -1 | cut -c1-160) printf 'no response: %s' "${err:-curl produced no output (status [${code}])}" } http_ok() { [[ "$(cat "$HTTP_STATUS_FILE" 2>/dev/null)" =~ ^2[0-9][0-9]$ ]]; } # jpy '' — evaluate against JSON on stdin. # Scalars print raw; dicts/lists print as JSON. Empty/invalid input -> "". jpy() { python3 -c ' import json, sys try: d = json.load(sys.stdin) except Exception: sys.exit(0) try: r = eval(sys.argv[1]) except Exception: sys.exit(0) if r is None: sys.exit(0) print(r if not isinstance(r, (dict, list)) else json.dumps(r))' "$1" } # jbuild '' — build JSON from a python literal that may use # os.environ (safe way to embed passwords and keys without quoting trouble). jbuild() { python3 -c 'import json, os, sys; print(json.dumps(eval(sys.argv[1])))' "$1" } # Shared state filled in as containers are configured. RADARR_API_KEY=""; SONARR_API_KEY=""; PROWLARR_API_KEY=""; BAZARR_API_KEY="" JELLYFIN_TOKEN="" declare -a TODO_LIST=() todo() { TODO_LIST+=("$*"); warn "$*"; } # ── Pre-flight ────────────────────────────────────────────────────────────── [ "$(id -u)" -eq 0 ] || die "Run this script as root (sudo su)." command -v zfs >/dev/null 2>&1 || die "zfs not found. Is this a TrueNAS shell?" command -v docker >/dev/null 2>&1 || die "docker not found. Enable the Apps service in the TrueNAS UI first." command -v python3 >/dev/null 2>&1 || die "python3 not found (it ships with TrueNAS)." command -v curl >/dev/null 2>&1 || die "curl not found." # ── Pool selection ────────────────────────────────────────────────────────── # Offer the pools that exist instead of asking the user to type a name. The # boot pool is never a valid target, so it is left out of the menu. mapfile -t POOLS < <(zpool list -H -o name | grep -vE '^(boot-pool|freenas-boot)$') [ "${#POOLS[@]}" -gt 0 ] || die "No data pools found. Create a pool in the TrueNAS UI first." # choose_pool [default] -> sets CHOSEN_POOL choose_pool() { local prompt="$1" default="${2:-}" i choice echo "" echo "$prompt" for i in "${!POOLS[@]}"; do printf ' %d) %-20s %s\n' "$(( i + 1 ))" "${POOLS[$i]}" \ "$(zpool list -H -o size,free "${POOLS[$i]}" | awk '{print "size "$1", free "$2}')" done while true; do if [ -n "$default" ]; then read -rp "Select a pool [1-${#POOLS[@]}] (Enter for $default): " choice [ -z "$choice" ] && { CHOSEN_POOL="$default"; return; } else read -rp "Select a pool [1-${#POOLS[@]}]: " choice fi if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#POOLS[@]}" ]; then CHOSEN_POOL="${POOLS[$(( choice - 1 ))]}" return fi echo "Please enter a number between 1 and ${#POOLS[@]}." done } if [ "${#POOLS[@]}" -eq 1 ]; then CONFIG_POOL="${POOLS[0]}" MEDIA_POOL="${POOLS[0]}" echo "Only one data pool found; using '$CONFIG_POOL' for configs, stacks and media." else choose_pool "Which pool should hold the app configs and compose stacks (SSD pool if you have one)?" CONFIG_POOL="$CHOSEN_POOL" choose_pool "Which pool should hold the media library (can be the same pool)?" "$CONFIG_POOL" MEDIA_POOL="$CHOSEN_POOL" fi # Capitals and spaces in a pool name go against best practice but work: every # path below is quoted. A colon can't work: Docker splits volumes on it. for pool in "$CONFIG_POOL" "$MEDIA_POOL"; do case "$pool" in *:*) die "Pool name '$pool' contains a colon, which Docker volume paths can't handle. Use another pool." ;; *[[:upper:][:space:]]*) warn "Pool name '$pool' has capitals or spaces. That works, but lowercase with no spaces is safer for other tools." ;; esac done echo "Configs/stacks pool: $CONFIG_POOL Media pool: $MEDIA_POOL" # ── Login ─────────────────────────────────────────────────────────────────── # One username and password for every app. The answers are kept in a # root-only file inside the configs dataset so a re-run offers them as the # defaults (the apps already hold that login; a different one would lock the # script out of them). SETUP_STATE_FILE="/mnt/$CONFIG_POOL/configs/arr-complete.env" if [ -s "$SETUP_STATE_FILE" ]; then # Written by this script with printf %q, so sourcing it is safe. # shellcheck source=/dev/null . "$SETUP_STATE_FILE" echo "Previous answers loaded from $SETUP_STATE_FILE (Enter keeps them)." fi echo "" echo "Login to create in every app (Enter keeps the value in brackets):" while :; do read -rp " Username [$ADMIN_USER]: " answer [ -z "$answer" ] && break if [[ "$answer" =~ ^[^[:space:]]{3,}$ ]]; then ADMIN_USER="$answer"; break; fi echo " At least 3 characters and no spaces (Profilarr insists on that)." done while :; do read -rsp " Password [$([ "$ADMIN_PASS" = changeme ] && echo changeme || echo 'keep the saved one')]: " answer; echo [ -z "$answer" ] && break if [ "${#answer}" -lt 8 ]; then echo " At least 8 characters (Tracearr insists on that)."; continue; fi if [[ "$answer" =~ ^[[:space:]]|[[:space:]]$ ]]; then echo " No leading or trailing spaces, please."; continue; fi read -rsp " Repeat the password: " answer2; echo if [ "$answer" != "$answer2" ]; then echo " They differ; try again."; continue; fi ADMIN_PASS="$answer"; break done unset answer answer2 # ── Host facts ────────────────────────────────────────────────────────────── # LAN network for the qBittorrent VPN LAN bypass. Use the interface that # carries the default route and its real prefix (not the first address # `hostname -I` returns, which can be a Docker bridge, and not an assumed /24). DEFAULT_IF=$(ip -o route show default 2>/dev/null | awk '{print $5; exit}') HOST_CIDR=$(ip -o -4 addr show dev "${DEFAULT_IF:-lo}" scope global 2>/dev/null | awk '{print $4; exit}') HOST_IP="${HOST_CIDR%/*}" if [ -n "$HOST_CIDR" ]; then CIDR_NETWORK=$(python3 -c 'import ipaddress,sys; print(ipaddress.ip_interface(sys.argv[1]).network)' "$HOST_CIDR" 2>/dev/null) fi if [ -z "${CIDR_NETWORK:-}" ]; then HOST_IP=$(hostname -I | awk '{print $1}') CIDR_NETWORK="${HOST_IP%.*}.0/24" warn "Could not derive the LAN prefix; assuming $CIDR_NETWORK" fi # Timezone for the containers (TZ=): TrueNAS's own by default, or the one # saved from the last run; the user can pick another. Typing a name that isn't # a timezone searches for it, e.g. "london" or "new york". DETECTED_TZ=$(timedatectl show -p Timezone --value 2>/dev/null || true) [ -n "$DETECTED_TZ" ] || DETECTED_TZ=$(cat /etc/timezone 2>/dev/null || true) [ -n "$DETECTED_TZ" ] || DETECTED_TZ="America/New_York" TZ_VALUE=${TZ_VALUE:-$DETECTED_TZ} list_timezones() { timedatectl list-timezones 2>/dev/null && return { echo UTC; awk '!/^#/ { print $3 }' /usr/share/zoneinfo/zone1970.tab /usr/share/zoneinfo/zone.tab 2>/dev/null; } | sort -u } choose_timezone() { local answer pick i local -a zones matches mapfile -t zones < <(list_timezones) while :; do echo "" read -rp "Timezone for the containers [$TZ_VALUE] (Enter keeps it, or type a city or region to search): " answer || return 0 answer="${answer#"${answer%%[![:space:]]*}"}"; answer="${answer%"${answer##*[![:space:]]}"}" [ -z "$answer" ] && return 0 if [ "${#zones[@]}" -eq 0 ]; then # No list to search: accept a name the system has a zone file for. if [ -f "/usr/share/zoneinfo/$answer" ]; then TZ_VALUE="$answer"; return 0; fi echo " '$answer' is not a timezone (e.g. Europe/London, America/Chicago)."; continue fi mapfile -t matches < <(printf '%s\n' "${zones[@]}" | grep -ixF -- "$answer") if [ "${#matches[@]}" -eq 1 ]; then TZ_VALUE="${matches[0]}"; return 0; fi mapfile -t matches < <(printf '%s\n' "${zones[@]}" | grep -iF -- "${answer// /_}") if [ "${#matches[@]}" -eq 0 ]; then echo " Nothing matches '$answer'. Try a big city in your timezone, e.g. Chicago, London, Sydney." continue fi if [ "${#matches[@]}" -eq 1 ]; then TZ_VALUE="${matches[0]}"; echo " Using $TZ_VALUE."; return 0 fi if [ "${#matches[@]}" -gt 30 ]; then echo " ${#matches[@]} timezones match '$answer'; type more of the name (e.g. America/Chicago)." continue fi for i in "${!matches[@]}"; do printf ' %d) %s\n' "$(( i + 1 ))" "${matches[$i]}"; done echo " 0) Search again" while :; do read -rp " Select a timezone [0-${#matches[@]}]: " pick || return 0 [ "$pick" = 0 ] && break if [[ "$pick" =~ ^[0-9]+$ ]] && [ "$pick" -le "${#matches[@]}" ]; then TZ_VALUE="${matches[$(( pick - 1 ))]}"; return 0 fi echo " Please enter a number between 0 and ${#matches[@]}." done done } choose_timezone echo "Containers will use timezone $TZ_VALUE." # Group that owns the Docker socket (999 on a standard TrueNAS install). # Dozzle runs as 568:568 and must join it to see containers. DOCKER_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo 999) # ── GPU for Jellyfin transcoding ──────────────────────────────────────────── # Decided here because it shapes the Jellyfin compose file. What the GPU can # actually decode and encode is measured right after (see below) with # Jellyfin's own ffmpeg in a throwaway container; the Jellyfin step applies it. # NVIDIA : needs the TrueNAS NVIDIA driver (Apps > Settings) for nvidia-smi # and the container runtime to exist; the card is passed with the # container toolkit. # Intel / AMD : the render node under /dev/dri is passed through, plus the # groups that own it, since Jellyfin runs as 568. # Preference when several are present: NVIDIA, then Intel, then AMD. GPU_VENDOR="none"; GPU_NAME=""; GPU_RENDER=""; GPU_NOTE="" detect_gpu() { local node vendor dev if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then GPU_VENDOR="nvidia" GPU_NAME=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1) return 0 fi if lspci 2>/dev/null | grep -iE 'vga|3d|display' | grep -qi nvidia; then GPU_NOTE="An NVIDIA GPU is in the PCI list but its driver is not loaded: turn on 'Install NVIDIA Drivers' under Apps > Settings in the TrueNAS UI and re-run this script. Jellyfin uses software transcoding until then." fi for node in /sys/class/drm/renderD*; do [ -r "$node/device/vendor" ] || continue vendor=$(cat "$node/device/vendor") case "$vendor" in 0x8086) GPU_VENDOR="intel"; GPU_RENDER="/dev/dri/${node##*/}"; break ;; 0x1002) [ "$GPU_VENDOR" = "none" ] && { GPU_VENDOR="amd"; GPU_RENDER="/dev/dri/${node##*/}"; } ;; esac done if [ "$GPU_VENDOR" != "none" ] && [ ! -c "$GPU_RENDER" ]; then GPU_NOTE="render node $GPU_RENDER is not a character device; cannot pass it to Jellyfin" GPU_VENDOR="none" fi if [ "$GPU_VENDOR" != "none" ]; then dev=$(basename "$(readlink -f "/sys/class/drm/${GPU_RENDER##*/}/device" 2>/dev/null)" 2>/dev/null) GPU_NAME=$(lspci -s "$dev" 2>/dev/null | sed 's/^[0-9a-f:.]* [^:]*: //' | head -1) [ -z "$GPU_NOTE" ] || GPU_NOTE="an NVIDIA GPU is also present but its driver is not loaded (Apps > Settings > Install NVIDIA Drivers); the ${GPU_VENDOR^^} GPU is used instead" fi } detect_gpu # Say what was looked at and what came of it, so a software-only result is # never a mystery (VM without passthrough, missing driver, no GPU at all). info "GPU for Jellyfin hardware transcoding" GPU_DRI_NODES=$(find /dev/dri -maxdepth 1 -name 'card*' -o -name 'renderD*' 2>/dev/null | sed 's|.*/||' | sort | tr '\n' ' ') GPU_DISPLAYS=$(lspci 2>/dev/null | grep -iE 'vga|3d|display' | sed 's/^/ /') echo " Checked: nvidia-smi, render nodes under /dev/dri, PCI display adapters" echo " /dev/dri: ${GPU_DRI_NODES:-(absent)}" echo " Display adapters (lspci):"; echo "${GPU_DISPLAYS:- (none listed)}" case "$GPU_VENDOR" in none) echo " Result: no usable GPU; Jellyfin will transcode in software." if [ -n "$GPU_DISPLAYS" ] && ! printf '%s\n' "$GPU_DISPLAYS" | grep -viE '1234:1111|qemu|bochs|virtio|vmware|hyper-v|microsoft corporation|red hat' | grep -q .; then echo " Every display adapter is a virtual one, so TrueNAS is running in a VM and the" echo " physical GPU has not been passed through. Pass it to the VM (PCI passthrough; a" echo " renderD* node then appears under /dev/dri) and re-run this script." elif [[ "$GPU_DRI_NODES" == *card* ]]; then echo " A display device exists but no renderD* node: its driver is not loaded or the GPU" echo " has no video engine Jellyfin can use." fi [ -n "$GPU_NOTE" ] && echo " $GPU_NOTE" ;; nvidia) echo " Result: NVIDIA ${GPU_NAME:-GPU} (NVENC/NVDEC)${GPU_NOTE:+; note: $GPU_NOTE}" ;; *) echo " Result: ${GPU_VENDOR^^} ${GPU_NAME:-GPU} via $GPU_RENDER${GPU_NOTE:+; note: $GPU_NOTE}" ;; esac # What can the GPU really decode and encode? Hardware varies too much for a # model table, so it is measured with Jellyfin's own ffmpeg in a one-off # container (docker run --rm: nothing stays behind except the Jellyfin image, # which the stack needs anyway): # Intel / AMD : vainfo lists the VA-API profiles (VLD = decode, EncSlice = # encode, EncSliceLP = Intel low-power encode); ffmpeg then # checks QSV (Intel) and OpenCL (HDR tone mapping), runs a # real encode per codec, normal and low-power, and tries the # driver's own HDR10 tone mapping (VPP) on a generated clip. # NVIDIA : ffmpeg encodes a test pattern with h264/hevc/av1_nvenc and # decodes generated clips with the *_cuvid decoders (NVDEC), # including 10-bit HEVC and VP9. # Results feed the Jellyfin step. If the container cannot even reach the GPU # (missing NVIDIA runtime, odd permissions), the stack is written without GPU # passthrough rather than failing to start. GPU_ACCEL=""; GPU_DEC=""; GPU_ENC=""; GPU_HEVC10=0; GPU_VP9_10=0; GPU_REXT10=0; GPU_REXT12=0; GPU_LP=""; GPU_TONEMAP=0; GPU_VPP=0; GPU_DRIVER="" JELLYFIN_IMAGE="lscr.io/linuxserver/jellyfin:latest" PROBE_VAAPI=$(cat <<'EOF' set +e FF=/usr/lib/jellyfin-ffmpeg/ffmpeg; VI=/usr/lib/jellyfin-ffmpeg/vainfo [ -x "$VI" ] || VI=$(command -v vainfo) timeout 60 "$VI" --display drm --device "$RENDER" 2>&1 | sed 's/^/vainfo: /' QSV=0 timeout 60 "$FF" -v error -init_hw_device vaapi=va:"$RENDER" -init_hw_device qsv=qs@va -f lavfi -i nullsrc=s=64x64:d=0.1 -f null - >/dev/null 2>&1 && { echo qsv=1; QSV=1; } timeout 60 "$FF" -v error -init_hw_device vaapi=va:"$RENDER" -init_hw_device opencl=ocl@va -f lavfi -i nullsrc=s=64x64:d=0.1 -f null - >/dev/null 2>&1 && echo opencl=1 # Real encodes, with the rate control Jellyfin uses (VBR). vainfo lists an # encoder profile whether or not it works: low-power (VDEnc) encoding on # older Intel generations needs the i915 HuC firmware, and only an actual # encode shows whether it is there. enc_test() { # enc_test if [ "$QSV" = 1 ]; then timeout 90 "$FF" -v error -init_hw_device vaapi=va:"$RENDER" -init_hw_device qsv=qs@va -filter_hw_device qs \ -f lavfi -i "testsrc2=s=320x240:r=25:d=0.4" -vf "format=nv12,hwupload=extra_hw_frames=64" \ -c:v "${1}_qsv" -preset veryfast -low_power "$2" -b:v 2M -maxrate 2M -bufsize 4M -f null - >/dev/null 2>&1 else timeout 90 "$FF" -v error -init_hw_device vaapi=va:"$RENDER" -filter_hw_device va \ -f lavfi -i "testsrc2=s=320x240:r=25:d=0.4" -vf "format=nv12,hwupload" \ -c:v "${1}_vaapi" -low_power "$2" -b:v 2M -maxrate 2M -f null - >/dev/null 2>&1 fi } for c in h264 hevc av1; do if enc_test "$c" 0; then echo "encok_$c=1"; else echo "encfail_$c=1"; fi enc_test "$c" 1 && echo "lpok_$c=1" done # Driver (VPP) tone mapping: tonemap_vaapi needs a real HDR10 input with # mastering-display metadata, so make a tiny one with libx265 first. This is # the filter chain Jellyfin uses for "Enable VPP Tone mapping". T=$(mktemp -d) if timeout 90 "$FF" -v error -y -f lavfi -i "testsrc2=s=320x240:r=25:d=0.4" -pix_fmt yuv420p10le -c:v libx265 \ -color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020nc \ -x265-params "log-level=none:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:master-display=G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1):max-cll=1000,400" \ "$T/hdr10.mp4" >/dev/null 2>&1; then timeout 60 "$FF" -v error -init_hw_device vaapi=va:"$RENDER" -hwaccel vaapi -hwaccel_device va -hwaccel_output_format vaapi \ -i "$T/hdr10.mp4" -vf "tonemap_vaapi=format=nv12:p=bt709:t=bt709:m=bt709" -f null - >/dev/null 2>&1 && echo vpp_tonemap=1 else echo vpp_untested=1; fi rm -rf "$T"; exit 0 EOF ) PROBE_NVIDIA=$(cat <<'EOF' set +e FF=/usr/lib/jellyfin-ffmpeg/ffmpeg; T=$(mktemp -d); cd "$T" || exit 1 SRC="-f lavfi -i testsrc2=s=320x240:r=25:d=0.4" enc() { timeout 60 "$FF" -v error -y $SRC -c:v "$1" -f null - >/dev/null 2>&1; } mk() { local f="$1"; shift; timeout 90 "$FF" -v error -y $SRC "$@" "$f" >/dev/null 2>&1 && [ -s "$f" ]; } dec() { timeout 60 "$FF" -v error -c:v "$1" -i "$2" -f null - >/dev/null 2>&1; } for c in h264 hevc av1; do enc "${c}_nvenc" && echo "enc_$c=1"; done mk h264.mp4 -c:v libx264 -pix_fmt yuv420p && dec h264_cuvid h264.mp4 && echo dec_h264=1 mk hevc.mp4 -c:v libx265 -pix_fmt yuv420p -x265-params log-level=none && dec hevc_cuvid hevc.mp4 && echo dec_hevc=1 mk hevc10.mp4 -c:v libx265 -pix_fmt yuv420p10le -x265-params log-level=none && dec hevc_cuvid hevc10.mp4 && echo dec_hevc10=1 mk vp9.webm -c:v libvpx-vp9 -pix_fmt yuv420p && dec vp9_cuvid vp9.webm && echo dec_vp9=1 mk vp9_10.webm -c:v libvpx-vp9 -pix_fmt yuv420p10le -profile:v 2 && dec vp9_cuvid vp9_10.webm && echo dec_vp9_10=1 mk vp8.webm -c:v libvpx -pix_fmt yuv420p && dec vp8_cuvid vp8.webm && echo dec_vp8=1 mk mpeg2.mpg -c:v mpeg2video -pix_fmt yuv420p && dec mpeg2_cuvid mpeg2.mpg && echo dec_mpeg2video=1 if mk av1.mkv -c:v libsvtav1 -pix_fmt yuv420p -preset 12 || mk av1.mkv -c:v libaom-av1 -pix_fmt yuv420p -cpu-used 8; then dec av1_cuvid av1.mkv && echo dec_av1=1 else echo av1_untested=1; fi rm -rf "$T"; exit 0 EOF ) gpu_tonemap_summary() { if [ "$GPU_TONEMAP" = 1 ] && [ "$GPU_VPP" = 1 ]; then echo "on (OpenCL, plus Intel VPP for HDR10)" elif [ "$GPU_TONEMAP" = 1 ]; then echo "on (OpenCL/CUDA)" elif [ "$GPU_VPP" = 1 ]; then echo "HDR10 only, via the Intel driver (VPP); no OpenCL runtime in this image, so HLG/Dolby Vision are not tone mapped" else echo "off (no OpenCL runtime in this image and no driver tone mapping)"; fi } probe_gpu() { local out echo " Measuring what the $GPU_VENDOR GPU can decode/encode with Jellyfin's ffmpeg in a throwaway container" echo " (docker run --rm $JELLYFIN_IMAGE; nothing stays behind except the image, which the stack needs anyway)..." case "$GPU_VENDOR" in nvidia) out=$(timeout 900 docker run --rm --gpus all -e NVIDIA_DRIVER_CAPABILITIES=all --entrypoint /bin/bash "$JELLYFIN_IMAGE" -c "$PROBE_NVIDIA" 2>&1) ;; *) out=$(timeout 900 docker run --rm --device /dev/dri:/dev/dri -e RENDER="$GPU_RENDER" --entrypoint /bin/bash "$JELLYFIN_IMAGE" -c "$PROBE_VAAPI" 2>&1) ;; esac local rc=$? if [ $rc -ne 0 ]; then GPU_NOTE="GPU probe container failed (exit $rc): $(printf '%s' "$out" | grep -v '^vainfo:' | tail -2 | tr '\n' ' '). Jellyfin runs without the GPU." GPU_VENDOR="none"; return 1 fi local parsed parsed=$(printf '%s\n' "$out" | VENDOR="$GPU_VENDOR" python3 -c ' import json, os, re, sys lines = sys.stdin.read().splitlines() flags = {l.split("=")[0]: 1 for l in lines if re.fullmatch(r"[a-z0-9_]+=1", l)} dec, enc, lp = [], [], [] hevc10 = vp9_10 = rext10 = rext12 = 0 driver = "" if os.environ["VENDOR"] == "nvidia": dec = [c for c in ("h264", "hevc", "vp8", "vp9", "av1", "mpeg2video") if flags.get("dec_" + c)] if "h264" in dec: dec.append("vc1") # NVDEC has decoded VC-1 on every generation that has NVDEC if "av1_untested" in flags and flags.get("enc_av1"): dec.append("av1") # AV1 encode implies AV1 decode enc = [c for c in ("h264", "hevc", "av1") if flags.get("enc_" + c)] hevc10, vp9_10 = flags.get("dec_hevc10", 0), flags.get("dec_vp9_10", 0) else: for l in lines: m = re.match(r"vainfo:\s*VAProfile(\S+)\s*:\s*VAEntrypoint(\S+)", l) dm = re.search(r"vainfo:.*Driver version:\s*(.*)", l) if dm: driver = dm.group(1).strip() if not m: continue prof, ep = m.groups() if ep == "VLD": if prof.startswith("H264"): dec.append("h264") elif prof in ("HEVCMain", "HEVCMain10"): dec.append("hevc"); hevc10 |= prof == "HEVCMain10" elif prof.startswith("HEVC"): rext10 |= prof in ("HEVCMain422_10", "HEVCMain444", "HEVCMain444_10"); rext12 |= prof in ("HEVCMain12", "HEVCMain422_12", "HEVCMain444_12") elif prof in ("VP9Profile0", "VP9Profile2"): dec.append("vp9"); vp9_10 |= prof == "VP9Profile2" elif prof == "AV1Profile0": dec.append("av1") elif prof.startswith("MPEG2"): dec.append("mpeg2video") elif prof.startswith("VC1"): dec.append("vc1") elif prof.startswith("VP8"): dec.append("vp8") elif ep in ("EncSlice", "EncSliceLP"): c = "h264" if prof.startswith("H264") else "hevc" if prof == "HEVCMain" else "av1" if prof == "AV1Profile0" else None if c: enc.append(c) if ep == "EncSliceLP": lp.append(c) dedupe = lambda xs: sorted(set(xs), key=["h264", "hevc", "mpeg2video", "vc1", "vp8", "vp9", "av1"].index) notes = [] if os.environ["VENDOR"] != "nvidia": # The driver lists a profile; the test encode decides. for c in dedupe(enc): if flags.get("encfail_" + c): notes.append(c + " encode is listed by the driver but a test encode failed; hardware " + c + " encoding stays off") bad_lp = [c for c in dedupe(lp) if not flags.get("lpok_" + c)] if bad_lp: notes.append("low-power " + "/".join(bad_lp) + " encoding is listed by the driver but a test encode failed (i915 HuC firmware not loaded?); the normal encoder is used instead") enc = [c for c in enc if not flags.get("encfail_" + c)] lp = [c for c in lp if flags.get("lpok_" + c)] print(json.dumps({"dec": dedupe(dec), "enc": dedupe(enc), "lp": dedupe(lp), "hevc10": int(bool(hevc10)), "vp9_10": int(bool(vp9_10)), "rext10": int(bool(rext10)), "rext12": int(bool(rext12)), "qsv": flags.get("qsv", 0), "opencl": flags.get("opencl", 0), "vpp": flags.get("vpp_tonemap", 0), "driver": driver, "notes": notes}))') GPU_DEC=$(printf '%s' "$parsed" | jpy "','.join(d['dec'])") GPU_ENC=$(printf '%s' "$parsed" | jpy "','.join(d['enc'])") GPU_LP=$(printf '%s' "$parsed" | jpy "','.join(d['lp'])") GPU_HEVC10=$(printf '%s' "$parsed" | jpy "d['hevc10']"); GPU_VP9_10=$(printf '%s' "$parsed" | jpy "d['vp9_10']") GPU_REXT10=$(printf '%s' "$parsed" | jpy "d['rext10']"); GPU_REXT12=$(printf '%s' "$parsed" | jpy "d['rext12']") GPU_DRIVER=$(printf '%s' "$parsed" | jpy "d['driver']") local note while IFS= read -r note; do [ -n "$note" ] && warn "GPU: $note"; done < <(printf '%s' "$parsed" | jpy "'\n'.join(d['notes'])") if [ -z "$GPU_DEC" ] && [ -z "$GPU_ENC" ]; then GPU_NOTE="The $GPU_VENDOR GPU exposed no decoders or encoders to Jellyfin's ffmpeg${GPU_DRIVER:+ (driver: $GPU_DRIVER)}; Jellyfin runs without it. Probe output: $(printf '%s' "$out" | tail -3 | tr '\n' ' ')" GPU_VENDOR="none"; return 1 fi case "$GPU_VENDOR" in nvidia) GPU_ACCEL="nvenc"; GPU_TONEMAP=1 ;; # CUDA tone mapping is available wherever NVENC is intel) if [ "$(printf '%s' "$parsed" | jpy "d['qsv']")" = "1" ]; then GPU_ACCEL="qsv"; else GPU_ACCEL="vaapi"; fi GPU_TONEMAP=$(printf '%s' "$parsed" | jpy "d['opencl']"); GPU_VPP=$(printf '%s' "$parsed" | jpy "d['vpp']") ;; amd) GPU_ACCEL="vaapi"; GPU_TONEMAP=$(printf '%s' "$parsed" | jpy "d['opencl']") ;; esac local tenbit="" [ "$GPU_HEVC10" = 1 ] && tenbit+=", 10-bit HEVC" [ "$GPU_VP9_10" = 1 ] && tenbit+=", 10-bit VP9" [ "$GPU_REXT10" = 1 ] && tenbit+=", HEVC RExt 10-bit" [ "$GPU_REXT12" = 1 ] && tenbit+=", HEVC RExt 12-bit" ok "GPU probe finished. The throwaway container found:" [ -n "$GPU_DRIVER" ] && echo " driver: $GPU_DRIVER" echo " hardware decode: ${GPU_DEC:-none}${tenbit}" echo " hardware encode: ${GPU_ENC:-none}${GPU_LP:+ (Intel low-power: $GPU_LP)}" echo " acceleration: $GPU_ACCEL" echo " HDR tone mapping: $(gpu_tonemap_summary)" echo " These settings are applied to Jellyfin in step 7." } [ "$GPU_VENDOR" = "none" ] || probe_gpu || warn "$GPU_NOTE" echo "" # Compose fragments for the Jellyfin stack (already indented for the file). JELLYFIN_GPU_ENV="" JELLYFIN_GPU_BLOCK="" case "$GPU_VENDOR" in intel|amd) render_gid=$(stat -c '%g' "$GPU_RENDER") # The card node of the same device (its group is usually "video"). card_node=$(find "/sys/class/drm/${GPU_RENDER##*/}/device/drm" -maxdepth 1 -name 'card*' 2>/dev/null | head -1) video_gid=$(stat -c '%g' "/dev/dri/${card_node##*/}" 2>/dev/null || echo "$render_gid") JELLYFIN_GPU_BLOCK=" devices: - /dev/dri:/dev/dri group_add: - \"$render_gid\"" [ "$video_gid" != "$render_gid" ] && JELLYFIN_GPU_BLOCK+=" - \"$video_gid\"" ;; nvidia) JELLYFIN_GPU_ENV=" - NVIDIA_VISIBLE_DEVICES=all - NVIDIA_DRIVER_CAPABILITIES=all" JELLYFIN_GPU_BLOCK=" deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: [gpu]" ;; esac # ── Layout ────────────────────────────────────────────────────────────────── # configs/: one dataset per app. Seerr is the exception: its container # runs as UID 1000 (the node user) by default, so its dataset is owned by # 1000 — see wiki.serversatho.me/en/seerr. # stacks/: one DIRECTORY per container inside the stacks dataset, # each with its own compose.yaml — the layout Dockge manages. CONFIG_DATASETS=("prowlarr" "radarr" "sonarr" "seerr" "profilarr" "bazarr" "jellyfin" "qbittorrent" "qui" "dozzle" "tracearr" "trawl" "dockge") MEDIA_SUBDIRECTORIES=("movies" "tv" "downloads" "downloads/torrent" "downloads/crossseed") STACKS=("qbittorrent" "radarr" "sonarr" "trawl" "prowlarr" "profilarr" "jellyfin" "seerr" "bazarr" "qui" "tracearr" "dozzle" "watchtower" "dockge") CONFIGS_PATH="/mnt/$CONFIG_POOL/configs" STACKS_PATH="/mnt/$CONFIG_POOL/stacks" MEDIA_PATH="/mnt/$MEDIA_POOL/media" QBITTORRENT_WIREGUARD_DIR="$CONFIGS_PATH/qbittorrent/wireguard" DOCKER_NETWORK="arr" # set_perms [owner] (default root:apps, 770) set_perms() { local path="$1" owner="${2:-root:apps}" chown "$owner" "$path" chmod 770 "$path" } # create_dataset [owner] # Prefers the TrueNAS middleware (midclt) so the UI knows about the dataset; # falls back to plain `zfs create` where midclt isn't available. create_dataset() { local pool_name="$1" dataset_name="$2" owner="${3:-root:apps}" local dataset_path="$pool_name/$dataset_name" local mountpoint="/mnt/$dataset_path" if ! zfs list "$dataset_path" >/dev/null 2>&1; then echo "Creating dataset: $dataset_path" if command -v midclt >/dev/null 2>&1; then midclt call pool.dataset.create "{\"name\": \"$dataset_path\"}" >/dev/null \ || zfs create "$dataset_path" \ || die "Failed to create dataset $dataset_path" else zfs create "$dataset_path" || die "Failed to create dataset $dataset_path" fi fi if ! mountpoint -q "$mountpoint"; then echo "Mounting dataset: $dataset_path" zfs mount "$dataset_path" || true fi if [ -d "$mountpoint" ]; then set_perms "$mountpoint" "$owner" else die "$mountpoint does not exist after mounting. Check dataset status." fi } # create_directory [owner] create_directory() { local dir_path="$1" owner="${2:-root:apps}" if [ ! -d "$dir_path" ]; then echo "Creating directory: $dir_path" mkdir -p "$dir_path" || die "Failed to create $dir_path" else echo "Directory already exists: $dir_path, updating permissions..." fi set_perms "$dir_path" "$owner" } # ── Datasets ──────────────────────────────────────────────────────────────── create_dataset "$CONFIG_POOL" "configs" # save_answers — keep the answers for re-runs (again after the forwarded port, # which is only asked when launching). save_answers() { { echo "# Answers from the last run of arr-complete.sh (root only). Delete to be asked afresh." printf 'ADMIN_USER=%q\nADMIN_PASS=%q\nQB_FORWARDED_PORT=%q\nTZ_VALUE=%q\n' "$ADMIN_USER" "$ADMIN_PASS" "$QB_FORWARDED_PORT" "$TZ_VALUE" } > "$SETUP_STATE_FILE" && chmod 600 "$SETUP_STATE_FILE" } save_answers for dataset in "${CONFIG_DATASETS[@]}"; do if [ "$dataset" = "seerr" ]; then create_dataset "$CONFIG_POOL" "configs/$dataset" "1000:1000" else create_dataset "$CONFIG_POOL" "configs/$dataset" fi done create_dataset "$MEDIA_POOL" "media" for subdir in "${MEDIA_SUBDIRECTORIES[@]}"; do create_directory "$MEDIA_PATH/$subdir" done # Tracearr's all-in-one image keeps its database, Redis and app data in three # folders; its entrypoint owns them for the internal postgres/redis users, so # they are created once and never re-owned (a running database must keep its # ownership). for subdir in postgres redis data; do [ -d "$CONFIGS_PATH/tracearr/$subdir" ] || create_directory "$CONFIGS_PATH/tracearr/$subdir" done # Its backups go to /data/backup, which the entrypoint does not re-own: the # folder belongs to the image's tracearr user (UID 1001) from the start. [ -d "$CONFIGS_PATH/tracearr/backup" ] || create_directory "$CONFIGS_PATH/tracearr/backup" "1001:apps" create_dataset "$CONFIG_POOL" "stacks" for stack in "${STACKS[@]}"; do create_directory "$STACKS_PATH/$stack" done # ── Compose stacks ────────────────────────────────────────────────────────── # One compose.yaml per container under stacks/. Each file is # regenerated every run (it has to reflect the pools you chose); when the # existing file differs it is backed up first, so hand edits are never lost. # Every stack joins the shared "arr" network, created below, so the # containers keep reaching each other by name (radarr, qbittorrent, ...) # even though they are separate compose projects. NETWORK_BLOCK=" networks: default: name: $DOCKER_NETWORK external: true" # write_stack (compose text on stdin, without the networks block) write_stack() { local name="$1" file="$STACKS_PATH/$1/compose.yaml" tmp backup tmp=$(mktemp) { cat; printf '%s\n' "$NETWORK_BLOCK"; } > "$tmp" if [ -f "$file" ] && ! cmp -s "$tmp" "$file"; then backup="$file.bak-$(date +%Y%m%d-%H%M%S)" cp -p "$file" "$backup" warn "stacks/$name/compose.yaml changed; the previous one is at $backup" fi mv "$tmp" "$file" chown root:apps "$file"; chmod 660 "$file" } write_stack prowlarr <-...). This Watchtower fork matches # names as regexes (wiki.serversatho.me/en/Watchtower), so "ix*" would # match nothing useful; the ^ keeps it to names that start with ix-. - WATCHTOWER_DISABLE_CONTAINERS=^ix-.* - WATCHTOWER_NO_STARTUP_MESSAGE=true - WATCHTOWER_SCHEDULE=0 0 3 * * * image: nickfedor/watchtower restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock EOF # Dockge manages the stacks directory. It must see the directory at the SAME # path inside the container as on the host, because the compose commands it # runs refer to that path. write_stack dockge </compose.yaml up -d" exit 0 fi docker info >/dev/null 2>&1 \ || die "Cannot talk to the Docker daemon. Enable the Apps service in the TrueNAS UI, then re-run." # WireGuard config for qBittorrent (hotio expects /config/wireguard/wg0.conf). # Asked here, once the qbittorrent dataset exists and before the stacks start, # so qBittorrent comes up with its tunnel. Paste one, or generate one from # AirVPN with airvpn-wg.sh: that script and its settings live next to wg0.conf # (its cron job, if any, runs it from there). WG_FILE="$QBITTORRENT_WIREGUARD_DIR/wg0.conf" AIRVPN_WG="$QBITTORRENT_WIREGUARD_DIR/airvpn-wg.sh" AIRVPN_WG_URL="https://raw.githubusercontent.com/serversathome/ServersatHome/refs/heads/main/airvpn-wg.sh" create_directory "$QBITTORRENT_WIREGUARD_DIR" # paste_wg — read a pasted wg0.conf and save it. paste_wg() { local conf echo "Paste your WireGuard configuration below using SHIFT+INS (press ENTER then Ctrl+D when done):" conf=$(cat) conf=${conf//$'\r'/} # a config copied from Windows keeps CRLF line ends if [ -z "${conf//[[:space:]]/}" ]; then warn "No WireGuard configuration entered." return fi grep -q '^\[Interface\]' <<<"$conf" && grep -q '^\[Peer\]' <<<"$conf" \ || warn "That does not look like a WireGuard config (no [Interface]/[Peer] sections); saving it anyway." printf '%s\n' "$conf" > "$WG_FILE" chown root:apps "$WG_FILE" chmod 660 "$WG_FILE" ok "WireGuard configuration saved to $WG_FILE" } # fetch_airvpn_wg — put the latest airvpn-wg.sh in the wireguard folder: the # repo's, else (offline) keep the one already there, else the copy next to # this script. Its monitor cron job runs that copy, so re-runs refresh it. # Prints what happened; returns 1 if there is no copy at all. fetch_airvpn_wg() { local local_copy local_copy="$(cd "$(dirname "$0")" && pwd)/airvpn-wg.sh" if curl -fsSL -m 60 "$AIRVPN_WG_URL" -o "$AIRVPN_WG.tmp" && grep -q '^# AirVPN WireGuard config generator' "$AIRVPN_WG.tmp"; then if [ -f "$AIRVPN_WG" ] && cmp -s "$AIRVPN_WG.tmp" "$AIRVPN_WG"; then rm -f "$AIRVPN_WG.tmp" else [ -f "$AIRVPN_WG" ] && ok "airvpn-wg.sh updated to the latest version (its monitor cron job runs it)" mv "$AIRVPN_WG.tmp" "$AIRVPN_WG" fi else rm -f "$AIRVPN_WG.tmp" # Offline: keep a working installed copy rather than swap in whatever # sits next to this script, which may be older. if [ -f "$AIRVPN_WG" ]; then warn "Could not download the latest airvpn-wg.sh; keeping the copy in $QBITTORRENT_WIREGUARD_DIR." elif [ -f "$local_copy" ]; then warn "Could not download the latest airvpn-wg.sh; using the copy next to arr-complete.sh." cp "$local_copy" "$AIRVPN_WG" else return 1 fi fi chown root:root "$AIRVPN_WG" chmod 700 "$AIRVPN_WG" } # airvpn_wg — fetch airvpn-wg.sh into the wireguard folder and run it # interactively. airvpn_wg() { fetch_airvpn_wg || { warn "Could not download airvpn-wg.sh ($AIRVPN_WG_URL)."; return; } echo "" AIRVPN_WG_EMBEDDED=1 bash "$AIRVPN_WG" --output "$WG_FILE" if [ -s "$WG_FILE" ]; then WG_FROM_AIRVPN=1 else warn "No WireGuard config was generated." fi } WG_FROM_AIRVPN=0 if [ -s "$WG_FILE" ]; then echo "WireGuard config already present at $WG_FILE (leaving it alone)." if [ -f "$AIRVPN_WG" ]; then fetch_airvpn_wg echo " To pick another AirVPN server or change alerts: bash $(q "$AIRVPN_WG") --output $(q "$WG_FILE")" fi else echo "" echo "qBittorrent needs a WireGuard config (wg0.conf) for its VPN:" echo " 1) Paste a wg0.conf from your VPN provider" echo " 2) Auto-generate a wg0.conf for me (AirVPN only, needs an API key)" echo " 3) Skip for now" while :; do # End of input (nothing left to read) counts as skip instead of looping. read -rp "Choose [1-3]: " answer || answer=3 case "$answer" in 1) paste_wg; break ;; 2) airvpn_wg; break ;; 3) break ;; *) echo "Please enter 1, 2 or 3." ;; esac done unset answer fi # Without a wg0.conf qBittorrent cannot bring its tunnel up: its stack is not # started and the steps that need it are skipped, with one note to finish. WG_MISSING=0 if [ ! -s "$WG_FILE" ]; then WG_MISSING=1 if [ -f "$AIRVPN_WG" ]; then wg_how="bash $(q "$AIRVPN_WG") --output $(q "$WG_FILE")" else wg_how="wget -O airvpn-wg.sh $AIRVPN_WG_URL && bash airvpn-wg.sh --output $(q "$WG_FILE")"; fi todo "qBittorrent has no WireGuard config yet, so it was not started or configured. Put a wg0.conf at $WG_FILE (paste your provider's, or for AirVPN: $wg_how), then re-run arr-complete.sh to start and configure qBittorrent and connect Radarr, Sonarr and Qui to it." warn "No WireGuard config: qBittorrent will not be started; the rest of the stack goes ahead." fi # Forwarded port: asked here, after the VPN config, since only a launch uses # it. qBittorrent's stack was written with the saved value; it is rewritten # (backing up the old file, as write_stack does) only if the answer changes. echo "" echo "qBittorrent VPN port forwarding (optional): if your VPN provider gave you a fixed" echo "forwarded port (AirVPN: Client Area -> Forwarded Ports), enter it and qBittorrent" echo "listens on it. Enter keeps the value in brackets." QB_PORT_WRITTEN=$QB_FORWARDED_PORT while :; do read -rp " Forwarded port [${QB_FORWARDED_PORT:-none}]: " answer || break [ -z "$answer" ] && break if [ "$answer" = none ]; then QB_FORWARDED_PORT=""; break; fi if [[ "$answer" =~ ^[0-9]+$ ]] && [ "$answer" -ge 1 ] && [ "$answer" -le 65535 ]; then QB_FORWARDED_PORT="$answer"; break; fi echo " A number between 1 and 65535, Enter to keep, or 'none' for no forwarded port." done unset answer if [ "$QB_FORWARDED_PORT" != "$QB_PORT_WRITTEN" ]; then write_qbittorrent_stack save_answers if [ -n "$QB_FORWARDED_PORT" ]; then ok "qBittorrent stack updated for forwarded port $QB_FORWARDED_PORT" else ok "qBittorrent stack updated: no forwarded port"; fi fi # The shared network, once. if docker network inspect "$DOCKER_NETWORK" >/dev/null 2>&1; then echo "Docker network '$DOCKER_NETWORK' already exists." else docker network create "$DOCKER_NETWORK" >/dev/null || die "Could not create the Docker network '$DOCKER_NETWORK'." echo "Docker network '$DOCKER_NETWORK' created." fi # Trawl replaced FlareSolverr. On an install made before that, take the old # stack down (it holds port 8191) and move its compose file out of stacks/ so # Dockge stops listing it; FlareSolverr kept no data. if [ -f "$STACKS_PATH/flaresolverr/compose.yaml" ] || docker container inspect flaresolverr >/dev/null 2>&1; then echo "Replacing FlareSolverr with Trawl..." if [ -f "$STACKS_PATH/flaresolverr/compose.yaml" ]; then docker compose -f "$STACKS_PATH/flaresolverr/compose.yaml" down >/dev/null 2>&1 mv "$STACKS_PATH/flaresolverr/compose.yaml" "$STACKS_PATH/flaresolverr-compose.yaml.bak-$(date +%Y%m%d-%H%M%S)" rmdir "$STACKS_PATH/flaresolverr" 2>/dev/null \ || warn "stacks/flaresolverr still holds other files; Dockge may keep listing it." fi docker rm -f flaresolverr >/dev/null 2>&1 ok "FlareSolverr removed (its compose file is kept in $STACKS_PATH as flaresolverr-compose.yaml.bak-*)" fi # Each stack is its own compose project, named after its directory (that is # what Dockge expects to find). echo "Launching the stacks from $STACKS_PATH..." for stack in "${STACKS[@]}"; do [ "$stack" = qbittorrent ] && [ "$WG_MISSING" = 1 ] && { echo "Not starting qbittorrent (no WireGuard config yet)."; continue; } docker compose -f "$STACKS_PATH/$stack/compose.yaml" up -d \ || todo "The '$stack' stack did not launch. Check: docker compose -f $(q "$STACKS_PATH/$stack/compose.yaml") logs" done echo "Stacks launched." # ============================================================================ # AUTO-CONFIGURATION PHASE # # Containers talk to each other by container name over the shared "arr" # network (radarr, sonarr, qbittorrent, ...). This script talks to them from # the TrueNAS shell through the published ports on localhost. # ============================================================================ export ADMIN_USER ADMIN_PASS ADMIN_EMAIL QB_URL="http://localhost:8080" RADARR_URL="http://localhost:7878"; RADARR_INT="http://radarr:7878" SONARR_URL="http://localhost:8989"; SONARR_INT="http://sonarr:8989" PROWLARR_URL="http://localhost:9696"; PROWLARR_INT="http://prowlarr:9696" TRAWL_URL="http://localhost:8191"; TRAWL_INT="http://trawl:8191/" PROFILARR_URL="http://localhost:6868" JELLYFIN_URL="http://localhost:8096"; JELLYFIN_INT="http://jellyfin:8096" SEERR_URL="http://localhost:5055"; SEERR_INT="http://seerr:5055" BAZARR_URL="http://localhost:6767" QUI_URL="http://localhost:7476" TRACEARR_URL="http://localhost:3000" DOCKGE_URL="http://localhost:5001" QB_INT="http://qbittorrent:8080" # ── Shared *arr helpers (Radarr / Sonarr / Prowlarr share one API shape) ──── # arr_api [json-body] arr_api() { local base="$1" key="$2" method="$3" path="$4" body="${5-}" http "$method" "$base$path" "$body" -H "X-Api-Key: $key" -H 'Content-Type: application/json' } # arr_key -> prints the API key from the app's config.xml (waits for it) arr_key() { local app="$1" cfg="$CONFIGS_PATH/$1/config.xml" wait_for "$app to write config.xml" 180 bash -c "grep -q '' '$cfg'" || return 1 grep -oP '(?<=)[^<]+' "$cfg" | head -1 | tr -d '[:space:]' } # arr_enable_forms_auth # GET the host config, set Forms login (required for all addresses) and switch # off "Send Anonymous Usage Data", PUT back. The *arr apps need the FULL # object on PUT; a partial body 500s. arr_enable_forms_auth() { local app="$1" base="$2" key="$3" ver="$4" cfg body cfg=$(arr_api "$base" "$key" GET "/api/$ver/config/host") http_ok || { todo "$app: could not read host config; enable Forms login in Settings > General."; return 1; } body=$(printf '%s' "$cfg" | python3 -c ' import json, os, sys d = json.load(sys.stdin) d.update(authenticationMethod="forms", authenticationRequired="enabled", username=os.environ["ADMIN_USER"], password=os.environ["ADMIN_PASS"], passwordConfirmation=os.environ["ADMIN_PASS"], analyticsEnabled=False) print(json.dumps(d))') arr_api "$base" "$key" PUT "/api/$ver/config/host/1" "$body" >/dev/null if http_ok; then ok "$app: Forms login enabled ($ADMIN_USER / $ADMIN_PASS); anonymous usage data off"; else todo "$app: Forms login not applied (HTTP $(http_code)); set it in Settings > General."; fi } # arr_add_root_folder arr_add_root_folder() { local app="$1" base="$2" key="$3" path="$4" existing existing=$(arr_api "$base" "$key" GET /api/v3/rootfolder | jpy "[r['path'] for r in d]") if [[ "$existing" == *"\"$path\""* ]]; then ok "$app: root folder $path already present"; return 0; fi arr_api "$base" "$key" POST /api/v3/rootfolder "{\"path\": \"$path\"}" >/dev/null if http_ok; then ok "$app: root folder $path added"; else todo "$app: could not add root folder $path (HTTP $(http_code)); add it in Settings > Media Management."; fi } # arr_errors — turn an *arr 400 body ([{propertyName, errorMessage}, ...]) # into one line for the messages; anything else is shown as-is, trimmed. arr_errors() { printf '%s' "$1" | python3 -c ' import json, sys raw = sys.stdin.read() try: d = json.loads(raw) if isinstance(d, list) and d and isinstance(d[0], dict) and "errorMessage" in d[0]: print("; ".join("%s: %s" % (e.get("propertyName", ""), e.get("errorMessage", "")) for e in d)); sys.exit(0) except Exception: pass print(raw.strip()[:240])' } # qb_diagnose — when cannot talk to qBittorrent, show why: a reach # probe and ONE login probe from inside the container (qBittorrent bans # an IP after 5 failed logins, so the login probe runs once per script run), # plus the WebUI lines from qBittorrent's own log. QB_LOGIN_PROBED="" qb_diagnose() { local app="$1" out log if docker exec "$app" curl --version >/dev/null 2>&1; then out=$(docker exec "$app" curl -sS -m 10 -o /dev/null -w '%{http_code}' "$QB_INT/api/v2/app/webapiVersion" 2>&1 | tail -c 160) echo " probe: GET $QB_INT/api/v2/app/webapiVersion from inside '$app' -> $out (403 = reachable, login needed; 000 = unreachable)" if [ -z "$QB_LOGIN_PROBED" ]; then QB_LOGIN_PROBED=1 out=$(qb_login_result "$(docker exec "$app" curl -sS -m 10 -w $'\n%{http_code}' --data-urlencode "username=$ADMIN_USER" --data-urlencode "password=$ADMIN_PASS" "$QB_INT/api/v2/auth/login" 2>&1)") echo " probe: login as $ADMIN_USER from inside '$app' -> $out (Ok. = good; Fails. = wrong password; banned = too many failed logins, 1h)" fi else echo " probe: no curl inside '$app'; cannot test the path to qBittorrent from there" fi log=$(find "$CONFIGS_PATH/qbittorrent" -name 'qbittorrent.log' 2>/dev/null | head -1) if [ -n "$log" ]; then echo " qBittorrent log, last WebUI lines:" grep -iE 'webui|webapi|banned|host header' "$log" | tail -5 | sed 's/^/ /' fi } # arr_add_qbittorrent # Builds the client from the app's own schema so field names stay correct. # The client is tested first (POST .../test) and only a passing one is # saved; forceSave then skips the save-time validation. The container name # is tried first, then the TrueNAS LAN address through the published port # as a fallback, and the real error is printed either way. arr_add_qbittorrent() { local app="$1" base="$2" key="$3" cat_field="$4" category="$5" have schema body res host if [ "$WG_MISSING" = 1 ]; then echo " $app: qBittorrent download client skipped (qBittorrent has no WireGuard config yet)"; return 0; fi have=$(arr_api "$base" "$key" GET /api/v3/downloadclient | jpy "next((c for c in d if c['implementation']=='QBittorrent'), None)") if [ -n "$have" ]; then # Present from an earlier run: make sure "Remove Completed" is off. if [ "$(printf '%s' "$have" | jpy "d.get('removeCompletedDownloads')")" = "True" ]; then res=$(arr_api "$base" "$key" PUT "/api/v3/downloadclient/$(printf '%s' "$have" | jpy "d['id']")?forceSave=true" "$(printf '%s' "$have" | jpy "dict(d, removeCompletedDownloads=False)")") if http_ok; then ok "$app: qBittorrent download client already present; 'Remove Completed' switched off"; else warn "$app: could not switch 'Remove Completed' off (HTTP $(http_code)): $(arr_errors "$res")"; fi else ok "$app: qBittorrent download client already present" fi return 0 fi schema=$(arr_api "$base" "$key" GET /api/v3/downloadclient/schema) for host in qbittorrent "$HOST_IP"; do body=$(printf '%s' "$schema" | QB_HOST="$host" CAT_FIELD="$cat_field" CATEGORY="$category" python3 -c ' import json, os, sys t = next(x for x in json.load(sys.stdin) if x.get("implementation") == "QBittorrent") vals = {"host": os.environ["QB_HOST"], "port": 8080, "useSsl": False, "urlBase": "", "username": os.environ["ADMIN_USER"], "password": os.environ["ADMIN_PASS"], os.environ["CAT_FIELD"]: os.environ["CATEGORY"]} for f in t["fields"]: if f["name"] in vals: f["value"] = vals[f["name"]] # "Remove Completed" stays off: finished torrents keep seeding in qBittorrent # (the Qui automations decide when they go); failed ones are still removed. t.update(name="qBittorrent", enable=True, protocol="torrent", priority=1, removeCompletedDownloads=False, removeFailedDownloads=True, tags=[]) t.pop("id", None) print(json.dumps(t))') || { todo "$app: qBittorrent not found in the download client schema."; return 1; } res=$(arr_api "$base" "$key" POST /api/v3/downloadclient/test "$body") http_ok && break warn "$app: qBittorrent connection test via '$host' failed (HTTP $(http_code)): $(arr_errors "$res")" [ "$host" = qbittorrent ] && qb_diagnose "$app" body="" done if [ -z "$body" ]; then todo "$app: qBittorrent download client not added: the connection test fails via the container name and via $HOST_IP (see the probes above). Add it in Settings > Download Clients once qBittorrent is reachable." return 1 fi res=$(arr_api "$base" "$key" POST '/api/v3/downloadclient?forceSave=true' "$body") if http_ok; then ok "$app: qBittorrent download client added (host '$host', category '$category')" [ "$host" = qbittorrent ] || warn "$app: the container name 'qbittorrent' did not work from $app; the client uses the host address $host instead." else todo "$app: could not add the qBittorrent download client (HTTP $(http_code)): $(arr_errors "$res")" fi } # ── 1. qBittorrent ────────────────────────────────────────────────────────── # The setup calls go to the WebUI from INSIDE the container (hotio's image # ships curl), which works even while the VPN firewall is still settling; the # published port is the fallback. # # Two traps, both hit on real runs: # * A login from loopback proves NOTHING about the password: with "bypass # authentication for clients on localhost" qBittorrent answers "Ok." to any # password. The login is therefore verified from the host through the # published port on every run (there the source is the Docker bridge and # the check is real) and set whenever that check fails. # * 5 failed logins ban the client IP for an hour (in memory). After the # password is (re)set the container is restarted once so Radarr, Sonarr # and Qui, which may have been banned by then, start clean. QB_MODE="host" # The cookie file must exist on the same filesystem curl runs on; /tmp is # present both on the host and inside the container. QB_COOKIE="/tmp/truenas-setup-qb.cookie" qb_curl() { if [ "$QB_MODE" = "exec" ]; then docker exec qbittorrent curl -sS -m 30 "$@" else curl -sS -m 30 "$@"; fi } qb_up() { [ "$(qb_curl -o /dev/null -w '%{http_code}' "$QB_URL/" 2>/dev/null)" = "200" ]; } # qb_login_result "> -> "Ok." | "Fails." | "banned [HTTP 403]" | " [HTTP code]" # The login API changed in qBittorrent 5.2: a good login answers 204 No # Content (older versions: 200 "Ok."), a bad one 401 (older: 200 "Fails."), # and a banned address 403 in both. Judge by the status code first. qb_login_result() { local out="$1" code body code="${out##*$'\n'}"; body="${out%$'\n'*}"; [ "$body" = "$out" ] && body="" case "$code" in 2??) if [ "$body" = "Fails." ]; then echo "Fails."; else echo "Ok."; fi ;; 401) echo "Fails." ;; 403) echo "banned [HTTP 403]" ;; *) printf '%s [HTTP %s]\n' "$(printf '%s' "$body" | tr -d '\r' | head -c 100)" "${code:-none}" ;; esac } qb_login() { # qb_login -> session cookie in $QB_COOKIE on success local out out=$(qb_curl -c "$QB_COOKIE" -w $'\n%{http_code}' --data-urlencode "username=$1" --data-urlencode "password=$2" "$QB_URL/api/v2/auth/login" 2>/dev/null) [ "$(qb_login_result "$out")" = "Ok." ] } qb_post() { qb_curl -b "$QB_COOKIE" "$@"; } # qb_post
qb_code() { qb_post -o /dev/null -w '%{http_code}' "$@" 2>/dev/null; } # HTTP status of a logged-in call qb_2xx() { case "$1" in 2??) return 0 ;; *) return 1 ;; esac; } # qBittorrent 5.2 answers 204 where older versions answered 200 # qb_login_from_host -> "Ok." | "Fails." | "banned [HTTP 403]" | " [HTTP code]" # A real credential check: the request enters through the published port. qb_login_from_host() { local out out=$(curl -sS -m 15 -w $'\n%{http_code}' --data-urlencode "username=$1" --data-urlencode "password=$2" "$QB_URL/api/v2/auth/login" 2>&1) qb_login_result "$out" } qb_pref() { printf '%s' "$1" | jpy "d.get('$2')"; } configure_qbittorrent() { info "[1/12] qBittorrent" if [ "$WG_MISSING" = 1 ]; then echo " skipped: no WireGuard config yet (see the note at the end)"; return 0; fi if docker exec qbittorrent curl --version >/dev/null 2>&1; then QB_MODE="exec"; fi # QB_URL is used both from inside the container and from the host; inside # the container localhost:8080 is the WebUI itself, so the same URL works. wait_for "qBittorrent WebUI (needs the VPN tunnel up)" 300 qb_up \ || { todo "qBittorrent never came up. Check the VPN config: docker logs qbittorrent"; return 1; } # 1. A session for the setup calls (loopback; may be a bypass session). local tmp_pw if ! qb_login "$ADMIN_USER" "$ADMIN_PASS"; then tmp_pw=$(docker logs qbittorrent 2>&1 | grep -oP 'temporary password is provided for this session: \K\S+' | tail -1) [ -n "$tmp_pw" ] || { todo "qBittorrent: no temporary password in the logs and $ADMIN_USER/$ADMIN_PASS did not work; set the WebUI password manually."; return 1; } qb_login "admin" "$tmp_pw" || { todo "qBittorrent: login with the temporary password failed; set the WebUI password manually."; return 1; } fi local prefs prefs=$(qb_post "$QB_URL/api/v2/app/preferences" 2>/dev/null) echo " qBittorrent WebUI settings: bypass_local_auth=$(qb_pref "$prefs" bypass_local_auth) host_header_validation=$(qb_pref "$prefs" web_ui_host_header_validation_enabled) csrf=$(qb_pref "$prefs" web_ui_csrf_protection_enabled) max_auth_fail=$(qb_pref "$prefs" web_ui_max_auth_fail_count) ban=$(qb_pref "$prefs" web_ui_ban_duration)s" # 2. Is the login already right? One attempt from the host (counts toward # the host's 5-strike limit, so only one). local verify code verify=$(qb_login_from_host "$ADMIN_USER" "$ADMIN_PASS") if [[ "$verify" == Ok.* ]]; then ok "qBittorrent: WebUI login $ADMIN_USER / $ADMIN_PASS verified from outside the container" else echo " qBittorrent: login from the host answered '$verify'; setting the WebUI login now." code=$(qb_code --data-urlencode "json=$(jbuild '{"web_ui_username": os.environ["ADMIN_USER"], "web_ui_password": os.environ["ADMIN_PASS"]}')" "$QB_URL/api/v2/app/setPreferences") qb_2xx "$code" || { todo "qBittorrent: setPreferences for the WebUI login returned HTTP $code; set $ADMIN_USER / $ADMIN_PASS in Tools > Options > WebUI."; return 1; } # 3. Restart: flushes the new login to disk and clears the in-memory # IP bans earlier failed logins (Radarr, Sonarr, Qui, you) have # collected. The VPN tunnel comes back on its own. sleep 5 echo " qBittorrent: restarting the container to clear login bans..." docker restart qbittorrent >/dev/null 2>&1 wait_for "qBittorrent WebUI after the restart" 300 qb_up \ || { todo "qBittorrent did not come back after the restart: docker logs qbittorrent"; return 1; } verify=$(qb_login_from_host "$ADMIN_USER" "$ADMIN_PASS") case "$verify" in Ok.*) ok "qBittorrent: WebUI login set to $ADMIN_USER / $ADMIN_PASS (verified from outside the container)" ;; Fails.*) todo "qBittorrent: the WebUI still rejects $ADMIN_USER / $ADMIN_PASS from outside the container; set it in Tools > Options > WebUI. Radarr, Sonarr and Qui cannot log in until then."; return 1 ;; *) todo "qBittorrent: port 8080 not reachable from the TrueNAS host ($verify); check VPN_LAN_NETWORK and: docker logs qbittorrent"; return 1 ;; esac qb_login "$ADMIN_USER" "$ADMIN_PASS" || { todo "qBittorrent: could not log back in after the restart; check the WebUI."; return 1; } fi # Save paths per wiki.serversatho.me/en/Folder-Structure: completed downloads # straight into /media/downloads (no per-category subfolders), incomplete # ones and .torrent files in /media/downloads/torrent. code=$(qb_code --data-urlencode 'json={"save_path":"/media/downloads","temp_path_enabled":true,"temp_path":"/media/downloads/torrent","export_dir":"/media/downloads/torrent","auto_tmm_enabled":true,"torrent_content_layout":"Original"}' \ "$QB_URL/api/v2/app/setPreferences") if qb_2xx "$code"; then ok "qBittorrent: save paths set (/media/downloads, incomplete in /media/downloads/torrent)"; else todo "qBittorrent: could not set the save paths (HTTP $code); set them in Tools > Options > Downloads."; fi # WebUI: bypass authentication for clients on localhost. Set only after the # real login was verified from the host above, because from here on a # loopback login succeeds with any password. code=$(qb_code --data-urlencode 'json={"bypass_local_auth":true}' "$QB_URL/api/v2/app/setPreferences") if qb_2xx "$code"; then ok "qBittorrent: bypass authentication for clients on localhost enabled"; else todo "qBittorrent: could not enable the localhost authentication bypass (HTTP $code); tick it in Tools > Options > WebUI."; fi # Connection / BitTorrent / Advanced per wiki.serversatho.me/en/qBittorrent: # no connection limits (-1 = box unchecked), no torrent queueing, more # parallel checking, all torrent traffic bound to the VPN interface on # every IPv4 address, and the Default disk IO type (0), which on hotio's # default libtorrent 2.1 build is pread/pwrite. Installs made when this # script set POSIX-compliant (2) are moved back to Default; # qBittorrent reads it at startup, so it applies from the next restart # (Watchtower's next update, or: docker restart qbittorrent). code=$(qb_code --data-urlencode 'json={"max_connec":-1,"max_connec_per_torrent":-1,"max_uploads":-1,"max_uploads_per_torrent":-1,"queueing_enabled":false,"max_active_checking_torrents":3,"current_network_interface":"wg0","current_interface_address":"0.0.0.0","disk_io_type":0}' \ "$QB_URL/api/v2/app/setPreferences") if qb_2xx "$code"; then ok "qBittorrent: connection limits off, queueing off, 3 checking torrents, interface wg0 / all IPv4, disk IO type Default (pread/pwrite)"; else todo "qBittorrent: could not apply the connection/advanced settings (HTTP $code); set them in Tools > Options."; fi # Categories for Radarr and Sonarr, both saving straight into # /media/downloads. The path is set explicitly: a category with an empty # save path would make qBittorrent use /media/downloads/. An # existing category (earlier run) is re-pointed with editCategory. local cat cats cur cats=$(qb_post "$QB_URL/api/v2/torrents/categories" 2>/dev/null) for cat in radarr sonarr; do if [ "$(printf '%s' "$cats" | jpy "'$cat' in d")" = "True" ]; then cur=$(printf '%s' "$cats" | jpy "d['$cat'].get('savePath')") if [ "$cur" = "/media/downloads" ]; then ok "qBittorrent: category '$cat' already saves to /media/downloads"; continue; fi # editCategory answers 409 when nothing changes, hence the check above. code=$(qb_code --data-urlencode "category=$cat" --data-urlencode "savePath=/media/downloads" "$QB_URL/api/v2/torrents/editCategory") if qb_2xx "$code"; then ok "qBittorrent: category '$cat' save path changed from '${cur:-}' to /media/downloads"; else warn "qBittorrent: could not change the save path of category '$cat' (HTTP $code); set it to /media/downloads in the WebUI"; fi continue fi code=$(qb_code --data-urlencode "category=$cat" --data-urlencode "savePath=/media/downloads" "$QB_URL/api/v2/torrents/createCategory") if qb_2xx "$code"; then ok "qBittorrent: category '$cat' -> /media/downloads"; else warn "qBittorrent: could not create category '$cat' (HTTP $code)"; fi done [ "$QB_MODE" = "exec" ] && docker exec qbittorrent rm -f "$QB_COOKIE" >/dev/null 2>&1 rm -f "$QB_COOKIE" } # ── 2/3. Radarr and Sonarr ────────────────────────────────────────────────── configure_radarr() { info "[2/12] Radarr" RADARR_API_KEY=$(arr_key radarr) || { todo "Radarr: config.xml never appeared; skipping."; return 1; } wait_for "Radarr API" 180 bash -c "curl -fs -H 'X-Api-Key: $RADARR_API_KEY' $RADARR_URL/api/v3/system/status -o /dev/null" \ || { todo "Radarr API not responding; skipping."; return 1; } arr_enable_forms_auth Radarr "$RADARR_URL" "$RADARR_API_KEY" v3 arr_add_root_folder Radarr "$RADARR_URL" "$RADARR_API_KEY" /media/movies arr_add_qbittorrent Radarr "$RADARR_URL" "$RADARR_API_KEY" movieCategory radarr } configure_sonarr() { info "[3/12] Sonarr" SONARR_API_KEY=$(arr_key sonarr) || { todo "Sonarr: config.xml never appeared; skipping."; return 1; } wait_for "Sonarr API" 180 bash -c "curl -fs -H 'X-Api-Key: $SONARR_API_KEY' $SONARR_URL/api/v3/system/status -o /dev/null" \ || { todo "Sonarr API not responding; skipping."; return 1; } arr_enable_forms_auth Sonarr "$SONARR_URL" "$SONARR_API_KEY" v3 arr_add_root_folder Sonarr "$SONARR_URL" "$SONARR_API_KEY" /media/tv arr_add_qbittorrent Sonarr "$SONARR_URL" "$SONARR_API_KEY" tvCategory sonarr } # ── 4. Trawl ──────────────────────────────────────────────────────────────── configure_trawl() { info "[4/12] Trawl" # It starts a browser before it answers; give it time. if wait_for "Trawl" 180 bash -c "curl -fs $TRAWL_URL/health -o /dev/null"; then ok "Trawl is up (nothing to configure; dashboard at http://$HOST_IP:8191/dashboard)" else todo "Trawl did not respond on :8191 (docker logs trawl); Prowlarr's proxy test may fail."; fi } # ── 5. Prowlarr ───────────────────────────────────────────────────────────── prowlarr_tag_id() { # prowlarr_tag_id